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<Arc<Union>>,
407    pub inferred_return_type: Option<Arc<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<Arc<Union>>,
441    pub inferred_return_type: Option<Arc<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/// Uncached version of collect_file_definitions for bulk operations like vendor
1162/// collection, where we don't need Salsa to cache the intermediate StubSlice
1163/// results. This avoids holding Arc<StubSlice> in Salsa's query cache after
1164/// ingestion.
1165pub fn collect_file_definitions_uncached(
1166    db: &dyn MirDatabase,
1167    file: SourceFile,
1168) -> FileDefinitions {
1169    let path = file.path(db);
1170    let text = file.text(db);
1171
1172    let arena = bumpalo::Bump::new();
1173    let parsed = php_rs_parser::parse(&arena, &text);
1174
1175    let mut all_issues: Vec<Issue> = parsed
1176        .errors
1177        .iter()
1178        .map(|err| {
1179            Issue::new(
1180                mir_issues::IssueKind::ParseError {
1181                    message: err.to_string(),
1182                },
1183                mir_issues::Location {
1184                    file: path.clone(),
1185                    line: 1,
1186                    line_end: 1,
1187                    col_start: 0,
1188                    col_end: 0,
1189                },
1190            )
1191        })
1192        .collect();
1193
1194    let collector =
1195        crate::collector::DefinitionCollector::new_for_slice(path, &text, &parsed.source_map);
1196    let (slice, collector_issues) = collector.collect_slice(&parsed.program);
1197    all_issues.extend(collector_issues);
1198
1199    FileDefinitions {
1200        slice: Arc::new(slice),
1201        issues: Arc::new(all_issues),
1202    }
1203}
1204
1205#[salsa::tracked]
1206pub fn collect_file_definitions(db: &dyn MirDatabase, file: SourceFile) -> FileDefinitions {
1207    collect_file_definitions_uncached(db, file)
1208}
1209
1210// ---------------------------------------------------------------------------
1211// MirDb concrete database
1212// ---------------------------------------------------------------------------
1213
1214/// Concrete in-process Salsa database.
1215///
1216/// `Clone` is required for parallel batch analysis: salsa's supported
1217/// pattern for sharing a db across threads is to give each worker its
1218/// own clone (each clone gets a fresh `ZalsaLocal`, sharing the
1219/// underlying memoization storage).  Sharing `&MirDb` across threads is
1220/// **not** supported because `salsa::Database: Send` (not `Sync`).
1221type MemberRegistry<V> = Arc<FxHashMap<Arc<str>, FxHashMap<Arc<str>, V>>>;
1222type ReferenceLocations =
1223    Arc<std::sync::Mutex<FxHashMap<Arc<str>, Vec<(Arc<str>, u32, u16, u16)>>>>;
1224
1225#[salsa::db]
1226#[derive(Default, Clone)]
1227pub struct MirDb {
1228    storage: salsa::Storage<Self>,
1229    // Keep registries behind `Arc`s so `MirDb::clone()` stays cheap for
1230    // parallel analysis workers. The salsa storage is already shared by clone;
1231    // these maps only hold stable input handles, so copy-on-write insertion is
1232    // enough for the canonical mutable db paths.
1233    /// FQCN → ClassNode handle registry (not tracked by Salsa; see
1234    /// `lookup_class_node` for the rationale). Keys are canonical FQCNs;
1235    /// case-insensitive lookups go through `class_node_keys_lower`.
1236    class_nodes: Arc<FxHashMap<Arc<str>, ClassNode>>,
1237    /// Lowercased FQCN → canonical FQCN. Maintained in lockstep with
1238    /// `class_nodes` so callers can resolve PHP's case-insensitive class
1239    /// names (`new arrayobject()` → `ArrayObject`).
1240    class_node_keys_lower: Arc<FxHashMap<String, Arc<str>>>,
1241    /// FQN → FunctionNode handle registry. Keys are canonical FQNs;
1242    /// case-insensitive lookups go through `function_node_keys_lower`.
1243    function_nodes: Arc<FxHashMap<Arc<str>, FunctionNode>>,
1244    /// Lowercased FQN → canonical FQN. Maintained in lockstep with
1245    /// `function_nodes` so callers can resolve PHP's case-insensitive
1246    /// function names (`STRLEN($x)` → `strlen`).
1247    function_node_keys_lower: Arc<FxHashMap<String, Arc<str>>>,
1248    /// (owner FQCN) → (method_name_lower → MethodNode) handle registry.
1249    method_nodes: MemberRegistry<MethodNode>,
1250    /// (owner FQCN) → (prop_name → PropertyNode) handle registry.
1251    property_nodes: MemberRegistry<PropertyNode>,
1252    /// (owner FQCN) → (const_name → ClassConstantNode) handle registry.
1253    class_constant_nodes: MemberRegistry<ClassConstantNode>,
1254    /// FQN → GlobalConstantNode handle registry.
1255    global_constant_nodes: Arc<FxHashMap<Arc<str>, GlobalConstantNode>>,
1256    /// File path → first declared namespace.
1257    file_namespaces: Arc<FxHashMap<Arc<str>, Arc<str>>>,
1258    /// File path → use-alias imports.
1259    file_imports: Arc<FxHashMap<Arc<str>, HashMap<String, String>>>,
1260    /// Global variable name (without `$`) → collected type.
1261    global_vars: Arc<FxHashMap<Arc<str>, Union>>,
1262    /// Symbol FQN → defining file.
1263    symbol_to_file: Arc<FxHashMap<Arc<str>, Arc<str>>>,
1264    /// Public symbol key → reference locations.
1265    reference_locations: ReferenceLocations,
1266}
1267
1268#[salsa::db]
1269impl salsa::Database for MirDb {}
1270
1271#[salsa::db]
1272impl MirDatabase for MirDb {
1273    fn php_version_str(&self) -> Arc<str> {
1274        Arc::from("8.2")
1275    }
1276
1277    fn lookup_class_node(&self, fqcn: &str) -> Option<ClassNode> {
1278        if let Some(&node) = self.class_nodes.get(fqcn) {
1279            return Some(node);
1280        }
1281        let lower = fqcn.to_ascii_lowercase();
1282        let canonical = self.class_node_keys_lower.get(&lower)?;
1283        self.class_nodes.get(canonical.as_ref()).copied()
1284    }
1285
1286    fn lookup_function_node(&self, fqn: &str) -> Option<FunctionNode> {
1287        if let Some(&node) = self.function_nodes.get(fqn) {
1288            return Some(node);
1289        }
1290        let lower = fqn.to_ascii_lowercase();
1291        let canonical = self.function_node_keys_lower.get(&lower)?;
1292        self.function_nodes.get(canonical.as_ref()).copied()
1293    }
1294
1295    fn lookup_method_node(&self, fqcn: &str, method_name_lower: &str) -> Option<MethodNode> {
1296        self.method_nodes
1297            .get(fqcn)
1298            .and_then(|m| m.get(method_name_lower).copied())
1299    }
1300
1301    fn lookup_property_node(&self, fqcn: &str, prop_name: &str) -> Option<PropertyNode> {
1302        self.property_nodes
1303            .get(fqcn)
1304            .and_then(|m| m.get(prop_name).copied())
1305    }
1306
1307    fn lookup_class_constant_node(
1308        &self,
1309        fqcn: &str,
1310        const_name: &str,
1311    ) -> Option<ClassConstantNode> {
1312        self.class_constant_nodes
1313            .get(fqcn)
1314            .and_then(|m| m.get(const_name).copied())
1315    }
1316
1317    fn lookup_global_constant_node(&self, fqn: &str) -> Option<GlobalConstantNode> {
1318        self.global_constant_nodes.get(fqn).copied()
1319    }
1320
1321    fn class_own_methods(&self, fqcn: &str) -> Vec<MethodNode> {
1322        self.method_nodes
1323            .get(fqcn)
1324            .map(|m| m.values().copied().collect())
1325            .unwrap_or_default()
1326    }
1327
1328    fn class_own_properties(&self, fqcn: &str) -> Vec<PropertyNode> {
1329        self.property_nodes
1330            .get(fqcn)
1331            .map(|m| m.values().copied().collect())
1332            .unwrap_or_default()
1333    }
1334
1335    fn active_class_node_fqcns(&self) -> Vec<Arc<str>> {
1336        self.class_nodes
1337            .iter()
1338            .filter_map(|(fqcn, node)| {
1339                if node.active(self) {
1340                    Some(fqcn.clone())
1341                } else {
1342                    None
1343                }
1344            })
1345            .collect()
1346    }
1347
1348    fn active_function_node_fqns(&self) -> Vec<Arc<str>> {
1349        self.function_nodes
1350            .iter()
1351            .filter_map(|(fqn, node)| {
1352                if node.active(self) {
1353                    Some(fqn.clone())
1354                } else {
1355                    None
1356                }
1357            })
1358            .collect()
1359    }
1360
1361    fn file_namespace(&self, file: &str) -> Option<Arc<str>> {
1362        self.file_namespaces.get(file).cloned()
1363    }
1364
1365    fn file_imports(&self, file: &str) -> HashMap<String, String> {
1366        self.file_imports.get(file).cloned().unwrap_or_default()
1367    }
1368
1369    fn global_var_type(&self, name: &str) -> Option<Union> {
1370        self.global_vars.get(name).cloned()
1371    }
1372
1373    fn file_import_snapshots(&self) -> Vec<(Arc<str>, HashMap<String, String>)> {
1374        self.file_imports
1375            .iter()
1376            .map(|(file, imports)| (file.clone(), imports.clone()))
1377            .collect()
1378    }
1379
1380    fn symbol_defining_file(&self, symbol: &str) -> Option<Arc<str>> {
1381        self.symbol_to_file.get(symbol).cloned()
1382    }
1383
1384    fn symbols_defined_in_file(&self, file: &str) -> Vec<Arc<str>> {
1385        self.symbol_to_file
1386            .iter()
1387            .filter_map(|(sym, defining_file)| {
1388                if defining_file.as_ref() == file {
1389                    Some(sym.clone())
1390                } else {
1391                    None
1392                }
1393            })
1394            .collect()
1395    }
1396
1397    fn record_reference_location(&self, loc: RefLoc) {
1398        let mut refs = self
1399            .reference_locations
1400            .lock()
1401            .expect("reference lock poisoned");
1402        let entry = refs.entry(loc.symbol_key).or_default();
1403        let tuple = (loc.file, loc.line, loc.col_start, loc.col_end);
1404        if !entry.iter().any(|existing| existing == &tuple) {
1405            entry.push(tuple);
1406        }
1407    }
1408
1409    fn replay_reference_locations(&self, file: Arc<str>, locs: &[(String, u32, u16, u16)]) {
1410        for (symbol, line, col_start, col_end) in locs {
1411            self.record_reference_location(RefLoc {
1412                symbol_key: Arc::from(symbol.as_str()),
1413                file: file.clone(),
1414                line: *line,
1415                col_start: *col_start,
1416                col_end: *col_end,
1417            });
1418        }
1419    }
1420
1421    fn extract_file_reference_locations(&self, file: &str) -> Vec<(Arc<str>, u32, u16, u16)> {
1422        let refs = self
1423            .reference_locations
1424            .lock()
1425            .expect("reference lock poisoned");
1426        let mut out = Vec::new();
1427        for (symbol, locs) in refs.iter() {
1428            for (loc_file, line, col_start, col_end) in locs {
1429                if loc_file.as_ref() == file {
1430                    out.push((symbol.clone(), *line, *col_start, *col_end));
1431                }
1432            }
1433        }
1434        out
1435    }
1436
1437    fn reference_locations(&self, symbol: &str) -> Vec<(Arc<str>, u32, u16, u16)> {
1438        let refs = self
1439            .reference_locations
1440            .lock()
1441            .expect("reference lock poisoned");
1442        refs.get(symbol).cloned().unwrap_or_default()
1443    }
1444
1445    fn has_reference(&self, symbol: &str) -> bool {
1446        let refs = self
1447            .reference_locations
1448            .lock()
1449            .expect("reference lock poisoned");
1450        refs.get(symbol).is_some_and(|locs| !locs.is_empty())
1451    }
1452
1453    fn clear_file_references(&self, file: &str) {
1454        let mut refs = self
1455            .reference_locations
1456            .lock()
1457            .expect("reference lock poisoned");
1458        for locs in refs.values_mut() {
1459            locs.retain(|(loc_file, _, _, _)| loc_file.as_ref() != file);
1460        }
1461    }
1462}
1463
1464// ---------------------------------------------------------------------------
1465// Inferred-return-type buffer (S3 deadlock resolution)
1466// ---------------------------------------------------------------------------
1467
1468/// Thread-safe buffer used by Pass 2's priming sweep to record inferred
1469/// return types per (function|method).  The sweep runs in parallel across
1470/// rayon workers each holding its own `MirDb` clone, so writing setters
1471/// from inside the closure would deadlock against `Storage::cancel_others`
1472/// (which waits for all clones to drop before allowing a write).
1473///
1474/// Instead, workers push into this buffer (a `Mutex<Vec<…>>` — pushes are
1475/// fast, contention is negligible vs the work each worker does).  After
1476/// the parallel sweep returns, all worker clones are dropped and
1477/// [`MirDb::commit_inferred_return_types`] drains the buffer into Salsa
1478/// setters on the canonical db (which now has strong-count==1).
1479#[derive(Default)]
1480#[allow(clippy::type_complexity)]
1481pub struct InferredReturnTypes {
1482    /// `(fqn, inferred)` pairs for free functions.
1483    functions: std::sync::Mutex<Vec<(Arc<str>, Union)>>,
1484    /// `(fqcn, method_name, inferred)` triples for methods.  `method_name`
1485    /// is the original (non-lowercased) name; `commit` lowercases at
1486    /// lookup time to match `MirDb::method_nodes`' key convention.
1487    methods: std::sync::Mutex<Vec<(Arc<str>, Arc<str>, Union)>>,
1488}
1489
1490impl InferredReturnTypes {
1491    pub fn new() -> Self {
1492        Self::default()
1493    }
1494
1495    pub fn push_function(&self, fqn: Arc<str>, inferred: Union) {
1496        if let Ok(mut g) = self.functions.lock() {
1497            g.push((fqn, inferred));
1498        }
1499    }
1500
1501    pub fn push_method(&self, fqcn: Arc<str>, name: Arc<str>, inferred: Union) {
1502        if let Ok(mut g) = self.methods.lock() {
1503            g.push((fqcn, name, inferred));
1504        }
1505    }
1506}
1507
1508/// Field bag for [`MirDb::upsert_class_node`].  Construct with `..Default::default()`
1509/// to fill in the fields that don't apply to your kind (e.g. interfaces leave
1510/// `parent`, `traits`, `mixins`, `is_abstract`, etc. at their defaults).
1511///
1512/// Per-kind constructors (`for_class` / `for_interface` / `for_trait` /
1513/// `for_enum`) seed the kind discriminators so the caller only has to populate
1514/// kind-specific fields.
1515#[derive(Debug, Clone, Default)]
1516pub struct ClassNodeFields {
1517    pub fqcn: Arc<str>,
1518    pub is_interface: bool,
1519    pub is_trait: bool,
1520    pub is_enum: bool,
1521    pub is_abstract: bool,
1522    pub parent: Option<Arc<str>>,
1523    pub interfaces: Arc<[Arc<str>]>,
1524    pub traits: Arc<[Arc<str>]>,
1525    pub extends: Arc<[Arc<str>]>,
1526    pub template_params: Arc<[TemplateParam]>,
1527    pub require_extends: Arc<[Arc<str>]>,
1528    pub require_implements: Arc<[Arc<str>]>,
1529    pub is_backed_enum: bool,
1530    pub mixins: Arc<[Arc<str>]>,
1531    pub deprecated: Option<Arc<str>>,
1532    pub enum_scalar_type: Option<Union>,
1533    pub is_final: bool,
1534    pub is_readonly: bool,
1535    pub location: Option<Location>,
1536    pub extends_type_args: Arc<[Union]>,
1537    pub implements_type_args: ImplementsTypeArgs,
1538}
1539
1540impl ClassNodeFields {
1541    pub fn for_class(fqcn: Arc<str>) -> Self {
1542        Self {
1543            fqcn,
1544            ..Self::default()
1545        }
1546    }
1547
1548    pub fn for_interface(fqcn: Arc<str>) -> Self {
1549        Self {
1550            fqcn,
1551            is_interface: true,
1552            ..Self::default()
1553        }
1554    }
1555
1556    pub fn for_trait(fqcn: Arc<str>) -> Self {
1557        Self {
1558            fqcn,
1559            is_trait: true,
1560            ..Self::default()
1561        }
1562    }
1563
1564    pub fn for_enum(fqcn: Arc<str>) -> Self {
1565        Self {
1566            fqcn,
1567            is_enum: true,
1568            ..Self::default()
1569        }
1570    }
1571}
1572
1573impl MirDb {
1574    pub fn remove_file_definitions(&mut self, file: &str) {
1575        let symbols = self.symbols_defined_in_file(file);
1576        for symbol in &symbols {
1577            self.deactivate_class_node(symbol);
1578            self.deactivate_function_node(symbol);
1579            self.deactivate_class_methods(symbol);
1580            self.deactivate_class_properties(symbol);
1581            self.deactivate_class_constants(symbol);
1582            self.deactivate_global_constant_node(symbol);
1583        }
1584        let symbol_set: HashSet<Arc<str>> = symbols.into_iter().collect();
1585        Arc::make_mut(&mut self.symbol_to_file).retain(|sym, defining_file| {
1586            defining_file.as_ref() != file && !symbol_set.contains(sym)
1587        });
1588        Arc::make_mut(&mut self.file_namespaces).retain(|path, _| path.as_ref() != file);
1589        Arc::make_mut(&mut self.file_imports).retain(|path, _| path.as_ref() != file);
1590        Arc::make_mut(&mut self.global_vars).retain(|name, _| !symbol_set.contains(name));
1591        self.clear_file_references(file);
1592    }
1593
1594    pub fn type_count(&self) -> usize {
1595        self.class_nodes
1596            .values()
1597            .filter(|node| node.active(self))
1598            .count()
1599    }
1600
1601    pub fn function_count(&self) -> usize {
1602        self.function_nodes
1603            .values()
1604            .filter(|node| node.active(self))
1605            .count()
1606    }
1607
1608    pub fn constant_count(&self) -> usize {
1609        self.global_constant_nodes
1610            .values()
1611            .filter(|node| node.active(self))
1612            .count()
1613    }
1614
1615    /// Walk one collected [`StubSlice`] and upsert the corresponding db nodes.
1616    ///
1617    /// This is the canonical post-Pass-1 ingestion path: each file's slice is
1618    /// fed in directly, so batch analysis does not need any intermediate
1619    /// mutable codebase store between Pass 1 and Pass 2.
1620    pub fn ingest_stub_slice(&mut self, slice: &StubSlice) {
1621        use std::collections::HashSet;
1622
1623        // Deduplicate param lists to save memory (many methods share identical signatures).
1624        // This reduces cold-start memory usage by ~100-150 MiB when analyzing vendor code.
1625        let mut slice = slice.clone();
1626        mir_codebase::storage::deduplicate_params_in_slice(&mut slice);
1627
1628        if let Some(file) = &slice.file {
1629            if let Some(namespace) = &slice.namespace {
1630                Arc::make_mut(&mut self.file_namespaces).insert(file.clone(), namespace.clone());
1631            }
1632            if !slice.imports.is_empty() {
1633                Arc::make_mut(&mut self.file_imports).insert(file.clone(), slice.imports.clone());
1634            }
1635            for (name, _) in &slice.global_vars {
1636                let global_name = name.strip_prefix('$').unwrap_or(name.as_ref());
1637                Arc::make_mut(&mut self.symbol_to_file)
1638                    .insert(Arc::from(global_name), file.clone());
1639            }
1640        }
1641        for (name, ty) in &slice.global_vars {
1642            let global_name = name.strip_prefix('$').unwrap_or(name.as_ref());
1643            Arc::make_mut(&mut self.global_vars).insert(Arc::from(global_name), ty.clone());
1644        }
1645
1646        for cls in &slice.classes {
1647            if let Some(file) = &slice.file {
1648                Arc::make_mut(&mut self.symbol_to_file).insert(cls.fqcn.clone(), file.clone());
1649            }
1650            self.upsert_class_node(ClassNodeFields {
1651                is_abstract: cls.is_abstract,
1652                parent: cls.parent.clone(),
1653                interfaces: Arc::from(cls.interfaces.as_ref()),
1654                traits: Arc::from(cls.traits.as_ref()),
1655                template_params: Arc::from(cls.template_params.as_ref()),
1656                mixins: Arc::from(cls.mixins.as_ref()),
1657                deprecated: cls.deprecated.clone(),
1658                is_final: cls.is_final,
1659                is_readonly: cls.is_readonly,
1660                location: cls.location.clone(),
1661                extends_type_args: Arc::from(cls.extends_type_args.as_ref()),
1662                implements_type_args: Arc::from(
1663                    cls.implements_type_args
1664                        .iter()
1665                        .map(|(iface, args)| (iface.clone(), Arc::from(args.as_ref())))
1666                        .collect::<Vec<_>>(),
1667                ),
1668                ..ClassNodeFields::for_class(cls.fqcn.clone())
1669            });
1670            if self.method_nodes.contains_key(cls.fqcn.as_ref()) {
1671                let method_keep: HashSet<&str> =
1672                    cls.own_methods.keys().map(|m| m.as_ref()).collect();
1673                self.prune_class_methods(&cls.fqcn, &method_keep);
1674            }
1675            for method in cls.own_methods.values() {
1676                // Avoid cloning complex return type Unions during vendor ingestion
1677                // by wrapping in Arc upfront. This is a per-method operation during
1678                // vendor type collection (rare after initialization), so the Arc
1679                // allocation is amortized.
1680                self.upsert_method_node(method.as_ref());
1681            }
1682            if self.property_nodes.contains_key(cls.fqcn.as_ref()) {
1683                let prop_keep: HashSet<&str> =
1684                    cls.own_properties.keys().map(|p| p.as_ref()).collect();
1685                self.prune_class_properties(&cls.fqcn, &prop_keep);
1686            }
1687            for prop in cls.own_properties.values() {
1688                self.upsert_property_node(&cls.fqcn, prop);
1689            }
1690            if self.class_constant_nodes.contains_key(cls.fqcn.as_ref()) {
1691                let const_keep: HashSet<&str> =
1692                    cls.own_constants.keys().map(|c| c.as_ref()).collect();
1693                self.prune_class_constants(&cls.fqcn, &const_keep);
1694            }
1695            for constant in cls.own_constants.values() {
1696                self.upsert_class_constant_node(&cls.fqcn, constant);
1697            }
1698        }
1699
1700        for iface in &slice.interfaces {
1701            if let Some(file) = &slice.file {
1702                Arc::make_mut(&mut self.symbol_to_file).insert(iface.fqcn.clone(), file.clone());
1703            }
1704            self.upsert_class_node(ClassNodeFields {
1705                extends: Arc::from(iface.extends.as_ref()),
1706                template_params: Arc::from(iface.template_params.as_ref()),
1707                location: iface.location.clone(),
1708                ..ClassNodeFields::for_interface(iface.fqcn.clone())
1709            });
1710            if self.method_nodes.contains_key(iface.fqcn.as_ref()) {
1711                let method_keep: HashSet<&str> =
1712                    iface.own_methods.keys().map(|m| m.as_ref()).collect();
1713                self.prune_class_methods(&iface.fqcn, &method_keep);
1714            }
1715            for method in iface.own_methods.values() {
1716                self.upsert_method_node(method.as_ref());
1717            }
1718            if self.class_constant_nodes.contains_key(iface.fqcn.as_ref()) {
1719                let const_keep: HashSet<&str> =
1720                    iface.own_constants.keys().map(|c| c.as_ref()).collect();
1721                self.prune_class_constants(&iface.fqcn, &const_keep);
1722            }
1723            for constant in iface.own_constants.values() {
1724                self.upsert_class_constant_node(&iface.fqcn, constant);
1725            }
1726        }
1727
1728        for tr in &slice.traits {
1729            if let Some(file) = &slice.file {
1730                Arc::make_mut(&mut self.symbol_to_file).insert(tr.fqcn.clone(), file.clone());
1731            }
1732            self.upsert_class_node(ClassNodeFields {
1733                traits: Arc::from(tr.traits.as_ref()),
1734                template_params: Arc::from(tr.template_params.as_ref()),
1735                require_extends: Arc::from(tr.require_extends.as_ref()),
1736                require_implements: Arc::from(tr.require_implements.as_ref()),
1737                location: tr.location.clone(),
1738                ..ClassNodeFields::for_trait(tr.fqcn.clone())
1739            });
1740            if self.method_nodes.contains_key(tr.fqcn.as_ref()) {
1741                let method_keep: HashSet<&str> =
1742                    tr.own_methods.keys().map(|m| m.as_ref()).collect();
1743                self.prune_class_methods(&tr.fqcn, &method_keep);
1744            }
1745            for method in tr.own_methods.values() {
1746                self.upsert_method_node(method.as_ref());
1747            }
1748            if self.property_nodes.contains_key(tr.fqcn.as_ref()) {
1749                let prop_keep: HashSet<&str> =
1750                    tr.own_properties.keys().map(|p| p.as_ref()).collect();
1751                self.prune_class_properties(&tr.fqcn, &prop_keep);
1752            }
1753            for prop in tr.own_properties.values() {
1754                self.upsert_property_node(&tr.fqcn, prop);
1755            }
1756            if self.class_constant_nodes.contains_key(tr.fqcn.as_ref()) {
1757                let const_keep: HashSet<&str> =
1758                    tr.own_constants.keys().map(|c| c.as_ref()).collect();
1759                self.prune_class_constants(&tr.fqcn, &const_keep);
1760            }
1761            for constant in tr.own_constants.values() {
1762                self.upsert_class_constant_node(&tr.fqcn, constant);
1763            }
1764        }
1765
1766        for en in &slice.enums {
1767            if let Some(file) = &slice.file {
1768                Arc::make_mut(&mut self.symbol_to_file).insert(en.fqcn.clone(), file.clone());
1769            }
1770            self.upsert_class_node(ClassNodeFields {
1771                interfaces: Arc::from(en.interfaces.as_ref()),
1772                is_backed_enum: en.scalar_type.is_some(),
1773                enum_scalar_type: en.scalar_type.clone(),
1774                location: en.location.clone(),
1775                ..ClassNodeFields::for_enum(en.fqcn.clone())
1776            });
1777            if self.method_nodes.contains_key(en.fqcn.as_ref()) {
1778                let mut method_keep: HashSet<&str> =
1779                    en.own_methods.keys().map(|m| m.as_ref()).collect();
1780                method_keep.insert("cases");
1781                if en.scalar_type.is_some() {
1782                    method_keep.insert("from");
1783                    method_keep.insert("tryfrom");
1784                }
1785                self.prune_class_methods(&en.fqcn, &method_keep);
1786            }
1787            for method in en.own_methods.values() {
1788                self.upsert_method_node(method.as_ref());
1789            }
1790            let synth_method = |name: &str| mir_codebase::storage::MethodStorage {
1791                fqcn: en.fqcn.clone(),
1792                name: Arc::from(name),
1793                params: Arc::from([].as_ref()),
1794                return_type: Some(Arc::new(Union::mixed())),
1795                inferred_return_type: None,
1796                visibility: Visibility::Public,
1797                is_static: true,
1798                is_abstract: false,
1799                is_constructor: false,
1800                template_params: vec![],
1801                assertions: vec![],
1802                throws: vec![],
1803                is_final: false,
1804                is_internal: false,
1805                is_pure: false,
1806                deprecated: None,
1807                location: None,
1808            };
1809            let already = |name: &str| {
1810                en.own_methods
1811                    .keys()
1812                    .any(|k| k.as_ref().eq_ignore_ascii_case(name))
1813            };
1814            if !already("cases") {
1815                self.upsert_method_node(&synth_method("cases"));
1816            }
1817            if en.scalar_type.is_some() {
1818                if !already("from") {
1819                    self.upsert_method_node(&synth_method("from"));
1820                }
1821                if !already("tryFrom") {
1822                    self.upsert_method_node(&synth_method("tryFrom"));
1823                }
1824            }
1825            if self.class_constant_nodes.contains_key(en.fqcn.as_ref()) {
1826                let mut const_keep: HashSet<&str> =
1827                    en.own_constants.keys().map(|c| c.as_ref()).collect();
1828                for case in en.cases.values() {
1829                    const_keep.insert(case.name.as_ref());
1830                }
1831                self.prune_class_constants(&en.fqcn, &const_keep);
1832            }
1833            for constant in en.own_constants.values() {
1834                self.upsert_class_constant_node(&en.fqcn, constant);
1835            }
1836            for case in en.cases.values() {
1837                let case_const = ConstantStorage {
1838                    name: case.name.clone(),
1839                    ty: mir_types::Union::mixed(),
1840                    visibility: None,
1841                    is_final: false,
1842                    location: case.location.clone(),
1843                };
1844                self.upsert_class_constant_node(&en.fqcn, &case_const);
1845            }
1846        }
1847
1848        for func in &slice.functions {
1849            if let Some(file) = &slice.file {
1850                Arc::make_mut(&mut self.symbol_to_file).insert(func.fqn.clone(), file.clone());
1851            }
1852            self.upsert_function_node(func);
1853        }
1854        for (fqn, ty) in &slice.constants {
1855            self.upsert_global_constant_node(fqn.clone(), ty.clone());
1856        }
1857    }
1858
1859    /// Create or update the `ClassNode` for `fqcn`.
1860    ///
1861    /// If a handle already exists, its fields are updated in-place so Salsa
1862    /// can track the change.  A new handle is created only on first registration.
1863    #[allow(clippy::too_many_arguments)]
1864    pub fn upsert_class_node(&mut self, fields: ClassNodeFields) -> ClassNode {
1865        use salsa::Setter as _;
1866        let ClassNodeFields {
1867            fqcn,
1868            is_interface,
1869            is_trait,
1870            is_enum,
1871            is_abstract,
1872            parent,
1873            interfaces,
1874            traits,
1875            extends,
1876            template_params,
1877            require_extends,
1878            require_implements,
1879            is_backed_enum,
1880            mixins,
1881            deprecated,
1882            enum_scalar_type,
1883            is_final,
1884            is_readonly,
1885            location,
1886            extends_type_args,
1887            implements_type_args,
1888        } = fields;
1889        if let Some(&node) = self.class_nodes.get(&fqcn) {
1890            // Fast-skip: an already-active node whose Salsa-tracked fields
1891            // match the upsert input.  Bulk re-ingest paths
1892            // (`ingest_stub_slice` / `lazy_load_*`) call this for every class
1893            // on every iteration; without the skip each call fires 13
1894            // setters, each acquiring the Salsa write lock.  Schema doesn't
1895            // mutate after Pass 1 (Pass 2 only writes `inferred_return_type`),
1896            // so an active node with matching fields is by construction up
1897            // to date.
1898            //
1899            // Mutation paths (LSP re-analyze) call `deactivate_class_node`
1900            // first; that flips `active=false`, defeating this guard so the
1901            // setters run as before.
1902            if node.active(self)
1903                && node.is_interface(self) == is_interface
1904                && node.is_trait(self) == is_trait
1905                && node.is_enum(self) == is_enum
1906                && node.is_abstract(self) == is_abstract
1907                && node.is_backed_enum(self) == is_backed_enum
1908                && node.parent(self) == parent
1909                && *node.interfaces(self) == *interfaces
1910                && *node.traits(self) == *traits
1911                && *node.extends(self) == *extends
1912                && *node.template_params(self) == *template_params
1913                && *node.require_extends(self) == *require_extends
1914                && *node.require_implements(self) == *require_implements
1915                && *node.mixins(self) == *mixins
1916                && node.deprecated(self) == deprecated
1917                && node.enum_scalar_type(self) == enum_scalar_type
1918                && node.is_final(self) == is_final
1919                && node.is_readonly(self) == is_readonly
1920                && node.location(self) == location
1921                && *node.extends_type_args(self) == *extends_type_args
1922                && *node.implements_type_args(self) == *implements_type_args
1923            {
1924                return node;
1925            }
1926            node.set_active(self).to(true);
1927            node.set_is_interface(self).to(is_interface);
1928            node.set_is_trait(self).to(is_trait);
1929            node.set_is_enum(self).to(is_enum);
1930            node.set_is_abstract(self).to(is_abstract);
1931            node.set_parent(self).to(parent);
1932            node.set_interfaces(self).to(interfaces);
1933            node.set_traits(self).to(traits);
1934            node.set_extends(self).to(extends);
1935            node.set_template_params(self).to(template_params);
1936            node.set_require_extends(self).to(require_extends);
1937            node.set_require_implements(self).to(require_implements);
1938            node.set_is_backed_enum(self).to(is_backed_enum);
1939            node.set_mixins(self).to(mixins);
1940            node.set_deprecated(self).to(deprecated);
1941            node.set_enum_scalar_type(self).to(enum_scalar_type);
1942            node.set_is_final(self).to(is_final);
1943            node.set_is_readonly(self).to(is_readonly);
1944            node.set_location(self).to(location);
1945            node.set_extends_type_args(self).to(extends_type_args);
1946            node.set_implements_type_args(self).to(implements_type_args);
1947            node
1948        } else {
1949            let node = ClassNode::new(
1950                self,
1951                fqcn.clone(),
1952                true,
1953                is_interface,
1954                is_trait,
1955                is_enum,
1956                is_abstract,
1957                parent,
1958                interfaces,
1959                traits,
1960                extends,
1961                template_params,
1962                require_extends,
1963                require_implements,
1964                is_backed_enum,
1965                mixins,
1966                deprecated,
1967                enum_scalar_type,
1968                is_final,
1969                is_readonly,
1970                location,
1971                extends_type_args,
1972                implements_type_args,
1973            );
1974            Arc::make_mut(&mut self.class_node_keys_lower)
1975                .insert(fqcn.to_ascii_lowercase(), fqcn.clone());
1976            Arc::make_mut(&mut self.class_nodes).insert(fqcn, node);
1977            node
1978        }
1979    }
1980
1981    /// Mark the `ClassNode` for `fqcn` as inactive.
1982    ///
1983    /// Dependent `class_ancestors` queries will observe the change and re-run,
1984    /// returning an empty list.
1985    pub fn deactivate_class_node(&mut self, fqcn: &str) {
1986        use salsa::Setter as _;
1987        if let Some(&node) = self.class_nodes.get(fqcn) {
1988            node.set_active(self).to(false);
1989        }
1990    }
1991
1992    /// Create or update the `FunctionNode` for the given `FunctionStorage`.
1993    pub fn upsert_function_node(&mut self, storage: &FunctionStorage) -> FunctionNode {
1994        use salsa::Setter as _;
1995        let fqn = &storage.fqn;
1996        if let Some(&node) = self.function_nodes.get(fqn.as_ref()) {
1997            // Fast-skip identical re-ingest — see `upsert_class_node` for rationale.
1998            // `inferred_return_type` is intentionally NOT compared / written:
1999            // it is owned by the priming sweep's serial commit phase
2000            // (`commit_inferred_return_types`) and Pass-1 re-ingest must not
2001            // clobber a previously-inferred value.
2002            if node.active(self)
2003                && node.short_name(self) == storage.short_name
2004                && node.is_pure(self) == storage.is_pure
2005                && node.deprecated(self) == storage.deprecated
2006                && node.return_type(self).as_deref() == storage.return_type.as_deref()
2007                && node.location(self) == storage.location
2008                && *node.params(self) == *storage.params.as_ref()
2009                && *node.template_params(self) == *storage.template_params
2010                && *node.assertions(self) == *storage.assertions
2011                && *node.throws(self) == *storage.throws
2012            {
2013                return node;
2014            }
2015            node.set_active(self).to(true);
2016            node.set_short_name(self).to(storage.short_name.clone());
2017            node.set_params(self).to(storage.params.clone());
2018            node.set_return_type(self).to(storage.return_type.clone());
2019            node.set_template_params(self)
2020                .to(Arc::from(storage.template_params.as_slice()));
2021            node.set_assertions(self)
2022                .to(Arc::from(storage.assertions.as_slice()));
2023            node.set_throws(self)
2024                .to(Arc::from(storage.throws.as_slice()));
2025            node.set_deprecated(self).to(storage.deprecated.clone());
2026            node.set_is_pure(self).to(storage.is_pure);
2027            node.set_location(self).to(storage.location.clone());
2028            node
2029        } else {
2030            let node = FunctionNode::new(
2031                self,
2032                fqn.clone(),
2033                storage.short_name.clone(),
2034                true,
2035                storage.params.clone(),
2036                storage.return_type.clone(),
2037                storage
2038                    .inferred_return_type
2039                    .as_ref()
2040                    .map(|t| Arc::new(t.clone())),
2041                Arc::from(storage.template_params.as_slice()),
2042                Arc::from(storage.assertions.as_slice()),
2043                Arc::from(storage.throws.as_slice()),
2044                storage.deprecated.clone(),
2045                storage.is_pure,
2046                storage.location.clone(),
2047            );
2048            Arc::make_mut(&mut self.function_node_keys_lower)
2049                .insert(fqn.to_ascii_lowercase(), fqn.clone());
2050            Arc::make_mut(&mut self.function_nodes).insert(fqn.clone(), node);
2051            node
2052        }
2053    }
2054
2055    /// Commit a parallel-sweep-collected [`InferredReturnTypes`] buffer
2056    /// into the Salsa db.  **Must be called serially**, after all rayon
2057    /// workers from the priming sweep have dropped their db clones, so
2058    /// that `Storage::cancel_others` sees strong-count==1 inside the
2059    /// setter.  Calling this from inside a `for_each_with` / `map_with`
2060    /// closure will deadlock.
2061    ///
2062    /// Skips writes whose value already matches the current Salsa-tracked
2063    /// value (preserves PR21's fast-skip semantics).  Skips inactive
2064    /// nodes — there's no point committing an inferred return for a node
2065    /// that has been deactivated by a re-analyze.
2066    pub fn commit_inferred_return_types(&mut self, buf: &InferredReturnTypes) {
2067        use salsa::Setter as _;
2068        let funcs = std::mem::take(&mut *buf.functions.lock().expect("inferred buffer poisoned"));
2069        for (fqn, inferred) in funcs {
2070            if let Some(&node) = self.function_nodes.get(fqn.as_ref()) {
2071                if !node.active(self) {
2072                    continue;
2073                }
2074                let new = Some(Arc::new(inferred));
2075                if node.inferred_return_type(self) == new {
2076                    continue;
2077                }
2078                node.set_inferred_return_type(self).to(new);
2079            }
2080        }
2081        let methods = std::mem::take(&mut *buf.methods.lock().expect("inferred buffer poisoned"));
2082        for (fqcn, name, inferred) in methods {
2083            let name_lower: Arc<str> = if name.chars().all(|c| !c.is_uppercase()) {
2084                name.clone()
2085            } else {
2086                Arc::from(name.to_lowercase().as_str())
2087            };
2088            let node = self
2089                .method_nodes
2090                .get(fqcn.as_ref())
2091                .and_then(|m| m.get(&name_lower))
2092                .copied();
2093            if let Some(node) = node {
2094                if !node.active(self) {
2095                    continue;
2096                }
2097                let new = Some(Arc::new(inferred));
2098                if node.inferred_return_type(self) == new {
2099                    continue;
2100                }
2101                node.set_inferred_return_type(self).to(new);
2102            }
2103        }
2104    }
2105
2106    /// Mark the `FunctionNode` for `fqn` as inactive.
2107    pub fn deactivate_function_node(&mut self, fqn: &str) {
2108        use salsa::Setter as _;
2109        if let Some(&node) = self.function_nodes.get(fqn) {
2110            node.set_active(self).to(false);
2111        }
2112    }
2113
2114    /// Create or update the `MethodNode` for `(storage.fqcn, storage.name.to_lowercase())`.
2115    pub fn upsert_method_node(&mut self, storage: &MethodStorage) -> MethodNode {
2116        use salsa::Setter as _;
2117        let fqcn = &storage.fqcn;
2118        let name_lower: Arc<str> = Arc::from(storage.name.to_lowercase().as_str());
2119        // Copy the existing handle out to release the immutable borrow before
2120        // calling node.set_*(self), which needs &mut self.
2121        let existing = self
2122            .method_nodes
2123            .get(fqcn.as_ref())
2124            .and_then(|m| m.get(&name_lower))
2125            .copied();
2126        if let Some(node) = existing {
2127            // Fast-skip identical re-ingest — see `upsert_class_node` for rationale.
2128            // `inferred_return_type` intentionally not compared / written here;
2129            // ownership is in the priming-sweep commit phase.
2130            if node.active(self)
2131                && node.visibility(self) == storage.visibility
2132                && node.is_static(self) == storage.is_static
2133                && node.is_abstract(self) == storage.is_abstract
2134                && node.is_final(self) == storage.is_final
2135                && node.is_constructor(self) == storage.is_constructor
2136                && node.is_pure(self) == storage.is_pure
2137                && node.deprecated(self) == storage.deprecated
2138                && node.return_type(self).as_deref() == storage.return_type.as_deref()
2139                && node.location(self) == storage.location
2140                && *node.params(self) == *storage.params.as_ref()
2141                && *node.template_params(self) == *storage.template_params
2142                && *node.assertions(self) == *storage.assertions
2143                && *node.throws(self) == *storage.throws
2144            {
2145                return node;
2146            }
2147            node.set_active(self).to(true);
2148            node.set_params(self).to(storage.params.clone());
2149            node.set_return_type(self).to(storage.return_type.clone());
2150            node.set_template_params(self)
2151                .to(Arc::from(storage.template_params.as_slice()));
2152            node.set_assertions(self)
2153                .to(Arc::from(storage.assertions.as_slice()));
2154            node.set_throws(self)
2155                .to(Arc::from(storage.throws.as_slice()));
2156            node.set_deprecated(self).to(storage.deprecated.clone());
2157            node.set_visibility(self).to(storage.visibility);
2158            node.set_is_static(self).to(storage.is_static);
2159            node.set_is_abstract(self).to(storage.is_abstract);
2160            node.set_is_final(self).to(storage.is_final);
2161            node.set_is_constructor(self).to(storage.is_constructor);
2162            node.set_is_pure(self).to(storage.is_pure);
2163            node.set_location(self).to(storage.location.clone());
2164            node
2165        } else {
2166            // MethodNode::new takes &mut self; insert after it returns.
2167            let node = MethodNode::new(
2168                self,
2169                fqcn.clone(),
2170                storage.name.clone(),
2171                true,
2172                storage.params.clone(),
2173                storage.return_type.clone(),
2174                storage
2175                    .inferred_return_type
2176                    .as_ref()
2177                    .map(|t| Arc::new(t.clone())),
2178                Arc::from(storage.template_params.as_slice()),
2179                Arc::from(storage.assertions.as_slice()),
2180                Arc::from(storage.throws.as_slice()),
2181                storage.deprecated.clone(),
2182                storage.visibility,
2183                storage.is_static,
2184                storage.is_abstract,
2185                storage.is_final,
2186                storage.is_constructor,
2187                storage.is_pure,
2188                storage.location.clone(),
2189            );
2190            Arc::make_mut(&mut self.method_nodes)
2191                .entry(fqcn.clone())
2192                .or_default()
2193                .insert(name_lower, node);
2194            node
2195        }
2196    }
2197
2198    /// Mark all `MethodNode`s owned by `fqcn` as inactive.
2199    pub fn deactivate_class_methods(&mut self, fqcn: &str) {
2200        use salsa::Setter as _;
2201        let nodes: Vec<MethodNode> = match self.method_nodes.get(fqcn) {
2202            Some(methods) => methods.values().copied().collect(),
2203            None => return,
2204        };
2205        for node in nodes {
2206            node.set_active(self).to(false);
2207        }
2208    }
2209
2210    /// Deactivate `MethodNode`s for `fqcn` whose lowercased name is not in
2211    /// `keep_lower`.  Used by `ingest_stub_slice` to prune stale stub methods
2212    /// when a user file shadows a bundled-stub class with a different method
2213    /// set.  Active-only check preserves PR21's fast-skip — already-inactive
2214    /// nodes don't fire a setter.
2215    pub fn prune_class_methods<T>(&mut self, fqcn: &str, keep_lower: &std::collections::HashSet<T>)
2216    where
2217        T: Eq + std::hash::Hash + std::borrow::Borrow<str>,
2218    {
2219        use salsa::Setter as _;
2220        let candidates: Vec<MethodNode> = self
2221            .method_nodes
2222            .get(fqcn)
2223            .map(|m| {
2224                m.iter()
2225                    .filter(|(k, _)| !keep_lower.contains(k.as_ref()))
2226                    .map(|(_, n)| *n)
2227                    .collect()
2228            })
2229            .unwrap_or_default();
2230        for node in candidates {
2231            if node.active(self) {
2232                node.set_active(self).to(false);
2233            }
2234        }
2235    }
2236
2237    /// Deactivate `PropertyNode`s for `fqcn` whose name is not in `keep`.
2238    pub fn prune_class_properties<T>(&mut self, fqcn: &str, keep: &std::collections::HashSet<T>)
2239    where
2240        T: Eq + std::hash::Hash + std::borrow::Borrow<str>,
2241    {
2242        use salsa::Setter as _;
2243        let candidates: Vec<PropertyNode> = self
2244            .property_nodes
2245            .get(fqcn)
2246            .map(|m| {
2247                m.iter()
2248                    .filter(|(k, _)| !keep.contains(k.as_ref()))
2249                    .map(|(_, n)| *n)
2250                    .collect()
2251            })
2252            .unwrap_or_default();
2253        for node in candidates {
2254            if node.active(self) {
2255                node.set_active(self).to(false);
2256            }
2257        }
2258    }
2259
2260    /// Deactivate `ClassConstantNode`s for `fqcn` whose name is not in `keep`.
2261    pub fn prune_class_constants<T>(&mut self, fqcn: &str, keep: &std::collections::HashSet<T>)
2262    where
2263        T: Eq + std::hash::Hash + std::borrow::Borrow<str>,
2264    {
2265        use salsa::Setter as _;
2266        let candidates: Vec<ClassConstantNode> = self
2267            .class_constant_nodes
2268            .get(fqcn)
2269            .map(|m| {
2270                m.iter()
2271                    .filter(|(k, _)| !keep.contains(k.as_ref()))
2272                    .map(|(_, n)| *n)
2273                    .collect()
2274            })
2275            .unwrap_or_default();
2276        for node in candidates {
2277            if node.active(self) {
2278                node.set_active(self).to(false);
2279            }
2280        }
2281    }
2282
2283    /// Create or update the `PropertyNode` for `(storage.fqcn, storage.name)`.
2284    pub fn upsert_property_node(&mut self, fqcn: &Arc<str>, storage: &PropertyStorage) {
2285        use salsa::Setter as _;
2286        let existing = self
2287            .property_nodes
2288            .get(fqcn.as_ref())
2289            .and_then(|m| m.get(storage.name.as_ref()))
2290            .copied();
2291        if let Some(node) = existing {
2292            // Fast-skip identical re-ingest — see `upsert_class_node` for rationale.
2293            if node.active(self)
2294                && node.visibility(self) == storage.visibility
2295                && node.is_static(self) == storage.is_static
2296                && node.is_readonly(self) == storage.is_readonly
2297                && node.ty(self) == storage.ty
2298                && node.location(self) == storage.location
2299            {
2300                return;
2301            }
2302            node.set_active(self).to(true);
2303            node.set_ty(self).to(storage.ty.clone());
2304            node.set_visibility(self).to(storage.visibility);
2305            node.set_is_static(self).to(storage.is_static);
2306            node.set_is_readonly(self).to(storage.is_readonly);
2307            node.set_location(self).to(storage.location.clone());
2308        } else {
2309            let node = PropertyNode::new(
2310                self,
2311                fqcn.clone(),
2312                storage.name.clone(),
2313                true,
2314                storage.ty.clone(),
2315                storage.visibility,
2316                storage.is_static,
2317                storage.is_readonly,
2318                storage.location.clone(),
2319            );
2320            Arc::make_mut(&mut self.property_nodes)
2321                .entry(fqcn.clone())
2322                .or_default()
2323                .insert(storage.name.clone(), node);
2324        }
2325    }
2326
2327    /// Mark all `PropertyNode`s owned by `fqcn` as inactive.
2328    pub fn deactivate_class_properties(&mut self, fqcn: &str) {
2329        use salsa::Setter as _;
2330        let nodes: Vec<PropertyNode> = match self.property_nodes.get(fqcn) {
2331            Some(props) => props.values().copied().collect(),
2332            None => return,
2333        };
2334        for node in nodes {
2335            node.set_active(self).to(false);
2336        }
2337    }
2338
2339    /// Create or update the `ClassConstantNode` for `(fqcn, storage.name)`.
2340    pub fn upsert_class_constant_node(&mut self, fqcn: &Arc<str>, storage: &ConstantStorage) {
2341        use salsa::Setter as _;
2342        let existing = self
2343            .class_constant_nodes
2344            .get(fqcn.as_ref())
2345            .and_then(|m| m.get(storage.name.as_ref()))
2346            .copied();
2347        if let Some(node) = existing {
2348            // Fast-skip identical re-ingest — see `upsert_class_node` for rationale.
2349            if node.active(self)
2350                && node.visibility(self) == storage.visibility
2351                && node.is_final(self) == storage.is_final
2352                && node.ty(self) == storage.ty
2353                && node.location(self) == storage.location
2354            {
2355                return;
2356            }
2357            node.set_active(self).to(true);
2358            node.set_ty(self).to(storage.ty.clone());
2359            node.set_visibility(self).to(storage.visibility);
2360            node.set_is_final(self).to(storage.is_final);
2361            node.set_location(self).to(storage.location.clone());
2362        } else {
2363            let node = ClassConstantNode::new(
2364                self,
2365                fqcn.clone(),
2366                storage.name.clone(),
2367                true,
2368                storage.ty.clone(),
2369                storage.visibility,
2370                storage.is_final,
2371                storage.location.clone(),
2372            );
2373            Arc::make_mut(&mut self.class_constant_nodes)
2374                .entry(fqcn.clone())
2375                .or_default()
2376                .insert(storage.name.clone(), node);
2377        }
2378    }
2379
2380    /// Create or update the `GlobalConstantNode` for `fqn`.
2381    pub fn upsert_global_constant_node(&mut self, fqn: Arc<str>, ty: Union) -> GlobalConstantNode {
2382        use salsa::Setter as _;
2383        if let Some(&node) = self.global_constant_nodes.get(&fqn) {
2384            // Fast-skip identical re-ingest — see `upsert_class_node` for rationale.
2385            if node.active(self) && node.ty(self) == ty {
2386                return node;
2387            }
2388            node.set_active(self).to(true);
2389            node.set_ty(self).to(ty);
2390            node
2391        } else {
2392            let node = GlobalConstantNode::new(self, fqn.clone(), true, ty);
2393            Arc::make_mut(&mut self.global_constant_nodes).insert(fqn, node);
2394            node
2395        }
2396    }
2397
2398    /// Mark the `GlobalConstantNode` for `fqn` as inactive.
2399    pub fn deactivate_global_constant_node(&mut self, fqn: &str) {
2400        use salsa::Setter as _;
2401        if let Some(&node) = self.global_constant_nodes.get(fqn) {
2402            node.set_active(self).to(false);
2403        }
2404    }
2405
2406    /// Mark all `ClassConstantNode`s owned by `fqcn` as inactive.
2407    pub fn deactivate_class_constants(&mut self, fqcn: &str) {
2408        use salsa::Setter as _;
2409        let nodes: Vec<ClassConstantNode> = match self.class_constant_nodes.get(fqcn) {
2410            Some(consts) => consts.values().copied().collect(),
2411            None => return,
2412        };
2413        for node in nodes {
2414            node.set_active(self).to(false);
2415        }
2416    }
2417}
2418
2419// ---------------------------------------------------------------------------
2420// S4 Step 1: analyze_file accumulators + tracked-query skeleton
2421// ---------------------------------------------------------------------------
2422//
2423// First step toward S4 (issues + reference locations as Salsa accumulators,
2424// `analyze_file` as a tracked query).  This step is purely additive:
2425//
2426//   1. Defines `IssueAccumulator` and `RefLocAccumulator` salsa accumulator
2427//      types — push targets for analyzer-emitted issues and reference-index
2428//      entries during tracked-query evaluation.
2429//   2. Defines `analyze_file` as a tracked-query stub keyed on a
2430//      `(SourceFile, AnalyzeFileInput)` pair.  The stub does NOT perform
2431//      analysis — it accumulates only the parse errors (a strict subset of
2432//      what `collect_file_definitions` already produces, so semantics are
2433//      unchanged).  The full analyzer wiring follows in subsequent S4 PRs.
2434//
2435// Nothing in this module is wired into the batch (`analyze`) or LSP
2436// (`re_analyze_file`) paths yet.  Behavior change: zero.
2437
2438/// Salsa accumulator carrying analyzer-emitted issues.  In the eventual
2439/// S4 design, every site that today calls `IssueBuffer::add` / `Vec::push`
2440/// from inside a tracked query will instead call
2441/// `IssueAccumulator(issue).accumulate(db)`, and `re_analyze_file` will read
2442/// the accumulated issues for the file with
2443/// `analyze_file::accumulated::<IssueAccumulator>(db, file, ...)`.
2444#[salsa::accumulator]
2445#[derive(Clone, Debug)]
2446pub struct IssueAccumulator(pub Issue);
2447
2448/// Reference-index entry as produced during analysis.  Mirrors the tuple
2449/// shape that `Codebase::record_ref` accepts:
2450///
2451/// - `symbol_key`: interner-bound string (`"fn:foo"`, `"cls:Foo"`,
2452///   `"prop:Foo::$bar"`, `"cnst:Foo::BAR"`, `"meth:Foo::bar"` — same keys
2453///   `Codebase::mark_*_referenced_at` use).
2454/// - `file`: the file in which the reference appears.
2455/// - `(line, col_start, col_end)`: span within the file.
2456#[derive(Clone, Debug, PartialEq, Eq)]
2457pub struct RefLoc {
2458    pub symbol_key: Arc<str>,
2459    pub file: Arc<str>,
2460    pub line: u32,
2461    pub col_start: u16,
2462    pub col_end: u16,
2463}
2464
2465/// Salsa accumulator carrying reference-index entries.  In the eventual
2466/// S4 design this replaces the `Codebase::mark_*_referenced_at` side
2467/// effects: instead of mutating the codebase's reference index inside a
2468/// tracked query (which Salsa cannot observe), the analyzer pushes
2469/// `RefLocAccumulator(loc)` and the consumer (LSP / dead-code) reads via
2470/// `analyze_file::accumulated::<RefLocAccumulator>(db, …)`.
2471#[salsa::accumulator]
2472#[derive(Clone, Debug)]
2473pub struct RefLocAccumulator(pub RefLoc);
2474
2475/// Salsa tracked-query input for `analyze_file`.  Carries the analysis
2476/// parameters that aren't already captured by `SourceFile` itself.  Kept
2477/// minimal in this PR; subsequent PRs in the S4 chain will extend it as
2478/// the query body grows to call the full analyzer pipeline.
2479#[salsa::input]
2480pub struct AnalyzeFileInput {
2481    /// Resolved PHP version (`"8.1"`, `"8.2"`, …) used by the analyzer.
2482    /// Mirrors `ProjectAnalyzer::resolved_php_version`.
2483    pub php_version: Arc<str>,
2484}
2485
2486/// Tracked-query skeleton for `analyze_file`.
2487///
2488/// **Current behavior (S4 step 1):** parses the file and emits parse-error
2489/// issues via `IssueAccumulator`.  Does NOT call into Pass 2 / the
2490/// statement / expression analyzer; full body analysis stays in
2491/// `Pass2Driver` until later S4 PRs migrate it.
2492///
2493/// The query exists at this stage to:
2494/// - validate that accumulators compile and accumulate against the
2495///   concrete `MirDb`,
2496/// - give subsequent PRs a stable signature to extend without churning
2497///   the public surface of `db.rs` again,
2498/// - provide a readable test of the accumulator round-trip
2499///   (`accumulate` → `accumulated::<…>(db, …)`).
2500#[salsa::tracked]
2501pub fn analyze_file(db: &dyn MirDatabase, file: SourceFile, _input: AnalyzeFileInput) {
2502    use salsa::Accumulator as _;
2503    let path = file.path(db);
2504    let text = file.text(db);
2505
2506    let arena = bumpalo::Bump::new();
2507    let parsed = php_rs_parser::parse(&arena, &text);
2508
2509    for err in &parsed.errors {
2510        let issue = Issue::new(
2511            mir_issues::IssueKind::ParseError {
2512                message: err.to_string(),
2513            },
2514            mir_issues::Location {
2515                file: path.clone(),
2516                line: 1,
2517                line_end: 1,
2518                col_start: 0,
2519                col_end: 0,
2520            },
2521        );
2522        IssueAccumulator(issue).accumulate(db);
2523    }
2524}
2525
2526// ---------------------------------------------------------------------------
2527// Tests
2528// ---------------------------------------------------------------------------
2529
2530#[cfg(test)]
2531mod tests {
2532    use super::*;
2533    use salsa::Setter as _;
2534
2535    fn upsert_class(
2536        db: &mut MirDb,
2537        fqcn: &str,
2538        parent: Option<Arc<str>>,
2539        extends: Arc<[Arc<str>]>,
2540        is_interface: bool,
2541    ) -> ClassNode {
2542        db.upsert_class_node(ClassNodeFields {
2543            is_interface,
2544            parent,
2545            extends,
2546            ..ClassNodeFields::for_class(Arc::from(fqcn))
2547        })
2548    }
2549
2550    #[test]
2551    fn mirdb_constructs() {
2552        let _db = MirDb::default();
2553    }
2554
2555    #[test]
2556    fn source_file_input_roundtrip() {
2557        let db = MirDb::default();
2558        let file = SourceFile::new(&db, Arc::from("/tmp/test.php"), Arc::from("<?php echo 1;"));
2559        assert_eq!(file.path(&db).as_ref(), "/tmp/test.php");
2560        assert_eq!(file.text(&db).as_ref(), "<?php echo 1;");
2561    }
2562
2563    #[test]
2564    fn collect_file_definitions_basic() {
2565        let db = MirDb::default();
2566        let src = Arc::from("<?php class Foo {}");
2567        let file = SourceFile::new(&db, Arc::from("/tmp/foo.php"), src);
2568        let defs = collect_file_definitions(&db, file);
2569        assert!(defs.issues.is_empty());
2570        assert_eq!(defs.slice.classes.len(), 1);
2571        assert_eq!(defs.slice.classes[0].fqcn.as_ref(), "Foo");
2572    }
2573
2574    #[test]
2575    fn collect_file_definitions_memoized() {
2576        let db = MirDb::default();
2577        let file = SourceFile::new(
2578            &db,
2579            Arc::from("/tmp/memo.php"),
2580            Arc::from("<?php class Bar {}"),
2581        );
2582
2583        let defs1 = collect_file_definitions(&db, file);
2584        let defs2 = collect_file_definitions(&db, file);
2585        assert!(
2586            Arc::ptr_eq(&defs1.slice, &defs2.slice),
2587            "unchanged file must return the memoized result"
2588        );
2589    }
2590
2591    #[test]
2592    fn analyze_file_accumulates_parse_errors() {
2593        let db = MirDb::default();
2594        // Unterminated string literal — guaranteed parser diagnostic.
2595        let file = SourceFile::new(
2596            &db,
2597            Arc::from("/tmp/parse_err.php"),
2598            Arc::from("<?php $x = \"unterminated"),
2599        );
2600        let input = AnalyzeFileInput::new(&db, Arc::from("8.2"));
2601        analyze_file(&db, file, input);
2602        let issues: Vec<&IssueAccumulator> = analyze_file::accumulated(&db, file, input);
2603        assert!(
2604            !issues.is_empty(),
2605            "expected parse error to surface as accumulated IssueAccumulator"
2606        );
2607        assert!(matches!(
2608            issues[0].0.kind,
2609            mir_issues::IssueKind::ParseError { .. }
2610        ));
2611    }
2612
2613    #[test]
2614    fn analyze_file_clean_input_accumulates_nothing() {
2615        let db = MirDb::default();
2616        let file = SourceFile::new(
2617            &db,
2618            Arc::from("/tmp/clean.php"),
2619            Arc::from("<?php class Foo {}"),
2620        );
2621        let input = AnalyzeFileInput::new(&db, Arc::from("8.2"));
2622        analyze_file(&db, file, input);
2623        let issues: Vec<&IssueAccumulator> = analyze_file::accumulated(&db, file, input);
2624        let refs: Vec<&RefLocAccumulator> = analyze_file::accumulated(&db, file, input);
2625        assert!(issues.is_empty());
2626        assert!(refs.is_empty());
2627    }
2628
2629    #[test]
2630    fn collect_file_definitions_recomputes_on_change() {
2631        let mut db = MirDb::default();
2632        let file = SourceFile::new(
2633            &db,
2634            Arc::from("/tmp/memo2.php"),
2635            Arc::from("<?php class Foo {}"),
2636        );
2637
2638        let defs1 = collect_file_definitions(&db, file);
2639        file.set_text(&mut db)
2640            .to(Arc::from("<?php class Foo {} class Bar {}"));
2641        let defs2 = collect_file_definitions(&db, file);
2642
2643        assert!(
2644            !Arc::ptr_eq(&defs1.slice, &defs2.slice),
2645            "changed file must produce a new result"
2646        );
2647        assert_eq!(defs2.slice.classes.len(), 2);
2648    }
2649
2650    #[test]
2651    fn class_ancestors_empty_for_root_class() {
2652        let mut db = MirDb::default();
2653        let node = upsert_class(&mut db, "Foo", None, Arc::from([]), false);
2654        let ancestors = class_ancestors(&db, node);
2655        assert!(ancestors.0.is_empty(), "root class has no ancestors");
2656    }
2657
2658    #[test]
2659    fn class_ancestors_single_parent() {
2660        let mut db = MirDb::default();
2661        upsert_class(&mut db, "Base", None, Arc::from([]), false);
2662        let child = upsert_class(
2663            &mut db,
2664            "Child",
2665            Some(Arc::from("Base")),
2666            Arc::from([]),
2667            false,
2668        );
2669        let ancestors = class_ancestors(&db, child);
2670        assert_eq!(ancestors.0.len(), 1);
2671        assert_eq!(ancestors.0[0].as_ref(), "Base");
2672    }
2673
2674    #[test]
2675    fn class_ancestors_transitive() {
2676        let mut db = MirDb::default();
2677        upsert_class(&mut db, "GrandParent", None, Arc::from([]), false);
2678        upsert_class(
2679            &mut db,
2680            "Parent",
2681            Some(Arc::from("GrandParent")),
2682            Arc::from([]),
2683            false,
2684        );
2685        let child = upsert_class(
2686            &mut db,
2687            "Child",
2688            Some(Arc::from("Parent")),
2689            Arc::from([]),
2690            false,
2691        );
2692        let ancestors = class_ancestors(&db, child);
2693        assert_eq!(ancestors.0.len(), 2);
2694        assert_eq!(ancestors.0[0].as_ref(), "Parent");
2695        assert_eq!(ancestors.0[1].as_ref(), "GrandParent");
2696    }
2697
2698    #[test]
2699    fn class_ancestors_cycle_returns_empty() {
2700        let mut db = MirDb::default();
2701        // A extends A — not valid PHP, but we must not panic.
2702        let node_a = upsert_class(&mut db, "A", Some(Arc::from("A")), Arc::from([]), false);
2703        let ancestors = class_ancestors(&db, node_a);
2704        // Cycle recovery: empty list (A's ancestors exclude itself).
2705        assert!(ancestors.0.is_empty(), "cycle must yield empty ancestors");
2706    }
2707
2708    #[test]
2709    fn class_ancestors_inactive_node_returns_empty() {
2710        let mut db = MirDb::default();
2711        let node = upsert_class(&mut db, "Foo", None, Arc::from([]), false);
2712        db.deactivate_class_node("Foo");
2713        let ancestors = class_ancestors(&db, node);
2714        assert!(ancestors.0.is_empty(), "inactive node must yield empty");
2715    }
2716
2717    #[test]
2718    fn class_ancestors_recomputes_on_parent_change() {
2719        let mut db = MirDb::default();
2720        upsert_class(&mut db, "Base", None, Arc::from([]), false);
2721        let child = upsert_class(&mut db, "Child", None, Arc::from([]), false);
2722
2723        let before = class_ancestors(&db, child);
2724        assert!(before.0.is_empty());
2725
2726        // Add Base as parent of Child.
2727        child.set_parent(&mut db).to(Some(Arc::from("Base")));
2728
2729        let after = class_ancestors(&db, child);
2730        assert_eq!(after.0.len(), 1);
2731        assert_eq!(after.0[0].as_ref(), "Base");
2732    }
2733
2734    #[test]
2735    fn interface_ancestors_via_extends() {
2736        let mut db = MirDb::default();
2737        upsert_class(&mut db, "Countable", None, Arc::from([]), true);
2738        let child_iface = upsert_class(
2739            &mut db,
2740            "Collection",
2741            None,
2742            Arc::from([Arc::from("Countable")]),
2743            true,
2744        );
2745        let ancestors = class_ancestors(&db, child_iface);
2746        assert_eq!(ancestors.0.len(), 1);
2747        assert_eq!(ancestors.0[0].as_ref(), "Countable");
2748    }
2749
2750    #[test]
2751    fn type_exists_via_db_tracks_active_state() {
2752        let mut db = MirDb::default();
2753        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
2754        assert!(type_exists_via_db(&db, "Foo"));
2755        assert!(!type_exists_via_db(&db, "Bar"));
2756        db.deactivate_class_node("Foo");
2757        assert!(!type_exists_via_db(&db, "Foo"));
2758    }
2759
2760    #[test]
2761    fn clone_preserves_class_node_lookups() {
2762        // PR10a: each parallel batch worker gets its own MirDb clone.
2763        // Verify the clone observes the same registered nodes.
2764        let mut db = MirDb::default();
2765        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
2766        let cloned = db.clone();
2767        assert!(
2768            type_exists_via_db(&cloned, "Foo"),
2769            "clone must observe nodes registered before clone()"
2770        );
2771        assert!(
2772            !type_exists_via_db(&cloned, "Bar"),
2773            "clone must not observe nodes that were never registered"
2774        );
2775        // Clones must also resolve ancestors through the same shared storage.
2776        let foo_node = cloned.lookup_class_node("Foo").expect("registered");
2777        let ancestors = class_ancestors(&cloned, foo_node);
2778        assert!(ancestors.0.is_empty(), "Foo has no ancestors");
2779    }
2780
2781    // -----------------------------------------------------------------
2782    // Helpers for method-related fixtures
2783    // -----------------------------------------------------------------
2784
2785    fn upsert_class_with_traits(
2786        db: &mut MirDb,
2787        fqcn: &str,
2788        parent: Option<Arc<str>>,
2789        traits: &[&str],
2790        is_interface: bool,
2791        is_trait: bool,
2792    ) -> ClassNode {
2793        db.upsert_class_node(ClassNodeFields {
2794            is_interface,
2795            is_trait,
2796            parent,
2797            traits: Arc::from(
2798                traits
2799                    .iter()
2800                    .map(|t| Arc::<str>::from(*t))
2801                    .collect::<Vec<_>>(),
2802            ),
2803            ..ClassNodeFields::for_class(Arc::from(fqcn))
2804        })
2805    }
2806
2807    fn upsert_method(db: &mut MirDb, fqcn: &str, name: &str, is_abstract: bool) -> MethodNode {
2808        let storage = MethodStorage {
2809            name: Arc::from(name),
2810            fqcn: Arc::from(fqcn),
2811            params: Arc::from([].as_slice()),
2812            return_type: None,
2813            inferred_return_type: None,
2814            visibility: Visibility::Public,
2815            is_static: false,
2816            is_abstract,
2817            is_final: false,
2818            is_constructor: name == "__construct",
2819            template_params: vec![],
2820            assertions: vec![],
2821            throws: vec![],
2822            deprecated: None,
2823            is_internal: false,
2824            is_pure: false,
2825            location: None,
2826        };
2827        db.upsert_method_node(&storage)
2828    }
2829
2830    fn upsert_enum(db: &mut MirDb, fqcn: &str, interfaces: &[&str], is_backed: bool) -> ClassNode {
2831        db.upsert_class_node(ClassNodeFields {
2832            interfaces: Arc::from(
2833                interfaces
2834                    .iter()
2835                    .map(|i| Arc::<str>::from(*i))
2836                    .collect::<Vec<_>>(),
2837            ),
2838            is_backed_enum: is_backed,
2839            ..ClassNodeFields::for_enum(Arc::from(fqcn))
2840        })
2841    }
2842
2843    // -----------------------------------------------------------------
2844    // method_exists_via_db
2845    // -----------------------------------------------------------------
2846
2847    #[test]
2848    fn method_exists_via_db_finds_own_method() {
2849        let mut db = MirDb::default();
2850        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
2851        upsert_method(&mut db, "Foo", "bar", false);
2852        assert!(method_exists_via_db(&db, "Foo", "bar"));
2853        assert!(!method_exists_via_db(&db, "Foo", "missing"));
2854    }
2855
2856    #[test]
2857    fn method_exists_via_db_walks_parent() {
2858        let mut db = MirDb::default();
2859        upsert_class(&mut db, "Base", None, Arc::from([]), false);
2860        upsert_method(&mut db, "Base", "inherited", false);
2861        upsert_class(
2862            &mut db,
2863            "Child",
2864            Some(Arc::from("Base")),
2865            Arc::from([]),
2866            false,
2867        );
2868        assert!(method_exists_via_db(&db, "Child", "inherited"));
2869    }
2870
2871    #[test]
2872    fn method_exists_via_db_walks_traits_transitively() {
2873        let mut db = MirDb::default();
2874        upsert_class_with_traits(&mut db, "InnerTrait", None, &[], false, true);
2875        upsert_method(&mut db, "InnerTrait", "deep_trait_method", false);
2876        upsert_class_with_traits(&mut db, "OuterTrait", None, &["InnerTrait"], false, true);
2877        upsert_class_with_traits(&mut db, "Foo", None, &["OuterTrait"], false, false);
2878        assert!(method_exists_via_db(&db, "Foo", "deep_trait_method"));
2879    }
2880
2881    #[test]
2882    fn method_exists_via_db_is_case_insensitive() {
2883        let mut db = MirDb::default();
2884        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
2885        upsert_method(&mut db, "Foo", "doStuff", false);
2886        // Stored with original case; lookup must lowercase internally.
2887        assert!(method_exists_via_db(&db, "Foo", "DoStuff"));
2888        assert!(method_exists_via_db(&db, "Foo", "DOSTUFF"));
2889    }
2890
2891    #[test]
2892    fn method_exists_via_db_unknown_class_returns_false() {
2893        let db = MirDb::default();
2894        assert!(!method_exists_via_db(&db, "Nope", "anything"));
2895    }
2896
2897    #[test]
2898    fn method_exists_via_db_inactive_class_returns_false() {
2899        let mut db = MirDb::default();
2900        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
2901        upsert_method(&mut db, "Foo", "bar", false);
2902        db.deactivate_class_node("Foo");
2903        assert!(!method_exists_via_db(&db, "Foo", "bar"));
2904    }
2905
2906    #[test]
2907    fn method_exists_via_db_finds_abstract_methods() {
2908        // Existence-only: abstracts count.  This is the difference vs.
2909        // method_is_concretely_implemented.
2910        let mut db = MirDb::default();
2911        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
2912        upsert_method(&mut db, "Foo", "abstr", true);
2913        assert!(method_exists_via_db(&db, "Foo", "abstr"));
2914    }
2915
2916    // -----------------------------------------------------------------
2917    // method_is_concretely_implemented
2918    // -----------------------------------------------------------------
2919
2920    #[test]
2921    fn method_is_concretely_implemented_skips_abstract() {
2922        let mut db = MirDb::default();
2923        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
2924        upsert_method(&mut db, "Foo", "abstr", true);
2925        assert!(!method_is_concretely_implemented(&db, "Foo", "abstr"));
2926    }
2927
2928    #[test]
2929    fn method_is_concretely_implemented_finds_concrete_in_trait() {
2930        let mut db = MirDb::default();
2931        upsert_class_with_traits(&mut db, "MyTrait", None, &[], false, true);
2932        upsert_method(&mut db, "MyTrait", "provided", false);
2933        upsert_class_with_traits(&mut db, "Foo", None, &["MyTrait"], false, false);
2934        assert!(method_is_concretely_implemented(&db, "Foo", "provided"));
2935    }
2936
2937    #[test]
2938    fn method_is_concretely_implemented_skips_interface_definitions() {
2939        // Interfaces don't supply implementations, regardless of how
2940        // their methods are stored.
2941        let mut db = MirDb::default();
2942        upsert_class(&mut db, "I", None, Arc::from([]), true);
2943        upsert_method(&mut db, "I", "m", false);
2944        upsert_class(&mut db, "C", None, Arc::from([Arc::from("I")]), false);
2945        // C "implements" I but has no own implementation.
2946        assert!(!method_is_concretely_implemented(&db, "C", "m"));
2947    }
2948
2949    // -----------------------------------------------------------------
2950    // extends_or_implements_via_db
2951    // -----------------------------------------------------------------
2952
2953    #[test]
2954    fn extends_or_implements_via_db_self_match() {
2955        let mut db = MirDb::default();
2956        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
2957        assert!(extends_or_implements_via_db(&db, "Foo", "Foo"));
2958    }
2959
2960    #[test]
2961    fn extends_or_implements_via_db_transitive() {
2962        let mut db = MirDb::default();
2963        upsert_class(&mut db, "Animal", None, Arc::from([]), false);
2964        upsert_class(
2965            &mut db,
2966            "Mammal",
2967            Some(Arc::from("Animal")),
2968            Arc::from([]),
2969            false,
2970        );
2971        upsert_class(
2972            &mut db,
2973            "Dog",
2974            Some(Arc::from("Mammal")),
2975            Arc::from([]),
2976            false,
2977        );
2978        assert!(extends_or_implements_via_db(&db, "Dog", "Animal"));
2979        assert!(extends_or_implements_via_db(&db, "Dog", "Mammal"));
2980        assert!(!extends_or_implements_via_db(&db, "Animal", "Dog"));
2981    }
2982
2983    #[test]
2984    fn extends_or_implements_via_db_unknown_returns_false() {
2985        let db = MirDb::default();
2986        assert!(!extends_or_implements_via_db(&db, "Nope", "Foo"));
2987    }
2988
2989    #[test]
2990    fn extends_or_implements_via_db_unit_enum_implicit() {
2991        let mut db = MirDb::default();
2992        upsert_enum(&mut db, "Status", &[], false);
2993        assert!(extends_or_implements_via_db(&db, "Status", "UnitEnum"));
2994        assert!(extends_or_implements_via_db(&db, "Status", "\\UnitEnum"));
2995        // Pure enum is NOT a BackedEnum.
2996        assert!(!extends_or_implements_via_db(&db, "Status", "BackedEnum"));
2997    }
2998
2999    #[test]
3000    fn extends_or_implements_via_db_backed_enum_implicit() {
3001        let mut db = MirDb::default();
3002        upsert_enum(&mut db, "Status", &[], true);
3003        assert!(extends_or_implements_via_db(&db, "Status", "UnitEnum"));
3004        assert!(extends_or_implements_via_db(&db, "Status", "BackedEnum"));
3005        assert!(extends_or_implements_via_db(&db, "Status", "\\BackedEnum"));
3006    }
3007
3008    #[test]
3009    fn extends_or_implements_via_db_enum_declared_interface() {
3010        let mut db = MirDb::default();
3011        upsert_class(&mut db, "Stringable", None, Arc::from([]), true);
3012        upsert_enum(&mut db, "Status", &["Stringable"], false);
3013        assert!(extends_or_implements_via_db(&db, "Status", "Stringable"));
3014    }
3015
3016    // -----------------------------------------------------------------
3017    // has_unknown_ancestor_via_db
3018    // -----------------------------------------------------------------
3019
3020    #[test]
3021    fn has_unknown_ancestor_via_db_clean_chain_returns_false() {
3022        let mut db = MirDb::default();
3023        upsert_class(&mut db, "Base", None, Arc::from([]), false);
3024        upsert_class(
3025            &mut db,
3026            "Child",
3027            Some(Arc::from("Base")),
3028            Arc::from([]),
3029            false,
3030        );
3031        assert!(!has_unknown_ancestor_via_db(&db, "Child"));
3032    }
3033
3034    #[test]
3035    fn has_unknown_ancestor_via_db_missing_parent_returns_true() {
3036        let mut db = MirDb::default();
3037        // Child claims to extend Missing, but Missing isn't registered.
3038        upsert_class(
3039            &mut db,
3040            "Child",
3041            Some(Arc::from("Missing")),
3042            Arc::from([]),
3043            false,
3044        );
3045        assert!(has_unknown_ancestor_via_db(&db, "Child"));
3046    }
3047
3048    #[test]
3049    fn class_template_params_via_db_returns_registered_params() {
3050        use mir_types::Variance;
3051        let mut db = MirDb::default();
3052        let tp = TemplateParam {
3053            name: Arc::from("T"),
3054            bound: None,
3055            defining_entity: Arc::from("Box"),
3056            variance: Variance::Invariant,
3057        };
3058        db.upsert_class_node(ClassNodeFields {
3059            template_params: Arc::from([tp.clone()]),
3060            ..ClassNodeFields::for_class(Arc::from("Box"))
3061        });
3062        let got = class_template_params_via_db(&db, "Box").expect("registered");
3063        assert_eq!(got.len(), 1);
3064        assert_eq!(got[0].name.as_ref(), "T");
3065
3066        assert!(class_template_params_via_db(&db, "Missing").is_none());
3067        db.deactivate_class_node("Box");
3068        assert!(class_template_params_via_db(&db, "Box").is_none());
3069    }
3070
3071    // -----------------------------------------------------------------
3072    // lookup_method_in_chain
3073    // -----------------------------------------------------------------
3074
3075    fn upsert_class_with_mixins(
3076        db: &mut MirDb,
3077        fqcn: &str,
3078        parent: Option<Arc<str>>,
3079        mixins: &[&str],
3080    ) -> ClassNode {
3081        db.upsert_class_node(ClassNodeFields {
3082            parent,
3083            mixins: Arc::from(
3084                mixins
3085                    .iter()
3086                    .map(|m| Arc::<str>::from(*m))
3087                    .collect::<Vec<_>>(),
3088            ),
3089            ..ClassNodeFields::for_class(Arc::from(fqcn))
3090        })
3091    }
3092
3093    #[test]
3094    fn lookup_method_in_chain_finds_own_then_ancestor() {
3095        let mut db = MirDb::default();
3096        upsert_class(&mut db, "Base", None, Arc::from([]), false);
3097        upsert_method(&mut db, "Base", "shared", false);
3098        upsert_class(
3099            &mut db,
3100            "Child",
3101            Some(Arc::from("Base")),
3102            Arc::from([]),
3103            false,
3104        );
3105        upsert_method(&mut db, "Child", "shared", false);
3106        // Own wins over ancestor.
3107        let found = lookup_method_in_chain(&db, "Child", "shared").expect("own");
3108        assert_eq!(found.fqcn(&db).as_ref(), "Child");
3109        // Inherited-only resolves to ancestor.
3110        upsert_method(&mut db, "Base", "only_in_base", false);
3111        let found = lookup_method_in_chain(&db, "Child", "only_in_base").expect("ancestor");
3112        assert_eq!(found.fqcn(&db).as_ref(), "Base");
3113    }
3114
3115    #[test]
3116    fn lookup_method_in_chain_walks_trait_of_traits() {
3117        let mut db = MirDb::default();
3118        upsert_class_with_traits(&mut db, "InnerTrait", None, &[], false, true);
3119        upsert_method(&mut db, "InnerTrait", "deep", false);
3120        upsert_class_with_traits(&mut db, "OuterTrait", None, &["InnerTrait"], false, true);
3121        upsert_class_with_traits(&mut db, "Foo", None, &["OuterTrait"], false, false);
3122        let found = lookup_method_in_chain(&db, "Foo", "deep").expect("transitive trait");
3123        assert_eq!(found.fqcn(&db).as_ref(), "InnerTrait");
3124    }
3125
3126    #[test]
3127    fn lookup_method_in_chain_walks_mixins() {
3128        let mut db = MirDb::default();
3129        upsert_class(&mut db, "MixinTarget", None, Arc::from([]), false);
3130        upsert_method(&mut db, "MixinTarget", "magic", false);
3131        upsert_class_with_mixins(&mut db, "Host", None, &["MixinTarget"]);
3132        let found = lookup_method_in_chain(&db, "Host", "magic").expect("via @mixin");
3133        assert_eq!(found.fqcn(&db).as_ref(), "MixinTarget");
3134    }
3135
3136    #[test]
3137    fn lookup_method_in_chain_mixin_cycle_does_not_hang() {
3138        let mut db = MirDb::default();
3139        // A → B → A (mutual @mixin); neither defines the method.
3140        upsert_class_with_mixins(&mut db, "A", None, &["B"]);
3141        upsert_class_with_mixins(&mut db, "B", None, &["A"]);
3142        assert!(lookup_method_in_chain(&db, "A", "missing").is_none());
3143    }
3144
3145    #[test]
3146    fn lookup_method_in_chain_is_case_insensitive() {
3147        let mut db = MirDb::default();
3148        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
3149        upsert_method(&mut db, "Foo", "doStuff", false);
3150        assert!(lookup_method_in_chain(&db, "Foo", "DOSTUFF").is_some());
3151        assert!(lookup_method_in_chain(&db, "Foo", "dostuff").is_some());
3152    }
3153
3154    #[test]
3155    fn lookup_method_in_chain_unknown_returns_none() {
3156        let db = MirDb::default();
3157        assert!(lookup_method_in_chain(&db, "Nope", "anything").is_none());
3158    }
3159
3160    // -----------------------------------------------------------------
3161    // lookup_property_in_chain
3162    // -----------------------------------------------------------------
3163
3164    fn upsert_property(db: &mut MirDb, fqcn: &str, name: &str, is_readonly: bool) -> PropertyNode {
3165        let storage = PropertyStorage {
3166            name: Arc::from(name),
3167            ty: None,
3168            inferred_ty: None,
3169            visibility: Visibility::Public,
3170            is_static: false,
3171            is_readonly,
3172            default: None,
3173            location: None,
3174        };
3175        let owner = Arc::<str>::from(fqcn);
3176        db.upsert_property_node(&owner, &storage);
3177        db.lookup_property_node(fqcn, name).expect("registered")
3178    }
3179
3180    #[test]
3181    fn lookup_property_in_chain_own_then_ancestor() {
3182        let mut db = MirDb::default();
3183        upsert_class(&mut db, "Base", None, Arc::from([]), false);
3184        upsert_property(&mut db, "Base", "x", false);
3185        upsert_class(
3186            &mut db,
3187            "Child",
3188            Some(Arc::from("Base")),
3189            Arc::from([]),
3190            false,
3191        );
3192        // Inherited resolves to Base.
3193        let found = lookup_property_in_chain(&db, "Child", "x").expect("ancestor");
3194        assert_eq!(found.fqcn(&db).as_ref(), "Base");
3195        // Own override wins.
3196        upsert_property(&mut db, "Child", "x", true);
3197        let found = lookup_property_in_chain(&db, "Child", "x").expect("own");
3198        assert_eq!(found.fqcn(&db).as_ref(), "Child");
3199        assert!(found.is_readonly(&db));
3200    }
3201
3202    #[test]
3203    fn lookup_property_in_chain_walks_mixins() {
3204        let mut db = MirDb::default();
3205        upsert_class(&mut db, "MixinTarget", None, Arc::from([]), false);
3206        upsert_property(&mut db, "MixinTarget", "exposed", false);
3207        upsert_class_with_mixins(&mut db, "Host", None, &["MixinTarget"]);
3208        let found = lookup_property_in_chain(&db, "Host", "exposed").expect("via @mixin");
3209        assert_eq!(found.fqcn(&db).as_ref(), "MixinTarget");
3210    }
3211
3212    #[test]
3213    fn lookup_property_in_chain_mixin_cycle_does_not_hang() {
3214        let mut db = MirDb::default();
3215        upsert_class_with_mixins(&mut db, "A", None, &["B"]);
3216        upsert_class_with_mixins(&mut db, "B", None, &["A"]);
3217        assert!(lookup_property_in_chain(&db, "A", "missing").is_none());
3218    }
3219
3220    #[test]
3221    fn lookup_property_in_chain_is_case_sensitive() {
3222        let mut db = MirDb::default();
3223        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
3224        upsert_property(&mut db, "Foo", "myProp", false);
3225        assert!(lookup_property_in_chain(&db, "Foo", "myProp").is_some());
3226        // Property names are case-sensitive in PHP.
3227        assert!(lookup_property_in_chain(&db, "Foo", "MyProp").is_none());
3228    }
3229
3230    #[test]
3231    fn lookup_property_in_chain_inactive_returns_none() {
3232        let mut db = MirDb::default();
3233        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
3234        upsert_property(&mut db, "Foo", "x", false);
3235        db.deactivate_class_node("Foo");
3236        assert!(lookup_property_in_chain(&db, "Foo", "x").is_none());
3237    }
3238
3239    // -----------------------------------------------------------------
3240    // class_constant_exists_in_chain
3241    // -----------------------------------------------------------------
3242
3243    fn upsert_constant(db: &mut MirDb, fqcn: &str, name: &str) {
3244        let storage = ConstantStorage {
3245            name: Arc::from(name),
3246            ty: mir_types::Union::mixed(),
3247            visibility: None,
3248            is_final: false,
3249            location: None,
3250        };
3251        let owner = Arc::<str>::from(fqcn);
3252        db.upsert_class_constant_node(&owner, &storage);
3253    }
3254
3255    #[test]
3256    fn class_constant_exists_in_chain_finds_own() {
3257        let mut db = MirDb::default();
3258        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
3259        upsert_constant(&mut db, "Foo", "MAX");
3260        assert!(class_constant_exists_in_chain(&db, "Foo", "MAX"));
3261        assert!(!class_constant_exists_in_chain(&db, "Foo", "MIN"));
3262    }
3263
3264    #[test]
3265    fn class_constant_exists_in_chain_walks_parent() {
3266        let mut db = MirDb::default();
3267        upsert_class(&mut db, "Base", None, Arc::from([]), false);
3268        upsert_constant(&mut db, "Base", "VERSION");
3269        upsert_class(
3270            &mut db,
3271            "Child",
3272            Some(Arc::from("Base")),
3273            Arc::from([]),
3274            false,
3275        );
3276        assert!(class_constant_exists_in_chain(&db, "Child", "VERSION"));
3277    }
3278
3279    #[test]
3280    fn class_constant_exists_in_chain_walks_interface() {
3281        let mut db = MirDb::default();
3282        upsert_class(&mut db, "I", None, Arc::from([]), true);
3283        upsert_constant(&mut db, "I", "TYPE");
3284        // A class that implements I — interfaces go in the `interfaces`
3285        // slot, not the `extends` slot which is interface-only.
3286        db.upsert_class_node(ClassNodeFields {
3287            interfaces: Arc::from([Arc::from("I")]),
3288            ..ClassNodeFields::for_class(Arc::from("Impl"))
3289        });
3290        assert!(class_constant_exists_in_chain(&db, "Impl", "TYPE"));
3291    }
3292
3293    #[test]
3294    fn class_constant_exists_in_chain_walks_direct_trait() {
3295        let mut db = MirDb::default();
3296        upsert_class_with_traits(&mut db, "T", None, &[], false, true);
3297        upsert_constant(&mut db, "T", "FROM_TRAIT");
3298        upsert_class_with_traits(&mut db, "Foo", None, &["T"], false, false);
3299        assert!(class_constant_exists_in_chain(&db, "Foo", "FROM_TRAIT"));
3300    }
3301
3302    #[test]
3303    fn class_constant_exists_in_chain_unknown_class_returns_false() {
3304        let db = MirDb::default();
3305        assert!(!class_constant_exists_in_chain(&db, "Nope", "ANY"));
3306    }
3307
3308    #[test]
3309    fn class_constant_exists_in_chain_inactive_returns_false() {
3310        let mut db = MirDb::default();
3311        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
3312        upsert_constant(&mut db, "Foo", "X");
3313        db.deactivate_class_node("Foo");
3314        db.deactivate_class_constants("Foo");
3315        assert!(!class_constant_exists_in_chain(&db, "Foo", "X"));
3316    }
3317
3318    /// Validates the S3-deadlock premise.  After `for_each_with` returns,
3319    /// all worker clones must drop so that a subsequent setter on the
3320    /// canonical db (strong-count==1) does not block on
3321    /// `Storage::cancel_others`.  Wrapped in a join-with-timeout so a
3322    /// regression hangs for at most 30s instead of forever.
3323    #[test]
3324    fn parallel_reads_then_serial_write_does_not_deadlock() {
3325        use rayon::prelude::*;
3326        use std::sync::mpsc;
3327        use std::time::Duration;
3328
3329        let (tx, rx) = mpsc::channel::<()>();
3330        std::thread::spawn(move || {
3331            let mut db = MirDb::default();
3332            let storage = mir_codebase::storage::FunctionStorage {
3333                fqn: Arc::from("foo"),
3334                short_name: Arc::from("foo"),
3335                params: Arc::from([].as_slice()),
3336                return_type: None,
3337                inferred_return_type: None,
3338                template_params: vec![],
3339                assertions: vec![],
3340                throws: vec![],
3341                deprecated: None,
3342                is_pure: false,
3343                location: None,
3344            };
3345            let node = db.upsert_function_node(&storage);
3346
3347            // Parallel sweep with cloned dbs; each worker reads via &dyn MirDatabase.
3348            let db_for_sweep = db.clone();
3349            (0..256u32)
3350                .into_par_iter()
3351                .for_each_with(db_for_sweep, |db, _| {
3352                    let _ = node.return_type(&*db as &dyn MirDatabase);
3353                });
3354
3355            // Sweep is done — clones owned by `for_each_with` are dropped.
3356            // If any worker-thread retains thread-local Salsa state pointing
3357            // at a clone, this setter will hang in `Storage::cancel_others`.
3358            node.set_return_type(&mut db)
3359                .to(Some(Arc::new(Union::mixed())));
3360            assert_eq!(node.return_type(&db), Some(Arc::new(Union::mixed())));
3361            tx.send(()).unwrap();
3362        });
3363
3364        match rx.recv_timeout(Duration::from_secs(30)) {
3365            Ok(()) => {}
3366            Err(_) => {
3367                panic!("S3 deadlock repro: setter after for_each_with did not return within 30s")
3368            }
3369        }
3370    }
3371
3372    /// Pins the actual root cause of the original S3 deadlock: a sibling
3373    /// `MirDb` clone (e.g. the `class_db` used by `ClassAnalyzer` in
3374    /// `project.rs`) being alive when a setter runs blocks
3375    /// `Storage::cancel_others` indefinitely.  Dropping the sibling before
3376    /// the setter unblocks it.
3377    ///
3378    /// This is the regression guard for `commit_inferred_return_types`: if
3379    /// a future refactor hoists a clone past the commit point, this test
3380    /// fails (either the "while sibling alive, setter is blocked" half
3381    /// or the "after drop, setter completes" half).
3382    #[test]
3383    fn sibling_clone_blocks_setter_until_dropped() {
3384        use std::sync::mpsc;
3385        use std::time::Duration;
3386
3387        let mut db = MirDb::default();
3388        let storage = mir_codebase::storage::FunctionStorage {
3389            fqn: Arc::from("foo"),
3390            short_name: Arc::from("foo"),
3391            params: Arc::from([].as_slice()),
3392            return_type: None,
3393            inferred_return_type: None,
3394            template_params: vec![],
3395            assertions: vec![],
3396            throws: vec![],
3397            deprecated: None,
3398            is_pure: false,
3399            location: None,
3400        };
3401        let node = db.upsert_function_node(&storage);
3402
3403        let sibling = db.clone();
3404
3405        // Move the writer into a worker thread so we can probe its progress
3406        // without blocking the test.  Channel signals when the setter returns.
3407        let (tx, rx) = mpsc::channel::<()>();
3408        let writer = std::thread::spawn(move || {
3409            node.set_return_type(&mut db)
3410                .to(Some(Arc::new(Union::mixed())));
3411            tx.send(()).unwrap();
3412        });
3413
3414        // While the sibling clone is alive the setter must NOT make progress —
3415        // strong-count > 1 forces `cancel_others` to wait.
3416        match rx.recv_timeout(Duration::from_millis(500)) {
3417            Err(mpsc::RecvTimeoutError::Timeout) => { /* expected */ }
3418            Ok(()) => panic!(
3419                "setter completed while sibling clone was alive — strong-count==1 \
3420                 invariant of `cancel_others` is broken; commit_inferred_return_types \
3421                 cannot rely on tight-scoping clones"
3422            ),
3423            Err(e) => panic!("unexpected channel error: {e:?}"),
3424        }
3425
3426        // Drop the sibling.  Strong-count drops to 1 and the setter unblocks.
3427        drop(sibling);
3428
3429        match rx.recv_timeout(Duration::from_secs(5)) {
3430            Ok(()) => {}
3431            Err(_) => panic!("setter did not complete within 5s after sibling clone dropped"),
3432        }
3433        writer.join().expect("writer thread panicked");
3434    }
3435}