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:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;')
|
||||
|
||||
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
201
shared/sx/tests/test_continuations.py
Normal file
201
shared/sx/tests/test_continuations.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user