Files
mono/shared/sx/async_eval.py
giles 527c4186ee Fix _aser_component: evaluate kwargs with _aser not async_eval
_aser_component expands component bodies in SX wire format mode,
but was evaluating kwarg values with async_eval (HTML mode). This
caused SxExpr kwargs to be fully rendered to HTML strings, which
then broke when serialized back to SX — producing bare symbols
like 'div' that the client couldn't resolve.

Fix: use _aser() for kwarg evaluation to keep values in SX format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:48:57 +00:00

1364 lines
44 KiB
Python

"""
Async s-expression evaluator and HTML renderer.
Mirrors the sync evaluator (evaluator.py) and HTML renderer (html.py) but
every step is ``async`` so I/O primitives can be ``await``ed inline.
This is the execution engine for ``defhandler`` — handlers contain I/O
calls (``query``, ``service``, ``request-arg``, etc.) interleaved with
control flow (``if``, ``let``, ``map``, ``when``). The sync
collect-then-substitute resolver can't handle data dependencies between
I/O results and control flow, so handlers need inline async evaluation.
Usage::
from shared.sx.async_eval import async_render
html = await async_render(handler_def.body, env, ctx)
"""
from __future__ import annotations
import inspect
from typing import Any
from .types import Component, Keyword, Lambda, Macro, NIL, Symbol
from .evaluator import _expand_macro, EvalError
from .primitives import _PRIMITIVES
from .primitives_io import IO_PRIMITIVES, RequestContext, execute_io
from .parser import SxExpr, serialize
from .html import (
HTML_TAGS, VOID_ELEMENTS, BOOLEAN_ATTRS,
escape_text, escape_attr, _RawHTML, css_class_collector,
)
# ---------------------------------------------------------------------------
# Async TCO — thunk + trampoline
# ---------------------------------------------------------------------------
class _AsyncThunk:
"""Deferred (expr, env, ctx) for tail-call optimization."""
__slots__ = ("expr", "env", "ctx")
def __init__(self, expr: Any, env: dict[str, Any], ctx: RequestContext) -> None:
self.expr = expr
self.env = env
self.ctx = ctx
async def _async_trampoline(val: Any) -> Any:
"""Iteratively resolve thunks from tail positions."""
while isinstance(val, _AsyncThunk):
val = await _async_eval(val.expr, val.env, val.ctx)
return val
# ---------------------------------------------------------------------------
# Async evaluate
# ---------------------------------------------------------------------------
async def async_eval(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
"""Public entry — evaluates and trampolines thunks."""
result = await _async_eval(expr, env, ctx)
while isinstance(result, _AsyncThunk):
result = await _async_eval(result.expr, result.env, result.ctx)
return result
async def _async_eval(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
"""Internal evaluator — may return _AsyncThunk for tail positions."""
# --- literals ---
if isinstance(expr, (int, float, str, bool)):
return expr
if expr is None or expr is NIL:
return NIL
# --- symbol lookup ---
if isinstance(expr, Symbol):
name = expr.name
if name in env:
return env[name]
if name in _PRIMITIVES:
return _PRIMITIVES[name]
if name == "true":
return True
if name == "false":
return False
if name == "nil":
return NIL
raise EvalError(f"Undefined symbol: {name}")
# --- keyword ---
if isinstance(expr, Keyword):
return expr.name
# --- dict literal ---
if isinstance(expr, dict):
return {k: await _async_trampoline(await _async_eval(v, env, ctx)) for k, v in expr.items()}
# --- list ---
if not isinstance(expr, list):
return expr
if not expr:
return []
head = expr[0]
if not isinstance(head, (Symbol, Lambda, list)):
return [await _async_trampoline(await _async_eval(x, env, ctx)) for x in expr]
if isinstance(head, Symbol):
name = head.name
# I/O primitives — await inline
if name in IO_PRIMITIVES:
args, kwargs = await _parse_io_args(expr[1:], env, ctx)
return await execute_io(name, args, kwargs, ctx)
# Special forms
sf = _ASYNC_SPECIAL_FORMS.get(name)
if sf is not None:
return await sf(expr, env, ctx)
ho = _ASYNC_HO_FORMS.get(name)
if ho is not None:
return await ho(expr, env, ctx)
# Macro expansion — tail position
if name in env:
val = env[name]
if isinstance(val, Macro):
expanded = _expand_macro(val, expr[1:], env)
return _AsyncThunk(expanded, env, ctx)
# Render forms in eval position — delegate to renderer and return
# as _RawHTML so it won't be double-escaped when used in render
# context later. Allows (let ((x (<> ...))) ...) etc.
if name in ("<>", "raw!") or name in HTML_TAGS:
html = await _arender(expr, env, ctx)
return _RawHTML(html)
# --- function / lambda call ---
fn = await _async_trampoline(await _async_eval(head, env, ctx))
args = [await _async_trampoline(await _async_eval(a, env, ctx)) for a in expr[1:]]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
result = fn(*args)
if inspect.iscoroutine(result):
return await result
return result
if isinstance(fn, Lambda):
return await _async_call_lambda(fn, args, env, ctx)
if isinstance(fn, Component):
return await _async_call_component(fn, expr[1:], env, ctx)
raise EvalError(f"Not callable: {fn!r}")
async def _parse_io_args(
exprs: list[Any], env: dict[str, Any], ctx: RequestContext,
) -> tuple[list[Any], dict[str, Any]]:
"""Parse and evaluate I/O node args."""
args: list[Any] = []
kwargs: dict[str, Any] = {}
i = 0
while i < len(exprs):
item = exprs[i]
if isinstance(item, Keyword) and i + 1 < len(exprs):
kwargs[item.name] = await async_eval(exprs[i + 1], env, ctx)
i += 2
else:
args.append(await async_eval(item, env, ctx))
i += 1
return args, kwargs
async def _async_call_lambda(
fn: Lambda, args: list[Any], caller_env: dict[str, Any], ctx: RequestContext,
) -> Any:
if len(args) != len(fn.params):
raise EvalError(f"{fn!r} expects {len(fn.params)} args, got {len(args)}")
local = dict(fn.closure)
local.update(caller_env)
for p, v in zip(fn.params, args):
local[p] = v
return _AsyncThunk(fn.body, local, ctx)
async def _async_call_component(
comp: Component, raw_args: list[Any], env: dict[str, Any], ctx: RequestContext,
) -> Any:
kwargs: dict[str, Any] = {}
children: list[Any] = []
i = 0
while i < len(raw_args):
arg = raw_args[i]
if isinstance(arg, Keyword) and i + 1 < len(raw_args):
kwargs[arg.name] = await async_eval(raw_args[i + 1], env, ctx)
i += 2
else:
children.append(await async_eval(arg, env, ctx))
i += 1
local = dict(comp.closure)
local.update(env)
for p in comp.params:
local[p] = kwargs.get(p, NIL)
if comp.has_children:
local["children"] = children
return _AsyncThunk(comp.body, local, ctx)
# ---------------------------------------------------------------------------
# Async special forms
# ---------------------------------------------------------------------------
async def _asf_if(expr, env, ctx):
cond = await _async_trampoline(await _async_eval(expr[1], env, ctx))
if cond and cond is not NIL:
return _AsyncThunk(expr[2], env, ctx)
if len(expr) > 3:
return _AsyncThunk(expr[3], env, ctx)
return NIL
async def _asf_when(expr, env, ctx):
cond = await _async_trampoline(await _async_eval(expr[1], env, ctx))
if cond and cond is not NIL:
for body_expr in expr[2:-1]:
await _async_trampoline(await _async_eval(body_expr, env, ctx))
if len(expr) > 2:
return _AsyncThunk(expr[-1], env, ctx)
return NIL
async def _asf_and(expr, env, ctx):
result: Any = True
for arg in expr[1:]:
result = await _async_trampoline(await _async_eval(arg, env, ctx))
if not result:
return result
return result
async def _asf_or(expr, env, ctx):
result: Any = False
for arg in expr[1:]:
result = await _async_trampoline(await _async_eval(arg, env, ctx))
if result:
return result
return result
async def _asf_let(expr, env, ctx):
bindings = expr[1]
local = dict(env)
if isinstance(bindings, list):
if bindings and isinstance(bindings[0], list):
for binding in bindings:
var = binding[0]
vname = var.name if isinstance(var, Symbol) else var
local[vname] = await _async_trampoline(await _async_eval(binding[1], local, ctx))
elif len(bindings) % 2 == 0:
for i in range(0, len(bindings), 2):
var = bindings[i]
vname = var.name if isinstance(var, Symbol) else var
local[vname] = await _async_trampoline(await _async_eval(bindings[i + 1], local, ctx))
for body_expr in expr[2:-1]:
await _async_trampoline(await _async_eval(body_expr, local, ctx))
if len(expr) > 2:
return _AsyncThunk(expr[-1], local, ctx)
return NIL
async def _asf_lambda(expr, env, ctx):
params_expr = expr[1]
param_names = []
for p in params_expr:
if isinstance(p, Symbol):
param_names.append(p.name)
elif isinstance(p, str):
param_names.append(p)
return Lambda(param_names, expr[2], dict(env))
async def _asf_define(expr, env, ctx):
name_sym = expr[1]
value = await _async_trampoline(await _async_eval(expr[2], env, ctx))
if isinstance(value, Lambda) and value.name is None:
value.name = name_sym.name
env[name_sym.name] = value
return value
async def _asf_defcomp(expr, env, ctx):
from .evaluator import _sf_defcomp
return _sf_defcomp(expr, env)
async def _asf_defmacro(expr, env, ctx):
from .evaluator import _sf_defmacro
return _sf_defmacro(expr, env)
async def _asf_defhandler(expr, env, ctx):
from .evaluator import _sf_defhandler
return _sf_defhandler(expr, env)
async def _asf_begin(expr, env, ctx):
for sub in expr[1:-1]:
await _async_trampoline(await _async_eval(sub, env, ctx))
if len(expr) > 1:
return _AsyncThunk(expr[-1], env, ctx)
return NIL
async def _asf_quote(expr, env, ctx):
return expr[1] if len(expr) > 1 else NIL
async def _asf_quasiquote(expr, env, ctx):
return await _async_qq_expand(expr[1], env, ctx)
async def _async_qq_expand(template, env, ctx):
if not isinstance(template, list):
return template
if not template:
return []
head = template[0]
if isinstance(head, Symbol):
if head.name == "unquote":
return await async_eval(template[1], env, ctx)
if head.name == "splice-unquote":
raise EvalError("splice-unquote not inside a list")
result: list[Any] = []
for item in template:
if (isinstance(item, list) and len(item) == 2
and isinstance(item[0], Symbol) and item[0].name == "splice-unquote"):
spliced = await async_eval(item[1], env, ctx)
if isinstance(spliced, list):
result.extend(spliced)
elif spliced is not None and spliced is not NIL:
result.append(spliced)
else:
result.append(await _async_qq_expand(item, env, ctx))
return result
async def _asf_cond(expr, env, ctx):
clauses = expr[1:]
if not clauses:
return NIL
if (isinstance(clauses[0], list) and len(clauses[0]) == 2
and not (isinstance(clauses[0][0], Symbol) and clauses[0][0].name in (
"=", "<", ">", "<=", ">=", "!=", "and", "or"))):
for clause in clauses:
test = clause[0]
if isinstance(test, Symbol) and test.name in ("else", ":else"):
return _AsyncThunk(clause[1], env, ctx)
if isinstance(test, Keyword) and test.name == "else":
return _AsyncThunk(clause[1], env, ctx)
if await _async_trampoline(await _async_eval(test, env, ctx)):
return _AsyncThunk(clause[1], env, ctx)
else:
i = 0
while i < len(clauses) - 1:
test = clauses[i]
result = clauses[i + 1]
if isinstance(test, Keyword) and test.name == "else":
return _AsyncThunk(result, env, ctx)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return _AsyncThunk(result, env, ctx)
if await _async_trampoline(await _async_eval(test, env, ctx)):
return _AsyncThunk(result, env, ctx)
i += 2
return NIL
async def _asf_case(expr, env, ctx):
match_val = await _async_trampoline(await _async_eval(expr[1], env, ctx))
clauses = expr[2:]
i = 0
while i < len(clauses) - 1:
test = clauses[i]
result = clauses[i + 1]
if isinstance(test, Keyword) and test.name == "else":
return _AsyncThunk(result, env, ctx)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return _AsyncThunk(result, env, ctx)
if match_val == await _async_trampoline(await _async_eval(test, env, ctx)):
return _AsyncThunk(result, env, ctx)
i += 2
return NIL
async def _asf_thread_first(expr, env, ctx):
result = await _async_trampoline(await _async_eval(expr[1], env, ctx))
for form in expr[2:]:
if isinstance(form, list):
fn = await _async_trampoline(await _async_eval(form[0], env, ctx))
args = [result] + [await _async_trampoline(await _async_eval(a, env, ctx)) for a in form[1:]]
else:
fn = await _async_trampoline(await _async_eval(form, env, ctx))
args = [result]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
result = fn(*args)
if inspect.iscoroutine(result):
result = await result
elif isinstance(fn, Lambda):
result = await _async_trampoline(await _async_call_lambda(fn, args, env, ctx))
else:
raise EvalError(f"-> form not callable: {fn!r}")
return result
async def _asf_set_bang(expr, env, ctx):
value = await _async_trampoline(await _async_eval(expr[2], env, ctx))
env[expr[1].name] = value
return value
_ASYNC_SPECIAL_FORMS: dict[str, Any] = {
"if": _asf_if,
"when": _asf_when,
"cond": _asf_cond,
"case": _asf_case,
"and": _asf_and,
"or": _asf_or,
"let": _asf_let,
"let*": _asf_let,
"lambda": _asf_lambda,
"fn": _asf_lambda,
"define": _asf_define,
"defcomp": _asf_defcomp,
"defmacro": _asf_defmacro,
"defhandler": _asf_defhandler,
"begin": _asf_begin,
"do": _asf_begin,
"quote": _asf_quote,
"quasiquote": _asf_quasiquote,
"->": _asf_thread_first,
"set!": _asf_set_bang,
}
# ---------------------------------------------------------------------------
# Async higher-order forms
# ---------------------------------------------------------------------------
async def _aho_map(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
results = []
for item in coll:
if isinstance(fn, Lambda):
results.append(await _async_trampoline(await _async_call_lambda(fn, [item], env, ctx)))
elif callable(fn):
r = fn(item)
results.append(await r if inspect.iscoroutine(r) else r)
else:
raise EvalError(f"map requires callable, got {type(fn).__name__}")
return results
async def _aho_map_indexed(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
results = []
for i, item in enumerate(coll):
if isinstance(fn, Lambda):
results.append(await _async_trampoline(await _async_call_lambda(fn, [i, item], env, ctx)))
elif callable(fn):
r = fn(i, item)
results.append(await r if inspect.iscoroutine(r) else r)
else:
raise EvalError(f"map-indexed requires callable, got {type(fn).__name__}")
return results
async def _aho_filter(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
results = []
for item in coll:
if isinstance(fn, Lambda):
val = await _async_trampoline(await _async_call_lambda(fn, [item], env, ctx))
elif callable(fn):
val = fn(item)
if inspect.iscoroutine(val):
val = await val
else:
raise EvalError(f"filter requires callable, got {type(fn).__name__}")
if val:
results.append(item)
return results
async def _aho_reduce(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
acc = await async_eval(expr[2], env, ctx)
coll = await async_eval(expr[3], env, ctx)
for item in coll:
if isinstance(fn, Lambda):
acc = await _async_trampoline(await _async_call_lambda(fn, [acc, item], env, ctx))
else:
acc = fn(acc, item)
if inspect.iscoroutine(acc):
acc = await acc
return acc
async def _aho_some(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
for item in coll:
if isinstance(fn, Lambda):
result = await _async_trampoline(await _async_call_lambda(fn, [item], env, ctx))
else:
result = fn(item)
if inspect.iscoroutine(result):
result = await result
if result:
return result
return NIL
async def _aho_every(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
for item in coll:
if isinstance(fn, Lambda):
val = await _async_trampoline(await _async_call_lambda(fn, [item], env, ctx))
else:
val = fn(item)
if inspect.iscoroutine(val):
val = await val
if not val:
return False
return True
async def _aho_for_each(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
for item in coll:
if isinstance(fn, Lambda):
await _async_trampoline(await _async_call_lambda(fn, [item], env, ctx))
elif callable(fn):
r = fn(item)
if inspect.iscoroutine(r):
await r
return NIL
_ASYNC_HO_FORMS: dict[str, Any] = {
"map": _aho_map,
"map-indexed": _aho_map_indexed,
"filter": _aho_filter,
"reduce": _aho_reduce,
"some": _aho_some,
"every?": _aho_every,
"for-each": _aho_for_each,
}
# ---------------------------------------------------------------------------
# Async HTML renderer
# ---------------------------------------------------------------------------
async def async_render(
expr: Any,
env: dict[str, Any],
ctx: RequestContext | None = None,
) -> str:
"""Render an s-expression to HTML, awaiting I/O primitives inline."""
if ctx is None:
ctx = RequestContext()
return await _arender(expr, env, ctx)
async def _arender(expr: Any, env: dict[str, Any], ctx: RequestContext) -> str:
if expr is None or expr is NIL or expr is False or expr is True:
return ""
if isinstance(expr, _RawHTML):
return expr.html
if isinstance(expr, str):
return escape_text(expr)
if isinstance(expr, (int, float)):
return escape_text(str(expr))
if isinstance(expr, Symbol):
val = await async_eval(expr, env, ctx)
return await _arender(val, env, ctx)
if isinstance(expr, Keyword):
return escape_text(expr.name)
if isinstance(expr, list):
if not expr:
return ""
return await _arender_list(expr, env, ctx)
if isinstance(expr, dict):
return ""
return escape_text(str(expr))
async def _arender_list(expr: list, env: dict[str, Any], ctx: RequestContext) -> str:
head = expr[0]
if isinstance(head, Symbol):
name = head.name
# I/O primitive — await, then render result
if name in IO_PRIMITIVES:
result = await async_eval(expr, env, ctx)
return await _arender(result, env, ctx)
# raw!
if name == "raw!":
parts = []
for arg in expr[1:]:
val = await async_eval(arg, env, ctx)
if isinstance(val, _RawHTML):
parts.append(val.html)
elif isinstance(val, str):
parts.append(val)
elif val is not None and val is not NIL:
parts.append(str(val))
return "".join(parts)
# <>
if name == "<>":
parts = []
for child in expr[1:]:
parts.append(await _arender(child, env, ctx))
return "".join(parts)
# Render-aware special forms
arsf = _ASYNC_RENDER_FORMS.get(name)
if arsf is not None:
return await arsf(expr, env, ctx)
# Macro expansion
if name in env:
val = env[name]
if isinstance(val, Macro):
expanded = _expand_macro(val, expr[1:], env)
return await _arender(expanded, env, ctx)
# HTML tag
if name in HTML_TAGS:
return await _arender_element(name, expr[1:], env, ctx)
# Component
if name.startswith("~"):
val = env.get(name)
if isinstance(val, Component):
return await _arender_component(val, expr[1:], env, ctx)
# Fallback — evaluate then render
result = await async_eval(expr, env, ctx)
return await _arender(result, env, ctx)
if isinstance(head, (Lambda, list)):
result = await async_eval(expr, env, ctx)
return await _arender(result, env, ctx)
# Data list
parts = []
for item in expr:
parts.append(await _arender(item, env, ctx))
return "".join(parts)
async def _arender_element(
tag: str, args: list, env: dict[str, Any], ctx: RequestContext,
) -> str:
attrs: dict[str, Any] = {}
children: list[Any] = []
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
attr_val = await async_eval(args[i + 1], env, ctx)
attrs[arg.name] = attr_val
i += 2
else:
children.append(arg)
i += 1
class_val = attrs.get("class")
if class_val is not None and class_val is not NIL and class_val is not False:
collector = css_class_collector.get(None)
if collector is not None:
collector.update(str(class_val).split())
parts = [f"<{tag}"]
for attr_name, attr_val in attrs.items():
if attr_val is None or attr_val is NIL or attr_val is False:
continue
if attr_name in BOOLEAN_ATTRS:
if attr_val:
parts.append(f" {attr_name}")
elif attr_val is True:
parts.append(f" {attr_name}")
else:
parts.append(f' {attr_name}="{escape_attr(str(attr_val))}"')
parts.append(">")
opening = "".join(parts)
if tag in VOID_ELEMENTS:
return opening
child_parts = []
for child in children:
child_parts.append(await _arender(child, env, ctx))
return f"{opening}{''.join(child_parts)}</{tag}>"
async def _arender_component(
comp: Component, args: list, env: dict[str, Any], ctx: RequestContext,
) -> str:
kwargs: dict[str, Any] = {}
children: list[Any] = []
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
kwargs[arg.name] = await async_eval(args[i + 1], env, ctx)
i += 2
else:
children.append(arg)
i += 1
local = dict(comp.closure)
local.update(env)
for p in comp.params:
local[p] = kwargs.get(p, NIL)
if comp.has_children:
child_html = []
for c in children:
child_html.append(await _arender(c, env, ctx))
local["children"] = _RawHTML("".join(child_html))
return await _arender(comp.body, local, ctx)
async def _arender_lambda(
fn: Lambda, args: tuple, env: dict[str, Any], ctx: RequestContext,
) -> str:
local = dict(fn.closure)
local.update(env)
for p, v in zip(fn.params, args):
local[p] = v
return await _arender(fn.body, local, ctx)
# ---------------------------------------------------------------------------
# Async render-aware special forms
# ---------------------------------------------------------------------------
async def _arsf_if(expr, env, ctx):
cond = await async_eval(expr[1], env, ctx)
if cond and cond is not NIL:
return await _arender(expr[2], env, ctx)
if len(expr) > 3:
return await _arender(expr[3], env, ctx)
return ""
async def _arsf_when(expr, env, ctx):
cond = await async_eval(expr[1], env, ctx)
if cond and cond is not NIL:
parts = []
for body_expr in expr[2:]:
parts.append(await _arender(body_expr, env, ctx))
return "".join(parts)
return ""
async def _arsf_cond(expr, env, ctx):
clauses = expr[1:]
if not clauses:
return ""
if isinstance(clauses[0], list) and len(clauses[0]) == 2:
for clause in clauses:
test = clause[0]
if isinstance(test, Symbol) and test.name in ("else", ":else"):
return await _arender(clause[1], env, ctx)
if isinstance(test, Keyword) and test.name == "else":
return await _arender(clause[1], env, ctx)
if await async_eval(test, env, ctx):
return await _arender(clause[1], env, ctx)
else:
i = 0
while i < len(clauses) - 1:
test = clauses[i]
result = clauses[i + 1]
if isinstance(test, Keyword) and test.name == "else":
return await _arender(result, env, ctx)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return await _arender(result, env, ctx)
if await async_eval(test, env, ctx):
return await _arender(result, env, ctx)
i += 2
return ""
async def _arsf_let(expr, env, ctx):
bindings = expr[1]
local = dict(env)
if isinstance(bindings, list):
if bindings and isinstance(bindings[0], list):
for binding in bindings:
var = binding[0]
vname = var.name if isinstance(var, Symbol) else var
local[vname] = await async_eval(binding[1], local, ctx)
elif len(bindings) % 2 == 0:
for i in range(0, len(bindings), 2):
var = bindings[i]
vname = var.name if isinstance(var, Symbol) else var
local[vname] = await async_eval(bindings[i + 1], local, ctx)
parts = []
for body_expr in expr[2:]:
parts.append(await _arender(body_expr, local, ctx))
return "".join(parts)
async def _arsf_begin(expr, env, ctx):
parts = []
for sub in expr[1:]:
parts.append(await _arender(sub, env, ctx))
return "".join(parts)
async def _arsf_define(expr, env, ctx):
await async_eval(expr, env, ctx)
return ""
async def _arsf_map(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
parts = []
for item in coll:
if isinstance(fn, Lambda):
parts.append(await _arender_lambda(fn, (item,), env, ctx))
elif callable(fn):
r = fn(item)
if inspect.iscoroutine(r):
r = await r
parts.append(await _arender(r, env, ctx))
else:
parts.append(await _arender(item, env, ctx))
return "".join(parts)
async def _arsf_map_indexed(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
parts = []
for i, item in enumerate(coll):
if isinstance(fn, Lambda):
parts.append(await _arender_lambda(fn, (i, item), env, ctx))
elif callable(fn):
r = fn(i, item)
if inspect.iscoroutine(r):
r = await r
parts.append(await _arender(r, env, ctx))
else:
parts.append(await _arender(item, env, ctx))
return "".join(parts)
async def _arsf_filter(expr, env, ctx):
result = await async_eval(expr, env, ctx)
return await _arender(result, env, ctx)
async def _arsf_for_each(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
parts = []
for item in coll:
if isinstance(fn, Lambda):
parts.append(await _arender_lambda(fn, (item,), env, ctx))
elif callable(fn):
r = fn(item)
if inspect.iscoroutine(r):
r = await r
parts.append(await _arender(r, env, ctx))
else:
parts.append(await _arender(item, env, ctx))
return "".join(parts)
_ASYNC_RENDER_FORMS: dict[str, Any] = {
"if": _arsf_if,
"when": _arsf_when,
"cond": _arsf_cond,
"let": _arsf_let,
"let*": _arsf_let,
"begin": _arsf_begin,
"do": _arsf_begin,
"define": _arsf_define,
"defcomp": _arsf_define,
"defmacro": _arsf_define,
"defhandler": _arsf_define,
"map": _arsf_map,
"map-indexed": _arsf_map_indexed,
"filter": _arsf_filter,
"for-each": _arsf_for_each,
}
# ---------------------------------------------------------------------------
# Async serialize — evaluate I/O/control flow, produce sx source (not HTML)
# ---------------------------------------------------------------------------
# Used by defhandler execution. Fragment providers need to return sx wire
# format (s-expression source) that consuming apps parse and render client-
# side. This mirrors the old Python handlers that used sx_call() to build
# sx strings.
#
# _aser ("async serialize") works like _arender but instead of producing
# HTML for components/tags/<>, it serializes them back to sx source with
# their arguments evaluated.
async def async_eval_to_sx(
expr: Any,
env: dict[str, Any],
ctx: RequestContext | None = None,
) -> str:
"""Evaluate *expr* (resolving I/O inline) and produce sx source string.
Unlike ``async_render`` (which produces HTML), this produces sx wire
format suitable for fragment responses that clients render themselves.
"""
if ctx is None:
ctx = RequestContext()
result = await _aser(expr, env, ctx)
if isinstance(result, SxExpr):
return result.source
if result is None or result is NIL:
return ""
return serialize(result)
async def async_eval_slot_to_sx(
expr: Any,
env: dict[str, Any],
ctx: RequestContext | None = None,
) -> str:
"""Like async_eval_to_sx but expands component calls.
Used by defpage slot evaluation where the content expression is
typically a component call like ``(~dashboard-content)``. Normal
``async_eval_to_sx`` serializes component calls without expanding;
this variant expands one level so IO primitives in the body execute,
then serializes the result as SX wire format.
"""
if ctx is None:
ctx = RequestContext()
# If expr is a component call, expand it through _aser
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 _aser_component(comp, expr[1:], env, ctx)
if isinstance(result, SxExpr):
return result.source
if result is None or result is NIL:
return ""
return serialize(result)
# Fall back to normal async_eval_to_sx
result = await _aser(expr, env, ctx)
if isinstance(result, SxExpr):
return result.source
if result is None or result is NIL:
return ""
# Page helpers return SX source strings from render_to_sx() —
# pass through directly instead of quoting via serialize().
if isinstance(result, str):
return result
return serialize(result)
async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
"""Evaluate *expr*, producing SxExpr for rendering forms, raw values
for everything else."""
if isinstance(expr, (int, float, bool)):
return expr
if isinstance(expr, str):
return expr
if isinstance(expr, SxExpr):
return expr
if expr is None or expr is NIL:
return NIL
if isinstance(expr, Symbol):
name = expr.name
if name in env:
return env[name]
if name in _PRIMITIVES:
return _PRIMITIVES[name]
if name == "true":
return True
if name == "false":
return False
if name == "nil":
return NIL
raise EvalError(f"Undefined symbol: {name}")
if isinstance(expr, Keyword):
return expr.name
if isinstance(expr, dict):
return {k: await _aser(v, env, ctx) for k, v in expr.items()}
if not isinstance(expr, list):
return expr
if not expr:
return []
head = expr[0]
if not isinstance(head, (Symbol, Lambda, list)):
return [await _aser(x, env, ctx) for x in expr]
if isinstance(head, Symbol):
name = head.name
# I/O primitives — await, return actual data
if name in IO_PRIMITIVES:
args, kwargs = await _parse_io_args(expr[1:], env, ctx)
return await execute_io(name, args, kwargs, ctx)
# <> — serialize children as sx fragment
if name == "<>":
return await _aser_fragment(expr[1:], env, ctx)
# raw! — serialize
if name == "raw!":
return await _aser_call("raw!", expr[1:], env, ctx)
# Component call — serialize (don't expand)
if name.startswith("~"):
return await _aser_call(name, expr[1:], env, ctx)
# Serialize-mode special/HO forms (checked BEFORE HTML_TAGS
# because some names like "map" are both HTML tags and sx forms)
sf = _ASER_FORMS.get(name)
if sf is not None:
return await sf(expr, env, ctx)
# HTML tag — serialize (don't render to HTML)
if name in HTML_TAGS:
return await _aser_call(name, expr[1:], env, ctx)
# Macro expansion
if name in env:
val = env[name]
if isinstance(val, Macro):
expanded = _expand_macro(val, expr[1:], env)
return await _aser(expanded, env, ctx)
# Function / lambda call — evaluate (produces data, not rendering)
fn = await async_eval(head, env, ctx)
args = [await async_eval(a, env, ctx) for a in expr[1:]]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
result = fn(*args)
if inspect.iscoroutine(result):
return await result
return result
if isinstance(fn, Lambda):
return await _async_trampoline(await _async_call_lambda(fn, args, env, ctx))
if isinstance(fn, Component):
# Component invoked as function — serialize the call
return await _aser_call(f"~{fn.name}", expr[1:], env, ctx)
raise EvalError(f"Not callable: {fn!r}")
async def _aser_fragment(children: list, env: dict, ctx: RequestContext) -> SxExpr:
"""Serialize ``(<> child1 child2 ...)`` to sx source."""
parts: list[str] = []
for child in children:
result = await _aser(child, env, ctx)
if isinstance(result, list):
# Flatten lists (e.g. from map)
for item in result:
if item is not NIL and item is not None:
parts.append(serialize(item))
elif result is not NIL and result is not None:
parts.append(serialize(result))
if not parts:
return SxExpr("")
return SxExpr("(<> " + " ".join(parts) + ")")
async def _aser_component(
comp: Component, args: list, env: dict, ctx: RequestContext,
) -> Any:
"""Expand a component body through _aser — produces SX, not HTML."""
kwargs: dict[str, Any] = {}
children: list[Any] = []
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
kwargs[arg.name] = await _aser(args[i + 1], env, ctx)
i += 2
else:
children.append(arg)
i += 1
local = dict(comp.closure)
local.update(env)
for p in comp.params:
local[p] = kwargs.get(p, NIL)
if comp.has_children:
child_parts = []
for c in children:
child_parts.append(serialize(await _aser(c, env, ctx)))
local["children"] = SxExpr("(<> " + " ".join(child_parts) + ")")
return await _aser(comp.body, local, ctx)
async def _aser_call(
name: str, args: list, env: dict, ctx: RequestContext,
) -> SxExpr:
"""Serialize ``(name :key val child ...)`` — evaluate args but keep
as sx source instead of rendering to HTML."""
parts = [name]
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
val = await _aser(args[i + 1], env, ctx)
if val is not NIL and val is not None:
parts.append(f":{arg.name}")
parts.append(serialize(val))
i += 2
else:
result = await _aser(arg, env, ctx)
if result is not NIL and result is not None:
parts.append(serialize(result))
i += 1
return SxExpr("(" + " ".join(parts) + ")")
# ---------------------------------------------------------------------------
# Serialize-mode special forms
# ---------------------------------------------------------------------------
async def _assf_if(expr, env, ctx):
cond = await async_eval(expr[1], env, ctx)
if cond and cond is not NIL:
return await _aser(expr[2], env, ctx)
if len(expr) > 3:
return await _aser(expr[3], env, ctx)
return NIL
async def _assf_when(expr, env, ctx):
cond = await async_eval(expr[1], env, ctx)
if cond and cond is not NIL:
result: Any = NIL
for body_expr in expr[2:]:
result = await _aser(body_expr, env, ctx)
return result
return NIL
async def _assf_let(expr, env, ctx):
bindings = expr[1]
local = dict(env)
if isinstance(bindings, list):
if bindings and isinstance(bindings[0], list):
for binding in bindings:
var = binding[0]
vname = var.name if isinstance(var, Symbol) else var
local[vname] = await _aser(binding[1], local, ctx)
elif len(bindings) % 2 == 0:
for i in range(0, len(bindings), 2):
var = bindings[i]
vname = var.name if isinstance(var, Symbol) else var
local[vname] = await _aser(bindings[i + 1], local, ctx)
result: Any = NIL
for body_expr in expr[2:]:
result = await _aser(body_expr, local, ctx)
return result
async def _assf_cond(expr, env, ctx):
clauses = expr[1:]
if not clauses:
return NIL
if (isinstance(clauses[0], list) and len(clauses[0]) == 2
and not (isinstance(clauses[0][0], Symbol) and clauses[0][0].name in (
"=", "<", ">", "<=", ">=", "!=", "and", "or"))):
for clause in clauses:
test = clause[0]
if isinstance(test, Symbol) and test.name in ("else", ":else"):
return await _aser(clause[1], env, ctx)
if isinstance(test, Keyword) and test.name == "else":
return await _aser(clause[1], env, ctx)
if await async_eval(test, env, ctx):
return await _aser(clause[1], env, ctx)
else:
i = 0
while i < len(clauses) - 1:
test = clauses[i]
result = clauses[i + 1]
if isinstance(test, Keyword) and test.name == "else":
return await _aser(result, env, ctx)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return await _aser(result, env, ctx)
if await async_eval(test, env, ctx):
return await _aser(result, env, ctx)
i += 2
return NIL
async def _assf_case(expr, env, ctx):
match_val = await async_eval(expr[1], env, ctx)
clauses = expr[2:]
i = 0
while i < len(clauses) - 1:
test = clauses[i]
result = clauses[i + 1]
if isinstance(test, Keyword) and test.name == "else":
return await _aser(result, env, ctx)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return await _aser(result, env, ctx)
if match_val == await async_eval(test, env, ctx):
return await _aser(result, env, ctx)
i += 2
return NIL
async def _assf_begin(expr, env, ctx):
result: Any = NIL
for sub in expr[1:]:
result = await _aser(sub, env, ctx)
return result
async def _assf_define(expr, env, ctx):
await async_eval(expr, env, ctx)
return NIL
async def _assf_lambda(expr, env, ctx):
return await _asf_lambda(expr, env, ctx)
async def _assf_and(expr, env, ctx):
return await _asf_and(expr, env, ctx)
async def _assf_or(expr, env, ctx):
return await _asf_or(expr, env, ctx)
async def _assf_quote(expr, env, ctx):
return expr[1] if len(expr) > 1 else NIL
async def _assf_quasiquote(expr, env, ctx):
return await _async_qq_expand(expr[1], env, ctx)
async def _assf_thread_first(expr, env, ctx):
return await _asf_thread_first(expr, env, ctx)
async def _assf_set_bang(expr, env, ctx):
return await _asf_set_bang(expr, env, ctx)
# ---------------------------------------------------------------------------
# Serialize-mode higher-order forms
# ---------------------------------------------------------------------------
async def _asho_ser_map(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
results = []
for item in coll:
if isinstance(fn, Lambda):
local = dict(fn.closure)
local.update(env)
for p, v in zip(fn.params, [item]):
local[p] = v
results.append(await _aser(fn.body, local, ctx))
elif callable(fn):
r = fn(item)
results.append(await r if inspect.iscoroutine(r) else r)
else:
raise EvalError(f"map requires callable, got {type(fn).__name__}")
return results
async def _asho_ser_map_indexed(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
results = []
for i, item in enumerate(coll):
if isinstance(fn, Lambda):
local = dict(fn.closure)
local.update(env)
local[fn.params[0]] = i
local[fn.params[1]] = item
results.append(await _aser(fn.body, local, ctx))
elif callable(fn):
r = fn(i, item)
results.append(await r if inspect.iscoroutine(r) else r)
else:
raise EvalError(f"map-indexed requires callable, got {type(fn).__name__}")
return results
async def _asho_ser_filter(expr, env, ctx):
# filter is value-producing, delegate to eval
return await async_eval(expr, env, ctx)
async def _asho_ser_for_each(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
results = []
for item in coll:
if isinstance(fn, Lambda):
local = dict(fn.closure)
local.update(env)
local[fn.params[0]] = item
results.append(await _aser(fn.body, local, ctx))
elif callable(fn):
r = fn(item)
results.append(await r if inspect.iscoroutine(r) else r)
return results
_ASER_FORMS: dict[str, Any] = {
"if": _assf_if,
"when": _assf_when,
"cond": _assf_cond,
"case": _assf_case,
"and": _assf_and,
"or": _assf_or,
"let": _assf_let,
"let*": _assf_let,
"lambda": _assf_lambda,
"fn": _assf_lambda,
"define": _assf_define,
"defcomp": _assf_define,
"defmacro": _assf_define,
"defhandler": _assf_define,
"begin": _assf_begin,
"do": _assf_begin,
"quote": _assf_quote,
"quasiquote": _assf_quasiquote,
"->": _assf_thread_first,
"set!": _assf_set_bang,
"map": _asho_ser_map,
"map-indexed": _asho_ser_map_indexed,
"filter": _asho_ser_filter,
"for-each": _asho_ser_for_each,
}