Roll the working tree back to the last approved shape, before the post-LICENSE span that false-greened the AFI parity matrix with symbol-presence probes and smuggled an unauthorized SQLAlchemy dependency into FastAPI's Shapes binding.
Forward commit, not a history rewrite — the six commits since 4effcc7 stay in the log as the record of what happened.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
517 lines
20 KiB
Rust
517 lines
20 KiB
Rust
//! `#[mizan(...)]` — on async fns. Generates:
|
|
//! * a synthetic Input struct (`<camelName>Input`) when the fn has params
|
|
//! * `MizanType` impl on the Input struct
|
|
//! * canonical type entries (`<camelName>Input` / `<camelName>Output`)
|
|
//! * Vec-element sub-type entries (so `Vec<T>` outputs surface `T` too)
|
|
//! * `FunctionSpec` impl on a ZST `__MizanFn_<name>`
|
|
//! * `FUNCTIONS` linkme registration of `&__MIZAN_FN_<NAME>_INSTANCE`
|
|
|
|
use heck::{ToLowerCamelCase, ToShoutySnakeCase};
|
|
use proc_macro2::TokenStream;
|
|
use quote::{format_ident, quote};
|
|
use syn::{
|
|
parse::Parser,
|
|
punctuated::Punctuated,
|
|
spanned::Spanned,
|
|
Expr, ExprPath, ExprTuple, FnArg, ItemFn, Meta, Pat, Path, ReturnType, Token, Type,
|
|
};
|
|
|
|
use crate::shape::{analyze_return, primitive_of, type_shape_expr, unwrap_option};
|
|
|
|
/// Parsed attribute args for `#[mizan(...)]`.
|
|
#[derive(Default)]
|
|
pub struct FunctionArgs {
|
|
pub context: Option<Path>,
|
|
pub affects: Vec<Path>,
|
|
pub merge: Vec<Path>,
|
|
pub websocket: bool,
|
|
pub private: bool,
|
|
}
|
|
|
|
impl FunctionArgs {
|
|
pub fn parse(attr_tokens: TokenStream) -> syn::Result<Self> {
|
|
if attr_tokens.is_empty() {
|
|
return Ok(Self::default());
|
|
}
|
|
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
|
|
let metas = parser.parse2(attr_tokens)?;
|
|
let mut out = Self::default();
|
|
for meta in metas {
|
|
match meta {
|
|
Meta::NameValue(nv) => {
|
|
if nv.path.is_ident("context") {
|
|
out.context = Some(expect_path(&nv.value)?);
|
|
} else if nv.path.is_ident("affects") {
|
|
out.affects = collect_paths(&nv.value)?;
|
|
} else if nv.path.is_ident("merge") {
|
|
out.merge = collect_paths(&nv.value)?;
|
|
} else {
|
|
return Err(syn::Error::new_spanned(
|
|
nv.path,
|
|
"unknown attribute key; expected one of: context, affects, merge",
|
|
));
|
|
}
|
|
}
|
|
Meta::Path(p) => {
|
|
if p.is_ident("websocket") {
|
|
out.websocket = true;
|
|
} else if p.is_ident("private") {
|
|
out.private = true;
|
|
} else {
|
|
return Err(syn::Error::new_spanned(
|
|
p,
|
|
"unknown flag; expected `websocket` or `private`",
|
|
));
|
|
}
|
|
}
|
|
Meta::List(l) => {
|
|
return Err(syn::Error::new_spanned(
|
|
l,
|
|
"list-shaped attribute args not supported here",
|
|
));
|
|
}
|
|
}
|
|
}
|
|
if out.context.is_some() && !out.affects.is_empty() {
|
|
return Err(syn::Error::new_spanned(
|
|
out.context.as_ref().unwrap(),
|
|
"`context` and `affects` are mutually exclusive — a function is either a context reader or a mutation.",
|
|
));
|
|
}
|
|
if out.context.is_some() && !out.merge.is_empty() {
|
|
return Err(syn::Error::new_spanned(
|
|
out.context.as_ref().unwrap(),
|
|
"`context` and `merge` are mutually exclusive — a function is either a context reader or a mutation.",
|
|
));
|
|
}
|
|
Ok(out)
|
|
}
|
|
}
|
|
|
|
fn expect_path(expr: &Expr) -> syn::Result<Path> {
|
|
if let Expr::Path(ExprPath { path, .. }) = expr {
|
|
Ok(path.clone())
|
|
} else {
|
|
Err(syn::Error::new_spanned(
|
|
expr,
|
|
"expected a type path (e.g. `UserCtx`)",
|
|
))
|
|
}
|
|
}
|
|
|
|
fn collect_paths(expr: &Expr) -> syn::Result<Vec<Path>> {
|
|
match expr {
|
|
Expr::Path(_) => Ok(vec![expect_path(expr)?]),
|
|
Expr::Tuple(ExprTuple { elems, .. }) => elems.iter().map(expect_path).collect(),
|
|
_ => Err(syn::Error::new_spanned(
|
|
expr,
|
|
"expected a context type or a tuple of context types (e.g. `UserCtx` or `(UserCtx, OrderCtx)`)",
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Information about one input parameter, extracted from the fn signature.
|
|
struct InputArg {
|
|
ident: syn::Ident,
|
|
ty: Type,
|
|
}
|
|
|
|
pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
|
if item.sig.asyncness.is_none() {
|
|
return syn::Error::new_spanned(
|
|
&item.sig.fn_token,
|
|
"#[mizan] requires an `async fn`. Wrap synchronous handlers if needed.",
|
|
)
|
|
.to_compile_error();
|
|
}
|
|
|
|
let fn_name = item.sig.ident.to_string();
|
|
let camel = fn_name.to_lower_camel_case();
|
|
let input_type_name = format!("{camel}Input");
|
|
let output_type_name = format!("{camel}Output");
|
|
|
|
let input_args = match collect_input_args(&item) {
|
|
Ok(v) => v,
|
|
Err(e) => return e.to_compile_error(),
|
|
};
|
|
let has_input = !input_args.is_empty();
|
|
let input_type_ident = format_ident!("{}", input_type_name);
|
|
|
|
let return_ty = match &item.sig.output {
|
|
ReturnType::Type(_, t) => (**t).clone(),
|
|
ReturnType::Default => {
|
|
return syn::Error::new_spanned(
|
|
&item.sig,
|
|
"#[mizan] requires an explicit return type. Add `-> T` to the signature.",
|
|
)
|
|
.to_compile_error();
|
|
}
|
|
};
|
|
let analysis = analyze_return(&return_ty);
|
|
|
|
// ─── Synthetic Input struct ────────────────────────────────────────────
|
|
let input_struct = if has_input {
|
|
let mut field_defs = Vec::new();
|
|
let mut field_shapes = Vec::new();
|
|
for arg in &input_args {
|
|
let ident = &arg.ident;
|
|
let ty = &arg.ty;
|
|
// Strip a leading underscore from the wire-level field name —
|
|
// Rust convention uses `_foo` to silence unused-arg warnings,
|
|
// but the wire schema and the Python fixture name the param
|
|
// `foo`. The struct field keeps its source ident (so the
|
|
// dispatch wrapper's `validated.#ident` compiles), and a serde
|
|
// `rename` bridges the wire-level JSON name.
|
|
let name_str = ident.to_string();
|
|
let wire_name = name_str.trim_start_matches('_').to_string();
|
|
let serde_rename = if wire_name != name_str {
|
|
quote! { #[serde(rename = #wire_name)] }
|
|
} else {
|
|
TokenStream::new()
|
|
};
|
|
field_defs.push(quote! { #serde_rename pub #ident: #ty, });
|
|
let is_optional = unwrap_option(ty).is_some();
|
|
let required = !is_optional;
|
|
let shape = type_shape_expr(ty);
|
|
field_shapes.push(quote! {
|
|
::mizan_core::StructField {
|
|
name: #wire_name,
|
|
required: #required,
|
|
default: ::std::option::Option::None,
|
|
shape: #shape,
|
|
}
|
|
});
|
|
}
|
|
quote! {
|
|
#[derive(::std::fmt::Debug, ::std::clone::Clone, ::serde::Serialize, ::serde::Deserialize)]
|
|
pub struct #input_type_ident {
|
|
#(#field_defs)*
|
|
}
|
|
|
|
impl ::mizan_core::MizanType for #input_type_ident {
|
|
const TYPE_NAME: &'static str = #input_type_name;
|
|
fn shape() -> ::mizan_core::NamedType {
|
|
::mizan_core::NamedType::Struct(::std::vec![
|
|
#(#field_shapes),*
|
|
])
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
TokenStream::new()
|
|
};
|
|
|
|
// ─── Type entry registrations ──────────────────────────────────────────
|
|
// - Input: TypeEntry pointing at the synthetic input struct's shape_fn.
|
|
// - Output: TypeEntry whose shape is a copy of the user's Output shape
|
|
// (for struct outputs) or an `Alias(List(Ref("T")))` (for Vec outputs).
|
|
// - For Vec<T> outputs, ALSO register T's TypeEntry pointing at T's
|
|
// MizanType impl (so the Ref resolves in the IR).
|
|
let mut type_registrations = Vec::new();
|
|
if has_input {
|
|
let static_ident =
|
|
format_ident!("__MIZAN_TYPE_{}", input_type_name.to_shouty_snake_case());
|
|
type_registrations.push(quote! {
|
|
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
|
|
#[linkme(crate = ::mizan_core::__priv::linkme)]
|
|
static #static_ident: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
|
|
name: #input_type_name,
|
|
shape_fn: <#input_type_ident as ::mizan_core::MizanType>::shape,
|
|
};
|
|
});
|
|
}
|
|
|
|
let output_static = format_ident!("__MIZAN_TYPE_{}", output_type_name.to_shouty_snake_case());
|
|
if analysis.is_vec {
|
|
let elem = analysis.vec_inner.as_ref().expect("vec_inner set");
|
|
// userOrdersOutput → alias { list { ref "OrderOutput" } }
|
|
// The Ref name is resolved via `<T as MizanType>::type_name()`.
|
|
type_registrations.push(quote! {
|
|
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
|
|
#[linkme(crate = ::mizan_core::__priv::linkme)]
|
|
static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
|
|
name: #output_type_name,
|
|
shape_fn: || ::mizan_core::NamedType::Alias(
|
|
::mizan_core::TypeShape::List(::std::boxed::Box::new(
|
|
::mizan_core::TypeShape::Ref(<#elem as ::mizan_core::MizanType>::TYPE_NAME)
|
|
))
|
|
),
|
|
};
|
|
});
|
|
// Also register the element type itself by its own name. `TYPE_NAME`
|
|
// is an associated const, so this is usable in a static initializer.
|
|
// The static ident scopes by the function name so two handlers
|
|
// returning `Vec<Same>` don't collide; the IrSnapshot's BTreeMap
|
|
// dedupes by the entry's `name` at emit time.
|
|
let elem_static =
|
|
element_type_static_ident_scoped(elem, &fn_name.to_shouty_snake_case());
|
|
type_registrations.push(quote! {
|
|
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
|
|
#[linkme(crate = ::mizan_core::__priv::linkme)]
|
|
static #elem_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
|
|
name: <#elem as ::mizan_core::MizanType>::TYPE_NAME,
|
|
shape_fn: <#elem as ::mizan_core::MizanType>::shape,
|
|
};
|
|
});
|
|
} else {
|
|
// Non-Vec output: copy the inner type's shape under the canonical name.
|
|
let inner_ty = &analysis.inner;
|
|
type_registrations.push(quote! {
|
|
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
|
|
#[linkme(crate = ::mizan_core::__priv::linkme)]
|
|
static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
|
|
name: #output_type_name,
|
|
shape_fn: <#inner_ty as ::mizan_core::MizanType>::shape,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ─── InputParam slice (for context-builder shared-param elevation) ────
|
|
let mut input_params = Vec::new();
|
|
for arg in &input_args {
|
|
// Wire-level name strips the underscore prefix — see input_struct
|
|
// above for the rationale.
|
|
let name_str = arg.ident.to_string();
|
|
let name_str = name_str.trim_start_matches('_').to_string();
|
|
let primitive = primitive_of(&arg.ty).unwrap_or_else(|| {
|
|
// Non-primitive params don't surface in the context's `param`
|
|
// block; they participate as opaque payloads. Using `String` as
|
|
// the placeholder primitive matches Python's fallback in
|
|
// `_annotation_to_primitive`.
|
|
quote! { ::mizan_core::Primitive::String }
|
|
});
|
|
let is_optional = unwrap_option(&arg.ty).is_some();
|
|
let required = !is_optional;
|
|
input_params.push(quote! {
|
|
::mizan_core::InputParam {
|
|
name: #name_str,
|
|
primitive: #primitive,
|
|
required: #required,
|
|
}
|
|
});
|
|
}
|
|
let params_static = format_ident!("__MIZAN_FN_{}_PARAMS", fn_name.to_shouty_snake_case());
|
|
let params_const = quote! {
|
|
const #params_static: &[::mizan_core::InputParam] = &[
|
|
#(#input_params),*
|
|
];
|
|
};
|
|
|
|
// ─── AffectTarget / merge / context wiring ─────────────────────────────
|
|
let affects_static = format_ident!("__MIZAN_FN_{}_AFFECTS", fn_name.to_shouty_snake_case());
|
|
let affects_entries: Vec<_> = args
|
|
.affects
|
|
.iter()
|
|
.map(|p| {
|
|
quote! {
|
|
::mizan_core::AffectTarget::Context(<#p as ::mizan_core::ContextMarker>::NAME)
|
|
}
|
|
})
|
|
.collect();
|
|
let affects_const = quote! {
|
|
const #affects_static: &[::mizan_core::AffectTarget] = &[
|
|
#(#affects_entries),*
|
|
];
|
|
};
|
|
|
|
let merge_static = format_ident!("__MIZAN_FN_{}_MERGE", fn_name.to_shouty_snake_case());
|
|
let merge_entries: Vec<_> = args
|
|
.merge
|
|
.iter()
|
|
.map(|p| quote! { <#p as ::mizan_core::ContextMarker>::NAME })
|
|
.collect();
|
|
let merge_const = quote! {
|
|
const #merge_static: &[&'static str] = &[
|
|
#(#merge_entries),*
|
|
];
|
|
};
|
|
|
|
let context_value = match &args.context {
|
|
Some(p) => quote! { ::std::option::Option::Some(<#p as ::mizan_core::ContextMarker>::NAME) },
|
|
None => quote! { ::std::option::Option::None },
|
|
};
|
|
|
|
let transport_value = if args.websocket {
|
|
quote! { ::mizan_core::Transport::Websocket }
|
|
} else {
|
|
quote! { ::mizan_core::Transport::Http }
|
|
};
|
|
|
|
// ─── Dispatch wrapper + FunctionSpec impl ──────────────────────────────
|
|
let inner_fn_ident = item.sig.ident.clone();
|
|
let spec_struct = format_ident!("__MizanFn_{}", inner_fn_ident);
|
|
let spec_const = format_ident!("__MIZAN_FN_{}_SPEC", fn_name.to_shouty_snake_case());
|
|
let register_static =
|
|
format_ident!("__MIZAN_FN_{}_REGISTER", fn_name.to_shouty_snake_case());
|
|
|
|
let input_type_opt = if has_input {
|
|
quote! { ::std::option::Option::Some(#input_type_name) }
|
|
} else {
|
|
quote! { ::std::option::Option::None }
|
|
};
|
|
|
|
let output_nullable = analysis.nullable;
|
|
let private = args.private;
|
|
|
|
let dispatch_body = build_dispatch(
|
|
&item,
|
|
&input_args,
|
|
has_input,
|
|
&input_type_ident,
|
|
analysis.returns_result,
|
|
);
|
|
|
|
quote! {
|
|
// Keep the user's original fn intact — the macro never rewrites the
|
|
// body, only wraps it for dispatch.
|
|
#item
|
|
|
|
#input_struct
|
|
|
|
#(#type_registrations)*
|
|
|
|
#params_const
|
|
#affects_const
|
|
#merge_const
|
|
|
|
#[allow(non_camel_case_types)]
|
|
pub struct #spec_struct;
|
|
|
|
impl ::mizan_core::FunctionSpec for #spec_struct {
|
|
fn name(&self) -> &'static str { #fn_name }
|
|
fn camel_name(&self) -> &'static str { #camel }
|
|
fn has_input(&self) -> bool { #has_input }
|
|
fn input_type(&self) -> ::std::option::Option<&'static str> { #input_type_opt }
|
|
fn output_type(&self) -> &'static str { #output_type_name }
|
|
fn output_nullable(&self) -> bool { #output_nullable }
|
|
fn context(&self) -> ::std::option::Option<&'static str> { #context_value }
|
|
fn affects(&self) -> &'static [::mizan_core::AffectTarget] { #affects_static }
|
|
fn merge(&self) -> &'static [&'static str] { #merge_static }
|
|
fn transport(&self) -> ::mizan_core::Transport { #transport_value }
|
|
fn private(&self) -> bool { #private }
|
|
fn input_params(&self) -> &'static [::mizan_core::InputParam] { #params_static }
|
|
|
|
fn dispatch<'a>(
|
|
&'a self,
|
|
req: ::mizan_core::RequestHandle<'a>,
|
|
args: ::mizan_core::__priv::serde_json::Value,
|
|
) -> ::std::pin::Pin<::std::boxed::Box<dyn ::std::future::Future<
|
|
Output = ::std::result::Result<
|
|
::mizan_core::__priv::serde_json::Value,
|
|
::mizan_core::MizanError,
|
|
>
|
|
> + ::std::marker::Send + 'a>> {
|
|
::std::boxed::Box::pin(async move {
|
|
#dispatch_body
|
|
})
|
|
}
|
|
}
|
|
|
|
#[allow(non_upper_case_globals)]
|
|
static #spec_const: #spec_struct = #spec_struct;
|
|
|
|
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::FUNCTIONS)]
|
|
#[linkme(crate = ::mizan_core::__priv::linkme)]
|
|
static #register_static: &dyn ::mizan_core::FunctionSpec = &#spec_const;
|
|
}
|
|
}
|
|
|
|
fn collect_input_args(item: &ItemFn) -> syn::Result<Vec<InputArg>> {
|
|
let mut out = Vec::new();
|
|
let mut iter = item.sig.inputs.iter();
|
|
// First arg is the request handle — skip without inspection. The function
|
|
// body uses it directly; the dispatch wrapper forwards `req`.
|
|
if iter.next().is_none() {
|
|
return Err(syn::Error::new(
|
|
item.sig.span(),
|
|
"#[mizan] functions must accept at least a request handle as the first parameter (e.g. `&Request` or `RequestHandle`).",
|
|
));
|
|
}
|
|
for arg in iter {
|
|
match arg {
|
|
FnArg::Typed(pat) => {
|
|
let ident = match &*pat.pat {
|
|
Pat::Ident(pi) => pi.ident.clone(),
|
|
_ => {
|
|
return Err(syn::Error::new_spanned(
|
|
&pat.pat,
|
|
"#[mizan] function parameters must be plain identifiers (no destructuring).",
|
|
));
|
|
}
|
|
};
|
|
out.push(InputArg {
|
|
ident,
|
|
ty: (*pat.ty).clone(),
|
|
});
|
|
}
|
|
FnArg::Receiver(_) => {
|
|
return Err(syn::Error::new_spanned(
|
|
arg,
|
|
"#[mizan] functions are free functions, not methods. `self` is not allowed.",
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn build_dispatch(
|
|
item: &ItemFn,
|
|
input_args: &[InputArg],
|
|
has_input: bool,
|
|
input_type_ident: &syn::Ident,
|
|
returns_result: bool,
|
|
) -> TokenStream {
|
|
let inner = &item.sig.ident;
|
|
// When the user returns `Result<T, MizanError>`, lift Err out into the
|
|
// dispatch wrapper's outer Result so the HTTP/IPC adapter can surface
|
|
// it as the standard error envelope. When the user returns `T`,
|
|
// serialize directly — the substrate has no error path for them.
|
|
let unwrap_user_result = if returns_result {
|
|
quote! { ? }
|
|
} else {
|
|
TokenStream::new()
|
|
};
|
|
if has_input {
|
|
let arg_names: Vec<_> = input_args.iter().map(|a| &a.ident).collect();
|
|
quote! {
|
|
let validated: #input_type_ident = ::mizan_core::__priv::serde_json::from_value(args)
|
|
.map_err(|e| ::mizan_core::MizanError::ValidationFailed {
|
|
message: format!("input validation failed: {e}"),
|
|
details: ::mizan_core::__priv::serde_json::Value::Null,
|
|
})?;
|
|
let result = #inner(
|
|
&req,
|
|
#( validated.#arg_names ),*
|
|
).await #unwrap_user_result;
|
|
::mizan_core::__priv::serde_json::to_value(&result)
|
|
.map_err(|e| ::mizan_core::MizanError::InternalError(
|
|
format!("output serialization failed: {e}"),
|
|
))
|
|
}
|
|
} else {
|
|
quote! {
|
|
let _ = args;
|
|
let result = #inner(&req).await #unwrap_user_result;
|
|
::mizan_core::__priv::serde_json::to_value(&result)
|
|
.map_err(|e| ::mizan_core::MizanError::InternalError(
|
|
format!("output serialization failed: {e}"),
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn element_type_static_ident_scoped(ty: &Type, fn_scope: &str) -> syn::Ident {
|
|
// Derive a unique static-name for the type's registration entry,
|
|
// scoped by the surrounding function so siblings returning the same
|
|
// `Vec<T>` don't collide at the static-name layer. The IR-side
|
|
// BTreeMap dedupes by TypeEntry.name at emission time.
|
|
let last = match ty {
|
|
Type::Path(tp) => tp.path.segments.last().map(|s| s.ident.to_string()),
|
|
_ => None,
|
|
};
|
|
let suffix = last.unwrap_or_else(|| "ANON".to_string()).to_shouty_snake_case();
|
|
format_ident!("__MIZAN_TYPE_ELEM_{}_FOR_{}", suffix, fn_scope)
|
|
}
|
|
|