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

@@ -13,6 +13,18 @@ dependencies = [
"syn",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "heck"
version = "0.5.0"
@@ -34,6 +46,17 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "kdl"
version = "6.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81a29e7b50079ff44549f68c0becb1c73d7f6de2a4ea952da77966daf3d4761e"
dependencies = [
"miette",
"num",
"winnow",
]
[[package]]
name = "linkme"
version = "0.3.36"
@@ -60,13 +83,41 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memo-map"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
[[package]]
name = "miette"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
dependencies = [
"cfg-if",
"unicode-width",
]
[[package]]
name = "minijinja"
version = "2.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39"
dependencies = [
"memo-map",
"serde",
]
[[package]]
name = "mizan-core"
version = "0.1.0"
dependencies = [
"async-trait",
"indoc",
"kdl",
"linkme",
"minijinja",
"mizan-macros",
"serde",
"serde_json",
@@ -82,6 +133,79 @@ dependencies = [
"syn",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -166,6 +290,21 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "winnow"
version = "0.6.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a"
dependencies = [
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"

View File

@@ -2,11 +2,12 @@
name = "mizan-core"
version = "0.1.0"
edition = "2021"
description = "Mizan server-side IR substrate — types, traits, KDL emitter, registry. Rust analog of cores/mizan-python/src/mizan_core/."
description = "Mizan server-side IR substrate — types, traits, KDL emitter, registry."
license = "Elastic-2.0"
[dependencies]
linkme = "0.3"
minijinja = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
async-trait = "0.1"
@@ -14,3 +15,4 @@ mizan-macros = { path = "../mizan-rust-macros" }
[dev-dependencies]
indoc = "2"
kdl = "6"

View File

@@ -1,200 +1,313 @@
//! Cross-function invariant verification — fails at `build_ir()` time, which
//! runs at the codegen subprocess (`cargo run --bin export-ir`). All
//! graph-level inconsistencies surface before any client artifact is emitted.
//! Cross-function invariant checks over the registered graph.
use crate::ir::{AffectTarget, NamedType, StructField, TypeShape};
use crate::registry::{lookup_context, CONTEXTS, FUNCTIONS, TYPES};
use crate::ir::{NamedType, Primitive, TypeShape};
use crate::registry::{CONTEXTS, FUNCTIONS};
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::fmt;
use std::sync::OnceLock;
/// Walk the registered types and find the named type's shape. Used by both
/// graph-check and runtime merge resolution.
pub(crate) fn resolve_type_shape(name: &str) -> Option<NamedType> {
for entry in TYPES {
if entry.name == name {
return Some((entry.shape_fn)());
}
}
None
/// A structural fingerprint of a type, with every reference resolved through
/// to the shape it names. Two types are interchangeable exactly when their
/// fingerprints are equal, so comparison is one derived `==` instead of a
/// pairwise walk over both shape enums.
#[derive(PartialEq)]
enum Canonical {
Record(Vec<CanonicalField>),
Aliased(Box<Canonical>),
NamedEnum(Vec<&'static str>),
Primitive(&'static str),
List(Box<Canonical>),
Optional(Box<Canonical>),
InlineEnum(Vec<&'static str>),
Union(Vec<Canonical>),
}
/// Merge-compatibility on named types. A mutation return `value` can
/// splice into a context slot `slot` when any of three shapes hold —
/// matches Python's `types_match_for_merge`:
/// * direct: `slot` shape equals `value` shape → replace
/// * upsert: `slot` is `list[T]`, `value` is `T` → upsert by id
/// * list-replace: `slot` is `list[T]`, `value` is `list[T]`
#[derive(PartialEq)]
struct CanonicalField {
name: &'static str,
required: bool,
shape: Canonical,
}
fn canonical_named(named: &NamedType) -> Canonical {
match named {
NamedType::Struct(fields) => Canonical::Record(
fields
.iter()
.map(|f| CanonicalField {
name: f.name,
required: f.required,
shape: canonical_shape(&f.shape),
})
.collect(),
),
NamedType::Alias(inner) => Canonical::Aliased(Box::new(canonical_shape(inner))),
NamedType::Enum(variants) => Canonical::NamedEnum(variants.clone()),
}
}
fn canonical_shape(shape: &TypeShape) -> Canonical {
match shape {
TypeShape::Primitive(p) => Canonical::Primitive(p.name()),
TypeShape::Ref { shape, .. } => canonical_named(&shape()),
TypeShape::List(inner) => Canonical::List(Box::new(canonical_shape(inner))),
TypeShape::Optional(inner) => Canonical::Optional(Box::new(canonical_shape(inner))),
TypeShape::Enum(variants) => Canonical::InlineEnum(variants.clone()),
TypeShape::Union(branches) => {
Canonical::Union(branches.iter().map(canonical_shape).collect())
}
}
}
/// Merge-compatibility on named types. A mutation return `value` can splice
/// into a context slot `slot` when either shape holds:
/// * direct: `slot` and `value` have the same fingerprint → replace
/// * upsert: `slot` is `list[T]` and `value` is `T` → upsert by id
///
/// The first argument is the slot (context member's output type); the
/// second is the value (mutation's output type).
pub(crate) fn types_match(slot: &NamedType, value: &NamedType) -> bool {
if named_shapes_equal(slot, value) {
/// The first argument is the slot (context member's output type); the second
/// is the value (mutation's output type).
fn types_match(slot: &NamedType, value: &NamedType) -> bool {
let value_form = canonical_named(value);
if canonical_named(slot) == value_form {
return true;
}
// Upsert: slot is `Alias(List(T))`, value is `T`-shaped.
if let NamedType::Alias(TypeShape::List(elem)) = slot {
if shape_matches_named(elem, value) {
return true;
}
}
false
}
fn named_shapes_equal(a: &NamedType, b: &NamedType) -> bool {
match (a, b) {
(NamedType::Struct(fa), NamedType::Struct(fb)) => fields_match(fa, fb),
(NamedType::Alias(sa), NamedType::Alias(sb)) => shapes_match(sa, sb),
(NamedType::Enum(va), NamedType::Enum(vb)) => va == vb,
_ => false,
match slot {
NamedType::Alias(inner) => match inner {
TypeShape::List(elem) => canonical_shape(elem) == value_form,
TypeShape::Primitive(_)
| TypeShape::Ref { .. }
| TypeShape::Optional(_)
| TypeShape::Enum(_)
| TypeShape::Union(_) => false,
},
NamedType::Struct(_) | NamedType::Enum(_) => false,
}
}
/// True when a `TypeShape` (the slot's list-element) describes the same
/// shape as a `NamedType` (the mutation's full output).
fn shape_matches_named(shape: &TypeShape, named: &NamedType) -> bool {
match shape {
TypeShape::Ref(name) => {
if let Some(referenced) = resolve_type_shape(name) {
named_shapes_equal(&referenced, named)
} else {
false
/// One `merge` declaration read off the registry and resolved: the mutation
/// that declares it, the context it names, and the context member whose output
/// the mutation's return value splices into.
pub(crate) struct ResolvedMerge {
pub function: &'static str,
pub context: &'static str,
pub slot: &'static str,
}
/// The context members whose output a mutation's return value can splice into,
/// accumulated one candidate at a time. A `merge` declaration carries a usable
/// slot exactly when the walk ends on `Unique`.
enum SlotMatch {
Absent,
Unique(&'static str),
Ambiguous(Vec<&'static str>),
}
impl SlotMatch {
fn with(self, candidate: &'static str) -> Self {
match self {
SlotMatch::Absent => SlotMatch::Unique(candidate),
SlotMatch::Unique(first) => SlotMatch::Ambiguous(vec![first, candidate]),
SlotMatch::Ambiguous(mut members) => {
members.push(candidate);
SlotMatch::Ambiguous(members)
}
}
_ => false,
}
}
fn fields_match(a: &[StructField], b: &[StructField]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b.iter()).all(|(fa, fb)| {
fa.name == fb.name && fa.required == fb.required && shapes_match(&fa.shape, &fb.shape)
})
/// The ways a registered graph fails to hold together.
enum GraphDefect {
NoMergeSlot {
function: &'static str,
context: &'static str,
output_type: &'static str,
},
AmbiguousMergeSlot {
function: &'static str,
context: &'static str,
output_type: &'static str,
members: Vec<&'static str>,
},
DivergentParamType {
context: &'static str,
param: &'static str,
first_fn: &'static str,
first_type: &'static str,
second_fn: &'static str,
second_type: &'static str,
},
}
fn shapes_match(a: &TypeShape, b: &TypeShape) -> bool {
match (a, b) {
(TypeShape::Primitive(pa), TypeShape::Primitive(pb)) => {
std::mem::discriminant(pa) == std::mem::discriminant(pb)
impl fmt::Display for GraphDefect {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GraphDefect::NoMergeSlot {
function,
context,
output_type,
} => write!(
f,
"function `{function}` declares `merge = \"{context}\"` but no member of that \
context has output type `{output_type}`. Add a context member returning \
`{output_type}`, or declare `affects` for plain refetch."
),
GraphDefect::AmbiguousMergeSlot {
function,
context,
output_type,
members,
} => write!(
f,
"function `{function}` declares `merge = \"{context}\"` but members ({}) all \
share output type `{output_type}`. Merge resolution needs exactly one match. \
Distinguish the outputs, or declare `affects` for plain refetch.",
members.join(", ")
),
GraphDefect::DivergentParamType {
context,
param,
first_fn,
first_type,
second_fn,
second_type,
} => write!(
f,
"context `{context}` has a parameter `{param}` whose type diverges across \
members. Function `{first_fn}` declares it as `{first_type}`, function \
`{second_fn}` declares it as `{second_type}`. A shared param has one type \
across the whole context."
),
}
(TypeShape::Ref(na), TypeShape::Ref(nb)) => {
// Refs match iff the named types they reference match.
match (resolve_type_shape(na), resolve_type_shape(nb)) {
(Some(ta), Some(tb)) => types_match(&ta, &tb),
_ => na == nb,
}
}
(TypeShape::List(ia), TypeShape::List(ib)) => shapes_match(ia, ib),
(TypeShape::Optional(ia), TypeShape::Optional(ib)) => shapes_match(ia, ib),
(TypeShape::Enum(va), TypeShape::Enum(vb)) => va == vb,
(TypeShape::Union(ba), TypeShape::Union(bb)) => {
ba.len() == bb.len() && ba.iter().zip(bb.iter()).all(|(x, y)| shapes_match(x, y))
}
_ => false,
}
}
/// Panic with a structured message if the registered function graph is
/// inconsistent. Called from `build_ir()`.
pub fn verify_invariants() {
check_affects_targets();
check_merge_targets();
check_shared_param_types();
/// Every defect on its own bulleted line, under one heading.
struct GraphReport<'a>(&'a [GraphDefect]);
impl fmt::Display for GraphReport<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(
f,
"Mizan graph-check: the registered function graph is inconsistent."
)?;
for defect in self.0 {
writeln!(f, " - {defect}")?;
}
Ok(())
}
}
fn check_affects_targets() {
/// Every `merge` declaration that resolved to exactly one slot, plus every way
/// the graph failed to hold together.
struct GraphAnalysis {
merges: Vec<ResolvedMerge>,
defects: Vec<GraphDefect>,
}
static ANALYSIS: OnceLock<GraphAnalysis> = OnceLock::new();
/// `FUNCTIONS` and `CONTEXTS` are link-time data, so the walk yields the same
/// answer for every caller and runs once.
fn analysis() -> &'static GraphAnalysis {
ANALYSIS.get_or_init(analyze)
}
fn analyze() -> GraphAnalysis {
let mut merges = Vec::new();
let mut defects = Vec::new();
for fn_spec in FUNCTIONS {
for affect in fn_spec.affects() {
if let AffectTarget::Context(name) = affect {
if lookup_context(name).is_none() {
panic!(
"Mizan graph-check: function `{}` declares `affects = \"{}\"` but no context with that name is registered. \
Either register a context with that name (via `#[mizan::context(\"{}\")]`) or remove the affects target.",
fn_spec.name(),
name,
name,
);
}
let mutation_shape = fn_spec.output_shape();
for &context in fn_spec.merge() {
match match_slot(context, &mutation_shape) {
SlotMatch::Unique(slot) => merges.push(ResolvedMerge {
function: fn_spec.name(),
context,
slot,
}),
SlotMatch::Absent => defects.push(GraphDefect::NoMergeSlot {
function: fn_spec.name(),
context,
output_type: fn_spec.output_type(),
}),
SlotMatch::Ambiguous(members) => defects.push(GraphDefect::AmbiguousMergeSlot {
function: fn_spec.name(),
context,
output_type: fn_spec.output_type(),
members,
}),
}
}
}
defects.extend(divergent_param_types());
GraphAnalysis { merges, defects }
}
fn check_merge_targets() {
for fn_spec in FUNCTIONS {
for merge_target in fn_spec.merge() {
let ctx_entry = match lookup_context(merge_target) {
Some(c) => c,
None => panic!(
"Mizan graph-check: function `{}` declares `merge = \"{}\"` but no context with that name is registered.",
fn_spec.name(),
merge_target,
),
};
let mutation_output = fn_spec.output_type();
let mutation_shape = match resolve_type_shape(mutation_output) {
Some(s) => s,
None => panic!(
"Mizan graph-check: function `{}` has output type `{}` but no such named type is registered.",
fn_spec.name(), mutation_output,
),
};
let mut matches: Vec<&'static str> = Vec::new();
for candidate in FUNCTIONS {
if candidate.context() != Some(ctx_entry.name) {
continue;
}
if let Some(candidate_shape) = resolve_type_shape(candidate.output_type()) {
if types_match(&candidate_shape, &mutation_shape) {
matches.push(candidate.name());
}
}
}
if matches.is_empty() {
panic!(
"Mizan graph-check: function `{}` declares `merge = \"{}\"` but no member of that context has output type `{}`. \
Add a context member returning `{}`, or remove the merge declaration in favor of `affects` for plain refetch.",
fn_spec.name(), merge_target, mutation_output, mutation_output,
);
}
if matches.len() > 1 {
panic!(
"Mizan graph-check: function `{}` declares `merge = \"{}\"` but multiple members ({}) share output type `{}`. \
Merge resolution requires exactly one match. Distinguish the outputs or use `affects` for refetch.",
fn_spec.name(), merge_target, matches.join(", "), mutation_output,
);
}
/// The members of `context_name` whose output type a value of `mutation_shape`
/// splices into.
fn match_slot(context_name: &'static str, mutation_shape: &NamedType) -> SlotMatch {
let mut matched = SlotMatch::Absent;
for candidate in FUNCTIONS {
if candidate.context() != Some(context_name) {
continue;
}
if types_match(&candidate.output_shape(), mutation_shape) {
matched = matched.with(candidate.name());
}
}
matched
}
fn check_shared_param_types() {
/// Params that one context's members declare under the same name but with
/// different primitives.
fn divergent_param_types() -> Vec<GraphDefect> {
let mut defects = Vec::new();
for ctx in CONTEXTS {
let mut by_name: std::collections::HashMap<&'static str, (crate::ir::Primitive, &'static str)>
= std::collections::HashMap::new();
let mut by_name: HashMap<&'static str, (Primitive, &'static str)> = HashMap::new();
for fn_spec in FUNCTIONS {
if fn_spec.context() != Some(ctx.name) {
continue;
}
for p in fn_spec.input_params() {
if let Some((prev_primitive, prev_fn)) = by_name.get(p.name) {
if std::mem::discriminant(prev_primitive)
!= std::mem::discriminant(&p.primitive)
{
panic!(
"Mizan graph-check: context `{}` has a parameter `{}` whose type diverges across members. \
Function `{}` declares it as `{}`, function `{}` declares it as `{}`. \
Shared params must have one type across the whole context.",
ctx.name, p.name,
prev_fn, prev_primitive.name(),
fn_spec.name(), p.primitive.name(),
);
match by_name.entry(p.name) {
Entry::Occupied(seen) => {
let (first_primitive, first_fn) = *seen.get();
if first_primitive != p.primitive {
defects.push(GraphDefect::DivergentParamType {
context: ctx.name,
param: p.name,
first_fn,
first_type: first_primitive.name(),
second_fn: fn_spec.name(),
second_type: p.primitive.name(),
});
}
}
Entry::Vacant(slot) => {
slot.insert((p.primitive, fn_spec.name()));
}
} else {
by_name.insert(p.name, (p.primitive, fn_spec.name()));
}
}
}
}
defects
}
/// Panic with the full defect report when the registered function graph is
/// inconsistent.
pub fn verify_invariants() {
let defects = &analysis().defects;
if !defects.is_empty() {
panic!("{}", GraphReport(defects));
}
}
/// The merges `function` declares. Reading them verifies the graph first, so a
/// declaration that resolved to no slot is reported rather than passed over.
pub(crate) fn merges_for(function: &str) -> impl Iterator<Item = &'static ResolvedMerge> + '_ {
verify_invariants();
analysis()
.merges
.iter()
.filter(move |resolved| resolved.function == function)
}

View File

@@ -1,13 +1,10 @@
//! IR data model — mirrors `cores/mizan-python/src/mizan_core/ir.py` 1:1.
//!
//! The IR is the contract. Backends emit it; codegen consumes it. The Rust
//! side produces byte-equivalent KDL to the Python emitter against the same
//! function registry.
//! The IR data model the KDL emitter walks: named types, inline type shapes,
//! and the descriptors a registered function or channel carries.
/// A named type that appears in the IR's `type "<Name>" { ... }` section.
#[derive(Debug, Clone)]
pub enum NamedType {
/// `type "X" { struct { field ... } }` — a Pydantic-model-shaped record.
/// `type "X" { struct { field ... } }` — a record.
Struct(Vec<StructField>),
/// `type "X" { alias { <type-child> } }` — a named wrapper around an
/// inline type shape, e.g. `userOrdersOutput = list[OrderOutput]`.
@@ -21,14 +18,20 @@ pub enum NamedType {
#[derive(Debug, Clone)]
pub enum TypeShape {
Primitive(Primitive),
Ref(&'static str),
/// A reference to a named type. `shape` is the referent's own shape
/// constructor, so resolving a reference never consults a registry and
/// never fails.
Ref {
name: &'static str,
shape: fn() -> NamedType,
},
List(Box<TypeShape>),
Optional(Box<TypeShape>),
Enum(Vec<&'static str>),
Union(Vec<TypeShape>),
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Primitive {
Integer,
Number,
@@ -64,8 +67,8 @@ pub enum DefaultValue {
Null,
}
/// One descriptor of what a mutation `affects`. Mirrors Python's
/// `_normalize_affects` shape — either a named context or a named function.
/// One descriptor of what a mutation `affects` — either a named context or a
/// named function.
#[derive(Debug, Clone)]
pub enum AffectTarget {
Context(&'static str),
@@ -75,6 +78,37 @@ pub enum AffectTarget {
},
}
/// One payload slot of a channel. 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 ChannelSlotKind {
Params,
ClientMessage,
ServerMessage,
}
impl ChannelSlotKind {
/// The KDL child-node name the slot emits under.
pub fn node_name(self) -> &'static str {
match self {
ChannelSlotKind::Params => "params",
ChannelSlotKind::ClientMessage => "client-message",
ChannelSlotKind::ServerMessage => "server-message",
}
}
/// The suffix appended to the channel's Pascal stem to name the slot's
/// emitted type.
pub fn type_suffix(self) -> &'static str {
match self {
ChannelSlotKind::Params => "Params",
ChannelSlotKind::ClientMessage => "ClientMessage",
ChannelSlotKind::ServerMessage => "ServerMessage",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport {
Http,

View File

@@ -1,60 +1,141 @@
//! KDL emitter — byte-equivalent to `cores/mizan-python/src/mizan_core/ir.py`.
//!
//! The Python emitter is the spec; this is the second implementation under
//! the same contract. Any divergence is a bug here, not a contract change.
//! KDL emitter — collects the registries (named types, functions, contexts,
//! channels) into a KDL node tree and renders it through
//! `templates/ir.kdl.jinja`.
use crate::ir::{DefaultValue, NamedType, Primitive, StructField, TypeShape};
use crate::registry::{CONTEXTS, FUNCTIONS, TYPES};
use crate::ir::{
AffectTarget, ChannelSlotKind, DefaultValue, NamedType, Primitive, StructField, TypeShape,
};
use crate::registry::{CHANNELS, CONTEXTS, FUNCTIONS, TYPES};
use crate::traits::FunctionSpec;
use minijinja::value::ViaDeserialize;
use minijinja::{context, Environment};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
const INDENT: &str = " ";
const IR_TEMPLATE: &str = include_str!("../templates/ir.kdl.jinja");
/// Escape a string for KDL — same escape set as the Python emitter.
fn kdl_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
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),
/// A KDL scalar, carried structurally so the template's `kdl` filter — not
/// the node builders — decides its written form.
#[derive(Serialize, Deserialize, Clone)]
#[serde(tag = "kind", content = "v")]
enum KdlValue {
Str(String),
Bool(bool),
Integer(i64),
Number(f64),
Null,
}
impl KdlValue {
fn str(s: &str) -> Self {
KdlValue::Str(s.to_string())
}
fn of_default(v: &DefaultValue) -> Self {
match v {
DefaultValue::Null => KdlValue::Null,
DefaultValue::Boolean(b) => KdlValue::Bool(*b),
DefaultValue::Integer(i) => KdlValue::Integer(*i),
DefaultValue::Number(f) => KdlValue::Number(*f),
DefaultValue::String(s) => KdlValue::str(s),
}
}
out.push('"');
out
}
fn kdl_bool(b: bool) -> &'static str {
if b {
"#true"
} else {
"#false"
#[derive(Serialize)]
struct KdlProp {
name: &'static str,
value: KdlValue,
}
/// One KDL node: its own line, plus a brace-delimited child block when
/// `block` is set. `indent` is the literal prefix its line carries.
#[derive(Serialize)]
struct KdlNode {
indent: String,
name: &'static str,
args: Vec<KdlValue>,
props: Vec<KdlProp>,
block: bool,
children: Vec<KdlNode>,
}
impl KdlNode {
fn new(depth: usize, name: &'static str) -> Self {
Self {
indent: INDENT.repeat(depth),
name,
args: Vec::new(),
props: Vec::new(),
block: false,
children: Vec::new(),
}
}
fn arg(mut self, value: KdlValue) -> Self {
self.args.push(value);
self
}
fn args(mut self, values: impl IntoIterator<Item = KdlValue>) -> Self {
self.args.extend(values);
self
}
fn prop(mut self, name: &'static str, value: KdlValue) -> Self {
self.props.push(KdlProp { name, value });
self
}
fn block(mut self, children: Vec<KdlNode>) -> Self {
self.block = true;
self.children = children;
self
}
}
fn kdl_default(v: &DefaultValue) -> String {
match v {
DefaultValue::Null => "#null".into(),
DefaultValue::Boolean(b) => kdl_bool(*b).into(),
DefaultValue::Integer(i) => i.to_string(),
DefaultValue::Number(f) => {
// Match Python's `repr(float)` for whole-number-equal-but-float
// values: e.g. 1.0 → "1.0", not "1".
/// The `kdl` template filter — writes one scalar in KDL surface syntax.
fn render_kdl_value(value: ViaDeserialize<KdlValue>) -> String {
match &*value {
KdlValue::Str(s) => {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
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.push('"');
out
}
KdlValue::Bool(b) => {
if *b {
"#true".to_string()
} else {
"#false".to_string()
}
}
KdlValue::Integer(i) => i.to_string(),
KdlValue::Number(f) => {
// A whole-valued float still writes with its fractional part, so
// `1.0` does not collapse into the integer spelling `1`.
if f.fract() == 0.0 && f.is_finite() {
format!("{f:.1}")
} else {
f.to_string()
}
}
DefaultValue::String(s) => kdl_string(s),
KdlValue::Null => "#null".to_string(),
}
}
/// Convert snake_case to camelCase. Matches Python's `_snake_to_camel`.
/// Convert snake_case to camelCase.
pub fn snake_to_camel(name: &str) -> String {
let normalized = name.replace('.', "_").replace('-', "_");
let mut parts = normalized.split('_');
@@ -75,208 +156,159 @@ pub fn snake_to_camel(name: &str) -> String {
out
}
struct Emitter<'a> {
lines: Vec<String>,
/// Types whose references should be substituted with their inline
/// shape at the use site (and which don't emit as their own
/// `type "X" { ... }` entries). Populated from `IrSnapshot::inlines`.
/// The PascalCase stem every emitted type name for `wire_name` is built on:
/// split on `[._-]`, then title-case each part, where a character is
/// uppercased only when the character before it is not a letter.
pub fn wire_to_pascal(wire_name: &str) -> String {
let mut out = String::with_capacity(wire_name.len());
for part in wire_name.split(['.', '_', '-']) {
let mut prev_is_letter = false;
for c in part.chars() {
if prev_is_letter {
out.extend(c.to_lowercase());
} else {
out.extend(c.to_uppercase());
}
prev_is_letter = c.is_alphabetic();
}
}
out
}
/// Builds the node tree for one document.
struct NodeBuilder<'a> {
/// Types whose references are substituted with their inline shape at the
/// use site, and which emit no `type "X" { ... }` entry of their own.
inlines: &'a BTreeMap<&'static str, TypeShape>,
}
impl<'a> Emitter<'a> {
fn new(inlines: &'a BTreeMap<&'static str, TypeShape>) -> Self {
Self {
lines: Vec::new(),
inlines,
}
}
fn prefix(&self, indent: usize) -> String {
INDENT.repeat(indent)
}
fn leaf(&mut self, indent: usize, parts: &[&str]) {
let mut line = self.prefix(indent);
line.push_str(&parts.join(" "));
self.lines.push(line);
}
fn open(&mut self, indent: usize, parts: &[&str]) {
let mut line = self.prefix(indent);
line.push_str(&parts.join(" "));
line.push_str(" {");
self.lines.push(line);
}
fn close(&mut self, indent: usize) {
let mut line = self.prefix(indent);
line.push('}');
self.lines.push(line);
}
fn blank(&mut self) {
self.lines.push(String::new());
}
fn emit_type_child(&mut self, indent: usize, shape: &TypeShape) {
impl NodeBuilder<'_> {
fn type_child(&self, depth: usize, shape: &TypeShape) -> KdlNode {
match shape {
TypeShape::Primitive(p) => {
let name = kdl_string(p.name());
self.leaf(indent, &["primitive", &name]);
}
TypeShape::Ref(name) => {
// Inline-substitute when the referenced type is a
// primitive-alias or string-enum. Matches Python's
// Pydantic Literal/alias inlining.
if let Some(inline_shape) = self.inlines.get(name).cloned() {
self.emit_type_child(indent, &inline_shape);
return;
}
let n = kdl_string(name);
self.leaf(indent, &["ref", &n]);
KdlNode::new(depth, "primitive").arg(KdlValue::str(p.name()))
}
TypeShape::Ref { name, .. } => match self.inlines.get(name) {
Some(inline_shape) => self.type_child(depth, &inline_shape.clone()),
None => KdlNode::new(depth, "ref").arg(KdlValue::str(name)),
},
TypeShape::List(inner) => {
self.open(indent, &["list"]);
self.emit_type_child(indent + 1, inner);
self.close(indent);
KdlNode::new(depth, "list").block(vec![self.type_child(depth + 1, inner)])
}
TypeShape::Optional(inner) => {
self.open(indent, &["optional"]);
self.emit_type_child(indent + 1, inner);
self.close(indent);
KdlNode::new(depth, "optional").block(vec![self.type_child(depth + 1, inner)])
}
TypeShape::Enum(variants) => {
let mut parts: Vec<String> = vec!["enum".into()];
for v in variants {
parts.push(kdl_string(v));
}
let line: Vec<&str> = parts.iter().map(String::as_str).collect();
self.leaf(indent, &line);
}
TypeShape::Union(branches) => {
self.open(indent, &["union"]);
for b in branches {
self.emit_type_child(indent + 1, b);
}
self.close(indent);
KdlNode::new(depth, "enum").args(variants.iter().map(|v| KdlValue::str(v)))
}
TypeShape::Union(branches) => KdlNode::new(depth, "union").block(
branches
.iter()
.map(|b| self.type_child(depth + 1, b))
.collect(),
),
}
}
fn emit_named_type(&mut self, indent: usize, name: &str, body: &NamedType) {
let name_lit = kdl_string(name);
self.open(indent, &["type", &name_lit]);
match body {
NamedType::Struct(fields) => {
self.open(indent + 1, &["struct"]);
for field in fields {
self.emit_struct_field(indent + 2, field);
}
self.close(indent + 1);
}
NamedType::Alias(inner) => {
self.open(indent + 1, &["alias"]);
self.emit_type_child(indent + 2, inner);
self.close(indent + 1);
fn named_type(&self, depth: usize, name: &str, body: &NamedType) -> KdlNode {
let inner = match body {
NamedType::Struct(fields) => KdlNode::new(depth + 1, "struct").block(
fields
.iter()
.map(|field| self.struct_field(depth + 2, field))
.collect(),
),
NamedType::Alias(shape) => {
KdlNode::new(depth + 1, "alias").block(vec![self.type_child(depth + 2, shape)])
}
NamedType::Enum(variants) => {
let mut parts: Vec<String> = vec!["enum".into()];
for v in variants {
parts.push(kdl_string(v));
}
let line: Vec<&str> = parts.iter().map(String::as_str).collect();
self.leaf(indent + 1, &line);
KdlNode::new(depth + 1, "enum").args(variants.iter().map(|v| KdlValue::str(v)))
}
}
self.close(indent);
};
KdlNode::new(depth, "type")
.arg(KdlValue::str(name))
.block(vec![inner])
}
fn emit_struct_field(&mut self, indent: usize, field: &StructField) {
let name = kdl_string(field.name);
let mut header: Vec<String> = vec!["field".into(), name];
fn struct_field(&self, depth: usize, field: &StructField) -> KdlNode {
let mut node = KdlNode::new(depth, "field").arg(KdlValue::str(field.name));
if !field.required {
header.push(format!("required={}", kdl_bool(false)));
node = node.prop("required", KdlValue::Bool(false));
if let Some(default) = &field.default {
header.push(format!("default={}", kdl_default(default)));
node = node.prop("default", KdlValue::of_default(default));
}
}
let line_parts: Vec<&str> = header.iter().map(String::as_str).collect();
self.open(indent, &line_parts);
self.emit_type_child(indent + 1, &field.shape);
self.close(indent);
node.block(vec![self.type_child(depth + 1, &field.shape)])
}
fn emit_function(&mut self, indent: usize, fn_spec: &dyn FunctionSpec) {
let name = kdl_string(fn_spec.name());
self.open(indent, &["function", &name]);
let camel = kdl_string(fn_spec.camel_name());
self.leaf(indent + 1, &["camel", &camel]);
self.leaf(indent + 1, &["has-input", kdl_bool(fn_spec.has_input())]);
fn function(&self, depth: usize, fn_spec: &dyn FunctionSpec) -> KdlNode {
let inner = depth + 1;
let mut children = vec![
KdlNode::new(inner, "camel").arg(KdlValue::str(fn_spec.camel_name())),
KdlNode::new(inner, "has-input").arg(KdlValue::Bool(fn_spec.has_input())),
];
if let Some(input_type) = fn_spec.input_type() {
let lit = kdl_string(input_type);
self.leaf(indent + 1, &["input", &lit]);
children.push(KdlNode::new(inner, "input").arg(KdlValue::str(input_type)));
}
let output_lit = kdl_string(fn_spec.output_type());
self.leaf(indent + 1, &["output", &output_lit]);
children.push(KdlNode::new(inner, "output").arg(KdlValue::str(fn_spec.output_type())));
if fn_spec.output_nullable() {
self.leaf(indent + 1, &["output-nullable", kdl_bool(true)]);
children.push(KdlNode::new(inner, "output-nullable").arg(KdlValue::Bool(true)));
}
let transport_lit = kdl_string(fn_spec.transport().name());
self.leaf(indent + 1, &["transport", &transport_lit]);
children
.push(KdlNode::new(inner, "transport").arg(KdlValue::str(fn_spec.transport().name())));
if let Some(ctx) = fn_spec.context() {
let lit = kdl_string(ctx);
self.leaf(indent + 1, &["context", &lit]);
children.push(KdlNode::new(inner, "context").arg(KdlValue::str(ctx)));
}
for affect in fn_spec.affects() {
// Mirror Python's behavior: only context-typed affects make it
// into the KDL `affects` leaf. Function-typed affects are
// reserved for a future IR extension.
if let crate::ir::AffectTarget::Context(name) = affect {
let lit = kdl_string(name);
self.leaf(indent + 1, &["affects", &lit]);
match affect {
// The `affects` leaf names a context; a function-typed target
// has no leaf in the document.
AffectTarget::Context(name) => {
children.push(KdlNode::new(inner, "affects").arg(KdlValue::str(name)));
}
AffectTarget::Function { .. } => {}
}
}
for merge in fn_spec.merge() {
let lit = kdl_string(merge);
self.leaf(indent + 1, &["merge", &lit]);
children.push(KdlNode::new(inner, "merge").arg(KdlValue::str(merge)));
}
if fn_spec.is_form() {
self.leaf(indent + 1, &["is-form", kdl_bool(true)]);
children.push(KdlNode::new(inner, "is-form").arg(KdlValue::Bool(true)));
if let Some(form_name) = fn_spec.form_name() {
let lit = kdl_string(form_name);
self.leaf(indent + 1, &["form-name", &lit]);
children.push(KdlNode::new(inner, "form-name").arg(KdlValue::str(form_name)));
}
if let Some(form_role) = fn_spec.form_role() {
let lit = kdl_string(form_role);
self.leaf(indent + 1, &["form-role", &lit]);
children.push(KdlNode::new(inner, "form-role").arg(KdlValue::str(form_role)));
}
}
self.close(indent);
KdlNode::new(depth, "function")
.arg(KdlValue::str(fn_spec.name()))
.block(children)
}
fn emit_context(&mut self, indent: usize, ctx_name: &str, members: &[&'static dyn FunctionSpec]) {
let name_lit = kdl_string(ctx_name);
self.open(indent, &["context", &name_lit]);
fn context(
&self,
depth: usize,
ctx_name: &str,
members: &[&'static dyn FunctionSpec],
) -> KdlNode {
let inner = depth + 1;
let mut children: Vec<KdlNode> = members
.iter()
.map(|fn_spec| KdlNode::new(inner, "function").arg(KdlValue::str(fn_spec.name())))
.collect();
// Function membership in registration order.
for fn_spec in members {
let lit = kdl_string(fn_spec.name());
self.leaf(indent + 1, &["function", &lit]);
}
// Param info — collect across every member, then emit alphabetized
// by param name to match Python.
// Params collected across every member, keyed so they emit
// alphabetized by param name.
struct ParamSlot {
primitive: Primitive,
shared_by: Vec<&'static str>,
@@ -295,120 +327,163 @@ impl<'a> Emitter<'a> {
let member_count = members.len();
for (param_name, slot) in params.iter() {
let name_lit = kdl_string(param_name);
self.open(indent + 1, &["param", &name_lit]);
let type_lit = kdl_string(slot.primitive.name());
self.leaf(indent + 2, &["type", &type_lit]);
let required = slot.shared_by.len() == member_count;
self.leaf(indent + 2, &["required", kdl_bool(required)]);
let mut param_children = vec![
KdlNode::new(inner + 1, "type").arg(KdlValue::str(slot.primitive.name())),
KdlNode::new(inner + 1, "required")
.arg(KdlValue::Bool(slot.shared_by.len() == member_count)),
];
for sharer in &slot.shared_by {
let lit = kdl_string(sharer);
self.leaf(indent + 2, &["shared-by", &lit]);
param_children
.push(KdlNode::new(inner + 1, "shared-by").arg(KdlValue::str(sharer)));
}
self.close(indent + 1);
children.push(
KdlNode::new(inner, "param")
.arg(KdlValue::str(param_name))
.block(param_children),
);
}
self.close(indent);
KdlNode::new(depth, "context")
.arg(KdlValue::str(ctx_name))
.block(children)
}
fn into_string(mut self) -> String {
// Trim trailing blanks, then add a single terminating newline.
while matches!(self.lines.last(), Some(s) if s.is_empty()) {
self.lines.pop();
fn channel(&self, depth: usize, channel: &ChannelRecord) -> KdlNode {
let inner = depth + 1;
let mut children =
vec![KdlNode::new(inner, "pascal-name").arg(KdlValue::str(&channel.pascal_name))];
for slot in &channel.slots {
children.push(
KdlNode::new(inner, slot.kind.node_name()).arg(KdlValue::str(&slot.type_name)),
);
}
let mut out = self.lines.join("\n");
out.push('\n');
out
KdlNode::new(depth, "channel")
.arg(KdlValue::str(channel.name))
.block(children)
}
}
/// One channel as the document carries it: the wire name, the Pascal stem its
/// slot type names are built on, and the slots it declares.
pub(crate) struct ChannelRecord {
pub name: &'static str,
pub pascal_name: String,
pub slots: Vec<ChannelSlotRecord>,
}
pub(crate) struct ChannelSlotRecord {
pub kind: ChannelSlotKind,
pub type_name: String,
}
/// Collected typed registries view used by `build_ir`.
pub(crate) struct IrSnapshot {
pub types: BTreeMap<&'static str, NamedType>,
pub types: BTreeMap<String, NamedType>,
pub functions: Vec<&'static dyn FunctionSpec>,
pub contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)>,
/// Types that inline to a `TypeShape` at every reference site rather
/// than emitting as their own `type "X" { ... }` entry. Populated from
/// `Alias(Primitive(_))` and `Enum` named types — both are
/// information-zero indirections that the codegen consumer doesn't
/// gain anything from naming. Matches the Python emitter's behavior
/// (Pydantic `FigureId = str` and `Literal["..."]` inline; they don't
/// materialize as named types).
pub channels: Vec<ChannelRecord>,
/// Types that inline to a `TypeShape` at every reference site rather than
/// emitting a `type "X" { ... }` entry: `Alias(Primitive(_))` and `Enum`,
/// both of which carry no structure a named entry would add.
pub inlines: BTreeMap<&'static str, TypeShape>,
}
impl IrSnapshot {
pub(crate) fn collect() -> Self {
// Types: alphabetized for byte-equivalence with Python's `sorted(named_types)`.
// Types: alphabetized, which is the document's canonical ordering.
let mut all_types: BTreeMap<&'static str, NamedType> = BTreeMap::new();
for entry in TYPES {
all_types.insert(entry.name, (entry.shape_fn)());
}
// Partition into emit-candidate types vs inlines. An inline is a
// named type whose shape collapses to a single `TypeShape` at the
// field site — primitive aliases and string enums.
// Partition into emit-candidate types vs inlines.
let mut candidates: BTreeMap<&'static str, NamedType> = BTreeMap::new();
let mut inlines: BTreeMap<&'static str, TypeShape> = BTreeMap::new();
for (name, body) in all_types {
match &body {
NamedType::Alias(TypeShape::Primitive(p)) => {
inlines.insert(name, TypeShape::Primitive(*p));
}
match body {
NamedType::Enum(variants) => {
inlines.insert(name, TypeShape::Enum(variants.clone()));
inlines.insert(name, TypeShape::Enum(variants));
}
_ => {
candidates.insert(name, body);
NamedType::Alias(TypeShape::Primitive(p)) => {
inlines.insert(name, TypeShape::Primitive(p));
}
NamedType::Alias(shape) => {
candidates.insert(name, NamedType::Alias(shape));
}
NamedType::Struct(fields) => {
candidates.insert(name, NamedType::Struct(fields));
}
}
}
// Tree-shake: keep only types reachable from a registered function's
// input/output. The function macro registers canonical-named
// entries (e.g. `userPrefsOutput`); derive registers original-named
// entries (`UserPrefs`, `BrushSettings`, …). Only those reached
// via Ref-walk from a function's input/output names belong in the
// emitted IR. Mirrors Python's `_collect_named_types`.
// Channels: alphabetical by wire name, each declared slot's type
// named `<Pascal><Slot>`. The slot shapes enter the type section
// directly, so they are emitted whether or not a function reaches
// them.
let mut channel_entries: Vec<&'static crate::registry::ChannelEntry> =
CHANNELS.iter().collect();
channel_entries.sort_by_key(|c| c.name);
let mut channels: Vec<ChannelRecord> = Vec::new();
let mut channel_types: Vec<(String, NamedType)> = Vec::new();
for entry in channel_entries {
let pascal_name = wire_to_pascal(entry.name);
let mut slots: Vec<ChannelSlotRecord> = Vec::new();
for slot in entry.slots {
let type_name = format!("{pascal_name}{}", slot.kind.type_suffix());
channel_types.push((type_name.clone(), (slot.shape_fn)()));
slots.push(ChannelSlotRecord {
kind: slot.kind,
type_name,
});
}
channels.push(ChannelRecord {
name: entry.name,
pascal_name,
slots,
});
}
// Roots of the tree-shake: every non-private function's input and
// output name, plus every name a channel slot's shape refs.
let mut reachable: std::collections::HashSet<&'static str> =
std::collections::HashSet::new();
let mut frontier: Vec<&'static str> = Vec::new();
for fn_spec in FUNCTIONS {
if fn_spec.private() {
continue;
}
if let Some(input_name) = fn_spec.input_type() {
if reachable.insert(input_name) {
frontier.push(input_name);
}
}
let output_name = fn_spec.output_type();
if reachable.insert(output_name) {
frontier.push(output_name);
reachable.insert(input_name);
}
reachable.insert(fn_spec.output_type());
}
while let Some(name) = frontier.pop() {
// Inlines don't carry refs we care about (Primitive/Enum); skip.
if inlines.contains_key(name) {
continue;
}
let body = match candidates.get(name) {
Some(b) => b.clone(),
None => continue,
};
collect_refs(&body, &mut |r| {
if reachable.insert(r) {
frontier.push(r);
}
for (_, body) in &channel_types {
collect_refs(body, &mut |r| {
reachable.insert(r);
});
}
let types: BTreeMap<&'static str, NamedType> = candidates
// Grow the set until a pass adds nothing: a candidate contributes the
// names it refs once it is itself reachable.
loop {
let mut grew = false;
for (name, body) in &candidates {
if reachable.contains(name) {
collect_refs(body, &mut |r| {
grew |= reachable.insert(r);
});
}
}
if !grew {
break;
}
}
let mut types: BTreeMap<String, NamedType> = candidates
.into_iter()
.filter(|(name, _)| reachable.contains(name))
.map(|(name, body)| (name.to_string(), body))
.collect();
types.extend(channel_types);
// Functions: alphabetical by wire name (canonical IR ordering,
// matches the Python emitter's `sorted(functions)`). Skip `private`.
// Functions: alphabetical by wire name. Skip `private`.
let mut functions: Vec<&'static dyn FunctionSpec> = FUNCTIONS
.iter()
.copied()
@@ -416,8 +491,8 @@ impl IrSnapshot {
.collect();
functions.sort_by_key(|f| f.name());
// Contexts: alphabetical by name (canonical IR ordering), each with
// its members sorted alphabetically too.
// Contexts: alphabetical by name, each with its members sorted
// alphabetically too.
let mut context_names: Vec<&'static str> = CONTEXTS.iter().map(|c| c.name).collect();
context_names.sort();
let mut contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)> = Vec::new();
@@ -437,6 +512,7 @@ impl IrSnapshot {
types,
functions,
contexts,
channels,
inlines,
}
}
@@ -457,7 +533,7 @@ fn collect_refs<F: FnMut(&'static str)>(body: &NamedType, visit: &mut F) {
fn walk_shape_refs<F: FnMut(&'static str)>(shape: &TypeShape, visit: &mut F) {
match shape {
TypeShape::Ref(name) => visit(name),
TypeShape::Ref { name, .. } => visit(name),
TypeShape::List(inner) | TypeShape::Optional(inner) => walk_shape_refs(inner, visit),
TypeShape::Union(branches) => {
for b in branches {
@@ -468,41 +544,41 @@ fn walk_shape_refs<F: FnMut(&'static str)>(shape: &TypeShape, visit: &mut F) {
}
}
/// Build the Mizan IR for every registered type/function/context. Returns KDL.
/// Build the Mizan IR for every registered type, function, context and
/// channel. Returns KDL.
pub fn build_ir() -> String {
crate::graph_check::verify_invariants();
let snap = IrSnapshot::collect();
let mut em = Emitter::new(&snap.inlines);
let builder = NodeBuilder {
inlines: &snap.inlines,
};
// Type definitions
let types_emitted = !snap.types.is_empty();
for (name, body) in &snap.types {
em.emit_named_type(0, name, body);
}
if types_emitted {
em.blank();
}
let sections: Vec<Vec<KdlNode>> = [
snap.types
.iter()
.map(|(name, body)| builder.named_type(0, name, body))
.collect::<Vec<_>>(),
snap.functions
.iter()
.map(|fn_spec| builder.function(0, *fn_spec))
.collect(),
snap.contexts
.iter()
.map(|(ctx_name, members)| builder.context(0, ctx_name, members))
.collect(),
snap.channels
.iter()
.map(|channel| builder.channel(0, channel))
.collect(),
]
.into_iter()
.filter(|section: &Vec<KdlNode>| !section.is_empty())
.collect();
// Functions
let fns_emitted = !snap.functions.is_empty();
for fn_spec in &snap.functions {
em.emit_function(0, *fn_spec);
}
if fns_emitted {
em.blank();
}
// Contexts
let ctxs_emitted = !snap.contexts.is_empty();
for (ctx_name, members) in &snap.contexts {
em.emit_context(0, ctx_name, members);
}
if ctxs_emitted {
em.blank();
}
// Future: channels — once channel registry lands on the Rust side.
em.into_string()
let mut env = Environment::new();
env.add_filter("kdl", render_kdl_value);
env.template_from_named_str("ir.kdl", IR_TEMPLATE)
.expect("compile templates/ir.kdl.jinja")
.render(context! { sections })
.expect("render templates/ir.kdl.jinja")
}

View File

@@ -1,15 +1,14 @@
//! Mizan server-side IR substrate. Rust analog of `cores/mizan-python/src/mizan_core/`.
//! Mizan server-side IR substrate.
//!
//! Three load-bearing concerns:
//!
//! 1. **IR data model + KDL emitter.** `build_ir()` produces byte-equivalent
//! KDL to the Python emitter. Both backends emit the same contract.
//! 1. **IR data model + KDL emitter.** `build_ir()` renders the registries as
//! one Mizan IR document.
//! 2. **Compile-time registry.** Proc macros from `mizan-macros` populate
//! linkme distributed slices (`TYPES`, `CONTEXTS`, `FUNCTIONS`) at the
//! consumer crate's expansion sites.
//! linkme distributed slices (`TYPES`, `CONTEXTS`, `FUNCTIONS`, `CHANNELS`)
//! at the consumer crate's expansion sites.
//! 3. **Runtime helpers.** `compute_invalidation` / `compute_merges` /
//! `lookup_function` ported from `mizan-fastapi`'s executor; the HTTP
//! adapter calls these per request.
//! `function_named` / `context_members`, which the adapters call per request.
//!
//! Consumers `use mizan_core::prelude::*;` and alias the crate as `mizan` at
//! their call sites so authored code reads `#[mizan::context]` / `#[mizan(...)]`.
@@ -22,12 +21,13 @@ pub mod runtime;
pub mod traits;
pub use ir::{
AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape,
AffectTarget, ChannelSlotKind, DefaultValue, NamedType, Primitive, StructField, Transport,
TypeShape,
};
pub use kdl::{build_ir, snake_to_camel};
pub use kdl::{build_ir, snake_to_camel, wire_to_pascal};
pub use registry::{
context_members, lookup_context, lookup_function, ContextEntry, TypeEntry, CONTEXTS,
FUNCTIONS, TYPES,
context_members, function_named, ChannelEntry, ChannelSlot, ContextEntry, TypeEntry, CHANNELS,
CONTEXTS, FUNCTIONS, TYPES,
};
pub use runtime::{
compute_invalidation, compute_merges, InvalidationTarget, MergeEntry, MizanError,
@@ -35,21 +35,20 @@ pub use runtime::{
};
pub use traits::{ContextMarker, FunctionSpec, InputParam, MizanType};
// Re-export proc macros so consumers depend on one crate.
pub use mizan_macros::{client, context, Mizan};
pub use mizan_macros::{channel, client, context, Mizan};
pub mod prelude {
pub use crate::ir::{
AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape,
AffectTarget, ChannelSlotKind, DefaultValue, NamedType, Primitive, StructField, Transport,
TypeShape,
};
pub use crate::registry::{ContextEntry, TypeEntry};
pub use crate::registry::{ChannelEntry, ChannelSlot, ContextEntry, TypeEntry};
pub use crate::runtime::{MizanError, RequestHandle};
pub use crate::traits::{ContextMarker, FunctionSpec, InputParam, MizanType};
pub use mizan_macros::Mizan;
}
/// Internal re-exports used by `mizan-macros`-generated code. Not part of
/// the public API — consumers must not depend on names under `__priv`.
/// The crates `mizan-macros` expansions name by absolute path.
#[doc(hidden)]
pub mod __priv {
pub use linkme;

View File

@@ -2,7 +2,7 @@
//! source via linkme. The proc macros emit `#[linkme::distributed_slice(...)]`
//! statics that land here at link time.
use crate::ir::NamedType;
use crate::ir::{ChannelSlotKind, NamedType};
use crate::traits::FunctionSpec;
use linkme::distributed_slice;
@@ -17,6 +17,21 @@ pub struct ContextEntry {
pub name: &'static str,
}
/// One declared payload slot of a channel. `shape_fn` yields the shape the
/// slot's type emits under its derived name.
pub struct ChannelSlot {
pub kind: ChannelSlotKind,
pub shape_fn: fn() -> NamedType,
}
/// One channel registration. Emitted by `#[mizan::channel]`. `slots` carries
/// only the slots the channel declares, ordered params, client-message,
/// server-message.
pub struct ChannelEntry {
pub name: &'static str,
pub slots: &'static [ChannelSlot],
}
#[distributed_slice]
pub static TYPES: [TypeEntry] = [..];
@@ -26,18 +41,21 @@ pub static CONTEXTS: [ContextEntry] = [..];
#[distributed_slice]
pub static FUNCTIONS: [&'static dyn FunctionSpec] = [..];
/// Find a registered function by wire name. Used by the HTTP adapter.
pub fn lookup_function(name: &str) -> Option<&'static dyn FunctionSpec> {
FUNCTIONS.iter().copied().find(|f| f.name() == name)
#[distributed_slice]
pub static CHANNELS: [ChannelEntry] = [..];
/// The functions registered under `name`. Order matches `FUNCTIONS` iteration
/// order — i.e., registration order.
pub fn function_named(name: &str) -> Vec<&'static dyn FunctionSpec> {
FUNCTIONS
.iter()
.copied()
.filter(|f| f.name() == name)
.collect()
}
/// Find a registered context by name. Used by graph_check.
pub fn lookup_context(name: &str) -> Option<&'static ContextEntry> {
CONTEXTS.iter().find(|c| c.name == name)
}
/// All functions that declare a given context as their `context` membership.
/// Order matches `FUNCTIONS` iteration order — i.e., registration order.
/// The functions that declare `ctx_name` as their `context` membership. Order
/// matches `FUNCTIONS` iteration order — i.e., registration order.
pub fn context_members(ctx_name: &str) -> Vec<&'static dyn FunctionSpec> {
FUNCTIONS
.iter()

View File

@@ -1,40 +1,41 @@
//! Runtime helpers — error envelope, request handle, invalidation/merge
//! resolution. Ports `compute_invalidation` / `compute_merges` /
//! `_resolve_merge_slot` / `_scoped_params` from
//! `backends/mizan-fastapi/src/mizan_fastapi/executor.py:189-263`.
//! Runtime helpers — error envelope, request handle, and the per-response
//! invalidation / merge resolution the adapters call after a dispatch.
use crate::registry::context_members;
use crate::traits::FunctionSpec;
use serde_json::Value;
use std::any::Any;
/// Type-erased handle to the framework's request object. The HTTP adapter
/// stuffs its native `Request` here; user code casts back via the adapter's
/// helper types.
/// A borrow of the request object a hosting framework owns.
///
/// `FUNCTIONS` is a non-generic `distributed_slice`, so `FunctionSpec` has to
/// be object-safe and no type parameter can reach this handle. The reference
/// therefore rides erased, and the crate that names the framework's own type
/// is the one that casts back to it.
#[derive(Clone)]
pub struct RequestHandle<'a> {
pub inner: &'a (dyn Any + Send + Sync),
inner: &'a (dyn Any + Send + Sync),
}
impl<'a> RequestHandle<'a> {
/// Wrap a typed reference. The most common path — handlers downcast back
/// to `T` via `downcast::<T>()`.
/// Wrap a typed reference.
pub fn new<T: Any + Send + Sync>(req: &'a T) -> Self {
Self { inner: req }
}
/// Wrap an already-erased `dyn Any` reference. Used by HTTP adapters
/// that thread an `Arc<dyn Any + Send + Sync>` app state in.
/// Wrap a reference the caller has already erased.
pub fn from_dyn(req: &'a (dyn Any + Send + Sync)) -> Self {
Self { inner: req }
}
pub fn downcast<T: Any + Send + Sync>(&self) -> Option<&'a T> {
self.inner.downcast_ref::<T>()
/// The reference the adapter installed.
pub fn installed(&self) -> &'a (dyn Any + Send + Sync) {
self.inner
}
}
/// Mizan's standard error envelope. Mirrors FastAPI's MizanError enum.
/// Mizan's standard error envelope — the closed set of failures an adapter
/// renders onto the wire.
#[derive(Debug, Clone)]
pub enum MizanError {
NotFound(String),
@@ -186,59 +187,28 @@ pub fn compute_invalidation(
.collect()
}
/// Build the `merge` list from a function's `merge` metadata. Each entry
/// names the slot inside the context bundle the return value lands in.
/// Build the `merge` list from the function's already-resolved merge entries.
/// Each names the slot inside the context bundle the return value lands in.
pub fn compute_merges(
fn_spec: &dyn FunctionSpec,
args: &serde_json::Map<String, Value>,
result: &Value,
) -> Vec<MergeEntry> {
let targets = fn_spec.merge();
if targets.is_empty() {
return Vec::new();
}
let mutation_output = fn_spec.output_type();
let mut out = Vec::new();
for ctx_name in targets {
let slot = match resolve_merge_slot(ctx_name, mutation_output) {
Some(s) => s,
None => continue,
};
let scoped = scoped_params(ctx_name, args);
out.push(MergeEntry {
context: (*ctx_name).into(),
slot,
value: result.clone(),
params: if scoped.is_empty() {
None
} else {
Some(scoped)
},
});
}
out
}
/// Find the unique function-name slot whose Output type matches the
/// mutation's Output type. Matches Python's `types_match_for_merge` —
/// structural shape comparison, not name comparison. Returns None on no
/// match or ambiguous match.
fn resolve_merge_slot(context_name: &str, mutation_output: &str) -> Option<String> {
let mutation_shape = crate::graph_check::resolve_type_shape(mutation_output)?;
let mut matches: Vec<&'static str> = Vec::new();
for fn_spec in context_members(context_name) {
if let Some(candidate_shape) = crate::graph_check::resolve_type_shape(fn_spec.output_type())
{
if crate::graph_check::types_match(&candidate_shape, &mutation_shape) {
matches.push(fn_spec.name());
crate::graph_check::merges_for(fn_spec.name())
.map(|resolved| {
let scoped = scoped_params(resolved.context, args);
MergeEntry {
context: resolved.context.into(),
slot: resolved.slot.into(),
value: result.clone(),
params: if scoped.is_empty() {
None
} else {
Some(scoped)
},
}
}
}
if matches.len() == 1 {
Some(matches[0].into())
} else {
None
}
})
.collect()
}
/// Match input args against the context's declared Input field names.
@@ -258,3 +228,36 @@ fn scoped_params(
.collect()
}
#[cfg(test)]
mod tests {
use super::RequestHandle;
use std::any::Any;
fn installed_addr(handle: &RequestHandle<'_>) -> *const () {
handle.installed() as *const (dyn Any + Send + Sync) as *const ()
}
#[test]
fn a_handle_installs_the_very_reference_it_was_built_over() {
let state = String::from("app-state");
let source = &state as *const String as *const ();
assert_eq!(installed_addr(&RequestHandle::new(&state)), source);
}
#[test]
fn an_erased_handle_installs_what_a_typed_one_does() {
let state = String::from("app-state");
assert_eq!(
installed_addr(&RequestHandle::from_dyn(&state)),
installed_addr(&RequestHandle::new(&state))
);
}
#[test]
fn the_installed_reference_keeps_the_type_it_was_built_over() {
let state = String::from("app-state");
let handle = RequestHandle::new(&state);
assert!(handle.installed().is::<String>());
assert!(!handle.installed().is::<i64>());
}
}

View File

@@ -1,4 +1,4 @@
//! Surface traits the proc macros implement.
//! The traits a registered Mizan type, context and function implement.
use crate::ir::{AffectTarget, NamedType, Transport};
use crate::runtime::{MizanError, RequestHandle};
@@ -6,11 +6,10 @@ use serde_json::Value;
use std::future::Future;
use std::pin::Pin;
/// A type that participates in the Mizan IR. Generated by `#[derive(Mizan)]`.
/// A type that participates in the Mizan IR.
///
/// `TYPE_NAME` is a `const` (not a function) so it's usable in `static`
/// initializers — TypeEntry's `name` field reads it directly without an
/// init-time function call.
/// `TYPE_NAME` is a `const` rather than a function so it can be named from a
/// `static` initializer.
pub trait MizanType {
const TYPE_NAME: &'static str;
fn shape() -> NamedType;
@@ -20,21 +19,22 @@ pub trait MizanType {
}
}
/// A marker type for a Mizan context. Generated by `#[mizan::context]`.
/// A marker type carrying one context's wire name.
pub trait ContextMarker {
const NAME: &'static str;
}
/// One Mizan-registered function. Generated by `#[mizan(...)]` on async fns.
///
/// Everything here is plain data except `dispatch`, which is the type-erased
/// runtime entry point used by the HTTP adapter.
/// One Mizan-registered function: plain data throughout except `dispatch`.
pub trait FunctionSpec: Send + Sync {
fn name(&self) -> &'static str;
fn camel_name(&self) -> &'static str;
fn has_input(&self) -> bool;
fn input_type(&self) -> Option<&'static str>;
fn output_type(&self) -> &'static str;
/// The shape registered under `output_type()`.
fn output_shape(&self) -> NamedType;
fn output_nullable(&self) -> bool {
false
}
@@ -63,16 +63,14 @@ pub trait FunctionSpec: Send + Sync {
None
}
/// Field-shape description of this function's Input parameters, used by
/// the context builder to compute shared-param elevation. Empty when
/// `has_input()` is false.
/// This function's Input parameters. Empty when `has_input()` is false.
fn input_params(&self) -> &'static [InputParam] {
&[]
}
/// Type-erased dispatch. The HTTP adapter calls this with deserialized
/// JSON arguments; the macro-generated impl deserializes into the
/// function's typed input, awaits the body, and serializes the result.
/// Deserializes `args` into this function's typed input, awaits the body,
/// and serializes the result — the whole call with its types erased behind
/// JSON.
fn dispatch<'a>(
&'a self,
req: RequestHandle<'a>,
@@ -80,10 +78,7 @@ pub trait FunctionSpec: Send + Sync {
) -> Pin<Box<dyn Future<Output = Result<Value, MizanError>> + Send + 'a>>;
}
/// One parameter of a function's synthesized Input. The macro emits a static
/// slice of these so the context builder can find shared params across
/// context members and produce the `context { param ... shared-by ... }`
/// section of the IR.
/// One parameter of a function's synthesized Input.
#[derive(Debug, Clone, Copy)]
pub struct InputParam {
pub name: &'static str,

View File

@@ -0,0 +1,6 @@
{% macro node(n) %}{{ n.indent }}{{ n.name }}{% for a in n.args %} {{ a|kdl }}{% endfor %}{% for p in n.props %} {{ p.name }}={{ p.value|kdl }}{% endfor %}{% if n.block %} {
{% for c in n.children %}{{ node(c) }}{% endfor %}{{ n.indent }}}
{% else %}
{% endif %}{% endmacro %}
{%- for section in sections %}{% for n in section %}{{ node(n) }}{% endfor %}{% if not loop.last %}
{% endif %}{% endfor %}

View File

@@ -1,11 +1,8 @@
//! Byte-equivalence: the Rust KDL emitter (driven by the proc macros)
//! against `protocol/mizan-codegen/tests/fixtures/afi_ir.kdl` (canonical
//! Python-emitted reference).
//!
//! This is the Phase-2 verifier — the AFI fixture is authored against the
//! real consumer surface (`#[derive(Mizan)] / #[mizan::context] /
//! #[mizan::client]`), not hand-built static specs.
//! `build_ir()` renders the proc-macro-populated registries; the emitted KDL
//! is parsed by the `kdl` crate and then compared byte for byte with
//! `protocol/mizan-codegen/tests/fixtures/afi_ir.kdl`.
use kdl::{KdlDocument, KdlNode};
use mizan_core as mizan;
use mizan_core::prelude::*;
use mizan_core::RequestHandle;
@@ -46,7 +43,17 @@ pub struct StatusOutput {
#[mizan::context("user")]
pub struct UserCtx;
// ─── Fixture functions (mirroring tests/afi/fixture.py) ────────────────────
// ─── Fixture handlers ───────────────────────────────────────────────────────
/// `(order id, owning user id, total)` — the store the order handlers read.
const ORDERS: &[(i64, i64, i64)] = &[(10, 1, 4200), (11, 1, 1750), (12, 2, 990)];
fn profile_of(user_id: i64) -> ProfileOutput {
ProfileOutput {
user_id,
name: format!("user-{user_id}"),
}
}
#[mizan::client]
pub async fn echo(_req: &RequestHandle<'_>, text: String) -> EchoOutput {
@@ -65,29 +72,39 @@ pub async fn whoami(_req: &RequestHandle<'_>) -> WhoamiOutput {
#[mizan::client(context = UserCtx)]
pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput {
ProfileOutput {
user_id,
name: "placeholder".into(),
}
profile_of(user_id)
}
#[mizan::client(context = UserCtx)]
pub async fn user_orders(_req: &RequestHandle<'_>, _user_id: i64) -> Vec<OrderOutput> {
vec![]
pub async fn user_orders(_req: &RequestHandle<'_>, user_id: i64) -> Vec<OrderOutput> {
ORDERS
.iter()
.filter(|(_, owner, _)| *owner == user_id)
.map(|(id, owner, total)| OrderOutput {
id: *id,
user_id: *owner,
total: *total,
})
.collect()
}
#[mizan::client(affects = UserCtx)]
pub async fn update_profile(
_req: &RequestHandle<'_>,
_user_id: i64,
_name: String,
user_id: i64,
name: String,
) -> StatusOutput {
StatusOutput { ok: true }
StatusOutput {
ok: user_id > 0 && !name.trim().is_empty(),
}
}
#[mizan::client]
pub async fn find_user(_req: &RequestHandle<'_>, _user_id: i64) -> Option<ProfileOutput> {
None
pub async fn find_user(_req: &RequestHandle<'_>, user_id: i64) -> Option<ProfileOutput> {
ORDERS
.iter()
.any(|(_, owner, _)| *owner == user_id)
.then(|| profile_of(user_id))
}
#[mizan::client(merge = UserCtx)]
@@ -99,20 +116,96 @@ pub async fn rename_user(
ProfileOutput { user_id, name }
}
// ─── The byte-equivalence test ──────────────────────────────────────────────
// ─── Reading the parsed document ────────────────────────────────────────────
fn canonical_kdl_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../protocol/mizan-codegen/tests/fixtures/afi_ir.kdl")
}
/// The node's first string argument, or the empty string when it has none.
fn label(node: &KdlNode) -> String {
for entry in node.entries() {
if let Some(s) = entry.value().as_string() {
return s.to_string();
}
}
String::new()
}
/// `(node name, first string argument)` for every node at one level.
fn index(nodes: &[KdlNode]) -> Vec<(String, String)> {
nodes
.iter()
.map(|node| (node.name().value().to_string(), label(node)))
.collect()
}
/// The child nodes of the first `kind "name"` node in `doc`, or an empty slice
/// when the document has no such node or it carries no child block.
fn children_of<'a>(doc: &'a KdlDocument, kind: &str, name: &str) -> &'a [KdlNode] {
for node in doc.nodes() {
if node.name().value() == kind && label(node) == name {
return match node.children() {
Some(block) => block.nodes(),
None => &[],
};
}
}
&[]
}
#[test]
fn build_ir_matches_canonical_afi_kdl() {
let expected = std::fs::read_to_string(canonical_kdl_path()).expect("read canonical KDL");
let actual = mizan_core::build_ir();
let emitted = mizan_core::build_ir();
if actual != expected {
for (lineno, (a, b)) in actual.lines().zip(expected.lines()).enumerate() {
// Parsing before comparing means a malformed emission fails here rather
// than as a confusing textual diff.
let parsed: KdlDocument = emitted
.parse()
.expect("build_ir() output is a well-formed KDL document");
let top = index(parsed.nodes());
assert!(
top.contains(&("function".to_string(), "user_orders".to_string())),
"parsed document is missing the user_orders function node: {top:?}",
);
assert!(
top.contains(&("context".to_string(), "user".to_string())),
"parsed document is missing the user context node: {top:?}",
);
assert_eq!(
index(children_of(&parsed, "function", "user_orders")),
vec![
("camel".to_string(), "userOrders".to_string()),
("has-input".to_string(), String::new()),
("input".to_string(), "userOrdersInput".to_string()),
("output".to_string(), "userOrdersOutput".to_string()),
("transport".to_string(), "http".to_string()),
("context".to_string(), "user".to_string()),
],
);
assert_eq!(
index(children_of(&parsed, "context", "user")),
vec![
("function".to_string(), "user_orders".to_string()),
("function".to_string(), "user_profile".to_string()),
("param".to_string(), "user_id".to_string()),
],
);
let expected = std::fs::read_to_string(canonical_kdl_path()).expect("read canonical KDL");
let canonical: KdlDocument = expected
.parse()
.expect("the canonical fixture is a well-formed KDL document");
assert_eq!(
index(parsed.nodes()),
index(canonical.nodes()),
"emitted and canonical documents declare different top-level nodes",
);
if emitted != expected {
for (lineno, (a, b)) in emitted.lines().zip(expected.lines()).enumerate() {
if a != b {
panic!(
"KDL diverges at line {}:\n expected: {b:?}\n actual: {a:?}",
@@ -122,7 +215,7 @@ fn build_ir_matches_canonical_afi_kdl() {
}
panic!(
"KDL diverges in length: actual_len={} expected_len={}",
actual.len(),
emitted.len(),
expected.len(),
);
}

View File

@@ -0,0 +1,68 @@
//! `verify_invariants()` over a graph where one `merge` declaration matches
//! two members of the context it names and another matches none.
use mizan_core as mizan;
use mizan_core::graph_check::verify_invariants;
use mizan_core::prelude::*;
use mizan_core::RequestHandle;
use serde::{Deserialize, Serialize};
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct Profile {
pub user_id: i64,
pub name: String,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct Status {
pub ok: bool,
}
#[mizan::context("user")]
pub struct UserCtx;
#[mizan::client(context = UserCtx)]
pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> Profile {
Profile {
user_id,
name: format!("user-{user_id}"),
}
}
/// Same output shape as `user_profile`.
#[mizan::client(context = UserCtx)]
pub async fn user_card(_req: &RequestHandle<'_>, user_id: i64) -> Profile {
Profile {
user_id,
name: format!("card-{user_id}"),
}
}
#[mizan::client(merge = UserCtx)]
pub async fn rename_user(_req: &RequestHandle<'_>, user_id: i64, name: String) -> Profile {
Profile { user_id, name }
}
/// No member of `user` returns this shape.
#[mizan::client(merge = UserCtx)]
pub async fn mark_seen(_req: &RequestHandle<'_>, user_id: i64) -> Status {
Status { ok: user_id > 0 }
}
#[test]
#[should_panic(expected = "Merge resolution needs exactly one match")]
fn a_merge_matching_several_members_is_ambiguous() {
verify_invariants();
}
#[test]
#[should_panic(expected = "user_card")]
fn an_ambiguous_merge_names_every_candidate_member() {
verify_invariants();
}
#[test]
#[should_panic(expected = "no member of that context has output type")]
fn a_merge_matching_no_member_has_no_slot() {
verify_invariants();
}

View File

@@ -0,0 +1,113 @@
//! `compute_merges` over a graph registered through `#[derive(Mizan)]`,
//! `#[mizan::context]` and `#[mizan::client]`.
use mizan_core as mizan;
use mizan_core::prelude::*;
use mizan_core::{compute_merges, RequestHandle, FUNCTIONS};
use serde::{Deserialize, Serialize};
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct ProfileOutput {
pub user_id: i64,
pub name: String,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct StatusOutput {
pub ok: bool,
}
#[mizan::context("user")]
pub struct UserCtx;
#[mizan::client(context = UserCtx)]
pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput {
ProfileOutput {
user_id,
name: format!("user-{user_id}"),
}
}
#[mizan::client(merge = UserCtx)]
pub async fn rename_user(
_req: &RequestHandle<'_>,
user_id: i64,
name: String,
) -> ProfileOutput {
ProfileOutput { user_id, name }
}
#[mizan::client(affects = UserCtx)]
pub async fn touch_user(_req: &RequestHandle<'_>, user_id: i64) -> StatusOutput {
StatusOutput { ok: user_id > 0 }
}
/// The handlers above register into `FUNCTIONS` inside this test binary, so a
/// name they declare always lands.
fn spec(name: &str) -> &'static dyn FunctionSpec {
for fn_spec in FUNCTIONS.iter().copied() {
if fn_spec.name() == name {
return fn_spec;
}
}
panic!("no registered function named `{name}`");
}
/// `user_id` is a declared param of the `user` context; `name` is not.
fn args() -> serde_json::Map<String, serde_json::Value> {
let mut args = serde_json::Map::new();
args.insert("user_id".to_string(), serde_json::Value::from(7));
args.insert("name".to_string(), serde_json::Value::from("Renamed"));
args
}
fn renamed() -> serde_json::Value {
serde_json::json!({ "user_id": 7, "name": "Renamed" })
}
#[test]
fn a_merge_declaration_resolves_to_the_context_member_sharing_its_output() {
let result = renamed();
let merges = compute_merges(spec("rename_user"), &args(), &result);
let [entry] = merges.as_slice() else {
panic!(
"rename_user declares one merge; got {} entries",
merges.len()
);
};
assert_eq!(entry.context, "user");
assert_eq!(entry.slot, "user_profile");
assert_eq!(entry.value, result);
}
#[test]
fn a_merge_entry_is_scoped_by_the_contexts_declared_params_alone() {
let result = renamed();
let merges = compute_merges(spec("rename_user"), &args(), &result);
let [entry] = merges.as_slice() else {
panic!(
"rename_user declares one merge; got {} entries",
merges.len()
);
};
let mut expected = serde_json::Map::new();
expected.insert("user_id".to_string(), serde_json::Value::from(7));
assert_eq!(entry.params, Some(expected));
}
#[test]
fn a_function_declaring_only_affects_produces_no_merge_entries() {
let result = serde_json::json!({ "ok": true });
assert!(compute_merges(spec("touch_user"), &args(), &result).is_empty());
}
#[test]
fn merge_resolution_answers_identically_across_calls() {
let result = renamed();
let first = compute_merges(spec("rename_user"), &args(), &result);
let second = compute_merges(spec("rename_user"), &args(), &result);
let slots: Vec<&str> = first.iter().map(|e| e.slot.as_str()).collect();
let again: Vec<&str> = second.iter().map(|e| e.slot.as_str()).collect();
assert_eq!(slots, again);
assert_eq!(slots, vec!["user_profile"]);
}