mirror of
https://github.com/Astatin3/unshell.git
synced 2026-06-08 22:38:01 -06:00
Big rewrite.
This commit is contained in:
@@ -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.
|
||||
@@ -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 }
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user