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