Skip to main content

mir_analyzer/batch/
mod.rs

1//! Batch-oriented project analysis on [`AnalysisSession`].
2//!
3//! This module hosts the multi-file orchestration that used to live on the
4//! retired `ProjectAnalyzer`: parallel definition collection, lazy class loading, dead-code
5//! sweep, reverse-dependency index, and the [`AnalysisResult`] return type.
6//! Per-file (LSP) entry points stay on `AnalysisSession` itself in
7//! `session.rs`.
8//!
9//! All methods are `impl AnalysisSession`; configuration that's only
10//! meaningful for batch runs (issue suppressions, progress callback, optional
11//! PHP version override) is grouped in [`BatchOptions`] and passed in rather
12//! than stored on the session.
13
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::sync::OnceLock;
17
18use rayon::prelude::*;
19use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
20
21use mir_issues::Issue;
22use mir_types::{Atomic, Type};
23
24use crate::body_analysis::BodyAnalyzer;
25use crate::cache::{hash_content, surface_fingerprint};
26use crate::db::{
27    collect_file_definitions, FileDefinitions, MirDatabase, MirDbStorage, RefLoc, SourceFile,
28};
29use crate::php_version::PhpVersion;
30use crate::session::AnalysisSession;
31use crate::stub_cache::{hash_source, prepare_for_ingest};
32
33/// Issue kinds emitted by [`crate::dead_code::DeadCodeAnalyzer`].
34///
35/// The dead-code pass is just an error group — these names participate in
36/// [`BatchOptions::suppressed_issue_kinds`] like any other `IssueKind`. If
37/// every kind listed here is suppressed, the dead-code pass is skipped
38/// entirely.
39pub fn dead_code_issue_kinds() -> &'static [&'static str] {
40    &[
41        "UnusedMethod",
42        "UnusedProperty",
43        "UnusedFunction",
44        "UnusedClass",
45    ]
46}
47
48/// Per-batch options for [`AnalysisSession::analyze_paths`] and friends.
49///
50/// Configuration that only makes sense for full-project (batch) analysis
51/// lives here instead of on [`AnalysisSession`], so the per-file LSP API
52/// isn't bloated with state nothing else reads.
53#[derive(Clone, Default)]
54pub struct BatchOptions {
55    /// Names of `IssueKind` variants to drop from the final result, e.g.
56    /// `["MissingThrowsDocblock", "UnusedMethod"]`. Applied as a final
57    /// post-filter so analyzer internals don't need to know which
58    /// diagnostics the consumer cares about. Empty by default.
59    pub suppressed_issue_kinds: HashSet<String>,
60    /// Called once after each file completes body analysis (progress reporting).
61    pub on_file_done: Option<Arc<dyn Fn() + Send + Sync>>,
62    /// Override the session's configured PHP version for this run. `None`
63    /// uses the session's version.
64    pub php_version_override: Option<PhpVersion>,
65    /// Skip collecting per-expression [`crate::symbol::ResolvedSymbol`]s
66    /// into the [`AnalysisResult`]. Defaults to `false` (symbols collected)
67    /// so existing consumers — LSP servers using
68    /// [`AnalysisResult::symbol_at`] for hover/go-to-definition — are
69    /// unaffected. Diagnostics-only consumers (the CLI) opt out: a
70    /// Laravel-scale batch retains ~600k symbols nothing reads.
71    pub skip_symbols: bool,
72}
73
74impl BatchOptions {
75    pub fn new() -> Self {
76        Self::default()
77    }
78
79    pub fn with_suppressed<I, S>(mut self, kinds: I) -> Self
80    where
81        I: IntoIterator<Item = S>,
82        S: Into<String>,
83    {
84        self.suppressed_issue_kinds = kinds.into_iter().map(Into::into).collect();
85        self
86    }
87
88    pub fn with_progress_callback(mut self, callback: Arc<dyn Fn() + Send + Sync>) -> Self {
89        self.on_file_done = Some(callback);
90        self
91    }
92
93    pub fn with_php_version(mut self, version: PhpVersion) -> Self {
94        self.php_version_override = Some(version);
95        self
96    }
97
98    /// Don't collect per-expression symbols into the result (see
99    /// [`Self::skip_symbols`]). For diagnostics-only consumers;
100    /// [`AnalysisResult::symbol_at`] will find nothing on the batch result.
101    pub fn without_symbols(mut self) -> Self {
102        self.skip_symbols = true;
103        self
104    }
105
106    /// True iff at least one dead-code [`IssueKind`] would be emitted (i.e.
107    /// not all of them are suppressed).
108    fn should_run_dead_code(&self) -> bool {
109        dead_code_issue_kinds()
110            .iter()
111            .any(|k| !self.suppressed_issue_kinds.contains(*k))
112    }
113
114    /// Drop issues whose [`IssueKind::display_name()`] is listed in
115    /// [`Self::suppressed_issue_kinds`] (display_name so plugin issues can be
116    /// suppressed by their own names).
117    fn apply(&self, issues: &mut Vec<Issue>) {
118        if self.suppressed_issue_kinds.is_empty() {
119            return;
120        }
121        issues.retain(|i| !self.suppressed_issue_kinds.contains(i.kind.display_name()));
122    }
123}
124
125struct ParsedProjectFile {
126    file: Arc<str>,
127    source: Arc<str>,
128    parsed: php_rs_parser::ParseResult,
129}
130
131impl ParsedProjectFile {
132    fn new(file: Arc<str>, source: Arc<str>) -> Self {
133        let parsed = php_rs_parser::parse(source.as_ref());
134        Self {
135            file,
136            source,
137            parsed,
138        }
139    }
140
141    fn source(&self) -> &str {
142        self.source.as_ref()
143    }
144
145    fn source_map(&self) -> &php_rs_parser::source_map::SourceMap {
146        &self.parsed.source_map
147    }
148
149    fn errors(&self) -> &[php_rs_parser::diagnostics::ParseError] {
150        &self.parsed.errors
151    }
152
153    fn owned(&self) -> &php_ast::owned::Program {
154        &self.parsed.program
155    }
156}
157
158impl AnalysisSession {
159    fn suppression_map_for_file(
160        &self,
161        db: &dyn MirDatabase,
162        file: &Arc<str>,
163    ) -> Option<Arc<crate::suppression::SuppressionMap>> {
164        let sf = db.lookup_source_file(file)?;
165        let text = sf.text(db).clone();
166        if let Some((_, map)) = self
167            .suppression_maps
168            .read()
169            .get(file)
170            .filter(|(cached_text, _)| cached_text.as_ref() == text.as_ref())
171        {
172            return Some(map.clone());
173        }
174
175        let map = Arc::new(crate::suppression::SuppressionMap::from_source(
176            text.as_ref(),
177        ));
178        self.suppression_maps
179            .write()
180            .insert(file.clone(), (text, map.clone()));
181        Some(map)
182    }
183
184    /// Cumulative hit / miss counts on the persistent definition cache attached
185    /// to this session. `(0, 0)` when no cache is configured.
186    #[doc(hidden)]
187    pub fn stub_cache_stats(&self) -> (u64, u64) {
188        match self.db.stub_cache.as_deref() {
189            Some(c) => (c.hits(), c.misses()),
190            None => (0, 0),
191        }
192    }
193
194    fn batch_php_version(&self, opts: &BatchOptions) -> PhpVersion {
195        opts.php_version_override.unwrap_or(self.php_version)
196    }
197
198    /// Mark issues silenced by inline suppression comments
199    /// (`@mir-ignore`, `@psalm-suppress`, `@phpstan-ignore*`, …) as suppressed.
200    ///
201    /// Runs as a final post-filter over the merged issue list so it applies
202    /// uniformly to every emitting pass — body analysis, the collector, class
203    /// checks and dead-code detection — including diagnostics the per-statement
204    /// `@psalm-suppress` path in `stmt/mod.rs` structurally cannot reach.
205    ///
206    /// Issues are *marked* rather than dropped, mirroring that per-statement
207    /// path and the kind-level `mir.xml` suppress handler; every consumer (CLI,
208    /// WASM, the test harness) already skips [`Issue::suppressed`].
209    /// Apply inline suppressions and then emit `UnusedSuppress` issues for
210    /// any named `@suppress`/`@psalm-suppress` annotations that matched nothing.
211    ///
212    /// `analyzed_files` must list every file that was analyzed in this batch so
213    /// that files with *zero* existing issues still have their suppression maps
214    /// inspected for unused annotations.
215    pub(crate) fn apply_suppressions_and_emit_unused(
216        &self,
217        issues: &mut Vec<Issue>,
218        analyzed_files: &[Arc<str>],
219    ) {
220        let db = self.snapshot_db();
221        let mut cache: HashMap<Arc<str>, Option<Arc<crate::suppression::SuppressionMap>>> =
222            HashMap::default();
223        for issue in issues.iter_mut() {
224            if issue.suppressed {
225                continue;
226            }
227            let map = cache
228                .entry(issue.location.file.clone())
229                .or_insert_with(|| self.suppression_map_for_file(&db, &issue.location.file));
230            if let Some(map) = map.as_ref() {
231                if map.is_suppressed(
232                    issue.location.line,
233                    issue.kind.display_name(),
234                    issue.kind.code(),
235                ) {
236                    issue.suppressed = true;
237                }
238            }
239        }
240        // Ensure suppression maps are built for every analyzed file, not just
241        // those that already have at least one issue (files with no issues would
242        // otherwise be skipped and their unused suppressions never detected).
243        for file in analyzed_files {
244            cache
245                .entry(file.clone())
246                .or_insert_with(|| self.suppression_map_for_file(&db, file));
247        }
248        // Now emit UnusedSuppress for each file that has named suppressions.
249        let files: Vec<Arc<str>> = cache
250            .iter()
251            .filter_map(|(f, m)| m.as_ref().map(|_| f.clone()))
252            .collect();
253        // Bucket issue refs by file once — a per-file scan of the global
254        // issue list would be O(files × issues), with clones on top.
255        let mut issues_by_file: HashMap<&str, Vec<&Issue>> = HashMap::default();
256        for issue in issues.iter() {
257            issues_by_file
258                .entry(issue.location.file.as_ref())
259                .or_default()
260                .push(issue);
261        }
262        let mut new_issues: Vec<Issue> = Vec::new();
263        for file in files {
264            if let Some(Some(map)) = cache.get(&file) {
265                if map.named_suppressions.is_empty() {
266                    continue;
267                }
268                let file_issues: &[&Issue] = issues_by_file
269                    .get(file.as_ref())
270                    .map(Vec::as_slice)
271                    .unwrap_or(&[]);
272                // Pre-suppressed issues arrived with suppressed=true from the
273                // IssueBuffer mechanism (collector / body analysis). They may be
274                // at a different line than the SuppressionMap target and need
275                // special handling in unused_named.
276                let pre_suppressed: Vec<&Issue> = file_issues
277                    .iter()
278                    .filter(|i| i.suppressed)
279                    .copied()
280                    .collect();
281                // Issues newly suppressed by the SuppressionMap in this pass
282                // arrived with suppressed=false; after the marking loop they
283                // also have suppressed=true. Pass all file issues for exact-line
284                // matching; pre_suppressed enables the docblock-range fallback.
285                let unused = map.unused_named(file_issues, &pre_suppressed);
286                for (line, kind) in unused {
287                    let loc = mir_types::Location::new(file.clone(), line, line, 0, 0);
288                    let mut issue = Issue::new(mir_issues::IssueKind::UnusedSuppress { kind }, loc);
289                    if map.is_suppressed(line, issue.kind.display_name(), issue.kind.code()) {
290                        issue.suppressed = true;
291                    }
292                    new_issues.push(issue);
293                }
294            }
295        }
296        issues.extend(new_issues);
297    }
298
299    fn type_exists(&self, fqcn: &str) -> bool {
300        let db = self.snapshot_db();
301        crate::db::class_exists(&db, fqcn)
302    }
303
304    fn collect_and_ingest_source(
305        &mut self,
306        file: Arc<str>,
307        src: &str,
308        php_version: PhpVersion,
309    ) -> FileDefinitions {
310        self.db.collect_and_ingest_file(file, src, php_version)
311    }
312
313    /// Rebuild the workspace symbol index singleton from every registered source
314    /// file. Required in the batch path because `workspace_index` reads the
315    /// maintained singleton, and that singleton is built from vendor *before*
316    /// `analyze_paths` registers project files (and before `lazy_load_*` faults
317    /// in referenced classes). Without refreshing it, `find_class_like` /
318    /// `class_exists` miss every project and lazy-loaded class, yielding false
319    /// `UndefinedClass`. Cheap after the definition caches are warm (no parsing).
320    fn refresh_workspace_index(&mut self) {
321        let guard = &mut self.db.salsa;
322        guard.rebuild_workspace_symbol_index();
323    }
324
325    /// Load the configured PHP version + built-in stubs + user stubs into
326    /// the shared db. Called by [`Self::analyze_paths`] and
327    /// [`Self::collect_definitions`].
328    fn load_batch_stubs(&mut self, php_version: PhpVersion) {
329        // Wire the PHP version into the db before any SourceFile inputs are
330        // registered — collect_file_definitions reads it for @since/@removed filtering.
331        {
332            let version_str = Arc::from(php_version.to_string());
333            self.db.salsa.set_php_version(version_str);
334        }
335
336        // Built-in stubs for the configured PHP version.
337        let paths: Vec<&'static str> = crate::stubs::stub_files().iter().map(|&(p, _)| p).collect();
338        self.db.ingest_stub_paths(&paths, php_version);
339
340        // User-configured stubs.
341        self.db
342            .ingest_user_stubs(&self.user_stub_files, &self.user_stub_dirs);
343
344        // Ensure a resolver is configured so pull-path lookups can map
345        // built-in FQCNs to the stub VFS paths registered above.
346        let guard = &mut self.db.salsa;
347        if guard.current_resolver().is_none() {
348            let resolver: Arc<dyn crate::ClassResolver> = Arc::new(crate::StubClassResolver);
349            guard.set_resolver(Some(resolver));
350        }
351    }
352}
353
354mod lazy;
355mod run;
356
357/// Analyze a PHP source string without a real file path. Useful for tests
358/// and single-file LSP mode. Allocates a throwaway db; doesn't touch any
359/// existing session.
360///
361/// This preserves the historical default of collecting per-expression
362/// symbols. Issue-only or type-env-only callers should prefer
363/// [`analyze_source_with_options`] with [`BatchOptions::without_symbols`].
364pub fn analyze_source(source: &str) -> AnalysisResult {
365    analyze_source_with_options(source, &BatchOptions::new())
366}
367
368/// Analyze a PHP source string with batch-style output controls.
369///
370/// Mirrors [`analyze_source`] but lets issue-only consumers skip retaining
371/// whole-file `ResolvedSymbol` payloads while keeping the same typed
372/// diagnostics and type-environment behavior.
373pub fn analyze_source_with_options(source: &str, opts: &BatchOptions) -> AnalysisResult {
374    use mir_issues::Issue;
375
376    let php_version = PhpVersion::LATEST;
377    let file: Arc<str> = Arc::from("<source>");
378    let mut db = MirDbStorage::default();
379    db.set_php_version(Arc::from(php_version.to_string()));
380    crate::stubs::load_stubs_for_version(&mut db, php_version);
381    // Register the file through the workspace registry (not a bare
382    // `SourceFile::new`) so it lands in `all_source_files()` and the
383    // workspace symbol index. Without this, body analysis can't look up the
384    // file's own functions/methods/classes and degrades every parameter to
385    // `mixed` via the `ast_derived_fn_params` fallback.
386    db.upsert_source_file(file.clone(), Arc::from(source));
387    let parsed = php_rs_parser::parse(source);
388    let mut collected_issues: Vec<Issue> = parsed
389        .errors
390        .iter()
391        .filter(|err| !crate::parser::is_spurious_reserved_class_error(err))
392        .map(|err| crate::parser::parse_error_to_issue(err, &file, source, &parsed.source_map))
393        .collect();
394    let collector = crate::collector::DefinitionCollector::new_for_slice(
395        file.clone(),
396        source,
397        &parsed.source_map,
398    )
399    .with_php_version(php_version);
400    let (mut slice, collector_issues) = collector.collect_slice(&parsed.program);
401    collected_issues.extend(collector_issues);
402    mir_codebase::definitions::deduplicate_params_in_slice(&mut slice);
403    let slice_arc = Arc::new(slice);
404    let issues_arc = Arc::new(collected_issues);
405    db.prime_parse_cache(
406        crate::stub_cache::hash_source(source),
407        php_version.cache_byte(),
408        Arc::clone(&slice_arc),
409        Arc::clone(&issues_arc),
410    );
411    let _file_defs = crate::db::FileDefinitions {
412        slice: slice_arc,
413        issues: issues_arc.clone(),
414    };
415    let suppressions = crate::suppression::SuppressionMap::from_source(source);
416    let mut all_issues = Arc::unwrap_or_clone(issues_arc);
417    if all_issues.iter().any(|issue| {
418        matches!(issue.kind, mir_issues::IssueKind::ParseError { .. })
419            && issue.severity == mir_issues::Severity::Error
420    }) {
421        mark_suppressed(&mut all_issues, &suppressions);
422        return AnalysisResult::build(all_issues, rustc_hash::FxHashMap::default(), Vec::new());
423    }
424    let mut type_envs = rustc_hash::FxHashMap::default();
425    let mut all_symbols = Vec::new();
426    let mut driver = BodyAnalyzer::new(&db, php_version);
427    driver.collect_symbols = !opts.skip_symbols;
428    all_issues.extend(driver.analyze_bodies_typed(
429        &parsed.program,
430        file.clone(),
431        source,
432        &parsed.source_map,
433        &mut type_envs,
434        &mut all_symbols,
435    ));
436    if let Some(plugins) = mir_plugin::snapshot() {
437        if plugins.hooks().before_add_issue {
438            all_issues.retain(|i| plugins.before_add_issue(i));
439        }
440    }
441    mark_suppressed(&mut all_issues, &suppressions);
442    emit_unused_suppressions(&mut all_issues, &suppressions, &file);
443    opts.apply(&mut all_issues);
444    AnalysisResult::build(all_issues, type_envs, all_symbols)
445}
446
447/// Mark issues silenced by a single file's [`SuppressionMap`]. Shared by the
448/// in-memory [`analyze_source`] entry point, which has the source in hand and
449/// does not go through the db-backed batch post-filter.
450fn mark_suppressed(issues: &mut [Issue], suppressions: &crate::suppression::SuppressionMap) {
451    if suppressions.is_empty() {
452        return;
453    }
454    for issue in issues.iter_mut() {
455        if !issue.suppressed
456            && suppressions.is_suppressed(
457                issue.location.line,
458                issue.kind.display_name(),
459                issue.kind.code(),
460            )
461        {
462            issue.suppressed = true;
463        }
464    }
465}
466
467/// Append `UnusedSuppress` issues for any named `@suppress`/`@psalm-suppress`
468/// annotations that did not match any issue in `all_issues`. The new issues are
469/// themselves subject to suppression (so `@suppress UnusedSuppress` works).
470fn emit_unused_suppressions(
471    all_issues: &mut Vec<Issue>,
472    suppressions: &crate::suppression::SuppressionMap,
473    file: &std::sync::Arc<str>,
474) {
475    let all_refs: Vec<&Issue> = all_issues.iter().collect();
476    let pre_suppressed: Vec<&Issue> = all_refs.iter().filter(|i| i.suppressed).copied().collect();
477    let unused = suppressions.unused_named(&all_refs, &pre_suppressed);
478    for (line, kind) in unused {
479        let loc = mir_types::Location::new(file.clone(), line, line, 0, 0);
480        let mut issue = Issue::new(mir_issues::IssueKind::UnusedSuppress { kind }, loc);
481        if suppressions.is_suppressed(line, issue.kind.display_name(), issue.kind.code()) {
482            issue.suppressed = true;
483        }
484        all_issues.push(issue);
485    }
486}
487
488/// Discover all `.php` files under a directory, recursively.
489pub fn discover_files(root: &Path) -> Vec<PathBuf> {
490    if root.is_file() {
491        return vec![root.to_path_buf()];
492    }
493    let mut files = Vec::new();
494    collect_php_files(root, &mut files);
495    files
496}
497
498pub(crate) fn collect_php_files(dir: &Path, out: &mut Vec<PathBuf>) {
499    if let Ok(entries) = std::fs::read_dir(dir) {
500        for entry in entries.flatten() {
501            if entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(false) {
502                continue;
503            }
504            let path = entry.path();
505            if path.is_dir() {
506                let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
507                if matches!(
508                    name,
509                    "vendor" | ".git" | "node_modules" | ".cache" | ".pnpm-store"
510                ) {
511                    continue;
512                }
513                collect_php_files(&path, out);
514            } else if path.extension().and_then(|e| e.to_str()) == Some("php") {
515                out.push(path);
516            }
517        }
518    }
519}
520
521// ---------------------------------------------------------------------------
522// FQCN reference walk — collects every class-name reference reachable from a
523// ClassLike's signature surface. Used by lazy_load_missing_classes to chase
524// transitive vendor types.
525// ---------------------------------------------------------------------------
526
527pub(crate) fn collect_class_referenced_fqcns(class: &crate::db::ClassLike, out: &mut Vec<String>) {
528    if let Some(p) = class.parent() {
529        out.push(p.to_string());
530    }
531    for i in class.interfaces() {
532        out.push(i.to_string());
533    }
534    for e in class.extends() {
535        out.push(e.to_string());
536    }
537    for t in class.class_traits() {
538        out.push(t.to_string());
539    }
540    for m in class.mixins() {
541        out.push(m.to_string());
542    }
543    for u in class.extends_type_args() {
544        collect_fqcns_in_union(u, out);
545    }
546    for (iface, args) in class.implements_type_args() {
547        out.push(iface.to_string());
548        for u in args {
549            collect_fqcns_in_union(u, out);
550        }
551    }
552    for (_, m) in class.own_methods().iter() {
553        for p in m.params.iter() {
554            if let Some(t) = &p.ty {
555                collect_fqcns_in_union(t, out);
556            }
557        }
558        if let Some(t) = &m.return_type {
559            collect_fqcns_in_union(t, out);
560        }
561        for thrown in m.throws.iter() {
562            out.push(thrown.to_string());
563        }
564    }
565    if let Some(props) = class.own_properties() {
566        for (_, p) in props.iter() {
567            if let Some(t) = &p.ty {
568                collect_fqcns_in_union(t, out);
569            }
570        }
571    }
572    for (_, c) in class.own_constants().iter() {
573        collect_fqcns_in_union(&c.ty, out);
574    }
575}
576
577pub(crate) fn collect_fqcns_in_union(u: &Type, out: &mut Vec<String>) {
578    for atom in u.types.iter() {
579        collect_fqcns_in_atomic(atom, out);
580    }
581}
582
583fn collect_fqcns_in_simple(t: &mir_types::compact::SimpleType, out: &mut Vec<String>) {
584    if let mir_types::compact::SimpleType::Complex(u) = t {
585        collect_fqcns_in_union(u, out);
586    }
587}
588
589pub(crate) fn collect_fqcns_in_atomic(a: &Atomic, out: &mut Vec<String>) {
590    match a {
591        Atomic::TNamedObject { fqcn, type_params } => {
592            out.push(fqcn.to_string());
593            for tp in type_params.iter() {
594                collect_fqcns_in_union(tp, out);
595            }
596        }
597        Atomic::TStaticObject { fqcn } | Atomic::TSelf { fqcn } | Atomic::TParent { fqcn } => {
598            out.push(fqcn.to_string());
599        }
600        Atomic::TLiteralEnumCase { enum_fqcn, .. } => {
601            out.push(enum_fqcn.to_string());
602        }
603        Atomic::TClassString(Some(s)) | Atomic::TInterfaceString(Some(s)) => {
604            out.push(s.to_string());
605        }
606        Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
607            collect_fqcns_in_union(key, out);
608            collect_fqcns_in_union(value, out);
609        }
610        Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
611            collect_fqcns_in_union(value, out);
612        }
613        Atomic::TKeyedArray { properties, .. } => {
614            for (_, kp) in properties.iter() {
615                collect_fqcns_in_union(&kp.ty, out);
616            }
617        }
618        Atomic::TClosure { data } => {
619            for p in data.params.iter() {
620                if let Some(t) = &p.ty {
621                    collect_fqcns_in_simple(t, out);
622                }
623            }
624            collect_fqcns_in_union(&data.return_type, out);
625            if let Some(t) = &data.this_type {
626                collect_fqcns_in_union(t, out);
627            }
628        }
629        Atomic::TCallable {
630            params,
631            return_type,
632        } => {
633            if let Some(ps) = params {
634                for p in ps {
635                    if let Some(t) = &p.ty {
636                        collect_fqcns_in_simple(t, out);
637                    }
638                }
639            }
640            if let Some(rt) = return_type {
641                collect_fqcns_in_union(rt, out);
642            }
643        }
644        Atomic::TIntersection { parts } => {
645            for p in parts.iter() {
646                collect_fqcns_in_union(p, out);
647            }
648        }
649        Atomic::TConditional { data } => {
650            collect_fqcns_in_union(&data.subject, out);
651            collect_fqcns_in_union(&data.if_true, out);
652            collect_fqcns_in_union(&data.if_false, out);
653        }
654        Atomic::TTemplateParam { as_type, .. } => {
655            collect_fqcns_in_union(as_type, out);
656        }
657        _ => {}
658    }
659}
660
661fn build_reverse_deps(db: &dyn crate::db::MirDatabase) -> HashMap<String, HashSet<String>> {
662    let mut reverse: HashMap<String, HashSet<String>> = HashMap::default();
663
664    let mut add_symbol_edge = |symbol_key: &str, dependent_file: &str| {
665        let lookup = crate::defining_file_lookup_key(symbol_key);
666        if let Some(defining_file) = db.symbol_defining_file(lookup) {
667            let def = defining_file.as_ref().to_string();
668            if def != dependent_file {
669                reverse
670                    .entry(def)
671                    .or_default()
672                    .insert(dependent_file.to_string());
673            }
674        }
675    };
676
677    for sf in db.all_source_files() {
678        let file = sf.path(db);
679        for symbol in crate::db::file_structural_symbols(db, sf).iter() {
680            add_symbol_edge(symbol, file.as_ref());
681        }
682    }
683
684    for (ref_file, symbol_key) in db.all_reference_location_pairs() {
685        add_symbol_edge(&symbol_key, ref_file.as_ref());
686    }
687
688    reverse
689}
690
691fn extract_reference_locations(
692    db: &dyn crate::db::MirDatabase,
693    file: &Arc<str>,
694) -> Arc<[crate::cache::CachedRefLoc]> {
695    db.extract_file_reference_locations(file.as_ref()).into()
696}
697
698pub struct AnalysisResult {
699    pub issues: Vec<Issue>,
700    #[doc(hidden)]
701    pub type_envs: rustc_hash::FxHashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
702    /// Per-expression resolved symbols from body analysis in analyzer emission
703    /// order. File indexing is built lazily for the legacy [`Self::symbol_at`]
704    /// helper so issue-only consumers do not pay the setup cost.
705    pub symbols: Vec<crate::symbol::ResolvedSymbol>,
706    /// Maps each file path to the symbol indices belonging to it. Built on
707    /// first use of [`Self::symbol_at`] because the batch result's navigation
708    /// helper is now primarily exercised by tests.
709    symbols_by_file: OnceLock<HashMap<Arc<str>, Vec<usize>>>,
710}
711
712impl AnalysisResult {
713    fn build(
714        issues: Vec<Issue>,
715        type_envs: rustc_hash::FxHashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
716        symbols: Vec<crate::symbol::ResolvedSymbol>,
717    ) -> Self {
718        Self {
719            issues,
720            type_envs,
721            symbols,
722            symbols_by_file: OnceLock::new(),
723        }
724    }
725
726    fn symbols_by_file(&self) -> &HashMap<Arc<str>, Vec<usize>> {
727        self.symbols_by_file.get_or_init(|| {
728            let mut by_file: HashMap<Arc<str>, Vec<usize>> = HashMap::default();
729            for (idx, symbol) in self.symbols.iter().enumerate() {
730                by_file.entry(symbol.file.clone()).or_default().push(idx);
731            }
732            by_file
733        })
734    }
735
736    pub fn error_count(&self) -> usize {
737        self.issues
738            .iter()
739            .filter(|i| i.severity == mir_issues::Severity::Error)
740            .count()
741    }
742
743    pub fn warning_count(&self) -> usize {
744        self.issues
745            .iter()
746            .filter(|i| i.severity == mir_issues::Severity::Warning)
747            .count()
748    }
749
750    pub fn issues_by_file(&self) -> HashMap<Arc<str>, Vec<&Issue>> {
751        let mut map: HashMap<Arc<str>, Vec<&Issue>> = HashMap::default();
752        for issue in &self.issues {
753            map.entry(issue.location.file.clone())
754                .or_default()
755                .push(issue);
756        }
757        map
758    }
759
760    pub fn count_by_severity(&self) -> Vec<(mir_issues::Severity, usize)> {
761        let mut counts: std::collections::BTreeMap<mir_issues::Severity, usize> =
762            std::collections::BTreeMap::new();
763        for issue in &self.issues {
764            *counts.entry(issue.severity).or_insert(0) += 1;
765        }
766        counts.into_iter().collect()
767    }
768
769    pub fn total_issue_count(&self) -> usize {
770        self.issues.len()
771    }
772
773    pub fn filter_issues<'a, F>(&'a self, predicate: F) -> impl Iterator<Item = &'a Issue>
774    where
775        F: Fn(&Issue) -> bool + 'a,
776    {
777        self.issues.iter().filter(move |i| predicate(i))
778    }
779
780    pub fn symbol_at(
781        &self,
782        file: &str,
783        byte_offset: u32,
784    ) -> Option<&crate::symbol::ResolvedSymbol> {
785        let indices = self.symbols_by_file().get(file)?;
786
787        // Primary: cursor is on an identifier token.
788        if let Some(sym) = indices
789            .iter()
790            .map(|idx| &self.symbols[*idx])
791            .filter(|s| s.span.start <= byte_offset && byte_offset < s.span.end)
792            .min_by_key(|s| s.span.end - s.span.start)
793        {
794            return Some(sym);
795        }
796
797        // Fallback: cursor is in a call-expression gap (e.g. the whitespace or
798        // argument list between two chained method calls).  Match against the
799        // full expression span recorded for call-like symbols and return the
800        // innermost (smallest) enclosing call, mirroring what an AST-walk to
801        // the innermost containing call expression would produce.
802        indices
803            .iter()
804            .map(|idx| &self.symbols[*idx])
805            .filter(|s| {
806                s.expr_span
807                    .is_some_and(|es| es.start <= byte_offset && byte_offset < es.end)
808            })
809            .min_by_key(|s| {
810                let es = s.expr_span.unwrap();
811                es.end - es.start
812            })
813    }
814}