* [PATCH perlmod 0/4] perlmod: add #[hash] parameter
@ 2026-07-20 15:25 Max R. Carrara
2026-07-20 15:25 ` [PATCH perlmod 1/4] ffi: implement ExactSizeIterator for StackIter Max R. Carrara
` (3 more replies)
0 siblings, 4 replies; 5+ messages in thread
From: Max R. Carrara @ 2026-07-20 15:25 UTC (permalink / raw)
To: pve-devel
perlmod: add #[hash] parameter - v1
===================================
This series implements a `#[hash]` parameter similar to the existing
`#[list]` one, allowing us to create APIs like
`foo("arg", key => "value")` directly from Rust.
In other words, this essentially mirrors how a sub with `%params` as its
last parameter would work.
For example, this is one of the functions called by the tests that are
added in this series:
#[export]
fn trailing_hash(first: u32, #[hash] rest: HashMap<u32, u32>) -> String {
format!("first={first}, rest is {rest:?}")
}
This can then be called e.g. like this:
let $res = TestLib::Hello::trailing_hash(42, 1337 => 123);
Summary of Changes
------------------
perlmod:
Max R. Carrara (4):
ffi: implement ExactSizeIterator for StackIter
value: implement serde::de::IntoDeserializer on Value
perlmod, macro: add #[hash] parameter
testlib: run perltidy
perlmod-macro/src/function.rs | 72 ++++++++++++++++++++++++++++++++---
perlmod/src/de.rs | 44 +++++++++++++++++++++
perlmod/src/ffi.rs | 7 ++++
perlmod/src/lib.rs | 5 +++
perlmod/src/value.rs | 9 +++++
testlib-tests/01-hello.t | 70 +++++++++++++++++++++++++++++++---
testlib/src/lib.rs | 24 +++++++++++-
7 files changed, 220 insertions(+), 11 deletions(-)
Summary over all repositories:
7 files changed, 220 insertions(+), 11 deletions(-)
--
Generated by murpp 0.11.0
^ permalink raw reply [flat|nested] 5+ messages in thread
* [PATCH perlmod 1/4] ffi: implement ExactSizeIterator for StackIter
2026-07-20 15:25 [PATCH perlmod 0/4] perlmod: add #[hash] parameter Max R. Carrara
@ 2026-07-20 15:25 ` Max R. Carrara
2026-07-20 15:25 ` [PATCH perlmod 2/4] value: implement serde::de::IntoDeserializer on Value Max R. Carrara
` (2 subsequent siblings)
3 siblings, 0 replies; 5+ messages in thread
From: Max R. Carrara @ 2026-07-20 15:25 UTC (permalink / raw)
To: pve-devel
Since we know how many remaining values there are on the stack, we can
confidently implement `ExactSizeIterator` on `StackIter`.
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
perlmod/src/ffi.rs | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/perlmod/src/ffi.rs b/perlmod/src/ffi.rs
index 1c68518..1b1066d 100644
--- a/perlmod/src/ffi.rs
+++ b/perlmod/src/ffi.rs
@@ -468,8 +468,15 @@ impl Iterator for StackIter {
}
}
}
+
+ fn size_hint(&self) -> (usize, Option<usize>) {
+ let remaining = self.end - self.at;
+ (remaining, Some(remaining))
+ }
}
+impl ExactSizeIterator for StackIter {}
+
/// Pop the current argument marker off of the argument marker stack.
///
/// # Safety
--
2.47.3
^ permalink raw reply related [flat|nested] 5+ messages in thread
* [PATCH perlmod 2/4] value: implement serde::de::IntoDeserializer on Value
2026-07-20 15:25 [PATCH perlmod 0/4] perlmod: add #[hash] parameter Max R. Carrara
2026-07-20 15:25 ` [PATCH perlmod 1/4] ffi: implement ExactSizeIterator for StackIter Max R. Carrara
@ 2026-07-20 15:25 ` Max R. Carrara
2026-07-20 15:25 ` [PATCH perlmod 3/4] perlmod, macro: add #[hash] parameter Max R. Carrara
2026-07-20 15:25 ` [PATCH perlmod 4/4] testlib: run perltidy Max R. Carrara
3 siblings, 0 replies; 5+ messages in thread
From: Max R. Carrara @ 2026-07-20 15:25 UTC (permalink / raw)
To: pve-devel
For better compatibility with serde, we implement
`serde::de::IntoDeserializer` on `Value`. This allows us to skip
explicitly converting to the `Value` deserializer in `perlmod::de`.
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
perlmod-macro/src/function.rs | 2 +-
perlmod/src/value.rs | 9 +++++++++
2 files changed, 10 insertions(+), 1 deletion(-)
diff --git a/perlmod-macro/src/function.rs b/perlmod-macro/src/function.rs
index 0a4cf0e..8b5d689 100644
--- a/perlmod-macro/src/function.rs
+++ b/perlmod-macro/src/function.rs
@@ -136,7 +136,7 @@ impl ArgumentAttrs {
let _guard = ::perlmod::__private__::InParameterDeserialization::guard();
match <#arg_type as ::perlmod::__private__::serde::Deserialize>::deserialize(
::perlmod::__private__::serde::de::value::SeqDeserializer::new(
- #extracted_name.map(::perlmod::de::Deserializer::<'static>::from_value)
+ #extracted_name
)
) {
Ok(arg) => arg,
diff --git a/perlmod/src/value.rs b/perlmod/src/value.rs
index 1240eb5..5c2d432 100644
--- a/perlmod/src/value.rs
+++ b/perlmod/src/value.rs
@@ -3,6 +3,7 @@
use std::fmt;
+use serde::de::IntoDeserializer;
use serde::{Deserialize, Serialize};
use crate::Error;
@@ -652,3 +653,11 @@ impl<'de> Deserialize<'de> for Value {
}
}
}
+
+impl<'de> IntoDeserializer<'de, Error> for Value {
+ type Deserializer = crate::de::Deserializer<'de>;
+
+ fn into_deserializer(self) -> Self::Deserializer {
+ Self::Deserializer::from_value(self)
+ }
+}
--
2.47.3
^ permalink raw reply related [flat|nested] 5+ messages in thread
* [PATCH perlmod 3/4] perlmod, macro: add #[hash] parameter
2026-07-20 15:25 [PATCH perlmod 0/4] perlmod: add #[hash] parameter Max R. Carrara
2026-07-20 15:25 ` [PATCH perlmod 1/4] ffi: implement ExactSizeIterator for StackIter Max R. Carrara
2026-07-20 15:25 ` [PATCH perlmod 2/4] value: implement serde::de::IntoDeserializer on Value Max R. Carrara
@ 2026-07-20 15:25 ` Max R. Carrara
2026-07-20 15:25 ` [PATCH perlmod 4/4] testlib: run perltidy Max R. Carrara
3 siblings, 0 replies; 5+ messages in thread
From: Max R. Carrara @ 2026-07-20 15:25 UTC (permalink / raw)
To: pve-devel
Like `#[list]`, `#[hash]` allows us to support subs for which any
number of additional key-value pairs may be passed, which is the same
as having an `;%` at the end of the Perl prototype.
The `ParamsIter` type is added as a helper for pairwise iteration that
makes use of the fact that `ffi::StackIter` is now an
`ExactSizeIterator`, returning an error if the number of values on the
stack is odd on creation. It is then directly passed on to
serde's `MapDeserializer`, mirroring how `#[list]` works with
`SeqDeserializer`.
That way we can support additional Perl APIs without new parameters
breaking the Rust side of the API, just like we can with `#[list]`.
Also document the `#[hash]` parameter and add corresponding tests
along the way.
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
perlmod-macro/src/function.rs | 70 +++++++++++++++++++++++++++++++++--
perlmod/src/de.rs | 44 ++++++++++++++++++++++
perlmod/src/lib.rs | 5 +++
testlib-tests/01-hello.t | 46 ++++++++++++++++++++++-
testlib/src/lib.rs | 24 +++++++++++-
5 files changed, 183 insertions(+), 6 deletions(-)
diff --git a/perlmod-macro/src/function.rs b/perlmod-macro/src/function.rs
index 8b5d689..8388f43 100644
--- a/perlmod-macro/src/function.rs
+++ b/perlmod-macro/src/function.rs
@@ -29,6 +29,11 @@ struct ArgumentAttrs {
/// Slurp the remaining arguments (like an `@rest` at the end).
/// This requires the parameter to implement `FromIterator<T: Deserialize>`.
list: Option<Span>,
+
+ /// Slurp the remaining arguments (like a `%hash` at the end).
+ /// This requires the parameter to implement `FromIterator<(K, V)> where K: Deserialize, V:
+ /// Deserialize`.
+ hash: Option<Span>,
}
impl ArgumentAttrs {
@@ -41,6 +46,8 @@ impl ArgumentAttrs {
self.cv = Some(path.span());
} else if path.is_ident("list") {
self.list = Some(path.span());
+ } else if path.is_ident("hash") {
+ self.hash = Some(path.span());
} else {
return false;
}
@@ -64,11 +71,12 @@ impl ArgumentAttrs {
+ self.try_from_ref as usize
+ self.cv.is_some() as usize
+ self.list.is_some() as usize
+ + self.hash.is_some() as usize
> 1
{
bail!(
span,
- "`raw` and `try_from_ref`, `cv` and `list` attributes are mutually exclusive"
+ "`raw`, `try_from_ref`, `cv`, `list`, and `hash` attributes are mutually exclusive"
);
}
Ok(())
@@ -97,6 +105,10 @@ impl ArgumentAttrs {
quote_spanned! { span=>
let #extracted_name = args.map(::perlmod::Value::from);
}
+ } else if self.hash.is_some() {
+ quote_spanned! { span=>
+ let #extracted_name = args.map(::perlmod::Value::from);
+ }
} else {
quote_spanned! { span=>
let #extracted_name: ::perlmod::Value = match args.next() {
@@ -148,6 +160,33 @@ impl ArgumentAttrs {
}
};
}
+ } else if self.hash.is_some() {
+ quote_spanned! { span=>
+ let #deserialized_name = {
+ let _guard = ::perlmod::__private__::InParameterDeserialization::guard();
+ match ::perlmod::de::ParamsIter::new(#extracted_name) {
+ Ok(params_iter) => {
+ match <#arg_type as ::perlmod::__private__::serde::Deserialize>::deserialize(
+ ::perlmod::__private__::serde::de::value::MapDeserializer::new(
+ params_iter
+ )
+ ) {
+ Ok(map) => map,
+ Err(err) => {
+ return Err(::perlmod::Value::new_string(&format!("{err:#}\n"))
+ .into_mortal()
+ .into_raw());
+ }
+ }
+ },
+ Err(err) => {
+ return Err(::perlmod::Value::new_string(&format!("{err:#}\n"))
+ .into_mortal()
+ .into_raw());
+ }
+ }
+ };
+ }
} else {
quote_spanned! { span=>
let #deserialized_name: #arg_type =
@@ -213,6 +252,7 @@ pub fn handle_function(
let mut passed_arguments = TokenStream::new();
let mut cv_arg_param = TokenStream::new();
let mut had_list_param = false;
+ let mut had_hash_param = false;
for arg in &mut func.sig.inputs {
let (argument_attrs, pat_ty) = ArgumentAttrs::for_input(arg)?;
@@ -224,6 +264,14 @@ pub fn handle_function(
had_list_param = argument_attrs.list.is_some();
}
+ if had_hash_param {
+ if let Some(span) = argument_attrs.hash {
+ bail!(span, "only 1 #[hash] parameter allowed");
+ }
+ } else {
+ had_hash_param = argument_attrs.hash.is_some();
+ }
+
let arg_name = match &*pat_ty.pat {
syn::Pat::Ident(ident) => {
if ident.by_ref.is_some() {
@@ -266,6 +314,8 @@ pub fn handle_function(
quote_spanned! { span=> ::perlmod::Value::new_undef(), }
} else if argument_attrs.list.is_some() {
TokenStream::new()
+ } else if argument_attrs.hash.is_some() {
+ TokenStream::new()
} else {
// only count the trailing options;
trailing_options = 0;
@@ -319,7 +369,7 @@ pub fn handle_function(
},
};
- let finalize_arguments = if !had_list_param {
+ let finalize_arguments = if !had_list_param && !had_hash_param {
let too_many_args_error = syn::LitStr::new(
&format!(
"too many parameters for function '{}', (expected {})\n",
@@ -405,13 +455,20 @@ pub fn handle_function(
func.sig.inputs.len(),
trailing_options,
had_list_param,
+ had_hash_param,
))
}),
})
}
-fn gen_prototype(arg_count: usize, trailing_options: usize, had_list_param: bool) -> String {
- let arg_count = arg_count - trailing_options - (had_list_param as usize);
+fn gen_prototype(
+ arg_count: usize,
+ trailing_options: usize,
+ had_list_param: bool,
+ had_hash_param: bool,
+) -> String {
+ let arg_count =
+ arg_count - trailing_options - (had_list_param as usize) - (had_hash_param as usize);
let mut proto = String::with_capacity(arg_count + trailing_options + 1);
@@ -426,8 +483,13 @@ fn gen_prototype(arg_count: usize, trailing_options: usize, had_list_param: bool
if had_list_param {
proto.push('@');
}
+ if had_hash_param {
+ proto.push('%');
+ }
} else if had_list_param {
proto.push_str(";@");
+ } else if had_hash_param {
+ proto.push_str(";%");
}
proto
}
diff --git a/perlmod/src/de.rs b/perlmod/src/de.rs
index cf1601f..1355d0c 100644
--- a/perlmod/src/de.rs
+++ b/perlmod/src/de.rs
@@ -782,3 +782,47 @@ impl<'de> MapAccess<'de> for RawDeserializer<'_> {
}
}
}
+
+pub struct ParamsIter<I> {
+ inner: I,
+}
+
+impl<I> ParamsIter<I>
+where
+ I: Iterator<Item = Value> + ExactSizeIterator,
+{
+ pub fn new<T>(into_iter: T) -> Result<Self, Error>
+ where
+ T: IntoIterator<Item = I::Item, IntoIter = I>,
+ {
+ let iter = into_iter.into_iter();
+
+ if iter.len() % 2 != 0 {
+ Err(Error::new(
+ "odd number of elements for parameter hash - must be even",
+ ))
+ } else {
+ Ok(Self { inner: iter })
+ }
+ }
+}
+
+impl<I> Iterator for ParamsIter<I>
+where
+ I: Iterator<Item = Value> + ExactSizeIterator,
+{
+ type Item = (Value, Value);
+
+ fn next(&mut self) -> Option<Self::Item> {
+ if let Some(key) = self.inner.next() {
+ let value = self
+ .inner
+ .next()
+ .expect("expected value for key - odd number of elements in params iterator");
+
+ Some((key, value))
+ } else {
+ None
+ }
+ }
+}
diff --git a/perlmod/src/lib.rs b/perlmod/src/lib.rs
index 83b9d8b..2dd1eb2 100644
--- a/perlmod/src/lib.rs
+++ b/perlmod/src/lib.rs
@@ -119,6 +119,11 @@ pub use perlmod_macro::package;
/// `serde::de::value::SeqDeserializer`.) This causes the prototype to end with `;@` (behaves
/// correctly with `Option` parameters in front of it).
///
+/// * `#[hash]`: Like `#[list]`, but instead of deserializing from a sequence, the final parameter
+/// needs to be able to deserialize from a map. (Technically just adapts Perl's argument stack
+/// into a `serde::de::value::MapDeserializer`.) This causes the prototype to end with `;%`
+/// (behaves correctly with `Option` parameters in front of it).
+///
/// For an example on making blessed objects, see [`Value::bless_box`](Value::bless_box()).
pub use perlmod_macro::export;
diff --git a/testlib-tests/01-hello.t b/testlib-tests/01-hello.t
index 8af0d5d..5c757ea 100644
--- a/testlib-tests/01-hello.t
+++ b/testlib-tests/01-hello.t
@@ -1,6 +1,6 @@
use v5.36;
-use Test::More tests => 16;
+use Test::More tests => 24;
use TestLib::Hello;
@@ -47,3 +47,47 @@ is(
'final option unset, empty list',
);
is(TestLib::Hello::sum_list(50, 51, 52), 153, 'pass a deserialized list');
+
+is(
+ TestLib::Hello::trailing_hash(60, 61 => 62),
+ 'first=60, rest is {61: 62}',
+ 'collecting 1 trailing key-value pair',
+);
+is(
+ TestLib::Hello::trailing_hash_ordered(63, 64 => 65, 66 => 67),
+ 'first=63, rest is {64: 65, 66: 67}',
+ 'collecting 2 trailing key-value pairs',
+);
+is(
+ TestLib::Hello::trailing_hash(67),
+ 'first=67, rest is {}',
+ 'collecting 0 trailing key-value pairs',
+);
+
+is(
+ eval { TestLib::Hello::trailing_hash(68, 69) } // $@,
+ "error: odd number of elements for parameter hash - must be even\n",
+ 'failing to collect odd number of trailing hash parameters',
+);
+
+is(
+ TestLib::Hello::trailing_hash_and_options(80),
+ 'first=80, second=None, rest is {}',
+ 'final option unset, collecting 0 trailing key-value pairs',
+);
+is(
+ TestLib::Hello::trailing_hash_and_options(81, 82),
+ 'first=81, second=Some(82), rest is {}',
+ 'final option set, collecting 0 trailing key-value pairs',
+);
+is(
+ TestLib::Hello::trailing_hash_and_options(81, 82, 83 => 84),
+ 'first=81, second=Some(82), rest is {83: 84}',
+ 'final option set, collecting 1 trailing key-value pairs',
+);
+
+is(
+ eval { TestLib::Hello::trailing_hash_and_options(85, 86, 87) } // $@,
+ "error: odd number of elements for parameter hash - must be even\n",
+ 'final option set, collecting 1 trailing key-value pairs',
+);
diff --git a/testlib/src/lib.rs b/testlib/src/lib.rs
index ac3bfde..81879cc 100644
--- a/testlib/src/lib.rs
+++ b/testlib/src/lib.rs
@@ -12,7 +12,10 @@ mod main_lib {}
#[perlmod::package(name = "TestLib::Hello", lib = "testlib", boot = "loaded")]
mod export {
- use std::sync::atomic::{AtomicBool, Ordering};
+ use std::{
+ collections::{BTreeMap, HashMap},
+ sync::atomic::{AtomicBool, Ordering},
+ };
use anyhow::{Error, bail};
use serde::{Deserialize, Serialize};
@@ -137,4 +140,23 @@ mod export {
fn sum_list(#[list] rest: Vec<u32>) -> u32 {
rest.into_iter().sum()
}
+
+ #[export]
+ fn trailing_hash(first: u32, #[hash] rest: HashMap<u32, u32>) -> String {
+ format!("first={first}, rest is {rest:?}")
+ }
+
+ #[export]
+ fn trailing_hash_ordered(first: u32, #[hash] rest: BTreeMap<u32, u32>) -> String {
+ format!("first={first}, rest is {rest:?}")
+ }
+
+ #[export]
+ fn trailing_hash_and_options(
+ first: u32,
+ second: Option<u32>,
+ #[hash] rest: HashMap<u32, u32>,
+ ) -> String {
+ format!("first={first}, second={second:?}, rest is {rest:?}")
+ }
}
--
2.47.3
^ permalink raw reply related [flat|nested] 5+ messages in thread
* [PATCH perlmod 4/4] testlib: run perltidy
2026-07-20 15:25 [PATCH perlmod 0/4] perlmod: add #[hash] parameter Max R. Carrara
` (2 preceding siblings ...)
2026-07-20 15:25 ` [PATCH perlmod 3/4] perlmod, macro: add #[hash] parameter Max R. Carrara
@ 2026-07-20 15:25 ` Max R. Carrara
3 siblings, 0 replies; 5+ messages in thread
From: Max R. Carrara @ 2026-07-20 15:25 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
testlib-tests/01-hello.t | 24 ++++++++++++++++++++----
1 file changed, 20 insertions(+), 4 deletions(-)
diff --git a/testlib-tests/01-hello.t b/testlib-tests/01-hello.t
index 5c757ea..9770525 100644
--- a/testlib-tests/01-hello.t
+++ b/testlib-tests/01-hello.t
@@ -12,13 +12,29 @@ is($x, 17, "first multi_return value should be 17");
is($y, 32, "second multi_return value should be 32");
my $param = { a => 1 };
-is(TestLib::Hello::opt_string($param->{x}), "Called with None.", "non-existent element passed to Option<String>");
+is(
+ TestLib::Hello::opt_string($param->{x}),
+ "Called with None.",
+ "non-existent element passed to Option<String>",
+);
ok(!exists($param->{x}), "param->{x} was not auto-vivified");
-is(TestLib::Hello::opt_str($param->{x}), "Called with None.", "non-existent element passed to Option<&str>");
+is(
+ TestLib::Hello::opt_str($param->{x}),
+ "Called with None.",
+ "non-existent element passed to Option<&str>",
+);
ok(!exists($param->{x}), "param->{x} was not auto-vivified (2)");
-is(TestLib::Hello::trailing_optional(1, 99), '1, Some(99)', 'passing value for trailing optional parameter');
-is(TestLib::Hello::trailing_optional(2, undef), '2, None', 'passing undef for trailing optional parameter');
+is(
+ TestLib::Hello::trailing_optional(1, 99),
+ '1, Some(99)',
+ 'passing value for trailing optional parameter',
+);
+is(
+ TestLib::Hello::trailing_optional(2, undef),
+ '2, None',
+ 'passing undef for trailing optional parameter',
+);
is(TestLib::Hello::trailing_optional(3), '3, None', 'skipping trailing optional parameter');
is(
--
2.47.3
^ permalink raw reply related [flat|nested] 5+ messages in thread
end of thread, other threads:[~2026-07-20 15:26 UTC | newest]
Thread overview: 5+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-07-20 15:25 [PATCH perlmod 0/4] perlmod: add #[hash] parameter Max R. Carrara
2026-07-20 15:25 ` [PATCH perlmod 1/4] ffi: implement ExactSizeIterator for StackIter Max R. Carrara
2026-07-20 15:25 ` [PATCH perlmod 2/4] value: implement serde::de::IntoDeserializer on Value Max R. Carrara
2026-07-20 15:25 ` [PATCH perlmod 3/4] perlmod, macro: add #[hash] parameter Max R. Carrara
2026-07-20 15:25 ` [PATCH perlmod 4/4] testlib: run perltidy Max R. Carrara
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox