Skip to main content

mir_plugin/
lib.rs

1//! Plugin API for the mir PHP static analyzer, modeled on Psalm's plugin
2//! event handlers (<https://psalm.dev/docs/running_psalm/plugins/plugins_overview/>).
3//!
4//! A plugin implements [`MirPlugin`], declares which hooks it wants via
5//! [`MirPlugin::hooks`], and is registered into a [`PluginRegistry`] that the
6//! host process installs globally with [`install`]. The analyzer snapshots the
7//! registry once per analysis pass; when no registry is installed every hook
8//! site reduces to a single `Option` check.
9//!
10//! Plugins come in two flavors:
11//! - **Rust plugins** — compiled in (registered directly) or loaded from a
12//!   cdylib at runtime (`dylib` feature, see [`dylib`]).
13//! - **Psalm PHP plugins** — reused through a PHP host subprocess
14//!   (`psalm-bridge` feature, see [`psalm`]).
15
16use std::path::PathBuf;
17use std::sync::Arc;
18
19use parking_lot::RwLock;
20use rustc_hash::FxHashMap;
21
22pub use mir_issues::{Issue, Severity};
23pub use mir_types::Type;
24// Re-exported so plugin crates match AST nodes and build types without
25// pinning the underlying crates themselves.
26pub use mir_types;
27pub use php_ast;
28
29#[cfg(feature = "dylib")]
30pub mod dylib;
31#[cfg(feature = "psalm-bridge")]
32pub mod psalm;
33
34/// Bumped whenever the [`MirPlugin`] trait or event types change incompatibly.
35/// Dylib plugins built against a different version are refused at load time.
36pub const MIR_PLUGIN_API_VERSION: u32 = 2;
37
38// ---------------------------------------------------------------------------
39// Issues emitted by plugins
40// ---------------------------------------------------------------------------
41
42/// An issue raised by a plugin. Converted by the analyzer into
43/// `IssueKind::PluginIssue` with a proper source `Location`.
44#[derive(Debug, Clone)]
45pub struct PluginIssue {
46    /// Issue name used for display and suppression matching
47    /// (`@mir-suppress MyIssueName`, `<MyIssueName errorLevel="suppress"/>`).
48    pub name: String,
49    pub message: String,
50    pub severity: Severity,
51    /// Span the issue points at. `None` means the span of the event's node.
52    pub span: Option<php_ast::Span>,
53}
54
55impl PluginIssue {
56    pub fn new(name: impl Into<String>, message: impl Into<String>) -> Self {
57        Self {
58            name: name.into(),
59            message: message.into(),
60            severity: Severity::Error,
61            span: None,
62        }
63    }
64
65    pub fn with_severity(mut self, severity: Severity) -> Self {
66        self.severity = severity;
67        self
68    }
69
70    pub fn with_span(mut self, span: php_ast::Span) -> Self {
71        self.span = Some(span);
72        self
73    }
74}
75
76// ---------------------------------------------------------------------------
77// Provided types
78// ---------------------------------------------------------------------------
79
80/// A type contributed by a plugin. `Parse` carries a docblock-syntax type
81/// string (e.g. `list<non-empty-string>`) that the analyzer resolves with its
82/// own type parser in the context of the analyzed file — this is what the
83/// Psalm bridge returns, since PHP-side plugins produce type strings.
84#[derive(Debug, Clone)]
85pub enum ProvidedType {
86    Union(Type),
87    Parse(String),
88}
89
90// ---------------------------------------------------------------------------
91// Events
92// ---------------------------------------------------------------------------
93
94/// Counterpart of Psalm's `AfterExpressionAnalysisEvent`.
95pub struct AfterExpressionAnalysisEvent<'a> {
96    pub expr: &'a php_ast::owned::Expr,
97    /// Type the analyzer inferred for the expression.
98    pub expr_type: &'a Type,
99    pub file: &'a str,
100    pub issues: Vec<PluginIssue>,
101}
102
103/// Counterpart of Psalm's `AfterStatementAnalysisEvent`.
104pub struct AfterStatementAnalysisEvent<'a> {
105    pub stmt: &'a php_ast::owned::Stmt,
106    pub file: &'a str,
107    pub issues: Vec<PluginIssue>,
108}
109
110/// Counterpart of Psalm's `AfterFunctionCallAnalysisEvent`. `return_type`
111/// starts as the analyzer's inferred type and may be replaced.
112pub struct AfterFunctionCallAnalysisEvent<'a> {
113    /// Lowercased fully-qualified function name without leading `\`.
114    pub function_id: &'a str,
115    pub args: &'a [php_ast::owned::Arg],
116    pub arg_types: &'a [Type],
117    pub span: php_ast::Span,
118    pub file: &'a str,
119    pub return_type: &'a mut Type,
120    pub issues: Vec<PluginIssue>,
121}
122
123/// Counterpart of Psalm's `AfterMethodCallAnalysisEvent`.
124pub struct AfterMethodCallAnalysisEvent<'a> {
125    /// `Fully\Qualified\Class::methodname` (class as declared, method lowercased).
126    pub method_id: &'a str,
127    pub args: &'a [php_ast::owned::Arg],
128    pub arg_types: &'a [Type],
129    pub span: php_ast::Span,
130    pub file: &'a str,
131    pub return_type: &'a mut Type,
132    pub issues: Vec<PluginIssue>,
133}
134
135/// Counterpart of Psalm's `FunctionReturnTypeProviderEvent`.
136pub struct FunctionReturnTypeProviderEvent<'a> {
137    /// Lowercased fully-qualified function name without leading `\`.
138    pub function_id: &'a str,
139    pub args: &'a [php_ast::owned::Arg],
140    pub arg_types: &'a [Type],
141    pub span: php_ast::Span,
142    pub file: &'a str,
143    /// Raw source text of the whole call expression, when available. The
144    /// Psalm bridge re-parses this on the PHP side to build genuine
145    /// `PhpParser` argument nodes for the wrapped plugin.
146    pub call_snippet: Option<&'a str>,
147}
148
149/// Counterpart of Psalm's `MethodReturnTypeProviderEvent`.
150pub struct MethodReturnTypeProviderEvent<'a> {
151    /// FQCN of the class the method was resolved on (no leading `\`).
152    pub fqcn: &'a str,
153    /// Lowercased method name (PHP method dispatch is case-insensitive).
154    pub method_name: &'a str,
155    pub args: &'a [php_ast::owned::Arg],
156    pub arg_types: &'a [Type],
157    pub span: php_ast::Span,
158    pub file: &'a str,
159    pub call_snippet: Option<&'a str>,
160}
161
162/// Counterpart of Psalm's `AfterCodebasePopulatedEvent`. Fired once per batch
163/// run after definition collection, before body analysis.
164pub struct AfterCodebasePopulatedEvent<'a> {
165    /// Files that were indexed in this pass.
166    pub files: &'a [Arc<str>],
167}
168
169/// An array-literal property default declared on a class, exposed to
170/// [`ClassPropertyProviderEvent`] so a plugin can interpret framework
171/// conventions like Eloquent's `protected $casts = [...]` without re-parsing.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct ArrayPropertyDefault {
174    /// Property name (no leading `$`), e.g. `casts`.
175    pub property: String,
176    /// Ordered `(key, value)` entries of the array literal. String-literal
177    /// values are unquoted; `Foo::class` values become the resolved class
178    /// FQCN. List-style arrays get positional string keys (`"0"`, `"1"`).
179    pub entries: Vec<(String, String)>,
180}
181
182/// Counterpart of Psalm's `PropertiesProviderInterface`: supply the type of a
183/// property that is not declared on the class (nor reachable via `@property`),
184/// e.g. an Eloquent attribute synthesized from `$casts`. Dispatched when a
185/// property access misses on a class whose own FQCN or an ancestor is listed
186/// in [`MirPlugin::class_property_classes`].
187pub struct ClassPropertyProviderEvent<'a> {
188    /// Concrete receiver class the property is resolved on (no leading `\`).
189    pub fqcn: &'a str,
190    /// Property name being resolved (no leading `$`).
191    pub property_name: &'a str,
192    /// Array-literal property defaults declared on `fqcn` or an ancestor,
193    /// nearest-class-wins.
194    pub array_property_defaults: &'a [ArrayPropertyDefault],
195    pub file: &'a str,
196}
197
198// ---------------------------------------------------------------------------
199// Plugin trait
200// ---------------------------------------------------------------------------
201
202/// Which event hooks a plugin subscribes to. Sites only construct events and
203/// dispatch when at least one registered plugin set the matching flag, so an
204/// unset flag keeps that hook zero-cost.
205#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
206pub struct HookFlags {
207    pub after_expression_analysis: bool,
208    pub after_statement_analysis: bool,
209    pub after_function_call_analysis: bool,
210    pub after_method_call_analysis: bool,
211    pub before_add_issue: bool,
212    pub after_codebase_populated: bool,
213}
214
215/// A mir plugin. Hook methods take `&self` and may run concurrently from
216/// rayon workers — use interior mutability with proper synchronization.
217///
218/// Return-type providers are separate from [`HookFlags`]: they are keyed by
219/// the ids returned from [`function_return_type_ids`] /
220/// [`method_return_type_classes`], mirroring Psalm's
221/// `FunctionReturnTypeProviderInterface::getFunctionIds()` and
222/// `MethodReturnTypeProviderInterface::getClassLikeNames()`.
223///
224/// [`function_return_type_ids`]: MirPlugin::function_return_type_ids
225/// [`method_return_type_classes`]: MirPlugin::method_return_type_classes
226pub trait MirPlugin: Send + Sync {
227    fn name(&self) -> &str;
228
229    fn hooks(&self) -> HookFlags {
230        HookFlags::default()
231    }
232
233    /// PHP stub files this plugin contributes (Psalm's
234    /// `RegistrationInterface::addStubFile`). Loaded before analysis.
235    fn stub_files(&self) -> Vec<PathBuf> {
236        Vec::new()
237    }
238
239    /// Function ids (lowercased FQNs, no leading `\`) this plugin provides
240    /// return types for.
241    fn function_return_type_ids(&self) -> Vec<String> {
242        Vec::new()
243    }
244
245    /// Override the return type of a call to one of the declared function
246    /// ids. `None` falls through to the next plugin / normal inference.
247    fn function_return_type(
248        &self,
249        _event: &FunctionReturnTypeProviderEvent<'_>,
250    ) -> Option<ProvidedType> {
251        None
252    }
253
254    /// Class FQCNs (no leading `\`) this plugin provides method return types
255    /// for.
256    fn method_return_type_classes(&self) -> Vec<String> {
257        Vec::new()
258    }
259
260    fn method_return_type(
261        &self,
262        _event: &MethodReturnTypeProviderEvent<'_>,
263    ) -> Option<ProvidedType> {
264        None
265    }
266
267    /// Class FQCNs (no leading `\`) this plugin provides undeclared-property
268    /// types for. A listed class matches when it is the receiver's own class
269    /// *or any ancestor*, so a framework base class (e.g.
270    /// `Illuminate\Database\Eloquent\Model`) covers every user subclass.
271    fn class_property_classes(&self) -> Vec<String> {
272        Vec::new()
273    }
274
275    /// Supply the type of an otherwise-undeclared property. `None` falls
276    /// through to the next plugin / normal `UndefinedProperty` reporting.
277    fn class_property(&self, _event: &ClassPropertyProviderEvent<'_>) -> Option<ProvidedType> {
278        None
279    }
280
281    fn after_expression_analysis(&self, _event: &mut AfterExpressionAnalysisEvent<'_>) {}
282
283    fn after_statement_analysis(&self, _event: &mut AfterStatementAnalysisEvent<'_>) {}
284
285    fn after_function_call_analysis(&self, _event: &mut AfterFunctionCallAnalysisEvent<'_>) {}
286
287    fn after_method_call_analysis(&self, _event: &mut AfterMethodCallAnalysisEvent<'_>) {}
288
289    /// Veto or pass an issue before it is reported (Psalm's
290    /// `BeforeAddIssueInterface`). `Some(false)` drops the issue, `Some(true)`
291    /// forces it through, `None` defers to other plugins.
292    fn before_add_issue(&self, _issue: &Issue) -> Option<bool> {
293        None
294    }
295
296    fn after_codebase_populated(&self, _event: &mut AfterCodebasePopulatedEvent<'_>) {}
297}
298
299// ---------------------------------------------------------------------------
300// Registry
301// ---------------------------------------------------------------------------
302
303/// Normalize a function id or FQCN for provider-map lookup: lowercase, no
304/// leading backslash.
305pub fn normalize_id(id: &str) -> String {
306    id.trim_start_matches('\\').to_ascii_lowercase()
307}
308
309#[derive(Default)]
310pub struct PluginRegistry {
311    plugins: Vec<Box<dyn MirPlugin>>,
312    combined_hooks: HookFlags,
313    /// normalized function id → plugin indices, in registration order.
314    function_providers: FxHashMap<String, Vec<usize>>,
315    /// normalized FQCN → plugin indices, in registration order.
316    method_providers: FxHashMap<String, Vec<usize>>,
317    /// normalized marker FQCN → plugin indices for undeclared-property
318    /// providers. Matched against the receiver's own class and its ancestors.
319    class_property_providers: FxHashMap<String, Vec<usize>>,
320    /// Indices of plugins subscribed to each hook, so dispatch skips
321    /// non-subscribers without a virtual call.
322    after_expr: Vec<usize>,
323    after_stmt: Vec<usize>,
324    after_fn_call: Vec<usize>,
325    after_method_call: Vec<usize>,
326    before_issue: Vec<usize>,
327    after_codebase: Vec<usize>,
328}
329
330impl PluginRegistry {
331    pub fn new() -> Self {
332        Self::default()
333    }
334
335    pub fn register(&mut self, plugin: Box<dyn MirPlugin>) {
336        let idx = self.plugins.len();
337        let hooks = plugin.hooks();
338        macro_rules! subscribe {
339            ($flag:ident, $list:ident) => {
340                if hooks.$flag {
341                    self.combined_hooks.$flag = true;
342                    self.$list.push(idx);
343                }
344            };
345        }
346        subscribe!(after_expression_analysis, after_expr);
347        subscribe!(after_statement_analysis, after_stmt);
348        subscribe!(after_function_call_analysis, after_fn_call);
349        subscribe!(after_method_call_analysis, after_method_call);
350        subscribe!(before_add_issue, before_issue);
351        subscribe!(after_codebase_populated, after_codebase);
352
353        for id in plugin.function_return_type_ids() {
354            self.function_providers
355                .entry(normalize_id(&id))
356                .or_default()
357                .push(idx);
358        }
359        for fqcn in plugin.method_return_type_classes() {
360            self.method_providers
361                .entry(normalize_id(&fqcn))
362                .or_default()
363                .push(idx);
364        }
365        for fqcn in plugin.class_property_classes() {
366            self.class_property_providers
367                .entry(normalize_id(&fqcn))
368                .or_default()
369                .push(idx);
370        }
371        self.plugins.push(plugin);
372    }
373
374    pub fn is_empty(&self) -> bool {
375        self.plugins.is_empty()
376    }
377
378    pub fn len(&self) -> usize {
379        self.plugins.len()
380    }
381
382    pub fn plugin_names(&self) -> Vec<&str> {
383        self.plugins.iter().map(|p| p.name()).collect()
384    }
385
386    pub fn hooks(&self) -> HookFlags {
387        self.combined_hooks
388    }
389
390    /// All stub files contributed by registered plugins.
391    pub fn stub_files(&self) -> Vec<PathBuf> {
392        self.plugins.iter().flat_map(|p| p.stub_files()).collect()
393    }
394
395    /// Whether any plugin provides a return type for `function_id`
396    /// (pre-normalized). Cheap gate before building the provider event.
397    pub fn has_function_provider(&self, function_id: &str) -> bool {
398        self.function_providers.contains_key(function_id)
399    }
400
401    pub fn has_method_provider(&self, fqcn_normalized: &str) -> bool {
402        self.method_providers.contains_key(fqcn_normalized)
403    }
404
405    /// Whether any registered plugin declares any return-type provider —
406    /// used to skip id normalization entirely on the hot call path.
407    pub fn has_any_function_provider(&self) -> bool {
408        !self.function_providers.is_empty()
409    }
410
411    pub fn has_any_method_provider(&self) -> bool {
412        !self.method_providers.is_empty()
413    }
414
415    /// First-plugin-wins return type for a function call, in registration
416    /// order (matching Psalm, where the last registered provider for an id
417    /// replaces earlier ones — we instead chain until one returns `Some`).
418    pub fn function_return_type(
419        &self,
420        event: &FunctionReturnTypeProviderEvent<'_>,
421    ) -> Option<ProvidedType> {
422        let indices = self.function_providers.get(event.function_id)?;
423        indices
424            .iter()
425            .find_map(|&i| self.plugins[i].function_return_type(event))
426    }
427
428    pub fn method_return_type(
429        &self,
430        fqcn_normalized: &str,
431        event: &MethodReturnTypeProviderEvent<'_>,
432    ) -> Option<ProvidedType> {
433        let indices = self.method_providers.get(fqcn_normalized)?;
434        indices
435            .iter()
436            .find_map(|&i| self.plugins[i].method_return_type(event))
437    }
438
439    pub fn has_any_class_property_provider(&self) -> bool {
440        !self.class_property_providers.is_empty()
441    }
442
443    /// Whether any plugin registered `marker_normalized` (pre-normalized) as a
444    /// class-property-provider marker. The analyzer calls this for the
445    /// receiver's own class and each ancestor.
446    pub fn has_class_property_marker(&self, marker_normalized: &str) -> bool {
447        self.class_property_providers
448            .contains_key(marker_normalized)
449    }
450
451    pub fn class_property(
452        &self,
453        marker_normalized: &str,
454        event: &ClassPropertyProviderEvent<'_>,
455    ) -> Option<ProvidedType> {
456        let indices = self.class_property_providers.get(marker_normalized)?;
457        indices
458            .iter()
459            .find_map(|&i| self.plugins[i].class_property(event))
460    }
461
462    pub fn after_expression_analysis(&self, event: &mut AfterExpressionAnalysisEvent<'_>) {
463        for &i in &self.after_expr {
464            self.plugins[i].after_expression_analysis(event);
465        }
466    }
467
468    pub fn after_statement_analysis(&self, event: &mut AfterStatementAnalysisEvent<'_>) {
469        for &i in &self.after_stmt {
470            self.plugins[i].after_statement_analysis(event);
471        }
472    }
473
474    pub fn after_function_call_analysis(&self, event: &mut AfterFunctionCallAnalysisEvent<'_>) {
475        for &i in &self.after_fn_call {
476            self.plugins[i].after_function_call_analysis(event);
477        }
478    }
479
480    pub fn after_method_call_analysis(&self, event: &mut AfterMethodCallAnalysisEvent<'_>) {
481        for &i in &self.after_method_call {
482            self.plugins[i].after_method_call_analysis(event);
483        }
484    }
485
486    /// `false` when some plugin vetoed the issue. First non-`None` wins.
487    pub fn before_add_issue(&self, issue: &Issue) -> bool {
488        for &i in &self.before_issue {
489            if let Some(keep) = self.plugins[i].before_add_issue(issue) {
490                return keep;
491            }
492        }
493        true
494    }
495
496    pub fn after_codebase_populated(&self, event: &mut AfterCodebasePopulatedEvent<'_>) {
497        for &i in &self.after_codebase {
498            self.plugins[i].after_codebase_populated(event);
499        }
500    }
501}
502
503// ---------------------------------------------------------------------------
504// Process-global registry
505// ---------------------------------------------------------------------------
506
507static REGISTRY: RwLock<Option<Arc<PluginRegistry>>> = RwLock::new(None);
508
509/// Install the process-wide plugin registry. The analyzer takes an `Arc`
510/// snapshot per pass, so re-installing affects subsequent passes only.
511pub fn install(registry: PluginRegistry) {
512    let shared = if registry.is_empty() {
513        None
514    } else {
515        Some(Arc::new(registry))
516    };
517    *REGISTRY.write() = shared;
518}
519
520/// Snapshot the installed registry. `None` when no plugins are loaded — the
521/// common case, which every hook site checks first.
522pub fn snapshot() -> Option<Arc<PluginRegistry>> {
523    REGISTRY.read().clone()
524}
525
526/// Remove the installed registry (used by tests).
527#[doc(hidden)]
528pub fn uninstall() {
529    *REGISTRY.write() = None;
530}
531
532// ---------------------------------------------------------------------------
533// Dylib plugin declaration (the exported entry point lives here so the macro
534// works without the `dylib` feature — only *loading* needs libloading).
535// ---------------------------------------------------------------------------
536
537/// Entry-point record a Rust cdylib plugin exports under the symbol
538/// `MIR_PLUGIN_DECLARATION`. Use [`export_plugin!`] instead of writing this
539/// by hand.
540#[repr(C)]
541pub struct PluginDeclaration {
542    pub api_version: u32,
543    pub create: fn() -> Box<dyn MirPlugin>,
544}
545
546/// Export a plugin constructor from a cdylib crate:
547///
548/// ```ignore
549/// fn create() -> Box<dyn mir_plugin::MirPlugin> { Box::new(MyPlugin) }
550/// mir_plugin::export_plugin!(create);
551/// ```
552///
553/// The dylib must be built with the same Rust toolchain and mir-plugin
554/// version as the mir binary that loads it — the loader refuses mismatched
555/// `api_version`s, but layout compatibility beyond that is on the builder.
556#[macro_export]
557macro_rules! export_plugin {
558    ($create:path) => {
559        #[no_mangle]
560        pub static MIR_PLUGIN_DECLARATION: $crate::PluginDeclaration = $crate::PluginDeclaration {
561            api_version: $crate::MIR_PLUGIN_API_VERSION,
562            create: $create,
563        };
564    };
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    struct NoopPlugin;
572    impl MirPlugin for NoopPlugin {
573        fn name(&self) -> &str {
574            "noop"
575        }
576    }
577
578    struct ExprPlugin;
579    impl MirPlugin for ExprPlugin {
580        fn name(&self) -> &str {
581            "expr"
582        }
583        fn hooks(&self) -> HookFlags {
584            HookFlags {
585                after_expression_analysis: true,
586                ..Default::default()
587            }
588        }
589        fn function_return_type_ids(&self) -> Vec<String> {
590            vec!["\\App\\helper".to_string()]
591        }
592        fn function_return_type(
593            &self,
594            _event: &FunctionReturnTypeProviderEvent<'_>,
595        ) -> Option<ProvidedType> {
596            Some(ProvidedType::Parse("non-empty-string".to_string()))
597        }
598    }
599
600    #[test]
601    fn registry_indexes_hooks_and_providers() {
602        let mut reg = PluginRegistry::new();
603        reg.register(Box::new(NoopPlugin));
604        reg.register(Box::new(ExprPlugin));
605
606        assert_eq!(reg.len(), 2);
607        assert!(reg.hooks().after_expression_analysis);
608        assert!(!reg.hooks().after_statement_analysis);
609        assert!(reg.has_function_provider("app\\helper"));
610        assert!(!reg.has_function_provider("app\\other"));
611        assert!(reg.has_any_function_provider());
612        assert!(!reg.has_any_method_provider());
613    }
614
615    #[test]
616    fn normalize_id_strips_backslash_and_lowercases() {
617        assert_eq!(normalize_id("\\App\\Helper"), "app\\helper");
618        assert_eq!(normalize_id("strlen"), "strlen");
619    }
620}