Skip to main content

mir_analyzer/
project.rs

1/// Project-level orchestration: file discovery, pass 1, pass 2.
2use std::mem::ManuallyDrop;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use parking_lot::Mutex;
7
8use rayon::prelude::*;
9
10use std::collections::{HashMap, HashSet};
11
12use crate::cache::{hash_content, AnalysisCache};
13use crate::db::{
14    collect_file_definitions, collect_file_definitions_uncached, FileDefinitions, MirDatabase,
15    MirDb, SourceFile,
16};
17use crate::pass2::Pass2Driver;
18use crate::php_version::PhpVersion;
19use crate::shared_db::SharedDb;
20use mir_issues::Issue;
21use mir_types::Union;
22use salsa::Setter as _;
23
24pub(crate) use crate::pass2::merge_return_types;
25
26/// Batch-oriented analyzer: file discovery, parsing, and analysis.
27///
28/// ProjectAnalyzer is the primary entry point for analyzing a project as a whole.
29/// It orchestrates parallel file discovery and parsing, using the same core
30/// analysis engine as [`AnalysisSession`] (salsa database and Pass 2 driver).
31///
32/// **Unified Design:** ProjectAnalyzer and `AnalysisSession` now share the same
33/// database management via [`SharedDb`]. ProjectAnalyzer is the batch API
34/// (all files at once), while `AnalysisSession` is the incremental API (file-by-file).
35/// Both use `Pass2Driver`, the same definition collection logic, and identical
36/// database operations, eliminating code duplication.
37///
38/// [`AnalysisSession`]: crate::session::AnalysisSession
39pub struct ProjectAnalyzer {
40    /// Shared database management (salsa, file registry, stub tracking).
41    /// Extracted to allow code sharing with AnalysisSession.
42    shared_db: Arc<SharedDb>,
43    /// Optional cache — when `Some`, Pass 2 results are read/written per file.
44    cache: Option<AnalysisCache>,
45    /// Called once after each file completes Pass 2 (used for progress reporting).
46    pub on_file_done: Option<Arc<dyn Fn() + Send + Sync>>,
47    /// PSR-4 autoloader mapping from composer.json, if available.
48    pub psr4: Option<Arc<crate::composer::Psr4Map>>,
49    /// When true, run dead code detection at the end of analysis.
50    pub find_dead_code: bool,
51    /// Target PHP language version. `None` means "not configured"; resolved to
52    /// `PhpVersion::LATEST` when passed down to `StatementsAnalyzer`.
53    pub php_version: Option<PhpVersion>,
54    /// Additional stub files to parse before analysis (absolute paths).
55    pub stub_files: Vec<PathBuf>,
56    /// Additional stub directories to walk and parse before analysis (absolute paths).
57    pub stub_dirs: Vec<PathBuf>,
58}
59
60struct ParsedProjectFile {
61    file: Arc<str>,
62    source: Arc<str>,
63    parsed: ManuallyDrop<php_rs_parser::ParseResult<'static, 'static>>,
64    arena: ManuallyDrop<Box<bumpalo::Bump>>,
65}
66
67impl ParsedProjectFile {
68    fn new(file: Arc<str>, source: Arc<str>) -> Self {
69        let arena = Box::new(crate::arena::create_parse_arena(source.len()));
70        let parsed = php_rs_parser::parse(&arena, &source);
71        // SAFETY: `parsed` borrows from `arena` and `source`, both owned by this
72        // struct and kept alive until `Drop`. `Drop` manually destroys `parsed`
73        // before releasing either owner, so the widened lifetimes never escape.
74        let parsed = unsafe {
75            std::mem::transmute::<
76                php_rs_parser::ParseResult<'_, '_>,
77                php_rs_parser::ParseResult<'static, 'static>,
78            >(parsed)
79        };
80        Self {
81            file,
82            source,
83            parsed: ManuallyDrop::new(parsed),
84            arena: ManuallyDrop::new(arena),
85        }
86    }
87
88    fn source(&self) -> &str {
89        self.source.as_ref()
90    }
91
92    fn parsed(&self) -> &php_rs_parser::ParseResult<'_, '_> {
93        &self.parsed
94    }
95}
96
97impl Drop for ParsedProjectFile {
98    fn drop(&mut self) {
99        unsafe {
100            ManuallyDrop::drop(&mut self.parsed);
101            ManuallyDrop::drop(&mut self.arena);
102        }
103    }
104}
105
106// SAFETY: after construction the parsed AST and source map are read-only. The
107// bump arena is never mutated again; it only owns backing storage for AST nodes
108// and is dropped after all parallel analysis has completed.
109unsafe impl Send for ParsedProjectFile {}
110unsafe impl Sync for ParsedProjectFile {}
111
112impl ProjectAnalyzer {
113    pub fn new() -> Self {
114        Self {
115            shared_db: Arc::new(SharedDb::new()),
116            cache: None,
117            on_file_done: None,
118            psr4: None,
119            find_dead_code: false,
120            php_version: None,
121            stub_files: Vec::new(),
122            stub_dirs: Vec::new(),
123        }
124    }
125
126    /// Create a `ProjectAnalyzer` with a disk-backed cache stored under `cache_dir`.
127    pub fn with_cache(cache_dir: &Path) -> Self {
128        Self {
129            shared_db: Arc::new(SharedDb::new()),
130            cache: Some(AnalysisCache::open(cache_dir)),
131            on_file_done: None,
132            psr4: None,
133            find_dead_code: false,
134            php_version: None,
135            stub_files: Vec::new(),
136            stub_dirs: Vec::new(),
137        }
138    }
139
140    /// Enable the disk-backed cache for an already-constructed analyzer.
141    pub fn set_cache_dir(&mut self, cache_dir: &Path) {
142        self.cache = Some(AnalysisCache::open(cache_dir));
143    }
144
145    /// Create a `ProjectAnalyzer` from a project root containing `composer.json`.
146    /// Returns the analyzer (with `psr4` set) and the `Psr4Map` so callers can
147    /// call `map.project_files()` / `map.vendor_files()`.
148    pub fn from_composer(
149        root: &Path,
150    ) -> Result<(Self, crate::composer::Psr4Map), crate::composer::ComposerError> {
151        let map = crate::composer::Psr4Map::from_composer(root)?;
152        let psr4 = Arc::new(map.clone());
153        let analyzer = Self {
154            shared_db: Arc::new(SharedDb::new()),
155            cache: None,
156            on_file_done: None,
157            psr4: Some(psr4),
158            find_dead_code: false,
159            php_version: None,
160            stub_files: Vec::new(),
161            stub_dirs: Vec::new(),
162        };
163        Ok((analyzer, map))
164    }
165
166    /// Builder method: set the target PHP version.
167    pub fn with_php_version(mut self, version: PhpVersion) -> Self {
168        self.php_version = Some(version);
169        self
170    }
171
172    /// Builder method: enable dead-code detection at the end of analysis.
173    pub fn with_dead_code(mut self, enabled: bool) -> Self {
174        self.find_dead_code = enabled;
175        self
176    }
177
178    /// Builder method: set a progress callback invoked once per analyzed file.
179    pub fn with_progress_callback(mut self, callback: Arc<dyn Fn() + Send + Sync>) -> Self {
180        self.on_file_done = Some(callback);
181        self
182    }
183
184    /// Builder method: add user stub files.
185    pub fn with_stub_files(mut self, files: Vec<PathBuf>) -> Self {
186        self.stub_files = files;
187        self
188    }
189
190    /// Builder method: add user stub directories.
191    pub fn with_stub_dirs(mut self, dirs: Vec<PathBuf>) -> Self {
192        self.stub_dirs = dirs;
193        self
194    }
195
196    /// Builder method: configure a disk-backed cache at the given directory.
197    pub fn with_cache_dir(mut self, cache_dir: &Path) -> Self {
198        self.cache = Some(AnalysisCache::open(cache_dir));
199        self
200    }
201
202    /// Builder method: attach a PSR-4 autoloader map.
203    pub fn with_psr4(mut self, map: Arc<crate::composer::Psr4Map>) -> Self {
204        self.psr4 = Some(map);
205        self
206    }
207
208    /// Resolve the configured PHP version, defaulting to `PhpVersion::LATEST`
209    /// when none has been set.
210    fn resolved_php_version(&self) -> PhpVersion {
211        self.php_version.unwrap_or(PhpVersion::LATEST)
212    }
213
214    fn type_exists(&self, fqcn: &str) -> bool {
215        let db = self.snapshot_db();
216        crate::db::type_exists_via_db(&db, fqcn)
217    }
218
219    /// Returns `true` if a function with `fqn` is registered and active.
220    pub fn contains_function(&self, fqn: &str) -> bool {
221        let db = self.snapshot_db();
222        db.lookup_function_node(fqn).is_some_and(|n| n.active(&db))
223    }
224
225    /// Returns `true` if a class / interface / trait / enum is registered.
226    pub fn contains_class(&self, fqcn: &str) -> bool {
227        let db = self.snapshot_db();
228        db.lookup_class_node(fqcn).is_some_and(|n| n.active(&db))
229    }
230
231    /// Returns `true` if `class` has a method named `name` (case-insensitive).
232    pub fn contains_method(&self, class: &str, name: &str) -> bool {
233        let db = self.snapshot_db();
234        let name_lower = name.to_ascii_lowercase();
235        db.lookup_method_node(class, &name_lower)
236            .is_some_and(|n| n.active(&db))
237    }
238
239    /// Acquire a cheap clone of the salsa db for a read-only query.
240    /// The lock is held only for the duration of the clone, so concurrent
241    /// readers never serialize on each other or on writes longer than the
242    /// clone itself.
243    fn snapshot_db(&self) -> MirDb {
244        self.shared_db.snapshot_db()
245    }
246
247    /// Internal: expose the salsa Mutex for unit tests that need a `&dyn MirDatabase`.
248    #[doc(hidden)]
249    pub fn salsa_db_for_test(
250        &self,
251    ) -> &parking_lot::Mutex<(
252        MirDb,
253        std::collections::HashMap<Arc<str>, crate::db::SourceFile>,
254    )> {
255        &self.shared_db.salsa
256    }
257
258    /// Legacy: look up the source location of a class member by name.
259    ///
260    /// Prefer [`Self::definition_of`] with [`crate::Symbol::method`] etc.
261    #[doc(hidden)]
262    pub fn member_location(
263        &self,
264        fqcn: &str,
265        member_name: &str,
266    ) -> Option<mir_codebase::storage::Location> {
267        let db = self.snapshot_db();
268        crate::db::member_location_via_db(&db, fqcn, member_name)
269    }
270
271    /// Legacy: look up a top-level symbol location.
272    ///
273    /// Prefer [`Self::definition_of`] with [`crate::Symbol`].
274    #[doc(hidden)]
275    pub fn symbol_location(&self, symbol: &str) -> Option<mir_codebase::storage::Location> {
276        let db = self.snapshot_db();
277        db.lookup_class_node(symbol)
278            .filter(|n| n.active(&db))
279            .and_then(|n| n.location(&db))
280            .or_else(|| {
281                db.lookup_function_node(symbol)
282                    .filter(|n| n.active(&db))
283                    .and_then(|n| n.location(&db))
284            })
285    }
286
287    /// Legacy: raw reference locations as `(file, line, col_start, col_end)`.
288    ///
289    /// Prefer [`Self::references_to`] which returns `(Arc<str>, Range)` pairs
290    /// and takes a strongly-typed [`crate::Symbol`].
291    #[doc(hidden)]
292    pub fn reference_locations(&self, symbol: &str) -> Vec<(Arc<str>, u32, u16, u16)> {
293        let db = self.snapshot_db();
294        db.reference_locations(symbol)
295    }
296
297    /// Resolve a symbol to its declaration location.
298    ///
299    /// Mirrors [`crate::AnalysisSession::definition_of`].
300    pub fn definition_of(
301        &self,
302        symbol: &crate::Symbol,
303    ) -> Result<mir_codebase::storage::Location, crate::SymbolLookupError> {
304        let db = self.snapshot_db();
305        match symbol {
306            crate::Symbol::Class(fqcn) => {
307                let node = db
308                    .lookup_class_node(fqcn.as_ref())
309                    .filter(|n| n.active(&db))
310                    .ok_or(crate::SymbolLookupError::NotFound)?;
311                node.location(&db)
312                    .ok_or(crate::SymbolLookupError::NoSourceLocation)
313            }
314            crate::Symbol::Function(fqn) => {
315                let node = db
316                    .lookup_function_node(fqn.as_ref())
317                    .filter(|n| n.active(&db))
318                    .ok_or(crate::SymbolLookupError::NotFound)?;
319                node.location(&db)
320                    .ok_or(crate::SymbolLookupError::NoSourceLocation)
321            }
322            crate::Symbol::Method { class, name }
323            | crate::Symbol::Property { class, name }
324            | crate::Symbol::ClassConstant { class, name } => {
325                crate::db::member_location_via_db(&db, class, name)
326                    .ok_or(crate::SymbolLookupError::NotFound)
327            }
328            crate::Symbol::GlobalConstant(_) => Err(crate::SymbolLookupError::NoSourceLocation),
329        }
330    }
331
332    /// All recorded references to a symbol, as `(file, range)` pairs.
333    ///
334    /// Mirrors [`crate::AnalysisSession::references_to`].
335    pub fn references_to(&self, symbol: &crate::Symbol) -> Vec<(Arc<str>, crate::Range)> {
336        let db = self.snapshot_db();
337        let key = symbol.codebase_key();
338        db.reference_locations(&key)
339            .into_iter()
340            .map(|(file, line, col_start, col_end)| {
341                let range = crate::Range {
342                    start: crate::Position {
343                        line,
344                        column: col_start as u32,
345                    },
346                    end: crate::Position {
347                        line,
348                        column: col_end as u32,
349                    },
350                };
351                (file, range)
352            })
353            .collect()
354    }
355
356    /// Load PHP built-in stubs. Called automatically by `analyze` if not done yet.
357    /// Stubs are filtered against the configured target PHP version (or
358    /// `PhpVersion::LATEST` if none was set).
359    pub fn load_stubs(&self) {
360        let php_version = self.resolved_php_version();
361
362        // Load all built-in stubs for the configured PHP version
363        let paths: Vec<&'static str> = crate::stubs::stub_files().iter().map(|&(p, _)| p).collect();
364        self.shared_db.ingest_stub_paths(&paths, php_version);
365
366        // Load user-configured stubs
367        self.shared_db
368            .ingest_user_stubs(&self.stub_files, &self.stub_dirs);
369    }
370
371    fn collect_and_ingest_source(&self, file: Arc<str>, src: &str) -> FileDefinitions {
372        self.shared_db.collect_and_ingest_file(file, src)
373    }
374
375    /// Run the full analysis pipeline on a set of file paths.
376    pub fn analyze(&self, paths: &[PathBuf]) -> AnalysisResult {
377        let mut all_issues = Vec::new();
378
379        // ---- Load PHP built-in stubs (before Pass 1 so user code can override)
380        self.load_stubs();
381
382        // ---- Pass 1: read files in parallel ----------------------------------
383        let parsed_files: Vec<ParsedProjectFile> = paths
384            .par_iter()
385            .filter_map(|path| match std::fs::read_to_string(path) {
386                Ok(src) => {
387                    let file = Arc::from(path.to_string_lossy().as_ref());
388                    Some(ParsedProjectFile::new(file, Arc::from(src)))
389                }
390                Err(e) => {
391                    eprintln!("Cannot read {}: {}", path.display(), e);
392                    None
393                }
394            })
395            .collect();
396
397        let file_data: Vec<(Arc<str>, Arc<str>)> = parsed_files
398            .iter()
399            .map(|parsed| (parsed.file.clone(), parsed.source.clone()))
400            .collect();
401
402        // ---- Pre-Pass-2 invalidation: evict dependents of changed files ------
403        if let Some(cache) = &self.cache {
404            let changed: Vec<String> = file_data
405                .par_iter()
406                .filter_map(|(f, src)| {
407                    let h = hash_content(src.as_ref());
408                    if cache.get(f, &h).is_none() {
409                        Some(f.to_string())
410                    } else {
411                        None
412                    }
413                })
414                .collect();
415            if !changed.is_empty() {
416                cache.evict_with_dependents(&changed);
417            }
418        }
419
420        // ---- Register Salsa source inputs for incremental follow-up calls ----
421        {
422            let mut guard = self.shared_db.salsa.lock();
423            let (ref mut db, ref mut files) = *guard;
424            for parsed in &parsed_files {
425                match files.get(parsed.file.as_ref()) {
426                    Some(&sf) => {
427                        if sf.text(db).as_ref() != parsed.source() {
428                            sf.set_text(db).to(parsed.source.clone());
429                        }
430                    }
431                    None => {
432                        let file = parsed.file.clone();
433                        let sf = SourceFile::new(db, file.clone(), parsed.source.clone());
434                        files.insert(file, sf);
435                    }
436                }
437            }
438        }
439
440        // ---- Pass 1: definition collection from the already-parsed AST -------
441        let file_defs: Vec<FileDefinitions> = parsed_files
442            .par_iter()
443            .map(|parsed| {
444                let parse_result = parsed.parsed();
445                let mut all_issues: Vec<Issue> = parse_result
446                    .errors
447                    .iter()
448                    .map(|err| {
449                        Issue::new(
450                            mir_issues::IssueKind::ParseError {
451                                message: err.to_string(),
452                            },
453                            mir_issues::Location {
454                                file: parsed.file.clone(),
455                                line: 1,
456                                line_end: 1,
457                                col_start: 0,
458                                col_end: 0,
459                            },
460                        )
461                    })
462                    .collect();
463                let collector = crate::collector::DefinitionCollector::new_for_slice(
464                    parsed.file.clone(),
465                    parsed.source(),
466                    &parse_result.source_map,
467                );
468                let (slice, collector_issues) = collector.collect_slice(&parse_result.program);
469                all_issues.extend(collector_issues);
470                FileDefinitions {
471                    slice: Arc::new(slice),
472                    issues: Arc::new(all_issues),
473                }
474            })
475            .collect();
476
477        let mut files_with_parse_errors: std::collections::HashSet<Arc<str>> =
478            std::collections::HashSet::new();
479        let mut files_needing_inference: std::collections::HashSet<Arc<str>> =
480            std::collections::HashSet::new();
481        {
482            let mut guard = self.shared_db.salsa.lock();
483            let (ref mut db, _) = *guard;
484            for defs in file_defs {
485                for issue in defs.issues.iter() {
486                    if matches!(issue.kind, mir_issues::IssueKind::ParseError { .. }) {
487                        files_with_parse_errors.insert(issue.location.file.clone());
488                    }
489                }
490                if stub_slice_needs_inference(&defs.slice) {
491                    if let Some(file) = defs.slice.file.as_ref() {
492                        files_needing_inference.insert(file.clone());
493                    }
494                }
495                db.ingest_stub_slice(&defs.slice);
496                all_issues.extend(Arc::unwrap_or_clone(defs.issues));
497            }
498        }
499
500        // ---- Lazy-load unknown classes via PSR-4 (issue #50) ----------------
501        if let Some(psr4) = &self.psr4 {
502            self.lazy_load_missing_classes(psr4.clone(), &mut all_issues);
503        }
504
505        // ---- Resolve @psalm-import-type declarations now that all Pass 1
506        // classes (including their `type_aliases`) are populated.
507        // ---- Build reverse dep graph and persist it for the next run ---------
508        if let Some(cache) = &self.cache {
509            let db_snapshot = {
510                let guard = self.shared_db.salsa.lock();
511                guard.0.clone()
512            };
513            let rev = build_reverse_deps(&db_snapshot);
514            cache.set_reverse_deps(rev);
515        }
516
517        // ---- Class-level checks (M11) ----------------------------------------
518        // `class_db` is scoped tightly: it must be dropped before the priming
519        // sweep's `commit_inferred_return_types` call below, otherwise the
520        // setter's `Storage::cancel_others` blocks waiting for this clone's
521        // Arc to drop (strong-count==1 invariant).
522        let analyzed_file_set: std::collections::HashSet<std::sync::Arc<str>> =
523            file_data.iter().map(|(f, _)| f.clone()).collect();
524        {
525            let class_db = {
526                let guard = self.shared_db.salsa.lock();
527                guard.0.clone()
528            };
529            let class_issues =
530                crate::class::ClassAnalyzer::with_files(&class_db, analyzed_file_set, &file_data)
531                    .analyze_all();
532            all_issues.extend(class_issues);
533        }
534
535        // ---- S5-PR10b: clone the salsa db once per parallel sweep so each
536        // rayon worker gets its own clone (Salsa databases are `Send` but
537        // `!Sync`; cloning shares the underlying memoization storage).
538        let db_priming = {
539            let guard = self.shared_db.salsa.lock();
540            guard.0.clone()
541        };
542
543        // ---- Pass 2 priming: populate inferred_return_type for all functions  --
544        // Run a first inference-only sweep so that cross-file inferred return
545        // types are available before the issue-emitting pass below (G6).
546        //
547        // Inferred types are collected into a thread-safe buffer during the
548        // parallel sweep and committed to the Salsa db serially after the sweep
549        // returns. Using `rayon::in_place_scope` ensures all worker threads and
550        // their thread-local Salsa state drop before we commit to the canonical db.
551        let filtered_parsed: Vec<_> = parsed_files
552            .par_iter()
553            .filter(|parsed| {
554                !files_with_parse_errors.contains(&parsed.file)
555                    && files_needing_inference.contains(&parsed.file)
556            })
557            .collect();
558
559        let (functions, methods) =
560            run_inference_sweep(db_priming, filtered_parsed, self.resolved_php_version());
561
562        {
563            let mut guard = self.shared_db.salsa.lock();
564            guard.0.commit_inferred_return_types(functions, methods);
565        }
566
567        let db_main = {
568            let guard = self.shared_db.salsa.lock();
569            guard.0.clone()
570        };
571
572        // ---- Pass 2: analyze function/method bodies in parallel (M14) --------
573        let pass2_results: Vec<(Vec<Issue>, Vec<crate::symbol::ResolvedSymbol>)> = parsed_files
574            .par_iter()
575            .filter(|parsed| !files_with_parse_errors.contains(&parsed.file))
576            .map_with(db_main, |db, parsed| {
577                let driver =
578                    Pass2Driver::new(&*db as &dyn MirDatabase, self.resolved_php_version());
579                let result = if let Some(cache) = &self.cache {
580                    let h = hash_content(parsed.source());
581                    if let Some((cached_issues, ref_locs)) = cache.get(&parsed.file, &h) {
582                        db.replay_reference_locations(parsed.file.clone(), &ref_locs);
583                        (cached_issues, Vec::new())
584                    } else {
585                        let parse_result = parsed.parsed();
586                        let (issues, symbols) = driver.analyze_bodies(
587                            &parse_result.program,
588                            parsed.file.clone(),
589                            parsed.source(),
590                            &parse_result.source_map,
591                        );
592                        let ref_locs = extract_reference_locations(&*db, &parsed.file);
593                        cache.put(&parsed.file, h, issues.clone(), ref_locs);
594                        (issues, symbols)
595                    }
596                } else {
597                    let parse_result = parsed.parsed();
598                    driver.analyze_bodies(
599                        &parse_result.program,
600                        parsed.file.clone(),
601                        parsed.source(),
602                        &parse_result.source_map,
603                    )
604                };
605                if let Some(cb) = &self.on_file_done {
606                    cb();
607                }
608                result
609            })
610            .collect();
611
612        let mut all_symbols = Vec::new();
613        for (issues, symbols) in pass2_results {
614            all_issues.extend(issues);
615            all_symbols.extend(symbols);
616        }
617
618        // ---- Post-Pass-2 lazy loading: FQCNs used without `use` imports ------
619        // FQCNs in function/method bodies aren't visible until Pass 2 runs, so
620        // the pre-Pass-2 lazy load misses them.  We collect UndefinedClass names,
621        // resolve them via PSR-4, load those files, re-finalize, then re-analyze
622        // only the affected files to clear the false positives.
623        if let Some(psr4) = &self.psr4 {
624            self.lazy_load_from_body_issues(
625                psr4.clone(),
626                &file_data,
627                &files_with_parse_errors,
628                &mut all_issues,
629                &mut all_symbols,
630            );
631        }
632
633        // Persist cache hits/misses to disk
634        if let Some(cache) = &self.cache {
635            cache.flush();
636        }
637
638        // ---- Compact the reference index ------------------------------------
639        // ---- Dead-code detection (M18) --------------------------------------
640        if self.find_dead_code {
641            let salsa = self.shared_db.salsa.lock();
642            let dead_code_issues = crate::dead_code::DeadCodeAnalyzer::new(&salsa.0).analyze();
643            drop(salsa);
644            all_issues.extend(dead_code_issues);
645        }
646
647        AnalysisResult::build(all_issues, std::collections::HashMap::new(), all_symbols)
648    }
649
650    fn lazy_load_missing_classes(
651        &self,
652        psr4: Arc<crate::composer::Psr4Map>,
653        all_issues: &mut Vec<Issue>,
654    ) {
655        use std::collections::HashSet;
656
657        let max_depth = 10;
658        let mut loaded: HashSet<String> = HashSet::new();
659
660        for _ in 0..max_depth {
661            let mut to_load: Vec<(String, PathBuf)> = Vec::new();
662
663            let mut try_queue = |fqcn: &str| {
664                if !self.type_exists(fqcn) && !loaded.contains(fqcn) {
665                    if let Some(path) = psr4.resolve(fqcn) {
666                        to_load.push((fqcn.to_string(), path));
667                    }
668                }
669            };
670
671            // Drive the inheritance scan from already-ingested `ClassNode`s.
672            let mut inheritance_candidates = Vec::new();
673            let import_candidates = {
674                let guard = self.shared_db.salsa.lock();
675                let db = &guard.0;
676                for fqcn in db.active_class_node_fqcns() {
677                    let Some(node) = db.lookup_class_node(&fqcn) else {
678                        continue;
679                    };
680                    if node.is_interface(db) {
681                        for parent in node.extends(db).iter() {
682                            inheritance_candidates.push(parent.to_string());
683                        }
684                    } else if node.is_enum(db) {
685                        for iface in node.interfaces(db).iter() {
686                            inheritance_candidates.push(iface.to_string());
687                        }
688                    } else if node.is_trait(db) {
689                        for used in node.traits(db).iter() {
690                            inheritance_candidates.push(used.to_string());
691                        }
692                    } else {
693                        if let Some(parent) = node.parent(db) {
694                            inheritance_candidates.push(parent.to_string());
695                        }
696                        for iface in node.interfaces(db).iter() {
697                            inheritance_candidates.push(iface.to_string());
698                        }
699                    }
700                }
701                db.file_import_snapshots()
702                    .into_iter()
703                    .flat_map(|(_, imports)| imports.into_values())
704                    .collect::<Vec<_>>()
705            };
706            for fqcn in inheritance_candidates {
707                try_queue(&fqcn);
708            }
709
710            // Also lazy-load any type referenced via `use` imports that isn't yet
711            // in the codebase (covers enums and classes used only in type hints or
712            // static calls, which never appear in the inheritance scan above).
713            for fqcn in import_candidates {
714                try_queue(&fqcn);
715            }
716
717            if to_load.is_empty() {
718                break;
719            }
720
721            for (fqcn, path) in to_load {
722                loaded.insert(fqcn);
723                if let Ok(src) = std::fs::read_to_string(&path) {
724                    let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
725                    let defs = self.collect_and_ingest_source(file, &src);
726                    all_issues.extend(Arc::unwrap_or_clone(defs.issues));
727                }
728            }
729        }
730    }
731
732    fn lazy_load_from_body_issues(
733        &self,
734        psr4: Arc<crate::composer::Psr4Map>,
735        file_data: &[(Arc<str>, Arc<str>)],
736        files_with_parse_errors: &HashSet<Arc<str>>,
737        all_issues: &mut Vec<Issue>,
738        all_symbols: &mut Vec<crate::symbol::ResolvedSymbol>,
739    ) {
740        use mir_issues::IssueKind;
741
742        let max_depth = 5;
743        let mut loaded: HashSet<String> = HashSet::new();
744
745        for _ in 0..max_depth {
746            // Deduplicate by FQCN: HashMap prevents loading the same class twice
747            // when multiple files share the same UndefinedClass diagnostic.
748            let mut to_load: HashMap<String, PathBuf> = HashMap::new();
749
750            for issue in all_issues.iter() {
751                if let IssueKind::UndefinedClass { name } = &issue.kind {
752                    if !self.type_exists(name) && !loaded.contains(name) {
753                        if let Some(path) = psr4.resolve(name) {
754                            to_load.entry(name.clone()).or_insert(path);
755                        }
756                    }
757                }
758            }
759
760            if to_load.is_empty() {
761                break;
762            }
763
764            loaded.extend(to_load.keys().cloned());
765
766            for path in to_load.values() {
767                if let Ok(src) = std::fs::read_to_string(path) {
768                    let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
769                    let _ = self.collect_and_ingest_source(file, &src);
770                }
771            }
772
773            // Load inheritance deps of newly-added types and finalize.
774            // This covers e.g. `class Helper extends \App\Base` where Base is
775            // also not in the initial file set.
776            self.lazy_load_missing_classes(psr4.clone(), all_issues);
777
778            // Re-analyze every file that has an UndefinedClass for a type now
779            // present in the codebase — covers both direct and transitive loads.
780            let files_to_reanalyze: HashSet<Arc<str>> = all_issues
781                .iter()
782                .filter_map(|i| {
783                    if let IssueKind::UndefinedClass { name } = &i.kind {
784                        if self.type_exists(name) {
785                            return Some(i.location.file.clone());
786                        }
787                    }
788                    None
789                })
790                .collect();
791
792            if files_to_reanalyze.is_empty() {
793                break;
794            }
795
796            all_issues.retain(|i| !files_to_reanalyze.contains(&i.location.file));
797            all_symbols.retain(|s| !files_to_reanalyze.contains(&s.file));
798
799            // Two-phase reanalysis to avoid the salsa `cancel_others` deadlock:
800            //
801            // Phase 1: parallel inference-only Pass 2 on a cloned db. The
802            //   priming clone is consumed by `gather_inferred_types`, so all
803            //   per-thread db handles are dropped before we touch the canonical
804            //   db.
805            // Phase 1.5: single-threaded commit of the inferred return types.
806            // Phase 2: parallel full Pass 2 emits the actual issues + symbols.
807            //
808            // The previous in-line per-file commit (commit while a `db` clone
809            // was still alive in `map_with`) deadlocked salsa: `cancel_others`
810            // waits for outstanding storage references and the local clone is
811            // exactly one such reference.
812            let sweep: Vec<(Arc<str>, Arc<str>)> = file_data
813                .iter()
814                .filter(|(f, _)| {
815                    !files_with_parse_errors.contains(f) && files_to_reanalyze.contains(f)
816                })
817                .cloned()
818                .collect();
819
820            let (inferred_fns, inferred_methods) = crate::session::gather_inferred_types(
821                {
822                    let guard = self.shared_db.salsa.lock();
823                    guard.0.clone()
824                },
825                &sweep,
826                self.resolved_php_version(),
827            );
828
829            {
830                let mut guard_db = self.shared_db.salsa.lock();
831                guard_db
832                    .0
833                    .commit_inferred_return_types(inferred_fns, inferred_methods);
834            }
835
836            let db_full = {
837                let guard = self.shared_db.salsa.lock();
838                guard.0.clone()
839            };
840
841            let reanalysis: Vec<(Vec<Issue>, Vec<crate::symbol::ResolvedSymbol>)> = file_data
842                .par_iter()
843                .filter(|(f, _)| {
844                    !files_with_parse_errors.contains(f) && files_to_reanalyze.contains(f)
845                })
846                .map_with(db_full, |db, (file, src)| {
847                    let driver =
848                        Pass2Driver::new(&*db as &dyn MirDatabase, self.resolved_php_version());
849                    let arena = crate::arena::create_parse_arena(src.len());
850                    let parsed = php_rs_parser::parse(&arena, src);
851                    driver.analyze_bodies(&parsed.program, file.clone(), src, &parsed.source_map)
852                })
853                .collect();
854
855            for (issues, symbols) in reanalysis {
856                all_issues.extend(issues);
857                all_symbols.extend(symbols);
858            }
859        }
860    }
861
862    /// Re-analyze a single file within the existing codebase.
863    ///
864    /// This is the incremental analysis API for LSP:
865    /// 1. Removes old definitions from this file
866    /// 2. Re-runs Pass 1 (definition collection) on the new content
867    /// 3. Resolves any newly-collected `@psalm-import-type` declarations
868    /// 4. Re-runs Pass 2 (body analysis) on this file
869    /// 5. Returns the analysis result for this file only
870    pub fn re_analyze_file(&self, file_path: &str, new_content: &str) -> AnalysisResult {
871        // Fast path: content unchanged and cache has a valid entry — skip full re-analysis.
872        if let Some(cache) = &self.cache {
873            let h = hash_content(new_content);
874            if let Some((issues, ref_locs)) = cache.get(file_path, &h) {
875                let file: Arc<str> = Arc::from(file_path);
876                let guard = self.shared_db.salsa.lock();
877                guard.0.replay_reference_locations(file, &ref_locs);
878                return AnalysisResult::build(issues, HashMap::new(), Vec::new());
879            }
880        }
881
882        let file: Arc<str> = Arc::from(file_path);
883
884        {
885            let mut guard = self.shared_db.salsa.lock();
886            let (ref mut db, _) = *guard;
887            db.remove_file_definitions(file_path);
888        }
889
890        // --- Salsa-backed Pass 1: memoized parse + definition collection ------
891        let file_defs = {
892            let mut guard = self.shared_db.salsa.lock();
893            let (ref mut db, ref mut files) = *guard;
894            let salsa_file = match files.get(&file) {
895                Some(&sf) => {
896                    sf.set_text(db).to(Arc::from(new_content));
897                    sf
898                }
899                None => {
900                    let sf = SourceFile::new(db, file.clone(), Arc::from(new_content));
901                    files.insert(file.clone(), sf);
902                    sf
903                }
904            };
905            collect_file_definitions(db, salsa_file)
906        };
907
908        let mut all_issues: Vec<Issue> = Arc::unwrap_or_clone(file_defs.issues.clone());
909
910        // --- S2 + Pass 2: hold the Salsa lock for ClassNode upserts and body
911        // analysis so the db reference is live during Pass 2 (S5).
912        let symbols = {
913            let mut guard = self.shared_db.salsa.lock();
914            let (ref mut db, _) = *guard;
915
916            db.ingest_stub_slice(&file_defs.slice);
917
918            // Resolve any newly-collected @psalm-import-type declarations so
919            // Pass 2 reads the imported aliases out of `type_aliases`.
920            // Re-parse in the arena so Pass 2 can walk the AST.
921            let arena = bumpalo::Bump::new();
922            let parsed = php_rs_parser::parse(&arena, new_content);
923
924            if parsed.errors.is_empty() {
925                let db_ref: &dyn MirDatabase = db;
926                let driver = Pass2Driver::new_inference_only(db_ref, self.resolved_php_version());
927                driver.analyze_bodies(
928                    &parsed.program,
929                    file.clone(),
930                    new_content,
931                    &parsed.source_map,
932                );
933                let inferred = driver.take_inferred_types();
934                db.commit_inferred_return_types(inferred.functions, inferred.methods);
935
936                let db_ref: &dyn MirDatabase = db;
937                let driver = Pass2Driver::new(db_ref, self.resolved_php_version());
938                let (body_issues, symbols) = driver.analyze_bodies(
939                    &parsed.program,
940                    file.clone(),
941                    new_content,
942                    &parsed.source_map,
943                );
944                all_issues.extend(body_issues);
945                symbols
946            } else {
947                Vec::new()
948            }
949        };
950
951        if let Some(cache) = &self.cache {
952            let h = hash_content(new_content);
953            cache.evict_with_dependents(&[file_path.to_string()]);
954            let guard = self.shared_db.salsa.lock();
955            let ref_locs = extract_reference_locations(&guard.0, &file);
956            cache.put(file_path, h, all_issues.clone(), ref_locs);
957        }
958
959        AnalysisResult::build(all_issues, HashMap::new(), symbols)
960    }
961
962    /// Analyze a PHP source string without a real file path.
963    /// Useful for tests and LSP single-file mode.
964    pub fn analyze_source(source: &str) -> AnalysisResult {
965        let analyzer = ProjectAnalyzer::new();
966        let file: Arc<str> = Arc::from("<source>");
967        let mut db = MirDb::default();
968        for slice in crate::stubs::builtin_stub_slices_for_version(analyzer.resolved_php_version())
969        {
970            db.ingest_stub_slice(&slice);
971        }
972        let salsa_file = SourceFile::new(&db, file.clone(), Arc::from(source));
973        let file_defs = collect_file_definitions(&db, salsa_file);
974        db.ingest_stub_slice(&file_defs.slice);
975        let mut all_issues = Arc::unwrap_or_clone(file_defs.issues);
976        if all_issues
977            .iter()
978            .any(|issue| matches!(issue.kind, mir_issues::IssueKind::ParseError { .. }))
979        {
980            return AnalysisResult::build(all_issues, std::collections::HashMap::new(), Vec::new());
981        }
982        let mut type_envs = std::collections::HashMap::new();
983        let mut all_symbols = Vec::new();
984        let arena = bumpalo::Bump::new();
985        let result = php_rs_parser::parse(&arena, source);
986
987        let driver = Pass2Driver::new_inference_only(&db, analyzer.resolved_php_version());
988        driver.analyze_bodies(&result.program, file.clone(), source, &result.source_map);
989        let inferred = driver.take_inferred_types();
990        db.commit_inferred_return_types(inferred.functions, inferred.methods);
991
992        let driver = Pass2Driver::new(&db, analyzer.resolved_php_version());
993        all_issues.extend(driver.analyze_bodies_typed(
994            &result.program,
995            file.clone(),
996            source,
997            &result.source_map,
998            &mut type_envs,
999            &mut all_symbols,
1000        ));
1001        AnalysisResult::build(all_issues, type_envs, all_symbols)
1002    }
1003
1004    /// Discover all `.php` files under a directory, recursively.
1005    pub fn discover_files(root: &Path) -> Vec<PathBuf> {
1006        if root.is_file() {
1007            return vec![root.to_path_buf()];
1008        }
1009        let mut files = Vec::new();
1010        collect_php_files(root, &mut files);
1011        files
1012    }
1013
1014    /// Pass 1 only: collect type definitions from `paths` into the codebase without
1015    /// analyzing method bodies or emitting issues. Used to load vendor types.
1016    pub fn collect_types_only(&self, paths: &[PathBuf]) {
1017        let file_data: Vec<(Arc<str>, Arc<str>)> = paths
1018            .par_iter()
1019            .filter_map(|path| {
1020                let src = std::fs::read_to_string(path).ok()?;
1021                Some((
1022                    Arc::from(path.to_string_lossy().as_ref()),
1023                    Arc::<str>::from(src),
1024                ))
1025            })
1026            .collect();
1027
1028        let source_files: Vec<SourceFile> = {
1029            let mut guard = self.shared_db.salsa.lock();
1030            let (ref mut db, ref mut files) = *guard;
1031            file_data
1032                .iter()
1033                .map(|(file, src)| match files.get(file) {
1034                    Some(&sf) => {
1035                        if sf.text(db).as_ref() != src.as_ref() {
1036                            sf.set_text(db).to(src.clone());
1037                        }
1038                        sf
1039                    }
1040                    None => {
1041                        let file = file.clone();
1042                        let sf = SourceFile::new(db, file.clone(), src.clone());
1043                        files.insert(file, sf);
1044                        sf
1045                    }
1046                })
1047                .collect()
1048        };
1049
1050        let db_pass1 = {
1051            let guard = self.shared_db.salsa.lock();
1052            guard.0.clone()
1053        };
1054
1055        let file_defs: Vec<FileDefinitions> = source_files
1056            .par_iter()
1057            .map_with(db_pass1, |db, salsa_file| {
1058                collect_file_definitions_uncached(&*db, *salsa_file)
1059            })
1060            .collect();
1061
1062        let mut guard = self.shared_db.salsa.lock();
1063        let (ref mut db, _) = *guard;
1064        for defs in file_defs {
1065            db.ingest_stub_slice(&defs.slice);
1066        }
1067        drop(guard);
1068
1069        // Print profiling statistics for the collection phase.
1070        crate::collector::print_collector_stats();
1071    }
1072}
1073
1074impl Default for ProjectAnalyzer {
1075    fn default() -> Self {
1076        Self::new()
1077    }
1078}
1079
1080// Helper: Inference sweep with rayon::in_place_scope
1081
1082#[allow(clippy::type_complexity)]
1083fn run_inference_sweep(
1084    db_priming: MirDb,
1085    parsed_files: Vec<&ParsedProjectFile>,
1086    php_version: PhpVersion,
1087) -> (Vec<(Arc<str>, Union)>, Vec<(Arc<str>, Arc<str>, Union)>) {
1088    let functions = Arc::new(Mutex::new(Vec::new()));
1089    let methods = Arc::new(Mutex::new(Vec::new()));
1090
1091    rayon::in_place_scope(|s| {
1092        for parsed in parsed_files {
1093            let db = db_priming.clone();
1094            let functions = Arc::clone(&functions);
1095            let methods = Arc::clone(&methods);
1096
1097            s.spawn(move |_| {
1098                let driver = Pass2Driver::new_inference_only(&db as &dyn MirDatabase, php_version);
1099                let parse_result = parsed.parsed();
1100                driver.analyze_bodies(
1101                    &parse_result.program,
1102                    parsed.file.clone(),
1103                    parsed.source(),
1104                    &parse_result.source_map,
1105                );
1106
1107                let inferred = driver.take_inferred_types();
1108                {
1109                    let mut funcs = functions.lock();
1110                    funcs.extend(inferred.functions);
1111                }
1112                {
1113                    let mut meths = methods.lock();
1114                    meths.extend(inferred.methods);
1115                }
1116            });
1117        }
1118    });
1119
1120    let functions = Arc::try_unwrap(functions)
1121        .map(|mutex| mutex.into_inner())
1122        .unwrap_or_else(|arc| arc.lock().clone());
1123    let methods = Arc::try_unwrap(methods)
1124        .map(|mutex| mutex.into_inner())
1125        .unwrap_or_else(|arc| arc.lock().clone());
1126
1127    (functions, methods)
1128}
1129
1130fn stub_slice_needs_inference(slice: &mir_codebase::storage::StubSlice) -> bool {
1131    slice
1132        .functions
1133        .iter()
1134        .any(|func| func.return_type.is_none())
1135        || slice.classes.iter().any(|class| {
1136            class
1137                .own_methods
1138                .values()
1139                .any(|method| !method.is_abstract && method.return_type.is_none())
1140        })
1141        || slice.traits.iter().any(|tr| {
1142            tr.own_methods
1143                .values()
1144                .any(|method| !method.is_abstract && method.return_type.is_none())
1145        })
1146        || slice.enums.iter().any(|en| {
1147            en.own_methods
1148                .values()
1149                .any(|method| !method.is_abstract && method.return_type.is_none())
1150        })
1151}
1152
1153pub(crate) fn collect_php_files(dir: &Path, out: &mut Vec<PathBuf>) {
1154    if let Ok(entries) = std::fs::read_dir(dir) {
1155        for entry in entries.flatten() {
1156            if entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(false) {
1157                continue;
1158            }
1159            let path = entry.path();
1160            if path.is_dir() {
1161                let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1162                if matches!(
1163                    name,
1164                    "vendor" | ".git" | "node_modules" | ".cache" | ".pnpm-store"
1165                ) {
1166                    continue;
1167                }
1168                collect_php_files(&path, out);
1169            } else if path.extension().and_then(|e| e.to_str()) == Some("php") {
1170                out.push(path);
1171            }
1172        }
1173    }
1174}
1175
1176// build_reverse_deps
1177
1178fn build_reverse_deps(db: &dyn crate::db::MirDatabase) -> HashMap<String, HashSet<String>> {
1179    let mut reverse: HashMap<String, HashSet<String>> = HashMap::new();
1180
1181    let mut add_edge = |symbol: &str, dependent_file: &str| {
1182        if let Some(defining_file) = db.symbol_defining_file(symbol) {
1183            let def = defining_file.as_ref().to_string();
1184            if def != dependent_file {
1185                reverse
1186                    .entry(def)
1187                    .or_default()
1188                    .insert(dependent_file.to_string());
1189            }
1190        }
1191    };
1192
1193    for (file, imports) in db.file_import_snapshots() {
1194        let file = file.as_ref().to_string();
1195        for fqcn in imports.values() {
1196            add_edge(fqcn, &file);
1197        }
1198    }
1199
1200    for fqcn in db.active_class_node_fqcns() {
1201        // Only true classes contribute class-direction edges in this loop.
1202        // Interface / trait / enum edges are not currently emitted here —
1203        // this function only ever read classes.
1204        let kind = match crate::db::class_kind_via_db(db, fqcn.as_ref()) {
1205            Some(k) if !k.is_interface && !k.is_trait && !k.is_enum => k,
1206            _ => continue,
1207        };
1208        let _ = kind;
1209        let Some(file) = db
1210            .symbol_defining_file(fqcn.as_ref())
1211            .map(|f| f.as_ref().to_string())
1212        else {
1213            continue;
1214        };
1215
1216        let Some(node) = db.lookup_class_node(fqcn.as_ref()) else {
1217            continue;
1218        };
1219        if let Some(parent) = node.parent(db) {
1220            add_edge(parent.as_ref(), &file);
1221        }
1222        for iface in node.interfaces(db).iter() {
1223            add_edge(iface.as_ref(), &file);
1224        }
1225        for tr in node.traits(db).iter() {
1226            add_edge(tr.as_ref(), &file);
1227        }
1228    }
1229
1230    reverse
1231}
1232
1233fn extract_reference_locations(
1234    db: &dyn crate::db::MirDatabase,
1235    file: &Arc<str>,
1236) -> Vec<(String, u32, u16, u16)> {
1237    db.extract_file_reference_locations(file.as_ref())
1238        .into_iter()
1239        .map(|(sym, line, col_start, col_end)| (sym.to_string(), line, col_start, col_end))
1240        .collect()
1241}
1242
1243pub struct AnalysisResult {
1244    pub issues: Vec<Issue>,
1245    #[doc(hidden)]
1246    pub type_envs: std::collections::HashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
1247    /// Per-expression resolved symbols from Pass 2, sorted by file path.
1248    pub symbols: Vec<crate::symbol::ResolvedSymbol>,
1249    /// Maps each file path to the contiguous range within `symbols` that belongs
1250    /// to it. Built once after analysis; allows `symbol_at` to scan only the
1251    /// relevant file's slice rather than the entire codebase-wide vector.
1252    symbols_by_file: HashMap<Arc<str>, std::ops::Range<usize>>,
1253}
1254
1255impl AnalysisResult {
1256    fn build(
1257        issues: Vec<Issue>,
1258        type_envs: std::collections::HashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
1259        mut symbols: Vec<crate::symbol::ResolvedSymbol>,
1260    ) -> Self {
1261        symbols.sort_unstable_by(|a, b| a.file.as_ref().cmp(b.file.as_ref()));
1262        let mut symbols_by_file: HashMap<Arc<str>, std::ops::Range<usize>> = HashMap::new();
1263        let mut i = 0;
1264        while i < symbols.len() {
1265            let file = Arc::clone(&symbols[i].file);
1266            let start = i;
1267            while i < symbols.len() && symbols[i].file == file {
1268                i += 1;
1269            }
1270            symbols_by_file.insert(file, start..i);
1271        }
1272        Self {
1273            issues,
1274            type_envs,
1275            symbols,
1276            symbols_by_file,
1277        }
1278    }
1279}
1280
1281impl AnalysisResult {
1282    pub fn error_count(&self) -> usize {
1283        self.issues
1284            .iter()
1285            .filter(|i| i.severity == mir_issues::Severity::Error)
1286            .count()
1287    }
1288
1289    pub fn warning_count(&self) -> usize {
1290        self.issues
1291            .iter()
1292            .filter(|i| i.severity == mir_issues::Severity::Warning)
1293            .count()
1294    }
1295
1296    /// Group issues by source file.
1297    pub fn issues_by_file(&self) -> HashMap<std::sync::Arc<str>, Vec<&Issue>> {
1298        let mut map: HashMap<std::sync::Arc<str>, Vec<&Issue>> = HashMap::new();
1299        for issue in &self.issues {
1300            map.entry(issue.location.file.clone())
1301                .or_default()
1302                .push(issue);
1303        }
1304        map
1305    }
1306
1307    /// Count issues by severity. Returned as `(severity, count)` pairs sorted
1308    /// by severity (Info, Warning, Error).
1309    pub fn count_by_severity(&self) -> Vec<(mir_issues::Severity, usize)> {
1310        let mut counts: std::collections::BTreeMap<mir_issues::Severity, usize> =
1311            std::collections::BTreeMap::new();
1312        for issue in &self.issues {
1313            *counts.entry(issue.severity).or_insert(0) += 1;
1314        }
1315        counts.into_iter().collect()
1316    }
1317
1318    /// Total number of issues across all severities and files.
1319    pub fn total_issue_count(&self) -> usize {
1320        self.issues.len()
1321    }
1322
1323    /// Iterator of issues matching `predicate`. Useful for filtering by
1324    /// severity, kind, or file without materializing intermediate vectors.
1325    pub fn filter_issues<'a, F>(&'a self, predicate: F) -> impl Iterator<Item = &'a Issue>
1326    where
1327        F: Fn(&Issue) -> bool + 'a,
1328    {
1329        self.issues.iter().filter(move |i| predicate(i))
1330    }
1331
1332    /// Return the innermost resolved symbol whose span contains `byte_offset`
1333    /// in `file`, or `None` if no symbol was recorded at that position.
1334    pub fn symbol_at(
1335        &self,
1336        file: &str,
1337        byte_offset: u32,
1338    ) -> Option<&crate::symbol::ResolvedSymbol> {
1339        let range = self.symbols_by_file.get(file)?;
1340        self.symbols[range.clone()]
1341            .iter()
1342            .filter(|s| s.span.start <= byte_offset && byte_offset < s.span.end)
1343            .min_by_key(|s| s.span.end - s.span.start)
1344    }
1345}