public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH perlmod v2 0/5] perlmod: add #[hash] parameter attribute
@ 2026-07-24 14:49 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
                   ` (4 more replies)
  0 siblings, 5 replies; 6+ messages in thread
From: Max R. Carrara @ 2026-07-24 14:49 UTC (permalink / raw)
  To: pve-devel

perlmod: add #[hash] parameter attribute - v2
=============================================

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);

Changes Since v1
----------------

- Drop patches that were already applied (thanks!)

- Call it "parameter attribute", not "parameter"

- Take Wolfgang's feedback (thanks!) on patch #3 of v1 into account and
  refactor the majority of `perlmod-macro/src/function.rs` before
  actually implementing the #[hash] parameter attribute.

  This means that all patches but the last just massage `function.rs`
  into a shape that makes it convenient to add and debug new parameter
  attributes. The overall logic was improved as well, making it more
  clear / explicit what's happening in a given block of code.

The tests and docstrings added in the last patch are otherwise kept
identical, including the implementation of `ParamsIter<I>`.

Previous Versions
-----------------

v1: https://lore.proxmox.com/pve-devel/20260720152541.524463-1-m.carrara@proxmox.com/

Summary of Changes
------------------


perlmod:

Max R. Carrara (5):
  macro: function: make argument code generation helpers standalone fns
  macro: function: make argument attribute handling more extendable
  macro: function: explicitly pass identifier for generated arg iter
  macro: function: move signature inputs handling into helper struct
  perlmod, macro: add #[hash] parameter attribute

 perlmod-macro/src/function.rs | 648 ++++++++++++++++++++++------------
 perlmod/src/de.rs             |  44 +++
 perlmod/src/lib.rs            |   5 +
 testlib-tests/01-hello.t      |  46 ++-
 testlib/src/lib.rs            |  24 +-
 5 files changed, 538 insertions(+), 229 deletions(-)


Summary over all repositories:
  5 files changed, 538 insertions(+), 229 deletions(-)

-- 
Generated by murpp 0.11.0




^ permalink raw reply	[flat|nested] 6+ messages in thread

* [PATCH perlmod v2 1/5] macro: function: make argument code generation helpers standalone fns
  2026-07-24 14:49 [PATCH perlmod v2 0/5] perlmod: add #[hash] parameter attribute Max R. Carrara
@ 2026-07-24 14:49 ` Max R. Carrara
  2026-07-24 14:49 ` [PATCH perlmod v2 2/5] macro: function: make argument attribute handling more extendable Max R. Carrara
                   ` (3 subsequent siblings)
  4 siblings, 0 replies; 6+ messages in thread
From: Max R. Carrara @ 2026-07-24 14:49 UTC (permalink / raw)
  To: pve-devel

There is not really much of a reason to associate them with
`ArgumentAttrs` from a semantic standpoint, so make those helpers
standalone functions instead.

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 perlmod-macro/src/function.rs | 142 +++++++++++++++++-----------------
 1 file changed, 72 insertions(+), 70 deletions(-)

diff --git a/perlmod-macro/src/function.rs b/perlmod-macro/src/function.rs
index 8b5d689..9d4a4af 100644
--- a/perlmod-macro/src/function.rs
+++ b/perlmod-macro/src/function.rs
@@ -86,80 +86,80 @@ impl ArgumentAttrs {
             }
         }
     }
+}
 
-    fn extract_argument_code(
-        &self,
-        span: Span,
-        extracted_name: &Ident,
-        none_handling: TokenStream,
-    ) -> TokenStream {
-        if self.list.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() {
-                    Some(arg) => ::perlmod::Value::from(arg),
-                    None => #none_handling
-                };
-            }
+fn extract_argument_code(
+    arg_attr: &ArgumentAttrs,
+    span: Span,
+    extracted_name: &Ident,
+    none_handling: TokenStream,
+) -> TokenStream {
+    if arg_attr.list.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() {
+                Some(arg) => ::perlmod::Value::from(arg),
+                None => #none_handling
+            };
         }
     }
+}
 
-    fn deserialized_argument_code(
-        &self,
-        span: Span,
-        arg_type: &syn::Type,
-        deserialized_name: &Ident,
-        extracted_name: Ident,
-    ) -> TokenStream {
-        if self.raw {
-            quote_spanned! { span=>
-                let #deserialized_name = #extracted_name;
-            }
-        } else if self.try_from_ref {
-            quote_spanned! { span=>
-                let #deserialized_name: #arg_type =
-                    match ::std::convert::TryFrom::try_from(&#extracted_name) {
-                        Ok(arg) => arg,
-                        Err(err) => {
-                            return Err(::perlmod::Value::new_string(&format!("{err:#}\n"))
-                                .into_mortal()
-                                .into_raw());
-                        }
-                    };
-            }
-        } else if self.list.is_some() {
-            quote_spanned! { span=>
-                let #deserialized_name = {
-                    let _guard = ::perlmod::__private__::InParameterDeserialization::guard();
-                    match <#arg_type as ::perlmod::__private__::serde::Deserialize>::deserialize(
-                        ::perlmod::__private__::serde::de::value::SeqDeserializer::new(
-                            #extracted_name
-                        )
-                    ) {
-                        Ok(arg) => arg,
-                        Err(err) => {
-                            return Err(::perlmod::Value::new_string(&format!("{err:#}\n"))
-                                .into_mortal()
-                                .into_raw());
-                        }
+fn deserialized_argument_code(
+    arg_attr: &ArgumentAttrs,
+    span: Span,
+    arg_type: &syn::Type,
+    deserialized_name: &Ident,
+    extracted_name: Ident,
+) -> TokenStream {
+    if arg_attr.raw {
+        quote_spanned! { span=>
+            let #deserialized_name = #extracted_name;
+        }
+    } else if arg_attr.try_from_ref {
+        quote_spanned! { span=>
+            let #deserialized_name: #arg_type =
+                match ::std::convert::TryFrom::try_from(&#extracted_name) {
+                    Ok(arg) => arg,
+                    Err(err) => {
+                        return Err(::perlmod::Value::new_string(&format!("{err:#}\n"))
+                            .into_mortal()
+                            .into_raw());
+                    }
+                };
+        }
+    } else if arg_attr.list.is_some() {
+        quote_spanned! { span=>
+            let #deserialized_name = {
+                let _guard = ::perlmod::__private__::InParameterDeserialization::guard();
+                match <#arg_type as ::perlmod::__private__::serde::Deserialize>::deserialize(
+                    ::perlmod::__private__::serde::de::value::SeqDeserializer::new(
+                        #extracted_name
+                    )
+                ) {
+                    Ok(arg) => arg,
+                    Err(err) => {
+                        return Err(::perlmod::Value::new_string(&format!("{err:#}\n"))
+                            .into_mortal()
+                            .into_raw());
+                    }
+                }
+            };
+        }
+    } else {
+        quote_spanned! { span=>
+            let #deserialized_name: #arg_type =
+                match ::perlmod::from_ref_value(&#extracted_name) {
+                    Ok(data) => data,
+                    Err(err) => {
+                        return Err(::perlmod::Value::new_string(&format!("{err:#}\n"))
+                            .into_mortal()
+                            .into_raw());
                     }
                 };
-            }
-        } else {
-            quote_spanned! { span=>
-                let #deserialized_name: #arg_type =
-                    match ::perlmod::from_ref_value(&#extracted_name) {
-                        Ok(data) => data,
-                        Err(err) => {
-                            return Err(::perlmod::Value::new_string(&format!("{err:#}\n"))
-                                .into_mortal()
-                                .into_raw());
-                        }
-                    };
-            }
         }
     }
 }
@@ -278,13 +278,15 @@ pub fn handle_function(
             }
         };
 
-        extract_arguments.extend(argument_attrs.extract_argument_code(
+        extract_arguments.extend(extract_argument_code(
+            &argument_attrs,
             span,
             &extracted_name,
             none_handling,
         ));
 
-        deserialized_arguments.extend(argument_attrs.deserialized_argument_code(
+        deserialized_arguments.extend(deserialized_argument_code(
+            &argument_attrs,
             span,
             arg_type,
             &deserialized_name,
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 6+ messages in thread

* [PATCH perlmod v2 2/5] macro: function: make argument attribute handling more extendable
  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 ` 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
                   ` (2 subsequent siblings)
  4 siblings, 0 replies; 6+ messages in thread
From: Max R. Carrara @ 2026-07-24 14:49 UTC (permalink / raw)
  To: pve-devel

Introduce a new private `ArgumentAttrType` enum for tracking whether
an argument attribute is `#[raw]`, `#[list]`, etc. and replace the
flags inside `ArgumentAttrs` with it.

Move the reference to `syn::PatType` into `ArgumentAttrs` and make
`ArgumentAttrs` take a corresponding lifetime as parameter.

Replace the existing machinery that has the `for_input()` function as
entrypoint with the new `new_from_fn_arg()` function, which makes use
of the new fields in `ArgumentAttrs`. Replicate the previous logic
inside `new_from_fn_arg()`, but laid out more explicitly -- now there
is no need to bounce between a couple function calls when reading the
code.

Rename `ArgumentAttrs` to `ArgumentAttr`, since we never return more
than one anyway, and all argument attributes are mutually exclusive
anyway.

Finally, adapt the remaining code in `handle_function()`,
`extract_argument_code()`, and `deserialized_argument_code()`
correspondingly by making it use the new struct fields.

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 perlmod-macro/src/function.rs | 217 ++++++++++++++++++++--------------
 1 file changed, 131 insertions(+), 86 deletions(-)

diff --git a/perlmod-macro/src/function.rs b/perlmod-macro/src/function.rs
index 9d4a4af..3ce0a17 100644
--- a/perlmod-macro/src/function.rs
+++ b/perlmod-macro/src/function.rs
@@ -15,112 +15,159 @@ pub struct XSub {
     pub prototype: Option<String>,
 }
 
-#[derive(Default)]
-struct ArgumentAttrs {
-    /// This is the `CV` pointer.
-    cv: Option<Span>,
-
+enum ArgumentAttrType {
     /// Skip the deserializer for this argument.
-    raw: bool,
+    Raw,
 
     /// Call `TryFrom<&Value>::try_from` for this argument instead of deserializing it.
-    try_from_ref: bool,
+    TryFromRef,
+
+    /// This is the `CV` pointer.
+    CVPtr(Span),
 
     /// Slurp the remaining arguments (like an `@rest` at the end).
     /// This requires the parameter to implement `FromIterator<T: Deserialize>`.
-    list: Option<Span>,
+    TrailingList(Span),
 }
 
-impl ArgumentAttrs {
-    fn handle_path(&mut self, path: &syn::Path) -> bool {
+impl ArgumentAttrType {
+    fn from_attr(attr: &syn::Attribute) -> Option<Self> {
+        Self::from_path(attr.path())
+    }
+
+    fn from_path(path: &syn::Path) -> Option<Self> {
         if path.is_ident("raw") {
-            self.raw = true;
-        } else if path.is_ident("try_from_ref") {
-            self.try_from_ref = true;
-        } else if path.is_ident("cv") {
-            self.cv = Some(path.span());
-        } else if path.is_ident("list") {
-            self.list = Some(path.span());
-        } else {
-            return false;
+            return Some(Self::Raw);
         }
 
-        true
+        if path.is_ident("try_from_ref") {
+            return Some(Self::TryFromRef);
+        }
+
+        if path.is_ident("cv") {
+            return Some(Self::CVPtr(path.span()));
+        }
+
+        if path.is_ident("list") {
+            return Some(Self::TrailingList(path.span()));
+        }
+
+        None
     }
 
-    fn handle_attr(&mut self, attr: &syn::Attribute) -> bool {
-        if self.handle_path(attr.path()) {
-            if !matches!(attr.meta, Meta::Path(_)) {
-                error!(&attr.meta => "attribute does not take any value or parameter");
-            }
-            true
-        } else {
-            false
+    fn is_same_variant_as(&self, other: &Self) -> bool {
+        core::mem::discriminant(self) == core::mem::discriminant(other)
+    }
+
+    fn as_str(&self) -> &'static str {
+        match self {
+            Self::Raw => "raw",
+            Self::TryFromRef => "try_from_ref",
+            Self::CVPtr(_) => "cv",
+            Self::TrailingList(_) => "list",
         }
     }
+}
 
-    fn validate(&self, span: Span) -> Result<(), Error> {
-        if self.raw as usize
-            + self.try_from_ref as usize
-            + self.cv.is_some() as usize
-            + self.list.is_some() as usize
-            > 1
-        {
-            bail!(
-                span,
-                "`raw` and `try_from_ref`, `cv` and `list` attributes are mutually exclusive"
-            );
-        }
-        Ok(())
-    }
-
-    fn for_input(arg: &mut syn::FnArg) -> Result<(Self, &syn::PatType), Error> {
-        let mut this = Self::default();
+struct ArgumentAttr<'s> {
+    attr_type: Option<ArgumentAttrType>,
+    pat_type: &'s syn::PatType,
+}
 
+impl<'s> ArgumentAttr<'s> {
+    fn new_from_fn_arg(arg: &'s mut syn::FnArg) -> Result<Self, Error> {
         match arg {
-            syn::FnArg::Receiver(_) => bail!(arg => "cannot export self-taking methods as xsubs"),
-            syn::FnArg::Typed(pt) => {
-                pt.attrs.retain(|attr| !this.handle_attr(attr));
-                this.validate(pt.span())?;
-                Ok((this, &*pt))
+            syn::FnArg::Receiver(_) => {
+                bail!(arg => "cannot export self-taking methods as xsubs");
+            }
+            syn::FnArg::Typed(pat_type) => {
+                let mut attr_type: Option<ArgumentAttrType> = None;
+                let mut has_err = false;
+
+                let span = pat_type.span();
+
+                pat_type.attrs.retain(|attr| {
+                    let Some(got_attr_type) = ArgumentAttrType::from_attr(attr) else {
+                        // Attribute has no matching attribute type => retain it
+                        return true;
+                    };
+
+                    if !matches!(attr.meta, Meta::Path(_)) {
+                        error!(&attr.meta => "attribute does not take any value or parameter");
+                        has_err = true;
+                        return false;
+                    }
+
+                    let Some(existing_attr_type) = attr_type.as_ref() else {
+                        // No existing attribute type => assign and don't retain
+                        attr_type = Some(got_attr_type);
+                        return false;
+                    };
+
+                    if existing_attr_type.is_same_variant_as(&got_attr_type) {
+                        let attr_str = existing_attr_type.as_str();
+                        error!(span, "duplicate attribute '{attr_str}'");
+                        has_err = true;
+                        return false;
+                    }
+
+                    // At this point we have two differing attributes
+                    error!(
+                        span,
+                        "`raw`, `try_from_ref`, `cv`, and `list` attributes are mutually exclusive"
+                    );
+                    has_err = true;
+                    false
+                });
+
+                if has_err {
+                    bail!(span, "failed to determine argument attribute");
+                }
+
+                Ok(Self {
+                    attr_type,
+                    pat_type,
+                })
             }
         }
     }
 }
 
 fn extract_argument_code(
-    arg_attr: &ArgumentAttrs,
+    arg_attr: &ArgumentAttr,
     span: Span,
     extracted_name: &Ident,
     none_handling: TokenStream,
 ) -> TokenStream {
-    if arg_attr.list.is_some() {
-        quote_spanned! { span=>
-            let #extracted_name = args.map(::perlmod::Value::from);
+    match arg_attr.attr_type {
+        Some(ArgumentAttrType::TrailingList(_)) => {
+            quote_spanned! { span=>
+                let #extracted_name = args.map(::perlmod::Value::from);
+            }
         }
-    } else {
-        quote_spanned! { span=>
-            let #extracted_name: ::perlmod::Value = match args.next() {
-                Some(arg) => ::perlmod::Value::from(arg),
-                None => #none_handling
-            };
+        _ => {
+            quote_spanned! { span=>
+                let #extracted_name: ::perlmod::Value = match args.next() {
+                    Some(arg) => ::perlmod::Value::from(arg),
+                    None => #none_handling
+                };
+            }
         }
     }
 }
 
 fn deserialized_argument_code(
-    arg_attr: &ArgumentAttrs,
+    arg_attr: &ArgumentAttr,
     span: Span,
     arg_type: &syn::Type,
     deserialized_name: &Ident,
     extracted_name: Ident,
 ) -> TokenStream {
-    if arg_attr.raw {
-        quote_spanned! { span=>
+    match &arg_attr.attr_type {
+        Some(ArgumentAttrType::Raw) => quote_spanned! { span=>
             let #deserialized_name = #extracted_name;
-        }
-    } else if arg_attr.try_from_ref {
-        quote_spanned! { span=>
+        },
+        Some(ArgumentAttrType::TryFromRef) => quote_spanned! { span=>
             let #deserialized_name: #arg_type =
                 match ::std::convert::TryFrom::try_from(&#extracted_name) {
                     Ok(arg) => arg,
@@ -130,9 +177,8 @@ fn deserialized_argument_code(
                             .into_raw());
                     }
                 };
-        }
-    } else if arg_attr.list.is_some() {
-        quote_spanned! { span=>
+        },
+        Some(ArgumentAttrType::TrailingList(_)) => quote_spanned! { span=>
             let #deserialized_name = {
                 let _guard = ::perlmod::__private__::InParameterDeserialization::guard();
                 match <#arg_type as ::perlmod::__private__::serde::Deserialize>::deserialize(
@@ -148,9 +194,8 @@ fn deserialized_argument_code(
                     }
                 }
             };
-        }
-    } else {
-        quote_spanned! { span=>
+        },
+        Some(ArgumentAttrType::CVPtr(_)) | None => quote_spanned! { span=>
             let #deserialized_name: #arg_type =
                 match ::perlmod::from_ref_value(&#extracted_name) {
                     Ok(data) => data,
@@ -160,7 +205,7 @@ fn deserialized_argument_code(
                             .into_raw());
                     }
                 };
-        }
+        },
     }
 }
 
@@ -214,17 +259,17 @@ pub fn handle_function(
     let mut cv_arg_param = TokenStream::new();
     let mut had_list_param = false;
     for arg in &mut func.sig.inputs {
-        let (argument_attrs, pat_ty) = ArgumentAttrs::for_input(arg)?;
+        let arg_attr = ArgumentAttr::new_from_fn_arg(arg)?;
 
-        if had_list_param {
-            if let Some(span) = argument_attrs.list {
-                bail!(span, "only 1 #[list] parameter allowed");
+        if let Some(ArgumentAttrType::TrailingList(list_span)) = arg_attr.attr_type {
+            if had_list_param {
+                bail!(list_span, "only 1 #[list] parameter allowed");
             }
-        } else {
-            had_list_param = argument_attrs.list.is_some();
+
+            had_list_param = true;
         }
 
-        let arg_name = match &*pat_ty.pat {
+        let arg_name = match &*arg_attr.pat_type.pat {
             syn::Pat::Ident(ident) => {
                 if ident.by_ref.is_some() {
                     bail!(ident => "xsub does not support by-ref parameters");
@@ -234,12 +279,12 @@ pub fn handle_function(
                 }
                 &ident.ident
             }
-            _ => bail!(&pat_ty.pat => "xsub does not support this kind of parameter"),
+            _ => bail!(&arg_attr.pat_type.pat => "xsub does not support this kind of parameter"),
         };
 
-        let arg_type = &*pat_ty.ty;
+        let arg_type = &*arg_attr.pat_type.ty;
 
-        if let Some(cv_span) = argument_attrs.cv {
+        if let Some(ArgumentAttrType::CVPtr(cv_span)) = arg_attr.attr_type {
             if !cv_arg_param.is_empty() {
                 bail!(cv_span, "only 1 'cv' parameter allowed");
             }
@@ -264,7 +309,7 @@ pub fn handle_function(
         let none_handling = if is_option_type(arg_type).is_some() {
             trailing_options += 1;
             quote_spanned! { span=> ::perlmod::Value::new_undef(), }
-        } else if argument_attrs.list.is_some() {
+        } else if matches!(arg_attr.attr_type, Some(ArgumentAttrType::TrailingList(_))) {
             TokenStream::new()
         } else {
             // only count the trailing options;
@@ -279,14 +324,14 @@ pub fn handle_function(
         };
 
         extract_arguments.extend(extract_argument_code(
-            &argument_attrs,
+            &arg_attr,
             span,
             &extracted_name,
             none_handling,
         ));
 
         deserialized_arguments.extend(deserialized_argument_code(
-            &argument_attrs,
+            &arg_attr,
             span,
             arg_type,
             &deserialized_name,
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 6+ messages in thread

* [PATCH perlmod v2 3/5] macro: function: explicitly pass identifier for generated arg iter
  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 ` 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 ` [PATCH perlmod v2 5/5] perlmod, macro: add #[hash] parameter attribute Max R. Carrara
  4 siblings, 0 replies; 6+ messages in thread
From: Max R. Carrara @ 2026-07-24 14:49 UTC (permalink / raw)
  To: pve-devel

The `args` variable that we define in generated code is used in
multiple places, making them implicitly depend on each other.

Make this explicit instead by defining the identifier as a
`syn::Ident` and passing it to / interpolating it into the respective
places.

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 perlmod-macro/src/function.rs | 14 +++++++++-----
 1 file changed, 9 insertions(+), 5 deletions(-)

diff --git a/perlmod-macro/src/function.rs b/perlmod-macro/src/function.rs
index 3ce0a17..3ca7bd0 100644
--- a/perlmod-macro/src/function.rs
+++ b/perlmod-macro/src/function.rs
@@ -136,18 +136,19 @@ impl<'s> ArgumentAttr<'s> {
 fn extract_argument_code(
     arg_attr: &ArgumentAttr,
     span: Span,
+    arguments_name: &Ident,
     extracted_name: &Ident,
     none_handling: TokenStream,
 ) -> TokenStream {
     match arg_attr.attr_type {
         Some(ArgumentAttrType::TrailingList(_)) => {
             quote_spanned! { span=>
-                let #extracted_name = args.map(::perlmod::Value::from);
+                let #extracted_name = #arguments_name.map(::perlmod::Value::from);
             }
         }
         _ => {
             quote_spanned! { span=>
-                let #extracted_name: ::perlmod::Value = match args.next() {
+                let #extracted_name: ::perlmod::Value = match #arguments_name.next() {
                     Some(arg) => ::perlmod::Value::from(arg),
                     None => #none_handling
                 };
@@ -252,6 +253,8 @@ pub fn handle_function(
         });
     let impl_xs_name = Ident::new(&format!("impl_xs_{name}"), name.span());
 
+    let arguments_name = syn::Ident::new("args", name.span());
+
     let mut trailing_options = 0;
     let mut extract_arguments = TokenStream::new();
     let mut deserialized_arguments = TokenStream::new();
@@ -326,6 +329,7 @@ pub fn handle_function(
         extract_arguments.extend(extract_argument_code(
             &arg_attr,
             span,
+            &arguments_name,
             &extracted_name,
             none_handling,
         ));
@@ -377,7 +381,7 @@ pub fn handle_function(
         );
 
         quote_spanned! { span=>
-            if args.next().is_some() {
+            if #arguments_name.next().is_some() {
                 return Err(::perlmod::Value::new_string(#too_many_args_error)
                     .into_mortal()
                     .into_raw());
@@ -417,8 +421,8 @@ pub fn handle_function(
             #visibility_action
 
             let argmark = unsafe { ::perlmod::ffi::pop_arg_mark() };
-            let mut args = argmark.iter();
-            { let _ = &mut args; }
+            let mut #arguments_name = argmark.iter();
+            { let _ = &mut #arguments_name; }
 
             #extract_arguments
 
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 6+ messages in thread

* [PATCH perlmod v2 4/5] macro: function: move signature inputs handling into helper struct
  2026-07-24 14:49 [PATCH perlmod v2 0/5] perlmod: add #[hash] parameter attribute Max R. Carrara
                   ` (2 preceding siblings ...)
  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 ` Max R. Carrara
  2026-07-24 14:49 ` [PATCH perlmod v2 5/5] perlmod, macro: add #[hash] parameter attribute Max R. Carrara
  4 siblings, 0 replies; 6+ messages in thread
From: Max R. Carrara @ 2026-07-24 14:49 UTC (permalink / raw)
  To: pve-devel

The handling of function signature inputs adds a lot of mutable state
to the body of the `handle_function()` fn, which makes it somewhat
hard to track when that state is actually mutated throughout the
function.

Therefore, introduce the private `FnInputs` struct and heave all of
the fn signature input handling into `FnInputs::from_signature()` to
limit the scope where mutability occurs. Additionally, use an inline
`State` struct inside `FnInputs::from_signature()` in order to make it
explicit at a glance when mutable state is in fact mutated.

Also introduce the `TrailingType` enum and use it inside `FnInputs`
instead of the `had_list_param` boolean in order to make adding such
proc macro parameter attributes a little easier in the future.

Take the opportunity to improve the overall signature inputs handling
logic by making use of some more pattern matching and using `break`s
from labeled blocks [1] instead of an if-elseif-else expression when
generating the code for the `None` handling.

Adapt the `gen_prototype()` function and make it take a reference to
`&FnInputs` instead, and also use pattern matching when defining the
string for the Perl subroutine prototype. Avoid shadowing the
`arg_count` variable too by renaming the parameter to
`total_arg_count`.

Adapt the `handle_return_kind()` function and make it take a reference
to `&FnInputs` as well, removing obsolete parameters in the process.
Adapt its body accordingly.

[1] https://blog.rust-lang.org/2022/11/03/Rust-1.65.0/#break-from-labeled-blocks

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 perlmod-macro/src/function.rs | 326 ++++++++++++++++++++++------------
 1 file changed, 208 insertions(+), 118 deletions(-)

diff --git a/perlmod-macro/src/function.rs b/perlmod-macro/src/function.rs
index 3ca7bd0..f5415c5 100644
--- a/perlmod-macro/src/function.rs
+++ b/perlmod-macro/src/function.rs
@@ -210,6 +210,171 @@ fn deserialized_argument_code(
     }
 }
 
+enum TrailingType {
+    List,
+}
+
+impl TrailingType {
+    fn as_str(&self) -> &'static str {
+        match self {
+            Self::List => "list",
+        }
+    }
+}
+
+struct FnInputs {
+    trailing_options: usize,
+    extract_arguments: TokenStream,
+    deserialized_arguments: TokenStream,
+    passed_arguments: TokenStream,
+    cv_arg_param: TokenStream,
+    trailing_type: Option<TrailingType>,
+}
+
+impl FnInputs {
+    fn from_signature(
+        signature: &mut syn::Signature,
+        arguments_name: &Ident,
+    ) -> Result<Self, Error> {
+        #[derive(Default)]
+        struct State {
+            trailing_options: usize,
+            extract_arguments: TokenStream,
+            deserialized_arguments: TokenStream,
+            passed_arguments: TokenStream,
+            cv_arg_param: TokenStream,
+            trailing_type: Option<TrailingType>,
+        }
+
+        let mut state = State::default();
+
+        let span = signature.ident.span();
+
+        for arg in &mut signature.inputs {
+            let arg_attr = ArgumentAttr::new_from_fn_arg(arg)?;
+
+            let arg_type: &syn::Type = arg_attr.pat_type.ty.as_ref();
+
+            let arg_name = {
+                let pattern: &syn::Pat = arg_attr.pat_type.pat.as_ref();
+                match pattern {
+                    syn::Pat::Ident(ident) => {
+                        if ident.by_ref.is_some() {
+                            bail!(ident => "xsub does not support by-ref parameters");
+                        }
+                        if ident.subpat.is_some() {
+                            bail!(ident => "xsub does not support sub-patterns on parameters");
+                        }
+                        &ident.ident
+                    }
+                    _ => bail!(pattern => "xsub does not support this kind of parameter"),
+                }
+            };
+
+            if let Some(ref attr_type) = arg_attr.attr_type {
+                match attr_type {
+                    ArgumentAttrType::Raw => {}
+                    ArgumentAttrType::TryFromRef => {}
+                    ArgumentAttrType::CVPtr(cv_span) => {
+                        if !state.cv_arg_param.is_empty() {
+                            bail!(*cv_span, "only 1 'cv' parameter allowed");
+                        }
+
+                        state.cv_arg_param = quote_spanned! { span=> #arg_name: #arg_type };
+                        if state.passed_arguments.is_empty() {
+                            state
+                                .passed_arguments
+                                .extend(quote_spanned! { span=> #arg_name });
+                        } else {
+                            state
+                                .passed_arguments
+                                .extend(quote_spanned! { span=> , #arg_name });
+                        }
+
+                        continue;
+                    }
+                    ArgumentAttrType::TrailingList(trailing_span) => {
+                        if let Some(trailing_type) = state.trailing_type {
+                            bail!(
+                                *trailing_span,
+                                "only 1 parameter for trailing values allowed, already got '{}'",
+                                trailing_type.as_str(),
+                            );
+                        }
+
+                        state.trailing_type = Some(TrailingType::List);
+                    }
+                }
+            }
+
+            let extracted_name = Ident::new(&format!("extracted_arg_{arg_name}"), arg_name.span());
+            let deserialized_name =
+                Ident::new(&format!("deserialized_arg_{arg_name}"), arg_name.span());
+
+            let missing_message = syn::LitStr::new(
+                &format!("missing required parameter: '{arg_name}'\n"),
+                arg_name.span(),
+            );
+
+            let none_handling = 'handling: {
+                if is_option_type(arg_type).is_some() {
+                    state.trailing_options += 1;
+                    break 'handling quote_spanned! { span=> ::perlmod::Value::new_undef(), };
+                }
+
+                if matches!(arg_attr.attr_type, Some(ArgumentAttrType::TrailingList(_))) {
+                    break 'handling TokenStream::new();
+                }
+
+                // only count the trailing options
+                state.trailing_options = 0;
+                quote_spanned! { span=>
+                    {
+                        return Err(::perlmod::Value::new_string(#missing_message)
+                            .into_mortal()
+                            .into_raw());
+                    }
+                }
+            };
+
+            let arg_code = extract_argument_code(
+                &arg_attr,
+                span,
+                arguments_name,
+                &extracted_name,
+                none_handling,
+            );
+
+            let de_arg_code = deserialized_argument_code(
+                &arg_attr,
+                span,
+                arg_type,
+                &deserialized_name,
+                extracted_name,
+            );
+
+            let passed_arg = if state.passed_arguments.is_empty() {
+                quote_spanned! { span=> #deserialized_name }
+            } else {
+                quote_spanned! { span=> , #deserialized_name }
+            };
+
+            state.extract_arguments.extend(arg_code);
+            state.deserialized_arguments.extend(de_arg_code);
+            state.passed_arguments.extend(passed_arg)
+        }
+
+        Ok(Self {
+            trailing_options: state.trailing_options,
+            extract_arguments: state.extract_arguments,
+            deserialized_arguments: state.deserialized_arguments,
+            passed_arguments: state.passed_arguments,
+            cv_arg_param: state.cv_arg_param,
+            trailing_type: state.trailing_type,
+        })
+    }
+}
+
 struct Return {
     result: bool,
     value: ReturnValue,
@@ -255,99 +420,7 @@ pub fn handle_function(
 
     let arguments_name = syn::Ident::new("args", name.span());
 
-    let mut trailing_options = 0;
-    let mut extract_arguments = TokenStream::new();
-    let mut deserialized_arguments = TokenStream::new();
-    let mut passed_arguments = TokenStream::new();
-    let mut cv_arg_param = TokenStream::new();
-    let mut had_list_param = false;
-    for arg in &mut func.sig.inputs {
-        let arg_attr = ArgumentAttr::new_from_fn_arg(arg)?;
-
-        if let Some(ArgumentAttrType::TrailingList(list_span)) = arg_attr.attr_type {
-            if had_list_param {
-                bail!(list_span, "only 1 #[list] parameter allowed");
-            }
-
-            had_list_param = true;
-        }
-
-        let arg_name = match &*arg_attr.pat_type.pat {
-            syn::Pat::Ident(ident) => {
-                if ident.by_ref.is_some() {
-                    bail!(ident => "xsub does not support by-ref parameters");
-                }
-                if ident.subpat.is_some() {
-                    bail!(ident => "xsub does not support sub-patterns on parameters");
-                }
-                &ident.ident
-            }
-            _ => bail!(&arg_attr.pat_type.pat => "xsub does not support this kind of parameter"),
-        };
-
-        let arg_type = &*arg_attr.pat_type.ty;
-
-        if let Some(ArgumentAttrType::CVPtr(cv_span)) = arg_attr.attr_type {
-            if !cv_arg_param.is_empty() {
-                bail!(cv_span, "only 1 'cv' parameter allowed");
-            }
-            cv_arg_param = quote_spanned! { span=> #arg_name: #arg_type };
-            if passed_arguments.is_empty() {
-                passed_arguments.extend(quote_spanned! { span=> #arg_name });
-            } else {
-                passed_arguments.extend(quote_spanned! { span=> , #arg_name });
-            }
-            continue;
-        }
-
-        let extracted_name = Ident::new(&format!("extracted_arg_{arg_name}"), arg_name.span());
-        let deserialized_name =
-            Ident::new(&format!("deserialized_arg_{arg_name}"), arg_name.span());
-
-        let missing_message = syn::LitStr::new(
-            &format!("missing required parameter: '{arg_name}'\n"),
-            arg_name.span(),
-        );
-
-        let none_handling = if is_option_type(arg_type).is_some() {
-            trailing_options += 1;
-            quote_spanned! { span=> ::perlmod::Value::new_undef(), }
-        } else if matches!(arg_attr.attr_type, Some(ArgumentAttrType::TrailingList(_))) {
-            TokenStream::new()
-        } else {
-            // only count the trailing options;
-            trailing_options = 0;
-            quote_spanned! { span=>
-                {
-                    return Err(::perlmod::Value::new_string(#missing_message)
-                        .into_mortal()
-                        .into_raw());
-                }
-            }
-        };
-
-        extract_arguments.extend(extract_argument_code(
-            &arg_attr,
-            span,
-            &arguments_name,
-            &extracted_name,
-            none_handling,
-        ));
-
-        deserialized_arguments.extend(deserialized_argument_code(
-            &arg_attr,
-            span,
-            arg_type,
-            &deserialized_name,
-            extracted_name,
-        ));
-
-        if passed_arguments.is_empty() {
-            passed_arguments.extend(quote_spanned! { span=> #deserialized_name });
-        } else {
-            passed_arguments.extend(quote_spanned! { span=> , #deserialized_name });
-        }
-    }
+    let inputs = FnInputs::from_signature(&mut func.sig, &arguments_name)?;
 
     let has_return_value = match &func.sig.output {
         syn::ReturnType::Default => Return {
@@ -370,12 +443,12 @@ pub fn handle_function(
         },
     };
 
-    let finalize_arguments = if !had_list_param {
+    let finalize_arguments = if !inputs.trailing_type.is_some() {
         let too_many_args_error = syn::LitStr::new(
             &format!(
                 "too many parameters for function '{}', (expected {})\n",
                 name,
-                func.sig.inputs.len() - (!cv_arg_param.is_empty()) as usize
+                func.sig.inputs.len() - (!inputs.cv_arg_param.is_empty()) as usize
             ),
             Span::call_site(),
         );
@@ -397,13 +470,12 @@ pub fn handle_function(
         wrapper_func,
     } = handle_return_kind(
         &attr,
+        &inputs,
         has_return_value,
         &name,
         &xs_name,
         &impl_xs_name,
-        passed_arguments,
         export_public,
-        !cv_arg_param.is_empty(),
     )?;
 
     let visibility_action = check_visibility(&func);
@@ -414,6 +486,10 @@ pub fn handle_function(
         #wrapper_func
     };
 
+    let cv_arg_param = &inputs.cv_arg_param;
+    let extract_arguments = &inputs.extract_arguments;
+    let deserialized_arguments = &inputs.deserialized_arguments;
+
     tokens.extend(quote_spanned! { span=>
         #[inline(never)]
         #[allow(non_snake_case)]
@@ -451,36 +527,48 @@ pub fn handle_function(
         perl_name: attr.perl_name,
         xs_name,
         tokens,
-        prototype: attr.prototype.or_else(|| {
-            Some(gen_prototype(
-                func.sig.inputs.len(),
-                trailing_options,
-                had_list_param,
-            ))
-        }),
+        prototype: attr
+            .prototype
+            .or_else(|| Some(gen_prototype(func.sig.inputs.len(), &inputs))),
     })
 }
 
-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(total_arg_count: usize, inputs: &FnInputs) -> String {
+    let trailing_options = inputs.trailing_options;
+    let trailing_type = inputs.trailing_type.as_ref();
+
+    let arg_count = total_arg_count - trailing_options - (trailing_type.is_some() as usize);
 
     let mut proto = String::with_capacity(arg_count + trailing_options + 1);
 
     for _ in 0..arg_count {
         proto.push('$');
     }
-    if trailing_options > 0 {
-        proto.push(';');
-        for _ in 0..trailing_options {
-            proto.push('$');
+
+    match (trailing_options, trailing_type) {
+        (1.., ty) => {
+            proto.push(';');
+
+            for _ in 0..trailing_options {
+                proto.push('$');
+            }
+
+            match ty {
+                Some(TrailingType::List) => proto.push('@'),
+                None => {}
+            }
+
+            proto
         }
-        if had_list_param {
-            proto.push('@');
+        (0, ty) => {
+            match ty {
+                Some(TrailingType::List) => proto.push_str(";@"),
+                None => {}
+            }
+
+            proto
         }
-    } else if had_list_param {
-        proto.push_str(";@");
     }
-    proto
 }
 
 struct ReturnHandling {
@@ -492,16 +580,17 @@ struct ReturnHandling {
 #[allow(clippy::too_many_arguments)]
 fn handle_return_kind(
     attr: &FunctionAttrs,
+    inputs: &FnInputs,
     ret: Return,
     name: &Ident,
     xs_name: &Ident,
     impl_xs_name: &Ident,
-    passed_arguments: TokenStream,
     export_public: Option<&syn::Visibility>,
-    cv_arg: bool,
 ) -> Result<ReturnHandling, Error> {
     let span = name.span();
 
+    let passed_arguments = &inputs.passed_arguments;
+
     let return_type;
     let mut handle_return;
     let wrapper_func;
@@ -511,7 +600,8 @@ fn handle_return_kind(
         None => quote_spanned! { span=> #[allow(non_snake_case)] },
     };
 
-    let (cv_arg_name, cv_arg_passed) = if cv_arg {
+    let has_cv_arg = !inputs.cv_arg_param.is_empty();
+    let (cv_arg_name, cv_arg_passed) = if has_cv_arg {
         (
             quote_spanned! { span=> cv },
             quote_spanned! { span=> ::perlmod::Value::from_raw_ref(cv as *mut ::perlmod::ffi::SV) },
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 6+ messages in thread

* [PATCH perlmod v2 5/5] perlmod, macro: add #[hash] parameter attribute
  2026-07-24 14:49 [PATCH perlmod v2 0/5] perlmod: add #[hash] parameter attribute Max R. Carrara
                   ` (3 preceding siblings ...)
  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
  4 siblings, 0 replies; 6+ messages in thread
From: Max R. Carrara @ 2026-07-24 14:49 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 | 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





^ permalink raw reply related	[flat|nested] 6+ messages in thread

end of thread, other threads:[~2026-07-24 14:50 UTC | newest]

Thread overview: 6+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
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 ` [PATCH perlmod v2 5/5] perlmod, macro: add #[hash] parameter attribute Max R. Carrara

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