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