Skip to main content

mir_analyzer/
lib.rs

1use rustc_hash::FxHashMap;
2
3pub(crate) mod analyzer_db;
4pub(crate) mod attributes;
5pub mod batch;
6pub(crate) mod body_analysis;
7#[doc(hidden)]
8pub mod cache;
9pub(crate) mod call;
10pub(crate) mod class;
11pub(crate) mod collector;
12pub(crate) mod contradiction;
13#[doc(hidden)]
14pub mod db;
15pub(crate) mod dead_code;
16pub(crate) mod diagnostics;
17pub(crate) mod expr;
18pub mod file_analyzer;
19pub(crate) mod flow_state;
20pub(crate) mod generic;
21pub mod indexing;
22#[doc(hidden)]
23pub mod metrics;
24pub(crate) mod narrowing;
25#[doc(hidden)]
26pub mod parse_cache;
27#[doc(hidden)]
28pub mod parser;
29pub mod php_version;
30pub mod prelude;
31pub mod session;
32pub mod source_provider;
33pub(crate) mod stmt;
34#[doc(hidden)]
35pub mod stub_cache;
36#[doc(hidden)]
37pub mod stubs;
38pub(crate) mod subtype;
39pub mod suppression;
40pub(crate) mod taint;
41pub(crate) mod type_env;
42pub(crate) mod util;
43
44pub use batch::{
45    analyze_source, dead_code_issue_kinds, discover_files, AnalysisResult, BatchOptions,
46};
47pub use file_analyzer::{FileAnalysis, FileAnalyzer};
48pub use indexing::{IndexBatchOutcome, IndexCancel, IndexParallelism};
49pub use parser::type_from_hint::type_from_hint;
50pub use parser::{DocblockParser, ParsedDocblock};
51pub use php_version::{ParsePhpVersionError, PhpVersion};
52pub use session::{AnalysisSession, SubtypeClassSite};
53pub use source_provider::{FsSourceProvider, SourceProvider};
54
55/// Returns `Some((used, canonical))` when `written` and `canonical` FQCNs differ only in casing.
56/// Uses the short (last-segment) form when only the final segment is wrong and the namespace
57/// prefix is already correct; otherwise returns the full path so the mismatch is visible.
58pub(crate) fn fqcn_case_mismatch(written: &str, canonical: &str) -> Option<(String, String)> {
59    let w = written.trim_start_matches('\\');
60    let c = canonical.trim_start_matches('\\');
61    if w == c || !w.eq_ignore_ascii_case(c) {
62        return None;
63    }
64    let w_last = w.rsplit('\\').next().unwrap_or(w);
65    let c_last = c.rsplit('\\').next().unwrap_or(c);
66    if w_last != c_last {
67        let w_prefix = w.rsplit_once('\\').map_or("", |(p, _)| p);
68        let c_prefix = c.rsplit_once('\\').map_or("", |(p, _)| p);
69        if w_prefix == c_prefix {
70            return Some((w_last.to_string(), c_last.to_string()));
71        }
72    }
73    Some((w.to_string(), c.to_string()))
74}
75pub use stubs::{
76    is_builtin_function, stub_files, stub_path_for_class, ChainedClassResolver, StubClassResolver,
77    StubVfs,
78};
79
80// ============================================================================
81// Analysis entry points
82// ============================================================================
83//
84// `AnalysisSession` is the single analysis engine. It supports two usage modes:
85//
86// - Batch (CLI, CI, bulk analysis): use `analyze_paths` / `BatchOptions` to
87//   run definition collection and body analysis over many files in parallel.
88//
89// - Incremental (LSP, watch mode): ingest files as they change; per-file
90//   results come from `FileAnalyzer::analyze`. Builder-style configuration
91//   (`with_cache`, `with_psr4`, …).
92//
93// The two phases of analysis are:
94//   1. Definition collection — discovers classes, functions, constants in a
95//      file and registers them in the salsa database.
96//   2. Body analysis (`BodyAnalyzer`) — walks function/method bodies,
97//      inferring types and emitting issues.
98
99/// A position in source code: 1-based line, 0-based codepoint column.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
101pub struct Position {
102    pub line: u32,
103    pub column: u32,
104}
105
106/// A range in source code: start and end positions.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
108pub struct Range {
109    pub start: Position,
110    pub end: Position,
111}
112
113/// A semantic identifier for a code entity that the analyzer can resolve.
114///
115/// Replaces the previous stringly-typed `&str` keys. Method names are
116/// normalized (lowercased) at construction since PHP method dispatch is
117/// case-insensitive — this prevents a class of correctness bugs where
118/// callers pass mixed-case names and get empty results.
119#[derive(Debug, Clone, PartialEq, Eq, Hash)]
120pub enum Name {
121    /// A class, interface, trait, or enum (FQCN).
122    Class(std::sync::Arc<str>),
123    /// A global function (FQN).
124    Function(std::sync::Arc<str>),
125    /// An instance or static method.
126    Method {
127        class: std::sync::Arc<str>,
128        name: std::sync::Arc<str>,
129    },
130    /// A class property.
131    Property {
132        class: std::sync::Arc<str>,
133        name: std::sync::Arc<str>,
134    },
135    /// A class / interface / enum constant.
136    ClassConstant {
137        class: std::sync::Arc<str>,
138        name: std::sync::Arc<str>,
139    },
140    /// A global constant.
141    GlobalConstant(std::sync::Arc<str>),
142}
143
144impl Name {
145    /// Construct a method symbol. Normalizes `name` to lowercase since PHP
146    /// methods are case-insensitive.
147    pub fn method(class: impl Into<std::sync::Arc<str>>, name: &str) -> Self {
148        Name::Method {
149            class: class.into(),
150            name: std::sync::Arc::from(name.to_ascii_lowercase()),
151        }
152    }
153
154    /// Construct a class symbol.
155    pub fn class(fqcn: impl Into<std::sync::Arc<str>>) -> Self {
156        Name::Class(fqcn.into())
157    }
158
159    /// Construct a function symbol.
160    pub fn function(fqn: impl Into<std::sync::Arc<str>>) -> Self {
161        Name::Function(fqn.into())
162    }
163
164    /// Construct a property symbol.
165    pub fn property(
166        class: impl Into<std::sync::Arc<str>>,
167        name: impl Into<std::sync::Arc<str>>,
168    ) -> Self {
169        Name::Property {
170            class: class.into(),
171            name: name.into(),
172        }
173    }
174
175    /// Construct a class constant symbol.
176    pub fn class_constant(
177        class: impl Into<std::sync::Arc<str>>,
178        name: impl Into<std::sync::Arc<str>>,
179    ) -> Self {
180        Name::ClassConstant {
181            class: class.into(),
182            name: name.into(),
183        }
184    }
185
186    /// Construct a global constant symbol.
187    pub fn global_constant(fqn: impl Into<std::sync::Arc<str>>) -> Self {
188        Name::GlobalConstant(fqn.into())
189    }
190
191    /// The codebase lookup key for this symbol (used internally for the
192    /// reference-locations index). Stable across releases.
193    ///
194    /// Kind-prefixed (`cls:`, `fn:`, `meth:`, `prop:`, `cnst:`, `gcnst:`) so
195    /// that a method, property, and class constant sharing the same class and
196    /// name (e.g. `Foo::bar` as both a property and a method) never collide
197    /// on the same reference-index entry — an unprefixed scheme merged their
198    /// usages, which both scrambled `references_to` results and hid truly
199    /// dead members behind an unrelated same-named symbol's usage.
200    pub fn codebase_key(&self) -> String {
201        match self {
202            Name::Class(fqcn) => format!("cls:{fqcn}"),
203            Name::Function(fqn) => format!("fn:{fqn}"),
204            Name::Method { class, name } => format!("meth:{class}::{name}"),
205            Name::Property { class, name } => format!("prop:{class}::{name}"),
206            Name::ClassConstant { class, name } => format!("cnst:{class}::{name}"),
207            Name::GlobalConstant(fqn) => format!("gcnst:{fqn}"),
208        }
209    }
210}
211
212/// Reduce a reference-index symbol key (as produced by [`Name::codebase_key`])
213/// to the bare class/function/global-constant name that
214/// `MirDatabase::symbol_defining_file` is keyed by.
215///
216/// Strips the kind prefix, then — for member keys (`meth:`/`prop:`/`cnst:`,
217/// shaped `Class::member`) — keeps only the class portion, since a member has
218/// no defining file of its own; only its owning class does.
219pub(crate) fn defining_file_lookup_key(symbol_key: &str) -> &str {
220    let stripped = symbol_key
221        .strip_prefix("meth:")
222        .or_else(|| symbol_key.strip_prefix("prop:"))
223        .or_else(|| symbol_key.strip_prefix("cnst:"))
224        .or_else(|| symbol_key.strip_prefix("cls:"))
225        .or_else(|| symbol_key.strip_prefix("fn:"))
226        .or_else(|| symbol_key.strip_prefix("gcnst:"))
227        .unwrap_or(symbol_key);
228    match stripped.split_once("::") {
229        Some((class, _)) => class,
230        None => stripped,
231    }
232}
233
234/// Reason a symbol lookup did not return a location.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum SymbolLookupError {
237    /// No such symbol exists in the codebase.
238    NotFound,
239    /// The symbol exists but has no recorded source location (e.g. a
240    /// stub-only declaration without a span).
241    NoSourceLocation,
242}
243
244/// Outcome of a [`AnalysisSession::load_class`] attempt.
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum LoadOutcome {
247    /// The symbol was already present in the session; no work performed.
248    AlreadyLoaded,
249    /// The symbol was resolved by the configured [`ClassResolver`] and the
250    /// defining file was ingested.
251    Loaded,
252    /// No resolver is configured, the resolver could not map the FQCN to a
253    /// file, or the resolved file could not be read / did not define the
254    /// requested symbol.
255    NotResolvable,
256}
257
258impl LoadOutcome {
259    /// `true` when the symbol is now present in the session (whether it was
260    /// already there or just freshly loaded).
261    pub fn is_loaded(self) -> bool {
262        !matches!(self, LoadOutcome::NotResolvable)
263    }
264}
265
266/// Pluggable strategy for mapping a fully-qualified class name to the file
267/// that should define it. The analyzer never touches `vendor/` or the
268/// filesystem on its own — it asks a `ClassResolver` when a symbol is needed.
269///
270/// `mir_analyzer::Psr4Map` is the built-in implementation for Composer-based
271/// projects. Consumers with non-Composer conventions (WordPress, Drupal, a
272/// custom autoloader, a workspace-walk index) supply their own.
273pub trait ClassResolver: Send + Sync {
274    /// Resolve `fqcn` to the file that defines it. Returning `None` causes
275    /// the analyzer to fall back to emitting `UndefinedClass`.
276    fn resolve(&self, fqcn: &str) -> Option<std::path::PathBuf>;
277}
278
279impl ClassResolver for composer::Psr4Map {
280    fn resolve(&self, fqcn: &str) -> Option<std::path::PathBuf> {
281        composer::Psr4Map::resolve(self, fqcn)
282    }
283}
284
285impl std::fmt::Display for SymbolLookupError {
286    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287        match self {
288            SymbolLookupError::NotFound => write!(f, "symbol not found"),
289            SymbolLookupError::NoSourceLocation => write!(f, "symbol has no source location"),
290        }
291    }
292}
293
294impl std::error::Error for SymbolLookupError {}
295
296/// Hover information for a symbol at a source location.
297/// Includes the inferred type, optional docstring, and location of definition.
298#[derive(Debug, Clone)]
299pub struct HoverInfo {
300    /// Inferred type of the symbol.
301    pub ty: Type,
302    /// Docstring / documentation comment for the symbol (if available).
303    pub docstring: Option<String>,
304    /// Source location of the symbol's definition.
305    pub definition: Option<mir_types::Location>,
306}
307
308/// File dependency graph: tracks which files depend on which other files.
309/// Used for incremental invalidation in LSP servers and build systems.
310#[derive(Debug, Clone)]
311pub struct DependencyGraph {
312    /// Direct dependencies: file → [files it depends on]
313    dependencies: FxHashMap<String, Vec<String>>,
314    /// Reverse dependencies: file → [files that depend on it]
315    dependents: FxHashMap<String, Vec<String>>,
316}
317
318impl DependencyGraph {
319    /// Files that `file` directly depends on (imports, parent classes, interfaces, traits).
320    pub fn dependencies_of(&self, file: &str) -> &[String] {
321        self.dependencies
322            .get(file)
323            .map(|v| v.as_slice())
324            .unwrap_or(&[])
325    }
326
327    /// Files that directly depend on `file` (reverse edge).
328    pub fn dependents_of(&self, file: &str) -> &[String] {
329        self.dependents
330            .get(file)
331            .map(|v| v.as_slice())
332            .unwrap_or(&[])
333    }
334
335    /// All files transitively depended upon by `file` (including indirect).
336    pub fn transitive_dependencies(&self, file: &str) -> Vec<String> {
337        let mut visited = rustc_hash::FxHashSet::default();
338        let mut queue = vec![file.to_string()];
339        let mut result = Vec::new();
340
341        while let Some(current) = queue.pop() {
342            if !visited.insert(current.clone()) {
343                continue;
344            }
345            for dep in self.dependencies_of(&current) {
346                if !visited.contains(dep) {
347                    queue.push(dep.clone());
348                    result.push(dep.clone());
349                }
350            }
351        }
352        result
353    }
354
355    /// All files that transitively depend on `file` (reverse transitive).
356    pub fn transitive_dependents(&self, file: &str) -> Vec<String> {
357        let mut visited = rustc_hash::FxHashSet::default();
358        let mut queue = vec![file.to_string()];
359        let mut result = Vec::new();
360
361        while let Some(current) = queue.pop() {
362            if !visited.insert(current.clone()) {
363                continue;
364            }
365            for dep in self.dependents_of(&current) {
366                if !visited.contains(dep) {
367                    queue.push(dep.clone());
368                    result.push(dep.clone());
369                }
370            }
371        }
372        result
373    }
374}
375
376pub mod symbol;
377pub use mir_codebase::definitions::{DeclaredParam, TemplateParam, Visibility};
378pub use mir_issues::{Issue, IssueKind, Severity};
379pub use mir_types::Type;
380
381/// Convert a parser [`php_ast::Span`] (byte-offset range) into a
382/// [`mir_types::Location`] (file path + 1-based line range +
383/// 0-based codepoint columns) using `source` and the parser's `source_map`.
384///
385/// This is the canonical way for consumers to translate body-analysis result spans
386/// (e.g. [`crate::symbol::ResolvedSymbol::span`]) into source locations they
387/// can hand to their own protocol layer. Consumers that need different
388/// position semantics (LSP UTF-16 code units, byte offsets, etc.) translate
389/// from this `Location` rather than re-implementing the column math.
390pub fn location_from_span(
391    span: php_ast::Span,
392    file: std::sync::Arc<str>,
393    source: &str,
394    source_map: &php_rs_parser::source_map::SourceMap,
395) -> mir_types::Location {
396    let (line, col_start) = diagnostics::offset_to_line_col(source, span.start, source_map);
397    let (line_end, col_end) = if span.start < span.end {
398        diagnostics::offset_to_line_col(source, span.end, source_map)
399    } else {
400        (line, col_start)
401    };
402    mir_types::Location {
403        file,
404        line,
405        line_end,
406        col_start,
407        col_end: diagnostics::clamp_col_end(line, line_end, col_start, col_end),
408    }
409}
410pub use symbol::{DeclarationKind, DocumentSymbol, ReferenceKind, ResolvedSymbol};
411
412pub mod composer;
413pub use composer::{ComposerError, Psr4Map};
414pub use type_env::ScopeId;
415
416#[doc(hidden)]
417pub mod test_utils;