Implement delimited continuations (shift/reset) across all evaluators

Bootstrap shift/reset to both Python and JS targets. The implementation
uses exception-based capture with re-evaluation: reset wraps in try/catch
for ShiftSignal, shift raises to the nearest reset, and continuation
invocation pushes a resume value and re-evaluates the body.

- Add Continuation type and _ShiftSignal to shared/sx/types.py
- Add sf_reset/sf_shift to hand-written evaluator.py
- Add async versions to async_eval.py
- Add shift/reset dispatch to eval.sx spec
- Bootstrap to Python: FIXUPS_PY with sf_reset/sf_shift, regenerate sx_ref.py
- Bootstrap to JS: Continuation/ShiftSignal types, sfReset/sfShift in fixups
- Add continuation? primitive to both bootstrappers and primitives.sx
- Allow callables (including Continuation) in hand-written HO map
- 44 unit tests (22 per evaluator) covering: passthrough, abort, invoke,
  double invoke, predicate, stored continuation, nested reset, practical patterns
- Update continuations essay to reflect implemented status with examples

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 00:58:50 +00:00
parent 12fe93bb55
commit 102a27e845
12 changed files with 480 additions and 15 deletions

View File

@@ -484,6 +484,48 @@ _ASYNC_SPECIAL_FORMS: dict[str, Any] = {
}
# ---------------------------------------------------------------------------
# Async delimited continuations — shift / reset
# ---------------------------------------------------------------------------
_ASYNC_RESET_RESUME: list = []
async def _asf_reset(expr, env, ctx):
"""(reset body) — async version."""
from .types import Continuation, _ShiftSignal
body = expr[1]
try:
return await async_eval(body, env, ctx)
except _ShiftSignal as sig:
def cont_fn(value=None):
from .types import NIL
_ASYNC_RESET_RESUME.append(value if value is not None else NIL)
try:
# Sync re-evaluation; the async caller will trampoline
from .evaluator import _eval as sync_eval, _trampoline
return _trampoline(sync_eval(body, env))
finally:
_ASYNC_RESET_RESUME.pop()
k = Continuation(cont_fn)
sig_env = dict(sig.env)
sig_env[sig.k_name] = k
return await async_eval(sig.body, sig_env, ctx)
async def _asf_shift(expr, env, ctx):
"""(shift k body) — async version."""
from .types import _ShiftSignal
if _ASYNC_RESET_RESUME:
return _ASYNC_RESET_RESUME[-1]
k_name = expr[1].name
body = expr[2]
raise _ShiftSignal(k_name, body, env)
_ASYNC_SPECIAL_FORMS["reset"] = _asf_reset
_ASYNC_SPECIAL_FORMS["shift"] = _asf_shift
# ---------------------------------------------------------------------------
# Async higher-order forms
# ---------------------------------------------------------------------------

View File

@@ -33,7 +33,7 @@ from __future__ import annotations
from typing import Any
from .types import Component, HandlerDef, Keyword, Lambda, Macro, NIL, PageDef, RelationDef, Symbol
from .types import Component, Continuation, HandlerDef, Keyword, Lambda, Macro, NIL, PageDef, RelationDef, Symbol, _ShiftSignal
from .primitives import _PRIMITIVES
@@ -874,6 +874,42 @@ def _sf_defpage(expr: list, env: dict) -> PageDef:
return page
# ---------------------------------------------------------------------------
# Delimited continuations — shift / reset
# ---------------------------------------------------------------------------
_RESET_RESUME = [] # stack of resume values; empty = not resuming
_RESET_SENTINEL = object()
def _sf_reset(expr, env):
"""(reset body) — establish a continuation delimiter."""
body = expr[1]
try:
return _trampoline(_eval(body, env))
except _ShiftSignal as sig:
def cont_fn(value=NIL):
_RESET_RESUME.append(value)
try:
return _trampoline(_eval(body, env))
finally:
_RESET_RESUME.pop()
k = Continuation(cont_fn)
sig_env = dict(sig.env)
sig_env[sig.k_name] = k
return _trampoline(_eval(sig.body, sig_env))
def _sf_shift(expr, env):
"""(shift k body) — capture continuation to nearest reset."""
if _RESET_RESUME:
return _RESET_RESUME[-1]
k_name = expr[1].name # symbol
body = expr[2]
raise _ShiftSignal(k_name, body, env)
_SPECIAL_FORMS: dict[str, Any] = {
"if": _sf_if,
"when": _sf_when,
@@ -901,6 +937,8 @@ _SPECIAL_FORMS: dict[str, Any] = {
"defpage": _sf_defpage,
"defquery": _sf_defquery,
"defaction": _sf_defaction,
"reset": _sf_reset,
"shift": _sf_shift,
}
@@ -913,9 +951,11 @@ def _ho_map(expr: list, env: dict) -> list:
raise EvalError("map requires fn and collection")
fn = _trampoline(_eval(expr[1], env))
coll = _trampoline(_eval(expr[2], env))
if not isinstance(fn, Lambda):
raise EvalError(f"map requires lambda, got {type(fn).__name__}")
return [_trampoline(_call_lambda(fn, [item], env)) for item in coll]
if isinstance(fn, Lambda):
return [_trampoline(_call_lambda(fn, [item], env)) for item in coll]
if callable(fn):
return [fn(item) for item in coll]
raise EvalError(f"map requires lambda, got {type(fn).__name__}")
def _ho_map_indexed(expr: list, env: dict) -> list:

View File

@@ -187,6 +187,11 @@ def prim_is_list(x: Any) -> bool:
def prim_is_dict(x: Any) -> bool:
return isinstance(x, dict)
@register_primitive("continuation?")
def prim_is_continuation(x: Any) -> bool:
from .types import Continuation
return isinstance(x, Continuation)
@register_primitive("empty?")
def prim_is_empty(coll: Any) -> bool:
if coll is None or coll is NIL:

View File

@@ -174,6 +174,8 @@ class JSEmitter:
"sf-quasiquote": "sfQuasiquote",
"sf-thread-first": "sfThreadFirst",
"sf-set!": "sfSetBang",
"sf-reset": "sfReset",
"sf-shift": "sfShift",
"qq-expand": "qqExpand",
"ho-map": "hoMap",
"ho-map-indexed": "hoMapIndexed",
@@ -1162,6 +1164,16 @@ PREAMBLE = '''\
}
StyleValue.prototype._styleValue = true;
function Continuation(fn) { this.fn = fn; }
Continuation.prototype._continuation = true;
Continuation.prototype.call = function(value) { return this.fn(value !== undefined ? value : NIL); };
function ShiftSignal(kName, body, env) {
this.kName = kName;
this.body = body;
this.env = env;
}
function isSym(x) { return x != null && x._sym === true; }
function isKw(x) { return x != null && x._kw === true; }
@@ -1199,6 +1211,7 @@ PLATFORM_JS = '''
if (x._macro) return "macro";
if (x._raw) return "raw-html";
if (x._styleValue) return "style-value";
if (x._continuation) return "continuation";
if (typeof Node !== "undefined" && x instanceof Node) return "dom-node";
if (Array.isArray(x)) return "list";
if (typeof x === "object") return "dict";
@@ -1370,6 +1383,7 @@ PLATFORM_JS = '''
PRIMITIVES["string?"] = function(x) { return typeof x === "string"; };
PRIMITIVES["list?"] = Array.isArray;
PRIMITIVES["dict?"] = function(x) { return x !== null && typeof x === "object" && !Array.isArray(x) && !x._sym && !x._kw; };
PRIMITIVES["continuation?"] = function(x) { return x != null && x._continuation === true; };
PRIMITIVES["empty?"] = function(c) { return isNil(c) || (Array.isArray(c) ? c.length === 0 : typeof c === "string" ? c.length === 0 : Object.keys(c).length === 0); };
PRIMITIVES["contains?"] = function(c, k) {
if (typeof c === "string") return c.indexOf(String(k)) !== -1;
@@ -1559,7 +1573,7 @@ PLATFORM_JS = '''
"if":1,"when":1,"cond":1,"case":1,"and":1,"or":1,"let":1,"let*":1,
"lambda":1,"fn":1,"define":1,"defcomp":1,"defmacro":1,"defstyle":1,
"defkeyframes":1,"defhandler":1,"begin":1,"do":1,
"quote":1,"quasiquote":1,"->":1,"set!":1
"quote":1,"quasiquote":1,"->":1,"set!":1,"reset":1,"shift":1
}; }
function isHoForm(n) { return n in {
"map":1,"map-indexed":1,"filter":1,"reduce":1,"some":1,"every?":1,"for-each":1
@@ -2639,6 +2653,44 @@ def fixups_js(has_html, has_sx, has_dom):
return _rawCallLambda(f, args, callerEnv);
};
// =========================================================================
// Delimited continuations (shift/reset)
// =========================================================================
var _resetResume = []; // stack of resume values
function sfReset(args, env) {
var body = args[0];
try {
return trampoline(evalExpr(body, env));
} catch (e) {
if (e instanceof ShiftSignal) {
var sig = e;
var cont = new Continuation(function(value) {
if (value === undefined) value = NIL;
_resetResume.push(value);
try {
return trampoline(evalExpr(body, env));
} finally {
_resetResume.pop();
}
});
var sigEnv = merge(sig.env);
sigEnv[sig.kName] = cont;
return trampoline(evalExpr(sig.body, sigEnv));
}
throw e;
}
}
function sfShift(args, env) {
if (_resetResume.length > 0) {
return _resetResume[_resetResume.length - 1];
}
var kName = symbolName(args[0]);
var body = args[1];
throw new ShiftSignal(kName, body, env);
}
// Expose render functions as primitives so SX code can call them''']
if has_html:
lines.append(' if (typeof renderToHtml === "function") PRIMITIVES["render-to-html"] = renderToHtml;')

View File

@@ -186,6 +186,8 @@ class PyEmitter:
"sf-quasiquote": "sf_quasiquote",
"sf-thread-first": "sf_thread_first",
"sf-set!": "sf_set_bang",
"sf-reset": "sf_reset",
"sf-shift": "sf_shift",
"qq-expand": "qq_expand",
"ho-map": "ho_map",
"ho-map-indexed": "ho_map_indexed",
@@ -887,8 +889,8 @@ from typing import Any
# =========================================================================
from shared.sx.types import (
NIL, Symbol, Keyword, Lambda, Component, Macro, StyleValue,
HandlerDef, QueryDef, ActionDef, PageDef,
NIL, Symbol, Keyword, Lambda, Component, Continuation, Macro, StyleValue,
HandlerDef, QueryDef, ActionDef, PageDef, _ShiftSignal,
)
from shared.sx.parser import SxExpr
'''
@@ -998,6 +1000,8 @@ def type_of(x):
return "raw-html"
if isinstance(x, StyleValue):
return "style-value"
if isinstance(x, Continuation):
return "continuation"
if isinstance(x, list):
return "list"
if isinstance(x, dict):
@@ -1338,7 +1342,7 @@ _SPECIAL_FORM_NAMES = frozenset([
"define", "defcomp", "defmacro", "defstyle", "defkeyframes",
"defhandler", "defpage", "defquery", "defaction", "defrelation",
"begin", "do", "quote", "quasiquote",
"->", "set!",
"->", "set!", "reset", "shift",
])
_HO_FORM_NAMES = frozenset([
@@ -1501,6 +1505,11 @@ def aser_special(name, expr, env):
"defhandler", "defpage", "defquery", "defaction", "defrelation"):
trampoline(eval_expr(expr, env))
return NIL
# reset/shift — evaluate normally in aser mode (they're control flow)
if name == "reset":
return sf_reset(args, env)
if name == "shift":
return sf_shift(args, env)
# Lambda/fn, quote, quasiquote, set!, -> : evaluate normally
result = eval_expr(expr, env)
return trampoline(result)
@@ -1587,6 +1596,7 @@ PRIMITIVES["number?"] = lambda x: isinstance(x, (int, float)) and not isinstance
PRIMITIVES["string?"] = lambda x: isinstance(x, str)
PRIMITIVES["list?"] = lambda x: isinstance(x, _b_list)
PRIMITIVES["dict?"] = lambda x: isinstance(x, _b_dict)
PRIMITIVES["continuation?"] = lambda x: isinstance(x, Continuation)
PRIMITIVES["empty?"] = lambda c: (
c is None or c is NIL or
(isinstance(c, (_b_list, str, _b_dict)) and _b_len(c) == 0)
@@ -1725,6 +1735,38 @@ concat = PRIMITIVES["concat"]
'''
FIXUPS_PY = '''
# =========================================================================
# Delimited continuations (shift/reset)
# =========================================================================
_RESET_RESUME = [] # stack of resume values; empty = not resuming
def sf_reset(args, env):
"""(reset body) -- establish a continuation delimiter."""
body = first(args)
try:
return trampoline(eval_expr(body, env))
except _ShiftSignal as sig:
def cont_fn(value=NIL):
_RESET_RESUME.append(value)
try:
return trampoline(eval_expr(body, env))
finally:
_RESET_RESUME.pop()
k = Continuation(cont_fn)
sig_env = dict(sig.env)
sig_env[sig.k_name] = k
return trampoline(eval_expr(sig.body, sig_env))
def sf_shift(args, env):
"""(shift k body) -- capture continuation to nearest reset."""
if _RESET_RESUME:
return _RESET_RESUME[-1]
k_name = symbol_name(first(args))
body = nth(args, 1)
raise _ShiftSignal(k_name, body, env)
# =========================================================================
# Fixups -- wire up render adapter dispatch
# =========================================================================

View File

@@ -153,6 +153,8 @@
(= name "quasiquote") (sf-quasiquote args env)
(= name "->") (sf-thread-first args env)
(= name "set!") (sf-set! args env)
(= name "reset") (sf-reset args env)
(= name "shift") (sf-shift args env)
;; Higher-order forms
(= name "map") (ho-map args env)

View File

@@ -197,6 +197,11 @@
:returns "boolean"
:doc "True if x is a dict/map.")
(define-primitive "continuation?"
:params (x)
:returns "boolean"
:doc "True if x is a captured continuation.")
(define-primitive "empty?"
:params (coll)
:returns "boolean"

View File

@@ -17,8 +17,8 @@ from typing import Any
# =========================================================================
from shared.sx.types import (
NIL, Symbol, Keyword, Lambda, Component, Macro, StyleValue,
HandlerDef, QueryDef, ActionDef, PageDef,
NIL, Symbol, Keyword, Lambda, Component, Continuation, Macro, StyleValue,
HandlerDef, QueryDef, ActionDef, PageDef, _ShiftSignal,
)
from shared.sx.parser import SxExpr
@@ -127,6 +127,8 @@ def type_of(x):
return "raw-html"
if isinstance(x, StyleValue):
return "style-value"
if isinstance(x, Continuation):
return "continuation"
if isinstance(x, list):
return "list"
if isinstance(x, dict):
@@ -467,7 +469,7 @@ _SPECIAL_FORM_NAMES = frozenset([
"define", "defcomp", "defmacro", "defstyle", "defkeyframes",
"defhandler", "defpage", "defquery", "defaction", "defrelation",
"begin", "do", "quote", "quasiquote",
"->", "set!",
"->", "set!", "reset", "shift",
])
_HO_FORM_NAMES = frozenset([
@@ -630,6 +632,11 @@ def aser_special(name, expr, env):
"defhandler", "defpage", "defquery", "defaction", "defrelation"):
trampoline(eval_expr(expr, env))
return NIL
# reset/shift — evaluate normally in aser mode (they're control flow)
if name == "reset":
return sf_reset(args, env)
if name == "shift":
return sf_shift(args, env)
# Lambda/fn, quote, quasiquote, set!, -> : evaluate normally
result = eval_expr(expr, env)
return trampoline(result)
@@ -715,6 +722,7 @@ PRIMITIVES["number?"] = lambda x: isinstance(x, (int, float)) and not isinstance
PRIMITIVES["string?"] = lambda x: isinstance(x, str)
PRIMITIVES["list?"] = lambda x: isinstance(x, _b_list)
PRIMITIVES["dict?"] = lambda x: isinstance(x, _b_dict)
PRIMITIVES["continuation?"] = lambda x: isinstance(x, Continuation)
PRIMITIVES["empty?"] = lambda c: (
c is None or c is NIL or
(isinstance(c, (_b_list, str, _b_dict)) and _b_len(c) == 0)
@@ -861,7 +869,7 @@ trampoline = lambda val: (lambda result: (trampoline(eval_expr(thunk_expr(result
eval_expr = lambda expr, env: _sx_case(type_of(expr), [('number', lambda: expr), ('string', lambda: expr), ('boolean', lambda: expr), ('nil', lambda: NIL), ('symbol', lambda: (lambda name: (env_get(env, name) if sx_truthy(env_has(env, name)) else (get_primitive(name) if sx_truthy(is_primitive(name)) else (True if sx_truthy((name == 'true')) else (False if sx_truthy((name == 'false')) else (NIL if sx_truthy((name == 'nil')) else error(sx_str('Undefined symbol: ', name))))))))(symbol_name(expr))), ('keyword', lambda: keyword_name(expr)), ('dict', lambda: map_dict(lambda k, v: trampoline(eval_expr(v, env)), expr)), ('list', lambda: ([] if sx_truthy(empty_p(expr)) else eval_list(expr, env))), (None, lambda: expr)])
# eval-list
eval_list = lambda expr, env: (lambda head: (lambda args: (map(lambda x: trampoline(eval_expr(x, env)), expr) if sx_truthy((not sx_truthy(((type_of(head) == 'symbol') if sx_truthy((type_of(head) == 'symbol')) else ((type_of(head) == 'lambda') if sx_truthy((type_of(head) == 'lambda')) else (type_of(head) == 'list')))))) else ((lambda name: (sf_if(args, env) if sx_truthy((name == 'if')) else (sf_when(args, env) if sx_truthy((name == 'when')) else (sf_cond(args, env) if sx_truthy((name == 'cond')) else (sf_case(args, env) if sx_truthy((name == 'case')) else (sf_and(args, env) if sx_truthy((name == 'and')) else (sf_or(args, env) if sx_truthy((name == 'or')) else (sf_let(args, env) if sx_truthy((name == 'let')) else (sf_let(args, env) if sx_truthy((name == 'let*')) else (sf_lambda(args, env) if sx_truthy((name == 'lambda')) else (sf_lambda(args, env) if sx_truthy((name == 'fn')) else (sf_define(args, env) if sx_truthy((name == 'define')) else (sf_defcomp(args, env) if sx_truthy((name == 'defcomp')) else (sf_defmacro(args, env) if sx_truthy((name == 'defmacro')) else (sf_defstyle(args, env) if sx_truthy((name == 'defstyle')) else (sf_defkeyframes(args, env) if sx_truthy((name == 'defkeyframes')) else (sf_defhandler(args, env) if sx_truthy((name == 'defhandler')) else (sf_defpage(args, env) if sx_truthy((name == 'defpage')) else (sf_defquery(args, env) if sx_truthy((name == 'defquery')) else (sf_defaction(args, env) if sx_truthy((name == 'defaction')) else (sf_begin(args, env) if sx_truthy((name == 'begin')) else (sf_begin(args, env) if sx_truthy((name == 'do')) else (sf_quote(args, env) if sx_truthy((name == 'quote')) else (sf_quasiquote(args, env) if sx_truthy((name == 'quasiquote')) else (sf_thread_first(args, env) if sx_truthy((name == '->')) else (sf_set_bang(args, env) if sx_truthy((name == 'set!')) else (ho_map(args, env) if sx_truthy((name == 'map')) else (ho_map_indexed(args, env) if sx_truthy((name == 'map-indexed')) else (ho_filter(args, env) if sx_truthy((name == 'filter')) else (ho_reduce(args, env) if sx_truthy((name == 'reduce')) else (ho_some(args, env) if sx_truthy((name == 'some')) else (ho_every(args, env) if sx_truthy((name == 'every?')) else (ho_for_each(args, env) if sx_truthy((name == 'for-each')) else ((lambda mac: make_thunk(expand_macro(mac, args, env), env))(env_get(env, name)) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else (render_expr(expr, env) if sx_truthy(is_render_expr(expr)) else eval_call(head, args, env))))))))))))))))))))))))))))))))))))(symbol_name(head)) if sx_truthy((type_of(head) == 'symbol')) else eval_call(head, args, env))))(rest(expr)))(first(expr))
eval_list = lambda expr, env: (lambda head: (lambda args: (map(lambda x: trampoline(eval_expr(x, env)), expr) if sx_truthy((not sx_truthy(((type_of(head) == 'symbol') if sx_truthy((type_of(head) == 'symbol')) else ((type_of(head) == 'lambda') if sx_truthy((type_of(head) == 'lambda')) else (type_of(head) == 'list')))))) else ((lambda name: (sf_if(args, env) if sx_truthy((name == 'if')) else (sf_when(args, env) if sx_truthy((name == 'when')) else (sf_cond(args, env) if sx_truthy((name == 'cond')) else (sf_case(args, env) if sx_truthy((name == 'case')) else (sf_and(args, env) if sx_truthy((name == 'and')) else (sf_or(args, env) if sx_truthy((name == 'or')) else (sf_let(args, env) if sx_truthy((name == 'let')) else (sf_let(args, env) if sx_truthy((name == 'let*')) else (sf_lambda(args, env) if sx_truthy((name == 'lambda')) else (sf_lambda(args, env) if sx_truthy((name == 'fn')) else (sf_define(args, env) if sx_truthy((name == 'define')) else (sf_defcomp(args, env) if sx_truthy((name == 'defcomp')) else (sf_defmacro(args, env) if sx_truthy((name == 'defmacro')) else (sf_defstyle(args, env) if sx_truthy((name == 'defstyle')) else (sf_defkeyframes(args, env) if sx_truthy((name == 'defkeyframes')) else (sf_defhandler(args, env) if sx_truthy((name == 'defhandler')) else (sf_defpage(args, env) if sx_truthy((name == 'defpage')) else (sf_defquery(args, env) if sx_truthy((name == 'defquery')) else (sf_defaction(args, env) if sx_truthy((name == 'defaction')) else (sf_begin(args, env) if sx_truthy((name == 'begin')) else (sf_begin(args, env) if sx_truthy((name == 'do')) else (sf_quote(args, env) if sx_truthy((name == 'quote')) else (sf_quasiquote(args, env) if sx_truthy((name == 'quasiquote')) else (sf_thread_first(args, env) if sx_truthy((name == '->')) else (sf_set_bang(args, env) if sx_truthy((name == 'set!')) else (sf_reset(args, env) if sx_truthy((name == 'reset')) else (sf_shift(args, env) if sx_truthy((name == 'shift')) else (ho_map(args, env) if sx_truthy((name == 'map')) else (ho_map_indexed(args, env) if sx_truthy((name == 'map-indexed')) else (ho_filter(args, env) if sx_truthy((name == 'filter')) else (ho_reduce(args, env) if sx_truthy((name == 'reduce')) else (ho_some(args, env) if sx_truthy((name == 'some')) else (ho_every(args, env) if sx_truthy((name == 'every?')) else (ho_for_each(args, env) if sx_truthy((name == 'for-each')) else ((lambda mac: make_thunk(expand_macro(mac, args, env), env))(env_get(env, name)) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else (render_expr(expr, env) if sx_truthy(is_render_expr(expr)) else eval_call(head, args, env))))))))))))))))))))))))))))))))))))))(symbol_name(head)) if sx_truthy((type_of(head) == 'symbol')) else eval_call(head, args, env))))(rest(expr)))(first(expr))
# eval-call
eval_call = lambda head, args, env: (lambda f: (lambda evaluated_args: (apply(f, evaluated_args) if sx_truthy((is_callable(f) if not sx_truthy(is_callable(f)) else ((not sx_truthy(is_lambda(f))) if not sx_truthy((not sx_truthy(is_lambda(f)))) else (not sx_truthy(is_component(f)))))) else (call_lambda(f, evaluated_args, env) if sx_truthy(is_lambda(f)) else (call_component(f, args, env) if sx_truthy(is_component(f)) else error(sx_str('Not callable: ', inspect(f)))))))(map(lambda a: trampoline(eval_expr(a, env)), args)))(trampoline(eval_expr(head, env)))
@@ -1100,6 +1108,38 @@ aser_fragment = lambda children, env: (lambda parts: ('' if sx_truthy(empty_p(pa
aser_call = lambda name, args, env: (lambda parts: _sx_begin(reduce(lambda state, arg: (lambda skip: (assoc(state, 'skip', False, 'i', (get(state, 'i') + 1)) if sx_truthy(skip) else ((lambda val: _sx_begin((_sx_begin(_sx_append(parts, sx_str(':', keyword_name(arg))), _sx_append(parts, serialize(val))) if sx_truthy((not sx_truthy(is_nil(val)))) else NIL), assoc(state, 'skip', True, 'i', (get(state, 'i') + 1))))(aser(nth(args, (get(state, 'i') + 1)), env)) if sx_truthy(((type_of(arg) == 'keyword') if not sx_truthy((type_of(arg) == 'keyword')) else ((get(state, 'i') + 1) < len(args)))) else (lambda val: _sx_begin((_sx_append(parts, serialize(val)) if sx_truthy((not sx_truthy(is_nil(val)))) else NIL), assoc(state, 'i', (get(state, 'i') + 1))))(aser(arg, env)))))(get(state, 'skip')), {'i': 0, 'skip': False}, args), sx_str('(', join(' ', parts), ')')))([name])
# =========================================================================
# Delimited continuations (shift/reset)
# =========================================================================
_RESET_RESUME = [] # stack of resume values; empty = not resuming
def sf_reset(args, env):
"""(reset body) -- establish a continuation delimiter."""
body = first(args)
try:
return trampoline(eval_expr(body, env))
except _ShiftSignal as sig:
def cont_fn(value=NIL):
_RESET_RESUME.append(value)
try:
return trampoline(eval_expr(body, env))
finally:
_RESET_RESUME.pop()
k = Continuation(cont_fn)
sig_env = dict(sig.env)
sig_env[sig.k_name] = k
return trampoline(eval_expr(sig.body, sig_env))
def sf_shift(args, env):
"""(shift k body) -- capture continuation to nearest reset."""
if _RESET_RESUME:
return _RESET_RESUME[-1]
k_name = symbol_name(first(args))
body = nth(args, 1)
raise _ShiftSignal(k_name, body, env)
# =========================================================================
# Fixups -- wire up render adapter dispatch
# =========================================================================

View File

@@ -0,0 +1,201 @@
"""Tests for delimited continuations (shift/reset).
Tests run against both the hand-written evaluator and the transpiled
sx_ref evaluator to verify both implementations match.
"""
import pytest
from shared.sx import parse, evaluate, EvalError, NIL
from shared.sx.types import Continuation
from shared.sx.ref import sx_ref
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def ev(text, env=None):
"""Parse and evaluate via hand-written evaluator."""
return evaluate(parse(text), env)
def ev_ref(text, env=None):
"""Parse and evaluate via transpiled sx_ref."""
return sx_ref.evaluate(parse(text), env)
EVALUATORS = [
pytest.param(ev, id="hand-written"),
pytest.param(ev_ref, id="sx_ref"),
]
# ---------------------------------------------------------------------------
# Basic shift/reset
# ---------------------------------------------------------------------------
class TestBasicReset:
"""Reset without shift is a no-op wrapper."""
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_reset_passthrough(self, evaluate):
assert evaluate("(reset 42)") == 42
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_reset_expression(self, evaluate):
assert evaluate("(reset (+ 1 2))") == 3
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_reset_with_let(self, evaluate):
assert evaluate("(reset (let (x 10) (+ x 5)))") == 15
class TestShiftAbort:
"""Shift without invoking k aborts to the reset boundary."""
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_abort_returns_shift_body(self, evaluate):
# (reset (+ 1 (shift k 42))) → shift body 42 is returned, + 1 is abandoned
assert evaluate("(reset (+ 1 (shift k 42)))") == 42
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_abort_string(self, evaluate):
assert evaluate('(reset (+ 1 (shift k "aborted")))') == "aborted"
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_abort_with_computation(self, evaluate):
assert evaluate("(reset (+ 1 (shift k (* 6 7))))") == 42
class TestContinuationInvoke:
"""Invoking the captured continuation re-enters the reset body."""
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_invoke_once(self, evaluate):
# (reset (+ 1 (shift k (k 10)))) → k resumes with 10, so + 1 10 = 11
assert evaluate("(reset (+ 1 (shift k (k 10))))") == 11
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_invoke_with_zero(self, evaluate):
assert evaluate("(reset (+ 1 (shift k (k 0))))") == 1
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_invoke_twice(self, evaluate):
# k invoked twice: (+ (k 1) (k 10)) → (+ 1 1) + ... → (+ 2 11) = 13
# Actually: (k 1) re-evaluates (+ 1 <shift>) where shift returns 1 → 2
# Then (k 10) re-evaluates (+ 1 <shift>) where shift returns 10 → 11
# Then (+ 2 11) = 13
assert evaluate("(reset (+ 1 (shift k (+ (k 1) (k 10)))))") == 13
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_invoke_transforms_value(self, evaluate):
# k wraps: (reset (* 2 (shift k (k (k 3)))))
# k(3) → (* 2 3) = 6, k(6) → (* 2 6) = 12
assert evaluate("(reset (* 2 (shift k (k (k 3)))))") == 12
class TestContinuationPredicate:
"""The continuation? predicate identifies captured continuations."""
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_continuation_is_true(self, evaluate):
result = evaluate("(reset (shift k (continuation? k)))")
assert result is True
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_non_continuation(self, evaluate):
assert evaluate("(continuation? 42)") is False
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_nil_not_continuation(self, evaluate):
assert evaluate("(continuation? nil)") is False
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_lambda_not_continuation(self, evaluate):
assert evaluate("(continuation? (fn (x) x))") is False
class TestStoredContinuation:
"""Continuations can be stored and invoked later."""
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_stored_in_variable(self, evaluate):
code = """
(let (saved nil)
(reset (+ 1 (shift k (do (set! saved k) "captured"))))
)
"""
# The reset returns "captured" (abort path)
assert evaluate(code) == "captured"
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_continuation_type(self, evaluate):
"""Verify that a captured continuation is identified by continuation?."""
code = '(reset (shift k (continuation? k)))'
result = evaluate(code)
assert result is True
class TestNestedReset:
"""Nested reset blocks delimit independently."""
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_inner_reset(self, evaluate):
code = "(reset (+ 1 (reset (+ 2 (shift k (k 10))))))"
# Inner reset: (+ 2 (shift k (k 10))) → k(10) → (+ 2 10) = 12
# Outer reset: (+ 1 12) = 13
assert evaluate(code) == 13
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_inner_abort_outer_continues(self, evaluate):
code = "(reset (+ 1 (reset (shift k 99))))"
# Inner reset aborts with 99
# Outer reset: (+ 1 99) = 100
assert evaluate(code) == 100
class TestPracticalPatterns:
"""Practical uses of delimited continuations."""
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_early_return(self, evaluate):
"""Shift without invoking k acts as early return."""
code = """
(reset
(let (x 5)
(if (> x 3)
(shift k "too big")
(* x x))))
"""
assert evaluate(code) == "too big"
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_normal_path(self, evaluate):
"""When condition doesn't trigger shift, normal result."""
code = """
(reset
(let (x 2)
(if (> x 3)
(shift k "too big")
(* x x))))
"""
assert evaluate(code) == 4
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_continuation_as_function(self, evaluate):
"""Map over a continuation to apply it to multiple values."""
code = """
(reset
(+ 10 (shift k
(map k (list 1 2 3)))))
"""
result = evaluate(code)
assert result == [11, 12, 13]
@pytest.mark.parametrize("evaluate", EVALUATORS)
def test_default_value(self, evaluate):
"""Calling k with no args passes NIL."""
code = '(reset (shift k (nil? (k))))'
# k() passes NIL, reset body re-evals: (shift k ...) returns NIL
# Then the outer shift body checks: (nil? NIL) = true
assert evaluate(code) is True

View File

@@ -302,9 +302,45 @@ class StyleValue:
return self.class_name
# ---------------------------------------------------------------------------
# Continuation
# ---------------------------------------------------------------------------
class Continuation:
"""A captured delimited continuation (shift/reset).
Callable with one argument — provides the value that the shift
expression "returns" within the delimited context.
"""
__slots__ = ("fn",)
def __init__(self, fn):
self.fn = fn
def __call__(self, value=NIL):
return self.fn(value)
def __repr__(self):
return "<continuation>"
class _ShiftSignal(BaseException):
"""Raised by shift to unwind to the nearest reset.
Inherits from BaseException (not Exception) to avoid being caught
by generic except clauses in user code.
"""
__slots__ = ("k_name", "body", "env")
def __init__(self, k_name, body, env):
self.k_name = k_name
self.body = body
self.env = env
# ---------------------------------------------------------------------------
# Type alias
# ---------------------------------------------------------------------------
# An s-expression value after evaluation
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | HandlerDef | RelationDef | PageDef | QueryDef | ActionDef | StyleValue | list | dict | _Nil | None
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | Continuation | HandlerDef | RelationDef | PageDef | QueryDef | ActionDef | StyleValue | list | dict | _Nil | None