Skip to main content

mir_analyzer/
lib.rs

1use rustc_hash::FxHashMap;
2use std::sync::{Arc, OnceLock};
3
4pub(crate) mod analyzer_db;
5pub(crate) mod attributes;
6pub mod batch;
7pub(crate) mod body_analysis;
8#[doc(hidden)]
9pub mod cache;
10pub(crate) mod call;
11pub(crate) mod class;
12pub(crate) mod collector;
13pub(crate) mod contradiction;
14#[doc(hidden)]
15pub mod db;
16pub(crate) mod dead_code;
17pub(crate) mod diagnostics;
18pub(crate) mod expr;
19pub mod file_analyzer;
20pub(crate) mod flow_state;
21pub(crate) mod generic;
22pub mod indexing;
23#[doc(hidden)]
24pub mod metrics;
25pub(crate) mod narrowing;
26#[doc(hidden)]
27pub mod parse_cache;
28#[doc(hidden)]
29pub mod parser;
30#[doc(hidden)]
31pub mod perf_fixture;
32pub mod php_version;
33pub mod prelude;
34pub(crate) mod reference_key;
35pub mod session;
36pub mod source_provider;
37pub(crate) mod stmt;
38#[doc(hidden)]
39pub mod stub_cache;
40#[doc(hidden)]
41pub mod stubs;
42pub(crate) mod subtype;
43pub mod suppression;
44pub(crate) mod taint;
45pub(crate) mod type_env;
46pub(crate) mod util;
47
48pub use batch::{
49    analyze_source, analyze_source_with_options, dead_code_issue_kinds, discover_files,
50    AnalysisResult, BatchOptions,
51};
52pub use file_analyzer::{FileAnalysis, FileAnalyzer};
53pub use indexing::{IndexBatchOutcome, IndexCancel, IndexParallelism};
54pub use parser::type_from_hint::type_from_hint;
55pub use parser::{DocblockParser, ParsedDocblock};
56pub use php_version::{ParsePhpVersionError, PhpVersion};
57pub use session::{AnalysisSession, ReferenceIncludes, SubtypeClassSite};
58pub use source_provider::{FsSourceProvider, SourceProvider};
59
60/// Returns `Some((used, canonical))` when `written` and `canonical` FQCNs differ only in casing.
61/// Uses the short (last-segment) form when only the final segment is wrong and the namespace
62/// prefix is already correct; otherwise returns the full path so the mismatch is visible.
63pub(crate) fn fqcn_case_mismatch(written: &str, canonical: &str) -> Option<(String, String)> {
64    let w = written.trim_start_matches('\\');
65    let c = canonical.trim_start_matches('\\');
66    if w == c || !w.eq_ignore_ascii_case(c) {
67        return None;
68    }
69    let w_last = w.rsplit('\\').next().unwrap_or(w);
70    let c_last = c.rsplit('\\').next().unwrap_or(c);
71    if w_last != c_last {
72        let w_prefix = w.rsplit_once('\\').map_or("", |(p, _)| p);
73        let c_prefix = c.rsplit_once('\\').map_or("", |(p, _)| p);
74        if w_prefix == c_prefix {
75            return Some((w_last.to_string(), c_last.to_string()));
76        }
77    }
78    Some((w.to_string(), c.to_string()))
79}
80pub use stubs::{
81    is_builtin_constant, is_builtin_function, stub_files, stub_path_for_class,
82    ChainedClassResolver, StubClassResolver, StubVfs,
83};
84
85// ============================================================================
86// Analysis entry points
87// ============================================================================
88//
89// `AnalysisSession` is the single analysis engine. It supports two usage modes:
90//
91// - Batch (CLI, CI, bulk analysis): use `analyze_paths` / `BatchOptions` to
92//   run definition collection and body analysis over many files in parallel.
93//
94// - Incremental (LSP, watch mode): ingest files as they change; per-file
95//   diagnostics come from `FileAnalyzer::analyze_diagnostics_only`, and
96//   cursor navigation comes from `AnalysisSession::{symbol_at,hover_at,definition_at}`.
97//   Builder-style configuration (`with_cache`, `with_psr4`, …).
98//
99// The two phases of analysis are:
100//   1. Definition collection — discovers classes, functions, constants in a
101//      file and registers them in the salsa database.
102//   2. Body analysis (`BodyAnalyzer`) — walks function/method bodies,
103//      inferring types and emitting issues.
104
105/// A position in source code: 1-based line, 0-based codepoint column.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
107pub struct Position {
108    pub line: u32,
109    pub column: u32,
110}
111
112/// A range in source code: start and end positions.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
114pub struct Range {
115    pub start: Position,
116    pub end: Position,
117}
118
119/// A semantic identifier for a code entity that the analyzer can resolve.
120///
121/// Replaces the previous stringly-typed `&str` keys. Method names are
122/// normalized (lowercased) at construction since PHP method dispatch is
123/// case-insensitive — this prevents a class of correctness bugs where
124/// callers pass mixed-case names and get empty results.
125#[derive(Debug, Clone, PartialEq, Eq, Hash)]
126pub enum Name {
127    /// A class, interface, trait, or enum (FQCN).
128    Class(std::sync::Arc<str>),
129    /// A global function (FQN).
130    Function(std::sync::Arc<str>),
131    /// An instance or static method.
132    Method {
133        class: std::sync::Arc<str>,
134        name: std::sync::Arc<str>,
135    },
136    /// A class property.
137    Property {
138        class: std::sync::Arc<str>,
139        name: std::sync::Arc<str>,
140    },
141    /// A class / interface / enum constant.
142    ClassConstant {
143        class: std::sync::Arc<str>,
144        name: std::sync::Arc<str>,
145    },
146    /// A global constant.
147    GlobalConstant(std::sync::Arc<str>),
148}
149
150impl Name {
151    /// Construct a method symbol. Normalizes `name` to lowercase since PHP
152    /// methods are case-insensitive.
153    pub fn method(class: impl Into<std::sync::Arc<str>>, name: &str) -> Self {
154        Name::Method {
155            class: class.into(),
156            name: std::sync::Arc::from(name.to_ascii_lowercase()),
157        }
158    }
159
160    /// Construct a class symbol.
161    pub fn class(fqcn: impl Into<std::sync::Arc<str>>) -> Self {
162        Name::Class(fqcn.into())
163    }
164
165    /// Construct a function symbol.
166    pub fn function(fqn: impl Into<std::sync::Arc<str>>) -> Self {
167        Name::Function(fqn.into())
168    }
169
170    /// Construct a property symbol.
171    pub fn property(
172        class: impl Into<std::sync::Arc<str>>,
173        name: impl Into<std::sync::Arc<str>>,
174    ) -> Self {
175        Name::Property {
176            class: class.into(),
177            name: name.into(),
178        }
179    }
180
181    /// Construct a class constant symbol.
182    pub fn class_constant(
183        class: impl Into<std::sync::Arc<str>>,
184        name: impl Into<std::sync::Arc<str>>,
185    ) -> Self {
186        Name::ClassConstant {
187            class: class.into(),
188            name: name.into(),
189        }
190    }
191
192    /// Construct a global constant symbol.
193    pub fn global_constant(fqn: impl Into<std::sync::Arc<str>>) -> Self {
194        Name::GlobalConstant(fqn.into())
195    }
196
197    /// The codebase lookup key for this symbol (used internally for the
198    /// reference-locations index). Stable across releases.
199    ///
200    /// Kind-prefixed (`cls:`, `fn:`, `meth:`, `prop:`, `cnst:`, `gcnst:`) so
201    /// that a method, property, and class constant sharing the same class and
202    /// name (e.g. `Foo::bar` as both a property and a method) never collide
203    /// on the same reference-index entry — an unprefixed scheme merged their
204    /// usages, which both scrambled `references_to` results and hid truly
205    /// dead members behind an unrelated same-named symbol's usage.
206    pub fn codebase_key(&self) -> String {
207        match self {
208            Name::Class(fqcn) => format!("cls:{fqcn}"),
209            Name::Function(fqn) => format!("fn:{fqn}"),
210            Name::Method { class, name } => format!("meth:{class}::{name}"),
211            Name::Property { class, name } => format!("prop:{class}::{name}"),
212            Name::ClassConstant { class, name } => format!("cnst:{class}::{name}"),
213            Name::GlobalConstant(fqn) => format!("gcnst:{fqn}"),
214        }
215    }
216}
217
218/// Reduce a reference-index symbol key (as produced by [`Name::codebase_key`])
219/// to the bare class/function/global-constant name that
220/// `MirDatabase::symbol_defining_file` is keyed by.
221///
222/// Strips the kind prefix, then — for member keys (`meth:`/`prop:`/`cnst:`,
223/// shaped `Class::member`) — keeps only the class portion, since a member has
224/// no defining file of its own; only its owning class does.
225pub(crate) fn defining_file_lookup_key(symbol_key: &str) -> &str {
226    let stripped = symbol_key
227        .strip_prefix("meth:")
228        .or_else(|| symbol_key.strip_prefix("prop:"))
229        .or_else(|| symbol_key.strip_prefix("cnst:"))
230        .or_else(|| symbol_key.strip_prefix("cls:"))
231        .or_else(|| symbol_key.strip_prefix("fn:"))
232        .or_else(|| symbol_key.strip_prefix("gcnst:"))
233        .unwrap_or(symbol_key);
234    match stripped.split_once("::") {
235        Some((class, _)) => class,
236        None => stripped,
237    }
238}
239
240/// Reason a symbol lookup did not return a location.
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub enum SymbolLookupError {
243    /// No such symbol exists in the codebase.
244    NotFound,
245    /// The symbol exists but has no recorded source location (e.g. a
246    /// stub-only declaration without a span).
247    NoSourceLocation,
248}
249
250/// Outcome of a [`AnalysisSession::load_class`] attempt.
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub enum LoadOutcome {
253    /// The symbol was already present in the session; no work performed.
254    AlreadyLoaded,
255    /// The symbol was resolved by the configured [`ClassResolver`] and the
256    /// defining file was ingested.
257    Loaded,
258    /// No resolver is configured, the resolver could not map the FQCN to a
259    /// file, or the resolved file could not be read / did not define the
260    /// requested symbol.
261    NotResolvable,
262}
263
264impl LoadOutcome {
265    /// `true` when the symbol is now present in the session (whether it was
266    /// already there or just freshly loaded).
267    pub fn is_loaded(self) -> bool {
268        !matches!(self, LoadOutcome::NotResolvable)
269    }
270}
271
272/// Pluggable strategy for mapping a fully-qualified class name to the file
273/// that should define it. The analyzer never touches `vendor/` or the
274/// filesystem on its own — it asks a `ClassResolver` when a symbol is needed.
275///
276/// `mir_analyzer::Psr4Map` is the built-in implementation for Composer-based
277/// projects. Consumers with non-Composer conventions (WordPress, Drupal, a
278/// custom autoloader, a workspace-walk index) supply their own.
279pub trait ClassResolver: Send + Sync {
280    /// Resolve `fqcn` to the file that defines it. Returning `None` causes
281    /// the analyzer to fall back to emitting `UndefinedClass`.
282    fn resolve(&self, fqcn: &str) -> Option<std::path::PathBuf>;
283}
284
285impl ClassResolver for composer::Psr4Map {
286    fn resolve(&self, fqcn: &str) -> Option<std::path::PathBuf> {
287        composer::Psr4Map::resolve(self, fqcn)
288    }
289}
290
291impl std::fmt::Display for SymbolLookupError {
292    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293        match self {
294            SymbolLookupError::NotFound => write!(f, "symbol not found"),
295            SymbolLookupError::NoSourceLocation => write!(f, "symbol has no source location"),
296        }
297    }
298}
299
300impl std::error::Error for SymbolLookupError {}
301
302/// Hover information for a symbol at a source location.
303/// Includes the inferred type, optional docstring, and location of definition.
304#[derive(Debug, Clone)]
305pub struct HoverInfo {
306    /// Inferred type of the symbol.
307    pub ty: Type,
308    /// Docstring / documentation comment for the symbol (if available).
309    pub docstring: Option<String>,
310    /// Source location of the symbol's definition.
311    pub definition: Option<mir_types::Location>,
312}
313
314/// File dependency graph: tracks which files depend on which other files.
315/// Used for incremental invalidation in LSP servers and build systems.
316#[derive(Debug)]
317pub struct DependencyGraph {
318    files: Vec<Arc<str>>,
319    file_ids: FxHashMap<Arc<str>, u32>,
320    /// Direct dependencies: file id → [file ids it depends on]
321    dependencies: Vec<Vec<u32>>,
322    /// Reverse dependencies: file id → [file ids that depend on it]
323    dependents: Vec<Vec<u32>>,
324    legacy_dependencies: OnceLock<FxHashMap<String, Vec<String>>>,
325    legacy_dependents: OnceLock<FxHashMap<String, Vec<String>>>,
326}
327
328impl Clone for DependencyGraph {
329    fn clone(&self) -> Self {
330        Self {
331            files: self.files.clone(),
332            file_ids: self.file_ids.clone(),
333            dependencies: self.dependencies.clone(),
334            dependents: self.dependents.clone(),
335            legacy_dependencies: OnceLock::new(),
336            legacy_dependents: OnceLock::new(),
337        }
338    }
339}
340
341impl DependencyGraph {
342    pub(crate) fn from_compact_parts(
343        files: Vec<Arc<str>>,
344        file_ids: FxHashMap<Arc<str>, u32>,
345        dependencies: Vec<Vec<u32>>,
346        dependents: Vec<Vec<u32>>,
347    ) -> Self {
348        Self {
349            files,
350            file_ids,
351            dependencies,
352            dependents,
353            legacy_dependencies: OnceLock::new(),
354            legacy_dependents: OnceLock::new(),
355        }
356    }
357
358    /// Number of files tracked by this graph.
359    pub fn file_count(&self) -> usize {
360        self.files.len()
361    }
362
363    /// Number of direct dependency edges.
364    pub fn dependency_edge_count(&self) -> usize {
365        self.dependencies.iter().map(Vec::len).sum()
366    }
367
368    /// Number of reverse dependency edges.
369    pub fn dependent_edge_count(&self) -> usize {
370        self.dependents.iter().map(Vec::len).sum()
371    }
372
373    fn file_path(&self, id: u32) -> &Arc<str> {
374        &self.files[id as usize]
375    }
376
377    fn stringify_adjacency(&self, adjacency: &[Vec<u32>]) -> FxHashMap<String, Vec<String>> {
378        adjacency
379            .iter()
380            .enumerate()
381            .filter(|(_, deps)| !deps.is_empty())
382            .map(|(file_id, deps)| {
383                (
384                    self.files[file_id].to_string(),
385                    deps.iter()
386                        .map(|&dep_id| self.file_path(dep_id).to_string())
387                        .collect(),
388                )
389            })
390            .collect()
391    }
392
393    fn direct_arcs<'a>(
394        &'a self,
395        file: &str,
396        adjacency: &'a [Vec<u32>],
397    ) -> impl Iterator<Item = &'a Arc<str>> {
398        self.file_ids
399            .get(file)
400            .and_then(|&id| adjacency.get(id as usize))
401            .into_iter()
402            .flatten()
403            .map(|&id| self.file_path(id))
404    }
405
406    /// Files that `file` directly depends on, as interned paths.
407    pub fn dependency_paths_of(&self, file: &str) -> Vec<Arc<str>> {
408        self.direct_arcs(file, &self.dependencies)
409            .cloned()
410            .collect()
411    }
412
413    /// Files that directly depend on `file`, as interned paths.
414    pub fn dependent_paths_of(&self, file: &str) -> Vec<Arc<str>> {
415        self.direct_arcs(file, &self.dependents).cloned().collect()
416    }
417
418    /// Files that `file` directly depends on (imports, parent classes, interfaces, traits).
419    pub fn dependencies_of(&self, file: &str) -> &[String] {
420        self.legacy_dependencies
421            .get_or_init(|| self.stringify_adjacency(&self.dependencies))
422            .get(file)
423            .map(|v| v.as_slice())
424            .unwrap_or(&[])
425    }
426
427    /// Files that directly depend on `file` (reverse edge).
428    pub fn dependents_of(&self, file: &str) -> &[String] {
429        self.legacy_dependents
430            .get_or_init(|| self.stringify_adjacency(&self.dependents))
431            .get(file)
432            .map(|v| v.as_slice())
433            .unwrap_or(&[])
434    }
435
436    /// All files transitively depended upon by `file` (including indirect).
437    pub fn transitive_dependencies(&self, file: &str) -> Vec<String> {
438        let mut visited = rustc_hash::FxHashSet::default();
439        let Some(&seed) = self.file_ids.get(file) else {
440            return Vec::new();
441        };
442        let mut queue = vec![seed];
443        visited.insert(seed);
444        let mut result = Vec::new();
445
446        while let Some(current) = queue.pop() {
447            for &dep in &self.dependencies[current as usize] {
448                if visited.insert(dep) {
449                    queue.push(dep);
450                    result.push(self.file_path(dep).to_string());
451                }
452            }
453        }
454        result
455    }
456
457    /// All files that transitively depend on `file` (reverse transitive).
458    pub fn transitive_dependents(&self, file: &str) -> Vec<String> {
459        let mut visited = rustc_hash::FxHashSet::default();
460        let Some(&seed) = self.file_ids.get(file) else {
461            return Vec::new();
462        };
463        let mut queue = vec![seed];
464        visited.insert(seed);
465        let mut result = Vec::new();
466
467        while let Some(current) = queue.pop() {
468            for &dep in &self.dependents[current as usize] {
469                if visited.insert(dep) {
470                    queue.push(dep);
471                    result.push(self.file_path(dep).to_string());
472                }
473            }
474        }
475        result
476    }
477}
478
479pub mod symbol;
480pub use mir_codebase::definitions::{DeclaredParam, FunctionDef, TemplateParam, Visibility};
481pub use mir_issues::{Issue, IssueKind, Severity};
482pub use mir_types::Type;
483
484#[cfg(test)]
485mod dependency_graph_tests {
486    use super::*;
487
488    fn graph(edges: &[(&str, &[&str])]) -> DependencyGraph {
489        let mut files: Vec<Arc<str>> = edges
490            .iter()
491            .flat_map(|(from, deps)| {
492                std::iter::once(*from)
493                    .chain(deps.iter().copied())
494                    .map(Arc::<str>::from)
495            })
496            .collect();
497        files.sort();
498        files.dedup();
499
500        let file_ids: FxHashMap<Arc<str>, u32> = files
501            .iter()
502            .enumerate()
503            .map(|(id, file)| (file.clone(), id as u32))
504            .collect();
505        let mut dependencies = vec![Vec::new(); files.len()];
506        let mut dependents = vec![Vec::new(); files.len()];
507
508        for (from, deps) in edges {
509            let from_id = file_ids[*from];
510            for dep in *deps {
511                let dep_id = file_ids[*dep];
512                dependencies[from_id as usize].push(dep_id);
513                dependents[dep_id as usize].push(from_id);
514            }
515        }
516        for adjacency in [&mut dependencies, &mut dependents] {
517            for deps in adjacency {
518                deps.sort();
519                deps.dedup();
520            }
521        }
522
523        DependencyGraph::from_compact_parts(files, file_ids, dependencies, dependents)
524    }
525
526    #[test]
527    fn transitive_traversal_deduplicates_diamond_graphs() {
528        let graph = graph(&[
529            ("a.php", &["b.php", "c.php"]),
530            ("b.php", &["d.php"]),
531            ("c.php", &["d.php"]),
532        ]);
533
534        let deps = graph.transitive_dependencies("a.php");
535        assert_eq!(
536            deps.iter().filter(|path| path.as_str() == "d.php").count(),
537            1
538        );
539        assert_eq!(deps.len(), 3);
540
541        let dependents = graph.transitive_dependents("d.php");
542        assert_eq!(
543            dependents
544                .iter()
545                .filter(|path| path.as_str() == "a.php")
546                .count(),
547            1
548        );
549        assert_eq!(dependents.len(), 3);
550    }
551
552    #[test]
553    fn direct_arc_accessors_do_not_require_legacy_string_maps() {
554        let graph = graph(&[("consumer.php", &["service.php"])]);
555
556        let deps = graph.dependency_paths_of("consumer.php");
557        assert_eq!(deps, vec![Arc::<str>::from("service.php")]);
558        assert!(graph.legacy_dependencies.get().is_none());
559
560        let dependents = graph.dependent_paths_of("service.php");
561        assert_eq!(dependents, vec![Arc::<str>::from("consumer.php")]);
562        assert!(graph.legacy_dependents.get().is_none());
563
564        assert_eq!(
565            graph.dependencies_of("consumer.php"),
566            &["service.php".to_string()]
567        );
568        assert!(graph.legacy_dependencies.get().is_some());
569    }
570}
571
572/// Convert a parser [`php_ast::Span`] (byte-offset range) into a
573/// [`mir_types::Location`] (file path + 1-based line range +
574/// 0-based codepoint columns) using `source` and the parser's `source_map`.
575///
576/// This is the canonical way for consumers to translate body-analysis result spans
577/// (e.g. [`crate::symbol::ResolvedSymbol::span`]) into source locations they
578/// can hand to their own protocol layer. Consumers that need different
579/// position semantics (LSP UTF-16 code units, byte offsets, etc.) translate
580/// from this `Location` rather than re-implementing the column math.
581pub fn location_from_span(
582    span: php_ast::Span,
583    file: std::sync::Arc<str>,
584    source: &str,
585    source_map: &php_rs_parser::source_map::SourceMap,
586) -> mir_types::Location {
587    let (line, col_start) = diagnostics::offset_to_line_col(source, span.start, source_map);
588    let (line_end, col_end) = if span.start < span.end {
589        diagnostics::offset_to_line_col(source, span.end, source_map)
590    } else {
591        (line, col_start)
592    };
593    mir_types::Location {
594        file,
595        line,
596        line_end,
597        col_start,
598        col_end: diagnostics::clamp_col_end(line, line_end, col_start, col_end),
599    }
600}
601pub use symbol::{DeclarationKind, DocumentSymbol, ReferenceKind, ResolvedSymbol};
602
603pub mod composer;
604pub use composer::{ComposerError, Psr4Map};
605pub use type_env::ScopeId;
606
607#[doc(hidden)]
608pub mod test_utils;