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