From 94ed8e2f49aa67a7e9347bd2bd5894d0a9232adc Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Tue, 28 Jul 2026 13:48:25 +0100 Subject: [PATCH] Finish host function macro --- crates/xrpl-host-functions-macros/src/lib.rs | 152 +++++++++++++++++- .../src/parsed_host_function.rs | 140 +++++++++++++++- crates/xrpl-host-functions/src/lib.rs | 6 +- .../tests/generated_abi.rs | 92 +++++++++++ 4 files changed, 379 insertions(+), 11 deletions(-) create mode 100644 crates/xrpl-host-functions/tests/generated_abi.rs diff --git a/crates/xrpl-host-functions-macros/src/lib.rs b/crates/xrpl-host-functions-macros/src/lib.rs index 0bccdf10df..08f49bc6ca 100644 --- a/crates/xrpl-host-functions-macros/src/lib.rs +++ b/crates/xrpl-host-functions-macros/src/lib.rs @@ -1,6 +1,8 @@ mod errors; mod parsed_host_function; +use std::collections::HashSet; + use proc_macro2::TokenStream; use quote::quote; use syn::{ @@ -32,20 +34,86 @@ fn expand(input: TokenStream) -> syn::Result { if let Some(error) = errors::combine(errors) { return Err(error); } + if let Some(error) = errors::combine(collisions(&parsed)) { + return Err(error); + } - Ok(quote! { - trait HostFunctions { + Ok(generate(&parsed)) +} +/// Names two declarations may not share, because the generated code would then +/// fail to compile at a span the caller cannot see. +fn collisions(functions: &[ParsedHostFunction]) -> Vec { + let mut errors = Vec::new(); + let mut variants = HashSet::new(); + let mut wasm_names = HashSet::new(); + + for function in functions { + if !variants.insert(function.variant.to_string()) { + errors.push(syn::Error::new_spanned( + &function.variant, + format!( + "another host function already becomes the `{}` variant", + function.variant + ), + )); + } + if !wasm_names.insert(function.wasm_name.value()) { + errors.push(syn::Error::new_spanned( + &function.wasm_name, + format!( + "another host function is already imported as `{}`", + function.wasm_name.value() + ), + )); + } + } + + errors +} + +fn generate(functions: &[ParsedHostFunction]) -> TokenStream { + let trait_methods = functions.iter().map(ParsedHostFunction::trait_method); + let variants = functions + .iter() + .map(ParsedHostFunction::variant_declaration); + let spec_arms = functions.iter().map(ParsedHostFunction::spec_arm); + let all = functions.iter().map(|function| &function.variant); + + quote! { + /// The host ABI: one method per function a guest may import. + pub trait HostFunctions { + #(#trait_methods)* } - enum HostFunctionSpec { - + /// Identifies a host function, and carries its ABI metadata. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum HostFunctionSpec { + #(#variants,)* } impl HostFunctionSpec { + /// Every host function, in declaration order. + pub const ALL: &'static [Self] = &[#(Self::#all,)*]; + /// The wasm import name and base gas cost of this function. + pub const fn spec(self) -> HostFnSpec { + match self { + #(#spec_arms,)* + } + } + + /// The name a guest imports this function under. + pub const fn wasm_name(self) -> &'static str { + self.spec().name + } + + /// The consensus-fixed base gas charged before the call runs. + pub const fn gas(self) -> u64 { + self.spec().base_gas + } } - }) + } } struct HostFunctionsInput { @@ -93,4 +161,78 @@ mod tests { let error = expand(quote! { fn missing_semicolon() }).expect_err("expected a syntax error"); assert!(!error.to_string().is_empty()); } + + /// The messages of every diagnostic recorded by one failed `expand`. + fn messages(input: TokenStream) -> Vec { + let Err(error) = expand(input) else { + panic!("expected expansion to fail"); + }; + error.into_iter().map(|error| error.to_string()).collect() + } + + #[test] + fn generates_the_trait_the_enum_and_the_table() { + let generated = expand(quote! { + #[gas = 60] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn() -> [u8; 4]; + + #[gas = 500] + #[wasm_name = "trace_num"] + fn trace_num(msg: &str, number: i64); + }) + .unwrap() + .to_string(); + + for expected in [ + "pub trait HostFunctions", + "fn get_ledger_sqn (& mut self) -> [u8 ; 4] ;", + "fn trace_num (& mut self , msg : & str , number : i64) ;", + "pub enum HostFunctionSpec { GetLedgerSqn , TraceNum , }", + "pub const ALL : & 'static [Self] = & [Self :: GetLedgerSqn , Self :: TraceNum ,]", + "pub const fn spec (self) -> HostFnSpec", + "Self :: GetLedgerSqn => HostFnSpec { name : \"ldgr_index\" , base_gas : 60u64 }", + ] { + assert!(generated.contains(expected), "missing {expected:?}"); + } + } + + #[test] + fn rejects_two_functions_that_share_a_wasm_name() { + let messages = messages(quote! { + #[gas = 60] + #[wasm_name = "trace"] + fn trace(msg: &str); + + #[gas = 70] + #[wasm_name = "trace"] + fn trace_num(msg: &str, number: i64); + }); + + assert_eq!(messages.len(), 1, "{messages:?}"); + assert!( + messages[0].contains("already imported as `trace`"), + "{messages:?}" + ); + } + + /// Names that differ only in underscores collapse to one enum variant. + #[test] + fn rejects_two_functions_that_share_a_variant() { + let messages = messages(quote! { + #[gas = 60] + #[wasm_name = "a"] + fn get_ledger_sqn() -> [u8; 4]; + + #[gas = 70] + #[wasm_name = "b"] + fn get_ledger__sqn() -> [u8; 4]; + }); + + assert_eq!(messages.len(), 1, "{messages:?}"); + assert!( + messages[0].contains("`GetLedgerSqn` variant"), + "{messages:?}" + ); + } } diff --git a/crates/xrpl-host-functions-macros/src/parsed_host_function.rs b/crates/xrpl-host-functions-macros/src/parsed_host_function.rs index c79fdeb1c4..f9c97c16f1 100644 --- a/crates/xrpl-host-functions-macros/src/parsed_host_function.rs +++ b/crates/xrpl-host-functions-macros/src/parsed_host_function.rs @@ -1,4 +1,6 @@ -use syn::{Attribute, Expr, ExprLit, Lit, Signature, TraitItemFn}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{Attribute, Expr, ExprLit, Ident, Lit, LitStr, Signature, TraitItemFn, parse_quote}; use crate::errors; @@ -12,13 +14,55 @@ const DOC: &str = "doc"; /// One entry of a `host_functions!` block: its ABI metadata and its signature. pub(crate) struct ParsedHostFunction { pub(crate) gas: u64, - pub(crate) wasm_name: String, + /// Kept as the literal the user wrote, so diagnostics and the generated + /// string both carry that span. + pub(crate) wasm_name: LitStr, /// Doc comments, in source order, to re-emit on the generated items. pub(crate) docs: Vec, + /// The enum variant this declaration becomes, spanned at the function name. + pub(crate) variant: Ident, pub(crate) signature: Signature, } impl ParsedHostFunction { + /// `#[doc …] fn get_ledger_sqn(&mut self) -> [u8; 4];` + pub(crate) fn trait_method(&self) -> TokenStream { + let docs = &self.docs; + + // The receiver is not part of the wasm ABI, so declarations omit it and + // only the trait needs one. + let mut signature = self.signature.clone(); + signature.inputs.insert(0, parse_quote!(&mut self)); + + quote! { + #(#docs)* + #signature; + } + } + + /// `#[doc …] GetLedgerSqn` + pub(crate) fn variant_declaration(&self) -> TokenStream { + let docs = &self.docs; + let variant = &self.variant; + quote! { + #(#docs)* + #variant + } + } + + /// `Self::GetLedgerSqn => HostFnSpec { name: "ldgr_index", base_gas: 60u64 }` + pub(crate) fn spec_arm(&self) -> TokenStream { + let Self { + gas, + wasm_name, + variant, + .. + } = self; + quote! { + Self::#variant => HostFnSpec { name: #wasm_name, base_gas: #gas } + } + } + pub(crate) fn parse(function: TraitItemFn) -> syn::Result { let mut gas = None; let mut wasm_name = None; @@ -97,11 +141,42 @@ impl ParsedHostFunction { gas, wasm_name, docs, + variant: variant_ident(&function.sig.ident), signature: function.sig, }) } } +/// The enum variant a declaration becomes: `get_ledger_sqn` -> `GetLedgerSqn`. +/// +/// The result carries `ident`'s span, so anything the compiler says about the +/// variant points at the declaration that produced it. +fn variant_ident(ident: &Ident) -> Ident { + // `to_string` spells raw identifiers `r#type`; the `r#` is not part of the name. + let name = ident.to_string(); + let name = name.strip_prefix("r#").unwrap_or(&name); + + let mut pascal = String::with_capacity(name.len()); + let mut capitalize = true; + for character in name.chars() { + if character == '_' { + capitalize = true; + } else if capitalize { + pascal.extend(character.to_uppercase()); + capitalize = false; + } else { + pascal.push(character); + } + } + + // A name of nothing but underscores would leave `pascal` empty, and + // `format_ident!` panics on an invalid identifier. + if pascal.is_empty() { + return ident.clone(); + } + format_ident!("{pascal}", span = ident.span()) +} + /// Records `value`, or reports that the attribute appeared more than once. fn set_once(slot: &mut Option, value: T, attr: &Attribute) -> syn::Result<()> { if slot.replace(value).is_some() { @@ -125,12 +200,12 @@ fn int_value(attr: &Attribute) -> syn::Result { } } -fn string_value(attr: &Attribute) -> syn::Result { +fn string_value(attr: &Attribute) -> syn::Result { match &attr.meta.require_name_value()?.value { Expr::Lit(ExprLit { lit: Lit::Str(string), .. - }) => Ok(string.value()), + }) => Ok(string.clone()), other => Err(syn::Error::new_spanned( other, format!("`{}` expects a string literal", path_name(attr)), @@ -184,11 +259,66 @@ mod tests { .unwrap(); assert_eq!(parsed.gas, 60); - assert_eq!(parsed.wasm_name, "ldgr_index"); + assert_eq!(parsed.wasm_name.value(), "ldgr_index"); assert_eq!(parsed.signature.ident.to_string(), "get_ledger_sqn"); + assert_eq!(parsed.variant.to_string(), "GetLedgerSqn"); assert!(parsed.docs.is_empty()); } + #[test] + fn derives_variant_names_from_function_names() { + for (function, variant) in [ + ("get_ledger_sqn", "GetLedgerSqn"), + ("sha512_half", "Sha512Half"), + ("trace", "Trace"), + ("get_current_ledger_obj_field", "GetCurrentLedgerObjField"), + ("r#type", "Type"), + // Pathological, but must not panic: no letters to capitalize. + ("__", "__"), + ] { + let ident = format_ident!("{function}"); + assert_eq!(variant_ident(&ident).to_string(), variant); + } + } + + #[test] + fn trait_method_takes_a_receiver_and_ends_in_a_semicolon() { + let parsed = ParsedHostFunction::parse(parse_quote! { + /// Hashes `data`. + #[gas = 2000] + #[wasm_name = "sha512_half"] + fn sha512_half(data: &[u8]) -> [u8; 32]; + }) + .unwrap(); + + // `///` reaches the macro as `#[doc = r"..."]`: rustc's lexer spells doc + // comments as raw string literals. + let method = parsed.trait_method().to_string(); + assert!( + method.starts_with("# [doc = r\" Hashes `data`.\"]"), + "{method}" + ); + assert!( + method.contains("fn sha512_half (& mut self , data : & [u8]) -> [u8 ; 32] ;"), + "{method}" + ); + } + + #[test] + fn spec_arm_carries_the_name_and_the_gas() { + let parsed = ParsedHostFunction::parse(parse_quote! { + #[gas = 60] + #[wasm_name = "ldgr_index"] + fn get_ledger_sqn() -> [u8; 4]; + }) + .unwrap(); + + assert_eq!( + parsed.spec_arm().to_string(), + "Self :: GetLedgerSqn => HostFnSpec { name : \"ldgr_index\" , base_gas : 60u64 }" + ); + } + #[test] fn keeps_doc_comments_in_source_order() { let parsed = ParsedHostFunction::parse(parse_quote! { diff --git a/crates/xrpl-host-functions/src/lib.rs b/crates/xrpl-host-functions/src/lib.rs index 73a2deecbc..43cf5d921e 100644 --- a/crates/xrpl-host-functions/src/lib.rs +++ b/crates/xrpl-host-functions/src/lib.rs @@ -1,5 +1,9 @@ #![no_std] -use xrpl_host_functions_macros::host_abi; +extern crate alloc; + +use alloc::vec::Vec; + +use xrpl_host_functions_macros::host_functions; /// Error codes a host function may return. /// diff --git a/crates/xrpl-host-functions/tests/generated_abi.rs b/crates/xrpl-host-functions/tests/generated_abi.rs new file mode 100644 index 0000000000..033db2a538 --- /dev/null +++ b/crates/xrpl-host-functions/tests/generated_abi.rs @@ -0,0 +1,92 @@ +//! Exercises what `host_functions!` generates: the trait is implementable and +//! the spec table agrees with the declarations in `src/lib.rs`. + +use xrpl_host_functions::{HASH_LEN, HostFnSpec, HostFunctionSpec, HostFunctions}; + +/// Records what it was asked to do; enough to prove the trait is usable. +#[derive(Default)] +struct FakeHost { + traced: Vec, +} + +impl HostFunctions for FakeHost { + fn get_ledger_sqn(&mut self) -> [u8; 4] { + 7u32.to_le_bytes() + } + + fn get_current_ledger_obj_field(&mut self, field: i32) -> Vec { + vec![field as u8] + } + + fn sha512_half(&mut self, data: &[u8]) -> [u8; HASH_LEN] { + let mut digest = [0; HASH_LEN]; + digest[0] = data.len() as u8; + digest + } + + fn trace(&mut self, msg: &str, data: &[u8], as_hex: bool) { + self.traced.push(format!("{msg}/{}/{as_hex}", data.len())); + } + + fn trace_num(&mut self, msg: &str, number: i64) { + self.traced.push(format!("{msg}={number}")); + } +} + +#[test] +fn the_trait_is_implementable() { + let mut host = FakeHost::default(); + + assert_eq!(host.get_ledger_sqn(), [7, 0, 0, 0]); + assert_eq!(host.get_current_ledger_obj_field(3), vec![3]); + assert_eq!(host.sha512_half(b"abc")[0], 3); + host.trace("hello", b"xy", true); + host.trace_num("count", -1); + + assert_eq!(host.traced, ["hello/2/true", "count=-1"]); +} + +#[test] +fn the_spec_table_matches_the_declarations() { + assert_eq!(HostFunctionSpec::ALL.len(), 5); + assert_eq!( + HostFunctionSpec::GetLedgerSqn.spec(), + HostFnSpec { + name: "ldgr_index", + base_gas: 60 + } + ); + assert_eq!(HostFunctionSpec::Sha512Half.gas(), 2000); + assert_eq!( + HostFunctionSpec::GetCurrentLedgerObjField.wasm_name(), + "home_le_field" + ); +} + +/// `ALL` is what a wasm engine iterates to register imports, so it must be complete. +#[test] +fn every_variant_appears_in_all_exactly_once() { + let mut names: Vec<&str> = HostFunctionSpec::ALL + .iter() + .map(|function| function.wasm_name()) + .collect(); + names.sort_unstable(); + + assert_eq!( + names, + [ + "home_le_field", + "ldgr_index", + "sha512_half", + "trace", + "trace_num" + ] + ); +} + +/// The generated `spec` is `const`, so gas costs are available at compile time. +#[test] +fn the_table_is_usable_in_const_context() { + const TRACE_GAS: u64 = HostFunctionSpec::Trace.gas(); + assert_eq!(TRACE_GAS, 500); +}