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

@@ -6,11 +6,14 @@ description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-ag
requires-python = ">=3.10"
dependencies = [
"PyJWT>=2.0",
"jinja2>=3.1",
"pydantic>=2.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ckdl>=1.0",
]
[build-system]

View File

@@ -1,32 +1,36 @@
"""
Cache backends — MemoryCache (testing) and RedisCache (production).
Simple key-value stores. No reverse indexes. Cache keys are derived
from HMAC, so scoped purge just recomputes the key and deletes it.
Broad purge uses key-prefix scan (rare operation).
"""
"""Cache backends — a key/value store keyed by the derived HMAC cache key."""
from __future__ import annotations
from typing import Protocol
import abc
class CacheBackend(Protocol):
"""Interface that all Mizan cache backends implement."""
class CacheBackend(abc.ABC):
"""A key/value store holding serialized context payloads."""
def get(self, key: str) -> bytes | None: ...
def set(self, key: str, value: bytes) -> None: ...
def delete(self, key: str) -> bool: ...
def delete_by_prefix(self, prefix: str) -> int: ...
def clear(self) -> None: ...
@abc.abstractmethod
def get(self, key: str) -> bytes | None:
"""The stored value for `key`, or None when absent."""
@abc.abstractmethod
def set(self, key: str, value: bytes) -> None:
"""Store `value` under `key`, replacing anything already there."""
@abc.abstractmethod
def delete(self, key: str) -> bool:
"""Drop `key`. True if it was present."""
@abc.abstractmethod
def delete_by_prefix(self, prefix: str) -> int:
"""Drop every key starting with `prefix`. Returns how many were dropped."""
@abc.abstractmethod
def clear(self) -> None:
"""Drop every key this backend owns."""
class MemoryCache:
"""
In-memory cache backend for testing.
Uses a Python dict. No persistence, no cross-process sharing.
"""
class MemoryCache(CacheBackend):
"""A process-local dict. No persistence, no cross-process sharing."""
def __init__(self) -> None:
self._store: dict[str, bytes] = {}
@@ -53,13 +57,8 @@ class MemoryCache:
self._store.clear()
class RedisCache:
"""
Redis-backed cache backend for production.
Simple GET/SET/DEL. No reverse indexes. Scoped purge recomputes
the HMAC key and deletes directly. Broad purge uses SCAN.
"""
class RedisCache(CacheBackend):
"""Redis GET/SET/UNLINK behind a key namespace, with SCAN for prefix drops."""
DEFAULT_TTL = 86400 # 24h safety-net
@@ -71,11 +70,11 @@ class RedisCache:
) -> None:
try:
import redis as redis_lib
except ImportError:
except ImportError as exc:
raise ImportError(
"Redis is required for Mizan's cache backend. "
"Install it with: pip install mizan[cache]"
)
) from exc
self._client = redis_lib.from_url(
redis_url,
socket_connect_timeout=5,

View File

@@ -1,12 +1,4 @@
"""
Cache key derivation — HMAC-SHA256 over JSON-canonical form.
Protocol-critical: every Mizan adapter must produce identical output
for identical inputs. Cross-language conformance verified by pin tests.
Scoped purge recomputes the key directly — no reverse index needed.
Broad purge uses a context prefix scan.
"""
"""Cache key derivation — HMAC-SHA256 over a canonical JSON form."""
from __future__ import annotations
@@ -15,7 +7,6 @@ import hmac
import json
from typing import Any
# Context prefix for broad purge (SCAN pattern)
CONTEXT_KEY_PREFIX = "ctx:"
@@ -33,8 +24,9 @@ def derive_cache_key(
broad purge can SCAN by prefix "ctx:{context}:*".
"""
def _normalize(v: Any) -> str:
"""Normalize values for cross-language HMAC consistency.
Python str(True)="True" but JS String(true)="true". Use JSON-native forms."""
"""Render a param value in its JSON-native spelling.
Python str(True) is "True" but JS String(true) is "true", and the two
must hash identically."""
if v is True:
return "true"
if v is False:

View File

@@ -1,16 +1,14 @@
"""
mizan Server Functions - Core Primitive
Server functions: the `@client` decorator and the `ServerFunction` class it
produces, `ReactContext` for grouping them, and `compose` for combining
contexts into one provider.
Server functions are the core primitive. Everything else builds on them.
Two styles supported:
1. Function-based (recommended, Django Ninja style):
@client("update-profile")
def update_profile(request, input: UpdateProfileInput) -> UpdateProfileOutput:
Function form:
@client
def update_profile(request, name: str) -> UpdateProfileOutput:
return UpdateProfileOutput(success=True)
2. Class-based (for complex cases):
Class form:
class UpdateProfile(ServerFunction):
def call(self, input: UpdateProfileInput) -> UpdateProfileOutput:
return UpdateProfileOutput(success=True)
@@ -21,7 +19,6 @@ from __future__ import annotations
import asyncio
import inspect
import warnings
from abc import ABC, abstractmethod
from typing import (
Any,
@@ -30,24 +27,16 @@ from typing import (
Generic,
Literal,
TypeVar,
Union,
get_args,
get_origin,
get_type_hints,
)
from pydantic import BaseModel
# ─── Framework-response-base hook ───────────────────────────────────────────
#
# View-path detection — distinguishing functions that return data (RPC path)
# from functions that return a framework-native response object (view path) —
# requires knowing the framework's response base class. Each backend adapter
# registers its base class here at import time.
#
# Django sets this to django.http.HttpResponseBase. FastAPI would set it to
# starlette.responses.Response. If unset, all functions are treated as RPC.
# needs the framework's response base class, which only the backend adapter
# knows. While it is unset, every function is treated as RPC.
_framework_response_base: type | None = None
@@ -101,7 +90,7 @@ class ReactContext:
return f"ReactContext({self.name!r})"
# Built-in global context (auto-mounted at root, SSR-hydrated)
# The context named 'global', pre-made so callers share one instance of it.
GlobalContext = ReactContext("global")
@@ -209,7 +198,7 @@ class ServerFunction(ABC, Generic[TInput, TOutput]):
class _FunctionWrapper(ServerFunction):
"""Internal wrapper that makes a plain function behave like a ServerFunction."""
# Will be set per-wrapper instance
# Set per-wrapper subclass by _create_server_function
_wrapped_fn: ClassVar[Callable]
_input_cls: ClassVar[type[BaseModel] | None]
_output_cls: ClassVar[type[BaseModel]]
@@ -284,12 +273,6 @@ def _resolve_context(context: ContextMode) -> str | Literal[False]:
if isinstance(context, str):
if not context.strip():
raise ValueError("context must be a non-empty string, ReactContext, or False.")
if context == "local":
warnings.warn(
"context='local' is deprecated. Use ReactContext('name') instead.",
DeprecationWarning,
stacklevel=3,
)
return context
raise ValueError(
f"context must be a ReactContext, a string, or False. Got {type(context).__name__}."
@@ -327,7 +310,6 @@ def client(
context: Named context for React state management.
- False (default): Not a context, just a callable function.
- ReactContext instance: groups functions into a named context.
- GlobalContext: reserved, auto-mounted at root, SSR-hydrated.
affects: Declare which contexts or functions this mutation invalidates.
Mutually exclusive with context=.
@@ -526,8 +508,8 @@ def _create_server_function(
is_view_path = is_framework_response(output_type)
if is_view_path:
# View path — no Pydantic output wrapping needed
output_cls = BaseModel # placeholder, never used for serialization
# A view path serializes nothing, so Output is never read off this class.
output_cls = BaseModel
is_primitive_output = False
else:
# RPC path — resolve output type
@@ -555,7 +537,7 @@ def _create_server_function(
FunctionWrapper._output_cls = output_cls
FunctionWrapper._is_primitive_output = is_primitive_output
# Set Input/Output class attributes for compatibility
# Input/Output are the names the ServerFunction contract exposes them under
if input_cls is not None:
FunctionWrapper.Input = input_cls
FunctionWrapper.Output = output_cls
@@ -615,8 +597,8 @@ def _create_server_function(
# Always assign a fresh dict to prevent shared-dict mutation across classes
FunctionWrapper._meta = {**meta}
# Note: Registration happens via discovery (mizan_clients), not here.
# This allows the decorator to be used without import-time side effects.
# Registration happens via discovery (mizan_clients), so the decorator has
# no import-time side effects.
return FunctionWrapper
@@ -657,15 +639,14 @@ class ComposedContext:
"leaves": [leaf.name for leaf in leaves],
}
@classmethod
def get_schema_export(cls) -> dict[str, Any]:
def get_schema_export(self) -> dict[str, Any]:
"""Export schema for TypeScript generation."""
return {
"name": cls.name,
"name": self.name,
"type": "compose",
"meta": cls._meta,
"children": cls._meta.get("children", []),
"leaves": cls._meta.get("leaves", []),
"meta": self._meta,
"children": self._meta["children"],
"leaves": self._meta["leaves"],
}
@@ -676,7 +657,6 @@ def _get_leaves(item) -> list[type[ServerFunction]]:
elif isinstance(item, ComposedContext):
return item._leaves.copy()
elif hasattr(item, "_leaves"):
# Duck typing for composed contexts
return item._leaves.copy()
else:
raise TypeError(f"Expected ServerFunction or ComposedContext, got {type(item)}")
@@ -714,21 +694,18 @@ def compose(
- True: Bundled call over WebSocket. All children must have websocket=True.
Usage:
@client(context='local')
UserContext = ReactContext('user')
@client(context=UserContext)
def user_profile(request, user_id: int) -> ProfileOutput: ...
@client(context='local')
@client(context=UserContext)
def user_posts(request, user_id: int) -> PostsOutput: ...
@compose(user_profile, user_posts)
def user_page():
pass
# Frontend generates:
# <UserPageProvider user_id={123}>
# <App />
# </UserPageProvider>
Nesting:
@compose(ctx_a, ctx_b)
def ab(): pass
@@ -767,7 +744,7 @@ def compose(
if id(leaf) in seen:
raise ValueError(
f"Duplicate context '{leaf.name}' in @compose({name}). "
f"Each context can only appear once. Use named kwargs for reuse (future feature)."
f"Each context appears at most once across the flattened children."
)
seen.add(id(leaf))

View File

@@ -1,37 +1,37 @@
"""
Mizan IR — KDL emission from the live `mizan_core.registry`.
`build_ir()` walks every registered function class, introspects its
Pydantic Input/Output models directly (not via JSON-Schema), and emits
KDL — the canonical Mizan protocol IR. Every backend adapter exposes
this via a backend-specific entry point (Django management command,
FastAPI CLI, mizan-ts equivalent); every codegen target consumes this.
`build_ir()` walks every registered function class, introspects its Pydantic
Input/Output models directly (not via JSON-Schema), computes a plain-data
document, and renders it through `templates/ir/document.kdl.j2`.
KDL grammar — locked contract:
KDL grammar:
type "<Name>" {
struct {
field "<name>" required=#true|#false default=<lit> {
primitive "integer|number|boolean|string"
| ref "<TypeName>"
| list { <type-child> }
| optional { <type-child> }
| enum "<v1>" "<v2>" ...
field "<name>" required=#false default=<lit> {
<type-child>
}
...
}
| list { <type-child> }
| enum "<v1>" "<v2>" ...
| alias { <type-child> }
}
<type-child> =
primitive "integer|number|boolean|string"
| ref "<TypeName>"
| enum "<v1>" "<v2>" ...
| list { <type-child> }
| optional { <type-child> }
| union { <type-child> ... }
function "<wire_name>" {
camel "<camelCase>"
has-input #true|#false
input "<TypeName>" // omitted if has-input=#false
output "<TypeName>"
output-nullable #true|#false // omitted when #false (default)
transport "http"|"websocket"|"both"
output-nullable #true // omitted when #false (default)
transport "http"|"websocket"
context "<ctx_name>" // omitted unless context-grouped
affects "<ctx_name>" // 0..N occurrences
merge "<ctx_name>" // 0..N occurrences
@@ -51,303 +51,174 @@ KDL grammar — locked contract:
}
}
channel "<name>" {
channel "<wire_name>" {
pascal-name "<PascalCase>"
params "<TypeName>" // omitted if no params
react-message "<TypeName>" // omitted if no react message
django-message "<TypeName>" // omitted if no django message
params "<TypeName>" // omitted when the channel takes no params
client-message "<TypeName>" // client -> server; omitted if none
server-message "<TypeName>" // server -> client; omitted if none
}
Nothing else lives in the IR. OpenAPI envelope, JSON-Schema $ref dance,
the Pydantic→json-schema converter — all gone.
Channel slots are named from the client's point of view — `client-message`
travels up, `server-message` travels down — and their type names are
`<Pascal>Params`, `<Pascal>ClientMessage` and `<Pascal>ServerMessage`, where
`<Pascal>` comes from `wire_to_pascal`. Backends that publish channel types
into their own schema documents call `wire_to_pascal` rather than deriving a
second Pascal form.
"""
from __future__ import annotations
import re
import types
from typing import Any, Literal, Union, get_args, get_origin
from pydantic import BaseModel
from jinja2 import Environment, PackageLoader, StrictUndefined
from pydantic import BaseModel, create_model
from pydantic_core import PydanticUndefined
from mizan_core.registry import get_all_functions, get_context_groups, get_function
from mizan_core.registry import (
get_all_functions,
get_context_groups,
get_function,
get_registry,
)
from mizan_core.type_utils import extract_list_element, extract_optional
__all__ = ["build_ir"]
__all__ = ["build_ir", "wire_to_pascal"]
# Common user-identity param names; mirrors the equivalent in mizan-django /
# mizan-fastapi schema-export logic.
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
# ─── Wire-name derivations ──────────────────────────────────────────────────
# ─── KDL value formatting ───────────────────────────────────────────────────
def wire_to_pascal(wire_name: str) -> str:
"""The PascalCase stem every emitted type name for `wire_name` is built on."""
return "".join(part.title() for part in re.split(r"[._-]", wire_name))
def _kdl_string(s: str) -> str:
"""KDL-escape a string and wrap in quotes."""
# ─── KDL value encoding ─────────────────────────────────────────────────────
def _kdl(value: str) -> str:
"""KDL-escape a string and wrap it in quotes."""
escaped = (
s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
value.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
return f'"{escaped}"'
def _kdl_bool(b: bool) -> str:
return "#true" if b else "#false"
def _kdlbool(value: bool) -> str:
return "#true" if value else "#false"
def _kdl_value(v: Any) -> str:
"""Render a JSON-shape Python value as a KDL literal."""
if v is None:
return "#null"
if v is True or v is False:
return _kdl_bool(v)
if isinstance(v, (int, float)):
return repr(v)
if isinstance(v, str):
return _kdl_string(v)
# Fallback for compound values — defaults aren't typed in our IR.
import json
return _kdl_string(json.dumps(v))
_ENV = Environment(
loader=PackageLoader("mizan_core", "templates"),
undefined=StrictUndefined,
keep_trailing_newline=True,
trim_blocks=True,
lstrip_blocks=True,
)
_ENV.filters["kdl"] = _kdl
_ENV.filters["kdlbool"] = _kdlbool
# ─── KDL Builder ────────────────────────────────────────────────────────────
def _default_literal(value: Any) -> dict[str, Any] | None:
"""Tag a field default so the template can pick its KDL literal form.
A KDL entry value is a scalar, so the literal forms are exactly bool,
number and string. A default of any other shape — a nested model, a list,
a dict, an enum member — has no scalar form and yields `None`: the field
emits `required=#false` with no `default`, and the server-side Pydantic
model stays the authority for the value it fills in.
"""
if value is True or value is False:
return {"kind": "bool", "value": value}
if isinstance(value, (int, float)):
return {"kind": "number", "value": value}
if isinstance(value, str):
return {"kind": "string", "value": value}
return None
class _Block:
"""Open-children context for a KDL node. Tracks indent level."""
__slots__ = ("lines", "indent")
def __init__(self, lines: list[str], indent: int):
self.lines = lines
self.indent = indent
def _prefix(self) -> str:
return " " * self.indent
def node(self, name: str, *args: str, **props: str) -> "_OpenNode":
"""Open a node. `args` are positional KDL args; `props` are key=value pairs."""
return _OpenNode(self.lines, self.indent, name, list(args), dict(props))
def leaf(self, name: str, *args: str, **props: str) -> None:
"""Emit a leaf node — no children block."""
parts = [name]
parts.extend(args)
for k, v in props.items():
parts.append(f"{k}={v}")
self.lines.append(f"{self._prefix()}{' '.join(parts)}")
# ─── Type shapes ────────────────────────────────────────────────────────────
class _OpenNode:
"""A KDL node whose children are being built."""
def _shape(annotation: Any, refs: list[type[BaseModel]]) -> dict[str, Any]:
"""Reduce a Python annotation to a shape tree, appending every model it
references to `refs`."""
inner, is_optional = extract_optional(annotation)
if is_optional:
return {"kind": "optional", "of": _shape(inner, refs)}
def __init__(
self,
lines: list[str],
indent: int,
name: str,
args: list[str],
props: dict[str, str],
):
self.lines = lines
self.indent = indent
self.name = name
self.args = args
self.props = props
self._children_emitted = False
def __enter__(self) -> _Block:
parts = [self.name]
parts.extend(self.args)
for k, v in self.props.items():
parts.append(f"{k}={v}")
self.lines.append(f"{' ' * self.indent}{' '.join(parts)} {{")
self._children_emitted = True
return _Block(self.lines, self.indent + 1)
def __exit__(self, *_exc: Any) -> None:
if self._children_emitted:
self.lines.append(f"{' ' * self.indent}}}")
# ─── Type emission ──────────────────────────────────────────────────────────
def _emit_type_child(block: _Block, annotation: Any, named_types: dict[str, Any]) -> None:
"""Emit the type-shape KDL for a Python annotation, recursing as needed."""
# Strip Optional[T] → emit `optional` wrapper.
inner, is_opt = extract_optional(annotation)
if is_opt:
with block.node("optional") as inner_block:
_emit_type_child(inner_block, inner, named_types)
return
# Multi-arm union (T | U) — emit `union { <each-branch> }`.
origin = get_origin(annotation)
if origin is Union or isinstance(annotation, types.UnionType):
branches = [a for a in get_args(annotation) if a is not type(None)]
if len(branches) > 1:
with block.node("union") as inner_block:
for branch in branches:
_emit_type_child(inner_block, branch, named_types)
return
return {
"kind": "union",
"branches": [_shape(branch, refs) for branch in branches],
}
# list[T] / tuple[T, ...] / set[T] / frozenset[T] → `list { ... }`
elem = extract_list_element(annotation)
if elem is not None:
with block.node("list") as inner_block:
_emit_type_child(inner_block, elem, named_types)
return
element = extract_list_element(annotation)
if element is not None:
return {"kind": "list", "of": _shape(element, refs)}
# Literal[a, b, c] → enum
if origin is Literal:
args = get_args(annotation)
if all(isinstance(a, str) for a in args):
quoted = " ".join(_kdl_string(a) for a in args)
block.lines.append(f"{block._prefix()}enum {quoted}")
return
values = get_args(annotation)
if all(isinstance(v, str) for v in values):
return {"kind": "enum", "values": list(values)}
# Pydantic model → reference by name.
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
type_name = annotation.__name__
named_types.setdefault(type_name, _StructShape(annotation))
block.leaf("ref", _kdl_string(type_name))
return
refs.append(annotation)
return {"kind": "ref", "name": annotation.__name__}
# Primitives
if annotation is int:
block.leaf("primitive", _kdl_string("integer"))
return
return {"kind": "primitive", "name": "integer"}
if annotation is float:
block.leaf("primitive", _kdl_string("number"))
return
return {"kind": "primitive", "name": "number"}
if annotation is bool:
block.leaf("primitive", _kdl_string("boolean"))
return
if annotation is str:
block.leaf("primitive", _kdl_string("string"))
return
return {"kind": "primitive", "name": "boolean"}
# Open-shape fallback (dict / Any / etc).
block.leaf("primitive", _kdl_string("string"))
# str, dict, Any and every other open shape collapse to string.
return {"kind": "primitive", "name": "string"}
def _emit_alias_type(block: _Block, annotation: Any, named_types: dict[str, Any]) -> None:
"""Emit `type "X" { alias { <type-child> } }` for a non-struct wrapper."""
with block.node("alias") as alias_block:
_emit_type_child(alias_block, annotation, named_types)
def _struct_fields(
model: type[BaseModel], refs: list[type[BaseModel]]
) -> list[dict[str, Any]]:
fields: list[dict[str, Any]] = []
for field_name, field_info in model.model_fields.items():
# `is_required()` covers both the explicit Required marker and the
# presence of a default.
required = field_info.is_required()
default = field_info.default
has_default = (
not required
and default is not None
and default is not PydanticUndefined
and default is not ...
)
fields.append(
{
"name": field_name,
"required": required,
"default": _default_literal(default) if has_default else None,
"shape": _shape(field_info.annotation, refs),
}
)
return fields
def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[str, Any]) -> None:
"""Emit a `struct { field ... }` block for a Pydantic model."""
with block.node("struct") as struct_block:
for field_name, field_info in model.model_fields.items():
props: dict[str, str] = {}
# `field_info.is_required()` checks both the explicit Required
# marker and the presence of a default.
required = field_info.is_required()
if not required:
props["required"] = _kdl_bool(False)
default = field_info.default
if default is not None and default is not PydanticUndefined and default is not ...:
props["default"] = _kdl_value(default)
with struct_block.node("field", _kdl_string(field_name), **props) as field_block:
_emit_type_child(field_block, field_info.annotation, named_types)
# ─── Named types ────────────────────────────────────────────────────────────
class _StructShape:
"""A Pydantic BaseModel that emits as `type "X" { struct { ... } }`."""
__slots__ = ("model",)
def __init__(self, model: type[BaseModel]):
self.model = model
class _AliasShape:
"""A named alias wrapper — e.g. `<CamelName>Output = list[<Inner>]`."""
__slots__ = ("annotation",)
def __init__(self, annotation: Any):
self.annotation = annotation
def _collect_named_types(functions: dict[str, Any]) -> dict[str, Any]:
"""First pass: collect every named type the IR's `function` section references.
Two kinds:
- Pydantic BaseModels seen anywhere in Input/Output traversal — emit
as `type "X" { struct { ... } }`.
- Function-output wrapper aliases (`<CamelName>Output = list[T]` /
`<CamelName>Output = T | None`) — emit as `type "X" { alias { ... } }`
so the consumer has a single named type to reference.
"""
seen: dict[str, Any] = {}
def visit_model(model: type[BaseModel]) -> None:
if model.__name__ in seen:
return
seen[model.__name__] = _StructShape(model)
for field_info in model.model_fields.values():
for nested in _nested_models(field_info.annotation):
visit_model(nested)
def visit_annotation(ann: Any) -> None:
for nested in _nested_models(ann):
visit_model(nested)
for fn_class in functions.values():
input_cls = getattr(fn_class, "Input", None)
if _has_input(input_cls):
input_named = _name_input_model(fn_class)
visit_model(input_named)
output_cls = getattr(fn_class, "Output", None)
if output_cls is None:
continue
camel = _snake_to_camel(fn_class.name)
output_name = f"{camel}Output"
inner, _ = extract_optional(output_cls)
elem = extract_list_element(inner)
if elem is not None:
# `list[T]` (possibly wrapped in Optional) — emit a list alias.
# Visit the element type so its struct shape gets emitted too.
visit_annotation(output_cls)
if output_name not in seen:
seen[output_name] = _AliasShape(output_cls)
elif isinstance(inner, type) and issubclass(inner, BaseModel):
# `<Model>` or `Optional[<Model>]` — emit the model under the
# canonical name (rename if necessary).
output_named = _name_output_model(fn_class, inner)
visit_model(output_named)
# If the Optional wrapper differs from the bare model, emit an
# alias under the canonical output name too.
if output_named.__name__ != output_name:
seen.setdefault(output_name, _AliasShape(output_cls))
else:
# Primitive-wrapped output (`result: int`) — emit as alias.
seen.setdefault(output_name, _AliasShape(output_cls))
return seen
def _nested_models(annotation: Any) -> list[type[BaseModel]]:
"""All Pydantic models that appear anywhere inside `annotation`."""
out: list[type[BaseModel]] = []
inner, _ = extract_optional(annotation)
elem = extract_list_element(inner)
if elem is not None:
out.extend(_nested_models(elem))
return out
if isinstance(inner, type) and issubclass(inner, BaseModel):
out.append(inner)
return out
def _snake_to_camel(name: str) -> str:
parts = name.replace(".", "_").replace("-", "_").split("_")
return parts[0] + "".join(p.title() for p in parts[1:] if p)
def _has_input(input_cls: Any) -> bool:
@@ -359,206 +230,135 @@ def _has_input(input_cls: Any) -> bool:
)
def _snake_to_camel(name: str) -> str:
parts = name.replace(".", "_").replace("-", "_").split("_")
return parts[0] + "".join(p.title() for p in parts[1:] if p)
def _name_input_model(fn_class: Any) -> type[BaseModel]:
"""Return a copy of the function's Input model named `<CamelName>Input`."""
from pydantic import create_model
camel = _snake_to_camel(fn_class.name)
canonical = f"{camel}Input"
"""The function's Input model under the canonical `<CamelName>Input` name."""
canonical = f"{_snake_to_camel(fn_class.name)}Input"
src = fn_class.Input
if src.__name__ == canonical:
return src
# Re-derive under the canonical name so codegen consumers see a stable name.
return create_model(canonical, __base__=src)
def _name_output_model(fn_class: Any, base: type[BaseModel]) -> type[BaseModel]:
"""Return a copy of the model named `<CamelName>Output`."""
from pydantic import create_model
camel = _snake_to_camel(fn_class.name)
canonical = f"{camel}Output"
"""`base` under the canonical `<CamelName>Output` name."""
canonical = f"{_snake_to_camel(fn_class.name)}Output"
if base.__name__ == canonical:
return base
return create_model(canonical, __base__=base)
# ─── Function / context / channel emission ──────────────────────────────────
def _bind(
sources: dict[str, tuple[str, Any]], name: str, source: tuple[str, Any]
) -> None:
"""Claim `name` for one declaration. Two different declarations under one
name would emit two `type` blocks that no `ref` can tell apart, so the
second claim raises."""
claimed = sources.setdefault(name, source)
if claimed != source:
raise ValueError(
f"named type '{name}' is claimed twice, by {claimed[1]!r} "
f"and by {source[1]!r}"
)
def _function_props(fn_class: Any, output_type_name: str, output_nullable: bool) -> dict[str, Any]:
"""Collect every value that goes inside a `function` block."""
def _seed_named_types(
functions: dict[str, Any], channel_models: list[tuple[str, type[BaseModel]]]
) -> dict[str, tuple[str, Any]]:
"""Name → ("struct", model) | ("alias", annotation) for every type the
function and channel sections reference directly."""
seeds: dict[str, tuple[str, Any]] = {}
for fn_class in functions.values():
if _has_input(getattr(fn_class, "Input", None)):
named_input = _name_input_model(fn_class)
_bind(seeds, named_input.__name__, ("struct", named_input))
output_cls = getattr(fn_class, "Output", None)
if output_cls is None:
continue
output_name = f"{_snake_to_camel(fn_class.name)}Output"
inner, _ = extract_optional(output_cls)
wraps_model = (
extract_list_element(inner) is None
and isinstance(inner, type)
and issubclass(inner, BaseModel)
)
if wraps_model:
_bind(seeds, output_name, ("struct", _name_output_model(fn_class, inner)))
else:
_bind(seeds, output_name, ("alias", output_cls))
for type_name, model in channel_models:
_bind(seeds, type_name, ("struct", model))
return seeds
def _resolve_named_types(seeds: dict[str, tuple[str, Any]]) -> list[dict[str, Any]]:
"""Resolve seeds to a fixed point — resolving one type discovers the models
it references, which are themselves resolved — then order by name."""
sources = dict(seeds)
resolved: dict[str, dict[str, Any]] = {}
while True:
unresolved = [name for name in sources if name not in resolved]
if not unresolved:
break
for name in unresolved:
kind, payload = sources[name]
refs: list[type[BaseModel]] = []
if kind == "struct":
resolved[name] = {
"name": name,
"kind": "struct",
"fields": _struct_fields(payload, refs),
}
else:
resolved[name] = {
"name": name,
"kind": "alias",
"shape": _shape(payload, refs),
}
for model in refs:
_bind(sources, model.__name__, ("struct", model))
return [resolved[name] for name in sorted(resolved)]
# ─── Functions, contexts, channels ──────────────────────────────────────────
def _is_emitted(fn_class: Any) -> bool:
meta = getattr(fn_class, "_meta", {})
name = fn_class.name
camel = _snake_to_camel(name)
input_cls = getattr(fn_class, "Input", None)
has_input = _has_input(input_cls)
is_context = meta.get("context")
is_form = meta.get("form", False)
return not (meta.get("private") or meta.get("view_path"))
def _function_entry(fn_class: Any) -> dict[str, Any]:
meta = getattr(fn_class, "_meta", {})
camel = _snake_to_camel(fn_class.name)
has_input = _has_input(getattr(fn_class, "Input", None))
_, output_nullable = extract_optional(getattr(fn_class, "Output", None))
context = meta.get("context")
return {
"name": name,
"name": fn_class.name,
"camel": camel,
"has_input": has_input,
"input_type": f"{camel}Input" if has_input else None,
"output_type": output_type_name,
"output_type": f"{camel}Output",
"output_nullable": output_nullable,
"transport": "websocket" if meta.get("websocket") else "http",
"context": is_context if isinstance(is_context, str) else None,
"affects": [a["name"] for a in meta.get("affects") or [] if a.get("type") == "context"],
"context": context if isinstance(context, str) else None,
"affects": [
a["name"] for a in meta.get("affects") or [] if a.get("type") == "context"
],
"merge": list(meta.get("merge") or []),
"is_form": bool(is_form),
"is_form": bool(meta.get("form", False)),
"form_name": meta.get("form_name"),
"form_role": meta.get("form_role"),
}
def _resolve_output(fn_class: Any) -> tuple[str, bool]:
"""Return `(output_type_name, output_nullable)` for an emitted function block."""
camel = _snake_to_camel(fn_class.name)
canonical = f"{camel}Output"
output_cls = getattr(fn_class, "Output", None)
if output_cls is None:
return canonical, False
_, nullable = extract_optional(output_cls)
return canonical, nullable
def _collect_channels() -> list[dict[str, Any]]:
"""Pull channel registrations from the optional `channels` registry extension."""
from mizan_core.registry import _extensions # type: ignore[attr-defined]
ext = _extensions.get("channels")
if ext is None:
return []
schema = ext.schema()
return list(schema or [])
# ─── Top-level builder ──────────────────────────────────────────────────────
def build_ir() -> str:
"""Build the Mizan IR for every registered function. Returns KDL source."""
functions = get_all_functions()
context_groups = get_context_groups()
channels = _collect_channels()
named_types = _collect_named_types(functions)
lines: list[str] = []
root = _Block(lines, indent=0)
# ── Type definitions ──
for type_name in sorted(named_types):
shape = named_types[type_name]
with root.node("type", _kdl_string(type_name)) as type_block:
if isinstance(shape, _StructShape):
_emit_struct_type(type_block, shape.model, named_types)
elif isinstance(shape, _AliasShape):
_emit_alias_type(type_block, shape.annotation, named_types)
else:
raise TypeError(f"unknown named-type shape: {type(shape).__name__}")
if named_types:
lines.append("")
# ── Functions ──
# Alphabetical by wire name — the IR is a canonical contract, not a
# transcript of registration order. Both Python and Rust emitters sort
# so byte-equivalence holds across language-backed backends.
for fn_name in sorted(functions):
fn_class = functions[fn_name]
meta = getattr(fn_class, "_meta", {})
if meta.get("private") or meta.get("view_path"):
continue
output_type_name, output_nullable = _resolve_output(fn_class)
props = _function_props(fn_class, output_type_name, output_nullable)
_emit_function(root, props)
if functions:
lines.append("")
# ── Contexts ──
# Alphabetical by context name — same reason as functions above.
for ctx_name in sorted(context_groups):
_emit_context(root, ctx_name, context_groups[ctx_name])
if context_groups:
lines.append("")
# ── Channels ──
for channel in channels:
_emit_channel(root, channel)
# Trim trailing blanks then add a single terminating newline.
while lines and not lines[-1]:
lines.pop()
return "\n".join(lines) + "\n"
def _emit_function(root: _Block, props: dict[str, Any]) -> None:
with root.node("function", _kdl_string(props["name"])) as block:
block.leaf("camel", _kdl_string(props["camel"]))
block.leaf("has-input", _kdl_bool(props["has_input"]))
if props["input_type"]:
block.leaf("input", _kdl_string(props["input_type"]))
block.leaf("output", _kdl_string(props["output_type"]))
if props["output_nullable"]:
block.leaf("output-nullable", _kdl_bool(True))
block.leaf("transport", _kdl_string(props["transport"]))
if props["context"]:
block.leaf("context", _kdl_string(props["context"]))
for affect_name in props["affects"]:
block.leaf("affects", _kdl_string(affect_name))
for merge_name in props["merge"]:
block.leaf("merge", _kdl_string(merge_name))
if props["is_form"]:
block.leaf("is-form", _kdl_bool(True))
if props["form_name"]:
block.leaf("form-name", _kdl_string(props["form_name"]))
if props["form_role"]:
block.leaf("form-role", _kdl_string(props["form_role"]))
def _emit_context(root: _Block, ctx_name: str, fn_names: list[str]) -> None:
# First pass: collect param info across every function in the context.
param_info: dict[str, dict[str, Any]] = {}
for fn_name in fn_names:
fn_class = get_function(fn_name)
if fn_class is None:
continue
input_cls = getattr(fn_class, "Input", None)
if not _has_input(input_cls):
continue
for param_name, field_info in input_cls.model_fields.items():
slot = param_info.setdefault(param_name, {"type": None, "shared_by": []})
slot["type"] = _annotation_to_primitive(field_info.annotation)
slot["shared_by"].append(fn_name)
# A param is required iff every function in the context declares it.
for slot in param_info.values():
slot["required"] = len(slot["shared_by"]) == len(fn_names)
with root.node("context", _kdl_string(ctx_name)) as block:
# Members alphabetical — canonical order.
for fn_name in sorted(fn_names):
block.leaf("function", _kdl_string(fn_name))
for param_name in sorted(param_info):
slot = param_info[param_name]
with block.node("param", _kdl_string(param_name)) as param_block:
param_block.leaf("type", _kdl_string(slot["type"]))
param_block.leaf("required", _kdl_bool(slot["required"]))
# `shared-by` follows the same canonical ordering.
for sharer in sorted(slot["shared_by"]):
param_block.leaf("shared-by", _kdl_string(sharer))
def _annotation_to_primitive(annotation: Any) -> str:
inner, _ = extract_optional(annotation)
if inner is int:
@@ -570,13 +370,95 @@ def _annotation_to_primitive(annotation: Any) -> str:
return "string"
def _emit_channel(root: _Block, channel: dict[str, Any]) -> None:
name = channel["name"]
with root.node("channel", _kdl_string(name)) as block:
block.leaf("pascal-name", _kdl_string(channel["pascalName"]))
if channel.get("hasParams") and channel.get("paramsType"):
block.leaf("params", _kdl_string(channel["paramsType"]))
if channel.get("hasReactMessage") and channel.get("reactMessageType"):
block.leaf("react-message", _kdl_string(channel["reactMessageType"]))
if channel.get("hasDjangoMessage") and channel.get("djangoMessageType"):
block.leaf("django-message", _kdl_string(channel["djangoMessageType"]))
def _context_entry(ctx_name: str, fn_names: list[str]) -> dict[str, Any]:
param_info: dict[str, dict[str, Any]] = {}
for fn_name in fn_names:
input_cls = getattr(get_function(fn_name), "Input", None)
if not _has_input(input_cls):
continue
for param_name, field_info in input_cls.model_fields.items():
slot = param_info.setdefault(param_name, {"shared_by": []})
slot["type"] = _annotation_to_primitive(field_info.annotation)
slot["shared_by"].append(fn_name)
return {
"name": ctx_name,
"functions": sorted(fn_names),
"params": [
{
"name": param_name,
"type": param_info[param_name]["type"],
# A param is required iff every function in the context takes it.
"required": len(param_info[param_name]["shared_by"]) == len(fn_names),
"shared_by": sorted(param_info[param_name]["shared_by"]),
}
for param_name in sorted(param_info)
],
}
_CHANNEL_SLOTS = (
("Params", "params"),
("ClientMessage", "client_message"),
("ServerMessage", "server_message"),
)
def _collect_channels() -> tuple[
list[dict[str, Any]], list[tuple[str, type[BaseModel]]]
]:
"""Channel blocks in wire-name order, plus the (emitted type name, model)
pair each declared slot resolves to."""
channel_classes = get_registry().get("channels", {})
records: list[dict[str, Any]] = []
models: list[tuple[str, type[BaseModel]]] = []
for wire_name in sorted(channel_classes):
channel_class = channel_classes[wire_name]
pascal = wire_to_pascal(wire_name)
record: dict[str, Any] = {"name": wire_name, "pascal_name": pascal}
for attribute, slot in _CHANNEL_SLOTS:
declared = getattr(channel_class, attribute, None)
if declared is None:
record[slot] = None
continue
if not (isinstance(declared, type) and issubclass(declared, BaseModel)):
raise TypeError(
f"channel '{wire_name}' declares {attribute} as {declared!r}, "
f"which is not a pydantic BaseModel subclass"
)
type_name = f"{pascal}{attribute}"
record[slot] = type_name
models.append((type_name, declared))
records.append(record)
return records, models
# ─── Top-level builder ──────────────────────────────────────────────────────
def build_ir() -> str:
"""Build the Mizan IR for every registered function. Returns KDL source.
An empty registry renders the empty document — zero bytes, zero nodes.
"""
functions = get_all_functions()
context_groups = get_context_groups()
channels, channel_models = _collect_channels()
# Every section is sorted by name, so the document does not depend on the
# order things were registered in.
return _ENV.get_template("ir/document.kdl.j2").render(
types=_resolve_named_types(_seed_named_types(functions, channel_models)),
functions=[
_function_entry(functions[name])
for name in sorted(functions)
if _is_emitted(functions[name])
],
contexts=[
_context_entry(name, context_groups[name])
for name in sorted(context_groups)
],
channels=channels,
)

View File

@@ -1,17 +1,12 @@
"""
Mizan core registry — function and composition registration with an
extension hook for backend-specific registries (channels, forms, etc.)
to plug into.
This is the framework-agnostic registry. Backends own their own
type-specific registries (channels in Django Channels, forms in Django
Forms, websockets in FastAPI, etc.) and register them as extensions
here so the unified schema export can include them.
Mizan core registry — function and composition registration, plus an
extension hook backend-specific registries (channels, forms, …) plug into.
"""
from __future__ import annotations
from typing import Any, Callable, Protocol
import abc
from typing import Any, Callable
# ─── Core registries ────────────────────────────────────────────────────────
@@ -22,17 +17,23 @@ _compositions: dict[str, Any] = {}
# ─── Extension hook ─────────────────────────────────────────────────────────
class RegistryExtension(Protocol):
class RegistryExtension(abc.ABC):
"""
Backend-specific registries plug into core via this Protocol.
Each extension owns its own registry of backend-shaped registrations
(channels, forms, websocket consumers, etc.) and contributes a schema
subdict to the unified schema export.
A backend registry of its own registrations (channels, forms, websocket
consumers, …) contributing one subdict to the unified schema export.
"""
def schema(self) -> dict[str, Any]: ...
def clear(self) -> None: ...
@abc.abstractmethod
def all(self) -> dict[str, Any]:
"""The live registry: registered name → registered class."""
@abc.abstractmethod
def schema(self) -> dict[str, Any]:
"""The schema subdict exported under this extension's name."""
@abc.abstractmethod
def clear(self) -> None:
"""Drop every registration held by this extension."""
_extensions: dict[str, RegistryExtension] = {}
@@ -146,10 +147,7 @@ def get_registry() -> dict[str, Any]:
"compositions": _compositions.copy(),
}
for name, ext in _extensions.items():
# Extensions optionally expose their backing dict via .all()
# (Protocol doesn't require it; only schema() and clear() are mandatory)
if hasattr(ext, "all"):
out[name] = ext.all()
out[name] = ext.all()
return out

View File

@@ -0,0 +1,121 @@
{% macro literal(lit) %}
{%- if lit.kind == "string" -%}
{{ lit.value | kdl }}
{%- elif lit.kind == "bool" -%}
{{ lit.value | kdlbool }}
{%- else -%}
{{ lit.value }}
{%- endif %}
{%- endmacro %}
{% macro shape(node, depth) %}
{%- set pad = " " * depth %}
{%- if node.kind == "primitive" %}
{{ pad }}primitive {{ node.name | kdl }}
{%- elif node.kind == "ref" %}
{{ pad }}ref {{ node.name | kdl }}
{%- elif node.kind == "enum" %}
{{ pad }}enum {{ node["values"] | map("kdl") | join(" ") }}
{%- elif node.kind == "list" %}
{{ pad }}list {
{{ shape(node.of, depth + 1) }}
{{ pad }}}
{%- elif node.kind == "optional" %}
{{ pad }}optional {
{{ shape(node.of, depth + 1) }}
{{ pad }}}
{%- elif node.kind == "union" %}
{{ pad }}union {
{% for branch in node.branches %}
{{ shape(branch, depth + 1) }}
{% endfor %}
{{ pad }}}
{%- endif %}
{%- endmacro %}
{% for type in types %}
type {{ type.name | kdl }} {
{% if type.kind == "struct" %}
struct {
{% for field in type.fields %}
field {{ field.name | kdl }}{% if not field.required %} required={{ field.required | kdlbool }}{% endif %}{% if field.default %} default={{ literal(field.default) }}{% endif %} {
{{ shape(field.shape, 3) }}
}
{% endfor %}
}
{% else %}
alias {
{{ shape(type.shape, 2) }}
}
{% endif %}
}
{% endfor %}
{% if types and (functions or contexts or channels) %}
{% endif %}
{% for fn in functions %}
function {{ fn.name | kdl }} {
camel {{ fn.camel | kdl }}
has-input {{ fn.has_input | kdlbool }}
{% if fn.input_type %}
input {{ fn.input_type | kdl }}
{% endif %}
output {{ fn.output_type | kdl }}
{% if fn.output_nullable %}
output-nullable {{ fn.output_nullable | kdlbool }}
{% endif %}
transport {{ fn.transport | kdl }}
{% if fn.context %}
context {{ fn.context | kdl }}
{% endif %}
{% for affected in fn.affects %}
affects {{ affected | kdl }}
{% endfor %}
{% for merged in fn.merge %}
merge {{ merged | kdl }}
{% endfor %}
{% if fn.is_form %}
is-form {{ fn.is_form | kdlbool }}
{% if fn.form_name %}
form-name {{ fn.form_name | kdl }}
{% endif %}
{% if fn.form_role %}
form-role {{ fn.form_role | kdl }}
{% endif %}
{% endif %}
}
{% endfor %}
{% if functions and (contexts or channels) %}
{% endif %}
{% for context in contexts %}
context {{ context.name | kdl }} {
{% for fn_name in context.functions %}
function {{ fn_name | kdl }}
{% endfor %}
{% for param in context.params %}
param {{ param.name | kdl }} {
type {{ param.type | kdl }}
required {{ param.required | kdlbool }}
{% for sharer in param.shared_by %}
shared-by {{ sharer | kdl }}
{% endfor %}
}
{% endfor %}
}
{% endfor %}
{% if contexts and channels %}
{% endif %}
{% for channel in channels %}
channel {{ channel.name | kdl }} {
pascal-name {{ channel.pascal_name | kdl }}
{% if channel.params %}
params {{ channel.params | kdl }}
{% endif %}
{% if channel.client_message %}
client-message {{ channel.client_message | kdl }}
{% endif %}
{% if channel.server_message %}
server-message {{ channel.server_message | kdl }}
{% endif %}
}
{% endfor %}

View File

@@ -1,10 +1,4 @@
"""
Type-introspection helpers shared across backend adapters.
Both mizan-django and mizan-fastapi need to walk @client-decorated function
annotations the same way during schema export. Drift here breaks AFI parity,
so the helpers live in core.
"""
"""Annotation-introspection helpers used when walking @client function signatures."""
from __future__ import annotations
@@ -28,9 +22,8 @@ def extract_optional(annotation: Any) -> tuple[Any, bool]:
Returns `(T, True)` for a union containing exactly one non-None member
and `None` itself. For anything else, returns `(annotation, False)`.
Multi-arm unions like `A | B | None` are returned as-is — protocol-level
discriminated unions aren't supported yet, and silently picking one arm
would hide that.
A multi-arm union like `A | B | None` is returned as-is — picking one arm
would silently discard the others.
"""
origin = get_origin(annotation)
if origin is Union or isinstance(annotation, types.UnionType):
@@ -80,10 +73,9 @@ def is_structured_output(annotation: Any) -> bool:
def types_match_for_merge(slot_type: Any, value_type: Any) -> bool:
"""True if a `value_type` mutation return can splice into a `slot_type` context slot.
"""True if a `value_type` mutation return can splice into a `slot_type` slot.
Used by backend dispatch to resolve `@client(merge=ctx)` to a concrete
function-name slot inside the context bundle. Three shapes match:
Three shapes match:
- direct: slot is `T`, value is `T` → replace
- upsert: slot is `list[T]`, value is `T` → upsert by id

View File

@@ -0,0 +1,107 @@
"""Unit tests for KDL IR emission. Every assertion runs on a real KDL parse tree."""
from unittest import TestCase
import ckdl
from pydantic import BaseModel
from mizan_core.client.function import client
from mizan_core.ir import build_ir
from mizan_core.registry import clear_registry, register
class Prefs(BaseModel):
live: bool = True
class Settings(BaseModel):
meta: Prefs = Prefs()
label: str = "plain"
quoted: str = 'a "b" \\ c\nd\te'
retries: int = 3
ratio: float = 0.5
enabled: bool = False
tags: list[str] = []
mapping: dict[str, str] = {}
note: str | None = None
who: str
def _struct_fields(document: ckdl.Document, type_name: str) -> dict[str, ckdl.Node]:
"""Field nodes of the named struct, keyed by field name."""
for node in document.nodes:
if node.name == "type" and node.args[0] == type_name:
for child in node.children:
if child.name == "struct":
return {field.args[0]: field for field in child.children}
raise AssertionError(f"no struct type {type_name!r} in:\n{document}")
class EmptyDocumentTests(TestCase):
"""The IR of an empty registry."""
def setUp(self):
clear_registry()
def tearDown(self):
clear_registry()
def test_empty_registry_emits_zero_bytes(self):
"""Nothing registered renders the empty document, not a blank line."""
self.assertEqual(build_ir(), "")
def test_empty_document_parses_to_zero_nodes(self):
"""The empty document is valid KDL carrying no nodes."""
self.assertEqual(len(ckdl.parse(build_ir()).nodes), 0)
class FieldDefaultTests(TestCase):
"""Which Pydantic field defaults reach the document as KDL literals."""
def setUp(self):
clear_registry()
@client
def get_settings(request) -> Settings:
return Settings(who="anyone")
register(get_settings, "get_settings")
self.fields = _struct_fields(
ckdl.parse(build_ir()), "getSettingsOutput"
)
def tearDown(self):
clear_registry()
def test_scalar_defaults_survive_the_round_trip(self):
"""bool, int, float and str defaults parse back to the Python values."""
self.assertEqual(self.fields["label"].properties["default"], "plain")
self.assertEqual(self.fields["retries"].properties["default"], 3)
self.assertEqual(self.fields["ratio"].properties["default"], 0.5)
self.assertEqual(self.fields["enabled"].properties["default"], False)
def test_string_default_escapes_round_trip(self):
"""Quotes, backslashes and control characters survive KDL escaping."""
self.assertEqual(
self.fields["quoted"].properties["default"], Settings.model_fields["quoted"].default
)
def test_model_valued_default_carries_no_literal(self):
"""A nested-model default has no KDL scalar form, so no `default` is emitted."""
meta = self.fields["meta"]
self.assertNotIn("default", meta.properties)
self.assertIs(meta.properties["required"], False)
def test_container_defaults_carry_no_literal(self):
"""List and dict defaults have no KDL scalar form either."""
self.assertNotIn("default", self.fields["tags"].properties)
self.assertNotIn("default", self.fields["mapping"].properties)
def test_none_default_carries_no_literal(self):
"""`= None` leaves the optional shape to say the field may be absent."""
self.assertNotIn("default", self.fields["note"].properties)
def test_required_field_has_no_required_property(self):
"""A required field is the default, so the property is left off entirely."""
self.assertNotIn("required", self.fields["who"].properties)
self.assertNotIn("default", self.fields["who"].properties)

View File

@@ -0,0 +1,111 @@
//! `#[mizan::channel("<wire-name>", params = T, client_message = T,
//! server_message = T)]` — emit the linkme `ChannelEntry` registration for a
//! unit struct. Every slot is optional; only the declared ones register, and
//! each slot type must implement `MizanType` (via `#[derive(Mizan)]`).
use heck::ToShoutySnakeCase;
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{
parse::{Parse, ParseStream},
ItemStruct, LitStr, Path, Token,
};
mod kw {
syn::custom_keyword!(params);
syn::custom_keyword!(client_message);
syn::custom_keyword!(server_message);
}
/// Attribute args: the wire name, then the slot types the channel declares.
pub struct ChannelArgs {
pub wire_name: String,
pub params: Option<Path>,
pub client_message: Option<Path>,
pub server_message: Option<Path>,
}
impl Parse for ChannelArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let name: LitStr = input.parse()?;
let mut out = Self {
wire_name: name.value(),
params: None,
client_message: None,
server_message: None,
};
while input.peek(Token![,]) {
input.parse::<Token![,]>()?;
if input.is_empty() {
break;
}
if input.peek(kw::params) {
input.parse::<kw::params>()?;
input.parse::<Token![=]>()?;
out.params = Some(input.parse()?);
} else if input.peek(kw::client_message) {
input.parse::<kw::client_message>()?;
input.parse::<Token![=]>()?;
out.client_message = Some(input.parse()?);
} else if input.peek(kw::server_message) {
input.parse::<kw::server_message>()?;
input.parse::<Token![=]>()?;
out.server_message = Some(input.parse()?);
} else {
return Err(input.error(
"expected a channel slot: params, client_message, or server_message",
));
}
}
Ok(out)
}
}
pub fn expand(args: ChannelArgs, item: ItemStruct) -> TokenStream {
if !item.fields.is_empty() {
return syn::Error::new_spanned(
&item.fields,
"#[mizan::channel] requires a unit struct — the payload types are declared in the attribute.",
)
.to_compile_error();
}
let ident = item.ident.clone();
let wire_name = args.wire_name;
// Slots register in the order the IR emits them: params, client-message,
// server-message.
let mut slot_exprs: Vec<TokenStream> = Vec::new();
for (kind, declared) in [
(format_ident!("Params"), args.params),
(format_ident!("ClientMessage"), args.client_message),
(format_ident!("ServerMessage"), args.server_message),
] {
if let Some(ty) = declared {
slot_exprs.push(quote! {
::mizan_core::ChannelSlot {
kind: ::mizan_core::ChannelSlotKind::#kind,
shape_fn: <#ty as ::mizan_core::MizanType>::shape,
}
});
}
}
let register_static = format_ident!(
"__MIZAN_CHANNEL_REGISTER_{}",
ident.to_string().to_shouty_snake_case()
);
quote! {
#item
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::CHANNELS)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #register_static: ::mizan_core::ChannelEntry = ::mizan_core::ChannelEntry {
name: #wire_name,
slots: &[
#(#slot_exprs),*
],
};
}
}

View File

@@ -6,33 +6,33 @@ use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{parse::Parser, punctuated::Punctuated, ItemStruct, Lit, LitStr, Meta, Token};
/// Attribute args: either nothing, or one string literal that overrides the
/// derived snake_case context name.
pub struct ContextArgs {
pub explicit_name: Option<String>,
/// Where the context's wire name comes from: the attribute, or the struct's
/// own identifier when the attribute names none.
pub enum ContextName {
Explicit(String),
FromIdent,
}
impl ContextArgs {
impl ContextName {
/// Both `#[mizan::context("user")]` (bare string literal) and
/// `#[mizan::context(name = "user")]` name the context explicitly.
pub fn parse(attr_tokens: TokenStream) -> syn::Result<Self> {
if attr_tokens.is_empty() {
return Ok(Self { explicit_name: None });
return Ok(ContextName::FromIdent);
}
// Support both `#[mizan::context("user")]` (string literal) and
// `#[mizan::context(name = "user")]` (key=value).
if let Ok(lit) = syn::parse2::<LitStr>(attr_tokens.clone()) {
return Ok(Self {
explicit_name: Some(lit.value()),
});
return Ok(ContextName::Explicit(lit.value()));
}
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
let metas = parser.parse2(attr_tokens)?;
for meta in metas {
if let Meta::NameValue(nv) = meta {
if nv.path.is_ident("name") {
if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value {
return Ok(Self {
explicit_name: Some(s.value()),
});
if let syn::Expr::Lit(syn::ExprLit {
lit: Lit::Str(s), ..
}) = nv.value
{
return Ok(ContextName::Explicit(s.value()));
}
}
}
@@ -42,9 +42,16 @@ impl ContextArgs {
"expected `#[mizan::context]` or `#[mizan::context(\"<name>\")]` or `#[mizan::context(name = \"<name>\")]`",
))
}
fn resolve(self, ident: &syn::Ident) -> String {
match self {
ContextName::Explicit(name) => name,
ContextName::FromIdent => ident.to_string().to_snake_case(),
}
}
}
pub fn expand(args: ContextArgs, item: ItemStruct) -> TokenStream {
pub fn expand(name: ContextName, item: ItemStruct) -> TokenStream {
if !item.fields.is_empty() {
return syn::Error::new_spanned(
&item.fields,
@@ -54,9 +61,7 @@ pub fn expand(args: ContextArgs, item: ItemStruct) -> TokenStream {
}
let ident = item.ident.clone();
let name = args
.explicit_name
.unwrap_or_else(|| ident.to_string().to_snake_case());
let name = name.resolve(&ident);
let register_static =
format_ident!("__MIZAN_CTX_REGISTER_{}", ident.to_string().to_uppercase());

View File

@@ -1,18 +1,20 @@
//! `#[derive(Mizan)]` — emit `MizanType` impl + linkme registration.
use heck::{ToKebabCase, ToLowerCamelCase, ToShoutySnakeCase, ToSnakeCase, ToUpperCamelCase};
use proc_macro2::TokenStream;
use quote::quote;
use proc_macro2::{TokenStream, TokenTree};
use quote::{format_ident, quote};
use syn::{
parse::Parser, punctuated::Punctuated, Data, DataEnum, DataStruct, DeriveInput, Fields, Lit,
Meta, Token,
parse::{Parse, ParseStream},
Data, DeriveInput, Field, Fields, FieldsNamed, Ident, Lit, Meta, Type,
};
use crate::shape::type_shape_expr;
use crate::shape::{is_optional, type_shape_expr};
/// Apply a `#[serde(rename_all = "...")]` casing transform to a Rust
/// variant identifier so the IR's enum variant matches what serde emits
/// on the wire. Supported casings mirror serde's set.
/// variant identifier so the IR's enum variant matches what serde emits on
/// the wire. Supported casings mirror serde's set; any other rule — including
/// the empty rule an undecorated enum carries — leaves the identifier as
/// written.
fn apply_rename_all(rule: &str, ident: &str) -> String {
match rule {
"lowercase" => ident.to_lowercase(),
@@ -26,87 +28,210 @@ fn apply_rename_all(rule: &str, ident: &str) -> String {
}
}
/// Walk the enum's outer attributes for `#[serde(rename_all = "...")]`.
fn serde_rename_all(attrs: &[syn::Attribute]) -> Option<String> {
/// The string a `#[serde(<key> = "...")]` entry in `attrs` carries, or
/// `fallback` when no entry names `key`. serde owns that attribute's grammar
/// and its own derive reports a malformed body, so a body without the
/// `<key> = <string>` triple reads here as "no override".
fn serde_string(attrs: &[syn::Attribute], key: &str, fallback: String) -> String {
for attr in attrs {
if !attr.path().is_ident("serde") {
continue;
}
let list = match &attr.meta {
Meta::List(l) => l,
_ => continue,
};
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
let metas = match parser.parse2(list.tokens.clone()) {
Ok(m) => m,
Err(_) => continue,
};
for meta in metas {
if let Meta::NameValue(nv) = meta {
if nv.path.is_ident("rename_all") {
if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value {
return Some(s.value());
}
}
}
}
}
None
}
/// Walk a variant's attributes for an explicit `#[serde(rename = "...")]`
/// override. Variant-level rename overrides the enum-level rename_all.
fn serde_rename(attrs: &[syn::Attribute]) -> Option<String> {
for attr in attrs {
if !attr.path().is_ident("serde") {
let Meta::List(list) = &attr.meta else {
continue;
}
let list = match &attr.meta {
Meta::List(l) => l,
_ => continue,
};
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
let metas = match parser.parse2(list.tokens.clone()) {
Ok(m) => m,
Err(_) => continue,
};
for meta in metas {
if let Meta::NameValue(nv) = meta {
if nv.path.is_ident("rename") {
if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value {
return Some(s.value());
let mut on_key = false;
let mut on_value = false;
for tree in list.tokens.clone() {
match tree {
TokenTree::Ident(ident) => {
on_key = ident == key;
on_value = false;
}
TokenTree::Punct(punct) => {
on_value = on_key && punct.as_char() == '=';
}
TokenTree::Literal(literal) => {
if on_value {
if let Lit::Str(s) = Lit::new(literal) {
return s.value();
}
}
on_key = false;
on_value = false;
}
TokenTree::Group(_) => {
on_key = false;
on_value = false;
}
}
}
}
None
fallback
}
/// Expand `#[derive(Mizan)]`. Emits the `MizanType` impl AND a linkme
/// TypeEntry registration. Every Mizan-shaped type lands in the IR;
/// the emitter's inline-substitution pass collapses primitive-aliases
/// and enums at use sites so the IR stays tight.
pub fn expand(input: DeriveInput) -> TokenStream {
let ident = input.ident.clone();
/// A braced struct field paired with the identifier it carries. `all` is the
/// only constructor and it reads a `FieldsNamed` group, so `ident` is a total
/// accessor rather than an Option the caller has to open.
struct NamedField<'a> {
ident: &'a Ident,
field: &'a Field,
}
impl<'a> NamedField<'a> {
fn all(braced: &'a FieldsNamed) -> impl Iterator<Item = Self> {
braced
.named
.iter()
.flat_map(|field| field.ident.as_ref().map(|ident| Self { ident, field }))
}
/// The wire name serde emits: a `#[serde(rename)]` override, else the
/// identifier with serde's `r#` raw-prefix stripping applied.
fn wire_name(&self) -> String {
let raw_ident = self.ident.to_string();
let default = raw_ident.trim_start_matches("r#").to_string();
serde_string(&self.field.attrs, "rename", default)
}
}
/// One struct field reduced to what the IR carries: the name serde puts on the
/// wire and the declared Rust type.
struct FieldShape {
wire_name: String,
ty: Type,
}
/// The two type forms the IR can express.
enum DerivedShape {
Struct(Vec<FieldShape>),
Enum(Vec<String>),
}
/// A derive input already reduced to the IR form its body takes. The token
/// stream is parsed straight into this shape, so `expand` reads a settled
/// name and body and has nothing left to reject.
pub struct MizanDerive {
ident: Ident,
shape: DerivedShape,
}
impl Parse for MizanDerive {
fn parse(input: ParseStream) -> syn::Result<Self> {
let input: DeriveInput = input.parse()?;
let shape = match &input.data {
Data::Struct(s) => {
let braced = match &s.fields {
Fields::Named(named) => named,
Fields::Unnamed(_) => {
return Err(syn::Error::new_spanned(
&s.fields,
"#[derive(Mizan)] requires named fields. Tuple structs aren't part of the IR shape.",
));
}
Fields::Unit => {
return Err(syn::Error::new_spanned(
&s.fields,
"#[derive(Mizan)] requires named fields. Unit structs aren't part of the IR shape.",
));
}
};
let mut fields = Vec::new();
for named in NamedField::all(braced) {
fields.push(FieldShape {
wire_name: named.wire_name(),
ty: named.field.ty.clone(),
});
}
DerivedShape::Struct(fields)
}
Data::Enum(e) => {
let rename_all = serde_string(&input.attrs, "rename_all", String::new());
let mut variants = Vec::new();
for variant in &e.variants {
match &variant.fields {
Fields::Unit => {}
Fields::Named(_) => {
return Err(syn::Error::new_spanned(
&variant.fields,
"#[derive(Mizan)] only supports unit-variant enums (string-literal enums in the IR). Struct variants aren't expressible in the current IR.",
));
}
Fields::Unnamed(_) => {
return Err(syn::Error::new_spanned(
&variant.fields,
"#[derive(Mizan)] only supports unit-variant enums (string-literal enums in the IR). Tuple variants aren't expressible in the current IR.",
));
}
}
// Variant-level `rename` wins over the enum-level
// `rename_all` rule.
let default = apply_rename_all(&rename_all, &variant.ident.to_string());
variants.push(serde_string(&variant.attrs, "rename", default));
}
DerivedShape::Enum(variants)
}
Data::Union(_) => {
return Err(syn::Error::new_spanned(
&input,
"#[derive(Mizan)] does not support `union` types — use a struct or enum.",
));
}
};
Ok(Self {
ident: input.ident,
shape,
})
}
}
/// Build the `NamedType` expression the generated `shape()` returns.
fn named_type_expr(shape: &DerivedShape) -> TokenStream {
match shape {
DerivedShape::Struct(fields) => {
let field_exprs: Vec<TokenStream> = fields
.iter()
.map(|field| {
let name = &field.wire_name;
// A Rust struct-field declaration carries no default
// expression, so `default` is always None and `required`
// follows the Option wrapper.
let required = !is_optional(&field.ty);
let shape = type_shape_expr(&field.ty);
quote! {
::mizan_core::StructField {
name: #name,
required: #required,
default: ::std::option::Option::None,
shape: #shape,
}
}
})
.collect();
quote! {
::mizan_core::NamedType::Struct(::std::vec![
#(#field_exprs),*
])
}
}
DerivedShape::Enum(variants) => quote! {
::mizan_core::NamedType::Enum(::std::vec![
#(#variants),*
])
},
}
}
/// Expand `#[derive(Mizan)]` — the `MizanType` impl plus the linkme
/// `TypeEntry` registration for the derived type.
pub fn expand(derived: MizanDerive) -> TokenStream {
let MizanDerive { ident, shape } = derived;
let named_type_body = named_type_expr(&shape);
let type_name = ident.to_string();
let rename_all = serde_rename_all(&input.attrs);
let named_type_body = match &input.data {
Data::Struct(s) => emit_struct(s),
Data::Enum(e) => emit_enum(e, rename_all.as_deref()),
Data::Union(_) => {
return syn::Error::new_spanned(
&input,
"#[derive(Mizan)] does not support `union` types — use a struct or enum.",
)
.to_compile_error();
}
};
let register_static =
quote::format_ident!("__MIZAN_TYPE_REGISTER_{}", ident.to_string().to_shouty_snake_case());
let register_static = format_ident!(
"__MIZAN_TYPE_REGISTER_{}",
type_name.to_shouty_snake_case()
);
quote! {
impl ::mizan_core::MizanType for #ident {
@@ -123,84 +248,3 @@ pub fn expand(input: DeriveInput) -> TokenStream {
};
}
}
fn emit_struct(s: &DataStruct) -> TokenStream {
let fields = match &s.fields {
Fields::Named(named) => &named.named,
Fields::Unnamed(_) | Fields::Unit => {
return syn::Error::new_spanned(
&s.fields,
"#[derive(Mizan)] requires named fields. Tuple structs and unit structs aren't part of the IR shape.",
)
.to_compile_error();
}
};
let mut field_exprs: Vec<TokenStream> = Vec::new();
for field in fields {
let ident = field
.ident
.as_ref()
.expect("named field always has an ident");
// Field-level `#[serde(rename = "...")]` wins; otherwise strip
// the raw-identifier prefix that Rust uses to escape keywords
// (`r#type` → `type`). Serde itself strips the prefix when
// computing the default field name; the IR has to match the
// wire form, not the Rust source form.
let raw_ident = ident.to_string();
let stripped = raw_ident.strip_prefix("r#").unwrap_or(&raw_ident);
let name = serde_rename(&field.attrs).unwrap_or_else(|| stripped.to_string());
let shape = type_shape_expr(&field.ty);
// A field is `required` iff its type is not `Option<...>`. Defaults
// are not encodable from Rust syntax (no `= expr` on a struct field
// declaration) — the macro emits `required: false, default: None`
// for Option-wrapped fields, leaving defaults for a future
// attribute-based extension.
let is_optional = crate::shape::unwrap_option(&field.ty).is_some();
let required = !is_optional;
field_exprs.push(quote! {
::mizan_core::StructField {
name: #name,
required: #required,
default: ::std::option::Option::None,
shape: #shape,
}
});
}
quote! {
::mizan_core::NamedType::Struct(::std::vec![
#(#field_exprs),*
])
}
}
fn emit_enum(e: &DataEnum, rename_all: Option<&str>) -> TokenStream {
let mut variants: Vec<TokenStream> = Vec::new();
for variant in &e.variants {
if !matches!(variant.fields, Fields::Unit) {
return syn::Error::new_spanned(
&variant.fields,
"#[derive(Mizan)] only supports unit-variant enums (string-literal enums in the IR). Variants with payload aren't expressible in the current IR.",
)
.to_compile_error();
}
let raw = variant.ident.to_string();
// Variant-level `#[serde(rename = "...")]` wins; otherwise apply
// the enum-level `#[serde(rename_all = "...")]` rule.
let name = if let Some(explicit) = serde_rename(&variant.attrs) {
explicit
} else if let Some(rule) = rename_all {
apply_rename_all(rule, &raw)
} else {
raw
};
variants.push(quote! { #name });
}
quote! {
::mizan_core::NamedType::Enum(::std::vec![
#(#variants),*
])
}
}

View File

@@ -2,7 +2,7 @@
//! * a synthetic Input struct (`<camelName>Input`) when the fn has params
//! * `MizanType` impl on the Input struct
//! * canonical type entries (`<camelName>Input` / `<camelName>Output`)
//! * Vec-element sub-type entries (so `Vec<T>` outputs surface `T` too)
//! * list-element sub-type entries (so `Vec<T>` outputs surface `T` too)
//! * `FunctionSpec` impl on a ZST `__MizanFn_<name>`
//! * `FUNCTIONS` linkme registration of `&__MIZAN_FN_<NAME>_INSTANCE`
@@ -10,13 +10,25 @@ use heck::{ToLowerCamelCase, ToShoutySnakeCase};
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{
parse::Parser,
parenthesized,
parse::{Parse, ParseStream},
punctuated::Punctuated,
spanned::Spanned,
Expr, ExprPath, ExprTuple, FnArg, ItemFn, Meta, Pat, Path, ReturnType, Token, Type,
token::Paren,
FnArg, Ident, ItemFn, Pat, Path, ReturnType, Token, Type,
};
use crate::shape::{analyze_return, primitive_of, type_shape_expr, unwrap_option};
use crate::shape::{
analyze_return, classify, is_optional, path_head, ref_shape_expr, type_shape_expr, Head,
ReturnForm, TypeForm,
};
mod kw {
syn::custom_keyword!(context);
syn::custom_keyword!(affects);
syn::custom_keyword!(merge);
syn::custom_keyword!(websocket);
syn::custom_keyword!(private);
}
/// Parsed attribute args for `#[mizan(...)]`.
#[derive(Default)]
@@ -28,125 +40,149 @@ pub struct FunctionArgs {
pub private: bool,
}
impl FunctionArgs {
pub fn parse(attr_tokens: TokenStream) -> syn::Result<Self> {
if attr_tokens.is_empty() {
return Ok(Self::default());
}
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
let metas = parser.parse2(attr_tokens)?;
impl Parse for FunctionArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut out = Self::default();
for meta in metas {
match meta {
Meta::NameValue(nv) => {
if nv.path.is_ident("context") {
out.context = Some(expect_path(&nv.value)?);
} else if nv.path.is_ident("affects") {
out.affects = collect_paths(&nv.value)?;
} else if nv.path.is_ident("merge") {
out.merge = collect_paths(&nv.value)?;
} else {
return Err(syn::Error::new_spanned(
nv.path,
"unknown attribute key; expected one of: context, affects, merge",
));
}
}
Meta::Path(p) => {
if p.is_ident("websocket") {
out.websocket = true;
} else if p.is_ident("private") {
out.private = true;
} else {
return Err(syn::Error::new_spanned(
p,
"unknown flag; expected `websocket` or `private`",
));
}
}
Meta::List(l) => {
return Err(syn::Error::new_spanned(
l,
"list-shaped attribute args not supported here",
));
}
while !input.is_empty() {
if input.peek(kw::context) {
input.parse::<kw::context>()?;
input.parse::<Token![=]>()?;
out.context = Some(input.parse()?);
} else if input.peek(kw::affects) {
input.parse::<kw::affects>()?;
input.parse::<Token![=]>()?;
out.affects = parse_path_group(input)?;
} else if input.peek(kw::merge) {
input.parse::<kw::merge>()?;
input.parse::<Token![=]>()?;
out.merge = parse_path_group(input)?;
} else if input.peek(kw::websocket) {
input.parse::<kw::websocket>()?;
out.websocket = true;
} else if input.peek(kw::private) {
input.parse::<kw::private>()?;
out.private = true;
} else {
return Err(input.error(
"expected one of: `context = T`, `affects = T`, `merge = T`, `websocket`, `private`",
));
}
if input.is_empty() {
break;
}
input.parse::<Token![,]>()?;
}
if out.context.is_some() && !out.affects.is_empty() {
return Err(syn::Error::new_spanned(
out.context.as_ref().unwrap(),
"`context` and `affects` are mutually exclusive — a function is either a context reader or a mutation.",
));
}
if out.context.is_some() && !out.merge.is_empty() {
return Err(syn::Error::new_spanned(
out.context.as_ref().unwrap(),
"`context` and `merge` are mutually exclusive — a function is either a context reader or a mutation.",
));
if let Some(ctx) = &out.context {
if !out.affects.is_empty() {
return Err(syn::Error::new_spanned(
ctx,
"`context` and `affects` are mutually exclusive — a function is either a context reader or a mutation.",
));
}
if !out.merge.is_empty() {
return Err(syn::Error::new_spanned(
ctx,
"`context` and `merge` are mutually exclusive — a function is either a context reader or a mutation.",
));
}
}
Ok(out)
}
}
fn expect_path(expr: &Expr) -> syn::Result<Path> {
if let Expr::Path(ExprPath { path, .. }) = expr {
Ok(path.clone())
/// One context type (`affects = Ctx`) or a parenthesized group of them
/// (`affects = (CtxA, CtxB)`).
fn parse_path_group(input: ParseStream) -> syn::Result<Vec<Path>> {
if input.peek(Paren) {
let group;
parenthesized!(group in input);
Ok(Punctuated::<Path, Token![,]>::parse_terminated(&group)?
.into_iter()
.collect())
} else {
Err(syn::Error::new_spanned(
expr,
"expected a type path (e.g. `UserCtx`)",
))
}
}
fn collect_paths(expr: &Expr) -> syn::Result<Vec<Path>> {
match expr {
Expr::Path(_) => Ok(vec![expect_path(expr)?]),
Expr::Tuple(ExprTuple { elems, .. }) => elems.iter().map(expect_path).collect(),
_ => Err(syn::Error::new_spanned(
expr,
"expected a context type or a tuple of context types (e.g. `UserCtx` or `(UserCtx, OrderCtx)`)",
)),
Ok(vec![input.parse()?])
}
}
/// Information about one input parameter, extracted from the fn signature.
struct InputArg {
ident: syn::Ident,
ident: Ident,
ty: Type,
}
pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
if item.sig.asyncness.is_none() {
return syn::Error::new_spanned(
&item.sig.fn_token,
"#[mizan] requires an `async fn`. Wrap synchronous handlers if needed.",
)
.to_compile_error();
/// The handler grammar `#[mizan::client]` accepts: an `async fn` taking a
/// request handle followed by plain-identifier params, with an explicit return
/// type. The token stream is parsed straight into this shape, so `expand`
/// reads three settled fields and has nothing left to reject.
///
/// A missing `async` or a missing request handle needs no rejection here: the
/// dispatch wrapper `expand` emits calls the handler with `&req` and awaits
/// the call, so rustc rejects both at the generated call site.
pub struct Handler {
item: ItemFn,
input_args: Vec<InputArg>,
return_ty: Type,
}
impl Parse for Handler {
fn parse(input: ParseStream) -> syn::Result<Self> {
let item: ItemFn = input.parse()?;
let ReturnType::Type(_, declared) = &item.sig.output else {
return Err(syn::Error::new_spanned(
&item.sig,
"#[mizan] requires an explicit return type. Add `-> T` to the signature.",
));
};
let return_ty = (**declared).clone();
let mut input_args = Vec::new();
// The first arg is the request handle, which the dispatch wrapper
// forwards as `req`; it never becomes an Input field.
for arg in item.sig.inputs.iter().skip(1) {
let typed = match arg {
FnArg::Typed(typed) => typed,
FnArg::Receiver(_) => {
return Err(syn::Error::new_spanned(
arg,
"#[mizan] functions are free functions, not methods. `self` is not allowed.",
));
}
};
let Pat::Ident(bound) = &*typed.pat else {
return Err(syn::Error::new_spanned(
&typed.pat,
"#[mizan] function parameters must be plain identifiers (no destructuring).",
));
};
input_args.push(InputArg {
ident: bound.ident.clone(),
ty: (*typed.ty).clone(),
});
}
Ok(Self {
item,
input_args,
return_ty,
})
}
}
pub fn expand(args: FunctionArgs, handler: Handler) -> TokenStream {
let Handler {
item,
input_args,
return_ty,
} = handler;
let fn_name = item.sig.ident.to_string();
let camel = fn_name.to_lower_camel_case();
let input_type_name = format!("{camel}Input");
let output_type_name = format!("{camel}Output");
let input_args = match collect_input_args(&item) {
Ok(v) => v,
Err(e) => return e.to_compile_error(),
};
let has_input = !input_args.is_empty();
let input_type_ident = format_ident!("{}", input_type_name);
let return_ty = match &item.sig.output {
ReturnType::Type(_, t) => (**t).clone(),
ReturnType::Default => {
return syn::Error::new_spanned(
&item.sig,
"#[mizan] requires an explicit return type. Add `-> T` to the signature.",
)
.to_compile_error();
}
};
let analysis = analyze_return(&return_ty);
// ─── Synthetic Input struct ────────────────────────────────────────────
@@ -156,12 +192,11 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
for arg in &input_args {
let ident = &arg.ident;
let ty = &arg.ty;
// Strip a leading underscore from the wire-level field name —
// Rust convention uses `_foo` to silence unused-arg warnings,
// but the wire schema and the Python fixture name the param
// `foo`. The struct field keeps its source ident (so the
// dispatch wrapper's `validated.#ident` compiles), and a serde
// `rename` bridges the wire-level JSON name.
// Rust convention writes `_foo` to silence an unused-arg warning,
// but the wire schema names the param `foo`. The struct field
// keeps its source ident so the dispatch wrapper's
// `validated.#ident` compiles, and a serde `rename` bridges the
// JSON name.
let name_str = ident.to_string();
let wire_name = name_str.trim_start_matches('_').to_string();
let serde_rename = if wire_name != name_str {
@@ -170,8 +205,7 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
TokenStream::new()
};
field_defs.push(quote! { #serde_rename pub #ident: #ty, });
let is_optional = unwrap_option(ty).is_some();
let required = !is_optional;
let required = !is_optional(ty);
let shape = type_shape_expr(ty);
field_shapes.push(quote! {
::mizan_core::StructField {
@@ -202,11 +236,6 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
};
// ─── Type entry registrations ──────────────────────────────────────────
// - Input: TypeEntry pointing at the synthetic input struct's shape_fn.
// - Output: TypeEntry whose shape is a copy of the user's Output shape
// (for struct outputs) or an `Alias(List(Ref("T")))` (for Vec outputs).
// - For Vec<T> outputs, ALSO register T's TypeEntry pointing at T's
// MizanType impl (so the Ref resolves in the IR).
let mut type_registrations = Vec::new();
if has_input {
let static_ident =
@@ -222,66 +251,67 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
}
let output_static = format_ident!("__MIZAN_TYPE_{}", output_type_name.to_shouty_snake_case());
if analysis.is_vec {
let elem = analysis.vec_inner.as_ref().expect("vec_inner set");
// userOrdersOutput → alias { list { ref "OrderOutput" } }
// The Ref name is resolved via `<T as MizanType>::type_name()`.
type_registrations.push(quote! {
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
name: #output_type_name,
shape_fn: || ::mizan_core::NamedType::Alias(
::mizan_core::TypeShape::List(::std::boxed::Box::new(
::mizan_core::TypeShape::Ref(<#elem as ::mizan_core::MizanType>::TYPE_NAME)
))
),
let output_shape_expr = match &analysis.form {
ReturnForm::Sequence { element } => {
let element_ref = ref_shape_expr(element);
let alias = quote! {
::mizan_core::NamedType::Alias(
::mizan_core::TypeShape::List(::std::boxed::Box::new(#element_ref))
)
};
});
// Also register the element type itself by its own name. `TYPE_NAME`
// is an associated const, so this is usable in a static initializer.
// The static ident scopes by the function name so two handlers
// returning `Vec<Same>` don't collide; the IrSnapshot's BTreeMap
// dedupes by the entry's `name` at emit time.
let elem_static =
element_type_static_ident_scoped(elem, &fn_name.to_shouty_snake_case());
type_registrations.push(quote! {
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #elem_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
name: <#elem as ::mizan_core::MizanType>::TYPE_NAME,
shape_fn: <#elem as ::mizan_core::MizanType>::shape,
};
});
} else {
// Non-Vec output: copy the inner type's shape under the canonical name.
let inner_ty = &analysis.inner;
type_registrations.push(quote! {
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
name: #output_type_name,
shape_fn: <#inner_ty as ::mizan_core::MizanType>::shape,
};
});
}
type_registrations.push(quote! {
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
name: #output_type_name,
shape_fn: || #alias,
};
});
// The element type also registers under its own name. The static
// ident is scoped by the function name so two handlers returning
// `Vec<Same>` don't collide; the emitter dedupes by entry name.
let element_static =
element_type_static_ident_scoped(element, &fn_name.to_shouty_snake_case());
type_registrations.push(quote! {
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #element_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
name: <#element as ::mizan_core::MizanType>::TYPE_NAME,
shape_fn: <#element as ::mizan_core::MizanType>::shape,
};
});
alias
}
ReturnForm::Scalar { inner } => {
type_registrations.push(quote! {
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
name: #output_type_name,
shape_fn: <#inner as ::mizan_core::MizanType>::shape,
};
});
quote! { <#inner as ::mizan_core::MizanType>::shape() }
}
};
// ─── InputParam slice (for context-builder shared-param elevation) ────
// A non-primitive param is an opaque payload in the context's `param`
// block and carries the string primitive.
let opaque_primitive = || quote! { ::mizan_core::Primitive::String };
let mut input_params = Vec::new();
for arg in &input_args {
// Wire-level name strips the underscore prefix — see input_struct
// above for the rationale.
// above.
let name_str = arg.ident.to_string();
let name_str = name_str.trim_start_matches('_').to_string();
let primitive = primitive_of(&arg.ty).unwrap_or_else(|| {
// Non-primitive params don't surface in the context's `param`
// block; they participate as opaque payloads. Using `String` as
// the placeholder primitive matches Python's fallback in
// `_annotation_to_primitive`.
quote! { ::mizan_core::Primitive::String }
});
let is_optional = unwrap_option(&arg.ty).is_some();
let required = !is_optional;
let primitive = match classify(&arg.ty) {
TypeForm::Primitive(p) => p,
TypeForm::Optional(_) => opaque_primitive(),
TypeForm::Sequence(_) => opaque_primitive(),
TypeForm::Named(_) => opaque_primitive(),
};
let required = !is_optional(&arg.ty);
input_params.push(quote! {
::mizan_core::InputParam {
name: #name_str,
@@ -354,7 +384,7 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
let private = args.private;
let dispatch_body = build_dispatch(
&item,
&inner_fn_ident,
&input_args,
has_input,
&input_type_ident,
@@ -362,8 +392,6 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
);
quote! {
// Keep the user's original fn intact — the macro never rewrites the
// body, only wraps it for dispatch.
#item
#input_struct
@@ -383,6 +411,7 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
fn has_input(&self) -> bool { #has_input }
fn input_type(&self) -> ::std::option::Option<&'static str> { #input_type_opt }
fn output_type(&self) -> &'static str { #output_type_name }
fn output_shape(&self) -> ::mizan_core::NamedType { #output_shape_expr }
fn output_nullable(&self) -> bool { #output_nullable }
fn context(&self) -> ::std::option::Option<&'static str> { #context_value }
fn affects(&self) -> &'static [::mizan_core::AffectTarget] { #affects_static }
@@ -416,57 +445,15 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
}
}
fn collect_input_args(item: &ItemFn) -> syn::Result<Vec<InputArg>> {
let mut out = Vec::new();
let mut iter = item.sig.inputs.iter();
// First arg is the request handle — skip without inspection. The function
// body uses it directly; the dispatch wrapper forwards `req`.
if iter.next().is_none() {
return Err(syn::Error::new(
item.sig.span(),
"#[mizan] functions must accept at least a request handle as the first parameter (e.g. `&Request` or `RequestHandle`).",
));
}
for arg in iter {
match arg {
FnArg::Typed(pat) => {
let ident = match &*pat.pat {
Pat::Ident(pi) => pi.ident.clone(),
_ => {
return Err(syn::Error::new_spanned(
&pat.pat,
"#[mizan] function parameters must be plain identifiers (no destructuring).",
));
}
};
out.push(InputArg {
ident,
ty: (*pat.ty).clone(),
});
}
FnArg::Receiver(_) => {
return Err(syn::Error::new_spanned(
arg,
"#[mizan] functions are free functions, not methods. `self` is not allowed.",
));
}
}
}
Ok(out)
}
fn build_dispatch(
item: &ItemFn,
inner: &Ident,
input_args: &[InputArg],
has_input: bool,
input_type_ident: &syn::Ident,
input_type_ident: &Ident,
returns_result: bool,
) -> TokenStream {
let inner = &item.sig.ident;
// When the user returns `Result<T, MizanError>`, lift Err out into the
// dispatch wrapper's outer Result so the HTTP/IPC adapter can surface
// it as the standard error envelope. When the user returns `T`,
// serialize directly — the substrate has no error path for them.
// `?` lifts a user `Result<T, MizanError>`'s Err into the wrapper's outer
// Result; a plain `T` serializes directly.
let unwrap_user_result = if returns_result {
quote! { ? }
} else {
@@ -501,16 +488,17 @@ fn build_dispatch(
}
}
fn element_type_static_ident_scoped(ty: &Type, fn_scope: &str) -> syn::Ident {
// Derive a unique static-name for the type's registration entry,
// scoped by the surrounding function so siblings returning the same
// `Vec<T>` don't collide at the static-name layer. The IR-side
// BTreeMap dedupes by TypeEntry.name at emission time.
let last = match ty {
Type::Path(tp) => tp.path.segments.last().map(|s| s.ident.to_string()),
_ => None,
/// A static-name for the element type's registration entry, scoped by the
/// surrounding function so siblings returning the same `Vec<T>` don't collide
/// at the static-name layer.
fn element_type_static_ident_scoped(ty: &Type, fn_scope: &str) -> Ident {
let stem = match path_head(ty) {
Head::Path { name, .. } => name,
Head::Unnamed => "ANON".to_string(),
};
let suffix = last.unwrap_or_else(|| "ANON".to_string()).to_shouty_snake_case();
format_ident!("__MIZAN_TYPE_ELEM_{}_FOR_{}", suffix, fn_scope)
format_ident!(
"__MIZAN_TYPE_ELEM_{}_FOR_{}",
stem.to_shouty_snake_case(),
fn_scope
)
}

View File

@@ -1,47 +1,34 @@
//! Proc macros for `mizan-core`. See sibling modules for each macro's body.
//! Proc macros for `mizan-core`. See sibling modules for each macro's body:
//! `derive` for `#[derive(Mizan)]`, `context` / `function` / `channel` for the
//! three attribute macros, `shape` for the shared `syn::Type` lowering.
//!
//! Consumer code reads:
//! ```ignore
//! use mizan_core::prelude::*;
//! pub use mizan_core as mizan; // so `#[mizan::context]` / `#[mizan::client]` read naturally
//!
//! #[derive(Mizan, serde::Serialize, serde::Deserialize)]
//! pub struct ProfileOutput { pub user_id: i64, pub name: String }
//!
//! #[mizan::context("user")]
//! pub struct UserCtx;
//!
//! #[mizan::client(context = UserCtx)]
//! pub async fn user_profile(req: &Request, user_id: i64) -> ProfileOutput { ... }
//! ```
//!
//! The function macro is named `client` to mirror Python's `@client`
//! decorator and to keep the namespace `mizan::` purely a module path —
//! `#[mizan(...)]` would collide with `mizan::context` (a module path
//! can't simultaneously be a callable macro in Rust).
//! The function macro is named `client` so `mizan::` stays purely a module
//! path — a module path can't simultaneously be a callable macro in Rust, so
//! `#[mizan(...)]` would collide with `mizan::context`.
mod channel;
mod context;
mod derive;
mod function;
mod shape;
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput, ItemFn, ItemStruct};
use syn::{parse_macro_input, ItemStruct};
#[proc_macro_derive(Mizan)]
pub fn derive_mizan(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
derive::expand(input).into()
let derived = parse_macro_input!(input as derive::MizanDerive);
derive::expand(derived).into()
}
#[proc_macro_attribute]
pub fn context(attr: TokenStream, item: TokenStream) -> TokenStream {
let args = match context::ContextArgs::parse(attr.into()) {
Ok(a) => a,
let name = match context::ContextName::parse(attr.into()) {
Ok(n) => n,
Err(e) => return e.to_compile_error().into(),
};
let item = parse_macro_input!(item as ItemStruct);
context::expand(args, item).into()
context::expand(name, item).into()
}
/// The function-registration attribute macro. Used as `#[mizan::client]`
@@ -49,10 +36,17 @@ pub fn context(attr: TokenStream, item: TokenStream) -> TokenStream {
/// websocket, private)]`.
#[proc_macro_attribute]
pub fn client(attr: TokenStream, item: TokenStream) -> TokenStream {
let args = match function::FunctionArgs::parse(attr.into()) {
Ok(a) => a,
Err(e) => return e.to_compile_error().into(),
};
let item = parse_macro_input!(item as ItemFn);
function::expand(args, item).into()
let args = parse_macro_input!(attr as function::FunctionArgs);
let handler = parse_macro_input!(item as function::Handler);
function::expand(args, handler).into()
}
/// The channel-registration attribute macro. Used as
/// `#[mizan::channel("<wire-name>", params = P, client_message = C,
/// server_message = S)]` on a unit struct; every slot is optional.
#[proc_macro_attribute]
pub fn channel(attr: TokenStream, item: TokenStream) -> TokenStream {
let args = parse_macro_input!(attr as channel::ChannelArgs);
let item = parse_macro_input!(item as ItemStruct);
channel::expand(args, item).into()
}

View File

@@ -6,203 +6,189 @@ use proc_macro2::TokenStream;
use quote::quote;
use syn::{GenericArgument, PathArguments, Type, TypePath};
/// Result of inspecting a fn's return type.
/// The IR-relevant form of a Rust type. Every `syn::Type` lands in exactly
/// one arm, so classification never reports "unknown".
pub enum TypeForm {
/// `Option<T>` — the wire field is nullable.
Optional(Type),
/// `Vec<T>`, `[T; N]`, or a map whose values are `T` — a JSON array.
Sequence(Type),
/// A scalar, carrying the `::mizan_core::Primitive` variant expression.
Primitive(TokenStream),
/// Anything else: a type expected to implement `MizanType`.
Named(Type),
}
/// What a type's head is, as the lowering reads it. `Unnamed` covers the
/// forms with no path to name — tuples, references, slices, bare fns — which
/// carry no keyword the callers below test for.
pub enum Head {
Path { name: String, generics: Vec<Type> },
Unnamed,
}
/// Which of the two output shapes a handler's return type produces.
pub enum ReturnForm {
/// The handler yields a list; the caller registers an alias type over
/// `element`'s Ref.
Sequence { element: Type },
/// The handler yields one value; the caller registers `inner`'s own shape
/// under the canonical output name.
Scalar { inner: Type },
}
pub struct ReturnAnalysis {
/// Inner type once `Option<...>` is unwrapped.
pub inner: Type,
/// True if the outermost wrapper is `Option<...>`.
pub form: ReturnForm,
/// True if the outermost wrapper (after `Result`) is `Option<...>`.
pub nullable: bool,
/// True if `inner` is `Vec<T>` — caller emits an alias type entry.
pub is_vec: bool,
/// When `is_vec`, this is the element type `T`.
pub vec_inner: Option<Type>,
/// True when the user's return type is `Result<T, MizanError>` — the
/// dispatch wrapper emits `?` so user-side errors bubble out as
/// `MizanError` instead of being serialized into the success payload.
/// The IR sees only the `T` side; the error variant is the substrate's
/// invariant, not part of the output shape.
pub returns_result: bool,
}
pub fn analyze_return(ty: &Type) -> ReturnAnalysis {
let (effective, returns_result) = if let Some(ok) = unwrap_result_ok(ty) {
(ok, true)
} else {
(ty.clone(), false)
let (effective, returns_result) = strip_result(ty);
let (unwrapped, nullable) = match classify(&effective) {
TypeForm::Optional(inner) => (inner, true),
TypeForm::Sequence(_) | TypeForm::Primitive(_) | TypeForm::Named(_) => (effective, false),
};
let (inner, nullable) = if let Some(t) = unwrap_option(&effective) {
(t, true)
} else {
(effective, false)
let form = match classify(&unwrapped) {
TypeForm::Sequence(element) => ReturnForm::Sequence { element },
TypeForm::Optional(_) | TypeForm::Primitive(_) | TypeForm::Named(_) => {
ReturnForm::Scalar { inner: unwrapped }
}
};
if let Some(elem) = unwrap_vec(&inner) {
ReturnAnalysis {
inner: inner.clone(),
nullable,
is_vec: true,
vec_inner: Some(elem),
returns_result,
}
} else {
ReturnAnalysis {
inner,
nullable,
is_vec: false,
vec_inner: None,
returns_result,
}
ReturnAnalysis {
form,
nullable,
returns_result,
}
}
/// If `ty` is `Result<T, E>`, return `T`. Otherwise None. The substrate
/// only honors `Result<T, MizanError>`; the macro doesn't try to verify
/// `E` here — it lets rustc raise the type-mismatch at the `?` site if
/// the consumer used a non-MizanError variant.
pub fn unwrap_result_ok(ty: &Type) -> Option<Type> {
let path = match ty {
Type::Path(TypePath { qself: None, path }) => path,
_ => return None,
};
let last = path.segments.last()?;
if last.ident != "Result" {
return None;
/// Peel `Result<T, E>` down to `T`. `E` is left to rustc: a non-`MizanError`
/// error type fails at the `?` site the dispatch wrapper emits.
pub fn strip_result(ty: &Type) -> (Type, bool) {
if let Head::Path { name, generics } = path_head(ty) {
if name == "Result" {
if let [ok, ..] = generics.as_slice() {
return (ok.clone(), true);
}
}
}
extract_single_generic(&last.arguments)
(ty.clone(), false)
}
/// Emit a `TypeShape` const-expression for `ty`. Used inside `#[derive(Mizan)]`
/// when constructing the struct field shapes.
pub fn classify(ty: &Type) -> TypeForm {
if let Type::Array(array) = ty {
return TypeForm::Sequence((*array.elem).clone());
}
let Head::Path { name, generics } = path_head(ty) else {
return TypeForm::Named(ty.clone());
};
let args = generics.as_slice();
if name == "Option" {
if let [inner, ..] = args {
return TypeForm::Optional(inner.clone());
}
}
if name == "Vec" {
if let [element, ..] = args {
return TypeForm::Sequence(element.clone());
}
}
if name == "BTreeMap" || name == "HashMap" {
// A string-keyed map lands on the wire as a JSON object; the IR
// carries only the value shape, as a list element.
if let [_key, value, ..] = args {
return TypeForm::Sequence(value.clone());
}
}
classify_scalar(ty, &name)
}
pub fn is_optional(ty: &Type) -> bool {
matches!(classify(ty), TypeForm::Optional(_))
}
/// Emit a `TypeShape` const-expression for `ty`. Used inside
/// `#[derive(Mizan)]` when constructing the struct field shapes.
pub fn type_shape_expr(ty: &Type) -> TokenStream {
if let Some(inner) = unwrap_option(ty) {
let inner_shape = type_shape_expr(&inner);
return quote! {
::mizan_core::TypeShape::Optional(::std::boxed::Box::new(#inner_shape))
};
}
if let Some(elem) = unwrap_vec(ty) {
let inner_shape = type_shape_expr(&elem);
return quote! {
::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape))
};
}
if let Some(elem) = unwrap_array(ty) {
// `[T; N]` lowers to `list { T }` on the wire — JSON arrays don't
// carry length, so the IR contract is the same as `Vec<T>`.
let inner_shape = type_shape_expr(&elem);
return quote! {
::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape))
};
}
if let Some(elem) = unwrap_btreemap_value(ty) {
// `BTreeMap<K, V>` on the wire is a JSON object keyed by `K`'s
// string form. The Mizan IR doesn't model dynamic-keyed maps as a
// distinct shape — closest equivalent is a list of value entries.
let inner_shape = type_shape_expr(&elem);
return quote! {
::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape))
};
}
if let Some(p) = primitive_of(ty) {
return quote! { ::mizan_core::TypeShape::Primitive(#p) };
}
// Fallback: assume a user-defined struct/enum implementing MizanType.
// The Ref name comes from `<T as MizanType>::TYPE_NAME` (associated const).
quote! { ::mizan_core::TypeShape::Ref(<#ty as ::mizan_core::MizanType>::TYPE_NAME) }
}
/// If `ty` is `[T; N]`, return `T`. Otherwise None.
pub fn unwrap_array(ty: &Type) -> Option<Type> {
if let Type::Array(a) = ty {
Some((*a.elem).clone())
} else {
None
}
}
/// If `ty` is `BTreeMap<K, V>` or `HashMap<K, V>`, return `V` (the value).
/// String-keyed maps land on the wire as JSON objects; the IR carries the
/// value shape as a list element since KDL doesn't model dynamic-keyed maps
/// distinctly yet.
pub fn unwrap_btreemap_value(ty: &Type) -> Option<Type> {
let path = match ty {
Type::Path(TypePath { qself: None, path }) => path,
_ => return None,
};
let last = path.segments.last()?;
let name = last.ident.to_string();
if name != "BTreeMap" && name != "HashMap" {
return None;
}
let args = match &last.arguments {
PathArguments::AngleBracketed(a) => a,
_ => return None,
};
// BTreeMap<K, V> — second type argument is V.
let mut type_args = args.args.iter().filter_map(|a| {
if let GenericArgument::Type(t) = a {
Some(t.clone())
} else {
None
match classify(ty) {
TypeForm::Optional(inner) => {
let inner_shape = type_shape_expr(&inner);
quote! {
::mizan_core::TypeShape::Optional(::std::boxed::Box::new(#inner_shape))
}
}
});
type_args.next()?; // skip K
type_args.next()
}
/// Emit a `Primitive` const-expression for `ty`, or `None` if `ty` isn't a
/// known primitive scalar.
pub fn primitive_of(ty: &Type) -> Option<TokenStream> {
let path = match ty {
Type::Path(TypePath { qself: None, path }) => path,
_ => return None,
};
let last = path.segments.last()?;
let name = last.ident.to_string();
match name.as_str() {
"i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128"
| "usize" => Some(quote! { ::mizan_core::Primitive::Integer }),
"f32" | "f64" => Some(quote! { ::mizan_core::Primitive::Number }),
"bool" => Some(quote! { ::mizan_core::Primitive::Boolean }),
"String" | "str" => Some(quote! { ::mizan_core::Primitive::String }),
_ => None,
TypeForm::Sequence(element) => {
let inner_shape = type_shape_expr(&element);
quote! {
::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape))
}
}
TypeForm::Primitive(primitive) => {
quote! { ::mizan_core::TypeShape::Primitive(#primitive) }
}
TypeForm::Named(named) => ref_shape_expr(&named),
}
}
/// If `ty` is `Option<T>`, return `T`. Otherwise None.
pub fn unwrap_option(ty: &Type) -> Option<Type> {
let path = match ty {
Type::Path(TypePath { qself: None, path }) => path,
_ => return None,
};
let last = path.segments.last()?;
if last.ident != "Option" {
return None;
/// A `TypeShape::Ref` carrying both the referent's IR name and its shape
/// constructor, so resolving the reference needs no registry lookup.
pub fn ref_shape_expr(ty: &Type) -> TokenStream {
quote! {
::mizan_core::TypeShape::Ref {
name: <#ty as ::mizan_core::MizanType>::TYPE_NAME,
shape: <#ty as ::mizan_core::MizanType>::shape,
}
}
extract_single_generic(&last.arguments)
}
/// If `ty` is `Vec<T>`, return `T`. Otherwise None.
pub fn unwrap_vec(ty: &Type) -> Option<Type> {
let path = match ty {
Type::Path(TypePath { qself: None, path }) => path,
_ => return None,
};
let last = path.segments.last()?;
if last.ident != "Vec" {
return None;
const INTEGER_IDENTS: &[&str] = &[
"i8", "i16", "i32", "i64", "i128", "isize", "u8", "u16", "u32", "u64", "u128", "usize",
];
fn classify_scalar(ty: &Type, name: &str) -> TypeForm {
if INTEGER_IDENTS.contains(&name) {
return TypeForm::Primitive(quote! { ::mizan_core::Primitive::Integer });
}
extract_single_generic(&last.arguments)
if name == "f32" || name == "f64" {
return TypeForm::Primitive(quote! { ::mizan_core::Primitive::Number });
}
if name == "bool" {
return TypeForm::Primitive(quote! { ::mizan_core::Primitive::Boolean });
}
if name == "String" || name == "str" {
return TypeForm::Primitive(quote! { ::mizan_core::Primitive::String });
}
TypeForm::Named(ty.clone())
}
fn extract_single_generic(args: &PathArguments) -> Option<Type> {
let args = match args {
/// The last path segment's identifier and its generic type arguments.
pub fn path_head(ty: &Type) -> Head {
if let Type::Path(TypePath { qself: None, path }) = ty {
if let Some(last) = path.segments.last() {
return Head::Path {
name: last.ident.to_string(),
generics: generic_types(&last.arguments),
};
}
}
Head::Unnamed
}
fn generic_types(args: &PathArguments) -> Vec<Type> {
let angled = match args {
PathArguments::AngleBracketed(a) => a,
_ => return None,
PathArguments::None => return Vec::new(),
PathArguments::Parenthesized(_) => return Vec::new(),
};
for arg in &args.args {
let mut out = Vec::new();
for arg in &angled.args {
if let GenericArgument::Type(t) = arg {
return Some(t.clone());
out.push(t.clone());
}
}
None
out
}

View File

@@ -1,20 +1,14 @@
//! Mizan SSR engine.
//! Mizan SSR engine: an embedded `deno_core` V8 runtime composed with
//! `deno_web`, holding one evaluated JS bundle plus the `renderApp` function
//! that bundle defines.
//!
//! Embeds a `deno_core` V8 runtime composed with `deno_web` so the build-time
//! JS bundle (component + `react-dom/server.browser`, produced by the bundler
//! during `mizan-generate`) renders to HTML in-process. The bundle exposes a
//! global render function; the engine evals it once and calls it per request.
//! No external JS runtime — node and bun are build-time tools only.
//! `deno_web` supplies the web-platform globals a bare isolate lacks —
//! `TextEncoder`/`TextDecoder`, timers, `MessagePort`, `performance` — as real
//! implementations rather than partial shims.
//!
//! The host globals a bare V8 isolate lacks — `TextEncoder`/`TextDecoder`,
//! timers, `MessagePort`, `performance` — come from `deno_web` as real
//! web-platform implementations, not shims (a partial polyfill is
//! silent-failure-shaped: it passes until a render path hits the gap).
//!
//! Props never enter evaluated source. Only the trusted bundle is `eval`'d;
//! per-render data crosses as a `v8::json::parse`d value passed as a function
//! argument, so a prop string has no source to break out of — code injection
//! is structurally absent, not filtered.
//! Only the bundle is ever `eval`'d. Per-render props enter through
//! `v8::json::parse` and are handed in as a call argument, so a prop string has
//! no surrounding source to break out of.
use std::sync::Arc;
@@ -36,15 +30,32 @@ const INSTALL_WEB_GLOBALS: &str = r#"{
globalThis.TextDecoder = te.TextDecoder;
}"#;
/// Yield the bundle's `renderApp`, throwing on the JS side when it is absent
/// or not callable. The script therefore either fails — arriving in Rust as
/// the evaluator's own error — or produces a callable, which is what lets the
/// engine take it as a `v8::Function` without a second check.
const TAKE_RENDER_APP: &str = r#"(() => {
const f = globalThis.renderApp;
if (typeof f !== "function") {
throw new TypeError("the SSR bundle assigns no callable `renderApp`");
}
return f;
})()"#;
/// An embedded V8 runtime carrying one rendered bundle, plus the web-platform
/// globals react-dom needs. One isolate per engine (V8's Locker constraint
/// means an engine is not `Send`; hold one per worker thread).
///
/// `render_fn` is taken during construction, so a render calls a function this
/// engine already owns and repeats no lookup.
pub struct SsrEngine {
runtime: JsRuntime,
render_fn: v8::Global<v8::Function>,
}
impl SsrEngine {
/// Build the runtime and eval `bundle` (which assigns `globalThis.renderApp`).
/// Build the runtime, eval `bundle` (which assigns `globalThis.renderApp`),
/// and take hold of that function.
pub fn new(bundle: String) -> Result<Self> {
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![
@@ -64,50 +75,64 @@ impl SsrEngine {
runtime
.execute_script("[mizan:bundle]", bundle)
.context("evaluating the SSR bundle")?;
Ok(Self { runtime })
let render_app = runtime
.execute_script("[mizan:render-app]", TAKE_RENDER_APP)
.context("taking `renderApp` from the evaluated bundle")?;
let render_fn = {
deno_core::scope!(scope, &mut runtime);
let func = v8::Local::new(scope, render_app).cast::<v8::Function>();
v8::Global::new(scope, func)
};
Ok(Self { runtime, render_fn })
}
/// Render to HTML by calling the bundle's `renderApp(props)`. `props_json`
/// is a JSON object string; it is parsed to a V8 value and passed as an
/// argument — never spliced into evaluated source.
pub fn render(&mut self, props_json: &str) -> Result<String> {
let render_fn = self.render_fn.clone();
deno_core::scope!(scope, &mut self.runtime);
let context = scope.get_current_context();
let global = context.global(scope);
let key = v8::String::new(scope, "renderApp").context("intern renderApp key")?;
let func_val = global
.get(scope, key.into())
.ok_or_else(|| anyhow!("renderApp is not defined on globalThis"))?;
let func: v8::Local<v8::Function> = func_val
.try_into()
.map_err(|_| anyhow!("renderApp is not a function"))?;
let props_str = v8::String::new(scope, props_json).context("intern props")?;
let props = v8::json::parse(scope, props_str)
.ok_or_else(|| anyhow!("props are not valid JSON"))?;
let func = v8::Local::new(scope, &render_fn);
let props = parse_props(scope, props_json)?;
let recv = v8::undefined(scope).into();
let result = func
let html = func
.call(scope, recv, &[props])
.ok_or_else(|| anyhow!("renderApp threw or returned nothing"))?;
Ok(result.to_rust_string_lossy(scope))
Ok(html.to_rust_string_lossy(scope))
}
}
/// The one crossing where untrusted request text becomes a value inside the
/// isolate. Both steps report that boundary's failure and nothing else: V8
/// refuses a string past its length limit, and its JSON grammar rejects
/// malformed input.
fn parse_props<'s>(
scope: &v8::PinScope<'s, '_>,
props_json: &str,
) -> Result<v8::Local<'s, v8::Value>> {
let text = v8::String::new(scope, props_json)
.ok_or_else(|| anyhow!("props exceed V8's maximum string length"))?;
v8::json::parse(scope, text).ok_or_else(|| anyhow!("props are not valid JSON"))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn renders_react_bundle_in_embedded_v8() {
let bundle = std::fs::read_to_string(concat!(
fn fixture_bundle() -> String {
std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixture/bundle.js"
))
.expect("tests/fixture/bundle.js — build it via the fixture's esbuild step");
.expect("tests/fixture/bundle.js — build it via the fixture's esbuild step")
}
let mut engine = SsrEngine::new(bundle).expect("engine init");
#[tokio::test]
async fn renders_react_bundle_in_embedded_v8() {
let mut engine = SsrEngine::new(fixture_bundle()).expect("engine init");
let html = engine.render(r#"{"name":"World"}"#).expect("render");
assert_eq!(html, r#"<div id="greeting">Hello, World!</div>"#);
}
@@ -117,17 +142,19 @@ mod tests {
// A prop value that would break out of a string-built `renderApp(...)`
// call. Through the value-call path it is inert data: it reaches the
// component as a string, never as source.
let bundle = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixture/bundle.js"
))
.expect("fixture bundle");
let mut engine = SsrEngine::new(bundle).expect("engine init");
let mut engine = SsrEngine::new(fixture_bundle()).expect("engine init");
let html = engine
.render(r#"{"name":"x\"}); globalThis.__pwned = true; ({\"y\":\""}"#)
.expect("render");
// The payload rendered as text; it did not execute.
assert!(html.contains("__pwned"));
}
#[tokio::test]
async fn a_bundle_without_a_callable_render_app_is_rejected() {
let err = SsrEngine::new("globalThis.renderApp = 7;".to_string())
.map(|_| ())
.expect_err("a bundle whose renderApp is not callable must not build an engine");
assert!(err.to_string().contains("renderApp"), "unexpected: {err}");
}
}

View File

@@ -1,7 +1,6 @@
import { createElement } from "react"
// A trivial component: props in, element out. The keystone only needs to prove
// a real React tree renders to HTML inside a bare JS context.
// Props in, element out. The `id` is the handle the render assertions match on.
export function Hello({ name }) {
return createElement("div", { id: "greeting" }, `Hello, ${name}!`)
}

View File

@@ -2,7 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server.browser"
import { createElement } from "react"
import { Hello } from "./Hello.js"
// The bundle exposes one global the embedded engine calls. No module system at
// runtime — the engine receives a bare script that defines `renderApp`. This is
// the production shape in miniature: build-time bundle, runtime eval.
// There is no module system in the embedded engine — it receives a bare
// script, so the entry point has to land on `globalThis` for the Rust side to
// reach it.
globalThis.renderApp = (props) => renderToStaticMarkup(createElement(Hello, props))

View File

@@ -1,14 +1,11 @@
// Proxy for the embedded-V8 runtime: a bare global context with no Node
// builtins. Load the IIFE bundle (which assigns globalThis.renderApp) and call
// it. What renders here renders in rusty_v8 — the engine swaps, the contract
// (bundle defines a global render fn over a bare context) does not.
// Runs bundle.js inside a `vm` context holding only the globals listed below,
// so the bundle sees the same bare environment the embedded V8 engine gives it.
const fs = require("fs")
const vm = require("vm")
const code = fs.readFileSync(__dirname + "/bundle.js", "utf8")
// The minimal host globals React's bundle touches at init / sync render. The
// rusty_v8 engine must provide the same set — this list is the spec for it.
// The host globals React's bundle touches at init and during a sync render.
const sandbox = {
console, setTimeout, clearTimeout, queueMicrotask, MessageChannel, performance,
TextEncoder, TextDecoder,
@@ -19,11 +16,12 @@ vm.createContext(sandbox)
vm.runInContext(code, sandbox)
const html = sandbox.renderApp({ name: "World" })
console.log("RENDERED:", html)
const expected = '<div id="greeting">Hello, World!</div>'
if (html !== expected) {
console.error("MISMATCH — expected:", expected)
console.error(`expected ${expected}, got ${html}`)
process.exit(1)
}
console.log("OK — React bundle renders in a bare JS context (V8 proxy)")
console.log(html)
// The sandbox's MessageChannel holds an open handle, so the event loop never
// drains on its own; exit once the render has been checked.
process.exit(0)

View File

@@ -1,29 +1,19 @@
//! Guard — Mizan SSR is hand-rolled (bare renderer + AFI data injection +
//! injected kernel). No frontend adapter imports an SSR runtime / meta-framework
//! (Next, Nuxt, SvelteKit) or a server-functions layer (RSC / Flight).
//!
//! React Server Components and the Flight serialization protocol carry
//! CVE-2025-55182 ("React2Shell" — unauthenticated remote code execution,
//! CVSS 10.0): the server deserializes a client-supplied Flight payload and an
//! attacker reaches prototype-pollution → RCE.
//!
//! Mizan renders **synchronously from props** — data is fetched server-side
//! through the AFI and passed in, never deserialized from a client payload — so
//! it sits structurally outside that attack surface. This test keeps it there:
//! it goes red the instant any RSC / Flight / streaming surface enters the
//! authored SSR source or its dependencies. Absence is not enough; this is the
//! forcing function that makes re-entry loud.
//! Scans the SSR fixture's authored JS for tokens that only appear when React
//! Server Components, the Flight protocol, or a meta-framework SSR runtime is
//! in play. The scan goes red the moment one of them enters the source.
use std::path::Path;
/// Tokens that only appear when RSC / Flight / streaming rendering is in play.
const FORBIDDEN: &[&str] = &[
// React Server Components / Flight — CVE-2025-55182 (pre-auth RCE, CVSS 10.0)
// React Server Components / Flight
"react-server-dom",
"renderToReadableStream",
"renderToPipeableStream",
"createFromReadableStream",
"createFromFetch",
"use server",
// SSR runtimes / meta-frameworks — forbidden across every frontend adapter
// SSR runtimes / meta-frameworks
"next/",
"nuxt",
"@sveltejs/kit",
@@ -39,15 +29,16 @@ const SCANNED: &[&str] = &[
#[test]
fn ssr_has_no_rsc_or_flight_surface() {
for path in SCANNED {
let Ok(src) = std::fs::read_to_string(path) else {
continue; // a generated/optional file absent is fine; authored source is the point
};
assert!(
Path::new(path).is_file(),
"{path} is a tracked fixture this scan reads; it is missing",
);
let src = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("reading {path} for the RSC scan: {e}"));
for needle in FORBIDDEN {
assert!(
!src.contains(needle),
"RSC/Flight surface {needle:?} found in {path} — forbidden. \
RSC carries CVE-2025-55182 (unauth RCE, CVSS 10.0); Mizan SSR is \
classic renderToString-family only, rendered synchronously from props.",
"{needle:?} found in {path}; this scan forbids it",
);
}
}

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"]);
}