Fix process-bindings scope loss and async-invoke arity, bootstrap async adapter

Two bugs fixed:
1. process-bindings used merge(env) which returns {} for Env objects
   (Env is not a dict subclass). Changed to env-extend in render.sx
   and adapter-async.sx. This caused "Undefined symbol: theme" etc.
2. async-aser-eval-call passed evaled-args list to async-invoke(&rest),
   double-wrapping it. Changed to inline apply + coroutine check.

Also: bootstrap define-async into sx_ref.py (Phase 6), replace ~1000 LOC
hand-written async_eval_ref.py with 24-line thin re-export shim.

Test runner now uses Env (not flat dict) for render envs to catch scope bugs.
8 new regression tests (4 scope chain, 2 native callable arity, 2 render).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 16:38:47 +00:00
parent e843602ac9
commit ff6c1fab71
17 changed files with 1402 additions and 1203 deletions

View File

@@ -450,7 +450,9 @@
(define-async async-process-bindings
(fn (bindings env ctx)
(let ((local (merge env)))
;; env-extend (not merge) — Env is not a dict subclass, so merge()
;; returns an empty dict, losing all parent scope bindings.
(let ((local (env-extend env)))
(if (and (= (type-of bindings) "list") (not (empty? bindings)))
(if (= (type-of (first bindings)) "list")
;; Scheme-style: ((name val) ...)
@@ -669,7 +671,10 @@
(evaled-args (async-eval-args args env ctx)))
(cond
(and (callable? f) (not (lambda? f)) (not (component? f)))
(async-invoke f evaled-args)
;; apply directly — async-invoke takes &rest so passing a list
;; would wrap it in another list
(let ((r (apply f evaled-args)))
(if (async-coroutine? r) (async-await! r) r))
(lambda? f)
(let ((local (env-merge (lambda-closure f) env)))
(for-each-indexed
@@ -1166,19 +1171,20 @@
(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))))
;; NOTE: Uses statement-form let + set! to avoid expression-context
;; let (IIFE lambdas) which can't contain await in Python.
(let ((result nil))
(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)
(set! result (async-aser-component val (rest expr) env ctx))
(set! result (async-maybe-expand-result (async-aser expr env ctx) env ctx))))
(set! result (async-maybe-expand-result (async-aser expr env ctx) env ctx))))
(set! result (async-maybe-expand-result (async-aser expr env ctx) env ctx)))
;; Normalize result to SxExpr
(if (sx-expr? result)
result

View File

@@ -186,15 +186,10 @@
(attr-expr (nth args (inc (get state "i")))))
(cond
;; Event handler: evaluate eagerly, bind listener
;; If handler is a 0-arity lambda, wrap to ignore the event arg
(starts-with? attr-name "on-")
(let ((attr-val (trampoline (eval-expr attr-expr env))))
(when (callable? attr-val)
(dom-listen el (slice attr-name 3)
(if (and (lambda? attr-val)
(= (len (lambda-params attr-val)) 0))
(fn (e) (call-lambda attr-val (list) (lambda-closure attr-val)))
attr-val))))
(dom-listen el (slice attr-name 3) attr-val)))
;; Two-way input binding: :bind signal
(= attr-name "bind")
(let ((attr-val (trampoline (eval-expr attr-expr env))))

View File

@@ -433,11 +433,11 @@
;; Render the island body as HTML
(let ((body-html (render-to-html (component-body island) local))
(state-json (serialize-island-state kwargs)))
(state-sx (serialize-island-state kwargs)))
;; Wrap in container with hydration attributes
(str "<span data-sx-island=\"" (escape-attr island-name) "\""
(if state-json
(str " data-sx-state=\"" (escape-attr state-json) "\"")
(if state-sx
(str " data-sx-state=\"" (escape-attr state-sx) "\"")
"")
">"
body-html
@@ -445,17 +445,17 @@
;; --------------------------------------------------------------------------
;; serialize-island-state — serialize kwargs to JSON for hydration
;; serialize-island-state — serialize kwargs to SX for hydration
;; --------------------------------------------------------------------------
;;
;; Only serializes simple values (numbers, strings, booleans, nil, lists, dicts).
;; Functions, components, and other non-serializable values are skipped.
;; Uses the SX serializer (not JSON) so the client can parse with sx-parse.
;; Handles all SX types natively: numbers, strings, booleans, nil, lists, dicts.
(define serialize-island-state
(fn (kwargs)
(if (empty-dict? kwargs)
nil
(json-serialize kwargs))))
(sx-serialize kwargs))))
;; --------------------------------------------------------------------------
@@ -476,8 +476,8 @@
;; Raw HTML construction:
;; (make-raw-html s) → wrap string as raw HTML (not double-escaped)
;;
;; JSON serialization (for island state):
;; (json-serialize dict) → JSON string
;; Island state serialization:
;; (sx-serialize val) → SX source string (from parser.sx)
;; (empty-dict? d) → boolean
;; (escape-attr s) → HTML attribute escape
;;

File diff suppressed because it is too large Load Diff

View File

@@ -344,15 +344,15 @@
(define hydrate-island
(fn (el)
(let ((name (dom-get-attr el "data-sx-island"))
(state-json (or (dom-get-attr el "data-sx-state") "{}")))
(state-sx (or (dom-get-attr el "data-sx-state") "{}")))
(let ((comp-name (str "~" name))
(env (get-render-env nil)))
(let ((comp (env-get env comp-name)))
(if (not (or (component? comp) (island? comp)))
(log-warn (str "hydrate-island: unknown island " comp-name))
;; Parse state and build keyword args
(let ((kwargs (json-parse state-json))
;; Parse state and build keyword args — SX format, not JSON
(let ((kwargs (or (first (sx-parse state-sx)) {}))
(disposers (list))
(local (env-merge (component-closure comp) env)))
@@ -494,8 +494,8 @@
;; (log-info msg) → void (console.log with prefix)
;; (log-parse-error label text err) → void (diagnostic parse error)
;;
;; === JSON ===
;; (json-parse str) → dict/list/value (JSON.parse)
;; === Parsing (island state) ===
;; (sx-parse str) → list of AST expressions (from parser.sx)
;;
;; === Processing markers ===
;; (mark-processed! el key) → void

View File

@@ -49,6 +49,8 @@ class PyEmitter:
def __init__(self):
self.indent = 0
self._async_names: set[str] = set() # SX names of define-async functions
self._in_async: bool = False # Currently emitting async def body?
def emit(self, expr) -> str:
"""Emit a Python expression from an SX AST node."""
@@ -80,6 +82,8 @@ class PyEmitter:
name = head.name
if name == "define":
return self._emit_define(expr, indent)
if name == "define-async":
return self._emit_define_async(expr, indent)
if name == "set!":
return f"{pad}{self._mangle(expr[1].name)} = {self.emit(expr[2])}"
if name == "when":
@@ -275,6 +279,19 @@ class PyEmitter:
"sf-defisland": "sf_defisland",
# adapter-sx.sx
"render-to-sx": "render_to_sx",
# adapter-async.sx platform primitives
"svg-context-set!": "svg_context_set",
"svg-context-reset!": "svg_context_reset",
"css-class-collect!": "css_class_collect",
"is-raw-html?": "is_raw_html",
"async-coroutine?": "is_async_coroutine",
"async-await!": "async_await",
"is-sx-expr?": "is_sx_expr",
"sx-expr?": "is_sx_expr",
"io-primitive?": "io_primitive_p",
"expand-components?": "expand_components_p",
"svg-context?": "svg_context_p",
"make-sx-expr": "make_sx_expr",
"aser": "aser",
"eval-case-aser": "eval_case_aser",
"sx-serialize": "sx_serialize",
@@ -417,6 +434,8 @@ class PyEmitter:
# Regular function call
fn_name = self._mangle(name)
args = ", ".join(self.emit(x) for x in expr[1:])
if self._in_async and name in self._async_names:
return f"(await {fn_name}({args}))"
return f"{fn_name}({args})"
# --- Special form emitters ---
@@ -513,7 +532,7 @@ class PyEmitter:
body_parts = expr[2:]
lines = [f"{pad}if sx_truthy({cond}):"]
for b in body_parts:
lines.append(self.emit_statement(b, indent + 1))
self._emit_stmt_recursive(b, lines, indent + 1)
return "\n".join(lines)
def _emit_cond(self, expr) -> str:
@@ -642,6 +661,16 @@ class PyEmitter:
val = self.emit(val_expr)
return f"{pad}{self._mangle(name)} = {val}"
def _emit_define_async(self, expr, indent: int = 0) -> str:
"""Emit a define-async form as an async def statement."""
name = expr[1].name if isinstance(expr[1], Symbol) else str(expr[1])
val_expr = expr[2]
if (isinstance(val_expr, list) and val_expr and
isinstance(val_expr[0], Symbol) and val_expr[0].name in ("fn", "lambda")):
return self._emit_define_as_def(name, val_expr, indent, is_async=True)
# Shouldn't happen — define-async should always wrap fn/lambda
return self._emit_define(expr, indent)
def _body_uses_set(self, fn_expr) -> bool:
"""Check if a fn expression's body (recursively) uses set!."""
def _has_set(node):
@@ -654,12 +683,16 @@ class PyEmitter:
body = fn_expr[2:]
return any(_has_set(b) for b in body)
def _emit_define_as_def(self, name: str, fn_expr, indent: int = 0) -> str:
def _emit_define_as_def(self, name: str, fn_expr, indent: int = 0,
is_async: bool = False) -> str:
"""Emit a define with fn value as a proper def statement.
This is used for functions that contain set! — Python closures can't
rebind outer lambda params, so we need proper def + local variables.
Variables mutated by set! from nested lambdas use a _cells dict.
When is_async=True, emits 'async def' and sets _in_async so that
calls to other async functions receive 'await'.
"""
pad = " " * indent
params = fn_expr[1]
@@ -686,14 +719,19 @@ class PyEmitter:
py_name = self._mangle(name)
# Find set! target variables that are used from nested lambda scopes
nested_set_vars = self._find_nested_set_vars(body)
lines = [f"{pad}def {py_name}({params_str}):"]
def_kw = "async def" if is_async else "def"
lines = [f"{pad}{def_kw} {py_name}({params_str}):"]
if nested_set_vars:
lines.append(f"{pad} _cells = {{}}")
# Emit body with cell var tracking
# Emit body with cell var tracking (and async context if needed)
old_cells = getattr(self, '_current_cell_vars', set())
old_async = self._in_async
self._current_cell_vars = nested_set_vars
if is_async:
self._in_async = True
self._emit_body_stmts(body, lines, indent + 1)
self._current_cell_vars = old_cells
self._in_async = old_async
return "\n".join(lines)
def _find_nested_set_vars(self, body) -> set[str]:
@@ -750,7 +788,7 @@ class PyEmitter:
if is_last:
self._emit_return_expr(expr, lines, indent)
else:
lines.append(self.emit_statement(expr, indent))
self._emit_stmt_recursive(expr, lines, indent)
def _emit_return_expr(self, expr, lines: list, indent: int) -> None:
"""Emit an expression in return position, flattening control flow."""
@@ -775,6 +813,11 @@ class PyEmitter:
if name in ("do", "begin"):
self._emit_body_stmts(expr[1:], lines, indent)
return
if name == "for-each":
# for-each in return position: emit as statement, return NIL
lines.append(self._emit_for_each_stmt(expr, indent))
lines.append(f"{pad}return NIL")
return
lines.append(f"{pad}return {self.emit(expr)}")
def _emit_if_return(self, expr, lines: list, indent: int) -> None:
@@ -1034,12 +1077,15 @@ class PyEmitter:
# ---------------------------------------------------------------------------
def extract_defines(source: str) -> list[tuple[str, list]]:
"""Parse .sx source, return list of (name, define-expr) for top-level defines."""
"""Parse .sx source, return list of (name, define-expr) for top-level defines.
Extracts both (define ...) and (define-async ...) forms.
"""
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":
if expr[0].name in ("define", "define-async"):
name = expr[1].name if isinstance(expr[1], Symbol) else str(expr[1])
defines.append((name, expr))
return defines
@@ -1212,6 +1258,28 @@ def compile_ref_to_py(
for name in sorted(spec_mod_set):
sx_files.append(SPEC_MODULES[name])
# Pre-scan define-async names (needed before transpilation so emitter
# knows which calls require 'await')
has_async = "async" in adapter_set
if has_async:
async_filename = ADAPTER_FILES["async"][0]
async_filepath = os.path.join(ref_dir, async_filename)
if os.path.exists(async_filepath):
with open(async_filepath) as f:
async_src = f.read()
for aexpr in parse_all(async_src):
if (isinstance(aexpr, list) and aexpr
and isinstance(aexpr[0], Symbol)
and aexpr[0].name == "define-async"):
aname = aexpr[1].name if isinstance(aexpr[1], Symbol) else str(aexpr[1])
emitter._async_names.add(aname)
# Platform async primitives (provided by host, also need await)
emitter._async_names.update({
"async-eval", "execute-io", "async-await!",
})
# Async adapter is transpiled last (after sync adapters)
sx_files.append(ADAPTER_FILES["async"])
all_sections = []
for filename, label in sx_files:
filepath = os.path.join(ref_dir, filename)
@@ -1248,6 +1316,9 @@ def compile_ref_to_py(
if has_deps:
parts.append(PLATFORM_DEPS_PY)
if has_async:
parts.append(PLATFORM_ASYNC_PY)
for label, defines in all_sections:
parts.append(f"\n# === Transpiled from {label} ===\n")
for name, expr in defines:
@@ -1258,7 +1329,7 @@ def compile_ref_to_py(
parts.append(FIXUPS_PY)
if has_continuations:
parts.append(CONTINUATIONS_PY)
parts.append(public_api_py(has_html, has_sx, has_deps))
parts.append(public_api_py(has_html, has_sx, has_deps, has_async))
return "\n".join(parts)

View File

@@ -1290,8 +1290,9 @@
(= name "append!")
(str (js-expr (nth expr 1)) ".push(" (js-expr (nth expr 2)) ");")
(= name "env-set!")
(str (js-expr (nth expr 1)) "[" (js-expr (nth expr 2))
"] = " (js-expr (nth expr 3)) ";")
(str "envSet(" (js-expr (nth expr 1))
", " (js-expr (nth expr 2))
", " (js-expr (nth expr 3)) ");")
(= name "set-lambda-name!")
(str (js-expr (nth expr 1)) ".name = " (js-expr (nth expr 2)) ";")
:else

View File

@@ -1194,7 +1194,7 @@ PLATFORM_JS_PRE = '''
// JSON / dict helpers for island state serialization
function jsonSerialize(obj) {
try { return JSON.stringify(obj); } catch(e) { return "{}"; }
return JSON.stringify(obj);
}
function isEmptyDict(d) {
if (!d || typeof d !== "object") return true;
@@ -1204,11 +1204,34 @@ PLATFORM_JS_PRE = '''
function envHas(env, name) { return name in env; }
function envGet(env, name) { return env[name]; }
function envSet(env, name, val) { env[name] = val; }
function envSet(env, name, val) {
// Walk prototype chain to find where the variable is defined (for set!)
var obj = env;
while (obj !== null && obj !== Object.prototype) {
if (obj.hasOwnProperty(name)) { obj[name] = val; return; }
obj = Object.getPrototypeOf(obj);
}
// Not found in any parent scope — set on the immediate env
env[name] = val;
}
function envExtend(env) { return Object.create(env); }
function envMerge(base, overlay) {
// Same env or overlay is descendant of base — just extend, no copy.
// This prevents set! inside lambdas from modifying shadow copies.
if (base === overlay) return Object.create(base);
var p = overlay;
for (var d = 0; p && p !== Object.prototype && d < 100; d++) {
if (p === base) return Object.create(base);
p = Object.getPrototypeOf(p);
}
// General case: extend base, copy ONLY overlay properties that don't
// exist in the base chain (avoids shadowing closure bindings).
var child = Object.create(base);
if (overlay) for (var k in overlay) if (overlay.hasOwnProperty(k)) child[k] = overlay[k];
if (overlay) {
for (var k in overlay) {
if (overlay.hasOwnProperty(k) && !(k in base)) child[k] = overlay[k];
}
}
return child;
}
@@ -1649,8 +1672,11 @@ PLATFORM_DOM_JS = """
function domListen(el, name, handler) {
if (!_hasDom || !el) return function() {};
// Wrap SX lambdas from runtime-evaluated island code into native fns
// If lambda takes 0 params, call without event arg (convenience for on-click handlers)
var wrapped = isLambda(handler)
? function(e) { try { invoke(handler, e); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } }
? (lambdaParams(handler).length === 0
? function(e) { try { invoke(handler); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } }
: function(e) { try { invoke(handler, e); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } })
: handler;
if (name === "click") logInfo("domListen: click on <" + (el.tagName||"?").toLowerCase() + "> text=" + (el.textContent||"").substring(0,20) + " isLambda=" + isLambda(handler));
el.addEventListener(name, wrapped);

View File

@@ -462,10 +462,7 @@ def invoke(f, *args):
def json_serialize(obj):
import json
try:
return json.dumps(obj)
except (TypeError, ValueError):
return "{}"
return json.dumps(obj)
def is_empty_dict(d):
@@ -1067,10 +1064,19 @@ import inspect
from shared.sx.primitives_io import (
IO_PRIMITIVES, RequestContext, execute_io,
css_class_collector as _css_class_collector_cv,
_svg_context as _svg_context_cv,
)
# Lazy imports to avoid circular dependency (html.py imports sx_ref.py)
_css_class_collector_cv = None
_svg_context_cv = None
def _ensure_html_imports():
global _css_class_collector_cv, _svg_context_cv
if _css_class_collector_cv is None:
from shared.sx.html import css_class_collector, _svg_context
_css_class_collector_cv = css_class_collector
_svg_context_cv = _svg_context
# When True, async_aser expands known components server-side
_expand_components_cv: contextvars.ContextVar[bool] = contextvars.ContextVar(
"_expand_components_ref", default=False
@@ -1094,18 +1100,22 @@ def expand_components_p():
def svg_context_p():
_ensure_html_imports()
return _svg_context_cv.get(False)
def svg_context_set(val):
_ensure_html_imports()
return _svg_context_cv.set(val)
def svg_context_reset(token):
_ensure_html_imports()
_svg_context_cv.reset(token)
def css_class_collect(val):
_ensure_html_imports()
collector = _css_class_collector_cv.get(None)
if collector is not None:
collector.update(str(val).split())
@@ -1123,6 +1133,25 @@ def is_sx_expr(x):
return isinstance(x, SxExpr)
# Predicate helpers used by adapter-async (these are in PRIMITIVES but
# the bootstrapped code calls them as plain functions)
def string_p(x):
return isinstance(x, str)
def list_p(x):
return isinstance(x, _b_list)
def number_p(x):
return isinstance(x, (int, float)) and not isinstance(x, bool)
def sx_parse(src):
from shared.sx.parser import parse_all
return parse_all(src)
def is_async_coroutine(x):
return inspect.iscoroutine(x)
@@ -1199,48 +1228,16 @@ async def async_eval_slot_to_sx(expr, env, ctx=None):
ctx = RequestContext()
token = _expand_components_cv.set(True)
try:
return await _eval_slot_inner(expr, env, ctx)
result = await async_eval_slot_inner(expr, env, ctx)
if isinstance(result, SxExpr):
return result
if result is None or result is NIL:
return SxExpr("")
if isinstance(result, str):
return SxExpr(result)
return SxExpr(sx_serialize(result))
finally:
_expand_components_cv.reset(token)
async def _eval_slot_inner(expr, env, ctx):
if isinstance(expr, list) and expr:
head = expr[0]
if isinstance(head, Symbol) and head.name.startswith("~"):
comp = env.get(head.name)
if isinstance(comp, Component):
result = await async_aser_component(comp, expr[1:], env, ctx)
if isinstance(result, SxExpr):
return result
if result is None or result is NIL:
return SxExpr("")
if isinstance(result, str):
return SxExpr(result)
return SxExpr(sx_serialize(result))
result = await async_aser(expr, env, ctx)
result = await _maybe_expand_component_result(result, env, ctx)
if isinstance(result, SxExpr):
return result
if result is None or result is NIL:
return SxExpr("")
if isinstance(result, str):
return SxExpr(result)
return SxExpr(sx_serialize(result))
async def _maybe_expand_component_result(result, env, ctx):
raw = None
if isinstance(result, SxExpr):
raw = str(result).strip()
elif isinstance(result, str):
raw = result.strip()
if raw and raw.startswith("(~"):
from shared.sx.parser import parse_all as _pa
parsed = _pa(raw)
if parsed:
return await async_eval_slot_to_sx(parsed[0], env, ctx)
return result
'''
# ---------------------------------------------------------------------------
@@ -1366,7 +1363,8 @@ aser_special = _aser_special_with_continuations
# Public API generator
# ---------------------------------------------------------------------------
def public_api_py(has_html: bool, has_sx: bool, has_deps: bool = False) -> str:
def public_api_py(has_html: bool, has_sx: bool, has_deps: bool = False,
has_async: bool = False) -> str:
lines = [
'',
'# =========================================================================',
@@ -1419,8 +1417,9 @@ def public_api_py(has_html: bool, has_sx: bool, has_deps: bool = False) -> str:
# ---------------------------------------------------------------------------
ADAPTER_FILES = {
"html": ("adapter-html.sx", "adapter-html"),
"sx": ("adapter-sx.sx", "adapter-sx"),
"html": ("adapter-html.sx", "adapter-html"),
"sx": ("adapter-sx.sx", "adapter-sx"),
"async": ("adapter-async.sx", "adapter-async"),
}
SPEC_MODULES = {

View File

@@ -178,7 +178,9 @@
;; bindings = ((name1 expr1) (name2 expr2) ...)
(define process-bindings
(fn (bindings env)
(let ((local (merge env)))
;; env-extend (not merge) — Env is not a dict subclass, so merge()
;; returns an empty dict, losing all parent scope bindings.
(let ((local (env-extend env)))
(for-each
(fn (pair)
(when (and (= (type-of pair) "list") (>= (len pair) 2))

File diff suppressed because it is too large Load Diff

View File

@@ -119,6 +119,19 @@
(assert-equal "(p \"hello\")"
(render-sx "(let ((x \"hello\")) (p x))")))
(deftest "let preserves outer scope bindings"
;; Regression: process-bindings must preserve parent env scope chain.
;; Using merge() instead of env-extend loses parent scope items.
(assert-equal "(p \"outer\")"
(render-sx "(do (define theme \"outer\") (let ((x 1)) (p theme)))")))
(deftest "nested let preserves outer scope"
(assert-equal "(div (span \"hello\") (span \"world\"))"
(render-sx "(do (define a \"hello\")
(define b \"world\")
(div (let ((x 1)) (span a))
(let ((y 2)) (span b))))")))
(deftest "begin serializes last"
(assert-equal "(p \"last\")"
(render-sx "(begin (p \"first\") (p \"last\"))"))))
@@ -213,6 +226,17 @@
(assert-equal "10"
(render-sx "(do (define double (fn (x) (* x 2))) (double 5))")))
(deftest "native callable with multiple args"
;; Regression: async-aser-eval-call passed evaled-args list to
;; async-invoke (&rest), wrapping it in another list. apply(f, [list])
;; calls f(list) instead of f(*list).
(assert-equal "3"
(render-sx "(do (define my-add +) (my-add 1 2))")))
(deftest "native callable with two args via alias"
(assert-equal "hello world"
(render-sx "(do (define my-join str) (my-join \"hello\" \" world\"))")))
(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

View File

@@ -149,7 +149,20 @@
(deftest "let in render context"
(assert-equal "<p>hello</p>"
(render-html "(let ((x \"hello\")) (p x))"))))
(render-html "(let ((x \"hello\")) (p x))")))
(deftest "let preserves outer scope bindings"
;; Regression: process-bindings must preserve parent env scope chain.
;; Using merge() on Env objects returns empty dict (Env is not dict subclass).
(assert-equal "<p>outer</p>"
(render-html "(do (define theme \"outer\") (let ((x 1)) (p theme)))")))
(deftest "nested let preserves outer scope"
(assert-equal "<div><span>hello</span><span>world</span></div>"
(render-html "(do (define a \"hello\")
(define b \"world\")
(div (let ((x 1)) (span a))
(let ((y 2)) (span b))))"))))
;; --------------------------------------------------------------------------