Proc macros for components, views

This commit is contained in:
2026-03-21 20:50:13 -04:00
parent 497dff987e
commit 437b318ad9
6 changed files with 625 additions and 5 deletions

View 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)
}