feat(syntax): resolve_text dispatch loop with text/escape/var resolution
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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) == ""
|
||||
Reference in New Issue
Block a user