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:
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
16
cores/mizan-python/src/mizan_core/cache/keys.py
vendored
16
cores/mizan-python/src/mizan_core/cache/keys.py
vendored
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
121
cores/mizan-python/src/mizan_core/templates/ir/document.kdl.j2
Normal file
121
cores/mizan-python/src/mizan_core/templates/ir/document.kdl.j2
Normal 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 %}
|
||||
@@ -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
|
||||
|
||||
107
cores/mizan-python/tests/test_ir.py
Normal file
107
cores/mizan-python/tests/test_ir.py
Normal 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)
|
||||
111
cores/mizan-rust-macros/src/channel.rs
Normal file
111
cores/mizan-rust-macros/src/channel.rs
Normal 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),*
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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),*
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}!`)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
139
cores/mizan-rust/Cargo.lock
generated
139
cores/mizan-rust/Cargo.lock
generated
@@ -13,6 +13,18 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@@ -34,6 +46,17 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "kdl"
|
||||
version = "6.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81a29e7b50079ff44549f68c0becb1c73d7f6de2a4ea952da77966daf3d4761e"
|
||||
dependencies = [
|
||||
"miette",
|
||||
"num",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linkme"
|
||||
version = "0.3.36"
|
||||
@@ -60,13 +83,41 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "memo-map"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
|
||||
|
||||
[[package]]
|
||||
name = "miette"
|
||||
version = "7.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minijinja"
|
||||
version = "2.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39"
|
||||
dependencies = [
|
||||
"memo-map",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mizan-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"indoc",
|
||||
"kdl",
|
||||
"linkme",
|
||||
"minijinja",
|
||||
"mizan-macros",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -82,6 +133,79 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -166,6 +290,21 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.6.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
name = "mizan-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Mizan server-side IR substrate — types, traits, KDL emitter, registry. Rust analog of cores/mizan-python/src/mizan_core/."
|
||||
description = "Mizan server-side IR substrate — types, traits, KDL emitter, registry."
|
||||
license = "Elastic-2.0"
|
||||
|
||||
[dependencies]
|
||||
linkme = "0.3"
|
||||
minijinja = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
async-trait = "0.1"
|
||||
@@ -14,3 +15,4 @@ mizan-macros = { path = "../mizan-rust-macros" }
|
||||
|
||||
[dev-dependencies]
|
||||
indoc = "2"
|
||||
kdl = "6"
|
||||
|
||||
@@ -1,200 +1,313 @@
|
||||
//! Cross-function invariant verification — fails at `build_ir()` time, which
|
||||
//! runs at the codegen subprocess (`cargo run --bin export-ir`). All
|
||||
//! graph-level inconsistencies surface before any client artifact is emitted.
|
||||
//! Cross-function invariant checks over the registered graph.
|
||||
|
||||
use crate::ir::{AffectTarget, NamedType, StructField, TypeShape};
|
||||
use crate::registry::{lookup_context, CONTEXTS, FUNCTIONS, TYPES};
|
||||
use crate::ir::{NamedType, Primitive, TypeShape};
|
||||
use crate::registry::{CONTEXTS, FUNCTIONS};
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Walk the registered types and find the named type's shape. Used by both
|
||||
/// graph-check and runtime merge resolution.
|
||||
pub(crate) fn resolve_type_shape(name: &str) -> Option<NamedType> {
|
||||
for entry in TYPES {
|
||||
if entry.name == name {
|
||||
return Some((entry.shape_fn)());
|
||||
}
|
||||
}
|
||||
None
|
||||
/// A structural fingerprint of a type, with every reference resolved through
|
||||
/// to the shape it names. Two types are interchangeable exactly when their
|
||||
/// fingerprints are equal, so comparison is one derived `==` instead of a
|
||||
/// pairwise walk over both shape enums.
|
||||
#[derive(PartialEq)]
|
||||
enum Canonical {
|
||||
Record(Vec<CanonicalField>),
|
||||
Aliased(Box<Canonical>),
|
||||
NamedEnum(Vec<&'static str>),
|
||||
Primitive(&'static str),
|
||||
List(Box<Canonical>),
|
||||
Optional(Box<Canonical>),
|
||||
InlineEnum(Vec<&'static str>),
|
||||
Union(Vec<Canonical>),
|
||||
}
|
||||
|
||||
/// Merge-compatibility on named types. A mutation return `value` can
|
||||
/// splice into a context slot `slot` when any of three shapes hold —
|
||||
/// matches Python's `types_match_for_merge`:
|
||||
/// * direct: `slot` shape equals `value` shape → replace
|
||||
/// * upsert: `slot` is `list[T]`, `value` is `T` → upsert by id
|
||||
/// * list-replace: `slot` is `list[T]`, `value` is `list[T]`
|
||||
#[derive(PartialEq)]
|
||||
struct CanonicalField {
|
||||
name: &'static str,
|
||||
required: bool,
|
||||
shape: Canonical,
|
||||
}
|
||||
|
||||
fn canonical_named(named: &NamedType) -> Canonical {
|
||||
match named {
|
||||
NamedType::Struct(fields) => Canonical::Record(
|
||||
fields
|
||||
.iter()
|
||||
.map(|f| CanonicalField {
|
||||
name: f.name,
|
||||
required: f.required,
|
||||
shape: canonical_shape(&f.shape),
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
NamedType::Alias(inner) => Canonical::Aliased(Box::new(canonical_shape(inner))),
|
||||
NamedType::Enum(variants) => Canonical::NamedEnum(variants.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_shape(shape: &TypeShape) -> Canonical {
|
||||
match shape {
|
||||
TypeShape::Primitive(p) => Canonical::Primitive(p.name()),
|
||||
TypeShape::Ref { shape, .. } => canonical_named(&shape()),
|
||||
TypeShape::List(inner) => Canonical::List(Box::new(canonical_shape(inner))),
|
||||
TypeShape::Optional(inner) => Canonical::Optional(Box::new(canonical_shape(inner))),
|
||||
TypeShape::Enum(variants) => Canonical::InlineEnum(variants.clone()),
|
||||
TypeShape::Union(branches) => {
|
||||
Canonical::Union(branches.iter().map(canonical_shape).collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge-compatibility on named types. A mutation return `value` can splice
|
||||
/// into a context slot `slot` when either shape holds:
|
||||
/// * direct: `slot` and `value` have the same fingerprint → replace
|
||||
/// * upsert: `slot` is `list[T]` and `value` is `T` → upsert by id
|
||||
///
|
||||
/// The first argument is the slot (context member's output type); the
|
||||
/// second is the value (mutation's output type).
|
||||
pub(crate) fn types_match(slot: &NamedType, value: &NamedType) -> bool {
|
||||
if named_shapes_equal(slot, value) {
|
||||
/// The first argument is the slot (context member's output type); the second
|
||||
/// is the value (mutation's output type).
|
||||
fn types_match(slot: &NamedType, value: &NamedType) -> bool {
|
||||
let value_form = canonical_named(value);
|
||||
if canonical_named(slot) == value_form {
|
||||
return true;
|
||||
}
|
||||
// Upsert: slot is `Alias(List(T))`, value is `T`-shaped.
|
||||
if let NamedType::Alias(TypeShape::List(elem)) = slot {
|
||||
if shape_matches_named(elem, value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn named_shapes_equal(a: &NamedType, b: &NamedType) -> bool {
|
||||
match (a, b) {
|
||||
(NamedType::Struct(fa), NamedType::Struct(fb)) => fields_match(fa, fb),
|
||||
(NamedType::Alias(sa), NamedType::Alias(sb)) => shapes_match(sa, sb),
|
||||
(NamedType::Enum(va), NamedType::Enum(vb)) => va == vb,
|
||||
_ => false,
|
||||
match slot {
|
||||
NamedType::Alias(inner) => match inner {
|
||||
TypeShape::List(elem) => canonical_shape(elem) == value_form,
|
||||
TypeShape::Primitive(_)
|
||||
| TypeShape::Ref { .. }
|
||||
| TypeShape::Optional(_)
|
||||
| TypeShape::Enum(_)
|
||||
| TypeShape::Union(_) => false,
|
||||
},
|
||||
NamedType::Struct(_) | NamedType::Enum(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when a `TypeShape` (the slot's list-element) describes the same
|
||||
/// shape as a `NamedType` (the mutation's full output).
|
||||
fn shape_matches_named(shape: &TypeShape, named: &NamedType) -> bool {
|
||||
match shape {
|
||||
TypeShape::Ref(name) => {
|
||||
if let Some(referenced) = resolve_type_shape(name) {
|
||||
named_shapes_equal(&referenced, named)
|
||||
} else {
|
||||
false
|
||||
/// One `merge` declaration read off the registry and resolved: the mutation
|
||||
/// that declares it, the context it names, and the context member whose output
|
||||
/// the mutation's return value splices into.
|
||||
pub(crate) struct ResolvedMerge {
|
||||
pub function: &'static str,
|
||||
pub context: &'static str,
|
||||
pub slot: &'static str,
|
||||
}
|
||||
|
||||
/// The context members whose output a mutation's return value can splice into,
|
||||
/// accumulated one candidate at a time. A `merge` declaration carries a usable
|
||||
/// slot exactly when the walk ends on `Unique`.
|
||||
enum SlotMatch {
|
||||
Absent,
|
||||
Unique(&'static str),
|
||||
Ambiguous(Vec<&'static str>),
|
||||
}
|
||||
|
||||
impl SlotMatch {
|
||||
fn with(self, candidate: &'static str) -> Self {
|
||||
match self {
|
||||
SlotMatch::Absent => SlotMatch::Unique(candidate),
|
||||
SlotMatch::Unique(first) => SlotMatch::Ambiguous(vec![first, candidate]),
|
||||
SlotMatch::Ambiguous(mut members) => {
|
||||
members.push(candidate);
|
||||
SlotMatch::Ambiguous(members)
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn fields_match(a: &[StructField], b: &[StructField]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter().zip(b.iter()).all(|(fa, fb)| {
|
||||
fa.name == fb.name && fa.required == fb.required && shapes_match(&fa.shape, &fb.shape)
|
||||
})
|
||||
/// The ways a registered graph fails to hold together.
|
||||
enum GraphDefect {
|
||||
NoMergeSlot {
|
||||
function: &'static str,
|
||||
context: &'static str,
|
||||
output_type: &'static str,
|
||||
},
|
||||
AmbiguousMergeSlot {
|
||||
function: &'static str,
|
||||
context: &'static str,
|
||||
output_type: &'static str,
|
||||
members: Vec<&'static str>,
|
||||
},
|
||||
DivergentParamType {
|
||||
context: &'static str,
|
||||
param: &'static str,
|
||||
first_fn: &'static str,
|
||||
first_type: &'static str,
|
||||
second_fn: &'static str,
|
||||
second_type: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
fn shapes_match(a: &TypeShape, b: &TypeShape) -> bool {
|
||||
match (a, b) {
|
||||
(TypeShape::Primitive(pa), TypeShape::Primitive(pb)) => {
|
||||
std::mem::discriminant(pa) == std::mem::discriminant(pb)
|
||||
impl fmt::Display for GraphDefect {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
GraphDefect::NoMergeSlot {
|
||||
function,
|
||||
context,
|
||||
output_type,
|
||||
} => write!(
|
||||
f,
|
||||
"function `{function}` declares `merge = \"{context}\"` but no member of that \
|
||||
context has output type `{output_type}`. Add a context member returning \
|
||||
`{output_type}`, or declare `affects` for plain refetch."
|
||||
),
|
||||
GraphDefect::AmbiguousMergeSlot {
|
||||
function,
|
||||
context,
|
||||
output_type,
|
||||
members,
|
||||
} => write!(
|
||||
f,
|
||||
"function `{function}` declares `merge = \"{context}\"` but members ({}) all \
|
||||
share output type `{output_type}`. Merge resolution needs exactly one match. \
|
||||
Distinguish the outputs, or declare `affects` for plain refetch.",
|
||||
members.join(", ")
|
||||
),
|
||||
GraphDefect::DivergentParamType {
|
||||
context,
|
||||
param,
|
||||
first_fn,
|
||||
first_type,
|
||||
second_fn,
|
||||
second_type,
|
||||
} => write!(
|
||||
f,
|
||||
"context `{context}` has a parameter `{param}` whose type diverges across \
|
||||
members. Function `{first_fn}` declares it as `{first_type}`, function \
|
||||
`{second_fn}` declares it as `{second_type}`. A shared param has one type \
|
||||
across the whole context."
|
||||
),
|
||||
}
|
||||
(TypeShape::Ref(na), TypeShape::Ref(nb)) => {
|
||||
// Refs match iff the named types they reference match.
|
||||
match (resolve_type_shape(na), resolve_type_shape(nb)) {
|
||||
(Some(ta), Some(tb)) => types_match(&ta, &tb),
|
||||
_ => na == nb,
|
||||
}
|
||||
}
|
||||
(TypeShape::List(ia), TypeShape::List(ib)) => shapes_match(ia, ib),
|
||||
(TypeShape::Optional(ia), TypeShape::Optional(ib)) => shapes_match(ia, ib),
|
||||
(TypeShape::Enum(va), TypeShape::Enum(vb)) => va == vb,
|
||||
(TypeShape::Union(ba), TypeShape::Union(bb)) => {
|
||||
ba.len() == bb.len() && ba.iter().zip(bb.iter()).all(|(x, y)| shapes_match(x, y))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Panic with a structured message if the registered function graph is
|
||||
/// inconsistent. Called from `build_ir()`.
|
||||
pub fn verify_invariants() {
|
||||
check_affects_targets();
|
||||
check_merge_targets();
|
||||
check_shared_param_types();
|
||||
/// Every defect on its own bulleted line, under one heading.
|
||||
struct GraphReport<'a>(&'a [GraphDefect]);
|
||||
|
||||
impl fmt::Display for GraphReport<'_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
writeln!(
|
||||
f,
|
||||
"Mizan graph-check: the registered function graph is inconsistent."
|
||||
)?;
|
||||
for defect in self.0 {
|
||||
writeln!(f, " - {defect}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn check_affects_targets() {
|
||||
/// Every `merge` declaration that resolved to exactly one slot, plus every way
|
||||
/// the graph failed to hold together.
|
||||
struct GraphAnalysis {
|
||||
merges: Vec<ResolvedMerge>,
|
||||
defects: Vec<GraphDefect>,
|
||||
}
|
||||
|
||||
static ANALYSIS: OnceLock<GraphAnalysis> = OnceLock::new();
|
||||
|
||||
/// `FUNCTIONS` and `CONTEXTS` are link-time data, so the walk yields the same
|
||||
/// answer for every caller and runs once.
|
||||
fn analysis() -> &'static GraphAnalysis {
|
||||
ANALYSIS.get_or_init(analyze)
|
||||
}
|
||||
|
||||
fn analyze() -> GraphAnalysis {
|
||||
let mut merges = Vec::new();
|
||||
let mut defects = Vec::new();
|
||||
for fn_spec in FUNCTIONS {
|
||||
for affect in fn_spec.affects() {
|
||||
if let AffectTarget::Context(name) = affect {
|
||||
if lookup_context(name).is_none() {
|
||||
panic!(
|
||||
"Mizan graph-check: function `{}` declares `affects = \"{}\"` but no context with that name is registered. \
|
||||
Either register a context with that name (via `#[mizan::context(\"{}\")]`) or remove the affects target.",
|
||||
fn_spec.name(),
|
||||
name,
|
||||
name,
|
||||
);
|
||||
}
|
||||
let mutation_shape = fn_spec.output_shape();
|
||||
for &context in fn_spec.merge() {
|
||||
match match_slot(context, &mutation_shape) {
|
||||
SlotMatch::Unique(slot) => merges.push(ResolvedMerge {
|
||||
function: fn_spec.name(),
|
||||
context,
|
||||
slot,
|
||||
}),
|
||||
SlotMatch::Absent => defects.push(GraphDefect::NoMergeSlot {
|
||||
function: fn_spec.name(),
|
||||
context,
|
||||
output_type: fn_spec.output_type(),
|
||||
}),
|
||||
SlotMatch::Ambiguous(members) => defects.push(GraphDefect::AmbiguousMergeSlot {
|
||||
function: fn_spec.name(),
|
||||
context,
|
||||
output_type: fn_spec.output_type(),
|
||||
members,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
defects.extend(divergent_param_types());
|
||||
GraphAnalysis { merges, defects }
|
||||
}
|
||||
|
||||
fn check_merge_targets() {
|
||||
for fn_spec in FUNCTIONS {
|
||||
for merge_target in fn_spec.merge() {
|
||||
let ctx_entry = match lookup_context(merge_target) {
|
||||
Some(c) => c,
|
||||
None => panic!(
|
||||
"Mizan graph-check: function `{}` declares `merge = \"{}\"` but no context with that name is registered.",
|
||||
fn_spec.name(),
|
||||
merge_target,
|
||||
),
|
||||
};
|
||||
|
||||
let mutation_output = fn_spec.output_type();
|
||||
let mutation_shape = match resolve_type_shape(mutation_output) {
|
||||
Some(s) => s,
|
||||
None => panic!(
|
||||
"Mizan graph-check: function `{}` has output type `{}` but no such named type is registered.",
|
||||
fn_spec.name(), mutation_output,
|
||||
),
|
||||
};
|
||||
let mut matches: Vec<&'static str> = Vec::new();
|
||||
for candidate in FUNCTIONS {
|
||||
if candidate.context() != Some(ctx_entry.name) {
|
||||
continue;
|
||||
}
|
||||
if let Some(candidate_shape) = resolve_type_shape(candidate.output_type()) {
|
||||
if types_match(&candidate_shape, &mutation_shape) {
|
||||
matches.push(candidate.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matches.is_empty() {
|
||||
panic!(
|
||||
"Mizan graph-check: function `{}` declares `merge = \"{}\"` but no member of that context has output type `{}`. \
|
||||
Add a context member returning `{}`, or remove the merge declaration in favor of `affects` for plain refetch.",
|
||||
fn_spec.name(), merge_target, mutation_output, mutation_output,
|
||||
);
|
||||
}
|
||||
if matches.len() > 1 {
|
||||
panic!(
|
||||
"Mizan graph-check: function `{}` declares `merge = \"{}\"` but multiple members ({}) share output type `{}`. \
|
||||
Merge resolution requires exactly one match. Distinguish the outputs or use `affects` for refetch.",
|
||||
fn_spec.name(), merge_target, matches.join(", "), mutation_output,
|
||||
);
|
||||
}
|
||||
/// The members of `context_name` whose output type a value of `mutation_shape`
|
||||
/// splices into.
|
||||
fn match_slot(context_name: &'static str, mutation_shape: &NamedType) -> SlotMatch {
|
||||
let mut matched = SlotMatch::Absent;
|
||||
for candidate in FUNCTIONS {
|
||||
if candidate.context() != Some(context_name) {
|
||||
continue;
|
||||
}
|
||||
if types_match(&candidate.output_shape(), mutation_shape) {
|
||||
matched = matched.with(candidate.name());
|
||||
}
|
||||
}
|
||||
matched
|
||||
}
|
||||
|
||||
fn check_shared_param_types() {
|
||||
/// Params that one context's members declare under the same name but with
|
||||
/// different primitives.
|
||||
fn divergent_param_types() -> Vec<GraphDefect> {
|
||||
let mut defects = Vec::new();
|
||||
for ctx in CONTEXTS {
|
||||
let mut by_name: std::collections::HashMap<&'static str, (crate::ir::Primitive, &'static str)>
|
||||
= std::collections::HashMap::new();
|
||||
let mut by_name: HashMap<&'static str, (Primitive, &'static str)> = HashMap::new();
|
||||
for fn_spec in FUNCTIONS {
|
||||
if fn_spec.context() != Some(ctx.name) {
|
||||
continue;
|
||||
}
|
||||
for p in fn_spec.input_params() {
|
||||
if let Some((prev_primitive, prev_fn)) = by_name.get(p.name) {
|
||||
if std::mem::discriminant(prev_primitive)
|
||||
!= std::mem::discriminant(&p.primitive)
|
||||
{
|
||||
panic!(
|
||||
"Mizan graph-check: context `{}` has a parameter `{}` whose type diverges across members. \
|
||||
Function `{}` declares it as `{}`, function `{}` declares it as `{}`. \
|
||||
Shared params must have one type across the whole context.",
|
||||
ctx.name, p.name,
|
||||
prev_fn, prev_primitive.name(),
|
||||
fn_spec.name(), p.primitive.name(),
|
||||
);
|
||||
match by_name.entry(p.name) {
|
||||
Entry::Occupied(seen) => {
|
||||
let (first_primitive, first_fn) = *seen.get();
|
||||
if first_primitive != p.primitive {
|
||||
defects.push(GraphDefect::DivergentParamType {
|
||||
context: ctx.name,
|
||||
param: p.name,
|
||||
first_fn,
|
||||
first_type: first_primitive.name(),
|
||||
second_fn: fn_spec.name(),
|
||||
second_type: p.primitive.name(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Entry::Vacant(slot) => {
|
||||
slot.insert((p.primitive, fn_spec.name()));
|
||||
}
|
||||
} else {
|
||||
by_name.insert(p.name, (p.primitive, fn_spec.name()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
defects
|
||||
}
|
||||
|
||||
/// Panic with the full defect report when the registered function graph is
|
||||
/// inconsistent.
|
||||
pub fn verify_invariants() {
|
||||
let defects = &analysis().defects;
|
||||
if !defects.is_empty() {
|
||||
panic!("{}", GraphReport(defects));
|
||||
}
|
||||
}
|
||||
|
||||
/// The merges `function` declares. Reading them verifies the graph first, so a
|
||||
/// declaration that resolved to no slot is reported rather than passed over.
|
||||
pub(crate) fn merges_for(function: &str) -> impl Iterator<Item = &'static ResolvedMerge> + '_ {
|
||||
verify_invariants();
|
||||
analysis()
|
||||
.merges
|
||||
.iter()
|
||||
.filter(move |resolved| resolved.function == function)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
//! IR data model — mirrors `cores/mizan-python/src/mizan_core/ir.py` 1:1.
|
||||
//!
|
||||
//! The IR is the contract. Backends emit it; codegen consumes it. The Rust
|
||||
//! side produces byte-equivalent KDL to the Python emitter against the same
|
||||
//! function registry.
|
||||
//! The IR data model the KDL emitter walks: named types, inline type shapes,
|
||||
//! and the descriptors a registered function or channel carries.
|
||||
|
||||
/// A named type that appears in the IR's `type "<Name>" { ... }` section.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NamedType {
|
||||
/// `type "X" { struct { field ... } }` — a Pydantic-model-shaped record.
|
||||
/// `type "X" { struct { field ... } }` — a record.
|
||||
Struct(Vec<StructField>),
|
||||
/// `type "X" { alias { <type-child> } }` — a named wrapper around an
|
||||
/// inline type shape, e.g. `userOrdersOutput = list[OrderOutput]`.
|
||||
@@ -21,14 +18,20 @@ pub enum NamedType {
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TypeShape {
|
||||
Primitive(Primitive),
|
||||
Ref(&'static str),
|
||||
/// A reference to a named type. `shape` is the referent's own shape
|
||||
/// constructor, so resolving a reference never consults a registry and
|
||||
/// never fails.
|
||||
Ref {
|
||||
name: &'static str,
|
||||
shape: fn() -> NamedType,
|
||||
},
|
||||
List(Box<TypeShape>),
|
||||
Optional(Box<TypeShape>),
|
||||
Enum(Vec<&'static str>),
|
||||
Union(Vec<TypeShape>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Primitive {
|
||||
Integer,
|
||||
Number,
|
||||
@@ -64,8 +67,8 @@ pub enum DefaultValue {
|
||||
Null,
|
||||
}
|
||||
|
||||
/// One descriptor of what a mutation `affects`. Mirrors Python's
|
||||
/// `_normalize_affects` shape — either a named context or a named function.
|
||||
/// One descriptor of what a mutation `affects` — either a named context or a
|
||||
/// named function.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AffectTarget {
|
||||
Context(&'static str),
|
||||
@@ -75,6 +78,37 @@ pub enum AffectTarget {
|
||||
},
|
||||
}
|
||||
|
||||
/// One payload slot of a channel. Direction is named from the client's point
|
||||
/// of view: a `ClientMessage` travels client → server, a `ServerMessage`
|
||||
/// travels server → client.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChannelSlotKind {
|
||||
Params,
|
||||
ClientMessage,
|
||||
ServerMessage,
|
||||
}
|
||||
|
||||
impl ChannelSlotKind {
|
||||
/// The KDL child-node name the slot emits under.
|
||||
pub fn node_name(self) -> &'static str {
|
||||
match self {
|
||||
ChannelSlotKind::Params => "params",
|
||||
ChannelSlotKind::ClientMessage => "client-message",
|
||||
ChannelSlotKind::ServerMessage => "server-message",
|
||||
}
|
||||
}
|
||||
|
||||
/// The suffix appended to the channel's Pascal stem to name the slot's
|
||||
/// emitted type.
|
||||
pub fn type_suffix(self) -> &'static str {
|
||||
match self {
|
||||
ChannelSlotKind::Params => "Params",
|
||||
ChannelSlotKind::ClientMessage => "ClientMessage",
|
||||
ChannelSlotKind::ServerMessage => "ServerMessage",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Transport {
|
||||
Http,
|
||||
|
||||
@@ -1,60 +1,141 @@
|
||||
//! KDL emitter — byte-equivalent to `cores/mizan-python/src/mizan_core/ir.py`.
|
||||
//!
|
||||
//! The Python emitter is the spec; this is the second implementation under
|
||||
//! the same contract. Any divergence is a bug here, not a contract change.
|
||||
//! KDL emitter — collects the registries (named types, functions, contexts,
|
||||
//! channels) into a KDL node tree and renders it through
|
||||
//! `templates/ir.kdl.jinja`.
|
||||
|
||||
use crate::ir::{DefaultValue, NamedType, Primitive, StructField, TypeShape};
|
||||
use crate::registry::{CONTEXTS, FUNCTIONS, TYPES};
|
||||
use crate::ir::{
|
||||
AffectTarget, ChannelSlotKind, DefaultValue, NamedType, Primitive, StructField, TypeShape,
|
||||
};
|
||||
use crate::registry::{CHANNELS, CONTEXTS, FUNCTIONS, TYPES};
|
||||
use crate::traits::FunctionSpec;
|
||||
use minijinja::value::ViaDeserialize;
|
||||
use minijinja::{context, Environment};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const INDENT: &str = " ";
|
||||
const IR_TEMPLATE: &str = include_str!("../templates/ir.kdl.jinja");
|
||||
|
||||
/// Escape a string for KDL — same escape set as the Python emitter.
|
||||
fn kdl_string(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'"' => out.push_str("\\\""),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
other => out.push(other),
|
||||
/// A KDL scalar, carried structurally so the template's `kdl` filter — not
|
||||
/// the node builders — decides its written form.
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[serde(tag = "kind", content = "v")]
|
||||
enum KdlValue {
|
||||
Str(String),
|
||||
Bool(bool),
|
||||
Integer(i64),
|
||||
Number(f64),
|
||||
Null,
|
||||
}
|
||||
|
||||
impl KdlValue {
|
||||
fn str(s: &str) -> Self {
|
||||
KdlValue::Str(s.to_string())
|
||||
}
|
||||
|
||||
fn of_default(v: &DefaultValue) -> Self {
|
||||
match v {
|
||||
DefaultValue::Null => KdlValue::Null,
|
||||
DefaultValue::Boolean(b) => KdlValue::Bool(*b),
|
||||
DefaultValue::Integer(i) => KdlValue::Integer(*i),
|
||||
DefaultValue::Number(f) => KdlValue::Number(*f),
|
||||
DefaultValue::String(s) => KdlValue::str(s),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
fn kdl_bool(b: bool) -> &'static str {
|
||||
if b {
|
||||
"#true"
|
||||
} else {
|
||||
"#false"
|
||||
#[derive(Serialize)]
|
||||
struct KdlProp {
|
||||
name: &'static str,
|
||||
value: KdlValue,
|
||||
}
|
||||
|
||||
/// One KDL node: its own line, plus a brace-delimited child block when
|
||||
/// `block` is set. `indent` is the literal prefix its line carries.
|
||||
#[derive(Serialize)]
|
||||
struct KdlNode {
|
||||
indent: String,
|
||||
name: &'static str,
|
||||
args: Vec<KdlValue>,
|
||||
props: Vec<KdlProp>,
|
||||
block: bool,
|
||||
children: Vec<KdlNode>,
|
||||
}
|
||||
|
||||
impl KdlNode {
|
||||
fn new(depth: usize, name: &'static str) -> Self {
|
||||
Self {
|
||||
indent: INDENT.repeat(depth),
|
||||
name,
|
||||
args: Vec::new(),
|
||||
props: Vec::new(),
|
||||
block: false,
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn arg(mut self, value: KdlValue) -> Self {
|
||||
self.args.push(value);
|
||||
self
|
||||
}
|
||||
|
||||
fn args(mut self, values: impl IntoIterator<Item = KdlValue>) -> Self {
|
||||
self.args.extend(values);
|
||||
self
|
||||
}
|
||||
|
||||
fn prop(mut self, name: &'static str, value: KdlValue) -> Self {
|
||||
self.props.push(KdlProp { name, value });
|
||||
self
|
||||
}
|
||||
|
||||
fn block(mut self, children: Vec<KdlNode>) -> Self {
|
||||
self.block = true;
|
||||
self.children = children;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn kdl_default(v: &DefaultValue) -> String {
|
||||
match v {
|
||||
DefaultValue::Null => "#null".into(),
|
||||
DefaultValue::Boolean(b) => kdl_bool(*b).into(),
|
||||
DefaultValue::Integer(i) => i.to_string(),
|
||||
DefaultValue::Number(f) => {
|
||||
// Match Python's `repr(float)` for whole-number-equal-but-float
|
||||
// values: e.g. 1.0 → "1.0", not "1".
|
||||
/// The `kdl` template filter — writes one scalar in KDL surface syntax.
|
||||
fn render_kdl_value(value: ViaDeserialize<KdlValue>) -> String {
|
||||
match &*value {
|
||||
KdlValue::Str(s) => {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'"' => out.push_str("\\\""),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
KdlValue::Bool(b) => {
|
||||
if *b {
|
||||
"#true".to_string()
|
||||
} else {
|
||||
"#false".to_string()
|
||||
}
|
||||
}
|
||||
KdlValue::Integer(i) => i.to_string(),
|
||||
KdlValue::Number(f) => {
|
||||
// A whole-valued float still writes with its fractional part, so
|
||||
// `1.0` does not collapse into the integer spelling `1`.
|
||||
if f.fract() == 0.0 && f.is_finite() {
|
||||
format!("{f:.1}")
|
||||
} else {
|
||||
f.to_string()
|
||||
}
|
||||
}
|
||||
DefaultValue::String(s) => kdl_string(s),
|
||||
KdlValue::Null => "#null".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert snake_case to camelCase. Matches Python's `_snake_to_camel`.
|
||||
/// Convert snake_case to camelCase.
|
||||
pub fn snake_to_camel(name: &str) -> String {
|
||||
let normalized = name.replace('.', "_").replace('-', "_");
|
||||
let mut parts = normalized.split('_');
|
||||
@@ -75,208 +156,159 @@ pub fn snake_to_camel(name: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
struct Emitter<'a> {
|
||||
lines: Vec<String>,
|
||||
/// Types whose references should be substituted with their inline
|
||||
/// shape at the use site (and which don't emit as their own
|
||||
/// `type "X" { ... }` entries). Populated from `IrSnapshot::inlines`.
|
||||
/// The PascalCase stem every emitted type name for `wire_name` is built on:
|
||||
/// split on `[._-]`, then title-case each part, where a character is
|
||||
/// uppercased only when the character before it is not a letter.
|
||||
pub fn wire_to_pascal(wire_name: &str) -> String {
|
||||
let mut out = String::with_capacity(wire_name.len());
|
||||
for part in wire_name.split(['.', '_', '-']) {
|
||||
let mut prev_is_letter = false;
|
||||
for c in part.chars() {
|
||||
if prev_is_letter {
|
||||
out.extend(c.to_lowercase());
|
||||
} else {
|
||||
out.extend(c.to_uppercase());
|
||||
}
|
||||
prev_is_letter = c.is_alphabetic();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Builds the node tree for one document.
|
||||
struct NodeBuilder<'a> {
|
||||
/// Types whose references are substituted with their inline shape at the
|
||||
/// use site, and which emit no `type "X" { ... }` entry of their own.
|
||||
inlines: &'a BTreeMap<&'static str, TypeShape>,
|
||||
}
|
||||
|
||||
impl<'a> Emitter<'a> {
|
||||
fn new(inlines: &'a BTreeMap<&'static str, TypeShape>) -> Self {
|
||||
Self {
|
||||
lines: Vec::new(),
|
||||
inlines,
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix(&self, indent: usize) -> String {
|
||||
INDENT.repeat(indent)
|
||||
}
|
||||
|
||||
fn leaf(&mut self, indent: usize, parts: &[&str]) {
|
||||
let mut line = self.prefix(indent);
|
||||
line.push_str(&parts.join(" "));
|
||||
self.lines.push(line);
|
||||
}
|
||||
|
||||
fn open(&mut self, indent: usize, parts: &[&str]) {
|
||||
let mut line = self.prefix(indent);
|
||||
line.push_str(&parts.join(" "));
|
||||
line.push_str(" {");
|
||||
self.lines.push(line);
|
||||
}
|
||||
|
||||
fn close(&mut self, indent: usize) {
|
||||
let mut line = self.prefix(indent);
|
||||
line.push('}');
|
||||
self.lines.push(line);
|
||||
}
|
||||
|
||||
fn blank(&mut self) {
|
||||
self.lines.push(String::new());
|
||||
}
|
||||
|
||||
fn emit_type_child(&mut self, indent: usize, shape: &TypeShape) {
|
||||
impl NodeBuilder<'_> {
|
||||
fn type_child(&self, depth: usize, shape: &TypeShape) -> KdlNode {
|
||||
match shape {
|
||||
TypeShape::Primitive(p) => {
|
||||
let name = kdl_string(p.name());
|
||||
self.leaf(indent, &["primitive", &name]);
|
||||
}
|
||||
TypeShape::Ref(name) => {
|
||||
// Inline-substitute when the referenced type is a
|
||||
// primitive-alias or string-enum. Matches Python's
|
||||
// Pydantic Literal/alias inlining.
|
||||
if let Some(inline_shape) = self.inlines.get(name).cloned() {
|
||||
self.emit_type_child(indent, &inline_shape);
|
||||
return;
|
||||
}
|
||||
let n = kdl_string(name);
|
||||
self.leaf(indent, &["ref", &n]);
|
||||
KdlNode::new(depth, "primitive").arg(KdlValue::str(p.name()))
|
||||
}
|
||||
TypeShape::Ref { name, .. } => match self.inlines.get(name) {
|
||||
Some(inline_shape) => self.type_child(depth, &inline_shape.clone()),
|
||||
None => KdlNode::new(depth, "ref").arg(KdlValue::str(name)),
|
||||
},
|
||||
TypeShape::List(inner) => {
|
||||
self.open(indent, &["list"]);
|
||||
self.emit_type_child(indent + 1, inner);
|
||||
self.close(indent);
|
||||
KdlNode::new(depth, "list").block(vec![self.type_child(depth + 1, inner)])
|
||||
}
|
||||
TypeShape::Optional(inner) => {
|
||||
self.open(indent, &["optional"]);
|
||||
self.emit_type_child(indent + 1, inner);
|
||||
self.close(indent);
|
||||
KdlNode::new(depth, "optional").block(vec![self.type_child(depth + 1, inner)])
|
||||
}
|
||||
TypeShape::Enum(variants) => {
|
||||
let mut parts: Vec<String> = vec!["enum".into()];
|
||||
for v in variants {
|
||||
parts.push(kdl_string(v));
|
||||
}
|
||||
let line: Vec<&str> = parts.iter().map(String::as_str).collect();
|
||||
self.leaf(indent, &line);
|
||||
}
|
||||
TypeShape::Union(branches) => {
|
||||
self.open(indent, &["union"]);
|
||||
for b in branches {
|
||||
self.emit_type_child(indent + 1, b);
|
||||
}
|
||||
self.close(indent);
|
||||
KdlNode::new(depth, "enum").args(variants.iter().map(|v| KdlValue::str(v)))
|
||||
}
|
||||
TypeShape::Union(branches) => KdlNode::new(depth, "union").block(
|
||||
branches
|
||||
.iter()
|
||||
.map(|b| self.type_child(depth + 1, b))
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_named_type(&mut self, indent: usize, name: &str, body: &NamedType) {
|
||||
let name_lit = kdl_string(name);
|
||||
self.open(indent, &["type", &name_lit]);
|
||||
match body {
|
||||
NamedType::Struct(fields) => {
|
||||
self.open(indent + 1, &["struct"]);
|
||||
for field in fields {
|
||||
self.emit_struct_field(indent + 2, field);
|
||||
}
|
||||
self.close(indent + 1);
|
||||
}
|
||||
NamedType::Alias(inner) => {
|
||||
self.open(indent + 1, &["alias"]);
|
||||
self.emit_type_child(indent + 2, inner);
|
||||
self.close(indent + 1);
|
||||
fn named_type(&self, depth: usize, name: &str, body: &NamedType) -> KdlNode {
|
||||
let inner = match body {
|
||||
NamedType::Struct(fields) => KdlNode::new(depth + 1, "struct").block(
|
||||
fields
|
||||
.iter()
|
||||
.map(|field| self.struct_field(depth + 2, field))
|
||||
.collect(),
|
||||
),
|
||||
NamedType::Alias(shape) => {
|
||||
KdlNode::new(depth + 1, "alias").block(vec![self.type_child(depth + 2, shape)])
|
||||
}
|
||||
NamedType::Enum(variants) => {
|
||||
let mut parts: Vec<String> = vec!["enum".into()];
|
||||
for v in variants {
|
||||
parts.push(kdl_string(v));
|
||||
}
|
||||
let line: Vec<&str> = parts.iter().map(String::as_str).collect();
|
||||
self.leaf(indent + 1, &line);
|
||||
KdlNode::new(depth + 1, "enum").args(variants.iter().map(|v| KdlValue::str(v)))
|
||||
}
|
||||
}
|
||||
self.close(indent);
|
||||
};
|
||||
KdlNode::new(depth, "type")
|
||||
.arg(KdlValue::str(name))
|
||||
.block(vec![inner])
|
||||
}
|
||||
|
||||
fn emit_struct_field(&mut self, indent: usize, field: &StructField) {
|
||||
let name = kdl_string(field.name);
|
||||
let mut header: Vec<String> = vec!["field".into(), name];
|
||||
fn struct_field(&self, depth: usize, field: &StructField) -> KdlNode {
|
||||
let mut node = KdlNode::new(depth, "field").arg(KdlValue::str(field.name));
|
||||
if !field.required {
|
||||
header.push(format!("required={}", kdl_bool(false)));
|
||||
node = node.prop("required", KdlValue::Bool(false));
|
||||
if let Some(default) = &field.default {
|
||||
header.push(format!("default={}", kdl_default(default)));
|
||||
node = node.prop("default", KdlValue::of_default(default));
|
||||
}
|
||||
}
|
||||
let line_parts: Vec<&str> = header.iter().map(String::as_str).collect();
|
||||
self.open(indent, &line_parts);
|
||||
self.emit_type_child(indent + 1, &field.shape);
|
||||
self.close(indent);
|
||||
node.block(vec![self.type_child(depth + 1, &field.shape)])
|
||||
}
|
||||
|
||||
fn emit_function(&mut self, indent: usize, fn_spec: &dyn FunctionSpec) {
|
||||
let name = kdl_string(fn_spec.name());
|
||||
self.open(indent, &["function", &name]);
|
||||
|
||||
let camel = kdl_string(fn_spec.camel_name());
|
||||
self.leaf(indent + 1, &["camel", &camel]);
|
||||
|
||||
self.leaf(indent + 1, &["has-input", kdl_bool(fn_spec.has_input())]);
|
||||
fn function(&self, depth: usize, fn_spec: &dyn FunctionSpec) -> KdlNode {
|
||||
let inner = depth + 1;
|
||||
let mut children = vec![
|
||||
KdlNode::new(inner, "camel").arg(KdlValue::str(fn_spec.camel_name())),
|
||||
KdlNode::new(inner, "has-input").arg(KdlValue::Bool(fn_spec.has_input())),
|
||||
];
|
||||
|
||||
if let Some(input_type) = fn_spec.input_type() {
|
||||
let lit = kdl_string(input_type);
|
||||
self.leaf(indent + 1, &["input", &lit]);
|
||||
children.push(KdlNode::new(inner, "input").arg(KdlValue::str(input_type)));
|
||||
}
|
||||
|
||||
let output_lit = kdl_string(fn_spec.output_type());
|
||||
self.leaf(indent + 1, &["output", &output_lit]);
|
||||
children.push(KdlNode::new(inner, "output").arg(KdlValue::str(fn_spec.output_type())));
|
||||
|
||||
if fn_spec.output_nullable() {
|
||||
self.leaf(indent + 1, &["output-nullable", kdl_bool(true)]);
|
||||
children.push(KdlNode::new(inner, "output-nullable").arg(KdlValue::Bool(true)));
|
||||
}
|
||||
|
||||
let transport_lit = kdl_string(fn_spec.transport().name());
|
||||
self.leaf(indent + 1, &["transport", &transport_lit]);
|
||||
children
|
||||
.push(KdlNode::new(inner, "transport").arg(KdlValue::str(fn_spec.transport().name())));
|
||||
|
||||
if let Some(ctx) = fn_spec.context() {
|
||||
let lit = kdl_string(ctx);
|
||||
self.leaf(indent + 1, &["context", &lit]);
|
||||
children.push(KdlNode::new(inner, "context").arg(KdlValue::str(ctx)));
|
||||
}
|
||||
|
||||
for affect in fn_spec.affects() {
|
||||
// Mirror Python's behavior: only context-typed affects make it
|
||||
// into the KDL `affects` leaf. Function-typed affects are
|
||||
// reserved for a future IR extension.
|
||||
if let crate::ir::AffectTarget::Context(name) = affect {
|
||||
let lit = kdl_string(name);
|
||||
self.leaf(indent + 1, &["affects", &lit]);
|
||||
match affect {
|
||||
// The `affects` leaf names a context; a function-typed target
|
||||
// has no leaf in the document.
|
||||
AffectTarget::Context(name) => {
|
||||
children.push(KdlNode::new(inner, "affects").arg(KdlValue::str(name)));
|
||||
}
|
||||
AffectTarget::Function { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
for merge in fn_spec.merge() {
|
||||
let lit = kdl_string(merge);
|
||||
self.leaf(indent + 1, &["merge", &lit]);
|
||||
children.push(KdlNode::new(inner, "merge").arg(KdlValue::str(merge)));
|
||||
}
|
||||
|
||||
if fn_spec.is_form() {
|
||||
self.leaf(indent + 1, &["is-form", kdl_bool(true)]);
|
||||
children.push(KdlNode::new(inner, "is-form").arg(KdlValue::Bool(true)));
|
||||
if let Some(form_name) = fn_spec.form_name() {
|
||||
let lit = kdl_string(form_name);
|
||||
self.leaf(indent + 1, &["form-name", &lit]);
|
||||
children.push(KdlNode::new(inner, "form-name").arg(KdlValue::str(form_name)));
|
||||
}
|
||||
if let Some(form_role) = fn_spec.form_role() {
|
||||
let lit = kdl_string(form_role);
|
||||
self.leaf(indent + 1, &["form-role", &lit]);
|
||||
children.push(KdlNode::new(inner, "form-role").arg(KdlValue::str(form_role)));
|
||||
}
|
||||
}
|
||||
|
||||
self.close(indent);
|
||||
KdlNode::new(depth, "function")
|
||||
.arg(KdlValue::str(fn_spec.name()))
|
||||
.block(children)
|
||||
}
|
||||
|
||||
fn emit_context(&mut self, indent: usize, ctx_name: &str, members: &[&'static dyn FunctionSpec]) {
|
||||
let name_lit = kdl_string(ctx_name);
|
||||
self.open(indent, &["context", &name_lit]);
|
||||
fn context(
|
||||
&self,
|
||||
depth: usize,
|
||||
ctx_name: &str,
|
||||
members: &[&'static dyn FunctionSpec],
|
||||
) -> KdlNode {
|
||||
let inner = depth + 1;
|
||||
let mut children: Vec<KdlNode> = members
|
||||
.iter()
|
||||
.map(|fn_spec| KdlNode::new(inner, "function").arg(KdlValue::str(fn_spec.name())))
|
||||
.collect();
|
||||
|
||||
// Function membership in registration order.
|
||||
for fn_spec in members {
|
||||
let lit = kdl_string(fn_spec.name());
|
||||
self.leaf(indent + 1, &["function", &lit]);
|
||||
}
|
||||
|
||||
// Param info — collect across every member, then emit alphabetized
|
||||
// by param name to match Python.
|
||||
// Params collected across every member, keyed so they emit
|
||||
// alphabetized by param name.
|
||||
struct ParamSlot {
|
||||
primitive: Primitive,
|
||||
shared_by: Vec<&'static str>,
|
||||
@@ -295,120 +327,163 @@ impl<'a> Emitter<'a> {
|
||||
|
||||
let member_count = members.len();
|
||||
for (param_name, slot) in params.iter() {
|
||||
let name_lit = kdl_string(param_name);
|
||||
self.open(indent + 1, &["param", &name_lit]);
|
||||
let type_lit = kdl_string(slot.primitive.name());
|
||||
self.leaf(indent + 2, &["type", &type_lit]);
|
||||
let required = slot.shared_by.len() == member_count;
|
||||
self.leaf(indent + 2, &["required", kdl_bool(required)]);
|
||||
let mut param_children = vec![
|
||||
KdlNode::new(inner + 1, "type").arg(KdlValue::str(slot.primitive.name())),
|
||||
KdlNode::new(inner + 1, "required")
|
||||
.arg(KdlValue::Bool(slot.shared_by.len() == member_count)),
|
||||
];
|
||||
for sharer in &slot.shared_by {
|
||||
let lit = kdl_string(sharer);
|
||||
self.leaf(indent + 2, &["shared-by", &lit]);
|
||||
param_children
|
||||
.push(KdlNode::new(inner + 1, "shared-by").arg(KdlValue::str(sharer)));
|
||||
}
|
||||
self.close(indent + 1);
|
||||
children.push(
|
||||
KdlNode::new(inner, "param")
|
||||
.arg(KdlValue::str(param_name))
|
||||
.block(param_children),
|
||||
);
|
||||
}
|
||||
|
||||
self.close(indent);
|
||||
KdlNode::new(depth, "context")
|
||||
.arg(KdlValue::str(ctx_name))
|
||||
.block(children)
|
||||
}
|
||||
|
||||
fn into_string(mut self) -> String {
|
||||
// Trim trailing blanks, then add a single terminating newline.
|
||||
while matches!(self.lines.last(), Some(s) if s.is_empty()) {
|
||||
self.lines.pop();
|
||||
fn channel(&self, depth: usize, channel: &ChannelRecord) -> KdlNode {
|
||||
let inner = depth + 1;
|
||||
let mut children =
|
||||
vec![KdlNode::new(inner, "pascal-name").arg(KdlValue::str(&channel.pascal_name))];
|
||||
for slot in &channel.slots {
|
||||
children.push(
|
||||
KdlNode::new(inner, slot.kind.node_name()).arg(KdlValue::str(&slot.type_name)),
|
||||
);
|
||||
}
|
||||
let mut out = self.lines.join("\n");
|
||||
out.push('\n');
|
||||
out
|
||||
KdlNode::new(depth, "channel")
|
||||
.arg(KdlValue::str(channel.name))
|
||||
.block(children)
|
||||
}
|
||||
}
|
||||
|
||||
/// One channel as the document carries it: the wire name, the Pascal stem its
|
||||
/// slot type names are built on, and the slots it declares.
|
||||
pub(crate) struct ChannelRecord {
|
||||
pub name: &'static str,
|
||||
pub pascal_name: String,
|
||||
pub slots: Vec<ChannelSlotRecord>,
|
||||
}
|
||||
|
||||
pub(crate) struct ChannelSlotRecord {
|
||||
pub kind: ChannelSlotKind,
|
||||
pub type_name: String,
|
||||
}
|
||||
|
||||
/// Collected typed registries view used by `build_ir`.
|
||||
pub(crate) struct IrSnapshot {
|
||||
pub types: BTreeMap<&'static str, NamedType>,
|
||||
pub types: BTreeMap<String, NamedType>,
|
||||
pub functions: Vec<&'static dyn FunctionSpec>,
|
||||
pub contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)>,
|
||||
/// Types that inline to a `TypeShape` at every reference site rather
|
||||
/// than emitting as their own `type "X" { ... }` entry. Populated from
|
||||
/// `Alias(Primitive(_))` and `Enum` named types — both are
|
||||
/// information-zero indirections that the codegen consumer doesn't
|
||||
/// gain anything from naming. Matches the Python emitter's behavior
|
||||
/// (Pydantic `FigureId = str` and `Literal["..."]` inline; they don't
|
||||
/// materialize as named types).
|
||||
pub channels: Vec<ChannelRecord>,
|
||||
/// Types that inline to a `TypeShape` at every reference site rather than
|
||||
/// emitting a `type "X" { ... }` entry: `Alias(Primitive(_))` and `Enum`,
|
||||
/// both of which carry no structure a named entry would add.
|
||||
pub inlines: BTreeMap<&'static str, TypeShape>,
|
||||
}
|
||||
|
||||
impl IrSnapshot {
|
||||
pub(crate) fn collect() -> Self {
|
||||
// Types: alphabetized for byte-equivalence with Python's `sorted(named_types)`.
|
||||
// Types: alphabetized, which is the document's canonical ordering.
|
||||
let mut all_types: BTreeMap<&'static str, NamedType> = BTreeMap::new();
|
||||
for entry in TYPES {
|
||||
all_types.insert(entry.name, (entry.shape_fn)());
|
||||
}
|
||||
|
||||
// Partition into emit-candidate types vs inlines. An inline is a
|
||||
// named type whose shape collapses to a single `TypeShape` at the
|
||||
// field site — primitive aliases and string enums.
|
||||
// Partition into emit-candidate types vs inlines.
|
||||
let mut candidates: BTreeMap<&'static str, NamedType> = BTreeMap::new();
|
||||
let mut inlines: BTreeMap<&'static str, TypeShape> = BTreeMap::new();
|
||||
for (name, body) in all_types {
|
||||
match &body {
|
||||
NamedType::Alias(TypeShape::Primitive(p)) => {
|
||||
inlines.insert(name, TypeShape::Primitive(*p));
|
||||
}
|
||||
match body {
|
||||
NamedType::Enum(variants) => {
|
||||
inlines.insert(name, TypeShape::Enum(variants.clone()));
|
||||
inlines.insert(name, TypeShape::Enum(variants));
|
||||
}
|
||||
_ => {
|
||||
candidates.insert(name, body);
|
||||
NamedType::Alias(TypeShape::Primitive(p)) => {
|
||||
inlines.insert(name, TypeShape::Primitive(p));
|
||||
}
|
||||
NamedType::Alias(shape) => {
|
||||
candidates.insert(name, NamedType::Alias(shape));
|
||||
}
|
||||
NamedType::Struct(fields) => {
|
||||
candidates.insert(name, NamedType::Struct(fields));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tree-shake: keep only types reachable from a registered function's
|
||||
// input/output. The function macro registers canonical-named
|
||||
// entries (e.g. `userPrefsOutput`); derive registers original-named
|
||||
// entries (`UserPrefs`, `BrushSettings`, …). Only those reached
|
||||
// via Ref-walk from a function's input/output names belong in the
|
||||
// emitted IR. Mirrors Python's `_collect_named_types`.
|
||||
// Channels: alphabetical by wire name, each declared slot's type
|
||||
// named `<Pascal><Slot>`. The slot shapes enter the type section
|
||||
// directly, so they are emitted whether or not a function reaches
|
||||
// them.
|
||||
let mut channel_entries: Vec<&'static crate::registry::ChannelEntry> =
|
||||
CHANNELS.iter().collect();
|
||||
channel_entries.sort_by_key(|c| c.name);
|
||||
let mut channels: Vec<ChannelRecord> = Vec::new();
|
||||
let mut channel_types: Vec<(String, NamedType)> = Vec::new();
|
||||
for entry in channel_entries {
|
||||
let pascal_name = wire_to_pascal(entry.name);
|
||||
let mut slots: Vec<ChannelSlotRecord> = Vec::new();
|
||||
for slot in entry.slots {
|
||||
let type_name = format!("{pascal_name}{}", slot.kind.type_suffix());
|
||||
channel_types.push((type_name.clone(), (slot.shape_fn)()));
|
||||
slots.push(ChannelSlotRecord {
|
||||
kind: slot.kind,
|
||||
type_name,
|
||||
});
|
||||
}
|
||||
channels.push(ChannelRecord {
|
||||
name: entry.name,
|
||||
pascal_name,
|
||||
slots,
|
||||
});
|
||||
}
|
||||
|
||||
// Roots of the tree-shake: every non-private function's input and
|
||||
// output name, plus every name a channel slot's shape refs.
|
||||
let mut reachable: std::collections::HashSet<&'static str> =
|
||||
std::collections::HashSet::new();
|
||||
let mut frontier: Vec<&'static str> = Vec::new();
|
||||
for fn_spec in FUNCTIONS {
|
||||
if fn_spec.private() {
|
||||
continue;
|
||||
}
|
||||
if let Some(input_name) = fn_spec.input_type() {
|
||||
if reachable.insert(input_name) {
|
||||
frontier.push(input_name);
|
||||
}
|
||||
}
|
||||
let output_name = fn_spec.output_type();
|
||||
if reachable.insert(output_name) {
|
||||
frontier.push(output_name);
|
||||
reachable.insert(input_name);
|
||||
}
|
||||
reachable.insert(fn_spec.output_type());
|
||||
}
|
||||
while let Some(name) = frontier.pop() {
|
||||
// Inlines don't carry refs we care about (Primitive/Enum); skip.
|
||||
if inlines.contains_key(name) {
|
||||
continue;
|
||||
}
|
||||
let body = match candidates.get(name) {
|
||||
Some(b) => b.clone(),
|
||||
None => continue,
|
||||
};
|
||||
collect_refs(&body, &mut |r| {
|
||||
if reachable.insert(r) {
|
||||
frontier.push(r);
|
||||
}
|
||||
for (_, body) in &channel_types {
|
||||
collect_refs(body, &mut |r| {
|
||||
reachable.insert(r);
|
||||
});
|
||||
}
|
||||
let types: BTreeMap<&'static str, NamedType> = candidates
|
||||
// Grow the set until a pass adds nothing: a candidate contributes the
|
||||
// names it refs once it is itself reachable.
|
||||
loop {
|
||||
let mut grew = false;
|
||||
for (name, body) in &candidates {
|
||||
if reachable.contains(name) {
|
||||
collect_refs(body, &mut |r| {
|
||||
grew |= reachable.insert(r);
|
||||
});
|
||||
}
|
||||
}
|
||||
if !grew {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut types: BTreeMap<String, NamedType> = candidates
|
||||
.into_iter()
|
||||
.filter(|(name, _)| reachable.contains(name))
|
||||
.map(|(name, body)| (name.to_string(), body))
|
||||
.collect();
|
||||
types.extend(channel_types);
|
||||
|
||||
// Functions: alphabetical by wire name (canonical IR ordering,
|
||||
// matches the Python emitter's `sorted(functions)`). Skip `private`.
|
||||
// Functions: alphabetical by wire name. Skip `private`.
|
||||
let mut functions: Vec<&'static dyn FunctionSpec> = FUNCTIONS
|
||||
.iter()
|
||||
.copied()
|
||||
@@ -416,8 +491,8 @@ impl IrSnapshot {
|
||||
.collect();
|
||||
functions.sort_by_key(|f| f.name());
|
||||
|
||||
// Contexts: alphabetical by name (canonical IR ordering), each with
|
||||
// its members sorted alphabetically too.
|
||||
// Contexts: alphabetical by name, each with its members sorted
|
||||
// alphabetically too.
|
||||
let mut context_names: Vec<&'static str> = CONTEXTS.iter().map(|c| c.name).collect();
|
||||
context_names.sort();
|
||||
let mut contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)> = Vec::new();
|
||||
@@ -437,6 +512,7 @@ impl IrSnapshot {
|
||||
types,
|
||||
functions,
|
||||
contexts,
|
||||
channels,
|
||||
inlines,
|
||||
}
|
||||
}
|
||||
@@ -457,7 +533,7 @@ fn collect_refs<F: FnMut(&'static str)>(body: &NamedType, visit: &mut F) {
|
||||
|
||||
fn walk_shape_refs<F: FnMut(&'static str)>(shape: &TypeShape, visit: &mut F) {
|
||||
match shape {
|
||||
TypeShape::Ref(name) => visit(name),
|
||||
TypeShape::Ref { name, .. } => visit(name),
|
||||
TypeShape::List(inner) | TypeShape::Optional(inner) => walk_shape_refs(inner, visit),
|
||||
TypeShape::Union(branches) => {
|
||||
for b in branches {
|
||||
@@ -468,41 +544,41 @@ fn walk_shape_refs<F: FnMut(&'static str)>(shape: &TypeShape, visit: &mut F) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the Mizan IR for every registered type/function/context. Returns KDL.
|
||||
/// Build the Mizan IR for every registered type, function, context and
|
||||
/// channel. Returns KDL.
|
||||
pub fn build_ir() -> String {
|
||||
crate::graph_check::verify_invariants();
|
||||
let snap = IrSnapshot::collect();
|
||||
let mut em = Emitter::new(&snap.inlines);
|
||||
let builder = NodeBuilder {
|
||||
inlines: &snap.inlines,
|
||||
};
|
||||
|
||||
// Type definitions
|
||||
let types_emitted = !snap.types.is_empty();
|
||||
for (name, body) in &snap.types {
|
||||
em.emit_named_type(0, name, body);
|
||||
}
|
||||
if types_emitted {
|
||||
em.blank();
|
||||
}
|
||||
let sections: Vec<Vec<KdlNode>> = [
|
||||
snap.types
|
||||
.iter()
|
||||
.map(|(name, body)| builder.named_type(0, name, body))
|
||||
.collect::<Vec<_>>(),
|
||||
snap.functions
|
||||
.iter()
|
||||
.map(|fn_spec| builder.function(0, *fn_spec))
|
||||
.collect(),
|
||||
snap.contexts
|
||||
.iter()
|
||||
.map(|(ctx_name, members)| builder.context(0, ctx_name, members))
|
||||
.collect(),
|
||||
snap.channels
|
||||
.iter()
|
||||
.map(|channel| builder.channel(0, channel))
|
||||
.collect(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|section: &Vec<KdlNode>| !section.is_empty())
|
||||
.collect();
|
||||
|
||||
// Functions
|
||||
let fns_emitted = !snap.functions.is_empty();
|
||||
for fn_spec in &snap.functions {
|
||||
em.emit_function(0, *fn_spec);
|
||||
}
|
||||
if fns_emitted {
|
||||
em.blank();
|
||||
}
|
||||
|
||||
// Contexts
|
||||
let ctxs_emitted = !snap.contexts.is_empty();
|
||||
for (ctx_name, members) in &snap.contexts {
|
||||
em.emit_context(0, ctx_name, members);
|
||||
}
|
||||
if ctxs_emitted {
|
||||
em.blank();
|
||||
}
|
||||
|
||||
// Future: channels — once channel registry lands on the Rust side.
|
||||
|
||||
em.into_string()
|
||||
let mut env = Environment::new();
|
||||
env.add_filter("kdl", render_kdl_value);
|
||||
env.template_from_named_str("ir.kdl", IR_TEMPLATE)
|
||||
.expect("compile templates/ir.kdl.jinja")
|
||||
.render(context! { sections })
|
||||
.expect("render templates/ir.kdl.jinja")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
//! Mizan server-side IR substrate. Rust analog of `cores/mizan-python/src/mizan_core/`.
|
||||
//! Mizan server-side IR substrate.
|
||||
//!
|
||||
//! Three load-bearing concerns:
|
||||
//!
|
||||
//! 1. **IR data model + KDL emitter.** `build_ir()` produces byte-equivalent
|
||||
//! KDL to the Python emitter. Both backends emit the same contract.
|
||||
//! 1. **IR data model + KDL emitter.** `build_ir()` renders the registries as
|
||||
//! one Mizan IR document.
|
||||
//! 2. **Compile-time registry.** Proc macros from `mizan-macros` populate
|
||||
//! linkme distributed slices (`TYPES`, `CONTEXTS`, `FUNCTIONS`) at the
|
||||
//! consumer crate's expansion sites.
|
||||
//! linkme distributed slices (`TYPES`, `CONTEXTS`, `FUNCTIONS`, `CHANNELS`)
|
||||
//! at the consumer crate's expansion sites.
|
||||
//! 3. **Runtime helpers.** `compute_invalidation` / `compute_merges` /
|
||||
//! `lookup_function` ported from `mizan-fastapi`'s executor; the HTTP
|
||||
//! adapter calls these per request.
|
||||
//! `function_named` / `context_members`, which the adapters call per request.
|
||||
//!
|
||||
//! Consumers `use mizan_core::prelude::*;` and alias the crate as `mizan` at
|
||||
//! their call sites so authored code reads `#[mizan::context]` / `#[mizan(...)]`.
|
||||
@@ -22,12 +21,13 @@ pub mod runtime;
|
||||
pub mod traits;
|
||||
|
||||
pub use ir::{
|
||||
AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape,
|
||||
AffectTarget, ChannelSlotKind, DefaultValue, NamedType, Primitive, StructField, Transport,
|
||||
TypeShape,
|
||||
};
|
||||
pub use kdl::{build_ir, snake_to_camel};
|
||||
pub use kdl::{build_ir, snake_to_camel, wire_to_pascal};
|
||||
pub use registry::{
|
||||
context_members, lookup_context, lookup_function, ContextEntry, TypeEntry, CONTEXTS,
|
||||
FUNCTIONS, TYPES,
|
||||
context_members, function_named, ChannelEntry, ChannelSlot, ContextEntry, TypeEntry, CHANNELS,
|
||||
CONTEXTS, FUNCTIONS, TYPES,
|
||||
};
|
||||
pub use runtime::{
|
||||
compute_invalidation, compute_merges, InvalidationTarget, MergeEntry, MizanError,
|
||||
@@ -35,21 +35,20 @@ pub use runtime::{
|
||||
};
|
||||
pub use traits::{ContextMarker, FunctionSpec, InputParam, MizanType};
|
||||
|
||||
// Re-export proc macros so consumers depend on one crate.
|
||||
pub use mizan_macros::{client, context, Mizan};
|
||||
pub use mizan_macros::{channel, client, context, Mizan};
|
||||
|
||||
pub mod prelude {
|
||||
pub use crate::ir::{
|
||||
AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape,
|
||||
AffectTarget, ChannelSlotKind, DefaultValue, NamedType, Primitive, StructField, Transport,
|
||||
TypeShape,
|
||||
};
|
||||
pub use crate::registry::{ContextEntry, TypeEntry};
|
||||
pub use crate::registry::{ChannelEntry, ChannelSlot, ContextEntry, TypeEntry};
|
||||
pub use crate::runtime::{MizanError, RequestHandle};
|
||||
pub use crate::traits::{ContextMarker, FunctionSpec, InputParam, MizanType};
|
||||
pub use mizan_macros::Mizan;
|
||||
}
|
||||
|
||||
/// Internal re-exports used by `mizan-macros`-generated code. Not part of
|
||||
/// the public API — consumers must not depend on names under `__priv`.
|
||||
/// The crates `mizan-macros` expansions name by absolute path.
|
||||
#[doc(hidden)]
|
||||
pub mod __priv {
|
||||
pub use linkme;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! source via linkme. The proc macros emit `#[linkme::distributed_slice(...)]`
|
||||
//! statics that land here at link time.
|
||||
|
||||
use crate::ir::NamedType;
|
||||
use crate::ir::{ChannelSlotKind, NamedType};
|
||||
use crate::traits::FunctionSpec;
|
||||
use linkme::distributed_slice;
|
||||
|
||||
@@ -17,6 +17,21 @@ pub struct ContextEntry {
|
||||
pub name: &'static str,
|
||||
}
|
||||
|
||||
/// One declared payload slot of a channel. `shape_fn` yields the shape the
|
||||
/// slot's type emits under its derived name.
|
||||
pub struct ChannelSlot {
|
||||
pub kind: ChannelSlotKind,
|
||||
pub shape_fn: fn() -> NamedType,
|
||||
}
|
||||
|
||||
/// One channel registration. Emitted by `#[mizan::channel]`. `slots` carries
|
||||
/// only the slots the channel declares, ordered params, client-message,
|
||||
/// server-message.
|
||||
pub struct ChannelEntry {
|
||||
pub name: &'static str,
|
||||
pub slots: &'static [ChannelSlot],
|
||||
}
|
||||
|
||||
#[distributed_slice]
|
||||
pub static TYPES: [TypeEntry] = [..];
|
||||
|
||||
@@ -26,18 +41,21 @@ pub static CONTEXTS: [ContextEntry] = [..];
|
||||
#[distributed_slice]
|
||||
pub static FUNCTIONS: [&'static dyn FunctionSpec] = [..];
|
||||
|
||||
/// Find a registered function by wire name. Used by the HTTP adapter.
|
||||
pub fn lookup_function(name: &str) -> Option<&'static dyn FunctionSpec> {
|
||||
FUNCTIONS.iter().copied().find(|f| f.name() == name)
|
||||
#[distributed_slice]
|
||||
pub static CHANNELS: [ChannelEntry] = [..];
|
||||
|
||||
/// The functions registered under `name`. Order matches `FUNCTIONS` iteration
|
||||
/// order — i.e., registration order.
|
||||
pub fn function_named(name: &str) -> Vec<&'static dyn FunctionSpec> {
|
||||
FUNCTIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| f.name() == name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find a registered context by name. Used by graph_check.
|
||||
pub fn lookup_context(name: &str) -> Option<&'static ContextEntry> {
|
||||
CONTEXTS.iter().find(|c| c.name == name)
|
||||
}
|
||||
|
||||
/// All functions that declare a given context as their `context` membership.
|
||||
/// Order matches `FUNCTIONS` iteration order — i.e., registration order.
|
||||
/// The functions that declare `ctx_name` as their `context` membership. Order
|
||||
/// matches `FUNCTIONS` iteration order — i.e., registration order.
|
||||
pub fn context_members(ctx_name: &str) -> Vec<&'static dyn FunctionSpec> {
|
||||
FUNCTIONS
|
||||
.iter()
|
||||
|
||||
@@ -1,40 +1,41 @@
|
||||
//! Runtime helpers — error envelope, request handle, invalidation/merge
|
||||
//! resolution. Ports `compute_invalidation` / `compute_merges` /
|
||||
//! `_resolve_merge_slot` / `_scoped_params` from
|
||||
//! `backends/mizan-fastapi/src/mizan_fastapi/executor.py:189-263`.
|
||||
//! Runtime helpers — error envelope, request handle, and the per-response
|
||||
//! invalidation / merge resolution the adapters call after a dispatch.
|
||||
|
||||
use crate::registry::context_members;
|
||||
use crate::traits::FunctionSpec;
|
||||
use serde_json::Value;
|
||||
use std::any::Any;
|
||||
|
||||
/// Type-erased handle to the framework's request object. The HTTP adapter
|
||||
/// stuffs its native `Request` here; user code casts back via the adapter's
|
||||
/// helper types.
|
||||
/// A borrow of the request object a hosting framework owns.
|
||||
///
|
||||
/// `FUNCTIONS` is a non-generic `distributed_slice`, so `FunctionSpec` has to
|
||||
/// be object-safe and no type parameter can reach this handle. The reference
|
||||
/// therefore rides erased, and the crate that names the framework's own type
|
||||
/// is the one that casts back to it.
|
||||
#[derive(Clone)]
|
||||
pub struct RequestHandle<'a> {
|
||||
pub inner: &'a (dyn Any + Send + Sync),
|
||||
inner: &'a (dyn Any + Send + Sync),
|
||||
}
|
||||
|
||||
impl<'a> RequestHandle<'a> {
|
||||
/// Wrap a typed reference. The most common path — handlers downcast back
|
||||
/// to `T` via `downcast::<T>()`.
|
||||
/// Wrap a typed reference.
|
||||
pub fn new<T: Any + Send + Sync>(req: &'a T) -> Self {
|
||||
Self { inner: req }
|
||||
}
|
||||
|
||||
/// Wrap an already-erased `dyn Any` reference. Used by HTTP adapters
|
||||
/// that thread an `Arc<dyn Any + Send + Sync>` app state in.
|
||||
/// Wrap a reference the caller has already erased.
|
||||
pub fn from_dyn(req: &'a (dyn Any + Send + Sync)) -> Self {
|
||||
Self { inner: req }
|
||||
}
|
||||
|
||||
pub fn downcast<T: Any + Send + Sync>(&self) -> Option<&'a T> {
|
||||
self.inner.downcast_ref::<T>()
|
||||
/// The reference the adapter installed.
|
||||
pub fn installed(&self) -> &'a (dyn Any + Send + Sync) {
|
||||
self.inner
|
||||
}
|
||||
}
|
||||
|
||||
/// Mizan's standard error envelope. Mirrors FastAPI's MizanError enum.
|
||||
/// Mizan's standard error envelope — the closed set of failures an adapter
|
||||
/// renders onto the wire.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MizanError {
|
||||
NotFound(String),
|
||||
@@ -186,59 +187,28 @@ pub fn compute_invalidation(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build the `merge` list from a function's `merge` metadata. Each entry
|
||||
/// names the slot inside the context bundle the return value lands in.
|
||||
/// Build the `merge` list from the function's already-resolved merge entries.
|
||||
/// Each names the slot inside the context bundle the return value lands in.
|
||||
pub fn compute_merges(
|
||||
fn_spec: &dyn FunctionSpec,
|
||||
args: &serde_json::Map<String, Value>,
|
||||
result: &Value,
|
||||
) -> Vec<MergeEntry> {
|
||||
let targets = fn_spec.merge();
|
||||
if targets.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mutation_output = fn_spec.output_type();
|
||||
let mut out = Vec::new();
|
||||
for ctx_name in targets {
|
||||
let slot = match resolve_merge_slot(ctx_name, mutation_output) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
let scoped = scoped_params(ctx_name, args);
|
||||
out.push(MergeEntry {
|
||||
context: (*ctx_name).into(),
|
||||
slot,
|
||||
value: result.clone(),
|
||||
params: if scoped.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(scoped)
|
||||
},
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Find the unique function-name slot whose Output type matches the
|
||||
/// mutation's Output type. Matches Python's `types_match_for_merge` —
|
||||
/// structural shape comparison, not name comparison. Returns None on no
|
||||
/// match or ambiguous match.
|
||||
fn resolve_merge_slot(context_name: &str, mutation_output: &str) -> Option<String> {
|
||||
let mutation_shape = crate::graph_check::resolve_type_shape(mutation_output)?;
|
||||
let mut matches: Vec<&'static str> = Vec::new();
|
||||
for fn_spec in context_members(context_name) {
|
||||
if let Some(candidate_shape) = crate::graph_check::resolve_type_shape(fn_spec.output_type())
|
||||
{
|
||||
if crate::graph_check::types_match(&candidate_shape, &mutation_shape) {
|
||||
matches.push(fn_spec.name());
|
||||
crate::graph_check::merges_for(fn_spec.name())
|
||||
.map(|resolved| {
|
||||
let scoped = scoped_params(resolved.context, args);
|
||||
MergeEntry {
|
||||
context: resolved.context.into(),
|
||||
slot: resolved.slot.into(),
|
||||
value: result.clone(),
|
||||
params: if scoped.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(scoped)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches.len() == 1 {
|
||||
Some(matches[0].into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Match input args against the context's declared Input field names.
|
||||
@@ -258,3 +228,36 @@ fn scoped_params(
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RequestHandle;
|
||||
use std::any::Any;
|
||||
|
||||
fn installed_addr(handle: &RequestHandle<'_>) -> *const () {
|
||||
handle.installed() as *const (dyn Any + Send + Sync) as *const ()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_handle_installs_the_very_reference_it_was_built_over() {
|
||||
let state = String::from("app-state");
|
||||
let source = &state as *const String as *const ();
|
||||
assert_eq!(installed_addr(&RequestHandle::new(&state)), source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_erased_handle_installs_what_a_typed_one_does() {
|
||||
let state = String::from("app-state");
|
||||
assert_eq!(
|
||||
installed_addr(&RequestHandle::from_dyn(&state)),
|
||||
installed_addr(&RequestHandle::new(&state))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_installed_reference_keeps_the_type_it_was_built_over() {
|
||||
let state = String::from("app-state");
|
||||
let handle = RequestHandle::new(&state);
|
||||
assert!(handle.installed().is::<String>());
|
||||
assert!(!handle.installed().is::<i64>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Surface traits the proc macros implement.
|
||||
//! The traits a registered Mizan type, context and function implement.
|
||||
|
||||
use crate::ir::{AffectTarget, NamedType, Transport};
|
||||
use crate::runtime::{MizanError, RequestHandle};
|
||||
@@ -6,11 +6,10 @@ use serde_json::Value;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
/// A type that participates in the Mizan IR. Generated by `#[derive(Mizan)]`.
|
||||
/// A type that participates in the Mizan IR.
|
||||
///
|
||||
/// `TYPE_NAME` is a `const` (not a function) so it's usable in `static`
|
||||
/// initializers — TypeEntry's `name` field reads it directly without an
|
||||
/// init-time function call.
|
||||
/// `TYPE_NAME` is a `const` rather than a function so it can be named from a
|
||||
/// `static` initializer.
|
||||
pub trait MizanType {
|
||||
const TYPE_NAME: &'static str;
|
||||
fn shape() -> NamedType;
|
||||
@@ -20,21 +19,22 @@ pub trait MizanType {
|
||||
}
|
||||
}
|
||||
|
||||
/// A marker type for a Mizan context. Generated by `#[mizan::context]`.
|
||||
/// A marker type carrying one context's wire name.
|
||||
pub trait ContextMarker {
|
||||
const NAME: &'static str;
|
||||
}
|
||||
|
||||
/// One Mizan-registered function. Generated by `#[mizan(...)]` on async fns.
|
||||
///
|
||||
/// Everything here is plain data except `dispatch`, which is the type-erased
|
||||
/// runtime entry point used by the HTTP adapter.
|
||||
/// One Mizan-registered function: plain data throughout except `dispatch`.
|
||||
pub trait FunctionSpec: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
fn camel_name(&self) -> &'static str;
|
||||
fn has_input(&self) -> bool;
|
||||
fn input_type(&self) -> Option<&'static str>;
|
||||
fn output_type(&self) -> &'static str;
|
||||
|
||||
/// The shape registered under `output_type()`.
|
||||
fn output_shape(&self) -> NamedType;
|
||||
|
||||
fn output_nullable(&self) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -63,16 +63,14 @@ pub trait FunctionSpec: Send + Sync {
|
||||
None
|
||||
}
|
||||
|
||||
/// Field-shape description of this function's Input parameters, used by
|
||||
/// the context builder to compute shared-param elevation. Empty when
|
||||
/// `has_input()` is false.
|
||||
/// This function's Input parameters. Empty when `has_input()` is false.
|
||||
fn input_params(&self) -> &'static [InputParam] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// Type-erased dispatch. The HTTP adapter calls this with deserialized
|
||||
/// JSON arguments; the macro-generated impl deserializes into the
|
||||
/// function's typed input, awaits the body, and serializes the result.
|
||||
/// Deserializes `args` into this function's typed input, awaits the body,
|
||||
/// and serializes the result — the whole call with its types erased behind
|
||||
/// JSON.
|
||||
fn dispatch<'a>(
|
||||
&'a self,
|
||||
req: RequestHandle<'a>,
|
||||
@@ -80,10 +78,7 @@ pub trait FunctionSpec: Send + Sync {
|
||||
) -> Pin<Box<dyn Future<Output = Result<Value, MizanError>> + Send + 'a>>;
|
||||
}
|
||||
|
||||
/// One parameter of a function's synthesized Input. The macro emits a static
|
||||
/// slice of these so the context builder can find shared params across
|
||||
/// context members and produce the `context { param ... shared-by ... }`
|
||||
/// section of the IR.
|
||||
/// One parameter of a function's synthesized Input.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct InputParam {
|
||||
pub name: &'static str,
|
||||
|
||||
6
cores/mizan-rust/templates/ir.kdl.jinja
Normal file
6
cores/mizan-rust/templates/ir.kdl.jinja
Normal file
@@ -0,0 +1,6 @@
|
||||
{% macro node(n) %}{{ n.indent }}{{ n.name }}{% for a in n.args %} {{ a|kdl }}{% endfor %}{% for p in n.props %} {{ p.name }}={{ p.value|kdl }}{% endfor %}{% if n.block %} {
|
||||
{% for c in n.children %}{{ node(c) }}{% endfor %}{{ n.indent }}}
|
||||
{% else %}
|
||||
{% endif %}{% endmacro %}
|
||||
{%- for section in sections %}{% for n in section %}{{ node(n) }}{% endfor %}{% if not loop.last %}
|
||||
{% endif %}{% endfor %}
|
||||
@@ -1,11 +1,8 @@
|
||||
//! Byte-equivalence: the Rust KDL emitter (driven by the proc macros)
|
||||
//! against `protocol/mizan-codegen/tests/fixtures/afi_ir.kdl` (canonical
|
||||
//! Python-emitted reference).
|
||||
//!
|
||||
//! This is the Phase-2 verifier — the AFI fixture is authored against the
|
||||
//! real consumer surface (`#[derive(Mizan)] / #[mizan::context] /
|
||||
//! #[mizan::client]`), not hand-built static specs.
|
||||
//! `build_ir()` renders the proc-macro-populated registries; the emitted KDL
|
||||
//! is parsed by the `kdl` crate and then compared byte for byte with
|
||||
//! `protocol/mizan-codegen/tests/fixtures/afi_ir.kdl`.
|
||||
|
||||
use kdl::{KdlDocument, KdlNode};
|
||||
use mizan_core as mizan;
|
||||
use mizan_core::prelude::*;
|
||||
use mizan_core::RequestHandle;
|
||||
@@ -46,7 +43,17 @@ pub struct StatusOutput {
|
||||
#[mizan::context("user")]
|
||||
pub struct UserCtx;
|
||||
|
||||
// ─── Fixture functions (mirroring tests/afi/fixture.py) ────────────────────
|
||||
// ─── Fixture handlers ───────────────────────────────────────────────────────
|
||||
|
||||
/// `(order id, owning user id, total)` — the store the order handlers read.
|
||||
const ORDERS: &[(i64, i64, i64)] = &[(10, 1, 4200), (11, 1, 1750), (12, 2, 990)];
|
||||
|
||||
fn profile_of(user_id: i64) -> ProfileOutput {
|
||||
ProfileOutput {
|
||||
user_id,
|
||||
name: format!("user-{user_id}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[mizan::client]
|
||||
pub async fn echo(_req: &RequestHandle<'_>, text: String) -> EchoOutput {
|
||||
@@ -65,29 +72,39 @@ pub async fn whoami(_req: &RequestHandle<'_>) -> WhoamiOutput {
|
||||
|
||||
#[mizan::client(context = UserCtx)]
|
||||
pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput {
|
||||
ProfileOutput {
|
||||
user_id,
|
||||
name: "placeholder".into(),
|
||||
}
|
||||
profile_of(user_id)
|
||||
}
|
||||
|
||||
#[mizan::client(context = UserCtx)]
|
||||
pub async fn user_orders(_req: &RequestHandle<'_>, _user_id: i64) -> Vec<OrderOutput> {
|
||||
vec![]
|
||||
pub async fn user_orders(_req: &RequestHandle<'_>, user_id: i64) -> Vec<OrderOutput> {
|
||||
ORDERS
|
||||
.iter()
|
||||
.filter(|(_, owner, _)| *owner == user_id)
|
||||
.map(|(id, owner, total)| OrderOutput {
|
||||
id: *id,
|
||||
user_id: *owner,
|
||||
total: *total,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[mizan::client(affects = UserCtx)]
|
||||
pub async fn update_profile(
|
||||
_req: &RequestHandle<'_>,
|
||||
_user_id: i64,
|
||||
_name: String,
|
||||
user_id: i64,
|
||||
name: String,
|
||||
) -> StatusOutput {
|
||||
StatusOutput { ok: true }
|
||||
StatusOutput {
|
||||
ok: user_id > 0 && !name.trim().is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
#[mizan::client]
|
||||
pub async fn find_user(_req: &RequestHandle<'_>, _user_id: i64) -> Option<ProfileOutput> {
|
||||
None
|
||||
pub async fn find_user(_req: &RequestHandle<'_>, user_id: i64) -> Option<ProfileOutput> {
|
||||
ORDERS
|
||||
.iter()
|
||||
.any(|(_, owner, _)| *owner == user_id)
|
||||
.then(|| profile_of(user_id))
|
||||
}
|
||||
|
||||
#[mizan::client(merge = UserCtx)]
|
||||
@@ -99,20 +116,96 @@ pub async fn rename_user(
|
||||
ProfileOutput { user_id, name }
|
||||
}
|
||||
|
||||
// ─── The byte-equivalence test ──────────────────────────────────────────────
|
||||
// ─── Reading the parsed document ────────────────────────────────────────────
|
||||
|
||||
fn canonical_kdl_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../protocol/mizan-codegen/tests/fixtures/afi_ir.kdl")
|
||||
}
|
||||
|
||||
/// The node's first string argument, or the empty string when it has none.
|
||||
fn label(node: &KdlNode) -> String {
|
||||
for entry in node.entries() {
|
||||
if let Some(s) = entry.value().as_string() {
|
||||
return s.to_string();
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// `(node name, first string argument)` for every node at one level.
|
||||
fn index(nodes: &[KdlNode]) -> Vec<(String, String)> {
|
||||
nodes
|
||||
.iter()
|
||||
.map(|node| (node.name().value().to_string(), label(node)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The child nodes of the first `kind "name"` node in `doc`, or an empty slice
|
||||
/// when the document has no such node or it carries no child block.
|
||||
fn children_of<'a>(doc: &'a KdlDocument, kind: &str, name: &str) -> &'a [KdlNode] {
|
||||
for node in doc.nodes() {
|
||||
if node.name().value() == kind && label(node) == name {
|
||||
return match node.children() {
|
||||
Some(block) => block.nodes(),
|
||||
None => &[],
|
||||
};
|
||||
}
|
||||
}
|
||||
&[]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ir_matches_canonical_afi_kdl() {
|
||||
let expected = std::fs::read_to_string(canonical_kdl_path()).expect("read canonical KDL");
|
||||
let actual = mizan_core::build_ir();
|
||||
let emitted = mizan_core::build_ir();
|
||||
|
||||
if actual != expected {
|
||||
for (lineno, (a, b)) in actual.lines().zip(expected.lines()).enumerate() {
|
||||
// Parsing before comparing means a malformed emission fails here rather
|
||||
// than as a confusing textual diff.
|
||||
let parsed: KdlDocument = emitted
|
||||
.parse()
|
||||
.expect("build_ir() output is a well-formed KDL document");
|
||||
let top = index(parsed.nodes());
|
||||
|
||||
assert!(
|
||||
top.contains(&("function".to_string(), "user_orders".to_string())),
|
||||
"parsed document is missing the user_orders function node: {top:?}",
|
||||
);
|
||||
assert!(
|
||||
top.contains(&("context".to_string(), "user".to_string())),
|
||||
"parsed document is missing the user context node: {top:?}",
|
||||
);
|
||||
assert_eq!(
|
||||
index(children_of(&parsed, "function", "user_orders")),
|
||||
vec![
|
||||
("camel".to_string(), "userOrders".to_string()),
|
||||
("has-input".to_string(), String::new()),
|
||||
("input".to_string(), "userOrdersInput".to_string()),
|
||||
("output".to_string(), "userOrdersOutput".to_string()),
|
||||
("transport".to_string(), "http".to_string()),
|
||||
("context".to_string(), "user".to_string()),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
index(children_of(&parsed, "context", "user")),
|
||||
vec![
|
||||
("function".to_string(), "user_orders".to_string()),
|
||||
("function".to_string(), "user_profile".to_string()),
|
||||
("param".to_string(), "user_id".to_string()),
|
||||
],
|
||||
);
|
||||
|
||||
let expected = std::fs::read_to_string(canonical_kdl_path()).expect("read canonical KDL");
|
||||
let canonical: KdlDocument = expected
|
||||
.parse()
|
||||
.expect("the canonical fixture is a well-formed KDL document");
|
||||
assert_eq!(
|
||||
index(parsed.nodes()),
|
||||
index(canonical.nodes()),
|
||||
"emitted and canonical documents declare different top-level nodes",
|
||||
);
|
||||
|
||||
if emitted != expected {
|
||||
for (lineno, (a, b)) in emitted.lines().zip(expected.lines()).enumerate() {
|
||||
if a != b {
|
||||
panic!(
|
||||
"KDL diverges at line {}:\n expected: {b:?}\n actual: {a:?}",
|
||||
@@ -122,7 +215,7 @@ fn build_ir_matches_canonical_afi_kdl() {
|
||||
}
|
||||
panic!(
|
||||
"KDL diverges in length: actual_len={} expected_len={}",
|
||||
actual.len(),
|
||||
emitted.len(),
|
||||
expected.len(),
|
||||
);
|
||||
}
|
||||
|
||||
68
cores/mizan-rust/tests/merge_defects.rs
Normal file
68
cores/mizan-rust/tests/merge_defects.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! `verify_invariants()` over a graph where one `merge` declaration matches
|
||||
//! two members of the context it names and another matches none.
|
||||
|
||||
use mizan_core as mizan;
|
||||
use mizan_core::graph_check::verify_invariants;
|
||||
use mizan_core::prelude::*;
|
||||
use mizan_core::RequestHandle;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Profile {
|
||||
pub user_id: i64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Status {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
#[mizan::context("user")]
|
||||
pub struct UserCtx;
|
||||
|
||||
#[mizan::client(context = UserCtx)]
|
||||
pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> Profile {
|
||||
Profile {
|
||||
user_id,
|
||||
name: format!("user-{user_id}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Same output shape as `user_profile`.
|
||||
#[mizan::client(context = UserCtx)]
|
||||
pub async fn user_card(_req: &RequestHandle<'_>, user_id: i64) -> Profile {
|
||||
Profile {
|
||||
user_id,
|
||||
name: format!("card-{user_id}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[mizan::client(merge = UserCtx)]
|
||||
pub async fn rename_user(_req: &RequestHandle<'_>, user_id: i64, name: String) -> Profile {
|
||||
Profile { user_id, name }
|
||||
}
|
||||
|
||||
/// No member of `user` returns this shape.
|
||||
#[mizan::client(merge = UserCtx)]
|
||||
pub async fn mark_seen(_req: &RequestHandle<'_>, user_id: i64) -> Status {
|
||||
Status { ok: user_id > 0 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Merge resolution needs exactly one match")]
|
||||
fn a_merge_matching_several_members_is_ambiguous() {
|
||||
verify_invariants();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "user_card")]
|
||||
fn an_ambiguous_merge_names_every_candidate_member() {
|
||||
verify_invariants();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "no member of that context has output type")]
|
||||
fn a_merge_matching_no_member_has_no_slot() {
|
||||
verify_invariants();
|
||||
}
|
||||
113
cores/mizan-rust/tests/merge_resolution.rs
Normal file
113
cores/mizan-rust/tests/merge_resolution.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
//! `compute_merges` over a graph registered through `#[derive(Mizan)]`,
|
||||
//! `#[mizan::context]` and `#[mizan::client]`.
|
||||
|
||||
use mizan_core as mizan;
|
||||
use mizan_core::prelude::*;
|
||||
use mizan_core::{compute_merges, RequestHandle, FUNCTIONS};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct ProfileOutput {
|
||||
pub user_id: i64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct StatusOutput {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
#[mizan::context("user")]
|
||||
pub struct UserCtx;
|
||||
|
||||
#[mizan::client(context = UserCtx)]
|
||||
pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput {
|
||||
ProfileOutput {
|
||||
user_id,
|
||||
name: format!("user-{user_id}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[mizan::client(merge = UserCtx)]
|
||||
pub async fn rename_user(
|
||||
_req: &RequestHandle<'_>,
|
||||
user_id: i64,
|
||||
name: String,
|
||||
) -> ProfileOutput {
|
||||
ProfileOutput { user_id, name }
|
||||
}
|
||||
|
||||
#[mizan::client(affects = UserCtx)]
|
||||
pub async fn touch_user(_req: &RequestHandle<'_>, user_id: i64) -> StatusOutput {
|
||||
StatusOutput { ok: user_id > 0 }
|
||||
}
|
||||
|
||||
/// The handlers above register into `FUNCTIONS` inside this test binary, so a
|
||||
/// name they declare always lands.
|
||||
fn spec(name: &str) -> &'static dyn FunctionSpec {
|
||||
for fn_spec in FUNCTIONS.iter().copied() {
|
||||
if fn_spec.name() == name {
|
||||
return fn_spec;
|
||||
}
|
||||
}
|
||||
panic!("no registered function named `{name}`");
|
||||
}
|
||||
|
||||
/// `user_id` is a declared param of the `user` context; `name` is not.
|
||||
fn args() -> serde_json::Map<String, serde_json::Value> {
|
||||
let mut args = serde_json::Map::new();
|
||||
args.insert("user_id".to_string(), serde_json::Value::from(7));
|
||||
args.insert("name".to_string(), serde_json::Value::from("Renamed"));
|
||||
args
|
||||
}
|
||||
|
||||
fn renamed() -> serde_json::Value {
|
||||
serde_json::json!({ "user_id": 7, "name": "Renamed" })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_merge_declaration_resolves_to_the_context_member_sharing_its_output() {
|
||||
let result = renamed();
|
||||
let merges = compute_merges(spec("rename_user"), &args(), &result);
|
||||
let [entry] = merges.as_slice() else {
|
||||
panic!(
|
||||
"rename_user declares one merge; got {} entries",
|
||||
merges.len()
|
||||
);
|
||||
};
|
||||
assert_eq!(entry.context, "user");
|
||||
assert_eq!(entry.slot, "user_profile");
|
||||
assert_eq!(entry.value, result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_merge_entry_is_scoped_by_the_contexts_declared_params_alone() {
|
||||
let result = renamed();
|
||||
let merges = compute_merges(spec("rename_user"), &args(), &result);
|
||||
let [entry] = merges.as_slice() else {
|
||||
panic!(
|
||||
"rename_user declares one merge; got {} entries",
|
||||
merges.len()
|
||||
);
|
||||
};
|
||||
let mut expected = serde_json::Map::new();
|
||||
expected.insert("user_id".to_string(), serde_json::Value::from(7));
|
||||
assert_eq!(entry.params, Some(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_function_declaring_only_affects_produces_no_merge_entries() {
|
||||
let result = serde_json::json!({ "ok": true });
|
||||
assert!(compute_merges(spec("touch_user"), &args(), &result).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_resolution_answers_identically_across_calls() {
|
||||
let result = renamed();
|
||||
let first = compute_merges(spec("rename_user"), &args(), &result);
|
||||
let second = compute_merges(spec("rename_user"), &args(), &result);
|
||||
let slots: Vec<&str> = first.iter().map(|e| e.slot.as_str()).collect();
|
||||
let again: Vec<&str> = second.iter().map(|e| e.slot.as_str()).collect();
|
||||
assert_eq!(slots, again);
|
||||
assert_eq!(slots, vec!["user_profile"]);
|
||||
}
|
||||
Reference in New Issue
Block a user