diff --git a/Cargo.toml b/Cargo.toml index afac85b2..ad207180 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ "rclrs", + "rclrs-macros", ] resolver = "2" diff --git a/rclrs-macros/Cargo.toml b/rclrs-macros/Cargo.toml new file mode 100644 index 00000000..2447fbd1 --- /dev/null +++ b/rclrs-macros/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "rclrs-macros" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Procedural macros for the rclrs ROS 2 client library" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0" +quote = "1.0" +syn = { version = "2.0", features = ["full", "extra-traits"] } diff --git a/rclrs-macros/package.xml b/rclrs-macros/package.xml new file mode 100644 index 00000000..78917e00 --- /dev/null +++ b/rclrs-macros/package.xml @@ -0,0 +1,14 @@ + + + + rclrs-macros + 0.5.0 + Procedural macros for the rclrs ROS 2 client library. + Esteve Fernandez + Apache License 2.0 + + ament_cargo + + diff --git a/rclrs-macros/src/lib.rs b/rclrs-macros/src/lib.rs new file mode 100644 index 00000000..ee3d015b --- /dev/null +++ b/rclrs-macros/src/lib.rs @@ -0,0 +1,15 @@ +use proc_macro::TokenStream; + +/// Derive macro for declaring ROS 2 parameters from struct definitions. +/// +/// See `rclrs::ParameterSet` for full documentation. +#[proc_macro_derive(ParameterSet, attributes(parameters, param))] +pub fn derive_parameter_set(input: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(input as syn::DeriveInput); + match parameter_set::expand(input) { + Ok(tokens) => tokens.into(), + Err(e) => e.to_compile_error().into(), + } +} + +mod parameter_set; diff --git a/rclrs-macros/src/parameter_set/codegen.rs b/rclrs-macros/src/parameter_set/codegen.rs new file mode 100644 index 00000000..36882d69 --- /dev/null +++ b/rclrs-macros/src/parameter_set/codegen.rs @@ -0,0 +1,110 @@ +//! Code generation for individual fields in a `ParameterSet` struct. + +use proc_macro2::TokenStream; +use quote::quote; + +use super::field_attrs::{FieldAttrs, ParameterType, RangeAttr}; +use super::field_info::FieldInfo; + +/// Generates the field initializer expression for one struct field. +pub(crate) fn generate_field(field: &FieldInfo) -> TokenStream { + match field { + FieldInfo::Leaf { + ident, + attrs, + param_type, + } => { + let name_str = ident.to_string(); + let builder_chain = generate_builder_chain(attrs); + let terminal_call = generate_terminal(*param_type); + quote! { + #ident: node + .declare_parameter( + rclrs::__private::param_name(prefix, #name_str) + ) + #builder_chain + #terminal_call? + } + } + FieldInfo::Nested { ident, flatten } => generate_nested(ident, *flatten), + } +} + +/// Generates the chained builder method calls from field attributes. +fn generate_builder_chain(attrs: &FieldAttrs) -> TokenStream { + let mut calls = TokenStream::new(); + + if let Some(default) = &attrs.default { + calls.extend(quote! { .default(#default) }); + } + if let Some(description) = &attrs.description { + calls.extend(quote! { .description(#description) }); + } + if let Some(constraints) = &attrs.constraints { + calls.extend(quote! { .constraints(#constraints) }); + } + if let Some(range) = &attrs.range { + let range_expr = generate_range(range); + calls.extend(quote! { .range(#range_expr) }); + } + if attrs.ignore_override { + calls.extend(quote! { .ignore_override() }); + } + if attrs.discard_mismatching_prior_value { + calls.extend(quote! { .discard_mismatching_prior_value() }); + } + if let Some(discriminate) = &attrs.discriminate { + calls.extend(quote! { .discriminate(#discriminate) }); + } + + calls +} + +/// Generates the terminal builder call (`.mandatory()`, `.optional()`, `.read_only()`). +fn generate_terminal(param_type: ParameterType) -> TokenStream { + match param_type { + ParameterType::Mandatory => quote! { .mandatory() }, + ParameterType::Optional => quote! { .optional() }, + ParameterType::ReadOnly => quote! { .read_only() }, + } +} + +/// Generates a `ParameterRange { ... }` expression from parsed range attributes. +fn generate_range(range: &RangeAttr) -> TokenStream { + let lower = match &range.lower { + Some(expr) => quote! { Some(#expr) }, + None => quote! { None }, + }; + let upper = match &range.upper { + Some(expr) => quote! { Some(#expr) }, + None => quote! { None }, + }; + let step = match &range.step { + Some(expr) => quote! { Some(#expr) }, + None => quote! { None }, + }; + quote! { + rclrs::ParameterRange { + lower: #lower, + upper: #upper, + step: #step, + } + } +} + +/// Generates the field initializer for a nested `ParameterSet` field. +fn generate_nested(ident: &syn::Ident, flatten: bool) -> TokenStream { + let name_str = ident.to_string(); + if flatten { + quote! { + #ident: rclrs::ParameterSet::declare(node, prefix)? + } + } else { + quote! { + #ident: rclrs::ParameterSet::declare( + node, + &rclrs::__private::param_name(prefix, #name_str), + )? + } + } +} diff --git a/rclrs-macros/src/parameter_set/field_attrs.rs b/rclrs-macros/src/parameter_set/field_attrs.rs new file mode 100644 index 00000000..04bb8812 --- /dev/null +++ b/rclrs-macros/src/parameter_set/field_attrs.rs @@ -0,0 +1,154 @@ +//! Parsing of field-level `#[param(...)]` attributes. + +use syn::{Expr, LitStr}; + +/// The kind of parameter: mandatory, optional, or read-only. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ParameterType { + Mandatory, + Optional, + ReadOnly, +} + +/// Parsed range attribute: `range(lower = x, upper = y, step = z)`. +/// All fields are optional. +#[derive(Debug, Default)] +pub(crate) struct RangeAttr { + pub lower: Option, + pub upper: Option, + pub step: Option, +} + +/// Parsed field-level configuration from `#[param(...)]`. +#[derive(Debug)] +pub(crate) struct FieldAttrs { + /// Explicit parameter type (escape hatch for type aliases). + pub param_type: Option, + /// Default value expression. + pub default: Option, + /// Parameter description string. + pub description: Option, + /// Parameter constraints string. + pub constraints: Option, + /// Parameter range. + pub range: Option, + /// Whether to ignore parameter overrides from command line. + pub ignore_override: bool, + /// Whether to discard prior values with mismatching types. + pub discard_mismatching_prior_value: bool, + /// Path to a custom discriminator function. + pub discriminate: Option, + /// Whether this nested field should be flattened (no extra namespace). + pub flatten: bool, +} + +impl FieldAttrs { + /// Parses all `#[param(...)]` attributes on a field. + pub fn parse(field: &syn::Field) -> syn::Result { + let mut attrs = Self { + param_type: None, + default: None, + description: None, + constraints: None, + range: None, + ignore_override: false, + discard_mismatching_prior_value: false, + discriminate: None, + flatten: false, + }; + + for attr in &field.attrs { + if !attr.path().is_ident("param") { + continue; + } + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("mandatory") { + attrs.set_param_type(ParameterType::Mandatory, &meta)?; + Ok(()) + } else if meta.path.is_ident("optional") { + attrs.set_param_type(ParameterType::Optional, &meta)?; + Ok(()) + } else if meta.path.is_ident("read_only") { + attrs.set_param_type(ParameterType::ReadOnly, &meta)?; + Ok(()) + } else if meta.path.is_ident("default") { + attrs.default = Some(meta.value()?.parse()?); + Ok(()) + } else if meta.path.is_ident("description") { + attrs.description = Some(meta.value()?.parse()?); + Ok(()) + } else if meta.path.is_ident("constraints") { + attrs.constraints = Some(meta.value()?.parse()?); + Ok(()) + } else if meta.path.is_ident("range") { + attrs.range = Some(parse_range(&meta)?); + Ok(()) + } else if meta.path.is_ident("ignore_override") { + attrs.ignore_override = true; + Ok(()) + } else if meta.path.is_ident("discard_mismatching_prior_value") { + attrs.discard_mismatching_prior_value = true; + Ok(()) + } else if meta.path.is_ident("discriminate") { + attrs.discriminate = Some(meta.value()?.parse()?); + Ok(()) + } else if meta.path.is_ident("flatten") { + attrs.flatten = true; + Ok(()) + } else { + Err(meta.error(format!( + "unknown param attribute: '{}'", + meta.path.get_ident().map_or("?".into(), |i| i.to_string()) + ))) + } + })?; + } + + Ok(attrs) + } + + /// Sets the parameter type, erroring if one was already set. + fn set_param_type( + &mut self, + param_type: ParameterType, + meta: &syn::meta::ParseNestedMeta<'_>, + ) -> syn::Result<()> { + if self.param_type.is_some() { + return Err(meta.error("only one of 'mandatory', 'optional', 'read_only' is allowed")); + } + self.param_type = Some(param_type); + Ok(()) + } + + /// Returns true if any leaf-only attribute was set. + pub fn has_leaf_attrs(&self) -> bool { + self.default.is_some() + || self.description.is_some() + || self.constraints.is_some() + || self.range.is_some() + || self.ignore_override + || self.discard_mismatching_prior_value + || self.discriminate.is_some() + || self.param_type.is_some() + } +} + +/// Parses `range(lower = x, upper = y, step = z)`. +fn parse_range(meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result { + let mut range = RangeAttr::default(); + meta.parse_nested_meta(|nested| { + if nested.path.is_ident("lower") { + range.lower = Some(nested.value()?.parse()?); + Ok(()) + } else if nested.path.is_ident("upper") { + range.upper = Some(nested.value()?.parse()?); + Ok(()) + } else if nested.path.is_ident("step") { + range.step = Some(nested.value()?.parse()?); + Ok(()) + } else { + Err(nested.error("expected 'lower', 'upper', or 'step'")) + } + })?; + Ok(range) +} diff --git a/rclrs-macros/src/parameter_set/field_info.rs b/rclrs-macros/src/parameter_set/field_info.rs new file mode 100644 index 00000000..ee8f23f6 --- /dev/null +++ b/rclrs-macros/src/parameter_set/field_info.rs @@ -0,0 +1,110 @@ +//! Field classification: determines if a field is a leaf parameter or a nested +//! `ParameterSet` struct, and validates that attributes are used correctly. + +use syn::spanned::Spanned; + +use super::field_attrs::{FieldAttrs, ParameterType}; + +/// A classified struct field. +pub(crate) enum FieldInfo<'a> { + /// A leaf parameter field — generates builder calls. + Leaf { + ident: &'a syn::Ident, + attrs: FieldAttrs, + param_type: ParameterType, + }, + /// A nested `ParameterSet` field — generates a recursive `declare` call. + Nested { + ident: &'a syn::Ident, + flatten: bool, + }, +} + +impl<'a> FieldInfo<'a> { + /// Classifies a field based on its type and attributes. + pub fn from_field(field: &'a syn::Field) -> syn::Result { + let ident = field + .ident + .as_ref() + .ok_or_else(|| syn::Error::new(field.span(), "ParameterSet requires named fields"))?; + + let attrs = FieldAttrs::parse(field)?; + let inferred = infer_param_type_from_type(&field.ty); + + // Validate contradictions between explicit attribute and inferred type + if let (Some(explicit), Some(inferred)) = (attrs.param_type, inferred) { + if explicit != inferred { + let explicit_name = param_type_name(explicit); + let inferred_name = param_type_name(inferred); + return Err(syn::Error::new( + field.ty.span(), + format!( + "field type implies '{inferred_name}' but attribute specifies \ + '{explicit_name}'" + ), + )); + } + } + + // Determine if leaf or nested + let param_type = attrs.param_type.or(inferred); + match param_type { + Some(param_type) => { + // It's a leaf — validate no nested-only attrs + if attrs.flatten { + return Err(syn::Error::new( + ident.span(), + "'flatten' can only be used on nested ParameterSet fields, \ + not parameter fields", + )); + } + Ok(FieldInfo::Leaf { + ident, + attrs, + param_type, + }) + } + None => { + // It's nested — validate no leaf-only attrs + if attrs.has_leaf_attrs() { + return Err(syn::Error::new( + ident.span(), + "parameter attributes (default, description, range, etc.) \ + can only be used on parameter fields, not nested ParameterSet fields", + )); + } + Ok(FieldInfo::Nested { + ident, + flatten: attrs.flatten, + }) + } + } + } +} + +/// Tries to infer the parameter type from the field's type name. +/// +/// Returns `Some(param_type)` if the outermost type is `MandatoryParameter`, +/// `OptionalParameter`, or `ReadOnlyParameter`. Returns `None` for any other +/// type (assumed to be a nested `ParameterSet`). +fn infer_param_type_from_type(ty: &syn::Type) -> Option { + let syn::Type::Path(type_path) = ty else { + return None; + }; + let segment = type_path.path.segments.last()?; + match segment.ident.to_string().as_str() { + "MandatoryParameter" => Some(ParameterType::Mandatory), + "OptionalParameter" => Some(ParameterType::Optional), + "ReadOnlyParameter" => Some(ParameterType::ReadOnly), + _ => None, + } +} + +/// Returns the attribute name for a parameter type (for error messages). +fn param_type_name(t: ParameterType) -> &'static str { + match t { + ParameterType::Mandatory => "mandatory", + ParameterType::Optional => "optional", + ParameterType::ReadOnly => "read_only", + } +} diff --git a/rclrs-macros/src/parameter_set/mod.rs b/rclrs-macros/src/parameter_set/mod.rs new file mode 100644 index 00000000..c958d3c8 --- /dev/null +++ b/rclrs-macros/src/parameter_set/mod.rs @@ -0,0 +1,114 @@ +//! Implementation of the `#[derive(ParameterSet)]` macro. +//! +//! The macro generates a `ParameterSet` trait impl for a struct, expanding +//! each field into either a parameter builder chain (for leaf fields) or a +//! recursive `ParameterSet::declare` call (for nested structs). + +mod codegen; +mod field_attrs; +mod field_info; +mod struct_attrs; + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{spanned::Spanned, DeriveInput}; + +use field_info::FieldInfo; +use struct_attrs::StructAttrs; + +/// Converts a PascalCase string to snake_case. +/// +/// Consecutive uppercase letters are each treated as separate words: +/// `"HTTPServer"` becomes `"h_t_t_p_server"`. This is acceptable for +/// typical ROS 2 PascalCase struct names. +fn to_snake_case(s: &str) -> String { + let mut result = String::with_capacity(s.len() + 4); + for (i, c) in s.chars().enumerate() { + if c.is_uppercase() { + if i > 0 { + result.push('_'); + } + result.push(c.to_lowercase().next().unwrap()); + } else { + result.push(c); + } + } + result +} + +/// Entry point for the derive macro expansion. +pub(crate) fn expand(input: DeriveInput) -> syn::Result { + let struct_attrs = StructAttrs::parse(&input)?; + let ident = &input.ident; + + // Only works on structs with named fields + let fields = match &input.data { + syn::Data::Struct(data) => match &data.fields { + syn::Fields::Named(fields) => &fields.named, + _ => { + return Err(syn::Error::new( + data.fields.span(), + "ParameterSet can only be derived for structs with named fields", + )) + } + }, + syn::Data::Enum(e) => { + return Err(syn::Error::new( + e.enum_token.span(), + "ParameterSet cannot be derived for enums", + )) + } + syn::Data::Union(u) => { + return Err(syn::Error::new( + u.union_token.span(), + "ParameterSet cannot be derived for unions", + )) + } + }; + + // Classify each field and generate its initializer + let field_initializers: Vec = fields + .iter() + .map(|field| { + let info = FieldInfo::from_field(field)?; + Ok(codegen::generate_field(&info)) + }) + .collect::>>()?; + + // Determine the default namespace + let namespace = match &struct_attrs.namespace { + Some(ns) => ns.clone(), + None => to_snake_case(&ident.to_string()), + }; + + Ok(quote! { + impl rclrs::ParameterSet for #ident { + fn default_namespace() -> &'static str { + #namespace + } + + fn declare( + node: &rclrs::NodeState, + prefix: &str, + ) -> ::core::result::Result { + ::core::result::Result::Ok(Self { + #(#field_initializers,)* + }) + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_to_snake_case() { + assert_eq!(to_snake_case("DriveConfig"), "drive_config"); + assert_eq!(to_snake_case("Robot"), "robot"); + assert_eq!(to_snake_case("SensorConfig"), "sensor_config"); + assert_eq!(to_snake_case("A"), "a"); + assert_eq!(to_snake_case("AB"), "a_b"); + } +} diff --git a/rclrs-macros/src/parameter_set/struct_attrs.rs b/rclrs-macros/src/parameter_set/struct_attrs.rs new file mode 100644 index 00000000..f05bb09d --- /dev/null +++ b/rclrs-macros/src/parameter_set/struct_attrs.rs @@ -0,0 +1,50 @@ +//! Parsing of struct-level `#[parameters(...)]` attributes. + +use syn::{DeriveInput, LitStr}; + +/// Parsed struct-level configuration from `#[parameters(...)]`. +pub(crate) struct StructAttrs { + /// The namespace for this parameter set. + /// `None` means derive from struct name (snake_case). + /// `Some("")` means flatten (struct name not used as namespace). + /// `Some("custom")` means use this exact string. + pub namespace: Option, +} + +impl StructAttrs { + /// Parses `#[parameters(...)]` attributes from the derive input. + pub fn parse(input: &DeriveInput) -> syn::Result { + let mut namespace = None; + + for attr in &input.attrs { + if !attr.path().is_ident("parameters") { + continue; + } + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("flatten") { + if namespace.is_some() { + return Err(meta.error("cannot combine 'flatten' with 'namespace'")); + } + namespace = Some(String::new()); + Ok(()) + } else if meta.path.is_ident("namespace") { + if namespace.is_some() { + return Err(meta.error( + "cannot specify 'namespace' more than once, or combine with 'flatten'", + )); + } + let value: LitStr = meta.value()?.parse()?; + namespace = Some(value.value()); + Ok(()) + } else { + Err(meta.error(format!( + "unknown parameters attribute: '{}'", + meta.path.get_ident().map_or("?".into(), |i| i.to_string()) + ))) + } + })?; + } + + Ok(Self { namespace }) + } +} diff --git a/rclrs/Cargo.toml b/rclrs/Cargo.toml index 8692e19a..5fbb2775 100644 --- a/rclrs/Cargo.toml +++ b/rclrs/Cargo.toml @@ -54,6 +54,8 @@ tokio-stream = "0.1" # Needed by action clients to generate UUID values for their goals uuid = { version = "1", features = ["v4"] } +rclrs-macros = { path = "../rclrs-macros" } + [dev-dependencies] # Needed for e.g. writing yaml files in tests tempfile = "3.3.0" diff --git a/rclrs/src/lib.rs b/rclrs/src/lib.rs index 9db4767f..ac59406f 100644 --- a/rclrs/src/lib.rs +++ b/rclrs/src/lib.rs @@ -221,6 +221,7 @@ pub use parameter::*; pub use publisher::*; pub use qos::*; pub use rcl_bindings::rmw_request_id_t; +pub use rclrs_macros::ParameterSet; pub use service::*; pub use subscription::*; pub use time::*; diff --git a/rclrs/src/node.rs b/rclrs/src/node.rs index ed436df2..1b074a39 100644 --- a/rclrs/src/node.rs +++ b/rclrs/src/node.rs @@ -38,13 +38,13 @@ use crate::{ }, rcl_bindings::*, ActionClient, ActionClientState, ActionGoalReceiver, ActionServer, ActionServerState, - AnyTimerCallback, Client, ClientOptions, ClientState, Clock, ContextHandle, ExecutorCommands, - IntoActionClientOptions, IntoActionServerOptions, IntoAsyncServiceCallback, + AnyTimerCallback, Client, ClientOptions, ClientState, Clock, ContextHandle, DeclarationError, + ExecutorCommands, IntoActionClientOptions, IntoActionServerOptions, IntoAsyncServiceCallback, IntoAsyncSubscriptionCallback, IntoNodeServiceCallback, IntoNodeSubscriptionCallback, IntoNodeTimerOneshotCallback, IntoNodeTimerRepeatingCallback, IntoTimerOptions, LogParams, - Logger, MessageInfo, ParameterBuilder, ParameterInterface, ParameterVariant, Parameters, - Promise, Publisher, PublisherOptions, PublisherState, RclrsError, RequestedGoal, Service, - ServiceOptions, ServiceState, Subscription, SubscriptionOptions, SubscriptionState, + Logger, MessageInfo, ParameterBuilder, ParameterInterface, ParameterSet, ParameterVariant, + Parameters, Promise, Publisher, PublisherOptions, PublisherState, RclrsError, RequestedGoal, + Service, ServiceOptions, ServiceState, Subscription, SubscriptionOptions, SubscriptionState, TerminatedGoal, TimeSource, Timer, TimerState, ToLogParams, Worker, WorkerOptions, WorkerState, ENTITY_LIFECYCLE_MUTEX, }; @@ -1430,6 +1430,69 @@ impl NodeState { } } + /// Declares a set of parameters from a struct that derives [`ParameterSet`]. + /// + /// Uses the struct's default namespace (derived from the struct name in + /// snake_case) as the parameter prefix. + /// + /// # Example + /// ```no_run + /// use rclrs::*; + /// + /// #[derive(ParameterSet)] + /// struct DriveConfig { + /// #[param(default = 50.0)] + /// speed: MandatoryParameter, + /// } + /// + /// # let executor = Context::default_from_env()?.create_basic_executor(); + /// # let node = executor.create_node("my_node")?; + /// // Declares "drive_config.speed" + /// let config: DriveConfig = node.declare_parameter_set()?; + /// # Ok::<(), RclrsError>(()) + /// ``` + pub fn declare_parameter_set(&self) -> Result { + T::declare(self, T::default_namespace()) + } + + /// Declares a set of parameters with an additional prefix prepended to the + /// struct's default namespace. + /// + /// For a struct `DriveConfig` (default namespace `"drive_config"`), calling + /// `declare_parameter_set_with_prefix("robot")` declares parameters under + /// `"robot.drive_config.*"`. + /// + /// # Example + /// ```no_run + /// use rclrs::*; + /// + /// #[derive(ParameterSet)] + /// struct DriveConfig { + /// #[param(default = 50.0)] + /// speed: MandatoryParameter, + /// } + /// + /// # let executor = Context::default_from_env()?.create_basic_executor(); + /// # let node = executor.create_node("my_node")?; + /// // Declares "robot.drive_config.speed" + /// let config: DriveConfig = node.declare_parameter_set_with_prefix("robot")?; + /// # Ok::<(), RclrsError>(()) + /// ``` + pub fn declare_parameter_set_with_prefix( + &self, + prefix: &str, + ) -> Result { + let ns = T::default_namespace(); + let full_prefix = if prefix.is_empty() { + ns.to_string() + } else if ns.is_empty() { + prefix.to_string() + } else { + format!("{prefix}.{ns}") + }; + T::declare(self, &full_prefix) + } + /// Same as [`Self::notify_on_graph_change_with_period`] but uses a /// recommended default period of 100ms. pub fn notify_on_graph_change( diff --git a/rclrs/src/parameter.rs b/rclrs/src/parameter.rs index fe9fa091..ec286283 100644 --- a/rclrs/src/parameter.rs +++ b/rclrs/src/parameter.rs @@ -3,6 +3,9 @@ mod range; mod service; mod value; +mod parameter_set; +pub use parameter_set::*; + pub(crate) use override_map::*; pub use range::*; use service::*; diff --git a/rclrs/src/parameter/parameter_set.rs b/rclrs/src/parameter/parameter_set.rs new file mode 100644 index 00000000..57a741a0 --- /dev/null +++ b/rclrs/src/parameter/parameter_set.rs @@ -0,0 +1,63 @@ +use crate::{DeclarationError, NodeState}; + +/// A collection of parameters that can be declared together on a node. +/// +/// This trait is typically derived using `#[derive(ParameterSet)]` from the +/// `rclrs` crate. The derive macro generates builder calls for each field +/// based on struct annotations. +/// +/// # Example +/// +/// ```no_run +/// use rclrs::*; +/// +/// #[derive(ParameterSet)] +/// struct MyParams { +/// #[param(default = 50.0, description = "Max speed")] +/// speed: MandatoryParameter, +/// } +/// +/// let executor = Context::default_from_env().unwrap().create_basic_executor(); +/// let node = executor.create_node("my_node").unwrap(); +/// let params: MyParams = node.declare_parameter_set().unwrap(); +/// ``` +pub trait ParameterSet: Sized { + /// The default namespace for this parameter set. + /// + /// Derived from the struct name converted to snake_case. + /// Override with `#[parameters(namespace = "custom")]`. + /// Returns `""` when `#[parameters(flatten)]` is used. + /// + /// Note: `flatten` only affects this struct's own namespace. It does not + /// propagate to nested `ParameterSet` fields — those still use their own + /// `default_namespace()` unless individually marked with `#[param(flatten)]`. + fn default_namespace() -> &'static str; + + /// Declares all parameters on the node under the given prefix. + /// + /// An empty prefix means parameters are declared at root level. + /// A non-empty prefix results in parameter names like `"{prefix}.{field}"`. + fn declare(node: &NodeState, prefix: &str) -> Result; +} + +/// Helper utilities used by the `ParameterSet` derive macro. +/// +/// These are not part of the public API and may change without notice. +#[doc(hidden)] +pub mod __private { + /// Builds a parameter name from a prefix and field name. + /// + /// - Empty prefix: returns the field name directly. + /// - Non-empty prefix: returns `"{prefix}.{field}"`. + pub fn param_name(prefix: &str, field: &str) -> String { + if prefix.is_empty() { + field.to_string() + } else { + format!("{prefix}.{field}") + } + } +} + +#[cfg(test)] +#[path = "parameter_set_tests.rs"] +mod tests; diff --git a/rclrs/src/parameter/parameter_set_tests.rs b/rclrs/src/parameter/parameter_set_tests.rs new file mode 100644 index 00000000..5fbfd3c6 --- /dev/null +++ b/rclrs/src/parameter/parameter_set_tests.rs @@ -0,0 +1,269 @@ +//! Integration tests for the `ParameterSet` derive macro. + +use crate as rclrs; +use crate::*; + +#[derive(ParameterSet)] +struct BasicParams { + #[param(default = 42)] + count: MandatoryParameter, + + #[param(default = 3.14)] + ratio: OptionalParameter, + + #[param(default = true)] + enabled: ReadOnlyParameter, +} + +#[test] +fn test_basic_parameter_set() { + assert_eq!(BasicParams::default_namespace(), "basic_params"); + + let executor = Context::default().create_basic_executor(); + let node = executor + .create_node(&format!("param_set_test_{}", line!())) + .unwrap(); + + let params: BasicParams = node.declare_parameter_set().unwrap(); + + // Values are correct + assert_eq!(params.count.get(), 42); + assert_eq!(params.ratio.get(), Some(3.14)); + assert!(params.enabled.get()); + + // Parameters are declared under default namespace "basic_params.*" + assert_eq!( + node.use_undeclared_parameters() + .get::("basic_params.count"), + Some(42) + ); +} + +#[derive(ParameterSet)] +struct SensorConfig { + #[param(default = 30)] + rate: MandatoryParameter, +} + +#[derive(ParameterSet)] +struct RobotParams { + #[param(default = 50.0)] + speed: MandatoryParameter, + + sensors: SensorConfig, +} + +#[test] +fn test_nested_parameter_set() { + let executor = Context::default().create_basic_executor(); + let node = executor + .create_node(&format!("param_set_test_{}", line!())) + .unwrap(); + + let robot: RobotParams = node.declare_parameter_set().unwrap(); + + assert_eq!(robot.speed.get(), 50.0); + assert_eq!(robot.sensors.rate.get(), 30); + + // Check namespacing: "robot_params.speed", "robot_params.sensors.rate" + assert_eq!( + node.use_undeclared_parameters() + .get::("robot_params.speed"), + Some(50.0) + ); + assert_eq!( + node.use_undeclared_parameters() + .get::("robot_params.sensors.rate"), + Some(30) + ); +} + +#[test] +fn test_custom_prefix() { + let executor = Context::default().create_basic_executor(); + let node = executor + .create_node(&format!("param_set_test_{}", line!())) + .unwrap(); + + // Prefix is additive: "bot" + "robot_params" -> "bot.robot_params.*" + let _robot: RobotParams = node.declare_parameter_set_with_prefix("bot").unwrap(); + + assert_eq!( + node.use_undeclared_parameters() + .get::("bot.robot_params.speed"), + Some(50.0) + ); + assert_eq!( + node.use_undeclared_parameters() + .get::("bot.robot_params.sensors.rate"), + Some(30) + ); +} + +#[derive(ParameterSet)] +#[parameters(flatten)] +struct FlattenedParams { + #[param(default = 100)] + global_limit: MandatoryParameter, +} + +#[test] +fn test_flattened_parameter_set() { + assert_eq!(FlattenedParams::default_namespace(), ""); + + let executor = Context::default().create_basic_executor(); + let node = executor + .create_node(&format!("param_set_test_{}", line!())) + .unwrap(); + + let params: FlattenedParams = node.declare_parameter_set().unwrap(); + + assert_eq!(params.global_limit.get(), 100); + assert_eq!( + node.use_undeclared_parameters().get::("global_limit"), + Some(100) + ); +} + +#[derive(ParameterSet)] +#[parameters(namespace = "drive")] +struct DriveConfig { + #[param(default = 50.0)] + speed: MandatoryParameter, +} + +#[test] +fn test_custom_namespace() { + assert_eq!(DriveConfig::default_namespace(), "drive"); + + let executor = Context::default().create_basic_executor(); + let node = executor + .create_node(&format!("param_set_test_{}", line!())) + .unwrap(); + + let config: DriveConfig = node.declare_parameter_set().unwrap(); + + assert_eq!(config.speed.get(), 50.0); + assert_eq!( + node.use_undeclared_parameters().get::("drive.speed"), + Some(50.0) + ); +} + +#[derive(ParameterSet)] +struct Limits { + #[param(default = 100.0)] + max_force: MandatoryParameter, +} + +#[derive(ParameterSet)] +struct FlattenTest { + #[param(default = 1.0)] + speed: MandatoryParameter, + + #[param(flatten)] + limits: Limits, +} + +#[test] +fn test_flatten() { + let executor = Context::default().create_basic_executor(); + let node = executor + .create_node(&format!("param_set_test_{}", line!())) + .unwrap(); + + let params: FlattenTest = node.declare_parameter_set().unwrap(); + + assert_eq!(params.speed.get(), 1.0); + assert_eq!(params.limits.max_force.get(), 100.0); + + // "limits" should NOT appear in the parameter name + assert_eq!( + node.use_undeclared_parameters() + .get::("flatten_test.max_force"), + Some(100.0) + ); + assert_eq!( + node.use_undeclared_parameters() + .get::("flatten_test.speed"), + Some(1.0) + ); +} + +#[derive(ParameterSet)] +#[parameters(flatten)] +struct FullOptions { + #[param( + default = 50, + description = "Motor speed", + constraints = "must be positive", + range(lower = 0, upper = 100), + ignore_override + )] + speed: MandatoryParameter, +} + +#[test] +fn test_full_builder_options() { + let executor = Context::default().create_basic_executor(); + let node = executor + .create_node(&format!("param_set_test_{}", line!())) + .unwrap(); + + let params: FullOptions = node.declare_parameter_set().unwrap(); + + assert_eq!(params.speed.get(), 50); + // Range should be enforced + assert!(params.speed.set(200).is_err()); + assert!(params.speed.set(75).is_ok()); + assert_eq!(params.speed.get(), 75); +} + +fn always_max(_available: AvailableValues) -> Option { + // Return the known upper bound of the range + Some(100) +} + +#[derive(ParameterSet)] +#[parameters(flatten)] +struct DiscriminateParams { + #[param( + default = 10, + range(lower = 0, upper = 100), + discriminate = always_max, + )] + value: MandatoryParameter, +} + +#[test] +fn test_discriminate() { + let executor = Context::default().create_basic_executor(); + let node = executor + .create_node(&format!("param_set_test_{}", line!())) + .unwrap(); + + let params: DiscriminateParams = node.declare_parameter_set().unwrap(); + + // Discriminator should pick upper bound (100), not default (10) + assert_eq!(params.value.get(), 100); +} + +type Speed = MandatoryParameter; + +#[derive(ParameterSet)] +#[parameters(flatten)] +struct AliasedParams { + #[param(mandatory, default = 25.0)] + speed: Speed, +} + +#[test] +fn test_explicit_parameter_type_with_type_alias() { + let executor = Context::default().create_basic_executor(); + let node = executor + .create_node(&format!("param_set_test_{}", line!())) + .unwrap(); + + let params: AliasedParams = node.declare_parameter_set().unwrap(); + assert_eq!(params.speed.get(), 25.0); +}