1#![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#[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}