feat(syntax): resolve_text dispatch loop with text/escape/var resolution

This commit is contained in:
DumiFlex
2026-04-28 05:13:35 +03:00
parent 6942159d6a
commit 9e05e1fb66
3 changed files with 138 additions and 0 deletions
+2
View File
@@ -3,6 +3,7 @@
See `docs/superpowers/specs/2026-04-28-engine-syntax-design.md` for the
language contract and parity rules.
"""
from engine.syntax.resolve import resolve_text
from engine.syntax.tokenize import tokenize_text
from engine.syntax.types import (
CycleDetectedError,
@@ -28,5 +29,6 @@ __all__ = [
"Token",
"TokenKind",
"UnknownRefError",
"resolve_text",
"tokenize_text",
]
+68
View File
@@ -0,0 +1,68 @@
"""Token-walking resolver. Mirrors `resolveTokens.ts` (Task 12 will land that).
Each token kind has a `_resolve_<kind>` helper. The main loop walks tokens
left-to-right, dispatches by kind, and concatenates results. Recursion
happens inside `_resolve_ref` (Task 9) and brace resolvers (Tasks 10–11).
"""
from __future__ import annotations
from engine.syntax.tokenize import tokenize_text
from engine.syntax.types import ResolveContext, Token, TokenKind
def resolve_text(text: str, ctx: ResolveContext) -> str:
"""Resolve `text` against `ctx`, returning the final string.
Walks tokens once; for each token, dispatches to a per-kind resolver.
`depth` and `visited` are managed internally during ref recursion.
"""
tokens = tokenize_text(text)
return _resolve_tokens(tokens, ctx, depth=0, visited=())
def _resolve_tokens(
tokens: list[Token],
ctx: ResolveContext,
depth: int,
visited: tuple[str, ...],
) -> str:
parts: list[str] = []
for tok in tokens:
if tok.kind == TokenKind.TEXT:
parts.append(tok.raw)
elif tok.kind == TokenKind.ESCAPE:
parts.append(tok.meta.get("literal", ""))
elif tok.kind == TokenKind.VAR:
parts.append(_resolve_var(tok, ctx))
elif tok.kind == TokenKind.REF:
# Implemented in Task 9
raise NotImplementedError("REF resolution lands in Task 9")
elif tok.kind == TokenKind.DP_BRACE:
# Implemented in Task 10
raise NotImplementedError("DP_BRACE resolution lands in Task 10")
elif tok.kind == TokenKind.DP_MULTI:
# Implemented in Task 11
raise NotImplementedError("DP_MULTI resolution lands in Task 11")
elif tok.kind == TokenKind.DP_PIPE:
# DP_PIPE is never emitted as a top-level token by the current
# tokenizer (pipes are absorbed into branch boundaries). If one
# appears here, that's a tokenizer bug.
raise AssertionError("DP_PIPE should never be top-level")
else:
raise AssertionError(f"unknown token kind: {tok.kind}")
return "".join(parts)
def _resolve_var(tok: Token, ctx: ResolveContext) -> str:
"""Look up a variable in ctx. Returns empty string for missing or internal-key vars."""
name = tok.meta.get("name", "")
if name.startswith("__"):
# Engine-internal keys are never substituted.
return ""
value = ctx.get_var(name)
if value is None:
# TODO(task-9-onward): push warning when unknown var? Spec doesn't
# explicitly call out unknown vars as warnings; they emit "" silently.
# Revisit if user feedback shows confusion.
return ""
return str(value)
+68
View File
@@ -0,0 +1,68 @@
"""Test resolve_text core dispatch loop and per-kind resolution."""
from __future__ import annotations
import random
from dataclasses import dataclass, field
from typing import Any
from engine.syntax import resolve_text
from engine.syntax.types import SurfaceKind
@dataclass
class _FakeCtx:
"""Test double matching the ResolveContext Protocol."""
rng: random.Random = field(default_factory=lambda: random.Random(42))
max_ref_depth: int = 8
strict: bool = False
surface: SurfaceKind = "wildcard"
developer_mode: bool = False
warnings: list[dict[str, Any]] = field(default_factory=list)
_vars: dict[str, str] = field(default_factory=dict)
_modules: dict[str, dict[str, Any]] = field(default_factory=dict)
def get_var(self, name: str) -> str | None:
return self._vars.get(name)
def get_module(self, uuid: str) -> dict[str, Any] | None:
return self._modules.get(uuid)
def _ctx(**kwargs) -> _FakeCtx:
return _FakeCtx(**kwargs)
def test_resolve_empty_string():
assert resolve_text("", _ctx()) == ""
def test_resolve_plain_text():
assert resolve_text("hello world", _ctx()) == "hello world"
def test_resolve_escape_dollar():
assert resolve_text("price: $$5", _ctx()) == "price: $5"
def test_resolve_escape_at():
assert resolve_text("user@@example", _ctx()) == "user@example"
def test_resolve_var_present():
ctx = _ctx(_vars={"color": "red"})
assert resolve_text("$color thing", ctx) == "red thing"
def test_resolve_var_missing_lenient_emits_empty():
ctx = _ctx(strict=False)
assert resolve_text("$missing thing", ctx) == " thing"
# Note: whitespace cleanup is the caller's responsibility (engine/template.py
# adds a thin pass). The raw resolver output is what we test here.
def test_resolve_var_skips_internal_keys():
"""$__name__ pattern is engine-internal and should never resolve via ctx."""
ctx = _ctx(_vars={"__wp_node_seed__": "42"})
# Tokenizer recognizes $__wp_node_seed__ as a var, but resolver checks
# if name starts with __ and refuses.
assert resolve_text("$__wp_node_seed__", ctx) == ""