Mizan codegen substrate: Rust kernel + Rust codegen binary, JS generator deleted
The Mizan codegen substrate moves off JavaScript template-literal emission
onto a compiled Rust binary that consumes the same OpenAPI + x-mizan-* IR
the JS substrate consumed. Three structural wins fall out of one move:
1. Moat closes. The codegen logic (how `affects` becomes auto-invalidation,
how named contexts collapse onto bundled fetches, how the registry-to-
Provider mapping is shaped) ships compiled instead of as source bytes
in every consumer's node_modules.
2. Pattern F (lines.push append-walls) becomes structurally unauthorable.
The emit substrate is askama templates in templates/<target>/*.j2 —
actual target-language files with {{ ... }} substitution markers,
syntax-highlighted natively, type-checked against the render context
structs at compile time. The Rust emit modules build typed render
contexts and call .render(); no string-builder surface exists.
3. OpenAPI `default`-bearing fields now emit as non-optional in TS / Python
/ Rust — the server always populates them, so consumer code reads them
without nullable checks. Surfaced by Blazr's typecheck on regeneration.
Layout:
frontends/mizan-rust/ — Rust port of @mizan/base; #[cfg(feature="pyo3")]
exposes PyMizanClient for the Python target.
protocol/mizan-codegen/ — codegen binary source + askama templates.
protocol/mizan-generate/ — npm-package shim. bin/launcher.mjs dispatches
to the platform-appropriate prebuilt binary.
Old generator/ JS tree deleted.
tests/rust/ — wire-parity drivers. drive_kernel exercises
raw client.call() / fetch_context(); drive_emitted
exercises the typed crate the codegen emits.
tests/afi/afi_codegen_app.py — codegen entrypoint module (imports + registers).
backends/mizan-fastapi/.../schema.py — adds outputNullable so the Rust
codegen can wrap T | None responses in Option<T>.
Verification:
- 20 mizan-codegen tests green (IR deserialization, byte-equivalent
parity vs JS baseline for stage1/rust/python/react/vue/svelte,
structural test for channels).
- tests/rust/run_wire_parity.py — 12/12 probes green via the Rust binary
driving the FastAPI fixture end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
474
protocol/mizan-codegen/src/emit/rust.rs
Normal file
474
protocol/mizan-codegen/src/emit/rust.rs
Normal file
@@ -0,0 +1,474 @@
|
||||
//! Rust target — emits a complete Cargo crate consuming the
|
||||
//! `mizan-rust` kernel. Output shape lives at `templates/rust/*.j2`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use askama::Template;
|
||||
use indexmap::IndexMap;
|
||||
|
||||
use crate::config::{Config, RustKernelSpec};
|
||||
use crate::emit::CodegenTarget;
|
||||
use crate::emit::EmittedFile;
|
||||
use crate::emit::casing::{pascal_case, rust_ident, rust_type_ident, snake_case};
|
||||
use crate::ir::{IsContext, JsonSchema, MizanContext, MizanFunction, MizanIR};
|
||||
|
||||
|
||||
pub struct RustCrate;
|
||||
|
||||
|
||||
impl CodegenTarget for RustCrate {
|
||||
fn name(&self) -> &'static str { "rust" }
|
||||
|
||||
fn emit(&self, ir: &MizanIR, config: &Config) -> Vec<EmittedFile> {
|
||||
let crate_name = config
|
||||
.rust_crate_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "mizan_client".to_string());
|
||||
|
||||
let kernel_dep = format_kernel_dep(config.rust_kernel.as_ref());
|
||||
|
||||
let mut out: Vec<EmittedFile> = Vec::new();
|
||||
|
||||
out.push(EmittedFile::new(
|
||||
"Cargo.toml",
|
||||
CargoTemplate { crate_name: &crate_name, kernel_dep: &kernel_dep }
|
||||
.render().expect("Cargo.toml renders"),
|
||||
));
|
||||
|
||||
out.push(EmittedFile::new("src/types.rs", emit_types_rs(&ir.components.schemas)));
|
||||
|
||||
let mut context_modules: Vec<String> = Vec::new();
|
||||
for (ctx_name, ctx_meta) in &ir.contexts {
|
||||
let module_name = snake_case(ctx_name);
|
||||
out.push(EmittedFile::new(
|
||||
PathBuf::from("src/contexts").join(format!("{module_name}.rs")),
|
||||
emit_context_file(ctx_name, ctx_meta, &ir.functions),
|
||||
));
|
||||
context_modules.push(module_name);
|
||||
}
|
||||
if !context_modules.is_empty() {
|
||||
out.push(EmittedFile::new("src/contexts/mod.rs", emit_mod_file(&context_modules)));
|
||||
}
|
||||
|
||||
let mut mutation_modules: Vec<String> = Vec::new();
|
||||
let mut function_modules: Vec<String> = Vec::new();
|
||||
for fn_meta in &ir.functions {
|
||||
if !matches!(fn_meta.is_context, IsContext::No) || fn_meta.is_form { continue; }
|
||||
let is_mutation = !fn_meta.affects.is_empty();
|
||||
let kind = if is_mutation { "mutations" } else { "functions" };
|
||||
let module_name = snake_case(&fn_meta.camel_name);
|
||||
out.push(EmittedFile::new(
|
||||
PathBuf::from(format!("src/{kind}")).join(format!("{module_name}.rs")),
|
||||
emit_call_file(fn_meta),
|
||||
));
|
||||
if is_mutation {
|
||||
mutation_modules.push(module_name);
|
||||
} else {
|
||||
function_modules.push(module_name);
|
||||
}
|
||||
}
|
||||
if !mutation_modules.is_empty() {
|
||||
out.push(EmittedFile::new("src/mutations/mod.rs", emit_mod_file(&mutation_modules)));
|
||||
}
|
||||
if !function_modules.is_empty() {
|
||||
out.push(EmittedFile::new("src/functions/mod.rs", emit_mod_file(&function_modules)));
|
||||
}
|
||||
|
||||
out.push(EmittedFile::new(
|
||||
"src/lib.rs",
|
||||
LibTemplate {
|
||||
has_contexts: !context_modules.is_empty(),
|
||||
has_mutations: !mutation_modules.is_empty(),
|
||||
has_functions: !function_modules.is_empty(),
|
||||
}.render().expect("lib.rs renders"),
|
||||
));
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "rust/Cargo.toml.j2", escape = "none")]
|
||||
struct CargoTemplate<'a> {
|
||||
crate_name: &'a str,
|
||||
kernel_dep: &'a str,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "rust/lib.rs.j2", escape = "none")]
|
||||
struct LibTemplate {
|
||||
has_contexts: bool,
|
||||
has_mutations: bool,
|
||||
has_functions: bool,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "rust/mod.rs.j2", escape = "none")]
|
||||
struct ModTemplate {
|
||||
modules: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "rust/context.rs.j2", escape = "none")]
|
||||
struct ContextTemplate<'a> {
|
||||
pascal: String,
|
||||
snake: String,
|
||||
ctx_name: &'a str,
|
||||
type_imports: Vec<String>,
|
||||
data_fields: Vec<StructField>,
|
||||
params: Vec<StructField>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "rust/call.rs.j2", escape = "none")]
|
||||
struct CallTemplate<'a> {
|
||||
snake: String,
|
||||
name: &'a str,
|
||||
return_type: String,
|
||||
type_imports: Vec<String>,
|
||||
input_param: String,
|
||||
args_value: &'static str,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "rust/types.rs.j2", escape = "none")]
|
||||
struct TypesTemplate {
|
||||
schemas_block: String,
|
||||
hoisted_enums_block: String,
|
||||
}
|
||||
|
||||
|
||||
struct StructField {
|
||||
raw_name: String,
|
||||
ident: String,
|
||||
ty: String,
|
||||
has_rename: bool,
|
||||
}
|
||||
|
||||
|
||||
fn dedupe_preserving_order(items: impl IntoIterator<Item = String>) -> Vec<String> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
items.into_iter().filter(|s| seen.insert(s.clone())).collect()
|
||||
}
|
||||
|
||||
|
||||
// ─── Cargo.toml ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
fn format_kernel_dep(spec: Option<&RustKernelSpec>) -> String {
|
||||
match spec {
|
||||
Some(RustKernelSpec::Path { path }) => format!("{{ path = {} }}", json_str(path)),
|
||||
Some(RustKernelSpec::Git { git, tag, rev, branch }) => {
|
||||
let mut parts = vec![format!("git = {}", json_str(git))];
|
||||
if let Some(t) = tag { parts.push(format!("tag = {}", json_str(t))); }
|
||||
if let Some(r) = rev { parts.push(format!("rev = {}", json_str(r))); }
|
||||
if let Some(b) = branch { parts.push(format!("branch = {}", json_str(b))); }
|
||||
format!("{{ {} }}", parts.join(", "))
|
||||
}
|
||||
Some(RustKernelSpec::Version { version }) => format!("{{ version = {} }}", json_str(version)),
|
||||
None => "{ version = \"0.1\" }".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn json_str(s: &str) -> String {
|
||||
serde_json::to_string(s).expect("string literal serializes")
|
||||
}
|
||||
|
||||
|
||||
// ─── mod.rs ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
fn emit_mod_file(module_names: &[String]) -> String {
|
||||
let mut sorted = module_names.to_vec();
|
||||
sorted.sort();
|
||||
ModTemplate { modules: sorted }.render().expect("mod.rs renders")
|
||||
}
|
||||
|
||||
|
||||
// ─── Context file ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
fn emit_context_file(
|
||||
ctx_name: &str,
|
||||
ctx_meta: &MizanContext,
|
||||
all_functions: &[MizanFunction],
|
||||
) -> String {
|
||||
let pascal = pascal_case(ctx_name);
|
||||
let snake = snake_case(ctx_name);
|
||||
|
||||
let ctx_fns: Vec<&MizanFunction> = all_functions
|
||||
.iter()
|
||||
.filter(|f| f.is_context.as_str() == Some(ctx_name))
|
||||
.collect();
|
||||
|
||||
let type_imports = dedupe_preserving_order(
|
||||
ctx_fns.iter().map(|f| rust_type_ident(&f.output_type)),
|
||||
);
|
||||
|
||||
let data_fields: Vec<StructField> = ctx_fns.iter()
|
||||
.map(|f| {
|
||||
let ident = rust_ident(&f.name);
|
||||
StructField {
|
||||
has_rename: ident != f.name,
|
||||
raw_name: f.name.clone(),
|
||||
ident,
|
||||
ty: rust_type_ident(&f.output_type),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let params: Vec<StructField> = ctx_meta.params.iter()
|
||||
.map(|(p_name, p_meta)| {
|
||||
let ident = rust_ident(p_name);
|
||||
let base = param_rust_type(&p_meta.ty);
|
||||
let ty = if p_meta.required { base.to_string() } else { format!("Option<{base}>") };
|
||||
StructField {
|
||||
has_rename: ident != *p_name,
|
||||
raw_name: p_name.clone(),
|
||||
ident,
|
||||
ty,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
ContextTemplate {
|
||||
pascal,
|
||||
snake,
|
||||
ctx_name,
|
||||
type_imports,
|
||||
data_fields,
|
||||
params,
|
||||
}.render().expect("context.rs renders")
|
||||
}
|
||||
|
||||
|
||||
fn param_rust_type(json_ty: &str) -> &'static str {
|
||||
match json_ty {
|
||||
"integer" => "i64",
|
||||
"number" => "f64",
|
||||
"boolean" => "bool",
|
||||
_ => "String",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─── Call file ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
fn emit_call_file(fn_meta: &MizanFunction) -> String {
|
||||
let output_type = rust_type_ident(&fn_meta.output_type);
|
||||
let return_type = if fn_meta.output_nullable {
|
||||
format!("Option<{output_type}>")
|
||||
} else {
|
||||
output_type.clone()
|
||||
};
|
||||
|
||||
let input_type = fn_meta.input_type.as_deref().map(rust_type_ident);
|
||||
|
||||
let mut used_seed: Vec<String> = vec![output_type.clone()];
|
||||
if let Some(t) = &input_type { used_seed.push(t.clone()); }
|
||||
let type_imports = dedupe_preserving_order(used_seed);
|
||||
|
||||
let (input_param, args_value) = if fn_meta.has_input {
|
||||
let it = input_type.as_deref().unwrap_or("");
|
||||
(
|
||||
format!(", args: &{it}"),
|
||||
"serde_json::to_value(args).unwrap_or(Value::Object(Default::default()))",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
String::new(),
|
||||
"Value::Object(Default::default())",
|
||||
)
|
||||
};
|
||||
|
||||
CallTemplate {
|
||||
snake: snake_case(&fn_meta.name),
|
||||
name: &fn_meta.name,
|
||||
return_type,
|
||||
type_imports,
|
||||
input_param,
|
||||
args_value,
|
||||
}.render().expect("call.rs renders")
|
||||
}
|
||||
|
||||
|
||||
// ─── types.rs ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
struct EnumCtx {
|
||||
hoisted: Vec<(String, Vec<serde_json::Value>)>,
|
||||
depth: usize,
|
||||
enum_name: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
fn emit_types_rs(schemas: &IndexMap<String, JsonSchema>) -> String {
|
||||
let mut ctx = EnumCtx { hoisted: Vec::new(), depth: 0, enum_name: None };
|
||||
|
||||
let schemas_block = schemas.iter()
|
||||
.map(|(raw_name, schema)| {
|
||||
let name = rust_type_ident(raw_name);
|
||||
if let Some(values) = &schema.r#enum {
|
||||
if schema.ty.as_deref() == Some("string") {
|
||||
return emit_string_enum(&name, values);
|
||||
}
|
||||
}
|
||||
if schema.ty.as_deref() == Some("array") {
|
||||
return emit_transparent_array(&name, schema, &mut ctx);
|
||||
}
|
||||
if schema.ty.as_deref() == Some("object") {
|
||||
if let Some(props) = &schema.properties {
|
||||
return emit_struct(&name, schema, props, &mut ctx);
|
||||
}
|
||||
}
|
||||
emit_type_alias(&name, schema, &mut ctx)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let hoisted_enums_block = ctx.hoisted.iter()
|
||||
.map(|(n, v)| emit_string_enum(n, v))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
TypesTemplate { schemas_block, hoisted_enums_block }
|
||||
.render().expect("types.rs renders")
|
||||
}
|
||||
|
||||
|
||||
fn emit_string_enum(name: &str, variants: &[serde_json::Value]) -> String {
|
||||
let body = variants.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|v| {
|
||||
let ident = pascal_case(v);
|
||||
let rename = if ident == v {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" #[serde(rename = {})]\n", json_str(v))
|
||||
};
|
||||
format!("{rename} {ident},")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!(
|
||||
"#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\npub enum {name} {{\n{body}\n}}\n",
|
||||
name = rust_type_ident(name),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
fn emit_transparent_array(name: &str, schema: &JsonSchema, ctx: &mut EnumCtx) -> String {
|
||||
ctx.depth = 1;
|
||||
ctx.enum_name = None;
|
||||
let inner = rust_type_from_schema(schema.items.as_deref().unwrap_or(&JsonSchema::default()), ctx);
|
||||
format!(
|
||||
"#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(transparent)]\npub struct {name}(pub Vec<{inner}>);\n",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
fn emit_struct(
|
||||
name: &str,
|
||||
schema: &JsonSchema,
|
||||
properties: &IndexMap<String, JsonSchema>,
|
||||
ctx: &mut EnumCtx,
|
||||
) -> String {
|
||||
let required: std::collections::HashSet<&str> =
|
||||
schema.required.iter().map(String::as_str).collect();
|
||||
|
||||
let fields = properties.iter()
|
||||
.map(|(field_raw, field_schema)| {
|
||||
let field_name = rust_ident(field_raw);
|
||||
ctx.depth = 1;
|
||||
ctx.enum_name = Some(format!("{name}_{}", pascal_case(field_raw)));
|
||||
let mut ty = rust_type_from_schema(field_schema, ctx);
|
||||
let is_required = required.contains(field_raw.as_str())
|
||||
|| field_schema.default.is_some();
|
||||
if !is_required && !ty.starts_with("Option<") {
|
||||
ty = format!("Option<{ty}>");
|
||||
}
|
||||
let rename = if field_name == *field_raw {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" #[serde(rename = \"{field_raw}\")]\n")
|
||||
};
|
||||
format!("{rename} pub {field_name}: {ty},")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
format!(
|
||||
"#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct {name} {{\n{fields}\n}}\n",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
fn emit_type_alias(name: &str, schema: &JsonSchema, ctx: &mut EnumCtx) -> String {
|
||||
ctx.depth = 0;
|
||||
ctx.enum_name = Some(name.to_string());
|
||||
let ty = rust_type_from_schema(schema, ctx);
|
||||
format!("pub type {name} = {ty};\n")
|
||||
}
|
||||
|
||||
|
||||
fn rust_type_from_schema(schema: &JsonSchema, ctx: &mut EnumCtx) -> String {
|
||||
if let Some(r) = schema.ref_name() {
|
||||
return rust_type_ident(r);
|
||||
}
|
||||
|
||||
if let Some(any_of) = &schema.any_of {
|
||||
let has_null = any_of.iter().any(|s| s.ty.as_deref() == Some("null"));
|
||||
let non_null: Vec<&JsonSchema> = any_of
|
||||
.iter()
|
||||
.filter(|s| s.ty.as_deref() != Some("null"))
|
||||
.collect();
|
||||
if has_null && non_null.len() == 1 {
|
||||
ctx.enum_name = None;
|
||||
return format!("Option<{}>", rust_type_from_schema(non_null[0], ctx));
|
||||
}
|
||||
}
|
||||
|
||||
let nullable = schema.nullable;
|
||||
let inner = inner_rust_type(schema, ctx);
|
||||
if nullable {
|
||||
format!("Option<{inner}>")
|
||||
} else {
|
||||
inner
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn inner_rust_type(schema: &JsonSchema, ctx: &mut EnumCtx) -> String {
|
||||
if let Some(values) = &schema.r#enum {
|
||||
if schema.ty.as_deref() == Some("string") {
|
||||
let enum_name = ctx
|
||||
.enum_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("Enum_{}", ctx.depth));
|
||||
ctx.hoisted.push((enum_name.clone(), values.clone()));
|
||||
return enum_name;
|
||||
}
|
||||
}
|
||||
match schema.ty.as_deref() {
|
||||
Some("integer") => "i64".to_string(),
|
||||
Some("number") => "f64".to_string(),
|
||||
Some("boolean") => "bool".to_string(),
|
||||
Some("string") => "String".to_string(),
|
||||
Some("array") => {
|
||||
ctx.depth += 1;
|
||||
ctx.enum_name = None;
|
||||
let inner = rust_type_from_schema(schema.items.as_deref().unwrap_or(&JsonSchema::default()), ctx);
|
||||
format!("Vec<{inner}>")
|
||||
}
|
||||
Some("object") => "serde_json::Value".to_string(),
|
||||
_ => "serde_json::Value".to_string(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user