//! 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 kdl::{KdlDocument, KdlNode, KdlValue}; #[derive(Debug, Default)] pub struct MizanIR { pub types: IndexMap, pub functions: Vec, pub contexts: IndexMap, pub channels: Vec, } // ─── Type system ──────────────────────────────────────────────────────────── #[derive(Debug, Clone)] pub enum NamedType { Struct(Vec), List(TypeShape), Enum(Vec), Alias(TypeShape), } #[derive(Debug, Clone)] pub struct StructField { pub name: String, pub required: bool, pub default: Option, pub shape: TypeShape, } #[derive(Debug, Clone)] pub enum TypeShape { Primitive(Primitive), Ref(String), List(Box), Optional(Box), Enum(Vec), /// Multi-arm union with two or more non-null branches. Union(Vec), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Primitive { Integer, Number, Boolean, String } impl Primitive { fn parse(s: &str) -> Result { 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, pub camel_name: String, pub has_input: bool, pub input_type: Option, pub output_type: String, pub output_nullable: bool, pub transport: Transport, pub is_context: IsContext, pub is_form: bool, pub form_name: Option, pub form_role: Option, pub affects: Vec, pub merge: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Transport { Http, Websocket, Both } impl Transport { fn parse(s: &str) -> Result { 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) } impl IsContext { pub fn as_str(&self) -> Option<&str> { match self { IsContext::No => None, IsContext::Yes(s) => Some(s.as_str()), } } } #[derive(Debug, Clone)] pub struct AffectTarget { pub kind: AffectKind, pub name: String, pub context: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AffectKind { Context, Function } // ─── Contexts ─────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Default)] pub struct MizanContext { pub functions: Vec, pub params: IndexMap, } #[derive(Debug, Clone)] pub struct ContextParam { pub ty: Primitive, pub required: bool, pub shared_by: Vec, } // ─── Channels (Django-only) ───────────────────────────────────────────────── #[derive(Debug, Clone)] pub struct MizanChannel { pub name: String, pub pascal_name: String, pub params_type: Option, pub react_message_type: Option, pub django_message_type: Option, } 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 { 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> { 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 { 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 { 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 { 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 { 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> = 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 { 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 = Vec::new(); let mut merge: Vec = 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 { 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 { 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 { first_string_arg(node).context(format!("{label}: requires a string argument")) } fn bool_arg(node: &KdlNode, label: &str) -> Result { 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 { node.entry(key).and_then(|e| e.value().as_bool()) } fn parse_string_args(node: &KdlNode) -> Vec { 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 { parse_ir(source) }