public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: "Max R. Carrara" <m.carrara@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH perlmod 3/4] perlmod, macro: add #[hash] parameter
Date: Mon, 20 Jul 2026 17:25:39 +0200	[thread overview]
Message-ID: <20260720152541.524463-4-m.carrara@proxmox.com> (raw)
In-Reply-To: <20260720152541.524463-1-m.carrara@proxmox.com>

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





  parent reply	other threads:[~2026-07-20 15:26 UTC|newest]

Thread overview: 5+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
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 [this message]
2026-07-20 15:25 ` [PATCH perlmod 4/4] testlib: run perltidy Max R. Carrara

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260720152541.524463-4-m.carrara@proxmox.com \
    --to=m.carrara@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
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