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"); +}