Finish host function macro

This commit is contained in:
Sergey Kuznetsov
2026-07-28 13:48:25 +01:00
parent c4ce52c810
commit 94ed8e2f49
4 changed files with 379 additions and 11 deletions

View File

@@ -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<TokenStream> {
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<syn::Error> {
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<String> {
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:?}"
);
}
}

View File

@@ -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<Attribute>,
/// 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<Self> {
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<T>(slot: &mut Option<T>, value: T, attr: &Attribute) -> syn::Result<()> {
if slot.replace(value).is_some() {
@@ -125,12 +200,12 @@ fn int_value(attr: &Attribute) -> syn::Result<u64> {
}
}
fn string_value(attr: &Attribute) -> syn::Result<String> {
fn string_value(attr: &Attribute) -> syn::Result<LitStr> {
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! {

View File

@@ -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.
///

View File

@@ -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<String>,
}
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<u8> {
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);
}