From f16be8d64ac0e8831a461156aca17391d54343b0 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:19:33 -0600 Subject: [PATCH] Support module-inferred leaf hosts --- examples/protocol/leaf_derive.rs | 7 +- examples/protocol/remote_shell_endpoint.rs | 9 +- examples/protocol/remote_shell_receive.rs | 2 +- .../protocol/remote_shell_single_endpoint.rs | 19 +- unshell-leaves/Cargo.toml | 4 +- unshell-leaves/src/remote_shell/endpoint.rs | 19 +- .../src/remote_shell/endpoint/session.rs | 4 +- unshell-leaves/src/remote_shell/mod.rs | 78 +---- unshell-leaves/src/remote_shell/tui.rs | 13 +- unshell-macros/ABOUT.md | 32 ++- unshell-macros/src/leaf_decl.rs | 268 +++++++++++------- unshell-macros/src/lib.rs | 47 +-- unshell-protocol/src/protocol/tests/call.rs | 7 +- .../src/protocol/tests/leaf_decl.rs | 19 +- .../src/protocol/tests/procedure.rs | 14 +- 15 files changed, 275 insertions(+), 267 deletions(-) diff --git a/examples/protocol/leaf_derive.rs b/examples/protocol/leaf_derive.rs index 643fdfe..d6864d8 100644 --- a/examples/protocol/leaf_derive.rs +++ b/examples/protocol/leaf_derive.rs @@ -19,11 +19,8 @@ struct EchoLeaf { sessions: BTreeMap, } -leaf! { - id = "org.example.v1.echo", - procedures = [EchoOpen], - endpoint_struct = EchoLeaf, -} +#[leaf(id = "org.example.v1.echo", procedures = [EchoOpen], endpoint_struct = EchoLeaf)] +struct Echo; #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] struct EchoRequest { diff --git a/examples/protocol/remote_shell_endpoint.rs b/examples/protocol/remote_shell_endpoint.rs index 8015ef2..e57c10a 100644 --- a/examples/protocol/remote_shell_endpoint.rs +++ b/examples/protocol/remote_shell_endpoint.rs @@ -19,15 +19,12 @@ fn main() -> Result<(), Box> { agent_path(), Some(Vec::new()), Vec::new(), - vec![remote_shell::endpoint::RemoteShellEndpoint::protocol_leaf_spec()], + vec![remote_shell::endpoint::RemoteShell::protocol_leaf_spec()], ); let mut runtime = ProcedureRuntime::< - remote_shell::endpoint::RemoteShellEndpoint, + remote_shell::endpoint::RemoteShell, remote_shell::endpoint::Open, - >::new( - endpoint, - remote_shell::endpoint::RemoteShellEndpoint::default(), - ); + >::new(endpoint, remote_shell::endpoint::RemoteShell::default()); println!( "connected to controller at {}", diff --git a/examples/protocol/remote_shell_receive.rs b/examples/protocol/remote_shell_receive.rs index 0f55452..1323ac6 100644 --- a/examples/protocol/remote_shell_receive.rs +++ b/examples/protocol/remote_shell_receive.rs @@ -28,7 +28,7 @@ fn main() -> Result<(), Box> { Vec::new(), ); let hook_id = endpoint.allocate_hook_id(); - let shell_leaf_name = remote_shell::endpoint::RemoteShellEndpoint::protocol_leaf_name(); + let shell_leaf_name = remote_shell::endpoint::RemoteShell::protocol_leaf_name(); let open_procedure = remote_shell::endpoint::Open::protocol_procedure_id(); remote_shell::endpoint::send_forward( diff --git a/examples/protocol/remote_shell_single_endpoint.rs b/examples/protocol/remote_shell_single_endpoint.rs index e9ae47f..129b1cc 100644 --- a/examples/protocol/remote_shell_single_endpoint.rs +++ b/examples/protocol/remote_shell_single_endpoint.rs @@ -1,9 +1,12 @@ -//! Smallest in-process `RemoteShellLeaf` endpoint example. +//! Smallest in-process `remote_shell` declaration example. //! -//! This example hosts exactly one protocol endpoint with exactly one leaf, `RemoteShellLeaf`, and -//! performs a local introspection request against that leaf. It does not open any sockets or spawn -//! a shell process, so it is the easiest place to see how the endpoint and leaf metadata fit -//! together. +//! This example hosts exactly one protocol endpoint with exactly one leaf and performs a local +//! introspection request against that leaf. The important detail is that the endpoint metadata is +//! taken from `remote_shell::endpoint::RemoteShellEndpoint::protocol_leaf_spec()`, which is +//! generated by the `leaf!` declaration in `unshell-leaves/src/remote_shell/mod.rs`. +//! +//! It does not open any sockets or spawn a shell process, so it is the easiest place to verify +//! that the shared compile-time leaf declaration and the generated endpoint host metadata line up. use std::error::Error; @@ -12,17 +15,18 @@ use unshell::protocol::tree::{EndpointOutcome, LocalEvent, ProtocolEndpoint}; use unshell::protocol::{INTROSPECTION_PROCEDURE_ID, LeafIntrospection}; fn main() -> Result<(), Box> { + let leaf_spec = remote_shell::endpoint::RemoteShell::protocol_leaf_spec(); let mut endpoint = ProtocolEndpoint::new( agent_path(), Some(Vec::new()), Vec::new(), - vec![remote_shell::endpoint::RemoteShellEndpoint::protocol_leaf_spec()], + vec![leaf_spec.clone()], ); let hook_id = endpoint.allocate_hook_id(); let outcome = endpoint.send_call( agent_path(), - Some(remote_shell::endpoint::RemoteShellEndpoint::protocol_leaf_name()), + Some(remote_shell::endpoint::RemoteShell::protocol_leaf_name()), INTROSPECTION_PROCEDURE_ID, Some(hook_id), Vec::new(), @@ -38,6 +42,7 @@ fn main() -> Result<(), Box> { remote_shell::endpoint::LISTEN_ADDR ); println!("endpoint path: {:?}", agent_path()); + println!("declared leaf: {}", leaf_spec.name); println!("leaf: {}", payload.leaf_name); println!("procedures: {:?}", payload.procedures); Ok(()) diff --git a/unshell-leaves/Cargo.toml b/unshell-leaves/Cargo.toml index 7185df7..cf397e4 100644 --- a/unshell-leaves/Cargo.toml +++ b/unshell-leaves/Cargo.toml @@ -6,12 +6,12 @@ description = "Application-layer UnShell leaves and client surfaces" [features] default = [] -leaf_endpoint = ["dep:portable-pty"] +leaf_endpoint = [] leaf_tui = [] [dependencies] rkyv = { workspace = true } -portable-pty = { workspace = true, optional = true } +portable-pty = { workspace = true } unshell-macros = { workspace = true } unshell-protocol = { workspace = true } diff --git a/unshell-leaves/src/remote_shell/endpoint.rs b/unshell-leaves/src/remote_shell/endpoint.rs index c5a4d3f..aee6ba2 100644 --- a/unshell-leaves/src/remote_shell/endpoint.rs +++ b/unshell-leaves/src/remote_shell/endpoint.rs @@ -20,27 +20,27 @@ use super::OpenRequest; /// caller-owned hook identity. That makes ownership and cleanup of hook-backed /// shell processes easy to inspect during debugging. #[derive(Default)] -pub struct RemoteShellEndpoint { +pub struct RemoteShell { sessions: BTreeMap, } -impl ProcedureStore for RemoteShellEndpoint { +impl ProcedureStore for RemoteShell { fn procedure_sessions(&mut self) -> &mut BTreeMap { &mut self.sessions } } -impl Procedure for Open { +impl Procedure for Open { type Error = ShellLeafError; type Input = OpenRequest; - fn open(_leaf: &mut RemoteShellEndpoint, call: Call) -> Result { + fn open(_leaf: &mut RemoteShell, call: Call) -> Result { let hook_key = call.response_hook.ok_or(ShellLeafError::MissingHook)?; Open::spawn(hook_key.return_path, hook_key.hook_id, call.procedure_id) } fn on_data( - _leaf: &mut RemoteShellEndpoint, + _leaf: &mut RemoteShell, session: &mut Self, data: unshell::protocol::tree::IncomingData, ) -> Result { @@ -48,21 +48,18 @@ impl Procedure for Open { } fn on_fault( - _leaf: &mut RemoteShellEndpoint, + _leaf: &mut RemoteShell, _session: &mut Self, _fault: unshell::protocol::tree::IncomingFault, ) -> Result<(), Self::Error> { Ok(()) } - fn poll( - _leaf: &mut RemoteShellEndpoint, - session: &mut Self, - ) -> Result { + fn poll(_leaf: &mut RemoteShell, session: &mut Self) -> Result { session.poll() } - fn close(_leaf: &mut RemoteShellEndpoint, mut session: Self) -> Result<(), Self::Error> { + fn close(_leaf: &mut RemoteShell, mut session: Self) -> Result<(), Self::Error> { session.terminate() } } diff --git a/unshell-leaves/src/remote_shell/endpoint/session.rs b/unshell-leaves/src/remote_shell/endpoint/session.rs index 16cc0c0..cb93077 100644 --- a/unshell-leaves/src/remote_shell/endpoint/session.rs +++ b/unshell-leaves/src/remote_shell/endpoint/session.rs @@ -14,7 +14,7 @@ use portable_pty::{CommandBuilder, ExitStatus, PtySize, native_pty_system}; use unshell::Procedure; use unshell::protocol::tree::{IncomingData, OutgoingData, ProcedureEffect}; -use super::RemoteShellEndpoint; +use super::RemoteShell; use super::errors::ShellLeafError; /// Per-hook shell session created by the `open` procedure. @@ -22,7 +22,7 @@ use super::errors::ShellLeafError; /// The procedure type is also the stored session type so the mapping between /// one opening procedure and one live hook remains direct and visible. #[derive(Procedure)] -#[procedure(leaf = RemoteShellEndpoint, name = "open")] +#[procedure(leaf = RemoteShell, name = "open")] pub struct Open { /// Spawned PTY child process. pub(super) child: Box, diff --git a/unshell-leaves/src/remote_shell/mod.rs b/unshell-leaves/src/remote_shell/mod.rs index b908b0a..e286675 100644 --- a/unshell-leaves/src/remote_shell/mod.rs +++ b/unshell-leaves/src/remote_shell/mod.rs @@ -1,48 +1,14 @@ //! Remote shell leaf and its user-facing surfaces. //! -//! The module always exports the protocol contract for the leaf. Role-specific -//! implementations live behind crate-wide features: -//! - `leaf_endpoint` builds the PTY-backed runtime leaf -//! - `leaf_tui` builds a placeholder client-side TUI surface +//! The module always exports the protocol contract for the leaf together with the +//! endpoint and TUI host implementations. use rkyv::{Archive, Deserialize, Serialize}; -#[cfg(not(feature = "leaf_endpoint"))] -use std::string::String; +use unshell_macros::leaf; -#[cfg(feature = "leaf_endpoint")] pub mod endpoint; -#[cfg(feature = "leaf_tui")] pub mod tui; -#[cfg(feature = "leaf_endpoint")] -pub use endpoint::Open; -#[cfg(feature = "leaf_endpoint")] -pub use endpoint::RemoteShellEndpoint; -#[cfg(feature = "leaf_tui")] -pub use tui::RemoteShellTui; - -#[cfg(not(feature = "leaf_endpoint"))] -/// Compile-time procedure symbol kept available even when the endpoint runtime is -/// not built, so the leaf declaration still validates its declared inventory. -pub struct Open; - -#[cfg(not(feature = "leaf_endpoint"))] -#[doc(hidden)] -pub struct RemoteShellDeclarationPlaceholder; - -#[cfg(not(feature = "leaf_endpoint"))] -impl crate::protocol::tree::ProtocolLeaf for RemoteShellDeclarationPlaceholder { - fn leaf_name() -> String { - String::from("remote_shell") - } -} - -#[cfg(not(feature = "leaf_endpoint"))] -impl crate::protocol::tree::ProcedureMetadata for Open { - type Leaf = RemoteShellDeclarationPlaceholder; - const PROCEDURE_SUFFIX: &'static str = "open"; -} - /// Open-request payload for the remote shell leaf. /// /// The shell currently needs no structured arguments, but a named payload type is @@ -50,33 +16,11 @@ impl crate::protocol::tree::ProcedureMetadata for Open { #[derive(Archive, Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct OpenRequest; -#[cfg(any(feature = "leaf_endpoint", feature = "leaf_tui"))] -macro_rules! declare_remote_shell_leaf { - ($($role_args:tt)*) => { - crate::leaf! { - name = "remote_shell", - procedures = [Open], - $($role_args)* - } - }; -} - -#[cfg(all(feature = "leaf_endpoint", not(feature = "leaf_tui")))] -declare_remote_shell_leaf!(endpoint_struct = RemoteShellEndpoint,); - -#[cfg(all(not(feature = "leaf_endpoint"), feature = "leaf_tui"))] -declare_remote_shell_leaf!(tui_struct = RemoteShellTui,); - -#[cfg(all(feature = "leaf_endpoint", feature = "leaf_tui"))] -declare_remote_shell_leaf!( - endpoint_struct = RemoteShellEndpoint, - tui_struct = RemoteShellTui, -); - -crate::role_leaf! { - /// Feature-selected remote shell surface. - pub type RemoteShell { - endpoint => endpoint::RemoteShellEndpoint, - tui => tui::RemoteShellTui, - } -} +#[leaf( + name = "remote_shell", + procedures = [Open], + endpoint = endpoint, + tui = tui, +)] +/// Shared compile-time declaration for the `remote_shell` leaf surface. +pub struct RemoteShell; diff --git a/unshell-leaves/src/remote_shell/tui.rs b/unshell-leaves/src/remote_shell/tui.rs index 9bfb8ec..c371c58 100644 --- a/unshell-leaves/src/remote_shell/tui.rs +++ b/unshell-leaves/src/remote_shell/tui.rs @@ -8,23 +8,24 @@ use std::string::String; use std::vec::Vec; use unshell::protocol::DataMessage; +use unshell_macros::Procedure; use crate::{LeafTui, TuiError}; /// Stub TUI surface for the remote shell leaf. #[derive(Default)] -pub struct RemoteShellTui { +pub struct RemoteShell { transcript: Vec, } -impl RemoteShellTui { +impl RemoteShell { /// Returns a short explanation of the current stub status. pub fn status_line(&self) -> &'static str { "remote shell TUI stub: rendering is placeholder-only for now" } } -impl LeafTui for RemoteShellTui { +impl LeafTui for RemoteShell { fn leaf_name(&self) -> String { Self::protocol_leaf_name() } @@ -39,3 +40,9 @@ impl LeafTui for RemoteShellTui { format!("{}\n\n{}", self.status_line(), body) } } + +/// TUI-side placeholder procedure symbol for the shared `remote_shell` leaf +/// declaration. +#[derive(Procedure)] +#[procedure(leaf = RemoteShell, name = "open")] +pub struct Open {} diff --git a/unshell-macros/ABOUT.md b/unshell-macros/ABOUT.md index fc55f01..0473113 100644 --- a/unshell-macros/ABOUT.md +++ b/unshell-macros/ABOUT.md @@ -16,7 +16,7 @@ In practical terms, the macro system is responsible for: - deriving canonical procedure identifiers - generating compile-time procedure inventories for leaves -- binding one leaf declaration to separate endpoint and TUI host structs without +- binding one leaf declaration to separate endpoint and TUI host modules without repeating the metadata on each host - generating dispatch glue for simple call-driven leaves @@ -32,7 +32,7 @@ The declaration answers: - what is this leaf called on the wire? - which procedure suffixes belong to it? -- which host structs implement its endpoint and TUI roles? +- which host modules implement its endpoint and TUI roles? The goal is that this information is written once and reused everywhere. @@ -46,6 +46,28 @@ One leaf can have multiple host structs with different responsibilities. Those hosts should not each have to repeat the leaf name or procedure inventory. They bind to the declaration instead. +The current convention is module-based. A declaration such as: + +```rust +#[leaf( + name = "remote_shell", + procedures = [Open], + endpoint = endpoint, + tui = tui, +)] +pub struct RemoteShell; +``` + +means: + +- the endpoint host type is inferred as `endpoint::RemoteShell` +- the TUI host type is inferred as `tui::RemoteShell` +- type-based procedure metadata is resolved from the endpoint module as + `endpoint::Open` + +This convention removes repeated host type paths from the declaration while still +keeping the generated code deterministic and inspectable. + ### 3. Procedure and method metadata Procedures and future typed remote methods need stable canonical identifiers. @@ -56,9 +78,9 @@ compile-time inventory instead of handwritten lists. ## Current direction -The public declaration model is now centered on `leaf!`. +The public declaration model is now centered on `#[leaf(...)]`. -- `leaf!` declares the canonical protocol surface once +- `#[leaf(...)]` declares the canonical protocol surface once - `#[derive(Procedure)]` derives stateful procedure metadata - `#[procedures]` derives one-shot call dispatch for simple leaves @@ -75,6 +97,8 @@ The system is optimized for a few constraints that matter to this repository. - generated code should stay explicit enough to debug - endpoint and TUI roles should share metadata but not be forced into the same runtime trait when their behavior differs +- host inference should stay convention-based instead of discovery-based so a + declaration can be understood from its source without macro expansion tools - migration should be low-breakage for the existing examples and tests ## Non-goals diff --git a/unshell-macros/src/leaf_decl.rs b/unshell-macros/src/leaf_decl.rs index 6fe51fe..734f534 100644 --- a/unshell-macros/src/leaf_decl.rs +++ b/unshell-macros/src/leaf_decl.rs @@ -1,89 +1,46 @@ use proc_macro2::TokenStream; use quote::{format_ident, quote}; use syn::{ - Error, Ident, LitStr, Result, Token, Visibility, + Error, ItemStruct, LitStr, Path, Result, Token, parse::{Parse, ParseStream}, punctuated::Punctuated, }; use crate::utils::option_litstr_tokens; -pub(crate) struct LeafDeclarationInput { - visibility: Visibility, +pub(crate) struct LeafDeclarationAttributes { name: Option, id: Option, org: Option, product: Option, version: Option, - endpoint_struct: Option, - tui_struct: Option, procedures: Vec, + host_bindings: Vec, } -impl Parse for LeafDeclarationInput { +impl Parse for LeafDeclarationAttributes { fn parse(input: ParseStream<'_>) -> Result { - let visibility = if input.peek(Token![pub]) { - input.parse()? - } else { - Visibility::Inherited - }; - let assignments = Punctuated::::parse_terminated(input)?; let mut parsed = Self { - visibility, name: None, id: None, org: None, product: None, version: None, - endpoint_struct: None, - tui_struct: None, procedures: Vec::new(), + host_bindings: Vec::new(), }; for assignment in assignments { match assignment { - LeafAssignment::Name(value) => { - if parsed.name.is_some() { - return Err(Error::new_spanned(value, "duplicate leaf name")); - } - parsed.name = Some(value); - } - LeafAssignment::Id(value) => { - if parsed.id.is_some() { - return Err(Error::new_spanned(value, "duplicate leaf id")); - } - parsed.id = Some(value); - } - LeafAssignment::Org(value) => { - if parsed.org.is_some() { - return Err(Error::new_spanned(value, "duplicate leaf org")); - } - parsed.org = Some(value); - } + LeafAssignment::Name(value) => set_once(&mut parsed.name, value, "leaf name")?, + LeafAssignment::Id(value) => set_once(&mut parsed.id, value, "leaf id")?, + LeafAssignment::Org(value) => set_once(&mut parsed.org, value, "leaf org")?, LeafAssignment::Product(value) => { - if parsed.product.is_some() { - return Err(Error::new_spanned(value, "duplicate leaf product")); - } - parsed.product = Some(value); + set_once(&mut parsed.product, value, "leaf product")? } LeafAssignment::Version(value) => { - if parsed.version.is_some() { - return Err(Error::new_spanned(value, "duplicate leaf version")); - } - parsed.version = Some(value); - } - LeafAssignment::EndpointStruct(value) => { - if parsed.endpoint_struct.is_some() { - return Err(Error::new_spanned(value, "duplicate endpoint_struct")); - } - parsed.endpoint_struct = Some(value); - } - LeafAssignment::TuiStruct(value) => { - if parsed.tui_struct.is_some() { - return Err(Error::new_spanned(value, "duplicate tui_struct")); - } - parsed.tui_struct = Some(value); + set_once(&mut parsed.version, value, "leaf version")? } LeafAssignment::Procedures(values) => { if !parsed.procedures.is_empty() { @@ -91,19 +48,20 @@ impl Parse for LeafDeclarationInput { } parsed.procedures = values; } + LeafAssignment::HostBinding(binding) => parsed.host_bindings.push(binding), } } if parsed.name.is_none() && parsed.id.is_none() { return Err(Error::new( input.span(), - "leaf! requires either `name = \"...\"` or `id = \"...\"`", + "#[leaf(...)] requires either `name = \"...\"` or `id = \"...\"`", )); } - if parsed.endpoint_struct.is_none() && parsed.tui_struct.is_none() { + if parsed.host_bindings.is_empty() { return Err(Error::new( input.span(), - "leaf! requires at least one of `endpoint_struct = ...` or `tui_struct = ...`", + "#[leaf(...)] requires at least one host binding", )); } @@ -117,13 +75,17 @@ enum LeafAssignment { Org(LitStr), Product(LitStr), Version(LitStr), - EndpointStruct(Ident), - TuiStruct(Ident), Procedures(Vec), + HostBinding(HostBinding), +} + +struct HostBinding { + module_path: Option, + host_path: Option, } enum ProcedureRef { - Symbol(Ident), + Symbol(Path), Suffix(LitStr), } @@ -138,16 +100,26 @@ impl Parse for ProcedureRef { impl Parse for LeafAssignment { fn parse(input: ParseStream<'_>) -> Result { - let name: Ident = input.parse()?; + let name: Path = input.parse()?; input.parse::()?; - match name.to_string().as_str() { + let key = name + .get_ident() + .ok_or_else(|| Error::new_spanned(&name, "leaf keys must be identifiers"))? + .to_string(); + match key.as_str() { "name" => Ok(Self::Name(input.parse()?)), "id" => Ok(Self::Id(input.parse()?)), "org" => Ok(Self::Org(input.parse()?)), "product" => Ok(Self::Product(input.parse()?)), "version" => Ok(Self::Version(input.parse()?)), - "endpoint_struct" => Ok(Self::EndpointStruct(input.parse()?)), - "tui_struct" => Ok(Self::TuiStruct(input.parse()?)), + "endpoint_struct" | "tui_struct" => Ok(Self::HostBinding(HostBinding { + module_path: None, + host_path: Some(input.parse()?), + })), + "endpoint" | "tui" => Ok(Self::HostBinding(HostBinding { + module_path: Some(input.parse()?), + host_path: None, + })), "procedures" => { let content; syn::bracketed!(content in input); @@ -156,57 +128,47 @@ impl Parse for LeafAssignment { .collect::>(); Ok(Self::Procedures(values)) } - _ => Err(Error::new_spanned(name, "unsupported leaf! assignment")), + _ => Err(Error::new_spanned( + name, + "unsupported #[leaf(...)] key; expected one of name, id, org, product, version, procedures, endpoint, tui, endpoint_struct, or tui_struct", + )), } } } -pub(crate) fn expand_leaf_declaration(input: LeafDeclarationInput) -> Result { - let _visibility = input.visibility; - let declaration_ident = format_ident!( - "__UnshellLeafDecl_{}", - input - .endpoint_struct - .as_ref() - .or(input.tui_struct.as_ref()) - .expect("leaf declaration requires at least one host") - ); - let id = option_litstr_tokens(input.id.as_ref()); - let org = option_litstr_tokens(input.org.as_ref()); - let product = option_litstr_tokens(input.product.as_ref()); - let version = option_litstr_tokens(input.version.as_ref()); - let leaf_name = option_litstr_tokens(input.name.as_ref()); - let procedure_suffixes = input +pub(crate) fn expand_leaf_declaration( + attr: LeafDeclarationAttributes, + item: ItemStruct, +) -> Result { + let declaration_ident = item.ident.clone(); + let id = option_litstr_tokens(attr.id.as_ref()); + let org = option_litstr_tokens(attr.org.as_ref()); + let product = option_litstr_tokens(attr.product.as_ref()); + let version = option_litstr_tokens(attr.version.as_ref()); + let leaf_name = option_litstr_tokens(attr.name.as_ref()); + let canonical_procedure_module = attr + .host_bindings + .iter() + .find_map(|binding| binding.module_path.as_ref()) + .cloned(); + let procedure_suffixes = attr .procedures .iter() - .map(|procedure| match procedure { - ProcedureRef::Symbol(procedure) => { - quote! { <#procedure as ::unshell::protocol::tree::ProcedureMetadata>::PROCEDURE_SUFFIX } - } - ProcedureRef::Suffix(suffix) => quote! { #suffix }, - }) - .collect::>(); - let procedure_type_checks = input - .procedures + .map(|procedure| procedure_suffix_tokens(procedure, canonical_procedure_module.as_ref())) + .collect::>>()?; + let procedure_type_checks = attr + .host_bindings .iter() - .filter_map(|procedure| match procedure { - ProcedureRef::Symbol(procedure) => Some(procedure), - ProcedureRef::Suffix(_) => None, - }); - - let endpoint_impl = input - .endpoint_struct - .as_ref() - .map(|endpoint_struct| expand_binding_impl(endpoint_struct, &declaration_ident)); - let tui_impl = input - .tui_struct - .as_ref() - .map(|tui_struct| expand_binding_impl(tui_struct, &declaration_ident)); + .map(|binding| procedure_type_check_tokens(binding, &attr.procedures, &declaration_ident)) + .collect::>>()?; + let host_impls = attr + .host_bindings + .iter() + .map(|binding| expand_binding_impl(binding, &declaration_ident)) + .collect::>>()?; Ok(quote! { - #[allow(non_camel_case_types)] - #[doc(hidden)] - pub struct #declaration_ident; + #item impl ::unshell::protocol::tree::ProtocolLeaf for #declaration_ident { fn leaf_name() -> ::unshell::alloc::string::String { @@ -252,16 +214,16 @@ pub(crate) fn expand_leaf_declaration(input: LeafDeclarationInput) -> Result;)* + #(#procedure_type_checks)* }; - #endpoint_impl - #tui_impl + #(#host_impls)* }) } -fn expand_binding_impl(host: &Ident, declaration: &Ident) -> TokenStream { - quote! { +fn expand_binding_impl(binding: &HostBinding, declaration: &syn::Ident) -> Result { + let host = host_path_for_binding(binding, declaration)?; + Ok(quote! { impl ::unshell::protocol::tree::ProtocolLeaf for #host { fn leaf_name() -> ::unshell::alloc::string::String { <#declaration as ::unshell::protocol::tree::ProtocolLeaf>::leaf_name() @@ -296,5 +258,91 @@ fn expand_binding_impl(host: &Ident, declaration: &Ident) -> TokenStream { ::procedure_id(suffix) } } + }) +} + +fn host_path_for_binding(binding: &HostBinding, declaration: &syn::Ident) -> Result { + if let Some(path) = &binding.host_path { + return Ok(path.clone()); + } + + let Some(module_path) = &binding.module_path else { + return Err(Error::new( + declaration.span(), + "leaf binding is missing a host path", + )); + }; + + let mut path = module_path.clone(); + path.segments.push(format_ident!("{declaration}").into()); + Ok(path) +} + +fn procedure_suffix_tokens( + procedure: &ProcedureRef, + canonical_module: Option<&Path>, +) -> Result { + match procedure { + ProcedureRef::Symbol(procedure) => { + let procedure_path = if let Some(module_path) = canonical_module { + let mut path = module_path.clone(); + let ident = procedure.get_ident().ok_or_else(|| { + Error::new_spanned( + procedure, + "procedure names must be bare identifiers when inferred from a module", + ) + })?; + path.segments.push(ident.clone().into()); + path + } else { + procedure.clone() + }; + Ok( + quote! { <#procedure_path as ::unshell::protocol::tree::ProcedureMetadata>::PROCEDURE_SUFFIX }, + ) + } + ProcedureRef::Suffix(suffix) => Ok(quote! { #suffix }), } } + +fn procedure_type_check_tokens( + binding: &HostBinding, + procedures: &[ProcedureRef], + declaration: &syn::Ident, +) -> Result { + let Some(module_path) = &binding.module_path else { + return Ok(quote! {}); + }; + + let checks = procedures + .iter() + .filter_map(|procedure| match procedure { + ProcedureRef::Symbol(procedure) => Some(procedure), + ProcedureRef::Suffix(_) => None, + }) + .map(|procedure| { + let mut path = module_path.clone(); + let ident = procedure.get_ident().ok_or_else(|| { + Error::new_spanned( + procedure, + "procedure names must be bare identifiers when inferred from a module", + ) + })?; + path.segments.push(ident.clone().into()); + Ok::(quote! { + let _ = ::core::marker::PhantomData::<#path>; + }) + }) + .collect::>>()?; + + let _ = declaration; + Ok(quote! { #(#checks)* }) +} + +fn set_once(target: &mut Option, value: LitStr, label: &str) -> Result<()> { + if target.is_some() { + return Err(Error::new_spanned(value, format!("duplicate {label}"))); + } + *target = Some(value); + Ok(()) +} diff --git a/unshell-macros/src/lib.rs b/unshell-macros/src/lib.rs index 46c79f6..8dec4f9 100644 --- a/unshell-macros/src/lib.rs +++ b/unshell-macros/src/lib.rs @@ -6,13 +6,14 @@ mod procedures; mod utils; use proc_macro::TokenStream; -use syn::{DeriveInput, ItemImpl, parse_macro_input}; +use syn::{DeriveInput, ItemImpl, ItemStruct, parse_macro_input}; /// Declares one compile-time leaf surface and binds it to endpoint and/or TUI /// host structs. /// -/// What it is: a function-like macro that generates the shared protocol-visible -/// metadata for one leaf and applies that metadata to the listed host structs. +/// What it is: an attribute macro placed on a marker struct that generates the +/// shared protocol-visible metadata for one leaf and applies that metadata to the +/// listed host structs. /// /// Why it exists: endpoint and TUI hosts should not each have to repeat the leaf /// name and procedure inventory, and endpoint construction should not need a @@ -20,18 +21,20 @@ use syn::{DeriveInput, ItemImpl, parse_macro_input}; /// /// # Example /// ```ignore -/// unshell::leaf! { +/// #[unshell::leaf( /// name = "remote_shell", -/// procedures = [Open, Reset, whoami], -/// endpoint_struct = RemoteShellEndpoint, -/// tui_struct = RemoteShellTui, -/// } +/// procedures = [Open], +/// leaf_endpoint = endpoint::RemoteShellEndpoint, +/// leaf_tui = tui::RemoteShellTui, +/// )] +/// pub struct RemoteShell; /// ``` -#[proc_macro] -pub fn leaf(input: TokenStream) -> TokenStream { - match leaf_decl::expand_leaf_declaration(parse_macro_input!( - input as leaf_decl::LeafDeclarationInput - )) { +#[proc_macro_attribute] +pub fn leaf(attr: TokenStream, item: TokenStream) -> TokenStream { + match leaf_decl::expand_leaf_declaration( + parse_macro_input!(attr as leaf_decl::LeafDeclarationAttributes), + parse_macro_input!(item as ItemStruct), + ) { Ok(tokens) => tokens.into(), Err(error) => error.to_compile_error().into(), } @@ -49,13 +52,12 @@ pub fn leaf(input: TokenStream) -> TokenStream { /// ```ignore /// use unshell::{Procedure, leaf}; /// -/// struct ShellLeaf; -/// -/// leaf! { +/// #[leaf( /// name = "shell", /// procedures = [OpenSession], /// endpoint_struct = ShellLeaf, -/// } +/// )] +/// struct Shell; /// /// struct ShellLeaf; /// @@ -86,13 +88,14 @@ pub fn derive_procedure(input: TokenStream) -> TokenStream { /// ```ignore /// use unshell::{leaf, procedures}; /// -/// struct EchoLeaf; -/// -/// leaf! { +/// #[leaf( /// id = "org.example.v1.echo", -/// procedures = [echo], +/// procedures = ["echo"], /// endpoint_struct = EchoLeaf, -/// } +/// )] +/// struct Echo; +/// +/// struct EchoLeaf; /// /// #[procedures(error = core::convert::Infallible)] /// impl EchoLeaf { diff --git a/unshell-protocol/src/protocol/tests/call.rs b/unshell-protocol/src/protocol/tests/call.rs index 93f9ae8..0560ce2 100644 --- a/unshell-protocol/src/protocol/tests/call.rs +++ b/unshell-protocol/src/protocol/tests/call.rs @@ -18,11 +18,8 @@ struct EchoLeaf { prefix: String, } -leaf! { - id = "org.example.v1.echo", - endpoint_struct = EchoLeaf, - procedures = ["echo"], -} +#[leaf(id = "org.example.v1.echo", endpoint_struct = EchoLeaf, procedures = ["echo"])] +struct Echo; #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] struct EchoRequest { diff --git a/unshell-protocol/src/protocol/tests/leaf_decl.rs b/unshell-protocol/src/protocol/tests/leaf_decl.rs index 649e9b5..c91b90d 100644 --- a/unshell-protocol/src/protocol/tests/leaf_decl.rs +++ b/unshell-protocol/src/protocol/tests/leaf_decl.rs @@ -17,11 +17,8 @@ impl ProcedureMetadata for Reset { const PROCEDURE_SUFFIX: &'static str = "reset"; } -leaf! { - id = "org.example.v1.demo", - procedures = [Open, Reset], - endpoint_struct = EndpointHost, -} +#[leaf(id = "org.example.v1.demo", procedures = [Open, Reset], endpoint_struct = EndpointHost)] +struct Demo; struct EndpointHalf; struct TuiHalf; @@ -32,7 +29,7 @@ impl ProcedureMetadata for Connect { const PROCEDURE_SUFFIX: &'static str = "connect"; } -leaf! { +#[leaf( name = "chat", org = "org", product = "example", @@ -40,7 +37,8 @@ leaf! { procedures = [Connect], endpoint_struct = EndpointHalf, tui_struct = TuiHalf, -} +)] +struct Chat; struct TuiOnly; struct Tail; @@ -50,11 +48,8 @@ impl ProcedureMetadata for Tail { const PROCEDURE_SUFFIX: &'static str = "tail"; } -leaf! { - id = "org.example.v1.transcript", - procedures = [Tail], - tui_struct = TuiOnly, -} +#[leaf(id = "org.example.v1.transcript", procedures = [Tail], tui_struct = TuiOnly)] +struct Transcript; #[test] fn leaf_declaration_generates_endpoint_host_metadata() { diff --git a/unshell-protocol/src/protocol/tests/procedure.rs b/unshell-protocol/src/protocol/tests/procedure.rs index e3ef10e..03241e7 100644 --- a/unshell-protocol/src/protocol/tests/procedure.rs +++ b/unshell-protocol/src/protocol/tests/procedure.rs @@ -17,11 +17,8 @@ struct StreamLeaf { sessions: BTreeMap, } -leaf! { - id = "org.example.v1.stream", - procedures = [ProcedureOpen], - endpoint_struct = StreamLeaf, -} +#[leaf(id = "org.example.v1.stream", procedures = [ProcedureOpen], endpoint_struct = StreamLeaf)] +struct Stream; impl ProcedureStore for StreamLeaf { fn procedure_sessions(&mut self) -> &mut BTreeMap { @@ -146,11 +143,8 @@ struct DuplexLeaf { sessions: BTreeMap, } -leaf! { - id = "org.example.v1.duplex", - procedures = [DuplexProcedure], - endpoint_struct = DuplexLeaf, -} +#[leaf(id = "org.example.v1.duplex", procedures = [DuplexProcedure], endpoint_struct = DuplexLeaf)] +struct Duplex; impl ProcedureStore for DuplexLeaf { fn procedure_sessions(&mut self) -> &mut BTreeMap {