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