Make continuations an optional extension, add special-forms.sx, ellipsis parsing

- Both bootstrappers (JS + Python) now gate shift/reset behind --extensions
  continuations flag. Without it, using reset/shift errors at runtime.
- JS bootstrapper: extracted Continuation/ShiftSignal types, sfReset/sfShift,
  continuation? primitive, and typeOf handling into CONTINUATIONS_JS constant.
  Extension wraps evalList, aserSpecial, and typeOf post-transpilation.
- Python bootstrapper: added special-forms.sx validation cross-check against
  eval.sx dispatch, warns on mismatches.
- Added shared/sx/ref/special-forms.sx: 36 declarative form specs with syntax,
  docs, tail-position, and examples. Used by bootstrappers for validation.
- Added ellipsis (...) support to both parser.py and parser.sx spec.
- Updated continuations essay to reflect optional extension architecture.
- Updated specs page and nav with special-forms.sx entry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 01:44:50 +00:00
parent 31ace8768e
commit ca8de3be1a
9 changed files with 799 additions and 138 deletions

View File

@@ -803,13 +803,110 @@ ADAPTER_FILES = {
}
def compile_ref_to_py(adapters: list[str] | None = None) -> str:
EXTENSION_NAMES = {"continuations"}
# Extension-provided special forms (not in eval.sx core)
EXTENSION_FORMS = {
"continuations": {"reset", "shift"},
}
def _parse_special_forms_spec(ref_dir: str) -> set[str]:
"""Parse special-forms.sx to extract declared form names."""
filepath = os.path.join(ref_dir, "special-forms.sx")
if not os.path.exists(filepath):
return set()
with open(filepath) as f:
src = f.read()
names = set()
for expr in parse_all(src):
if (isinstance(expr, list) and len(expr) >= 2
and isinstance(expr[0], Symbol)
and expr[0].name == "define-special-form"
and isinstance(expr[1], str)):
names.add(expr[1])
return names
def _extract_eval_dispatch_names(all_sections: list) -> set[str]:
"""Extract special form names dispatched in eval-list from transpiled sections."""
names = set()
for _label, defines in all_sections:
for name, _expr in defines:
# sf-* functions correspond to dispatched special forms
if name.startswith("sf-"):
# sf-if → if, sf-set! → set!, sf-named-let → named-let
form = name[3:]
# Map back: sf_cond_scheme etc. are internal, skip
if form in ("cond-scheme", "cond-clojure", "case-loop"):
continue
names.add(form)
if name.startswith("ho-"):
form = name[3:]
names.add(form)
return names
def _validate_special_forms(ref_dir: str, all_sections: list,
has_continuations: bool) -> None:
"""Cross-check special-forms.sx against eval.sx dispatch. Warn on mismatches."""
spec_names = _parse_special_forms_spec(ref_dir)
if not spec_names:
return # no spec file, skip validation
# Collect what eval.sx dispatches
dispatch_names = _extract_eval_dispatch_names(all_sections)
# Add extension forms if enabled
if has_continuations:
dispatch_names |= EXTENSION_FORMS["continuations"]
# Normalize: eval.sx sf-* names don't always match form names directly
# sf-thread-first → ->, sf-named-let is internal, ho-every → every?
name_aliases = {
"thread-first": "->",
"every": "every?",
"set-bang": "set!",
}
normalized_dispatch = set()
for n in dispatch_names:
normalized_dispatch.add(name_aliases.get(n, n))
# Internal helpers that aren't user-facing forms
internal = {"named-let"}
normalized_dispatch -= internal
# Forms in spec but not dispatched
undispatched = spec_names - normalized_dispatch
# Ignore aliases and domain forms that are handled differently
ignore = {"fn", "let*", "do", "defrelation"}
undispatched -= ignore
# Forms dispatched but not in spec
unspecced = normalized_dispatch - spec_names
unspecced -= ignore
if undispatched:
import sys
print(f"# WARNING: special-forms.sx declares forms not in eval.sx: "
f"{', '.join(sorted(undispatched))}", file=sys.stderr)
if unspecced:
import sys
print(f"# WARNING: eval.sx dispatches forms not in special-forms.sx: "
f"{', '.join(sorted(unspecced))}", file=sys.stderr)
def compile_ref_to_py(adapters: list[str] | None = None,
extensions: list[str] | None = None) -> str:
"""Read reference .sx files and emit Python.
Args:
adapters: List of adapter names to include.
Valid names: html, sx.
None = include all server-side adapters.
extensions: List of optional extensions to include.
Valid names: continuations.
None = no extensions.
"""
ref_dir = os.path.dirname(os.path.abspath(__file__))
emitter = PyEmitter()
@@ -844,6 +941,18 @@ def compile_ref_to_py(adapters: list[str] | None = None) -> str:
defines = extract_defines(src)
all_sections.append((label, defines))
# Resolve extensions
ext_set = set()
if extensions:
for e in extensions:
if e not in EXTENSION_NAMES:
raise ValueError(f"Unknown extension: {e!r}. Valid: {', '.join(EXTENSION_NAMES)}")
ext_set.add(e)
has_continuations = "continuations" in ext_set
# Validate special forms
_validate_special_forms(ref_dir, all_sections, has_continuations)
# Build output
has_html = "html" in adapter_set
has_sx = "sx" in adapter_set
@@ -861,6 +970,8 @@ def compile_ref_to_py(adapters: list[str] | None = None) -> str:
parts.append("")
parts.append(FIXUPS_PY)
if has_continuations:
parts.append(CONTINUATIONS_PY)
parts.append(public_api_py(has_html, has_sx))
return "\n".join(parts)
@@ -1342,7 +1453,7 @@ _SPECIAL_FORM_NAMES = frozenset([
"define", "defcomp", "defmacro", "defstyle", "defkeyframes",
"defhandler", "defpage", "defquery", "defaction", "defrelation",
"begin", "do", "quote", "quasiquote",
"->", "set!", "reset", "shift",
"->", "set!",
])
_HO_FORM_NAMES = frozenset([
@@ -1505,11 +1616,6 @@ 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)
@@ -1735,38 +1841,6 @@ 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
# =========================================================================
@@ -1799,6 +1873,65 @@ def _wrap_aser_outputs():
aser_fragment = _aser_fragment_wrapped
'''
CONTINUATIONS_PY = '''
# =========================================================================
# Extension: delimited continuations (shift/reset)
# =========================================================================
_RESET_RESUME = [] # stack of resume values; empty = not resuming
_SPECIAL_FORM_NAMES = _SPECIAL_FORM_NAMES | frozenset(["reset", "shift"])
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)
# Wrap eval_list to inject shift/reset dispatch
_base_eval_list = eval_list
def _eval_list_with_continuations(expr, env):
head = first(expr)
if type_of(head) == "symbol":
name = symbol_name(head)
args = rest(expr)
if name == "reset":
return sf_reset(args, env)
if name == "shift":
return sf_shift(args, env)
return _base_eval_list(expr, env)
eval_list = _eval_list_with_continuations
# Inject into aser_special
_base_aser_special = aser_special
def _aser_special_with_continuations(name, expr, env):
if name == "reset":
return sf_reset(expr[1:], env)
if name == "shift":
return sf_shift(expr[1:], env)
return _base_aser_special(name, expr, env)
aser_special = _aser_special_with_continuations
'''
def public_api_py(has_html: bool, has_sx: bool) -> str:
lines = [
@@ -1853,9 +1986,15 @@ def main():
default=None,
help="Comma-separated adapter names (html,sx). Default: all server-side.",
)
parser.add_argument(
"--extensions",
default=None,
help="Comma-separated extensions (continuations). Default: none.",
)
args = parser.parse_args()
adapters = args.adapters.split(",") if args.adapters else None
print(compile_ref_to_py(adapters))
extensions = args.extensions.split(",") if args.extensions else None
print(compile_ref_to_py(adapters, extensions))
if __name__ == "__main__":