Big rewrite.

This commit is contained in:
Michael Mikovsky
2026-05-16 13:10:51 -06:00
parent da9166daf0
commit 56abb5e1e0
63 changed files with 4 additions and 14547 deletions
-109
View File
@@ -1,109 +0,0 @@
# UnShell Macros
This crate owns the compile-time declaration layer for UnShell application-facing
leaves.
## Purpose
The protocol crate intentionally stays generic: it knows how to route packets,
validate framing, and deliver local events, but it should not need handwritten
registration code for every leaf.
The macro layer exists to move as much of that registration work as possible to
compile time.
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 modules without
repeating the metadata on each host
- generating dispatch glue for simple call-driven leaves
## Model
There are three layers in the intended design.
### 1. Leaf declaration
One declaration is the source of truth for one protocol leaf.
The declaration answers:
- what is this leaf called on the wire?
- which procedure suffixes belong to it?
- which host modules implement its endpoint and TUI roles?
The goal is that this information is written once and reused everywhere.
### 2. Host structs
One leaf can have multiple host structs with different responsibilities.
- the endpoint host owns runtime state and protocol-side behavior
- the TUI host owns user-interface state and interpretation behavior
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.
The macro layer generates those identifiers from the leaf declaration and the
local suffix for each procedure or method. That lets the runtime consume a
compile-time inventory instead of handwritten lists.
## Current direction
The public declaration model is now centered on `#[leaf(...)]`.
- `#[leaf(...)]` declares the canonical protocol surface once
- `#[derive(Procedure)]` derives stateful procedure metadata
- `#[procedures]` derives one-shot call dispatch for simple leaves
The next evolution from here is typed remote-method metadata on top of the same
declaration model.
## Design constraints
The system is optimized for a few constraints that matter to this repository.
- compile-time declaration should replace handwritten runtime registration where
possible
- protocol-visible names should remain deterministic and canonical
- 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
This crate does not own transport, connection management, or packet execution.
Those remain in `unshell-protocol` and higher application layers.
The macro crate should generate metadata and glue, not hide the runtime model.
-13
View File
@@ -1,13 +0,0 @@
[package]
name = "unshell-macros"
version.workspace = true
edition.workspace = true
description = "Proc macros for unshell leaf declarations"
[lib]
proc-macro = true
[dependencies]
syn = { workspace = true, features = ["full"] }
quote = { workspace = true }
proc-macro2 = { workspace = true }
-348
View File
@@ -1,348 +0,0 @@
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{
Error, ItemStruct, LitStr, Path, Result, Token,
parse::{Parse, ParseStream},
punctuated::Punctuated,
};
use crate::utils::option_litstr_tokens;
pub(crate) struct LeafDeclarationAttributes {
name: Option<LitStr>,
id: Option<LitStr>,
org: Option<LitStr>,
product: Option<LitStr>,
version: Option<LitStr>,
procedures: Vec<ProcedureRef>,
host_bindings: Vec<HostBinding>,
}
impl Parse for LeafDeclarationAttributes {
fn parse(input: ParseStream<'_>) -> Result<Self> {
let assignments = Punctuated::<LeafAssignment, Token![,]>::parse_terminated(input)?;
let mut parsed = Self {
name: None,
id: None,
org: None,
product: None,
version: None,
procedures: Vec::new(),
host_bindings: Vec::new(),
};
for assignment in assignments {
match assignment {
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) => {
set_once(&mut parsed.product, value, "leaf product")?
}
LeafAssignment::Version(value) => {
set_once(&mut parsed.version, value, "leaf version")?
}
LeafAssignment::Procedures(values) => {
if !parsed.procedures.is_empty() {
return Err(Error::new(input.span(), "duplicate procedures list"));
}
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 = \"...\"`",
));
}
if parsed.host_bindings.is_empty() {
return Err(Error::new(
input.span(),
"#[leaf(...)] requires at least one host binding",
));
}
Ok(parsed)
}
}
enum LeafAssignment {
Name(LitStr),
Id(LitStr),
Org(LitStr),
Product(LitStr),
Version(LitStr),
Procedures(Vec<ProcedureRef>),
HostBinding(HostBinding),
}
struct HostBinding {
module_path: Option<Path>,
host_path: Option<Path>,
}
enum ProcedureRef {
Symbol(Path),
Suffix(LitStr),
}
impl Parse for ProcedureRef {
fn parse(input: ParseStream<'_>) -> Result<Self> {
if input.peek(LitStr) {
return Ok(Self::Suffix(input.parse()?));
}
Ok(Self::Symbol(input.parse()?))
}
}
impl Parse for LeafAssignment {
fn parse(input: ParseStream<'_>) -> Result<Self> {
let name: Path = input.parse()?;
input.parse::<Token![=]>()?;
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" | "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);
let values = Punctuated::<ProcedureRef, Token![,]>::parse_terminated(&content)?
.into_iter()
.collect::<Vec<_>>();
Ok(Self::Procedures(values))
}
_ => 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(
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| procedure_suffix_tokens(procedure, canonical_procedure_module.as_ref()))
.collect::<Result<Vec<_>>>()?;
let procedure_type_checks = attr
.host_bindings
.iter()
.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! {
#item
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)
}
}
const _: fn() = || {
#(#procedure_type_checks)*
};
#(#host_impls)*
})
}
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()
}
}
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 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(())
}
-119
View File
@@ -1,119 +0,0 @@
//! Proc macros for `unshell` application-layer leaf declarations.
mod leaf_decl;
mod procedure;
mod procedures;
mod utils;
use proc_macro::TokenStream;
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: 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
/// handwritten list of procedure ids.
///
/// # Example
/// ```ignore
/// #[unshell::leaf(
/// name = "remote_shell",
/// procedures = [Open],
/// leaf_endpoint = endpoint::RemoteShellEndpoint,
/// leaf_tui = tui::RemoteShellTui,
/// )]
/// pub struct RemoteShell;
/// ```
#[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(),
}
}
/// Derives canonical stateful-procedure metadata for one procedure type.
///
/// What it is: a derive macro that records one procedure suffix and generates
/// the canonical `protocol_procedure_id()` helper for that procedure.
///
/// Why it exists: hook-backed procedures need one stable `procedure_id`, but the
/// runtime should not require each procedure to handwrite the identifier logic.
///
/// # Example
/// ```ignore
/// use unshell::{Procedure, leaf};
///
/// #[leaf(
/// name = "shell",
/// procedures = [OpenSession],
/// endpoint_struct = ShellLeaf,
/// )]
/// struct Shell;
///
/// struct ShellLeaf;
///
/// #[derive(Procedure)]
/// #[procedure(leaf = ShellLeaf, name = "open")]
/// struct OpenSession;
///
/// assert!(OpenSession::protocol_procedure_id().ends_with(".open"));
/// ```
#[proc_macro_derive(Procedure, attributes(procedure))]
pub fn derive_procedure(input: TokenStream) -> TokenStream {
match procedure::expand_procedure(parse_macro_input!(input as DeriveInput)) {
Ok(tokens) => tokens.into(),
Err(error) => error.to_compile_error().into(),
}
}
/// Generates dispatch glue for a simple call-driven leaf impl block.
///
/// What it is: an attribute macro placed on one `impl` block whose `#[call]`
/// methods define the callable surface for that leaf.
///
/// Why it exists: one-shot leaves should be able to declare a small RPC-like API
/// on ordinary Rust methods while still producing the canonical procedure list
/// and dispatch logic expected by the protocol runtime.
///
/// # Example
/// ```ignore
/// use unshell::{leaf, procedures};
///
/// #[leaf(
/// id = "org.example.v1.echo",
/// procedures = ["echo"],
/// endpoint_struct = EchoLeaf,
/// )]
/// struct Echo;
///
/// struct EchoLeaf;
///
/// #[procedures(error = core::convert::Infallible)]
/// impl EchoLeaf {
/// #[call]
/// fn echo(&mut self, input: String) -> String {
/// input
/// }
/// }
///
/// assert!(EchoLeaf::protocol_procedure_id("echo").is_some());
/// ```
#[proc_macro_attribute]
pub fn procedures(attr: TokenStream, item: TokenStream) -> TokenStream {
match procedures::expand_procedures(
parse_macro_input!(attr as procedures::ProceduresAttributes),
parse_macro_input!(item as ItemImpl),
) {
Ok(tokens) => tokens.into(),
Err(error) => error.to_compile_error().into(),
}
}
-108
View File
@@ -1,108 +0,0 @@
use quote::quote;
use syn::{Attribute, Data, DeriveInput, Error, LitStr, Result, Type};
#[derive(Default)]
struct ProcedureAttributes {
leaf: Option<Type>,
name: Option<LitStr>,
}
impl ProcedureAttributes {
fn parse_from(attrs: &[Attribute]) -> Result<Self> {
let mut parsed = Self::default();
for attr in attrs {
if !attr.path().is_ident("procedure") {
continue;
}
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("leaf") {
if parsed.leaf.is_some() {
return Err(meta.error("duplicate procedure leaf attribute"));
}
parsed.leaf = Some(meta.value()?.parse()?);
return Ok(());
}
if meta.path.is_ident("name") {
if parsed.name.is_some() {
return Err(meta.error("duplicate procedure name attribute"));
}
parsed.name = Some(meta.value()?.parse()?);
return Ok(());
}
Err(meta.error("unsupported #[procedure(...)] attribute"))
})?;
}
Ok(parsed)
}
}
pub(crate) fn expand_procedure(input: DeriveInput) -> Result<proc_macro2::TokenStream> {
let procedure_name = input.ident;
match input.data {
Data::Struct(_) => {}
_ => {
return Err(Error::new_spanned(
procedure_name,
"Procedure can only be derived for structs",
));
}
};
let parsed = ProcedureAttributes::parse_from(&input.attrs)?;
let leaf_ty = parsed.leaf.ok_or_else(|| {
Error::new_spanned(
&procedure_name,
"missing #[procedure(leaf = LeafType, name = \"...\")] attribute",
)
})?;
let suffix = parsed.name.ok_or_else(|| {
Error::new_spanned(
&procedure_name,
"missing #[procedure(leaf = LeafType, name = \"...\")] attribute",
)
})?;
if suffix.value().is_empty() {
return Err(Error::new_spanned(
&suffix,
"procedure name must not be empty",
));
}
if suffix.value().contains('.') {
return Err(Error::new_spanned(
&suffix,
"procedure name must be one local suffix without dots",
));
}
if suffix.value().chars().any(char::is_whitespace) {
return Err(Error::new_spanned(
&suffix,
"procedure name must not contain whitespace",
));
}
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
Ok(quote! {
impl #impl_generics ::unshell::protocol::tree::ProcedureMetadata
for #procedure_name #ty_generics #where_clause
where
#leaf_ty: ::unshell::protocol::tree::ProtocolLeaf,
{
type Leaf = #leaf_ty;
const PROCEDURE_SUFFIX: &'static str = #suffix;
}
impl #impl_generics #procedure_name #ty_generics #where_clause {
/// Returns the full canonical `procedure_id` for this stateful procedure.
pub fn protocol_procedure_id() -> ::unshell::alloc::string::String {
<Self as ::unshell::protocol::tree::ProcedureMetadata>::procedure_id()
}
}
})
}
-403
View File
@@ -1,403 +0,0 @@
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{
Error, FnArg, Ident, ImplItem, ImplItemFn, ItemImpl, LitStr, PatType, Result, ReturnType,
Token, Type, parse::Parse, punctuated::Punctuated,
};
use crate::utils::{
extract_outer_type_argument, extract_result_type_arguments, is_unit_type, take_call_attr,
};
#[derive(Default)]
pub(crate) struct ProceduresAttributes {
error: Option<Type>,
}
impl Parse for ProceduresAttributes {
fn parse(input: syn::parse::ParseStream<'_>) -> Result<Self> {
if input.is_empty() {
return Ok(Self::default());
}
let mut parsed = Self::default();
let assignments = Punctuated::<Assignment, Token![,]>::parse_terminated(input)?;
for assignment in assignments {
if assignment.name == "error" {
if parsed.error.is_some() {
return Err(Error::new_spanned(
assignment.name,
"duplicate procedures error attribute",
));
}
parsed.error = Some(assignment.value);
continue;
}
return Err(Error::new_spanned(
assignment.name,
"unsupported #[procedures(...)] attribute",
));
}
Ok(parsed)
}
}
struct Assignment {
name: Ident,
value: Type,
}
impl Parse for Assignment {
fn parse(input: syn::parse::ParseStream<'_>) -> Result<Self> {
Ok(Self {
name: input.parse()?,
value: {
input.parse::<Token![=]>()?;
input.parse()?
},
})
}
}
struct CallArm {
suffix_literal: LitStr,
dispatch_tokens: TokenStream,
}
#[derive(Clone, Copy)]
enum EndpointArgKind {
Shared,
Mutable,
}
pub(crate) fn expand_procedures(
attr: ProceduresAttributes,
mut item: ItemImpl,
) -> Result<TokenStream> {
let self_ty = item.self_ty.clone();
let impl_generics = item.generics.clone();
let (impl_generics_tokens, _ty_generics, where_clause) = impl_generics.split_for_impl();
let error_ty = attr.error.ok_or_else(|| {
Error::new_spanned(
&item.self_ty,
"missing #[procedures(error = MyError)] attribute",
)
})?;
let mut dispatch_arms = Vec::new();
let mut seen_suffixes = std::collections::BTreeSet::new();
for impl_item in &mut item.items {
let ImplItem::Fn(method) = impl_item else {
continue;
};
let has_call_attr = method.attrs.iter().any(|attr| attr.path().is_ident("call"));
if !has_call_attr {
continue;
}
let arm = expand_call_arm(method)?;
take_call_attr(&mut method.attrs);
if !seen_suffixes.insert(arm.suffix_literal.value()) {
return Err(Error::new_spanned(
method,
"duplicate #[call] procedure suffix in this impl block",
));
}
dispatch_arms.push(arm);
}
if dispatch_arms.is_empty() {
return Err(Error::new_spanned(
&item.self_ty,
"#[procedures] requires at least one #[call] method",
));
}
let dispatch_checks = dispatch_arms.iter().map(|arm| arm.dispatch_tokens.clone());
Ok(quote! {
#item
impl #impl_generics_tokens ::unshell::protocol::tree::CallProcedures for #self_ty #where_clause {
type Error = #error_ty;
fn dispatch_call(
&mut self,
endpoint: &mut ::unshell::protocol::tree::ProtocolEndpoint,
call: ::unshell::protocol::tree::IncomingCall,
) -> ::core::result::Result<
::unshell::protocol::tree::CallReply,
::unshell::protocol::tree::DispatchError<Self::Error>,
> {
#(#dispatch_checks)*
unreachable!("protocol runtime validated local procedure dispatch")
}
}
})
}
fn expand_call_arm(method: &ImplItemFn) -> Result<CallArm> {
let method_name = &method.sig.ident;
let suffix_literal = call_suffix_literal(method)?;
let call_id_expr = quote! {
{
let mut __unshell_id = <Self as ::unshell::protocol::tree::ProtocolLeaf>::leaf_name();
__unshell_id.push('.');
__unshell_id.push_str(#suffix_literal);
__unshell_id
}
};
let inputs = method
.sig
.inputs
.iter()
.filter(|input| !matches!(input, FnArg::Receiver(_)))
.collect::<Vec<_>>();
let (endpoint_arg, inputs) = split_endpoint_arg(&inputs)?;
let invocation = expand_invocation(method_name, endpoint_arg, &inputs)?;
let return_value = expand_return_conversion(&method.sig.output, quote! { __unshell_result })?;
Ok(CallArm {
suffix_literal: suffix_literal.clone(),
dispatch_tokens: quote! {
if call.message.procedure_id == #call_id_expr {
let __unshell_result = #invocation;
return { #return_value };
}
},
})
}
fn expand_invocation(
method_name: &Ident,
endpoint_arg: Option<EndpointArgKind>,
inputs: &[&FnArg],
) -> Result<TokenStream> {
let endpoint_prefix = endpoint_arg.map(endpoint_arg_tokens);
if inputs.is_empty() {
return Ok(if let Some(prefix) = endpoint_prefix {
quote! { self.#method_name(#prefix) }
} else {
quote! { self.#method_name() }
});
}
if inputs.len() == 1 {
let FnArg::Typed(PatType { ty, .. }) = inputs[0] else {
return Err(Error::new_spanned(
inputs[0],
"unsupported receiver in procedure signature",
));
};
if let Some(inner) = extract_call_inner_type(ty) {
return Ok(quote! {{
let __unshell_input = ::unshell::protocol::tree::decode_call_input::<#inner>(
call.message.data.as_slice(),
)
.map_err(::unshell::protocol::tree::DispatchError::Decode)?;
// Rebuild the normalized `Call<T>` value expected by generated handlers from the
// validated protocol envelope plus the typed payload we just decoded.
let __unshell_call = ::unshell::protocol::tree::Call {
input: __unshell_input,
caller_path: call.header.src_path.clone(),
procedure_id: call.message.procedure_id.clone(),
dst_leaf: call.header.dst_leaf.clone(),
response_hook: call
.message
.response_hook
.as_ref()
.map(|hook| ::unshell::protocol::tree::HookKey::new(
hook.return_path.clone(),
hook.hook_id,
)),
};
self.#method_name(#endpoint_prefix __unshell_call)
}});
}
return Ok(quote! {{
let __unshell_input = ::unshell::protocol::tree::decode_call_input::<#ty>(
call.message.data.as_slice(),
)
.map_err(::unshell::protocol::tree::DispatchError::Decode)?;
self.#method_name(#endpoint_prefix __unshell_input)
}});
}
let tuple_types = inputs
.iter()
.map(|input| match input {
FnArg::Typed(PatType { ty, .. }) => Ok(ty.clone()),
other => Err(Error::new_spanned(
other,
"unsupported receiver in procedure signature",
)),
})
.collect::<Result<Vec<_>>>()?;
let vars = (0..tuple_types.len())
.map(|index| format_ident!("__unshell_arg_{index}"))
.collect::<Vec<_>>();
Ok(quote! {{
let (#(#vars),*) = ::unshell::protocol::tree::decode_call_input::<(#(#tuple_types),*)>(
call.message.data.as_slice(),
)
.map_err(::unshell::protocol::tree::DispatchError::Decode)?;
self.#method_name(#endpoint_prefix #(#vars),*)
}})
}
fn split_endpoint_arg<'a>(
inputs: &[&'a FnArg],
) -> Result<(Option<EndpointArgKind>, Vec<&'a FnArg>)> {
let Some(first) = inputs.first() else {
return Ok((None, Vec::new()));
};
let Some(kind) = endpoint_arg_kind(first)? else {
return Ok((None, inputs.to_vec()));
};
Ok((Some(kind), inputs[1..].to_vec()))
}
fn endpoint_arg_kind(arg: &FnArg) -> Result<Option<EndpointArgKind>> {
let FnArg::Typed(PatType { ty, .. }) = arg else {
return Ok(None);
};
let Type::Reference(reference) = ty.as_ref() else {
return Ok(None);
};
let Type::Path(type_path) = reference.elem.as_ref() else {
return Ok(None);
};
let Some(segment) = type_path.path.segments.last() else {
return Ok(None);
};
if segment.ident != "ProtocolEndpoint" {
return Ok(None);
}
Ok(Some(if reference.mutability.is_some() {
EndpointArgKind::Mutable
} else {
EndpointArgKind::Shared
}))
}
fn endpoint_arg_tokens(kind: EndpointArgKind) -> TokenStream {
match kind {
EndpointArgKind::Shared => quote! { &*endpoint, },
EndpointArgKind::Mutable => quote! { endpoint, },
}
}
fn expand_return_conversion(return_type: &ReturnType, value: TokenStream) -> Result<TokenStream> {
match return_type {
ReturnType::Default => Ok(quote! {
let _ = #value;
::core::result::Result::Ok(::unshell::protocol::tree::CallReply::NoReply)
}),
ReturnType::Type(_, ty) => normalize_output_type(ty, value),
}
}
fn normalize_output_type(ty: &Type, value: TokenStream) -> Result<TokenStream> {
if is_unit_type(ty) {
return Ok(quote! {
let _ = #value;
::core::result::Result::Ok(::unshell::protocol::tree::CallReply::NoReply)
});
}
if let Some(inner) = extract_outer_type_argument(ty, "CallResult") {
let inner_conversion = normalize_reply_value(inner, quote! { __unshell_value })?;
return Ok(quote! {
match #value {
::unshell::protocol::tree::CallResult::Reply(__unshell_value) => {
#inner_conversion
}
::unshell::protocol::tree::CallResult::NoReply => {
::core::result::Result::Ok(::unshell::protocol::tree::CallReply::NoReply)
}
}
});
}
if let Some((ok_ty, _error_ty)) = extract_result_type_arguments(ty) {
let ok_conversion = normalize_output_type(ok_ty, quote! { __unshell_value })?;
return Ok(quote! {
match #value {
::core::result::Result::Ok(__unshell_value) => { #ok_conversion }
::core::result::Result::Err(__unshell_error) => {
::core::result::Result::Err(
::unshell::protocol::tree::DispatchError::Handler(__unshell_error)
)
}
}
});
}
normalize_reply_value(ty, value)
}
fn normalize_reply_value(_ty: &Type, value: TokenStream) -> Result<TokenStream> {
Ok(quote! {
::core::result::Result::Ok(::unshell::protocol::tree::CallReply::Reply(
::unshell::protocol::tree::encode_call_reply(&#value)
.map_err(::unshell::protocol::tree::DispatchError::Encode)?
))
})
}
fn extract_call_inner_type(ty: &Type) -> Option<&Type> {
extract_outer_type_argument(ty, "Call")
}
fn call_suffix_literal(method: &ImplItemFn) -> Result<LitStr> {
let mut suffix = None;
for attr in &method.attrs {
if !attr.path().is_ident("call") {
continue;
}
if matches!(attr.meta, syn::Meta::Path(_)) {
continue;
}
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("name") {
if suffix.is_some() {
return Err(meta.error("duplicate call name attribute"));
}
suffix = Some(meta.value()?.parse()?);
return Ok(());
}
Err(meta.error("unsupported #[call(...)] attribute"))
})?;
}
let suffix = suffix
.unwrap_or_else(|| LitStr::new(&method.sig.ident.to_string(), method.sig.ident.span()));
if suffix.value().is_empty() {
return Err(Error::new_spanned(&suffix, "call name must not be empty"));
}
if suffix.value().contains('.') {
return Err(Error::new_spanned(
&suffix,
"call name must be one local suffix without dots",
));
}
if suffix.value().chars().any(char::is_whitespace) {
return Err(Error::new_spanned(
&suffix,
"call name must not contain whitespace",
));
}
Ok(suffix)
}
-60
View File
@@ -1,60 +0,0 @@
use proc_macro2::TokenStream;
use quote::quote;
use syn::{Attribute, GenericArgument, LitStr, Type, TypePath};
pub(crate) fn option_litstr_tokens(value: Option<&LitStr>) -> TokenStream {
match value {
Some(value) => quote! { ::core::option::Option::Some(#value) },
None => quote! { ::core::option::Option::None },
}
}
pub(crate) fn extract_outer_type_argument<'a>(ty: &'a Type, expected: &str) -> Option<&'a Type> {
let Type::Path(TypePath { path, .. }) = ty else {
return None;
};
let segment = path.segments.last()?;
if segment.ident != expected {
return None;
}
let syn::PathArguments::AngleBracketed(arguments) = &segment.arguments else {
return None;
};
match arguments.args.first()? {
GenericArgument::Type(inner) => Some(inner),
_ => None,
}
}
pub(crate) fn extract_result_type_arguments(ty: &Type) -> Option<(&Type, &Type)> {
let Type::Path(TypePath { path, .. }) = ty else {
return None;
};
let segment = path.segments.last()?;
if segment.ident != "Result" {
return None;
}
let syn::PathArguments::AngleBracketed(arguments) = &segment.arguments else {
return None;
};
let mut args = arguments.args.iter();
let ok = match args.next()? {
GenericArgument::Type(value) => value,
_ => return None,
};
let err = match args.next()? {
GenericArgument::Type(value) => value,
_ => return None,
};
Some((ok, err))
}
pub(crate) fn is_unit_type(ty: &Type) -> bool {
matches!(ty, Type::Tuple(tuple) if tuple.elems.is_empty())
}
pub(crate) fn take_call_attr(attrs: &mut Vec<Attribute>) -> bool {
let original_len = attrs.len();
attrs.retain(|attr| !attr.path().is_ident("call"));
original_len != attrs.len()
}