A channel's message slots are named from the client, on every backend

The IR called them react-message and django-message, so a FastAPI channel had
to declare a DjangoMessage. They are client-message and server-message now,
and the direction words hold wherever a channel is declared: Params /
ClientMessage / ServerMessage, with mizan-core deriving <Pascal>Params and
friends so no backend names a type itself. Django's ReactChannel and
FastAPI's ReactChannel are both Channel.

mizan-fastapi never registered a channels extension, so build_ir() emitted no
channel at all and every payload type was invisible to codegen. It registers
one now. RegistryExtension is an ABC requiring all(), which is what the IR
reads — an extension that cannot enumerate its registrations no longer exists.

The gate that should have caught the rename could not: tests/afi registered no
channel because mizan-rust had no channel registry to register one in, so a
five-package rename of the wire contract passed byte-parity without a channel
byte crossing it. mizan-rust grows ChannelSlotKind, a CHANNELS slice, a
#[mizan::channel] macro, and KDL emission whose wire_to_pascal matches Python's
split; the AFI fixture now carries a channel with every slot and one with a
single slot, so all three backends prove the contract byte for byte.

MizanChannel held three Option<String> beside three has_*() predicates and
unwrapped them with defaults; it holds an ordered slot vector, so an absent
slot is absent rather than defaulted. The channels target emitted a React
hooks file that a stage1-only consumer could not compile — react emits that
now. The codegen's parity tests byte-compared emitted source against baselines
without ever compiling it: they compile the generated crate and run its tests,
import the generated Python package and call every method, and typecheck each
TypeScript target against a consumer.

Also fixed at source: app_visitor printed its import diagnostic to stdout, the
stream export_mizan_ir writes KDL to, so a failed import silently corrupted the
IR; the apps root was hardcoded to "apps"; _default_literal crashed build_ir on
any non-JSON-serializable field default; Django and mizan-core derived Pascal
names two different ways, disagreeing on every dotted channel name.

ir.py builds a document and renders templates/ir/document.kdl.j2 rather than
appending KDL strings with hand-tracked indentation, and named types resolve to
a fixed point — a model reachable only through a union branch was referenced by
a ref that no type block ever defined.

The rest is the write-gate's own classifiers run over the standing tree:
relative imports, silent swallows, Protocol contracts that should be ABCs,
emitters hand-rendering target source, catch-all arms over closed enums, and
comments narrating the project rather than the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 14:03:19 -04:00
parent 398c90fc8b
commit 3aafec6dd4
345 changed files with 11054 additions and 17359 deletions

View File

@@ -1,27 +1,23 @@
#!/usr/bin/env python3
"""Pydantic → Rust codegen helper invoked by mizan-codegen's
`[source.rust.pydantic]` step.
"""Introspect a Pydantic module and print its declarations as JSON.
Reads a JSON payload from argv[1] with keys:
argv[1] is a JSON object:
- module: Python module to import (e.g. "claude_manage.schema")
- output: Path to write the generated Rust file
- derives: List of derive identifiers to apply to every emitted item
- header: Optional file prefix (e.g. an AUTO-GENERATED warning)
- derives: derive identifiers decoru applies to every emitted struct
Discovers every BaseModel subclass declared in the module (handled by
decoru) AND every Enum subclass declared there (handled inline — decoru
itself is scoped to BaseModel). Writes one Rust file containing both.
stdout is a JSON object:
- enums: [{"name": <python class name>, "variants": [<rust ident>, ...]}]
- structs: [<rust source>, ...] as rendered by decoru
Bundled with the mizan-codegen binary (include_str!) and piped to
`python -` at codegen time — no install step beyond decoru being
importable in the python environment.
decoru itself is scoped to BaseModel, so Enum subclasses are reported as
shapes for the caller to render; only their variant identifiers go through
decoru, which keeps them equal to the ones it bakes into field defaults.
"""
import importlib
import inspect
import json
import sys
import textwrap
from enum import Enum
from pathlib import Path
@@ -60,46 +56,6 @@ def discover_enums(module) -> list[type[Enum]]:
]
# Last-variant-is-default matches the catch-all idiom (e.g. `Metadata`
# in `claude_manage.schema.EntryType`). Decoru's `emit_rust_struct`
# emits `impl Default` unconditionally on every BaseModel, so any
# enum-typed field that lacks a Pydantic default must still satisfy
# `EntryType::default()`. Forcing #[default] on the last member keeps
# the generated structs compilable without per-enum config.
_ENUM_TEMPLATE = textwrap.dedent("""\
#[derive({derives})]
#[serde(rename_all = "snake_case")]
pub enum {name} {{
{variants}
}}
""")
def _render_variant(member: Enum, *, is_default: bool) -> str:
# Pascal-casing the Python member name is the same conversion decoru
# applies when capturing enum field defaults. Sharing the function is
# load-bearing — divergent conversions emit non-compiling schema.rs.
pascal = to_rust_variant_ident(member.name)
default_attr = " #[default]\n" if is_default else ""
return f"{default_attr} {pascal},"
def emit_rust_enum(enum_class: type[Enum], derives: tuple[str, ...]) -> str:
"""Render a Rust enum with PascalCase variants from Python member
names. Pairs `#[serde(rename_all = "snake_case")]` so the wire form
matches each member's `value`. Adds `Default` to the derives and
marks the last member `#[default]` — see `_ENUM_TEMPLATE` for the
rationale."""
name = enum_class.__name__
members = list(enum_class)
full_derives = ", ".join((*derives, "Default"))
variants = "\n".join(
_render_variant(m, is_default=(i == len(members) - 1))
for i, m in enumerate(members)
)
return _ENUM_TEMPLATE.format(derives=full_derives, name=name, variants=variants)
def main() -> int:
if len(sys.argv) < 2:
sys.stderr.write("run_decoru.py: missing JSON payload argument\n")
@@ -107,9 +63,7 @@ def main() -> int:
payload = json.loads(sys.argv[1])
module_name: str = payload["module"]
output_path = Path(payload["output"]).resolve()
derives = tuple(payload.get("derives", ()))
header = payload.get("header") or ""
derives = tuple(payload["derives"])
sys.path.insert(0, str(Path.cwd()))
@@ -122,15 +76,21 @@ def main() -> int:
)
return 3
enum_blocks = [emit_rust_enum(e, derives) for e in enums]
struct_blocks = [emit_rust_struct(walk_pydantic_model(m), derives=derives) for m in models]
body = "\n".join((*enum_blocks, *struct_blocks))
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(header + body)
sys.stderr.write(
f"run_decoru.py: wrote {len(enums)} enum(s) + {len(models)} struct(s) to {output_path}\n"
json.dump(
{
"enums": [
{
"name": enum_class.__name__,
"variants": [to_rust_variant_ident(m.name) for m in enum_class],
}
for enum_class in enums
],
"structs": [
emit_rust_struct(walk_pydantic_model(model), derives=derives)
for model in models
],
},
sys.stdout,
)
return 0

View File

@@ -1,5 +1,5 @@
//! Codegen configuration — deserialized from `mizan.toml` at the consumer
//! project root. Replaces the JS substrate's `mizan.config.mjs`.
//! project root.
//!
//! Example:
//!
@@ -20,38 +20,82 @@
use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::Deserialize;
use serde::{Deserialize, Deserializer};
/// Every field carries a value once deserialization returns — a key absent
/// from the TOML takes the corresponding field of `Config::default()`.
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Config {
#[serde(default)]
pub project_id: Option<String>,
#[serde(default = "default_output")]
pub output: PathBuf,
#[serde(default = "default_targets")]
pub targets: Vec<String>,
#[serde(default)]
pub source: SourceConfig,
#[serde(default)]
pub rust_kernel: Option<RustKernelSpec>,
#[serde(default)]
pub rust_crate_name: Option<String>,
pub rust_crate_name: String,
}
fn default_output() -> PathBuf {
PathBuf::from("src/api")
impl Default for Config {
fn default() -> Self {
Self {
project_id: None,
output: PathBuf::from("src/api"),
targets: vec!["react".to_string()],
source: SourceConfig::default(),
rust_kernel: None,
rust_crate_name: "mizan_client".to_string(),
}
}
}
fn default_targets() -> Vec<String> {
vec!["react".to_string()]
fn default_python() -> String {
"python".to_string()
}
/// A subprocess invocation split into the program and its argv. The
/// deserializer rejects an empty array, so every `CommandLine` in hand names
/// a program.
#[derive(Debug, Clone)]
pub struct CommandLine {
program: String,
args: Vec<String>,
}
impl CommandLine {
pub fn program_only(program: &str) -> Self {
Self { program: program.to_string(), args: Vec::new() }
}
pub fn program(&self) -> &str {
&self.program
}
pub fn args(&self) -> &[String] {
&self.args
}
}
impl<'de> Deserialize<'de> for CommandLine {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let mut parts = Vec::<String>::deserialize(deserializer)?.into_iter();
match parts.next() {
Some(program) => Ok(CommandLine { program, args: parts.collect() }),
None => Err(serde::de::Error::custom(
"command must be a non-empty array naming the program first",
)),
}
}
}
@@ -63,20 +107,11 @@ pub struct SourceConfig {
#[serde(default)]
pub django: Option<DjangoSource>,
/// Canonical "Pydantic + Rust" DX path. The Rust crate is the IR
/// authority; an optional `pydantic` sub-block invokes decoru as a
/// pre-step to author Rust types from Pydantic models. Pure-Rust
/// usage (no Pydantic) just omits the sub-block.
#[serde(default)]
pub rust: Option<RustSource>,
/// `[source.script]` — generic source. Spawn an arbitrary command and
/// read its stdout as KDL IR. Use when none of the language-specific
/// sources fit — e.g. a Python module that walks `mizan_core.registry`
/// for a non-Django/non-FastAPI consumer, or a custom IR emitter.
/// Keeps mizan-codegen out of the business of knowing every possible
/// backend language while preserving the "subprocess emits KDL"
/// contract every other source already follows.
/// `[source.script]` — spawn an arbitrary command and read its stdout
/// as KDL IR.
#[serde(default)]
pub script: Option<ScriptSource>,
}
@@ -89,11 +124,11 @@ pub struct FastapiSource {
#[serde(default)]
pub cwd: Option<PathBuf>,
#[serde(default)]
pub python: Option<String>,
#[serde(default = "default_python")]
pub python: String,
#[serde(default)]
pub command: Option<Vec<String>>,
pub command: Option<CommandLine>,
#[serde(default)]
pub env: BTreeMap<String, String>,
@@ -104,11 +139,11 @@ pub struct FastapiSource {
pub struct DjangoSource {
pub manage_path: PathBuf,
#[serde(default)]
pub python: Option<String>,
#[serde(default = "default_python")]
pub python: String,
#[serde(default)]
pub command: Option<Vec<String>>,
pub command: Option<CommandLine>,
#[serde(default)]
pub env: BTreeMap<String, String>,
@@ -122,12 +157,11 @@ pub struct DjangoSource {
#[derive(Debug, Deserialize)]
pub struct RustSource {
/// Path to the consumer's Cargo.toml, relative to the codegen config
/// directory. Defaults to `Cargo.toml` (i.e. config_dir/Cargo.toml).
#[serde(default)]
pub manifest_path: Option<PathBuf>,
/// directory.
#[serde(default = "default_manifest_path")]
pub manifest_path: PathBuf,
/// Name of the binary under `[[bin]]` that exports the IR. Defaults
/// to `emit-mizan-ir` — the convention this substrate documents.
/// Name of the binary under `[[bin]]` that exports the IR.
#[serde(default = "default_rust_bin")]
pub bin: String,
@@ -135,8 +169,7 @@ pub struct RustSource {
#[serde(default)]
pub features: Vec<String>,
/// Build in release mode. Defaults to false (dev mode is faster for
/// codegen, and the binary is throwaway).
/// Build in release mode.
#[serde(default)]
pub release: bool,
@@ -144,25 +177,26 @@ pub struct RustSource {
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Optional pre-step — invoke decoru on a Pydantic source module
/// before running the Cargo bin. When present, the pipeline becomes:
/// 1. python + decoru → write Rust types to `pydantic.output`
/// 2. cargo run --bin <bin> → emit IR to stdout
/// Omit for pure-Rust usage (hand-authored or otherwise-generated
/// Rust types with `#[derive(Mizan)]`).
/// Pre-step run before the Cargo bin: decoru writes Rust types from a
/// Pydantic source module to `pydantic.output`.
#[serde(default)]
pub pydantic: Option<PydanticPreStep>,
}
fn default_manifest_path() -> PathBuf {
PathBuf::from("Cargo.toml")
}
fn default_rust_bin() -> String {
"emit-mizan-ir".to_string()
}
/// Pydantic → Rust pre-step. Runs an embedded Python helper that walks
/// the named module for `BaseModel` subclasses and invokes decoru's
/// `walk_pydantic_model` + `emit_rust_struct` to produce a Rust file.
/// Pydantic → Rust pre-step. Runs an embedded Python helper that reports
/// the module's `BaseModel` and `Enum` declarations, then writes the Rust
/// file those shapes render to.
#[derive(Debug, Deserialize)]
pub struct PydanticPreStep {
/// Python module name to import (e.g. `claude_manage.schema`).
@@ -178,26 +212,24 @@ pub struct PydanticPreStep {
#[serde(default)]
pub cwd: Option<PathBuf>,
/// Python executable. Defaults to `python`.
#[serde(default)]
pub python: Option<String>,
/// Python executable.
#[serde(default = "default_python")]
pub python: String,
/// Full command override (e.g. `["uv", "run", "python"]`). Wins over
/// `python` when present.
#[serde(default)]
pub command: Option<Vec<String>>,
pub command: Option<CommandLine>,
/// Derive macros to apply to every generated struct. The default
/// matches the Mizan-canonical set used in `cores/rust/blazr/session`
/// — serde + mizan_core::Mizan for end-to-end RPC participation.
/// Derive macros applied to every generated struct.
#[serde(default = "default_pydantic_derives")]
pub derives: Vec<String>,
/// Optional prelude inserted at the top of the generated file
/// (typically a "// AUTO-GENERATED" warning + `use` statements for
/// referenced types not produced by decoru itself).
/// Prelude inserted at the top of the generated file — the leading
/// comment plus `use` statements for referenced types decoru does not
/// itself produce.
#[serde(default)]
pub header: Option<String>,
pub header: String,
/// Environment overrides for the python subprocess.
#[serde(default)]
@@ -216,11 +248,8 @@ fn default_pydantic_derives() -> Vec<String> {
}
/// `[source.script]` — generic stdout-of-arbitrary-command source.
///
/// Spawns `command` with `args`, reads its stdout, and parses it as KDL
/// Mizan IR. The same contract every other source follows; this one just
/// doesn't bake in any language-specific assumptions.
/// `[source.script]` — spawns `command`, reads its stdout, and parses it as
/// KDL Mizan IR.
///
/// Example:
///
@@ -230,9 +259,8 @@ fn default_pydantic_derives() -> Vec<String> {
/// ```
#[derive(Debug, Deserialize)]
pub struct ScriptSource {
/// Full command vector. First entry is the program; rest are argv.
/// Must be non-empty.
pub command: Vec<String>,
/// Program plus argv.
pub command: CommandLine,
/// Working directory for the subprocess, relative to the codegen
/// config directory. Defaults to the config directory itself.

View File

@@ -1,4 +1,4 @@
//! Casing transforms — port of `protocol/mizan-generate/generator/lib/casing.mjs`.
//! Casing transforms.
//!
//! The Mizan IR uses snake_case names (`user_id`, `update_profile`). Per-target
//! identifier conventions vary: TypeScript wants `pascalCase`/`camelCase`,
@@ -7,7 +7,7 @@
fn split_parts(s: &str) -> Vec<&str> {
s.split(|c: char| c == '.' || c == '-' || c == '_')
s.split(['.', '-', '_'])
.filter(|p| !p.is_empty())
.collect()
}
@@ -107,7 +107,7 @@ mod tests {
use super::*;
#[test]
fn pascal_case_matches_js_codegen() {
fn pascal_case_joins_every_separator_class() {
assert_eq!(pascal_case("user_profile"), "UserProfile");
assert_eq!(pascal_case("find-user"), "FindUser");
assert_eq!(pascal_case("api.v1.users"), "ApiV1Users");
@@ -115,7 +115,7 @@ mod tests {
}
#[test]
fn camel_case_matches_js_codegen() {
fn camel_case_lowercases_only_the_first_part() {
assert_eq!(camel_case("user_profile"), "userProfile");
assert_eq!(camel_case("UpdateProfile"), "updateProfile");
}
@@ -125,7 +125,9 @@ mod tests {
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
// A run of capitals carries no lowercase-to-uppercase boundary, so it
// collapses into one part.
assert_eq!(snake_case("HTTPResponse"), "httpresponse");
}
#[test]

View File

@@ -1,17 +1,17 @@
//! Channels target — emits `channels.ts` (typed message envelopes + channel
//! registry) and `channels.hooks.tsx` (`useXChannel` React hooks) from the
//! `x-mizan-channels` extension. Django-only feature; the FastAPI backend's
//! IR carries an empty channels list and this target emits nothing.
//! Channels target — emits `channels.ts`: one TypeScript declaration per
//! channel slot type plus the `CHANNELS` registry keyed by wire name.
use std::collections::HashSet;
use std::path::PathBuf;
use askama::Template;
use indexmap::IndexMap;
use crate::config::Config;
use crate::emit::stage1::{ts_schema, TsSchema};
use crate::emit::CodegenTarget;
use crate::emit::EmittedFile;
use crate::ir::{MizanChannel, MizanIR, NamedType, Primitive, StructField, TypeShape};
use crate::ir::{ChannelSlot, MizanChannel, MizanIR, NamedType, SlotKind};
pub struct ChannelsTarget;
@@ -25,29 +25,12 @@ impl CodegenTarget for ChannelsTarget {
return Vec::new();
}
let schemas_block = emit_channel_schemas(&ir.channels, &ir.types);
let types_content = ChannelsTypes {
let content = ChannelsTypes {
channels: ir.channels.iter().map(ChannelView::from_ir).collect(),
schemas_block,
}.render().expect("channels.ts renders");
schemas: slot_schemas(&ir.channels, &ir.types),
}.render().unwrap_or_else(|e| panic!("template channels/channels.ts.j2: {e}"));
let mut type_imports: Vec<String> = Vec::new();
for ch in &ir.channels {
if ch.has_params() { if let Some(t) = &ch.params_type { type_imports.push(t.clone()); } }
if ch.has_react_message() { if let Some(t) = &ch.react_message_type { type_imports.push(t.clone()); } }
if ch.has_django_message() { if let Some(t) = &ch.django_message_type { type_imports.push(t.clone()); } }
}
let hooks_content = ChannelsHooks {
channels: ir.channels.iter().map(ChannelView::from_ir).collect(),
type_imports,
}.render().expect("channels.hooks.tsx renders");
vec![
EmittedFile::new(PathBuf::from("channels.ts"), types_content),
EmittedFile::new(PathBuf::from("channels.hooks.tsx"), hooks_content),
]
vec![EmittedFile::new(PathBuf::from("channels.ts"), content)]
}
}
@@ -56,123 +39,66 @@ impl CodegenTarget for ChannelsTarget {
#[template(path = "channels/channels.ts.j2", escape = "none")]
struct ChannelsTypes<'a> {
channels: Vec<ChannelView<'a>>,
schemas_block: String,
}
#[derive(Template)]
#[template(path = "channels/channels.hooks.tsx.j2", escape = "none")]
struct ChannelsHooks<'a> {
channels: Vec<ChannelView<'a>>,
type_imports: Vec<String>,
schemas: Vec<TsSchema<'a>>,
}
struct ChannelView<'a> {
name: &'a str,
pascal_name: &'a str,
has_params: bool,
has_react_message: bool,
has_django_message: bool,
params_type: String,
react_message_type: String,
django_message_type: String,
params_type_or_record: String,
react_msg_type_or_never: String,
django_msg_type_or_never: String,
slots: Vec<SlotView<'a>>,
}
struct SlotView<'a> {
registry_key: &'static str,
type_name: &'a str,
}
impl<'a> ChannelView<'a> {
fn from_ir(ch: &'a MizanChannel) -> Self {
let params_type = ch.params_type.clone().unwrap_or_default();
let react_message_type = ch.react_message_type.clone().unwrap_or_default();
let django_message_type = ch.django_message_type.clone().unwrap_or_default();
Self {
name: &ch.name,
pascal_name: &ch.pascal_name,
has_params: ch.has_params(),
has_react_message: ch.has_react_message(),
has_django_message: ch.has_django_message(),
params_type_or_record: if ch.has_params() { params_type.clone() } else { "Record<string, never>".to_string() },
react_msg_type_or_never: if ch.has_react_message() { react_message_type.clone() } else { "never".to_string() },
django_msg_type_or_never: if ch.has_django_message() { django_message_type.clone() } else { "never".to_string() },
params_type,
react_message_type,
django_message_type,
slots: ch.slots.iter().map(SlotView::from_ir).collect(),
}
}
}
fn emit_channel_schemas(
channels: &[MizanChannel],
types: &IndexMap<String, NamedType>,
) -> String {
let mut blocks: Vec<String> = Vec::new();
for ch in channels {
for ty in [&ch.params_type, &ch.react_message_type, &ch.django_message_type].iter().filter_map(|t| t.as_ref()) {
if let Some(named) = types.get(ty) {
blocks.push(emit_named_type_as_ts(ty, named));
}
impl<'a> SlotView<'a> {
fn from_ir(slot: &'a ChannelSlot) -> Self {
Self {
registry_key: registry_key(slot.kind),
type_name: &slot.type_name,
}
}
blocks.join("\n\n")
}
fn emit_named_type_as_ts(name: &str, ty: &NamedType) -> String {
match ty {
NamedType::Struct(fields) => emit_interface(name, fields),
NamedType::List(inner) => format!("export type {name} = {}[]", ts_type_expression(inner)),
NamedType::Enum(variants) => {
let union = variants.iter().map(|v| format!("\"{v}\"")).collect::<Vec<_>>().join(" | ");
format!("export type {name} = {union}")
}
NamedType::Alias(inner) => format!("export type {name} = {}", ts_type_expression(inner)),
fn registry_key(kind: SlotKind) -> &'static str {
match kind {
SlotKind::Params => "paramsType",
SlotKind::ClientMessage => "clientMessageType",
SlotKind::ServerMessage => "serverMessageType",
}
}
fn emit_interface(name: &str, fields: &[StructField]) -> String {
if fields.is_empty() {
return format!("export interface {name} {{}}");
}
let body = fields.iter()
.map(|f| {
let is_required = f.required || f.default.is_some();
let opt = if is_required { "" } else { "?" };
format!(" {}{opt}: {}", f.name, ts_type_expression(&f.shape))
})
.collect::<Vec<_>>()
.join("\n");
format!("export interface {name} {{\n{body}\n}}")
}
fn ts_type_expression(shape: &TypeShape) -> String {
match shape {
TypeShape::Ref(name) => name.clone(),
TypeShape::Primitive(p) => primitive_to_ts(*p).to_string(),
TypeShape::List(inner) => format!("{}[]", ts_type_expression(inner)),
TypeShape::Optional(inner) => format!("{} | null", ts_type_expression(inner)),
TypeShape::Enum(variants) => variants.iter()
.map(|v| format!("\"{v}\""))
.collect::<Vec<_>>()
.join(" | "),
TypeShape::Union(branches) => branches.iter()
.map(ts_type_expression)
.collect::<Vec<_>>()
.join(" | "),
}
}
fn primitive_to_ts(p: Primitive) -> &'static str {
match p {
Primitive::Integer | Primitive::Number => "number",
Primitive::Boolean => "boolean",
Primitive::String => "string",
}
/// The subset of the type table that channel slots name, in type-table order.
/// Selection by membership emits one declaration for a type two slots share.
fn slot_schemas<'a>(
channels: &'a [MizanChannel],
types: &'a IndexMap<String, NamedType>,
) -> Vec<TsSchema<'a>> {
let slot_types: HashSet<&str> = channels.iter()
.flat_map(|ch| ch.slots.iter())
.map(|slot| slot.type_name.as_str())
.collect();
types.iter()
.filter(|(name, _)| slot_types.contains(name.as_str()))
.map(|(name, ty)| ts_schema(name, ty))
.collect()
}

View File

@@ -4,15 +4,12 @@
//! a `Vec<EmittedFile>`. The dispatcher in `main.rs` iterates one target
//! per `--target` flag and writes each `EmittedFile` to disk under the
//! configured output directory.
//!
//! Targets land in subsequent phases; Phase 2 establishes the trait so
//! the dispatch surface is settled before any target's emit logic is
//! written.
use std::collections::HashSet;
use std::path::PathBuf;
use crate::config::Config;
use crate::ir::MizanIR;
use crate::ir::{MizanFunction, MizanIR};
pub mod casing;
pub mod channels;
@@ -51,17 +48,129 @@ impl EmittedFile {
}
/// Look up a registered target by name. Returns `None` for unknown
/// targets so the CLI can warn instead of panicking.
pub fn target_by_name(name: &str) -> Option<Box<dyn CodegenTarget>> {
match name {
"stage1" => Some(Box::new(stage1::Stage1)),
"rust" => Some(Box::new(rust::RustCrate)),
"python" => Some(Box::new(python::PythonClient)),
"react" => Some(Box::new(react::ReactAdapter)),
"vue" => Some(Box::new(vue::VueAdapter)),
"svelte" => Some(Box::new(svelte::SvelteAdapter)),
"channels" => Some(Box::new(channels::ChannelsTarget)),
_ => None,
/// Drop repeats while keeping first-occurrence order — import lists stay
/// stable across runs because the IR order drives them.
pub fn dedupe_preserving_order(items: impl IntoIterator<Item = String>) -> Vec<String> {
let mut seen = HashSet::new();
items.into_iter().filter(|s| seen.insert(s.clone())).collect()
}
/// The stage-1 declarations a framework adapter pulls in, carried as bare
/// names. Each adapter's template spells the import specifiers those names
/// stand for; an adapter whose output never mentions a group leaves that
/// vector empty.
pub struct Stage1Imports {
pub contexts: Vec<String>,
pub calls: Vec<String>,
pub context_output_types: Vec<String>,
}
impl Stage1Imports {
pub fn is_empty(&self) -> bool {
self.contexts.is_empty()
&& self.calls.is_empty()
&& self.context_output_types.is_empty()
}
}
/// Pascal-cased names of every registered context, in IR order.
pub fn context_pascal_names(ir: &MizanIR) -> Vec<String> {
ir.contexts.keys().map(|name| casing::pascal_case(name)).collect()
}
/// Every plain function and mutation the adapters expose a hook for —
/// mutations first, then plain calls.
pub fn callable_functions(ir: &MizanIR) -> Vec<&MizanFunction> {
let is_callable = |f: &&MizanFunction| {
matches!(f.is_context, crate::ir::IsContext::No) && !f.is_form
};
let mutations = ir.functions.iter().filter(is_callable)
.filter(|f| !f.affects.is_empty());
let plain = ir.functions.iter().filter(is_callable)
.filter(|f| f.affects.is_empty());
mutations.chain(plain).collect()
}
/// Output type names of every context member, deduped in IR order.
pub fn context_output_types(ir: &MizanIR) -> Vec<String> {
dedupe_preserving_order(
ir.functions.iter()
.filter(|f| !matches!(f.is_context, crate::ir::IsContext::No))
.map(|f| f.output_type.clone()),
)
}
/// The registered emit targets. Every variant carries an emitter, so once a
/// configured name is matched against `Target::ALL` the dispatch is total.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
Stage1,
Channels,
Rust,
Python,
React,
Vue,
Svelte,
}
impl Target {
/// Emission order: stage 1 lands before the framework adapters that
/// import from it.
pub const ALL: [Target; 7] = [
Target::Stage1,
Target::Channels,
Target::Rust,
Target::Python,
Target::React,
Target::Vue,
Target::Svelte,
];
/// The `--target` flag value and `targets = [...]` entry naming this
/// target.
pub fn name(self) -> &'static str {
match self {
Target::Stage1 => "stage1",
Target::Channels => "channels",
Target::Rust => "rust",
Target::Python => "python",
Target::React => "react",
Target::Vue => "vue",
Target::Svelte => "svelte",
}
}
pub fn emitter(self) -> Box<dyn CodegenTarget> {
match self {
Target::Stage1 => Box::new(stage1::Stage1),
Target::Channels => Box::new(channels::ChannelsTarget),
Target::Rust => Box::new(rust::RustCrate),
Target::Python => Box::new(python::PythonClient),
Target::React => Box::new(react::ReactAdapter),
Target::Vue => Box::new(vue::VueAdapter),
Target::Svelte => Box::new(svelte::SvelteAdapter),
}
}
/// Every registered target whose name appears in `names`, in emission
/// order. Names matching nothing select nothing.
pub fn selected(names: &[String]) -> Vec<Target> {
Target::ALL.into_iter()
.filter(|t| names.iter().any(|n| n == t.name()))
.collect()
}
/// Configured names that match no registered target.
pub fn unregistered(names: &[String]) -> Vec<&String> {
names.iter()
.filter(|n| !Target::ALL.into_iter().any(|t| t.name() == n.as_str()))
.collect()
}
}

View File

@@ -1,20 +1,17 @@
//! Python target — emits a Pydantic-typed client wrapping the PyO3
//! extension exposed by `mizan-rust`.
//!
//! Output shape lives at `templates/python/*.j2`. Per-method bodies are
//! pre-rendered in Rust before passing into `client.py.j2` so the template
//! only owns top-level section layout, not Python method-signature details.
//! extension exposed by `mizan-rust`. Every line of emitted Python comes
//! from `templates/python/*.j2`; this module only computes the render
//! context.
use std::path::PathBuf;
use askama::Template;
use crate::config::Config;
use crate::emit::CodegenTarget;
use crate::emit::EmittedFile;
use crate::emit::casing::{pascal_case, rust_ident, snake_case};
use crate::emit::{CodegenTarget, EmittedFile};
use crate::ir::{
IsContext, MizanContext, MizanFunction, MizanIR, NamedType, Primitive, StructField, TypeShape,
CallInput, IsContext, MizanIR, NamedType, Primitive, StructField, TypeShape,
};
@@ -25,14 +22,14 @@ impl CodegenTarget for PythonClient {
fn name(&self) -> &'static str { "python" }
fn emit(&self, ir: &MizanIR, _config: &Config) -> Vec<EmittedFile> {
let schemas_block = ir.types.iter()
.map(|(name, ty)| emit_schema_block(name, ty))
.collect::<Vec<_>>()
.join("\n\n");
let types_py = TypesTemplate {
schemas: ir.types.iter().map(|(name, ty)| py_schema(name, ty)).collect(),
}.render().unwrap_or_else(|e| panic!("template python/types.py.j2: {e}"));
let types_py = TypesTemplate { schemas_block }.render().expect("types.py renders");
let client_py = build_client_template(ir).render().expect("client.py renders");
let init_py = InitTemplate {}.render().expect("__init__.py renders");
let client_py = build_client(ir).render()
.unwrap_or_else(|e| panic!("template python/client.py.j2: {e}"));
let init_py = InitTemplate {}.render()
.unwrap_or_else(|e| panic!("template python/__init__.py.j2: {e}"));
vec![
EmittedFile::new(PathBuf::from("types.py"), types_py),
@@ -48,232 +45,200 @@ impl CodegenTarget for PythonClient {
struct InitTemplate {}
// ─── types.py ──────────────────────────────────────────────────────────────
#[derive(Template)]
#[template(path = "python/types.py.j2", escape = "none")]
struct TypesTemplate {
schemas_block: String,
struct TypesTemplate<'a> {
schemas: Vec<PySchema<'a>>,
}
#[derive(Template)]
#[template(path = "python/schema.py.j2", escape = "none")]
struct PySchema<'a> {
name: String,
kind: PySchemaKind<'a>,
}
enum PySchemaKind<'a> {
Class(Vec<PyField<'a>>),
ListOf(&'a TypeShape),
Literal(&'a [String]),
Alias(&'a TypeShape),
}
struct PyField<'a> {
ident: String,
ty: PyTypeExpr<'a>,
/// The annotation needs a ` | None` suffix appended; false when the
/// shape already renders one.
append_none: bool,
optional: bool,
}
fn py_schema<'a>(raw_name: &str, ty: &'a NamedType) -> PySchema<'a> {
let kind = match ty {
NamedType::Struct(fields) => PySchemaKind::Class(fields.iter().map(py_field).collect()),
NamedType::List(inner) => PySchemaKind::ListOf(inner),
NamedType::Enum(variants) => PySchemaKind::Literal(variants.as_slice()),
NamedType::Alias(inner) => PySchemaKind::Alias(inner),
};
PySchema { name: pascal_case(raw_name), kind }
}
fn py_field(f: &StructField) -> PyField<'_> {
let optional = !(f.required || f.default.is_some());
PyField {
ident: rust_ident(&f.name),
ty: py_type(&f.shape),
append_none: optional && !renders_nullable(&f.shape),
optional,
}
}
/// Whether the rendered annotation for `shape` already ends in `| None`.
fn renders_nullable(shape: &TypeShape) -> bool {
match shape {
TypeShape::Optional(_) => true,
TypeShape::Union(branches) => renders_nullable(branches.trailing()),
TypeShape::Ref(_)
| TypeShape::Primitive(_)
| TypeShape::List(_)
| TypeShape::Enum(_) => false,
}
}
// ─── Type expressions ──────────────────────────────────────────────────────
#[derive(Template)]
#[template(path = "python/type_expr.py.j2", escape = "none")]
pub(crate) struct PyTypeExpr<'a> {
shape: &'a TypeShape,
}
pub(crate) fn py_type(shape: &TypeShape) -> PyTypeExpr<'_> {
PyTypeExpr { shape }
}
pub(crate) fn primitive_to_py(p: &Primitive) -> &'static str {
match p {
Primitive::Integer => "int",
Primitive::Number => "float",
Primitive::Boolean => "bool",
Primitive::String => "str",
}
}
// ─── client.py ─────────────────────────────────────────────────────────────
#[derive(Template)]
#[template(path = "python/client.py.j2", escape = "none")]
struct ClientTemplate {
ctx_methods_block: String,
call_methods_block: String,
data_classes_block: String,
struct ClientTemplate<'a> {
contexts: Vec<PyContext<'a>>,
calls: Vec<PyCall<'a>>,
data_classes: Vec<PyDataClass>,
}
// ─── types.py schema bodies ────────────────────────────────────────────────
fn emit_schema_block(raw_name: &str, ty: &NamedType) -> String {
let name = pascal_case(raw_name);
match ty {
NamedType::Struct(fields) => emit_pydantic_class(&name, fields),
NamedType::List(inner) => format!("{name} = list[{}]", py_type_expression(inner)),
NamedType::Enum(variants) => {
let literal = variants.iter().map(|v| format!("\"{v}\"")).collect::<Vec<_>>().join(", ");
format!("{name} = Literal[{literal}]")
}
NamedType::Alias(inner) => format!("{name} = {}", py_type_expression(inner)),
}
struct PyContext<'a> {
name: &'a str,
snake: String,
data_class: String,
params: Vec<PyParam<'a>>,
}
fn emit_pydantic_class(name: &str, fields: &[StructField]) -> String {
if fields.is_empty() {
return format!("class {name}(BaseModel):\n pass");
}
let field_lines = fields.iter()
.map(|f| {
let mut ty = py_type_expression(&f.shape);
let is_required = f.required || f.default.is_some();
if !is_required {
if !ty.ends_with(" | None") {
ty = format!("{ty} | None");
}
format!(" {}: {ty} = None", rust_ident(&f.name))
} else {
format!(" {}: {ty}", rust_ident(&f.name))
}
struct PyParam<'a> {
raw_name: &'a str,
ident: String,
ty: &'static str,
required: bool,
}
struct PyCall<'a> {
wire_name: &'a str,
snake: String,
input: &'a CallInput,
output: String,
nullable: bool,
}
struct PyDataClass {
class_name: String,
snake: String,
fields: Vec<PyDataField>,
}
struct PyDataField {
ident: String,
ty: String,
nullable: bool,
}
fn context_data_class(ctx_name: &str) -> String {
format!("{}ContextData", pascal_case(ctx_name))
}
fn build_client(ir: &MizanIR) -> ClientTemplate<'_> {
let contexts: Vec<PyContext> = ir.contexts.iter()
.map(|(ctx_name, ctx_meta)| PyContext {
name: ctx_name,
snake: snake_case(ctx_name),
data_class: context_data_class(ctx_name),
params: ctx_meta.params.iter()
.map(|(p_name, p_meta)| PyParam {
raw_name: p_name,
ident: rust_ident(p_name),
ty: primitive_to_py(&p_meta.ty),
required: p_meta.required,
})
.collect(),
})
.collect::<Vec<_>>()
.join("\n");
format!("class {name}(BaseModel):\n{field_lines}")
}
.collect();
fn py_type_expression(shape: &TypeShape) -> String {
match shape {
TypeShape::Ref(name) => pascal_case(name),
TypeShape::Primitive(p) => primitive_to_py(*p).to_string(),
TypeShape::List(inner) => format!("list[{}]", py_type_expression(inner)),
TypeShape::Optional(inner) => format!("{} | None", py_type_expression(inner)),
TypeShape::Enum(variants) => {
let parts = variants.iter().map(|v| format!("\"{v}\"")).collect::<Vec<_>>().join(", ");
format!("Literal[{parts}]")
}
TypeShape::Union(branches) => branches.iter()
.map(py_type_expression)
.collect::<Vec<_>>()
.join(" | "),
}
}
fn primitive_to_py(p: Primitive) -> &'static str {
match p {
Primitive::Integer => "int",
Primitive::Number => "float",
Primitive::Boolean => "bool",
Primitive::String => "str",
}
}
// ─── client.py method blocks ───────────────────────────────────────────────
fn build_client_template(ir: &MizanIR) -> ClientTemplate {
let ctx_methods_block = ir.contexts.iter()
.map(|(ctx_name, ctx_meta)| {
let fetch = emit_fetch_method(ctx_name, ctx_meta);
let subscribe = emit_subscribe_method(ctx_name, ctx_meta);
format!("{fetch}{subscribe}")
})
.collect::<Vec<_>>()
.join("\n");
let call_methods_block = ir.functions.iter()
let calls: Vec<PyCall> = ir.functions.iter()
.filter(|f| matches!(f.is_context, IsContext::No) && !f.is_form)
.map(emit_call_method)
.collect::<Vec<_>>()
.join("\n");
let data_classes_block = ir.contexts.iter()
.map(|(ctx_name, _)| {
let ctx_fns: Vec<&MizanFunction> = ir.functions.iter()
.filter(|f| f.is_context.as_str() == Some(ctx_name))
.collect();
emit_context_data_class(ctx_name, &ctx_fns)
.map(|f| PyCall {
wire_name: &f.name,
snake: snake_case(&f.name),
input: &f.input,
output: pascal_case(&f.output_type),
nullable: f.output_nullable,
})
.collect::<Vec<_>>()
.join("\n");
.collect();
ClientTemplate { ctx_methods_block, call_methods_block, data_classes_block }
}
fn py_arg_type(p: Primitive) -> &'static str {
match p {
Primitive::Integer => "int",
Primitive::Number => "float",
Primitive::Boolean => "bool",
Primitive::String => "str",
}
}
fn emit_fetch_method(ctx_name: &str, ctx_meta: &MizanContext) -> String {
let method_name = format!("fetch_{}_context", snake_case(ctx_name));
let param_args = ctx_meta.params.iter()
.map(|(n, m)| {
let ident = rust_ident(n);
let ty = py_arg_type(m.ty);
if m.required { format!("{ident}: {ty}") }
else { format!("{ident}: {ty} | None = None") }
let data_classes: Vec<PyDataClass> = ir.contexts.keys()
.map(|ctx_name| PyDataClass {
class_name: context_data_class(ctx_name),
snake: snake_case(ctx_name),
fields: ir.functions.iter()
.filter(|f| f.is_context.as_str() == Some(ctx_name.as_str()))
.map(|f| PyDataField {
ident: rust_ident(&f.name),
ty: pascal_case(&f.output_type),
nullable: f.output_nullable,
})
.collect(),
})
.collect::<Vec<_>>()
.join(", ");
let param_dict = if ctx_meta.params.is_empty() {
"{}".to_string()
} else {
let pairs = ctx_meta.params.iter()
.map(|(n, _)| format!("\"{n}\": {}", rust_ident(n)))
.collect::<Vec<_>>()
.join(", ");
format!("{{{pairs}}}")
};
let data_class = format!("{}ContextData", pascal_case(ctx_name));
let arg_sig = if param_args.is_empty() { String::new() } else { format!(", {param_args}") };
.collect();
format!(
" def {method_name}(self{arg_sig}) -> \"{data_class}\":\n raw = self._inner.fetch_context(\"{ctx_name}\", {param_dict})\n return {data_class}(**raw)\n",
)
}
fn emit_subscribe_method(ctx_name: &str, ctx_meta: &MizanContext) -> String {
let param_args = ctx_meta.params.iter()
.map(|(n, m)| {
let ident = rust_ident(n);
let ty = py_arg_type(m.ty);
if m.required { format!("{ident}: {ty}") }
else { format!("{ident}: {ty} | None = None") }
})
.collect::<Vec<_>>()
.join(", ");
let param_dict = if ctx_meta.params.is_empty() {
"{}".to_string()
} else {
let pairs = ctx_meta.params.iter()
.map(|(n, _)| format!("\"{n}\": {}", rust_ident(n)))
.collect::<Vec<_>>()
.join(", ");
format!("{{{pairs}}}")
};
let arg_sig = if param_args.is_empty() { String::new() } else { format!(", {param_args}") };
let snake = snake_case(ctx_name);
let indent_39 = " ".repeat(39);
format!(
" def subscribe_{snake}_context(self{arg_sig},\n{indent_39}callback: Callable[[dict[str, Any]], None]) -> PyContextSubscription:\n return self._inner.subscribe_context(\"{ctx_name}\", {param_dict}, callback)\n",
)
}
fn emit_call_method(fn_meta: &MizanFunction) -> String {
let method_name = format!("call_{}", snake_case(&fn_meta.name));
let pascal_output = pascal_case(&fn_meta.output_type);
let input_arg = if fn_meta.has_input {
let it = fn_meta.input_type.as_deref().unwrap_or("");
format!(", args: {}", pascal_case(it))
} else {
String::new()
};
let args_expr = if fn_meta.has_input { "args.model_dump()" } else { "{}" };
let return_type = if fn_meta.output_nullable {
format!("{pascal_output} | None")
} else {
pascal_output.clone()
};
let decode_expr = if fn_meta.output_nullable {
format!("{pascal_output}(**raw) if raw is not None else None")
} else {
format!("{pascal_output}(**raw)")
};
format!(
" def {method_name}(self{input_arg}) -> {return_type}:\n raw = self._inner.call(\"{wire}\", {args_expr})\n return {decode_expr}\n",
wire = fn_meta.name,
)
}
fn emit_context_data_class(ctx_name: &str, ctx_fns: &[&MizanFunction]) -> String {
let class_name = format!("{}ContextData", pascal_case(ctx_name));
let field_lines = ctx_fns.iter()
.map(|fn_meta| {
let pascal_out = pascal_case(&fn_meta.output_type);
let ty = if fn_meta.output_nullable { format!("{pascal_out} | None") } else { pascal_out };
format!(" {}: {ty}", rust_ident(&fn_meta.name))
})
.collect::<Vec<_>>()
.join("\n");
format!(
"class {class_name}(BaseModel):\n \"\"\"Bundled return of fetch_{snake}_context.\"\"\"\n{field_lines}\n",
snake = snake_case(ctx_name),
)
ClientTemplate { contexts, calls, data_classes }
}

View File

@@ -1,18 +1,22 @@
//! React target — Stage 2 emit on top of Stage 1. Wraps each registered
//! context in a React Provider so kernel subscription happens once per
//! provider mount; consumer hooks read from React Context.
//! React target — wraps each registered context in a React Provider so kernel
//! subscription happens once per provider mount; consumer hooks read from React
//! Context. When the IR carries channels, the target also emits the
//! `useXChannel` hooks that bind `mizan/channels` to the emitted channel types.
//!
//! Output shape lives at `templates/react/react.tsx.j2`.
//! Output shapes live at `templates/react/react.tsx.j2` and
//! `templates/channels/channels.hooks.tsx.j2`.
use std::path::PathBuf;
use askama::Template;
use crate::config::Config;
use crate::emit::CodegenTarget;
use crate::emit::EmittedFile;
use crate::emit::casing::pascal_case;
use crate::ir::{IsContext, MizanFunction, MizanIR};
use crate::emit::{
callable_functions, context_output_types, context_pascal_names, dedupe_preserving_order,
CodegenTarget, EmittedFile, Stage1Imports,
};
use crate::ir::{CallInput, ChannelSlot, MizanIR, SlotKind};
pub struct ReactAdapter;
@@ -22,8 +26,20 @@ impl CodegenTarget for ReactAdapter {
fn name(&self) -> &'static str { "react" }
fn emit(&self, ir: &MizanIR, _config: &Config) -> Vec<EmittedFile> {
let content = build_template(ir).render().expect("react template renders");
vec![EmittedFile::new(PathBuf::from("react.tsx"), content)]
let mut files = vec![EmittedFile::new(
PathBuf::from("react.tsx"),
build_template(ir)
.render().unwrap_or_else(|e| panic!("template react/react.tsx.j2: {e}")),
)];
if !ir.channels.is_empty() {
files.push(EmittedFile::new(
PathBuf::from("channels.hooks.tsx"),
build_channel_hooks(ir).render().unwrap_or_else(|e| {
panic!("template channels/channels.hooks.tsx.j2: {e}")
}),
));
}
files
}
}
@@ -32,7 +48,7 @@ impl CodegenTarget for ReactAdapter {
#[template(path = "react/react.tsx.j2", escape = "none")]
struct ReactTemplate<'a> {
has_global: bool,
stage1_imports: Vec<String>,
stage1_imports: Stage1Imports,
global_fns: Vec<HookRender<'a>>,
named_contexts: Vec<CtxRender<'a>>,
calls: Vec<CallRender>,
@@ -60,12 +76,6 @@ struct CallRender {
}
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()
}
fn build_template(ir: &MizanIR) -> ReactTemplate<'_> {
let has_global = ir.contexts.contains_key("global");
@@ -98,45 +108,119 @@ fn build_template(ir: &MizanIR) -> ReactTemplate<'_> {
})
.collect();
let mutations: Vec<&MizanFunction> = ir.functions.iter()
.filter(|f| matches!(f.is_context, IsContext::No) && !f.is_form && !f.affects.is_empty())
.collect();
let plain_fns: Vec<&MizanFunction> = ir.functions.iter()
.filter(|f| matches!(f.is_context, IsContext::No) && !f.is_form && f.affects.is_empty())
.collect();
let calls: Vec<CallRender> = mutations.iter().chain(plain_fns.iter())
let calls: Vec<CallRender> = callable_functions(ir).iter()
.map(|f| CallRender {
pascal: pascal_case(&f.camel_name),
has_input: f.has_input,
has_input: matches!(f.input, CallInput::Typed(_)),
})
.collect();
let mut stage1: Vec<String> = Vec::new();
for ctx_name in ir.contexts.keys() {
let p = pascal_case(ctx_name);
stage1.push(format!("fetch{p}Context"));
stage1.push(format!("type {p}ContextData"));
stage1.push(format!("type {p}ContextParams"));
}
for fn_meta in mutations.iter().chain(plain_fns.iter()) {
stage1.push(format!("call{}", pascal_case(&fn_meta.camel_name)));
}
let context_fns: Vec<&MizanFunction> = ir.functions.iter()
.filter(|f| !matches!(f.is_context, IsContext::No))
.collect();
let output_types = dedupe_preserving_order(
context_fns.iter().map(|f| f.output_type.clone()),
);
for t in output_types {
stage1.push(format!("type {t}"));
}
let stage1_imports = Stage1Imports {
contexts: context_pascal_names(ir),
calls: calls.iter().map(|c| c.pascal.clone()).collect(),
context_output_types: context_output_types(ir),
};
ReactTemplate {
has_global,
stage1_imports: stage1,
stage1_imports,
global_fns,
named_contexts,
calls,
}
}
// ─── Channel hooks ──────────────────────────────────────────────────────────
#[derive(Template)]
#[template(path = "channels/channels.hooks.tsx.j2", escape = "none")]
struct ChannelHooks<'a> {
type_imports: Vec<String>,
param_channels: Vec<ParamChannelHook<'a>>,
paramless_channels: Vec<ParamlessChannelHook<'a>>,
}
struct ParamChannelHook<'a> {
name: &'a str,
pascal_name: &'a str,
params_type: &'a str,
client_message_type: &'a str,
server_message_type: &'a str,
}
struct ParamlessChannelHook<'a> {
name: &'a str,
pascal_name: &'a str,
client_message_type: &'a str,
server_message_type: &'a str,
}
enum ParamsSurface<'a> {
Absent,
Typed(&'a str),
}
/// The TypeScript type expressions a channel's hook signature interpolates. A
/// message slot the channel does not declare renders as `never`, which closes
/// that direction of the subscription.
struct HookTypes<'a> {
params: ParamsSurface<'a>,
client_message: &'a str,
server_message: &'a str,
}
fn hook_types(slots: &[ChannelSlot]) -> HookTypes<'_> {
let mut types = HookTypes {
params: ParamsSurface::Absent,
client_message: "never",
server_message: "never",
};
for slot in slots {
match slot.kind {
SlotKind::Params => types.params = ParamsSurface::Typed(&slot.type_name),
SlotKind::ClientMessage => types.client_message = &slot.type_name,
SlotKind::ServerMessage => types.server_message = &slot.type_name,
}
}
types
}
fn build_channel_hooks(ir: &MizanIR) -> ChannelHooks<'_> {
let mut param_channels: Vec<ParamChannelHook> = Vec::new();
let mut paramless_channels: Vec<ParamlessChannelHook> = Vec::new();
for ch in &ir.channels {
let types = hook_types(&ch.slots);
match types.params {
ParamsSurface::Typed(params_type) => param_channels.push(ParamChannelHook {
name: &ch.name,
pascal_name: &ch.pascal_name,
params_type,
client_message_type: types.client_message,
server_message_type: types.server_message,
}),
ParamsSurface::Absent => paramless_channels.push(ParamlessChannelHook {
name: &ch.name,
pascal_name: &ch.pascal_name,
client_message_type: types.client_message,
server_message_type: types.server_message,
}),
}
}
let type_imports = dedupe_preserving_order(
ir.channels.iter()
.flat_map(|ch| ch.slots.iter())
.map(|slot| slot.type_name.clone()),
);
ChannelHooks { type_imports, param_channels, paramless_channels }
}

View File

@@ -1,5 +1,6 @@
//! Rust target — emits a complete Cargo crate consuming the
//! `mizan-rust` kernel. Output shape lives at `templates/rust/*.j2`.
//! Rust target — emits a complete Cargo crate consuming the `mizan-rust`
//! kernel. Every line of emitted Rust comes from `templates/rust/*.j2`;
//! this module only computes the render context.
use std::path::PathBuf;
@@ -7,11 +8,10 @@ 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::emit::{dedupe_preserving_order, CodegenTarget, EmittedFile};
use crate::ir::{
IsContext, MizanContext, MizanFunction, MizanIR, NamedType, Primitive,
CallInput, IsContext, MizanContext, MizanFunction, MizanIR, NamedType, Primitive,
StructField as IrStructField, TypeShape,
};
@@ -23,19 +23,14 @@ 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"),
CargoTemplate {
crate_name: &config.rust_crate_name,
kernel_dep: kernel_dep_entries(config.rust_kernel.as_ref()),
}.render().unwrap_or_else(|e| panic!("template rust/Cargo.toml.j2: {e}")),
));
out.push(EmittedFile::new("src/types.rs", emit_types_rs(&ir.types)));
@@ -83,7 +78,7 @@ impl CodegenTarget for RustCrate {
has_contexts: !context_modules.is_empty(),
has_mutations: !mutation_modules.is_empty(),
has_functions: !function_modules.is_empty(),
}.render().expect("lib.rs renders"),
}.render().unwrap_or_else(|e| panic!("template rust/lib.rs.j2: {e}")),
));
out
@@ -91,14 +86,69 @@ impl CodegenTarget for RustCrate {
}
/// Escape `s` for embedding between the quotes of a Rust or TOML string
/// literal.
fn escape_string_literal(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
other => out.push(other),
}
}
out
}
// ─── Cargo.toml ────────────────────────────────────────────────────────────
#[derive(Template)]
#[template(path = "rust/Cargo.toml.j2", escape = "none")]
struct CargoTemplate<'a> {
crate_name: &'a str,
kernel_dep: &'a str,
kernel_dep: Vec<KernelDepEntry>,
}
struct KernelDepEntry {
key: &'static str,
value: String,
}
fn kernel_dep_entry(key: &'static str, value: &str) -> KernelDepEntry {
KernelDepEntry { key, value: escape_string_literal(value) }
}
fn kernel_dep_entries(spec: Option<&RustKernelSpec>) -> Vec<KernelDepEntry> {
match spec {
Some(RustKernelSpec::Path { path }) => vec![kernel_dep_entry("path", path)],
Some(RustKernelSpec::Git { git, tag, rev, branch }) => {
let mut entries = vec![kernel_dep_entry("git", git)];
entries.extend(
[("tag", tag), ("rev", rev), ("branch", branch)]
.into_iter()
.filter_map(|(key, value)| {
value.as_deref().map(|v| kernel_dep_entry(key, v))
}),
);
entries
}
Some(RustKernelSpec::Version { version }) => vec![kernel_dep_entry("version", version)],
None => vec![kernel_dep_entry("version", "0.1")],
}
}
// ─── lib.rs / mod.rs ───────────────────────────────────────────────────────
#[derive(Template)]
#[template(path = "rust/lib.rs.j2", escape = "none")]
struct LibTemplate {
@@ -115,6 +165,218 @@ struct ModTemplate {
}
fn emit_mod_file(module_names: &[String]) -> String {
let mut sorted = module_names.to_vec();
sorted.sort();
ModTemplate { modules: sorted }
.render().unwrap_or_else(|e| panic!("template rust/mod.rs.j2: {e}"))
}
// ─── Type expressions ──────────────────────────────────────────────────────
/// A type shape with every inline enum already hoisted to a named Rust
/// enum, so rendering needs no further analysis.
pub(crate) enum RustShape {
Named(String),
List(Box<RustShape>),
Optional(Box<RustShape>),
Json,
}
#[derive(Template)]
#[template(path = "rust/type_expr.rs.j2", escape = "none")]
pub(crate) struct RustTypeExpr<'a> {
shape: &'a RustShape,
}
pub(crate) fn rust_type(shape: &RustShape) -> RustTypeExpr<'_> {
RustTypeExpr { shape }
}
fn render_rust_type(shape: &RustShape) -> String {
rust_type(shape)
.render().unwrap_or_else(|e| panic!("template rust/type_expr.rs.j2: {e}"))
}
fn primitive_rust_type(p: &Primitive) -> &'static str {
match p {
Primitive::Integer => "i64",
Primitive::Number => "f64",
Primitive::Boolean => "bool",
Primitive::String => "String",
}
}
/// The Rust type name an inline `enum` shape hoists to. `Anonymous` covers
/// positions with no enclosing named field to borrow a name from.
enum InlineEnumName {
Anonymous,
Named(String),
}
struct HoistedEnum {
name: String,
variants: Vec<String>,
}
fn resolve_shape(
shape: &TypeShape,
enum_name: &InlineEnumName,
hoisted: &mut Vec<HoistedEnum>,
) -> RustShape {
match shape {
TypeShape::Ref(name) => RustShape::Named(rust_type_ident(name)),
TypeShape::Primitive(p) => RustShape::Named(primitive_rust_type(p).to_string()),
TypeShape::List(inner) => RustShape::List(Box::new(
resolve_shape(inner, &InlineEnumName::Anonymous, hoisted),
)),
TypeShape::Optional(inner) => RustShape::Optional(Box::new(
resolve_shape(inner, &InlineEnumName::Anonymous, hoisted),
)),
TypeShape::Enum(variants) => {
let name = match enum_name {
InlineEnumName::Named(n) => n.clone(),
InlineEnumName::Anonymous => "Enum_inline".to_string(),
};
hoisted.push(HoistedEnum { name: name.clone(), variants: variants.clone() });
RustShape::Named(name)
}
// serde has no untagged multi-arm decode that keeps the branch, so a
// union lands as raw JSON for the consumer to match on.
TypeShape::Union(_) => RustShape::Json,
}
}
// ─── types.rs ──────────────────────────────────────────────────────────────
#[derive(Template)]
#[template(path = "rust/types.rs.j2", escape = "none")]
struct TypesTemplate {
schemas: Vec<RustSchema>,
hoisted_enums: Vec<RustSchema>,
}
#[derive(Template)]
#[template(path = "rust/schema.rs.j2", escape = "none")]
struct RustSchema {
name: String,
kind: RustSchemaKind,
}
enum RustSchemaKind {
Struct(Vec<RustSchemaField>),
StringEnum(Vec<RustEnumVariant>),
TransparentArray(String),
Alias(String),
}
struct RustSchemaField {
wire_name: String,
ident: String,
ty: String,
has_rename: bool,
}
struct RustEnumVariant {
wire_name: String,
ident: String,
has_rename: bool,
}
fn emit_types_rs(types: &IndexMap<String, NamedType>) -> String {
let mut hoisted: Vec<HoistedEnum> = Vec::new();
let schemas: Vec<RustSchema> = types.iter()
.map(|(raw_name, ty)| {
let name = rust_type_ident(raw_name);
let kind = match ty {
NamedType::Struct(fields) => struct_kind(&name, fields, &mut hoisted),
NamedType::List(inner) => RustSchemaKind::TransparentArray(render_rust_type(
&resolve_shape(inner, &InlineEnumName::Anonymous, &mut hoisted),
)),
NamedType::Enum(variants) => string_enum_kind(variants),
NamedType::Alias(inner) => RustSchemaKind::Alias(render_rust_type(
&resolve_shape(inner, &InlineEnumName::Named(name.clone()), &mut hoisted),
)),
};
RustSchema { name, kind }
})
.collect();
let hoisted_enums: Vec<RustSchema> = hoisted.iter()
.map(|e| RustSchema { name: e.name.clone(), kind: string_enum_kind(&e.variants) })
.collect();
TypesTemplate { schemas, hoisted_enums }
.render().unwrap_or_else(|e| panic!("template rust/types.rs.j2: {e}"))
}
fn struct_kind(
name: &str,
fields: &[IrStructField],
hoisted: &mut Vec<HoistedEnum>,
) -> RustSchemaKind {
RustSchemaKind::Struct(
fields.iter()
.map(|f| {
let ident = rust_ident(&f.name);
let field_enum_name =
InlineEnumName::Named(format!("{name}_{}", pascal_case(&f.name)));
let resolved = resolve_shape(&f.shape, &field_enum_name, hoisted);
let is_required = f.required || f.default.is_some();
let shape = if is_required || matches!(resolved, RustShape::Optional(_)) {
resolved
} else {
RustShape::Optional(Box::new(resolved))
};
RustSchemaField {
wire_name: escape_string_literal(&f.name),
has_rename: ident != f.name,
ident,
ty: render_rust_type(&shape),
}
})
.collect(),
)
}
fn string_enum_kind(variants: &[String]) -> RustSchemaKind {
RustSchemaKind::StringEnum(
variants.iter()
.map(|v| {
let ident = pascal_case(v);
RustEnumVariant {
wire_name: escape_string_literal(v),
has_rename: ident != *v,
ident,
}
})
.collect(),
)
}
// ─── Context file ──────────────────────────────────────────────────────────
#[derive(Template)]
#[template(path = "rust/context.rs.j2", escape = "none")]
struct ContextTemplate<'a> {
@@ -127,88 +389,22 @@ struct ContextTemplate<'a> {
}
#[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,
}
/// Renderer-side view of a single Rust struct field. Distinct from
/// `ir::StructField` (the IR shape) because the renderer carries
/// already-rendered identifiers and rename flags.
/// Renderer-side view of one Rust struct field: identifiers already cased,
/// with the rename and `Option<..>` decisions resolved to flags.
struct RustField {
raw_name: String,
wire_name: String,
ident: String,
ty: String,
has_rename: bool,
optional: 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))
@@ -222,10 +418,11 @@ fn emit_context_file(
.map(|f| {
let ident = rust_ident(&f.name);
RustField {
wire_name: escape_string_literal(&f.name),
has_rename: ident != f.name,
raw_name: f.name.clone(),
ident,
ty: rust_type_ident(&f.output_type),
optional: false,
}
})
.collect();
@@ -233,212 +430,57 @@ fn emit_context_file(
let params: Vec<RustField> = 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}>") };
RustField {
wire_name: escape_string_literal(p_name),
has_rename: ident != *p_name,
raw_name: p_name.clone(),
ident,
ty,
ty: primitive_rust_type(&p_meta.ty).to_string(),
optional: !p_meta.required,
}
})
.collect();
ContextTemplate {
pascal,
snake,
pascal: pascal_case(ctx_name),
snake: snake_case(ctx_name),
ctx_name,
type_imports,
data_fields,
params,
}.render().expect("context.rs renders")
}
fn param_rust_type(p: Primitive) -> &'static str {
match p {
Primitive::Integer => "i64",
Primitive::Number => "f64",
Primitive::Boolean => "bool",
Primitive::String => "String",
}
}.render().unwrap_or_else(|e| panic!("template rust/context.rs.j2: {e}"))
}
// ─── Call file ─────────────────────────────────────────────────────────────
#[derive(Template)]
#[template(path = "rust/call.rs.j2", escape = "none")]
struct CallTemplate<'a> {
snake: String,
name: &'a str,
output_type: String,
nullable: bool,
type_imports: Vec<String>,
input: &'a CallInput,
}
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())",
)
};
match &fn_meta.input {
CallInput::Typed(t) => used_seed.push(rust_type_ident(t)),
CallInput::Absent => (),
}
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 ──────────────────────────────────────────────────────────────
/// Per-types-file context tracking enum names hoisted out of inline
/// `field { enum "a" "b" }` declarations into Rust top-level enum types.
struct EnumCtx {
hoisted: Vec<(String, Vec<String>)>,
enum_name: Option<String>,
}
fn emit_types_rs(types: &IndexMap<String, NamedType>) -> String {
let mut ctx = EnumCtx { hoisted: Vec::new(), enum_name: None };
let schemas_block = types.iter()
.map(|(raw_name, ty)| {
let name = rust_type_ident(raw_name);
match ty {
NamedType::Struct(fields) => emit_struct_decl(&name, fields, &mut ctx),
NamedType::List(inner) => emit_transparent_array(&name, inner, &mut ctx),
NamedType::Enum(variants) => emit_string_enum(&name, variants),
NamedType::Alias(inner) => emit_type_alias(&name, inner, &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: &[String]) -> String {
let body = variants.iter()
.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, inner: &TypeShape, ctx: &mut EnumCtx) -> String {
ctx.enum_name = None;
let inner_ty = rust_type_from_shape(inner, ctx);
format!(
"#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(transparent)]\npub struct {name}(pub Vec<{inner_ty}>);\n",
)
}
fn emit_struct_decl(
name: &str,
fields: &[IrStructField],
ctx: &mut EnumCtx,
) -> String {
let fields_body = fields.iter()
.map(|f| {
let field_name = rust_ident(&f.name);
ctx.enum_name = Some(format!("{name}_{}", pascal_case(&f.name)));
let mut ty = rust_type_from_shape(&f.shape, ctx);
let is_required = f.required || f.default.is_some();
if !is_required && !ty.starts_with("Option<") {
ty = format!("Option<{ty}>");
}
let rename = if field_name == f.name {
String::new()
} else {
format!(" #[serde(rename = \"{raw}\")]\n", raw = f.name)
};
format!("{rename} pub {field_name}: {ty},")
})
.collect::<Vec<_>>()
.join("\n");
format!(
"#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct {name} {{\n{fields_body}\n}}\n",
)
}
fn emit_type_alias(name: &str, inner: &TypeShape, ctx: &mut EnumCtx) -> String {
ctx.enum_name = Some(name.to_string());
let ty = rust_type_from_shape(inner, ctx);
format!("pub type {name} = {ty};\n")
}
fn rust_type_from_shape(shape: &TypeShape, ctx: &mut EnumCtx) -> String {
match shape {
TypeShape::Ref(name) => rust_type_ident(name),
TypeShape::Primitive(Primitive::Integer) => "i64".to_string(),
TypeShape::Primitive(Primitive::Number) => "f64".to_string(),
TypeShape::Primitive(Primitive::Boolean) => "bool".to_string(),
TypeShape::Primitive(Primitive::String) => "String".to_string(),
TypeShape::List(inner) => {
ctx.enum_name = None;
format!("Vec<{}>", rust_type_from_shape(inner, ctx))
}
TypeShape::Optional(inner) => {
ctx.enum_name = None;
format!("Option<{}>", rust_type_from_shape(inner, ctx))
}
TypeShape::Enum(variants) => {
// Inline enums hoist out into top-level Rust enum types so the
// generated struct field can reference them by name.
let enum_name = ctx
.enum_name
.clone()
.unwrap_or_else(|| "Enum_inline".to_string());
ctx.hoisted.push((enum_name.clone(), variants.clone()));
enum_name
}
TypeShape::Union(_branches) => {
// Rust serde doesn't have a clean way to deserialize an untagged
// multi-arm union without losing type info; fall back to a JSON
// Value so the consumer can match on the runtime variant.
"serde_json::Value".to_string()
}
}
output_type,
nullable: fn_meta.output_nullable,
type_imports: dedupe_preserving_order(used_seed),
input: &fn_meta.input,
}.render().unwrap_or_else(|e| panic!("template rust/call.rs.j2: {e}"))
}

View File

@@ -1,18 +1,13 @@
//! Stage 1 — framework-agnostic TypeScript emission.
//!
//! Output mirrors `protocol/mizan-generate/generator/lib/stage1.mjs`:
//!
//! types.ts — typed declarations for every Pydantic model
//! contexts/<name>.ts — `fetch<Name>Context(params)` per context group
//! mutations/<name>.ts — `call<Name>(args)` per mutation
//! functions/<name>.ts — `call<Name>(args)` per plain function
//! index.ts — re-exports
//!
//! The deterministic per-function/per-context files match the JS codegen
//! byte-for-byte against an identical IR; types.ts emits Pydantic schemas
//! directly as TS interfaces instead of routing through openapi-typescript.
//! Consumers import by name from index.ts so the structural shape of
//! types.ts is not load-bearing — only the named exports are.
//! Every line of emitted TypeScript comes from `templates/stage1/*.j2`; this
//! module only computes the render context.
use std::path::PathBuf;
@@ -20,71 +15,12 @@ use askama::Template;
use indexmap::IndexMap;
use crate::config::Config;
use crate::ir::{
IsContext, MizanContext, MizanFunction, MizanIR, NamedType, Primitive, StructField, TypeShape,
};
use crate::emit::CodegenTarget;
use crate::emit::EmittedFile;
use crate::emit::casing::pascal_case;
#[derive(Template)]
#[template(path = "stage1/call.ts.j2", escape = "none")]
struct CallTemplate<'a> {
pascal: &'a str,
name: &'a str,
has_input: bool,
input_type: &'a str,
output_type: &'a str,
type_imports: Vec<String>,
}
#[derive(Template)]
#[template(path = "stage1/context.ts.j2", escape = "none")]
struct ContextTemplate<'a> {
pascal: &'a str,
ctx_name: &'a str,
type_imports: Vec<String>,
data_fields: Vec<ContextDataField<'a>>,
has_params: bool,
params: Vec<ContextParamField<'a>>,
}
struct ContextDataField<'a> {
name: &'a str,
output_type: &'a str,
}
struct ContextParamField<'a> {
name: &'a str,
ts_type: &'static str,
required: bool,
}
#[derive(Template)]
#[template(path = "stage1/index.ts.j2", escape = "none")]
struct IndexTemplate<'a> {
contexts: Vec<IndexContext<'a>>,
calls: Vec<IndexCall<'a>>,
framework_adapters: Vec<&'static str>,
}
struct IndexContext<'a> {
pascal: String,
name: &'a str,
}
struct IndexCall<'a> {
pascal: String,
camel_name: &'a str,
dir: &'static str,
}
use crate::emit::{dedupe_preserving_order, CodegenTarget, EmittedFile};
use crate::ir::{
CallInput, IsContext, MizanContext, MizanFunction, MizanIR, NamedType, Primitive, StructField,
TypeShape,
};
pub struct Stage1;
@@ -127,15 +63,120 @@ fn regular_functions(functions: &[MizanFunction]) -> impl Iterator<Item = &Mizan
}
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()
// ─── Type expressions ──────────────────────────────────────────────────────
#[derive(Template)]
#[template(path = "stage1/type_expr.ts.j2", escape = "none")]
pub(crate) struct TsTypeExpr<'a> {
shape: &'a TypeShape,
}
pub(crate) fn ts_type(shape: &TypeShape) -> TsTypeExpr<'_> {
TsTypeExpr { shape }
}
pub(crate) fn primitive_to_ts(p: &Primitive) -> &'static str {
match p {
Primitive::Integer | Primitive::Number => "number",
Primitive::Boolean => "boolean",
Primitive::String => "string",
}
}
// ─── types.ts ──────────────────────────────────────────────────────────────
#[derive(Template)]
#[template(path = "stage1/types.ts.j2", escape = "none")]
struct TypesTemplate<'a> {
schemas: Vec<TsSchema<'a>>,
}
#[derive(Template)]
#[template(path = "stage1/schema.ts.j2", escape = "none")]
pub(crate) struct TsSchema<'a> {
name: &'a str,
kind: TsSchemaKind<'a>,
}
enum TsSchemaKind<'a> {
Interface(Vec<TsField<'a>>),
ArrayOf(&'a TypeShape),
Union(&'a [String]),
Alias(&'a TypeShape),
}
struct TsField<'a> {
name: &'a str,
ty: TsTypeExpr<'a>,
required: bool,
}
fn emit_types(types: &IndexMap<String, NamedType>) -> String {
TypesTemplate {
schemas: types.iter().map(|(name, ty)| ts_schema(name, ty)).collect(),
}.render().unwrap_or_else(|e| panic!("template stage1/types.ts.j2: {e}"))
}
pub(crate) fn ts_schema<'a>(name: &'a str, ty: &'a NamedType) -> TsSchema<'a> {
let kind = match ty {
NamedType::Struct(fields) => TsSchemaKind::Interface(fields.iter().map(ts_field).collect()),
NamedType::List(inner) => TsSchemaKind::ArrayOf(inner),
NamedType::Enum(variants) => TsSchemaKind::Union(variants.as_slice()),
NamedType::Alias(inner) => TsSchemaKind::Alias(inner),
};
TsSchema { name, kind }
}
fn ts_field(f: &StructField) -> TsField<'_> {
TsField {
name: &f.name,
ty: ts_type(&f.shape),
// A default means the server always populates the field, so it is
// non-optional on the wire even when the schema marks it not required.
required: f.required || f.default.is_some(),
}
}
// ─── Per-context file ──────────────────────────────────────────────────────
#[derive(Template)]
#[template(path = "stage1/context.ts.j2", escape = "none")]
struct ContextTemplate<'a> {
pascal: &'a str,
ctx_name: &'a str,
type_imports: Vec<String>,
data_fields: Vec<ContextDataField<'a>>,
has_params: bool,
params: Vec<ContextParamField<'a>>,
}
struct ContextDataField<'a> {
name: &'a str,
output_type: &'a str,
}
struct ContextParamField<'a> {
name: &'a str,
ts_type: &'static str,
required: bool,
}
fn emit_context_file(
ctx_name: &str,
ctx_meta: &MizanContext,
@@ -159,60 +200,81 @@ fn emit_context_file(
let params: Vec<ContextParamField> = ctx_meta.params.iter()
.map(|(name, meta)| ContextParamField {
name,
ts_type: primitive_to_ts(meta.ty),
ts_type: primitive_to_ts(&meta.ty),
required: meta.required,
})
.collect();
let template = ContextTemplate {
ContextTemplate {
pascal: &pascal,
ctx_name,
type_imports,
data_fields,
has_params: !ctx_meta.params.is_empty(),
params,
};
template.render().expect("context template renders")
}
fn primitive_to_ts(p: Primitive) -> &'static str {
match p {
Primitive::Integer | Primitive::Number => "number",
Primitive::Boolean => "boolean",
Primitive::String => "string",
}
}.render().unwrap_or_else(|e| panic!("template stage1/context.ts.j2: {e}"))
}
// ─── Per-function (call) file — same shape for mutations + plain ──────────
#[derive(Template)]
#[template(path = "stage1/call.ts.j2", escape = "none")]
struct CallTemplate<'a> {
pascal: &'a str,
name: &'a str,
input: &'a CallInput,
output_type: &'a str,
type_imports: Vec<String>,
}
fn emit_call_file(fn_meta: &MizanFunction) -> String {
let pascal = pascal_case(&fn_meta.camel_name);
let mut imports: Vec<String> = Vec::new();
if fn_meta.has_input {
if let Some(t) = &fn_meta.input_type { imports.push(t.clone()); }
match &fn_meta.input {
CallInput::Typed(t) => imports.push(t.clone()),
CallInput::Absent => (),
}
imports.push(fn_meta.output_type.clone());
let type_imports = dedupe_preserving_order(imports);
let template = CallTemplate {
CallTemplate {
pascal: &pascal,
name: &fn_meta.name,
has_input: fn_meta.has_input,
input_type: fn_meta.input_type.as_deref().unwrap_or(""),
input: &fn_meta.input,
output_type: &fn_meta.output_type,
type_imports,
};
template.render().expect("call template renders")
type_imports: dedupe_preserving_order(imports),
}.render().unwrap_or_else(|e| panic!("template stage1/call.ts.j2: {e}"))
}
// ─── Stage 1 index ─────────────────────────────────────────────────────────
#[derive(Template)]
#[template(path = "stage1/index.ts.j2", escape = "none")]
struct IndexTemplate<'a> {
contexts: Vec<IndexContext<'a>>,
calls: Vec<IndexCall<'a>>,
framework_adapters: Vec<&'static str>,
}
struct IndexContext<'a> {
pascal: String,
name: &'a str,
}
struct IndexCall<'a> {
pascal: String,
camel_name: &'a str,
dir: &'static str,
}
fn emit_stage1_index(ir: &MizanIR, config: &Config) -> String {
let contexts: Vec<IndexContext> = ir.contexts.keys()
.map(|ctx_name| IndexContext { pascal: pascal_case(ctx_name), name: ctx_name })
@@ -226,75 +288,11 @@ fn emit_stage1_index(ir: &MizanIR, config: &Config) -> String {
})
.collect();
// Stage 2 single-file frontend adapters get re-exported from index.ts so
// consumers can `import { MizanContext, useEcho } from './api'`.
let framework_adapters: Vec<&'static str> = ["react", "vue", "svelte"].iter()
.copied()
.filter(|t| config.targets.iter().any(|cfg_t| cfg_t == t))
.collect();
IndexTemplate { contexts, calls, framework_adapters }
.render().expect("index template renders")
}
// ─── types.ts ──────────────────────────────────────────────────────────────
fn emit_types(types: &IndexMap<String, NamedType>) -> String {
let mut out = String::new();
out.push_str("// AUTO-GENERATED by mizan — do not edit\n\n");
for (name, ty) in types {
out.push_str(&emit_named_type(name, ty));
out.push('\n');
}
out
}
fn emit_named_type(name: &str, ty: &NamedType) -> String {
match ty {
NamedType::Struct(fields) => emit_interface(name, fields),
NamedType::List(inner) => format!("export type {name} = {}[]\n", ts_type_expression(inner)),
NamedType::Enum(variants) => {
let union = variants.iter().map(|v| format!("\"{v}\"")).collect::<Vec<_>>().join(" | ");
format!("export type {name} = {union}\n")
}
NamedType::Alias(inner) => format!("export type {name} = {}\n", ts_type_expression(inner)),
}
}
fn emit_interface(name: &str, fields: &[StructField]) -> String {
if fields.is_empty() {
return format!("export interface {name} {{}}\n");
}
let body = fields.iter()
.map(|f| {
// Field is non-optional if required OR has a default (server always populates).
let is_required = f.required || f.default.is_some();
let opt = if is_required { "" } else { "?" };
format!(" {}{opt}: {}", f.name, ts_type_expression(&f.shape))
})
.collect::<Vec<_>>()
.join("\n");
format!("export interface {name} {{\n{body}\n}}\n")
}
fn ts_type_expression(shape: &TypeShape) -> String {
match shape {
TypeShape::Ref(name) => name.clone(),
TypeShape::Primitive(p) => primitive_to_ts(*p).to_string(),
TypeShape::List(inner) => format!("{}[]", ts_type_expression(inner)),
TypeShape::Optional(inner) => format!("{} | null", ts_type_expression(inner)),
TypeShape::Enum(variants) => variants.iter()
.map(|v| format!("\"{v}\""))
.collect::<Vec<_>>()
.join(" | "),
TypeShape::Union(branches) => branches.iter()
.map(ts_type_expression)
.collect::<Vec<_>>()
.join(" | "),
}
.render().unwrap_or_else(|e| panic!("template stage1/index.ts.j2: {e}"))
}

View File

@@ -9,7 +9,8 @@ use crate::config::Config;
use crate::emit::CodegenTarget;
use crate::emit::EmittedFile;
use crate::emit::casing::pascal_case;
use crate::ir::{IsContext, MizanIR};
use crate::emit::{callable_functions, context_pascal_names, Stage1Imports};
use crate::ir::MizanIR;
pub struct SvelteAdapter;
@@ -19,7 +20,8 @@ impl CodegenTarget for SvelteAdapter {
fn name(&self) -> &'static str { "svelte" }
fn emit(&self, ir: &MizanIR, _config: &Config) -> Vec<EmittedFile> {
let content = build_template(ir).render().expect("svelte template renders");
let content = build_template(ir)
.render().unwrap_or_else(|e| panic!("template svelte/svelte.ts.j2: {e}"));
vec![EmittedFile::new(PathBuf::from("svelte.ts"), content)]
}
}
@@ -28,7 +30,7 @@ impl CodegenTarget for SvelteAdapter {
#[derive(Template)]
#[template(path = "svelte/svelte.ts.j2", escape = "none")]
struct SvelteTemplate<'a> {
stage1_imports: Vec<String>,
stage1_imports: Stage1Imports,
contexts: Vec<CtxRender<'a>>,
call_exports: Vec<String>,
}
@@ -55,24 +57,17 @@ fn build_template(ir: &MizanIR) -> SvelteTemplate<'_> {
})
.collect();
let mutations = ir.functions.iter()
.filter(|f| matches!(f.is_context, IsContext::No) && !f.is_form && !f.affects.is_empty());
let plain_fns = ir.functions.iter()
.filter(|f| matches!(f.is_context, IsContext::No) && !f.is_form && f.affects.is_empty());
let call_exports: Vec<String> = mutations.chain(plain_fns)
let call_exports: Vec<String> = callable_functions(ir).iter()
.map(|f| pascal_case(&f.camel_name))
.collect();
let mut stage1: Vec<String> = Vec::new();
for ctx_name in ir.contexts.keys() {
let p = pascal_case(ctx_name);
stage1.push(format!("fetch{p}Context"));
stage1.push(format!("type {p}ContextData"));
stage1.push(format!("type {p}ContextParams"));
}
for c in &call_exports {
stage1.push(format!("call{c}"));
}
// The store bodies name no function output type, so svelte's import list
// covers the context accessors and calls alone.
let stage1_imports = Stage1Imports {
contexts: context_pascal_names(ir),
calls: call_exports.clone(),
context_output_types: Vec::new(),
};
SvelteTemplate { stage1_imports: stage1, contexts, call_exports }
SvelteTemplate { stage1_imports, contexts, call_exports }
}

View File

@@ -6,10 +6,12 @@ use std::path::PathBuf;
use askama::Template;
use crate::config::Config;
use crate::emit::CodegenTarget;
use crate::emit::EmittedFile;
use crate::emit::casing::pascal_case;
use crate::ir::{IsContext, MizanFunction, MizanIR};
use crate::emit::{
callable_functions, context_output_types, context_pascal_names, CodegenTarget, EmittedFile,
Stage1Imports,
};
use crate::ir::{CallInput, MizanIR};
pub struct VueAdapter;
@@ -19,7 +21,8 @@ impl CodegenTarget for VueAdapter {
fn name(&self) -> &'static str { "vue" }
fn emit(&self, ir: &MizanIR, _config: &Config) -> Vec<EmittedFile> {
let content = build_template(ir).render().expect("vue template renders");
let content = build_template(ir)
.render().unwrap_or_else(|e| panic!("template vue/vue.ts.j2: {e}"));
vec![EmittedFile::new(PathBuf::from("vue.ts"), content)]
}
}
@@ -28,7 +31,7 @@ impl CodegenTarget for VueAdapter {
#[derive(Template)]
#[template(path = "vue/vue.ts.j2", escape = "none")]
struct VueTemplate<'a> {
stage1_imports: Vec<String>,
stage1_imports: Stage1Imports,
contexts: Vec<CtxRender<'a>>,
calls: Vec<CallRender>,
}
@@ -78,30 +81,20 @@ fn build_template(ir: &MizanIR) -> VueTemplate<'_> {
})
.collect();
let mutations: Vec<&MizanFunction> = ir.functions.iter()
.filter(|f| matches!(f.is_context, IsContext::No) && !f.is_form && !f.affects.is_empty())
.collect();
let plain_fns: Vec<&MizanFunction> = ir.functions.iter()
.filter(|f| matches!(f.is_context, IsContext::No) && !f.is_form && f.affects.is_empty())
.collect();
let calls: Vec<CallRender> = mutations.iter().chain(plain_fns.iter())
let calls: Vec<CallRender> = callable_functions(ir).iter()
.map(|f| CallRender {
pascal: pascal_case(&f.camel_name),
has_input: f.has_input,
has_input: matches!(f.input, CallInput::Typed(_)),
})
.collect();
let mut stage1: Vec<String> = Vec::new();
for ctx_name in ir.contexts.keys() {
let p = pascal_case(ctx_name);
stage1.push(format!("fetch{p}Context"));
stage1.push(format!("type {p}ContextData"));
stage1.push(format!("type {p}ContextParams"));
}
for fn_meta in mutations.iter().chain(plain_fns.iter()) {
stage1.push(format!("call{}", pascal_case(&fn_meta.camel_name)));
}
// The per-context composables annotate each computed with its function's
// output type, so those names have to come across from stage 1 too.
let stage1_imports = Stage1Imports {
contexts: context_pascal_names(ir),
calls: calls.iter().map(|c| c.pascal.clone()).collect(),
context_output_types: context_output_types(ir),
};
VueTemplate { stage1_imports: stage1, contexts, calls }
VueTemplate { stage1_imports, contexts, calls }
}

View File

@@ -2,33 +2,36 @@
//! and parses the KDL it writes to stdout.
//!
//! Backends:
//! - FastAPI: `python -m mizan_fastapi.ir <module>`
//! - Django: `python manage.py export_mizan_ir`
//! - Rust: `cargo run --bin <bin>` (consumer-side binary that
//! force-links its `#[derive(Mizan)]` types and
//! `#[mizan::client]` functions, then calls
//! `mizan_core::build_ir()`).
//!
//! - FastAPI: `python -m mizan_fastapi.ir <module>`
//! - Django: `python manage.py export_mizan_ir`
//! - Rust: `cargo run --bin <bin>`, a consumer-side binary that
//! force-links its `#[derive(Mizan)]` types and `#[mizan::client]`
//! functions, then calls `mizan_core::build_ir()`.
//!
//! The Rust source supports an optional `[source.rust.pydantic]`
//! pre-step that invokes decoru on a Pydantic module to author the
//! Rust types before the cargo bin runs — the "Pydantic + Rust"
//! canonical DX.
//! pre-step: a Python helper reports the module's Pydantic and Enum
//! declarations, and this module renders them into the Rust file the
//! cargo bin then compiles against.
use std::io::Write;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::process::Command;
use anyhow::{anyhow, Context, Result};
use askama::Template;
use serde::Deserialize;
use serde_json::json;
use crate::config::{Config, DjangoSource, FastapiSource, PydanticPreStep, RustSource, ScriptSource};
use crate::config::{
CommandLine, Config, DjangoSource, FastapiSource, PydanticPreStep, RustSource, ScriptSource,
};
use crate::ir::{parse_ir, MizanIR};
/// Embedded decoru bridge script — piped to `python -` at codegen time
/// when `[source.rust.pydantic]` is set. The script imports decoru,
/// walks the named module's BaseModel subclasses, and writes a Rust
/// file. See `scripts/run_decoru.py` for the full body.
/// Bridge script handed to the configured Python interpreter when
/// `[source.rust.pydantic]` is set.
const DECORU_BRIDGE_SCRIPT: &str = include_str!("../scripts/run_decoru.py");
@@ -55,43 +58,60 @@ pub fn fetch_schema(config: &Config, config_dir: &Path) -> Result<MizanIR> {
}
fn run_fastapi(src: &FastapiSource, config_dir: &Path) -> Result<String> {
let cwd = match &src.cwd {
Some(rel) => config_dir.join(rel),
fn resolve_cwd(rel: &Option<PathBuf>, config_dir: &Path) -> PathBuf {
match rel {
Some(path) => config_dir.join(path),
None => config_dir.to_path_buf(),
};
}
}
let (program, mut args) = resolve_command(&src.command, &src.python);
/// The interpreter invocation for a source: the explicit `command` when the
/// consumer set one, otherwise the bare `python` executable.
fn interpreter(explicit: &Option<CommandLine>, python: &str) -> CommandLine {
match explicit {
Some(cmd) => cmd.clone(),
None => CommandLine::program_only(python),
}
}
fn run_fastapi(src: &FastapiSource, config_dir: &Path) -> Result<String> {
let cwd = resolve_cwd(&src.cwd, config_dir);
let command = interpreter(&src.command, &src.python);
let mut args = command.args().to_vec();
args.extend([
"-m".to_string(),
"mizan_fastapi.ir".to_string(),
src.module.clone(),
]);
run_subprocess(&program, &args, &cwd, &src.env, "FastAPI IR export")
run_subprocess(command.program(), &args, &cwd, &src.env, "FastAPI IR export")
}
fn run_script(src: &ScriptSource, config_dir: &Path) -> Result<String> {
let cwd = match &src.cwd {
Some(rel) => config_dir.join(rel),
None => config_dir.to_path_buf(),
};
let (program, args) = src.command.split_first().ok_or_else(|| {
anyhow!("[source.script]: command must be non-empty")
})?;
run_subprocess(program, args, &cwd, &src.env, "script IR export")
let cwd = resolve_cwd(&src.cwd, config_dir);
run_subprocess(
src.command.program(),
src.command.args(),
&cwd,
&src.env,
"script IR export",
)
}
fn run_django(src: &DjangoSource, config_dir: &Path) -> Result<String> {
let manage_path = config_dir.join(&src.manage_path);
let manage_dir = manage_path
.parent()
.ok_or_else(|| anyhow!("django manage_path has no parent: {}", manage_path.display()))?
.to_path_buf();
// `manage_path` is `config_dir` plus at least the manage.py component, so
// popping the final component always leaves the directory holding it.
let mut manage_dir = manage_path.clone();
manage_dir.pop();
let (program, mut args) = resolve_command(&src.command, &src.python);
let command = interpreter(&src.command, &src.python);
let mut args = command.args().to_vec();
if src.command.is_some() {
args.push("manage.py".to_string());
@@ -100,20 +120,7 @@ fn run_django(src: &DjangoSource, config_dir: &Path) -> Result<String> {
}
args.push("export_mizan_ir".to_string());
run_subprocess(&program, &args, &manage_dir, &src.env, "Django IR export")
}
fn resolve_command(
explicit: &Option<Vec<String>>,
python_override: &Option<String>,
) -> (String, Vec<String>) {
if let Some(cmd) = explicit {
let (head, tail) = cmd.split_first().expect("command must be non-empty");
return (head.clone(), tail.to_vec());
}
let python = python_override.as_deref().unwrap_or("python");
(python.to_string(), Vec::new())
run_subprocess(command.program(), &args, &manage_dir, &src.env, "Django IR export")
}
@@ -121,7 +128,7 @@ fn run_subprocess(
program: &str,
args: &[String],
cwd: &Path,
env: &std::collections::BTreeMap<String, String>,
env: &BTreeMap<String, String>,
label: &str,
) -> Result<String> {
let mut cmd = Command::new(program);
@@ -143,18 +150,13 @@ fn run_subprocess(
));
}
let stdout = String::from_utf8(output.stdout)
.with_context(|| format!("{label}: non-UTF-8 stdout"))?;
Ok(stdout)
String::from_utf8(output.stdout)
.with_context(|| format!("{label}: non-UTF-8 stdout"))
}
fn run_rust(src: &RustSource, config_dir: &Path) -> Result<String> {
let manifest = config_dir.join(
src.manifest_path
.clone()
.unwrap_or_else(|| PathBuf::from("Cargo.toml")),
);
let manifest = config_dir.join(&src.manifest_path);
let mut args: Vec<String> = vec![
"run".to_string(),
@@ -176,11 +178,78 @@ fn run_rust(src: &RustSource, config_dir: &Path) -> Result<String> {
}
// ─── Pydantic pre-step ─────────────────────────────────────────────────────
#[derive(Deserialize)]
struct DecoruDiscovery {
enums: Vec<DecoruEnumSpec>,
structs: Vec<String>,
}
#[derive(Deserialize)]
struct DecoruEnumSpec {
name: String,
variants: Vec<String>,
}
#[derive(Template)]
#[template(path = "decoru/enum.rs.j2", escape = "none")]
struct DecoruEnumTemplate<'a> {
name: &'a str,
derives: Vec<String>,
variants: Vec<DecoruVariant<'a>>,
}
struct DecoruVariant<'a> {
ident: &'a str,
is_default: bool,
}
#[derive(Template)]
#[template(path = "decoru/schema.rs.j2", escape = "none")]
struct DecoruSchemaTemplate<'a> {
header: &'a str,
blocks: Vec<String>,
}
fn render_decoru_enum(spec: &DecoruEnumSpec, derives: &[String]) -> String {
// decoru puts `impl Default` on every struct it emits, so an enum-typed
// field with no Pydantic default still has to satisfy `T::default()`.
// The last member carries `#[default]` to keep the file compiling.
let last = spec.variants.len().saturating_sub(1);
let variants = spec.variants.iter()
.enumerate()
.map(|(i, ident)| DecoruVariant { ident, is_default: i == last })
.collect();
let mut derives = derives.to_vec();
derives.push("Default".to_string());
DecoruEnumTemplate { name: &spec.name, derives, variants }
.render()
.unwrap_or_else(|e| panic!("template decoru/enum.rs.j2: {e}"))
}
/// Materialize the bridge script so the interpreter can be handed a path
/// instead of a stdin pipe.
fn write_bridge_script() -> Result<PathBuf> {
let path = std::env::temp_dir()
.join(format!("mizan-decoru-bridge-{}.py", std::process::id()));
fs::write(&path, DECORU_BRIDGE_SCRIPT)
.with_context(|| format!("writing decoru bridge to {}", path.display()))?;
Ok(path)
}
fn run_pydantic_prestep(src: &PydanticPreStep, config_dir: &Path) -> Result<()> {
let cwd = match &src.cwd {
Some(rel) => config_dir.join(rel),
None => config_dir.to_path_buf(),
};
let cwd = resolve_cwd(&src.cwd, config_dir);
let output_abs = if src.output.is_absolute() {
src.output.clone()
@@ -190,50 +259,43 @@ fn run_pydantic_prestep(src: &PydanticPreStep, config_dir: &Path) -> Result<()>
let payload = json!({
"module": &src.module,
"output": output_abs.to_string_lossy(),
"derives": &src.derives,
"header": &src.header,
})
.to_string();
let (program, mut args) = resolve_command(&src.command, &src.python);
// `python -` reads the script body from stdin; the JSON payload is
// passed as argv[1] (which lands on sys.argv[1] inside the script).
args.push("-".to_string());
let script_path = write_bridge_script()?;
let command = interpreter(&src.command, &src.python);
let mut args = command.args().to_vec();
args.push(script_path.to_string_lossy().into_owned());
args.push(payload);
let mut cmd = Command::new(&program);
cmd.args(&args).current_dir(&cwd);
for (k, v) in &src.env {
cmd.env(k, v);
}
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
let stdout = run_subprocess(command.program(), &args, &cwd, &src.env, "decoru bridge")?;
let mut child = cmd
.spawn()
.with_context(|| format!("spawning decoru bridge ({program})"))?;
let discovery: DecoruDiscovery = serde_json::from_str(&stdout)
.context("decoding the decoru bridge's JSON report")?;
{
let stdin = child
.stdin
.as_mut()
.ok_or_else(|| anyhow!("failed to acquire decoru bridge stdin"))?;
stdin
.write_all(DECORU_BRIDGE_SCRIPT.as_bytes())
.context("piping decoru bridge script to python")?;
}
let mut blocks: Vec<String> = discovery.enums.iter()
.map(|spec| render_decoru_enum(spec, &src.derives))
.collect();
let enum_count = blocks.len();
let struct_count = discovery.structs.len();
blocks.extend(discovery.structs);
let status = child
.wait()
.context("waiting for decoru bridge to complete")?;
if !status.success() {
return Err(anyhow!(
"[source.rust.pydantic]: decoru bridge exited with status {:?}",
status.code()
));
let rendered = DecoruSchemaTemplate { header: &src.header, blocks }
.render()
.unwrap_or_else(|e| panic!("template decoru/schema.rs.j2: {e}"));
if let Some(parent) = output_abs.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("mkdir {}", parent.display()))?;
}
fs::write(&output_abs, rendered)
.with_context(|| format!("write {}", output_abs.display()))?;
eprintln!(
"[mizan] decoru: {enum_count} enum(s) + {struct_count} struct(s) -> {}",
output_abs.display(),
);
Ok(())
}

View File

@@ -1,10 +1,11 @@
//! Mizan IR — the canonical KDL document every backend adapter emits and
//! every codegen target consumes. See `docs/AFI_ARCHITECTURE.md` and
//! `cores/mizan-python/src/mizan_core/ir.py` for the locked grammar.
//! The Mizan IR data model. `ir::kdl` is the reader that builds one of these
//! from a backend's KDL export.
use anyhow::{anyhow, bail, Context, Result};
use indexmap::IndexMap;
use kdl::{KdlDocument, KdlNode, KdlValue};
pub mod kdl;
pub use crate::ir::kdl::parse_ir;
#[derive(Debug, Default)]
@@ -44,8 +45,38 @@ pub enum TypeShape {
List(Box<TypeShape>),
Optional(Box<TypeShape>),
Enum(Vec<String>),
/// Multi-arm union with two or more non-null branches.
Union(Vec<TypeShape>),
Union(Branches),
}
/// A union's arms, rendered `leading | … | trailing`.
#[derive(Debug, Clone)]
pub struct Branches {
leading: Vec<TypeShape>,
trailing: Box<TypeShape>,
}
impl Branches {
pub fn new(leading: Vec<TypeShape>, trailing: TypeShape) -> Self {
Self { leading, trailing: Box::new(trailing) }
}
/// The arm every target renders last.
pub fn trailing(&self) -> &TypeShape {
&self.trailing
}
}
impl<'a> IntoIterator for &'a Branches {
type Item = &'a TypeShape;
type IntoIter =
std::iter::Chain<std::slice::Iter<'a, TypeShape>, std::iter::Once<&'a TypeShape>>;
fn into_iter(self) -> Self::IntoIter {
self.leading.iter().chain(std::iter::once(self.trailing.as_ref()))
}
}
@@ -53,19 +84,6 @@ pub enum TypeShape {
pub enum Primitive { Integer, Number, Boolean, String }
impl Primitive {
fn parse(s: &str) -> Result<Self> {
match s {
"integer" => Ok(Primitive::Integer),
"number" => Ok(Primitive::Number),
"boolean" => Ok(Primitive::Boolean),
"string" => Ok(Primitive::String),
other => bail!("unknown primitive {other:?}"),
}
}
}
#[derive(Debug, Clone)]
pub enum DefaultValue {
Integer(i64),
@@ -79,12 +97,20 @@ pub enum DefaultValue {
// ─── Functions ──────────────────────────────────────────────────────────────
/// The argument surface of one call: either the call takes nothing, or it
/// takes a single value of the named IR type.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CallInput {
Absent,
Typed(String),
}
#[derive(Debug, Clone)]
pub struct MizanFunction {
pub name: String,
pub camel_name: String,
pub has_input: bool,
pub input_type: Option<String>,
pub input: CallInput,
pub output_type: String,
pub output_nullable: bool,
pub transport: Transport,
@@ -101,18 +127,6 @@ pub struct MizanFunction {
pub enum Transport { Http, Websocket, Both }
impl Transport {
fn parse(s: &str) -> Result<Self> {
match s {
"http" => Ok(Transport::Http),
"websocket" => Ok(Transport::Websocket),
"both" => Ok(Transport::Both),
other => bail!("unknown transport {other:?}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IsContext { No, Yes(String) }
@@ -157,312 +171,28 @@ pub struct ContextParam {
}
// ─── Channels (Django-only) ─────────────────────────────────────────────────
// ─── Channels ───────────────────────────────────────────────────────────────
/// Message direction is named from the client's point of view: a
/// `ClientMessage` travels client → server, a `ServerMessage` travels
/// server → client.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SlotKind { Params, ClientMessage, ServerMessage }
#[derive(Debug, Clone)]
pub struct ChannelSlot {
pub kind: SlotKind,
pub type_name: String,
}
/// A slot the channel does not declare is absent from `slots`; the vector is
/// ordered params, client-message, server-message.
#[derive(Debug, Clone)]
pub struct MizanChannel {
pub name: String,
pub pascal_name: String,
pub params_type: Option<String>,
pub react_message_type: Option<String>,
pub django_message_type: Option<String>,
}
impl MizanChannel {
pub fn has_params(&self) -> bool { self.params_type.is_some() }
pub fn has_react_message(&self) -> bool { self.react_message_type.is_some() }
pub fn has_django_message(&self) -> bool { self.django_message_type.is_some() }
}
// ─── KDL parsing ────────────────────────────────────────────────────────────
pub fn parse_ir(source: &str) -> Result<MizanIR> {
let doc: KdlDocument = source.parse()
.map_err(|e| anyhow!("KDL parse error: {e}"))?;
let mut ir = MizanIR::default();
for node in doc.nodes() {
match node.name().value() {
"type" => {
let (name, ty) = parse_named_type(node)?;
ir.types.insert(name, ty);
}
"function" => ir.functions.push(parse_function(node)?),
"context" => {
let (name, ctx) = parse_context(node)?;
ir.contexts.insert(name, ctx);
}
"channel" => ir.channels.push(parse_channel(node)?),
other => bail!("unknown top-level KDL node {other:?}"),
}
}
Ok(ir)
}
fn parse_named_type(node: &KdlNode) -> Result<(String, NamedType)> {
let name = first_string_arg(node)
.context("`type` requires a name as its first argument")?;
let children = node.children()
.ok_or_else(|| anyhow!("type {name:?}: missing children block"))?;
let kind_node = single_child(children, &format!("type {name:?}"))?;
let kind = match kind_node.name().value() {
"struct" => NamedType::Struct(parse_struct_fields(kind_node)?),
"list" => NamedType::List(type_child_of(kind_node, &format!("type {name:?} list"))?),
"enum" => NamedType::Enum(parse_string_args(kind_node)),
"alias" => NamedType::Alias(type_child_of(kind_node, &format!("type {name:?} alias"))?),
other => bail!("type {name:?}: unknown shape node {other:?}"),
};
Ok((name, kind))
}
fn parse_struct_fields(struct_node: &KdlNode) -> Result<Vec<StructField>> {
let mut fields = Vec::new();
let Some(children) = struct_node.children() else { return Ok(fields); };
for child in children.nodes() {
if child.name().value() != "field" {
bail!("struct: unexpected node {:?}", child.name().value());
}
fields.push(parse_struct_field(child)?);
}
Ok(fields)
}
fn parse_struct_field(field_node: &KdlNode) -> Result<StructField> {
let name = first_string_arg(field_node).context("`field` requires a name")?;
let required = bool_prop(field_node, "required").unwrap_or(true);
let default = field_node.entry("default")
.map(|e| parse_default_value(e.value()))
.transpose()?;
let shape = type_child_of(field_node, &format!("field {name:?}"))?;
Ok(StructField { name, required, default, shape })
}
fn parse_default_value(v: &KdlValue) -> Result<DefaultValue> {
if v.is_null() { return Ok(DefaultValue::Null); }
if let Some(b) = v.as_bool() { return Ok(DefaultValue::Boolean(b)); }
if let Some(i) = v.as_integer() { return Ok(DefaultValue::Integer(i as i64)); }
if let Some(f) = v.as_float() { return Ok(DefaultValue::Number(f)); }
if let Some(s) = v.as_string() { return Ok(DefaultValue::String(s.to_string())); }
bail!("unsupported default literal: {v:?}")
}
fn type_child_of(parent: &KdlNode, label: &str) -> Result<TypeShape> {
let children = parent.children()
.ok_or_else(|| anyhow!("{label}: missing children for type-shape"))?;
let nodes = children.nodes();
if nodes.len() != 1 {
bail!("{label}: expected exactly one type-shape child, got {}", nodes.len());
}
parse_type_shape(&nodes[0])
}
fn parse_type_shape(node: &KdlNode) -> Result<TypeShape> {
match node.name().value() {
"primitive" => Ok(TypeShape::Primitive(Primitive::parse(&first_string_arg(node)?)?)),
"ref" => Ok(TypeShape::Ref(first_string_arg(node)?)),
"list" => Ok(TypeShape::List(Box::new(type_child_of(node, "list")?))),
"optional" => Ok(TypeShape::Optional(Box::new(type_child_of(node, "optional")?))),
"enum" => Ok(TypeShape::Enum(parse_string_args(node))),
"union" => {
let children = node.children()
.ok_or_else(|| anyhow!("union: missing children"))?;
let branches: Result<Vec<TypeShape>> = children.nodes().iter()
.map(parse_type_shape).collect();
Ok(TypeShape::Union(branches?))
}
other => bail!("unknown type-shape node {other:?}"),
}
}
fn parse_function(node: &KdlNode) -> Result<MizanFunction> {
let name = first_string_arg(node)
.context("`function` requires a name as its first argument")?;
let children = node.children()
.ok_or_else(|| anyhow!("function {name:?}: missing children"))?;
let mut camel = None;
let mut has_input = false;
let mut input_type = None;
let mut output_type = None;
let mut output_nullable = false;
let mut transport = Transport::Http;
let mut is_context = IsContext::No;
let mut is_form = false;
let mut form_name = None;
let mut form_role = None;
let mut affects: Vec<AffectTarget> = Vec::new();
let mut merge: Vec<String> = Vec::new();
for child in children.nodes() {
match child.name().value() {
"camel" => camel = Some(string_arg(child, "camel")?),
"has-input" => has_input = bool_arg(child, "has-input")?,
"input" => input_type = Some(string_arg(child, "input")?),
"output" => output_type = Some(string_arg(child, "output")?),
"output-nullable" => output_nullable = bool_arg(child, "output-nullable")?,
"transport" => transport = Transport::parse(&string_arg(child, "transport")?)?,
"context" => is_context = IsContext::Yes(string_arg(child, "context")?),
"is-form" => is_form = bool_arg(child, "is-form")?,
"form-name" => form_name = Some(string_arg(child, "form-name")?),
"form-role" => form_role = Some(string_arg(child, "form-role")?),
"affects" => affects.push(AffectTarget {
kind: AffectKind::Context,
name: string_arg(child, "affects")?,
context: None,
}),
"merge" => merge.push(string_arg(child, "merge")?),
other => bail!("function {name:?}: unknown child {other:?}"),
}
}
Ok(MizanFunction {
name: name.clone(),
camel_name: camel.ok_or_else(|| anyhow!("function {name:?}: missing `camel`"))?,
has_input,
input_type,
output_type: output_type.ok_or_else(|| anyhow!("function {name:?}: missing `output`"))?,
output_nullable,
transport,
is_context,
is_form,
form_name,
form_role,
affects,
merge,
})
}
fn parse_context(node: &KdlNode) -> Result<(String, MizanContext)> {
let name = first_string_arg(node).context("`context` requires a name")?;
let mut ctx = MizanContext::default();
let Some(children) = node.children() else { return Ok((name, ctx)); };
for child in children.nodes() {
match child.name().value() {
"function" => ctx.functions.push(string_arg(child, "function")?),
"param" => {
let (pname, param) = parse_context_param(child)?;
ctx.params.insert(pname, param);
}
other => bail!("context {name:?}: unknown child {other:?}"),
}
}
Ok((name, ctx))
}
fn parse_context_param(node: &KdlNode) -> Result<(String, ContextParam)> {
let pname = first_string_arg(node).context("`param` requires a name")?;
let children = node.children()
.ok_or_else(|| anyhow!("param {pname:?}: missing children"))?;
let mut ty = None;
let mut required = false;
let mut shared_by = Vec::new();
for child in children.nodes() {
match child.name().value() {
"type" => ty = Some(Primitive::parse(&string_arg(child, "type")?)?),
"required" => required = bool_arg(child, "required")?,
"shared-by" => shared_by.push(string_arg(child, "shared-by")?),
other => bail!("param {pname:?}: unknown child {other:?}"),
}
}
Ok((pname.clone(), ContextParam {
ty: ty.ok_or_else(|| anyhow!("param {pname:?}: missing `type`"))?,
required,
shared_by,
}))
}
fn parse_channel(node: &KdlNode) -> Result<MizanChannel> {
let name = first_string_arg(node).context("`channel` requires a name")?;
let children = node.children()
.ok_or_else(|| anyhow!("channel {name:?}: missing children"))?;
let mut pascal_name = None;
let mut params_type = None;
let mut react_message_type = None;
let mut django_message_type = None;
for child in children.nodes() {
match child.name().value() {
"pascal-name" => pascal_name = Some(string_arg(child, "pascal-name")?),
"params" => params_type = Some(string_arg(child, "params")?),
"react-message" => react_message_type = Some(string_arg(child, "react-message")?),
"django-message" => django_message_type = Some(string_arg(child, "django-message")?),
other => bail!("channel {name:?}: unknown child {other:?}"),
}
}
Ok(MizanChannel {
name: name.clone(),
pascal_name: pascal_name.ok_or_else(|| anyhow!("channel {name:?}: missing `pascal-name`"))?,
params_type,
react_message_type,
django_message_type,
})
}
// ─── KDL accessor helpers ───────────────────────────────────────────────────
fn first_string_arg(node: &KdlNode) -> Result<String> {
let entry = node.entries().iter()
.find(|e| e.name().is_none())
.ok_or_else(|| anyhow!("node {:?}: missing positional argument", node.name().value()))?;
entry.value().as_string()
.map(str::to_string)
.ok_or_else(|| anyhow!("node {:?}: positional argument is not a string", node.name().value()))
}
fn string_arg(node: &KdlNode, label: &str) -> Result<String> {
first_string_arg(node).context(format!("{label}: requires a string argument"))
}
fn bool_arg(node: &KdlNode, label: &str) -> Result<bool> {
node.entries().iter()
.find(|e| e.name().is_none())
.and_then(|e| e.value().as_bool())
.ok_or_else(|| anyhow!("{label}: missing positional bool argument"))
}
fn bool_prop(node: &KdlNode, key: &str) -> Option<bool> {
node.entry(key).and_then(|e| e.value().as_bool())
}
fn parse_string_args(node: &KdlNode) -> Vec<String> {
node.entries().iter()
.filter(|e| e.name().is_none())
.filter_map(|e| e.value().as_string().map(str::to_string))
.collect()
}
fn single_child<'a>(children: &'a KdlDocument, label: &str) -> Result<&'a KdlNode> {
let nodes = children.nodes();
if nodes.len() != 1 {
bail!("{label}: expected exactly one child node, got {}", nodes.len());
}
Ok(&nodes[0])
}
// ─── Library entry point ────────────────────────────────────────────────────
pub fn parse_ir_from_str(source: &str) -> Result<MizanIR> {
parse_ir(source)
pub slots: Vec<ChannelSlot>,
}

View File

@@ -0,0 +1,536 @@
//! The KDL perimeter: one pass over a borrowed `kdl` AST that reads `MizanIR`
//! and collects every way the document failed the IR grammar. `parse_ir` is
//! the single crossing — it hands back the IR or the collected faults.
//!
//! Top-level nodes are `type`, `function`, `context`, and `channel`; each
//! reads into the corresponding field of `MizanIR`.
use anyhow::{anyhow, Result};
use kdl::{KdlDocument, KdlNode, KdlValue};
use crate::ir::{
AffectKind, AffectTarget, Branches, CallInput, ChannelSlot, ContextParam, DefaultValue,
IsContext, MizanChannel, MizanContext, MizanFunction, MizanIR, NamedType, Primitive, SlotKind,
StructField, Transport, TypeShape,
};
/// The shape a faulted read stands in for; every read that lands on it also
/// records a fault, and `parse_ir` discards the IR whenever a fault was
/// recorded.
const FAULTED_SHAPE: TypeShape = TypeShape::Primitive(Primitive::String);
// ─── Entry point ────────────────────────────────────────────────────────────
pub fn parse_ir(source: &str) -> Result<MizanIR> {
let doc: KdlDocument = source.parse()
.map_err(|e| anyhow!("KDL parse error: {e}"))?;
let mut reader = Reader::default();
let ir = reader.ir(&doc);
if reader.faults.is_empty() {
return Ok(ir);
}
Err(anyhow!(
"the KDL a backend exported is not Mizan IR:\n{}",
reader.faults.join("\n"),
))
}
// ─── The reader ─────────────────────────────────────────────────────────────
/// A borrowed view of one `kdl` node.
#[derive(Clone, Copy)]
struct Node<'a>(&'a KdlNode);
impl<'a> Node<'a> {
fn name(self) -> &'a str {
self.0.name().value()
}
fn children(self) -> impl Iterator<Item = Node<'a>> {
self.0.children().into_iter().flat_map(|doc| doc.nodes().iter().map(Node))
}
}
fn value_kind(value: &KdlValue) -> &'static str {
match value {
KdlValue::String(_) => "a string",
KdlValue::Integer(_) => "an integer",
KdlValue::Float(_) => "a number",
KdlValue::Bool(_) => "a boolean",
KdlValue::Null => "null",
}
}
fn default_value(value: &KdlValue) -> DefaultValue {
match value {
KdlValue::String(s) => DefaultValue::String(s.clone()),
KdlValue::Integer(i) => DefaultValue::Integer(*i as i64),
KdlValue::Float(f) => DefaultValue::Number(*f),
KdlValue::Bool(b) => DefaultValue::Boolean(*b),
KdlValue::Null => DefaultValue::Null,
}
}
/// Every reader below reads a whole production and records what the document
/// got wrong, so one pass reports every fault rather than the first.
#[derive(Default)]
struct Reader {
faults: Vec<String>,
}
impl Reader {
fn fault(&mut self, message: String) {
self.faults.push(message);
}
/// The node's first positional entry read as a string — the KDL spelling
/// of a node's own argument, as distinct from its `key=value` properties.
fn text(&mut self, node: Node, label: &str) -> String {
for entry in node.0.entries() {
if entry.name().is_some() {
continue;
}
return match entry.value() {
KdlValue::String(s) => s.clone(),
found @ (KdlValue::Integer(_) | KdlValue::Float(_)
| KdlValue::Bool(_) | KdlValue::Null) => {
self.fault(format!(
"{label}: expected a string argument, found {}", value_kind(found)));
String::new()
}
};
}
self.fault(format!("{label}: missing a string argument"));
String::new()
}
fn flag(&mut self, node: Node, label: &str) -> bool {
for entry in node.0.entries() {
if entry.name().is_some() {
continue;
}
return match entry.value() {
KdlValue::Bool(b) => *b,
found @ (KdlValue::String(_) | KdlValue::Integer(_)
| KdlValue::Float(_) | KdlValue::Null) => {
self.fault(format!(
"{label}: expected a boolean argument, found {}", value_kind(found)));
false
}
};
}
self.fault(format!("{label}: missing a boolean argument"));
false
}
/// Every positional entry as a string — the spelling `enum` uses to list
/// its variants.
fn text_list(&mut self, node: Node, label: &str) -> Vec<String> {
let mut words = Vec::new();
for entry in node.0.entries() {
if entry.name().is_some() {
continue;
}
match entry.value() {
KdlValue::String(s) => words.push(s.clone()),
found @ (KdlValue::Integer(_) | KdlValue::Float(_)
| KdlValue::Bool(_) | KdlValue::Null) => self.fault(format!(
"{label}: expected string variants, found {}", value_kind(found))),
}
}
words
}
/// A `key=value` property read as a boolean; `absent` is the reading for a
/// node that does not carry the property at all.
fn flag_prop(&mut self, node: Node, key: &str, absent: bool, label: &str) -> bool {
for entry in node.0.entries() {
match entry.name() {
Some(name) if name.value() == key => {
return match entry.value() {
KdlValue::Bool(b) => *b,
found @ (KdlValue::String(_) | KdlValue::Integer(_)
| KdlValue::Float(_) | KdlValue::Null) => {
self.fault(format!(
"{label}: `{key}` must be a boolean, found {}", value_kind(found)));
absent
}
};
}
Some(_) | None => {}
}
}
absent
}
fn primitive(&mut self, node: Node, label: &str) -> Primitive {
match self.text(node, label).as_str() {
"integer" => Primitive::Integer,
"number" => Primitive::Number,
"boolean" => Primitive::Boolean,
"string" => Primitive::String,
other => {
self.fault(format!("{label}: unknown primitive {other:?}"));
Primitive::String
}
}
}
fn transport(&mut self, node: Node, label: &str) -> Transport {
match self.text(node, label).as_str() {
"http" => Transport::Http,
"websocket" => Transport::Websocket,
"both" => Transport::Both,
other => {
self.fault(format!("{label}: unknown transport {other:?}"));
Transport::Http
}
}
}
fn ir(&mut self, doc: &KdlDocument) -> MizanIR {
let mut ir = MizanIR::default();
for kdl_node in doc.nodes() {
let node = Node(kdl_node);
match node.name() {
"type" => {
let (name, ty) = self.named_type(node);
ir.types.insert(name, ty);
}
"function" => {
let function = self.function(node);
ir.functions.push(function);
}
"context" => {
let (name, ctx) = self.context(node);
ir.contexts.insert(name, ctx);
}
"channel" => {
let channel = self.channel(node);
ir.channels.push(channel);
}
other => self.fault(format!("document: unknown top-level node {other:?}")),
}
}
ir
}
}
// ─── Types ──────────────────────────────────────────────────────────────────
impl Reader {
fn named_type(&mut self, node: Node) -> (String, NamedType) {
let name = self.text(node, "`type` requires a name as its first argument");
let label = format!("type {name:?}");
let children: Vec<Node> = node.children().collect();
let kind = match children.as_slice() {
[child] => self.named_shape(*child, &label),
seen => {
self.fault(format!(
"{label}: expected exactly one shape node, got {}", seen.len()));
NamedType::Alias(FAULTED_SHAPE)
}
};
(name, kind)
}
fn named_shape(&mut self, node: Node, label: &str) -> NamedType {
match node.name() {
"struct" => NamedType::Struct(self.struct_fields(node, label)),
"list" => NamedType::List(self.wrapped_shape(node, label)),
"enum" => NamedType::Enum(self.text_list(node, label)),
"alias" => NamedType::Alias(self.wrapped_shape(node, label)),
other => {
self.fault(format!("{label}: unknown shape node {other:?}"));
NamedType::Alias(FAULTED_SHAPE)
}
}
}
fn struct_fields(&mut self, node: Node, label: &str) -> Vec<StructField> {
let mut fields = Vec::new();
for child in node.children() {
match child.name() {
"field" => {
let field = self.struct_field(child);
fields.push(field);
}
other => self.fault(format!("{label}: unknown struct child {other:?}")),
}
}
fields
}
fn struct_field(&mut self, node: Node) -> StructField {
let name = self.text(node, "`field` requires a name");
let label = format!("field {name:?}");
let required = self.flag_prop(node, "required", true, &label);
// A field carrying no `default` property is a different declaration
// from one whose default is the KDL null literal, so absence stays
// `None`.
let mut default = None;
for entry in node.0.entries() {
match entry.name() {
Some(key) if key.value() == "default" => {
default = Some(default_value(entry.value()));
}
Some(_) | None => {}
}
}
let shape = self.wrapped_shape(node, &label);
StructField { name, required, default, shape }
}
/// The one shape node a wrapping production admits.
fn wrapped_shape(&mut self, node: Node, label: &str) -> TypeShape {
let children: Vec<Node> = node.children().collect();
match children.as_slice() {
[child] => self.type_shape(*child, label),
seen => {
self.fault(format!(
"{label}: expected exactly one shape node, got {}", seen.len()));
FAULTED_SHAPE
}
}
}
fn type_shape(&mut self, node: Node, label: &str) -> TypeShape {
match node.name() {
"primitive" => TypeShape::Primitive(self.primitive(node, label)),
"ref" => TypeShape::Ref(self.text(node, label)),
"list" => TypeShape::List(Box::new(self.wrapped_shape(node, label))),
"optional" => TypeShape::Optional(Box::new(self.wrapped_shape(node, label))),
"enum" => TypeShape::Enum(self.text_list(node, label)),
"union" => TypeShape::Union(self.branches(node, label)),
other => {
self.fault(format!("{label}: unknown type-shape node {other:?}"));
FAULTED_SHAPE
}
}
}
/// A union's arms in document order.
fn branches(&mut self, node: Node, label: &str) -> Branches {
let mut arms: Vec<TypeShape> = Vec::new();
for child in node.children() {
let branch = self.type_shape(child, label);
arms.push(branch);
}
match arms.len() {
0 => {
self.fault(format!("{label}: union with no branches"));
Branches::new(Vec::new(), FAULTED_SHAPE)
}
spelled => {
let trailing = arms.remove(spelled - 1);
Branches::new(arms, trailing)
}
}
}
}
// ─── Functions ──────────────────────────────────────────────────────────────
impl Reader {
fn function(&mut self, node: Node) -> MizanFunction {
let name = self.text(node, "`function` requires a name as its first argument");
let label = format!("function {name:?}");
let mut camel_name = String::new();
let mut has_input = false;
let mut input_type = None;
let mut output_type = String::new();
let mut output_nullable = false;
let mut transport = Transport::Http;
let mut is_context = IsContext::No;
let mut is_form = false;
let mut form_name = None;
let mut form_role = None;
let mut affects = Vec::new();
let mut merge = Vec::new();
for child in node.children() {
match child.name() {
"camel" => camel_name = self.text(child, &label),
"has-input" => has_input = self.flag(child, &label),
"input" => input_type = Some(self.text(child, &label)),
"output" => output_type = self.text(child, &label),
"output-nullable" => output_nullable = self.flag(child, &label),
"transport" => transport = self.transport(child, &label),
"context" => is_context = IsContext::Yes(self.text(child, &label)),
"is-form" => is_form = self.flag(child, &label),
"form-name" => form_name = Some(self.text(child, &label)),
"form-role" => form_role = Some(self.text(child, &label)),
"affects" => affects.push(AffectTarget {
kind: AffectKind::Context,
name: self.text(child, &label),
context: None,
}),
"merge" => merge.push(self.text(child, &label)),
other => self.fault(format!("{label}: unknown function child {other:?}")),
}
}
if camel_name.is_empty() {
self.fault(format!("{label}: missing `camel`"));
}
if output_type.is_empty() {
self.fault(format!("{label}: missing `output`"));
}
MizanFunction {
name,
camel_name,
input: self.call_input(has_input, input_type, &label),
output_type,
output_nullable,
transport,
is_context,
is_form,
form_name,
form_role,
affects,
merge,
}
}
/// `has-input` and `input` are two spellings of one fact; a document that
/// spells them against each other faults here.
fn call_input(&mut self, has_input: bool, input_type: Option<String>, label: &str)
-> CallInput
{
match (has_input, input_type) {
(true, Some(type_name)) => CallInput::Typed(type_name),
(false, None) => CallInput::Absent,
(true, None) => {
self.fault(format!("{label}: `has-input` is #true with no `input` type"));
CallInput::Absent
}
(false, Some(declared)) => {
self.fault(format!(
"{label}: `input` names {declared:?} while `has-input` is #false"));
CallInput::Absent
}
}
}
}
// ─── Contexts ───────────────────────────────────────────────────────────────
impl Reader {
fn context(&mut self, node: Node) -> (String, MizanContext) {
let name = self.text(node, "`context` requires a name");
let label = format!("context {name:?}");
let mut ctx = MizanContext::default();
for child in node.children() {
match child.name() {
"function" => {
let function = self.text(child, &label);
ctx.functions.push(function);
}
"param" => {
let (pname, param) = self.context_param(child);
ctx.params.insert(pname, param);
}
other => self.fault(format!("{label}: unknown context child {other:?}")),
}
}
(name, ctx)
}
fn context_param(&mut self, node: Node) -> (String, ContextParam) {
let pname = self.text(node, "`param` requires a name");
let label = format!("param {pname:?}");
let mut ty = Primitive::String;
let mut typed = false;
let mut required = false;
let mut shared_by = Vec::new();
for child in node.children() {
match child.name() {
"type" => {
ty = self.primitive(child, &label);
typed = true;
}
"required" => required = self.flag(child, &label),
"shared-by" => {
let sharer = self.text(child, &label);
shared_by.push(sharer);
}
other => self.fault(format!("{label}: unknown param child {other:?}")),
}
}
if !typed {
self.fault(format!("{label}: missing `type`"));
}
(pname, ContextParam { ty, required, shared_by })
}
}
// ─── Channels ───────────────────────────────────────────────────────────────
impl Reader {
fn channel(&mut self, node: Node) -> MizanChannel {
let name = self.text(node, "`channel` requires a name");
let label = format!("channel {name:?}");
let mut pascal_name = String::new();
let mut params = String::new();
let mut client_message = String::new();
let mut server_message = String::new();
for child in node.children() {
match child.name() {
"pascal-name" => pascal_name = self.text(child, &label),
"params" => params = self.text(child, &label),
"client-message" => client_message = self.text(child, &label),
"server-message" => server_message = self.text(child, &label),
other => self.fault(format!("{label}: unknown channel child {other:?}")),
}
}
if pascal_name.is_empty() {
self.fault(format!("{label}: missing `pascal-name`"));
}
// An undeclared slot reads as the empty name and takes no place in the
// vector, which stays ordered params, client-message, server-message.
let mut slots = Vec::new();
for (kind, type_name) in [
(SlotKind::Params, params),
(SlotKind::ClientMessage, client_message),
(SlotKind::ServerMessage, server_message),
] {
if !type_name.is_empty() {
slots.push(ChannelSlot { kind, type_name });
}
}
MizanChannel { name, pascal_name, slots }
}
}

View File

@@ -1,6 +1,5 @@
//! `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.
@@ -11,7 +10,15 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::Parser;
use mizan_codegen::{config, emit, fetch};
use mizan_codegen::emit::Target;
use mizan_codegen::{config, fetch};
/// Git reads a `.gitattributes` per directory and applies it to everything
/// beneath, so one file at the output root marks the whole emitted tree —
/// diffs collapse, language stats skip it, and the mark lives in the file
/// format tooling actually reads instead of a comment in every emitted source.
const OUTPUT_ATTRIBUTES: &str = "* linguist-generated=true\n";
#[derive(Parser, Debug)]
@@ -32,8 +39,8 @@ struct Cli {
#[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.
/// Read the IR from a file holding the KDL document instead of spawning
/// the backend's schema-export command.
#[arg(long)]
from_json: Option<PathBuf>,
}
@@ -52,12 +59,9 @@ fn main() -> Result<()> {
// --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,
..config::Config::default()
}
} else {
return Err(anyhow::anyhow!(
@@ -73,6 +77,11 @@ fn main() -> Result<()> {
config.targets = t.split(',').map(|s| s.trim().to_string()).collect();
}
for name in Target::unregistered(&config.targets) {
eprintln!("[mizan] WARN: no emitter is registered for target '{name}'");
}
let mut targets = Target::selected(&config.targets);
let config_dir = if config_exists {
resolve_config_dir(&cli.config)?
} else {
@@ -104,22 +113,25 @@ fn main() -> Result<()> {
// 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());
let needs_stage1 = targets.iter()
.any(|t| matches!(t, Target::React | Target::Vue | Target::Svelte));
if needs_stage1 && !targets.contains(&Target::Stage1) {
targets.insert(0, Target::Stage1);
}
// 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")
// The react target's channel hooks import their message types from
// `./channels`, so the channels target has to run alongside it whenever
// the schema actually carries channels.
if targets.contains(&Target::React)
&& !ir.channels.is_empty()
&& !config.targets.iter().any(|t| t == "channels")
&& !targets.contains(&Target::Channels)
{
config.targets.push("channels".to_string());
targets.push(Target::Channels);
}
eprintln!("[mizan] Targets: {}", config.targets.join(", "));
eprintln!(
"[mizan] Targets: {}",
targets.iter().map(|t| t.name()).collect::<Vec<_>>().join(", "),
);
let output_dir = if config.output.is_absolute() {
config.output.clone()
@@ -127,31 +139,28 @@ fn main() -> Result<()> {
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 {
for target in &targets {
for file in target.emitter().emit(&ir, &config) {
let path = output_dir.join(&file.rel_path);
write_output(&path, &file.content)?;
eprintln!("[mizan] {} -> {}", target.name(), file.rel_path.display());
}
}
write_output(&output_dir.join(".gitattributes"), OUTPUT_ATTRIBUTES)?;
eprintln!("[mizan] Generation complete.");
Ok(())
}
fn resolve_config_dir(config_path: &Path) -> Result<PathBuf> {
let abs = fs::canonicalize(config_path)
let mut dir = 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(".")))
// The canonicalized config path names a file, so popping its final
// component yields the directory that holds it.
dir.pop();
Ok(dir)
}

View File

@@ -1,26 +1,20 @@
'use client'
// AUTO-GENERATED by mizan — do not edit
import { useChannel, type ChannelSubscription } from 'mizan/channels'
{% if !type_imports.is_empty() -%}
import type { {{ type_imports|join(", ") }} } from './channels'
{% endif -%}
// ── Channel Hooks ─────────────────────────────────────────────────────────
{% for ch in channels -%}
/**
* Hook for the {{ ch.name }} channel.
*/
{% if ch.has_params -%}
export function use{{ ch.pascal_name }}Channel(params: {{ ch.params_type_or_record }}): ChannelSubscription<{{ ch.params_type_or_record }}, {{ ch.django_msg_type_or_never }}, {{ ch.react_msg_type_or_never }}> {
{% for ch in param_channels -%}
export function use{{ ch.pascal_name }}Channel(params: {{ ch.params_type }}): ChannelSubscription<{{ ch.params_type }}, {{ ch.server_message_type }}, {{ ch.client_message_type }}> {
return useChannel('{{ ch.name }}', params)
}
{% else -%}
export function use{{ ch.pascal_name }}Channel(): ChannelSubscription<Record<string, never>, {{ ch.django_msg_type_or_never }}, {{ ch.react_msg_type_or_never }}> {
{% endfor -%}
{% for ch in paramless_channels -%}
export function use{{ ch.pascal_name }}Channel(): ChannelSubscription<Record<string, never>, {{ ch.server_message_type }}, {{ ch.client_message_type }}> {
return useChannel('{{ ch.name }}', {})
}
{% endif %}
{% endfor -%}

View File

@@ -1,25 +1,15 @@
// AUTO-GENERATED by mizan — do not edit
{% for schema in schemas %}{{ schema }}{% if !loop.last %}
{{ schemas_block }}
// ── Channel Registry ──────────────────────────────────────────────────────
{% endif %}{% endfor %}
export const CHANNELS = {
{%- for ch in channels %}
{{ ch.name }}: {
'{{ ch.name }}': {
name: '{{ ch.name }}',
pascalName: '{{ ch.pascal_name }}',
hasParams: {{ ch.has_params }},
hasReactMessage: {{ ch.has_react_message }},
hasDjangoMessage: {{ ch.has_django_message }},
{%- if ch.has_params %}
paramsType: '{{ ch.params_type }}',
{%- endif %}
{%- if ch.has_react_message %}
reactMessageType: '{{ ch.react_message_type }}',
{%- endif %}
{%- if ch.has_django_message %}
djangoMessageType: '{{ ch.django_message_type }}',
{%- endif %}
{%- for slot in ch.slots %}
{{ slot.registry_key }}: '{{ slot.type_name }}',
{%- endfor %}
},
{%- endfor %}
} as const

View File

@@ -0,0 +1,7 @@
#[derive({% for d in derives %}{% if !loop.first %}, {% endif %}{{ d }}{% endfor %})]
#[serde(rename_all = "snake_case")]
pub enum {{ name }} {
{% for v in variants %}{% if v.is_default %} #[default]
{% endif %} {{ v.ident }},
{% endfor %}}

View File

@@ -0,0 +1,2 @@
{{ header }}{% for block in blocks %}{{ block }}{% if !loop.last %}
{% endif %}{% endfor %}

View File

@@ -1,5 +1,3 @@
# AUTO-GENERATED by mizan — do not edit
from .client import MizanClient # noqa: F401
from .types import * # noqa: F401, F403

View File

@@ -1,5 +1,3 @@
# AUTO-GENERATED by mizan — do not edit
from __future__ import annotations
from collections.abc import Callable
@@ -25,8 +23,19 @@ class MizanClient:
csrf_header_name=csrf_header_name,
)
{{ ctx_methods_block }}
{{ call_methods_block }}
{% for ctx in contexts %} def fetch_{{ ctx.snake }}_context(self{% for p in ctx.params %}, {{ p.ident }}: {{ p.ty }}{% if !p.required %} | None = None{% endif %}{% endfor %}) -> "{{ ctx.data_class }}":
raw = self._inner.fetch_context("{{ ctx.name }}", {{ "{" }}{% for p in ctx.params %}{% if !loop.first %}, {% endif %}"{{ p.raw_name }}": {{ p.ident }}{% endfor %}{{ "}" }})
return {{ ctx.data_class }}(**raw)
def subscribe_{{ ctx.snake }}_context(self{% for p in ctx.params %}, {{ p.ident }}: {{ p.ty }}{% if !p.required %} | None = None{% endif %}{% endfor %},
callback: Callable[[dict[str, Any]], None]) -> PyContextSubscription:
return self._inner.subscribe_context("{{ ctx.name }}", {{ "{" }}{% for p in ctx.params %}{% if !loop.first %}, {% endif %}"{{ p.raw_name }}": {{ p.ident }}{% endfor %}{{ "}" }}, callback)
{% if !loop.last %}
{% endif %}{% endfor %}
{% for call in calls %} def call_{{ call.snake }}(self{% match call.input %}{% when CallInput::Typed with (t) %}, args: {{ crate::emit::casing::pascal_case(t) }}{% when CallInput::Absent %}{% endmatch %}) -> {{ call.output }}{% if call.nullable %} | None{% endif %}:
raw = self._inner.call("{{ call.wire_name }}", {% match call.input %}{% when CallInput::Typed with (t) %}args.model_dump(){% when CallInput::Absent %}{}{% endmatch %})
return {{ call.output }}(**raw){% if call.nullable %} if raw is not None else None{% endif %}
{% if !loop.last %}
{% endif %}{% endfor %}
def invalidate(self, context: str) -> None:
self._inner.invalidate(context)
@@ -34,6 +43,8 @@ class MizanClient:
self._inner.invalidate_scoped(context, params)
# ── Context data shapes (per-context bundle) ──────────────────────────────
{{ data_classes_block }}
{% for dc in data_classes %}class {{ dc.class_name }}(BaseModel):
"""Bundled return of fetch_{{ dc.snake }}_context."""
{% for f in dc.fields %} {{ f.ident }}: {{ f.ty }}{% if f.nullable %} | None{% endif %}
{% endfor %}{% if !loop.last %}
{% endif %}{% endfor %}

View File

@@ -0,0 +1,17 @@
{%- match kind -%}
{%- when PySchemaKind::Class with (fields) -%}
class {{ name }}(BaseModel):
{%- if fields.is_empty() %}
pass
{%- else %}
{%- for f in fields %}
{{ f.ident }}: {{ f.ty }}{% if f.append_none %} | None{% endif %}{% if f.optional %} = None{% endif %}
{%- endfor %}
{%- endif %}
{%- when PySchemaKind::ListOf with (inner) -%}
{{ name }} = list[{{ crate::emit::python::py_type(inner) }}]
{%- when PySchemaKind::Literal with (variants) -%}
{{ name }} = Literal[{% for v in variants %}{% if !loop.first %}, {% endif %}"{{ v }}"{% endfor %}]
{%- when PySchemaKind::Alias with (inner) -%}
{{ name }} = {{ crate::emit::python::py_type(inner) }}
{%- endmatch -%}

View File

@@ -0,0 +1,14 @@
{%- match shape -%}
{%- when TypeShape::Ref with (name) -%}
{{ crate::emit::casing::pascal_case(name) }}
{%- when TypeShape::Primitive with (p) -%}
{{ crate::emit::python::primitive_to_py(p) }}
{%- when TypeShape::List with (inner) -%}
list[{{ crate::emit::python::py_type(inner) }}]
{%- when TypeShape::Optional with (inner) -%}
{{ crate::emit::python::py_type(inner) }} | None
{%- when TypeShape::Enum with (variants) -%}
Literal[{% for v in variants %}{% if !loop.first %}, {% endif %}"{{ v }}"{% endfor %}]
{%- when TypeShape::Union with (branches) -%}
{% for b in branches %}{% if !loop.first %} | {% endif %}{{ crate::emit::python::py_type(b) }}{% endfor %}
{%- endmatch -%}

View File

@@ -1,10 +1,9 @@
# AUTO-GENERATED by mizan — do not edit
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel
{{ schemas_block }}
{% for schema in schemas %}{{ schema }}
{% if !loop.last %}
{% endif %}{% endfor %}

View File

@@ -1,7 +1,5 @@
'use client'
// AUTO-GENERATED by mizan — do not edit
{% set has_contexts = has_global || !named_contexts.is_empty() -%}
import {
{% if has_contexts -%}
@@ -30,11 +28,14 @@ import {
} from '@mizan/base'
{% if !stage1_imports.is_empty() -%}
import { {{ stage1_imports|join(", ") }} } from './index'
import {
{%- for p in stage1_imports.contexts %} fetch{{ p }}Context, type {{ p }}ContextData, type {{ p }}ContextParams,{% endfor %}
{%- for p in stage1_imports.calls %} call{{ p }},{% endfor %}
{%- for t in stage1_imports.context_output_types %} type {{ t }},{% endfor %} } from './index'
{% endif -%}
{% if has_contexts -%}
// Internal — runs inside a Provider, registers with the kernel exactly once.
// Runs inside a Provider, registers with the kernel exactly once.
function useContextSubscription<T>(
name: string,
params: Record<string, any>,
@@ -56,7 +57,7 @@ function useContextSubscription<T>(
}
{% endif %}
// Internal — wraps an imperative call() with isPending / error state.
// Wraps an imperative call() with isPending / error state.
interface MutationHook<TArgs, TResult> {
mutate: (args: TArgs) => Promise<TResult>
isPending: boolean
@@ -85,8 +86,6 @@ function useMutation<TArgs, TResult>(
return { mutate, isPending, error }
}
{% if has_global %}
// ── Global Context ──
const GlobalCtx = createContext<ContextState<GlobalContextData> | null>(null)
export function GlobalContextProvider({ children }: { children: ReactNode }) {
@@ -107,8 +106,6 @@ export function use{{ fn.pascal }}(): {{ fn.output_type }} | null {
{% endfor -%}
{% endif -%}
{% for ctx in named_contexts %}
// ── {{ ctx.pascal }} Context ──
const {{ ctx.pascal }}Ctx = createContext<ContextState<{{ ctx.pascal }}ContextData> | null>(null)
{% if ctx.has_params -%}
@@ -144,8 +141,6 @@ export function use{{ call.pascal }}() {
}
{% endif -%}
{% endfor %}
// ── MizanContext root provider ──
export interface MizanContextProps {
/** Base URL for protocol endpoints. Defaults to "/api/mizan". */
baseUrl?: string
@@ -155,8 +150,8 @@ export interface MizanContextProps {
}
/**
* Root provider — calls configure() once and mounts the global context (if defined).
* Must wrap any component using Mizan-generated hooks.
* Calls configure() once and mounts the global context when one is defined.
* Every component reading a generated hook resolves through this provider.
*/
export function MizanContext({ baseUrl, session, children }: MizanContextProps) {
const configured = useRef(false)
@@ -174,12 +169,7 @@ export function MizanContext({ baseUrl, session, children }: MizanContextProps)
{%- endif %}
}
// ── Imperative escape hatch ──
/**
* Returns the imperative kernel API. For test harnesses or rare cases where
* a typed generated hook does not fit. Most app code should use the typed hooks.
*/
/** The untyped kernel entry points, bound to the configured client. */
export function useMizan() {
return { call: mizanCall, fetch: mizanFetch }
}

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
mizan-rust = {{ kernel_dep }}
mizan-rust = { {% for kv in kernel_dep %}{% if !loop.first %}, {% endif %}{{ kv.key }} = "{{ kv.value }}"{% endfor %} }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt", "macros"] }

View File

@@ -1,15 +1,12 @@
// AUTO-GENERATED by mizan — do not edit
use serde_json::Value;
use mizan_rust::{MizanClient, MizanError};
{% if !type_imports.is_empty() -%}
use crate::types::{ {{- type_imports|join(", ") -}} };
{% endif -%}
pub async fn call_{{ snake }}(client: &MizanClient{{ input_param }}) -> Result<{{ return_type }}, MizanError> {
let args_value = {{ args_value }};
pub async fn call_{{ snake }}(client: &MizanClient{% match input %}{% when CallInput::Typed with (t) %}, args: &{{ crate::emit::casing::rust_type_ident(t) }}{% when CallInput::Absent %}{% endmatch %}) -> Result<{% if nullable %}Option<{{ output_type }}>{% else %}{{ output_type }}{% endif %}, MizanError> {
let args_value = {% match input %}{% when CallInput::Typed with (t) %}serde_json::to_value(args)
.map_err(|e| MizanError::transport(format!("encode {{ name }} args: {e}")))?{% when CallInput::Absent %}serde_json::Value::Object(Default::default()){% endmatch %};
let raw = client.call("{{ name }}", args_value).await?;
serde_json::from_value(raw)
.map_err(|e| MizanError::transport(format!("decode {{ name }} result: {e}")))

View File

@@ -1,7 +1,4 @@
// AUTO-GENERATED by mizan — do not edit
use serde::{Deserialize, Serialize};
use serde_json::Value;
use mizan_rust::{MizanClient, MizanError};
@@ -12,16 +9,16 @@ use crate::types::{ {{- type_imports|join(", ") -}} };
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct {{ pascal }}ContextData {
{% for field in data_fields -%}
{% if field.has_rename %} #[serde(rename = "{{ field.raw_name }}")]
{% endif %} pub {{ field.ident }}: {{ field.ty }},
{% if field.has_rename %} #[serde(rename = "{{ field.wire_name }}")]
{% endif %} pub {{ field.ident }}: {% if field.optional %}Option<{{ field.ty }}>{% else %}{{ field.ty }}{% endif %},
{% endfor -%}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct {{ pascal }}ContextParams {
{% for p in params -%}
{% if p.has_rename %} #[serde(rename = "{{ p.raw_name }}")]
{% endif %} pub {{ p.ident }}: {{ p.ty }},
{% if p.has_rename %} #[serde(rename = "{{ p.wire_name }}")]
{% endif %} pub {{ p.ident }}: {% if p.optional %}Option<{{ p.ty }}>{% else %}{{ p.ty }}{% endif %},
{% endfor -%}
}
@@ -29,7 +26,8 @@ pub async fn fetch_{{ snake }}_context(
client: &MizanClient,
params: &{{ pascal }}ContextParams,
) -> Result<{{ pascal }}ContextData, MizanError> {
let params_value = serde_json::to_value(params).unwrap_or(Value::Object(Default::default()));
let params_value = serde_json::to_value(params)
.map_err(|e| MizanError::transport(format!("encode {{ ctx_name }} context params: {e}")))?;
let raw = client.fetch_context("{{ ctx_name }}", &params_value).await?;
serde_json::from_value(raw)
.map_err(|e| MizanError::transport(format!("decode {{ ctx_name }} context: {e}")))

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
pub mod types;
{% if has_contexts %}pub mod contexts;
{% endif %}{% if has_mutations %}pub mod mutations;

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
{% for name in modules -%}
pub mod {{ name }};
{% endfor %}

View File

@@ -0,0 +1,20 @@
{%- match kind -%}
{%- when RustSchemaKind::Struct with (fields) -%}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct {{ name }} {
{% for f in fields %}{% if f.has_rename %} #[serde(rename = "{{ f.wire_name }}")]
{% endif %} pub {{ f.ident }}: {{ f.ty }},
{% endfor %}}
{%- when RustSchemaKind::StringEnum with (variants) -%}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum {{ name }} {
{% for v in variants %}{% if v.has_rename %} #[serde(rename = "{{ v.wire_name }}")]
{% endif %} {{ v.ident }},
{% endfor %}}
{%- when RustSchemaKind::TransparentArray with (inner) -%}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct {{ name }}(pub Vec<{{ inner }}>);
{%- when RustSchemaKind::Alias with (inner) -%}
pub type {{ name }} = {{ inner }};
{%- endmatch -%}

View File

@@ -0,0 +1,10 @@
{%- match shape -%}
{%- when RustShape::Named with (name) -%}
{{ name }}
{%- when RustShape::List with (inner) -%}
Vec<{{ crate::emit::rust::rust_type(inner) }}>
{%- when RustShape::Optional with (inner) -%}
Option<{{ crate::emit::rust::rust_type(inner) }}>
{%- when RustShape::Json -%}
serde_json::Value
{%- endmatch -%}

View File

@@ -1,8 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
#![allow(non_camel_case_types)]
use serde::{Deserialize, Serialize};
{{ schemas_block }}
{{ hoisted_enums_block }}
{% for schema in schemas %}{{ schema }}
{% endfor %}{% for hoisted in hoisted_enums %}{{ hoisted }}
{% if !loop.last %}
{% endif %}{% endfor %}

View File

@@ -1,17 +1,15 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
{% if !type_imports.is_empty() -%}
import type { {{ type_imports|join(", ") }} } from '../types'
{% endif -%}
{% if has_input -%}
export function call{{ pascal }}(args: {{ input_type }}): Promise<{{ output_type }}> {
{% match input %}{% when CallInput::Typed with (t) -%}
export function call{{ pascal }}(args: {{ t }}): Promise<{{ output_type }}> {
return mizanCall('{{ name }}', args)
}
{% else -%}
{% when CallInput::Absent -%}
export function call{{ pascal }}(): Promise<{{ output_type }}> {
return mizanCall('{{ name }}', {})
}
{% endif %}
{% endmatch %}

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanFetch } from '@mizan/base'
{% if !type_imports.is_empty() -%}

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
export * from './types'
{% if !contexts.is_empty() %}
{%- for ctx in contexts %}
@@ -12,7 +10,6 @@ export { call{{ call.pascal }} } from './{{ call.dir }}/{{ call.camel_name }}'
{%- endfor %}
{% endif -%}
{% if !framework_adapters.is_empty() %}
// Stage 2 framework adapter
{%- for name in framework_adapters %}
export * from './{{ name }}'
{%- endfor %}

View File

@@ -0,0 +1,16 @@
{%- match kind -%}
{%- when TsSchemaKind::Interface with (fields) -%}
{%- if fields.is_empty() -%}
export interface {{ name }} {}
{%- else -%}
export interface {{ name }} {
{% for f in fields %} {{ f.name }}{% if !f.required %}?{% endif %}: {{ f.ty }}
{% endfor %}}
{%- endif -%}
{%- when TsSchemaKind::ArrayOf with (inner) -%}
export type {{ name }} = {{ crate::emit::stage1::ts_type(inner) }}[]
{%- when TsSchemaKind::Union with (variants) -%}
export type {{ name }} = {% for v in variants %}{% if !loop.first %} | {% endif %}"{{ v }}"{% endfor %}
{%- when TsSchemaKind::Alias with (inner) -%}
export type {{ name }} = {{ crate::emit::stage1::ts_type(inner) }}
{%- endmatch -%}

View File

@@ -0,0 +1,14 @@
{%- match shape -%}
{%- when TypeShape::Ref with (name) -%}
{{ name }}
{%- when TypeShape::Primitive with (p) -%}
{{ crate::emit::stage1::primitive_to_ts(p) }}
{%- when TypeShape::List with (inner) -%}
{{ crate::emit::stage1::ts_type(inner) }}[]
{%- when TypeShape::Optional with (inner) -%}
{{ crate::emit::stage1::ts_type(inner) }} | null
{%- when TypeShape::Enum with (variants) -%}
{% for v in variants %}{% if !loop.first %} | {% endif %}"{{ v }}"{% endfor %}
{%- when TypeShape::Union with (branches) -%}
{% for b in branches %}{% if !loop.first %} | {% endif %}{{ crate::emit::stage1::ts_type(b) }}{% endfor %}
{%- endmatch -%}

View File

@@ -0,0 +1,3 @@
{% for schema in schemas %}{{ schema }}
{% endfor %}

View File

@@ -1,10 +1,11 @@
// AUTO-GENERATED by mizan — do not edit
import { readable, type Readable } from 'svelte/store'
import { registerContext, type ContextState } from '@mizan/base'
{% if !stage1_imports.is_empty() -%}
import { {{ stage1_imports|join(", ") }} } from '../index'
import {
{%- for p in stage1_imports.contexts %} fetch{{ p }}Context, type {{ p }}ContextData, type {{ p }}ContextParams,{% endfor %}
{%- for p in stage1_imports.calls %} call{{ p }},{% endfor %}
{%- for t in stage1_imports.context_output_types %} type {{ t }},{% endfor %} } from '../index'
{% endif -%}
{% for ctx in contexts -%}

View File

@@ -1,10 +1,11 @@
// AUTO-GENERATED by mizan — do not edit
import { ref, computed, onMounted, onUnmounted, onServerPrefetch, type ComputedRef } from 'vue'
import { registerContext, type ContextState } from '@mizan/base'
{% if !stage1_imports.is_empty() -%}
import { {{ stage1_imports|join(", ") }} } from '../index'
import {
{%- for p in stage1_imports.contexts %} fetch{{ p }}Context, type {{ p }}ContextData, type {{ p }}ContextParams,{% endfor %}
{%- for p in stage1_imports.calls %} call{{ p }},{% endfor %}
{%- for t in stage1_imports.context_output_types %} type {{ t }},{% endfor %} } from '../index'
{% endif -%}
{% for ctx in contexts -%}

View File

@@ -1,80 +0,0 @@
//! Smoke test for the channels target against a synthetic fixture.
//! The JS channels.mjs runs types through `openapi-typescript` which the
//! Rust codegen replaces with direct interface emission; byte-equivalence
//! against the JS baseline is intentionally not the gate. Instead this
//! test checks structural properties of the emitted output.
use std::collections::BTreeMap;
use std::path::PathBuf;
use mizan_codegen::config::{Config, SourceConfig};
use mizan_codegen::emit::CodegenTarget;
use mizan_codegen::emit::channels::ChannelsTarget;
use mizan_codegen::fetch::parse_ir_from_str;
fn fixture_config() -> Config {
Config {
project_id: None,
output: PathBuf::from("/tmp"),
targets: vec!["channels".to_string()],
source: SourceConfig { fastapi: None, django: None, rust: None, script: None },
rust_kernel: None,
rust_crate_name: None,
}
}
#[test]
fn channels_target_emits_expected_files() {
let raw = std::fs::read_to_string(
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/channels_ir.kdl"),
).unwrap();
let ir = parse_ir_from_str(&raw).unwrap();
let files = ChannelsTarget.emit(&ir, &fixture_config());
assert_eq!(files.len(), 2, "channels target emits 2 files when channels present");
let by_path: BTreeMap<PathBuf, &str> =
files.iter().map(|f| (f.rel_path.clone(), f.content.as_str())).collect();
let ts = by_path.get(&PathBuf::from("channels.ts"))
.expect("channels.ts emitted");
for expected in [
"export interface ChatChannelParams",
"export interface ChatReactMessage",
"export interface ChatDjangoMessage",
"export interface NotificationsDjangoMessage",
"export const CHANNELS = {",
"chat: {",
"notifications: {",
"hasParams: true",
"hasParams: false",
] {
assert!(ts.contains(expected), "channels.ts must contain {expected:?}");
}
let hooks = by_path.get(&PathBuf::from("channels.hooks.tsx"))
.expect("channels.hooks.tsx emitted");
for expected in [
"import { useChannel, type ChannelSubscription } from 'mizan/channels'",
"export function useChatChannel(params: ChatChannelParams)",
"export function useNotificationsChannel()",
"ChannelSubscription<ChatChannelParams, ChatDjangoMessage, ChatReactMessage>",
"ChannelSubscription<Record<string, never>, NotificationsDjangoMessage, never>",
] {
assert!(hooks.contains(expected), "channels.hooks.tsx must contain {expected:?}");
}
}
#[test]
fn channels_target_emits_nothing_when_empty() {
// AFI fixture has no channels — target should produce zero files.
let raw = std::fs::read_to_string(
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_ir.kdl"),
).unwrap();
let ir = parse_ir_from_str(&raw).unwrap();
let files = ChannelsTarget.emit(&ir, &fixture_config());
assert!(files.is_empty(), "no channels → no files");
}

View File

@@ -1,4 +0,0 @@
# AUTO-GENERATED by mizan — do not edit
from .client import MizanClient # noqa: F401
from .types import * # noqa: F401, F403

View File

@@ -1,67 +0,0 @@
# AUTO-GENERATED by mizan — do not edit
from __future__ import annotations
from collections.abc import Callable
from typing import Any
# Built from frontends/mizan-rust with `maturin develop --features pyo3`.
from mizan_rust import PyMizanClient, PyContextSubscription
from .types import * # noqa: F401, F403
from .types import BaseModel # re-import for the synthesized ContextData classes
class MizanClient:
"""Typed Python facade over the PyO3 mizan-rust kernel."""
def __init__(self, base_url: str, *, session: bool = False,
csrf_cookie_name: str = "csrftoken",
csrf_header_name: str = "X-CSRFToken") -> None:
self._inner = PyMizanClient(
base_url,
session=session,
csrf_cookie_name=csrf_cookie_name,
csrf_header_name=csrf_header_name,
)
def fetch_user_context(self, user_id: int) -> "UserContextData":
raw = self._inner.fetch_context("user", {"user_id": user_id})
return UserContextData(**raw)
def subscribe_user_context(self, user_id: int,
callback: Callable[[dict[str, Any]], None]) -> PyContextSubscription:
return self._inner.subscribe_context("user", {"user_id": user_id}, callback)
def call_echo(self, args: EchoInput) -> EchoOutput:
raw = self._inner.call("echo", args.model_dump())
return EchoOutput(**raw)
def call_find_user(self, args: FindUserInput) -> FindUserOutput | None:
raw = self._inner.call("find_user", args.model_dump())
return FindUserOutput(**raw) if raw is not None else None
def call_rename_user(self, args: RenameUserInput) -> RenameUserOutput:
raw = self._inner.call("rename_user", args.model_dump())
return RenameUserOutput(**raw)
def call_update_profile(self, args: UpdateProfileInput) -> UpdateProfileOutput:
raw = self._inner.call("update_profile", args.model_dump())
return UpdateProfileOutput(**raw)
def call_whoami(self) -> WhoamiOutput:
raw = self._inner.call("whoami", {})
return WhoamiOutput(**raw)
def invalidate(self, context: str) -> None:
self._inner.invalidate(context)
def invalidate_scoped(self, context: str, params: dict[str, Any]) -> None:
self._inner.invalidate_scoped(context, params)
# ── Context data shapes (per-context bundle) ──────────────────────────────
class UserContextData(BaseModel):
"""Bundled return of fetch_user_context."""
user_orders: UserOrdersOutput
user_profile: UserProfileOutput

View File

@@ -1,56 +0,0 @@
# AUTO-GENERATED by mizan — do not edit
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel
class OrderOutput(BaseModel):
id: int
user_id: int
total: int
class EchoInput(BaseModel):
text: str
class EchoOutput(BaseModel):
message: str
class FindUserInput(BaseModel):
user_id: int
class FindUserOutput(BaseModel):
user_id: int
name: str
class RenameUserInput(BaseModel):
user_id: int
name: str
class RenameUserOutput(BaseModel):
user_id: int
name: str
class UpdateProfileInput(BaseModel):
user_id: int
name: str
class UpdateProfileOutput(BaseModel):
ok: bool
class UserOrdersInput(BaseModel):
user_id: int
UserOrdersOutput = list[OrderOutput]
class UserProfileInput(BaseModel):
user_id: int
class UserProfileOutput(BaseModel):
user_id: int
name: str
class WhoamiOutput(BaseModel):
email: str
authenticated: bool

View File

@@ -1,18 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanFetch } from '@mizan/base'
import type { userOrdersOutput, userProfileOutput } from '../types'
export interface UserContextData {
user_orders: userOrdersOutput
user_profile: userProfileOutput
}
export interface UserContextParams {
user_id: number
}
export function fetchUserContext(params: UserContextParams): Promise<UserContextData> {
return mizanFetch('user', params)
}

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { echoInput, echoOutput } from '../types'
export function callEcho(args: echoInput): Promise<echoOutput> {
return mizanCall('echo', args)
}

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { findUserInput, findUserOutput } from '../types'
export function callFindUser(args: findUserInput): Promise<findUserOutput> {
return mizanCall('find_user', args)
}

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { renameUserInput, renameUserOutput } from '../types'
export function callRenameUser(args: renameUserInput): Promise<renameUserOutput> {
return mizanCall('rename_user', args)
}

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { whoamiOutput } from '../types'
export function callWhoami(): Promise<whoamiOutput> {
return mizanCall('whoami', {})
}

View File

@@ -1,14 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
export * from './types'
export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user'
export { callEcho } from './functions/echo'
export { callFindUser } from './functions/findUser'
export { callRenameUser } from './functions/renameUser'
export { callUpdateProfile } from './mutations/updateProfile'
export { callWhoami } from './functions/whoami'
// Stage 2 framework adapter
export * from './react'

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { updateProfileInput, updateProfileOutput } from '../types'
export function callUpdateProfile(args: updateProfileInput): Promise<updateProfileOutput> {
return mizanCall('update_profile', args)
}

View File

@@ -1,156 +0,0 @@
'use client'
// AUTO-GENERATED by mizan — do not edit
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
useSyncExternalStore,
type ReactNode,
} from 'react'
import {
configure,
mizanCall,
mizanFetch,
registerContext,
type ContextState,
} from '@mizan/base'
import { fetchUserContext, type UserContextData, type UserContextParams, callUpdateProfile, callEcho, callFindUser, callRenameUser, callWhoami, type userOrdersOutput, type userProfileOutput } from './index'
// Internal — runs inside a Provider, registers with the kernel exactly once.
function useContextSubscription<T>(
name: string,
params: Record<string, any>,
fetchFn: () => Promise<T>,
initialData?: T,
): ContextState<T> {
const ref = useRef<ReturnType<typeof registerContext> | null>(null)
if (!ref.current) {
ref.current = registerContext(name, params, fetchFn, initialData)
}
const handle = ref.current
useEffect(() => {
if (handle.getState().status === 'idle') handle.refetch()
return () => handle.unregister()
}, [handle])
return useSyncExternalStore(handle.subscribe, handle.getState, handle.getState)
}
// Internal — wraps an imperative call() with isPending / error state.
interface MutationHook<TArgs, TResult> {
mutate: (args: TArgs) => Promise<TResult>
isPending: boolean
error: Error | null
}
function useMutation<TArgs, TResult>(
callFn: (args: TArgs) => Promise<TResult>,
): MutationHook<TArgs, TResult> {
const [isPending, setIsPending] = useState(false)
const [error, setError] = useState<Error | null>(null)
const mutate = useCallback(async (args: TArgs) => {
setIsPending(true)
setError(null)
try {
return await callFn(args)
} catch (e) {
setError(e as Error)
throw e
} finally {
setIsPending(false)
}
}, [callFn])
return { mutate, isPending, error }
}
// ── User Context ──
const UserCtx = createContext<ContextState<UserContextData> | null>(null)
export function UserContext({ children, ...params }: UserContextParams & { children: ReactNode }) {
const state = useContextSubscription('user', params, () => fetchUserContext(params))
return <UserCtx.Provider value={state}>{children}</UserCtx.Provider>
}
export function useUserContext(): ContextState<UserContextData> {
const ctx = useContext(UserCtx)
if (!ctx) throw new Error('useUserContext requires <UserContext>')
return ctx
}
export function useUserOrders(): userOrdersOutput | null {
return useUserContext().data?.user_orders ?? null
}
export function useUserProfile(): userProfileOutput | null {
return useUserContext().data?.user_profile ?? null
}
export function useUpdateProfile() {
return useMutation<Parameters<typeof callUpdateProfile>[0], Awaited<ReturnType<typeof callUpdateProfile>>>(callUpdateProfile)
}
export function useEcho() {
return useMutation<Parameters<typeof callEcho>[0], Awaited<ReturnType<typeof callEcho>>>(callEcho)
}
export function useFindUser() {
return useMutation<Parameters<typeof callFindUser>[0], Awaited<ReturnType<typeof callFindUser>>>(callFindUser)
}
export function useRenameUser() {
return useMutation<Parameters<typeof callRenameUser>[0], Awaited<ReturnType<typeof callRenameUser>>>(callRenameUser)
}
export function useWhoami() {
return useMutation<void, Awaited<ReturnType<typeof callWhoami>>>(() => callWhoami() as any)
}
// ── MizanContext root provider ──
export interface MizanContextProps {
/** Base URL for protocol endpoints. Defaults to "/api/mizan". */
baseUrl?: string
/** Set to `false` for backends without a `/session/` endpoint (e.g. FastAPI). */
session?: boolean
children: ReactNode
}
/**
* Root provider — calls configure() once and mounts the global context (if defined).
* Must wrap any component using Mizan-generated hooks.
*/
export function MizanContext({ baseUrl, session, children }: MizanContextProps) {
const configured = useRef(false)
if (!configured.current) {
const opts: Parameters<typeof configure>[0] = {}
if (baseUrl !== undefined) opts.baseUrl = baseUrl
if (session !== undefined) opts.session = session
if (Object.keys(opts).length > 0) configure(opts)
configured.current = true
}
return <>{children}</>
}
// ── Imperative escape hatch ──
/**
* Returns the imperative kernel API. For test harnesses or rare cases where
* a typed generated hook does not fit. Most app code should use the typed hooks.
*/
export function useMizan() {
return { call: mizanCall, fetch: mizanFetch }
}
export type { ContextState } from '@mizan/base'
export { configure, initSession, MizanError } from '@mizan/base'

View File

@@ -1,64 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
export interface OrderOutput {
id: number
user_id: number
total: number
}
export interface echoInput {
text: string
}
export interface echoOutput {
message: string
}
export interface findUserInput {
user_id: number
}
export interface findUserOutput {
user_id: number
name: string
}
export interface renameUserInput {
user_id: number
name: string
}
export interface renameUserOutput {
user_id: number
name: string
}
export interface updateProfileInput {
user_id: number
name: string
}
export interface updateProfileOutput {
ok: boolean
}
export interface userOrdersInput {
user_id: number
}
export type userOrdersOutput = OrderOutput[]
export interface userProfileInput {
user_id: number
}
export interface userProfileOutput {
user_id: number
name: string
}
export interface whoamiOutput {
email: string
authenticated: boolean
}

View File

@@ -1,10 +0,0 @@
[package]
name = "fixture_client"
version = "0.1.0"
edition = "2021"
[dependencies]
mizan-rust = { path = "../../../frontends/mizan-rust" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt", "macros"] }

View File

@@ -1,3 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
pub mod user;

View File

@@ -1,29 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
use serde::{Deserialize, Serialize};
use serde_json::Value;
use mizan_rust::{MizanClient, MizanError};
use crate::types::{UserOrdersOutput, UserProfileOutput};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserContextData {
pub user_orders: UserOrdersOutput,
pub user_profile: UserProfileOutput,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserContextParams {
pub user_id: i64,
}
pub async fn fetch_user_context(
client: &MizanClient,
params: &UserContextParams,
) -> Result<UserContextData, MizanError> {
let params_value = serde_json::to_value(params).unwrap_or(Value::Object(Default::default()));
let raw = client.fetch_context("user", &params_value).await?;
serde_json::from_value(raw)
.map_err(|e| MizanError::transport(format!("decode user context: {e}")))
}

View File

@@ -1,14 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
use serde_json::Value;
use mizan_rust::{MizanClient, MizanError};
use crate::types::{EchoOutput, EchoInput};
pub async fn call_echo(client: &MizanClient, args: &EchoInput) -> Result<EchoOutput, MizanError> {
let args_value = serde_json::to_value(args).unwrap_or(Value::Object(Default::default()));
let raw = client.call("echo", args_value).await?;
serde_json::from_value(raw)
.map_err(|e| MizanError::transport(format!("decode echo result: {e}")))
}

View File

@@ -1,14 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
use serde_json::Value;
use mizan_rust::{MizanClient, MizanError};
use crate::types::{FindUserOutput, FindUserInput};
pub async fn call_find_user(client: &MizanClient, args: &FindUserInput) -> Result<Option<FindUserOutput>, MizanError> {
let args_value = serde_json::to_value(args).unwrap_or(Value::Object(Default::default()));
let raw = client.call("find_user", args_value).await?;
serde_json::from_value(raw)
.map_err(|e| MizanError::transport(format!("decode find_user result: {e}")))
}

View File

@@ -1,6 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
pub mod echo;
pub mod find_user;
pub mod rename_user;
pub mod whoami;

View File

@@ -1,14 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
use serde_json::Value;
use mizan_rust::{MizanClient, MizanError};
use crate::types::{RenameUserOutput, RenameUserInput};
pub async fn call_rename_user(client: &MizanClient, args: &RenameUserInput) -> Result<RenameUserOutput, MizanError> {
let args_value = serde_json::to_value(args).unwrap_or(Value::Object(Default::default()));
let raw = client.call("rename_user", args_value).await?;
serde_json::from_value(raw)
.map_err(|e| MizanError::transport(format!("decode rename_user result: {e}")))
}

View File

@@ -1,14 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
use serde_json::Value;
use mizan_rust::{MizanClient, MizanError};
use crate::types::{WhoamiOutput};
pub async fn call_whoami(client: &MizanClient) -> Result<WhoamiOutput, MizanError> {
let args_value = Value::Object(Default::default());
let raw = client.call("whoami", args_value).await?;
serde_json::from_value(raw)
.map_err(|e| MizanError::transport(format!("decode whoami result: {e}")))
}

View File

@@ -1,8 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
pub mod types;
pub mod contexts;
pub mod mutations;
pub mod functions;
pub use mizan_rust::{MizanClient, MizanConfig, MizanError};

View File

@@ -1,3 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
pub mod update_profile;

View File

@@ -1,14 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
use serde_json::Value;
use mizan_rust::{MizanClient, MizanError};
use crate::types::{UpdateProfileOutput, UpdateProfileInput};
pub async fn call_update_profile(client: &MizanClient, args: &UpdateProfileInput) -> Result<UpdateProfileOutput, MizanError> {
let args_value = serde_json::to_value(args).unwrap_or(Value::Object(Default::default()));
let raw = client.call("update_profile", args_value).await?;
serde_json::from_value(raw)
.map_err(|e| MizanError::transport(format!("decode update_profile result: {e}")))
}

View File

@@ -1,81 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
#![allow(non_camel_case_types)]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderOutput {
pub id: i64,
pub user_id: i64,
pub total: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EchoInput {
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EchoOutput {
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FindUserInput {
pub user_id: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FindUserOutput {
pub user_id: i64,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenameUserInput {
pub user_id: i64,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenameUserOutput {
pub user_id: i64,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateProfileInput {
pub user_id: i64,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateProfileOutput {
pub ok: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserOrdersInput {
pub user_id: i64,
}
pub type UserOrdersOutput = Vec<OrderOutput>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserProfileInput {
pub user_id: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserProfileOutput {
pub user_id: i64,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WhoamiOutput {
pub email: String,
pub authenticated: bool,
}

View File

@@ -1,18 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanFetch } from '@mizan/base'
import type { userOrdersOutput, userProfileOutput } from '../types'
export interface UserContextData {
user_orders: userOrdersOutput
user_profile: userProfileOutput
}
export interface UserContextParams {
user_id: number
}
export function fetchUserContext(params: UserContextParams): Promise<UserContextData> {
return mizanFetch('user', params)
}

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { echoInput, echoOutput } from '../types'
export function callEcho(args: echoInput): Promise<echoOutput> {
return mizanCall('echo', args)
}

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { findUserInput, findUserOutput } from '../types'
export function callFindUser(args: findUserInput): Promise<findUserOutput> {
return mizanCall('find_user', args)
}

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { renameUserInput, renameUserOutput } from '../types'
export function callRenameUser(args: renameUserInput): Promise<renameUserOutput> {
return mizanCall('rename_user', args)
}

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { whoamiOutput } from '../types'
export function callWhoami(): Promise<whoamiOutput> {
return mizanCall('whoami', {})
}

View File

@@ -1,11 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
export * from './types'
export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user'
export { callEcho } from './functions/echo'
export { callFindUser } from './functions/findUser'
export { callRenameUser } from './functions/renameUser'
export { callUpdateProfile } from './mutations/updateProfile'
export { callWhoami } from './functions/whoami'

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { updateProfileInput, updateProfileOutput } from '../types'
export function callUpdateProfile(args: updateProfileInput): Promise<updateProfileOutput> {
return mizanCall('update_profile', args)
}

View File

@@ -1,64 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
export interface OrderOutput {
id: number
user_id: number
total: number
}
export interface echoInput {
text: string
}
export interface echoOutput {
message: string
}
export interface findUserInput {
user_id: number
}
export interface findUserOutput {
user_id: number
name: string
}
export interface renameUserInput {
user_id: number
name: string
}
export interface renameUserOutput {
user_id: number
name: string
}
export interface updateProfileInput {
user_id: number
name: string
}
export interface updateProfileOutput {
ok: boolean
}
export interface userOrdersInput {
user_id: number
}
export type userOrdersOutput = OrderOutput[]
export interface userProfileInput {
user_id: number
}
export interface userProfileOutput {
user_id: number
name: string
}
export interface whoamiOutput {
email: string
authenticated: boolean
}

View File

@@ -1,18 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanFetch } from '@mizan/base'
import type { userOrdersOutput, userProfileOutput } from '../types'
export interface UserContextData {
user_orders: userOrdersOutput
user_profile: userProfileOutput
}
export interface UserContextParams {
user_id: number
}
export function fetchUserContext(params: UserContextParams): Promise<UserContextData> {
return mizanFetch('user', params)
}

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { echoInput, echoOutput } from '../types'
export function callEcho(args: echoInput): Promise<echoOutput> {
return mizanCall('echo', args)
}

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { findUserInput, findUserOutput } from '../types'
export function callFindUser(args: findUserInput): Promise<findUserOutput> {
return mizanCall('find_user', args)
}

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { renameUserInput, renameUserOutput } from '../types'
export function callRenameUser(args: renameUserInput): Promise<renameUserOutput> {
return mizanCall('rename_user', args)
}

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { whoamiOutput } from '../types'
export function callWhoami(): Promise<whoamiOutput> {
return mizanCall('whoami', {})
}

View File

@@ -1,14 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
export * from './types'
export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user'
export { callEcho } from './functions/echo'
export { callFindUser } from './functions/findUser'
export { callRenameUser } from './functions/renameUser'
export { callUpdateProfile } from './mutations/updateProfile'
export { callWhoami } from './functions/whoami'
// Stage 2 framework adapter
export * from './svelte'

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { updateProfileInput, updateProfileOutput } from '../types'
export function callUpdateProfile(args: updateProfileInput): Promise<updateProfileOutput> {
return mizanCall('update_profile', args)
}

View File

@@ -1,29 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { readable, type Readable } from 'svelte/store'
import { registerContext, type ContextState } from '@mizan/base'
import { fetchUserContext, type UserContextData, type UserContextParams, callUpdateProfile, callEcho, callFindUser, callRenameUser, callWhoami } from '../index'
export function createUserContext(params: UserContextParams) {
const store = readable<ContextState<UserContextData>>(
{ data: null, status: 'idle', error: null },
(set) => {
const handle = registerContext('user', params, () => fetchUserContext(params))
const unsub = handle.subscribe(() => set(handle.getState()))
handle.refetch()
return () => { unsub(); handle.unregister() }
},
)
return store
}
export { callUpdateProfile } from '../index'
export { callEcho } from '../index'
export { callFindUser } from '../index'
export { callRenameUser } from '../index'
export { callWhoami } from '../index'
export type { ContextState } from '@mizan/base'
export { configure, initSession, MizanError } from '@mizan/base'

View File

@@ -1,64 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
export interface OrderOutput {
id: number
user_id: number
total: number
}
export interface echoInput {
text: string
}
export interface echoOutput {
message: string
}
export interface findUserInput {
user_id: number
}
export interface findUserOutput {
user_id: number
name: string
}
export interface renameUserInput {
user_id: number
name: string
}
export interface renameUserOutput {
user_id: number
name: string
}
export interface updateProfileInput {
user_id: number
name: string
}
export interface updateProfileOutput {
ok: boolean
}
export interface userOrdersInput {
user_id: number
}
export type userOrdersOutput = OrderOutput[]
export interface userProfileInput {
user_id: number
}
export interface userProfileOutput {
user_id: number
name: string
}
export interface whoamiOutput {
email: string
authenticated: boolean
}

View File

@@ -1,18 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanFetch } from '@mizan/base'
import type { userOrdersOutput, userProfileOutput } from '../types'
export interface UserContextData {
user_orders: userOrdersOutput
user_profile: userProfileOutput
}
export interface UserContextParams {
user_id: number
}
export function fetchUserContext(params: UserContextParams): Promise<UserContextData> {
return mizanFetch('user', params)
}

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { echoInput, echoOutput } from '../types'
export function callEcho(args: echoInput): Promise<echoOutput> {
return mizanCall('echo', args)
}

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { findUserInput, findUserOutput } from '../types'
export function callFindUser(args: findUserInput): Promise<findUserOutput> {
return mizanCall('find_user', args)
}

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { renameUserInput, renameUserOutput } from '../types'
export function callRenameUser(args: renameUserInput): Promise<renameUserOutput> {
return mizanCall('rename_user', args)
}

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { whoamiOutput } from '../types'
export function callWhoami(): Promise<whoamiOutput> {
return mizanCall('whoami', {})
}

View File

@@ -1,14 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
export * from './types'
export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user'
export { callEcho } from './functions/echo'
export { callFindUser } from './functions/findUser'
export { callRenameUser } from './functions/renameUser'
export { callUpdateProfile } from './mutations/updateProfile'
export { callWhoami } from './functions/whoami'
// Stage 2 framework adapter
export * from './vue'

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { updateProfileInput, updateProfileOutput } from '../types'
export function callUpdateProfile(args: updateProfileInput): Promise<updateProfileOutput> {
return mizanCall('update_profile', args)
}

View File

@@ -1,64 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
export interface OrderOutput {
id: number
user_id: number
total: number
}
export interface echoInput {
text: string
}
export interface echoOutput {
message: string
}
export interface findUserInput {
user_id: number
}
export interface findUserOutput {
user_id: number
name: string
}
export interface renameUserInput {
user_id: number
name: string
}
export interface renameUserOutput {
user_id: number
name: string
}
export interface updateProfileInput {
user_id: number
name: string
}
export interface updateProfileOutput {
ok: boolean
}
export interface userOrdersInput {
user_id: number
}
export type userOrdersOutput = OrderOutput[]
export interface userProfileInput {
user_id: number
}
export interface userProfileOutput {
user_id: number
name: string
}
export interface whoamiOutput {
email: string
authenticated: boolean
}

View File

@@ -1,96 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { ref, computed, onMounted, onUnmounted, onServerPrefetch, type ComputedRef } from 'vue'
import { registerContext, type ContextState } from '@mizan/base'
import { fetchUserContext, type UserContextData, type UserContextParams, callUpdateProfile, callEcho, callFindUser, callRenameUser, callWhoami } from '../index'
export function useUserContext(params: UserContextParams) {
const state = ref<ContextState<UserContextData>>({ data: null, status: 'idle', error: null })
let handle: ReturnType<typeof registerContext> | null = null
onMounted(() => {
handle = registerContext('user', params, () => fetchUserContext(params))
handle.subscribe(() => { state.value = handle!.getState() })
handle.refetch()
})
onServerPrefetch(async () => {
handle = registerContext('user', params, () => fetchUserContext(params))
await handle.refetch()
state.value = handle.getState()
})
onUnmounted(() => { handle?.unregister() })
return {
state,
userOrders: computed(() => state.value.data?.user_orders ?? null) as ComputedRef<userOrdersOutput | null>,
userProfile: computed(() => state.value.data?.user_profile ?? null) as ComputedRef<userProfileOutput | null>,
loading: computed(() => state.value.status === 'loading'),
error: computed(() => state.value.error),
}
}
export function useUpdateProfile() {
const isPending = ref(false)
const error = ref<Error | null>(null)
async function mutate(args: Parameters<typeof callUpdateProfile>[0]) {
isPending.value = true; error.value = null
try { return await callUpdateProfile(args) }
catch (e) { error.value = e as Error; throw e }
finally { isPending.value = false }
}
return { mutate, isPending, error }
}
export function useEcho() {
const isPending = ref(false)
const error = ref<Error | null>(null)
async function mutate(args: Parameters<typeof callEcho>[0]) {
isPending.value = true; error.value = null
try { return await callEcho(args) }
catch (e) { error.value = e as Error; throw e }
finally { isPending.value = false }
}
return { mutate, isPending, error }
}
export function useFindUser() {
const isPending = ref(false)
const error = ref<Error | null>(null)
async function mutate(args: Parameters<typeof callFindUser>[0]) {
isPending.value = true; error.value = null
try { return await callFindUser(args) }
catch (e) { error.value = e as Error; throw e }
finally { isPending.value = false }
}
return { mutate, isPending, error }
}
export function useRenameUser() {
const isPending = ref(false)
const error = ref<Error | null>(null)
async function mutate(args: Parameters<typeof callRenameUser>[0]) {
isPending.value = true; error.value = null
try { return await callRenameUser(args) }
catch (e) { error.value = e as Error; throw e }
finally { isPending.value = false }
}
return { mutate, isPending, error }
}
export function useWhoami() {
const isPending = ref(false)
const error = ref<Error | null>(null)
async function mutate() {
isPending.value = true; error.value = null
try { return await callWhoami() }
catch (e) { error.value = e as Error; throw e }
finally { isPending.value = false }
}
return { mutate, isPending, error }
}
export type { ContextState } from '@mizan/base'
export { configure, initSession, MizanError } from '@mizan/base'

View File

@@ -1,4 +1,4 @@
type "ChatChannelParams" {
type "ChatParams" {
struct {
field "room_id" {
primitive "string"
@@ -6,7 +6,7 @@ type "ChatChannelParams" {
}
}
type "ChatReactMessage" {
type "ChatClientMessage" {
struct {
field "text" {
primitive "string"
@@ -14,7 +14,7 @@ type "ChatReactMessage" {
}
}
type "ChatDjangoMessage" {
type "ChatServerMessage" {
struct {
field "text" {
primitive "string"
@@ -25,7 +25,7 @@ type "ChatDjangoMessage" {
}
}
type "NotificationsDjangoMessage" {
type "NotificationsServerMessage" {
struct {
field "body" {
primitive "string"
@@ -35,12 +35,12 @@ type "NotificationsDjangoMessage" {
channel "chat" {
pascal-name "Chat"
params "ChatChannelParams"
react-message "ChatReactMessage"
django-message "ChatDjangoMessage"
params "ChatParams"
client-message "ChatClientMessage"
server-message "ChatServerMessage"
}
channel "notifications" {
pascal-name "Notifications"
django-message "NotificationsDjangoMessage"
server-message "NotificationsServerMessage"
}

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"strict": true,
"noEmit": true,
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Node",
"jsx": "preserve",
"skipLibCheck": true,
"types": []
},
"files": ["shims/mizan-channels.d.ts", "channels.ts", "channels.hooks.tsx"]
}

View File

@@ -0,0 +1,59 @@
import {
MizanContext,
UserContext,
useEcho,
useFindUser,
useMizan,
useRenameUser,
useUpdateProfile,
useUserContext,
useUserOrders,
useUserProfile,
useWhoami,
type ContextState,
} from '../src/react'
import type { UserContextData } from '../src/index'
export function Screen() {
return (
<MizanContext baseUrl="/api/mizan" session={false}>
<UserContext user_id={1}>
<Panel />
</UserContext>
</MizanContext>
)
}
function Panel() {
const context: ContextState<UserContextData> = useUserContext()
const orders = useUserOrders()
const profile = useUserProfile()
const echo = useEcho()
const findUser = useFindUser()
const renameUser = useRenameUser()
const updateProfile = useUpdateProfile()
const whoami = useWhoami()
const { call, fetch } = useMizan()
async function run() {
const echoed = await echo.mutate({ text: 'hello' })
const found = await findUser.mutate({ user_id: 1 })
const renamed = await renameUser.mutate({ user_id: 1, name: 'renamed' })
const updated = await updateProfile.mutate({ user_id: 1, name: 'renamed' })
const identity = await whoami.mutate()
const raw = await call('echo', { text: 'hello' })
const bundle = await fetch('user', { user_id: 1 })
console.log(echoed.message, found.name, renamed.name, updated.ok,
identity.authenticated, raw, bundle)
}
const pending: boolean = echo.isPending
const failure: Error | null = echo.error
return (
<button onClick={run} disabled={pending}>
{context.status} {orders?.length ?? 0} {profile?.name ?? ''} {failure?.message ?? ''}
</button>
)
}

View File

@@ -0,0 +1,58 @@
import {
callEcho,
callFindUser,
callRenameUser,
callUpdateProfile,
callWhoami,
fetchUserContext,
type UserContextData,
type UserContextParams,
type echoInput,
type echoOutput,
type findUserInput,
type findUserOutput,
type renameUserInput,
type renameUserOutput,
type updateProfileInput,
type updateProfileOutput,
type userOrdersInput,
type userOrdersOutput,
type userProfileInput,
type userProfileOutput,
type whoamiOutput,
} from '../src/index'
export async function everyStage1EntryPoint(): Promise<void> {
const echoArgs: echoInput = { text: 'hello' }
const echoed: echoOutput = await callEcho(echoArgs)
const message: string = echoed.message
const whoami: whoamiOutput = await callWhoami()
const email: string = whoami.email
const authenticated: boolean = whoami.authenticated
const findArgs: findUserInput = { user_id: 1 }
const found: findUserOutput = await callFindUser(findArgs)
const foundName: string = found.name
const renameArgs: renameUserInput = { user_id: 1, name: 'renamed' }
const renamed: renameUserOutput = await callRenameUser(renameArgs)
const renamedId: number = renamed.user_id
const updateArgs: updateProfileInput = { user_id: 1, name: 'renamed' }
const updated: updateProfileOutput = await callUpdateProfile(updateArgs)
const ok: boolean = updated.ok
const params: UserContextParams = { user_id: 1 }
const bundle: UserContextData = await fetchUserContext(params)
const orders: userOrdersOutput = bundle.user_orders
const profile: userProfileOutput = bundle.user_profile
const ordersArgs: userOrdersInput = { user_id: 1 }
const profileArgs: userProfileInput = { user_id: 1 }
console.log(
message, email, authenticated, foundName, renamedId, ok,
orders.length, profile.name, ordersArgs.user_id, profileArgs.user_id,
)
}

View File

@@ -0,0 +1,28 @@
import {
callEcho,
callFindUser,
callRenameUser,
callUpdateProfile,
callWhoami,
createUserContext,
type ContextState,
} from '../src/stores/svelte'
import type { UserContextData, UserContextParams } from '../src/index'
export async function everySvelteStoreEntryPoint(): Promise<void> {
const params: UserContextParams = { user_id: 1 }
const store = createUserContext(params)
const unsubscribe = store.subscribe((state: ContextState<UserContextData>) => {
console.log(state.status, state.data?.user_profile.name ?? '')
})
unsubscribe()
const echoed = await callEcho({ text: 'hello' })
const found = await callFindUser({ user_id: 1 })
const renamed = await callRenameUser({ user_id: 1, name: 'renamed' })
const updated = await callUpdateProfile({ user_id: 1, name: 'renamed' })
const identity = await callWhoami()
console.log(echoed.message, found.name, renamed.name, updated.ok, identity.authenticated)
}

View File

@@ -0,0 +1,32 @@
import {
useEcho,
useFindUser,
useRenameUser,
useUpdateProfile,
useUserContext,
useWhoami,
type ContextState,
} from '../src/composables/vue'
import type { UserContextData, UserContextParams } from '../src/index'
export async function everyVueComposable(): Promise<void> {
const params: UserContextParams = { user_id: 1 }
const user = useUserContext(params)
const state: ContextState<UserContextData> = user.state.value
const orders = user.userOrders.value
const profile = user.userProfile.value
const loading: boolean = user.loading.value
const failure: Error | null = user.error.value
const echoed = await useEcho().mutate({ text: 'hello' })
const found = await useFindUser().mutate({ user_id: 1 })
const renamed = await useRenameUser().mutate({ user_id: 1, name: 'renamed' })
const updated = await useUpdateProfile().mutate({ user_id: 1, name: 'renamed' })
const identity = await useWhoami().mutate()
console.log(
state.status, orders?.length ?? 0, profile?.name ?? '', loading, failure?.message ?? '',
echoed.message, found.name, renamed.name, updated.ok, identity.authenticated,
)
}

View File

@@ -0,0 +1,46 @@
"""Import target for the generated client's `from mizan_rust import ...`.
The real module is a compiled PyO3 extension; this one records each call so the
driver can read the name and payload the generated method sent, and replies with
whatever `reply` currently holds.
"""
from typing import Any, Callable
class PyContextSubscription:
def __init__(self, name: str, params: dict[str, Any],
callback: Callable[[dict[str, Any]], None]) -> None:
self.name = name
self.params = params
self.callback = callback
class PyMizanClient:
def __init__(self, base_url: str, *, session: bool,
csrf_cookie_name: str, csrf_header_name: str) -> None:
self.base_url = base_url
self.session = session
self.csrf_cookie_name = csrf_cookie_name
self.csrf_header_name = csrf_header_name
self.calls: list[tuple[str, dict[str, Any]]] = []
self.invalidated: list[tuple[str, dict[str, Any] | None]] = []
self.reply: Any = None
def call(self, name: str, args: dict[str, Any]) -> Any:
self.calls.append((name, args))
return self.reply
def fetch_context(self, name: str, params: dict[str, Any]) -> Any:
self.calls.append((name, params))
return self.reply
def subscribe_context(self, name: str, params: dict[str, Any],
callback: Callable[[dict[str, Any]], None]) -> PyContextSubscription:
return PyContextSubscription(name, params, callback)
def invalidate(self, context: str) -> None:
self.invalidated.append((context, None))
def invalidate_scoped(self, context: str, params: dict[str, Any]) -> None:
self.invalidated.append((context, params))

View File

@@ -0,0 +1,73 @@
"""Runs every generated client method against the recording kernel stub."""
from mizan_client import MizanClient
from mizan_client.client import UserContextData
from mizan_client.types import (
EchoInput,
EchoOutput,
FindUserInput,
FindUserOutput,
OrderOutput,
RenameUserInput,
RenameUserOutput,
UpdateProfileInput,
UpdateProfileOutput,
UserProfileOutput,
WhoamiOutput,
)
client = MizanClient("http://127.0.0.1:9/api/mizan")
kernel = client._inner
kernel.reply = {"message": "hi"}
echoed = client.call_echo(EchoInput(text="hello"))
assert isinstance(echoed, EchoOutput)
assert echoed.message == "hi"
assert kernel.calls[-1] == ("echo", {"text": "hello"})
kernel.reply = {"email": "ryth@example.com", "authenticated": True}
identity = client.call_whoami()
assert isinstance(identity, WhoamiOutput)
assert identity.authenticated is True
assert kernel.calls[-1] == ("whoami", {})
kernel.reply = {"user_id": 1, "name": "ryth"}
found = client.call_find_user(FindUserInput(user_id=1))
assert isinstance(found, FindUserOutput)
assert found.name == "ryth"
assert kernel.calls[-1] == ("find_user", {"user_id": 1})
kernel.reply = None
assert client.call_find_user(FindUserInput(user_id=2)) is None
kernel.reply = {"user_id": 1, "name": "renamed"}
renamed = client.call_rename_user(RenameUserInput(user_id=1, name="renamed"))
assert isinstance(renamed, RenameUserOutput)
assert renamed.name == "renamed"
kernel.reply = {"ok": True}
updated = client.call_update_profile(UpdateProfileInput(user_id=1, name="renamed"))
assert isinstance(updated, UpdateProfileOutput)
assert updated.ok is True
kernel.reply = {
"user_orders": [{"id": 7, "user_id": 1, "total": 42}],
"user_profile": {"user_id": 1, "name": "ryth"},
}
bundle = client.fetch_user_context(1)
assert isinstance(bundle, UserContextData)
assert isinstance(bundle.user_profile, UserProfileOutput)
assert isinstance(bundle.user_orders[0], OrderOutput)
assert bundle.user_orders[0].total == 42
assert kernel.calls[-1] == ("user", {"user_id": 1})
received: list[dict] = []
subscription = client.subscribe_user_context(1, received.append)
assert subscription.name == "user"
assert subscription.params == {"user_id": 1}
client.invalidate("user")
client.invalidate_scoped("user", {"user_id": 1})
assert kernel.invalidated == [("user", None), ("user", {"user_id": 1})]
print("generated python client exercised")

Some files were not shown because too many files have changed in this diff Show More