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