266 lines
8.9 KiB
Python
266 lines
8.9 KiB
Python
"""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."""
|