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:
@@ -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
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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))))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user