Proc macros for components, views
This commit is contained in:
12
lib/ruin_app_proc_macros/Cargo.toml
Normal file
12
lib/ruin_app_proc_macros/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "ruin-app-proc-macros"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
proc-macro2 = "1"
|
||||
quote = "1"
|
||||
syn = { version = "2", features = ["full"] }
|
||||
490
lib/ruin_app_proc_macros/src/lib.rs
Normal file
490
lib/ruin_app_proc_macros/src/lib.rs
Normal file
@@ -0,0 +1,490 @@
|
||||
use proc_macro::TokenStream;
|
||||
use proc_macro2::Span;
|
||||
use quote::{format_ident, quote};
|
||||
use syn::parse::{Parse, ParseStream};
|
||||
use syn::punctuated::Punctuated;
|
||||
use syn::{
|
||||
Error, Expr, FnArg, Ident, ItemFn, Pat, PatIdent, Path, Result, ReturnType, Token, Type,
|
||||
braced, parenthesized, parse_macro_input,
|
||||
};
|
||||
|
||||
#[proc_macro_attribute]
|
||||
pub fn component(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
if !proc_macro2::TokenStream::from(attr).is_empty() {
|
||||
return Error::new(Span::call_site(), "#[component] takes no arguments")
|
||||
.to_compile_error()
|
||||
.into();
|
||||
}
|
||||
|
||||
let function = parse_macro_input!(item as ItemFn);
|
||||
match expand_component(function) {
|
||||
Ok(tokens) => tokens.into(),
|
||||
Err(error) => error.to_compile_error().into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[proc_macro]
|
||||
pub fn view(input: TokenStream) -> TokenStream {
|
||||
let root = parse_macro_input!(input as ViewRoot);
|
||||
expand_node(&root.root).into()
|
||||
}
|
||||
|
||||
fn expand_component(mut function: ItemFn) -> Result<proc_macro2::TokenStream> {
|
||||
validate_component_function(&function)?;
|
||||
|
||||
let vis = function.vis.clone();
|
||||
let component_name = function.sig.ident.clone();
|
||||
let render_name = format_ident!("__ruin_app_render_{}", component_name);
|
||||
function.sig.ident = render_name.clone();
|
||||
function
|
||||
.attrs
|
||||
.push(syn::parse_quote!(#[allow(non_snake_case)]));
|
||||
|
||||
let props = function
|
||||
.sig
|
||||
.inputs
|
||||
.iter()
|
||||
.map(parse_prop)
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
let builder_name = format_ident!("__{}Builder", component_name);
|
||||
|
||||
let builder_tokens = if props.is_empty() {
|
||||
quote! {
|
||||
#vis struct #builder_name;
|
||||
|
||||
impl #builder_name {
|
||||
#vis fn children(self, _children: ()) -> #component_name {
|
||||
#component_name
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let missing_names = props
|
||||
.iter()
|
||||
.map(|prop| format_ident!("__{}__{}_Missing", component_name, prop.ident))
|
||||
.collect::<Vec<_>>();
|
||||
let present_names = props
|
||||
.iter()
|
||||
.map(|prop| format_ident!("__{}__{}_Present", component_name, prop.ident))
|
||||
.collect::<Vec<_>>();
|
||||
let state_idents = (0..props.len())
|
||||
.map(|index| format_ident!("P{index}"))
|
||||
.collect::<Vec<_>>();
|
||||
let field_idents = props.iter().map(|prop| &prop.ident).collect::<Vec<_>>();
|
||||
let marker_structs = props
|
||||
.iter()
|
||||
.zip(missing_names.iter())
|
||||
.zip(present_names.iter())
|
||||
.map(|((prop, missing), present)| {
|
||||
let ty = &prop.ty;
|
||||
quote! {
|
||||
#[allow(non_camel_case_types)]
|
||||
#vis struct #missing;
|
||||
#[allow(non_camel_case_types)]
|
||||
#vis struct #present(#ty);
|
||||
}
|
||||
});
|
||||
|
||||
let builder_methods = props.iter().enumerate().map(|(index, prop)| {
|
||||
let value_ident = &prop.ident;
|
||||
let value_ty = &prop.ty;
|
||||
let missing_at_index = &missing_names[index];
|
||||
let present_at_index = &present_names[index];
|
||||
|
||||
let impl_generics = state_idents
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(current, _)| *current != index)
|
||||
.map(|(_, ident)| ident);
|
||||
let impl_generics = impl_generics.collect::<Vec<_>>();
|
||||
|
||||
let self_builder_args = state_idents
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(current, ident)| {
|
||||
if current == index {
|
||||
quote! { #missing_at_index }
|
||||
} else {
|
||||
quote! { #ident }
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let next_builder_args = state_idents
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(current, ident)| {
|
||||
if current == index {
|
||||
quote! { #present_at_index }
|
||||
} else {
|
||||
quote! { #ident }
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let field_initializers = field_idents
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(current, field_ident)| {
|
||||
if current == index {
|
||||
quote! { #field_ident: #present_at_index(#value_ident) }
|
||||
} else {
|
||||
quote! { #field_ident: self.#field_ident }
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if impl_generics.is_empty() {
|
||||
quote! {
|
||||
impl #builder_name<#(#self_builder_args),*> {
|
||||
#vis fn #value_ident(self, #value_ident: #value_ty) -> #builder_name<#(#next_builder_args),*> {
|
||||
#builder_name {
|
||||
#(#field_initializers),*
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
impl<#(#impl_generics),*> #builder_name<#(#self_builder_args),*> {
|
||||
#vis fn #value_ident(self, #value_ident: #value_ty) -> #builder_name<#(#next_builder_args),*> {
|
||||
#builder_name {
|
||||
#(#field_initializers),*
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let ready_builder_args = present_names
|
||||
.iter()
|
||||
.map(|present| quote! { #present })
|
||||
.collect::<Vec<_>>();
|
||||
let missing_builder_args = missing_names
|
||||
.iter()
|
||||
.map(|missing| quote! { #missing })
|
||||
.collect::<Vec<_>>();
|
||||
let field_extractors = field_idents
|
||||
.iter()
|
||||
.map(|field_ident| quote! { #field_ident: builder.#field_ident.0 });
|
||||
|
||||
quote! {
|
||||
#(#marker_structs)*
|
||||
|
||||
#vis struct #builder_name<#(#state_idents),*> {
|
||||
#(#field_idents: #state_idents),*
|
||||
}
|
||||
|
||||
#(#builder_methods)*
|
||||
|
||||
impl #builder_name<#(#ready_builder_args),*> {
|
||||
#vis fn children(self, _children: ()) -> #component_name {
|
||||
#component_name::from_builder(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl #component_name {
|
||||
fn from_builder(builder: #builder_name<#(#ready_builder_args),*>) -> Self {
|
||||
Self {
|
||||
#(#field_extractors),*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ::ruin_app::Component for #component_name {
|
||||
type Builder = #builder_name<#(#missing_builder_args),*>;
|
||||
|
||||
fn builder() -> Self::Builder {
|
||||
#builder_name {
|
||||
#(#field_idents: #missing_names),*
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self) -> ::ruin_app::View {
|
||||
::ruin_app::IntoView::into_view(#render_name(
|
||||
#(::core::clone::Clone::clone(&self.#field_idents)),*
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let render_call = if props.is_empty() {
|
||||
quote! { #render_name() }
|
||||
} else {
|
||||
let field_idents = props.iter().map(|prop| &prop.ident);
|
||||
quote! { #render_name(#(::core::clone::Clone::clone(&self.#field_idents)),*) }
|
||||
};
|
||||
|
||||
let component_tokens = if props.is_empty() {
|
||||
quote! {
|
||||
#vis struct #component_name;
|
||||
|
||||
#builder_tokens
|
||||
|
||||
impl ::ruin_app::Component for #component_name {
|
||||
type Builder = #builder_name;
|
||||
|
||||
fn builder() -> Self::Builder {
|
||||
#builder_name
|
||||
}
|
||||
|
||||
fn render(&self) -> ::ruin_app::View {
|
||||
::ruin_app::IntoView::into_view(#render_call)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let field_idents = props.iter().map(|prop| &prop.ident);
|
||||
let field_types = props.iter().map(|prop| &prop.ty);
|
||||
quote! {
|
||||
#vis struct #component_name {
|
||||
#( #field_idents: #field_types ),*
|
||||
}
|
||||
|
||||
#builder_tokens
|
||||
}
|
||||
};
|
||||
|
||||
Ok(quote! {
|
||||
#function
|
||||
|
||||
#component_tokens
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_component_function(function: &ItemFn) -> Result<()> {
|
||||
let signature = &function.sig;
|
||||
|
||||
if signature.asyncness.is_some() {
|
||||
return Err(Error::new_spanned(
|
||||
signature.asyncness,
|
||||
"components must be synchronous functions",
|
||||
));
|
||||
}
|
||||
if !signature.generics.params.is_empty() || signature.generics.where_clause.is_some() {
|
||||
return Err(Error::new_spanned(
|
||||
&signature.generics,
|
||||
"generic components are not supported yet",
|
||||
));
|
||||
}
|
||||
if signature.constness.is_some() {
|
||||
return Err(Error::new_spanned(
|
||||
signature.fn_token,
|
||||
"components cannot be const",
|
||||
));
|
||||
}
|
||||
if signature.unsafety.is_some() {
|
||||
return Err(Error::new_spanned(
|
||||
signature.fn_token,
|
||||
"components cannot be unsafe",
|
||||
));
|
||||
}
|
||||
if signature.abi.is_some() {
|
||||
return Err(Error::new_spanned(
|
||||
&signature.abi,
|
||||
"components cannot declare an ABI",
|
||||
));
|
||||
}
|
||||
if signature.variadic.is_some() {
|
||||
return Err(Error::new_spanned(
|
||||
&signature.variadic,
|
||||
"components cannot be variadic",
|
||||
));
|
||||
}
|
||||
for input in &signature.inputs {
|
||||
let FnArg::Typed(typed) = input else {
|
||||
return Err(Error::new_spanned(
|
||||
input,
|
||||
"methods with `self` are not supported by #[component]",
|
||||
));
|
||||
};
|
||||
let Pat::Ident(PatIdent { ident, .. }) = typed.pat.as_ref() else {
|
||||
return Err(Error::new_spanned(
|
||||
&typed.pat,
|
||||
"component props must be simple identifier bindings",
|
||||
));
|
||||
};
|
||||
if ident == "children" {
|
||||
return Err(Error::new_spanned(
|
||||
ident,
|
||||
"`children` is reserved for the generated builder finalizer",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
match &signature.output {
|
||||
ReturnType::Default => Err(Error::new_spanned(
|
||||
&signature.ident,
|
||||
"components must return a view-like value",
|
||||
)),
|
||||
ReturnType::Type(_, _) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
struct Prop {
|
||||
ident: Ident,
|
||||
ty: Type,
|
||||
}
|
||||
|
||||
fn parse_prop(input: &FnArg) -> Result<Prop> {
|
||||
let FnArg::Typed(typed) = input else {
|
||||
return Err(Error::new_spanned(
|
||||
input,
|
||||
"methods with `self` are not supported by #[component]",
|
||||
));
|
||||
};
|
||||
let Pat::Ident(PatIdent { ident, .. }) = typed.pat.as_ref() else {
|
||||
return Err(Error::new_spanned(
|
||||
&typed.pat,
|
||||
"component props must be simple identifier bindings",
|
||||
));
|
||||
};
|
||||
|
||||
Ok(Prop {
|
||||
ident: ident.clone(),
|
||||
ty: (*typed.ty).clone(),
|
||||
})
|
||||
}
|
||||
|
||||
struct ViewRoot {
|
||||
root: Node,
|
||||
}
|
||||
|
||||
impl Parse for ViewRoot {
|
||||
fn parse(input: ParseStream<'_>) -> Result<Self> {
|
||||
let root = input.parse::<Node>()?;
|
||||
if !input.is_empty() {
|
||||
return Err(input.error("view! expects a single root node"));
|
||||
}
|
||||
Ok(Self { root })
|
||||
}
|
||||
}
|
||||
|
||||
struct Node {
|
||||
path: Path,
|
||||
props: Vec<Property>,
|
||||
children: Vec<Child>,
|
||||
}
|
||||
|
||||
struct Property {
|
||||
name: Ident,
|
||||
value: Expr,
|
||||
}
|
||||
|
||||
enum Child {
|
||||
Node(Node),
|
||||
Expr(Expr),
|
||||
}
|
||||
|
||||
impl Parse for Node {
|
||||
fn parse(input: ParseStream<'_>) -> Result<Self> {
|
||||
let path = input.parse::<Path>()?;
|
||||
|
||||
let props_content;
|
||||
parenthesized!(props_content in input);
|
||||
let props = Punctuated::<Property, Token![,]>::parse_terminated(&props_content)?
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let children_content;
|
||||
braced!(children_content in input);
|
||||
let mut children = Vec::new();
|
||||
while !children_content.is_empty() {
|
||||
if looks_like_node(&children_content)? {
|
||||
children.push(Child::Node(children_content.parse()?));
|
||||
} else {
|
||||
let expr = children_content.parse::<Expr>()?;
|
||||
if children_content.peek(Token![;]) {
|
||||
let _ = children_content.parse::<Token![;]>()?;
|
||||
}
|
||||
children.push(Child::Expr(expr));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
path,
|
||||
props,
|
||||
children,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Parse for Property {
|
||||
fn parse(input: ParseStream<'_>) -> Result<Self> {
|
||||
Ok(Self {
|
||||
name: input.parse()?,
|
||||
value: {
|
||||
let _ = input.parse::<Token![=]>()?;
|
||||
input.parse()?
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_node(input: ParseStream<'_>) -> Result<bool> {
|
||||
let fork = input.fork();
|
||||
if fork.parse::<Path>().is_err() {
|
||||
return Ok(false);
|
||||
}
|
||||
if !fork.peek(syn::token::Paren) {
|
||||
return Ok(false);
|
||||
}
|
||||
let props_content;
|
||||
parenthesized!(props_content in fork);
|
||||
let _ = Punctuated::<Property, Token![,]>::parse_terminated(&props_content)?;
|
||||
Ok(fork.peek(syn::token::Brace))
|
||||
}
|
||||
|
||||
fn expand_node(node: &Node) -> proc_macro2::TokenStream {
|
||||
let path = &node.path;
|
||||
let prop_calls = node.props.iter().map(|property| {
|
||||
let name = &property.name;
|
||||
let value = &property.value;
|
||||
quote! { .#name(#value) }
|
||||
});
|
||||
let children = expand_children(&node.children);
|
||||
|
||||
if is_component_path(path) {
|
||||
quote! {
|
||||
#path::builder()
|
||||
#(#prop_calls)*
|
||||
.children(#children)
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
::ruin_app::#path()
|
||||
#(#prop_calls)*
|
||||
.children(#children)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_children(children: &[Child]) -> proc_macro2::TokenStream {
|
||||
match children {
|
||||
[] => quote! { () },
|
||||
[child] => expand_child(child),
|
||||
many => {
|
||||
let items = many.iter().map(expand_child);
|
||||
quote! { ( #(#items),* ) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_child(child: &Child) -> proc_macro2::TokenStream {
|
||||
match child {
|
||||
Child::Node(node) => expand_node(node),
|
||||
Child::Expr(expr) => quote! { #expr },
|
||||
}
|
||||
}
|
||||
|
||||
fn is_component_path(path: &Path) -> bool {
|
||||
path.segments
|
||||
.last()
|
||||
.map(|segment| segment.ident.to_string())
|
||||
.and_then(|name| name.chars().next())
|
||||
.map(|ch| ch.is_ascii_uppercase())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
Reference in New Issue
Block a user