From 3749fe9625b879596b97de1d96f6b4d2aebbbce9 Mon Sep 17 00:00:00 2001 From: giles Date: Fri, 6 Mar 2026 16:24:44 +0000 Subject: [PATCH] Fix bootstrapper dict literal transpilation: emit values through emit() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SX parser produces native Python dicts for {:key val} syntax, but both JSEmitter and PyEmitter had no dict case in emit() — falling through to str(expr) which output raw AST. This broke client-side routing because process-page-scripts used {"parsed" (parse-route-pattern ...)} and the function call was emitted as a JS array of Symbols instead of an actual function call. Add _emit_native_dict() to both bootstrappers + 8 unit tests. Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-browser.js | 4 +- shared/sx/ref/bootstrap_js.py | 9 ++++ shared/sx/ref/bootstrap_py.py | 9 ++++ shared/sx/tests/test_bootstrapper.py | 63 ++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 shared/sx/tests/test_bootstrapper.py diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 6e8136c..40d3993 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -2419,7 +2419,7 @@ callExpr.push(dictGet(kwargs, k)); } } var text = domTextContent(s); return (isSxTruthy((isSxTruthy(text) && !isEmpty(trim(text)))) ? (function() { var pages = parse(text); - return forEach(function(page) { return append_b(_pageRoutes, merge(page, {'parsed': [Symbol('parse-route-pattern'), [Symbol('get'), Symbol('page'), 'path']]})); }, pages); + return forEach(function(page) { return append_b(_pageRoutes, merge(page, {"parsed": parseRoutePattern(get(page, "path"))})); }, pages); })() : NIL); })()) : NIL); }, scripts); })(); }; @@ -3780,4 +3780,4 @@ callExpr.push(dictGet(kwargs, k)); } } if (typeof module !== "undefined" && module.exports) module.exports = Sx; else global.Sx = Sx; -})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this); +})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this); \ No newline at end of file diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index c5aec3b..c9fec06 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -50,6 +50,8 @@ class JSEmitter: return self._emit_symbol(expr.name) if isinstance(expr, Keyword): return self._js_string(expr.name) + if isinstance(expr, dict): + return self._emit_native_dict(expr) if isinstance(expr, list): return self._emit_list(expr) return str(expr) @@ -739,6 +741,13 @@ class JSEmitter: parts = [self.emit(e) for e in exprs] return "(" + ", ".join(parts) + ")" + def _emit_native_dict(self, expr: dict) -> str: + """Emit a native Python dict (from parser's {:key val} syntax).""" + parts = [] + for key, val in expr.items(): + parts.append(f"{self._js_string(key)}: {self.emit(val)}") + return "{" + ", ".join(parts) + "}" + def _emit_dict_literal(self, expr) -> str: pairs = expr[1:] parts = [] diff --git a/shared/sx/ref/bootstrap_py.py b/shared/sx/ref/bootstrap_py.py index c209f03..d9c4f5a 100644 --- a/shared/sx/ref/bootstrap_py.py +++ b/shared/sx/ref/bootstrap_py.py @@ -52,6 +52,8 @@ class PyEmitter: return self._emit_symbol(expr.name) if isinstance(expr, Keyword): return self._py_string(expr.name) + if isinstance(expr, dict): + return self._emit_native_dict(expr) if isinstance(expr, list): return self._emit_list(expr) return str(expr) @@ -526,6 +528,13 @@ class PyEmitter: parts = [self.emit(e) for e in exprs] return "_sx_begin(" + ", ".join(parts) + ")" + def _emit_native_dict(self, expr: dict) -> str: + """Emit a native Python dict (from parser's {:key val} syntax).""" + parts = [] + for key, val in expr.items(): + parts.append(f"{self._py_string(key)}: {self.emit(val)}") + return "{" + ", ".join(parts) + "}" + def _emit_dict_literal(self, expr) -> str: pairs = expr[1:] parts = [] diff --git a/shared/sx/tests/test_bootstrapper.py b/shared/sx/tests/test_bootstrapper.py new file mode 100644 index 0000000..aa8078e --- /dev/null +++ b/shared/sx/tests/test_bootstrapper.py @@ -0,0 +1,63 @@ +"""Test bootstrapper transpilation: JSEmitter and PyEmitter.""" +from __future__ import annotations + +import pytest +from shared.sx.parser import parse +from shared.sx.ref.bootstrap_js import JSEmitter +from shared.sx.ref.bootstrap_py import PyEmitter + + +class TestJSEmitterNativeDict: + """JS bootstrapper must handle native Python dicts from {:key val} syntax.""" + + def test_simple_string_values(self): + expr = parse('{"name" "hello"}') + assert isinstance(expr, dict) + js = JSEmitter().emit(expr) + assert js == '{"name": "hello"}' + + 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_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 + + +class TestPyEmitterNativeDict: + """Python bootstrapper must handle native Python dicts from {:key val} syntax.""" + + def test_simple_string_values(self): + expr = parse('{"name" "hello"}') + py = PyEmitter().emit(expr) + assert py == "{'name': 'hello'}" + + 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"))}') + py = PyEmitter().emit(expr) + assert "parse_route_pattern" in py + assert "Symbol" not in py + + def test_multiple_keys(self): + expr = parse('{"a" 1 "b" (+ x 2)}') + py = PyEmitter().emit(expr) + assert "'a': 1" in py + assert "'b': (x + 2)" in py