diff --git a/rclrs-macros/src/parameter_set/errors.rs b/rclrs-macros/src/errors.rs similarity index 92% rename from rclrs-macros/src/parameter_set/errors.rs rename to rclrs-macros/src/errors.rs index eb1f9b17..90befbf5 100644 --- a/rclrs-macros/src/parameter_set/errors.rs +++ b/rclrs-macros/src/errors.rs @@ -33,6 +33,11 @@ impl Errors { } } + /// Whether anything has been recorded yet. + pub fn is_empty(&self) -> bool { + self.0.is_none() + } + /// Returns the accumulated errors, if any. pub fn into_result(self) -> syn::Result<()> { match self.0 { diff --git a/rclrs-macros/src/lib.rs b/rclrs-macros/src/lib.rs index c9223f22..425d4d9f 100644 --- a/rclrs-macros/src/lib.rs +++ b/rclrs-macros/src/lib.rs @@ -4,7 +4,9 @@ use proc_macro::TokenStream; +mod errors; mod parameter_set; +mod parameter_variant; /// Declares a struct's fields as a group of ROS 2 parameters. /// @@ -54,3 +56,44 @@ pub fn derive_parameter_set(input: TokenStream) -> TokenStream { Err(err) => err.to_compile_error().into(), } } + +/// Represents a Rust type as a single ROS 2 parameter value. +/// +/// See the `rclrs::ParameterVariant` trait for what this provides, and `rclrs::ParameterSet` for +/// declaring parameters that use it. +/// +/// This is for a type you own, since a derive cannot be applied to one you do not. To use a type +/// from another crate, give the declaration a `rclrs::ParameterConversion` instead, with +/// `#[param(convert = ...)]` on a set field or `declare_parameter_with` on the builder. The +/// difference is where the representation is stated: a derive states it once for the type, a +/// conversion states it at each declaration. +/// +/// The representation is chosen by the shape of the type and by `#[parameter(...)]`: +/// +/// * An **enum whose variants carry no data** becomes a string holding the name of one variant. +/// `#[parameter(rename_all = "snake_case")]` sets the naming convention, one of `snake_case`, +/// `kebab-case`, `lowercase`, `UPPERCASE` or `SCREAMING_SNAKE_CASE`, and +/// `#[parameter(rename = "...")]` on a variant sets its stored value exactly. The valid values +/// appear in the parameter descriptor's constraints and in the message a rejected value gets. +/// * **`#[parameter(transparent)]`** on a type wrapping a single value gives it that value's +/// representation, including its range type. Useful for units: a `Meters(f64)` parameter +/// behaves exactly as an `f64` one, so its range is written in the units of the wrapped value, +/// `0.0..=10.0` rather than `Meters(0.0)..=Meters(10.0)`. +/// * **`#[parameter(from_str)]`** stores the type as a string, using its +/// [`FromStr`](std::str::FromStr) and [`Display`](std::fmt::Display) implementations. The +/// `FromStr` error is reported when a value is rejected, so it should say what was wrong. +/// +/// The type also needs to be [`Clone`], which every parameter value must be. +/// +/// A derived type works with the conversion-based API as well: +/// `rclrs::ParameterConversion::of_variant` assembles a conversion from what this derive emits, +/// so the kind and the constraints carry across and `ros2 param describe` reports them either +/// way. +#[proc_macro_derive(ParameterVariant, attributes(parameter))] +pub fn derive_parameter_variant(input: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(input as syn::DeriveInput); + match parameter_variant::expand(&input) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} diff --git a/rclrs-macros/src/parameter_set/attrs.rs b/rclrs-macros/src/parameter_set/attrs.rs index 66d88900..52d307c4 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 super::errors::Errors; +use crate::errors::Errors; /// Struct-level configuration from `#[parameters(...)]`. #[derive(Default)] diff --git a/rclrs-macros/src/parameter_set/mod.rs b/rclrs-macros/src/parameter_set/mod.rs index 7ea54623..5ceb5c1c 100644 --- a/rclrs-macros/src/parameter_set/mod.rs +++ b/rclrs-macros/src/parameter_set/mod.rs @@ -22,14 +22,13 @@ mod attrs; mod codegen; -mod errors; mod known_types; use proc_macro2::TokenStream; use syn::{spanned::Spanned, Data, DeriveInput, Fields, Ident}; +use crate::errors::Errors; use attrs::{FieldAttrs, SetAttrs}; -use errors::Errors; use known_types::{shape_of, TypeShape}; /// One field of the struct, with everything needed to generate its declaration. diff --git a/rclrs-macros/src/parameter_variant/attrs.rs b/rclrs-macros/src/parameter_variant/attrs.rs new file mode 100644 index 00000000..cca3f36b --- /dev/null +++ b/rclrs-macros/src/parameter_variant/attrs.rs @@ -0,0 +1,193 @@ +//! Parsing `#[parameter(...)]`. + +use syn::{spanned::Spanned, Attribute, Ident, LitStr}; + +use crate::errors::Errors; + +/// Type-level configuration from `#[parameter(...)]`. +#[derive(Default)] +pub(crate) struct TypeAttrs { + /// Represent the type as whatever the single value it wraps is represented as. + pub transparent: Option, + /// Represent the type as a string, via `FromStr` and `Display`. + pub from_str: Option, + /// Naming convention for the stored value of each variant. + pub rename_all: Option, +} + +impl TypeAttrs { + pub fn parse(attrs: &[Attribute], errors: &mut Errors) -> Self { + let mut parsed = Self::default(); + for attr in attrs { + if !attr.path().is_ident("parameter") { + continue; + } + let result = attr.parse_nested_meta(|meta| { + let ident = || { + meta.path + .get_ident() + .cloned() + .unwrap_or_else(|| Ident::new("parameter", attr.path().span())) + }; + if meta.path.is_ident("transparent") { + parsed.transparent = Some(ident()); + } else if meta.path.is_ident("from_str") { + parsed.from_str = Some(ident()); + } 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), + 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( + "unknown `parameter` option; expected one of `transparent`, `from_str`, \ + `rename_all`", + )); + } + Ok(()) + }); + errors.handle(result); + } + parsed + } +} + +/// Variant-level configuration from `#[parameter(...)]`. +#[derive(Default)] +pub(crate) struct VariantAttrs { + /// The exact string this variant is stored as. + pub rename: Option, +} + +impl VariantAttrs { + pub fn parse(attrs: &[Attribute], errors: &mut Errors) -> Self { + let mut parsed = Self::default(); + for attr in attrs { + if !attr.path().is_ident("parameter") { + continue; + } + let result = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename") { + parsed.rename = Some(meta.value()?.parse()?); + Ok(()) + } else { + Err(meta.error("unknown `parameter` option on a variant; expected `rename`")) + } + }); + errors.handle(result); + } + parsed + } +} + +/// How to turn a variant's name into the string it is stored as. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RenameAll { + /// Exactly as written. + None, + SnakeCase, + KebabCase, + LowerCase, + UpperCase, + ScreamingSnakeCase, +} + +impl RenameAll { + pub const NAMES: &'static [&'static str] = &[ + "\"snake_case\"", + "\"kebab-case\"", + "\"lowercase\"", + "\"UPPERCASE\"", + "\"SCREAMING_SNAKE_CASE\"", + ]; + + fn parse(value: &str) -> Option { + Some(match value { + "snake_case" => Self::SnakeCase, + "kebab-case" => Self::KebabCase, + "lowercase" => Self::LowerCase, + "UPPERCASE" => Self::UpperCase, + "SCREAMING_SNAKE_CASE" => Self::ScreamingSnakeCase, + _ => return None, + }) + } + + /// Applies the convention to a variant name, which is assumed to be `PascalCase`. + pub fn apply(self, name: &str) -> String { + match self { + Self::None => name.to_string(), + Self::LowerCase => name.to_lowercase(), + Self::UpperCase => name.to_uppercase(), + Self::SnakeCase => split_words(name).join("_"), + Self::KebabCase => split_words(name).join("-"), + Self::ScreamingSnakeCase => split_words(name).join("_").to_uppercase(), + } + } +} + +/// Splits a `PascalCase` name into lowercase words. +/// +/// A run of capitals is one word, so `HTTPServer` becomes `http_server` rather than +/// `h_t_t_p_server`. +fn split_words(name: &str) -> Vec { + let chars: Vec = name.chars().collect(); + let mut words = Vec::new(); + let mut current = String::new(); + for (index, &c) in chars.iter().enumerate() { + let starts_word = c.is_uppercase() + && index > 0 + && ( + // A lowercase letter or digit before a capital ends the previous word. + !chars[index - 1].is_uppercase() + // The last capital of a run belongs to the next word: `HTTPServer`. + || chars.get(index + 1).is_some_and(|next| next.is_lowercase()) + ); + if starts_word && !current.is_empty() { + words.push(std::mem::take(&mut current)); + } + current.extend(c.to_lowercase()); + } + if !current.is_empty() { + words.push(current); + } + words +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_naming_conventions() { + assert_eq!(RenameAll::None.apply("VelocityMode"), "VelocityMode"); + assert_eq!(RenameAll::SnakeCase.apply("VelocityMode"), "velocity_mode"); + assert_eq!(RenameAll::KebabCase.apply("VelocityMode"), "velocity-mode"); + assert_eq!(RenameAll::LowerCase.apply("VelocityMode"), "velocitymode"); + assert_eq!(RenameAll::UpperCase.apply("VelocityMode"), "VELOCITYMODE"); + assert_eq!( + RenameAll::ScreamingSnakeCase.apply("VelocityMode"), + "VELOCITY_MODE" + ); + } + + #[test] + fn test_runs_of_capitals_are_one_word() { + assert_eq!(RenameAll::SnakeCase.apply("HTTPServer"), "http_server"); + assert_eq!(RenameAll::SnakeCase.apply("PID"), "pid"); + assert_eq!( + RenameAll::KebabCase.apply("UseTCPNoDelay"), + "use-tcp-no-delay" + ); + assert_eq!(RenameAll::SnakeCase.apply("Velocity"), "velocity"); + } +} diff --git a/rclrs-macros/src/parameter_variant/codegen.rs b/rclrs-macros/src/parameter_variant/codegen.rs new file mode 100644 index 00000000..fd27caa7 --- /dev/null +++ b/rclrs-macros/src/parameter_variant/codegen.rs @@ -0,0 +1,180 @@ +//! Emitting the conversions and the `ParameterVariant` implementation. + +use proc_macro2::TokenStream; +use quote::quote; +use syn::DeriveInput; + +use super::Strategy; + +pub(crate) fn generate(input: &DeriveInput, strategy: &Strategy) -> TokenStream { + let ident = &input.ident; + let body = match strategy { + Strategy::Choice { variants } => choice(input, variants), + Strategy::Transparent { inner, accessor } => transparent(input, inner, accessor), + Strategy::FromStr => from_str(input), + }; + + quote! { + #body + + // The trait implementations that let this type be a field of a `ParameterSet`. + ::rclrs::declare_parameter_field!(#ident); + } +} + +/// An enum of plain variants, stored as a string. +fn choice(input: &DeriveInput, variants: &[super::Choice]) -> TokenStream { + let ident = &input.ident; + let type_name = ident.to_string(); + + let to_str = variants.iter().map(|choice| { + let variant = choice.ident; + let name = &choice.name; + quote!(#ident::#variant => #name) + }); + let from_str = variants.iter().map(|choice| { + let variant = choice.ident; + let name = &choice.name; + quote!(#name => ::core::result::Result::Ok(#ident::#variant)) + }); + + // Both the rejection message and the descriptor's constraints list the valid values, so an + // operator setting the parameter over the parameter services is told what they may be. + let valid_values = variants + .iter() + .map(|choice| choice.name.clone()) + .collect::>() + .join(", "); + let constraints = format!("one of: {valid_values}"); + let unknown = format!("unknown {type_name} '{{other}}', expected one of: {valid_values}"); + + quote! { + impl ::core::convert::From<#ident> for ::rclrs::ParameterValue { + fn from(value: #ident) -> Self { + ::rclrs::ParameterValue::String( + ::core::convert::Into::into(match value { #(#to_str,)* }), + ) + } + } + + impl ::core::convert::TryFrom<::rclrs::ParameterValue> for #ident { + type Error = ::rclrs::ParameterValueError; + + fn try_from( + value: ::rclrs::ParameterValue, + ) -> ::core::result::Result { + match value { + ::rclrs::ParameterValue::String(text) => match ::core::convert::AsRef::::as_ref(&text) { + #(#from_str,)* + other => ::core::result::Result::Err( + ::rclrs::ParameterValueError::Invalid(::std::format!(#unknown)), + ), + }, + _ => ::core::result::Result::Err(::rclrs::ParameterValueError::TypeMismatch), + } + } + } + + impl ::rclrs::ParameterVariant for #ident { + type Range = (); + + fn kind() -> ::rclrs::ParameterKind { + ::rclrs::ParameterKind::String + } + + fn type_constraints() -> ::core::option::Option<::std::sync::Arc> { + ::core::option::Option::Some(::core::convert::Into::into(#constraints)) + } + } + } +} + +/// A newtype, stored as whatever the value inside it is stored as. +fn transparent(input: &DeriveInput, inner: &syn::Type, accessor: &TokenStream) -> TokenStream { + let ident = &input.ident; + // Rebuilding the wrapper needs the field's shape: `Self(v)` or `Self { name: v }`. + let rebuild = match &input.data { + syn::Data::Struct(data) if matches!(data.fields, syn::Fields::Named(_)) => { + quote!(|value| #ident { #accessor: value }) + } + _ => quote!(#ident), + }; + + quote! { + impl ::core::convert::From<#ident> for ::rclrs::ParameterValue { + fn from(value: #ident) -> Self { + ::core::convert::Into::into(value.#accessor) + } + } + + impl ::core::convert::TryFrom<::rclrs::ParameterValue> for #ident { + // Whatever the inner type reports, so nothing is lost by wrapping it. + type Error = <#inner as ::core::convert::TryFrom<::rclrs::ParameterValue>>::Error; + + fn try_from( + value: ::rclrs::ParameterValue, + ) -> ::core::result::Result { + ::core::result::Result::map( + <#inner as ::core::convert::TryFrom<::rclrs::ParameterValue>>::try_from(value), + #rebuild, + ) + } + } + + impl ::rclrs::ParameterVariant for #ident { + // Ranges are expressed in the units of the wrapped type. + type Range = <#inner as ::rclrs::ParameterVariant>::Range; + + fn kind() -> ::rclrs::ParameterKind { + <#inner as ::rclrs::ParameterVariant>::kind() + } + + fn type_constraints() -> ::core::option::Option<::std::sync::Arc> { + <#inner as ::rclrs::ParameterVariant>::type_constraints() + } + } + } +} + +/// A type with a `FromStr`, stored as a string. +fn from_str(input: &DeriveInput) -> TokenStream { + let ident = &input.ident; + + quote! { + impl ::core::convert::From<#ident> for ::rclrs::ParameterValue { + fn from(value: #ident) -> Self { + ::rclrs::ParameterValue::String( + ::core::convert::Into::into(::std::string::ToString::to_string(&value)), + ) + } + } + + impl ::core::convert::TryFrom<::rclrs::ParameterValue> for #ident { + type Error = ::rclrs::ParameterValueError; + + fn try_from( + value: ::rclrs::ParameterValue, + ) -> ::core::result::Result { + match value { + ::rclrs::ParameterValue::String(text) => { + <#ident as ::core::str::FromStr>::from_str( + ::core::convert::AsRef::::as_ref(&text), + ) + .map_err(|err| { + ::rclrs::ParameterValueError::Invalid(::std::format!("{}", err)) + }) + } + _ => ::core::result::Result::Err(::rclrs::ParameterValueError::TypeMismatch), + } + } + } + + impl ::rclrs::ParameterVariant for #ident { + type Range = (); + + fn kind() -> ::rclrs::ParameterKind { + ::rclrs::ParameterKind::String + } + } + } +} diff --git a/rclrs-macros/src/parameter_variant/expand_tests.rs b/rclrs-macros/src/parameter_variant/expand_tests.rs new file mode 100644 index 00000000..f789dd2a --- /dev/null +++ b/rclrs-macros/src/parameter_variant/expand_tests.rs @@ -0,0 +1,214 @@ +//! Tests for what `#[derive(ParameterVariant)]` accepts and what it says about what it rejects. +//! +//! As in `parameter_set::expand_tests`, these assert on the messages the macro produces rather +//! than on how a particular rustc renders the generated code. The generated code itself is +//! exercised by the tests in `rclrs`. + +use super::expand; +use syn::DeriveInput; + +fn errors(input: &str) -> Vec { + let parsed: DeriveInput = syn::parse_str(input).expect("test input should parse"); + match expand(&parsed) { + Ok(_) => Vec::new(), + Err(error) => error.into_iter().map(|e| e.to_string()).collect(), + } +} + +#[track_caller] +fn rejected_with(input: &str, expected: &[&str]) { + let errors = errors(input); + assert!(!errors.is_empty(), "should have been rejected"); + let joined = errors.join("\n"); + for fragment in expected { + assert!( + joined.contains(fragment), + "message should mention {fragment:?}, but was: {joined}" + ); + } +} + +#[track_caller] +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] +fn test_a_plain_enum_becomes_a_string_of_variant_names() { + let generated = accepted( + r#" + #[parameter(rename_all = "snake_case")] + enum ControlMode { Velocity, Position, EffortLimited } + "#, + ); + assert!(generated.contains("\"velocity\""), "{generated}"); + assert!(generated.contains("\"effort_limited\""), "{generated}"); + // The valid values are reported both as descriptor constraints and in the rejection message. + assert!( + generated.contains("one of: velocity, position, effort_limited"), + "{generated}" + ); + assert!(generated.contains("unknown ControlMode"), "{generated}"); + // And the type is usable as a parameter set field. + assert!(generated.contains("declare_parameter_field"), "{generated}"); +} + +#[test] +fn test_a_variant_can_set_its_own_stored_value() { + let generated = accepted( + r#" + #[parameter(rename_all = "snake_case")] + enum Mode { Velocity, #[parameter(rename = "pos")] Position } + "#, + ); + assert!(generated.contains("\"pos\""), "{generated}"); + assert!(!generated.contains("\"position\""), "{generated}"); +} + +#[test] +fn test_variant_names_are_kept_as_written_by_default() { + let generated = accepted("enum Mode { Velocity, Position }"); + assert!(generated.contains("\"Velocity\""), "{generated}"); +} + +#[test] +fn test_transparent_takes_the_representation_of_what_it_wraps() { + let generated = accepted("#[parameter(transparent)] struct Meters(f64);"); + assert!(generated.contains("f64"), "{generated}"); + // Including the range type, so a range on a `Meters` parameter is written in metres. + assert!( + generated.contains("ParameterVariant > :: Range"), + "{generated}" + ); + + // A named field works too. + let generated = accepted("#[parameter(transparent)] struct Meters { value: f64 }"); + assert!(generated.contains("value"), "{generated}"); +} + +#[test] +fn test_from_str_uses_from_str_and_display() { + let generated = accepted("#[parameter(from_str)] struct Hostname(String);"); + assert!(generated.contains("FromStr"), "{generated}"); + assert!(generated.contains("to_string"), "{generated}"); +} + +// ------------------------------------------------------------------------------------------- +// Rejections +// ------------------------------------------------------------------------------------------- + +#[test] +fn test_rejects_a_struct_with_no_representation_chosen() { + rejected_with( + "struct Gains { kp: f64, ki: f64 }", + &[ + "no representation as a single parameter value", + "transparent", + "from_str", + "derive `ParameterSet`", + ], + ); +} + +#[test] +fn test_rejects_transparent_on_more_than_one_field() { + rejected_with( + "#[parameter(transparent)] struct Gains { kp: f64, ki: f64 }", + &["exactly one field"], + ); + rejected_with( + "#[parameter(transparent)] struct Nothing;", + &["exactly one field"], + ); +} + +#[test] +fn test_rejects_transparent_on_an_enum() { + rejected_with( + "#[parameter(transparent)] enum Mode { A, B }", + &["wraps a single value, not to an enum"], + ); +} + +#[test] +fn test_rejects_a_variant_that_carries_data() { + rejected_with( + "enum SensorConfig { Lidar { rate: i64 }, Disabled }", + &["carries data", "not supported yet"], + ); + rejected_with( + "enum SensorConfig { Lidar(LidarConfig) }", + &["carries data"], + ); +} + +#[test] +fn test_rejects_two_variants_stored_as_the_same_value() { + rejected_with( + r#" + enum Mode { + Velocity, + #[parameter(rename = "Velocity")] + Speed, + } + "#, + &["already the stored value of variant `Velocity`"], + ); +} + +#[test] +fn test_rejects_an_empty_enum() { + rejected_with("enum Nothing {}", &["no variants"]); +} + +#[test] +fn test_rejects_contradictory_representations() { + rejected_with( + "#[parameter(transparent, from_str)] struct Hostname(String);", + &["two different representations"], + ); +} + +#[test] +fn test_rejects_rename_all_where_it_has_no_meaning() { + rejected_with( + r#"#[parameter(from_str, rename_all = "snake_case")] struct Hostname(String);"#, + &["no meaning together with `from_str`"], + ); +} + +#[test] +fn test_rejects_an_unknown_naming_convention() { + rejected_with( + r#"#[parameter(rename_all = "SpongeBobCase")] enum Mode { A }"#, + &["unknown naming convention", "snake_case"], + ); +} + +#[test] +fn test_rejects_generic_and_union_types() { + rejected_with( + "#[parameter(transparent)] struct Wrapper(T);", + &["generic"], + ); + rejected_with("union U { a: f64 }", &["cannot be derived for a union"]); +} + +#[test] +fn test_rejects_unknown_options() { + rejected_with( + "#[parameter(json)] struct Gains { kp: f64 }", + &["unknown `parameter` option"], + ); + rejected_with( + r#"enum Mode { #[parameter(alias = "v")] Velocity }"#, + &["unknown `parameter` option on a variant"], + ); +} diff --git a/rclrs-macros/src/parameter_variant/mod.rs b/rclrs-macros/src/parameter_variant/mod.rs new file mode 100644 index 00000000..c3614db9 --- /dev/null +++ b/rclrs-macros/src/parameter_variant/mod.rs @@ -0,0 +1,206 @@ +//! Implementation of `#[derive(ParameterVariant)]`. +//! +//! Where `#[derive(ParameterSet)]` describes a *group* of parameters, this describes a single +//! parameter *value*: a Rust type that can be stored in one of the nine types ROS 2 has. +//! +//! Three strategies, one of which is chosen by the shape of the type and by +//! `#[parameter(...)]`: +//! +//! * an enum whose variants carry no data becomes a string, one spelling per variant, +//! * `#[parameter(transparent)]` on a newtype takes on the representation of the type inside it, +//! * `#[parameter(from_str)]` uses [`FromStr`](std::str::FromStr) and [`Display`](std::fmt::Display). +//! +//! All three emit `ParameterVariant`, so `ParameterConversion::of_variant` can assemble a +//! conversion from them and a derived type is usable through the conversion-based API too. The +//! 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; +mod codegen; + +use syn::{Data, DeriveInput, Fields}; + +use crate::errors::Errors; +use attrs::{RenameAll, VariantAttrs}; + +/// How a type is represented as a ROS 2 parameter. +pub(crate) enum Strategy<'a> { + /// A string holding the name of one variant. + Choice { variants: Vec> }, + /// Whatever the single field inside this newtype is represented as. + Transparent { + inner: &'a syn::Type, + /// The field to go through: an index for a tuple struct, a name otherwise. + accessor: proc_macro2::TokenStream, + }, + /// A string, parsed with `FromStr` and written with `Display`. + FromStr, +} + +/// One variant of a string-valued enum. +pub(crate) struct Choice<'a> { + pub ident: &'a syn::Ident, + /// The string this variant is stored as. + pub name: String, +} + +pub(crate) fn expand(input: &DeriveInput) -> syn::Result { + let mut errors = Errors::default(); + let attrs = attrs::TypeAttrs::parse(&input.attrs, &mut errors); + + if !input.generics.params.is_empty() { + errors.at( + &input.generics, + "`ParameterVariant` cannot be derived for a generic type: how it is represented as a \ + parameter has to be known when the type is defined", + ); + } + + let strategy = strategy_for(input, &attrs, &mut errors); + errors.into_result()?; + let strategy = strategy.expect("a strategy was produced when no error was recorded"); + + Ok(codegen::generate(input, &strategy)) +} + +/// Works out how the type should be represented, reporting the shapes that cannot be. +fn strategy_for<'a>( + input: &'a DeriveInput, + attrs: &attrs::TypeAttrs, + errors: &mut Errors, +) -> Option> { + if let (Some(_), Some(from_str)) = (&attrs.transparent, &attrs.from_str) { + errors.at( + from_str, + "`transparent` and `from_str` are two different representations, use one or the other", + ); + return None; + } + + if let Some(from_str) = &attrs.from_str { + if attrs.rename_all.is_some() { + errors.at( + from_str, + "`rename_all` names the variants of an enum, so it has no meaning together with \ + `from_str`", + ); + } + return Some(Strategy::FromStr); + } + + match &input.data { + Data::Struct(data) => { + if attrs.transparent.is_none() { + errors.at( + &input.ident, + "a struct has no representation as a single parameter value on its own. Add \ + `#[parameter(transparent)]` if it wraps a single value, or \ + `#[parameter(from_str)]` if it has a `FromStr` implementation. If it is \ + really a group of parameters, derive `ParameterSet` instead", + ); + return None; + } + transparent_strategy(&data.fields, errors) + } + Data::Enum(data) => { + if let Some(transparent) = &attrs.transparent { + errors.at( + transparent, + "`transparent` applies to a type that wraps a single value, not to an enum", + ); + return None; + } + choice_strategy(data, attrs.rename_all, errors) + } + Data::Union(data) => { + errors.at( + data.union_token, + "`ParameterVariant` cannot be derived for a union", + ); + None + } + } +} + +/// A newtype takes on the representation of the one value inside it. +fn transparent_strategy<'a>(fields: &'a Fields, errors: &mut Errors) -> Option> { + let mut iter = fields.iter(); + let (Some(field), None) = (iter.next(), iter.next()) else { + errors.at( + fields, + "`transparent` needs exactly one field, since the type is represented as whatever \ + that field is represented as", + ); + return None; + }; + let accessor = match &field.ident { + Some(name) => quote::quote!(#name), + None => quote::quote!(0), + }; + Some(Strategy::Transparent { + inner: &field.ty, + accessor, + }) +} + +/// An enum of plain variants becomes a string. +fn choice_strategy<'a>( + data: &'a syn::DataEnum, + rename_all: Option, + errors: &mut Errors, +) -> Option> { + if data.variants.is_empty() { + errors.at( + &data.variants, + "an enum with no variants has no value to store", + ); + return None; + } + + let mut variants = Vec::new(); + for variant in &data.variants { + if !matches!(variant.fields, Fields::Unit) { + errors.at( + &variant.fields, + "a variant that carries data is a group of parameters rather than a single \ + value, so it cannot be part of a `ParameterVariant`. A group of parameters that \ + is one of several shapes is not supported yet", + ); + continue; + } + let attrs = VariantAttrs::parse(&variant.attrs, errors); + let name = match &attrs.rename { + Some(rename) => rename.value(), + None => rename_all + .unwrap_or(RenameAll::None) + .apply(&variant.ident.to_string()), + }; + variants.push(Choice { + ident: &variant.ident, + name, + }); + } + + // Two variants stored as the same string would make the conversion back ambiguous. + let mut seen = std::collections::HashMap::new(); + for choice in &variants { + if let Some(previous) = seen.insert(choice.name.clone(), choice.ident) { + errors.at( + choice.ident, + format!( + "`{}` is already the stored value of variant `{previous}`", + choice.name + ), + ); + } + } + + if !errors.is_empty() { + return None; + } + Some(Strategy::Choice { variants }) +} + +#[cfg(test)] +#[path = "expand_tests.rs"] +mod expand_tests; diff --git a/rclrs/src/lib.rs b/rclrs/src/lib.rs index 619439a0..a2e3acdc 100644 --- a/rclrs/src/lib.rs +++ b/rclrs/src/lib.rs @@ -231,7 +231,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 rclrs_macros::{ParameterSet, ParameterVariant}; pub use service::*; pub use subscription::*; pub use time::*; diff --git a/rclrs/src/parameter.rs b/rclrs/src/parameter.rs index e4bfd11c..64f0093d 100644 --- a/rclrs/src/parameter.rs +++ b/rclrs/src/parameter.rs @@ -1388,6 +1388,10 @@ impl ParameterInterface { } } +#[cfg(test)] +#[path = "parameter/variant_tests.rs"] +mod variant_tests; + /// Shared support for the parameter tests in this module and its children. #[cfg(test)] pub(crate) mod test_support { diff --git a/rclrs/src/parameter/variant_tests.rs b/rclrs/src/parameter/variant_tests.rs new file mode 100644 index 00000000..ca47fd0a --- /dev/null +++ b/rclrs/src/parameter/variant_tests.rs @@ -0,0 +1,347 @@ +//! Tests for `#[derive(ParameterVariant)]`: the code it generates, and how the parameters that +//! use it behave when a value arrives from outside the node. + +use std::{fmt, str::FromStr, sync::Arc}; + +use crate::{parameter::test_support::parameter_descriptor, *}; + +fn node(name: &str) -> Node { + Context::default() + .create_basic_executor() + .create_node(name) + .unwrap() +} + +// --------------------------------------------------------------------------------------------- +// An enum of plain variants +// --------------------------------------------------------------------------------------------- + +/// Which quantity a controller closes the loop on. +#[derive(ParameterVariant, Clone, Copy, Debug, PartialEq)] +#[parameter(rename_all = "snake_case")] +enum ControlMode { + Velocity, + Position, + /// Stored under a different name from the variant's. + #[parameter(rename = "torque")] + Effort, +} + +#[test] +fn test_enum_round_trips_through_a_string() { + let stored: ParameterValue = ControlMode::Velocity.into(); + assert_eq!(stored, ParameterValue::String("velocity".into())); + assert_eq!( + ControlMode::try_from(stored).unwrap(), + ControlMode::Velocity + ); + + let stored: ParameterValue = ControlMode::Effort.into(); + assert_eq!(stored, ParameterValue::String("torque".into())); + assert_eq!(ControlMode::try_from(stored).unwrap(), ControlMode::Effort); +} + +#[test] +fn test_an_unknown_variant_is_rejected_with_the_valid_ones() { + let err = ControlMode::try_from(ParameterValue::String("banana".into())).unwrap_err(); + let ParameterValueError::Invalid(reason) = &err else { + panic!("expected Invalid, got {err:?}"); + }; + assert!(reason.contains("banana"), "{reason}"); + assert!( + reason.contains("velocity, position, torque"), + "the reason should list the valid values: {reason}" + ); + + // A value of the wrong ROS 2 type is a different kind of problem. + assert!(matches!( + ControlMode::try_from(ParameterValue::Integer(1)), + Err(ParameterValueError::TypeMismatch) + )); +} + +#[test] +fn test_enum_parameters_can_be_declared_and_set() { + let node = node("enum_param"); + let mode: MandatoryParameter = node + .declare_parameter("mode") + .default(ControlMode::Velocity) + .mandatory() + .unwrap(); + + assert_eq!(mode.get(), ControlMode::Velocity); + mode.set(ControlMode::Position).unwrap(); + assert_eq!(mode.get(), ControlMode::Position); + + // On the ROS 2 side it is a string. + assert_eq!( + node.use_undeclared_parameters() + .get::>("mode") + .as_deref(), + Some("position") + ); +} + +/// The reason a string-backed enum needs the declared type to be enforced: without it, this set +/// would be accepted and the next `get()` would panic. +#[test] +fn test_an_invalid_value_from_outside_the_node_is_rejected() { + let node = node("enum_param_rejects"); + let mode: MandatoryParameter = node + .declare_parameter("mode") + .default(ControlMode::Velocity) + .mandatory() + .unwrap(); + + let err = node + .use_undeclared_parameters() + .set::>("mode", "banana".into()) + .unwrap_err(); + assert!( + matches!(&err, ParameterValueError::Invalid(reason) if reason.contains("velocity")), + "unexpected error: {err}" + ); + assert_eq!(mode.get(), ControlMode::Velocity); +} + +/// The valid values reach the parameter descriptor, so `ros2 param describe` can report them +/// without the declaration having to restate them. +#[test] +fn test_the_valid_values_appear_in_the_descriptor() { + let node = node("enum_param_describe"); + let _mode: MandatoryParameter = node + .declare_parameter("mode") + .default(ControlMode::Velocity) + .mandatory() + .unwrap(); + + assert_eq!( + parameter_descriptor(&node, "mode") + .additional_constraints + .to_string(), + "one of: velocity, position, torque" + ); +} + +// --------------------------------------------------------------------------------------------- +// A newtype over another parameter type +// --------------------------------------------------------------------------------------------- + +/// A distance in metres. +#[derive(ParameterVariant, Clone, Copy, Debug, PartialEq, PartialOrd, Default)] +#[parameter(transparent)] +struct Meters(pub f64); + +/// A TCP port. +#[derive(ParameterVariant, Clone, Copy, Debug, PartialEq)] +#[parameter(transparent)] +struct Port { + number: u16, +} + +#[test] +fn test_a_transparent_newtype_behaves_as_what_it_wraps() { + let stored: ParameterValue = Meters(2.5).into(); + assert_eq!(stored, ParameterValue::Double(2.5)); + assert_eq!(Meters::try_from(stored).unwrap(), Meters(2.5)); + assert_eq!(Meters::kind(), ParameterKind::Double); + + // Including a named field. + let stored: ParameterValue = Port { number: 8080 }.into(); + assert_eq!(stored, ParameterValue::Integer(8080)); + assert_eq!(Port::try_from(stored).unwrap(), Port { number: 8080 }); +} + +/// The wrapped type's validation is inherited, so a `Port` cannot hold a value a `u16` could not. +#[test] +fn test_a_transparent_newtype_inherits_validation() { + assert!(Port::try_from(ParameterValue::Integer(70000)).is_err()); + + let node = node("transparent_validation"); + let port: MandatoryParameter = node + .declare_parameter("port") + .default(Port { number: 8080 }) + .mandatory() + .unwrap(); + + let err = node + .use_undeclared_parameters() + .set::("port", 70000) + .unwrap_err(); + assert!(matches!(err, ParameterValueError::Invalid(_)), "{err}"); + assert_eq!(port.get(), Port { number: 8080 }); +} + +/// Ranges are expressed in the units of the wrapped type, so a range on a `Meters` parameter is +/// written in metres. +#[test] +fn test_a_transparent_newtype_keeps_the_range_type() { + let node = node("transparent_range"); + let distance: MandatoryParameter = node + .declare_parameter("distance") + .default(Meters(1.0)) + .range(ParameterRange { + lower: Some(0.0), + upper: Some(5.0), + step: None, + }) + .mandatory() + .unwrap(); + + assert!(distance.set(Meters(3.0)).is_ok()); + assert!(distance.set(Meters(6.0)).is_err()); +} + +// --------------------------------------------------------------------------------------------- +// A type with a FromStr +// --------------------------------------------------------------------------------------------- + +/// A hostname, which must not be empty. +#[derive(ParameterVariant, Clone, Debug, PartialEq)] +#[parameter(from_str)] +struct Hostname(String); + +impl FromStr for Hostname { + type Err = String; + + fn from_str(text: &str) -> Result { + if text.is_empty() { + Err("a hostname must not be empty".to_string()) + } else { + Ok(Hostname(text.to_string())) + } + } +} + +impl fmt::Display for Hostname { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[test] +fn test_from_str_round_trips_and_reports_its_own_errors() { + let stored: ParameterValue = Hostname("robot.local".to_string()).into(); + assert_eq!(stored, ParameterValue::String("robot.local".into())); + assert_eq!( + Hostname::try_from(stored).unwrap(), + Hostname("robot.local".to_string()) + ); + + // The FromStr error becomes the reason the value was rejected. + let err = Hostname::try_from(ParameterValue::String("".into())).unwrap_err(); + assert!( + matches!(&err, ParameterValueError::Invalid(reason) if reason.contains("must not be empty")), + "unexpected error: {err}" + ); +} + +// --------------------------------------------------------------------------------------------- +// In a parameter set +// --------------------------------------------------------------------------------------------- + +/// Configuration using types of its own. +#[derive(ParameterSet, Debug, PartialEq)] +struct CustomTypesConfig { + /// How the controller closes the loop. + #[param(default = ControlMode::Velocity)] + mode: ControlMode, + + /// Stopping distance. + #[param(default = Meters(1.5), range = 0.0..=10.0)] + stopping_distance: Meters, + + /// Where to reach the robot. + #[param(default = Hostname("robot.local".to_string()))] + host: Hostname, + + /// Unset unless configured. + fallback: Option, +} + +#[test] +fn test_custom_types_as_parameter_set_fields() { + let node = node("custom_types_set"); + let config: CustomTypesConfig = node.load_parameters().unwrap(); + + assert_eq!( + config, + CustomTypesConfig { + mode: ControlMode::Velocity, + stopping_distance: Meters(1.5), + host: Hostname("robot.local".to_string()), + fallback: None, + } + ); +} + +/// The whole point of the enum representation: a parameter file names the variant. +#[test] +fn test_a_parameter_file_can_name_an_enum_variant() { + use std::io::Write; + + let mut file = tempfile::NamedTempFile::new().unwrap(); + write!( + file, + r#" +/custom_types_yaml: + ros__parameters: + mode: torque + stopping_distance: 2.5 + host: arm.local + fallback: position +"# + ) + .unwrap(); + let node = Context::default() + .create_basic_executor() + .create_node(NodeOptions::new("custom_types_yaml").arguments([ + "--ros-args", + "--params-file", + &file.path().display().to_string(), + ])) + .unwrap(); + + let config: CustomTypesConfig = node.load_parameters().unwrap(); + assert_eq!( + config, + CustomTypesConfig { + mode: ControlMode::Effort, + stopping_distance: Meters(2.5), + host: Hostname("arm.local".to_string()), + fallback: Some(ControlMode::Position), + } + ); +} + +/// A value in the file that is not a valid variant fails the declaration, naming the parameter. +#[test] +fn test_an_invalid_value_in_a_parameter_file_fails_the_declaration() { + use std::io::Write; + + let mut file = tempfile::NamedTempFile::new().unwrap(); + write!( + file, + r#" +/custom_types_bad_yaml: + ros__parameters: + mode: banana +"# + ) + .unwrap(); + let node = Context::default() + .create_basic_executor() + .create_node(NodeOptions::new("custom_types_bad_yaml").arguments([ + "--ros-args", + "--params-file", + &file.path().display().to_string(), + ])) + .unwrap(); + + let err = node + .declare_parameters::() + .err() + .unwrap(); + assert_eq!(err.name, "mode"); + assert_eq!(err.source, DeclarationError::OverrideValueTypeMismatch); +}