From 2688c37d0a24cebab04316f3f02baebbe709a80c Mon Sep 17 00:00:00 2001 From: Zack Maril Date: Tue, 18 Aug 2026 07:49:35 +0000 Subject: [PATCH] #[jedem::export] on a bare fn or a mod, not only an impl Item 6. A crate exporting free functions had to invent a type for them to hang off -- `pub struct Jawohl;` in jawohl's case, conveying nothing and existing only because the macro demanded an impl block. Every consumer was writing one. The attribute now accepts three forms, and all three capture the same things because they share one lowering path: #[jedem::export] pub fn greet(name: &str) -> String { ... } #[jedem::export] pub mod arithmetic { pub fn add(a: i64, b: i64) -> i64 { ... } } #[jedem::export] impl Greeter { pub fn greet(name: &str) -> String { ... } } The design problem was making `surface! { api: [...] }` read the same for all three. An impl exposes an associated `Type::JEDEM_INTERFACE`, and a first attempt tried to have `surface!` resolve between differently-named constants -- which Rust macros cannot do, since there is no "try this path, else that one". The fix uses a property of the language rather than fighting it: Rust keeps modules and functions in SEPARATE NAMESPACES, so a module named after a function can carry that function's descriptor without shadowing it. A bare `fn greet` now emits a hidden `mod greet` holding `JEDEM_INTERFACE`, a `mod` gets the constant injected into its own body, and an impl keeps its associated constant. All three are then reached identically as `Path::JEDEM_INTERFACE`, so the surface list is uniform and no resolution is needed. The op-lowering logic that was inline in the impl expansion is extracted to a shared `lower_fn`, so the three forms cannot drift apart in what they capture -- doc comments, name pins, borrowed parameters, inferred fallibility. Private functions in an exported module are skipped rather than exported, and a `mod foo;` declaration without a body is a clear error rather than silence. Six tests, including one asserting all three forms generate correct call paths in the same surface: a bare fn calls `core::shout`, a mod `core::arithmetic::add`, a type `core::Hello::greet`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HsDxLrGdx6nPaXkVEWkNvS --- crates/jedem-macros/src/lib.rs | 271 +++++++++++++++++++++++-------- crates/jedem/src/lib.rs | 18 +- demo/hello/tests/export_forms.rs | 105 ++++++++++++ 3 files changed, 325 insertions(+), 69 deletions(-) create mode 100644 demo/hello/tests/export_forms.rs diff --git a/crates/jedem-macros/src/lib.rs b/crates/jedem-macros/src/lib.rs index 7d78f85..d973e11 100644 --- a/crates/jedem-macros/src/lib.rs +++ b/crates/jedem-macros/src/lib.rs @@ -6,23 +6,145 @@ use proc_macro::TokenStream; use quote::{format_ident, quote}; use syn::{ - parse_macro_input, spanned::Spanned, FnArg, ImplItem, ItemImpl, Lit, Meta, ReturnType, Type, + parse_macro_input, spanned::Spanned, FnArg, ImplItem, Item, ItemFn, ItemImpl, ItemMod, Lit, + Meta, ReturnType, Type, }; -/// Mark an `impl` block for export. +/// Mark functions for export. /// -/// The block is emitted unchanged — the functions you wrote are the functions -/// that run — alongside a constant describing them. Drift between declaration -/// and implementation is impossible because there is only one artefact. +/// Accepts an `impl` block, a `mod`, or a single `fn`: +/// +/// ```ignore +/// #[jedem::export] +/// pub fn greet(name: &str) -> String { … } +/// +/// #[jedem::export] +/// mod api { +/// pub fn greet(name: &str) -> String { … } +/// } +/// +/// #[jedem::export] +/// impl Greeter { pub fn greet(name: &str) -> String { … } } +/// ``` +/// +/// Whatever the form, what you wrote is emitted unchanged — the functions you +/// wrote are the functions that run — alongside a constant describing them. +/// Drift between declaration and implementation is impossible because there is +/// only one artefact. +/// +/// A bare `fn` or a `mod` exists so that a crate exporting free functions does +/// not have to invent a type for them to hang off. `pub struct Api;` conveys +/// nothing, and every consumer was writing one. #[proc_macro_attribute] pub fn export(_attr: TokenStream, item: TokenStream) -> TokenStream { - let input = parse_macro_input!(item as ItemImpl); - match expand_export(&input) { + let parsed = parse_macro_input!(item as Item); + let expanded = match &parsed { + Item::Impl(i) => expand_export(i), + Item::Fn(f) => expand_fn(f), + Item::Mod(m) => expand_mod(m), + other => Err(syn::Error::new( + other.span(), + "#[jedem::export] goes on an `impl` block, a `mod`, or a `fn`", + )), + }; + match expanded { Ok(ts) => ts.into(), Err(e) => e.to_compile_error().into(), } } +/// A single exported function. The interface is named after the function, and +/// the surface lists it directly. +fn expand_fn(f: &ItemFn) -> syn::Result { + let op = lower_fn(&f.sig, &f.attrs, &f.vis, "")?.ok_or_else(|| { + syn::Error::new( + f.sig.ident.span(), + "#[jedem::export] on a function needs it to be `pub`", + ) + })?; + let name = f.sig.ident.to_string(); + let ident = &f.sig.ident; + let doc = opt_str(doc_of(&f.attrs).as_deref()); + let cleaned = strip_jedem_attrs_fn(f); + // Rust keeps modules and functions in separate namespaces, so a module + // named after the function can carry its descriptor without shadowing it. + // That is what lets `surface! { api: [greet] }` read the same for a bare + // function as for a type. + Ok(quote! { + #cleaned + + #[doc(hidden)] + pub mod #ident { + /// The jedem descriptor for the function of the same name. + pub const JEDEM_INTERFACE: &'static ::jedem::Interface = &::jedem::Interface { + name: #name, + doc: #doc, + ops: &[#op], + }; + } + }) +} + +/// Every public function in a module, as one interface named after the module. +fn expand_mod(m: &ItemMod) -> syn::Result { + let Some((_, items)) = &m.content else { + return Err(syn::Error::new( + m.span(), + "#[jedem::export] needs the module's body, not a `mod foo;` declaration", + )); + }; + let mod_name = m.ident.to_string(); + let mut ops = Vec::new(); + for item in items { + if let Item::Fn(f) = item { + if let Some(op) = lower_fn(&f.sig, &f.attrs, &f.vis, &format!("{mod_name}::"))? { + ops.push(op); + } + } + } + if ops.is_empty() { + return Err(syn::Error::new( + m.span(), + "#[jedem::export] found no public functions in this module", + )); + } + let doc = opt_str(doc_of(&m.attrs).as_deref()); + let mut cleaned = strip_jedem_attrs_mod(m); + // The descriptor goes inside the module, so it is reached the same way a + // type's is: `mymod::JEDEM_INTERFACE`. + let holder: syn::Item = syn::parse_quote! { + /// The jedem descriptor for this module. + #[doc(hidden)] + pub const JEDEM_INTERFACE: &'static ::jedem::Interface = &::jedem::Interface { + name: #mod_name, + doc: #doc, + ops: &[#(#ops),*], + }; + }; + if let Some((_, items)) = &mut cleaned.content { + items.push(holder); + } + Ok(quote! { #cleaned }) +} + +fn strip_jedem_attrs_fn(f: &ItemFn) -> ItemFn { + let mut out = f.clone(); + out.attrs.retain(|a| !a.path().is_ident("jedem")); + out +} + +fn strip_jedem_attrs_mod(m: &ItemMod) -> ItemMod { + let mut out = m.clone(); + if let Some((_, items)) = &mut out.content { + for item in items { + if let Item::Fn(f) = item { + f.attrs.retain(|a| !a.path().is_ident("jedem")); + } + } + } + out +} + fn expand_export(input: &ItemImpl) -> syn::Result { // An attribute macro receives the item with its own helper attributes // still attached, and must strip them before re-emitting -- unlike a @@ -54,11 +176,6 @@ fn expand_export(input: &ItemImpl) -> syn::Result { let mut ops = Vec::new(); for item in &input.items { let ImplItem::Fn(f) = item else { continue }; - if !matches!(f.vis, syn::Visibility::Public(_)) { - continue; - } - // v1 exports free functions: an associated fn with no receiver. A - // method needs a handle to hang itself on, which is beyond v1. if let Some(FnArg::Receiver(r)) = f.sig.inputs.first() { return Err(syn::Error::new( r.span(), @@ -67,60 +184,9 @@ fn expand_export(input: &ItemImpl) -> syn::Result { Make it an associated function, or remove it from the exported impl.", )); } - if f.sig.asyncness.is_some() { - return Err(syn::Error::new( - f.sig.asyncness.span(), - "jedem v1 is synchronous; async is not lowered yet", - )); - } - - let name = f.sig.ident.to_string(); - let doc = doc_of(&f.attrs); - let export_name = export_name_of(&f.attrs)?; - - let mut params = Vec::new(); - for arg in &f.sig.inputs { - let FnArg::Typed(pt) = arg else { continue }; - let pname = match &*pt.pat { - syn::Pat::Ident(i) => i.ident.to_string(), - other => { - return Err(syn::Error::new( - other.span(), - "jedem needs a plain parameter name", - )) - } - }; - let ty = lower_type(&pt.ty)?; - let borrowed = matches!(&*pt.ty, Type::Reference(_)); - params.push((pname, ty, borrowed)); + if let Some(op) = lower_fn(&f.sig, &f.attrs, &f.vis, &format!("{type_name}::"))? { + ops.push(op); } - - let (returns, fallible) = match &f.sig.output { - ReturnType::Default => (quote!(::jedem::Type::Unit), false), - ReturnType::Type(_, t) => match unwrap_result(t) { - Some(inner) => (lower_type(inner)?, true), - None => (lower_type(t)?, false), - }, - }; - - let rust_path = format!("{type_name}::{name}"); - let doc_tok = opt_str(doc.as_deref()); - let export_tok = opt_str(export_name.as_deref()); - let param_toks = params.iter().map(|(n, t, b)| { - quote! { ::jedem::Param { name: #n, ty: #t, borrowed: #b } } - }); - - ops.push(quote! { - ::jedem::Op { - name: #name, - doc: #doc_tok, - export_name: #export_tok, - params: &[#(#param_toks),*], - returns: #returns, - fallible: #fallible, - rust_path: #rust_path, - } - }); } if ops.is_empty() { @@ -164,8 +230,10 @@ pub fn surface(input: TokenStream) -> TokenStream { let decl = parse_macro_input!(input as SurfaceDecl); let name = decl.name; let version = decl.version; - let types = decl.api; - let refs = types.iter().map(|t| quote! { <#t>::JEDEM_INTERFACE }); + // Every form `#[jedem::export]` accepts exposes the interface at the same + // place -- `Path::JEDEM_INTERFACE` -- so `api:` reads uniformly whether the + // entry names a type, a module, or a bare function. + let refs = decl.api.iter().map(|t| quote! { #t::JEDEM_INTERFACE }); quote! { /// The jedem surface for this crate. pub const JEDEM_SURFACE: &'static ::jedem::Surface = &::jedem::Surface { @@ -220,6 +288,75 @@ impl syn::parse::Parse for SurfaceDecl { } } +/// Turn one function signature into an `Op`, or `None` when it is not public. +/// +/// Shared by all three forms `#[jedem::export]` accepts, so an `impl` block, a +/// `mod` and a bare `fn` cannot drift apart in what they capture. +fn lower_fn( + sig: &syn::Signature, + attrs: &[syn::Attribute], + vis: &syn::Visibility, + path_prefix: &str, +) -> syn::Result> { + if !matches!(vis, syn::Visibility::Public(_)) { + return Ok(None); + } + if sig.asyncness.is_some() { + return Err(syn::Error::new( + sig.asyncness.span(), + "jedem v1 is synchronous; async is not lowered yet", + )); + } + + let name = sig.ident.to_string(); + let doc = doc_of(attrs); + let export_name = export_name_of(attrs)?; + + let mut params = Vec::new(); + for arg in &sig.inputs { + let FnArg::Typed(pt) = arg else { continue }; + let pname = match &*pt.pat { + syn::Pat::Ident(i) => i.ident.to_string(), + other => { + return Err(syn::Error::new( + other.span(), + "jedem needs a plain parameter name", + )) + } + }; + let ty = lower_type(&pt.ty)?; + let borrowed = matches!(&*pt.ty, Type::Reference(_)); + params.push((pname, ty, borrowed)); + } + + let (returns, fallible) = match &sig.output { + ReturnType::Default => (quote!(::jedem::Type::Unit), false), + ReturnType::Type(_, t) => match unwrap_result(t) { + Some(inner) => (lower_type(inner)?, true), + None => (lower_type(t)?, false), + }, + }; + + let rust_path = format!("{path_prefix}{name}"); + let doc_tok = opt_str(doc.as_deref()); + let export_tok = opt_str(export_name.as_deref()); + let param_toks = params.iter().map(|(n, t, b)| { + quote! { ::jedem::Param { name: #n, ty: #t, borrowed: #b } } + }); + + Ok(Some(quote! { + ::jedem::Op { + name: #name, + doc: #doc_tok, + export_name: #export_tok, + params: &[#(#param_toks),*], + returns: #returns, + fallible: #fallible, + rust_path: #rust_path, + } + })) +} + // ---- helpers --------------------------------------------------------------- fn opt_str(s: Option<&str>) -> proc_macro2::TokenStream { diff --git a/crates/jedem/src/lib.rs b/crates/jedem/src/lib.rs index cdccf34..8ba7b8e 100644 --- a/crates/jedem/src/lib.rs +++ b/crates/jedem/src/lib.rs @@ -9,7 +9,8 @@ //! //! ## How it works //! -//! Annotate an ordinary `impl` block and name it in a surface: +//! Annotate ordinary Rust and name it in a surface. An `impl` block, a `mod`, +//! or a single `fn` — whichever suits the code you already have: //! //! ``` //! pub struct Greeter; @@ -25,6 +26,18 @@ //! jedem::surface! { name: "hello", version: "0.1.0", api: [Greeter] } //! ``` //! +//! A crate exporting free functions needs no type to hang them off: +//! +//! ``` +//! /// Greet someone by name. +//! #[jedem::export] +//! pub fn greet(name: &str) -> String { +//! format!("Hello, {name}!") +//! } +//! +//! jedem::surface! { name: "hello", version: "0.1.0", api: [greet] } +//! ``` +//! //! The macros expand to the impl you wrote plus a `&'static` [`Surface`] //! describing it. A small bin target then hands that constant to //! [`generate`] and writes the bindings: @@ -61,7 +74,8 @@ mod gen; pub use descriptor::{Interface, Op, Param, Surface, Type}; pub use gen::{generate, generate_crate, GeneratedFile, Target}; -/// Mark an `impl` block for export. See the [crate docs](crate). +/// Mark functions for export — on an `impl` block, a `mod`, or a bare `fn`. +/// See the [crate docs](crate). pub use jedem_macros::export; /// Declare a crate's surface. See the [crate docs](crate). diff --git a/demo/hello/tests/export_forms.rs b/demo/hello/tests/export_forms.rs new file mode 100644 index 0000000..8f9a4dd --- /dev/null +++ b/demo/hello/tests/export_forms.rs @@ -0,0 +1,105 @@ +//! `#[jedem::export]` accepts three forms, and they capture the same things. +//! +//! The bare `fn` and `mod` forms exist so a crate exporting free functions does +//! not have to invent a type for them to hang off. `pub struct Api;` conveys +//! nothing, and every consumer was writing one. + +/// A single exported function, with no surrounding type. +#[jedem::export] +pub fn shout(text: &str) -> String { + text.to_uppercase() +} + +/// A module's worth of exported functions. +#[jedem::export] +pub mod arithmetic { + /// Add two numbers. + pub fn add(a: i64, b: i64) -> i64 { + a + b + } + + /// Halve a number, refusing odd ones. + pub fn halve(a: i64) -> Result { + if a % 2 != 0 { + return Err(format!("{a} is odd")); + } + Ok(a / 2) + } + + /// Private functions are not exported. + #[allow(dead_code)] + fn helper() -> i64 { + 0 + } +} + +#[test] +fn a_bare_function_stays_callable() { + // The annotation is inert: this is an ordinary function. + assert_eq!(shout("hi"), "HI"); +} + +#[test] +fn a_module_stays_callable() { + assert_eq!(arithmetic::add(2, 40), 42); + assert_eq!(arithmetic::halve(9), Err("9 is odd".into())); +} + +#[test] +fn every_form_exposes_the_interface_at_the_same_path() { + // A type, a module and a bare function are all reached identically, which + // is what lets `surface! { api: [...] }` read uniformly. + assert_eq!(hello::Hello::JEDEM_INTERFACE.name, "Hello"); + assert_eq!(arithmetic::JEDEM_INTERFACE.name, "arithmetic"); + assert_eq!(shout::JEDEM_INTERFACE.name, "shout"); +} + +#[test] +fn a_bare_function_captures_what_an_impl_would() { + let iface = shout::JEDEM_INTERFACE; + assert_eq!(iface.ops.len(), 1); + let op = &iface.ops[0]; + assert_eq!(op.name, "shout"); + assert_eq!(op.rust_path, "shout", "no type prefix to call through"); + assert_eq!(op.params[0].ty, jedem::Type::Str); + assert!(op.params[0].borrowed); + assert!(!op.fallible); + assert!(op.doc.unwrap().contains("single exported function")); +} + +#[test] +fn a_module_exports_only_its_public_functions() { + let iface = arithmetic::JEDEM_INTERFACE; + let names: Vec<&str> = iface.ops.iter().map(|o| o.name).collect(); + assert_eq!(names, ["add", "halve"], "`helper` is private"); + + let halve = iface.ops.iter().find(|o| o.name == "halve").unwrap(); + assert!(halve.fallible, "-> Result<_, _>"); + assert_eq!(halve.returns, jedem::Type::I64, "the Result is unwrapped"); + assert_eq!( + halve.rust_path, "arithmetic::halve", + "calls go through the module" + ); +} + +#[test] +fn all_three_forms_generate() { + // The point of the uniform path: one surface, three kinds of entry. + const SURFACE: jedem::Surface = jedem::Surface { + name: "mixed", + version: "0.0.0", + interfaces: &[ + hello::Hello::JEDEM_INTERFACE, + arithmetic::JEDEM_INTERFACE, + shout::JEDEM_INTERFACE, + ], + }; + let py = jedem::generate(&SURFACE, jedem::Target::Python, "core"); + assert!(py.contains("pub fn shout(text: &str) -> String"), "{py}"); + assert!( + py.contains("core::shout(text)"), + "a bare fn needs no prefix" + ); + assert!(py.contains("core::arithmetic::add(a, b)"), "a mod does"); + assert!(py.contains("core::Hello::greet(name)"), "a type does"); +}