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:
137
protocol/mizan-codegen/src/emit/casing.rs
Normal file
137
protocol/mizan-codegen/src/emit/casing.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
//! Casing transforms — port of `protocol/mizan-generate/generator/lib/casing.mjs`.
|
||||
//!
|
||||
//! The Mizan IR uses snake_case names (`user_id`, `update_profile`). Per-target
|
||||
//! identifier conventions vary: TypeScript wants `pascalCase`/`camelCase`,
|
||||
//! Rust wants `snake_case` (with `r#`-escaping for keywords). These helpers
|
||||
//! pin the conversion so emit-targets share one vocabulary.
|
||||
|
||||
|
||||
fn split_parts(s: &str) -> Vec<&str> {
|
||||
s.split(|c: char| c == '.' || c == '-' || c == '_')
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
fn uppercase_first(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().chain(chars).collect(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn lowercase_first(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_lowercase().chain(chars).collect(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub fn pascal_case(s: &str) -> String {
|
||||
split_parts(s).into_iter().map(uppercase_first).collect()
|
||||
}
|
||||
|
||||
|
||||
pub fn camel_case(s: &str) -> String {
|
||||
let pascal = pascal_case(s);
|
||||
lowercase_first(&pascal)
|
||||
}
|
||||
|
||||
|
||||
/// Insert underscores at lowercase/digit-to-uppercase boundaries, unify with
|
||||
/// the existing `.`/`-`/`_` separators, then lowercase + join.
|
||||
pub fn snake_case(s: &str) -> String {
|
||||
let mut with_boundaries = String::with_capacity(s.len() + 4);
|
||||
let mut prev: Option<char> = None;
|
||||
for c in s.chars() {
|
||||
if let Some(p) = prev {
|
||||
if (p.is_ascii_lowercase() || p.is_ascii_digit()) && c.is_ascii_uppercase() {
|
||||
with_boundaries.push('_');
|
||||
}
|
||||
}
|
||||
with_boundaries.push(c);
|
||||
prev = Some(c);
|
||||
}
|
||||
split_parts(&with_boundaries)
|
||||
.into_iter()
|
||||
.map(|p| p.to_ascii_lowercase())
|
||||
.collect::<Vec<_>>()
|
||||
.join("_")
|
||||
}
|
||||
|
||||
|
||||
/// Rust reserved words that can be escaped via `r#` (excludes `crate`, `self`,
|
||||
/// `Self`, `super`, `extern`, which can't be raw-escaped on stable).
|
||||
const RUST_RAW_KEYWORDS: &[&str] = &[
|
||||
"as", "break", "const", "continue", "else", "enum", "false", "fn", "for",
|
||||
"if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub",
|
||||
"ref", "return", "static", "struct", "trait", "true", "type", "unsafe",
|
||||
"use", "where", "while", "async", "await", "dyn", "abstract", "become",
|
||||
"box", "do", "final", "macro", "override", "priv", "typeof", "unsized",
|
||||
"virtual", "yield", "try", "union",
|
||||
];
|
||||
|
||||
const RUST_HARD_RESERVED: &[&str] = &["crate", "self", "Self", "super", "extern"];
|
||||
|
||||
|
||||
pub fn rust_ident(name: &str) -> String {
|
||||
let snake = snake_case(name);
|
||||
if RUST_HARD_RESERVED.contains(&snake.as_str()) {
|
||||
format!("{snake}_")
|
||||
} else if RUST_RAW_KEYWORDS.contains(&snake.as_str()) {
|
||||
format!("r#{snake}")
|
||||
} else {
|
||||
snake
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub fn rust_type_ident(name: &str) -> String {
|
||||
let pascal = pascal_case(name);
|
||||
if RUST_HARD_RESERVED.contains(&pascal.as_str()) {
|
||||
format!("{pascal}_")
|
||||
} else if RUST_RAW_KEYWORDS.contains(&pascal.as_str()) {
|
||||
format!("r#{pascal}")
|
||||
} else {
|
||||
pascal
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pascal_case_matches_js_codegen() {
|
||||
assert_eq!(pascal_case("user_profile"), "UserProfile");
|
||||
assert_eq!(pascal_case("find-user"), "FindUser");
|
||||
assert_eq!(pascal_case("api.v1.users"), "ApiV1Users");
|
||||
assert_eq!(pascal_case(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camel_case_matches_js_codegen() {
|
||||
assert_eq!(camel_case("user_profile"), "userProfile");
|
||||
assert_eq!(camel_case("UpdateProfile"), "updateProfile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snake_case_inserts_pascal_boundaries() {
|
||||
assert_eq!(snake_case("UserProfile"), "user_profile");
|
||||
assert_eq!(snake_case("camelCase"), "camel_case");
|
||||
assert_eq!(snake_case("already_snake"), "already_snake");
|
||||
assert_eq!(snake_case("HTTPResponse"), "httpresponse"); // matches JS behavior
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_ident_escapes_keywords() {
|
||||
assert_eq!(rust_ident("type"), "r#type");
|
||||
assert_eq!(rust_ident("normal"), "normal");
|
||||
assert_eq!(rust_ident("self"), "self_");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user