diff --git a/rclrs-macros/src/lib.rs b/rclrs-macros/src/lib.rs index 425d4d9f..bda68851 100644 --- a/rclrs-macros/src/lib.rs +++ b/rclrs-macros/src/lib.rs @@ -8,7 +8,8 @@ mod errors; mod parameter_set; mod parameter_variant; -/// Declares a struct's fields as a group of ROS 2 parameters. +/// Declares a group of ROS 2 parameters: a struct whose fields are the parameters, or an enum +/// whose variants are the shapes the group can take. /// /// See the `rclrs::ParameterSet` trait for the full description, and /// `rclrs::NodeState::declare_parameters` for how to declare the result on a node. @@ -22,7 +23,18 @@ mod parameter_variant; /// must evaluate to `Self`, e.g. `Self::default()`. A field with its own /// `#[param(default = ...)]` keeps that default. /// * `#[parameters(handles = MyHandles)]`: name of the generated handles struct. Defaults to -/// the struct's own name with `Params` appended. +/// the type's own name with `Params` appended. +/// +/// # Enum attributes +/// +/// An enum set declares a read-only string parameter saying which variant is in use, and then +/// that variant's parameters. +/// +/// * `#[parameters(tag = "type")]`: the name of that parameter. Defaults to `type`. +/// * `#[parameters(rename_all = "snake_case")]`: the naming convention for its values, one of +/// `snake_case`, `kebab-case`, `lowercase`, `UPPERCASE` or `SCREAMING_SNAKE_CASE`. Without it, +/// variant names are used as written. `#[param(rename = "...")]` on a variant sets its value +/// exactly. /// /// # Field attributes /// diff --git a/rclrs-macros/src/parameter_set/attrs.rs b/rclrs-macros/src/parameter_set/attrs.rs index 52d307c4..6dee8255 100644 --- a/rclrs-macros/src/parameter_set/attrs.rs +++ b/rclrs-macros/src/parameter_set/attrs.rs @@ -2,7 +2,7 @@ use syn::{spanned::Spanned, Attribute, Expr, ExprRange, Ident, LitStr, RangeLimits}; -use crate::errors::Errors; +use crate::{errors::Errors, parameter_variant::attrs::RenameAll}; /// Struct-level configuration from `#[parameters(...)]`. #[derive(Default)] @@ -15,6 +15,10 @@ pub(crate) struct SetAttrs { pub default: Option, /// Name for the generated handles struct. pub handles: Option, + /// Enum sets only: the name of the parameter that says which variant is in use. + pub tag: Option, + /// Enum sets only: the naming convention for tag values. + pub rename_all: Option<(RenameAll, LitStr)>, } impl SetAttrs { @@ -32,10 +36,27 @@ impl SetAttrs { parsed.default = Some(meta.value()?.parse()?); } else if meta.path.is_ident("handles") { parsed.handles = Some(meta.value()?.parse()?); + } else if meta.path.is_ident("tag") { + parsed.tag = Some(meta.value()?.parse()?); + } else if meta.path.is_ident("rename_all") { + let value: LitStr = meta.value()?.parse()?; + match RenameAll::parse(&value.value()) { + Some(convention) => parsed.rename_all = Some((convention, value)), + None => { + return Err(syn::Error::new( + value.span(), + format!( + "unknown naming convention {:?}; expected one of {}", + value.value(), + RenameAll::NAMES.join(", "), + ), + )) + } + } } else { return Err(meta.error(format!( "unknown `parameters` option `{}`; expected one of `namespace`, \ - `default`, `handles`", + `default`, `handles`, `tag`, `rename_all`", path_name(&meta.path), ))); } @@ -199,6 +220,34 @@ impl FieldAttrs { } } + /// The span of any option other than `rename`, for reporting attributes that mean nothing on + /// an enum variant. + pub fn conflicts_with_rename(&self) -> Option { + macro_rules! first_of { + ($($field:ident),*) => { + $(if let Some(value) = &self.$field { + return Some(value.span()); + })* + }; + } + first_of!( + default, + description, + constraints, + range, + step, + read_only, + ignore_override, + discard_mismatching_prior_value, + validate, + on_change, + discriminate, + flatten, + skip + ); + None + } + /// The span of any option other than `skip` itself, for reporting attributes that `skip` /// makes meaningless. pub fn conflicts_with_skip(&self) -> Option { diff --git a/rclrs-macros/src/parameter_set/codegen.rs b/rclrs-macros/src/parameter_set/codegen.rs index a397b42a..5403dfcd 100644 --- a/rclrs-macros/src/parameter_set/codegen.rs +++ b/rclrs-macros/src/parameter_set/codegen.rs @@ -6,6 +6,7 @@ use syn::{spanned::Spanned, DeriveInput, Expr, Ident}; use super::{ attrs::{range_bounds, SetAttrs}, + enum_set::{Variant, VariantShape}, Field, }; @@ -484,3 +485,363 @@ fn snapshot_field(field: &Field) -> TokenStream { #ident: <#ty as ::rclrs::DeclareField<#mode>>::snapshot(&self.#ident) } } + +// --------------------------------------------------------------------------------------------- +// Enum sets +// --------------------------------------------------------------------------------------------- + +/// Generates the handles and trait implementations for an enum parameter set. +/// +/// Two types are generated: a struct holding the tag parameter's handle and the variant's +/// handles, and an enum mirroring the user's for the latter. The tag has to be kept somewhere so +/// that the parameter stays declared, and an enum cannot have a field common to all its variants. +pub(crate) fn generate_enum( + input: &DeriveInput, + set: &SetAttrs, + tag: &str, + variants: &[Variant], +) -> TokenStream { + let values = &input.ident; + let handles = handles_ident(input, set); + let variant_handles = format_ident!("{}VariantParams", values, span = values.span()); + let visibility = &input.vis; + + let handle_variants = variants.iter().map(|variant| { + let ident = variant.ident; + + match &variant.shape { + VariantShape::Fields(fields) => { + let handles = fields.iter().map(|field| { + let field_ident = field.ident; + let ty = field.ty; + let mode = field.mode(); + let doc = handle_field_doc(field); + + quote_spanned! { field.span() => + #[doc = #doc] + #field_ident: <#ty as ::rclrs::DeclareField<#mode>>::Handle + } + }); + + quote!(#ident { #(#handles,)* }) + } + VariantShape::Delegate(ty) => { + quote!(#ident(<#ty as ::rclrs::DeclareField<::rclrs::Writable>>::Handle)) + } + VariantShape::Unit => quote!(#ident), + } + }); + + let tag_values: Vec<&str> = variants.iter().map(|v| v.tag.as_str()).collect(); + let constraints = format!("one of: {}", tag_values.join(", ")); + let unknown_tag = format!( + "unknown {values} '{{}}', expected one of: {}", + tag_values.join(", ") + ); + let tag_description = set_doc(input); + + // Which tag value a supplied default value corresponds to. + let tag_defaults = variants.iter().map(|variant| { + let ident = variant.ident; + let tag = &variant.tag; + + let pattern = match &variant.shape { + VariantShape::Fields(_) => quote!(#values::#ident { .. }), + VariantShape::Delegate(_) => quote!(#values::#ident(..)), + VariantShape::Unit => quote!(#values::#ident), + }; + + quote! { + ::core::option::Option::Some(#pattern) => { + ::core::option::Option::Some(::std::string::String::from(#tag)) + } + } + }); + + let declare_arms = variants.iter().map(|variant| { + let ident = variant.ident; + let tag = &variant.tag; + + let body = match &variant.shape { + VariantShape::Fields(fields) => { + let declared: Vec<&Field> = fields.iter().filter(|f| f.is_declared()).collect(); + let defaults = destructure_variant_defaults(values, ident, &declared); + let inits = declared.iter().map(|field| declare_field(field, false)); + + quote! {{ + #defaults + #variant_handles::#ident { #(#inits,)* } + }} + } + VariantShape::Delegate(ty) => quote! {{ + let __inner_default = match __default { + ::core::option::Option::Some(#values::#ident(__inner)) => { + ::core::option::Option::Some(__inner) + } + _ => ::core::option::Option::None, + }; + + // Declared under this set's own namespace: the variant is identified by the tag, + // so it does not add a namespace of its own. + #variant_handles::#ident( + <#ty as ::rclrs::DeclareField<::rclrs::Writable>>::declare( + node, + prefix, + ::rclrs::FieldSpec { + default: __inner_default, + ..::core::default::Default::default() + }, + )?, + ) + }}, + VariantShape::Unit => quote!(#variant_handles::#ident), + }; + + quote!(#tag => #body) + }); + + let snapshot_arms = variants.iter().map(|variant| { + let ident = variant.ident; + + match &variant.shape { + VariantShape::Fields(fields) => { + let declared: Vec<&Field> = fields.iter().filter(|f| f.is_declared()).collect(); + let bindings = declared.iter().map(|field| field.ident); + + let reads = fields.iter().map(|field| { + let field_ident = field.ident; + + if !field.is_declared() { + return quote!(#field_ident: ::core::default::Default::default()); + } + + let ty = field.ty; + let mode = field.mode(); + + quote!(#field_ident: <#ty as ::rclrs::DeclareField<#mode>>::snapshot(#field_ident)) + }); + + quote! { + #variant_handles::#ident { #(#bindings,)* } => #values::#ident { #(#reads,)* } + } + } + VariantShape::Delegate(ty) => quote! { + #variant_handles::#ident(__inner) => #values::#ident( + <#ty as ::rclrs::DeclareField<::rclrs::Writable>>::snapshot(__inner), + ) + }, + VariantShape::Unit => quote!(#variant_handles::#ident => #values::#ident), + } + }); + + let namespace = set + .namespace + .as_ref() + .map(|ns| ns.value()) + .unwrap_or_default(); + + let default_source = match &set.default { + Some(expr) => quote! { + let __default = ::core::option::Option::or_else( + default, + || ::core::option::Option::Some(#expr), + ); + }, + None => quote!(let __default = default;), + }; + + let handles_doc = format!( + "Live parameter handles for [`{values}`].\n\n\ + Generated by `#[derive(ParameterSet)]`. `variant` holds the handles for whichever \ + variant the `{tag}` parameter selected.", + ); + + let variant_handles_doc = format!( + "Live parameter handles for the variants of [`{values}`], generated by \ + `#[derive(ParameterSet)]`.", + ); + + quote! { + #[doc = #handles_doc] + #visibility struct #handles { + #[doc = "Handles for the variant that is in use."] + pub variant: #variant_handles, + // Holds the tag parameter open for as long as these handles live. It is read-only, + // so there is nothing to expose beyond the value it resolved to. + tag: ::rclrs::ReadOnlyParameter<::std::string::String>, + } + + impl #handles { + #[doc = "The value of the tag parameter that selected this variant."] + pub fn tag(&self) -> ::std::string::String { + ::rclrs::ReadOnlyParameter::get(&self.tag) + } + } + + #[doc = #variant_handles_doc] + #visibility enum #variant_handles { + #(#handle_variants,)* + } + + impl ::rclrs::ParameterSet for #values { + type Handles = #handles; + + const NAMESPACE: &'static str = #namespace; + + fn declare( + node: &::rclrs::NodeState, + prefix: &str, + default: ::core::option::Option, + ) -> ::core::result::Result { + #default_source + + // The tag is declared first, because what else there is to declare depends on it. + let __tag_name = ::rclrs::join_parameter_name(prefix, #tag); + let __tag_default = match &__default { + #(#tag_defaults,)* + ::core::option::Option::None => ::core::option::Option::None, + }; + let __tag = <::std::string::String as ::rclrs::DeclareField<::rclrs::ReadOnly>>::declare( + node, + &__tag_name, + ::rclrs::FieldSpec { + default: __tag_default, + description: #tag_description, + constraints: #constraints, + validate: ::core::option::Option::Some(::std::boxed::Box::new( + |__value: &::std::string::String| { + match ::core::convert::AsRef::::as_ref(__value) { + #(#tag_values)|* => ::core::result::Result::Ok(()), + __other => ::core::result::Result::Err( + ::std::format!(#unknown_tag, __other), + ), + } + }, + )), + ..::core::default::Default::default() + }, + )?; + + let __variant = match ::core::convert::AsRef::::as_ref( + &::rclrs::ReadOnlyParameter::get(&__tag), + ) { + #(#declare_arms,)* + // Unreachable: the validate callback above rejects anything else, which fails + // the declaration of the tag before this point. + __other => ::core::unreachable!( + "the tag parameter accepted a value that is not a known variant: {}", + __other, + ), + }; + + ::core::result::Result::Ok(#handles { + variant: __variant, + tag: __tag, + }) + } + } + + impl ::rclrs::ParameterSetHandles for #handles { + type Values = #values; + + fn snapshot(&self) -> #values { + match &self.variant { + #(#snapshot_arms,)* + } + } + } + + impl ::rclrs::DeclareField<::rclrs::Writable> for #values { + type Value = Self; + type Handle = #handles; + type Range = (); + + fn declare( + node: &::rclrs::NodeState, + name: &str, + spec: ::rclrs::FieldSpec, + ) -> ::core::result::Result { + ::declare(node, name, spec.default) + } + + fn snapshot(handle: &Self::Handle) -> Self { + <#handles as ::rclrs::ParameterSetHandles>::snapshot(handle) + } + + fn into_default(self) -> ::core::option::Option { + ::core::option::Option::Some(self) + } + } + + impl ::rclrs::DeclareFlattened<::rclrs::Writable> for #values {} + } +} + +/// Takes a variant's default value apart, so that each of its fields can be given a default. +fn destructure_variant_defaults( + values: &Ident, + variant: &Ident, + declared: &[&Field], +) -> TokenStream { + if declared.is_empty() { + return quote!(let _ = &__default;); + } + + let bindings = declared.iter().map(|field| { + let binding = default_binding(field, DefaultSource::Supplied); + quote!(#binding) + }); + + let field_idents = declared.iter().map(|field| field.ident); + + let somes = declared + .iter() + .map(|field| { + let ident = field.ident; + quote!(::core::option::Option::Some(#ident)) + }) + .collect::>(); + + let nones = declared + .iter() + .map(|_| quote!(::core::option::Option::None)); + + quote! { + let (#(#bindings,)*) = match __default { + ::core::option::Option::Some(#values::#variant { #(#field_idents,)* .. }) => { + (#(#somes,)*) + } + _ => (#(#nones,)*), + }; + } +} + +/// The doc comment on the type itself, used to describe the tag parameter. +fn set_doc(input: &DeriveInput) -> String { + input + .attrs + .iter() + .filter_map(|attr| { + if !attr.path().is_ident("doc") { + return None; + } + + let syn::Meta::NameValue(name_value) = &attr.meta else { + return None; + }; + + let Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(text), + .. + }) = &name_value.value + else { + return None; + }; + + let line = text.value(); + + Some(line.strip_prefix(' ').unwrap_or(&line).to_string()) + }) + .collect::>() + .join("\n") +} diff --git a/rclrs-macros/src/parameter_set/enum_set.rs b/rclrs-macros/src/parameter_set/enum_set.rs new file mode 100644 index 00000000..3dcdf3fb --- /dev/null +++ b/rclrs-macros/src/parameter_set/enum_set.rs @@ -0,0 +1,194 @@ +//! Parameter sets that are enums: a group of parameters whose shape depends on a value. +//! +//! ROS 2 parameters are declared statically, so an enum set is declared in two steps. A +//! read-only string parameter, the *tag*, says which variant is in use, and the parameters of +//! that variant are then declared alongside it. The tag has to be read-only: changing the +//! variant at runtime would mean undeclaring one set of parameters and declaring another, +//! invalidating handles the caller may be holding. + +use syn::{DataEnum, Fields, Ident, Variant as SynVariant}; + +use super::{attrs::FieldAttrs, check_field, duplicate_parameter_name, parameter_name, Field}; +use crate::{errors::Errors, parameter_variant::attrs::RenameAll}; + +/// The default name of the parameter that says which variant is in use. +pub(crate) const DEFAULT_TAG: &str = "type"; + +/// One variant of an enum parameter set. +pub(crate) struct Variant<'a> { + pub ident: &'a Ident, + /// The string stored in the tag parameter for this variant. + pub tag: String, + pub shape: VariantShape<'a>, +} + +/// What a variant declares, beyond the tag. +pub(crate) enum VariantShape<'a> { + /// Named fields, declared under the set's own namespace. + Fields(Vec>), + /// Another parameter set, declared under the set's own namespace. + Delegate(&'a syn::Type), + /// Nothing. + Unit, +} + +/// Reads the variants of an enum parameter set, reporting the shapes that cannot be one. +pub(crate) fn variants<'a>( + data: &'a DataEnum, + ident: &Ident, + rename_all: Option, + tag: &str, + errors: &mut Errors, +) -> Vec> { + if data.variants.is_empty() { + errors.at( + &data.variants, + "an enum with no variants describes no parameters", + ); + return Vec::new(); + } + + if data + .variants + .iter() + .all(|variant| matches!(variant.fields, Fields::Unit)) + { + errors.at( + ident, + "no variant of this enum carries any parameters, so it is a single value rather than \ + a group of them. Derive `ParameterVariant` instead, which represents it as one \ + string parameter", + ); + return Vec::new(); + } + + let mut variants = Vec::new(); + for variant in &data.variants { + let attrs = FieldAttrs::parse(&variant.attrs, errors); + check_variant_attrs(variant, &attrs, errors); + + let tag_value = match &attrs.rename { + Some(rename) => rename.value(), + None => rename_all + .unwrap_or(RenameAll::None) + .apply(¶meter_name(&variant.ident)), + }; + + let shape = match &variant.fields { + Fields::Unit => VariantShape::Unit, + Fields::Named(fields) => { + let mut variant_fields = Vec::new(); + + for field in &fields.named { + let field_ident = field + .ident + .as_ref() + .expect("fields of a named variant have idents"); + + let field_attrs = FieldAttrs::parse(&field.attrs, errors); + + check_field(field, &field_attrs, errors); + + let name = match &field_attrs.rename { + Some(rename) => rename.value(), + None => parameter_name(field_ident), + }; + + if name == tag { + errors.at( + field_ident, + format!( + "this field would declare a parameter called `{name}`, which is \ + already the name of the parameter that says which variant is in \ + use. Rename the field, or choose another tag with \ + `#[parameters(tag = \"...\")]`" + ), + ); + } + + variant_fields.push(Field { + ident: field_ident, + ty: &field.ty, + name, + attrs: field_attrs, + }); + } + + if let Some(duplicate) = duplicate_parameter_name(&variant_fields) { + errors.at( + duplicate.ident, + format!( + "another field of this variant already declares a parameter called \ + `{}`", + duplicate.name + ), + ); + } + VariantShape::Fields(variant_fields) + } + Fields::Unnamed(fields) => { + let mut iter = fields.unnamed.iter(); + + let (Some(field), None) = (iter.next(), iter.next()) else { + errors.at( + fields, + "a variant of a parameter set holds either named fields, each of which \ + is a parameter, or a single parameter set to delegate to", + ); + continue; + }; + + // The delegated set's parameters are declared under this set's namespace, since + // the variant is already identified by the tag. A single *value* has nowhere to + // go: it would have to be named after the namespace it sits in. + if super::shape_of(&field.ty).is_definitely_leaf() { + errors.at( + &field.ty, + "a variant holding a single value has no name to declare it under, since \ + the variant itself is identified by the tag parameter. Use a struct \ + variant so the value has a field name", + ); + continue; + } + + VariantShape::Delegate(&field.ty) + } + }; + + variants.push(Variant { + ident: &variant.ident, + tag: tag_value, + shape, + }); + } + + // Two variants stored under the same tag value could not be told apart. + let mut seen = std::collections::HashMap::new(); + for variant in &variants { + if let Some(previous) = seen.insert(variant.tag.clone(), variant.ident) { + errors.at( + variant.ident, + format!( + "`{}` is already the tag value of variant `{previous}`", + variant.tag + ), + ); + } + } + + variants +} + +/// Only `rename` means anything on a variant, since the rest describe a single parameter. +fn check_variant_attrs(variant: &SynVariant, attrs: &FieldAttrs, errors: &mut Errors) { + if let Some(span) = attrs.conflicts_with_rename() { + errors.push(syn::Error::new( + span, + format!( + "this option describes a single parameter, so it has no meaning on the `{}` \ + variant. Put it on one of the variant's fields", + variant.ident + ), + )); + } +} diff --git a/rclrs-macros/src/parameter_set/expand_tests.rs b/rclrs-macros/src/parameter_set/expand_tests.rs index cb9e09a2..410159ad 100644 --- a/rclrs-macros/src/parameter_set/expand_tests.rs +++ b/rclrs-macros/src/parameter_set/expand_tests.rs @@ -36,10 +36,17 @@ fn rejected_with(input: &str, expected: &[&str]) { } } +/// Asserts that `input` is accepted, and returns the code generated from it. #[track_caller] -fn accepted(input: &str) { - let errors = errors(input); - assert!(errors.is_empty(), "should have been accepted: {errors:#?}"); +fn accepted(input: &str) -> String { + let parsed: DeriveInput = syn::parse_str(input).expect("test input should parse"); + match expand(&parsed) { + Ok(tokens) => tokens.to_string(), + Err(error) => panic!( + "should have been accepted: {:#?}", + error.into_iter().map(|e| e.to_string()).collect::>() + ), + } } #[test] @@ -336,11 +343,10 @@ fn test_reports_unknown_options() { fn test_rejects_shapes_that_cannot_describe_parameters() { rejected_with("struct C(f64);", &["requires named fields"]); rejected_with("struct C;", &["at least one field"]); - rejected_with("enum C { A }", &["cannot yet be derived for an enum"]); rejected_with("union C { a: f64 }", &["cannot be derived for a union"]); rejected_with( "struct C { speed: T }", - &["generic", "known when the struct is defined"], + &["generic", "known when the type is defined"], ); } @@ -359,3 +365,137 @@ fn test_reports_every_problem_at_once() { ); assert_eq!(errors.len(), 3, "{errors:#?}"); } + +// ------------------------------------------------------------------------------------------- +// Enum sets +// ------------------------------------------------------------------------------------------- + +#[test] +fn test_accepts_an_enum_of_variants_carrying_parameters() { + let generated = accepted( + r#" + #[parameters(rename_all = "snake_case")] + enum SensorConfig { + Lidar { + #[param(default = 30, range = 1..=100)] + rate: i64, + }, + Camera(CameraConfig), + Disabled, + } + "#, + ); + // A struct for the tag plus an enum for the variants' handles. + assert!( + generated.contains("struct SensorConfigParams"), + "{generated}" + ); + assert!( + generated.contains("enum SensorConfigVariantParams"), + "{generated}" + ); + // The tag is a read-only string, and its valid values are reported. + assert!(generated.contains("ReadOnly"), "{generated}"); + assert!( + generated.contains("one of: lidar, camera, disabled"), + "{generated}" + ); + assert!(generated.contains("\"type\""), "{generated}"); +} + +#[test] +fn test_the_tag_parameter_can_be_named() { + let generated = accepted( + r#" + #[parameters(tag = "kind")] + enum SensorConfig { Lidar { rate: i64 }, Disabled } + "#, + ); + assert!(generated.contains("\"kind\""), "{generated}"); +} + +#[test] +fn test_a_variant_can_set_its_own_tag_value() { + let generated = accepted( + r#" + enum SensorConfig { + #[param(rename = "2d_lidar")] + Lidar { rate: i64 }, + Disabled, + } + "#, + ); + assert!(generated.contains("2d_lidar"), "{generated}"); +} + +/// An enum whose variants carry nothing is a single value, and there is a derive for that. +#[test] +fn test_redirects_a_plain_enum_to_parameter_variant() { + rejected_with( + "enum ControlMode { Velocity, Position }", + &["single value", "Derive `ParameterVariant`"], + ); +} + +#[test] +fn test_rejects_a_variant_holding_a_single_value() { + rejected_with( + "enum SensorConfig { Timeout(f64), Disabled }", + &["no name to declare it under", "struct variant"], + ); +} + +#[test] +fn test_rejects_a_variant_holding_several_values() { + rejected_with( + "enum SensorConfig { Lidar(f64, f64), Disabled }", + &["named fields", "a single parameter set to delegate to"], + ); +} + +#[test] +fn test_rejects_a_field_colliding_with_the_tag() { + rejected_with( + "enum SensorConfig { Lidar { r#type: i64 }, Disabled }", + &["already the name of the parameter that says which variant"], + ); +} + +#[test] +fn test_rejects_two_variants_with_the_same_tag_value() { + rejected_with( + r#" + enum SensorConfig { + Lidar { rate: i64 }, + #[param(rename = "Lidar")] + Laser { rate: i64 }, + } + "#, + &["already the tag value of variant `Lidar`"], + ); +} + +#[test] +fn test_rejects_parameter_options_on_a_variant() { + rejected_with( + "enum SensorConfig { #[param(default = 1.0)] Lidar { rate: i64 }, Disabled }", + &["no meaning on the `Lidar` variant", "variant's fields"], + ); +} + +#[test] +fn test_rejects_enum_only_options_on_a_struct() { + rejected_with( + r#"#[parameters(tag = "type")] struct C { speed: f64 }"#, + &["`tag`", "no meaning for a struct"], + ); + rejected_with( + r#"#[parameters(rename_all = "snake_case")] struct C { speed: f64 }"#, + &["`rename_all`", "no meaning for a struct"], + ); +} + +#[test] +fn test_rejects_an_empty_enum() { + rejected_with("enum C {}", &["no variants"]); +} diff --git a/rclrs-macros/src/parameter_set/known_types.rs b/rclrs-macros/src/parameter_set/known_types.rs index c75b8647..7aeab6d5 100644 --- a/rclrs-macros/src/parameter_set/known_types.rs +++ b/rclrs-macros/src/parameter_set/known_types.rs @@ -50,6 +50,21 @@ impl TypeShape { } } + /// Whether the type is definitely a single parameter rather than a set. + /// + /// Used to reject a newtype variant holding a single value, which has no name to declare + /// itself under. An unrecognised type is not definitely anything, so it is let through and + /// left to trait resolution. + pub fn is_definitely_leaf(&self) -> bool { + matches!( + self, + TypeShape::Leaf { .. } + | TypeShape::OptionalLeaf { .. } + | TypeShape::Array + | TypeShape::OptionalArray + ) + } + /// Whether the field is an `Option`, as far as the macro can tell. Used to reject /// combinations such as `read_only` on an optional parameter, whose message is much better /// coming from here than from a missing trait implementation. diff --git a/rclrs-macros/src/parameter_set/mod.rs b/rclrs-macros/src/parameter_set/mod.rs index 5ceb5c1c..fb351f7a 100644 --- a/rclrs-macros/src/parameter_set/mod.rs +++ b/rclrs-macros/src/parameter_set/mod.rs @@ -22,6 +22,7 @@ mod attrs; mod codegen; +mod enum_set; mod known_types; use proc_macro2::TokenStream; @@ -31,6 +32,14 @@ use crate::errors::Errors; use attrs::{FieldAttrs, SetAttrs}; use known_types::{shape_of, TypeShape}; +/// The parameter name an identifier stands for. +/// +/// A raw identifier is written `r#type` in Rust but names the parameter `type`, which is the +/// point of using one: it lets a field be called after a parameter whose name is a keyword. +pub(crate) fn parameter_name(ident: &Ident) -> String { + ident.to_string().trim_start_matches("r#").to_string() +} + /// One field of the struct, with everything needed to generate its declaration. pub(crate) struct Field<'a> { pub ident: &'a Ident, @@ -48,39 +57,14 @@ pub(crate) fn expand(input: &DeriveInput) -> syn::Result { if !input.generics.params.is_empty() { errors.at( &input.generics, - "`ParameterSet` cannot be derived for a generic struct: the parameters to declare \ - have to be known when the struct is defined", + "`ParameterSet` cannot be derived for a generic type: the parameters to declare have \ + to be known when the type is defined", ); } - // Only a struct with named fields can describe a set of parameters, since each field name is - // a parameter name. - let named_fields = match &input.data { - Data::Struct(data) => match &data.fields { - Fields::Named(fields) => Some(&fields.named), - Fields::Unnamed(_) => { - errors.at( - &data.fields, - "`ParameterSet` requires named fields, because each field's name is the name \ - of the parameter it declares", - ); - None - } - Fields::Unit => { - errors.at( - &input.ident, - "`ParameterSet` requires a struct with at least one field", - ); - None - } - }, - Data::Enum(data) => { - errors.at( - data.enum_token, - "`ParameterSet` cannot yet be derived for an enum", - ); - None - } + let generated = match &input.data { + Data::Struct(data) => expand_struct(input, &set_attrs, data, &mut errors), + Data::Enum(data) => expand_enum(input, &set_attrs, data, &mut errors), Data::Union(data) => { errors.at( data.union_token, @@ -90,9 +74,51 @@ pub(crate) fn expand(input: &DeriveInput) -> syn::Result { } }; - // Nothing further can be said about a struct whose shape is wrong, so report what is known. - let Some(named_fields) = named_fields else { - return Err(errors.into_result().expect_err("an error was recorded")); + // Generating code from a type that was rejected only produces a second round of errors about + // the code that was generated from it. + errors.into_result()?; + Ok(generated.expect("code was generated when no error was recorded")) +} + +/// A struct: every field is a parameter. +fn expand_struct( + input: &DeriveInput, + set_attrs: &SetAttrs, + data: &syn::DataStruct, + errors: &mut Errors, +) -> Option { + if let Some(tag) = &set_attrs.tag { + errors.at( + tag, + "`tag` names the parameter that says which variant of an enum is in use, so it has \ + no meaning for a struct", + ); + } + if let Some((_, span)) = &set_attrs.rename_all { + errors.at( + span, + "`rename_all` names the variants of an enum, so it has no meaning for a struct. Use \ + `#[param(rename = \"...\")]` to rename an individual parameter", + ); + } + + let named_fields = match &data.fields { + Fields::Named(fields) => &fields.named, + Fields::Unnamed(_) => { + errors.at( + &data.fields, + "`ParameterSet` requires named fields, because each field's name is the name of \ + the parameter it declares", + ); + return None; + } + Fields::Unit => { + errors.at( + &input.ident, + "`ParameterSet` requires a struct with at least one field", + ); + return None; + } }; // Parse each field's attributes, check them against its type, and settle the name of the @@ -105,13 +131,13 @@ pub(crate) fn expand(input: &DeriveInput) -> syn::Result { .as_ref() .expect("fields of a named struct have idents"); - let attrs = FieldAttrs::parse(&field.attrs, &mut errors); + let attrs = FieldAttrs::parse(&field.attrs, errors); - check_field(field, &attrs, &mut errors); + check_field(field, &attrs, errors); let name = match &attrs.rename { Some(rename) => rename.value(), - None => ident.to_string(), + None => parameter_name(ident), }; fields.push(Field { @@ -134,11 +160,43 @@ pub(crate) fn expand(input: &DeriveInput) -> syn::Result { ); } - // Generating code from a struct that was rejected only produces a second round of errors - // about the code that was generated from it. Return the errors already collected instead. - errors.into_result()?; + if !errors.is_empty() { + return None; + } + + Some(codegen::generate(input, set_attrs, &fields)) +} + +/// An enum: a read-only tag parameter says which variant is in use, and that variant's +/// parameters are declared alongside it. +fn expand_enum( + input: &DeriveInput, + set_attrs: &SetAttrs, + data: &syn::DataEnum, + errors: &mut Errors, +) -> Option { + let tag = set_attrs + .tag + .as_ref() + .map(|tag| tag.value()) + .unwrap_or_else(|| enum_set::DEFAULT_TAG.to_string()); + + let variants = enum_set::variants( + data, + &input.ident, + set_attrs + .rename_all + .as_ref() + .map(|(convention, _)| *convention), + &tag, + errors, + ); + + if !errors.is_empty() { + return None; + } - Ok(codegen::generate(input, &set_attrs, &fields)) + Some(codegen::generate_enum(input, set_attrs, &tag, &variants)) } /// The `T` of an `Option`, by syntax alone. @@ -160,7 +218,7 @@ fn option_inner(ty: &syn::Type) -> Option<&syn::Type> { } /// Reports the mistakes the macro can recognise from the field's type and attributes. -fn check_field(field: &syn::Field, attrs: &FieldAttrs, errors: &mut Errors) { +pub(crate) fn check_field(field: &syn::Field, attrs: &FieldAttrs, errors: &mut Errors) { let shape = shape_of(&field.ty); // Check the attributes on a skipped field. A skipped field is not a parameter, so any other @@ -248,7 +306,7 @@ fn check_field(field: &syn::Field, attrs: &FieldAttrs, errors: &mut Errors) { } /// Two fields declaring the same parameter name, which `rename` makes possible. -fn duplicate_parameter_name<'a>(fields: &'a [Field<'a>]) -> Option<&'a Field<'a>> { +pub(crate) fn duplicate_parameter_name<'a>(fields: &'a [Field<'a>]) -> Option<&'a Field<'a>> { let mut seen = std::collections::HashSet::new(); fields .iter() diff --git a/rclrs-macros/src/parameter_variant/attrs.rs b/rclrs-macros/src/parameter_variant/attrs.rs index cca3f36b..7ba95e77 100644 --- a/rclrs-macros/src/parameter_variant/attrs.rs +++ b/rclrs-macros/src/parameter_variant/attrs.rs @@ -91,6 +91,8 @@ impl VariantAttrs { } /// How to turn a variant's name into the string it is stored as. +/// +/// Shared with the `ParameterSet` derive, whose enum sets name their variants the same way. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum RenameAll { /// Exactly as written. @@ -111,7 +113,7 @@ impl RenameAll { "\"SCREAMING_SNAKE_CASE\"", ]; - fn parse(value: &str) -> Option { + pub fn parse(value: &str) -> Option { Some(match value { "snake_case" => Self::SnakeCase, "kebab-case" => Self::KebabCase, diff --git a/rclrs-macros/src/parameter_variant/mod.rs b/rclrs-macros/src/parameter_variant/mod.rs index c3614db9..8e20baaf 100644 --- a/rclrs-macros/src/parameter_variant/mod.rs +++ b/rclrs-macros/src/parameter_variant/mod.rs @@ -15,7 +15,7 @@ //! derive is for a type you own. A conversion is what a type from another crate needs, since a //! derive cannot be applied to one. -mod attrs; +pub(crate) mod attrs; mod codegen; use syn::{Data, DeriveInput, Fields}; diff --git a/rclrs/src/parameter.rs b/rclrs/src/parameter.rs index 64f0093d..3e0d3dc7 100644 --- a/rclrs/src/parameter.rs +++ b/rclrs/src/parameter.rs @@ -1388,6 +1388,10 @@ impl ParameterInterface { } } +#[cfg(test)] +#[path = "parameter/enum_set_tests.rs"] +mod enum_set_tests; + #[cfg(test)] #[path = "parameter/variant_tests.rs"] mod variant_tests; diff --git a/rclrs/src/parameter/enum_set_tests.rs b/rclrs/src/parameter/enum_set_tests.rs new file mode 100644 index 00000000..9032abd8 --- /dev/null +++ b/rclrs/src/parameter/enum_set_tests.rs @@ -0,0 +1,367 @@ +//! Tests for `#[derive(ParameterSet)]` on an enum: a group of parameters whose shape depends on +//! which variant a parameter file selected. + +use std::sync::Arc; + +use crate::{ + parameter::test_support::{ + node_with_parameter_overrides as node_with_params, parameter_descriptor, + }, + *, +}; + +/// Configuration for one sensor. +#[derive(ParameterSet, Debug, PartialEq)] +#[parameters(rename_all = "snake_case")] +enum SensorConfig { + /// A 2D scanning lidar. + Lidar { + /// Scan rate in Hz. + #[param(default = 30, range = 1..=100)] + rate: i64, + /// Maximum usable range in m. + #[param(default = 25.0)] + range_m: f64, + }, + /// A USB camera, configured by an existing parameter set. + Camera(CameraConfig), + /// Present but not configured. + Disabled, +} + +/// Camera settings. +#[derive(ParameterSet, Debug, PartialEq)] +struct CameraConfig { + /// Frame width in pixels. + #[param(default = 1920)] + width: u32, + /// Frame height in pixels. + #[param(default = 1080)] + height: u32, +} + +#[test] +fn test_a_parameter_file_selects_the_variant() { + let (node, _file) = node_with_params( + "sensor", + r#" +/sensor: + ros__parameters: + type: lidar + rate: 40 + range_m: 30.0 +"#, + ); + + let config: SensorConfig = node.load_parameters().unwrap(); + assert_eq!( + config, + SensorConfig::Lidar { + rate: 40, + range_m: 30.0 + } + ); + + // Only the selected variant's parameters are declared. + let undeclared = node.use_undeclared_parameters(); + assert_eq!(undeclared.get::("rate"), Some(40)); + assert_eq!(undeclared.get::("width"), None); +} + +#[test] +fn test_a_newtype_variant_delegates_to_its_parameter_set() { + let (node, _file) = node_with_params( + "sensor_camera", + r#" +/sensor_camera: + ros__parameters: + type: camera + width: 640 +"#, + ); + + let config: SensorConfig = node.load_parameters().unwrap(); + assert_eq!( + config, + SensorConfig::Camera(CameraConfig { + width: 640, + // Not in the file, so the field's own default. + height: 1080, + }) + ); + + // The delegated set's parameters are declared under this set's namespace, not under a + // namespace of the variant's. + assert_eq!( + node.use_undeclared_parameters().get::("width"), + Some(640) + ); +} + +#[test] +fn test_a_unit_variant_declares_only_the_tag() { + let (node, _file) = node_with_params( + "sensor_disabled", + r#" +/sensor_disabled: + ros__parameters: + type: disabled +"#, + ); + + let config: SensorConfig = node.load_parameters().unwrap(); + assert_eq!(config, SensorConfig::Disabled); + assert_eq!(node.use_undeclared_parameters().get::("rate"), None); +} + +#[test] +fn test_the_variant_handles_can_be_matched_on() { + let (node, _file) = node_with_params( + "sensor_handles", + r#" +/sensor_handles: + ros__parameters: + type: lidar +"#, + ); + + let params = node.declare_parameters::().unwrap(); + assert_eq!(params.tag(), "lidar"); + + match ¶ms.variant { + SensorConfigVariantParams::Lidar { rate, range_m } => { + assert_eq!(rate.get(), 30); + assert_eq!(range_m.get(), 25.0); + // The variant's parameters are live, like any others. + rate.set(50).unwrap(); + assert!(rate.set(200).is_err(), "the range still applies"); + } + other => panic!("expected a lidar, got {:?}", std::mem::discriminant(other)), + } + + assert_eq!( + params.snapshot(), + SensorConfig::Lidar { + rate: 50, + range_m: 25.0 + } + ); +} + +/// The tag is read-only: the set of declared parameters depends on it, and changing which +/// parameters exist at runtime would invalidate handles the caller is holding. +#[test] +fn test_the_tag_cannot_be_changed() { + let (node, _file) = node_with_params( + "sensor_read_only_tag", + r#" +/sensor_read_only_tag: + ros__parameters: + type: lidar +"#, + ); + let _params = node.declare_parameters::().unwrap(); + + let err = node + .use_undeclared_parameters() + .set::>("type", "camera".into()) + .unwrap_err(); + assert!(matches!(err, ParameterValueError::ReadOnly), "{err}"); +} + +#[test] +fn test_an_unknown_variant_fails_the_declaration() { + let (node, _file) = node_with_params( + "sensor_unknown", + r#" +/sensor_unknown: + ros__parameters: + type: banana +"#, + ); + + let err = node.declare_parameters::().err().unwrap(); + assert_eq!(err.name, "type"); + let DeclarationError::InitialValueRejected(reason) = &err.source else { + panic!("expected InitialValueRejected, got {:?}", err.source); + }; + assert!(reason.contains("banana"), "{reason}"); + assert!( + reason.contains("lidar, camera, disabled"), + "the reason should list the variants: {reason}" + ); +} + +#[test] +fn test_a_missing_tag_fails_the_declaration() { + let node = Context::default() + .create_basic_executor() + .create_node("sensor_no_tag") + .unwrap(); + + let err = node.declare_parameters::().err().unwrap(); + assert_eq!(err.name, "type"); + assert_eq!(err.source, DeclarationError::NoValueAvailable); +} + +/// The valid variants reach the descriptor, so an operator can discover them. +#[test] +fn test_the_variants_appear_in_the_tag_descriptor() { + let (node, _file) = node_with_params( + "sensor_describe", + r#" +/sensor_describe: + ros__parameters: + type: disabled +"#, + ); + let _params = node.declare_parameters::().unwrap(); + + let descriptor = parameter_descriptor(&node, "type"); + assert_eq!( + descriptor.additional_constraints.to_string(), + "one of: lidar, camera, disabled" + ); + assert_eq!( + descriptor.description.to_string(), + "Configuration for one sensor." + ); + assert!(descriptor.read_only); +} + +// --------------------------------------------------------------------------------------------- +// Nesting and naming +// --------------------------------------------------------------------------------------------- + +/// A hub with one sensor, to check that an enum set nests like any other. +#[derive(ParameterSet, Debug, PartialEq)] +struct SensorHub { + /// How many times to retry a read. + #[param(default = 3)] + retries: u8, + /// The sensor to use. + sensor: SensorConfig, +} + +#[test] +fn test_an_enum_set_nests_inside_a_struct_set() { + let (node, _file) = node_with_params( + "hub", + r#" +/hub: + ros__parameters: + retries: 5 + sensor: + type: lidar + rate: 15 +"#, + ); + + let config: SensorHub = node.load_parameters().unwrap(); + assert_eq!( + config, + SensorHub { + retries: 5, + sensor: SensorConfig::Lidar { + rate: 15, + range_m: 25.0 + }, + } + ); + + let undeclared = node.use_undeclared_parameters(); + assert_eq!( + undeclared.get::>("sensor.type").as_deref(), + Some("lidar") + ); + assert_eq!(undeclared.get::("sensor.rate"), Some(15)); +} + +#[derive(ParameterSet, Debug, PartialEq)] +#[parameters(tag = "kind", rename_all = "kebab-case")] +enum Renamed { + TwoDimensional { resolution: f64 }, + Disabled, +} + +#[test] +fn test_the_tag_name_and_values_can_be_chosen() { + let (node, _file) = node_with_params( + "renamed", + r#" +/renamed: + ros__parameters: + kind: two-dimensional + resolution: 0.05 +"#, + ); + + let config: Renamed = node.load_parameters().unwrap(); + assert_eq!(config, Renamed::TwoDimensional { resolution: 0.05 }); +} + +// --------------------------------------------------------------------------------------------- +// Defaults +// --------------------------------------------------------------------------------------------- + +#[derive(ParameterSet, Debug, PartialEq)] +#[parameters(default = Self::default(), rename_all = "snake_case")] +enum Defaulted { + Fast { rate: i64 }, + Slow { rate: i64 }, +} + +impl Default for Defaulted { + fn default() -> Self { + Self::Slow { rate: 2 } + } +} + +/// A whole-value default supplies both which variant to use and that variant's values. +#[test] +fn test_a_default_supplies_the_variant_and_its_values() { + let node = Context::default() + .create_basic_executor() + .create_node("defaulted_enum") + .unwrap(); + + let config: Defaulted = node.load_parameters().unwrap(); + assert_eq!(config, Defaulted::Slow { rate: 2 }); +} + +/// A parameter file that selects a different variant gets that variant's own defaults, since the +/// values in the default belong to the variant it names. +#[test] +fn test_selecting_another_variant_does_not_take_the_defaults_values() { + let (node, _file) = node_with_params( + "defaulted_enum_other", + r#" +/defaulted_enum_other: + ros__parameters: + type: fast + rate: 100 +"#, + ); + + let config: Defaulted = node.load_parameters().unwrap(); + assert_eq!(config, Defaulted::Fast { rate: 100 }); +} + +#[test] +fn test_selecting_another_variant_without_defaults_and_no_overrides_errors() { + let (node, _file) = node_with_params( + "defaulted_enum_other", + r#" +/defaulted_enum_other: + ros__parameters: + type: fast +"#, + ); + + let err = node.declare_parameters::().err().unwrap(); + assert!( + err.to_string() + .contains("'rate': parameter was declared as non-optional but no value was available"), + "Expected error message not found" + ); +}