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:
164
protocol/mizan-codegen/src/main.rs
Normal file
164
protocol/mizan-codegen/src/main.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
//! `mizan-generate` — Rust codegen binary.
|
||||
//!
|
||||
//! Replaces the Node-based `protocol/mizan-generate/generator/cli.mjs`.
|
||||
//! Reads `mizan.toml`, spawns the configured backend to fetch the IR, and
|
||||
//! dispatches each `--target` to its `CodegenTarget` impl. Per-target file
|
||||
//! emission writes under the configured `output` directory.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
|
||||
use mizan_codegen::{config, emit, fetch};
|
||||
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "mizan-generate",
|
||||
about = "Mizan code generator — consumes Mizan IR; emits typed clients.",
|
||||
)]
|
||||
struct Cli {
|
||||
/// Path to the codegen config file.
|
||||
#[arg(short, long, default_value = "mizan.toml")]
|
||||
config: PathBuf,
|
||||
|
||||
/// Output directory (overrides `output` in config).
|
||||
#[arg(short, long)]
|
||||
output: Option<PathBuf>,
|
||||
|
||||
/// Comma-separated list of targets (overrides `targets` in config).
|
||||
#[arg(short, long)]
|
||||
target: Option<String>,
|
||||
|
||||
/// Read the IR from a JSON file instead of spawning the backend's
|
||||
/// schema-export command. The fixture path used by integration tests.
|
||||
#[arg(long)]
|
||||
from_json: Option<PathBuf>,
|
||||
}
|
||||
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let config_exists = cli.config.exists();
|
||||
let mut config: config::Config = if config_exists {
|
||||
let config_text = fs::read_to_string(&cli.config)
|
||||
.with_context(|| format!("reading config: {}", cli.config.display()))?;
|
||||
toml::from_str(&config_text)
|
||||
.with_context(|| format!("parsing TOML: {}", cli.config.display()))?
|
||||
} else if cli.from_json.is_some() {
|
||||
// --from-json bypasses the fetcher, so a missing config is fine —
|
||||
// CLI flags supply output + targets.
|
||||
config::Config {
|
||||
project_id: None,
|
||||
output: PathBuf::from("."),
|
||||
targets: vec![],
|
||||
source: Default::default(),
|
||||
rust_kernel: None,
|
||||
rust_crate_name: None,
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"config not found: {} (pass --from-json to skip fetch)",
|
||||
cli.config.display(),
|
||||
));
|
||||
};
|
||||
|
||||
if let Some(o) = cli.output {
|
||||
config.output = o;
|
||||
}
|
||||
if let Some(t) = cli.target {
|
||||
config.targets = t.split(',').map(|s| s.trim().to_string()).collect();
|
||||
}
|
||||
|
||||
let config_dir = if config_exists {
|
||||
resolve_config_dir(&cli.config)?
|
||||
} else {
|
||||
std::env::current_dir()?
|
||||
};
|
||||
|
||||
let ir = if let Some(json_path) = &cli.from_json {
|
||||
let abs = if json_path.is_absolute() {
|
||||
json_path.clone()
|
||||
} else {
|
||||
config_dir.join(json_path)
|
||||
};
|
||||
eprintln!("[mizan] Reading IR from {}", abs.display());
|
||||
let raw = fs::read_to_string(&abs)
|
||||
.with_context(|| format!("read {}", abs.display()))?;
|
||||
fetch::parse_ir_from_str(&raw)?
|
||||
} else {
|
||||
eprintln!("[mizan] Fetching schema...");
|
||||
fetch::fetch_schema(&config, &config_dir)?
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"[mizan] Loaded {} function(s), {} context group(s), {} schema(s)",
|
||||
ir.functions.len(),
|
||||
ir.contexts.len(),
|
||||
ir.components.schemas.len(),
|
||||
);
|
||||
|
||||
// Stage 1 is the framework-agnostic foundation that react/vue/svelte
|
||||
// import from. Auto-include it whenever any consumer of `./index`
|
||||
// (the Stage 1 re-export root) is in the target set.
|
||||
let needs_stage1 = config.targets.iter()
|
||||
.any(|t| matches!(t.as_str(), "react" | "vue" | "svelte"));
|
||||
if needs_stage1 && !config.targets.iter().any(|t| t == "stage1") {
|
||||
config.targets.insert(0, "stage1".to_string());
|
||||
}
|
||||
// Channels schema piggybacks on the main schema (x-mizan-channels);
|
||||
// auto-include the channels emit when react is the target and the
|
||||
// schema actually carries channels.
|
||||
if config.targets.iter().any(|t| t == "react")
|
||||
&& !ir.channels.is_empty()
|
||||
&& !config.targets.iter().any(|t| t == "channels")
|
||||
{
|
||||
config.targets.push("channels".to_string());
|
||||
}
|
||||
|
||||
eprintln!("[mizan] Targets: {}", config.targets.join(", "));
|
||||
|
||||
let output_dir = if config.output.is_absolute() {
|
||||
config.output.clone()
|
||||
} else {
|
||||
config_dir.join(&config.output)
|
||||
};
|
||||
|
||||
for target_name in &config.targets {
|
||||
let Some(target) = emit::target_by_name(target_name) else {
|
||||
eprintln!("[mizan] WARN: target '{target_name}' has no emitter yet (Phase 2 scaffold)");
|
||||
continue;
|
||||
};
|
||||
let files = target.emit(&ir, &config);
|
||||
for file in files {
|
||||
let path = output_dir.join(&file.rel_path);
|
||||
write_output(&path, &file.content)?;
|
||||
eprintln!("[mizan] {} -> {}", target.name(), file.rel_path.display());
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("[mizan] Generation complete.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
fn resolve_config_dir(config_path: &Path) -> Result<PathBuf> {
|
||||
let abs = fs::canonicalize(config_path)
|
||||
.with_context(|| format!("canonicalize {}", config_path.display()))?;
|
||||
Ok(abs
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| PathBuf::from(".")))
|
||||
}
|
||||
|
||||
|
||||
fn write_output(path: &Path, content: &str) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("mkdir {}", parent.display()))?;
|
||||
}
|
||||
fs::write(path, content).with_context(|| format!("write {}", path.display()))
|
||||
}
|
||||
Reference in New Issue
Block a user