* [PATCH perlmod 0/4] perlmod: add #[hash] parameter
@ 2026-07-20 15:25 Max R. Carrara
2026-07-20 15:25 ` [PATCH perlmod 1/4] ffi: implement ExactSizeIterator for StackIter Max R. Carrara
` (3 more replies)
0 siblings, 4 replies; 9+ messages in thread
From: Max R. Carrara @ 2026-07-20 15:25 UTC (permalink / raw)
To: pve-devel
perlmod: add #[hash] parameter - v1
===================================
This series implements a `#[hash]` parameter similar to the existing
`#[list]` one, allowing us to create APIs like
`foo("arg", key => "value")` directly from Rust.
In other words, this essentially mirrors how a sub with `%params` as its
last parameter would work.
For example, this is one of the functions called by the tests that are
added in this series:
#[export]
fn trailing_hash(first: u32, #[hash] rest: HashMap<u32, u32>) -> String {
format!("first={first}, rest is {rest:?}")
}
This can then be called e.g. like this:
let $res = TestLib::Hello::trailing_hash(42, 1337 => 123);
Summary of Changes
------------------
perlmod:
Max R. Carrara (4):
ffi: implement ExactSizeIterator for StackIter
value: implement serde::de::IntoDeserializer on Value
perlmod, macro: add #[hash] parameter
testlib: run perltidy
perlmod-macro/src/function.rs | 72 ++++++++++++++++++++++++++++++++---
perlmod/src/de.rs | 44 +++++++++++++++++++++
perlmod/src/ffi.rs | 7 ++++
perlmod/src/lib.rs | 5 +++
perlmod/src/value.rs | 9 +++++
testlib-tests/01-hello.t | 70 +++++++++++++++++++++++++++++++---
testlib/src/lib.rs | 24 +++++++++++-
7 files changed, 220 insertions(+), 11 deletions(-)
Summary over all repositories:
7 files changed, 220 insertions(+), 11 deletions(-)
--
Generated by murpp 0.11.0
^ permalink raw reply [flat|nested] 9+ messages in thread* [PATCH perlmod 1/4] ffi: implement ExactSizeIterator for StackIter 2026-07-20 15:25 [PATCH perlmod 0/4] perlmod: add #[hash] parameter Max R. Carrara @ 2026-07-20 15:25 ` Max R. Carrara 2026-07-20 15:25 ` [PATCH perlmod 2/4] value: implement serde::de::IntoDeserializer on Value Max R. Carrara ` (2 subsequent siblings) 3 siblings, 0 replies; 9+ messages in thread From: Max R. Carrara @ 2026-07-20 15:25 UTC (permalink / raw) To: pve-devel Since we know how many remaining values there are on the stack, we can confidently implement `ExactSizeIterator` on `StackIter`. Signed-off-by: Max R. Carrara <m.carrara@proxmox.com> --- perlmod/src/ffi.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/perlmod/src/ffi.rs b/perlmod/src/ffi.rs index 1c68518..1b1066d 100644 --- a/perlmod/src/ffi.rs +++ b/perlmod/src/ffi.rs @@ -468,8 +468,15 @@ impl Iterator for StackIter { } } } + + fn size_hint(&self) -> (usize, Option<usize>) { + let remaining = self.end - self.at; + (remaining, Some(remaining)) + } } +impl ExactSizeIterator for StackIter {} + /// Pop the current argument marker off of the argument marker stack. /// /// # Safety -- 2.47.3 ^ permalink raw reply related [flat|nested] 9+ messages in thread
* [PATCH perlmod 2/4] value: implement serde::de::IntoDeserializer on Value 2026-07-20 15:25 [PATCH perlmod 0/4] perlmod: add #[hash] parameter Max R. Carrara 2026-07-20 15:25 ` [PATCH perlmod 1/4] ffi: implement ExactSizeIterator for StackIter Max R. Carrara @ 2026-07-20 15:25 ` Max R. Carrara 2026-07-23 7:34 ` Wolfgang Bumiller 2026-07-20 15:25 ` [PATCH perlmod 3/4] perlmod, macro: add #[hash] parameter Max R. Carrara 2026-07-20 15:25 ` [PATCH perlmod 4/4] testlib: run perltidy Max R. Carrara 3 siblings, 1 reply; 9+ messages in thread From: Max R. Carrara @ 2026-07-20 15:25 UTC (permalink / raw) To: pve-devel For better compatibility with serde, we implement `serde::de::IntoDeserializer` on `Value`. This allows us to skip explicitly converting to the `Value` deserializer in `perlmod::de`. Signed-off-by: Max R. Carrara <m.carrara@proxmox.com> --- perlmod-macro/src/function.rs | 2 +- perlmod/src/value.rs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/perlmod-macro/src/function.rs b/perlmod-macro/src/function.rs index 0a4cf0e..8b5d689 100644 --- a/perlmod-macro/src/function.rs +++ b/perlmod-macro/src/function.rs @@ -136,7 +136,7 @@ impl ArgumentAttrs { let _guard = ::perlmod::__private__::InParameterDeserialization::guard(); match <#arg_type as ::perlmod::__private__::serde::Deserialize>::deserialize( ::perlmod::__private__::serde::de::value::SeqDeserializer::new( - #extracted_name.map(::perlmod::de::Deserializer::<'static>::from_value) + #extracted_name ) ) { Ok(arg) => arg, diff --git a/perlmod/src/value.rs b/perlmod/src/value.rs index 1240eb5..5c2d432 100644 --- a/perlmod/src/value.rs +++ b/perlmod/src/value.rs @@ -3,6 +3,7 @@ use std::fmt; +use serde::de::IntoDeserializer; use serde::{Deserialize, Serialize}; use crate::Error; @@ -652,3 +653,11 @@ impl<'de> Deserialize<'de> for Value { } } } + +impl<'de> IntoDeserializer<'de, Error> for Value { + type Deserializer = crate::de::Deserializer<'de>; + + fn into_deserializer(self) -> Self::Deserializer { + Self::Deserializer::from_value(self) + } +} -- 2.47.3 ^ permalink raw reply related [flat|nested] 9+ messages in thread
* Re: [PATCH perlmod 2/4] value: implement serde::de::IntoDeserializer on Value 2026-07-20 15:25 ` [PATCH perlmod 2/4] value: implement serde::de::IntoDeserializer on Value Max R. Carrara @ 2026-07-23 7:34 ` Wolfgang Bumiller 2026-07-23 7:50 ` partially-applied: " Wolfgang Bumiller 0 siblings, 1 reply; 9+ messages in thread From: Wolfgang Bumiller @ 2026-07-23 7:34 UTC (permalink / raw) To: Max R. Carrara; +Cc: pve-devel On Mon, Jul 20, 2026 at 05:25:38PM +0200, Max R. Carrara wrote: > For better compatibility with serde, we implement > `serde::de::IntoDeserializer` on `Value`. This allows us to skip > explicitly converting to the `Value` deserializer in `perlmod::de`. > > Signed-off-by: Max R. Carrara <m.carrara@proxmox.com> > --- > perlmod-macro/src/function.rs | 2 +- > perlmod/src/value.rs | 9 +++++++++ > 2 files changed, 10 insertions(+), 1 deletion(-) > > diff --git a/perlmod-macro/src/function.rs b/perlmod-macro/src/function.rs > index 0a4cf0e..8b5d689 100644 > --- a/perlmod-macro/src/function.rs > +++ b/perlmod-macro/src/function.rs > @@ -136,7 +136,7 @@ impl ArgumentAttrs { > let _guard = ::perlmod::__private__::InParameterDeserialization::guard(); > match <#arg_type as ::perlmod::__private__::serde::Deserialize>::deserialize( > ::perlmod::__private__::serde::de::value::SeqDeserializer::new( > - #extracted_name.map(::perlmod::de::Deserializer::<'static>::from_value) > + #extracted_name > ) > ) { > Ok(arg) => arg, > diff --git a/perlmod/src/value.rs b/perlmod/src/value.rs > index 1240eb5..5c2d432 100644 > --- a/perlmod/src/value.rs > +++ b/perlmod/src/value.rs > @@ -3,6 +3,7 @@ > > use std::fmt; > > +use serde::de::IntoDeserializer; For a single-use I'd rather skip the import. That said, if we do this at all, one might wonder whether `&'de Value` should also be IntoDeserializer (via `de::from_ref_value`). > use serde::{Deserialize, Serialize}; > > use crate::Error; > @@ -652,3 +653,11 @@ impl<'de> Deserialize<'de> for Value { > } > } > } > + > +impl<'de> IntoDeserializer<'de, Error> for Value { > + type Deserializer = crate::de::Deserializer<'de>; > + > + fn into_deserializer(self) -> Self::Deserializer { > + Self::Deserializer::from_value(self) > + } > +} > -- > 2.47.3 > > > > > -- ^ permalink raw reply [flat|nested] 9+ messages in thread
* partially-applied: [PATCH perlmod 2/4] value: implement serde::de::IntoDeserializer on Value 2026-07-23 7:34 ` Wolfgang Bumiller @ 2026-07-23 7:50 ` Wolfgang Bumiller 0 siblings, 0 replies; 9+ messages in thread From: Wolfgang Bumiller @ 2026-07-23 7:50 UTC (permalink / raw) To: Max R. Carrara; +Cc: pve-devel On Thu, Jul 23, 2026 at 09:34:51AM +0200, Wolfgang Bumiller wrote: > On Mon, Jul 20, 2026 at 05:25:38PM +0200, Max R. Carrara wrote: > > For better compatibility with serde, we implement > > `serde::de::IntoDeserializer` on `Value`. This allows us to skip > > explicitly converting to the `Value` deserializer in `perlmod::de`. > > > > Signed-off-by: Max R. Carrara <m.carrara@proxmox.com> > > --- > > perlmod-macro/src/function.rs | 2 +- > > perlmod/src/value.rs | 9 +++++++++ > > 2 files changed, 10 insertions(+), 1 deletion(-) > > > > diff --git a/perlmod-macro/src/function.rs b/perlmod-macro/src/function.rs > > index 0a4cf0e..8b5d689 100644 > > --- a/perlmod-macro/src/function.rs > > +++ b/perlmod-macro/src/function.rs > > @@ -136,7 +136,7 @@ impl ArgumentAttrs { > > let _guard = ::perlmod::__private__::InParameterDeserialization::guard(); > > match <#arg_type as ::perlmod::__private__::serde::Deserialize>::deserialize( > > ::perlmod::__private__::serde::de::value::SeqDeserializer::new( > > - #extracted_name.map(::perlmod::de::Deserializer::<'static>::from_value) > > + #extracted_name > > ) > > ) { > > Ok(arg) => arg, > > diff --git a/perlmod/src/value.rs b/perlmod/src/value.rs > > index 1240eb5..5c2d432 100644 > > --- a/perlmod/src/value.rs > > +++ b/perlmod/src/value.rs > > @@ -3,6 +3,7 @@ > > > > use std::fmt; > > > > +use serde::de::IntoDeserializer; > > For a single-use I'd rather skip the import. > > That said, if we do this at all, one might wonder whether `&'de Value` > should also be IntoDeserializer (via `de::from_ref_value`). Applied this and patch 1 anyway now. Could add this later if we need it. ^ permalink raw reply [flat|nested] 9+ messages in thread
* [PATCH perlmod 3/4] perlmod, macro: add #[hash] parameter 2026-07-20 15:25 [PATCH perlmod 0/4] perlmod: add #[hash] parameter Max R. Carrara 2026-07-20 15:25 ` [PATCH perlmod 1/4] ffi: implement ExactSizeIterator for StackIter Max R. Carrara 2026-07-20 15:25 ` [PATCH perlmod 2/4] value: implement serde::de::IntoDeserializer on Value Max R. Carrara @ 2026-07-20 15:25 ` Max R. Carrara 2026-07-23 7:54 ` Wolfgang Bumiller 2026-07-20 15:25 ` [PATCH perlmod 4/4] testlib: run perltidy Max R. Carrara 3 siblings, 1 reply; 9+ messages in thread From: Max R. Carrara @ 2026-07-20 15:25 UTC (permalink / raw) To: pve-devel Like `#[list]`, `#[hash]` allows us to support subs for which any number of additional key-value pairs may be passed, which is the same as having an `;%` at the end of the Perl prototype. The `ParamsIter` type is added as a helper for pairwise iteration that makes use of the fact that `ffi::StackIter` is now an `ExactSizeIterator`, returning an error if the number of values on the stack is odd on creation. It is then directly passed on to serde's `MapDeserializer`, mirroring how `#[list]` works with `SeqDeserializer`. That way we can support additional Perl APIs without new parameters breaking the Rust side of the API, just like we can with `#[list]`. Also document the `#[hash]` parameter and add corresponding tests along the way. Signed-off-by: Max R. Carrara <m.carrara@proxmox.com> --- perlmod-macro/src/function.rs | 70 +++++++++++++++++++++++++++++++++-- perlmod/src/de.rs | 44 ++++++++++++++++++++++ perlmod/src/lib.rs | 5 +++ testlib-tests/01-hello.t | 46 ++++++++++++++++++++++- testlib/src/lib.rs | 24 +++++++++++- 5 files changed, 183 insertions(+), 6 deletions(-) diff --git a/perlmod-macro/src/function.rs b/perlmod-macro/src/function.rs index 8b5d689..8388f43 100644 --- a/perlmod-macro/src/function.rs +++ b/perlmod-macro/src/function.rs @@ -29,6 +29,11 @@ struct ArgumentAttrs { /// Slurp the remaining arguments (like an `@rest` at the end). /// This requires the parameter to implement `FromIterator<T: Deserialize>`. list: Option<Span>, + + /// Slurp the remaining arguments (like a `%hash` at the end). + /// This requires the parameter to implement `FromIterator<(K, V)> where K: Deserialize, V: + /// Deserialize`. + hash: Option<Span>, } impl ArgumentAttrs { @@ -41,6 +46,8 @@ impl ArgumentAttrs { self.cv = Some(path.span()); } else if path.is_ident("list") { self.list = Some(path.span()); + } else if path.is_ident("hash") { + self.hash = Some(path.span()); } else { return false; } @@ -64,11 +71,12 @@ impl ArgumentAttrs { + self.try_from_ref as usize + self.cv.is_some() as usize + self.list.is_some() as usize + + self.hash.is_some() as usize > 1 { bail!( span, - "`raw` and `try_from_ref`, `cv` and `list` attributes are mutually exclusive" + "`raw`, `try_from_ref`, `cv`, `list`, and `hash` attributes are mutually exclusive" ); } Ok(()) @@ -97,6 +105,10 @@ impl ArgumentAttrs { quote_spanned! { span=> let #extracted_name = args.map(::perlmod::Value::from); } + } else if self.hash.is_some() { + quote_spanned! { span=> + let #extracted_name = args.map(::perlmod::Value::from); + } } else { quote_spanned! { span=> let #extracted_name: ::perlmod::Value = match args.next() { @@ -148,6 +160,33 @@ impl ArgumentAttrs { } }; } + } else if self.hash.is_some() { + quote_spanned! { span=> + let #deserialized_name = { + let _guard = ::perlmod::__private__::InParameterDeserialization::guard(); + match ::perlmod::de::ParamsIter::new(#extracted_name) { + Ok(params_iter) => { + match <#arg_type as ::perlmod::__private__::serde::Deserialize>::deserialize( + ::perlmod::__private__::serde::de::value::MapDeserializer::new( + params_iter + ) + ) { + Ok(map) => map, + Err(err) => { + return Err(::perlmod::Value::new_string(&format!("{err:#}\n")) + .into_mortal() + .into_raw()); + } + } + }, + Err(err) => { + return Err(::perlmod::Value::new_string(&format!("{err:#}\n")) + .into_mortal() + .into_raw()); + } + } + }; + } } else { quote_spanned! { span=> let #deserialized_name: #arg_type = @@ -213,6 +252,7 @@ pub fn handle_function( let mut passed_arguments = TokenStream::new(); let mut cv_arg_param = TokenStream::new(); let mut had_list_param = false; + let mut had_hash_param = false; for arg in &mut func.sig.inputs { let (argument_attrs, pat_ty) = ArgumentAttrs::for_input(arg)?; @@ -224,6 +264,14 @@ pub fn handle_function( had_list_param = argument_attrs.list.is_some(); } + if had_hash_param { + if let Some(span) = argument_attrs.hash { + bail!(span, "only 1 #[hash] parameter allowed"); + } + } else { + had_hash_param = argument_attrs.hash.is_some(); + } + let arg_name = match &*pat_ty.pat { syn::Pat::Ident(ident) => { if ident.by_ref.is_some() { @@ -266,6 +314,8 @@ pub fn handle_function( quote_spanned! { span=> ::perlmod::Value::new_undef(), } } else if argument_attrs.list.is_some() { TokenStream::new() + } else if argument_attrs.hash.is_some() { + TokenStream::new() } else { // only count the trailing options; trailing_options = 0; @@ -319,7 +369,7 @@ pub fn handle_function( }, }; - let finalize_arguments = if !had_list_param { + let finalize_arguments = if !had_list_param && !had_hash_param { let too_many_args_error = syn::LitStr::new( &format!( "too many parameters for function '{}', (expected {})\n", @@ -405,13 +455,20 @@ pub fn handle_function( func.sig.inputs.len(), trailing_options, had_list_param, + had_hash_param, )) }), }) } -fn gen_prototype(arg_count: usize, trailing_options: usize, had_list_param: bool) -> String { - let arg_count = arg_count - trailing_options - (had_list_param as usize); +fn gen_prototype( + arg_count: usize, + trailing_options: usize, + had_list_param: bool, + had_hash_param: bool, +) -> String { + let arg_count = + arg_count - trailing_options - (had_list_param as usize) - (had_hash_param as usize); let mut proto = String::with_capacity(arg_count + trailing_options + 1); @@ -426,8 +483,13 @@ fn gen_prototype(arg_count: usize, trailing_options: usize, had_list_param: bool if had_list_param { proto.push('@'); } + if had_hash_param { + proto.push('%'); + } } else if had_list_param { proto.push_str(";@"); + } else if had_hash_param { + proto.push_str(";%"); } proto } diff --git a/perlmod/src/de.rs b/perlmod/src/de.rs index cf1601f..1355d0c 100644 --- a/perlmod/src/de.rs +++ b/perlmod/src/de.rs @@ -782,3 +782,47 @@ impl<'de> MapAccess<'de> for RawDeserializer<'_> { } } } + +pub struct ParamsIter<I> { + inner: I, +} + +impl<I> ParamsIter<I> +where + I: Iterator<Item = Value> + ExactSizeIterator, +{ + pub fn new<T>(into_iter: T) -> Result<Self, Error> + where + T: IntoIterator<Item = I::Item, IntoIter = I>, + { + let iter = into_iter.into_iter(); + + if iter.len() % 2 != 0 { + Err(Error::new( + "odd number of elements for parameter hash - must be even", + )) + } else { + Ok(Self { inner: iter }) + } + } +} + +impl<I> Iterator for ParamsIter<I> +where + I: Iterator<Item = Value> + ExactSizeIterator, +{ + type Item = (Value, Value); + + fn next(&mut self) -> Option<Self::Item> { + if let Some(key) = self.inner.next() { + let value = self + .inner + .next() + .expect("expected value for key - odd number of elements in params iterator"); + + Some((key, value)) + } else { + None + } + } +} diff --git a/perlmod/src/lib.rs b/perlmod/src/lib.rs index 83b9d8b..2dd1eb2 100644 --- a/perlmod/src/lib.rs +++ b/perlmod/src/lib.rs @@ -119,6 +119,11 @@ pub use perlmod_macro::package; /// `serde::de::value::SeqDeserializer`.) This causes the prototype to end with `;@` (behaves /// correctly with `Option` parameters in front of it). /// +/// * `#[hash]`: Like `#[list]`, but instead of deserializing from a sequence, the final parameter +/// needs to be able to deserialize from a map. (Technically just adapts Perl's argument stack +/// into a `serde::de::value::MapDeserializer`.) This causes the prototype to end with `;%` +/// (behaves correctly with `Option` parameters in front of it). +/// /// For an example on making blessed objects, see [`Value::bless_box`](Value::bless_box()). pub use perlmod_macro::export; diff --git a/testlib-tests/01-hello.t b/testlib-tests/01-hello.t index 8af0d5d..5c757ea 100644 --- a/testlib-tests/01-hello.t +++ b/testlib-tests/01-hello.t @@ -1,6 +1,6 @@ use v5.36; -use Test::More tests => 16; +use Test::More tests => 24; use TestLib::Hello; @@ -47,3 +47,47 @@ is( 'final option unset, empty list', ); is(TestLib::Hello::sum_list(50, 51, 52), 153, 'pass a deserialized list'); + +is( + TestLib::Hello::trailing_hash(60, 61 => 62), + 'first=60, rest is {61: 62}', + 'collecting 1 trailing key-value pair', +); +is( + TestLib::Hello::trailing_hash_ordered(63, 64 => 65, 66 => 67), + 'first=63, rest is {64: 65, 66: 67}', + 'collecting 2 trailing key-value pairs', +); +is( + TestLib::Hello::trailing_hash(67), + 'first=67, rest is {}', + 'collecting 0 trailing key-value pairs', +); + +is( + eval { TestLib::Hello::trailing_hash(68, 69) } // $@, + "error: odd number of elements for parameter hash - must be even\n", + 'failing to collect odd number of trailing hash parameters', +); + +is( + TestLib::Hello::trailing_hash_and_options(80), + 'first=80, second=None, rest is {}', + 'final option unset, collecting 0 trailing key-value pairs', +); +is( + TestLib::Hello::trailing_hash_and_options(81, 82), + 'first=81, second=Some(82), rest is {}', + 'final option set, collecting 0 trailing key-value pairs', +); +is( + TestLib::Hello::trailing_hash_and_options(81, 82, 83 => 84), + 'first=81, second=Some(82), rest is {83: 84}', + 'final option set, collecting 1 trailing key-value pairs', +); + +is( + eval { TestLib::Hello::trailing_hash_and_options(85, 86, 87) } // $@, + "error: odd number of elements for parameter hash - must be even\n", + 'final option set, collecting 1 trailing key-value pairs', +); diff --git a/testlib/src/lib.rs b/testlib/src/lib.rs index ac3bfde..81879cc 100644 --- a/testlib/src/lib.rs +++ b/testlib/src/lib.rs @@ -12,7 +12,10 @@ mod main_lib {} #[perlmod::package(name = "TestLib::Hello", lib = "testlib", boot = "loaded")] mod export { - use std::sync::atomic::{AtomicBool, Ordering}; + use std::{ + collections::{BTreeMap, HashMap}, + sync::atomic::{AtomicBool, Ordering}, + }; use anyhow::{Error, bail}; use serde::{Deserialize, Serialize}; @@ -137,4 +140,23 @@ mod export { fn sum_list(#[list] rest: Vec<u32>) -> u32 { rest.into_iter().sum() } + + #[export] + fn trailing_hash(first: u32, #[hash] rest: HashMap<u32, u32>) -> String { + format!("first={first}, rest is {rest:?}") + } + + #[export] + fn trailing_hash_ordered(first: u32, #[hash] rest: BTreeMap<u32, u32>) -> String { + format!("first={first}, rest is {rest:?}") + } + + #[export] + fn trailing_hash_and_options( + first: u32, + second: Option<u32>, + #[hash] rest: HashMap<u32, u32>, + ) -> String { + format!("first={first}, second={second:?}, rest is {rest:?}") + } } -- 2.47.3 ^ permalink raw reply related [flat|nested] 9+ messages in thread
* Re: [PATCH perlmod 3/4] perlmod, macro: add #[hash] parameter 2026-07-20 15:25 ` [PATCH perlmod 3/4] perlmod, macro: add #[hash] parameter Max R. Carrara @ 2026-07-23 7:54 ` Wolfgang Bumiller 0 siblings, 0 replies; 9+ messages in thread From: Wolfgang Bumiller @ 2026-07-23 7:54 UTC (permalink / raw) To: Max R. Carrara; +Cc: pve-devel On Mon, Jul 20, 2026 at 05:25:39PM +0200, Max R. Carrara wrote: > Like `#[list]`, `#[hash]` allows us to support subs for which any > number of additional key-value pairs may be passed, which is the same > as having an `;%` at the end of the Perl prototype. > > The `ParamsIter` type is added as a helper for pairwise iteration that > makes use of the fact that `ffi::StackIter` is now an > `ExactSizeIterator`, returning an error if the number of values on the > stack is odd on creation. It is then directly passed on to > serde's `MapDeserializer`, mirroring how `#[list]` works with > `SeqDeserializer`. > > That way we can support additional Perl APIs without new parameters > breaking the Rust side of the API, just like we can with `#[list]`. > > Also document the `#[hash]` parameter and add corresponding tests > along the way. > > Signed-off-by: Max R. Carrara <m.carrara@proxmox.com> > --- > perlmod-macro/src/function.rs | 70 +++++++++++++++++++++++++++++++++-- > perlmod/src/de.rs | 44 ++++++++++++++++++++++ > perlmod/src/lib.rs | 5 +++ > testlib-tests/01-hello.t | 46 ++++++++++++++++++++++- > testlib/src/lib.rs | 24 +++++++++++- > 5 files changed, 183 insertions(+), 6 deletions(-) > > diff --git a/perlmod-macro/src/function.rs b/perlmod-macro/src/function.rs > index 8b5d689..8388f43 100644 > --- a/perlmod-macro/src/function.rs > +++ b/perlmod-macro/src/function.rs > @@ -29,6 +29,11 @@ struct ArgumentAttrs { > /// Slurp the remaining arguments (like an `@rest` at the end). > /// This requires the parameter to implement `FromIterator<T: Deserialize>`. > list: Option<Span>, > + > + /// Slurp the remaining arguments (like a `%hash` at the end). > + /// This requires the parameter to implement `FromIterator<(K, V)> where K: Deserialize, V: > + /// Deserialize`. > + hash: Option<Span>, > } > > impl ArgumentAttrs { > @@ -41,6 +46,8 @@ impl ArgumentAttrs { > self.cv = Some(path.span()); > } else if path.is_ident("list") { > self.list = Some(path.span()); > + } else if path.is_ident("hash") { > + self.hash = Some(path.span()); > } else { > return false; > } > @@ -64,11 +71,12 @@ impl ArgumentAttrs { > + self.try_from_ref as usize > + self.cv.is_some() as usize > + self.list.is_some() as usize > + + self.hash.is_some() as usize > > 1 > { > bail!( > span, > - "`raw` and `try_from_ref`, `cv` and `list` attributes are mutually exclusive" > + "`raw`, `try_from_ref`, `cv`, `list`, and `hash` attributes are mutually exclusive" > ); > } > Ok(()) > @@ -97,6 +105,10 @@ impl ArgumentAttrs { > quote_spanned! { span=> > let #extracted_name = args.map(::perlmod::Value::from); > } > + } else if self.hash.is_some() { > + quote_spanned! { span=> > + let #extracted_name = args.map(::perlmod::Value::from); > + } > } else { > quote_spanned! { span=> > let #extracted_name: ::perlmod::Value = match args.next() { > @@ -148,6 +160,33 @@ impl ArgumentAttrs { > } > }; > } > + } else if self.hash.is_some() { > + quote_spanned! { span=> > + let #deserialized_name = { > + let _guard = ::perlmod::__private__::InParameterDeserialization::guard(); > + match ::perlmod::de::ParamsIter::new(#extracted_name) { > + Ok(params_iter) => { > + match <#arg_type as ::perlmod::__private__::serde::Deserialize>::deserialize( > + ::perlmod::__private__::serde::de::value::MapDeserializer::new( > + params_iter > + ) > + ) { > + Ok(map) => map, > + Err(err) => { > + return Err(::perlmod::Value::new_string(&format!("{err:#}\n")) > + .into_mortal() > + .into_raw()); > + } > + } > + }, > + Err(err) => { > + return Err(::perlmod::Value::new_string(&format!("{err:#}\n")) > + .into_mortal() > + .into_raw()); > + } > + } > + }; > + } > } else { > quote_spanned! { span=> > let #deserialized_name: #arg_type = > @@ -213,6 +252,7 @@ pub fn handle_function( > let mut passed_arguments = TokenStream::new(); > let mut cv_arg_param = TokenStream::new(); > let mut had_list_param = false; > + let mut had_hash_param = false; > for arg in &mut func.sig.inputs { > let (argument_attrs, pat_ty) = ArgumentAttrs::for_input(arg)?; > > @@ -224,6 +264,14 @@ pub fn handle_function( > had_list_param = argument_attrs.list.is_some(); > } > > + if had_hash_param { > + if let Some(span) = argument_attrs.hash { > + bail!(span, "only 1 #[hash] parameter allowed"); > + } > + } else { > + had_hash_param = argument_attrs.hash.is_some(); > + } This should also check the list param, since trailing @rest and trailing %rest are mutually exclusive, for nicer error handling. This could end up as an enum `{ None, List, Hash }` so that `gen_prototype` doesn't also have 2 independent booleans and matches on the rest type instead. Otherwise LGTM. > + > let arg_name = match &*pat_ty.pat { > syn::Pat::Ident(ident) => { > if ident.by_ref.is_some() { > @@ -266,6 +314,8 @@ pub fn handle_function( > quote_spanned! { span=> ::perlmod::Value::new_undef(), } > } else if argument_attrs.list.is_some() { > TokenStream::new() > + } else if argument_attrs.hash.is_some() { > + TokenStream::new() > } else { > // only count the trailing options; > trailing_options = 0; > @@ -319,7 +369,7 @@ pub fn handle_function( > }, > }; > > - let finalize_arguments = if !had_list_param { > + let finalize_arguments = if !had_list_param && !had_hash_param { > let too_many_args_error = syn::LitStr::new( > &format!( > "too many parameters for function '{}', (expected {})\n", > @@ -405,13 +455,20 @@ pub fn handle_function( > func.sig.inputs.len(), > trailing_options, > had_list_param, > + had_hash_param, > )) > }), > }) > } > > -fn gen_prototype(arg_count: usize, trailing_options: usize, had_list_param: bool) -> String { > - let arg_count = arg_count - trailing_options - (had_list_param as usize); > +fn gen_prototype( > + arg_count: usize, > + trailing_options: usize, > + had_list_param: bool, > + had_hash_param: bool, > +) -> String { > + let arg_count = > + arg_count - trailing_options - (had_list_param as usize) - (had_hash_param as usize); > > let mut proto = String::with_capacity(arg_count + trailing_options + 1); > > @@ -426,8 +483,13 @@ fn gen_prototype(arg_count: usize, trailing_options: usize, had_list_param: bool > if had_list_param { > proto.push('@'); > } > + if had_hash_param { > + proto.push('%'); > + } > } else if had_list_param { > proto.push_str(";@"); > + } else if had_hash_param { > + proto.push_str(";%"); > } > proto > } > diff --git a/perlmod/src/de.rs b/perlmod/src/de.rs > index cf1601f..1355d0c 100644 > --- a/perlmod/src/de.rs > +++ b/perlmod/src/de.rs > @@ -782,3 +782,47 @@ impl<'de> MapAccess<'de> for RawDeserializer<'_> { > } > } > } > + > +pub struct ParamsIter<I> { > + inner: I, > +} > + > +impl<I> ParamsIter<I> > +where > + I: Iterator<Item = Value> + ExactSizeIterator, > +{ > + pub fn new<T>(into_iter: T) -> Result<Self, Error> > + where > + T: IntoIterator<Item = I::Item, IntoIter = I>, > + { > + let iter = into_iter.into_iter(); > + > + if iter.len() % 2 != 0 { > + Err(Error::new( > + "odd number of elements for parameter hash - must be even", > + )) > + } else { > + Ok(Self { inner: iter }) > + } > + } > +} > + > +impl<I> Iterator for ParamsIter<I> > +where > + I: Iterator<Item = Value> + ExactSizeIterator, > +{ > + type Item = (Value, Value); > + > + fn next(&mut self) -> Option<Self::Item> { > + if let Some(key) = self.inner.next() { > + let value = self > + .inner > + .next() > + .expect("expected value for key - odd number of elements in params iterator"); > + > + Some((key, value)) > + } else { > + None > + } > + } > +} > diff --git a/perlmod/src/lib.rs b/perlmod/src/lib.rs > index 83b9d8b..2dd1eb2 100644 > --- a/perlmod/src/lib.rs > +++ b/perlmod/src/lib.rs > @@ -119,6 +119,11 @@ pub use perlmod_macro::package; > /// `serde::de::value::SeqDeserializer`.) This causes the prototype to end with `;@` (behaves > /// correctly with `Option` parameters in front of it). > /// > +/// * `#[hash]`: Like `#[list]`, but instead of deserializing from a sequence, the final parameter > +/// needs to be able to deserialize from a map. (Technically just adapts Perl's argument stack > +/// into a `serde::de::value::MapDeserializer`.) This causes the prototype to end with `;%` > +/// (behaves correctly with `Option` parameters in front of it). > +/// > /// For an example on making blessed objects, see [`Value::bless_box`](Value::bless_box()). > pub use perlmod_macro::export; > > diff --git a/testlib-tests/01-hello.t b/testlib-tests/01-hello.t > index 8af0d5d..5c757ea 100644 > --- a/testlib-tests/01-hello.t > +++ b/testlib-tests/01-hello.t > @@ -1,6 +1,6 @@ > use v5.36; > > -use Test::More tests => 16; > +use Test::More tests => 24; > > use TestLib::Hello; > > @@ -47,3 +47,47 @@ is( > 'final option unset, empty list', > ); > is(TestLib::Hello::sum_list(50, 51, 52), 153, 'pass a deserialized list'); > + > +is( > + TestLib::Hello::trailing_hash(60, 61 => 62), > + 'first=60, rest is {61: 62}', > + 'collecting 1 trailing key-value pair', > +); > +is( > + TestLib::Hello::trailing_hash_ordered(63, 64 => 65, 66 => 67), > + 'first=63, rest is {64: 65, 66: 67}', > + 'collecting 2 trailing key-value pairs', > +); > +is( > + TestLib::Hello::trailing_hash(67), > + 'first=67, rest is {}', > + 'collecting 0 trailing key-value pairs', > +); > + > +is( > + eval { TestLib::Hello::trailing_hash(68, 69) } // $@, > + "error: odd number of elements for parameter hash - must be even\n", > + 'failing to collect odd number of trailing hash parameters', > +); > + > +is( > + TestLib::Hello::trailing_hash_and_options(80), > + 'first=80, second=None, rest is {}', > + 'final option unset, collecting 0 trailing key-value pairs', > +); > +is( > + TestLib::Hello::trailing_hash_and_options(81, 82), > + 'first=81, second=Some(82), rest is {}', > + 'final option set, collecting 0 trailing key-value pairs', > +); > +is( > + TestLib::Hello::trailing_hash_and_options(81, 82, 83 => 84), > + 'first=81, second=Some(82), rest is {83: 84}', > + 'final option set, collecting 1 trailing key-value pairs', > +); > + > +is( > + eval { TestLib::Hello::trailing_hash_and_options(85, 86, 87) } // $@, > + "error: odd number of elements for parameter hash - must be even\n", > + 'final option set, collecting 1 trailing key-value pairs', > +); > diff --git a/testlib/src/lib.rs b/testlib/src/lib.rs > index ac3bfde..81879cc 100644 > --- a/testlib/src/lib.rs > +++ b/testlib/src/lib.rs > @@ -12,7 +12,10 @@ mod main_lib {} > > #[perlmod::package(name = "TestLib::Hello", lib = "testlib", boot = "loaded")] > mod export { > - use std::sync::atomic::{AtomicBool, Ordering}; > + use std::{ > + collections::{BTreeMap, HashMap}, > + sync::atomic::{AtomicBool, Ordering}, > + }; > > use anyhow::{Error, bail}; > use serde::{Deserialize, Serialize}; > @@ -137,4 +140,23 @@ mod export { > fn sum_list(#[list] rest: Vec<u32>) -> u32 { > rest.into_iter().sum() > } > + > + #[export] > + fn trailing_hash(first: u32, #[hash] rest: HashMap<u32, u32>) -> String { > + format!("first={first}, rest is {rest:?}") > + } > + > + #[export] > + fn trailing_hash_ordered(first: u32, #[hash] rest: BTreeMap<u32, u32>) -> String { > + format!("first={first}, rest is {rest:?}") > + } > + > + #[export] > + fn trailing_hash_and_options( > + first: u32, > + second: Option<u32>, > + #[hash] rest: HashMap<u32, u32>, > + ) -> String { > + format!("first={first}, second={second:?}, rest is {rest:?}") > + } > } > -- > 2.47.3 ^ permalink raw reply [flat|nested] 9+ messages in thread
* [PATCH perlmod 4/4] testlib: run perltidy 2026-07-20 15:25 [PATCH perlmod 0/4] perlmod: add #[hash] parameter Max R. Carrara ` (2 preceding siblings ...) 2026-07-20 15:25 ` [PATCH perlmod 3/4] perlmod, macro: add #[hash] parameter Max R. Carrara @ 2026-07-20 15:25 ` Max R. Carrara 2026-07-23 7:55 ` partially-applied: " Wolfgang Bumiller 3 siblings, 1 reply; 9+ messages in thread From: Max R. Carrara @ 2026-07-20 15:25 UTC (permalink / raw) To: pve-devel Signed-off-by: Max R. Carrara <m.carrara@proxmox.com> --- testlib-tests/01-hello.t | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/testlib-tests/01-hello.t b/testlib-tests/01-hello.t index 5c757ea..9770525 100644 --- a/testlib-tests/01-hello.t +++ b/testlib-tests/01-hello.t @@ -12,13 +12,29 @@ is($x, 17, "first multi_return value should be 17"); is($y, 32, "second multi_return value should be 32"); my $param = { a => 1 }; -is(TestLib::Hello::opt_string($param->{x}), "Called with None.", "non-existent element passed to Option<String>"); +is( + TestLib::Hello::opt_string($param->{x}), + "Called with None.", + "non-existent element passed to Option<String>", +); ok(!exists($param->{x}), "param->{x} was not auto-vivified"); -is(TestLib::Hello::opt_str($param->{x}), "Called with None.", "non-existent element passed to Option<&str>"); +is( + TestLib::Hello::opt_str($param->{x}), + "Called with None.", + "non-existent element passed to Option<&str>", +); ok(!exists($param->{x}), "param->{x} was not auto-vivified (2)"); -is(TestLib::Hello::trailing_optional(1, 99), '1, Some(99)', 'passing value for trailing optional parameter'); -is(TestLib::Hello::trailing_optional(2, undef), '2, None', 'passing undef for trailing optional parameter'); +is( + TestLib::Hello::trailing_optional(1, 99), + '1, Some(99)', + 'passing value for trailing optional parameter', +); +is( + TestLib::Hello::trailing_optional(2, undef), + '2, None', + 'passing undef for trailing optional parameter', +); is(TestLib::Hello::trailing_optional(3), '3, None', 'skipping trailing optional parameter'); is( -- 2.47.3 ^ permalink raw reply related [flat|nested] 9+ messages in thread
* partially-applied: [PATCH perlmod 4/4] testlib: run perltidy 2026-07-20 15:25 ` [PATCH perlmod 4/4] testlib: run perltidy Max R. Carrara @ 2026-07-23 7:55 ` Wolfgang Bumiller 0 siblings, 0 replies; 9+ messages in thread From: Wolfgang Bumiller @ 2026-07-23 7:55 UTC (permalink / raw) To: Max R. Carrara; +Cc: pve-devel applied this one as well, thanks On Mon, Jul 20, 2026 at 05:25:40PM +0200, Max R. Carrara wrote: > Signed-off-by: Max R. Carrara <m.carrara@proxmox.com> > --- > testlib-tests/01-hello.t | 24 ++++++++++++++++++++---- > 1 file changed, 20 insertions(+), 4 deletions(-) > > diff --git a/testlib-tests/01-hello.t b/testlib-tests/01-hello.t > index 5c757ea..9770525 100644 > --- a/testlib-tests/01-hello.t > +++ b/testlib-tests/01-hello.t > @@ -12,13 +12,29 @@ is($x, 17, "first multi_return value should be 17"); > is($y, 32, "second multi_return value should be 32"); > > my $param = { a => 1 }; > -is(TestLib::Hello::opt_string($param->{x}), "Called with None.", "non-existent element passed to Option<String>"); > +is( > + TestLib::Hello::opt_string($param->{x}), > + "Called with None.", > + "non-existent element passed to Option<String>", > +); > ok(!exists($param->{x}), "param->{x} was not auto-vivified"); > -is(TestLib::Hello::opt_str($param->{x}), "Called with None.", "non-existent element passed to Option<&str>"); > +is( > + TestLib::Hello::opt_str($param->{x}), > + "Called with None.", > + "non-existent element passed to Option<&str>", > +); > ok(!exists($param->{x}), "param->{x} was not auto-vivified (2)"); > > -is(TestLib::Hello::trailing_optional(1, 99), '1, Some(99)', 'passing value for trailing optional parameter'); > -is(TestLib::Hello::trailing_optional(2, undef), '2, None', 'passing undef for trailing optional parameter'); > +is( > + TestLib::Hello::trailing_optional(1, 99), > + '1, Some(99)', > + 'passing value for trailing optional parameter', > +); > +is( > + TestLib::Hello::trailing_optional(2, undef), > + '2, None', > + 'passing undef for trailing optional parameter', > +); > is(TestLib::Hello::trailing_optional(3), '3, None', 'skipping trailing optional parameter'); > > is( > -- > 2.47.3 > > > > > -- ^ permalink raw reply [flat|nested] 9+ messages in thread
end of thread, other threads:[~2026-07-23 7:55 UTC | newest] Thread overview: 9+ messages (download: mbox.gz follow: Atom feed -- links below jump to the message on this page -- 2026-07-20 15:25 [PATCH perlmod 0/4] perlmod: add #[hash] parameter Max R. Carrara 2026-07-20 15:25 ` [PATCH perlmod 1/4] ffi: implement ExactSizeIterator for StackIter Max R. Carrara 2026-07-20 15:25 ` [PATCH perlmod 2/4] value: implement serde::de::IntoDeserializer on Value Max R. Carrara 2026-07-23 7:34 ` Wolfgang Bumiller 2026-07-23 7:50 ` partially-applied: " Wolfgang Bumiller 2026-07-20 15:25 ` [PATCH perlmod 3/4] perlmod, macro: add #[hash] parameter Max R. Carrara 2026-07-23 7:54 ` Wolfgang Bumiller 2026-07-20 15:25 ` [PATCH perlmod 4/4] testlib: run perltidy Max R. Carrara 2026-07-23 7:55 ` partially-applied: " Wolfgang Bumiller
This is a public inbox, see mirroring instructions for how to clone and mirror all data and code used for this inbox