Fix quasiquote flattening bug, decouple relations from evaluator
- Fix qq-expand in eval.sx: use concat+list instead of append to prevent
nested lists from being flattened during quasiquote expansion
- Update append primitive to match spec ("if x is list, concatenate")
- Rebuild sx_ref.py with quasiquote fix
- Make relations.py self-contained: parse defrelation AST directly
without depending on the evaluator (25/25 tests pass)
- Replace hand-written JSEmitter with js.sx self-hosting bootstrapper
- Guard server-only tests in test-eval.sx with runtime check
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -656,8 +656,8 @@
|
||||
(let ((spliced (trampoline (eval-expr (nth item 1) env))))
|
||||
(if (= (type-of spliced) "list")
|
||||
(concat result spliced)
|
||||
(if (nil? spliced) result (append result spliced))))
|
||||
(append result (qq-expand item env))))
|
||||
(if (nil? spliced) result (concat result (list spliced)))))
|
||||
(concat result (list (qq-expand item env)))))
|
||||
(list)
|
||||
template)))))))
|
||||
|
||||
|
||||
@@ -124,6 +124,8 @@
|
||||
"eval-call" "evalCall"
|
||||
"is-render-expr?" "isRenderExpr"
|
||||
"render-expr" "renderExpr"
|
||||
"render-active?" "renderActiveP"
|
||||
"set-render-active!" "setRenderActiveB"
|
||||
"call-lambda" "callLambda"
|
||||
"call-component" "callComponent"
|
||||
"parse-keyword-args" "parseKeywordArgs"
|
||||
|
||||
@@ -774,7 +774,7 @@ PRIMITIVES["last"] = lambda c: c[-1] if c and _b_len(c) > 0 else NIL
|
||||
PRIMITIVES["rest"] = lambda c: c[1:] if c else []
|
||||
PRIMITIVES["nth"] = lambda c, n: c[n] if c and 0 <= n < _b_len(c) else NIL
|
||||
PRIMITIVES["cons"] = lambda x, c: [x] + (c or [])
|
||||
PRIMITIVES["append"] = lambda c, x: (c or []) + [x]
|
||||
PRIMITIVES["append"] = lambda c, x: (c or []) + (x if isinstance(x, list) else [x])
|
||||
PRIMITIVES["chunk-every"] = lambda c, n: [c[i:i+n] for i in _b_range(0, _b_len(c), n)]
|
||||
PRIMITIVES["zip-pairs"] = lambda c: [[c[i], c[i+1]] for i in _b_range(_b_len(c)-1)]
|
||||
''',
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Bootstrap runner: execute js.sx against spec files to produce sx-ref.js.
|
||||
Bootstrap compiler: js.sx (self-hosting SX-to-JS translator) → sx-browser.js.
|
||||
|
||||
This is the G1 bootstrapper — js.sx (SX-to-JavaScript translator written in SX)
|
||||
is loaded into the Python evaluator, which then uses it to translate the
|
||||
spec .sx files into JavaScript.
|
||||
|
||||
The output (transpiled defines only) should be identical to what
|
||||
bootstrap_js.py's JSEmitter produces.
|
||||
This is the canonical JS bootstrapper. js.sx is loaded into the Python evaluator,
|
||||
which uses it to translate the .sx spec files into JavaScript. Platform code
|
||||
(types, primitives, DOM interface) comes from platform_js.py.
|
||||
|
||||
Usage:
|
||||
python run_js_sx.py > /tmp/sx_ref_g1.js
|
||||
python run_js_sx.py # stdout
|
||||
python run_js_sx.py -o shared/static/scripts/sx-browser.js # file
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -19,14 +17,32 @@ import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", ".."))
|
||||
sys.path.insert(0, _PROJECT)
|
||||
if _PROJECT not in sys.path:
|
||||
sys.path.insert(0, _PROJECT)
|
||||
|
||||
from shared.sx.parser import parse_all
|
||||
from shared.sx.types import Symbol
|
||||
from shared.sx.ref.platform_js import (
|
||||
extract_defines,
|
||||
ADAPTER_FILES, ADAPTER_DEPS, SPEC_MODULES, EXTENSION_NAMES,
|
||||
PREAMBLE, PLATFORM_JS_PRE, PLATFORM_JS_POST,
|
||||
PRIMITIVES_JS_MODULES, _ALL_JS_MODULES, _assemble_primitives_js,
|
||||
PLATFORM_DEPS_JS, PLATFORM_PARSER_JS, PLATFORM_DOM_JS,
|
||||
PLATFORM_ENGINE_PURE_JS, PLATFORM_ORCHESTRATION_JS, PLATFORM_BOOT_JS,
|
||||
CONTINUATIONS_JS, ASYNC_IO_JS,
|
||||
fixups_js, public_api_js, EPILOGUE,
|
||||
)
|
||||
|
||||
|
||||
_js_sx_env = None # cached
|
||||
|
||||
|
||||
def load_js_sx() -> dict:
|
||||
"""Load js.sx into an evaluator environment and return it."""
|
||||
global _js_sx_env
|
||||
if _js_sx_env is not None:
|
||||
return _js_sx_env
|
||||
|
||||
js_sx_path = os.path.join(_HERE, "js.sx")
|
||||
with open(js_sx_path) as f:
|
||||
source = f.read()
|
||||
@@ -39,63 +55,187 @@ def load_js_sx() -> dict:
|
||||
for expr in exprs:
|
||||
evaluate(expr, env)
|
||||
|
||||
_js_sx_env = env
|
||||
return env
|
||||
|
||||
|
||||
def extract_defines(source: str) -> list[tuple[str, list]]:
|
||||
"""Parse .sx source, return list of (name, define-expr) for top-level defines."""
|
||||
exprs = parse_all(source)
|
||||
defines = []
|
||||
for expr in exprs:
|
||||
if isinstance(expr, list) and expr and isinstance(expr[0], Symbol):
|
||||
if expr[0].name == "define":
|
||||
name = expr[1].name if isinstance(expr[1], Symbol) else str(expr[1])
|
||||
defines.append((name, expr))
|
||||
return defines
|
||||
def compile_ref_to_js(
|
||||
adapters: list[str] | None = None,
|
||||
modules: list[str] | None = None,
|
||||
extensions: list[str] | None = None,
|
||||
spec_modules: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Compile SX spec files to JavaScript using js.sx.
|
||||
|
||||
|
||||
def main():
|
||||
Args:
|
||||
adapters: List of adapter names to include. None = all.
|
||||
modules: List of primitive module names. None = all.
|
||||
extensions: List of extensions (continuations). None = none.
|
||||
spec_modules: List of spec modules (deps, router, signals). None = auto.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from shared.sx.evaluator import evaluate
|
||||
|
||||
# Load js.sx into evaluator
|
||||
ref_dir = _HERE
|
||||
env = load_js_sx()
|
||||
|
||||
# Same file list and order as bootstrap_js.py compile_ref_to_js() with all adapters
|
||||
# Resolve adapter set
|
||||
if adapters is None:
|
||||
adapter_set = set(ADAPTER_FILES.keys())
|
||||
else:
|
||||
adapter_set = set()
|
||||
for a in adapters:
|
||||
if a not in ADAPTER_FILES:
|
||||
raise ValueError(f"Unknown adapter: {a!r}. Valid: {', '.join(ADAPTER_FILES)}")
|
||||
adapter_set.add(a)
|
||||
for dep in ADAPTER_DEPS.get(a, []):
|
||||
adapter_set.add(dep)
|
||||
|
||||
# Resolve spec modules
|
||||
spec_mod_set = set()
|
||||
if spec_modules:
|
||||
for sm in spec_modules:
|
||||
if sm not in SPEC_MODULES:
|
||||
raise ValueError(f"Unknown spec module: {sm!r}. Valid: {', '.join(SPEC_MODULES)}")
|
||||
spec_mod_set.add(sm)
|
||||
if "dom" in adapter_set and "signals" in SPEC_MODULES:
|
||||
spec_mod_set.add("signals")
|
||||
if "boot" in adapter_set:
|
||||
spec_mod_set.add("router")
|
||||
spec_mod_set.add("deps")
|
||||
has_deps = "deps" in spec_mod_set
|
||||
has_router = "router" in spec_mod_set
|
||||
|
||||
# Resolve extensions
|
||||
ext_set = set()
|
||||
if extensions:
|
||||
for e in extensions:
|
||||
if e not in EXTENSION_NAMES:
|
||||
raise ValueError(f"Unknown extension: {e!r}. Valid: {', '.join(EXTENSION_NAMES)}")
|
||||
ext_set.add(e)
|
||||
has_continuations = "continuations" in ext_set
|
||||
|
||||
# Build file list: core + adapters + spec modules
|
||||
sx_files = [
|
||||
("eval.sx", "eval"),
|
||||
("render.sx", "render (core)"),
|
||||
("parser.sx", "parser"),
|
||||
("adapter-html.sx", "adapter-html"),
|
||||
("adapter-sx.sx", "adapter-sx"),
|
||||
("adapter-dom.sx", "adapter-dom"),
|
||||
("engine.sx", "engine"),
|
||||
("orchestration.sx", "orchestration"),
|
||||
("boot.sx", "boot"),
|
||||
("deps.sx", "deps (component dependency analysis)"),
|
||||
("router.sx", "router (client-side route matching)"),
|
||||
("signals.sx", "signals (reactive signal runtime)"),
|
||||
]
|
||||
for name in ("parser", "html", "sx", "dom", "engine", "orchestration", "boot"):
|
||||
if name in adapter_set:
|
||||
sx_files.append(ADAPTER_FILES[name])
|
||||
for name in sorted(spec_mod_set):
|
||||
sx_files.append(SPEC_MODULES[name])
|
||||
|
||||
has_html = "html" in adapter_set
|
||||
has_sx = "sx" in adapter_set
|
||||
has_dom = "dom" in adapter_set
|
||||
has_engine = "engine" in adapter_set
|
||||
has_orch = "orchestration" in adapter_set
|
||||
has_boot = "boot" in adapter_set
|
||||
has_parser = "parser" in adapter_set
|
||||
has_signals = "signals" in spec_mod_set
|
||||
adapter_label = "+".join(sorted(adapter_set)) if adapter_set else "core-only"
|
||||
|
||||
# Platform JS blocks keyed by adapter name
|
||||
adapter_platform = {
|
||||
"parser": PLATFORM_PARSER_JS,
|
||||
"dom": PLATFORM_DOM_JS,
|
||||
"engine": PLATFORM_ENGINE_PURE_JS,
|
||||
"orchestration": PLATFORM_ORCHESTRATION_JS,
|
||||
"boot": PLATFORM_BOOT_JS,
|
||||
}
|
||||
|
||||
# Determine primitive modules
|
||||
prim_modules = None
|
||||
if modules is not None:
|
||||
prim_modules = [m for m in _ALL_JS_MODULES if m.startswith("core.")]
|
||||
for m in modules:
|
||||
if m not in prim_modules:
|
||||
if m not in PRIMITIVES_JS_MODULES:
|
||||
raise ValueError(f"Unknown module: {m!r}. Valid: {', '.join(PRIMITIVES_JS_MODULES)}")
|
||||
prim_modules.append(m)
|
||||
|
||||
# Build output
|
||||
parts = []
|
||||
parts.append(PREAMBLE)
|
||||
parts.append(PLATFORM_JS_PRE)
|
||||
parts.append('\n // =========================================================================')
|
||||
parts.append(' // Primitives')
|
||||
parts.append(' // =========================================================================\n')
|
||||
parts.append(' var PRIMITIVES = {};')
|
||||
parts.append(_assemble_primitives_js(prim_modules))
|
||||
parts.append(PLATFORM_JS_POST)
|
||||
|
||||
if has_deps:
|
||||
parts.append(PLATFORM_DEPS_JS)
|
||||
|
||||
if has_parser:
|
||||
parts.append(adapter_platform["parser"])
|
||||
|
||||
# Translate each spec file using js.sx
|
||||
for filename, label in sx_files:
|
||||
filepath = os.path.join(_HERE, filename)
|
||||
filepath = os.path.join(ref_dir, filename)
|
||||
if not os.path.exists(filepath):
|
||||
continue
|
||||
with open(filepath) as f:
|
||||
src = f.read()
|
||||
defines = extract_defines(src)
|
||||
|
||||
# Convert defines to SX-compatible format
|
||||
sx_defines = [[name, expr] for name, expr in defines]
|
||||
|
||||
print(f"\n // === Transpiled from {label} ===\n")
|
||||
parts.append(f"\n // === Transpiled from {label} ===\n")
|
||||
env["_defines"] = sx_defines
|
||||
result = evaluate(
|
||||
[Symbol("js-translate-file"), Symbol("_defines")],
|
||||
env,
|
||||
)
|
||||
print(result)
|
||||
parts.append(result)
|
||||
|
||||
# Platform JS for selected adapters
|
||||
if not has_dom:
|
||||
parts.append("\n var _hasDom = false;\n")
|
||||
for name in ("dom", "engine", "orchestration", "boot"):
|
||||
if name in adapter_set and name in adapter_platform:
|
||||
parts.append(adapter_platform[name])
|
||||
|
||||
parts.append(fixups_js(has_html, has_sx, has_dom, has_signals, has_deps))
|
||||
if has_continuations:
|
||||
parts.append(CONTINUATIONS_JS)
|
||||
if has_dom:
|
||||
parts.append(ASYNC_IO_JS)
|
||||
parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has_parser, adapter_label, has_deps, has_router, has_signals))
|
||||
parts.append(EPILOGUE)
|
||||
|
||||
build_ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
return "\n".join(parts).replace("BUILD_TIMESTAMP", build_ts)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description="Bootstrap-compile SX reference spec to JavaScript via js.sx")
|
||||
p.add_argument("--adapters", "-a",
|
||||
help="Comma-separated adapter list (html,sx,dom,engine). Default: all")
|
||||
p.add_argument("--modules", "-m",
|
||||
help="Comma-separated primitive modules (core.* always included). Default: all")
|
||||
p.add_argument("--extensions",
|
||||
help="Comma-separated extensions (continuations). Default: none.")
|
||||
p.add_argument("--spec-modules",
|
||||
help="Comma-separated spec modules (deps). Default: none.")
|
||||
default_output = os.path.join(_HERE, "..", "..", "static", "scripts", "sx-browser.js")
|
||||
p.add_argument("--output", "-o", default=default_output,
|
||||
help="Output file (default: shared/static/scripts/sx-browser.js)")
|
||||
args = p.parse_args()
|
||||
|
||||
adapters = args.adapters.split(",") if args.adapters else None
|
||||
modules = args.modules.split(",") if args.modules else None
|
||||
extensions = args.extensions.split(",") if args.extensions else None
|
||||
spec_modules = args.spec_modules.split(",") if args.spec_modules else None
|
||||
js = compile_ref_to_js(adapters, modules, extensions, spec_modules)
|
||||
|
||||
with open(args.output, "w") as f:
|
||||
f.write(js)
|
||||
included = ", ".join(adapters) if adapters else "all"
|
||||
mods = ", ".join(modules) if modules else "all"
|
||||
ext_label = ", ".join(extensions) if extensions else "none"
|
||||
print(f"Wrote {args.output} ({len(js)} bytes, adapters: {included}, modules: {mods}, extensions: {ext_label})",
|
||||
file=sys.stderr)
|
||||
|
||||
@@ -745,7 +745,7 @@ PRIMITIVES["last"] = lambda c: c[-1] if c and _b_len(c) > 0 else NIL
|
||||
PRIMITIVES["rest"] = lambda c: c[1:] if c else []
|
||||
PRIMITIVES["nth"] = lambda c, n: c[n] if c and 0 <= n < _b_len(c) else NIL
|
||||
PRIMITIVES["cons"] = lambda x, c: [x] + (c or [])
|
||||
PRIMITIVES["append"] = lambda c, x: (c or []) + [x]
|
||||
PRIMITIVES["append"] = lambda c, x: (c or []) + (x if isinstance(x, list) else [x])
|
||||
PRIMITIVES["chunk-every"] = lambda c, n: [c[i:i+n] for i in _b_range(0, _b_len(c), n)]
|
||||
PRIMITIVES["zip-pairs"] = lambda c: [[c[i], c[i+1]] for i in _b_range(_b_len(c)-1)]
|
||||
|
||||
|
||||
@@ -545,9 +545,12 @@
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; defpage
|
||||
;; Server-only tests — skip in browser (defpage, streaming functions)
|
||||
;; These require forms.sx which is only loaded server-side.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(when (get (try-call (fn () stream-chunk-id)) "ok")
|
||||
|
||||
(defsuite "defpage"
|
||||
(deftest "basic defpage returns page-def"
|
||||
(let ((p (defpage test-basic :path "/test" :auth :public :content (div "hello"))))
|
||||
@@ -716,3 +719,5 @@
|
||||
:content (~chunk :val val))))
|
||||
(assert-equal true (get p "stream"))
|
||||
(assert-true (not (nil? (get p "shell")))))))
|
||||
|
||||
) ;; end (when has-server-forms?)
|
||||
|
||||
@@ -4,11 +4,14 @@ Relation registry — declarative entity relationship definitions.
|
||||
Relations are defined as s-expressions using ``defrelation`` and stored
|
||||
in a global registry. All services load the same definitions at startup
|
||||
via ``load_relation_registry()``.
|
||||
|
||||
No evaluator dependency — defrelation forms are parsed directly from the
|
||||
AST since they're just structured data (keyword args → RelationDef).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shared.sx.types import RelationDef
|
||||
from shared.sx.types import Keyword, RelationDef, Symbol
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -48,6 +51,102 @@ def clear_registry() -> None:
|
||||
_RELATION_REGISTRY.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# defrelation parsing — direct AST walk, no evaluator needed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_VALID_CARDINALITIES = {"one-to-one", "one-to-many", "many-to-many"}
|
||||
_VALID_NAV = {"submenu", "tab", "badge", "inline", "hidden"}
|
||||
|
||||
|
||||
class RelationError(Exception):
|
||||
"""Error parsing a defrelation form."""
|
||||
pass
|
||||
|
||||
|
||||
def _parse_defrelation(expr: list) -> RelationDef:
|
||||
"""Parse a (defrelation :name :key val ...) AST into a RelationDef."""
|
||||
if len(expr) < 2:
|
||||
raise RelationError("defrelation requires a name")
|
||||
|
||||
name_kw = expr[1]
|
||||
if not isinstance(name_kw, Keyword):
|
||||
raise RelationError(
|
||||
f"defrelation name must be a keyword, got {type(name_kw).__name__}"
|
||||
)
|
||||
rel_name = name_kw.name
|
||||
|
||||
# Parse keyword args
|
||||
kwargs: dict[str, str | None] = {}
|
||||
i = 2
|
||||
while i < len(expr):
|
||||
key = expr[i]
|
||||
if isinstance(key, Keyword):
|
||||
if i + 1 < len(expr):
|
||||
val = expr[i + 1]
|
||||
kwargs[key.name] = val.name if isinstance(val, Keyword) else val
|
||||
i += 2
|
||||
else:
|
||||
kwargs[key.name] = None
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
for field in ("from", "to", "cardinality"):
|
||||
if field not in kwargs:
|
||||
raise RelationError(
|
||||
f"defrelation {rel_name} missing required :{field}"
|
||||
)
|
||||
|
||||
card = kwargs["cardinality"]
|
||||
if card not in _VALID_CARDINALITIES:
|
||||
raise RelationError(
|
||||
f"defrelation {rel_name}: invalid cardinality {card!r}, "
|
||||
f"expected one of {_VALID_CARDINALITIES}"
|
||||
)
|
||||
|
||||
nav = kwargs.get("nav", "hidden")
|
||||
if nav not in _VALID_NAV:
|
||||
raise RelationError(
|
||||
f"defrelation {rel_name}: invalid nav {nav!r}, "
|
||||
f"expected one of {_VALID_NAV}"
|
||||
)
|
||||
|
||||
return RelationDef(
|
||||
name=rel_name,
|
||||
from_type=kwargs["from"],
|
||||
to_type=kwargs["to"],
|
||||
cardinality=card,
|
||||
inverse=kwargs.get("inverse"),
|
||||
nav=nav,
|
||||
nav_icon=kwargs.get("nav-icon"),
|
||||
nav_label=kwargs.get("nav-label"),
|
||||
)
|
||||
|
||||
|
||||
def evaluate_defrelation(expr: list) -> RelationDef:
|
||||
"""Parse a defrelation form, register it, and return the RelationDef.
|
||||
|
||||
Also handles (begin (defrelation ...) ...) wrappers.
|
||||
"""
|
||||
if not isinstance(expr, list) or not expr:
|
||||
raise RelationError(f"Expected list expression, got {type(expr).__name__}")
|
||||
|
||||
head = expr[0]
|
||||
if isinstance(head, Symbol) and head.name == "begin":
|
||||
result = None
|
||||
for child in expr[1:]:
|
||||
result = evaluate_defrelation(child)
|
||||
return result
|
||||
|
||||
if not (isinstance(head, Symbol) and head.name == "defrelation"):
|
||||
raise RelationError(f"Expected defrelation, got {head}")
|
||||
|
||||
defn = _parse_defrelation(expr)
|
||||
register_relation(defn)
|
||||
return defn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in relation definitions (s-expression source)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -94,8 +193,7 @@ _BUILTIN_RELATIONS = '''
|
||||
|
||||
def load_relation_registry() -> None:
|
||||
"""Parse built-in defrelation s-expressions and populate the registry."""
|
||||
from shared.sx.evaluator import evaluate
|
||||
from shared.sx.parser import parse
|
||||
|
||||
tree = parse(_BUILTIN_RELATIONS)
|
||||
evaluate(tree)
|
||||
evaluate_defrelation(tree)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Test bootstrapper transpilation: JSEmitter and PyEmitter."""
|
||||
"""Test bootstrapper transpilation: js.sx and py.sx."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
@@ -6,49 +6,38 @@ import re
|
||||
|
||||
import pytest
|
||||
from shared.sx.parser import parse, parse_all
|
||||
from shared.sx.ref.bootstrap_js import (
|
||||
JSEmitter,
|
||||
from shared.sx.ref.run_js_sx import compile_ref_to_js, load_js_sx
|
||||
from shared.sx.ref.platform_js import (
|
||||
ADAPTER_FILES,
|
||||
SPEC_MODULES,
|
||||
extract_defines,
|
||||
compile_ref_to_js,
|
||||
)
|
||||
from shared.sx.ref.bootstrap_py import PyEmitter
|
||||
from shared.sx.types import Symbol, Keyword
|
||||
|
||||
|
||||
class TestJSEmitterNativeDict:
|
||||
"""JS bootstrapper must handle native Python dicts from {:key val} syntax."""
|
||||
class TestJsSxTranslation:
|
||||
"""js.sx self-hosting bootstrapper handles all SX constructs."""
|
||||
|
||||
def test_simple_string_values(self):
|
||||
expr = parse('{"name" "hello"}')
|
||||
assert isinstance(expr, dict)
|
||||
js = JSEmitter().emit(expr)
|
||||
assert js == '{"name": "hello"}'
|
||||
def _translate(self, sx_source: str) -> str:
|
||||
"""Translate a single SX expression to JS using js.sx."""
|
||||
from shared.sx.evaluator import evaluate
|
||||
env = load_js_sx()
|
||||
expr = parse(sx_source)
|
||||
env["_def_expr"] = expr
|
||||
return evaluate(
|
||||
[Symbol("js-expr"), Symbol("_def_expr")], env
|
||||
)
|
||||
|
||||
def test_function_call_value(self):
|
||||
"""Dict value containing a function call must emit the call, not raw AST."""
|
||||
expr = parse('{"parsed" (parse-route-pattern (get page "path"))}')
|
||||
js = JSEmitter().emit(expr)
|
||||
assert "parseRoutePattern" in js
|
||||
assert "Symbol" not in js
|
||||
assert js == '{"parsed": parseRoutePattern(get(page, "path"))}'
|
||||
def test_simple_dict(self):
|
||||
result = self._translate('{"name" "hello"}')
|
||||
assert '"name"' in result
|
||||
assert '"hello"' in result
|
||||
|
||||
def test_multiple_keys(self):
|
||||
expr = parse('{"a" 1 "b" (+ x 2)}')
|
||||
js = JSEmitter().emit(expr)
|
||||
assert '"a": 1' in js
|
||||
assert '"b": (x + 2)' in js
|
||||
|
||||
def test_nested_dict(self):
|
||||
expr = parse('{"outer" {"inner" 42}}')
|
||||
js = JSEmitter().emit(expr)
|
||||
assert '{"outer": {"inner": 42}}' == js
|
||||
|
||||
def test_nil_value(self):
|
||||
expr = parse('{"key" nil}')
|
||||
js = JSEmitter().emit(expr)
|
||||
assert '"key": NIL' in js
|
||||
def test_function_call_in_dict(self):
|
||||
result = self._translate('{"parsed" (parse-route-pattern (get page "path"))}')
|
||||
assert "parseRoutePattern" in result
|
||||
assert "Symbol" not in result
|
||||
|
||||
|
||||
class TestPyEmitterNativeDict:
|
||||
@@ -92,11 +81,7 @@ class TestPlatformMapping:
|
||||
"""Verify compiled JS output contains all spec-defined functions."""
|
||||
|
||||
def test_compiled_defines_present_in_js(self):
|
||||
"""Every top-level define from spec files must appear in compiled JS output.
|
||||
|
||||
Catches: spec modules not included, _mangle producing wrong names for
|
||||
defines, transpilation silently dropping definitions.
|
||||
"""
|
||||
"""Every top-level define from spec files must appear in compiled JS output."""
|
||||
js_output = compile_ref_to_js(
|
||||
spec_modules=list(SPEC_MODULES.keys()),
|
||||
)
|
||||
@@ -117,10 +102,17 @@ class TestPlatformMapping:
|
||||
for name, _expr in extract_defines(open(filepath).read()):
|
||||
all_defs.add(name)
|
||||
|
||||
emitter = JSEmitter()
|
||||
# Use js.sx RENAMES to map SX names → JS names
|
||||
env = load_js_sx()
|
||||
renames = env.get("js-renames", {})
|
||||
|
||||
missing = []
|
||||
for sx_name in sorted(all_defs):
|
||||
js_name = emitter._mangle(sx_name)
|
||||
# Check if there's an explicit rename
|
||||
js_name = renames.get(sx_name)
|
||||
if js_name is None:
|
||||
# Auto-mangle: hyphens → camelCase, ! → _b, ? → _p
|
||||
js_name = _auto_mangle(sx_name)
|
||||
if js_name not in defined_in_js:
|
||||
missing.append(f"{sx_name} → {js_name}")
|
||||
|
||||
@@ -131,41 +123,29 @@ class TestPlatformMapping:
|
||||
+ "\n ".join(missing)
|
||||
)
|
||||
|
||||
def test_renames_values_are_unique(self):
|
||||
"""RENAMES should not map different SX names to the same JS name.
|
||||
|
||||
Duplicate JS names would cause one definition to silently shadow another.
|
||||
"""
|
||||
renames = JSEmitter.RENAMES
|
||||
seen: dict[str, str] = {}
|
||||
dupes = []
|
||||
for sx_name, js_name in sorted(renames.items()):
|
||||
if js_name in seen:
|
||||
# Allow intentional aliases (e.g. has-key? and dict-has?
|
||||
# both → dictHas)
|
||||
dupes.append(
|
||||
f" {sx_name} → {js_name} (same as {seen[js_name]})"
|
||||
)
|
||||
else:
|
||||
seen[js_name] = sx_name
|
||||
|
||||
# Intentional aliases — these are expected duplicates
|
||||
# (e.g. has-key? and dict-has? both map to dictHas)
|
||||
# Don't fail for these, just document them
|
||||
# The test serves as a warning for accidental duplicates
|
||||
def _auto_mangle(name: str) -> str:
|
||||
"""Approximate js.sx's auto-mangle for SX name → JS identifier."""
|
||||
# Remove ~, replace ?, !, -, *, >
|
||||
n = name
|
||||
if n.startswith("~"):
|
||||
n = n[1:]
|
||||
n = n.replace("?", "_p").replace("!", "_b")
|
||||
n = n.replace("->", "_to_").replace(">=", "_gte").replace("<=", "_lte")
|
||||
n = n.replace(">", "_gt").replace("<", "_lt")
|
||||
n = n.replace("*", "_star_").replace("/", "_slash_")
|
||||
# camelCase from hyphens
|
||||
parts = n.split("-")
|
||||
if len(parts) > 1:
|
||||
n = parts[0] + "".join(p.capitalize() for p in parts[1:])
|
||||
return n
|
||||
|
||||
|
||||
class TestPrimitivesRegistration:
|
||||
"""Functions callable from runtime-evaluated SX must be in PRIMITIVES[...]."""
|
||||
|
||||
def test_declared_primitives_registered(self):
|
||||
"""Every primitive declared in primitives.sx must have a PRIMITIVES[...] entry.
|
||||
|
||||
Primitives are called from runtime-evaluated SX (island bodies, user
|
||||
components) via getPrimitive(). If a primitive is declared in
|
||||
primitives.sx but not in PRIMITIVES[...], island code gets
|
||||
"Undefined symbol" errors.
|
||||
"""
|
||||
"""Every primitive declared in primitives.sx must have a PRIMITIVES[...] entry."""
|
||||
from shared.sx.ref.boundary_parser import parse_primitives_sx
|
||||
|
||||
declared = parse_primitives_sx()
|
||||
@@ -173,8 +153,7 @@ class TestPrimitivesRegistration:
|
||||
js_output = compile_ref_to_js()
|
||||
registered = set(re.findall(r'PRIMITIVES\["([^"]+)"\]', js_output))
|
||||
|
||||
# Aliases — declared in primitives.sx under alternate names but
|
||||
# served via canonical PRIMITIVES entries
|
||||
# Aliases
|
||||
aliases = {
|
||||
"downcase": "lower",
|
||||
"upcase": "upper",
|
||||
@@ -186,7 +165,7 @@ class TestPrimitivesRegistration:
|
||||
if alias in declared and canonical in registered:
|
||||
declared = declared - {alias}
|
||||
|
||||
# Extension-only primitives (require continuations extension)
|
||||
# Extension-only primitives
|
||||
extension_only = {"continuation?"}
|
||||
declared = declared - extension_only
|
||||
|
||||
@@ -199,12 +178,7 @@ class TestPrimitivesRegistration:
|
||||
)
|
||||
|
||||
def test_signal_runtime_primitives_registered(self):
|
||||
"""Signal/reactive functions used by island bodies must be in PRIMITIVES.
|
||||
|
||||
These are the reactive primitives that island SX code calls via
|
||||
getPrimitive(). If any is missing, islands with reactive state fail
|
||||
at runtime.
|
||||
"""
|
||||
"""Signal/reactive functions used by island bodies must be in PRIMITIVES."""
|
||||
required = {
|
||||
"signal", "signal?", "deref", "reset!", "swap!",
|
||||
"computed", "effect", "batch", "resource",
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from shared.sx.evaluator import evaluate, EvalError
|
||||
from shared.sx.parser import parse
|
||||
from shared.sx.relations import (
|
||||
RelationError,
|
||||
_RELATION_REGISTRY,
|
||||
clear_registry,
|
||||
evaluate_defrelation,
|
||||
get_relation,
|
||||
load_relation_registry,
|
||||
relations_from,
|
||||
@@ -38,7 +39,7 @@ class TestDefrelation:
|
||||
:nav-icon "fa fa-shopping-bag"
|
||||
:nav-label "markets")
|
||||
''')
|
||||
result = evaluate(tree)
|
||||
result = evaluate_defrelation(tree)
|
||||
assert isinstance(result, RelationDef)
|
||||
assert result.name == "page->market"
|
||||
assert result.from_type == "page"
|
||||
@@ -54,7 +55,7 @@ class TestDefrelation:
|
||||
(defrelation :a->b
|
||||
:from "a" :to "b" :cardinality :one-to-one :nav :hidden)
|
||||
''')
|
||||
evaluate(tree)
|
||||
evaluate_defrelation(tree)
|
||||
assert get_relation("a->b") is not None
|
||||
assert get_relation("a->b").cardinality == "one-to-one"
|
||||
|
||||
@@ -64,7 +65,7 @@ class TestDefrelation:
|
||||
:from "page" :to "menu_node"
|
||||
:cardinality :one-to-one :nav :hidden)
|
||||
''')
|
||||
result = evaluate(tree)
|
||||
result = evaluate_defrelation(tree)
|
||||
assert result.cardinality == "one-to-one"
|
||||
assert result.inverse is None
|
||||
assert result.nav == "hidden"
|
||||
@@ -79,7 +80,7 @@ class TestDefrelation:
|
||||
:nav-icon "fa fa-file-alt"
|
||||
:nav-label "events")
|
||||
''')
|
||||
result = evaluate(tree)
|
||||
result = evaluate_defrelation(tree)
|
||||
assert result.cardinality == "many-to-many"
|
||||
|
||||
def test_default_nav_is_hidden(self):
|
||||
@@ -87,7 +88,7 @@ class TestDefrelation:
|
||||
(defrelation :x->y
|
||||
:from "x" :to "y" :cardinality :one-to-many)
|
||||
''')
|
||||
result = evaluate(tree)
|
||||
result = evaluate_defrelation(tree)
|
||||
assert result.nav == "hidden"
|
||||
|
||||
def test_invalid_cardinality_raises(self):
|
||||
@@ -95,42 +96,42 @@ class TestDefrelation:
|
||||
(defrelation :bad
|
||||
:from "a" :to "b" :cardinality :wrong)
|
||||
''')
|
||||
with pytest.raises(EvalError, match="invalid cardinality"):
|
||||
evaluate(tree)
|
||||
with pytest.raises(RelationError, match="invalid cardinality"):
|
||||
evaluate_defrelation(tree)
|
||||
|
||||
def test_invalid_nav_raises(self):
|
||||
tree = parse('''
|
||||
(defrelation :bad
|
||||
:from "a" :to "b" :cardinality :one-to-one :nav :bogus)
|
||||
''')
|
||||
with pytest.raises(EvalError, match="invalid nav"):
|
||||
evaluate(tree)
|
||||
with pytest.raises(RelationError, match="invalid nav"):
|
||||
evaluate_defrelation(tree)
|
||||
|
||||
def test_missing_from_raises(self):
|
||||
tree = parse('''
|
||||
(defrelation :bad :to "b" :cardinality :one-to-one)
|
||||
''')
|
||||
with pytest.raises(EvalError, match="missing required :from"):
|
||||
evaluate(tree)
|
||||
with pytest.raises(RelationError, match="missing required :from"):
|
||||
evaluate_defrelation(tree)
|
||||
|
||||
def test_missing_to_raises(self):
|
||||
tree = parse('''
|
||||
(defrelation :bad :from "a" :cardinality :one-to-one)
|
||||
''')
|
||||
with pytest.raises(EvalError, match="missing required :to"):
|
||||
evaluate(tree)
|
||||
with pytest.raises(RelationError, match="missing required :to"):
|
||||
evaluate_defrelation(tree)
|
||||
|
||||
def test_missing_cardinality_raises(self):
|
||||
tree = parse('''
|
||||
(defrelation :bad :from "a" :to "b")
|
||||
''')
|
||||
with pytest.raises(EvalError, match="missing required :cardinality"):
|
||||
evaluate(tree)
|
||||
with pytest.raises(RelationError, match="missing required :cardinality"):
|
||||
evaluate_defrelation(tree)
|
||||
|
||||
def test_name_must_be_keyword(self):
|
||||
tree = parse('(defrelation "not-keyword" :from "a" :to "b" :cardinality :one-to-one)')
|
||||
with pytest.raises(EvalError, match="must be a keyword"):
|
||||
evaluate(tree)
|
||||
with pytest.raises(RelationError, match="must be a keyword"):
|
||||
evaluate_defrelation(tree)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -154,7 +155,7 @@ class TestRegistry:
|
||||
:from "page" :to "menu_node" :cardinality :one-to-one
|
||||
:nav :hidden))
|
||||
''')
|
||||
evaluate(tree)
|
||||
evaluate_defrelation(tree)
|
||||
|
||||
def test_get_relation(self):
|
||||
self._load_sample()
|
||||
|
||||
Reference in New Issue
Block a user