Skip to main content

mir_analyzer/
batch.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    &["UnusedMethod", "UnusedProperty", "UnusedFunction"]
40}
41
42/// Per-batch options for [`AnalysisSession::analyze_paths`] and friends.
43///
44/// Configuration that only makes sense for full-project (batch) analysis
45/// lives here instead of on [`AnalysisSession`], so the per-file LSP API
46/// isn't bloated with state nothing else reads.
47#[derive(Clone, Default)]
48pub struct BatchOptions {
49    /// Names of `IssueKind` variants to drop from the final result, e.g.
50    /// `["MissingThrowsDocblock", "UnusedMethod"]`. Applied as a final
51    /// post-filter so analyzer internals don't need to know which
52    /// diagnostics the consumer cares about. Empty by default.
53    pub suppressed_issue_kinds: HashSet<String>,
54    /// Called once after each file completes body analysis (progress reporting).
55    pub on_file_done: Option<Arc<dyn Fn() + Send + Sync>>,
56    /// Override the session's configured PHP version for this run. `None`
57    /// uses the session's version.
58    pub php_version_override: Option<PhpVersion>,
59}
60
61impl BatchOptions {
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    pub fn with_suppressed<I, S>(mut self, kinds: I) -> Self
67    where
68        I: IntoIterator<Item = S>,
69        S: Into<String>,
70    {
71        self.suppressed_issue_kinds = kinds.into_iter().map(Into::into).collect();
72        self
73    }
74
75    pub fn with_progress_callback(mut self, callback: Arc<dyn Fn() + Send + Sync>) -> Self {
76        self.on_file_done = Some(callback);
77        self
78    }
79
80    pub fn with_php_version(mut self, version: PhpVersion) -> Self {
81        self.php_version_override = Some(version);
82        self
83    }
84
85    /// True iff at least one dead-code [`IssueKind`] would be emitted (i.e.
86    /// not all of them are suppressed).
87    fn should_run_dead_code(&self) -> bool {
88        dead_code_issue_kinds()
89            .iter()
90            .any(|k| !self.suppressed_issue_kinds.contains(*k))
91    }
92
93    /// Drop issues whose [`IssueKind::name()`] is listed in
94    /// [`Self::suppressed_issue_kinds`].
95    fn apply(&self, issues: &mut Vec<Issue>) {
96        if self.suppressed_issue_kinds.is_empty() {
97            return;
98        }
99        issues.retain(|i| !self.suppressed_issue_kinds.contains(i.kind.name()));
100    }
101}
102
103struct ParsedProjectFile {
104    file: Arc<str>,
105    source: Arc<str>,
106    parsed: php_rs_parser::ParseResult,
107}
108
109impl ParsedProjectFile {
110    fn new(file: Arc<str>, source: Arc<str>) -> Self {
111        let parsed = php_rs_parser::parse(source.as_ref());
112        Self {
113            file,
114            source,
115            parsed,
116        }
117    }
118
119    fn source(&self) -> &str {
120        self.source.as_ref()
121    }
122
123    fn source_map(&self) -> &php_rs_parser::source_map::SourceMap {
124        &self.parsed.source_map
125    }
126
127    fn errors(&self) -> &[php_rs_parser::diagnostics::ParseError] {
128        &self.parsed.errors
129    }
130
131    fn owned(&self) -> &php_ast::owned::Program {
132        &self.parsed.program
133    }
134}
135
136impl AnalysisSession {
137    /// Cumulative hit / miss counts on the persistent definition cache attached
138    /// to this session. `(0, 0)` when no cache is configured.
139    #[doc(hidden)]
140    pub fn stub_cache_stats(&self) -> (u64, u64) {
141        match self.db.stub_cache.as_deref() {
142            Some(c) => (c.hits(), c.misses()),
143            None => (0, 0),
144        }
145    }
146
147    fn batch_php_version(&self, opts: &BatchOptions) -> PhpVersion {
148        opts.php_version_override.unwrap_or(self.php_version)
149    }
150
151    /// Mark issues silenced by inline suppression comments
152    /// (`@mir-ignore`, `@psalm-suppress`, `@phpstan-ignore*`, …) as suppressed.
153    ///
154    /// Runs as a final post-filter over the merged issue list so it applies
155    /// uniformly to every emitting pass — body analysis, the collector, class
156    /// checks and dead-code detection — including diagnostics the per-statement
157    /// `@psalm-suppress` path in `stmt/mod.rs` structurally cannot reach.
158    ///
159    /// Issues are *marked* rather than dropped, mirroring that per-statement
160    /// path and the kind-level `mir.xml` suppress handler; every consumer (CLI,
161    /// WASM, the test harness) already skips [`Issue::suppressed`].
162    fn apply_inline_suppressions(&self, issues: &mut [Issue]) {
163        use crate::suppression::SuppressionMap;
164        if issues.iter().all(|i| i.suppressed) {
165            return;
166        }
167        let db = self.snapshot_db();
168        // One map per distinct file, built lazily; `None` once we know a file
169        // has no source registered or no suppression comments.
170        let mut cache: HashMap<Arc<str>, Option<SuppressionMap>> = HashMap::default();
171        for issue in issues.iter_mut() {
172            if issue.suppressed {
173                continue;
174            }
175            let map = cache.entry(issue.location.file.clone()).or_insert_with(|| {
176                db.lookup_source_file(&issue.location.file)
177                    .map(|sf| SuppressionMap::from_source(&sf.text(&db)))
178                    .filter(|m| !m.is_empty())
179            });
180            if let Some(map) = map.as_ref() {
181                if map.is_suppressed(issue.location.line, issue.kind.name(), issue.kind.code()) {
182                    issue.suppressed = true;
183                }
184            }
185        }
186    }
187
188    fn type_exists(&self, fqcn: &str) -> bool {
189        let db = self.snapshot_db();
190        crate::db::class_exists(&db, fqcn)
191    }
192
193    fn collect_and_ingest_source(
194        &self,
195        file: Arc<str>,
196        src: &str,
197        php_version: PhpVersion,
198    ) -> FileDefinitions {
199        self.db.collect_and_ingest_file(file, src, php_version)
200    }
201
202    /// Load the configured PHP version + built-in stubs + user stubs into
203    /// the shared db. Called by [`Self::analyze_paths`] and
204    /// [`Self::collect_definitions`].
205    fn load_batch_stubs(&self, php_version: PhpVersion) {
206        // Wire the PHP version into the db before any SourceFile inputs are
207        // registered — collect_file_definitions reads it for @since/@removed filtering.
208        {
209            let version_str = Arc::from(php_version.to_string().as_str());
210            self.db.salsa.write().set_php_version(version_str);
211        }
212
213        // Built-in stubs for the configured PHP version.
214        let paths: Vec<&'static str> = crate::stubs::stub_files().iter().map(|&(p, _)| p).collect();
215        self.db.ingest_stub_paths(&paths, php_version);
216
217        // User-configured stubs.
218        self.db
219            .ingest_user_stubs(&self.user_stub_files, &self.user_stub_dirs);
220
221        // Ensure a resolver is configured so pull-path lookups can map
222        // built-in FQCNs to the stub VFS paths registered above.
223        let mut guard = self.db.salsa.write();
224        if guard.current_resolver().is_none() {
225            let resolver: Arc<dyn crate::ClassResolver> = Arc::new(crate::StubClassResolver);
226            guard.set_resolver(Some(resolver));
227        }
228    }
229
230    /// Run the full batch analysis pipeline on a set of file paths.
231    pub fn analyze_paths(&self, paths: &[PathBuf], opts: &BatchOptions) -> AnalysisResult {
232        let php_version = self.batch_php_version(opts);
233        let mut all_issues = Vec::new();
234        let _t0 = std::time::Instant::now();
235
236        // ---- Load PHP built-in stubs (before definition collection so user code can override)
237        self.load_batch_stubs(php_version);
238        let _t_stubs = _t0.elapsed();
239
240        // ---- Read files in parallel ----------------------------------
241        let parsed_files: Vec<ParsedProjectFile> = paths
242            .par_iter()
243            .filter_map(|path| match std::fs::read_to_string(path) {
244                Ok(src) => {
245                    let file = Arc::from(path.to_string_lossy().as_ref());
246                    Some(ParsedProjectFile::new(file, Arc::from(src)))
247                }
248                Err(e) => {
249                    eprintln!("Cannot read {}: {}", path.display(), e);
250                    None
251                }
252            })
253            .collect();
254        let _t_read = _t0.elapsed();
255
256        let file_data: Vec<(Arc<str>, Arc<str>)> = parsed_files
257            .iter()
258            .map(|parsed| (parsed.file.clone(), parsed.source.clone()))
259            .collect();
260
261        // ---- Pre-analysis invalidation: evict dependents of changed/removed files
262        if let Some(cache) = &self.cache {
263            let mut invalidated: Vec<String> = file_data
264                .par_iter()
265                .filter_map(|(f, src)| {
266                    let h = hash_content(src.as_ref());
267                    if cache.get(f, &h).is_none() {
268                        Some(f.to_string())
269                    } else {
270                        None
271                    }
272                })
273                .collect();
274
275            // Files analyzed in a previous run but now gone from disk: their
276            // dependents hold stale results that still assume the deleted
277            // definitions exist. A file merely absent from this run's path set
278            // (but still on disk) is NOT a deletion — checking disk existence
279            // avoids evicting dependents during partial-path analysis.
280            let current: std::collections::HashSet<&str> =
281                file_data.iter().map(|(f, _)| f.as_ref()).collect();
282            let removed: Vec<String> = cache
283                .cached_files()
284                .into_iter()
285                .filter(|f| !current.contains(f.as_str()) && !std::path::Path::new(f).exists())
286                .collect();
287            for f in &removed {
288                cache.evict(f);
289            }
290            invalidated.extend(removed);
291
292            if !invalidated.is_empty() {
293                cache.evict_with_dependents(&invalidated);
294            }
295        }
296
297        // ---- Register Salsa source inputs for incremental follow-up calls ----
298        {
299            let mut guard = self.db.salsa.write();
300            for parsed in &parsed_files {
301                guard.upsert_source_file(parsed.file.clone(), parsed.source.clone());
302            }
303        }
304        let _t_salsa_reg = _t0.elapsed();
305
306        // ---- Definition collection from the already-parsed AST -------
307        // Returns (FileDefinitions, content_hash, has_hard_parse_errors) so we
308        // can prime the parse cache before the pre-warm loop below.
309        type Pass1Entry = (FileDefinitions, [u8; 32], bool);
310        let file_defs: Vec<Pass1Entry> = parsed_files
311            .par_iter()
312            .map(|parsed| {
313                let content_hash = hash_source(parsed.source());
314                let has_hard_parse_errors = parsed
315                    .errors()
316                    .iter()
317                    .any(crate::parser::is_hard_parse_error);
318                let mut all_issues: Vec<Issue> = parsed
319                    .errors()
320                    .iter()
321                    .map(|err| {
322                        crate::parser::parse_error_to_issue(
323                            err,
324                            &parsed.file,
325                            parsed.source(),
326                            parsed.source_map(),
327                        )
328                    })
329                    .collect();
330                let collector = crate::collector::DefinitionCollector::new_for_slice(
331                    parsed.file.clone(),
332                    parsed.source(),
333                    parsed.source_map(),
334                );
335                let (mut slice, collector_issues) = collector.collect_slice(parsed.owned());
336                all_issues.extend(collector_issues);
337                mir_codebase::storage::deduplicate_params_in_slice(&mut slice);
338                let defs = FileDefinitions {
339                    slice: Arc::new(slice),
340                    issues: Arc::new(all_issues),
341                };
342                (defs, content_hash, has_hard_parse_errors)
343            })
344            .collect();
345        let _t_collect_defs = _t0.elapsed();
346
347        // Prime the in-process parse cache so the pre-warm loop below avoids
348        // re-parsing every project file through collect_file_definitions.
349        {
350            let guard = self.db.salsa.read();
351            for (defs, hash, has_hard_parse_errors) in &file_defs {
352                if !*has_hard_parse_errors {
353                    guard.prime_parse_cache(*hash, Arc::clone(&defs.slice));
354                }
355            }
356        }
357
358        let mut files_with_parse_errors: HashSet<Arc<str>> = HashSet::default();
359        for (defs, _hash, _hard_err) in file_defs {
360            for issue in defs.issues.iter() {
361                if matches!(issue.kind, mir_issues::IssueKind::ParseError { .. })
362                    && issue.severity == mir_issues::Severity::Error
363                {
364                    files_with_parse_errors.insert(issue.location.file.clone());
365                }
366            }
367            all_issues.extend(Arc::unwrap_or_clone(defs.issues));
368        }
369        let _t_ingest = _t0.elapsed();
370
371        // ---- Pre-warm collect_file_definitions for project files -------------
372        {
373            let db_prewarm = {
374                let guard = self.db.salsa.read();
375                (**guard).clone()
376            };
377            let project_source_files: Vec<SourceFile> = {
378                let guard = self.db.salsa.read();
379                parsed_files
380                    .iter()
381                    .filter_map(|p| (**guard).lookup_source_file(&p.file))
382                    .collect()
383            };
384            project_source_files
385                .into_par_iter()
386                .for_each_with(db_prewarm, |db, sf| {
387                    let _ = collect_file_definitions(db as &dyn MirDatabase, sf);
388                });
389        }
390        let _t_prewarm_ms = (_t0.elapsed() - _t_ingest).as_secs_f64() * 1000.0;
391
392        // ---- Lazy-load unknown classes via PSR-4 ----------------------------
393        let _t_before_lazy = _t0.elapsed();
394        if let Some(psr4) = self.psr4.clone() {
395            self.lazy_load_missing_classes(psr4, php_version, &mut all_issues);
396        }
397        let _t_lazyload_ms = (_t0.elapsed() - _t_before_lazy).as_secs_f64() * 1000.0;
398
399        // ---- Class-level checks ---------------------------------------------
400        let analyzed_file_set: HashSet<Arc<str>> =
401            file_data.iter().map(|(f, _)| f.clone()).collect();
402        let _t_class_analyzer = std::time::Instant::now();
403        {
404            let class_db = {
405                let guard = self.db.salsa.read();
406                (**guard).clone()
407            };
408            let class_issues = crate::class::ClassAnalyzer::with_files(
409                &class_db,
410                analyzed_file_set.clone(),
411                &file_data,
412            )
413            .analyze_all();
414            all_issues.extend(class_issues);
415        }
416        let _t_class_analyzer_ms = _t_class_analyzer.elapsed().as_secs_f64() * 1000.0;
417
418        let _t_class_checks = _t0.elapsed();
419
420        let db_main = {
421            let guard = self.db.salsa.read();
422            (**guard).clone()
423        };
424
425        // ---- Body analysis: function/method bodies in parallel --------------
426        let body_results: Vec<(Vec<Issue>, Vec<crate::symbol::ResolvedSymbol>, Vec<RefLoc>)> =
427            parsed_files
428                .par_iter()
429                .filter(|parsed| !files_with_parse_errors.contains(&parsed.file))
430                .map_with(db_main, |db, parsed| {
431                    let driver = BodyAnalyzer::new(&*db as &dyn MirDatabase, php_version);
432                    let (issues, symbols) = if let Some(cache) = &self.cache {
433                        let h = hash_content(parsed.source());
434                        if let Some((cached_issues, ref_locs)) = cache.get(&parsed.file, &h) {
435                            db.replay_reference_locations(parsed.file.clone(), &ref_locs);
436                            (cached_issues, Vec::new())
437                        } else {
438                            let (issues, symbols) = driver.analyze_bodies(
439                                parsed.owned(),
440                                parsed.file.clone(),
441                                parsed.source(),
442                                parsed.source_map(),
443                            );
444                            let pending = db.take_pending_ref_locs();
445                            let cache_locs = pending
446                                .iter()
447                                .map(|r| (r.symbol_key.to_string(), r.line, r.col_start, r.col_end))
448                                .collect();
449                            cache.put(&parsed.file, h, issues.clone(), cache_locs);
450                            if let Some(cb) = &opts.on_file_done {
451                                cb();
452                            }
453                            return (issues, symbols, pending);
454                        }
455                    } else {
456                        driver.analyze_bodies(
457                            parsed.owned(),
458                            parsed.file.clone(),
459                            parsed.source(),
460                            parsed.source_map(),
461                        )
462                    };
463                    let pending = db.take_pending_ref_locs();
464                    if let Some(cb) = &opts.on_file_done {
465                        cb();
466                    }
467                    (issues, symbols, pending)
468                })
469                .collect();
470
471        let _t_body_analysis = _t0.elapsed();
472
473        // Serial commit: one lock acquisition per map for all files combined.
474        let mut all_ref_locs: Vec<RefLoc> = Vec::new();
475        let mut all_symbols = Vec::new();
476        for (issues, symbols, ref_locs) in body_results {
477            all_issues.extend(issues);
478            all_symbols.extend(symbols);
479            all_ref_locs.extend(ref_locs);
480        }
481        {
482            let guard = self.db.salsa.read();
483            guard.commit_reference_locations_batch(all_ref_locs);
484        }
485
486        // ---- Post-analysis lazy loading: FQCNs used without `use` imports ------
487        if let Some(psr4) = self.psr4.clone() {
488            self.lazy_load_from_body_issues(
489                psr4,
490                php_version,
491                &file_data,
492                &files_with_parse_errors,
493                &mut all_issues,
494                &mut all_symbols,
495            );
496        }
497
498        // ---- Build reverse dep graph and persist it for the next run ---------
499        // Must run AFTER `commit_reference_locations_batch` (above): the graph's
500        // call-site / instantiation / inferred-return edges are derived from the
501        // committed reference-location map. Built any earlier (the salsa db is
502        // fresh each session) that map is empty, so only structural edges
503        // (parent/interface/trait/declared types) survive — and any dependent
504        // reachable only through a call site or inferred type goes stale.
505        if let Some(cache) = &self.cache {
506            let db_snapshot = {
507                let guard = self.db.salsa.read();
508                (**guard).clone()
509            };
510            let rev = build_reverse_deps(&db_snapshot);
511            cache.set_reverse_deps(rev);
512        }
513
514        // Persist cache hits/misses to disk
515        if let Some(cache) = &self.cache {
516            cache.flush();
517        }
518
519        // ---- Dead-code detection -------------------------------------------
520        if opts.should_run_dead_code() {
521            let salsa = self.snapshot_db();
522            let _t_dead_code = std::time::Instant::now();
523            let dead_code_issues =
524                crate::dead_code::DeadCodeAnalyzer::with_files(&salsa, analyzed_file_set.clone())
525                    .analyze();
526            all_issues.extend(dead_code_issues);
527            if std::env::var("MIR_TIMING").is_ok() {
528                eprintln!(
529                    "[timing] dead_code_analyzer={:.0}ms",
530                    _t_dead_code.elapsed().as_secs_f64() * 1000.0
531                );
532            }
533        }
534
535        let _t_total = _t0.elapsed();
536        if std::env::var("MIR_TIMING").is_ok() {
537            eprintln!(
538                "[timing] stubs={:.0}ms read={:.0}ms salsa_reg={:.0}ms collect_defs={:.0}ms ingest={:.0}ms class_checks={:.0}ms (prewarm={:.0}ms lazy_load={:.0}ms class_analyzer={:.0}ms) body_analysis={:.0}ms total={:.0}ms",
539                _t_stubs.as_secs_f64() * 1000.0,
540                (_t_read - _t_stubs).as_secs_f64() * 1000.0,
541                (_t_salsa_reg - _t_read).as_secs_f64() * 1000.0,
542                (_t_collect_defs - _t_salsa_reg).as_secs_f64() * 1000.0,
543                (_t_ingest - _t_collect_defs).as_secs_f64() * 1000.0,
544                (_t_class_checks - _t_ingest).as_secs_f64() * 1000.0,
545                _t_prewarm_ms,
546                _t_lazyload_ms,
547                _t_class_analyzer_ms,
548                (_t_body_analysis - _t_class_checks).as_secs_f64() * 1000.0,
549                _t_total.as_secs_f64() * 1000.0,
550            );
551        }
552
553        opts.apply(&mut all_issues);
554        self.apply_inline_suppressions(&mut all_issues);
555        if let Some(dump) = crate::metrics::dump() {
556            eprintln!("{dump}");
557        }
558
559        // ---- Build workspace symbol index singleton -------------------------
560        {
561            let mut guard = self.db.salsa.write();
562            guard.rebuild_workspace_symbol_index();
563        }
564
565        AnalysisResult::build(all_issues, rustc_hash::FxHashMap::default(), all_symbols)
566    }
567
568    fn lazy_load_missing_classes(
569        &self,
570        psr4: Arc<crate::composer::Psr4Map>,
571        php_version: PhpVersion,
572        all_issues: &mut Vec<Issue>,
573    ) {
574        let max_depth = 10;
575        let mut loaded: HashSet<String> = HashSet::default();
576        let mut scanned: HashSet<Arc<str>> = HashSet::default();
577
578        for _ in 0..max_depth {
579            let mut to_load: Vec<(String, PathBuf)> = Vec::new();
580
581            let mut try_queue = |fqcn: &str| {
582                if !self.type_exists(fqcn) && !loaded.contains(fqcn) {
583                    if let Some(path) = psr4.resolve(fqcn) {
584                        to_load.push((fqcn.to_string(), path));
585                    }
586                }
587            };
588
589            let mut candidates: Vec<String> = Vec::new();
590            let import_candidates = {
591                let db_owned = self.snapshot_db();
592                let db = &db_owned;
593                for fqcn in crate::db::workspace_classes(db).iter() {
594                    if scanned.contains(fqcn.as_ref()) {
595                        continue;
596                    }
597                    let here = crate::db::Fqcn::from_str(db, fqcn.as_ref());
598                    let Some(class) = crate::db::find_class_like(db, here) else {
599                        continue;
600                    };
601                    scanned.insert(fqcn.clone());
602                    collect_class_referenced_fqcns(&class, &mut candidates);
603                }
604                db.file_import_snapshots()
605                    .into_iter()
606                    .flat_map(|(_, imports)| {
607                        imports
608                            .values()
609                            .map(|sym| sym.as_str().to_string())
610                            .collect::<Vec<_>>()
611                    })
612                    .collect::<Vec<_>>()
613            };
614            for fqcn in candidates {
615                try_queue(&fqcn);
616            }
617            for fqcn in import_candidates {
618                try_queue(&fqcn);
619            }
620
621            if to_load.is_empty() {
622                break;
623            }
624
625            for (fqcn, path) in to_load {
626                loaded.insert(fqcn);
627                if let Ok(src) = std::fs::read_to_string(&path) {
628                    let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
629                    let is_vendor = file.contains("/vendor/") || file.contains("\\vendor\\");
630                    let defs = self.collect_and_ingest_source(file, &src, php_version);
631                    if !is_vendor {
632                        all_issues.extend(Arc::unwrap_or_clone(defs.issues));
633                    }
634                }
635            }
636        }
637    }
638
639    fn lazy_load_from_body_issues(
640        &self,
641        psr4: Arc<crate::composer::Psr4Map>,
642        php_version: PhpVersion,
643        file_data: &[(Arc<str>, Arc<str>)],
644        files_with_parse_errors: &HashSet<Arc<str>>,
645        all_issues: &mut Vec<Issue>,
646        all_symbols: &mut Vec<crate::symbol::ResolvedSymbol>,
647    ) {
648        use mir_issues::IssueKind;
649
650        let max_depth = 5;
651        let mut loaded: HashSet<String> = HashSet::default();
652
653        for _ in 0..max_depth {
654            let mut to_load: HashMap<String, PathBuf> = HashMap::default();
655
656            for issue in all_issues.iter() {
657                if let IssueKind::UndefinedClass { name } = &issue.kind {
658                    if !self.type_exists(name) && !loaded.contains(name) {
659                        if let Some(path) = psr4.resolve(name) {
660                            to_load.entry(name.clone()).or_insert(path);
661                        }
662                    }
663                }
664            }
665
666            if to_load.is_empty() {
667                break;
668            }
669
670            loaded.extend(to_load.keys().cloned());
671
672            for path in to_load.values() {
673                if let Ok(src) = std::fs::read_to_string(path) {
674                    let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
675                    let _ = self.collect_and_ingest_source(file, &src, php_version);
676                }
677            }
678
679            self.lazy_load_missing_classes(psr4.clone(), php_version, all_issues);
680
681            let files_to_reanalyze: HashSet<Arc<str>> = all_issues
682                .iter()
683                .filter_map(|i| {
684                    if let IssueKind::UndefinedClass { name } = &i.kind {
685                        if self.type_exists(name) {
686                            return Some(i.location.file.clone());
687                        }
688                    }
689                    None
690                })
691                .collect();
692
693            if files_to_reanalyze.is_empty() {
694                break;
695            }
696
697            all_issues.retain(|i| !files_to_reanalyze.contains(&i.location.file));
698            all_symbols.retain(|s| !files_to_reanalyze.contains(&s.file));
699
700            let db_full = {
701                let guard = self.db.salsa.read();
702                (**guard).clone()
703            };
704
705            let reanalysis: Vec<(Vec<Issue>, Vec<crate::symbol::ResolvedSymbol>, Vec<RefLoc>)> =
706                file_data
707                    .par_iter()
708                    .filter(|(f, _)| {
709                        !files_with_parse_errors.contains(f) && files_to_reanalyze.contains(f)
710                    })
711                    .map_with(db_full, |db, (file, src)| {
712                        let driver = BodyAnalyzer::new(&*db as &dyn MirDatabase, php_version);
713                        let parsed = php_rs_parser::parse(src);
714                        let (issues, symbols) = driver.analyze_bodies(
715                            &parsed.program,
716                            file.clone(),
717                            src,
718                            &parsed.source_map,
719                        );
720                        let pending = db.take_pending_ref_locs();
721                        (issues, symbols, pending)
722                    })
723                    .collect();
724
725            let mut reanalysis_ref_locs: Vec<RefLoc> = Vec::new();
726            for (issues, symbols, ref_locs) in reanalysis {
727                all_issues.extend(issues);
728                all_symbols.extend(symbols);
729                reanalysis_ref_locs.extend(ref_locs);
730            }
731            {
732                let guard = self.db.salsa.read();
733                guard.commit_reference_locations_batch(reanalysis_ref_locs);
734            }
735        }
736    }
737
738    /// Re-analyze a single file (definition collection + body analysis) within the batch context.
739    ///
740    /// Mirrors the old `ProjectAnalyzer::re_analyze_file` cache-aware path.
741    /// Use [`Self::reanalyze_dependents`] for LSP-style per-file flows that
742    /// don't need batch options.
743    pub fn re_analyze_file(
744        &self,
745        file_path: &str,
746        new_content: &str,
747        opts: &BatchOptions,
748    ) -> AnalysisResult {
749        let php_version = self.batch_php_version(opts);
750
751        // Fast path: content unchanged and cache has a valid entry.
752        if let Some(cache) = &self.cache {
753            let h = hash_content(new_content);
754            if let Some((mut issues, ref_locs)) = cache.get(file_path, &h) {
755                let file: Arc<str> = Arc::from(file_path);
756                let guard = self.db.salsa.read();
757                guard.replay_reference_locations(file, &ref_locs);
758                guard.commit_pending_to_maps();
759                drop(guard);
760                opts.apply(&mut issues);
761                self.apply_inline_suppressions(&mut issues);
762                return AnalysisResult::build(issues, HashMap::default(), Vec::new());
763            }
764        }
765
766        let file: Arc<str> = Arc::from(file_path);
767
768        {
769            let mut guard = self.db.salsa.write();
770            guard.remove_file_definitions(file_path);
771        }
772
773        let file_defs = {
774            let mut guard = self.db.salsa.write();
775            let salsa_file = guard.upsert_source_file(file.clone(), Arc::from(new_content));
776            collect_file_definitions(&**guard, salsa_file)
777        };
778
779        let mut all_issues: Vec<Issue> = Arc::unwrap_or_clone(file_defs.issues.clone());
780
781        {
782            let mut guard = self.db.salsa.write();
783            if guard.workspace_symbol_index_singleton().is_some() {
784                if let Some(sf) = guard.lookup_source_file(file.as_ref()) {
785                    if guard.file_declarations_changed(sf) {
786                        guard.rebuild_workspace_symbol_index();
787                    }
788                }
789            }
790        }
791
792        let symbols = {
793            let guard = self.db.salsa.write();
794
795            let parsed = php_rs_parser::parse(new_content);
796
797            let has_hard_errors = parsed.errors.iter().any(crate::parser::is_hard_parse_error);
798            if !has_hard_errors {
799                let db_ref: &dyn MirDatabase = &**guard;
800                let driver = BodyAnalyzer::new(db_ref, php_version);
801                let (body_issues, symbols) = driver.analyze_bodies(
802                    &parsed.program,
803                    file.clone(),
804                    new_content,
805                    &parsed.source_map,
806                );
807                all_issues.extend(body_issues);
808                guard.commit_pending_to_maps();
809                symbols
810            } else {
811                Vec::new()
812            }
813        };
814
815        // Bake inline-suppression marks in *before* caching: suppression is a
816        // pure function of file content (and the cache key hashes content), so
817        // the cached issues should already carry their marks. The cache-hit
818        // branch above replays this file's source without re-registering the
819        // `SourceFile` input, so the db-backed post-filter cannot recompute
820        // marks there — caching the canonical result is what keeps a fresh
821        // process honoring `@mir-ignore` on an unchanged file.
822        mark_suppressed(
823            &mut all_issues,
824            &crate::suppression::SuppressionMap::from_source(new_content),
825        );
826
827        if let Some(cache) = &self.cache {
828            let h = hash_content(new_content);
829            cache.evict_with_dependents(&[file_path.to_string()]);
830            let db = self.snapshot_db();
831            let ref_locs = extract_reference_locations(&db, &file);
832            cache.put(file_path, h, all_issues.clone(), ref_locs);
833        }
834
835        opts.apply(&mut all_issues);
836        AnalysisResult::build(all_issues, HashMap::default(), symbols)
837    }
838
839    /// Collect type definitions only from `paths` into the codebase
840    /// without analyzing method bodies or emitting issues. Used to load
841    /// vendor types.
842    ///
843    /// When a disk-backed cache is attached, per-file `StubSlice` results
844    /// from previous runs are reused on a content-hash match, eliminating
845    /// the parse + definition-collection step. Cache misses run the normal
846    /// pipeline and write back so subsequent runs hit.
847    pub fn collect_definitions(&self, paths: &[PathBuf]) {
848        let _timing = std::env::var("MIR_TIMING").is_ok();
849        let _t0 = std::time::Instant::now();
850
851        let php_v = self.php_version.cache_byte();
852
853        struct FileEntry {
854            file: Arc<str>,
855            src: Arc<str>,
856            hash: [u8; 32],
857            cached: Option<mir_codebase::storage::StubSlice>,
858        }
859        let entries: Vec<FileEntry> = paths
860            .par_iter()
861            .filter_map(|path| {
862                let src = std::fs::read_to_string(path).ok()?;
863                let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
864                let src: Arc<str> = Arc::from(src);
865                let hash = hash_source(&src);
866                let cached = self.db.stub_cache.as_ref().and_then(|c| {
867                    let mut slice = c.get(&file, &hash, php_v)?;
868                    prepare_for_ingest(&mut slice);
869                    Some(slice)
870                });
871                Some(FileEntry {
872                    file,
873                    src,
874                    hash,
875                    cached,
876                })
877            })
878            .collect();
879        let _t_read = _t0.elapsed();
880
881        let source_files: Vec<SourceFile> = {
882            let mut guard = self.db.salsa.write();
883            entries
884                .iter()
885                .map(|e| {
886                    guard.upsert_source_file_with_durability(
887                        e.file.clone(),
888                        e.src.clone(),
889                        salsa::Durability::HIGH,
890                    )
891                })
892                .collect()
893        };
894        let _t_reg = _t0.elapsed();
895
896        let db_pass1 = {
897            let guard = self.db.salsa.read();
898            (**guard).clone()
899        };
900        let stub_cache = self.db.stub_cache.clone();
901        let prepared: Vec<mir_codebase::storage::StubSlice> = entries
902            .into_par_iter()
903            .zip(source_files.into_par_iter())
904            .map_with(db_pass1, |db, (mut entry, salsa_file)| {
905                if let Some(slice) = entry.cached.take() {
906                    let slice_arc = Arc::new(slice);
907                    db.parse_cache().insert(entry.hash, Arc::clone(&slice_arc));
908                    return (*slice_arc).clone();
909                }
910                let defs = collect_file_definitions(&*db, salsa_file);
911                if let Some(cache) = stub_cache.as_ref() {
912                    cache.put(&entry.file, &entry.hash, php_v, &defs.slice);
913                }
914                (*defs.slice).clone()
915            })
916            .collect();
917        let _t_collect = _t0.elapsed();
918        drop(prepared);
919        let _t_ingest = _t0.elapsed();
920
921        if _timing {
922            let (hits, misses) = self.stub_cache_stats();
923            eprintln!(
924                "[vendor] read={:.0}ms reg={:.0}ms collect={:.0}ms ingest={:.0}ms total={:.0}ms (cache hits={hits} misses={misses})",
925                _t_read.as_secs_f64() * 1000.0,
926                (_t_reg - _t_read).as_secs_f64() * 1000.0,
927                (_t_collect - _t_reg).as_secs_f64() * 1000.0,
928                (_t_ingest - _t_collect).as_secs_f64() * 1000.0,
929                _t_ingest.as_secs_f64() * 1000.0,
930            );
931        }
932
933        {
934            let mut guard = self.db.salsa.write();
935            guard.rebuild_workspace_symbol_index();
936        }
937
938        crate::collector::print_collector_stats();
939    }
940}
941
942/// Analyze a PHP source string without a real file path. Useful for tests
943/// and single-file LSP mode. Allocates a throwaway db; doesn't touch any
944/// existing session.
945pub fn analyze_source(source: &str) -> AnalysisResult {
946    let php_version = PhpVersion::LATEST;
947    let file: Arc<str> = Arc::from("<source>");
948    let mut db = MirDbStorage::default();
949    db.set_php_version(Arc::from(php_version.to_string().as_str()));
950    crate::stubs::load_stubs_for_version(&mut db, php_version);
951    let salsa_file = SourceFile::new(&db, file.clone(), Arc::from(source));
952    let file_defs = collect_file_definitions(&db, salsa_file);
953    let suppressions = crate::suppression::SuppressionMap::from_source(source);
954    let mut all_issues = Arc::unwrap_or_clone(file_defs.issues);
955    if all_issues.iter().any(|issue| {
956        matches!(issue.kind, mir_issues::IssueKind::ParseError { .. })
957            && issue.severity == mir_issues::Severity::Error
958    }) {
959        mark_suppressed(&mut all_issues, &suppressions);
960        return AnalysisResult::build(all_issues, rustc_hash::FxHashMap::default(), Vec::new());
961    }
962    let mut type_envs = rustc_hash::FxHashMap::default();
963    let mut all_symbols = Vec::new();
964    let result = php_rs_parser::parse(source);
965
966    let driver = BodyAnalyzer::new(&db, php_version);
967    all_issues.extend(driver.analyze_bodies_typed(
968        &result.program,
969        file.clone(),
970        source,
971        &result.source_map,
972        &mut type_envs,
973        &mut all_symbols,
974    ));
975    mark_suppressed(&mut all_issues, &suppressions);
976    AnalysisResult::build(all_issues, type_envs, all_symbols)
977}
978
979/// Mark issues silenced by a single file's [`SuppressionMap`]. Shared by the
980/// in-memory [`analyze_source`] entry point, which has the source in hand and
981/// does not go through the db-backed batch post-filter.
982fn mark_suppressed(issues: &mut [Issue], suppressions: &crate::suppression::SuppressionMap) {
983    if suppressions.is_empty() {
984        return;
985    }
986    for issue in issues.iter_mut() {
987        if !issue.suppressed
988            && suppressions.is_suppressed(issue.location.line, issue.kind.name(), issue.kind.code())
989        {
990            issue.suppressed = true;
991        }
992    }
993}
994
995/// Discover all `.php` files under a directory, recursively.
996pub fn discover_files(root: &Path) -> Vec<PathBuf> {
997    if root.is_file() {
998        return vec![root.to_path_buf()];
999    }
1000    let mut files = Vec::new();
1001    collect_php_files(root, &mut files);
1002    files
1003}
1004
1005pub(crate) fn collect_php_files(dir: &Path, out: &mut Vec<PathBuf>) {
1006    if let Ok(entries) = std::fs::read_dir(dir) {
1007        for entry in entries.flatten() {
1008            if entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(false) {
1009                continue;
1010            }
1011            let path = entry.path();
1012            if path.is_dir() {
1013                let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1014                if matches!(
1015                    name,
1016                    "vendor" | ".git" | "node_modules" | ".cache" | ".pnpm-store"
1017                ) {
1018                    continue;
1019                }
1020                collect_php_files(&path, out);
1021            } else if path.extension().and_then(|e| e.to_str()) == Some("php") {
1022                out.push(path);
1023            }
1024        }
1025    }
1026}
1027
1028// ---------------------------------------------------------------------------
1029// FQCN reference walk — collects every class-name reference reachable from a
1030// ClassLike's signature surface. Used by lazy_load_missing_classes to chase
1031// transitive vendor types.
1032// ---------------------------------------------------------------------------
1033
1034pub(crate) fn collect_class_referenced_fqcns(class: &crate::db::ClassLike, out: &mut Vec<String>) {
1035    if let Some(p) = class.parent() {
1036        out.push(p.to_string());
1037    }
1038    for i in class.interfaces() {
1039        out.push(i.to_string());
1040    }
1041    for e in class.extends() {
1042        out.push(e.to_string());
1043    }
1044    for t in class.class_traits() {
1045        out.push(t.to_string());
1046    }
1047    for m in class.mixins() {
1048        out.push(m.to_string());
1049    }
1050    for u in class.extends_type_args() {
1051        collect_fqcns_in_union(u, out);
1052    }
1053    for (iface, args) in class.implements_type_args() {
1054        out.push(iface.to_string());
1055        for u in args {
1056            collect_fqcns_in_union(u, out);
1057        }
1058    }
1059    for (_, m) in class.own_methods().iter() {
1060        for p in m.params.iter() {
1061            if let Some(t) = &p.ty {
1062                collect_fqcns_in_union(t, out);
1063            }
1064        }
1065        if let Some(t) = &m.return_type {
1066            collect_fqcns_in_union(t, out);
1067        }
1068        for thrown in m.throws.iter() {
1069            out.push(thrown.to_string());
1070        }
1071    }
1072    if let Some(props) = class.own_properties() {
1073        for (_, p) in props.iter() {
1074            if let Some(t) = &p.ty {
1075                collect_fqcns_in_union(t, out);
1076            }
1077        }
1078    }
1079    for (_, c) in class.own_constants().iter() {
1080        collect_fqcns_in_union(&c.ty, out);
1081    }
1082}
1083
1084pub(crate) fn collect_fqcns_in_union(u: &Type, out: &mut Vec<String>) {
1085    for atom in u.types.iter() {
1086        collect_fqcns_in_atomic(atom, out);
1087    }
1088}
1089
1090fn collect_fqcns_in_simple(t: &mir_types::compact::SimpleType, out: &mut Vec<String>) {
1091    if let mir_types::compact::SimpleType::Complex(u) = t {
1092        collect_fqcns_in_union(u, out);
1093    }
1094}
1095
1096pub(crate) fn collect_fqcns_in_atomic(a: &Atomic, out: &mut Vec<String>) {
1097    match a {
1098        Atomic::TNamedObject { fqcn, type_params } => {
1099            out.push(fqcn.to_string());
1100            for tp in type_params.iter() {
1101                collect_fqcns_in_union(tp, out);
1102            }
1103        }
1104        Atomic::TStaticObject { fqcn } | Atomic::TSelf { fqcn } | Atomic::TParent { fqcn } => {
1105            out.push(fqcn.to_string());
1106        }
1107        Atomic::TLiteralEnumCase { enum_fqcn, .. } => {
1108            out.push(enum_fqcn.to_string());
1109        }
1110        Atomic::TClassString(Some(s)) => {
1111            out.push(s.to_string());
1112        }
1113        Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
1114            collect_fqcns_in_union(key, out);
1115            collect_fqcns_in_union(value, out);
1116        }
1117        Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
1118            collect_fqcns_in_union(value, out);
1119        }
1120        Atomic::TKeyedArray { properties, .. } => {
1121            for (_, kp) in properties.iter() {
1122                collect_fqcns_in_union(&kp.ty, out);
1123            }
1124        }
1125        Atomic::TClosure {
1126            params,
1127            return_type,
1128            this_type,
1129        } => {
1130            for p in params {
1131                if let Some(t) = &p.ty {
1132                    collect_fqcns_in_simple(t, out);
1133                }
1134            }
1135            collect_fqcns_in_union(return_type, out);
1136            if let Some(t) = this_type {
1137                collect_fqcns_in_union(t, out);
1138            }
1139        }
1140        Atomic::TCallable {
1141            params,
1142            return_type,
1143        } => {
1144            if let Some(ps) = params {
1145                for p in ps {
1146                    if let Some(t) = &p.ty {
1147                        collect_fqcns_in_simple(t, out);
1148                    }
1149                }
1150            }
1151            if let Some(rt) = return_type {
1152                collect_fqcns_in_union(rt, out);
1153            }
1154        }
1155        Atomic::TIntersection { parts } => {
1156            for p in parts.iter() {
1157                collect_fqcns_in_union(p, out);
1158            }
1159        }
1160        Atomic::TConditional {
1161            param_name: _,
1162            subject,
1163            if_true,
1164            if_false,
1165        } => {
1166            collect_fqcns_in_union(subject, out);
1167            collect_fqcns_in_union(if_true, out);
1168            collect_fqcns_in_union(if_false, out);
1169        }
1170        Atomic::TTemplateParam { as_type, .. } => {
1171            collect_fqcns_in_union(as_type, out);
1172        }
1173        _ => {}
1174    }
1175}
1176
1177fn build_reverse_deps(db: &dyn crate::db::MirDatabase) -> HashMap<String, HashSet<String>> {
1178    let mut reverse: HashMap<String, HashSet<String>> = HashMap::default();
1179
1180    let mut add_edge = |symbol: &str, dependent_file: &str| {
1181        if let Some(defining_file) = db.symbol_defining_file(symbol) {
1182            let def = defining_file.as_ref().to_string();
1183            if def != dependent_file {
1184                reverse
1185                    .entry(def)
1186                    .or_default()
1187                    .insert(dependent_file.to_string());
1188            }
1189        }
1190    };
1191
1192    for (file, imports) in db.file_import_snapshots() {
1193        let file = file.as_ref().to_string();
1194        for fqcn in imports.values() {
1195            add_edge(fqcn.as_str(), &file);
1196        }
1197    }
1198
1199    let extract_named_objects = |union: &mir_types::Type| {
1200        union
1201            .types
1202            .iter()
1203            .filter_map(|atomic| match atomic {
1204                mir_types::atomic::Atomic::TNamedObject { fqcn, .. } => Some(*fqcn),
1205                _ => None,
1206            })
1207            .collect::<Vec<_>>()
1208    };
1209
1210    for fqcn in crate::db::workspace_classes(db).iter() {
1211        let here = crate::db::Fqcn::from_str(db, fqcn.as_ref());
1212        let Some(class) = crate::db::find_class_like(db, here) else {
1213            continue;
1214        };
1215        if class.is_interface() || class.is_trait() || class.is_enum() {
1216            continue;
1217        }
1218        let Some(file) = db
1219            .symbol_defining_file(fqcn.as_ref())
1220            .map(|f| f.as_ref().to_string())
1221            .or_else(|| class.location().map(|l| l.file.as_ref().to_string()))
1222        else {
1223            continue;
1224        };
1225
1226        if let Some(parent) = class.parent() {
1227            add_edge(parent.as_ref(), &file);
1228        }
1229        for iface in class.interfaces().iter() {
1230            add_edge(iface.as_ref(), &file);
1231        }
1232        for tr in class.class_traits().iter() {
1233            add_edge(tr.as_ref(), &file);
1234        }
1235        if let Some(props) = class.own_properties() {
1236            for (_, p) in props.iter() {
1237                if let Some(ty) = &p.ty {
1238                    for named in extract_named_objects(ty) {
1239                        add_edge(named.as_ref(), &file);
1240                    }
1241                }
1242            }
1243        }
1244        for (_, method) in class.own_methods().iter() {
1245            for param in method.params.iter() {
1246                if let Some(ty) = &param.ty {
1247                    for named in extract_named_objects(ty.as_ref()) {
1248                        add_edge(named.as_ref(), &file);
1249                    }
1250                }
1251            }
1252            if let Some(rt) = method.return_type.as_deref() {
1253                for named in extract_named_objects(rt) {
1254                    add_edge(named.as_ref(), &file);
1255                }
1256            }
1257        }
1258    }
1259
1260    for fqn in crate::db::workspace_functions(db).iter() {
1261        let here = crate::db::Fqcn::from_str(db, fqn.as_ref());
1262        let Some(f) = crate::db::find_function(db, here) else {
1263            continue;
1264        };
1265        let Some(file) = db
1266            .symbol_defining_file(fqn.as_ref())
1267            .map(|f| f.as_ref().to_string())
1268            .or_else(|| f.location.as_ref().map(|l| l.file.as_ref().to_string()))
1269        else {
1270            continue;
1271        };
1272
1273        for param in f.params.iter() {
1274            if let Some(ty) = &param.ty {
1275                for named in extract_named_objects(ty.as_ref()) {
1276                    add_edge(named.as_ref(), &file);
1277                }
1278            }
1279        }
1280        if let Some(rt) = f.return_type.as_deref() {
1281            for named in extract_named_objects(rt) {
1282                add_edge(named.as_ref(), &file);
1283            }
1284        }
1285    }
1286
1287    for (ref_file, symbol_key) in db.all_reference_location_pairs() {
1288        let file_str = ref_file.as_ref().to_string();
1289        let lookup: &str = match symbol_key.split_once("::") {
1290            Some((class, _)) => class,
1291            None => &symbol_key,
1292        };
1293        add_edge(lookup, &file_str);
1294    }
1295
1296    reverse
1297}
1298
1299fn extract_reference_locations(
1300    db: &dyn crate::db::MirDatabase,
1301    file: &Arc<str>,
1302) -> Vec<(String, u32, u16, u16)> {
1303    db.extract_file_reference_locations(file.as_ref())
1304        .into_iter()
1305        .map(|(sym, line, col_start, col_end)| (sym.to_string(), line, col_start, col_end))
1306        .collect()
1307}
1308
1309pub struct AnalysisResult {
1310    pub issues: Vec<Issue>,
1311    #[doc(hidden)]
1312    pub type_envs: rustc_hash::FxHashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
1313    /// Per-expression resolved symbols from body analysis, sorted by file path.
1314    pub symbols: Vec<crate::symbol::ResolvedSymbol>,
1315    /// Maps each file path to the contiguous range within `symbols` that
1316    /// belongs to it.
1317    symbols_by_file: HashMap<Arc<str>, std::ops::Range<usize>>,
1318}
1319
1320impl AnalysisResult {
1321    fn build(
1322        issues: Vec<Issue>,
1323        type_envs: rustc_hash::FxHashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
1324        mut symbols: Vec<crate::symbol::ResolvedSymbol>,
1325    ) -> Self {
1326        symbols.sort_unstable_by(|a, b| a.file.as_ref().cmp(b.file.as_ref()));
1327        let mut symbols_by_file: HashMap<Arc<str>, std::ops::Range<usize>> = HashMap::default();
1328        let mut i = 0;
1329        while i < symbols.len() {
1330            let file = Arc::clone(&symbols[i].file);
1331            let start = i;
1332            while i < symbols.len() && symbols[i].file == file {
1333                i += 1;
1334            }
1335            symbols_by_file.insert(file, start..i);
1336        }
1337        Self {
1338            issues,
1339            type_envs,
1340            symbols,
1341            symbols_by_file,
1342        }
1343    }
1344
1345    pub fn error_count(&self) -> usize {
1346        self.issues
1347            .iter()
1348            .filter(|i| i.severity == mir_issues::Severity::Error)
1349            .count()
1350    }
1351
1352    pub fn warning_count(&self) -> usize {
1353        self.issues
1354            .iter()
1355            .filter(|i| i.severity == mir_issues::Severity::Warning)
1356            .count()
1357    }
1358
1359    pub fn issues_by_file(&self) -> HashMap<Arc<str>, Vec<&Issue>> {
1360        let mut map: HashMap<Arc<str>, Vec<&Issue>> = HashMap::default();
1361        for issue in &self.issues {
1362            map.entry(issue.location.file.clone())
1363                .or_default()
1364                .push(issue);
1365        }
1366        map
1367    }
1368
1369    pub fn count_by_severity(&self) -> Vec<(mir_issues::Severity, usize)> {
1370        let mut counts: std::collections::BTreeMap<mir_issues::Severity, usize> =
1371            std::collections::BTreeMap::new();
1372        for issue in &self.issues {
1373            *counts.entry(issue.severity).or_insert(0) += 1;
1374        }
1375        counts.into_iter().collect()
1376    }
1377
1378    pub fn total_issue_count(&self) -> usize {
1379        self.issues.len()
1380    }
1381
1382    pub fn filter_issues<'a, F>(&'a self, predicate: F) -> impl Iterator<Item = &'a Issue>
1383    where
1384        F: Fn(&Issue) -> bool + 'a,
1385    {
1386        self.issues.iter().filter(move |i| predicate(i))
1387    }
1388
1389    pub fn symbol_at(
1390        &self,
1391        file: &str,
1392        byte_offset: u32,
1393    ) -> Option<&crate::symbol::ResolvedSymbol> {
1394        let range = self.symbols_by_file.get(file)?;
1395        self.symbols[range.clone()]
1396            .iter()
1397            .filter(|s| s.span.start <= byte_offset && byte_offset < s.span.end)
1398            .min_by_key(|s| s.span.end - s.span.start)
1399    }
1400}