Mizan IR: cut over to KDL, delete OpenAPI envelope
Replaces the transitional OpenAPI 3.0 + `x-mizan-*` extensions
substrate with the canonical Mizan IR as KDL, per docs/AFI_ARCHITECTURE.md:
"KDL is the contract; everything else (REST envelopes, OpenAPI
documents, framework idioms) is sediment around it."
End-to-end cutover. No transitional path left on main.
Forward direction:
cores/mizan-python/src/mizan_core/ir.py
build_ir() walks mizan_core.registry, introspects Pydantic
models directly (no JSON-Schema indirection), and emits the
Mizan IR document. The KDL grammar is locked in this file's
module docstring.
Backends emit KDL:
backends/mizan-fastapi/src/mizan_fastapi/ir.py
`python -m mizan_fastapi.ir <module>` — CLI entry point.
backends/mizan-django/.../management/commands/export_mizan_ir.py
`manage.py export_mizan_ir` — Django mgmt command.
Codegen consumes KDL:
protocol/mizan-codegen/Cargo.toml: + kdl = "6"
protocol/mizan-codegen/src/ir.rs: NamedType { Struct/List/Enum/Alias }
+ TypeShape { Primitive/Ref/List/Optional/Enum/Union } sum types,
replacing the JsonSchema sprawl. KDL parser walks the
`kdl::KdlDocument` tree into typed Rust structs.
protocol/mizan-codegen/src/fetch.rs: subprocess command switches
to the new IR-export entry points.
All emit modules (stage1 / react / python / rust / vue / svelte /
channels) port their type-walkers from JsonSchema to the new
sum types — case analysis collapses substantially.
Substrate-honesty wins beyond the moat closure:
- `int | bool` multi-arm unions land as `TypeShape::Union` (was
silently coerced to "string" before).
- `<CamelName>Output = list[T]` returns emit as named alias
types instead of struct-shaped wrappers, so consumer code
`.map()` works directly on the type.
- Pydantic field defaults flow through to `default` properties
in KDL, then back to non-optional shape in every target.
Deleted:
- backends/mizan-fastapi/src/mizan_fastapi/{cli,schema}.py
- backends/mizan-django/.../export_mizan_schema.py
- openapi-bearing half of mizan/export/__init__.py (edge
manifest generator preserved — separate concern).
- tests/afi/schema_normalizer.py
- tests/fixtures/{afi_schema.json, channels_schema.json}
- tests/fixtures/js_* baseline directories.
Verification:
- 20 mizan-codegen unit tests green (IR deserialization,
byte-equivalence parity across stage1/rust/python/react/vue/svelte
against fresh KDL-driven baselines, channels structural).
- tests/rust/run_wire_parity.py: 12/12 probes green driving
the binary end-to-end through KDL.
- Blazr studio-ui typechecks against the regenerated React client.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,122 +1,121 @@
|
||||
//! Mizan IR — strongly-typed deserialization of the backends' schema export.
|
||||
//!
|
||||
//! Every Mizan backend (Django, FastAPI, mizan-ts) emits the same OpenAPI
|
||||
//! document with three load-bearing extension fields:
|
||||
//! - `x-mizan-functions` — array of function entries
|
||||
//! - `x-mizan-contexts` — map of context groups
|
||||
//! - `components.schemas` — OpenAPI Pydantic→JSONSchema per Input/Output
|
||||
//!
|
||||
//! The structs here deserialize that JSON envelope into typed Rust values
|
||||
//! the emit targets walk. The OpenAPI document body (paths, info, etc.) is
|
||||
//! intentionally not modeled — the codegen consumes only the extensions.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
//! 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.
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use indexmap::IndexMap;
|
||||
use serde::Deserialize;
|
||||
use kdl::{KdlDocument, KdlNode, KdlValue};
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MizanIR {
|
||||
#[serde(rename = "x-mizan-functions", default)]
|
||||
pub types: IndexMap<String, NamedType>,
|
||||
pub functions: Vec<MizanFunction>,
|
||||
|
||||
#[serde(rename = "x-mizan-contexts", default)]
|
||||
pub contexts: IndexMap<String, MizanContext>,
|
||||
|
||||
/// Django-only channel registrations. FastAPI backends emit an empty list.
|
||||
#[serde(rename = "x-mizan-channels", default)]
|
||||
pub channels: Vec<MizanChannel>,
|
||||
|
||||
#[serde(default)]
|
||||
pub components: Components,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct MizanChannel {
|
||||
// ─── Type system ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NamedType {
|
||||
Struct(Vec<StructField>),
|
||||
List(TypeShape),
|
||||
Enum(Vec<String>),
|
||||
Alias(TypeShape),
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StructField {
|
||||
pub name: String,
|
||||
#[serde(rename = "pascalName")]
|
||||
pub pascal_name: String,
|
||||
#[serde(rename = "hasParams", default)]
|
||||
pub has_params: bool,
|
||||
#[serde(rename = "hasReactMessage", default)]
|
||||
pub has_react_message: bool,
|
||||
#[serde(rename = "hasDjangoMessage", default)]
|
||||
pub has_django_message: bool,
|
||||
#[serde(rename = "paramsType", default)]
|
||||
pub params_type: Option<String>,
|
||||
#[serde(rename = "reactMessageType", default)]
|
||||
pub react_message_type: Option<String>,
|
||||
#[serde(rename = "djangoMessageType", default)]
|
||||
pub django_message_type: Option<String>,
|
||||
pub required: bool,
|
||||
pub default: Option<DefaultValue>,
|
||||
pub shape: TypeShape,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TypeShape {
|
||||
Primitive(Primitive),
|
||||
Ref(String),
|
||||
List(Box<TypeShape>),
|
||||
Optional(Box<TypeShape>),
|
||||
Enum(Vec<String>),
|
||||
/// Multi-arm union with two or more non-null branches.
|
||||
Union(Vec<TypeShape>),
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
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),
|
||||
Number(f64),
|
||||
Boolean(bool),
|
||||
String(String),
|
||||
Null,
|
||||
}
|
||||
|
||||
|
||||
// ─── Functions ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MizanFunction {
|
||||
pub name: String,
|
||||
|
||||
#[serde(rename = "camelName")]
|
||||
pub camel_name: String,
|
||||
|
||||
#[serde(rename = "hasInput")]
|
||||
pub has_input: bool,
|
||||
|
||||
#[serde(rename = "inputType")]
|
||||
pub input_type: Option<String>,
|
||||
|
||||
#[serde(rename = "outputType")]
|
||||
pub output_type: String,
|
||||
|
||||
#[serde(rename = "outputNullable", default)]
|
||||
pub output_nullable: bool,
|
||||
|
||||
pub transport: Transport,
|
||||
|
||||
#[serde(rename = "isContext", default)]
|
||||
pub is_context: IsContext,
|
||||
|
||||
#[serde(rename = "isForm", default)]
|
||||
pub is_form: bool,
|
||||
|
||||
#[serde(rename = "formName", default)]
|
||||
pub form_name: Option<String>,
|
||||
|
||||
#[serde(rename = "formRole", default)]
|
||||
pub form_role: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub affects: Vec<AffectTarget>,
|
||||
|
||||
/// Names of contexts whose state is patched by this function's return
|
||||
/// body via the kernel's `splice_slot` merger. Empty when the function
|
||||
/// is not a merge target.
|
||||
#[serde(default)]
|
||||
pub merge: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Transport {
|
||||
#[default]
|
||||
Http,
|
||||
Websocket,
|
||||
Both,
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// IR-level `isContext` value. The backends emit `false` for non-context
|
||||
/// functions and a string (`"global"`, `"user"`, …) for context-grouped
|
||||
/// functions. Custom Deserialize bridges the boolean/string union into a
|
||||
/// typed Rust enum.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub enum IsContext {
|
||||
#[default]
|
||||
No,
|
||||
Yes(String),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum IsContext { No, Yes(String) }
|
||||
|
||||
|
||||
impl IsContext {
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
@@ -127,122 +126,343 @@ impl IsContext {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for IsContext {
|
||||
fn deserialize<D>(de: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let v = serde_json::Value::deserialize(de)?;
|
||||
match v {
|
||||
serde_json::Value::Bool(false) => Ok(IsContext::No),
|
||||
serde_json::Value::Bool(true) => Err(serde::de::Error::custom(
|
||||
"isContext: bare `true` is not a valid context name",
|
||||
)),
|
||||
serde_json::Value::String(s) => Ok(IsContext::Yes(s)),
|
||||
serde_json::Value::Null => Ok(IsContext::No),
|
||||
other => Err(serde::de::Error::custom(format!(
|
||||
"isContext: expected `false` or string, got {other:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AffectTarget {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: AffectKind,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub context: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AffectKind {
|
||||
Context,
|
||||
Function,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AffectKind { Context, Function }
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize, Default, Clone)]
|
||||
// ─── Contexts ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MizanContext {
|
||||
#[serde(default)]
|
||||
pub functions: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub params: IndexMap<String, ContextParam>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContextParam {
|
||||
#[serde(rename = "type")]
|
||||
pub ty: String,
|
||||
|
||||
pub ty: Primitive,
|
||||
pub required: bool,
|
||||
|
||||
#[serde(rename = "sharedBy", default)]
|
||||
pub shared_by: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct Components {
|
||||
#[serde(default)]
|
||||
pub schemas: IndexMap<String, JsonSchema>,
|
||||
// ─── Channels (Django-only) ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
|
||||
/// JSON Schema subset used by the emit targets. Mirrors the surface the
|
||||
/// existing JS adapters traverse (`$ref`, `anyOf`, `enum`, `type`, `items`,
|
||||
/// `properties`, `required`, `nullable`). Unknown fields are stashed in
|
||||
/// `extra` so backends can include schema annotations the codegen ignores.
|
||||
#[derive(Debug, Deserialize, Default, Clone)]
|
||||
pub struct JsonSchema {
|
||||
#[serde(rename = "type", default)]
|
||||
pub ty: Option<String>,
|
||||
|
||||
#[serde(rename = "$ref", default)]
|
||||
pub r#ref: Option<String>,
|
||||
|
||||
#[serde(rename = "enum", default)]
|
||||
pub r#enum: Option<Vec<serde_json::Value>>,
|
||||
|
||||
#[serde(rename = "anyOf", default)]
|
||||
pub any_of: Option<Vec<JsonSchema>>,
|
||||
|
||||
#[serde(default)]
|
||||
pub nullable: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub items: Option<Box<JsonSchema>>,
|
||||
|
||||
#[serde(default)]
|
||||
pub properties: Option<IndexMap<String, JsonSchema>>,
|
||||
|
||||
#[serde(default)]
|
||||
pub required: Vec<String>,
|
||||
|
||||
#[serde(rename = "additionalProperties", default)]
|
||||
pub additional_properties: Option<serde_json::Value>,
|
||||
|
||||
/// Presence of this field means the schema has a default — the server
|
||||
/// always populates it. Consumers can treat the field as non-optional
|
||||
/// even if it's absent from `required`.
|
||||
#[serde(default)]
|
||||
pub default: Option<serde_json::Value>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, serde_json::Value>,
|
||||
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() }
|
||||
}
|
||||
|
||||
|
||||
impl JsonSchema {
|
||||
/// `$ref: "#/components/schemas/Foo"` → `Some("Foo")`.
|
||||
pub fn ref_name(&self) -> Option<&str> {
|
||||
self.r#ref
|
||||
.as_deref()
|
||||
.and_then(|s| s.strip_prefix("#/components/schemas/"))
|
||||
// ─── 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user