"""Foundational types for the engine syntax module. Defines: - Token + TokenKind (the tokenizer's output shape) - TokenMeta (per-token metadata payload) - ResolveContext Protocol (the interface resolvers depend on) - SyntaxError hierarchy (UnknownRefError, RefOutOfSurfaceError, RecursionLimitExceeded, CycleDetectedError, MalformedSyntaxError) """ from __future__ import annotations import enum import random import re from dataclasses import dataclass, field from typing import ( Any, Literal, Protocol, runtime_checkable, ) SurfaceKind = Literal["wildcard", "combine", "derivation", "assembler", "fixed_values"] class TokenKind(enum.Enum): """All token kinds emitted by tokenize_text. Single grammar, no surface variants.""" TEXT = "text" VAR = "var" REF = "ref" DP_BRACE = "dp_brace" DP_PIPE = "dp_pipe" DP_MULTI = "dp_multi" ESCAPE = "escape" @dataclass class Token: """A single tokenizer output. `meta` is kind-specific: - VAR: {"name": str} - REF: {"uuid": str} - DP_MULTI: {"count": int, "sep": str, "branches": list[str]} - DP_BRACE: {"branches": list[str]} - ESCAPE: {"literal": str} (one of "$" or "@") - TEXT, DP_PIPE: {} (empty) """ kind: TokenKind raw: str start: int end: int meta: dict[str, Any] = field(default_factory=dict) @dataclass class ListVar: """A list-valued variable plus the separator used to join it for a bare ``$name`` reference (SP2a multi-select). Produced only by a multi-select wildcard; exists only inside one resolution pass — never persisted or published. ``$name`` renders ``sep.join(items)``; ``$name.K`` indexes (0-based; out-of-range -> ""). """ items: list[str] sep: str def __str__(self) -> str: """A bare ``$name`` reference renders the list joined by ``sep``. Defining ``__str__`` (the @dataclass ``__repr__`` stays, for debug) keeps a list value that leaks into a plain ``str()`` context rendering as the user-facing joined string instead of ``ListVar(items=[...])``.""" return self.sep.join(self.items) _VAR_ACCESSOR_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\.(\d+)$") def split_var_accessor(name: str) -> tuple[str, int | None]: """Split a bare var reference into ``(base_name, index)`` (SP2a `.K`). ``"mood.0"`` -> ``("mood", 0)``; ``"mood"`` -> ``("mood", None)``. A name that is not an ``ident.digits`` shape is returned whole with ``index=None`` so unusual keys pass through unchanged. Callers strip any leading ``$`` before calling. """ if not isinstance(name, str): return ("", None) m = _VAR_ACCESSOR_RE.match(name) if m: return (m.group(1), int(m.group(2))) return (name, None) _VAR_REF_RE = re.compile( r"^([A-Za-z_][A-Za-z0-9_]*)" r"(?:\.(?:(\d+)(?:\.([A-Za-z_][A-Za-z0-9_]*))?" r"|([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?))?$" ) def parse_var_reference(name: str) -> tuple[str, int | None, str | None]: """Split a bare var reference into ``(base, index, axis)``. The string-path twin of the tokenizer's ``_VAR_RE`` — derivation conditions store ``condition.var`` as text, not tokens. Kept separate from :func:`split_var_accessor` so the SP2a list-accessor contract and its existing callers are untouched. Both accessor orders are accepted and normalise to the same triple: ``outfit.0.SHOES`` and ``outfit.SHOES.0`` are the same reference. A name that matches nothing is returned whole with both accessors ``None``, so an unusual ctx key resolves as itself rather than being truncated. """ if not isinstance(name, str): return ("", None, None) m = _VAR_REF_RE.match(name) if not m: return (name, None, None) idx = m.group(2) or m.group(5) axis = m.group(3) or m.group(4) return (m.group(1), int(idx) if idx is not None else None, axis) def bool_text(value: bool) -> str: """A boolean as a variable's text: "true" / "false", the words WP Toggles writes and a derivation's equals test compares against.""" return "true" if value else "false" def deref_var_value(value: str | ListVar | None, index: int | None) -> str: """Render a resolved var value to a string, honoring an optional ``.K`` list accessor (SP2a). ListVar: bare -> ``sep.join(items)``; ``.K`` -> ``items[K]`` or ``""`` when out of range. A plain string behaves as a 1-element list (``.0`` == itself, ``.K`` > 0 == ``""``). ``None`` -> ``""``. Single source of the accessor contract: resolve.py, derivation_handler.py and converters.py all defer here so the read sites never drift. """ if value is None: return "" if isinstance(value, ListVar): if index is not None: return value.items[index] if 0 <= index < len(value.items) else "" return value.sep.join(value.items) if isinstance(value, bool): s = bool_text(value) else: s = value if isinstance(value, str) else str(value) if index is not None: return s if index == 0 else "" return s @runtime_checkable class ResolveContext(Protocol): """Interface the resolver depends on. Construct via build_resolve_ctx in engine/modules/__init__.py at pipeline start; in tests use a duck-typed fake (any class with these attrs and methods). """ rng: random.Random max_ref_depth: int strict: bool surface: SurfaceKind developer_mode: bool warnings: list[dict[str, Any]] def get_var(self, name: str) -> str | ListVar | None: """Return the bound value of `name`, or None if unbound. A multi-select wildcard binds a ``ListVar`` (SP2a); everything else binds a ``str``.""" ... def get_axis(self, name: str, axis: str) -> str | list[str | None] | None: """Return the tag rolled for `name`'s `axis`, or None if it has none. A single-pick source yields a ``str``; a multi-select one yields a list with one slot per pick in pick order (``None`` for a pick whose option carries no tag on this axis, so indexes line up with the variable's own). The value was rolled when the wildcard picked — this only reads it back, which is what makes two modules (or two nodes) agree. """ ... def declared_axes(self, name: str) -> list[str] | None: """Return the ``accepts`` axes the wildcard bound to `name` declares, or None when that is not known. Lets an empty axis read tell "this pick carries no tag on the axis" apart from "there is no such axis".""" ... def get_module(self, uuid: str) -> dict[str, Any] | None: """Return the module catalog row for `uuid`, or None if missing. Row shape (only fields the resolver uses): `{"type": str, "var_binding": str, "options": list[{"value": str, "weight": float}]}`. """ ... # ---------- Error hierarchy --------------------------------------------------- class SyntaxError(Exception): """Base for all engine syntax errors. Distinct from Python's built-in SyntaxError (which is for source-code parse errors); this one is for wildcard syntax.""" class UnknownRefError(SyntaxError): """@{uuid} points to a module not in the catalog.""" def __init__(self, uuid: str) -> None: self.uuid = uuid super().__init__(f"unknown wildcard ref: @{{{uuid}}}") class RefOutOfSurfaceError(SyntaxError): """@{uuid} appears in a non-wildcard surface (combine/derivation/assembler).""" def __init__(self, uuid: str, surface: str) -> None: self.uuid = uuid self.surface = surface super().__init__( f"ref @{{{uuid}}} not allowed in {surface!r} surface; " f"only wildcard option values" ) class VarOutOfSurfaceError(SyntaxError): """$var read attempted in a surface that doesn't allow it. Combine, derivation, and assembler surfaces support $var reads. Wildcard and fixed_values surfaces are binding PRODUCERS, not consumers — $var reads in those surfaces warn (lenient) or raise (strict) per ResolveContext.strict. """ def __init__(self, name: str, surface: str) -> None: self.name = name self.surface = surface super().__init__( f"$var ${name} not allowed in {surface!r} surface; " f"only combine / derivation / assembler surfaces support $var reads" ) class RecursionLimitExceeded(SyntaxError): """@{uuid} chain reached configured max_ref_depth.""" class CycleDetectedError(SyntaxError): """@{uuid} chain returned to an already-visiting UUID.""" def __init__(self, chain: list[str]) -> None: self.chain = chain super().__init__( "cycle: " + " → ".join(f"@{{{u}}}" for u in chain) ) class MalformedSyntaxError(SyntaxError): """Tokenizer / resolver received structurally invalid token meta."""