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 v2 5/5] perlmod, macro: add #[hash] parameter attribute
Date: Fri, 24 Jul 2026 16:49:07 +0200	[thread overview]
Message-ID: <20260724144914.658730-6-m.carrara@proxmox.com> (raw)
In-Reply-To: <20260724144914.658730-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 | 69 +++++++++++++++++++++++++++++++----
 perlmod/src/de.rs             | 44 ++++++++++++++++++++++
 perlmod/src/lib.rs            |  5 +++
 testlib-tests/01-hello.t      | 46 ++++++++++++++++++++++-
 testlib/src/lib.rs            | 24 +++++++++++-
 5 files changed, 178 insertions(+), 10 deletions(-)

diff --git a/perlmod-macro/src/function.rs b/perlmod-macro/src/function.rs
index f5415c5..aa48cc9 100644
--- a/perlmod-macro/src/function.rs
+++ b/perlmod-macro/src/function.rs
@@ -28,6 +28,11 @@ enum ArgumentAttrType {
     /// Slurp the remaining arguments (like an `@rest` at the end).
     /// This requires the parameter to implement `FromIterator<T: Deserialize>`.
     TrailingList(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`.
+    TrailingHash(Span),
 }
 
 impl ArgumentAttrType {
@@ -52,6 +57,10 @@ impl ArgumentAttrType {
             return Some(Self::TrailingList(path.span()));
         }
 
+        if path.is_ident("hash") {
+            return Some(Self::TrailingHash(path.span()));
+        }
+
         None
     }
 
@@ -65,6 +74,7 @@ impl ArgumentAttrType {
             Self::TryFromRef => "try_from_ref",
             Self::CVPtr(_) => "cv",
             Self::TrailingList(_) => "list",
+            Self::TrailingHash(_) => "hash",
         }
     }
 }
@@ -114,7 +124,7 @@ impl<'s> ArgumentAttr<'s> {
                     // At this point we have two differing attributes
                     error!(
                         span,
-                        "`raw`, `try_from_ref`, `cv`, and `list` attributes are mutually exclusive"
+                        "`raw`, `try_from_ref`, `cv`, `list`, and `hash` attributes are mutually exclusive"
                     );
                     has_err = true;
                     false
@@ -141,7 +151,7 @@ fn extract_argument_code(
     none_handling: TokenStream,
 ) -> TokenStream {
     match arg_attr.attr_type {
-        Some(ArgumentAttrType::TrailingList(_)) => {
+        Some(ArgumentAttrType::TrailingList(_) | ArgumentAttrType::TrailingHash(_)) => {
             quote_spanned! { span=>
                 let #extracted_name = #arguments_name.map(::perlmod::Value::from);
             }
@@ -196,6 +206,32 @@ fn deserialized_argument_code(
                 }
             };
         },
+        Some(ArgumentAttrType::TrailingHash(_)) => 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());
+                    }
+                }
+            };
+        },
         Some(ArgumentAttrType::CVPtr(_)) | None => quote_spanned! { span=>
             let #deserialized_name: #arg_type =
                 match ::perlmod::from_ref_value(&#extracted_name) {
@@ -212,12 +248,22 @@ fn deserialized_argument_code(
 
 enum TrailingType {
     List,
+    Hash,
 }
 
 impl TrailingType {
     fn as_str(&self) -> &'static str {
         match self {
             Self::List => "list",
+            Self::Hash => "hash",
+        }
+    }
+
+    fn from_attr_type(attr_type: &ArgumentAttrType) -> Self {
+        match attr_type {
+            ArgumentAttrType::TrailingList(_) => Self::List,
+            ArgumentAttrType::TrailingHash(_) => Self::Hash,
+            _ => unreachable!(),
         }
     }
 }
@@ -272,10 +318,12 @@ impl FnInputs {
             };
 
             if let Some(ref attr_type) = arg_attr.attr_type {
+                use ArgumentAttrType as AT;
+
                 match attr_type {
-                    ArgumentAttrType::Raw => {}
-                    ArgumentAttrType::TryFromRef => {}
-                    ArgumentAttrType::CVPtr(cv_span) => {
+                    AT::Raw => {}
+                    AT::TryFromRef => {}
+                    AT::CVPtr(cv_span) => {
                         if !state.cv_arg_param.is_empty() {
                             bail!(*cv_span, "only 1 'cv' parameter allowed");
                         }
@@ -293,7 +341,7 @@ impl FnInputs {
 
                         continue;
                     }
-                    ArgumentAttrType::TrailingList(trailing_span) => {
+                    AT::TrailingList(trailing_span) | AT::TrailingHash(trailing_span) => {
                         if let Some(trailing_type) = state.trailing_type {
                             bail!(
                                 *trailing_span,
@@ -302,7 +350,7 @@ impl FnInputs {
                             );
                         }
 
-                        state.trailing_type = Some(TrailingType::List);
+                        state.trailing_type = Some(TrailingType::from_attr_type(attr_type));
                     }
                 }
             }
@@ -322,7 +370,10 @@ impl FnInputs {
                     break 'handling quote_spanned! { span=> ::perlmod::Value::new_undef(), };
                 }
 
-                if matches!(arg_attr.attr_type, Some(ArgumentAttrType::TrailingList(_))) {
+                if matches!(
+                    arg_attr.attr_type,
+                    Some(ArgumentAttrType::TrailingList(_) | ArgumentAttrType::TrailingHash(_))
+                ) {
                     break 'handling TokenStream::new();
                 }
 
@@ -555,6 +606,7 @@ fn gen_prototype(total_arg_count: usize, inputs: &FnInputs) -> String {
 
             match ty {
                 Some(TrailingType::List) => proto.push('@'),
+                Some(TrailingType::Hash) => proto.push('%'),
                 None => {}
             }
 
@@ -563,6 +615,7 @@ fn gen_prototype(total_arg_count: usize, inputs: &FnInputs) -> String {
         (0, ty) => {
             match ty {
                 Some(TrailingType::List) => proto.push_str(";@"),
+                Some(TrailingType::Hash) => proto.push_str(";%"),
                 None => {}
             }
 
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 535e5fb..9770525 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;
 
@@ -63,3 +63,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-24 14:49 UTC|newest]

Thread overview: 6+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-07-24 14:49 [PATCH perlmod v2 0/5] perlmod: add #[hash] parameter attribute Max R. Carrara
2026-07-24 14:49 ` [PATCH perlmod v2 1/5] macro: function: make argument code generation helpers standalone fns Max R. Carrara
2026-07-24 14:49 ` [PATCH perlmod v2 2/5] macro: function: make argument attribute handling more extendable Max R. Carrara
2026-07-24 14:49 ` [PATCH perlmod v2 3/5] macro: function: explicitly pass identifier for generated arg iter Max R. Carrara
2026-07-24 14:49 ` [PATCH perlmod v2 4/5] macro: function: move signature inputs handling into helper struct Max R. Carrara
2026-07-24 14:49 ` Max R. Carrara [this message]

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=20260724144914.658730-6-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