Add compile-time leaf declarations

Introduce a function-like leaf declaration macro, bind endpoint and TUI hosts to shared generated metadata, and move remote shell endpoint construction out of the leaf module into the examples and runtime assembly code.
This commit is contained in:
Michael Mikovsky
2026-04-26 13:54:44 -06:00
parent fccd61ea29
commit bc22d349bf
17 changed files with 598 additions and 170 deletions
+18 -2
View File
@@ -10,12 +10,24 @@ use std::sync::mpsc::RecvTimeoutError;
use std::time::Duration; use std::time::Duration;
use unshell::leaves::remote_shell; use unshell::leaves::remote_shell;
use unshell::protocol::tree::Ingress; use unshell::protocol::tree::{Ingress, ProcedureRuntime, ProtocolEndpoint};
fn main() -> Result<(), Box<dyn Error>> { fn main() -> Result<(), Box<dyn Error>> {
let mut stream = TcpStream::connect(remote_shell::endpoint::LISTEN_ADDR)?; let mut stream = TcpStream::connect(remote_shell::endpoint::LISTEN_ADDR)?;
let frame_rx = remote_shell::endpoint::spawn_frame_reader(stream.try_clone()?); let frame_rx = remote_shell::endpoint::spawn_frame_reader(stream.try_clone()?);
let mut runtime = remote_shell::endpoint::build_agent_runtime(); let endpoint = ProtocolEndpoint::new(
agent_path(),
Some(Vec::new()),
Vec::new(),
vec![remote_shell::endpoint::RemoteShellEndpoint::protocol_leaf_spec()],
);
let mut runtime = ProcedureRuntime::<
remote_shell::endpoint::RemoteShellEndpoint,
remote_shell::endpoint::Open,
>::new(
endpoint,
remote_shell::endpoint::RemoteShellEndpoint::default(),
);
println!( println!(
"connected to controller at {}", "connected to controller at {}",
@@ -39,3 +51,7 @@ fn main() -> Result<(), Box<dyn Error>> {
Ok(()) Ok(())
} }
fn agent_path() -> Vec<String> {
vec![String::from("agent")]
}
+10 -3
View File
@@ -9,7 +9,9 @@ use std::net::TcpListener;
use unshell::leaves::remote_shell; use unshell::leaves::remote_shell;
use unshell::leaves::remote_shell::OpenRequest; use unshell::leaves::remote_shell::OpenRequest;
use unshell::protocol::tree::encode_call_reply; use unshell::protocol::tree::encode_call_reply;
use unshell::protocol::tree::{Endpoint, EndpointOutcome, Ingress, LocalEvent}; use unshell::protocol::tree::{
ChildRoute, Endpoint, EndpointOutcome, Ingress, LocalEvent, ProtocolEndpoint,
};
fn main() -> Result<(), Box<dyn Error>> { fn main() -> Result<(), Box<dyn Error>> {
let listener = TcpListener::bind(remote_shell::endpoint::LISTEN_ADDR)?; let listener = TcpListener::bind(remote_shell::endpoint::LISTEN_ADDR)?;
@@ -19,10 +21,15 @@ fn main() -> Result<(), Box<dyn Error>> {
println!("accepted endpoint connection from {peer_addr}"); println!("accepted endpoint connection from {peer_addr}");
let frame_rx = remote_shell::endpoint::spawn_frame_reader(stream.try_clone()?); let frame_rx = remote_shell::endpoint::spawn_frame_reader(stream.try_clone()?);
let mut endpoint = remote_shell::endpoint::build_controller_endpoint(); let mut endpoint = ProtocolEndpoint::new(
Vec::new(),
None,
vec![ChildRoute::registered(agent_path())],
Vec::new(),
);
let hook_id = endpoint.allocate_hook_id(); 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::RemoteShellEndpoint::protocol_leaf_name();
let open_procedure = remote_shell::endpoint::ProcedureOpen::protocol_procedure_id(); let open_procedure = remote_shell::endpoint::Open::protocol_procedure_id();
remote_shell::endpoint::send_forward( remote_shell::endpoint::send_forward(
&mut stream, &mut stream,
@@ -16,10 +16,7 @@ fn main() -> Result<(), Box<dyn Error>> {
agent_path(), agent_path(),
Some(Vec::new()), Some(Vec::new()),
Vec::new(), Vec::new(),
vec![unshell::protocol::tree::LeafSpec { vec![remote_shell::endpoint::RemoteShellEndpoint::protocol_leaf_spec()],
name: remote_shell::endpoint::RemoteShellEndpoint::protocol_leaf_name(),
procedures: vec![remote_shell::endpoint::ProcedureOpen::protocol_procedure_id()],
}],
); );
let hook_id = endpoint.allocate_hook_id(); let hook_id = endpoint.allocate_hook_id();
+1 -1
View File
@@ -26,6 +26,6 @@ pub use unshell_protocol as protocol;
/// Re-export the leaf library crate behind the historical `unshell::leaves` path /// Re-export the leaf library crate behind the historical `unshell::leaves` path
pub use unshell_leaves as leaves; pub use unshell_leaves as leaves;
pub use unshell_macros::{Leaf, Procedure, procedures}; pub use unshell_macros::{Leaf, Procedure, leaf, procedures};
// pub use ush_obfuscate as obfuscate; // pub use ush_obfuscate as obfuscate;
+1 -1
View File
@@ -12,7 +12,7 @@ pub extern crate alloc;
use unshell_protocol::DataMessage; use unshell_protocol::DataMessage;
pub use unshell_macros::{Leaf, Procedure, procedures}; pub use unshell_macros::{Leaf, Procedure, leaf, procedures};
pub use unshell_protocol as protocol; pub use unshell_protocol as protocol;
/// Re-exports one role-specific type behind a stable public alias. /// Re-exports one role-specific type behind a stable public alias.
+8 -40
View File
@@ -6,13 +6,10 @@ mod transport;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use unshell::Leaf; use unshell::protocol::tree::{Call, HookKey, Procedure, ProcedureEffect, ProcedureStore};
use unshell::protocol::tree::{
Call, HookKey, Procedure, ProcedureEffect, ProcedureRuntime, ProcedureStore, ProtocolEndpoint,
};
pub use errors::ShellLeafError; pub use errors::ShellLeafError;
pub use session::ProcedureOpen; pub use session::Open;
pub use transport::{LISTEN_ADDR, send_forward, spawn_frame_reader, write_frames}; pub use transport::{LISTEN_ADDR, send_forward, spawn_frame_reader, write_frames};
use super::OpenRequest; use super::OpenRequest;
@@ -22,25 +19,24 @@ use super::OpenRequest;
/// The endpoint keeps each live shell session in an explicit map keyed by the /// The endpoint keeps each live shell session in an explicit map keyed by the
/// caller-owned hook identity. That makes ownership and cleanup of hook-backed /// caller-owned hook identity. That makes ownership and cleanup of hook-backed
/// shell processes easy to inspect during debugging. /// shell processes easy to inspect during debugging.
#[derive(Default, Leaf)] #[derive(Default)]
#[leaf(leaf_name = "remote_shell")]
pub struct RemoteShellEndpoint { pub struct RemoteShellEndpoint {
sessions: BTreeMap<HookKey, ProcedureOpen>, sessions: BTreeMap<HookKey, Open>,
} }
impl ProcedureStore<ProcedureOpen> for RemoteShellEndpoint { impl ProcedureStore<Open> for RemoteShellEndpoint {
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, ProcedureOpen> { fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, Open> {
&mut self.sessions &mut self.sessions
} }
} }
impl Procedure<RemoteShellEndpoint> for ProcedureOpen { impl Procedure<RemoteShellEndpoint> for Open {
type Error = ShellLeafError; type Error = ShellLeafError;
type Input = OpenRequest; type Input = OpenRequest;
fn open(_leaf: &mut RemoteShellEndpoint, call: Call<Self::Input>) -> Result<Self, Self::Error> { fn open(_leaf: &mut RemoteShellEndpoint, call: Call<Self::Input>) -> Result<Self, Self::Error> {
let hook_key = call.response_hook.ok_or(ShellLeafError::MissingHook)?; let hook_key = call.response_hook.ok_or(ShellLeafError::MissingHook)?;
ProcedureOpen::spawn(hook_key.return_path, hook_key.hook_id, call.procedure_id) Open::spawn(hook_key.return_path, hook_key.hook_id, call.procedure_id)
} }
fn on_data( fn on_data(
@@ -70,31 +66,3 @@ impl Procedure<RemoteShellEndpoint> for ProcedureOpen {
session.terminate() session.terminate()
} }
} }
/// Builds the controller endpoint used by the receiver example.
pub fn build_controller_endpoint() -> ProtocolEndpoint {
ProtocolEndpoint::new(
Vec::new(),
None,
vec![unshell::protocol::tree::ChildRoute::registered(agent_path())],
Vec::new(),
)
}
/// Builds the stateful shell runtime used by the endpoint example.
pub fn build_agent_runtime() -> ProcedureRuntime<RemoteShellEndpoint, ProcedureOpen> {
let endpoint = ProtocolEndpoint::new(
agent_path(),
Some(Vec::new()),
Vec::new(),
vec![unshell::protocol::tree::LeafSpec {
name: RemoteShellEndpoint::protocol_leaf_name(),
procedures: vec![ProcedureOpen::protocol_procedure_id()],
}],
);
ProcedureRuntime::new(endpoint, RemoteShellEndpoint::default())
}
fn agent_path() -> Vec<String> {
vec![String::from("agent")]
}
@@ -23,7 +23,7 @@ use super::errors::ShellLeafError;
/// one opening procedure and one live hook remains direct and visible. /// one opening procedure and one live hook remains direct and visible.
#[derive(Procedure)] #[derive(Procedure)]
#[procedure(leaf = RemoteShellEndpoint, name = "open")] #[procedure(leaf = RemoteShellEndpoint, name = "open")]
pub struct ProcedureOpen { pub struct Open {
/// Spawned PTY child process. /// Spawned PTY child process.
pub(super) child: Box<dyn portable_pty::Child + Send>, pub(super) child: Box<dyn portable_pty::Child + Send>,
/// Process-group leader used for Unix hangup and kill signaling. /// Process-group leader used for Unix hangup and kill signaling.
@@ -52,7 +52,7 @@ enum OutputEvent {
ReaderClosed, ReaderClosed,
} }
impl ProcedureOpen { impl Open {
pub(super) fn spawn( pub(super) fn spawn(
return_path: Vec<String>, return_path: Vec<String>,
hook_id: u64, hook_id: u64,
@@ -213,7 +213,7 @@ impl ProcedureOpen {
} }
} }
impl Drop for ProcedureOpen { impl Drop for Open {
fn drop(&mut self) { fn drop(&mut self) {
let _ = self.terminate(); let _ = self.terminate();
} }
+23
View File
@@ -24,6 +24,29 @@ pub use tui::RemoteShellTui;
#[derive(Archive, Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] #[derive(Archive, Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct OpenRequest; 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! { crate::role_leaf! {
/// Feature-selected remote shell surface. /// Feature-selected remote shell surface.
pub type RemoteShell { pub type RemoteShell {
+1 -3
View File
@@ -7,14 +7,12 @@
use std::string::String; use std::string::String;
use std::vec::Vec; use std::vec::Vec;
use unshell::Leaf;
use unshell::protocol::DataMessage; use unshell::protocol::DataMessage;
use crate::{LeafTui, TuiError}; use crate::{LeafTui, TuiError};
/// Stub TUI surface for the remote shell leaf. /// Stub TUI surface for the remote shell leaf.
#[derive(Default, Leaf)] #[derive(Default)]
#[leaf(leaf_name = "remote_shell")]
pub struct RemoteShellTui { pub struct RemoteShellTui {
transcript: Vec<u8>, transcript: Vec<u8>,
} }
+4
View File
@@ -146,6 +146,10 @@ pub(crate) fn expand_leaf(input: DeriveInput) -> Result<proc_macro2::TokenStream
} }
} }
impl #impl_generics ::unshell::protocol::tree::LeafBinding for #struct_name #ty_generics #where_clause {
type Declaration = Self;
}
impl #impl_generics #struct_name #ty_generics #where_clause { impl #impl_generics #struct_name #ty_generics #where_clause {
/// Returns the canonical dotted leaf name declared for this type. /// Returns the canonical dotted leaf name declared for this type.
#leaf_name_warning_attr #leaf_name_warning_attr
+307
View File
@@ -0,0 +1,307 @@
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{
Error, Ident, LitStr, Result, Token, Visibility,
parse::{Parse, ParseStream},
punctuated::Punctuated,
};
use crate::utils::option_litstr_tokens;
pub(crate) struct LeafDeclarationInput {
visibility: Visibility,
name: Option<LitStr>,
id: Option<LitStr>,
org: Option<LitStr>,
product: Option<LitStr>,
version: Option<LitStr>,
endpoint_struct: Option<Ident>,
tui_struct: Option<Ident>,
procedures: Vec<Ident>,
}
impl Parse for LeafDeclarationInput {
fn parse(input: ParseStream<'_>) -> Result<Self> {
let visibility = if input.peek(Token![pub]) {
input.parse()?
} else {
Visibility::Inherited
};
let assignments = Punctuated::<LeafAssignment, Token![,]>::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(),
};
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::Product(value) => {
if parsed.product.is_some() {
return Err(Error::new_spanned(value, "duplicate leaf product"));
}
parsed.product = Some(value);
}
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);
}
LeafAssignment::Procedures(values) => {
if !parsed.procedures.is_empty() {
return Err(Error::new(input.span(), "duplicate procedures list"));
}
parsed.procedures = values;
}
}
}
if parsed.name.is_none() && parsed.id.is_none() {
return Err(Error::new(
input.span(),
"leaf! requires either `name = \"...\"` or `id = \"...\"`",
));
}
if parsed.endpoint_struct.is_none() && parsed.tui_struct.is_none() {
return Err(Error::new(
input.span(),
"leaf! requires at least one of `endpoint_struct = ...` or `tui_struct = ...`",
));
}
Ok(parsed)
}
}
enum LeafAssignment {
Name(LitStr),
Id(LitStr),
Org(LitStr),
Product(LitStr),
Version(LitStr),
EndpointStruct(Ident),
TuiStruct(Ident),
Procedures(Vec<Ident>),
}
impl Parse for LeafAssignment {
fn parse(input: ParseStream<'_>) -> Result<Self> {
let name: Ident = input.parse()?;
input.parse::<Token![=]>()?;
match name.to_string().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()?)),
"procedures" => {
let content;
syn::bracketed!(content in input);
let values = Punctuated::<Ident, Token![,]>::parse_terminated(&content)?
.into_iter()
.collect::<Vec<_>>();
Ok(Self::Procedures(values))
}
_ => Err(Error::new_spanned(name, "unsupported leaf! assignment")),
}
}
}
pub(crate) fn expand_leaf_declaration(input: LeafDeclarationInput) -> Result<TokenStream> {
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
.procedures
.iter()
.map(|procedure| LitStr::new(&normalize_suffix(&procedure.to_string()), procedure.span()))
.collect::<Vec<_>>();
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));
Ok(quote! {
#[allow(non_camel_case_types)]
#[doc(hidden)]
pub struct #declaration_ident;
impl ::unshell::protocol::tree::ProtocolLeaf for #declaration_ident {
fn leaf_name() -> ::unshell::alloc::string::String {
::unshell::protocol::tree::derive_leaf_name(
::core::env!("CARGO_PKG_NAME"),
::core::env!("CARGO_PKG_VERSION_MAJOR"),
::core::env!("CARGO_PKG_VERSION_MINOR"),
::core::env!("CARGO_PKG_VERSION_PATCH"),
::core::module_path!(),
::core::stringify!(#declaration_ident),
#org,
#product,
#version,
#leaf_name,
#id,
)
}
}
impl ::unshell::protocol::tree::LeafDeclaration for #declaration_ident {
fn procedure_suffixes() -> &'static [&'static str] {
&[#(#procedure_suffixes),*]
}
}
impl #declaration_ident {
/// Returns the canonical dotted leaf name declared for this surface.
pub fn protocol_leaf_name() -> ::unshell::alloc::string::String {
<Self as ::unshell::protocol::tree::ProtocolLeaf>::leaf_name()
}
/// Returns the canonical protocol leaf metadata for this surface.
pub fn protocol_leaf_spec() -> ::unshell::protocol::tree::LeafSpec {
<Self as ::unshell::protocol::tree::LeafDeclaration>::leaf_spec()
}
/// Resolves one local procedure suffix to its full canonical `procedure_id`.
pub fn protocol_procedure_id(
suffix: &str,
) -> ::core::option::Option<::unshell::alloc::string::String> {
<Self as ::unshell::protocol::tree::LeafDeclaration>::procedure_id(suffix)
}
}
#endpoint_impl
#tui_impl
})
}
fn expand_binding_impl(host: &Ident, declaration: &Ident) -> TokenStream {
quote! {
impl ::unshell::protocol::tree::ProtocolLeaf for #host {
fn leaf_name() -> ::unshell::alloc::string::String {
<#declaration as ::unshell::protocol::tree::ProtocolLeaf>::leaf_name()
}
}
impl ::unshell::protocol::tree::LeafBinding for #host {
type Declaration = #declaration;
}
impl ::unshell::protocol::tree::LeafDeclaration for #host {
fn procedure_suffixes() -> &'static [&'static str] {
<#declaration as ::unshell::protocol::tree::LeafDeclaration>::procedure_suffixes()
}
}
impl #host {
/// Returns the canonical dotted leaf name declared for this host.
pub fn protocol_leaf_name() -> ::unshell::alloc::string::String {
<Self as ::unshell::protocol::tree::ProtocolLeaf>::leaf_name()
}
/// Returns the canonical protocol leaf metadata for this host.
pub fn protocol_leaf_spec() -> ::unshell::protocol::tree::LeafSpec {
<Self as ::unshell::protocol::tree::LeafDeclaration>::leaf_spec()
}
/// Resolves one local procedure suffix to its full canonical `procedure_id`.
pub fn protocol_procedure_id(
suffix: &str,
) -> ::core::option::Option<::unshell::alloc::string::String> {
<Self as ::unshell::protocol::tree::LeafDeclaration>::procedure_id(suffix)
}
}
}
}
fn normalize_suffix(value: &str) -> String {
let mut normalized = String::with_capacity(value.len());
let mut previous_was_separator = false;
for character in value.chars() {
if character.is_ascii_uppercase() {
if !normalized.is_empty() && !previous_was_separator {
normalized.push('_');
}
normalized.push(character.to_ascii_lowercase());
previous_was_separator = false;
continue;
}
if character.is_ascii_lowercase() || character.is_ascii_digit() {
normalized.push(character);
previous_was_separator = false;
continue;
}
if !normalized.is_empty() && !previous_was_separator {
normalized.push('_');
previous_was_separator = true;
}
}
while normalized.ends_with('_') {
normalized.pop();
}
if normalized.is_empty() {
String::from("procedure")
} else {
normalized
}
}
+30
View File
@@ -1,6 +1,7 @@
//! Proc macros for `unshell` application-layer leaf declarations. //! Proc macros for `unshell` application-layer leaf declarations.
mod leaf; mod leaf;
mod leaf_decl;
mod procedure; mod procedure;
mod procedures; mod procedures;
mod utils; mod utils;
@@ -8,6 +9,35 @@ mod utils;
use proc_macro::TokenStream; use proc_macro::TokenStream;
use syn::{DeriveInput, ItemImpl, parse_macro_input}; use syn::{DeriveInput, ItemImpl, 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.
///
/// 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
/// handwritten list of procedure ids.
///
/// # Example
/// ```ignore
/// unshell::leaf! {
/// name = "remote_shell",
/// procedures = [Open, Reset, whoami],
/// endpoint_struct = RemoteShellEndpoint,
/// tui_struct = RemoteShellTui,
/// }
/// ```
#[proc_macro]
pub fn leaf(input: TokenStream) -> TokenStream {
match leaf_decl::expand_leaf_declaration(parse_macro_input!(
input as leaf_decl::LeafDeclarationInput
)) {
Ok(tokens) => tokens.into(),
Err(error) => error.to_compile_error().into(),
}
}
/// Derives canonical protocol-leaf identity helpers for one host type. /// Derives canonical protocol-leaf identity helpers for one host type.
/// ///
/// What it is: a derive macro that implements `ProtocolLeaf` and generates the /// What it is: a derive macro that implements `ProtocolLeaf` and generates the
+4 -2
View File
@@ -88,11 +88,13 @@ pub(crate) fn expand_procedure(input: DeriveInput) -> Result<proc_macro2::TokenS
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
Ok(quote! { Ok(quote! {
impl #impl_generics ::unshell::protocol::tree::StatefulProcedureMetadata<#leaf_ty> impl #impl_generics ::unshell::protocol::tree::ProcedureMetadata
for #procedure_name #ty_generics #where_clause for #procedure_name #ty_generics #where_clause
where where
#leaf_ty: ::unshell::protocol::tree::ProtocolLeaf, #leaf_ty: ::unshell::protocol::tree::ProtocolLeaf,
{ {
type Leaf = #leaf_ty;
fn procedure_suffix() -> &'static str { fn procedure_suffix() -> &'static str {
#suffix #suffix
} }
@@ -101,7 +103,7 @@ pub(crate) fn expand_procedure(input: DeriveInput) -> Result<proc_macro2::TokenS
impl #impl_generics #procedure_name #ty_generics #where_clause { impl #impl_generics #procedure_name #ty_generics #where_clause {
/// Returns the full canonical `procedure_id` for this stateful procedure. /// Returns the full canonical `procedure_id` for this stateful procedure.
pub fn protocol_procedure_id() -> ::unshell::alloc::string::String { pub fn protocol_procedure_id() -> ::unshell::alloc::string::String {
<Self as ::unshell::protocol::tree::StatefulProcedureMetadata<#leaf_ty>>::procedure_id() <Self as ::unshell::protocol::tree::ProcedureMetadata>::procedure_id()
} }
} }
}) })
+8 -6
View File
@@ -114,19 +114,21 @@ pub(crate) fn expand_procedures(
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let procedure_matches = dispatch_arms.iter().map(|arm| { let procedure_matches = dispatch_arms.iter().map(|arm| {
let suffix = &arm.suffix_literal; let suffix = &arm.suffix_literal;
quote! { #suffix => <Self as ::unshell::protocol::tree::CallProcedures>::procedure_id(#suffix), } quote! { #suffix => <Self as ::unshell::protocol::tree::LeafDeclaration>::procedure_id(#suffix), }
}); });
let dispatch_checks = dispatch_arms.iter().map(|arm| arm.dispatch_tokens.clone()); let dispatch_checks = dispatch_arms.iter().map(|arm| arm.dispatch_tokens.clone());
Ok(quote! { Ok(quote! {
#item #item
impl #impl_generics_tokens ::unshell::protocol::tree::CallProcedures for #self_ty #where_clause { impl #impl_generics_tokens ::unshell::protocol::tree::LeafDeclaration for #self_ty #where_clause {
type Error = #error_ty;
fn procedure_suffixes() -> &'static [&'static str] { fn procedure_suffixes() -> &'static [&'static str] {
&[#(#suffix_literals),*] &[#(#suffix_literals),*]
} }
}
impl #impl_generics_tokens ::unshell::protocol::tree::CallProcedures for #self_ty #where_clause {
type Error = #error_ty;
fn dispatch_call( fn dispatch_call(
&mut self, &mut self,
@@ -143,7 +145,7 @@ pub(crate) fn expand_procedures(
impl #impl_generics_tokens #self_ty #where_clause { impl #impl_generics_tokens #self_ty #where_clause {
/// Returns the canonical protocol leaf metadata for this type. /// Returns the canonical protocol leaf metadata for this type.
pub fn protocol_leaf_spec() -> ::unshell::protocol::tree::LeafSpec { pub fn protocol_leaf_spec() -> ::unshell::protocol::tree::LeafSpec {
<Self as ::unshell::protocol::tree::CallProcedures>::leaf_spec() <Self as ::unshell::protocol::tree::LeafDeclaration>::leaf_spec()
} }
/// Resolves one local procedure suffix to its full canonical `procedure_id`. /// Resolves one local procedure suffix to its full canonical `procedure_id`.
@@ -163,7 +165,7 @@ fn expand_call_arm(method: &ImplItemFn) -> Result<CallArm> {
let method_name = &method.sig.ident; let method_name = &method.sig.ident;
let suffix_literal = call_suffix_literal(method)?; let suffix_literal = call_suffix_literal(method)?;
let call_id_expr = quote! { let call_id_expr = quote! {
<Self as ::unshell::protocol::tree::CallProcedures>::procedure_id(#suffix_literal) <Self as ::unshell::protocol::tree::LeafDeclaration>::procedure_id(#suffix_literal)
.expect("generated procedure id must exist") .expect("generated procedure id must exist")
}; };
+123 -89
View File
@@ -1,8 +1,9 @@
//! Application-facing leaf metadata helpers. //! Application-facing leaf metadata helpers.
//! //!
//! The protocol runtime itself only knows about `LeafSpec` metadata and validated //! The protocol runtime itself only knows about `LeafSpec` metadata and validated
//! `LocalEvent` delivery. `ProtocolLeaf` owns the canonical dotted leaf id, while //! `LocalEvent` delivery. `ProtocolLeaf` owns canonical identity, `LeafDeclaration`
//! `CallProcedures` owns generated procedure ids and initial call dispatch. //! owns the compile-time procedure inventory for one leaf surface, and
//! `CallProcedures` adds local call dispatch on top of that inventory.
use alloc::{string::String, vec::Vec}; use alloc::{string::String, vec::Vec};
@@ -37,6 +38,92 @@ pub trait ProtocolLeaf {
fn leaf_name() -> String; fn leaf_name() -> String;
} }
/// Compile-time declaration metadata for one leaf surface.
///
/// What it is: a trait for types that can describe the complete protocol-visible
/// surface of one leaf at compile time.
///
/// Why it exists: endpoint construction should not need handwritten procedure
/// lists. A leaf declaration can generate the canonical suffix inventory once and
/// let both endpoint and TUI host types reuse it.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{LeafDeclaration, ProtocolLeaf};
/// struct ExampleLeaf;
/// impl ProtocolLeaf for ExampleLeaf {
/// fn leaf_name() -> String { "org.example.v1.echo".into() }
/// }
/// impl LeafDeclaration for ExampleLeaf {
/// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] }
/// }
/// assert_eq!(ExampleLeaf::leaf_spec().procedures, vec![String::from("org.example.v1.echo.invoke")]);
/// ```
pub trait LeafDeclaration: ProtocolLeaf {
/// Returns the local procedure suffixes supported by this leaf.
fn procedure_suffixes() -> &'static [&'static str];
/// Resolves one local procedure suffix to its full canonical `procedure_id`.
fn procedure_id(suffix: &str) -> Option<String> {
if !Self::procedure_suffixes().contains(&suffix) {
return None;
}
let mut procedure_id = Self::leaf_name();
procedure_id.push('.');
procedure_id.push_str(suffix);
Some(procedure_id)
}
/// Returns the full canonical `procedure_id` values supported by this leaf.
fn procedure_ids() -> Vec<String> {
Self::procedure_suffixes()
.iter()
.filter_map(|suffix| Self::procedure_id(suffix))
.collect()
}
/// Materializes the runtime leaf metadata consumed by `ProtocolEndpoint`.
fn leaf_spec() -> LeafSpec {
LeafSpec {
name: Self::leaf_name(),
procedures: Self::procedure_ids(),
}
}
}
/// Declares that one host struct is bound to one compile-time leaf declaration.
///
/// What it is: a trait that links a concrete host type, such as an endpoint or
/// TUI struct, back to the declaration that owns its shared protocol metadata.
///
/// Why it exists: endpoint and TUI hosts often need different state and behavior,
/// but they should still share one canonical leaf identity and procedure list.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{LeafBinding, LeafDeclaration, ProtocolLeaf};
/// struct ExampleDecl;
/// impl ProtocolLeaf for ExampleDecl {
/// fn leaf_name() -> String { "org.example.v1.echo".into() }
/// }
/// impl LeafDeclaration for ExampleDecl {
/// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] }
/// }
/// struct ExampleHost;
/// impl ProtocolLeaf for ExampleHost {
/// fn leaf_name() -> String { ExampleDecl::leaf_name() }
/// }
/// impl LeafBinding for ExampleHost {
/// type Declaration = ExampleDecl;
/// }
/// assert_eq!(<ExampleHost as LeafBinding>::Declaration::leaf_name(), "org.example.v1.echo");
/// ```
pub trait LeafBinding: ProtocolLeaf {
/// Shared declaration that owns the canonical metadata for this host type.
type Declaration: ProtocolLeaf;
}
/// Generated call metadata and initial `Call` dispatch for one leaf. /// Generated call metadata and initial `Call` dispatch for one leaf.
/// ///
/// This exists so one leaf type can advertise which procedure suffixes it serves and convert an /// This exists so one leaf type can advertise which procedure suffixes it serves and convert an
@@ -58,95 +145,10 @@ pub trait ProtocolLeaf {
/// } /// }
/// assert_eq!(ExampleLeaf::procedure_id("invoke").unwrap(), "org.example.v1.echo.invoke"); /// assert_eq!(ExampleLeaf::procedure_id("invoke").unwrap(), "org.example.v1.echo.invoke");
/// ``` /// ```
pub trait CallProcedures: ProtocolLeaf { pub trait CallProcedures: LeafDeclaration {
/// Leaf-specific error surfaced when generated call dispatch fails. /// Leaf-specific error surfaced when generated call dispatch fails.
type Error; type Error;
/// Returns the local procedure suffixes supported by this leaf.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{CallProcedures, ProtocolLeaf};
/// struct ExampleLeaf;
/// impl ProtocolLeaf for ExampleLeaf { fn leaf_name() -> String { "org.example.v1.echo".into() } }
/// impl CallProcedures for ExampleLeaf {
/// type Error = core::convert::Infallible;
/// fn procedure_suffixes() -> &'static [&'static str] { &["invoke", "stream"] }
/// fn dispatch_call(&mut self, _call: unshell::protocol::tree::IncomingCall) -> Result<unshell::protocol::tree::CallReply, unshell::protocol::tree::DispatchError<Self::Error>> { Ok(unshell::protocol::tree::CallReply::NoReply) }
/// }
/// assert_eq!(ExampleLeaf::procedure_suffixes(), &["invoke", "stream"]);
/// ```
fn procedure_suffixes() -> &'static [&'static str];
/// Resolves one local procedure suffix to its full canonical `procedure_id`.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{CallProcedures, ProtocolLeaf};
/// struct ExampleLeaf;
/// impl ProtocolLeaf for ExampleLeaf { fn leaf_name() -> String { "org.example.v1.echo".into() } }
/// impl CallProcedures for ExampleLeaf {
/// type Error = core::convert::Infallible;
/// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] }
/// fn dispatch_call(&mut self, _call: unshell::protocol::tree::IncomingCall) -> Result<unshell::protocol::tree::CallReply, unshell::protocol::tree::DispatchError<Self::Error>> { Ok(unshell::protocol::tree::CallReply::NoReply) }
/// }
/// assert!(ExampleLeaf::procedure_id("invoke").is_some());
/// assert!(ExampleLeaf::procedure_id("missing").is_none());
/// ```
fn procedure_id(suffix: &str) -> Option<String> {
if !Self::procedure_suffixes().contains(&suffix) {
return None;
}
let mut procedure_id = Self::leaf_name();
procedure_id.push('.');
procedure_id.push_str(suffix);
Some(procedure_id)
}
/// Returns the full canonical `procedure_id` values supported by this leaf.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{CallProcedures, ProtocolLeaf};
/// struct ExampleLeaf;
/// impl ProtocolLeaf for ExampleLeaf { fn leaf_name() -> String { "org.example.v1.echo".into() } }
/// impl CallProcedures for ExampleLeaf {
/// type Error = core::convert::Infallible;
/// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] }
/// fn dispatch_call(&mut self, _call: unshell::protocol::tree::IncomingCall) -> Result<unshell::protocol::tree::CallReply, unshell::protocol::tree::DispatchError<Self::Error>> { Ok(unshell::protocol::tree::CallReply::NoReply) }
/// }
/// assert_eq!(ExampleLeaf::procedure_ids(), vec![String::from("org.example.v1.echo.invoke")]);
/// ```
fn procedure_ids() -> Vec<String> {
Self::procedure_suffixes()
.iter()
.filter_map(|suffix| Self::procedure_id(suffix))
.collect()
}
/// Materializes the runtime leaf metadata consumed by `ProtocolEndpoint`.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{CallProcedures, ProtocolLeaf};
/// struct ExampleLeaf;
/// impl ProtocolLeaf for ExampleLeaf { fn leaf_name() -> String { "org.example.v1.echo".into() } }
/// impl CallProcedures for ExampleLeaf {
/// type Error = core::convert::Infallible;
/// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] }
/// fn dispatch_call(&mut self, _call: unshell::protocol::tree::IncomingCall) -> Result<unshell::protocol::tree::CallReply, unshell::protocol::tree::DispatchError<Self::Error>> { Ok(unshell::protocol::tree::CallReply::NoReply) }
/// }
/// let spec = ExampleLeaf::leaf_spec();
/// assert_eq!(spec.name, "org.example.v1.echo");
/// ```
fn leaf_spec() -> LeafSpec {
LeafSpec {
name: Self::leaf_name(),
procedures: Self::procedure_ids(),
}
}
/// Dispatches one initial `Call` that targeted this leaf. /// Dispatches one initial `Call` that targeted this leaf.
/// ///
/// Implementations may assume the endpoint already proved the call targets this leaf. /// Implementations may assume the endpoint already proved the call targets this leaf.
@@ -313,7 +315,9 @@ fn normalize_leaf_segment(value: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::derive_leaf_name; use alloc::string::String;
use super::{LeafBinding, LeafDeclaration, ProtocolLeaf, derive_leaf_name};
#[test] #[test]
fn derive_leaf_name_normalizes_inputs_into_dotted_segments() { fn derive_leaf_name_normalizes_inputs_into_dotted_segments() {
@@ -374,4 +378,34 @@ mod tests {
"org.example.v1.echo.abc" "org.example.v1.echo.abc"
); );
} }
#[test]
fn bound_hosts_can_share_one_declaration() {
struct SharedDecl;
impl ProtocolLeaf for SharedDecl {
fn leaf_name() -> String {
String::from("org.example.v1.echo")
}
}
impl LeafDeclaration for SharedDecl {
fn procedure_suffixes() -> &'static [&'static str] {
&["invoke"]
}
}
struct Host;
impl ProtocolLeaf for Host {
fn leaf_name() -> String {
SharedDecl::leaf_name()
}
}
impl LeafBinding for Host {
type Declaration = SharedDecl;
}
assert_eq!(
<Host as LeafBinding>::Declaration::leaf_spec().name,
"org.example.v1.echo"
);
}
} }
+3 -3
View File
@@ -24,10 +24,10 @@ pub use endpoint::{
ProtocolEndpoint, ProtocolEndpoint,
}; };
pub use hook::{ActiveHook, HookConflict, HookKey, HookTable, PendingHook}; pub use hook::{ActiveHook, HookConflict, HookKey, HookTable, PendingHook};
pub use leaf::{CallProcedures, ProtocolLeaf, derive_leaf_name}; pub use leaf::{CallProcedures, LeafBinding, LeafDeclaration, ProtocolLeaf, derive_leaf_name};
pub use procedure::{ pub use procedure::{
Procedure, ProcedureEffect, ProcedureRuntime, ProcedureRuntimeError, ProcedureRuntimeOutcome, Procedure, ProcedureEffect, ProcedureMetadata, ProcedureRuntime, ProcedureRuntimeError,
ProcedureStore, StatefulProcedureMetadata, ProcedureRuntimeOutcome, ProcedureStore, StatefulProcedureMetadata,
}; };
pub use routing::{ pub use routing::{
CompiledRoutes, DefaultRouteProvider, LeafNode, RouteDecision, RouteProvider, TreeNode, CompiledRoutes, DefaultRouteProvider, LeafNode, RouteDecision, RouteProvider, TreeNode,
+53 -13
View File
@@ -27,6 +27,45 @@ use super::{
LocalEvent, OutgoingData, ProtocolEndpoint, ProtocolLeaf, decode_call_input, LocalEvent, OutgoingData, ProtocolEndpoint, ProtocolLeaf, decode_call_input,
}; };
/// Canonical compile-time metadata for one procedure surface.
///
/// What it is: a trait that defines the leaf type and local suffix used to derive
/// one stable protocol `procedure_id`.
///
/// Why it exists: compile-time leaf declarations and future typed remote methods
/// need to talk about procedures without hand-assembling identifiers at each use
/// site.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ProcedureMetadata, ProtocolLeaf};
/// struct ExampleLeaf;
/// impl ProtocolLeaf for ExampleLeaf {
/// fn leaf_name() -> String { "org.example.v1.shell".into() }
/// }
/// struct Open;
/// impl ProcedureMetadata for Open {
/// type Leaf = ExampleLeaf;
/// fn procedure_suffix() -> &'static str { "open" }
/// }
/// assert_eq!(Open::procedure_id(), "org.example.v1.shell.open");
/// ```
pub trait ProcedureMetadata: Sized {
/// Leaf surface this procedure belongs to.
type Leaf: ProtocolLeaf;
/// Returns the local suffix used to derive the full canonical `procedure_id`.
fn procedure_suffix() -> &'static str;
/// Returns the canonical `procedure_id` for this procedure.
fn procedure_id() -> String {
let mut procedure_id = <Self::Leaf as ProtocolLeaf>::leaf_name();
procedure_id.push('.');
procedure_id.push_str(Self::procedure_suffix());
procedure_id
}
}
/// Generated metadata for one stateful procedure bound to one leaf type. /// Generated metadata for one stateful procedure bound to one leaf type.
/// ///
/// This metadata is intentionally tiny: one procedure suffix plus the derived /// This metadata is intentionally tiny: one procedure suffix plus the derived
@@ -34,31 +73,32 @@ use super::{
/// ///
/// # Example /// # Example
/// ```rust /// ```rust
/// use unshell::protocol::tree::{ProtocolLeaf, StatefulProcedureMetadata}; /// use unshell::protocol::tree::{ProcedureMetadata, ProtocolLeaf, StatefulProcedureMetadata};
/// struct ExampleLeaf; /// struct ExampleLeaf;
/// impl ProtocolLeaf for ExampleLeaf { /// impl ProtocolLeaf for ExampleLeaf {
/// fn leaf_name() -> String { "org.example.v1.shell".into() } /// fn leaf_name() -> String { "org.example.v1.shell".into() }
/// } /// }
/// struct Open; /// struct Open;
/// impl StatefulProcedureMetadata<ExampleLeaf> for Open { /// impl ProcedureMetadata for Open {
/// type Leaf = ExampleLeaf;
///
/// fn procedure_suffix() -> &'static str { "open" } /// fn procedure_suffix() -> &'static str { "open" }
/// } /// }
/// fn _compat<T: StatefulProcedureMetadata<ExampleLeaf>>() {}
/// _compat::<Open>();
/// assert_eq!(Open::procedure_id(), "org.example.v1.shell.open"); /// assert_eq!(Open::procedure_id(), "org.example.v1.shell.open");
/// ``` /// ```
pub trait StatefulProcedureMetadata<L>: Sized pub trait StatefulProcedureMetadata<L>: ProcedureMetadata<Leaf = L> + Sized
where where
L: ProtocolLeaf, L: ProtocolLeaf,
{ {
/// Returns the local suffix used to derive the full canonical `procedure_id`. }
fn procedure_suffix() -> &'static str;
/// Returns the canonical `procedure_id` for this procedure. impl<T, L> StatefulProcedureMetadata<L> for T
fn procedure_id() -> String { where
let mut procedure_id = L::leaf_name(); T: ProcedureMetadata<Leaf = L>,
procedure_id.push('.'); L: ProtocolLeaf,
procedure_id.push_str(Self::procedure_suffix()); {
procedure_id
}
} }
/// Explicit storage access for one procedure session map inside the leaf. /// Explicit storage access for one procedure session map inside the leaf.
@@ -133,7 +173,7 @@ pub trait ProcedureStore<P> {
/// } /// }
/// } /// }
/// ``` /// ```
pub trait Procedure<L>: StatefulProcedureMetadata<L> + Sized pub trait Procedure<L>: ProcedureMetadata<Leaf = L> + Sized
where where
L: ProtocolLeaf, L: ProtocolLeaf,
{ {