diff --git a/shared/sx/async_eval.py b/shared/sx/async_eval.py index a260d4f..4388ecd 100644 --- a/shared/sx/async_eval.py +++ b/shared/sx/async_eval.py @@ -25,6 +25,7 @@ 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, @@ -837,3 +838,386 @@ _ASYNC_RENDER_FORMS: dict[str, Any] = { "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 _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)): + return fn(*args) + if isinstance(fn, Lambda): + return 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_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): + results.append(fn(item)) + 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): + results.append(fn(i, item)) + 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): + results.append(fn(item)) + 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, +} diff --git a/shared/sx/handlers.py b/shared/sx/handlers.py index 9d845bd..6e653f2 100644 --- a/shared/sx/handlers.py +++ b/shared/sx/handlers.py @@ -122,7 +122,7 @@ async def execute_handler( 4. Return rendered string """ from .jinja_bridge import get_component_env, _get_request_context - from .async_eval import async_render + from .async_eval import async_eval_to_sx from .types import NIL if args is None: @@ -139,8 +139,9 @@ async def execute_handler( # Get request context for I/O primitives ctx = _get_request_context() - # Async eval+render — I/O primitives are awaited inline - return await async_render(handler_def.body, env, ctx) + # Async eval → sx source — I/O primitives are awaited inline, + # but component/tag calls serialize to sx wire format (not HTML). + return await async_eval_to_sx(handler_def.body, env, ctx) # --------------------------------------------------------------------------- diff --git a/shared/sx/primitives.py b/shared/sx/primitives.py index 29d4fd5..eb4d0b9 100644 --- a/shared/sx/primitives.py +++ b/shared/sx/primitives.py @@ -189,7 +189,10 @@ def prim_is_dict(x: Any) -> bool: def prim_is_empty(coll: Any) -> bool: if coll is None or coll is NIL: return True - return len(coll) == 0 + try: + return len(coll) == 0 + except TypeError: + return False @register_primitive("contains?") def prim_contains(coll: Any, key: Any) -> bool: