Skip to main content

mir_analyzer/db/
queries.rs

1use std::sync::Arc;
2
3use mir_codebase::storage::{Location, TemplateParam};
4use mir_issues::Issue;
5use mir_types::Union;
6
7use super::*;
8
9/// Snapshot of a class's discriminator + abstractness, read from a
10/// registered active `ClassNode`.
11///
12/// Returned by [`class_kind_via_db`] when an active node exists for the
13/// given FQCN — call sites can use this in place of the corresponding
14/// `Codebase` lookups.
15#[derive(Debug, Clone, Copy)]
16pub struct ClassKind {
17    pub is_interface: bool,
18    pub is_trait: bool,
19    pub is_enum: bool,
20    pub is_abstract: bool,
21}
22
23/// Read class kind/abstractness from an active `ClassNode`, if one is
24/// registered for `fqcn`.  Returns `None` for unregistered or inactive
25/// nodes.  All bundled and user types are mirrored into `ClassNode` by
26/// `MirDb::ingest_stub_slice`, so a `None` here means the type genuinely
27/// doesn't exist (or is inactive after a `deactivate_class_node` pass).
28pub fn class_kind_via_db(db: &dyn MirDatabase, fqcn: &str) -> Option<ClassKind> {
29    let node = db.lookup_class_node(fqcn).filter(|n| n.active(db))?;
30    Some(ClassKind {
31        is_interface: node.is_interface(db),
32        is_trait: node.is_trait(db),
33        is_enum: node.is_enum(db),
34        is_abstract: node.is_abstract(db),
35    })
36}
37
38pub fn type_exists_via_db(db: &dyn MirDatabase, fqcn: &str) -> bool {
39    db.lookup_class_node(fqcn).is_some_and(|n| n.active(db))
40}
41
42#[allow(dead_code)]
43pub fn function_exists_via_db(db: &dyn MirDatabase, fqn: &str) -> bool {
44    db.lookup_function_node(fqn).is_some_and(|n| n.active(db))
45}
46
47#[allow(dead_code)]
48pub fn constant_exists_via_db(db: &dyn MirDatabase, fqn: &str) -> bool {
49    db.lookup_global_constant_node(fqn)
50        .is_some_and(|n| n.active(db))
51}
52
53pub fn resolve_name_via_db(db: &dyn MirDatabase, file: &str, name: &str) -> String {
54    if name.starts_with('\\') {
55        return name.trim_start_matches('\\').to_string();
56    }
57
58    let lower = name.to_ascii_lowercase();
59    if matches!(lower.as_str(), "self" | "static" | "parent") {
60        return name.to_string();
61    }
62
63    if name.contains('\\') {
64        if let Some(imports) = (!name.starts_with('\\')).then(|| db.file_imports(file)) {
65            if let Some((first, rest)) = name.split_once('\\') {
66                if let Some(base) = imports.get(first) {
67                    return format!("{base}\\{rest}");
68                }
69            }
70        }
71        if type_exists_via_db(db, name) {
72            return name.to_string();
73        }
74        if let Some(ns) = db.file_namespace(file) {
75            let qualified = format!("{}\\{}", ns, name);
76            if type_exists_via_db(db, &qualified) {
77                return qualified;
78            }
79        }
80        return name.to_string();
81    }
82
83    let imports = db.file_imports(file);
84    if let Some(fqcn) = imports.get(name) {
85        return fqcn.clone();
86    }
87    if let Some((_, fqcn)) = imports
88        .iter()
89        .find(|(alias, _)| alias.eq_ignore_ascii_case(name))
90    {
91        return fqcn.clone();
92    }
93    if let Some(ns) = db.file_namespace(file) {
94        return format!("{}\\{}", ns, name);
95    }
96    name.to_string()
97}
98
99/// Return the declared `@template` parameters for `fqcn` from an active
100/// `ClassNode`, if one is registered.  Returns `None` for unregistered
101/// or inactive nodes.  Authoritative after all collected slices have been
102/// fed through `ingest_stub_slice`.
103pub fn class_template_params_via_db(
104    db: &dyn MirDatabase,
105    fqcn: &str,
106) -> Option<Arc<[TemplateParam]>> {
107    let node = db.lookup_class_node(fqcn).filter(|n| n.active(db))?;
108    Some(node.template_params(db))
109}
110
111/// Walk the parent chain collecting template bindings from `@extends` type
112/// args.  Mirrors `Codebase::get_inherited_template_bindings`.
113///
114/// For `class UserRepo extends BaseRepo` with `@extends BaseRepo<User>`, this
115/// returns `{ T → User }` where `T` is `BaseRepo`'s declared template
116/// parameter.  Cycle-safe via a visited set.
117pub fn inherited_template_bindings_via_db(
118    db: &dyn MirDatabase,
119    fqcn: &str,
120) -> std::collections::HashMap<Arc<str>, Union> {
121    let mut bindings: std::collections::HashMap<Arc<str>, Union> = std::collections::HashMap::new();
122    let mut visited: rustc_hash::FxHashSet<Arc<str>> = rustc_hash::FxHashSet::default();
123    let mut current: Arc<str> = Arc::from(fqcn);
124    loop {
125        if !visited.insert(current.clone()) {
126            break;
127        }
128        let node = match db
129            .lookup_class_node(current.as_ref())
130            .filter(|n| n.active(db))
131        {
132            Some(n) => n,
133            None => break,
134        };
135        let parent = match node.parent(db) {
136            Some(p) => p,
137            None => break,
138        };
139        let extends_type_args = node.extends_type_args(db);
140        if !extends_type_args.is_empty() {
141            if let Some(parent_tps) = class_template_params_via_db(db, parent.as_ref()) {
142                for (tp, ty) in parent_tps.iter().zip(extends_type_args.iter()) {
143                    bindings
144                        .entry(tp.name.clone())
145                        .or_insert_with(|| ty.clone());
146                }
147            }
148        }
149        current = parent;
150    }
151    bindings
152}
153
154/// Predicate: does `fqcn` have any registered ancestor that lacks a
155/// `ClassNode` in the db?
156///
157/// `ingest_stub_slice` mirrors bundled stubs, user stubs, and PSR-4
158/// lazy-loaded definitions into the db before any Pass 2 driver runs, so
159/// a class with no active `ClassNode` is one that genuinely doesn't
160/// exist — and an unknown class trivially has no known ancestors.
161pub fn has_unknown_ancestor_via_db(db: &dyn MirDatabase, fqcn: &str) -> bool {
162    let Some(node) = db.lookup_class_node(fqcn).filter(|n| n.active(db)) else {
163        return false;
164    };
165    class_ancestors(db, node)
166        .0
167        .iter()
168        .any(|ancestor| !type_exists_via_db(db, ancestor))
169}
170
171pub fn method_is_concretely_implemented(
172    db: &dyn MirDatabase,
173    fqcn: &str,
174    method_name: &str,
175) -> bool {
176    let lower = method_name.to_lowercase();
177    let Some(self_node) = db.lookup_class_node(fqcn).filter(|n| n.active(db)) else {
178        return false;
179    };
180    // Interfaces don't supply implementations, regardless of how their methods
181    // are stored.
182    if self_node.is_interface(db) {
183        return false;
184    }
185    // 1. Direct own method.
186    if let Some(m) = db.lookup_method_node(fqcn, &lower).filter(|m| m.active(db)) {
187        if !m.is_abstract(db) {
188            return true;
189        }
190    }
191    // 2. Traits used directly by this class — walk transitively.
192    let mut visited_traits: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
193    for t in self_node.traits(db).iter() {
194        if trait_provides_method(db, t.as_ref(), &lower, &mut visited_traits) {
195            return true;
196        }
197    }
198    // 3. Ancestor chain (classes only — interfaces skipped, trait nodes here
199    //    are owning-class trait references already handled by their own walk).
200    for ancestor in class_ancestors(db, self_node).0.iter() {
201        let Some(anc_node) = db
202            .lookup_class_node(ancestor.as_ref())
203            .filter(|n| n.active(db))
204        else {
205            continue;
206        };
207        if anc_node.is_interface(db) {
208            continue;
209        }
210        // Ancestor's own method.
211        if !anc_node.is_trait(db) {
212            if let Some(m) = db
213                .lookup_method_node(ancestor.as_ref(), &lower)
214                .filter(|m| m.active(db))
215            {
216                if !m.is_abstract(db) {
217                    return true;
218                }
219            }
220        }
221        // Ancestor's used traits — walk transitively.  (For trait nodes in
222        // the ancestor list, this re-checks their own_methods + sub-traits.)
223        if anc_node.is_trait(db) {
224            if trait_provides_method(db, ancestor.as_ref(), &lower, &mut visited_traits) {
225                return true;
226            }
227        } else {
228            for t in anc_node.traits(db).iter() {
229                if trait_provides_method(db, t.as_ref(), &lower, &mut visited_traits) {
230                    return true;
231                }
232            }
233        }
234    }
235    false
236}
237
238/// Helper for [`method_is_concretely_implemented`]: walk a trait's own methods
239/// and recursively its used traits.  Returns true iff any provides a
240/// non-abstract method named `method_lower`.  Cycle-safe via `visited`.
241fn trait_provides_method(
242    db: &dyn MirDatabase,
243    trait_fqcn: &str,
244    method_lower: &str,
245    visited: &mut rustc_hash::FxHashSet<String>,
246) -> bool {
247    if !visited.insert(trait_fqcn.to_string()) {
248        return false;
249    }
250    if let Some(m) = db
251        .lookup_method_node(trait_fqcn, method_lower)
252        .filter(|m| m.active(db))
253    {
254        if !m.is_abstract(db) {
255            return true;
256        }
257    }
258    let Some(node) = db.lookup_class_node(trait_fqcn).filter(|n| n.active(db)) else {
259        return false;
260    };
261    if !node.is_trait(db) {
262        return false;
263    }
264    for t in node.traits(db).iter() {
265        if trait_provides_method(db, t.as_ref(), method_lower, visited) {
266            return true;
267        }
268    }
269    false
270}
271
272pub fn lookup_method_in_chain(
273    db: &dyn MirDatabase,
274    fqcn: &str,
275    method_name: &str,
276) -> Option<MethodNode> {
277    let mut visited_mixins: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
278    lookup_method_in_chain_inner(db, fqcn, &method_name.to_lowercase(), &mut visited_mixins)
279}
280
281fn lookup_method_in_chain_inner(
282    db: &dyn MirDatabase,
283    fqcn: &str,
284    lower: &str,
285    visited_mixins: &mut rustc_hash::FxHashSet<String>,
286) -> Option<MethodNode> {
287    let self_node = db.lookup_class_node(fqcn).filter(|n| n.active(db))?;
288
289    // 1. Direct own method.
290    if let Some(node) = db.lookup_method_node(fqcn, lower).filter(|n| n.active(db)) {
291        return Some(node);
292    }
293    // 2. Docblock @mixin chains (delegated magic-method lookup) — recurse so
294    //    each mixin's own walk includes its own mixins, traits, ancestors.
295    //    Cycle-safe via `visited_mixins`.
296    for m in self_node.mixins(db).iter() {
297        if visited_mixins.insert(m.to_string()) {
298            if let Some(node) = lookup_method_in_chain_inner(db, m.as_ref(), lower, visited_mixins)
299            {
300                return Some(node);
301            }
302        }
303    }
304    // 3. Traits used directly — walk transitively (trait-of-traits is *not*
305    //    included in `class_ancestors`, by design — see that fn's comments).
306    let mut visited_traits: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
307    for t in self_node.traits(db).iter() {
308        if let Some(node) = trait_provides_method_node(db, t.as_ref(), lower, &mut visited_traits) {
309            return Some(node);
310        }
311    }
312    // 4. Ancestor chain (parents, interfaces, traits — empty for enums).
313    for ancestor in class_ancestors(db, self_node).0.iter() {
314        if let Some(node) = db
315            .lookup_method_node(ancestor.as_ref(), lower)
316            .filter(|n| n.active(db))
317        {
318            return Some(node);
319        }
320        if let Some(anc_node) = db
321            .lookup_class_node(ancestor.as_ref())
322            .filter(|n| n.active(db))
323        {
324            if anc_node.is_trait(db) {
325                if let Some(node) =
326                    trait_provides_method_node(db, ancestor.as_ref(), lower, &mut visited_traits)
327                {
328                    return Some(node);
329                }
330            } else {
331                for t in anc_node.traits(db).iter() {
332                    if let Some(node) =
333                        trait_provides_method_node(db, t.as_ref(), lower, &mut visited_traits)
334                    {
335                        return Some(node);
336                    }
337                }
338                for m in anc_node.mixins(db).iter() {
339                    if visited_mixins.insert(m.to_string()) {
340                        if let Some(node) =
341                            lookup_method_in_chain_inner(db, m.as_ref(), lower, visited_mixins)
342                        {
343                            return Some(node);
344                        }
345                    }
346                }
347            }
348        }
349    }
350    None
351}
352
353/// Node-returning sibling of [`trait_declares_method`] used by
354/// [`lookup_method_in_chain`].  Walks `trait_fqcn`'s own MethodNode then its
355/// used traits transitively.  Cycle-safe via `visited`.
356fn trait_provides_method_node(
357    db: &dyn MirDatabase,
358    trait_fqcn: &str,
359    method_lower: &str,
360    visited: &mut rustc_hash::FxHashSet<String>,
361) -> Option<MethodNode> {
362    if !visited.insert(trait_fqcn.to_string()) {
363        return None;
364    }
365    if let Some(node) = db
366        .lookup_method_node(trait_fqcn, method_lower)
367        .filter(|n| n.active(db))
368    {
369        return Some(node);
370    }
371    let node = db.lookup_class_node(trait_fqcn).filter(|n| n.active(db))?;
372    if !node.is_trait(db) {
373        return None;
374    }
375    for t in node.traits(db).iter() {
376        if let Some(found) = trait_provides_method_node(db, t.as_ref(), method_lower, visited) {
377            return Some(found);
378        }
379    }
380    None
381}
382
383/// Existence-only sibling of [`trait_provides_method`].  Returns true iff the
384/// trait or any sub-trait declares a method named `method_lower` (abstract
385/// counts).  Cycle-safe via `visited`.
386#[allow(dead_code)]
387fn trait_declares_method(
388    db: &dyn MirDatabase,
389    trait_fqcn: &str,
390    method_lower: &str,
391    visited: &mut rustc_hash::FxHashSet<String>,
392) -> bool {
393    if !visited.insert(trait_fqcn.to_string()) {
394        return false;
395    }
396    if db
397        .lookup_method_node(trait_fqcn, method_lower)
398        .is_some_and(|m| m.active(db))
399    {
400        return true;
401    }
402    let Some(node) = db.lookup_class_node(trait_fqcn).filter(|n| n.active(db)) else {
403        return false;
404    };
405    if !node.is_trait(db) {
406        return false;
407    }
408    for t in node.traits(db).iter() {
409        if trait_declares_method(db, t.as_ref(), method_lower, visited) {
410            return true;
411        }
412    }
413    false
414}
415
416#[allow(dead_code)]
417pub fn method_exists_via_db(db: &dyn MirDatabase, fqcn: &str, method_name: &str) -> bool {
418    let lower = method_name.to_lowercase();
419    let Some(self_node) = db.lookup_class_node(fqcn).filter(|n| n.active(db)) else {
420        return false;
421    };
422    // Direct own method.
423    if db
424        .lookup_method_node(fqcn, &lower)
425        .is_some_and(|m| m.active(db))
426    {
427        return true;
428    }
429    // Traits used directly — walk transitively.
430    let mut visited_traits: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
431    for t in self_node.traits(db).iter() {
432        if trait_declares_method(db, t.as_ref(), &lower, &mut visited_traits) {
433            return true;
434        }
435    }
436    // Ancestor chain (parents, interfaces, traits).
437    for ancestor in class_ancestors(db, self_node).0.iter() {
438        if db
439            .lookup_method_node(ancestor.as_ref(), &lower)
440            .is_some_and(|m| m.active(db))
441        {
442            return true;
443        }
444        if let Some(anc_node) = db
445            .lookup_class_node(ancestor.as_ref())
446            .filter(|n| n.active(db))
447        {
448            if anc_node.is_trait(db) {
449                if trait_declares_method(db, ancestor.as_ref(), &lower, &mut visited_traits) {
450                    return true;
451                }
452            } else {
453                for t in anc_node.traits(db).iter() {
454                    if trait_declares_method(db, t.as_ref(), &lower, &mut visited_traits) {
455                        return true;
456                    }
457                }
458            }
459        }
460    }
461    false
462}
463
464pub fn lookup_property_in_chain(
465    db: &dyn MirDatabase,
466    fqcn: &str,
467    prop_name: &str,
468) -> Option<PropertyNode> {
469    let mut visited_mixins: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
470    lookup_property_in_chain_inner(db, fqcn, prop_name, &mut visited_mixins)
471}
472
473fn lookup_property_in_chain_inner(
474    db: &dyn MirDatabase,
475    fqcn: &str,
476    prop_name: &str,
477    visited_mixins: &mut rustc_hash::FxHashSet<String>,
478) -> Option<PropertyNode> {
479    let self_node = db.lookup_class_node(fqcn).filter(|n| n.active(db))?;
480
481    // 1. Own property.
482    if let Some(node) = db
483        .lookup_property_node(fqcn, prop_name)
484        .filter(|n| n.active(db))
485    {
486        return Some(node);
487    }
488    // 2. Docblock @mixin chains — recurse so each mixin's own walk includes
489    //    its own mixins, traits, ancestors.  Cycle-safe via `visited_mixins`.
490    for m in self_node.mixins(db).iter() {
491        if visited_mixins.insert(m.to_string()) {
492            if let Some(node) =
493                lookup_property_in_chain_inner(db, m.as_ref(), prop_name, visited_mixins)
494            {
495                return Some(node);
496            }
497        }
498    }
499    // 3. Ancestor chain (parents + interfaces + direct traits).  Each
500    //    ancestor may itself have `@mixin` declarations that forward
501    //    property access — recurse into those too.
502    for ancestor in class_ancestors(db, self_node).0.iter() {
503        if let Some(node) = db
504            .lookup_property_node(ancestor.as_ref(), prop_name)
505            .filter(|n| n.active(db))
506        {
507            return Some(node);
508        }
509        if let Some(anc_node) = db
510            .lookup_class_node(ancestor.as_ref())
511            .filter(|n| n.active(db))
512        {
513            for m in anc_node.mixins(db).iter() {
514                if visited_mixins.insert(m.to_string()) {
515                    if let Some(node) =
516                        lookup_property_in_chain_inner(db, m.as_ref(), prop_name, visited_mixins)
517                    {
518                        return Some(node);
519                    }
520                }
521            }
522        }
523    }
524    None
525}
526
527pub fn class_constant_exists_in_chain(db: &dyn MirDatabase, fqcn: &str, const_name: &str) -> bool {
528    if db
529        .lookup_class_constant_node(fqcn, const_name)
530        .is_some_and(|n| n.active(db))
531    {
532        return true;
533    }
534    let Some(class_node) = db.lookup_class_node(fqcn).filter(|n| n.active(db)) else {
535        return false;
536    };
537    for ancestor in class_ancestors(db, class_node).0.iter() {
538        if db
539            .lookup_class_constant_node(ancestor.as_ref(), const_name)
540            .is_some_and(|n| n.active(db))
541        {
542            return true;
543        }
544    }
545    false
546}
547
548pub fn member_location_via_db(
549    db: &dyn MirDatabase,
550    fqcn: &str,
551    member_name: &str,
552) -> Option<Location> {
553    if let Some(node) = lookup_method_in_chain(db, fqcn, member_name) {
554        if let Some(loc) = node.location(db) {
555            return Some(loc);
556        }
557    }
558    if let Some(node) = lookup_property_in_chain(db, fqcn, member_name) {
559        if let Some(loc) = node.location(db) {
560            return Some(loc);
561        }
562    }
563    // Class/interface/trait/enum constants and enum cases.
564    if let Some(node) = db
565        .lookup_class_constant_node(fqcn, member_name)
566        .filter(|n| n.active(db))
567    {
568        if let Some(loc) = node.location(db) {
569            return Some(loc);
570        }
571    }
572    let class_node = db.lookup_class_node(fqcn).filter(|n| n.active(db))?;
573    for ancestor in class_ancestors(db, class_node).0.iter() {
574        if let Some(node) = db
575            .lookup_class_constant_node(ancestor.as_ref(), member_name)
576            .filter(|n| n.active(db))
577        {
578            if let Some(loc) = node.location(db) {
579                return Some(loc);
580            }
581        }
582    }
583    None
584}
585
586pub fn extends_or_implements_via_db(db: &dyn MirDatabase, child: &str, ancestor: &str) -> bool {
587    if child == ancestor {
588        return true;
589    }
590    let Some(node) = db.lookup_class_node(child).filter(|n| n.active(db)) else {
591        return false;
592    };
593    if node.is_enum(db) {
594        // Enum semantics: only directly-declared interfaces participate
595        // (no transitive walk), plus the implicit UnitEnum / BackedEnum
596        // interfaces.
597        if node.interfaces(db).iter().any(|i| i.as_ref() == ancestor) {
598            return true;
599        }
600        if ancestor == "UnitEnum" || ancestor == "\\UnitEnum" {
601            return true;
602        }
603        if (ancestor == "BackedEnum" || ancestor == "\\BackedEnum") && node.is_backed_enum(db) {
604            return true;
605        }
606        return false;
607    }
608    class_ancestors(db, node)
609        .0
610        .iter()
611        .any(|p| p.as_ref() == ancestor)
612}
613
614// collect_file_definitions tracked query (S1)
615
616/// Uncached version of collect_file_definitions for bulk operations like vendor
617/// collection, where we don't need Salsa to cache the intermediate StubSlice
618/// results. This avoids holding Arc<StubSlice> in Salsa's query cache after
619/// ingestion.
620pub fn collect_file_definitions_uncached(
621    db: &dyn MirDatabase,
622    file: SourceFile,
623) -> FileDefinitions {
624    let path = file.path(db);
625    let text = file.text(db);
626
627    let arena = crate::arena::create_parse_arena(text.len());
628    let parsed = php_rs_parser::parse(&arena, &text);
629
630    let mut all_issues: Vec<Issue> = parsed
631        .errors
632        .iter()
633        .map(|err| {
634            Issue::new(
635                mir_issues::IssueKind::ParseError {
636                    message: err.to_string(),
637                },
638                mir_issues::Location {
639                    file: path.clone(),
640                    line: 1,
641                    line_end: 1,
642                    col_start: 0,
643                    col_end: 0,
644                },
645            )
646        })
647        .collect();
648
649    let collector =
650        crate::collector::DefinitionCollector::new_for_slice(path, &text, &parsed.source_map);
651    let (slice, collector_issues) = collector.collect_slice(&parsed.program);
652    all_issues.extend(collector_issues);
653
654    FileDefinitions {
655        slice: Arc::new(slice),
656        issues: Arc::new(all_issues),
657    }
658}
659
660#[salsa::tracked]
661pub fn collect_file_definitions(db: &dyn MirDatabase, file: SourceFile) -> FileDefinitions {
662    collect_file_definitions_uncached(db, file)
663}
664
665// S4 Step 3: Lazy inferred-type queries
666//
667// These tracked queries compute inferred return types on-demand during Pass 2.
668// When `Pass2Driver` encounters a function/method call, it reads the inferred
669// type via these queries instead of from a pre-computed buffer.
670//
671// This enables two key optimizations:
672// 1. Single-pass execution: inferred types are computed as needed, not upfront
673// 2. Incremental caching: if a dependent file doesn't call a function, its
674//    inferred type is never computed (Salsa skips the query)
675
676/// Lazily computes the inferred return type for a function.
677/// Called on-demand during Pass 2 analysis when we encounter a call to this function.
678/// Results are cached by Salsa; re-analysis of dependent files that don't call this
679/// function re-uses the cached inferred type.
680///
681/// **Current behavior (S4 PR3):** Reads from the already-committed `inferred_return_type`
682/// field on `FunctionNode`. Double-pass orchestration (Pass 2a inference + commit) still
683/// happens in `project.rs::analyze()`.
684///
685/// **Future (S4 PR4):** Will compute types on-demand by extracting the function body
686/// from source and running inference-only Pass 2, eliminating the double-pass.
687#[salsa::tracked]
688pub fn inferred_function_return_type(db: &dyn MirDatabase, node: FunctionNode) -> Arc<Union> {
689    // For now, read the already-committed inferred type from the FunctionNode input.
690    // This is set via commit_inferred_return_types() after Pass 2a completes.
691    node.inferred_return_type(db)
692        .unwrap_or_else(|| Arc::new(Union::mixed()))
693}
694
695/// Lazily computes the inferred return type for a method.
696///
697/// **Current behavior (S4 PR3):** Reads from the already-committed `inferred_return_type`
698/// field on `MethodNode`.
699///
700/// **Future (S4 PR4):** Will compute types on-demand by extracting the method body
701/// from source and running inference-only Pass 2.
702#[salsa::tracked]
703pub fn inferred_method_return_type(db: &dyn MirDatabase, node: MethodNode) -> Arc<Union> {
704    // For now, read the already-committed inferred type from the MethodNode input.
705    node.inferred_return_type(db)
706        .unwrap_or_else(|| Arc::new(Union::mixed()))
707}
708
709// Helper: collect analysis results via tracked query accumulators
710
711/// Collects all accumulated issues from a set of files analyzed via the
712/// `analyze_file` tracked query. Used during batch analysis to read issues
713/// that were emitted during tracked-query evaluation.
714#[allow(dead_code)]
715pub(crate) fn collect_accumulated_issues(
716    db: &dyn MirDatabase,
717    files: &[(Arc<str>, SourceFile)],
718    php_version: &str,
719) -> Vec<Issue> {
720    let mut all_issues = Vec::new();
721    let input = AnalyzeFileInput::new(db, Arc::from(php_version));
722
723    for (_path, file) in files {
724        // Call the tracked query to trigger analysis + accumulation
725        analyze_file(db, *file, input);
726
727        // Read back the accumulated issues for this file
728        let accumulated: Vec<&IssueAccumulator> = analyze_file::accumulated(db, *file, input);
729        for acc in accumulated {
730            all_issues.push(acc.0.clone());
731        }
732    }
733
734    all_issues
735}