""" 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, StyleValue, 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, _svg_context, ) # --------------------------------------------------------------------------- # 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_defstyle(expr, env, ctx): from .evaluator import _sf_defstyle return _sf_defstyle(expr, env) async def _asf_defkeyframes(expr, env, ctx): from .evaluator import _sf_defkeyframes return _sf_defkeyframes(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, "defstyle": _asf_defstyle, "defkeyframes": _asf_defkeyframes, "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) # html: prefix → force tag rendering if name.startswith("html:"): return await _arender_element(name[5:], expr[1:], env, ctx) # Render-aware special forms # If name is also an HTML tag and first arg is Keyword → tag call arsf = _ASYNC_RENDER_FORMS.get(name) if arsf is not None: if name in HTML_TAGS and len(expr) > 1 and isinstance(expr[1], Keyword): return await _arender_element(name, expr[1:], env, ctx) 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) # Custom element (hyphenated name) → render as tag if "-" in name: return await _arender_element(name, expr[1:], env, ctx) # SVG/MathML context → unknown names are child elements if _svg_context.get(False): return await _arender_element(name, 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 # Handle :style StyleValue — convert to class and register CSS rule style_val = attrs.get("style") if isinstance(style_val, StyleValue): from .css_registry import register_generated_rule register_generated_rule(style_val) existing_class = attrs.get("class") if existing_class and existing_class is not NIL and existing_class is not False: attrs["class"] = f"{existing_class} {style_val.class_name}" else: attrs["class"] = style_val.class_name del attrs["style"] 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 # SVG/MathML namespace auto-detection: set context for children token = None if tag in ("svg", "math"): token = _svg_context.set(True) try: child_parts = [] for child in children: child_parts.append(await _arender(child, env, ctx)) finally: if token is not None: _svg_context.reset(token) return f"{opening}{''.join(child_parts)}" 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, "defstyle": _arsf_define, "defkeyframes": _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) # html: prefix → force tag serialization if name.startswith("html:"): return await _aser_call(name[5:], 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). # If name is also an HTML tag and first arg is Keyword → tag call. sf = _ASER_FORMS.get(name) if sf is not None: if name in HTML_TAGS and len(expr) > 1 and isinstance(expr[1], Keyword): return await _aser_call(name, expr[1:], env, ctx) 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) # Custom element (hyphenated name) → serialize as tag if "-" in name: return await _aser_call(name, expr[1:], env, ctx) # SVG/MathML context → unknown names are child elements if _svg_context.get(False): return await _aser_call(name, expr[1:], 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 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_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.""" # SVG/MathML namespace auto-detection for serializer token = None if name in ("svg", "math"): token = _svg_context.set(True) try: parts = [name] extra_class: str | None = None # from :style StyleValue conversion 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: # :style StyleValue → convert to :class and register CSS if arg.name == "style" and isinstance(val, StyleValue): from .css_registry import register_generated_rule register_generated_rule(val) extra_class = val.class_name else: 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 # If we converted a :style to a class, merge into existing :class or add it if extra_class: _merge_class_into_parts(parts, extra_class) return SxExpr("(" + " ".join(parts) + ")") finally: if token is not None: _svg_context.reset(token) def _merge_class_into_parts(parts: list[str], class_name: str) -> None: """Merge an extra class name into the serialized parts list. If :class already exists, append to it. Otherwise add :class. """ for i, p in enumerate(parts): if p == ":class" and i + 1 < len(parts): # Existing :class — append our class existing = parts[i + 1] if existing.startswith('"') and existing.endswith('"'): # Quoted string — insert before closing quote parts[i + 1] = existing[:-1] + " " + class_name + '"' else: # Expression — wrap in (str ...) parts[i + 1] = f'(str {existing} " {class_name}")' return # No existing :class — add one parts.insert(1, f'"{class_name}"') parts.insert(1, ":class") # --------------------------------------------------------------------------- # 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, "defstyle": _assf_define, "defkeyframes": _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, }