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