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