Skip to main content

mir_analyzer/batch/
mod.rs

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