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:
139
cores/mizan-rust/Cargo.lock
generated
139
cores/mizan-rust/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
6
cores/mizan-rust/templates/ir.kdl.jinja
Normal file
6
cores/mizan-rust/templates/ir.kdl.jinja
Normal 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 %}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
68
cores/mizan-rust/tests/merge_defects.rs
Normal file
68
cores/mizan-rust/tests/merge_defects.rs
Normal 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();
|
||||
}
|
||||
113
cores/mizan-rust/tests/merge_resolution.rs
Normal file
113
cores/mizan-rust/tests/merge_resolution.rs
Normal 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"]);
|
||||
}
|
||||
Reference in New Issue
Block a user