Support module-inferred leaf hosts

This commit is contained in:
Michael Mikovsky
2026-04-26 15:19:33 -06:00
parent 54c44b407e
commit f16be8d64a
15 changed files with 275 additions and 267 deletions
+2 -5
View File
@@ -19,11 +19,8 @@ struct EchoLeaf {
sessions: BTreeMap<HookKey, EchoOpen>,
}
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 {
+3 -6
View File
@@ -19,15 +19,12 @@ fn main() -> Result<(), Box<dyn Error>> {
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 {}",
+1 -1
View File
@@ -28,7 +28,7 @@ fn main() -> Result<(), Box<dyn Error>> {
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(
@@ -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<dyn Error>> {
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<dyn Error>> {
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(())
+2 -2
View File
@@ -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 }
+8 -11
View File
@@ -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<HookKey, Open>,
}
impl ProcedureStore<Open> for RemoteShellEndpoint {
impl ProcedureStore<Open> for RemoteShell {
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, Open> {
&mut self.sessions
}
}
impl Procedure<RemoteShellEndpoint> for Open {
impl Procedure<RemoteShell> for Open {
type Error = ShellLeafError;
type Input = OpenRequest;
fn open(_leaf: &mut RemoteShellEndpoint, call: Call<Self::Input>) -> Result<Self, Self::Error> {
fn open(_leaf: &mut RemoteShell, call: Call<Self::Input>) -> Result<Self, Self::Error> {
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<ProcedureEffect, Self::Error> {
@@ -48,21 +48,18 @@ impl Procedure<RemoteShellEndpoint> 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<ProcedureEffect, Self::Error> {
fn poll(_leaf: &mut RemoteShell, session: &mut Self) -> Result<ProcedureEffect, Self::Error> {
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()
}
}
@@ -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<dyn portable_pty::Child + Send>,
+9 -65
View File
@@ -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! {
#[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,
}
}
endpoint = endpoint,
tui = tui,
)]
/// Shared compile-time declaration for the `remote_shell` leaf surface.
pub struct RemoteShell;
+10 -3
View File
@@ -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<u8>,
}
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 {}
+28 -4
View File
@@ -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
+158 -110
View File
@@ -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<LitStr>,
id: Option<LitStr>,
org: Option<LitStr>,
product: Option<LitStr>,
version: Option<LitStr>,
endpoint_struct: Option<Ident>,
tui_struct: Option<Ident>,
procedures: Vec<ProcedureRef>,
host_bindings: Vec<HostBinding>,
}
impl Parse for LeafDeclarationInput {
impl Parse for LeafDeclarationAttributes {
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(),
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<ProcedureRef>),
HostBinding(HostBinding),
}
struct HostBinding {
module_path: Option<Path>,
host_path: Option<Path>,
}
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<Self> {
let name: Ident = input.parse()?;
let name: Path = input.parse()?;
input.parse::<Token![=]>()?;
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::<Vec<_>>();
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<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
pub(crate) fn expand_leaf_declaration(
attr: LeafDeclarationAttributes,
item: ItemStruct,
) -> Result<TokenStream> {
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::<Vec<_>>();
let procedure_type_checks = input
.procedures
.map(|procedure| procedure_suffix_tokens(procedure, canonical_procedure_module.as_ref()))
.collect::<Result<Vec<_>>>()?;
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::<Result<Vec<_>>>()?;
let host_impls = attr
.host_bindings
.iter()
.map(|binding| expand_binding_impl(binding, &declaration_ident))
.collect::<Result<Vec<_>>>()?;
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<Tok
}
const _: fn() = || {
#(let _ = ::core::marker::PhantomData::<#procedure_type_checks>;)*
#(#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<TokenStream> {
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 {
<Self as ::unshell::protocol::tree::LeafDeclaration>::procedure_id(suffix)
}
}
})
}
fn host_path_for_binding(binding: &HostBinding, declaration: &syn::Ident) -> Result<Path> {
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<TokenStream> {
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<TokenStream> {
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::<TokenStream, Error>(quote! {
let _ = ::core::marker::PhantomData::<#path>;
})
})
.collect::<Result<Vec<_>>>()?;
let _ = declaration;
Ok(quote! { #(#checks)* })
}
fn set_once(target: &mut Option<LitStr>, value: LitStr, label: &str) -> Result<()> {
if target.is_some() {
return Err(Error::new_spanned(value, format!("duplicate {label}")));
}
*target = Some(value);
Ok(())
}
+25 -22
View File
@@ -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 {
+2 -5
View File
@@ -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 {
@@ -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() {
@@ -17,11 +17,8 @@ struct StreamLeaf {
sessions: BTreeMap<HookKey, ProcedureOpen>,
}
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<ProcedureOpen> for StreamLeaf {
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, ProcedureOpen> {
@@ -146,11 +143,8 @@ struct DuplexLeaf {
sessions: BTreeMap<HookKey, DuplexProcedure>,
}
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<DuplexProcedure> for DuplexLeaf {
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, DuplexProcedure> {