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