diff --git a/crates/pmp-core/src/attrs.rs b/crates/pmp-core/src/attrs.rs index d9a6dc2..e39b4bb 100644 --- a/crates/pmp-core/src/attrs.rs +++ b/crates/pmp-core/src/attrs.rs @@ -1,4 +1,4 @@ -use darling::{ FromMeta }; +use darling::FromMeta; /// Implemented by any item that carries a `Vec`. /// diff --git a/crates/pmp-core/src/types.rs b/crates/pmp-core/src/types.rs index 742444c..1cbe43f 100644 --- a/crates/pmp-core/src/types.rs +++ b/crates/pmp-core/src/types.rs @@ -65,3 +65,21 @@ impl FromMeta for StrictPath Path::from_expr(expr).map(StrictPath) } } + +/// A Rust expression that must be written as `= ` +#[derive(Debug, Clone)] +pub struct StrictExpr(pub syn::Expr); + +impl FromMeta for StrictExpr +{ + fn from_word() -> Result + { + Err(Error::custom( + "expected `= `, provide an expression, e.g. `target = Target::CLASS | Target::INTERFACE`" + )) + } + + fn from_expr(expr: &syn::Expr) -> Result { + Ok(StrictExpr(expr.clone())) + } +} diff --git a/crates/pmp-macro/Cargo.toml b/crates/pmp-macro/Cargo.toml new file mode 100644 index 0000000..5e13062 --- /dev/null +++ b/crates/pmp-macro/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "pmp-macro" +version = "1.0.0" + +edition.workspace = true +authors.workspace = true +license-file.workspace = true + +[dependencies] +pmp-macro-core = { path = "crates/pmp-macro-core" } +pmp-macro-misc = { path = "crates/pmp-macro-misc" } diff --git a/crates/pmp-macro/crates/pmp-macro-core/Cargo.toml b/crates/pmp-macro/crates/pmp-macro-core/Cargo.toml new file mode 100644 index 0000000..6df4e36 --- /dev/null +++ b/crates/pmp-macro/crates/pmp-macro-core/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "pmp-macro-core" +version = "1.0.0" + +edition.workspace = true +authors.workspace = true +license-file.workspace = true + +[lib] +proc-macro = true + +[dependencies] +pmp-core = { path = "../../../pmp-core" } +pmp-macro-misc = { path = "../pmp-macro-misc" } + +syn = { version = "^2", features = ["full"] } +quote = "^1" +darling = "^0" +proc-macro2 = "^1" diff --git a/crates/pmp-macro/crates/pmp-macro-core/src/attrs.rs b/crates/pmp-macro/crates/pmp-macro-core/src/attrs.rs new file mode 100644 index 0000000..a53b137 --- /dev/null +++ b/crates/pmp-macro/crates/pmp-macro-core/src/attrs.rs @@ -0,0 +1,49 @@ +use pmp_core::{ StrictBool, StrictExpr, StrictString }; +use darling::{ ast, FromField, FromDeriveInput }; +use syn::{ Type, Ident }; + +/// - `name = "..."` - PHP attribute name (default: struct name) +/// - `target = ` - which PHP constructs may use this attribute (default: Target::ALL) +/// - `docs = "..."` - description for diagnostics and generated docs +#[derive(Debug, FromDeriveInput)] +#[darling(attributes(pmp_macro), forward_attrs(allow, doc, cfg))] +pub struct MacroInput { + pub ident: Ident, + pub data: ast::Data, + + #[darling(default)] + pub name: Option, + #[darling(default)] + pub target: Option, + #[darling(default)] + pub docs: Option, +} + +/// - `php_type = "..."` - PHP type hint, e.g. `"class-string[]"` (required) +/// - `default = "..."` - PHP default value, e.g. `"[]"` +/// - `required = bool` - overrides required inference +#[derive(Debug, FromField)] +#[darling(attributes(param))] +pub struct FieldInput { + pub ident: Option, + #[allow(dead_code)] + pub ty: Type, + + #[darling(default)] + pub php_type: Option, + #[darling(default)] + pub default: Option, + #[darling(default)] + pub required: Option, +} + +impl FieldInput +{ + pub fn is_param(&self) -> bool { + self.php_type.is_some() + } + + pub fn is_required(&self) -> bool { + self.required.map(|b| b.0).unwrap_or(self.default.is_none()) + } +} diff --git a/crates/pmp-macro/crates/pmp-macro-core/src/expand.rs b/crates/pmp-macro/crates/pmp-macro-core/src/expand.rs new file mode 100644 index 0000000..4c84d3a --- /dev/null +++ b/crates/pmp-macro/crates/pmp-macro-core/src/expand.rs @@ -0,0 +1,112 @@ +use quote::{ format_ident, quote, quote_spanned }; +use proc_macro2::TokenStream as TokenStream2; + +use crate::attrs::MacroInput; + +pub fn expand(parsed: MacroInput) -> syn::Result +{ + let struct_ident = &parsed.ident; + + let fields = parsed.data.take_struct().ok_or_else(|| { + syn::Error::new_spanned(struct_ident, "PmpMacro can only be derived for structs") + })?; + + let attr_name: String = parsed.name + .map(|s| s.0) + .unwrap_or_else(|| struct_ident.to_string()); + + let target_expr: syn::Expr = parsed.target + .map(|e| e.0) + .unwrap_or_else(|| syn::parse_quote! { ::pmp_macro_misc::Target::ALL }); + + let docs: String = parsed.docs.map(|s| s.0).unwrap_or_default(); + + let mut param_tokens: Vec = Vec::new(); + let mut errors: Option = None; + + for field in fields.iter().filter(|f| f.is_param()) + { + let span = field.ident.as_ref().map(|i| i.span()).unwrap_or_else(|| struct_ident.span()); + let field_name = field.ident.as_ref().map(|i| i.to_string()).unwrap_or_default(); + + let php_type = match &field.php_type + { + Some(s) => s.0.clone(), + None => + { + let e = syn::Error::new( + span, + format!("field `{field_name}` has #[param] but is missing `php_type = \"...\"`"), + ); + match &mut errors { + None => errors = Some(e), + Some(existing) => existing.combine(e), + } + continue; + } + }; + + let required = field.is_required(); + let default_tokens = match field.default.as_ref().map(|s| s.0.as_str()) { + Some(d) => quote! { Some(#d) }, + None => quote! { None }, + }; + + param_tokens.push(quote_spanned! { span => + ::pmp_macro_misc::ParamDescriptor { + name: #field_name, + php_type: #php_type, + required: #required, + default: #default_tokens, + } + }); + } + + if let Some(e) = errors { return Err(e); } + + let mod_ident = format_ident!("__pmp_macro_impl_{}", struct_ident); + let handler = quote! { super::#struct_ident }; + + Ok(quote! + { + #[doc(hidden)] + mod #mod_ident + { + use ::pmp_macro_misc::{ + AnalyzeFn, MacroDescriptor, MacroRegistration, ParamDescriptor, + TransformFn, PmpMacro, + }; + + static PARAMS: &[ParamDescriptor] = &[ #(#param_tokens),* ]; + + static DESCRIPTOR: MacroDescriptor = MacroDescriptor { + name: #attr_name, + target: #target_expr, + docs: #docs, + params: PARAMS, + }; + + fn analyze( + ctx: &::pmp_macro_misc::MacroContext, + attr: &::pmp_macro_misc::ResolvedAttr, + node: &::pmp_macro_misc::AttributedNode, + ) -> ::std::vec::Vec<::pmp_macro_misc::Diagnostic> + { + <#handler as PmpMacro>::analyze(&::std::default::Default::default(), ctx, attr, node) + } + + fn transform( + ctx: &::pmp_macro_misc::MacroContext, + attr: &::pmp_macro_misc::ResolvedAttr, + node: &::pmp_macro_misc::AttributedNode, + ) -> ::std::vec::Vec<::pmp_macro_misc::Rewrite> + { + <#handler as PmpMacro>::transform(&::std::default::Default::default(), ctx, attr, node) + } + + ::inventory::submit! { + MacroRegistration::new(&DESCRIPTOR, analyze, transform) + } + } + }) +} diff --git a/crates/pmp-macro/crates/pmp-macro-core/src/lib.rs b/crates/pmp-macro/crates/pmp-macro-core/src/lib.rs new file mode 100644 index 0000000..6ba48cd --- /dev/null +++ b/crates/pmp-macro/crates/pmp-macro-core/src/lib.rs @@ -0,0 +1,55 @@ +use darling::FromDeriveInput; +use proc_macro::TokenStream; +use syn::DeriveInput; + +mod attrs; +mod expand; + +use attrs::MacroInput; + +/// Derives registration boilerplate for a PHP macro attribute handler. +/// +/// #### Struct-level attributes (`#[pmp_macro(...)]`) +/// +/// | Key | Type | Default | Description | +/// |------------|---------------|------------------|----------------------------------------------| +/// | `name` | `= "..."` | Struct name | PHP attribute name to match against | +/// | `target` | `= ` | `Target::ALL` | Which PHP constructs may use this attribute | +/// | `docs` | `= "..."` | `""` | Description for diagnostics and generated docs | +/// +/// #### Field-level attributes (`#[param(...)]`) +/// +/// Fields without `#[param(...)]` are ignored - they are treated as handler +/// implementation details, not PHP constructor parameters. +/// +/// | Key | Type | Default | Description | +/// |------------|-----------|-------------------|------------------------------------------| +/// | `php_type` | `= "..."` | *(required)* | PHP type hint, e.g. `"class-string[]"` | +/// | `default` | `= "..."` | `None` | PHP default value, e.g. `"[]"` | +/// | `required` | `= bool` | inferred | Overrides required inference | +/// +/// #### What is generated +/// +/// - A hidden module containing a `static MacroDescriptor` and `static [ParamDescriptor]` +/// - Two free functions delegating to the handler's `PmpMacro::analyze` / `::transform` +/// - An `inventory::submit!` registration wiring it all together +/// +/// #### Requirements +/// +/// - The struct must implement [`pmp_macro_misc::PmpMacro`] (user-written) +/// - The struct must implement [`Default`] (needed to construct a handler instance +/// inside the generated delegation functions) +#[proc_macro_derive(PmpMacro, attributes(pmp_macro, param))] +pub fn pmp_macro_derive(item: TokenStream) -> TokenStream +{ + let input = syn::parse_macro_input!(item as DeriveInput); + + let parsed = match MacroInput::from_derive_input(&input) { + Ok(p) => p, + Err(e) => return e.write_errors().into(), + }; + + expand::expand(parsed) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} diff --git a/crates/pmp-macro/crates/pmp-macro-misc/src/traits.rs b/crates/pmp-macro/crates/pmp-macro-misc/src/traits.rs index b0085d7..05b4324 100644 --- a/crates/pmp-macro/crates/pmp-macro-misc/src/traits.rs +++ b/crates/pmp-macro/crates/pmp-macro-misc/src/traits.rs @@ -5,7 +5,7 @@ use std::{ use crate::{ descriptor::MacroDescriptor, - context::{ Rewrite, ResolvedAttr, Diagnostic, MacroContext, AttributedNode } + context::{ Rewrite, Diagnostic, ResolvedAttr, MacroContext, AttributedNode } }; // --------------------------------------------------------------------------------------------- // diff --git a/crates/pmp-macro/src/lib.rs b/crates/pmp-macro/src/lib.rs new file mode 100644 index 0000000..53490a6 --- /dev/null +++ b/crates/pmp-macro/src/lib.rs @@ -0,0 +1,5 @@ +#[doc(hidden)] +pub extern crate pmp_macro_misc; + +pub use pmp_macro_misc::*; +pub use pmp_macro_core::*;