all lists on lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH perlmod v3 0/2] perlmod: add #[hash] parameter attribute
@ 2026-07-30 11:10 Max R. Carrara
  2026-07-30 11:10 ` [PATCH perlmod v3 1/2] macro: function: move signature inputs handling into helper struct Max R. Carrara
                   ` (2 more replies)
  0 siblings, 3 replies; 4+ messages in thread
From: Max R. Carrara @ 2026-07-30 11:10 UTC (permalink / raw)
  To: pve-devel

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

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 v2
----------------

- Drop patches that were already applied (thanks!)

- Incorporate Wolfgang's feedback on patch #4 of v2 [0] (thanks a lot!)
  and remove unnecessary bindings and indentation; can now be found in
  patch #1 of this series.

  Changed the commit message correspondingly as well.

- Rebase the very last patch that actually adds support for `#[hash]`
  on top of the previous change.

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

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

References
----------

[0] https://lore.proxmox.com/pve-devel/20260724144914.658730-1-m.carrara@proxmox.com/T/#mb48037a41330f7ec625a2a2295f9bee0b8b8adcf

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


perlmod:

Max R. Carrara (2):
  macro: function: move signature inputs handling into helper struct
  perlmod, macro: add #[hash] parameter attribute

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


Summary over all repositories:
  5 files changed, 367 insertions(+), 117 deletions(-)

-- 
Generated by murpp 0.12.0




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

* [PATCH perlmod v3 1/2] macro: function: move signature inputs handling into helper struct
  2026-07-30 11:10 [PATCH perlmod v3 0/2] perlmod: add #[hash] parameter attribute Max R. Carrara
@ 2026-07-30 11:10 ` Max R. Carrara
  2026-07-30 11:10 ` [PATCH perlmod v3 2/2] perlmod, macro: add #[hash] parameter attribute Max R. Carrara
  2026-07-30 17:00 ` [PATCH perlmod v3 0/2] perlmod: " Wolfgang Bumiller
  2 siblings, 0 replies; 4+ messages in thread
From: Max R. Carrara @ 2026-07-30 11:10 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 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 | 308 +++++++++++++++++++++-------------
 1 file changed, 195 insertions(+), 113 deletions(-)

diff --git a/perlmod-macro/src/function.rs b/perlmod-macro/src/function.rs
index 3ca7bd0..5bad3a6 100644
--- a/perlmod-macro/src/function.rs
+++ b/perlmod-macro/src/function.rs
@@ -210,6 +210,168 @@ 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 = match arg_attr.pat_type.pat.as_ref() {
+                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
+                }
+                pattern => 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 +417,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 +440,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 +467,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 +483,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,35 +524,42 @@ 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('$');
         }
-        if had_list_param {
-            proto.push('@');
+
+        match trailing_type {
+            Some(TrailingType::List) => proto.push('@'),
+            None => {}
+        }
+    } else {
+        match trailing_type {
+            Some(TrailingType::List) => proto.push_str(";@"),
+            None => {}
         }
-    } else if had_list_param {
-        proto.push_str(";@");
     }
+
     proto
 }
 
@@ -492,16 +572,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 +592,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] 4+ messages in thread

* [PATCH perlmod v3 2/2] perlmod, macro: add #[hash] parameter attribute
  2026-07-30 11:10 [PATCH perlmod v3 0/2] perlmod: add #[hash] parameter attribute Max R. Carrara
  2026-07-30 11:10 ` [PATCH perlmod v3 1/2] macro: function: move signature inputs handling into helper struct Max R. Carrara
@ 2026-07-30 11:10 ` Max R. Carrara
  2026-07-30 17:00 ` [PATCH perlmod v3 0/2] perlmod: " Wolfgang Bumiller
  2 siblings, 0 replies; 4+ messages in thread
From: Max R. Carrara @ 2026-07-30 11:10 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 5bad3a6..5ee71bc 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!(),
         }
     }
 }
@@ -269,10 +315,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");
                         }
@@ -290,7 +338,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,
@@ -299,7 +347,7 @@ impl FnInputs {
                             );
                         }
 
-                        state.trailing_type = Some(TrailingType::List);
+                        state.trailing_type = Some(TrailingType::from_attr_type(attr_type));
                     }
                 }
             }
@@ -319,7 +367,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();
                 }
 
@@ -551,11 +602,13 @@ fn gen_prototype(total_arg_count: usize, inputs: &FnInputs) -> String {
 
         match trailing_type {
             Some(TrailingType::List) => proto.push('@'),
+            Some(TrailingType::Hash) => proto.push('%'),
             None => {}
         }
     } else {
         match trailing_type {
             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 f238d00..0518062 100644
--- a/testlib/src/lib.rs
+++ b/testlib/src/lib.rs
@@ -13,7 +13,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};
@@ -138,4 +141,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] 4+ messages in thread

* Re: [PATCH perlmod v3 0/2] perlmod: add #[hash] parameter attribute
  2026-07-30 11:10 [PATCH perlmod v3 0/2] perlmod: add #[hash] parameter attribute Max R. Carrara
  2026-07-30 11:10 ` [PATCH perlmod v3 1/2] macro: function: move signature inputs handling into helper struct Max R. Carrara
  2026-07-30 11:10 ` [PATCH perlmod v3 2/2] perlmod, macro: add #[hash] parameter attribute Max R. Carrara
@ 2026-07-30 17:00 ` Wolfgang Bumiller
  2 siblings, 0 replies; 4+ messages in thread
From: Wolfgang Bumiller @ 2026-07-30 17:00 UTC (permalink / raw)
  To: pve-devel, Max R. Carrara

On Thu, 30 Jul 2026 13:10:56 +0200, Max R. Carrara wrote:
> perlmod: add #[hash] parameter attribute
> 
> perlmod: add #[hash] parameter attribute - v3
> =============================================
> 
> 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.
> 
> [...]

Applied, thanks!

[1/2] macro: function: move signature inputs handling into helper struct
      commit: 8b21fa276f9a96e6e1b432f9e69036414a58cb58
[2/2] perlmod, macro: add #[hash] parameter attribute
      commit: d85d4ebdd13c1dcb469e15eb0ce7b22640418b8e





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

end of thread, other threads:[~2026-07-30 17:01 UTC | newest]

Thread overview: 4+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-07-30 11:10 [PATCH perlmod v3 0/2] perlmod: add #[hash] parameter attribute Max R. Carrara
2026-07-30 11:10 ` [PATCH perlmod v3 1/2] macro: function: move signature inputs handling into helper struct Max R. Carrara
2026-07-30 11:10 ` [PATCH perlmod v3 2/2] perlmod, macro: add #[hash] parameter attribute Max R. Carrara
2026-07-30 17:00 ` [PATCH perlmod v3 0/2] perlmod: " Wolfgang Bumiller

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal