Mizan-Rust backend adapter: server-side substrate + three-way parity
Adds first-class Rust-backed Mizan to sit alongside mizan-django and
mizan-fastapi. A Rust dev writes:
#[derive(Mizan, Serialize, Deserialize)]
pub struct ProfileOutput { pub user_id: i64, pub name: String }
#[mizan::context("user")]
pub struct UserCtx;
#[mizan::client(context = UserCtx)]
pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput { ... }
…and gets byte-identical KDL to the Python emitters, served over the
same wire protocol the React / Rust / Vue / Svelte kernels speak.
New crates:
- cores/mizan-rust/ (Cargo: mizan-core) — IR types, KDL emitter, traits, registry,
runtime (compute_invalidation / compute_merges
ported from mizan-fastapi), graph_check with
structural type-matching
- cores/mizan-rust-macros/ (Cargo: mizan-macros) — #[derive(Mizan)], #[mizan::context],
#[mizan::client] proc macros
- backends/mizan-rust-axum/ (Cargo: mizan-axum) — axum HTTP adapter: /session/, /call/, /ctx/:name/
- tests/afi/rust_app/ — AFI fixture port + server / export-ir binaries
Substrate-shape moves required by cross-language equivalence:
- IR canonicalization: functions / contexts / context-members / shared-by
now sort alphabetically in both Python and Rust emitters. The IR is a
contract; linkme doesn't preserve declaration order, so canonical sort
is the only stable mapping. afi_ir.kdl + per-target baselines regenerated.
- MizanType::TYPE_NAME is a const (with a default type_name() reader) so
it's usable in linkme TypeEntry static initializers.
- Tree-shaken type registry: #[derive(Mizan)] only emits the trait impl;
the #[mizan::client] macro registers canonical-named entries from
fn signatures, including Vec<T> element types for ref resolution.
- Merge resolution is structural (NamedType shape comparison) rather than
by name — matches the Python types_match_for_merge semantics.
Three-way forcing functions:
- tests/afi/test_codegen_parity.py — Django ≡ FastAPI ≡ Rust on KDL bytes (3 pass)
- tests/rust/run_wire_parity.py — 12/12 probes against FastAPI + Rust (EXIT=0)
Incidental fixes surfaced by the new tests:
- Stale `from .registry import validate_registry` import removed from
mizan-django/setup/discovery.py (referenced a function that no longer
exists; was masking codegen-parity).
- BASE_DIR added to tests/afi/django_app/project/settings.py.
- /session/ endpoint added to mizan-fastapi for protocol-shaped readiness
probe parity (wire-parity harness now polls /api/mizan/session/ on both
backends rather than FastAPI's /openapi.json).
- Root .gitignore picks up Rust target/ across the tree so new crates
don't need per-crate gitignore.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
77
cores/mizan-rust-macros/src/context.rs
Normal file
77
cores/mizan-rust-macros/src/context.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
//! `#[mizan::context]` / `#[mizan::context("name")]` — emit `ContextMarker`
|
||||
//! impl + linkme registration for a unit struct.
|
||||
|
||||
use heck::ToSnakeCase;
|
||||
use proc_macro2::TokenStream;
|
||||
use quote::{format_ident, quote};
|
||||
use syn::{parse::Parser, punctuated::Punctuated, ItemStruct, Lit, LitStr, Meta, Token};
|
||||
|
||||
/// Attribute args: either nothing, or one string literal that overrides the
|
||||
/// derived snake_case context name.
|
||||
pub struct ContextArgs {
|
||||
pub explicit_name: Option<String>,
|
||||
}
|
||||
|
||||
impl ContextArgs {
|
||||
pub fn parse(attr_tokens: TokenStream) -> syn::Result<Self> {
|
||||
if attr_tokens.is_empty() {
|
||||
return Ok(Self { explicit_name: None });
|
||||
}
|
||||
// Support both `#[mizan::context("user")]` (string literal) and
|
||||
// `#[mizan::context(name = "user")]` (key=value).
|
||||
if let Ok(lit) = syn::parse2::<LitStr>(attr_tokens.clone()) {
|
||||
return Ok(Self {
|
||||
explicit_name: Some(lit.value()),
|
||||
});
|
||||
}
|
||||
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
|
||||
let metas = parser.parse2(attr_tokens)?;
|
||||
for meta in metas {
|
||||
if let Meta::NameValue(nv) = meta {
|
||||
if nv.path.is_ident("name") {
|
||||
if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value {
|
||||
return Ok(Self {
|
||||
explicit_name: Some(s.value()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(syn::Error::new_spanned(
|
||||
"",
|
||||
"expected `#[mizan::context]` or `#[mizan::context(\"<name>\")]` or `#[mizan::context(name = \"<name>\")]`",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expand(args: ContextArgs, item: ItemStruct) -> TokenStream {
|
||||
if !item.fields.is_empty() {
|
||||
return syn::Error::new_spanned(
|
||||
&item.fields,
|
||||
"#[mizan::context] requires a unit struct — context markers carry no data.",
|
||||
)
|
||||
.to_compile_error();
|
||||
}
|
||||
|
||||
let ident = item.ident.clone();
|
||||
let name = args
|
||||
.explicit_name
|
||||
.unwrap_or_else(|| ident.to_string().to_snake_case());
|
||||
|
||||
let register_static =
|
||||
format_ident!("__MIZAN_CTX_REGISTER_{}", ident.to_string().to_uppercase());
|
||||
|
||||
quote! {
|
||||
#item
|
||||
|
||||
impl ::mizan_core::ContextMarker for #ident {
|
||||
const NAME: &'static str = #name;
|
||||
}
|
||||
|
||||
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::CONTEXTS)]
|
||||
#[linkme(crate = ::mizan_core::__priv::linkme)]
|
||||
static #register_static: ::mizan_core::ContextEntry = ::mizan_core::ContextEntry {
|
||||
name: #name,
|
||||
};
|
||||
}
|
||||
}
|
||||
102
cores/mizan-rust-macros/src/derive.rs
Normal file
102
cores/mizan-rust-macros/src/derive.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
//! `#[derive(Mizan)]` — emit `MizanType` impl + linkme registration.
|
||||
|
||||
use proc_macro2::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::{Data, DataEnum, DataStruct, DeriveInput, Fields};
|
||||
|
||||
use crate::shape::type_shape_expr;
|
||||
|
||||
/// Expand `#[derive(Mizan)]`. Emits only the `MizanType` trait impl —
|
||||
/// registration into the IR `TYPES` slice happens at the function macro
|
||||
/// (which owns the canonical-named type entries `<camelName>Input` /
|
||||
/// `<camelName>Output`) and at sub-type discovery inside `Vec<T>` outputs.
|
||||
/// This keeps the type registry tree-shaken: only types actually reachable
|
||||
/// from a registered function appear in the emitted IR.
|
||||
pub fn expand(input: DeriveInput) -> TokenStream {
|
||||
let ident = input.ident.clone();
|
||||
let type_name = ident.to_string();
|
||||
|
||||
let named_type_body = match &input.data {
|
||||
Data::Struct(s) => emit_struct(s),
|
||||
Data::Enum(e) => emit_enum(e),
|
||||
Data::Union(_) => {
|
||||
return syn::Error::new_spanned(
|
||||
&input,
|
||||
"#[derive(Mizan)] does not support `union` types — use a struct or enum.",
|
||||
)
|
||||
.to_compile_error();
|
||||
}
|
||||
};
|
||||
|
||||
quote! {
|
||||
impl ::mizan_core::MizanType for #ident {
|
||||
const TYPE_NAME: &'static str = #type_name;
|
||||
fn shape() -> ::mizan_core::NamedType { #named_type_body }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_struct(s: &DataStruct) -> TokenStream {
|
||||
let fields = match &s.fields {
|
||||
Fields::Named(named) => &named.named,
|
||||
Fields::Unnamed(_) | Fields::Unit => {
|
||||
return syn::Error::new_spanned(
|
||||
&s.fields,
|
||||
"#[derive(Mizan)] requires named fields. Tuple structs and unit structs aren't part of the IR shape.",
|
||||
)
|
||||
.to_compile_error();
|
||||
}
|
||||
};
|
||||
|
||||
let mut field_exprs: Vec<TokenStream> = Vec::new();
|
||||
for field in fields {
|
||||
let ident = field
|
||||
.ident
|
||||
.as_ref()
|
||||
.expect("named field always has an ident");
|
||||
let name = ident.to_string();
|
||||
let shape = type_shape_expr(&field.ty);
|
||||
|
||||
// A field is `required` iff its type is not `Option<...>`. Defaults
|
||||
// are not encodable from Rust syntax (no `= expr` on a struct field
|
||||
// declaration) — the macro emits `required: false, default: None`
|
||||
// for Option-wrapped fields, leaving defaults for a future
|
||||
// attribute-based extension.
|
||||
let is_optional = crate::shape::unwrap_option(&field.ty).is_some();
|
||||
let required = !is_optional;
|
||||
field_exprs.push(quote! {
|
||||
::mizan_core::StructField {
|
||||
name: #name,
|
||||
required: #required,
|
||||
default: ::std::option::Option::None,
|
||||
shape: #shape,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
quote! {
|
||||
::mizan_core::NamedType::Struct(::std::vec![
|
||||
#(#field_exprs),*
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_enum(e: &DataEnum) -> TokenStream {
|
||||
let mut variants: Vec<TokenStream> = Vec::new();
|
||||
for variant in &e.variants {
|
||||
if !matches!(variant.fields, Fields::Unit) {
|
||||
return syn::Error::new_spanned(
|
||||
&variant.fields,
|
||||
"#[derive(Mizan)] only supports unit-variant enums (string-literal enums in the IR). Variants with payload aren't expressible in the current IR.",
|
||||
)
|
||||
.to_compile_error();
|
||||
}
|
||||
let name = variant.ident.to_string();
|
||||
variants.push(quote! { #name });
|
||||
}
|
||||
quote! {
|
||||
::mizan_core::NamedType::Enum(::std::vec![
|
||||
#(#variants),*
|
||||
])
|
||||
}
|
||||
}
|
||||
494
cores/mizan-rust-macros/src/function.rs
Normal file
494
cores/mizan-rust-macros/src/function.rs
Normal file
@@ -0,0 +1,494 @@
|
||||
//! `#[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.
|
||||
let elem_static = element_type_static_ident(elem);
|
||||
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);
|
||||
|
||||
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,
|
||||
) -> TokenStream {
|
||||
let inner = &item.sig.ident;
|
||||
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;
|
||||
::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;
|
||||
::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(ty: &Type) -> syn::Ident {
|
||||
// Derive a unique static-name for the type's registration entry. Uses
|
||||
// the last path segment's identifier as the discriminator.
|
||||
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_{}", suffix)
|
||||
}
|
||||
|
||||
58
cores/mizan-rust-macros/src/lib.rs
Normal file
58
cores/mizan-rust-macros/src/lib.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
//! Proc macros for `mizan-core`. See sibling modules for each macro's body.
|
||||
//!
|
||||
//! Consumer code reads:
|
||||
//! ```ignore
|
||||
//! use mizan_core::prelude::*;
|
||||
//! pub use mizan_core as mizan; // so `#[mizan::context]` / `#[mizan::client]` read naturally
|
||||
//!
|
||||
//! #[derive(Mizan, serde::Serialize, serde::Deserialize)]
|
||||
//! pub struct ProfileOutput { pub user_id: i64, pub name: String }
|
||||
//!
|
||||
//! #[mizan::context("user")]
|
||||
//! pub struct UserCtx;
|
||||
//!
|
||||
//! #[mizan::client(context = UserCtx)]
|
||||
//! pub async fn user_profile(req: &Request, user_id: i64) -> ProfileOutput { ... }
|
||||
//! ```
|
||||
//!
|
||||
//! The function macro is named `client` to mirror Python's `@client`
|
||||
//! decorator and to keep the namespace `mizan::` purely a module path —
|
||||
//! `#[mizan(...)]` would collide with `mizan::context` (a module path
|
||||
//! can't simultaneously be a callable macro in Rust).
|
||||
|
||||
mod context;
|
||||
mod derive;
|
||||
mod function;
|
||||
mod shape;
|
||||
|
||||
use proc_macro::TokenStream;
|
||||
use syn::{parse_macro_input, DeriveInput, ItemFn, ItemStruct};
|
||||
|
||||
#[proc_macro_derive(Mizan)]
|
||||
pub fn derive_mizan(input: TokenStream) -> TokenStream {
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
derive::expand(input).into()
|
||||
}
|
||||
|
||||
#[proc_macro_attribute]
|
||||
pub fn context(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let args = match context::ContextArgs::parse(attr.into()) {
|
||||
Ok(a) => a,
|
||||
Err(e) => return e.to_compile_error().into(),
|
||||
};
|
||||
let item = parse_macro_input!(item as ItemStruct);
|
||||
context::expand(args, item).into()
|
||||
}
|
||||
|
||||
/// The function-registration attribute macro. Used as `#[mizan::client]`
|
||||
/// (no args) or `#[mizan::client(context = X, affects = Y, merge = Z,
|
||||
/// websocket, private)]`.
|
||||
#[proc_macro_attribute]
|
||||
pub fn client(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let args = match function::FunctionArgs::parse(attr.into()) {
|
||||
Ok(a) => a,
|
||||
Err(e) => return e.to_compile_error().into(),
|
||||
};
|
||||
let item = parse_macro_input!(item as ItemFn);
|
||||
function::expand(args, item).into()
|
||||
}
|
||||
123
cores/mizan-rust-macros/src/shape.rs
Normal file
123
cores/mizan-rust-macros/src/shape.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
//! Lower a `syn::Type` to a TypeShape construction expression. Shared by
|
||||
//! `#[derive(Mizan)]` (for struct fields) and `#[mizan(...)]` (for fn input
|
||||
//! params + return-type analysis).
|
||||
|
||||
use proc_macro2::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::{GenericArgument, PathArguments, Type, TypePath};
|
||||
|
||||
/// Result of inspecting a fn's return type.
|
||||
pub struct ReturnAnalysis {
|
||||
/// Inner type once `Option<...>` is unwrapped.
|
||||
pub inner: Type,
|
||||
/// True if the outermost wrapper is `Option<...>`.
|
||||
pub nullable: bool,
|
||||
/// True if `inner` is `Vec<T>` — caller emits an alias type entry.
|
||||
pub is_vec: bool,
|
||||
/// When `is_vec`, this is the element type `T`.
|
||||
pub vec_inner: Option<Type>,
|
||||
}
|
||||
|
||||
pub fn analyze_return(ty: &Type) -> ReturnAnalysis {
|
||||
let (inner, nullable) = if let Some(t) = unwrap_option(ty) {
|
||||
(t, true)
|
||||
} else {
|
||||
(ty.clone(), false)
|
||||
};
|
||||
if let Some(elem) = unwrap_vec(&inner) {
|
||||
ReturnAnalysis {
|
||||
inner: inner.clone(),
|
||||
nullable,
|
||||
is_vec: true,
|
||||
vec_inner: Some(elem),
|
||||
}
|
||||
} else {
|
||||
ReturnAnalysis {
|
||||
inner,
|
||||
nullable,
|
||||
is_vec: false,
|
||||
vec_inner: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a `TypeShape` const-expression for `ty`. Used inside `#[derive(Mizan)]`
|
||||
/// when constructing the struct field shapes.
|
||||
pub fn type_shape_expr(ty: &Type) -> TokenStream {
|
||||
if let Some(inner) = unwrap_option(ty) {
|
||||
let inner_shape = type_shape_expr(&inner);
|
||||
return quote! {
|
||||
::mizan_core::TypeShape::Optional(::std::boxed::Box::new(#inner_shape))
|
||||
};
|
||||
}
|
||||
if let Some(elem) = unwrap_vec(ty) {
|
||||
let inner_shape = type_shape_expr(&elem);
|
||||
return quote! {
|
||||
::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape))
|
||||
};
|
||||
}
|
||||
if let Some(p) = primitive_of(ty) {
|
||||
return quote! { ::mizan_core::TypeShape::Primitive(#p) };
|
||||
}
|
||||
// Fallback: assume a user-defined struct/enum implementing MizanType.
|
||||
// The Ref name comes from `<T as MizanType>::type_name()` at runtime.
|
||||
quote! { ::mizan_core::TypeShape::Ref(<#ty as ::mizan_core::MizanType>::type_name()) }
|
||||
}
|
||||
|
||||
/// Emit a `Primitive` const-expression for `ty`, or `None` if `ty` isn't a
|
||||
/// known primitive scalar.
|
||||
pub fn primitive_of(ty: &Type) -> Option<TokenStream> {
|
||||
let path = match ty {
|
||||
Type::Path(TypePath { qself: None, path }) => path,
|
||||
_ => return None,
|
||||
};
|
||||
let last = path.segments.last()?;
|
||||
let name = last.ident.to_string();
|
||||
match name.as_str() {
|
||||
"i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128"
|
||||
| "usize" => Some(quote! { ::mizan_core::Primitive::Integer }),
|
||||
"f32" | "f64" => Some(quote! { ::mizan_core::Primitive::Number }),
|
||||
"bool" => Some(quote! { ::mizan_core::Primitive::Boolean }),
|
||||
"String" | "str" => Some(quote! { ::mizan_core::Primitive::String }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// If `ty` is `Option<T>`, return `T`. Otherwise None.
|
||||
pub fn unwrap_option(ty: &Type) -> Option<Type> {
|
||||
let path = match ty {
|
||||
Type::Path(TypePath { qself: None, path }) => path,
|
||||
_ => return None,
|
||||
};
|
||||
let last = path.segments.last()?;
|
||||
if last.ident != "Option" {
|
||||
return None;
|
||||
}
|
||||
extract_single_generic(&last.arguments)
|
||||
}
|
||||
|
||||
/// If `ty` is `Vec<T>`, return `T`. Otherwise None.
|
||||
pub fn unwrap_vec(ty: &Type) -> Option<Type> {
|
||||
let path = match ty {
|
||||
Type::Path(TypePath { qself: None, path }) => path,
|
||||
_ => return None,
|
||||
};
|
||||
let last = path.segments.last()?;
|
||||
if last.ident != "Vec" {
|
||||
return None;
|
||||
}
|
||||
extract_single_generic(&last.arguments)
|
||||
}
|
||||
|
||||
fn extract_single_generic(args: &PathArguments) -> Option<Type> {
|
||||
let args = match args {
|
||||
PathArguments::AngleBracketed(a) => a,
|
||||
_ => return None,
|
||||
};
|
||||
for arg in &args.args {
|
||||
if let GenericArgument::Type(t) = arg {
|
||||
return Some(t.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Reference in New Issue
Block a user