From e843602ac920b7ada0ddccb931e31b56371ffc1f Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 11 Mar 2026 14:59:31 +0000 Subject: [PATCH] Fix aser list flattening bug, add wire format test suite (41 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sync aser-call in adapter-sx.sx didn't flatten list results from map/filter in positional children — serialize(list) wrapped in parens creating ((div ...) ...) which re-parses as an invalid call. Rewrote aser-call from reduce to for-each (bootstrapper can't nest for-each inside reduce lambdas) and added list flattening in both aser-call and aser-fragment. Also adds test-aser.sx (41 tests), render-sx platform function, expanded test-render.sx (+7 map/filter children tests), and specs async-eval-slot-inner in adapter-async.sx. Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-browser.js | 28 ++-- shared/sx/ref/adapter-async.sx | 58 +++++++ shared/sx/ref/adapter-sx.sx | 65 +++++--- shared/sx/ref/sx_ref.py | 41 ++++- shared/sx/ref/test-aser.sx | 241 ++++++++++++++++++++++++++++ shared/sx/ref/test-render.sx | 43 +++++ shared/sx/tests/run.py | 30 +++- 7 files changed, 465 insertions(+), 41 deletions(-) create mode 100644 shared/sx/ref/test-aser.sx diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 69090b8..4f4a886 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-11T13:57:48Z"; + var SX_VERSION = "2026-03-11T14:54:55Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -1505,30 +1505,34 @@ return (function() { var _m = typeOf(expr); if (_m == "number") return expr; if // aser-fragment var aserFragment = function(children, env) { return (function() { - var parts = filter(function(x) { return !isSxTruthy(isNil(x)); }, map(function(c) { return aser(c, env); }, children)); - return (isSxTruthy(isEmpty(parts)) ? "" : (String("(<> ") + String(join(" ", map(serialize, parts))) + String(")"))); + var parts = []; + { var _c = children; for (var _i = 0; _i < _c.length; _i++) { var c = _c[_i]; (function() { + var result = aser(c, env); + return (isSxTruthy((typeOf(result) == "list")) ? forEach(function(item) { return (isSxTruthy(!isSxTruthy(isNil(item))) ? append_b(parts, serialize(item)) : NIL); }, result) : (isSxTruthy(!isSxTruthy(isNil(result))) ? append_b(parts, serialize(result)) : NIL)); +})(); } } + return (isSxTruthy(isEmpty(parts)) ? "" : (String("(<> ") + String(join(" ", parts)) + String(")"))); })(); }; // aser-call var aserCall = function(name, args, env) { return (function() { var parts = [name]; - reduce(function(state, arg) { return (function() { - var skip = get(state, "skip"); - return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() { - var val = aser(nth(args, (get(state, "i") + 1)), env); + var skip = false; + var i = 0; + { var _c = args; for (var _i = 0; _i < _c.length; _i++) { var arg = _c[_i]; (isSxTruthy(skip) ? ((skip = false), (i = (i + 1))) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((i + 1) < len(args)))) ? (function() { + var val = aser(nth(args, (i + 1)), env); if (isSxTruthy(!isSxTruthy(isNil(val)))) { parts.push((String(":") + String(keywordName(arg)))); parts.push(serialize(val)); } - return assoc(state, "skip", true, "i", (get(state, "i") + 1)); + skip = true; + return (i = (i + 1)); })() : (function() { var val = aser(arg, env); if (isSxTruthy(!isSxTruthy(isNil(val)))) { - parts.push(serialize(val)); + (isSxTruthy((typeOf(val) == "list")) ? forEach(function(item) { return (isSxTruthy(!isSxTruthy(isNil(item))) ? append_b(parts, serialize(item)) : NIL); }, val) : append_b(parts, serialize(val))); } - return assoc(state, "i", (get(state, "i") + 1)); -})())); -})(); }, {["i"]: 0, ["skip"]: false}, args); + return (i = (i + 1)); +})())); } } return (String("(") + String(join(" ", parts)) + String(")")); })(); }; diff --git a/shared/sx/ref/adapter-async.sx b/shared/sx/ref/adapter-async.sx index f57dfe3..b620090 100644 --- a/shared/sx/ref/adapter-async.sx +++ b/shared/sx/ref/adapter-async.sx @@ -1149,6 +1149,64 @@ results))) +;; -------------------------------------------------------------------------- +;; async-eval-slot-inner — server-side slot expansion for aser mode +;; -------------------------------------------------------------------------- +;; +;; Coordinates component expansion for server-rendered pages: +;; 1. If expression is a direct component call (~name ...), expand it +;; 2. Otherwise aser the expression, then check if result is a (~...) +;; call that should be re-expanded +;; +;; Platform primitives required: +;; (sx-parse src) — parse SX source string +;; (make-sx-expr s) — wrap as SxExpr +;; (sx-expr? x) — check if SxExpr +;; (set-expand-components!) — enable component expansion context var + +(define-async async-eval-slot-inner + (fn (expr env ctx) + (let ((result + (if (and (list? expr) (not (empty? expr))) + (let ((head (first expr))) + (if (and (= (type-of head) "symbol") + (starts-with? (symbol-name head) "~")) + (let ((name (symbol-name head)) + (val (if (env-has? env name) (env-get env name) nil))) + (if (component? val) + (async-aser-component val (rest expr) env ctx) + ;; Islands and unknown components — fall through to aser + (async-maybe-expand-result (async-aser expr env ctx) env ctx))) + (async-maybe-expand-result (async-aser expr env ctx) env ctx))) + (async-maybe-expand-result (async-aser expr env ctx) env ctx)))) + ;; Normalize result to SxExpr + (if (sx-expr? result) + result + (if (nil? result) + (make-sx-expr "") + (if (string? result) + (make-sx-expr result) + (make-sx-expr (serialize result)))))))) + + +(define-async async-maybe-expand-result + (fn (result env ctx) + ;; If the aser result is a component call string like "(~foo ...)", + ;; re-parse and expand it. This handles indirect component references + ;; (e.g. a let binding that evaluates to a component call). + (let ((raw (if (sx-expr? result) + (trim (str result)) + (if (string? result) + (trim result) + nil)))) + (if (and raw (starts-with? raw "(~")) + (let ((parsed (sx-parse raw))) + (if (and parsed (not (empty? parsed))) + (async-eval-slot-inner (first parsed) env ctx) + result)) + result)))) + + ;; -------------------------------------------------------------------------- ;; Platform interface — async adapter ;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/adapter-sx.sx b/shared/sx/ref/adapter-sx.sx index b045faa..5bc388f 100644 --- a/shared/sx/ref/adapter-sx.sx +++ b/shared/sx/ref/adapter-sx.sx @@ -106,35 +106,56 @@ (define aser-fragment (fn (children env) ;; Serialize (<> child1 child2 ...) to sx source string - (let ((parts (filter - (fn (x) (not (nil? x))) - (map (fn (c) (aser c env)) children)))) + ;; Must flatten list results (e.g. from map/filter) to avoid nested parens + (let ((parts (list))) + (for-each + (fn (c) + (let ((result (aser c env))) + (if (= (type-of result) "list") + (for-each + (fn (item) + (when (not (nil? item)) + (append! parts (serialize item)))) + result) + (when (not (nil? result)) + (append! parts (serialize result)))))) + children) (if (empty? parts) "" - (str "(<> " (join " " (map serialize parts)) ")"))))) + (str "(<> " (join " " parts) ")"))))) (define aser-call (fn (name args env) ;; Serialize (name :key val child ...) — evaluate args but keep as sx - (let ((parts (list name))) - (reduce - (fn (state arg) - (let ((skip (get state "skip"))) - (if skip - (assoc state "skip" false "i" (inc (get state "i"))) - (if (and (= (type-of arg) "keyword") - (< (inc (get state "i")) (len args))) - (let ((val (aser (nth args (inc (get state "i"))) env))) - (when (not (nil? val)) - (append! parts (str ":" (keyword-name arg))) - (append! parts (serialize val))) - (assoc state "skip" true "i" (inc (get state "i")))) - (let ((val (aser arg env))) - (when (not (nil? val)) - (append! parts (serialize val))) - (assoc state "i" (inc (get state "i")))))))) - (dict "i" 0 "skip" false) + ;; Uses for-each + mutable state (not reduce) so bootstrapper emits for-loops + ;; that can contain nested for-each for list flattening. + (let ((parts (list name)) + (skip false) + (i 0)) + (for-each + (fn (arg) + (if skip + (do (set! skip false) + (set! i (inc i))) + (if (and (= (type-of arg) "keyword") + (< (inc i) (len args))) + (let ((val (aser (nth args (inc i)) env))) + (when (not (nil? val)) + (append! parts (str ":" (keyword-name arg))) + (append! parts (serialize val))) + (set! skip true) + (set! i (inc i))) + (let ((val (aser arg env))) + (when (not (nil? val)) + (if (= (type-of val) "list") + (for-each + (fn (item) + (when (not (nil? item)) + (append! parts (serialize item)))) + val) + (append! parts (serialize val)))) + (set! i (inc i)))))) args) (str "(" (join " " parts) ")")))) diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index f5de87a..7512091 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -2015,16 +2015,49 @@ def aser_list(expr, env): # aser-fragment def aser_fragment(children, env): - parts = filter(lambda x: (not sx_truthy(is_nil(x))), map(lambda c: aser(c, env), children)) + parts = [] + for c in children: + result = aser(c, env) + if sx_truthy((type_of(result) == 'list')): + for item in result: + if sx_truthy((not sx_truthy(is_nil(item)))): + parts.append(serialize(item)) + else: + if sx_truthy((not sx_truthy(is_nil(result)))): + parts.append(serialize(result)) if sx_truthy(empty_p(parts)): return '' else: - return sx_str('(<> ', join(' ', map(serialize, parts)), ')') + return sx_str('(<> ', join(' ', parts), ')') # aser-call def aser_call(name, args, env): + _cells = {} parts = [name] - reduce(lambda state, arg: (lambda skip: (assoc(state, 'skip', False, 'i', (get(state, 'i') + 1)) if sx_truthy(skip) else ((lambda val: _sx_begin((_sx_begin(_sx_append(parts, sx_str(':', keyword_name(arg))), _sx_append(parts, serialize(val))) if sx_truthy((not sx_truthy(is_nil(val)))) else NIL), assoc(state, 'skip', True, 'i', (get(state, 'i') + 1))))(aser(nth(args, (get(state, 'i') + 1)), env)) if sx_truthy(((type_of(arg) == 'keyword') if not sx_truthy((type_of(arg) == 'keyword')) else ((get(state, 'i') + 1) < len(args)))) else (lambda val: _sx_begin((_sx_append(parts, serialize(val)) if sx_truthy((not sx_truthy(is_nil(val)))) else NIL), assoc(state, 'i', (get(state, 'i') + 1))))(aser(arg, env)))))(get(state, 'skip')), {'i': 0, 'skip': False}, args) + _cells['skip'] = False + _cells['i'] = 0 + for arg in args: + if sx_truthy(_cells['skip']): + _cells['skip'] = False + _cells['i'] = (_cells['i'] + 1) + else: + if sx_truthy(((type_of(arg) == 'keyword') if not sx_truthy((type_of(arg) == 'keyword')) else ((_cells['i'] + 1) < len(args)))): + val = aser(nth(args, (_cells['i'] + 1)), env) + if sx_truthy((not sx_truthy(is_nil(val)))): + parts.append(sx_str(':', keyword_name(arg))) + parts.append(serialize(val)) + _cells['skip'] = True + _cells['i'] = (_cells['i'] + 1) + else: + val = aser(arg, env) + if sx_truthy((not sx_truthy(is_nil(val)))): + if sx_truthy((type_of(val) == 'list')): + for item in val: + if sx_truthy((not sx_truthy(is_nil(item)))): + parts.append(serialize(item)) + else: + parts.append(serialize(val)) + _cells['i'] = (_cells['i'] + 1) return sx_str('(', join(' ', parts), ')') # SPECIAL_FORM_NAMES @@ -2753,4 +2786,4 @@ def render(expr, env=None): def make_env(**kwargs): """Create an environment with initial bindings.""" - return _Env(dict(kwargs)) \ No newline at end of file + return _Env(dict(kwargs)) diff --git a/shared/sx/ref/test-aser.sx b/shared/sx/ref/test-aser.sx new file mode 100644 index 0000000..053386a --- /dev/null +++ b/shared/sx/ref/test-aser.sx @@ -0,0 +1,241 @@ +;; ========================================================================== +;; test-aser.sx — Tests for the SX wire format (aser) adapter +;; +;; Requires: test-framework.sx loaded first. +;; Modules tested: adapter-sx.sx (aser, aser-call, aser-fragment, aser-special) +;; +;; Platform functions required (beyond test framework): +;; render-sx (sx-source) -> SX wire format string +;; Parses the sx-source string, evaluates via aser in a +;; fresh env, and returns the resulting SX wire format string. +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; Basic serialization +;; -------------------------------------------------------------------------- + +(defsuite "aser-basics" + (deftest "number literal passes through" + (assert-equal "42" + (render-sx "42"))) + + (deftest "string literal passes through" + ;; aser returns the raw string value; render-sx concatenates it directly + (assert-equal "hello" + (render-sx "\"hello\""))) + + (deftest "boolean true passes through" + (assert-equal "true" + (render-sx "true"))) + + (deftest "boolean false passes through" + (assert-equal "false" + (render-sx "false"))) + + (deftest "nil produces empty" + (assert-equal "" + (render-sx "nil")))) + + +;; -------------------------------------------------------------------------- +;; HTML tag serialization +;; -------------------------------------------------------------------------- + +(defsuite "aser-tags" + (deftest "simple div" + (assert-equal "(div \"hello\")" + (render-sx "(div \"hello\")"))) + + (deftest "nested tags" + (assert-equal "(div (span \"hi\"))" + (render-sx "(div (span \"hi\"))"))) + + (deftest "multiple children" + (assert-equal "(div (p \"a\") (p \"b\"))" + (render-sx "(div (p \"a\") (p \"b\"))"))) + + (deftest "attributes serialize" + (assert-equal "(div :class \"foo\" \"bar\")" + (render-sx "(div :class \"foo\" \"bar\")"))) + + (deftest "multiple attributes" + (assert-equal "(a :href \"/home\" :class \"link\" \"Home\")" + (render-sx "(a :href \"/home\" :class \"link\" \"Home\")"))) + + (deftest "void elements" + (assert-equal "(br)" + (render-sx "(br)"))) + + (deftest "void element with attrs" + (assert-equal "(img :src \"pic.jpg\")" + (render-sx "(img :src \"pic.jpg\")")))) + + +;; -------------------------------------------------------------------------- +;; Fragment serialization +;; -------------------------------------------------------------------------- + +(defsuite "aser-fragments" + (deftest "simple fragment" + (assert-equal "(<> (p \"a\") (p \"b\"))" + (render-sx "(<> (p \"a\") (p \"b\"))"))) + + (deftest "empty fragment" + (assert-equal "" + (render-sx "(<>)"))) + + (deftest "single-child fragment" + (assert-equal "(<> (div \"x\"))" + (render-sx "(<> (div \"x\"))")))) + + +;; -------------------------------------------------------------------------- +;; Control flow in aser mode +;; -------------------------------------------------------------------------- + +(defsuite "aser-control-flow" + (deftest "if true branch" + (assert-equal "(p \"yes\")" + (render-sx "(if true (p \"yes\") (p \"no\"))"))) + + (deftest "if false branch" + (assert-equal "(p \"no\")" + (render-sx "(if false (p \"yes\") (p \"no\"))"))) + + (deftest "when true" + (assert-equal "(p \"ok\")" + (render-sx "(when true (p \"ok\"))"))) + + (deftest "when false" + (assert-equal "" + (render-sx "(when false (p \"ok\"))"))) + + (deftest "cond serializes matching branch" + (assert-equal "(p \"two\")" + (render-sx "(cond false (p \"one\") true (p \"two\") :else (p \"three\"))"))) + + (deftest "let binds then serializes" + (assert-equal "(p \"hello\")" + (render-sx "(let ((x \"hello\")) (p x))"))) + + (deftest "begin serializes last" + (assert-equal "(p \"last\")" + (render-sx "(begin (p \"first\") (p \"last\"))")))) + + +;; -------------------------------------------------------------------------- +;; THE BUG — map/filter list flattening in children (critical regression) +;; -------------------------------------------------------------------------- + +(defsuite "aser-list-flattening" + (deftest "map inside tag flattens children" + (assert-equal "(div (span \"a\") (span \"b\") (span \"c\"))" + (render-sx "(do (define items (list \"a\" \"b\" \"c\")) + (div (map (fn (x) (span x)) items)))"))) + + (deftest "map inside tag with other children" + (assert-equal "(ul (li \"first\") (li \"a\") (li \"b\"))" + (render-sx "(do (define items (list \"a\" \"b\")) + (ul (li \"first\") (map (fn (x) (li x)) items)))"))) + + (deftest "filter result via let binding as children" + ;; Note: (filter ...) is treated as an SVG tag in aser dispatch (SVG has ), + ;; so we evaluate filter via let binding + map to serialize children + (assert-equal "(ul (li \"a\") (li \"b\"))" + (render-sx "(do (define items (list \"a\" nil \"b\")) + (define kept (filter (fn (x) (not (nil? x))) items)) + (ul (map (fn (x) (li x)) kept)))"))) + + (deftest "map inside fragment flattens" + (assert-equal "(<> (p \"a\") (p \"b\"))" + (render-sx "(do (define items (list \"a\" \"b\")) + (<> (map (fn (x) (p x)) items)))"))) + + (deftest "nested map does not double-wrap" + (assert-equal "(div (span \"1\") (span \"2\"))" + (render-sx "(do (define nums (list 1 2)) + (div (map (fn (n) (span (str n))) nums)))"))) + + (deftest "map with component-like output flattens" + (assert-equal "(div (li \"x\") (li \"y\"))" + (render-sx "(do (define items (list \"x\" \"y\")) + (div (map (fn (x) (li x)) items)))")))) + + +;; -------------------------------------------------------------------------- +;; Component serialization (NOT expanded in basic aser mode) +;; -------------------------------------------------------------------------- + +(defsuite "aser-components" + (deftest "unknown component serializes as-is" + (assert-equal "(~foo :title \"bar\")" + (render-sx "(~foo :title \"bar\")"))) + + (deftest "defcomp then unexpanded component call" + (assert-equal "(~card :title \"Hi\")" + (render-sx "(do (defcomp ~card (&key title) (h1 title)) (~card :title \"Hi\"))"))) + + (deftest "component with children serializes unexpanded" + (assert-equal "(~box (p \"inside\"))" + (render-sx "(do (defcomp ~box (&key &rest children) (div children)) + (~box (p \"inside\")))")))) + + +;; -------------------------------------------------------------------------- +;; Definition forms in aser mode +;; -------------------------------------------------------------------------- + +(defsuite "aser-definitions" + (deftest "define evaluates for side effects, returns nil" + (assert-equal "(p 42)" + (render-sx "(do (define x 42) (p x))"))) + + (deftest "defcomp evaluates and returns nil" + (assert-equal "(~tag :x 1)" + (render-sx "(do (defcomp ~tag (&key x) (span x)) (~tag :x 1))"))) + + (deftest "defisland evaluates AND serializes" + (let ((result (render-sx "(defisland ~counter (&key count) (span count))"))) + (assert-true (string-contains? result "defisland"))))) + + +;; -------------------------------------------------------------------------- +;; Function calls in aser mode +;; -------------------------------------------------------------------------- + +(defsuite "aser-function-calls" + (deftest "named function call evaluates fully" + (assert-equal "3" + (render-sx "(do (define inc1 (fn (x) (+ x 1))) (inc1 2))"))) + + (deftest "define + call" + (assert-equal "10" + (render-sx "(do (define double (fn (x) (* x 2))) (double 5))"))) + + (deftest "higher-order: map returns list" + (let ((result (render-sx "(map (fn (x) (+ x 1)) (list 1 2 3))"))) + ;; map at top level returns a list, not serialized tags + (assert-true (not (nil? result)))))) + + +;; -------------------------------------------------------------------------- +;; and/or short-circuit in aser mode +;; -------------------------------------------------------------------------- + +(defsuite "aser-logic" + (deftest "and short-circuits on false" + (assert-equal "false" + (render-sx "(and true false true)"))) + + (deftest "and returns last truthy" + (assert-equal "3" + (render-sx "(and 1 2 3)"))) + + (deftest "or short-circuits on true" + (assert-equal "1" + (render-sx "(or 1 2 3)"))) + + (deftest "or returns last falsy" + (assert-equal "false" + (render-sx "(or false false)")))) diff --git a/shared/sx/ref/test-render.sx b/shared/sx/ref/test-render.sx index c714fc7..08f1d7f 100644 --- a/shared/sx/ref/test-render.sx +++ b/shared/sx/ref/test-render.sx @@ -165,3 +165,46 @@ (let ((html (render-html "(do (defcomp ~box (&key &rest children) (div :class \"box\" children)) (~box (p \"inside\")))"))) (assert-true (string-contains? html "class=\"box\"")) (assert-true (string-contains? html "

inside

"))))) + + +;; -------------------------------------------------------------------------- +;; Map/filter producing multiple children (aser-adjacent regression tests) +;; -------------------------------------------------------------------------- + +(defsuite "render-map-children" + (deftest "map producing multiple children inside tag" + (assert-equal "
  • a
  • b
  • c
" + (render-html "(do (define items (list \"a\" \"b\" \"c\")) + (ul (map (fn (x) (li x)) items)))"))) + + (deftest "map with other siblings" + (assert-equal "
  • first
  • a
  • b
" + (render-html "(do (define items (list \"a\" \"b\")) + (ul (li \"first\") (map (fn (x) (li x)) items)))"))) + + (deftest "filter with nil results inside tag" + (assert-equal "
  • a
  • c
" + (render-html "(do (define items (list \"a\" nil \"c\")) + (ul (map (fn (x) (li x)) + (filter (fn (x) (not (nil? x))) items))))"))) + + (deftest "nested map inside let" + (assert-equal "
12
" + (render-html "(let ((nums (list 1 2))) + (div (map (fn (n) (span n)) nums)))"))) + + (deftest "component with &rest receiving mapped results" + (let ((html (render-html "(do (defcomp ~list-box (&key &rest children) (div :class \"lb\" children)) + (define items (list \"x\" \"y\")) + (~list-box (map (fn (x) (p x)) items)))"))) + (assert-true (string-contains? html "class=\"lb\"")) + (assert-true (string-contains? html "

x

")) + (assert-true (string-contains? html "

y

")))) + + (deftest "map-indexed renders with index" + (assert-equal "
  • 0: a
  • 1: b
  • " + (render-html "(map-indexed (fn (i x) (li (str i \": \" x))) (list \"a\" \"b\"))"))) + + (deftest "for-each renders each item" + (assert-equal "

    1

    2

    " + (render-html "(for-each (fn (x) (p x)) (list 1 2))")))) diff --git a/shared/sx/tests/run.py b/shared/sx/tests/run.py index abdc791..297ebac 100644 --- a/shared/sx/tests/run.py +++ b/shared/sx/tests/run.py @@ -134,6 +134,28 @@ def render_html(sx_source): return result +# --- Render SX (aser) platform function --- + +def render_sx(sx_source): + """Parse SX source and serialize to SX wire format via the bootstrapped evaluator.""" + try: + from shared.sx.ref.sx_ref import aser as _aser, serialize as _serialize + except ImportError: + raise RuntimeError("aser not available — sx_ref.py not built") + exprs = parse_all(sx_source) + render_env = dict(env) + result = "" + for expr in exprs: + val = _aser(expr, render_env) + if isinstance(val, str): + result += val + elif val is None or val is NIL: + pass + else: + result += _serialize(val) + return result + + # --- Signal platform primitives --- # Implements the signal runtime platform interface for testing signals.sx @@ -258,6 +280,7 @@ SPECS = { "parser": {"file": "test-parser.sx", "needs": ["sx-parse"]}, "router": {"file": "test-router.sx", "needs": []}, "render": {"file": "test-render.sx", "needs": ["render-html"]}, + "aser": {"file": "test-aser.sx", "needs": ["render-sx"]}, "deps": {"file": "test-deps.sx", "needs": []}, "engine": {"file": "test-engine.sx", "needs": []}, "orchestration": {"file": "test-orchestration.sx", "needs": []}, @@ -296,8 +319,9 @@ env = _Env({ "make-keyword": make_keyword, "symbol-name": symbol_name, "keyword-name": keyword_name, - # Render platform function + # Render platform functions "render-html": render_html, + "render-sx": render_sx, # Extra primitives needed by spec modules (router.sx, deps.sx) "for-each-indexed": "_deferred", # replaced below "dict-set!": "_deferred", @@ -773,9 +797,9 @@ def main(): print(f"# --- {spec_name} ---") eval_file(spec["file"], env) - # Reset render state after render tests to avoid leaking + # Reset render state after render/aser tests to avoid leaking # into subsequent specs (bootstrapped evaluator checks render_active) - if spec_name == "render": + if spec_name in ("render", "aser"): try: from shared.sx.ref.sx_ref import set_render_active_b set_render_active_b(False)