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