mirror of
https://github.com/Astatin3/unshell.git
synced 2026-06-08 22:38:01 -06:00
Make macro system and PTY test leaf
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "unshell-macros-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "Parser and code generator for UnShell procedural macros"
|
||||
|
||||
[dependencies]
|
||||
proc-macro2 = { workspace = true }
|
||||
quote = { workspace = true }
|
||||
syn = { workspace = true, features = ["full", "extra-traits"] }
|
||||
|
||||
[lints.rust]
|
||||
elided_lifetimes_in_paths = "warn"
|
||||
future_incompatible = { level = "warn", priority = -1 }
|
||||
nonstandard_style = { level = "warn", priority = -1 }
|
||||
rust_2018_idioms = { level = "warn", priority = -1 }
|
||||
rust_2021_prelude_collisions = "warn"
|
||||
semicolon_in_expressions_from_macros = "warn"
|
||||
unsafe_op_in_unsafe_fn = "warn"
|
||||
unused_import_braces = "warn"
|
||||
unused_lifetimes = "warn"
|
||||
trivial_casts = "allow"
|
||||
@@ -0,0 +1,78 @@
|
||||
use syn::{
|
||||
Expr, Ident, Result, Token, Type,
|
||||
parse::{Parse, ParseStream},
|
||||
};
|
||||
|
||||
/// Parsed arguments from `#[unshell_leaf(...)]`.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct UnshellLeafArgs {
|
||||
pub(crate) leaf: Ident,
|
||||
pub(crate) id: Expr,
|
||||
pub(crate) sessions: Vec<Type>,
|
||||
pub(crate) procedures: Vec<Type>,
|
||||
}
|
||||
|
||||
impl Parse for UnshellLeafArgs {
|
||||
fn parse(input: ParseStream<'_>) -> Result<Self> {
|
||||
let mut leaf = None;
|
||||
let mut id = None;
|
||||
let mut sessions = Vec::new();
|
||||
let mut procedures = Vec::new();
|
||||
|
||||
while !input.is_empty() {
|
||||
let key: Ident = input.parse()?;
|
||||
match key.to_string().as_str() {
|
||||
"leaf" => {
|
||||
reject_duplicate(&leaf, &key)?;
|
||||
input.parse::<Token![=]>()?;
|
||||
leaf = Some(input.parse()?);
|
||||
}
|
||||
"id" => {
|
||||
reject_duplicate(&id, &key)?;
|
||||
input.parse::<Token![=]>()?;
|
||||
id = Some(input.parse()?);
|
||||
}
|
||||
"sessions" => {
|
||||
sessions = parse_type_list(input)?;
|
||||
}
|
||||
"procedures" => {
|
||||
procedures = parse_type_list(input)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(syn::Error::new(
|
||||
key.span(),
|
||||
"expected `leaf`, `id`, `sessions`, or `procedures`",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if input.peek(Token![,]) {
|
||||
input.parse::<Token![,]>()?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
leaf: leaf.ok_or_else(|| input.error("missing `leaf = WrapperName`"))?,
|
||||
id: id.ok_or_else(|| input.error("missing `id = LEAF_ID`"))?,
|
||||
sessions,
|
||||
procedures,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Rejects repeated scalar keys while keeping repeated list keys additive by design.
|
||||
fn reject_duplicate<T>(slot: &Option<T>, key: &Ident) -> Result<()> {
|
||||
if slot.is_some() {
|
||||
Err(syn::Error::new(key.span(), "duplicate key"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `name(Type, Type)` argument payloads.
|
||||
fn parse_type_list(input: ParseStream<'_>) -> Result<Vec<Type>> {
|
||||
let content;
|
||||
syn::parenthesized!(content in input);
|
||||
let parsed = content.parse_terminated(Type::parse, Token![,])?;
|
||||
Ok(parsed.into_iter().collect())
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
use proc_macro2::TokenStream;
|
||||
use quote::{format_ident, quote};
|
||||
use syn::{Ident, ItemStruct, Result, Type};
|
||||
|
||||
use super::{
|
||||
UnshellLeafArgs,
|
||||
names::{last_type_ident, to_snake_case},
|
||||
};
|
||||
|
||||
/// Code generator state for one `#[unshell_leaf]` expansion.
|
||||
pub(crate) struct LeafGenerator {
|
||||
args: UnshellLeafArgs,
|
||||
state: ItemStruct,
|
||||
}
|
||||
|
||||
impl LeafGenerator {
|
||||
/// Creates a generator for one parsed state struct.
|
||||
pub(crate) fn new(args: UnshellLeafArgs, state: ItemStruct) -> Self {
|
||||
Self { args, state }
|
||||
}
|
||||
|
||||
/// Emits the original state struct plus the generated wrapper leaf.
|
||||
pub(crate) fn expand(self) -> Result<TokenStream> {
|
||||
let state = &self.state;
|
||||
let state_ident = &state.ident;
|
||||
let leaf_ident = &self.args.leaf;
|
||||
let leaf_id = &self.args.id;
|
||||
let vis = &state.vis;
|
||||
let generics = &state.generics;
|
||||
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
|
||||
let state_type = quote!(#state_ident #ty_generics);
|
||||
|
||||
let session_stores = self.session_stores()?;
|
||||
let fields = self.store_fields(&session_stores, &state_type);
|
||||
let initializers = self.store_initializers(&session_stores);
|
||||
let packet_predicates = self.packet_predicates(&state_type);
|
||||
let dispatch_arms = self.dispatch_arms(&session_stores, &state_type);
|
||||
let session_updates = self.session_updates(&session_stores, &state_type);
|
||||
let session_flushes = self.session_flushes(&session_stores);
|
||||
let session_retains = self.session_retains(&session_stores);
|
||||
let active_count_terms = self.active_count_terms(&session_stores);
|
||||
let pending_count_terms = self.pending_count_terms(&session_stores);
|
||||
let id_checks = self.id_checks(&state_type);
|
||||
|
||||
Ok(quote! {
|
||||
#state
|
||||
|
||||
#vis struct #leaf_ident #generics #where_clause {
|
||||
state: #state_type,
|
||||
__unshell_procedure_outbox: ::unshell::protocol::PacketQueue,
|
||||
#(#fields,)*
|
||||
}
|
||||
|
||||
impl #impl_generics #leaf_ident #ty_generics #where_clause {
|
||||
const __UNSHELL_PROCEDURE_ID_CHECKS: () = {
|
||||
#(#id_checks)*
|
||||
};
|
||||
|
||||
/// Creates the generated leaf wrapper around user-owned state.
|
||||
pub fn new(state: #state_type) -> Self {
|
||||
Self {
|
||||
state,
|
||||
__unshell_procedure_outbox: ::unshell::protocol::PacketQueue::new(),
|
||||
#(#initializers,)*
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns immutable access to the user-owned leaf state.
|
||||
pub fn state(&self) -> &#state_type {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Returns mutable access to the user-owned leaf state.
|
||||
pub fn state_mut(&mut self) -> &mut #state_type {
|
||||
&mut self.state
|
||||
}
|
||||
|
||||
/// Returns the number of active session entries across all session families.
|
||||
pub fn active_session_count(&self) -> usize {
|
||||
0usize #(+ #active_count_terms)*
|
||||
}
|
||||
|
||||
/// Returns queued inbound and outbound packets owned by this generated leaf.
|
||||
pub fn pending_packet_count(&self) -> usize {
|
||||
let mut __unshell_count = self.__unshell_procedure_outbox.len();
|
||||
#(#pending_count_terms)*
|
||||
__unshell_count
|
||||
}
|
||||
|
||||
fn __unshell_packet_is_owned(packet: &::unshell::protocol::Packet) -> bool {
|
||||
false #(|| #packet_predicates)*
|
||||
}
|
||||
|
||||
fn __unshell_dispatch(
|
||||
&mut self,
|
||||
endpoint: &mut ::unshell::protocol::Endpoint,
|
||||
packet: ::unshell::protocol::Packet,
|
||||
) {
|
||||
#(#dispatch_arms)*
|
||||
}
|
||||
|
||||
fn __unshell_update_sessions(&mut self) {
|
||||
#(#session_updates)*
|
||||
}
|
||||
|
||||
fn __unshell_flush_all(&mut self, endpoint: &mut ::unshell::protocol::Endpoint) {
|
||||
::unshell::protocol::flush_packet_queue(
|
||||
endpoint,
|
||||
&mut self.__unshell_procedure_outbox,
|
||||
);
|
||||
#(#session_flushes)*
|
||||
#(#session_retains)*
|
||||
}
|
||||
|
||||
fn __unshell_parent_reply_path(
|
||||
endpoint: &::unshell::protocol::Endpoint,
|
||||
) -> ::unshell::protocol::alloc::vec::Vec<u32> {
|
||||
if endpoint.path.len() > 1 {
|
||||
endpoint.path[..endpoint.path.len() - 1].to_vec()
|
||||
} else {
|
||||
endpoint.path.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl #impl_generics ::unshell::protocol::Leaf for #leaf_ident #ty_generics #where_clause {
|
||||
fn get_id(&self) -> u32 {
|
||||
#leaf_id
|
||||
}
|
||||
|
||||
fn update(&mut self, endpoint: &mut ::unshell::protocol::Endpoint) {
|
||||
self.__unshell_flush_all(endpoint);
|
||||
|
||||
let Some(__unshell_local_id) = endpoint.path.last().copied() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut __unshell_packets = ::unshell::protocol::alloc::vec::Vec::new();
|
||||
endpoint.take_inbound_matching(
|
||||
__unshell_local_id,
|
||||
Self::__unshell_packet_is_owned,
|
||||
|packet| __unshell_packets.push(packet),
|
||||
);
|
||||
|
||||
for __unshell_packet in __unshell_packets {
|
||||
self.__unshell_dispatch(endpoint, __unshell_packet);
|
||||
}
|
||||
|
||||
self.__unshell_update_sessions();
|
||||
self.__unshell_flush_all(endpoint);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Computes one generated store name per session type.
|
||||
fn session_stores(&self) -> Result<Vec<SessionStore>> {
|
||||
self.args
|
||||
.sessions
|
||||
.iter()
|
||||
.map(|session| {
|
||||
let suffix = last_type_ident(session)?;
|
||||
let field_suffix = to_snake_case(&suffix.to_string());
|
||||
Ok(SessionStore {
|
||||
ty: session.clone(),
|
||||
field: format_ident!("__unshell_{}_sessions", field_suffix),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Emits wrapper fields for session stores.
|
||||
fn store_fields(&self, stores: &[SessionStore], state_type: &TokenStream) -> Vec<TokenStream> {
|
||||
stores
|
||||
.iter()
|
||||
.map(|store| {
|
||||
let field = &store.field;
|
||||
let session_ty = &store.ty;
|
||||
quote! {
|
||||
#field: ::unshell::protocol::alloc::vec::Vec<
|
||||
::unshell::protocol::SessionEntry<
|
||||
<#session_ty as ::unshell::protocol::Session<#state_type>>::State
|
||||
>
|
||||
>
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Emits constructor field initializers for session stores.
|
||||
fn store_initializers(&self, stores: &[SessionStore]) -> Vec<TokenStream> {
|
||||
stores
|
||||
.iter()
|
||||
.map(|store| {
|
||||
let field = &store.field;
|
||||
quote!(#field: ::unshell::protocol::alloc::vec::Vec::new())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Emits boolean procedure-id ownership checks for the filtered endpoint drain.
|
||||
fn packet_predicates(&self, state_type: &TokenStream) -> Vec<TokenStream> {
|
||||
let session_checks = self.args.sessions.iter().map(|session_ty| {
|
||||
quote! {
|
||||
packet.procedure_id
|
||||
== <#session_ty as ::unshell::protocol::Session<#state_type>>::PROCEDURE_ID
|
||||
}
|
||||
});
|
||||
let procedure_checks = self.args.procedures.iter().map(|procedure_ty| {
|
||||
quote! {
|
||||
packet.procedure_id
|
||||
== <#procedure_ty as ::unshell::protocol::Procedure<#state_type>>::PROCEDURE_ID
|
||||
}
|
||||
});
|
||||
|
||||
session_checks.chain(procedure_checks).collect()
|
||||
}
|
||||
|
||||
/// Emits static dispatch branches for every session and procedure type.
|
||||
fn dispatch_arms(&self, stores: &[SessionStore], state_type: &TokenStream) -> Vec<TokenStream> {
|
||||
let mut arms = Vec::new();
|
||||
|
||||
for store in stores {
|
||||
let field = &store.field;
|
||||
let session_ty = &store.ty;
|
||||
arms.push(quote! {
|
||||
if packet.procedure_id
|
||||
== <#session_ty as ::unshell::protocol::Session<#state_type>>::PROCEDURE_ID
|
||||
{
|
||||
if let Some(__unshell_entry) = self
|
||||
.#field
|
||||
.iter_mut()
|
||||
.find(|entry| entry.hook_id == packet.hook_id)
|
||||
{
|
||||
__unshell_entry.inbox.push_back(packet);
|
||||
} else {
|
||||
let __unshell_hook_id = packet.hook_id;
|
||||
let __unshell_packet_path = packet.path.clone();
|
||||
let mut __unshell_init = ::unshell::protocol::SessionInit::new(
|
||||
__unshell_hook_id,
|
||||
__unshell_packet_path,
|
||||
);
|
||||
|
||||
match <#session_ty as ::unshell::protocol::Session<#state_type>>::init(
|
||||
&mut self.state,
|
||||
packet,
|
||||
&mut __unshell_init,
|
||||
) {
|
||||
::unshell::protocol::SessionInitResult::Created(__unshell_state) => {
|
||||
self.#field.push(::unshell::protocol::SessionEntry::new(
|
||||
__unshell_hook_id,
|
||||
__unshell_state,
|
||||
));
|
||||
}
|
||||
::unshell::protocol::SessionInitResult::Rejected => {}
|
||||
::unshell::protocol::SessionInitResult::RejectedWith(__unshell_packet) => {
|
||||
self.__unshell_procedure_outbox.push_back(__unshell_packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for procedure_ty in &self.args.procedures {
|
||||
arms.push(quote! {
|
||||
if packet.procedure_id
|
||||
== <#procedure_ty as ::unshell::protocol::Procedure<#state_type>>::PROCEDURE_ID
|
||||
{
|
||||
let mut __unshell_out = ::unshell::protocol::ProcedureOut::new(
|
||||
packet.hook_id,
|
||||
Self::__unshell_parent_reply_path(endpoint),
|
||||
<#procedure_ty as ::unshell::protocol::Procedure<#state_type>>::PROCEDURE_ID,
|
||||
);
|
||||
<#procedure_ty as ::unshell::protocol::Procedure<#state_type>>::handle(
|
||||
&mut self.state,
|
||||
endpoint,
|
||||
packet,
|
||||
&mut __unshell_out,
|
||||
);
|
||||
self.__unshell_procedure_outbox.extend(__unshell_out.into_packets());
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
arms
|
||||
}
|
||||
|
||||
/// Emits the per-session update loop for every session family.
|
||||
fn session_updates(
|
||||
&self,
|
||||
stores: &[SessionStore],
|
||||
state_type: &TokenStream,
|
||||
) -> Vec<TokenStream> {
|
||||
stores
|
||||
.iter()
|
||||
.map(|store| {
|
||||
let field = &store.field;
|
||||
let session_ty = &store.ty;
|
||||
quote! {
|
||||
for __unshell_entry in &mut self.#field {
|
||||
if __unshell_entry.closed {
|
||||
continue;
|
||||
}
|
||||
|
||||
let __unshell_reply_path =
|
||||
<#session_ty as ::unshell::protocol::Session<#state_type>>::reply_path(
|
||||
&__unshell_entry.state,
|
||||
)
|
||||
.to_vec();
|
||||
let mut __unshell_ctx = ::unshell::protocol::SessionCtx::new(
|
||||
__unshell_entry.hook_id,
|
||||
__unshell_reply_path,
|
||||
<#session_ty as ::unshell::protocol::Session<#state_type>>::PROCEDURE_ID,
|
||||
&mut __unshell_entry.outbox,
|
||||
);
|
||||
let __unshell_status =
|
||||
<#session_ty as ::unshell::protocol::Session<#state_type>>::update(
|
||||
&mut self.state,
|
||||
&mut __unshell_entry.state,
|
||||
&mut __unshell_entry.inbox,
|
||||
&mut __unshell_ctx,
|
||||
);
|
||||
|
||||
if ::core::matches!(
|
||||
__unshell_status,
|
||||
::unshell::protocol::SessionStatus::Closed
|
||||
) {
|
||||
__unshell_entry.closed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Emits retry flushing for every session outbox.
|
||||
fn session_flushes(&self, stores: &[SessionStore]) -> Vec<TokenStream> {
|
||||
stores
|
||||
.iter()
|
||||
.map(|store| {
|
||||
let field = &store.field;
|
||||
quote! {
|
||||
for __unshell_entry in &mut self.#field {
|
||||
::unshell::protocol::flush_packet_queue(
|
||||
endpoint,
|
||||
&mut __unshell_entry.outbox,
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Emits removal of closed sessions whose final packets have routed.
|
||||
fn session_retains(&self, stores: &[SessionStore]) -> Vec<TokenStream> {
|
||||
stores
|
||||
.iter()
|
||||
.map(|store| {
|
||||
let field = &store.field;
|
||||
quote! {
|
||||
self.#field
|
||||
.retain(|entry| !entry.closed || !entry.outbox.is_empty());
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Emits additive terms for active session counts.
|
||||
fn active_count_terms(&self, stores: &[SessionStore]) -> Vec<TokenStream> {
|
||||
stores
|
||||
.iter()
|
||||
.map(|store| {
|
||||
let field = &store.field;
|
||||
quote!(self.#field.len())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Emits statements that accumulate pending packet counts.
|
||||
fn pending_count_terms(&self, stores: &[SessionStore]) -> Vec<TokenStream> {
|
||||
stores
|
||||
.iter()
|
||||
.map(|store| {
|
||||
let field = &store.field;
|
||||
quote! {
|
||||
for __unshell_entry in &self.#field {
|
||||
__unshell_count +=
|
||||
__unshell_entry.inbox.len() + __unshell_entry.outbox.len();
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Emits pairwise const assertions for procedure-id uniqueness.
|
||||
fn id_checks(&self, state_type: &TokenStream) -> Vec<TokenStream> {
|
||||
let mut ids = Vec::new();
|
||||
for session_ty in &self.args.sessions {
|
||||
ids.push(
|
||||
quote!(<#session_ty as ::unshell::protocol::Session<#state_type>>::PROCEDURE_ID),
|
||||
);
|
||||
}
|
||||
for procedure_ty in &self.args.procedures {
|
||||
ids.push(
|
||||
quote!(<#procedure_ty as ::unshell::protocol::Procedure<#state_type>>::PROCEDURE_ID),
|
||||
);
|
||||
}
|
||||
|
||||
let mut checks = Vec::new();
|
||||
for left in 0..ids.len() {
|
||||
for right in (left + 1)..ids.len() {
|
||||
let left_id = &ids[left];
|
||||
let right_id = &ids[right];
|
||||
checks.push(quote! {
|
||||
assert!(
|
||||
#left_id != #right_id,
|
||||
"duplicate unshell procedure id in #[unshell_leaf]"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
checks
|
||||
}
|
||||
}
|
||||
|
||||
/// Generated storage metadata for one session family.
|
||||
struct SessionStore {
|
||||
ty: Type,
|
||||
field: Ident,
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Leaf wrapper macro implementation.
|
||||
//!
|
||||
//! Everything in this module is specific to `#[unshell_leaf]`: argument parsing,
|
||||
//! generated wrapper storage, static dispatch, and retry-safe session output. Future
|
||||
//! macro families should be added as sibling modules instead of sharing this internal
|
||||
//! structure.
|
||||
|
||||
mod args;
|
||||
mod generator;
|
||||
mod names;
|
||||
|
||||
use proc_macro2::TokenStream;
|
||||
use syn::{ItemStruct, Result, parse2};
|
||||
|
||||
pub(crate) use args::UnshellLeafArgs;
|
||||
pub(crate) use generator::LeafGenerator;
|
||||
|
||||
/// Expands `#[unshell_leaf(...)]` into a wrapper leaf and `Leaf` implementation.
|
||||
///
|
||||
/// Errors are returned as tokenized `compile_error!` output so the proc-macro shim can
|
||||
/// stay a thin transport layer from compiler tokens to this core implementation.
|
||||
pub fn expand_unshell_leaf(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
match expand_unshell_leaf_result(attr, item) {
|
||||
Ok(tokens) => tokens,
|
||||
Err(error) => error.to_compile_error(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallible expansion path used by unit tests.
|
||||
pub fn expand_unshell_leaf_result(attr: TokenStream, item: TokenStream) -> Result<TokenStream> {
|
||||
let args = parse2::<UnshellLeafArgs>(attr)?;
|
||||
let state = parse2::<ItemStruct>(item)?;
|
||||
LeafGenerator::new(args, state).expand()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use quote::quote;
|
||||
|
||||
#[test]
|
||||
fn parses_leaf_arguments() {
|
||||
let args = parse2::<UnshellLeafArgs>(quote! {
|
||||
leaf = DemoLeaf,
|
||||
id = 42,
|
||||
sessions(DemoSession),
|
||||
procedures(PingProcedure)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(args.leaf, "DemoLeaf");
|
||||
assert_eq!(args.sessions.len(), 1);
|
||||
assert_eq!(args.procedures.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_leaf_is_rejected() {
|
||||
let error = parse2::<UnshellLeafArgs>(quote! { id = 42 }).unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("missing `leaf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expansion_contains_static_dispatch() {
|
||||
let expanded = expand_unshell_leaf_result(
|
||||
quote! { leaf = DemoLeaf, id = 9, sessions(DemoSession) },
|
||||
quote! { pub struct DemoState; },
|
||||
)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
assert!(expanded.contains("struct DemoLeaf"));
|
||||
assert!(expanded.contains("impl :: unshell :: protocol :: Leaf for DemoLeaf"));
|
||||
assert!(expanded.contains("DemoSession"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use syn::{Ident, Result, Type};
|
||||
|
||||
/// Returns the final path segment for a session type.
|
||||
pub(crate) fn last_type_ident(ty: &Type) -> Result<Ident> {
|
||||
let Type::Path(path) = ty else {
|
||||
return Err(syn::Error::new_spanned(
|
||||
ty,
|
||||
"session types must be named paths",
|
||||
));
|
||||
};
|
||||
let Some(segment) = path.path.segments.last() else {
|
||||
return Err(syn::Error::new_spanned(ty, "session type path is empty"));
|
||||
};
|
||||
|
||||
Ok(segment.ident.clone())
|
||||
}
|
||||
|
||||
/// Converts a Rust type name into a snake-case fragment for generated private fields.
|
||||
pub(crate) fn to_snake_case(name: &str) -> String {
|
||||
let mut output = String::with_capacity(name.len());
|
||||
let chars: Vec<char> = name.chars().collect();
|
||||
|
||||
for (index, character) in chars.iter().copied().enumerate() {
|
||||
if character.is_ascii_uppercase() {
|
||||
let previous = index
|
||||
.checked_sub(1)
|
||||
.and_then(|previous| chars.get(previous));
|
||||
let next = chars.get(index + 1);
|
||||
let previous_needs_boundary = previous
|
||||
.map(|previous| previous.is_ascii_lowercase() || previous.is_ascii_digit())
|
||||
.unwrap_or(false);
|
||||
let acronym_needs_boundary = previous
|
||||
.map(|previous| previous.is_ascii_uppercase())
|
||||
.unwrap_or(false)
|
||||
&& next.map(|next| next.is_ascii_lowercase()).unwrap_or(false);
|
||||
|
||||
if previous_needs_boundary || acronym_needs_boundary {
|
||||
output.push('_');
|
||||
}
|
||||
output.push(character.to_ascii_lowercase());
|
||||
} else {
|
||||
output.push(character);
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::to_snake_case;
|
||||
|
||||
#[test]
|
||||
fn session_store_fields_are_snake_case() {
|
||||
assert_eq!(to_snake_case("PtySession"), "pty_session");
|
||||
assert_eq!(to_snake_case("HTTPServer"), "http_server");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Parser and code generator for UnShell procedural macros.
|
||||
//!
|
||||
//! This crate is intentionally not a proc-macro crate. Keeping each macro family's
|
||||
//! parser and code generator here makes them unit-testable and prevents parsing
|
||||
//! dependencies from leaking into runtime crates.
|
||||
|
||||
mod leaf;
|
||||
|
||||
pub use leaf::{expand_unshell_leaf, expand_unshell_leaf_result};
|
||||
Reference in New Issue
Block a user