Add TCO to evaluator, update SX docs messaging
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m3s

Evaluator: add _Thunk + _trampoline for tail-call optimization in
lambdas, components, if/when/cond/case/let/begin. All callers in
html.py, resolver.py, handlers.py, pages.py, jinja_bridge.py, and
query_registry.py unwrap thunks at non-tail positions.

SX docs: update tagline to "s-expressions for the web", rewrite intro
to reflect that SX replaces most JavaScript need, fix "What sx is not"
to acknowledge macros and TCO exist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 10:31:31 +00:00
parent a3318b4fd7
commit 5069072715
9 changed files with 131 additions and 94 deletions

View File

@@ -42,6 +42,22 @@ class EvalError(Exception):
pass pass
class _Thunk:
"""Deferred evaluation — returned from tail positions for TCO."""
__slots__ = ("expr", "env")
def __init__(self, expr: Any, env: dict[str, Any]):
self.expr = expr
self.env = env
def _trampoline(val: Any) -> Any:
"""Unwrap thunks by re-entering the evaluator until we get an actual value."""
while isinstance(val, _Thunk):
val = _eval(val.expr, val.env)
return val
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Public API # Public API
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -50,7 +66,10 @@ def evaluate(expr: Any, env: dict[str, Any] | None = None) -> Any:
"""Evaluate *expr* in *env* and return the result.""" """Evaluate *expr* in *env* and return the result."""
if env is None: if env is None:
env = {} env = {}
return _eval(expr, env) result = _eval(expr, env)
while isinstance(result, _Thunk):
result = _eval(result.expr, result.env)
return result
def make_env(**kwargs: Any) -> dict[str, Any]: def make_env(**kwargs: Any) -> dict[str, Any]:
@@ -90,7 +109,7 @@ def _eval(expr: Any, env: dict[str, Any]) -> Any:
# --- dict literal ----------------------------------------------------- # --- dict literal -----------------------------------------------------
if isinstance(expr, dict): if isinstance(expr, dict):
return {k: _eval(v, env) for k, v in expr.items()} return {k: _trampoline(_eval(v, env)) for k, v in expr.items()}
# --- list = call or special form -------------------------------------- # --- list = call or special form --------------------------------------
if not isinstance(expr, list): if not isinstance(expr, list):
@@ -103,7 +122,7 @@ def _eval(expr: Any, env: dict[str, Any]) -> Any:
# If head is not a symbol/lambda/list, treat entire list as data # If head is not a symbol/lambda/list, treat entire list as data
if not isinstance(head, (Symbol, Lambda, list)): if not isinstance(head, (Symbol, Lambda, list)):
return [_eval(x, env) for x in expr] return [_trampoline(_eval(x, env)) for x in expr]
# --- special forms ---------------------------------------------------- # --- special forms ----------------------------------------------------
if isinstance(head, Symbol): if isinstance(head, Symbol):
@@ -122,11 +141,11 @@ def _eval(expr: Any, env: dict[str, Any]) -> Any:
val = env[name] val = env[name]
if isinstance(val, Macro): if isinstance(val, Macro):
expanded = _expand_macro(val, expr[1:], env) expanded = _expand_macro(val, expr[1:], env)
return _eval(expanded, env) return _Thunk(expanded, env)
# --- function / lambda call ------------------------------------------- # --- function / lambda call -------------------------------------------
fn = _eval(head, env) fn = _trampoline(_eval(head, env))
args = [_eval(a, env) for a in expr[1:]] args = [_trampoline(_eval(a, env)) for a in expr[1:]]
if callable(fn) and not isinstance(fn, (Lambda, Component)): if callable(fn) and not isinstance(fn, (Lambda, Component)):
return fn(*args) return fn(*args)
@@ -151,7 +170,7 @@ def _call_lambda(fn: Lambda, args: list[Any], caller_env: dict[str, Any]) -> Any
local.update(caller_env) local.update(caller_env)
for p, v in zip(fn.params, args): for p, v in zip(fn.params, args):
local[p] = v local[p] = v
return _eval(fn.body, local) return _Thunk(fn.body, local)
def _call_component(comp: Component, raw_args: list[Any], env: dict[str, Any]) -> Any: def _call_component(comp: Component, raw_args: list[Any], env: dict[str, Any]) -> Any:
@@ -166,10 +185,10 @@ def _call_component(comp: Component, raw_args: list[Any], env: dict[str, Any]) -
while i < len(raw_args): while i < len(raw_args):
arg = raw_args[i] arg = raw_args[i]
if isinstance(arg, Keyword) and i + 1 < len(raw_args): if isinstance(arg, Keyword) and i + 1 < len(raw_args):
kwargs[arg.name] = _eval(raw_args[i + 1], env) kwargs[arg.name] = _trampoline(_eval(raw_args[i + 1], env))
i += 2 i += 2
else: else:
children.append(_eval(arg, env)) children.append(_trampoline(_eval(arg, env)))
i += 1 i += 1
local = dict(comp.closure) local = dict(comp.closure)
@@ -181,7 +200,7 @@ def _call_component(comp: Component, raw_args: list[Any], env: dict[str, Any]) -
local[p] = NIL local[p] = NIL
if comp.has_children: if comp.has_children:
local["children"] = children local["children"] = children
return _eval(comp.body, local) return _Thunk(comp.body, local)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -191,23 +210,22 @@ def _call_component(comp: Component, raw_args: list[Any], env: dict[str, Any]) -
def _sf_if(expr: list, env: dict) -> Any: def _sf_if(expr: list, env: dict) -> Any:
if len(expr) < 3: if len(expr) < 3:
raise EvalError("if requires condition and then-branch") raise EvalError("if requires condition and then-branch")
cond = _eval(expr[1], env) cond = _trampoline(_eval(expr[1], env))
if cond and cond is not NIL: if cond and cond is not NIL:
return _eval(expr[2], env) return _Thunk(expr[2], env)
if len(expr) > 3: if len(expr) > 3:
return _eval(expr[3], env) return _Thunk(expr[3], env)
return NIL return NIL
def _sf_when(expr: list, env: dict) -> Any: def _sf_when(expr: list, env: dict) -> Any:
if len(expr) < 3: if len(expr) < 3:
raise EvalError("when requires condition and body") raise EvalError("when requires condition and body")
cond = _eval(expr[1], env) cond = _trampoline(_eval(expr[1], env))
if cond and cond is not NIL: if cond and cond is not NIL:
result = NIL for body_expr in expr[2:-1]:
for body_expr in expr[2:]: _trampoline(_eval(body_expr, env))
result = _eval(body_expr, env) return _Thunk(expr[-1], env)
return result
return NIL return NIL
@@ -228,22 +246,22 @@ def _sf_cond(expr: list, env: dict) -> Any:
raise EvalError("cond clause must be (test result)") raise EvalError("cond clause must be (test result)")
test = clause[0] test = clause[0]
if isinstance(test, Symbol) and test.name in ("else", ":else"): if isinstance(test, Symbol) and test.name in ("else", ":else"):
return _eval(clause[1], env) return _Thunk(clause[1], env)
if isinstance(test, Keyword) and test.name == "else": if isinstance(test, Keyword) and test.name == "else":
return _eval(clause[1], env) return _Thunk(clause[1], env)
if _eval(test, env): if _trampoline(_eval(test, env)):
return _eval(clause[1], env) return _Thunk(clause[1], env)
else: else:
i = 0 i = 0
while i < len(clauses) - 1: while i < len(clauses) - 1:
test = clauses[i] test = clauses[i]
result = clauses[i + 1] result = clauses[i + 1]
if isinstance(test, Keyword) and test.name == "else": if isinstance(test, Keyword) and test.name == "else":
return _eval(result, env) return _Thunk(result, env)
if isinstance(test, Symbol) and test.name in (":else", "else"): if isinstance(test, Symbol) and test.name in (":else", "else"):
return _eval(result, env) return _Thunk(result, env)
if _eval(test, env): if _trampoline(_eval(test, env)):
return _eval(result, env) return _Thunk(result, env)
i += 2 i += 2
return NIL return NIL
@@ -251,18 +269,18 @@ def _sf_cond(expr: list, env: dict) -> Any:
def _sf_case(expr: list, env: dict) -> Any: def _sf_case(expr: list, env: dict) -> Any:
if len(expr) < 2: if len(expr) < 2:
raise EvalError("case requires expression to match") raise EvalError("case requires expression to match")
match_val = _eval(expr[1], env) match_val = _trampoline(_eval(expr[1], env))
clauses = expr[2:] clauses = expr[2:]
i = 0 i = 0
while i < len(clauses) - 1: while i < len(clauses) - 1:
test = clauses[i] test = clauses[i]
result = clauses[i + 1] result = clauses[i + 1]
if isinstance(test, Keyword) and test.name == "else": if isinstance(test, Keyword) and test.name == "else":
return _eval(result, env) return _Thunk(result, env)
if isinstance(test, Symbol) and test.name in (":else", "else"): if isinstance(test, Symbol) and test.name in (":else", "else"):
return _eval(result, env) return _Thunk(result, env)
if match_val == _eval(test, env): if match_val == _trampoline(_eval(test, env)):
return _eval(result, env) return _Thunk(result, env)
i += 2 i += 2
return NIL return NIL
@@ -270,7 +288,7 @@ def _sf_case(expr: list, env: dict) -> Any:
def _sf_and(expr: list, env: dict) -> Any: def _sf_and(expr: list, env: dict) -> Any:
result: Any = True result: Any = True
for arg in expr[1:]: for arg in expr[1:]:
result = _eval(arg, env) result = _trampoline(_eval(arg, env))
if not result: if not result:
return result return result
return result return result
@@ -279,7 +297,7 @@ def _sf_and(expr: list, env: dict) -> Any:
def _sf_or(expr: list, env: dict) -> Any: def _sf_or(expr: list, env: dict) -> Any:
result: Any = False result: Any = False
for arg in expr[1:]: for arg in expr[1:]:
result = _eval(arg, env) result = _trampoline(_eval(arg, env))
if result: if result:
return result return result
return result return result
@@ -299,23 +317,23 @@ def _sf_let(expr: list, env: dict) -> Any:
raise EvalError("let binding must be (name value)") raise EvalError("let binding must be (name value)")
var = binding[0] var = binding[0]
vname = var.name if isinstance(var, Symbol) else var vname = var.name if isinstance(var, Symbol) else var
local[vname] = _eval(binding[1], local) local[vname] = _trampoline(_eval(binding[1], local))
elif len(bindings) % 2 == 0: elif len(bindings) % 2 == 0:
# Clojure-style: (name val name val ...) # Clojure-style: (name val name val ...)
for i in range(0, len(bindings), 2): for i in range(0, len(bindings), 2):
var = bindings[i] var = bindings[i]
vname = var.name if isinstance(var, Symbol) else var vname = var.name if isinstance(var, Symbol) else var
local[vname] = _eval(bindings[i + 1], local) local[vname] = _trampoline(_eval(bindings[i + 1], local))
else: else:
raise EvalError("let bindings must be (name val ...) pairs") raise EvalError("let bindings must be (name val ...) pairs")
else: else:
raise EvalError("let bindings must be a list") raise EvalError("let bindings must be a list")
# Evaluate body expressions, return last # Evaluate body expressions — all but last non-tail, last is tail
result: Any = NIL body = expr[2:]
for body_expr in expr[2:]: for body_expr in body[:-1]:
result = _eval(body_expr, local) _trampoline(_eval(body_expr, local))
return result return _Thunk(body[-1], local)
def _sf_lambda(expr: list, env: dict) -> Lambda: def _sf_lambda(expr: list, env: dict) -> Lambda:
@@ -341,7 +359,7 @@ def _sf_define(expr: list, env: dict) -> Any:
name_sym = expr[1] name_sym = expr[1]
if not isinstance(name_sym, Symbol): if not isinstance(name_sym, Symbol):
raise EvalError(f"define name must be symbol, got {type(name_sym).__name__}") raise EvalError(f"define name must be symbol, got {type(name_sym).__name__}")
value = _eval(expr[2], env) value = _trampoline(_eval(expr[2], env))
if isinstance(value, Lambda) and value.name is None: if isinstance(value, Lambda) and value.name is None:
value.name = name_sym.name value.name = name_sym.name
env[name_sym.name] = value env[name_sym.name] = value
@@ -393,10 +411,11 @@ def _sf_defcomp(expr: list, env: dict) -> Component:
def _sf_begin(expr: list, env: dict) -> Any: def _sf_begin(expr: list, env: dict) -> Any:
result: Any = NIL if len(expr) < 2:
for sub in expr[1:]: return NIL
result = _eval(sub, env) for sub in expr[1:-1]:
return result _trampoline(_eval(sub, env))
return _Thunk(expr[-1], env)
def _sf_quote(expr: list, _env: dict) -> Any: def _sf_quote(expr: list, _env: dict) -> Any:
@@ -407,18 +426,18 @@ def _sf_thread_first(expr: list, env: dict) -> Any:
"""``(-> val (f a) (g b))`` → ``(g (f val a) b)``""" """``(-> val (f a) (g b))`` → ``(g (f val a) b)``"""
if len(expr) < 2: if len(expr) < 2:
raise EvalError("-> requires at least a value") raise EvalError("-> requires at least a value")
result = _eval(expr[1], env) result = _trampoline(_eval(expr[1], env))
for form in expr[2:]: for form in expr[2:]:
if isinstance(form, list): if isinstance(form, list):
fn = _eval(form[0], env) fn = _trampoline(_eval(form[0], env))
args = [result] + [_eval(a, env) for a in form[1:]] args = [result] + [_trampoline(_eval(a, env)) for a in form[1:]]
else: else:
fn = _eval(form, env) fn = _trampoline(_eval(form, env))
args = [result] args = [result]
if callable(fn) and not isinstance(fn, (Lambda, Component)): if callable(fn) and not isinstance(fn, (Lambda, Component)):
result = fn(*args) result = fn(*args)
elif isinstance(fn, Lambda): elif isinstance(fn, Lambda):
result = _call_lambda(fn, args, env) result = _trampoline(_call_lambda(fn, args, env))
else: else:
raise EvalError(f"-> form not callable: {fn!r}") raise EvalError(f"-> form not callable: {fn!r}")
return result return result
@@ -482,14 +501,14 @@ def _qq_expand(template: Any, env: dict) -> Any:
if head.name == "unquote": if head.name == "unquote":
if len(template) < 2: if len(template) < 2:
raise EvalError("unquote requires an expression") raise EvalError("unquote requires an expression")
return _eval(template[1], env) return _trampoline(_eval(template[1], env))
if head.name == "splice-unquote": if head.name == "splice-unquote":
raise EvalError("splice-unquote not inside a list") raise EvalError("splice-unquote not inside a list")
# Walk children, handling splice-unquote # Walk children, handling splice-unquote
result: list[Any] = [] result: list[Any] = []
for item in template: for item in template:
if isinstance(item, list) and len(item) == 2 and isinstance(item[0], Symbol) and item[0].name == "splice-unquote": if isinstance(item, list) and len(item) == 2 and isinstance(item[0], Symbol) and item[0].name == "splice-unquote":
spliced = _eval(item[1], env) spliced = _trampoline(_eval(item[1], env))
if isinstance(spliced, list): if isinstance(spliced, list):
result.extend(spliced) result.extend(spliced)
elif spliced is not None and spliced is not NIL: elif spliced is not None and spliced is not NIL:
@@ -516,7 +535,7 @@ def _expand_macro(macro: Macro, raw_args: list[Any], env: dict) -> Any:
rest_start = len(macro.params) rest_start = len(macro.params)
local[macro.rest_param] = list(raw_args[rest_start:]) local[macro.rest_param] = list(raw_args[rest_start:])
return _eval(macro.body, local) return _trampoline(_eval(macro.body, local))
def _sf_defhandler(expr: list, env: dict) -> HandlerDef: def _sf_defhandler(expr: list, env: dict) -> HandlerDef:
@@ -629,7 +648,7 @@ def _sf_set_bang(expr: list, env: dict) -> Any:
name_sym = expr[1] name_sym = expr[1]
if not isinstance(name_sym, Symbol): if not isinstance(name_sym, Symbol):
raise EvalError(f"set! name must be symbol, got {type(name_sym).__name__}") raise EvalError(f"set! name must be symbol, got {type(name_sym).__name__}")
value = _eval(expr[2], env) value = _trampoline(_eval(expr[2], env))
# Walk up scope if using Env objects; for plain dicts just overwrite # Walk up scope if using Env objects; for plain dicts just overwrite
env[name_sym.name] = value env[name_sym.name] = value
return value return value
@@ -660,7 +679,7 @@ def _sf_defrelation(expr: list, env: dict) -> RelationDef:
if isinstance(val, Keyword): if isinstance(val, Keyword):
kwargs[key.name] = val.name kwargs[key.name] = val.name
else: else:
kwargs[key.name] = _eval(val, env) if not isinstance(val, str) else val kwargs[key.name] = _trampoline(_eval(val, env)) if not isinstance(val, str) else val
i += 2 i += 2
else: else:
kwargs[key.name] = None kwargs[key.name] = None
@@ -746,7 +765,7 @@ def _sf_defpage(expr: list, env: dict) -> PageDef:
elif isinstance(item, str): elif isinstance(item, str):
auth.append(item) auth.append(item)
else: else:
auth.append(_eval(item, env)) auth.append(_trampoline(_eval(item, env)))
else: else:
auth = str(auth_val) if auth_val else "public" auth = str(auth_val) if auth_val else "public"
@@ -762,7 +781,7 @@ def _sf_defpage(expr: list, env: dict) -> PageDef:
cache_val = slots.get("cache") cache_val = slots.get("cache")
cache = None cache = None
if cache_val is not None: if cache_val is not None:
cache_result = _eval(cache_val, env) cache_result = _trampoline(_eval(cache_val, env))
if isinstance(cache_result, dict): if isinstance(cache_result, dict):
cache = cache_result cache = cache_result
@@ -818,57 +837,57 @@ _SPECIAL_FORMS: dict[str, Any] = {
def _ho_map(expr: list, env: dict) -> list: def _ho_map(expr: list, env: dict) -> list:
if len(expr) != 3: if len(expr) != 3:
raise EvalError("map requires fn and collection") raise EvalError("map requires fn and collection")
fn = _eval(expr[1], env) fn = _trampoline(_eval(expr[1], env))
coll = _eval(expr[2], env) coll = _trampoline(_eval(expr[2], env))
if not isinstance(fn, Lambda): if not isinstance(fn, Lambda):
raise EvalError(f"map requires lambda, got {type(fn).__name__}") raise EvalError(f"map requires lambda, got {type(fn).__name__}")
return [_call_lambda(fn, [item], env) for item in coll] return [_trampoline(_call_lambda(fn, [item], env)) for item in coll]
def _ho_map_indexed(expr: list, env: dict) -> list: def _ho_map_indexed(expr: list, env: dict) -> list:
if len(expr) != 3: if len(expr) != 3:
raise EvalError("map-indexed requires fn and collection") raise EvalError("map-indexed requires fn and collection")
fn = _eval(expr[1], env) fn = _trampoline(_eval(expr[1], env))
coll = _eval(expr[2], env) coll = _trampoline(_eval(expr[2], env))
if not isinstance(fn, Lambda): if not isinstance(fn, Lambda):
raise EvalError(f"map-indexed requires lambda, got {type(fn).__name__}") raise EvalError(f"map-indexed requires lambda, got {type(fn).__name__}")
if len(fn.params) < 2: if len(fn.params) < 2:
raise EvalError("map-indexed lambda needs (i item) params") raise EvalError("map-indexed lambda needs (i item) params")
return [_call_lambda(fn, [i, item], env) for i, item in enumerate(coll)] return [_trampoline(_call_lambda(fn, [i, item], env)) for i, item in enumerate(coll)]
def _ho_filter(expr: list, env: dict) -> list: def _ho_filter(expr: list, env: dict) -> list:
if len(expr) != 3: if len(expr) != 3:
raise EvalError("filter requires fn and collection") raise EvalError("filter requires fn and collection")
fn = _eval(expr[1], env) fn = _trampoline(_eval(expr[1], env))
coll = _eval(expr[2], env) coll = _trampoline(_eval(expr[2], env))
if not isinstance(fn, Lambda): if not isinstance(fn, Lambda):
raise EvalError(f"filter requires lambda, got {type(fn).__name__}") raise EvalError(f"filter requires lambda, got {type(fn).__name__}")
return [item for item in coll if _call_lambda(fn, [item], env)] return [item for item in coll if _trampoline(_call_lambda(fn, [item], env))]
def _ho_reduce(expr: list, env: dict) -> Any: def _ho_reduce(expr: list, env: dict) -> Any:
if len(expr) != 4: if len(expr) != 4:
raise EvalError("reduce requires fn, init, and collection") raise EvalError("reduce requires fn, init, and collection")
fn = _eval(expr[1], env) fn = _trampoline(_eval(expr[1], env))
acc = _eval(expr[2], env) acc = _trampoline(_eval(expr[2], env))
coll = _eval(expr[3], env) coll = _trampoline(_eval(expr[3], env))
if not isinstance(fn, Lambda): if not isinstance(fn, Lambda):
raise EvalError(f"reduce requires lambda, got {type(fn).__name__}") raise EvalError(f"reduce requires lambda, got {type(fn).__name__}")
for item in coll: for item in coll:
acc = _call_lambda(fn, [acc, item], env) acc = _trampoline(_call_lambda(fn, [acc, item], env))
return acc return acc
def _ho_some(expr: list, env: dict) -> Any: def _ho_some(expr: list, env: dict) -> Any:
if len(expr) != 3: if len(expr) != 3:
raise EvalError("some requires fn and collection") raise EvalError("some requires fn and collection")
fn = _eval(expr[1], env) fn = _trampoline(_eval(expr[1], env))
coll = _eval(expr[2], env) coll = _trampoline(_eval(expr[2], env))
if not isinstance(fn, Lambda): if not isinstance(fn, Lambda):
raise EvalError(f"some requires lambda, got {type(fn).__name__}") raise EvalError(f"some requires lambda, got {type(fn).__name__}")
for item in coll: for item in coll:
result = _call_lambda(fn, [item], env) result = _trampoline(_call_lambda(fn, [item], env))
if result: if result:
return result return result
return NIL return NIL
@@ -877,12 +896,12 @@ def _ho_some(expr: list, env: dict) -> Any:
def _ho_every(expr: list, env: dict) -> bool: def _ho_every(expr: list, env: dict) -> bool:
if len(expr) != 3: if len(expr) != 3:
raise EvalError("every? requires fn and collection") raise EvalError("every? requires fn and collection")
fn = _eval(expr[1], env) fn = _trampoline(_eval(expr[1], env))
coll = _eval(expr[2], env) coll = _trampoline(_eval(expr[2], env))
if not isinstance(fn, Lambda): if not isinstance(fn, Lambda):
raise EvalError(f"every? requires lambda, got {type(fn).__name__}") raise EvalError(f"every? requires lambda, got {type(fn).__name__}")
for item in coll: for item in coll:
if not _call_lambda(fn, [item], env): if not _trampoline(_call_lambda(fn, [item], env)):
return False return False
return True return True
@@ -890,12 +909,12 @@ def _ho_every(expr: list, env: dict) -> bool:
def _ho_for_each(expr: list, env: dict) -> Any: def _ho_for_each(expr: list, env: dict) -> Any:
if len(expr) != 3: if len(expr) != 3:
raise EvalError("for-each requires fn and collection") raise EvalError("for-each requires fn and collection")
fn = _eval(expr[1], env) fn = _trampoline(_eval(expr[1], env))
coll = _eval(expr[2], env) coll = _trampoline(_eval(expr[2], env))
if not isinstance(fn, Lambda): if not isinstance(fn, Lambda):
raise EvalError(f"for-each requires lambda, got {type(fn).__name__}") raise EvalError(f"for-each requires lambda, got {type(fn).__name__}")
for item in coll: for item in coll:
_call_lambda(fn, [item], env) _trampoline(_call_lambda(fn, [item], env))
return NIL return NIL

View File

@@ -69,7 +69,8 @@ def clear_handlers(service: str | None = None) -> None:
def load_handler_file(filepath: str, service_name: str) -> list[HandlerDef]: def load_handler_file(filepath: str, service_name: str) -> list[HandlerDef]:
"""Parse an .sx file, evaluate it, and register any HandlerDef values.""" """Parse an .sx file, evaluate it, and register any HandlerDef values."""
from .parser import parse_all from .parser import parse_all
from .evaluator import _eval from .evaluator import _eval as _raw_eval, _trampoline
_eval = lambda expr, env: _trampoline(_raw_eval(expr, env))
from .jinja_bridge import get_component_env from .jinja_bridge import get_component_env
with open(filepath, encoding="utf-8") as f: with open(filepath, encoding="utf-8") as f:

View File

@@ -28,7 +28,15 @@ import contextvars
from typing import Any from typing import Any
from .types import Component, Keyword, Lambda, Macro, NIL, Symbol from .types import Component, Keyword, Lambda, Macro, NIL, Symbol
from .evaluator import _eval, _call_component, _expand_macro from .evaluator import _eval as _raw_eval, _call_component as _raw_call_component, _expand_macro, _trampoline
def _eval(expr, env):
"""Evaluate and unwrap thunks — all html.py _eval calls are non-tail."""
return _trampoline(_raw_eval(expr, env))
def _call_component(comp, raw_args, env):
"""Call component and unwrap thunks — non-tail in html.py."""
return _trampoline(_raw_call_component(comp, raw_args, env))
# ContextVar for collecting CSS class names during render. # ContextVar for collecting CSS class names during render.
# Set to a set[str] to collect; None to skip. # Set to a set[str] to collect; None to skip.

View File

@@ -169,7 +169,8 @@ def register_components(sx_source: str) -> None:
(div :class "..." (div :class "..." title))))) (div :class "..." (div :class "..." title)))))
''') ''')
""" """
from .evaluator import _eval from .evaluator import _eval as _raw_eval, _trampoline
_eval = lambda expr, env: _trampoline(_raw_eval(expr, env))
from .parser import parse_all from .parser import parse_all
from .css_registry import scan_classes_from_sx from .css_registry import scan_classes_from_sx

View File

@@ -96,7 +96,8 @@ def get_page_helpers(service: str) -> dict[str, Any]:
def load_page_file(filepath: str, service_name: str) -> list[PageDef]: def load_page_file(filepath: str, service_name: str) -> list[PageDef]:
"""Parse an .sx file, evaluate it, and register any PageDef values.""" """Parse an .sx file, evaluate it, and register any PageDef values."""
from .parser import parse_all from .parser import parse_all
from .evaluator import _eval from .evaluator import _eval as _raw_eval, _trampoline
_eval = lambda expr, env: _trampoline(_raw_eval(expr, env))
from .jinja_bridge import get_component_env from .jinja_bridge import get_component_env
with open(filepath, encoding="utf-8") as f: with open(filepath, encoding="utf-8") as f:

View File

@@ -78,7 +78,8 @@ def clear(service: str | None = None) -> None:
def load_query_file(filepath: str, service_name: str) -> list[QueryDef]: def load_query_file(filepath: str, service_name: str) -> list[QueryDef]:
"""Parse an .sx file and register any defquery definitions.""" """Parse an .sx file and register any defquery definitions."""
from .parser import parse_all from .parser import parse_all
from .evaluator import _eval from .evaluator import _eval as _raw_eval, _trampoline
_eval = lambda expr, env: _trampoline(_raw_eval(expr, env))
from .jinja_bridge import get_component_env from .jinja_bridge import get_component_env
with open(filepath, encoding="utf-8") as f: with open(filepath, encoding="utf-8") as f:
@@ -102,7 +103,8 @@ def load_query_file(filepath: str, service_name: str) -> list[QueryDef]:
def load_action_file(filepath: str, service_name: str) -> list[ActionDef]: def load_action_file(filepath: str, service_name: str) -> list[ActionDef]:
"""Parse an .sx file and register any defaction definitions.""" """Parse an .sx file and register any defaction definitions."""
from .parser import parse_all from .parser import parse_all
from .evaluator import _eval from .evaluator import _eval as _raw_eval, _trampoline
_eval = lambda expr, env: _trampoline(_raw_eval(expr, env))
from .jinja_bridge import get_component_env from .jinja_bridge import get_component_env
with open(filepath, encoding="utf-8") as f: with open(filepath, encoding="utf-8") as f:

View File

@@ -31,7 +31,11 @@ import asyncio
from typing import Any from typing import Any
from .types import Component, Keyword, Lambda, NIL, Symbol from .types import Component, Keyword, Lambda, NIL, Symbol
from .evaluator import _eval from .evaluator import _eval as _raw_eval, _trampoline
def _eval(expr, env):
"""Evaluate and unwrap thunks — all resolver.py _eval calls are non-tail."""
return _trampoline(_raw_eval(expr, env))
from .html import render as html_render, _RawHTML from .html import render as html_render, _RawHTML
from .primitives_io import ( from .primitives_io import (
IO_PRIMITIVES, IO_PRIMITIVES,

View File

@@ -5,10 +5,10 @@
(h1 :class "text-5xl font-bold text-stone-900 mb-4" (h1 :class "text-5xl font-bold text-stone-900 mb-4"
(span :class "text-violet-600" "sx")) (span :class "text-violet-600" "sx"))
(p :class "text-2xl text-stone-600 mb-8" (p :class "text-2xl text-stone-600 mb-8"
"High power tools for HTML — with s-expressions") "s-expressions for the web")
(p :class "text-lg text-stone-500 max-w-2xl mx-auto mb-12" (p :class "text-lg text-stone-500 max-w-2xl mx-auto mb-12"
"A hypermedia-driven UI engine that combines htmx's server-first philosophy " "A hypermedia-driven UI engine that combines htmx's server-first philosophy "
"with React's component model. All rendered via s-expressions over the wire.") "with React's component model. S-expressions over the wire — no HTML, no JavaScript frameworks.")
(div :class "bg-stone-50 border border-stone-200 rounded-lg p-6 text-left font-mono text-sm overflow-x-auto" (div :class "bg-stone-50 border border-stone-200 rounded-lg p-6 text-left font-mono text-sm overflow-x-auto"
(pre :class "leading-relaxed" children)))) (pre :class "leading-relaxed" children))))
@@ -41,7 +41,7 @@
(div :class "flex-shrink-0 w-8 h-8 rounded-full bg-violet-100 text-violet-700 flex items-center justify-center font-bold" "1") (div :class "flex-shrink-0 w-8 h-8 rounded-full bg-violet-100 text-violet-700 flex items-center justify-center font-bold" "1")
(div (div
(h3 :class "font-semibold text-stone-900" "Server renders sx") (h3 :class "font-semibold text-stone-900" "Server renders sx")
(p :class "text-stone-600" "Python builds s-expression trees. Components, HTML elements, data — all in one format."))) (p :class "text-stone-600" "Python builds s-expression trees. Components, elements, data — all in one format.")))
(div :class "flex items-start gap-4" (div :class "flex items-start gap-4"
(div :class "flex-shrink-0 w-8 h-8 rounded-full bg-violet-100 text-violet-700 flex items-center justify-center font-bold" "2") (div :class "flex-shrink-0 w-8 h-8 rounded-full bg-violet-100 text-violet-700 flex items-center justify-center font-bold" "2")
(div (div
@@ -60,5 +60,5 @@
(a :href "https://htmx.org" :class "text-violet-600 hover:underline" "htmx") (a :href "https://htmx.org" :class "text-violet-600 hover:underline" "htmx")
" by Carson Gross. This documentation site is modelled on " " by Carson Gross. This documentation site is modelled on "
(a :href "https://four.htmx.org" :class "text-violet-600 hover:underline" "four.htmx.org") (a :href "https://four.htmx.org" :class "text-violet-600 hover:underline" "four.htmx.org")
". htmx showed that HTML is the right hypermedia format. " ". htmx showed that hypermedia belongs on the server. "
"sx takes that idea and wraps it in parentheses."))) "sx takes that idea and wraps it in parentheses.")))

View File

@@ -279,14 +279,15 @@ def _docs_introduction_sx() -> str:
'Components use defcomp with keyword parameters and optional children. ' 'Components use defcomp with keyword parameters and optional children. '
'The evaluator supports let bindings, conditionals, lambda, map/filter/reduce, and ~80 primitives.")' 'The evaluator supports let bindings, conditionals, lambda, map/filter/reduce, and ~80 primitives.")'
' (p :class "text-stone-600"' ' (p :class "text-stone-600"'
' "sx is not trying to replace JavaScript. It\'s trying to replace the pattern of ' ' "sx replaces the pattern of '
'shipping a JS framework + build step + client-side router + state management library ' 'shipping a JS framework + build step + client-side router + state management library '
'just to render some server data into HTML."))' 'just to render some server data. For most applications, sx eliminates the need for '
'JavaScript entirely — htmx attributes handle interactivity, hyperscript handles small behaviours, '
'and the server handles everything else."))'
' (~doc-section :title "What sx is not" :id "not"' ' (~doc-section :title "What sx is not" :id "not"'
' (ul :class "space-y-2 text-stone-600"' ' (ul :class "space-y-2 text-stone-600"'
' (li "Not a general-purpose programming language — it\'s a UI rendering language")' ' (li "Not a general-purpose programming language — it\'s a UI rendering language")'
' (li "Not a Lisp implementation — no macros, no continuations, no tail-call optimization")' ' (li "Not a full Lisp — it has macros and TCO, but no continuations or call/cc")'
' (li "Not a replacement for JavaScript — it handles rendering, not arbitrary DOM manipulation")'
' (li "Not production-hardened at scale — it runs one website"))))' ' (li "Not production-hardened at scale — it runs one website"))))'
) )