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>, sessions: BTreeMap<HookKey, EchoOpen>,
} }
leaf! { #[leaf(id = "org.example.v1.echo", procedures = [EchoOpen], endpoint_struct = EchoLeaf)]
id = "org.example.v1.echo", struct Echo;
procedures = [EchoOpen],
endpoint_struct = EchoLeaf,
}
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
struct EchoRequest { struct EchoRequest {
+3 -6
View File
@@ -19,15 +19,12 @@ fn main() -> Result<(), Box<dyn Error>> {
agent_path(), agent_path(),
Some(Vec::new()), Some(Vec::new()),
Vec::new(), Vec::new(),
vec![remote_shell::endpoint::RemoteShellEndpoint::protocol_leaf_spec()], vec![remote_shell::endpoint::RemoteShell::protocol_leaf_spec()],
); );
let mut runtime = ProcedureRuntime::< let mut runtime = ProcedureRuntime::<
remote_shell::endpoint::RemoteShellEndpoint, remote_shell::endpoint::RemoteShell,
remote_shell::endpoint::Open, remote_shell::endpoint::Open,
>::new( >::new(endpoint, remote_shell::endpoint::RemoteShell::default());
endpoint,
remote_shell::endpoint::RemoteShellEndpoint::default(),
);
println!( println!(
"connected to controller at {}", "connected to controller at {}",
+1 -1
View File
@@ -28,7 +28,7 @@ fn main() -> Result<(), Box<dyn Error>> {
Vec::new(), 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::RemoteShell::protocol_leaf_name();
let open_procedure = remote_shell::endpoint::Open::protocol_procedure_id(); let open_procedure = remote_shell::endpoint::Open::protocol_procedure_id();
remote_shell::endpoint::send_forward( 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 //! This example hosts exactly one protocol endpoint with exactly one leaf and performs a local
//! performs a local introspection request against that leaf. It does not open any sockets or spawn //! introspection request against that leaf. The important detail is that the endpoint metadata is
//! a shell process, so it is the easiest place to see how the endpoint and leaf metadata fit //! taken from `remote_shell::endpoint::RemoteShellEndpoint::protocol_leaf_spec()`, which is
//! together. //! 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; use std::error::Error;
@@ -12,17 +15,18 @@ use unshell::protocol::tree::{EndpointOutcome, LocalEvent, ProtocolEndpoint};
use unshell::protocol::{INTROSPECTION_PROCEDURE_ID, LeafIntrospection}; use unshell::protocol::{INTROSPECTION_PROCEDURE_ID, LeafIntrospection};
fn main() -> Result<(), Box<dyn Error>> { fn main() -> Result<(), Box<dyn Error>> {
let leaf_spec = remote_shell::endpoint::RemoteShell::protocol_leaf_spec();
let mut endpoint = ProtocolEndpoint::new( let mut endpoint = ProtocolEndpoint::new(
agent_path(), agent_path(),
Some(Vec::new()), Some(Vec::new()),
Vec::new(), Vec::new(),
vec![remote_shell::endpoint::RemoteShellEndpoint::protocol_leaf_spec()], vec![leaf_spec.clone()],
); );
let hook_id = endpoint.allocate_hook_id(); let hook_id = endpoint.allocate_hook_id();
let outcome = endpoint.send_call( let outcome = endpoint.send_call(
agent_path(), agent_path(),
Some(remote_shell::endpoint::RemoteShellEndpoint::protocol_leaf_name()), Some(remote_shell::endpoint::RemoteShell::protocol_leaf_name()),
INTROSPECTION_PROCEDURE_ID, INTROSPECTION_PROCEDURE_ID,
Some(hook_id), Some(hook_id),
Vec::new(), Vec::new(),
@@ -38,6 +42,7 @@ fn main() -> Result<(), Box<dyn Error>> {
remote_shell::endpoint::LISTEN_ADDR remote_shell::endpoint::LISTEN_ADDR
); );
println!("endpoint path: {:?}", agent_path()); println!("endpoint path: {:?}", agent_path());
println!("declared leaf: {}", leaf_spec.name);
println!("leaf: {}", payload.leaf_name); println!("leaf: {}", payload.leaf_name);
println!("procedures: {:?}", payload.procedures); println!("procedures: {:?}", payload.procedures);
Ok(()) Ok(())
+2 -2
View File
@@ -6,12 +6,12 @@ description = "Application-layer UnShell leaves and client surfaces"
[features] [features]
default = [] default = []
leaf_endpoint = ["dep:portable-pty"] leaf_endpoint = []
leaf_tui = [] leaf_tui = []
[dependencies] [dependencies]
rkyv = { workspace = true } rkyv = { workspace = true }
portable-pty = { workspace = true, optional = true } portable-pty = { workspace = true }
unshell-macros = { workspace = true } unshell-macros = { workspace = true }
unshell-protocol = { 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 /// 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)] #[derive(Default)]
pub struct RemoteShellEndpoint { pub struct RemoteShell {
sessions: BTreeMap<HookKey, Open>, sessions: BTreeMap<HookKey, Open>,
} }
impl ProcedureStore<Open> for RemoteShellEndpoint { impl ProcedureStore<Open> for RemoteShell {
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, Open> { fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, Open> {
&mut self.sessions &mut self.sessions
} }
} }
impl Procedure<RemoteShellEndpoint> for Open { impl Procedure<RemoteShell> 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 RemoteShell, 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)?;
Open::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(
_leaf: &mut RemoteShellEndpoint, _leaf: &mut RemoteShell,
session: &mut Self, session: &mut Self,
data: unshell::protocol::tree::IncomingData, data: unshell::protocol::tree::IncomingData,
) -> Result<ProcedureEffect, Self::Error> { ) -> Result<ProcedureEffect, Self::Error> {
@@ -48,21 +48,18 @@ impl Procedure<RemoteShellEndpoint> for Open {
} }
fn on_fault( fn on_fault(
_leaf: &mut RemoteShellEndpoint, _leaf: &mut RemoteShell,
_session: &mut Self, _session: &mut Self,
_fault: unshell::protocol::tree::IncomingFault, _fault: unshell::protocol::tree::IncomingFault,
) -> Result<(), Self::Error> { ) -> Result<(), Self::Error> {
Ok(()) Ok(())
} }
fn poll( fn poll(_leaf: &mut RemoteShell, session: &mut Self) -> Result<ProcedureEffect, Self::Error> {
_leaf: &mut RemoteShellEndpoint,
session: &mut Self,
) -> Result<ProcedureEffect, Self::Error> {
session.poll() 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() session.terminate()
} }
} }
@@ -14,7 +14,7 @@ use portable_pty::{CommandBuilder, ExitStatus, PtySize, native_pty_system};
use unshell::Procedure; use unshell::Procedure;
use unshell::protocol::tree::{IncomingData, OutgoingData, ProcedureEffect}; use unshell::protocol::tree::{IncomingData, OutgoingData, ProcedureEffect};
use super::RemoteShellEndpoint; use super::RemoteShell;
use super::errors::ShellLeafError; use super::errors::ShellLeafError;
/// Per-hook shell session created by the `open` procedure. /// 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 /// The procedure type is also the stored session type so the mapping between
/// 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 = RemoteShell, name = "open")]
pub struct Open { 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>,
+9 -65
View File
@@ -1,48 +1,14 @@
//! Remote shell leaf and its user-facing surfaces. //! Remote shell leaf and its user-facing surfaces.
//! //!
//! The module always exports the protocol contract for the leaf. Role-specific //! The module always exports the protocol contract for the leaf together with the
//! implementations live behind crate-wide features: //! endpoint and TUI host implementations.
//! - `leaf_endpoint` builds the PTY-backed runtime leaf
//! - `leaf_tui` builds a placeholder client-side TUI surface
use rkyv::{Archive, Deserialize, Serialize}; use rkyv::{Archive, Deserialize, Serialize};
#[cfg(not(feature = "leaf_endpoint"))] use unshell_macros::leaf;
use std::string::String;
#[cfg(feature = "leaf_endpoint")]
pub mod endpoint; pub mod endpoint;
#[cfg(feature = "leaf_tui")]
pub mod 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. /// Open-request payload for the remote shell leaf.
/// ///
/// The shell currently needs no structured arguments, but a named payload type is /// 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)] #[derive(Archive, Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct OpenRequest; pub struct OpenRequest;
#[cfg(any(feature = "leaf_endpoint", feature = "leaf_tui"))] #[leaf(
macro_rules! declare_remote_shell_leaf {
($($role_args:tt)*) => {
crate::leaf! {
name = "remote_shell", name = "remote_shell",
procedures = [Open], procedures = [Open],
$($role_args)* endpoint = endpoint,
} tui = tui,
}; )]
} /// Shared compile-time declaration for the `remote_shell` leaf surface.
pub struct RemoteShell;
#[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,
}
}
+10 -3
View File
@@ -8,23 +8,24 @@ use std::string::String;
use std::vec::Vec; use std::vec::Vec;
use unshell::protocol::DataMessage; use unshell::protocol::DataMessage;
use unshell_macros::Procedure;
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)] #[derive(Default)]
pub struct RemoteShellTui { pub struct RemoteShell {
transcript: Vec<u8>, transcript: Vec<u8>,
} }
impl RemoteShellTui { impl RemoteShell {
/// Returns a short explanation of the current stub status. /// Returns a short explanation of the current stub status.
pub fn status_line(&self) -> &'static str { pub fn status_line(&self) -> &'static str {
"remote shell TUI stub: rendering is placeholder-only for now" "remote shell TUI stub: rendering is placeholder-only for now"
} }
} }
impl LeafTui for RemoteShellTui { impl LeafTui for RemoteShell {
fn leaf_name(&self) -> String { fn leaf_name(&self) -> String {
Self::protocol_leaf_name() Self::protocol_leaf_name()
} }
@@ -39,3 +40,9 @@ impl LeafTui for RemoteShellTui {
format!("{}\n\n{}", self.status_line(), body) 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 - deriving canonical procedure identifiers
- generating compile-time procedure inventories for leaves - 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 repeating the metadata on each host
- generating dispatch glue for simple call-driven leaves - generating dispatch glue for simple call-driven leaves
@@ -32,7 +32,7 @@ The declaration answers:
- what is this leaf called on the wire? - what is this leaf called on the wire?
- which procedure suffixes belong to it? - 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. 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. Those hosts should not each have to repeat the leaf name or procedure inventory.
They bind to the declaration instead. 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 ### 3. Procedure and method metadata
Procedures and future typed remote methods need stable canonical identifiers. Procedures and future typed remote methods need stable canonical identifiers.
@@ -56,9 +78,9 @@ compile-time inventory instead of handwritten lists.
## Current direction ## 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 - `#[derive(Procedure)]` derives stateful procedure metadata
- `#[procedures]` derives one-shot call dispatch for simple leaves - `#[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 - generated code should stay explicit enough to debug
- endpoint and TUI roles should share metadata but not be forced into the same - endpoint and TUI roles should share metadata but not be forced into the same
runtime trait when their behavior differs 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 - migration should be low-breakage for the existing examples and tests
## Non-goals ## Non-goals
+158 -110
View File
@@ -1,89 +1,46 @@
use proc_macro2::TokenStream; use proc_macro2::TokenStream;
use quote::{format_ident, quote}; use quote::{format_ident, quote};
use syn::{ use syn::{
Error, Ident, LitStr, Result, Token, Visibility, Error, ItemStruct, LitStr, Path, Result, Token,
parse::{Parse, ParseStream}, parse::{Parse, ParseStream},
punctuated::Punctuated, punctuated::Punctuated,
}; };
use crate::utils::option_litstr_tokens; use crate::utils::option_litstr_tokens;
pub(crate) struct LeafDeclarationInput { pub(crate) struct LeafDeclarationAttributes {
visibility: Visibility,
name: Option<LitStr>, name: Option<LitStr>,
id: Option<LitStr>, id: Option<LitStr>,
org: Option<LitStr>, org: Option<LitStr>,
product: Option<LitStr>, product: Option<LitStr>,
version: Option<LitStr>, version: Option<LitStr>,
endpoint_struct: Option<Ident>,
tui_struct: Option<Ident>,
procedures: Vec<ProcedureRef>, procedures: Vec<ProcedureRef>,
host_bindings: Vec<HostBinding>,
} }
impl Parse for LeafDeclarationInput { impl Parse for LeafDeclarationAttributes {
fn parse(input: ParseStream<'_>) -> Result<Self> { 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 assignments = Punctuated::<LeafAssignment, Token![,]>::parse_terminated(input)?;
let mut parsed = Self { let mut parsed = Self {
visibility,
name: None, name: None,
id: None, id: None,
org: None, org: None,
product: None, product: None,
version: None, version: None,
endpoint_struct: None,
tui_struct: None,
procedures: Vec::new(), procedures: Vec::new(),
host_bindings: Vec::new(),
}; };
for assignment in assignments { for assignment in assignments {
match assignment { match assignment {
LeafAssignment::Name(value) => { LeafAssignment::Name(value) => set_once(&mut parsed.name, value, "leaf name")?,
if parsed.name.is_some() { LeafAssignment::Id(value) => set_once(&mut parsed.id, value, "leaf id")?,
return Err(Error::new_spanned(value, "duplicate leaf name")); LeafAssignment::Org(value) => set_once(&mut parsed.org, value, "leaf org")?,
}
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) => { LeafAssignment::Product(value) => {
if parsed.product.is_some() { set_once(&mut parsed.product, value, "leaf product")?
return Err(Error::new_spanned(value, "duplicate leaf product"));
}
parsed.product = Some(value);
} }
LeafAssignment::Version(value) => { LeafAssignment::Version(value) => {
if parsed.version.is_some() { set_once(&mut parsed.version, value, "leaf version")?
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) => { LeafAssignment::Procedures(values) => {
if !parsed.procedures.is_empty() { if !parsed.procedures.is_empty() {
@@ -91,19 +48,20 @@ impl Parse for LeafDeclarationInput {
} }
parsed.procedures = values; parsed.procedures = values;
} }
LeafAssignment::HostBinding(binding) => parsed.host_bindings.push(binding),
} }
} }
if parsed.name.is_none() && parsed.id.is_none() { if parsed.name.is_none() && parsed.id.is_none() {
return Err(Error::new( return Err(Error::new(
input.span(), 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( return Err(Error::new(
input.span(), 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), Org(LitStr),
Product(LitStr), Product(LitStr),
Version(LitStr), Version(LitStr),
EndpointStruct(Ident),
TuiStruct(Ident),
Procedures(Vec<ProcedureRef>), Procedures(Vec<ProcedureRef>),
HostBinding(HostBinding),
}
struct HostBinding {
module_path: Option<Path>,
host_path: Option<Path>,
} }
enum ProcedureRef { enum ProcedureRef {
Symbol(Ident), Symbol(Path),
Suffix(LitStr), Suffix(LitStr),
} }
@@ -138,16 +100,26 @@ impl Parse for ProcedureRef {
impl Parse for LeafAssignment { impl Parse for LeafAssignment {
fn parse(input: ParseStream<'_>) -> Result<Self> { fn parse(input: ParseStream<'_>) -> Result<Self> {
let name: Ident = input.parse()?; let name: Path = input.parse()?;
input.parse::<Token![=]>()?; 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()?)), "name" => Ok(Self::Name(input.parse()?)),
"id" => Ok(Self::Id(input.parse()?)), "id" => Ok(Self::Id(input.parse()?)),
"org" => Ok(Self::Org(input.parse()?)), "org" => Ok(Self::Org(input.parse()?)),
"product" => Ok(Self::Product(input.parse()?)), "product" => Ok(Self::Product(input.parse()?)),
"version" => Ok(Self::Version(input.parse()?)), "version" => Ok(Self::Version(input.parse()?)),
"endpoint_struct" => Ok(Self::EndpointStruct(input.parse()?)), "endpoint_struct" | "tui_struct" => Ok(Self::HostBinding(HostBinding {
"tui_struct" => Ok(Self::TuiStruct(input.parse()?)), module_path: None,
host_path: Some(input.parse()?),
})),
"endpoint" | "tui" => Ok(Self::HostBinding(HostBinding {
module_path: Some(input.parse()?),
host_path: None,
})),
"procedures" => { "procedures" => {
let content; let content;
syn::bracketed!(content in input); syn::bracketed!(content in input);
@@ -156,57 +128,47 @@ impl Parse for LeafAssignment {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
Ok(Self::Procedures(values)) 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> { pub(crate) fn expand_leaf_declaration(
let _visibility = input.visibility; attr: LeafDeclarationAttributes,
let declaration_ident = format_ident!( item: ItemStruct,
"__UnshellLeafDecl_{}", ) -> Result<TokenStream> {
input let declaration_ident = item.ident.clone();
.endpoint_struct let id = option_litstr_tokens(attr.id.as_ref());
.as_ref() let org = option_litstr_tokens(attr.org.as_ref());
.or(input.tui_struct.as_ref()) let product = option_litstr_tokens(attr.product.as_ref());
.expect("leaf declaration requires at least one host") let version = option_litstr_tokens(attr.version.as_ref());
); let leaf_name = option_litstr_tokens(attr.name.as_ref());
let id = option_litstr_tokens(input.id.as_ref()); let canonical_procedure_module = attr
let org = option_litstr_tokens(input.org.as_ref()); .host_bindings
let product = option_litstr_tokens(input.product.as_ref()); .iter()
let version = option_litstr_tokens(input.version.as_ref()); .find_map(|binding| binding.module_path.as_ref())
let leaf_name = option_litstr_tokens(input.name.as_ref()); .cloned();
let procedure_suffixes = input let procedure_suffixes = attr
.procedures .procedures
.iter() .iter()
.map(|procedure| match procedure { .map(|procedure| procedure_suffix_tokens(procedure, canonical_procedure_module.as_ref()))
ProcedureRef::Symbol(procedure) => { .collect::<Result<Vec<_>>>()?;
quote! { <#procedure as ::unshell::protocol::tree::ProcedureMetadata>::PROCEDURE_SUFFIX } let procedure_type_checks = attr
} .host_bindings
ProcedureRef::Suffix(suffix) => quote! { #suffix },
})
.collect::<Vec<_>>();
let procedure_type_checks = input
.procedures
.iter() .iter()
.filter_map(|procedure| match procedure { .map(|binding| procedure_type_check_tokens(binding, &attr.procedures, &declaration_ident))
ProcedureRef::Symbol(procedure) => Some(procedure), .collect::<Result<Vec<_>>>()?;
ProcedureRef::Suffix(_) => None, let host_impls = attr
}); .host_bindings
.iter()
let endpoint_impl = input .map(|binding| expand_binding_impl(binding, &declaration_ident))
.endpoint_struct .collect::<Result<Vec<_>>>()?;
.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! { Ok(quote! {
#[allow(non_camel_case_types)] #item
#[doc(hidden)]
pub struct #declaration_ident;
impl ::unshell::protocol::tree::ProtocolLeaf for #declaration_ident { impl ::unshell::protocol::tree::ProtocolLeaf for #declaration_ident {
fn leaf_name() -> ::unshell::alloc::string::String { fn leaf_name() -> ::unshell::alloc::string::String {
@@ -252,16 +214,16 @@ pub(crate) fn expand_leaf_declaration(input: LeafDeclarationInput) -> Result<Tok
} }
const _: fn() = || { const _: fn() = || {
#(let _ = ::core::marker::PhantomData::<#procedure_type_checks>;)* #(#procedure_type_checks)*
}; };
#endpoint_impl #(#host_impls)*
#tui_impl
}) })
} }
fn expand_binding_impl(host: &Ident, declaration: &Ident) -> TokenStream { fn expand_binding_impl(binding: &HostBinding, declaration: &syn::Ident) -> Result<TokenStream> {
quote! { let host = host_path_for_binding(binding, declaration)?;
Ok(quote! {
impl ::unshell::protocol::tree::ProtocolLeaf for #host { impl ::unshell::protocol::tree::ProtocolLeaf for #host {
fn leaf_name() -> ::unshell::alloc::string::String { fn leaf_name() -> ::unshell::alloc::string::String {
<#declaration as ::unshell::protocol::tree::ProtocolLeaf>::leaf_name() <#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) <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; mod utils;
use proc_macro::TokenStream; 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 /// Declares one compile-time leaf surface and binds it to endpoint and/or TUI
/// host structs. /// host structs.
/// ///
/// What it is: a function-like macro that generates the shared protocol-visible /// What it is: an attribute macro placed on a marker struct that generates the
/// metadata for one leaf and applies that metadata to the listed host structs. /// 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 /// 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 /// name and procedure inventory, and endpoint construction should not need a
@@ -20,18 +21,20 @@ use syn::{DeriveInput, ItemImpl, parse_macro_input};
/// ///
/// # Example /// # Example
/// ```ignore /// ```ignore
/// unshell::leaf! { /// #[unshell::leaf(
/// name = "remote_shell", /// name = "remote_shell",
/// procedures = [Open, Reset, whoami], /// procedures = [Open],
/// endpoint_struct = RemoteShellEndpoint, /// leaf_endpoint = endpoint::RemoteShellEndpoint,
/// tui_struct = RemoteShellTui, /// leaf_tui = tui::RemoteShellTui,
/// } /// )]
/// pub struct RemoteShell;
/// ``` /// ```
#[proc_macro] #[proc_macro_attribute]
pub fn leaf(input: TokenStream) -> TokenStream { pub fn leaf(attr: TokenStream, item: TokenStream) -> TokenStream {
match leaf_decl::expand_leaf_declaration(parse_macro_input!( match leaf_decl::expand_leaf_declaration(
input as leaf_decl::LeafDeclarationInput parse_macro_input!(attr as leaf_decl::LeafDeclarationAttributes),
)) { parse_macro_input!(item as ItemStruct),
) {
Ok(tokens) => tokens.into(), Ok(tokens) => tokens.into(),
Err(error) => error.to_compile_error().into(), Err(error) => error.to_compile_error().into(),
} }
@@ -49,13 +52,12 @@ pub fn leaf(input: TokenStream) -> TokenStream {
/// ```ignore /// ```ignore
/// use unshell::{Procedure, leaf}; /// use unshell::{Procedure, leaf};
/// ///
/// struct ShellLeaf; /// #[leaf(
///
/// leaf! {
/// name = "shell", /// name = "shell",
/// procedures = [OpenSession], /// procedures = [OpenSession],
/// endpoint_struct = ShellLeaf, /// endpoint_struct = ShellLeaf,
/// } /// )]
/// struct Shell;
/// ///
/// struct ShellLeaf; /// struct ShellLeaf;
/// ///
@@ -86,13 +88,14 @@ pub fn derive_procedure(input: TokenStream) -> TokenStream {
/// ```ignore /// ```ignore
/// use unshell::{leaf, procedures}; /// use unshell::{leaf, procedures};
/// ///
/// struct EchoLeaf; /// #[leaf(
///
/// leaf! {
/// id = "org.example.v1.echo", /// id = "org.example.v1.echo",
/// procedures = [echo], /// procedures = ["echo"],
/// endpoint_struct = EchoLeaf, /// endpoint_struct = EchoLeaf,
/// } /// )]
/// struct Echo;
///
/// struct EchoLeaf;
/// ///
/// #[procedures(error = core::convert::Infallible)] /// #[procedures(error = core::convert::Infallible)]
/// impl EchoLeaf { /// impl EchoLeaf {
+2 -5
View File
@@ -18,11 +18,8 @@ struct EchoLeaf {
prefix: String, prefix: String,
} }
leaf! { #[leaf(id = "org.example.v1.echo", endpoint_struct = EchoLeaf, procedures = ["echo"])]
id = "org.example.v1.echo", struct Echo;
endpoint_struct = EchoLeaf,
procedures = ["echo"],
}
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
struct EchoRequest { struct EchoRequest {
@@ -17,11 +17,8 @@ impl ProcedureMetadata for Reset {
const PROCEDURE_SUFFIX: &'static str = "reset"; const PROCEDURE_SUFFIX: &'static str = "reset";
} }
leaf! { #[leaf(id = "org.example.v1.demo", procedures = [Open, Reset], endpoint_struct = EndpointHost)]
id = "org.example.v1.demo", struct Demo;
procedures = [Open, Reset],
endpoint_struct = EndpointHost,
}
struct EndpointHalf; struct EndpointHalf;
struct TuiHalf; struct TuiHalf;
@@ -32,7 +29,7 @@ impl ProcedureMetadata for Connect {
const PROCEDURE_SUFFIX: &'static str = "connect"; const PROCEDURE_SUFFIX: &'static str = "connect";
} }
leaf! { #[leaf(
name = "chat", name = "chat",
org = "org", org = "org",
product = "example", product = "example",
@@ -40,7 +37,8 @@ leaf! {
procedures = [Connect], procedures = [Connect],
endpoint_struct = EndpointHalf, endpoint_struct = EndpointHalf,
tui_struct = TuiHalf, tui_struct = TuiHalf,
} )]
struct Chat;
struct TuiOnly; struct TuiOnly;
struct Tail; struct Tail;
@@ -50,11 +48,8 @@ impl ProcedureMetadata for Tail {
const PROCEDURE_SUFFIX: &'static str = "tail"; const PROCEDURE_SUFFIX: &'static str = "tail";
} }
leaf! { #[leaf(id = "org.example.v1.transcript", procedures = [Tail], tui_struct = TuiOnly)]
id = "org.example.v1.transcript", struct Transcript;
procedures = [Tail],
tui_struct = TuiOnly,
}
#[test] #[test]
fn leaf_declaration_generates_endpoint_host_metadata() { fn leaf_declaration_generates_endpoint_host_metadata() {
@@ -17,11 +17,8 @@ struct StreamLeaf {
sessions: BTreeMap<HookKey, ProcedureOpen>, sessions: BTreeMap<HookKey, ProcedureOpen>,
} }
leaf! { #[leaf(id = "org.example.v1.stream", procedures = [ProcedureOpen], endpoint_struct = StreamLeaf)]
id = "org.example.v1.stream", struct Stream;
procedures = [ProcedureOpen],
endpoint_struct = StreamLeaf,
}
impl ProcedureStore<ProcedureOpen> for StreamLeaf { impl ProcedureStore<ProcedureOpen> for StreamLeaf {
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, ProcedureOpen> { fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, ProcedureOpen> {
@@ -146,11 +143,8 @@ struct DuplexLeaf {
sessions: BTreeMap<HookKey, DuplexProcedure>, sessions: BTreeMap<HookKey, DuplexProcedure>,
} }
leaf! { #[leaf(id = "org.example.v1.duplex", procedures = [DuplexProcedure], endpoint_struct = DuplexLeaf)]
id = "org.example.v1.duplex", struct Duplex;
procedures = [DuplexProcedure],
endpoint_struct = DuplexLeaf,
}
impl ProcedureStore<DuplexProcedure> for DuplexLeaf { impl ProcedureStore<DuplexProcedure> for DuplexLeaf {
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, DuplexProcedure> { fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, DuplexProcedure> {