Skip to main content

mir_analyzer/batch/
mod.rs

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