Skip to main content

forma_ir_derive/
lib.rs

1//! `PageProps` derives the existing FMIR schema and projects Rust values through
2//! the declared schema. Consumers use the re-export from `forma-ir`.
3#![forbid(unsafe_code)]
4use proc_macro::TokenStream;
5use proc_macro2::{Span, TokenStream as Tokens};
6use quote::{quote, quote_spanned};
7use std::collections::HashSet;
8use syn::{parse_macro_input, spanned::Spanned, Data, DeriveInput, Fields, Type};
9
10/// Derive a named props struct or unit enum from the supported closed type set.
11#[proc_macro_derive(PageProps, attributes(props))]
12pub fn page_props(input: TokenStream) -> TokenStream {
13    match expand(parse_macro_input!(input as DeriveInput)) {
14        Ok(tokens) => tokens.into(),
15        Err(error) => error.to_compile_error().into(),
16    }
17}
18fn source(span: Span) -> Tokens {
19    quote_spanned!(span=>::forma_ir::props::PropsSource::new(file!(),line!(),column!()))
20}
21fn name(attrs: &[syn::Attribute], default: String) -> syn::Result<String> {
22    let mut rename = None;
23    for attr in attrs.iter().filter(|attr| attr.path().is_ident("props")) {
24        let mut found = false;
25        attr.parse_nested_meta(|meta| {
26            found = true;
27            if !meta.path.is_ident("rename") {
28                return Err(meta.error("PageProps supports only #[props(rename = \"name\")]"));
29            }
30            if rename.is_some() {
31                return Err(meta.error("duplicate PageProps rename"));
32            }
33            let value: syn::LitStr = meta.value()?.parse()?;
34            if value.value().is_empty() || value.value().chars().any(char::is_control) {
35                return Err(syn::Error::new(
36                    value.span(),
37                    "PageProps rename must be nonempty and contain no control characters",
38                ));
39            }
40            rename = Some(value.value());
41            Ok(())
42        })?;
43        if !found {
44            return Err(syn::Error::new(
45                attr.span(),
46                "PageProps requires #[props(rename = \"name\")]; empty attributes are unsupported",
47            ));
48        }
49    }
50    Ok(rename.unwrap_or(default))
51}
52fn camel(name: &str) -> String {
53    let mut output = String::new();
54    let mut uppercase = false;
55    for character in name.trim_start_matches("r#").chars() {
56        if character == '_' {
57            uppercase = true;
58        } else if uppercase && !output.is_empty() {
59            output.extend(character.to_uppercase());
60            uppercase = false;
61        } else {
62            output.push(character);
63            uppercase = false;
64        }
65    }
66    output
67}
68fn supported(ty: &Type) -> syn::Result<()> {
69    let Type::Path(path) = ty else {
70        return Err(syn::Error::new(ty.span(),"PageProps field must be a supported scalar, SystemTime, Option<T>, Vec<T>, or derived named type"));
71    };
72    if path.qself.is_some() {
73        return Err(syn::Error::new(
74            ty.span(),
75            "PageProps associated type projections are unsupported",
76        ));
77    }
78    let Some(last) = path.path.segments.last() else {
79        return Err(syn::Error::new(ty.span(), "PageProps field has no type"));
80    };
81    let ident = last.ident.to_string();
82    if matches!(
83        ident.as_str(),
84        "i128"
85            | "u128"
86            | "char"
87            | "str"
88            | "HashMap"
89            | "BTreeMap"
90            | "HashSet"
91            | "BTreeSet"
92            | "Box"
93            | "Rc"
94            | "Arc"
95            | "Value"
96    ) {
97        return Err(syn::Error::new(
98            ty.span(),
99            "unsupported PageProps field type; use the declared closed type set",
100        ));
101    }
102    if ident == "Option" || ident == "Vec" {
103        let syn::PathArguments::AngleBracketed(args) = &last.arguments else {
104            return Err(syn::Error::new(
105                ty.span(),
106                "Option and Vec require one props type",
107            ));
108        };
109        if args.args.len() != 1 {
110            return Err(syn::Error::new(
111                args.span(),
112                "Option and Vec require exactly one props type",
113            ));
114        }
115        let Some(syn::GenericArgument::Type(inner)) = args.args.first() else {
116            return Err(syn::Error::new(
117                args.span(),
118                "Option and Vec require a type argument",
119            ));
120        };
121        supported(inner)?;
122    } else if !matches!(last.arguments, syn::PathArguments::None) {
123        return Err(syn::Error::new(
124            ty.span(),
125            "generic props types other than Option and Vec are unsupported",
126        ));
127    }
128    Ok(())
129}
130fn expand(input: DeriveInput) -> syn::Result<Tokens> {
131    for attr in &input.attrs {
132        if attr.path().is_ident("props") {
133            return Err(syn::Error::new(
134                attr.span(),
135                "props attributes belong on fields or enum variants",
136            ));
137        }
138    }
139    if !input.generics.params.is_empty() {
140        return Err(syn::Error::new(
141            input.generics.span(),
142            "PageProps requires a concrete named type without generic parameters",
143        ));
144    }
145    let root_source = source(input.ident.span());
146    let ident = &input.ident;
147    let (describe, encode) = match &input.data {
148        Data::Struct(data) => {
149            let Fields::Named(fields) = &data.fields else {
150                return Err(syn::Error::new(input.ident.span(),"PageProps structs require named fields; tuple and unit structs are unsupported"));
151            };
152            let mut names = HashSet::new();
153            let mut describe = Vec::new();
154            let mut arms = Vec::new();
155            for field in &fields.named {
156                supported(&field.ty)?;
157                let Some(field_ident) = &field.ident else {
158                    return Err(syn::Error::new(
159                        field.span(),
160                        "PageProps requires a named field",
161                    ));
162                };
163                let field_name = name(&field.attrs, camel(&field_ident.to_string()))?;
164                if !names.insert(field_name.clone()) {
165                    return Err(syn::Error::new(
166                        field_ident.span(),
167                        format!(
168                            "duplicate PageProps field name {field_name:?} after camelCase/rename"
169                        ),
170                    ));
171                }
172                let field_source = source(field_ident.span());
173                let ty = &field.ty;
174                let field_type = quote_spanned!(ty.span()=><#ty as ::forma_ir::props::PropValue>::describe(builder)?);
175                describe.push(
176                    quote! {{let kind=#field_type;builder.field(#field_name,#field_source,kind)?}},
177                );
178                let encode = quote_spanned!(ty.span()=>::forma_ir::props::PropValue::encode_value(&self.#field_ident,&child,&field.kind,&child_path)?);
179                arms.push(quote!{#field_name=>{let child=context.field(field,#field_source,&child_path)?;if let Some(value)=#encode {values.insert(name.to_owned(),value);}}});
180            }
181            (
182                quote! {builder.object::<Self>(|builder|{Ok(::std::vec![#(#describe),*])})},
183                quote! {
184                    let mut values=::forma_ir::props::JsonMap::new();
185                    for field in context.object(kind,path)? {
186                        let name=context.name(field.name,path)?;
187                        let child_path=::forma_ir::props::child_path(path,name);
188                        match name {#(#arms)* _=>return Err(context.error("RENDER-404",&child_path,"declared field is absent from the Rust handler")),}
189                    }
190                    Ok(Some(::forma_ir::props::JsonValue::Object(values)))
191                },
192            )
193        }
194        Data::Enum(data) => {
195            if data.variants.is_empty() {
196                return Err(syn::Error::new(
197                    input.ident.span(),
198                    "PageProps unit enums require at least one variant",
199                ));
200            }
201            let mut names = HashSet::new();
202            let mut literals = Vec::new();
203            let mut arms = Vec::new();
204            for variant in &data.variants {
205                if !matches!(variant.fields, Fields::Unit) {
206                    return Err(syn::Error::new(
207                        variant.fields.span(),
208                        "PageProps enum variants must be unit variants without payloads",
209                    ));
210                }
211                if variant.discriminant.is_some() {
212                    return Err(syn::Error::new(
213                        variant.span(),
214                        "PageProps unit enums do not support explicit discriminants",
215                    ));
216                }
217                let literal = name(
218                    &variant.attrs,
219                    variant
220                        .ident
221                        .to_string()
222                        .trim_start_matches("r#")
223                        .to_string(),
224                )?;
225                if !names.insert(literal.clone()) {
226                    return Err(syn::Error::new(
227                        variant.ident.span(),
228                        format!("duplicate PageProps enum literal {literal:?}"),
229                    ));
230                }
231                let variant_ident = &variant.ident;
232                arms.push(quote! {Self::#variant_ident=>#literal,});
233                literals.push(literal);
234            }
235            (
236                quote! {builder.enumeration(&[#(#literals),*])},
237                quote! {context.enumeration(kind,path,match self{#(#arms)*})},
238            )
239        }
240        Data::Union(data) => {
241            return Err(syn::Error::new(
242                data.union_token.span,
243                "PageProps does not support unions",
244            ))
245        }
246    };
247    Ok(quote! {
248        impl ::forma_ir::props::PropValue for #ident {
249            fn describe(builder:&mut ::forma_ir::props::SchemaBuilder)->::std::result::Result<::forma_ir::schema::PropType,::forma_ir::props::PropsError>{#describe}
250            fn encode_value(&self,context:&::forma_ir::props::EncodeContext<'_>,kind:&::forma_ir::schema::PropType,path:&str)->::std::result::Result<::std::option::Option<::forma_ir::props::JsonValue>,::forma_ir::props::PropsError>{#encode}
251            fn source()->::forma_ir::props::PropsSource {#root_source}
252        }
253        impl ::forma_ir::props::PageProps for #ident {}
254    })
255}