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