public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [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

* [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

* [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

* 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

* 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

* 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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal