use convert_case::{Case, Casing}; use indoc::{formatdoc, indoc}; use proc_macro2::TokenStream as TokenStream2; use quote::{ToTokens, format_ident, quote}; use syn::parse::{Parse, ParseStream, Parser}; use syn::punctuated::Punctuated; use syn::spanned::Spanned; use syn::token::{Comma, RArrow}; use syn::{ AttrStyle, Attribute, Error, Expr, FnArg, GenericArgument, GenericParam, Ident, ItemFn, Lit, LitFloat, LitInt, LitStr, Meta, Pat, PatIdent, PatType, Path, PathArguments, ReturnType, TraitBound, Type, TypeImplTrait, TypeParam, TypeParamBound, Visibility, WhereClause, parse_quote, }; use crate::codegen::generate_node_code; use crate::crate_ident::CrateIdent; use crate::shader_nodes::ShaderNodeType; #[derive(Clone, Debug)] pub(crate) struct Implementation { pub(crate) input: Type, pub(crate) _arrow: RArrow, pub(crate) output: Type, } #[derive(Debug)] pub(crate) struct ParsedNodeFn { pub(crate) vis: Visibility, pub(crate) attributes: NodeFnAttributes, pub(crate) fn_name: Ident, pub(crate) struct_name: Ident, pub(crate) mod_name: Ident, pub(crate) fn_generics: Vec, pub(crate) where_clause: Option, pub(crate) input: Input, pub(crate) output_type: Type, pub(crate) output_depth: u8, pub(crate) is_async: bool, pub(crate) fields: Vec, /// The caller's frame claim, declared by a record-opaque kernel that /// serves through it; not a wired input. pub(crate) claim: Option, pub(crate) body: TokenStream2, pub(crate) description: String, } /// An `Attr` slot in a parameter's read tuple: a declared attribute /// read on that input, not a wired input of its own. #[derive(Clone, Debug)] pub(crate) struct AttributeRead { pub(crate) pat_ident: PatIdent, pub(crate) marker: Type, } /// One attribute write slot: the marker, and whether it crosses as an owned /// copy (`OwnedAttr`) instead of an evaluation-lifetime value (`Attr`). pub(crate) struct AttrWrite { pub(crate) marker: Type, pub(crate) owned: bool, } /// The write half of a record kernel's return: the element type in the first /// tuple slot, then the attribute markers written and the ones removed. `None` /// unless the value is a well-formed write tuple (a non-marker element first, /// then only `Attr`, `OwnedAttr` and `RemoveAttr` slots, at least one). pub(crate) struct RecordWrites { pub(crate) element: Type, pub(crate) markers: Vec, pub(crate) removes: Vec, } pub(crate) fn record_writes(value: &Type) -> Option { let Type::Tuple(tuple) = value else { return None }; let mut slots = tuple.elems.iter(); let element = slots.next()?; if attr_marker(element).is_some() || owned_attr_marker(element).is_some() || remove_attr_marker(element).is_some() { return None; } let mut markers = Vec::new(); let mut removes = Vec::new(); for slot in slots { if let Some(marker) = attr_marker(slot) { markers.push(AttrWrite { marker, owned: false }); } else if let Some(marker) = owned_attr_marker(slot) { markers.push(AttrWrite { marker, owned: true }); } else if let Some(marker) = remove_attr_marker(slot) { removes.push(marker); } else { return None; } } (!markers.is_empty() || !removes.is_empty()).then(|| RecordWrites { element: element.clone(), markers, removes, }) } /// Returns the marker type of an `Attr` type, if `ty` is one. pub(crate) fn attr_marker(ty: &Type) -> Option { marker_of(ty, "Attr") } /// Returns the marker type of an `OwnedAttr` type, if `ty` is one. pub(crate) fn owned_attr_marker(ty: &Type) -> Option { marker_of(ty, "OwnedAttr") } /// Returns the marker type of a `RemoveAttr` type, if `ty` is one. pub(crate) fn remove_attr_marker(ty: &Type) -> Option { marker_of(ty, "RemoveAttr") } /// Splits a `Named` marker into its placeholder and value type. A write /// of one takes its name from the input the placeholder is declared at rather /// than from the marker, so the name folds at graph compile time. pub(crate) fn named_marker(ty: &Type) -> Option<(Type, Type)> { let mut args = named_arguments(ty)?.into_iter(); let (placeholder, value) = (args.next()?, args.next()?); args.next().is_none().then_some((placeholder, value)) } /// The placeholder a `Named` parameter declares. Such a parameter is the /// name source for every `Attr>` the signature writes, and crosses /// the wire as constant text. pub(crate) fn named_source(ty: &Type) -> Option { let mut args = named_arguments(ty)?.into_iter(); let placeholder = args.next()?; args.next().is_none().then_some(placeholder) } fn named_arguments(ty: &Type) -> Option> { let Type::Path(path) = ty else { return None }; let segment = path.path.segments.last()?; if segment.ident != "Named" { return None; } let PathArguments::AngleBracketed(args) = &segment.arguments else { return None }; Some( args.args .iter() .filter_map(|argument| match argument { GenericArgument::Type(ty) => Some(ty.clone()), _ => None, }) .collect(), ) } fn marker_of(ty: &Type, wrapper: &str) -> Option { let Type::Path(path) = ty else { return None }; let segment = path.path.segments.last()?; if segment.ident != wrapper { return None; } let PathArguments::AngleBracketed(args) = &segment.arguments else { return None }; let mut types = args.args.iter().filter_map(|argument| match argument { GenericArgument::Type(ty) => Some(ty), _ => None, }); let marker = types.next()?; types.next().is_none().then(|| marker.clone()) } #[derive(Debug, Default, Clone)] pub(crate) struct NodeFnAttributes { pub(crate) category: Option, pub(crate) display_name: Option, pub(crate) path: Option, pub(crate) skip_impl: bool, pub(crate) properties_string: Option, /// whether to `#[cfg]` gate the node implementation, defaults to None pub(crate) cfg: Option, /// if this node should get a gpu implementation, defaults to None pub(crate) shader_node: Option, /// Custom serialization function path (e.g., "my_module::custom_serialize") pub(crate) serialize: Option, /// Whether the preprocessor should add a Memoize node after this node in the generated subnetwork pub(crate) memoize: bool, /// Whether this node provides a scope pub(crate) inject_scope: bool, /// Function producing a stand-in value while an async source node's real value is in flight pub(crate) placeholder: Option, /// Function overriding the generated `extent` method pub(crate) extent: Option, /// Function overriding the generated `extent` method with the raw node/ctx/level form pub(crate) extent_raw: Option, /// Function overriding the generated `eval_batch` method pub(crate) batch: Option, /// Whether partial upstream values are mapped to `Pending` instead of flowing into this node pub(crate) no_partial: bool, /// Whether this node keeps the plain-input lowering during the record transition pub(crate) plain: bool, } #[derive(Clone, Debug, Default)] pub enum ParsedValueSource { #[default] None, Default(TokenStream2), Scope(Box), SourceId, } // #[widget(ParsedWidgetOverride::Hidden)] // #[widget(ParsedWidgetOverride::String = "Some string")] // #[widget(ParsedWidgetOverride::Custom = "Custom string")] #[derive(Clone, Debug, Default)] pub enum ParsedWidgetOverride { #[default] None, Hidden, String(LitStr), Custom(LitStr), } impl Parse for ParsedWidgetOverride { fn parse(input: ParseStream) -> syn::Result { // Parse the full path (e.g., ParsedWidgetOverride::Hidden) let path: Path = input.parse()?; // Ensure the path starts with `ParsedWidgetOverride` if path.segments.len() == 2 && path.segments[0].ident == "ParsedWidgetOverride" { let variant = &path.segments[1].ident; match variant.to_string().as_str() { "Hidden" => Ok(ParsedWidgetOverride::Hidden), "String" => { input.parse::()?; let lit: LitStr = input.parse()?; Ok(ParsedWidgetOverride::String(lit)) } "Custom" => { input.parse::()?; let lit: LitStr = input.parse()?; Ok(ParsedWidgetOverride::Custom(lit)) } _ => Err(Error::new(variant.span(), "Unknown ParsedWidgetOverride variant")), } } else { Err(Error::new(input.span(), "Expected ParsedWidgetOverride::")) } } } #[derive(Clone, Debug)] pub struct ParsedField { pub pat_ident: PatIdent, pub name: Option, pub description: String, pub widget_override: ParsedWidgetOverride, pub ty: ParsedFieldType, pub number_display_decimal_places: Option, pub number_step: Option, pub unit: Option, pub is_data_field: bool, /// The attribute reads destructured from this input's tuple, resolved /// against this input. pub(crate) attribute_reads: Vec, } // Both variants are large parsed-syntax payloads (888/672 bytes), so boxing one still leaves the other large while forcing a // deref on every pattern match across codegen; this is built once per node at compile time, never on a hot path #[allow(clippy::large_enum_variant)] #[derive(Clone, Debug)] pub enum ParsedFieldType { Regular(RegularParsedField), Node(NodeParsedField), } impl ParsedFieldType { /// The shared value-field data, present for every value field but not a lazy `Node`. pub fn regular(&self) -> Option<&RegularParsedField> { match self { ParsedFieldType::Regular(field) => Some(field), ParsedFieldType::Node(_) => None, } } } /// A single numeric endpoint within a `#[soft(..)]` or `#[hard(..)]` bounds range. /// Accepts both integer literals (e.g. `1`, `-1`) and float literals (e.g. `1.`, `-500.`). #[derive(Clone, Debug)] pub struct NumberBound { is_negative: bool, literal: NumberBoundLiteral, } #[derive(Clone, Debug)] enum NumberBoundLiteral { Float(LitFloat), Int(LitInt), } impl NumberBound { pub fn to_f64(&self) -> f64 { let magnitude = match &self.literal { NumberBoundLiteral::Float(lit) => lit.base10_parse::().unwrap_or_default(), NumberBoundLiteral::Int(lit) => lit.base10_parse::().unwrap_or_default() as f64, }; if self.is_negative { -magnitude } else { magnitude } } } impl Parse for NumberBound { fn parse(input: ParseStream) -> syn::Result { let is_negative = input.peek(syn::Token![-]); if is_negative { let _: syn::Token![-] = input.parse()?; } let literal = if input.peek(LitFloat) { NumberBoundLiteral::Float(input.parse()?) } else if input.peek(LitInt) { NumberBoundLiteral::Int(input.parse()?) } else { return Err(input.error("expected a numeric literal (integer or float)")); }; Ok(NumberBound { is_negative, literal }) } } impl ToTokens for NumberBound { fn to_tokens(&self, stream: &mut TokenStream2) { match (&self.literal, self.is_negative) { (NumberBoundLiteral::Float(lit), false) => lit.to_tokens(stream), (NumberBoundLiteral::Float(lit), true) => stream.extend(quote!(-#lit)), (NumberBoundLiteral::Int(lit), false) => stream.extend(quote!(#lit as f64)), (NumberBoundLiteral::Int(lit), true) => stream.extend(quote!(-(#lit as f64))), } } } /// A pair of numeric bounds parsed from the `#[soft(a..b)]` and `#[hard(a..b)]` attributes. /// Either endpoint may be omitted for an open-ended bound (`a..` or `..b`), and each endpoint /// independently accepts an integer or float literal (each cast to `f64`), so a mixed range like /// `0..3.14159` is valid. /// /// The operator is always the bare `..`; both endpoints are treated as inclusive (clamping reaches them). /// Unlike a Rust range there is no `..=` form, `..` is purely this attribute DSL's bounds operator. #[derive(Clone, Debug)] pub struct NumberRange { start: Option, end: Option, } impl Parse for NumberRange { fn parse(input: ParseStream) -> syn::Result { if input.is_empty() { return Err(input.error("expected a range like `0..100`, `..100`, or `0..`")); } // A leading endpoint is present unless the range opens directly into the `..` operator. let start = if input.peek(syn::Token![..=]) || input.peek(syn::Token![..]) { None } else { Some(input.parse::()?) }; // Only the bare `..` is accepted. `..=` is rejected even though both endpoints are inclusive here: // this DSL treats `..` as its own bounds operator, deliberately diverging from Rust's range semantics. if input.peek(syn::Token![..=]) { return Err(input.error("use `..` rather than `..=` for number bounds; both endpoints are always inclusive (e.g. `0..100`)")); } if !input.peek(syn::Token![..]) { return Err(input.error("expected a range like `0..100`, `..100`, or `0..`")); } input.parse::()?; let end = if input.is_empty() { None } else { Some(input.parse::()?) }; if start.is_none() && end.is_none() { return Err(input.error("a bounds range must specify at least a lower or upper bound")); } Ok(NumberRange { start, end }) } } /// a param of any kind, either a concrete type or a generic type with a set of possible types specified via /// `#[implementation(type)]` #[derive(Clone, Debug)] pub struct RegularParsedField { pub ty: Type, /// The placeholder this parameter names, written `Named`. Its `ty` is /// rewritten to `String`, since the wire carries the name as constant text /// while the kernel takes only the placeholder. pub name_source: Option, /// `IList` nesting stripped from `ty` at parse; `ty` holds the element row. pub list_levels: u8, /// The original reference tokens when the parameter was written `&T`; `ty` holds the peeled inner type. pub lend: Option, pub exposed: bool, pub value_source: ParsedValueSource, pub number_soft_min: Option, pub number_soft_max: Option, pub number_hard_min: Option, pub number_hard_max: Option, /// Whether the number input renders as a draggable slider (the `#[range]` attribute) rather than the default increment field. pub number_mode_range: bool, pub implementations: Punctuated, pub gpu_image: bool, } /// a param of `impl Node` with `#[implementation(in -> out)]` #[derive(Clone, Debug)] pub struct NodeParsedField { pub input_type: Type, pub output_type: Type, pub implementations: Punctuated, } #[derive(Clone, Debug)] pub(crate) struct Input { pub(crate) pat_ident: PatIdent, pub(crate) ty: Type, pub(crate) implementations: Punctuated, pub(crate) context_features: Vec, } impl Parse for Implementation { fn parse(input: ParseStream) -> syn::Result { let input_type: Type = input.parse().map_err(|e| { Error::new( input.span(), formatdoc!( "Failed to parse input type for #[implementation(...)]. Expected a valid Rust type. Error: {}", e, ), ) })?; let arrow: RArrow = input.parse().map_err(|_| { Error::new( input.span(), indoc!( "Expected `->` arrow after input type in #[implementations(...)] on a field of type `impl Node`. The correct syntax is `InputType -> OutputType`." ), ) })?; let output_type: Type = input.parse().map_err(|e| { Error::new( input.span(), formatdoc!( "Failed to parse output type for #[implementation(...)]. Expected a valid Rust type after `->`. Error: {}", e ), ) })?; Ok(Implementation { input: input_type, _arrow: arrow, output: output_type, }) } } impl Parse for NodeFnAttributes { fn parse(input: ParseStream) -> syn::Result { let mut category = None; let mut display_name = None; let mut path = None; let mut skip_impl = false; let mut properties_string = None; let mut cfg = None; let mut shader_node = None; let mut serialize = None; let mut memoize = false; let mut inject_scope = false; let mut placeholder = None; let mut extent = None; let mut extent_raw = None; let mut batch = None; let mut no_partial = false; let mut plain = false; let content = input; // let content; // syn::parenthesized!(content in input); let nested = content.call(Punctuated::::parse_terminated)?; for meta in nested.iter() { let name = meta.path().get_ident().ok_or_else(|| Error::new_spanned(meta.path(), "Node macro expects a known Ident, not a path"))?; match name.to_string().as_str() { // User-facing category in the node catalog. The empty string `category("")` hides the node from the catalog. // // Example usage: // #[node_macro::node(..., category("Math: Arithmetic"), ...)] "category" => { let meta = meta.require_list()?; if category.is_some() { return Err(Error::new_spanned(meta, "Multiple 'category' attributes are not allowed")); } let lit: LitStr = meta .parse_args() .map_err(|_| Error::new_spanned(meta, "Expected a string literal for 'category', e.g., category(\"Value\")"))?; category = Some(lit); } // Override for the display name in the node catalog in place of the auto-generated name taken from the function name with inferred Title Case formatting. // Use this if capitalization or formatting needs to be overridden. // // Example usage: // #[node_macro::node(..., name("Request URL"), ...)] "name" => { let meta = meta.require_list()?; if display_name.is_some() { return Err(Error::new_spanned(meta, "Multiple 'name' attributes are not allowed")); } let parsed_name: LitStr = meta.parse_args().map_err(|_| Error::new_spanned(meta, "Expected a string for 'name', e.g., name(\"Memoize\")"))?; display_name = Some(parsed_name); } // Override for the fully qualified path used by Graphene to identify the node implementation. // If not provided, the path will be inferred from the module path and function name. // Use this if the node implementation has moved to a different module or crate but a migration to that new path is not desired. // // Example usage: // #[node_macro::node(..., path(core_types::vector), ...)] "path" => { let meta = meta.require_list()?; if path.is_some() { return Err(Error::new_spanned(meta, "Multiple 'path' attributes are not allowed")); } let parsed_path: Path = meta .parse_args() .map_err(|_| Error::new_spanned(meta, "Expected a valid path for 'path', e.g., path(crate::MemoizeNode)"))?; path = Some(parsed_path); } // Indicator that the node should allow generic type arguments but skip the automatic generation of concrete type implementations. // It allows the type arguments in this node to not include the normally required `#[implementations(...)]` attribute on each generic parameter. // Instead, concrete implementations must be manually listed in the Node Registry, or where impossible, produced at runtime by the compile server. // This is used by a few advanced nodes that need to support many types where listing them all would be cumbersome or impossible. // // Example usage: // #[node_macro::node(..., skip_impl, ...)] "skip_impl" => { let path = meta.require_path_only()?; if skip_impl { return Err(Error::new_spanned(path, "Multiple 'skip_impl' attributes are not allowed")); } skip_impl = true; } // Override UI layout generator function name defined in `node_properties.rs` that returns a custom Properties panel layout for this node. // This is used to create custom UI for the input parameters of the node in cases where the defaults generated from the type and attributes are insufficient. // // Example usage: // #[node_macro::node(..., properties("channel_mixer_properties"), ...)] "properties" => { let meta = meta.require_list()?; if properties_string.is_some() { return Err(Error::new_spanned(path, "Multiple 'properties' attributes are not allowed")); } let parsed_properties_string: LitStr = meta .parse_args() .map_err(|_| Error::new_spanned(meta, "Expected a string for 'properties', e.g., properties(\"channel_mixer_properties\")"))?; properties_string = Some(parsed_properties_string); } // Conditional compilation tokens to gate when this node is included in the build. // // Example usage: // #[node_macro::node(..., cfg(feature = "std"), ...)] "cfg" => { if cfg.is_some() { return Err(Error::new_spanned(path, "Multiple 'cfg' attributes are not allowed")); } let meta = meta.require_list()?; cfg = Some(meta.tokens.clone()); } // Reference to a specific shader definition struct that is used to run the logic of this node on the GPU. // // Example usage: // #[node_macro::node(..., shader_node(PerPixelAdjust), ...)] "shader_node" => { if shader_node.is_some() { return Err(Error::new_spanned(path, "Multiple 'shader_node' attributes are not allowed")); } let meta = meta.require_list()?; shader_node = Some(syn::parse2(meta.tokens.to_token_stream())?); } // Function name for custom serialization of this node's data. This is only used by the Monitor node. // // Example usage: // #[node_macro::node(..., serialize(my_module::custom_serialize), ...)] "serialize" => { let meta = meta.require_list()?; if serialize.is_some() { return Err(Error::new_spanned(meta, "Multiple 'serialize' attributes are not allowed")); } let parsed_path: Path = meta .parse_args() .map_err(|_| Error::new_spanned(meta, "Expected a valid path for 'serialize', e.g., serialize(my_module::custom_serialize)"))?; serialize = Some(parsed_path); } // Instructs the preprocessor to insert a Memoize node after this node in the generated subnetwork, // caching its output across evaluations with identical inputs. // // Example usage: // #[node_macro::node(..., memoize, ...)] "memoize" => { let path = meta.require_path_only()?; if memoize { return Err(Error::new_spanned(path, "Multiple 'memoize' attributes are not allowed")); } memoize = true; } // Instructs the preprocessor to make this node available as a scope. // Other nodes can then access it with `#[scope(node::IDENTIFIER)]`. // // Example usage: // #[node_macro::node(..., inject_scope, ...)] "inject_scope" => { let path = meta.require_path_only()?; if inject_scope { return Err(Error::new_spanned(path, "Multiple 'inject_scope' attributes are not allowed")); } inject_scope = true; } // Function producing a stand-in value for an async source node while the spawned future is in flight. // The node reports `Partial` with the stand-in until the real value lands; without a placeholder it reports `Pending`. // // Example usage: // #[node_macro::node(..., placeholder(empty_image), ...)] "placeholder" => { let meta = meta.require_list()?; if placeholder.is_some() { return Err(Error::new_spanned(meta, "Multiple 'placeholder' attributes are not allowed")); } let parsed_path: Path = meta .parse_args() .map_err(|_| Error::new_spanned(meta, "Expected a valid path for 'placeholder', e.g., placeholder(empty_image)"))?; placeholder = Some(parsed_path); } // Function overriding the generated `extent` method, replacing the default meet over the node's inputs. // // Example usage: // #[node_macro::node(..., extent(my_extent), ...)] "extent" => { let meta = meta.require_list()?; if extent.is_some() { return Err(Error::new_spanned(meta, "Multiple 'extent' attributes are not allowed")); } let parsed_path: Path = meta.parse_args().map_err(|_| Error::new_spanned(meta, "Expected a valid path for 'extent', e.g., extent(my_extent)"))?; extent = Some(parsed_path); } // Escape hatch for extent overrides needing arbitrary context access: the raw // `(node, ctx, level)` form instead of the typed `extent(fn)` input surface. // // Example usage: // #[node_macro::node(..., extent_raw(my_extent), ...)] "extent_raw" => { let meta = meta.require_list()?; if extent_raw.is_some() { return Err(Error::new_spanned(meta, "Multiple 'extent_raw' attributes are not allowed")); } let parsed_path: Path = meta .parse_args() .map_err(|_| Error::new_spanned(meta, "Expected a valid path for 'extent_raw', e.g., extent_raw(my_extent)"))?; extent_raw = Some(parsed_path); } // Function overriding the generated `eval_batch` method, replacing the trait's per-lane spec loop. // // Example usage: // #[node_macro::node(..., batch(my_batch), ...)] "batch" => { let meta = meta.require_list()?; if batch.is_some() { return Err(Error::new_spanned(meta, "Multiple 'batch' attributes are not allowed")); } let parsed_path: Path = meta.parse_args().map_err(|_| Error::new_spanned(meta, "Expected a valid path for 'batch', e.g., batch(my_batch)"))?; batch = Some(parsed_path); } // Keeps the plain-input lowering for this node during the record transition. // // Example usage: // #[node_macro::node(..., plain, ...)] "plain" => { let path = meta.require_path_only()?; if plain { return Err(Error::new_spanned(path, "Multiple 'plain' attributes are not allowed")); } plain = true; } // Instructs the generated eval to report `Pending` instead of passing partial upstream values into this node. // // Example usage: // #[node_macro::node(..., no_partial, ...)] "no_partial" => { let path = meta.require_path_only()?; if no_partial { return Err(Error::new_spanned(path, "Multiple 'no_partial' attributes are not allowed")); } no_partial = true; } _ => { return Err(Error::new_spanned( meta, indoc!( r#" Unsupported attribute in `node`. Supported attributes are 'category', 'name', 'path', 'skip_impl', 'properties', 'cfg', 'shader_node', 'serialize', 'memoize', 'inject_scope', 'placeholder', 'extent', 'extent_raw', 'batch', and 'no_partial'. Example usage: #[node_macro::node(..., name("Test Node"), ...)] "# ), )); } } } if category.is_none() { return Err(Error::new_spanned( nested, indoc!( r#" The attribute 'category' is required. Example usage: #[node_macro::node(..., category("Value"), ...)] "#, ), )); } if let (Some(_), Some(raw)) = (&extent, &extent_raw) { return Err(Error::new_spanned(raw, "'extent' and 'extent_raw' are mutually exclusive")); } Ok(NodeFnAttributes { category, display_name, path, skip_impl, properties_string, cfg, shader_node, serialize, memoize, inject_scope, placeholder, extent, extent_raw, batch, no_partial, plain, }) } } pub(crate) fn parse_node_fn(attr: TokenStream2, item: TokenStream2) -> syn::Result { let attributes = syn::parse2::(attr.clone()).map_err(|e| Error::new(e.span(), format!("Failed to parse node_fn attributes:\n{e}")))?; let input_fn = syn::parse2::(item.clone()).map_err(|e| Error::new(e.span(), format!("Failed to parse function: {e}. Make sure it's a valid Rust function.")))?; let vis = input_fn.vis; let fn_name = input_fn.sig.ident.clone(); let struct_name = format_ident!("{}", fn_name.to_string().to_case(Case::Pascal)); let mod_name = fn_name.clone(); let fn_generics = input_fn.sig.generics.params.into_iter().collect(); let is_async = input_fn.sig.asyncness.is_some(); let (input, fields, claim) = parse_inputs(&input_fn.sig.inputs)?; let (output_type, output_depth) = crate::codegen::ir::strip_output_rank(&parse_output(&input_fn.sig.output)?); let where_clause = input_fn.sig.generics.where_clause; let body = input_fn.block.to_token_stream(); let description = input_fn .attrs .iter() .filter_map(|a| { if a.style != AttrStyle::Outer { return None; } let Meta::NameValue(name_val) = &a.meta else { return None }; if name_val.path.get_ident().map(|x| x.to_string()) != Some("doc".into()) { return None; } let Expr::Lit(expr_lit) = &name_val.value else { return None }; let Lit::Str(ref text) = expr_lit.lit else { return None }; Some(text.value().trim().to_string()) }) .fold(String::new(), |acc, b| acc + &b + "\n"); Ok(ParsedNodeFn { vis, attributes, fn_name, struct_name, mod_name, fn_generics, input, output_type, output_depth, is_async, fields, claim, where_clause, body, description, }) } fn parse_inputs(inputs: &Punctuated) -> syn::Result<(Input, Vec, Option)> { let mut fields = Vec::new(); let mut input = None; let mut claim = None; for (index, arg) in inputs.iter().enumerate() { if let FnArg::Typed(PatType { pat, ty, attrs, .. }) = arg { // Call argument if index == 0 { if extract_attribute(attrs, "default").is_some() { return Err(Error::new_spanned(&attrs[0], "Call argument cannot be given a default value".to_string())); } if extract_attribute(attrs, "expose").is_some() { return Err(Error::new_spanned(&attrs[0], "Call argument cannot be exposed".to_string())); } let pat_ident = match (**pat).clone() { Pat::Ident(pat_ident) => pat_ident, Pat::Wild(wild) => PatIdent { attrs: wild.attrs, by_ref: None, mutability: None, ident: wild.underscore_token.into(), subpat: None, }, _ => continue, }; let implementations = extract_attribute(attrs, "implementations") .map(|attr| parse_implementations(attr, &pat_ident.ident)) .transpose()? .unwrap_or_default(); let context_features = parse_context_feature_idents(ty); input = Some(Input { pat_ident, ty: (**ty).clone(), implementations, context_features, }); } else if let Pat::Ident(pat_ident) = &**pat { if attr_marker(ty).is_some() { return Err(Error::new_spanned(pat_ident, "an attribute read binds to an input: destructure it as `(value, Attr<..>)`")); } // The claim is the caller's, not an input: it reaches the kernel // from the serve the node is lowered into. if is_frame_claim(ty) { claim = Some(PatType { attrs: Vec::new(), pat: pat.clone(), colon_token: Default::default(), ty: ty.clone(), }); continue; } let field = parse_field(pat_ident.clone(), (**ty).clone(), attrs).map_err(|e| Error::new_spanned(pat_ident, format!("Failed to parse argument '{}': {}", pat_ident.ident, e)))?; fields.push(field); } else if let Pat::Tuple(pat_tuple) = &**pat { let field = parse_read_tuple(pat_tuple, ty, attrs, index)?; fields.push(field); } else if let Pat::Wild(wild) = &**pat { let pat_ident = PatIdent { attrs: wild.attrs.clone(), by_ref: None, mutability: None, ident: format_ident!("_unit{}", index, span = wild.underscore_token.span), subpat: None, }; let field = parse_field(pat_ident, (**ty).clone(), attrs).map_err(|e| Error::new_spanned(pat, format!("Failed to parse argument: {e}")))?; fields.push(field); } else { return Err(Error::new_spanned(pat, "Expected a simple identifier for the field name")); } } else { return Err(Error::new_spanned(arg, "Expected a typed argument (e.g., `x: i32`)")); } } let input = input.ok_or_else(|| Error::new_spanned(inputs, "Expected at least one input argument. The first argument should be the node input type."))?; Ok((input, fields, claim)) } /// Whether the parameter is the caller-provided frame claim a record-opaque /// kernel serves through. fn is_frame_claim(ty: &Type) -> bool { matches!(ty, Type::Path(path) if path.path.segments.last().is_some_and(|segment| segment.ident == "FrameClaim")) } /// Splits a lazy input's `Output = (T, Attr<..>..)` tuple into the element /// type (the input type) and the declared reads on that input. A tuple without /// `Attr` slots is an ordinary tuple output and passes through untouched. fn split_lazy_reads(output_type: Type) -> syn::Result<(Type, Vec)> { let Type::Tuple(tuple) = &output_type else { return Ok((output_type, Vec::new())); }; if !tuple.elems.iter().any(|slot| attr_marker(slot).is_some()) { return Ok((output_type, Vec::new())); } let spelling = "a lazy input with attribute reads declares `Output = (T, Attr<..>)`"; let mut slots = tuple.elems.iter(); let element = slots.next().ok_or_else(|| Error::new_spanned(tuple, spelling))?; if attr_marker(element).is_some() { return Err(Error::new_spanned(element, spelling)); } let attribute_reads: Vec = slots .enumerate() .map(|(index, slot)| { let marker = attr_marker(slot).ok_or_else(|| Error::new_spanned(slot, spelling))?; Ok(AttributeRead { pat_ident: PatIdent { attrs: Vec::new(), by_ref: None, mutability: None, ident: format_ident!("__lazy_read_{}", index, span = slot.span()), subpat: None, }, marker, }) }) .collect::>()?; Ok((element.clone(), attribute_reads)) } /// Parses a `(value, reads..): (T, Attr<..>..)` parameter: the value component /// is an ordinary field of the value type, each `Attr` component a read bound /// to this input. fn parse_read_tuple(pat_tuple: &syn::PatTuple, ty: &Type, attrs: &[Attribute], index: usize) -> syn::Result { let spelling = "an input with attribute reads destructures as `(value, Attr<..>)` over `(T, Attr<..>)`"; let Type::Tuple(ty_tuple) = ty else { return Err(Error::new_spanned(ty, spelling)); }; if pat_tuple.elems.len() != ty_tuple.elems.len() || ty_tuple.elems.len() < 2 { return Err(Error::new_spanned(pat_tuple, spelling)); } let mut slots = pat_tuple.elems.iter().zip(ty_tuple.elems.iter()); let (value_pat, value_ty) = slots.next().expect("length checked above"); if attr_marker(value_ty).is_some() { return Err(Error::new_spanned(value_ty, spelling)); } let value_ident = match value_pat { Pat::Ident(pat_ident) => pat_ident.clone(), Pat::Wild(wild) => PatIdent { attrs: wild.attrs.clone(), by_ref: None, mutability: None, ident: format_ident!("_value{}", index, span = wild.underscore_token.span), subpat: None, }, _ => return Err(Error::new_spanned(value_pat, "Expected a simple identifier for the value component")), }; let attribute_reads: Vec = slots .map(|(pat, ty)| { let marker = attr_marker(ty).ok_or_else(|| Error::new_spanned(ty, spelling))?; let Pat::Ident(pat_ident) = pat else { return Err(Error::new_spanned(pat, "Expected a simple identifier for the attribute read")); }; Ok(AttributeRead { pat_ident: pat_ident.clone(), marker }) }) .collect::>()?; let mut field = parse_field(value_ident.clone(), value_ty.clone(), attrs).map_err(|e| Error::new_spanned(&value_ident, format!("Failed to parse argument '{}': {}", value_ident.ident, e)))?; field.attribute_reads = attribute_reads; Ok(field) } /// A declared context feature; `ExtractIndex` carries the index level it reads. #[derive(Debug, Clone, PartialEq)] pub(crate) struct ContextFeatureDecl { pub(crate) ident: Ident, pub(crate) level: Option, } impl ContextFeatureDecl { pub(crate) fn new(ident: Ident) -> Self { Self { ident, level: None } } } impl quote::ToTokens for ContextFeatureDecl { fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { let ident = &self.ident; match self.level { Some(level) => tokens.extend(quote::quote!(#ident(#level))), None => ident.to_tokens(tokens), } } } /// The level of an `ExtractIndex` bound, defaulting to the innermost. fn parse_index_level(segment: &syn::PathSegment) -> u8 { let syn::PathArguments::AngleBracketed(arguments) = &segment.arguments else { return 0; }; for argument in &arguments.args { if let syn::GenericArgument::Const(syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Int(int), .. })) = argument && let Ok(level) = int.base10_parse::() { return level; } } 0 } /// Parse context feature identifiers from the trait bounds of a context parameter. fn parse_context_feature_idents(ty: &Type) -> Vec { let mut features = Vec::new(); // Check if this is an impl trait (impl Ctx + ...) if let Type::ImplTrait(TypeImplTrait { bounds, .. }) = ty { for bound in bounds { if let TypeParamBound::Trait(TraitBound { path, .. }) = bound { // Extract the last segment of the trait path if let Some(segment) = path.segments.last() { match segment.ident.to_string().as_str() { "ExtractIndex" => features.push(ContextFeatureDecl { ident: segment.ident.clone(), level: Some(parse_index_level(segment)), }), // Reading the chain without a statically known level keeps every level. "ExtractIndices" => features.push(ContextFeatureDecl { ident: format_ident!("ExtractIndex"), level: Some(u8::MAX), }), "ExtractFootprint" | "ExtractRealTime" | "ExtractAnimationTime" | "ExtractPointerPosition" | "ExtractPosition" | "ExtractVarArgs" | "InjectFootprint" | "InjectRealTime" | "InjectAnimationTime" | "InjectPointerPosition" | "InjectPosition" | "InjectVarArgs" => { features.push(ContextFeatureDecl::new(segment.ident.clone())); } // Modify* is conditionally transparent: the node rewrites the // field only on its content's behalf, so it names no // requirement of its own and the field nullifies early when // nothing upstream reads it. "ModifyFootprint" | "ModifyRealTime" | "ModifyAnimationTime" | "ModifyPointerPosition" | "ModifyPosition" | "ModifyIndex" | "ModifyVarArgs" => {} // InjectIndex stays undeclared: a record node's injection // re-addresses lanes derived from the incoming index, so it // must not cancel the cone's index requirement in the // nullification pass. // Also ignore other traits like Ctx, ExtractAll, etc. _ => {} } } } } } features } fn parse_implementations(attr: &Attribute, name: &Ident) -> syn::Result> { let content: TokenStream2 = attr.parse_args()?; let parser = Punctuated::::parse_terminated; parser.parse2(content.clone()).map_err(|e| { let span = e.span(); // Get the span of the error Error::new(span, format!("Failed to parse implementations for argument '{name}': {e}")) }) } fn parse_node_implementations(attr: &Attribute, name: &Ident) -> syn::Result> { let content: TokenStream2 = attr.parse_args()?; let parser = Punctuated::::parse_terminated; parser.parse2(content.clone()).map_err(|e| { Error::new( e.span(), formatdoc!( "Invalid #[implementations(...)] for argument `{}`. Expected a comma-separated list of `InputType -> OutputType` pairs. Example: #[implementations(i32 -> f64, String -> Vec)] Error: {}", name, e ), ) }) } fn parse_field(pat_ident: PatIdent, ty: Type, attrs: &[Attribute]) -> syn::Result { let (ty, list_levels) = crate::codegen::ir::strip_ilist(&ty); let ident = &pat_ident.ident; // Checks for the #[data] attribute, indicating that this is a data field rather than an input parameter to the node. // Data fields act as internal state, using interior mutability to cache data between node evaluations. // // Normally, an input parameter is a construction argument to the node that is stored as a field on the node struct. // Specifically, its struct field stores the connected upstream node (an evaluatable lambda that returns data of the connection's type). // By comparison, a data field is also stored as a field on the node struct, allowing it to persist state between evaluations. // But it acts as internal state only, not exposed as a parameter in the UI or able to be wired to another node. // // Nodes implemented using a data field must ensure the persistent state is used in a manner that respects the invariant of idempotence, // meaning the node's output is always deterministic whether or not the internal state is present. let is_data_field = extract_attribute(attrs, "data").is_some(); let default_value = extract_attribute(attrs, "default") .map(|attr| attr.parse_args().map_err(|e| Error::new_spanned(attr, format!("Invalid `default` value for argument '{ident}': {e}")))) .transpose()?; let scope = extract_attribute(attrs, "scope") .map(|attr| attr.parse_args().map_err(|e| Error::new_spanned(attr, format!("Invalid `scope` value for argument '{ident}': {e}")))) .transpose()?; let name = extract_attribute(attrs, "name") .map(|attr| attr.parse_args().map_err(|e| Error::new_spanned(attr, format!("Invalid `name` value for argument '{ident}': {e}")))) .transpose()?; let widget_override = extract_attribute(attrs, "widget") .map(|attr| { attr.parse_args() .map_err(|e| Error::new_spanned(attr, format!("Invalid `widget override` value for argument '{ident}': {e}"))) }) .transpose()? .unwrap_or_default(); let exposed = extract_attribute(attrs, "expose").is_some(); // Validate data field attributes if is_data_field { if default_value.is_some() { return Err(Error::new_spanned( &pat_ident, "Data fields (#[data]) cannot have #[default] attribute. They are automatically initialized with Default::default()", )); } if scope.is_some() { return Err(Error::new_spanned(&pat_ident, "Data fields (#[data]) cannot have #[scope] attribute")); } if exposed { return Err(Error::new_spanned( &pat_ident, "Data fields (#[data]) cannot be exposed (#[expose]). They are internal state, not node parameters", )); } } let value_source = match (default_value, scope) { (Some(_), Some(_)) => return Err(Error::new_spanned(&pat_ident, "Cannot have both `default` and `scope` attributes")), (Some(default_value), _) => ParsedValueSource::Default(default_value), (_, Some(scope)) => ParsedValueSource::Scope(Box::new(scope)), _ => ParsedValueSource::None, }; // The slider's interactive extent (`#[soft(a..b)]`) and the enforced clamp (`#[hard(a..b)]`), each an // optionally open-ended range. They decompose into the four bound values used by codegen and the UI. let number_soft_bounds = extract_attribute(attrs, "soft") .map(|attr| { attr.parse_args::() .map_err(|e| Error::new_spanned(attr, format!("Invalid `soft` bounds for argument '{ident}': {e}\nUSAGE EXAMPLE: #[soft(0..100)]"))) }) .transpose()?; let number_hard_bounds = extract_attribute(attrs, "hard") .map(|attr| { attr.parse_args::() .map_err(|e| Error::new_spanned(attr, format!("Invalid `hard` bounds for argument '{ident}': {e}\nUSAGE EXAMPLE: #[hard(0..100)]"))) }) .transpose()?; let number_soft_min = number_soft_bounds.as_ref().and_then(|range| range.start.clone()); let number_soft_max = number_soft_bounds.as_ref().and_then(|range| range.end.clone()); let number_hard_min = number_hard_bounds.as_ref().and_then(|range| range.start.clone()); let number_hard_max = number_hard_bounds.as_ref().and_then(|range| range.end.clone()); // The `#[range]` marker selects the slider widget; its extent is derived from the soft (then hard) bounds. let number_mode_range = extract_attribute(attrs, "range").is_some(); let unit = extract_attribute(attrs, "unit") .map(|attr| attr.parse_args::().map_err(|_e| Error::new_spanned(attr, "Expected a unit type as string".to_string()))) .transpose()?; let number_display_decimal_places = extract_attribute(attrs, "display_decimal_places") .map(|attr| { attr.parse_args::().map_err(|e| { Error::new_spanned( attr, format!("Invalid `integer` for number of decimals for argument '{ident}': {e}\nUSAGE EXAMPLE: #[display_decimal_places(2)]"), ) }) }) .transpose()? .map(|f| { if let Err(e) = f.base10_parse::() { Err(Error::new_spanned(f, format!("Expected a `u32` for `display_decimal_places` for '{ident}': {e}"))) } else { Ok(f) } }) .transpose()?; let number_step = extract_attribute(attrs, "step") .map(|attr| { attr.parse_args::() .map_err(|e| Error::new_spanned(attr, format!("Invalid `step` for argument '{ident}': {e}\nUSAGE EXAMPLE: #[step(2.)]"))) }) .transpose()?; let gpu_image = extract_attribute(attrs, "gpu_image").is_some(); let (is_node, node_input_type, node_output_type) = parse_node_type(&ty); let description = attrs .iter() .filter_map(|a| { if a.style != AttrStyle::Outer { return None; } let Meta::NameValue(name_val) = &a.meta else { return None }; if name_val.path.get_ident().map(|x| x.to_string()) != Some("doc".into()) { return None; } let Expr::Lit(expr_lit) = &name_val.value else { return None }; let Lit::Str(ref text) = expr_lit.lit else { return None }; Some(text.value().trim().to_string()) }) .fold(String::new(), |acc, b| acc + &b + "\n"); if is_node { // Data fields cannot be impl Node types if is_data_field { return Err(Error::new_spanned( &ty, "Data fields (#[data]) cannot be of type `impl Node`. Data fields must be concrete types that implement Default", )); } let input_type = node_input_type.ok_or_else(|| Error::new_spanned(&ty, "Invalid Node type. Expected `impl Node` or `impl Node`"))?; // A subject named without an output is a whole-record input: the kernel // serves it through its own claim rather than reading an element. let output_type = node_output_type.unwrap_or_else(|| syn::parse_quote!(Served<'_>)); if !matches!(&value_source, ParsedValueSource::None) { return Err(Error::new_spanned(&ty, "No default values for `impl Node` allowed")); } let implementations = extract_attribute(attrs, "implementations") .map(|attr| parse_node_implementations(attr, ident)) .transpose()? .unwrap_or_default(); let (output_type, attribute_reads) = split_lazy_reads(output_type)?; Ok(ParsedField { pat_ident, ty: ParsedFieldType::Node(NodeParsedField { input_type, output_type, implementations, }), name, description, widget_override, number_display_decimal_places, number_step, unit, is_data_field, attribute_reads, }) } else { let implementations = extract_attribute(attrs, "implementations") .map(|attr| parse_implementations(attr, ident)) .transpose()? .unwrap_or_default(); let (ty, lend) = match ty { Type::Reference(reference) => ((*reference.elem).clone(), Some(reference)), ty => (ty, None), }; // Error if a float literal is given for a bound on an integer-typed field if is_integer_type(&ty) { let bound_attrs = [ (&number_soft_min, "soft", "lower"), (&number_soft_max, "soft", "upper"), (&number_hard_min, "hard", "lower"), (&number_hard_max, "hard", "upper"), ]; for (bound, attr_name, end) in bound_attrs { if let Some(NumberBound { literal: NumberBoundLiteral::Float(_), .. }) = bound { return Err(Error::new_spanned( &pat_ident, format!("The {end} `#[{attr_name}]` bound on `{ident}` is a float literal, but `{ident}` is an integer type. Use an integer literal without a decimal point."), )); } } } // A `Named` parameter declares where `X`'s name is wired: the input // carries constant text, the kernel takes only the placeholder. let name_source = named_source(&ty); let ty = match name_source { Some(_) => parse_quote!(String), None => ty, }; Ok(ParsedField { pat_ident, ty: ParsedFieldType::Regular(RegularParsedField { name_source, exposed, number_soft_min, number_soft_max, number_hard_min, number_hard_max, number_mode_range, ty, list_levels, lend, value_source, implementations, gpu_image, }), name, description, widget_override, number_display_decimal_places, number_step, unit, is_data_field, attribute_reads: Vec::new(), }) } } fn parse_node_type(ty: &Type) -> (bool, Option, Option) { if let Type::ImplTrait(impl_trait) = ty { for bound in &impl_trait.bounds { if let syn::TypeParamBound::Trait(trait_bound) = bound && trait_bound.path.segments.last().is_some_and(|seg| seg.ident == "Node") && let syn::PathArguments::AngleBracketed(args) = &trait_bound.path.segments.last().unwrap().arguments { let input_type = args.args.iter().find_map(|arg| if let syn::GenericArgument::Type(ty) = arg { Some(ty.clone()) } else { None }); let output_type = args.args.iter().find_map(|arg| { if let syn::GenericArgument::AssocType(assoc_type) = arg { if assoc_type.ident == "Output" { Some(assoc_type.ty.clone()) } else { None } } else { None } }); return (true, input_type, output_type); } } } (false, None, None) } fn is_integer_type(ty: &Type) -> bool { let Type::Path(type_path) = ty else { return false }; let Some(segment) = type_path.path.segments.last() else { return false }; matches!( segment.ident.to_string().as_str(), "u8" | "u16" | "u32" | "u64" | "u128" | "usize" | "i8" | "i16" | "i32" | "i64" | "i128" | "isize" ) } fn parse_output(output: &ReturnType) -> syn::Result { match output { ReturnType::Default => Ok(syn::parse_quote!(())), ReturnType::Type(_, ty) => Ok((**ty).clone()), } } fn extract_attribute<'a>(attrs: &'a [Attribute], name: &str) -> Option<&'a Attribute> { attrs.iter().find(|attr| attr.path().is_ident(name)) } // Modify the new_node_fn function to use the code generation pub fn new_node_fn(attr: TokenStream2, item: TokenStream2) -> syn::Result { let crate_ident = CrateIdent::default(); let mut parsed_node = parse_node_fn(attr, item.clone()).map_err(|e| Error::new(e.span(), format!("Failed to parse node function:\n{e}")))?; parsed_node.replace_impl_trait_in_input(); if parsed_node.injects_async_source_fields() { let core_types = crate_ident.gcore()?.clone(); parsed_node.inject_async_source_fields(&core_types); } crate::validation::validate_node_fn(&parsed_node).map_err(|e| Error::new(e.span(), format!("Validation error:\n{e}")))?; generate_node_code(&crate_ident, &parsed_node).map_err(|e| Error::new(e.span(), format!("Failed to generate node code:\n{e}"))) } impl ParsedNodeFn { pub fn replace_impl_trait_in_input(&mut self) { if let Type::ImplTrait(impl_trait) = self.input.ty.clone() { let ident = Ident::new("_Input", impl_trait.span()); let mut bounds = impl_trait.bounds; bounds.push(parse_quote!('n)); self.fn_generics.push(GenericParam::Type(TypeParam { attrs: Default::default(), ident: ident.clone(), colon_token: Some(Default::default()), bounds, eq_token: None, default: None, })); self.input.ty = parse_quote!(#ident); if self.input.implementations.is_empty() { self.input.implementations.push(parse_quote!(gcore::Context)); } } if self.input.pat_ident.ident == "_" { self.input.pat_ident.ident = Ident::new("__ctx", self.input.pat_ident.ident.span()); } } pub fn injects_async_source_fields(&self) -> bool { self.is_async || crate::codegen::is_source_kernel(&self.output_type) } pub fn inject_async_source_fields(&mut self, core_types: &TokenStream2) { let hidden_field = |name: &str, ty: Type, value_source: ParsedValueSource| ParsedField { pat_ident: PatIdent { attrs: Vec::new(), by_ref: None, mutability: None, ident: Ident::new(name, proc_macro2::Span::call_site()), subpat: None, }, name: None, description: String::new(), widget_override: ParsedWidgetOverride::Hidden, ty: ParsedFieldType::Regular(RegularParsedField { ty, name_source: None, list_levels: 0, lend: None, exposed: false, value_source, number_soft_min: None, number_soft_max: None, number_hard_min: None, number_hard_max: None, number_mode_range: false, implementations: Default::default(), gpu_image: false, }), number_display_decimal_places: None, number_step: None, unit: None, is_data_field: false, attribute_reads: Vec::new(), }; self.fields.push(hidden_field( "_runtime", parse_quote!(#core_types::runtime::RuntimeHandle), ParsedValueSource::Scope(Box::new(parse_quote!("graphene_std::runtime::RuntimeNode"))), )); self.fields.push(hidden_field("_source", parse_quote!(#core_types::SourceId), ParsedValueSource::SourceId)); } } #[cfg(test)] mod tests { use super::*; use proc_macro2::Span; use quote::{quote, quote_spanned}; use syn::parse_quote; fn pat_ident(name: &str) -> PatIdent { PatIdent { attrs: Vec::new(), by_ref: None, mutability: None, ident: Ident::new(name, Span::call_site()), subpat: None, } } fn assert_parsed_node_fn(parsed: &ParsedNodeFn, expected: &ParsedNodeFn) { assert_eq!(parsed.fn_name, expected.fn_name); assert_eq!(parsed.struct_name, expected.struct_name); assert_eq!(parsed.mod_name, expected.mod_name); assert_eq!(parsed.is_async, expected.is_async); assert_eq!(format!("{:?}", parsed.input), format!("{:?}", expected.input)); assert_eq!(format!("{:?}", parsed.output_type), format!("{:?}", expected.output_type)); assert_eq!(parsed.attributes.category, expected.attributes.category); assert_eq!(parsed.attributes.display_name, expected.attributes.display_name); assert_eq!(parsed.attributes.path, expected.attributes.path); assert_eq!(parsed.attributes.skip_impl, expected.attributes.skip_impl); assert_eq!(parsed.fields.len(), expected.fields.len()); assert_eq!(parsed.description, expected.description); for (parsed_field, expected_field) in parsed.fields.iter().zip(expected.fields.iter()) { match (parsed_field, expected_field) { ( ParsedField { pat_ident: p_name, ty: ParsedFieldType::Regular(RegularParsedField { ty: p_ty, exposed: p_exp, value_source: p_default, .. }), .. }, ParsedField { pat_ident: e_name, ty: ParsedFieldType::Regular(RegularParsedField { ty: e_ty, exposed: e_exp, value_source: e_default, .. }), .. }, ) => { assert_eq!(p_name, e_name); assert_eq!(p_exp, e_exp); match (p_default, e_default) { (ParsedValueSource::None, ParsedValueSource::None) => {} (ParsedValueSource::Default(p), ParsedValueSource::Default(e)) => { assert_eq!(p.to_token_stream().to_string(), e.to_token_stream().to_string()); } (ParsedValueSource::Scope(p), ParsedValueSource::Scope(e)) => { assert_eq!(p.to_token_stream().to_string(), e.to_token_stream().to_string()); } _ => panic!("Mismatched default values"), } assert_eq!(format!("{p_ty:?}"), format!("{:?}", e_ty)); } ( ParsedField { pat_ident: p_name, ty: ParsedFieldType::Node(NodeParsedField { input_type: p_input, output_type: p_output, .. }), .. }, ParsedField { pat_ident: e_name, ty: ParsedFieldType::Node(NodeParsedField { input_type: e_input, output_type: e_output, .. }), .. }, ) => { assert_eq!(p_name, e_name); assert_eq!(format!("{p_input:?}"), format!("{:?}", e_input)); assert_eq!(format!("{p_output:?}"), format!("{:?}", e_output)); } _ => panic!("Mismatched field types"), } } } #[test] fn test_basic_node() { let attr = quote!(category("Math: Arithmetic"), path(core_types::TestNode), skip_impl); let input = quote!( /// Multi /// Line fn add(a: f64, b: f64) -> f64 { a + b } ); let parsed = parse_node_fn(attr, input).unwrap(); let expected = ParsedNodeFn { vis: Visibility::Inherited, attributes: NodeFnAttributes { category: Some(parse_quote!("Math: Arithmetic")), display_name: None, path: Some(parse_quote!(core_types::TestNode)), skip_impl: true, properties_string: None, cfg: None, shader_node: None, serialize: None, memoize: false, inject_scope: false, placeholder: None, extent: None, extent_raw: None, batch: None, no_partial: false, plain: false, }, fn_name: Ident::new("add", Span::call_site()), struct_name: Ident::new("Add", Span::call_site()), mod_name: Ident::new("add", Span::call_site()), fn_generics: vec![], where_clause: None, input: Input { pat_ident: pat_ident("a"), ty: parse_quote!(f64), implementations: Punctuated::new(), context_features: vec![], }, output_type: parse_quote!(f64), output_depth: 0, is_async: false, claim: None, fields: vec![ParsedField { pat_ident: pat_ident("b"), name: None, description: String::new(), widget_override: ParsedWidgetOverride::None, ty: ParsedFieldType::Regular(RegularParsedField { name_source: None, lend: None, list_levels: 0, ty: parse_quote!(f64), exposed: false, value_source: ParsedValueSource::None, number_soft_min: None, number_soft_max: None, number_hard_min: None, number_hard_max: None, number_mode_range: false, implementations: Punctuated::new(), gpu_image: false, }), number_display_decimal_places: None, number_step: None, unit: None, is_data_field: false, attribute_reads: Vec::new(), }], body: TokenStream2::new(), description: String::from("Multi\nLine\n"), }; assert_parsed_node_fn(&parsed, &expected); } #[test] fn test_node_with_impl_node() { let attr = quote!(category("General")); let input = quote!( /** Hello World */ fn transform(footprint: Footprint, transform_target: impl Node, translate: DVec2) -> T { // Implementation details... } ); let parsed = parse_node_fn(attr, input).unwrap(); let expected = ParsedNodeFn { vis: Visibility::Inherited, attributes: NodeFnAttributes { category: Some(parse_quote!("General")), display_name: None, path: None, skip_impl: false, properties_string: None, cfg: None, shader_node: None, serialize: None, memoize: false, inject_scope: false, placeholder: None, extent: None, extent_raw: None, batch: None, no_partial: false, plain: false, }, fn_name: Ident::new("transform", Span::call_site()), struct_name: Ident::new("Transform", Span::call_site()), mod_name: Ident::new("transform", Span::call_site()), fn_generics: vec![parse_quote!(T: 'static)], where_clause: None, input: Input { pat_ident: pat_ident("footprint"), ty: parse_quote!(Footprint), implementations: Punctuated::new(), context_features: vec![], }, output_type: parse_quote!(T), output_depth: 0, is_async: false, claim: None, fields: vec![ ParsedField { pat_ident: pat_ident("transform_target"), name: None, description: String::new(), widget_override: ParsedWidgetOverride::None, ty: ParsedFieldType::Node(NodeParsedField { input_type: parse_quote!(Footprint), output_type: parse_quote!(T), implementations: Punctuated::new(), }), number_display_decimal_places: None, number_step: None, unit: None, is_data_field: false, attribute_reads: Vec::new(), }, ParsedField { pat_ident: pat_ident("translate"), name: None, description: String::new(), widget_override: ParsedWidgetOverride::None, ty: ParsedFieldType::Regular(RegularParsedField { name_source: None, lend: None, list_levels: 0, ty: parse_quote!(DVec2), exposed: false, value_source: ParsedValueSource::None, number_soft_min: None, number_soft_max: None, number_hard_min: None, number_hard_max: None, number_mode_range: false, implementations: Punctuated::new(), gpu_image: false, }), number_display_decimal_places: None, number_step: None, unit: None, is_data_field: false, attribute_reads: Vec::new(), }, ], body: TokenStream2::new(), description: String::from("Hello\n\t\t\t\tWorld\n"), }; assert_parsed_node_fn(&parsed, &expected); } #[test] fn test_node_with_default_values() { let attr = quote!(category("Vector: Shape")); let input = quote!( /// Test fn circle(_: impl Ctx + ExtractFootprint, #[default(50.)] radius: f64) -> Vector { // Implementation details... } ); let parsed = parse_node_fn(attr, input).unwrap(); let expected = ParsedNodeFn { vis: Visibility::Inherited, attributes: NodeFnAttributes { category: Some(parse_quote!("Vector: Shape")), display_name: None, path: None, skip_impl: false, properties_string: None, cfg: None, shader_node: None, serialize: None, memoize: false, inject_scope: false, placeholder: None, extent: None, extent_raw: None, batch: None, no_partial: false, plain: false, }, fn_name: Ident::new("circle", Span::call_site()), struct_name: Ident::new("Circle", Span::call_site()), mod_name: Ident::new("circle", Span::call_site()), fn_generics: vec![], where_clause: None, input: Input { pat_ident: pat_ident("_"), ty: parse_quote!(impl Ctx + ExtractFootprint), implementations: Punctuated::new(), context_features: vec![ContextFeatureDecl::new(format_ident!("ExtractFootprint"))], }, output_type: parse_quote!(Vector), output_depth: 0, is_async: false, claim: None, fields: vec![ParsedField { pat_ident: pat_ident("radius"), name: None, description: String::new(), widget_override: ParsedWidgetOverride::None, ty: ParsedFieldType::Regular(RegularParsedField { name_source: None, lend: None, list_levels: 0, ty: parse_quote!(f64), exposed: false, value_source: ParsedValueSource::Default(quote!(50.)), number_soft_min: None, number_soft_max: None, number_hard_min: None, number_hard_max: None, number_mode_range: false, implementations: Punctuated::new(), gpu_image: false, }), number_display_decimal_places: None, number_step: None, unit: None, is_data_field: false, attribute_reads: Vec::new(), }], body: TokenStream2::new(), description: "Test\n".into(), }; assert_parsed_node_fn(&parsed, &expected); } #[test] fn test_node_with_implementations() { let attr = quote!(category("Raster: Adjustment")); let input = quote!( fn levels(image: List>, #[implementations(f32, f64)] shadows: f64) -> List> { // Implementation details... } ); let parsed = parse_node_fn(attr, input).unwrap(); let expected = ParsedNodeFn { vis: Visibility::Inherited, attributes: NodeFnAttributes { category: Some(parse_quote!("Raster: Adjustment")), display_name: None, path: None, skip_impl: false, properties_string: None, cfg: None, shader_node: None, serialize: None, memoize: false, inject_scope: false, placeholder: None, extent: None, extent_raw: None, batch: None, no_partial: false, plain: false, }, fn_name: Ident::new("levels", Span::call_site()), struct_name: Ident::new("Levels", Span::call_site()), mod_name: Ident::new("levels", Span::call_site()), fn_generics: vec![parse_quote!(P: Pixel)], where_clause: None, input: Input { pat_ident: pat_ident("image"), ty: parse_quote!(List>), implementations: Punctuated::new(), context_features: vec![], }, output_type: parse_quote!(List>), output_depth: 0, is_async: false, claim: None, fields: vec![ParsedField { pat_ident: pat_ident("shadows"), name: None, description: String::new(), widget_override: ParsedWidgetOverride::None, ty: ParsedFieldType::Regular(RegularParsedField { name_source: None, lend: None, list_levels: 0, ty: parse_quote!(f64), exposed: false, value_source: ParsedValueSource::None, number_soft_min: None, number_soft_max: None, number_hard_min: None, number_hard_max: None, number_mode_range: false, implementations: { let mut p = Punctuated::new(); p.push(parse_quote!(f32)); p.push(parse_quote!(f64)); p }, gpu_image: false, }), number_display_decimal_places: None, number_step: None, unit: None, is_data_field: false, attribute_reads: Vec::new(), }], body: TokenStream2::new(), description: String::new(), }; assert_parsed_node_fn(&parsed, &expected); } #[test] fn test_number_min_max_range_mode() { let attr = quote!(category("Math: Arithmetic"), path(core_types::TestNode)); let input = quote!( fn add( a: f64, /// b #[range] #[soft(0..100)] #[hard(-500..500)] b: f64, ) -> f64 { a + b } ); let parsed = parse_node_fn(attr, input).unwrap(); let expected = ParsedNodeFn { vis: Visibility::Inherited, attributes: NodeFnAttributes { category: Some(parse_quote!("Math: Arithmetic")), display_name: None, path: Some(parse_quote!(core_types::TestNode)), skip_impl: false, properties_string: None, cfg: None, shader_node: None, serialize: None, memoize: false, inject_scope: false, placeholder: None, extent: None, extent_raw: None, batch: None, no_partial: false, plain: false, }, fn_name: Ident::new("add", Span::call_site()), struct_name: Ident::new("Add", Span::call_site()), mod_name: Ident::new("add", Span::call_site()), fn_generics: vec![], where_clause: None, input: Input { pat_ident: pat_ident("a"), ty: parse_quote!(f64), implementations: Punctuated::new(), context_features: vec![], }, output_type: parse_quote!(f64), output_depth: 0, is_async: false, claim: None, fields: vec![ParsedField { pat_ident: pat_ident("b"), name: None, description: String::from("b"), widget_override: ParsedWidgetOverride::None, ty: ParsedFieldType::Regular(RegularParsedField { name_source: None, lend: None, list_levels: 0, ty: parse_quote!(f64), exposed: false, value_source: ParsedValueSource::None, number_soft_min: Some(parse_quote!(0)), number_soft_max: Some(parse_quote!(100)), number_hard_min: Some(parse_quote!(-500)), number_hard_max: Some(parse_quote!(500)), number_mode_range: true, implementations: Punctuated::new(), gpu_image: false, }), number_display_decimal_places: None, number_step: None, unit: None, is_data_field: false, attribute_reads: Vec::new(), }], body: TokenStream2::new(), description: String::new(), }; assert_parsed_node_fn(&parsed, &expected); } #[test] fn test_empty_bounds_range() { let attr = quote!(category("Math: Arithmetic")); let input = quote!( fn add(a: f64, #[soft()] b: f64) -> f64 { a + b } ); let result = parse_node_fn(attr, input); assert!(result.is_err()); let error_message = result.unwrap_err().to_string(); assert!(error_message.contains("expected a range like `0..100`, `..100`, or `0..`")); } #[test] fn test_async_node() { let attr = quote!(category("IO")); let input = quote!( async fn load_image(api: &PlatformEditorApi, #[expose] path: String) -> List> { // Implementation details... } ); let parsed = parse_node_fn(attr, input).unwrap(); let expected = ParsedNodeFn { vis: Visibility::Inherited, attributes: NodeFnAttributes { category: Some(parse_quote!("IO")), display_name: None, path: None, skip_impl: false, properties_string: None, cfg: None, shader_node: None, serialize: None, memoize: false, inject_scope: false, placeholder: None, extent: None, extent_raw: None, batch: None, no_partial: false, plain: false, }, fn_name: Ident::new("load_image", Span::call_site()), struct_name: Ident::new("LoadImage", Span::call_site()), mod_name: Ident::new("load_image", Span::call_site()), fn_generics: vec![], where_clause: None, input: Input { pat_ident: pat_ident("api"), ty: parse_quote!(&PlatformEditorApi), implementations: Punctuated::new(), context_features: vec![], }, output_type: parse_quote!(List>), output_depth: 0, is_async: true, claim: None, fields: vec![ParsedField { pat_ident: pat_ident("path"), name: None, description: String::new(), widget_override: ParsedWidgetOverride::None, ty: ParsedFieldType::Regular(RegularParsedField { name_source: None, lend: None, list_levels: 0, ty: parse_quote!(String), exposed: true, value_source: ParsedValueSource::None, number_soft_min: None, number_soft_max: None, number_hard_min: None, number_hard_max: None, number_mode_range: false, implementations: Punctuated::new(), gpu_image: false, }), number_display_decimal_places: None, number_step: None, unit: None, is_data_field: false, attribute_reads: Vec::new(), }], body: TokenStream2::new(), description: String::new(), }; assert_parsed_node_fn(&parsed, &expected); } #[test] fn test_node_with_custom_name() { let attr = quote!(category("Custom"), name("CustomNode2")); let input = quote!( fn custom_node(input: i32) -> i32 { input * 2 } ); let parsed = parse_node_fn(attr, input).unwrap(); let expected = ParsedNodeFn { vis: Visibility::Inherited, attributes: NodeFnAttributes { category: Some(parse_quote!("Custom")), display_name: Some(parse_quote!("CustomNode2")), path: None, skip_impl: false, properties_string: None, cfg: None, shader_node: None, serialize: None, memoize: false, inject_scope: false, placeholder: None, extent: None, extent_raw: None, batch: None, no_partial: false, plain: false, }, fn_name: Ident::new("custom_node", Span::call_site()), struct_name: Ident::new("CustomNode", Span::call_site()), mod_name: Ident::new("custom_node", Span::call_site()), fn_generics: vec![], where_clause: None, input: Input { pat_ident: pat_ident("input"), ty: parse_quote!(i32), implementations: Punctuated::new(), context_features: vec![], }, output_type: parse_quote!(i32), output_depth: 0, is_async: false, claim: None, fields: vec![], body: TokenStream2::new(), description: String::new(), }; assert_parsed_node_fn(&parsed, &expected); } #[test] #[should_panic(expected = "Multiple 'category' attributes are not allowed")] fn test_multiple_categories() { let attr = quote!(category("Math: Arithmetic"), category("General")); let input = quote!( fn add(a: i32, b: i32) -> i32 { a + b } ); parse_node_fn(attr, input).unwrap(); } #[test] #[should_panic(expected = "Call argument cannot be given a default value")] fn test_default_value_for_first_arg() { let attr = quote!(category("Invalid")); let input = quote!( fn invalid_node(#[default(())] node: impl Node<(), Output = i32>) -> i32 { node.eval(()) } ); parse_node_fn(attr, input).unwrap(); } #[test] #[should_panic(expected = "No default values for `impl Node` allowed")] fn test_default_value_for_impl_node() { let attr = quote!(category("Invalid")); let input = quote!( fn invalid_node(_: (), #[default(())] node: impl Node<(), Output = i32>) -> i32 { node.eval(()) } ); parse_node_fn(attr, input).unwrap(); } #[test] #[should_panic(expected = "Unsupported attribute in `node`")] fn test_unsupported_attribute() { let attr = quote!(unsupported("Value")); let input = quote!( fn test_node(input: i32) -> i32 { input } ); parse_node_fn(attr, input).unwrap(); } #[test] fn test_invalid_implementation_syntax() { let attr = quote!(category("Test")); let input = quote!( fn test_node(_: (), #[implementations((Footprint, Color), (Footprint, List>))] input: impl Node) -> T { // Implementation details... } ); let result = parse_node_fn(attr, input); assert!(result.is_err()); let error = result.unwrap_err(); let error_message = error.to_string(); assert!(error_message.contains("Invalid #[implementations(...)] for argument `input`")); assert!(error_message.contains("Expected a comma-separated list of `InputType -> OutputType` pairs")); assert!(error_message.contains("Expected `->` arrow after input type in #[implementations(...)] on a field of type `impl Node`")); } #[test] fn test_implementation_on_first_arg() { let attr = quote!(category("Test")); // Use quote_spanned! to attach a specific span to the problematic part let problem_span = Span::call_site(); // You could create a custom span here if needed let tuples = quote_spanned!(problem_span=> () ()); let input = quote! { fn test_node( #[implementations((), #tuples, Footprint)] footprint: F, #[implementations( () -> List>, () -> List, () -> List, Footprint -> List>, Footprint -> List, Footprint -> List, )] image: impl Node, ) -> T { // Implementation details... } }; let result = parse_node_fn(attr, input); assert!(result.is_err(), "Expected an error, but parsing succeeded"); let error = result.unwrap_err(); let error_string = error.to_string(); assert!(error_string.contains("Failed to parse implementations for argument 'footprint'")); assert!(error_string.contains("expected `,`")); // Instead of checking for exact line and column, // verify that the error span is the one we specified assert_eq!(error.span().start(), problem_span.start()); } }