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