mizan: IR inline-substitution + reachability tree-shake + serde rename_all
Three substrate moves required by the Blazr-session port that surfaced
real cross-backend divergences:
1. Inline-substitution for primitive aliases and string enums. Named
types whose body is `Alias(Primitive(_))` or `Enum(_)` collapse into
their inline TypeShape at every `Ref` use site, and don't emit as
their own `type "X" { ... }` entry. Matches Python's Pydantic Literal
and `Foo = str` alias inlining — codegen consumers see the primitive
directly rather than chasing a single-hop indirection.
2. Reachability tree-shake on the type registry. `#[derive(Mizan)]` now
auto-registers every Mizan type into the TYPES slice; the emitter
then transitively walks Refs from function inputs/outputs and emits
only the reachable subset. Original-named entries from derive
register only when something refs them; canonical-renamed entries
from the function macro are reachable by definition. Mirrors
Python's `_collect_named_types`.
3. `#[serde(rename_all = "...")]` + `#[serde(rename = "...")]`
propagation in `#[derive(Mizan)]` for enums. IR enum variants now
match the on-wire JSON casing (lowercase / snake_case / kebab-case /
etc.), not the Rust variant idents. Supports all serde casings.
AFI codegen + wire parity stays green after these changes (the AFI
fixture's enum-free + Pydantic-shape types are unchanged by the three
substrate extensions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,24 +1,101 @@
|
|||||||
//! `#[derive(Mizan)]` — emit `MizanType` impl + linkme registration.
|
//! `#[derive(Mizan)]` — emit `MizanType` impl + linkme registration.
|
||||||
|
|
||||||
|
use heck::{ToKebabCase, ToLowerCamelCase, ToShoutySnakeCase, ToSnakeCase, ToUpperCamelCase};
|
||||||
use proc_macro2::TokenStream;
|
use proc_macro2::TokenStream;
|
||||||
use quote::quote;
|
use quote::quote;
|
||||||
use syn::{Data, DataEnum, DataStruct, DeriveInput, Fields};
|
use syn::{
|
||||||
|
parse::Parser, punctuated::Punctuated, Data, DataEnum, DataStruct, DeriveInput, Fields, Lit,
|
||||||
|
Meta, Token,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::shape::type_shape_expr;
|
use crate::shape::type_shape_expr;
|
||||||
|
|
||||||
/// Expand `#[derive(Mizan)]`. Emits only the `MizanType` trait impl —
|
/// Apply a `#[serde(rename_all = "...")]` casing transform to a Rust
|
||||||
/// registration into the IR `TYPES` slice happens at the function macro
|
/// variant identifier so the IR's enum variant matches what serde emits
|
||||||
/// (which owns the canonical-named type entries `<camelName>Input` /
|
/// on the wire. Supported casings mirror serde's set.
|
||||||
/// `<camelName>Output`) and at sub-type discovery inside `Vec<T>` outputs.
|
fn apply_rename_all(rule: &str, ident: &str) -> String {
|
||||||
/// This keeps the type registry tree-shaken: only types actually reachable
|
match rule {
|
||||||
/// from a registered function appear in the emitted IR.
|
"lowercase" => ident.to_lowercase(),
|
||||||
|
"UPPERCASE" => ident.to_uppercase(),
|
||||||
|
"PascalCase" => ident.to_upper_camel_case(),
|
||||||
|
"camelCase" => ident.to_lower_camel_case(),
|
||||||
|
"snake_case" => ident.to_snake_case(),
|
||||||
|
"SCREAMING_SNAKE_CASE" => ident.to_shouty_snake_case(),
|
||||||
|
"kebab-case" => ident.to_kebab_case(),
|
||||||
|
_ => ident.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk the enum's outer attributes for `#[serde(rename_all = "...")]`.
|
||||||
|
fn serde_rename_all(attrs: &[syn::Attribute]) -> Option<String> {
|
||||||
|
for attr in attrs {
|
||||||
|
if !attr.path().is_ident("serde") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let list = match &attr.meta {
|
||||||
|
Meta::List(l) => l,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
|
||||||
|
let metas = match parser.parse2(list.tokens.clone()) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for meta in metas {
|
||||||
|
if let Meta::NameValue(nv) = meta {
|
||||||
|
if nv.path.is_ident("rename_all") {
|
||||||
|
if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value {
|
||||||
|
return Some(s.value());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk a variant's attributes for an explicit `#[serde(rename = "...")]`
|
||||||
|
/// override. Variant-level rename overrides the enum-level rename_all.
|
||||||
|
fn serde_rename(attrs: &[syn::Attribute]) -> Option<String> {
|
||||||
|
for attr in attrs {
|
||||||
|
if !attr.path().is_ident("serde") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let list = match &attr.meta {
|
||||||
|
Meta::List(l) => l,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
|
||||||
|
let metas = match parser.parse2(list.tokens.clone()) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for meta in metas {
|
||||||
|
if let Meta::NameValue(nv) = meta {
|
||||||
|
if nv.path.is_ident("rename") {
|
||||||
|
if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value {
|
||||||
|
return Some(s.value());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand `#[derive(Mizan)]`. Emits the `MizanType` impl AND a linkme
|
||||||
|
/// TypeEntry registration. Every Mizan-shaped type lands in the IR;
|
||||||
|
/// the emitter's inline-substitution pass collapses primitive-aliases
|
||||||
|
/// and enums at use sites so the IR stays tight.
|
||||||
pub fn expand(input: DeriveInput) -> TokenStream {
|
pub fn expand(input: DeriveInput) -> TokenStream {
|
||||||
let ident = input.ident.clone();
|
let ident = input.ident.clone();
|
||||||
let type_name = ident.to_string();
|
let type_name = ident.to_string();
|
||||||
|
|
||||||
|
let rename_all = serde_rename_all(&input.attrs);
|
||||||
|
|
||||||
let named_type_body = match &input.data {
|
let named_type_body = match &input.data {
|
||||||
Data::Struct(s) => emit_struct(s),
|
Data::Struct(s) => emit_struct(s),
|
||||||
Data::Enum(e) => emit_enum(e),
|
Data::Enum(e) => emit_enum(e, rename_all.as_deref()),
|
||||||
Data::Union(_) => {
|
Data::Union(_) => {
|
||||||
return syn::Error::new_spanned(
|
return syn::Error::new_spanned(
|
||||||
&input,
|
&input,
|
||||||
@@ -28,11 +105,22 @@ pub fn expand(input: DeriveInput) -> TokenStream {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let register_static =
|
||||||
|
quote::format_ident!("__MIZAN_TYPE_REGISTER_{}", ident.to_string().to_shouty_snake_case());
|
||||||
|
|
||||||
quote! {
|
quote! {
|
||||||
impl ::mizan_core::MizanType for #ident {
|
impl ::mizan_core::MizanType for #ident {
|
||||||
const TYPE_NAME: &'static str = #type_name;
|
const TYPE_NAME: &'static str = #type_name;
|
||||||
fn shape() -> ::mizan_core::NamedType { #named_type_body }
|
fn shape() -> ::mizan_core::NamedType { #named_type_body }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
|
||||||
|
#[linkme(crate = ::mizan_core::__priv::linkme)]
|
||||||
|
#[allow(non_upper_case_globals)]
|
||||||
|
static #register_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
|
||||||
|
name: #type_name,
|
||||||
|
shape_fn: <#ident as ::mizan_core::MizanType>::shape,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +169,7 @@ fn emit_struct(s: &DataStruct) -> TokenStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_enum(e: &DataEnum) -> TokenStream {
|
fn emit_enum(e: &DataEnum, rename_all: Option<&str>) -> TokenStream {
|
||||||
let mut variants: Vec<TokenStream> = Vec::new();
|
let mut variants: Vec<TokenStream> = Vec::new();
|
||||||
for variant in &e.variants {
|
for variant in &e.variants {
|
||||||
if !matches!(variant.fields, Fields::Unit) {
|
if !matches!(variant.fields, Fields::Unit) {
|
||||||
@@ -91,7 +179,16 @@ fn emit_enum(e: &DataEnum) -> TokenStream {
|
|||||||
)
|
)
|
||||||
.to_compile_error();
|
.to_compile_error();
|
||||||
}
|
}
|
||||||
let name = variant.ident.to_string();
|
let raw = variant.ident.to_string();
|
||||||
|
// Variant-level `#[serde(rename = "...")]` wins; otherwise apply
|
||||||
|
// the enum-level `#[serde(rename_all = "...")]` rule.
|
||||||
|
let name = if let Some(explicit) = serde_rename(&variant.attrs) {
|
||||||
|
explicit
|
||||||
|
} else if let Some(rule) = rename_all {
|
||||||
|
apply_rename_all(rule, &raw)
|
||||||
|
} else {
|
||||||
|
raw
|
||||||
|
};
|
||||||
variants.push(quote! { #name });
|
variants.push(quote! { #name });
|
||||||
}
|
}
|
||||||
quote! {
|
quote! {
|
||||||
|
|||||||
@@ -75,13 +75,20 @@ pub fn snake_to_camel(name: &str) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Emitter {
|
struct Emitter<'a> {
|
||||||
lines: Vec<String>,
|
lines: Vec<String>,
|
||||||
|
/// Types whose references should be substituted with their inline
|
||||||
|
/// shape at the use site (and which don't emit as their own
|
||||||
|
/// `type "X" { ... }` entries). Populated from `IrSnapshot::inlines`.
|
||||||
|
inlines: &'a BTreeMap<&'static str, TypeShape>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Emitter {
|
impl<'a> Emitter<'a> {
|
||||||
fn new() -> Self {
|
fn new(inlines: &'a BTreeMap<&'static str, TypeShape>) -> Self {
|
||||||
Self { lines: Vec::new() }
|
Self {
|
||||||
|
lines: Vec::new(),
|
||||||
|
inlines,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prefix(&self, indent: usize) -> String {
|
fn prefix(&self, indent: usize) -> String {
|
||||||
@@ -118,6 +125,13 @@ impl Emitter {
|
|||||||
self.leaf(indent, &["primitive", &name]);
|
self.leaf(indent, &["primitive", &name]);
|
||||||
}
|
}
|
||||||
TypeShape::Ref(name) => {
|
TypeShape::Ref(name) => {
|
||||||
|
// Inline-substitute when the referenced type is a
|
||||||
|
// primitive-alias or string-enum. Matches Python's
|
||||||
|
// Pydantic Literal/alias inlining.
|
||||||
|
if let Some(inline_shape) = self.inlines.get(name).cloned() {
|
||||||
|
self.emit_type_child(indent, &inline_shape);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let n = kdl_string(name);
|
let n = kdl_string(name);
|
||||||
self.leaf(indent, &["ref", &n]);
|
self.leaf(indent, &["ref", &n]);
|
||||||
}
|
}
|
||||||
@@ -313,16 +327,86 @@ pub(crate) struct IrSnapshot {
|
|||||||
pub types: BTreeMap<&'static str, NamedType>,
|
pub types: BTreeMap<&'static str, NamedType>,
|
||||||
pub functions: Vec<&'static dyn FunctionSpec>,
|
pub functions: Vec<&'static dyn FunctionSpec>,
|
||||||
pub contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)>,
|
pub contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)>,
|
||||||
|
/// Types that inline to a `TypeShape` at every reference site rather
|
||||||
|
/// than emitting as their own `type "X" { ... }` entry. Populated from
|
||||||
|
/// `Alias(Primitive(_))` and `Enum` named types — both are
|
||||||
|
/// information-zero indirections that the codegen consumer doesn't
|
||||||
|
/// gain anything from naming. Matches the Python emitter's behavior
|
||||||
|
/// (Pydantic `FigureId = str` and `Literal["..."]` inline; they don't
|
||||||
|
/// materialize as named types).
|
||||||
|
pub inlines: BTreeMap<&'static str, TypeShape>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IrSnapshot {
|
impl IrSnapshot {
|
||||||
pub(crate) fn collect() -> Self {
|
pub(crate) fn collect() -> Self {
|
||||||
// Types: alphabetized for byte-equivalence with Python's `sorted(named_types)`.
|
// Types: alphabetized for byte-equivalence with Python's `sorted(named_types)`.
|
||||||
let mut types: BTreeMap<&'static str, NamedType> = BTreeMap::new();
|
let mut all_types: BTreeMap<&'static str, NamedType> = BTreeMap::new();
|
||||||
for entry in TYPES {
|
for entry in TYPES {
|
||||||
types.insert(entry.name, (entry.shape_fn)());
|
all_types.insert(entry.name, (entry.shape_fn)());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Partition into emit-candidate types vs inlines. An inline is a
|
||||||
|
// named type whose shape collapses to a single `TypeShape` at the
|
||||||
|
// field site — primitive aliases and string enums.
|
||||||
|
let mut candidates: BTreeMap<&'static str, NamedType> = BTreeMap::new();
|
||||||
|
let mut inlines: BTreeMap<&'static str, TypeShape> = BTreeMap::new();
|
||||||
|
for (name, body) in all_types {
|
||||||
|
match &body {
|
||||||
|
NamedType::Alias(TypeShape::Primitive(p)) => {
|
||||||
|
inlines.insert(name, TypeShape::Primitive(*p));
|
||||||
|
}
|
||||||
|
NamedType::Enum(variants) => {
|
||||||
|
inlines.insert(name, TypeShape::Enum(variants.clone()));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
candidates.insert(name, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tree-shake: keep only types reachable from a registered function's
|
||||||
|
// input/output. The function macro registers canonical-named
|
||||||
|
// entries (e.g. `userPrefsOutput`); derive registers original-named
|
||||||
|
// entries (`UserPrefs`, `BrushSettings`, …). Only those reached
|
||||||
|
// via Ref-walk from a function's input/output names belong in the
|
||||||
|
// emitted IR. Mirrors Python's `_collect_named_types`.
|
||||||
|
let mut reachable: std::collections::HashSet<&'static str> =
|
||||||
|
std::collections::HashSet::new();
|
||||||
|
let mut frontier: Vec<&'static str> = Vec::new();
|
||||||
|
for fn_spec in FUNCTIONS {
|
||||||
|
if fn_spec.private() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(input_name) = fn_spec.input_type() {
|
||||||
|
if reachable.insert(input_name) {
|
||||||
|
frontier.push(input_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let output_name = fn_spec.output_type();
|
||||||
|
if reachable.insert(output_name) {
|
||||||
|
frontier.push(output_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while let Some(name) = frontier.pop() {
|
||||||
|
// Inlines don't carry refs we care about (Primitive/Enum); skip.
|
||||||
|
if inlines.contains_key(name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let body = match candidates.get(name) {
|
||||||
|
Some(b) => b.clone(),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
collect_refs(&body, &mut |r| {
|
||||||
|
if reachable.insert(r) {
|
||||||
|
frontier.push(r);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let types: BTreeMap<&'static str, NamedType> = candidates
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(name, _)| reachable.contains(name))
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Functions: alphabetical by wire name (canonical IR ordering,
|
// Functions: alphabetical by wire name (canonical IR ordering,
|
||||||
// matches the Python emitter's `sorted(functions)`). Skip `private`.
|
// matches the Python emitter's `sorted(functions)`). Skip `private`.
|
||||||
let mut functions: Vec<&'static dyn FunctionSpec> = FUNCTIONS
|
let mut functions: Vec<&'static dyn FunctionSpec> = FUNCTIONS
|
||||||
@@ -353,15 +437,42 @@ impl IrSnapshot {
|
|||||||
types,
|
types,
|
||||||
functions,
|
functions,
|
||||||
contexts,
|
contexts,
|
||||||
|
inlines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Walk every Ref in a NamedType's shape and call `visit` for each name.
|
||||||
|
fn collect_refs<F: FnMut(&'static str)>(body: &NamedType, visit: &mut F) {
|
||||||
|
match body {
|
||||||
|
NamedType::Struct(fields) => {
|
||||||
|
for field in fields {
|
||||||
|
walk_shape_refs(&field.shape, visit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NamedType::Alias(inner) => walk_shape_refs(inner, visit),
|
||||||
|
NamedType::Enum(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_shape_refs<F: FnMut(&'static str)>(shape: &TypeShape, visit: &mut F) {
|
||||||
|
match shape {
|
||||||
|
TypeShape::Ref(name) => visit(name),
|
||||||
|
TypeShape::List(inner) | TypeShape::Optional(inner) => walk_shape_refs(inner, visit),
|
||||||
|
TypeShape::Union(branches) => {
|
||||||
|
for b in branches {
|
||||||
|
walk_shape_refs(b, visit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TypeShape::Primitive(_) | TypeShape::Enum(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the Mizan IR for every registered type/function/context. Returns KDL.
|
/// Build the Mizan IR for every registered type/function/context. Returns KDL.
|
||||||
pub fn build_ir() -> String {
|
pub fn build_ir() -> String {
|
||||||
crate::graph_check::verify_invariants();
|
crate::graph_check::verify_invariants();
|
||||||
let snap = IrSnapshot::collect();
|
let snap = IrSnapshot::collect();
|
||||||
let mut em = Emitter::new();
|
let mut em = Emitter::new(&snap.inlines);
|
||||||
|
|
||||||
// Type definitions
|
// Type definitions
|
||||||
let types_emitted = !snap.types.is_empty();
|
let types_emitted = !snap.types.is_empty();
|
||||||
|
|||||||
Reference in New Issue
Block a user