From 455e48df074f695dc81477c48b63346488164f22 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 14 Mar 2026 10:11:48 +0000 Subject: [PATCH] Replace invoke with cek-call in reactive island primitives All signal operations (computed, effect, batch, etc.) now dispatch function calls through cek-call, which routes SX lambdas via cek-run and native callables via apply. This replaces the invoke shim. Key changes: - cek.sx: add cek-call (defined before reactive-shift-deref), replace invoke in subscriber disposal and ReactiveResetFrame handler - signals.sx: replace all 11 invoke calls with cek-call - js.sx: fix octal escape in js-quote-string (char-from-code 0) - platform_js.py: fix JS append to match Python (list concat semantics), add Continuation type guard in PLATFORM_CEK_JS, add scheduleIdle safety check, module ordering (cek before signals) - platform_py.py: fix ident-char regex (remove [ ] from valid chars), module ordering (cek before signals) - run_js_sx.py: emit PLATFORM_CEK_JS before transpiled spec files - page-functions.sx: add cek and provide page functions for SX URLs Co-Authored-By: Claude Opus 4.6 (1M context) --- shared/static/scripts/sx-browser.js | 525 +++++++++++---------- shared/sx/boundary.py | 26 +- shared/sx/ref/bootstrap_py.py | 198 +++++++- shared/sx/ref/boundary_parser.py | 18 +- shared/sx/ref/cek.sx | 16 +- shared/sx/ref/js.sx | 2 +- shared/sx/ref/parser.sx | 8 +- shared/sx/ref/platform_js.py | 32 +- shared/sx/ref/platform_py.py | 4 +- shared/sx/ref/run_cek_tests.py | 2 +- shared/sx/ref/run_continuation_tests.py | 2 +- shared/sx/ref/run_js_sx.py | 7 +- shared/sx/ref/run_signal_tests.py | 2 +- shared/sx/ref/run_type_tests.py | 2 +- shared/sx/ref/signals.sx | 33 +- shared/sx/ref/sx_ref.py | 580 ++++++++++++------------ shared/sx/types.py | 37 ++ sx/sx/nav-data.sx | 4 +- sx/sx/page-functions.sx | 12 + sx/sxc/pages/docs.sx | 1 + 20 files changed, 911 insertions(+), 600 deletions(-) diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index c3b8379..382fcb7 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-14T01:23:35Z"; + var SX_VERSION = "2026-03-14T10:06:04Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -414,7 +414,7 @@ PRIMITIVES["rest"] = function(c) { if (c && typeof c.slice !== "function") { console.error("[sx-debug] rest called on non-sliceable:", typeof c, c, new Error().stack); return []; } return c ? c.slice(1) : []; }; PRIMITIVES["nth"] = function(c, n) { return c && n >= 0 && n < c.length ? c[n] : NIL; }; PRIMITIVES["cons"] = function(x, c) { return [x].concat(c || []); }; - PRIMITIVES["append"] = function(c, x) { return (c || []).concat([x]); }; + PRIMITIVES["append"] = function(c, x) { return (c || []).concat(Array.isArray(x) ? x : [x]); }; PRIMITIVES["append!"] = function(arr, x) { arr.push(x); return arr; }; PRIMITIVES["chunk-every"] = function(c, n) { var r = []; for (var i = 0; i < c.length; i += n) r.push(c.slice(i, i + n)); return r; @@ -744,10 +744,10 @@ // ========================================================================= // Character classification derived from the grammar: // ident-start → [a-zA-Z_~*+\-><=/!?&] - // ident-char → ident-start + [0-9.:\/\[\]#,] + // ident-char → ident-start + [0-9.:\/\#,] var _identStartRe = /[a-zA-Z_~*+\-><=/!?&]/; - var _identCharRe = /[a-zA-Z0-9_~*+\-><=/!?.:&/\[\]#,]/; + var _identCharRe = /[a-zA-Z0-9_~*+\-><=/!?.:&/#,]/; function isIdentStart(ch) { return _identStartRe.test(ch); } function isIdentChar(ch) { return _identCharRe.test(ch); } @@ -759,6 +759,35 @@ var charFromCode = PRIMITIVES["char-from-code"]; + // ========================================================================= + // Platform: CEK module — explicit CEK machine + // ========================================================================= + + // Continuation type (needed by CEK even without the tree-walk shift/reset extension) + if (typeof Continuation === "undefined") { + function Continuation(fn) { this.fn = fn; } + Continuation.prototype._continuation = true; + Continuation.prototype.call = function(value) { return this.fn(value !== undefined ? value : NIL); }; + PRIMITIVES["continuation?"] = function(x) { return x != null && x._continuation === true; }; + } + + // Standalone aliases for primitives used by cek.sx / frames.sx + var inc = PRIMITIVES["inc"]; + var dec = PRIMITIVES["dec"]; + var zip_pairs = PRIMITIVES["zip-pairs"]; + + var continuation_p = PRIMITIVES["continuation?"]; + + function makeCekContinuation(captured, restKont) { + var c = new Continuation(function(v) { return v !== undefined ? v : NIL; }); + c._cek_data = {"captured": captured, "rest-kont": restKont}; + return c; + } + function continuationData(c) { + return (c && c._cek_data) ? c._cek_data : {}; + } + + // === Transpiled from eval === // trampoline @@ -1473,7 +1502,8 @@ if (isSxTruthy((pos >= lenSrc))) { return error("Unexpected end of input"); } el if (isSxTruthy((ch == "("))) { pos = (pos + 1); return readList(")"); } else if (isSxTruthy((ch == "["))) { pos = (pos + 1); return readList("]"); } else if (isSxTruthy((ch == "{"))) { pos = (pos + 1); -return readMap(); } else if (isSxTruthy((ch == "\""))) { return readString(); } else if (isSxTruthy((ch == ":"))) { return readKeyword(); } else if (isSxTruthy((ch == "`"))) { pos = (pos + 1); +return readMap(); } else if (isSxTruthy((ch == "\""))) { return readString(); } else if (isSxTruthy((ch == ":"))) { return readKeyword(); } else if (isSxTruthy((ch == "'"))) { pos = (pos + 1); +return [makeSymbol("quote"), readExpr()]; } else if (isSxTruthy((ch == "`"))) { pos = (pos + 1); return [makeSymbol("quasiquote"), readExpr()]; } else if (isSxTruthy((ch == ","))) { pos = (pos + 1); if (isSxTruthy((isSxTruthy((pos < lenSrc)) && (nth(source, pos) == "@")))) { pos = (pos + 1); return [makeSymbol("splice-unquote"), readExpr()]; } else { return [makeSymbol("unquote"), readExpr()]; } } else if (isSxTruthy((ch == "#"))) { pos = (pos + 1); @@ -4385,224 +4415,6 @@ return scan(kont, []); }; })(); }; - // === Transpiled from signals (reactive signal runtime) === - - // make-signal - var makeSignal = function(value) { return {["__signal"]: true, ["value"]: value, ["subscribers"]: [], ["deps"]: []}; }; - - // signal? - var isSignal = function(x) { return (isSxTruthy(isDict(x)) && dictHas(x, "__signal")); }; - - // signal-value - var signalValue = function(s) { return get(s, "value"); }; - - // signal-set-value! - var signalSetValue = function(s, v) { return dictSet(s, "value", v); }; - - // signal-subscribers - var signalSubscribers = function(s) { return get(s, "subscribers"); }; - - // signal-add-sub! - var signalAddSub = function(s, f) { return (isSxTruthy(!isSxTruthy(contains(get(s, "subscribers"), f))) ? append_b(get(s, "subscribers"), f) : NIL); }; - - // signal-remove-sub! - var signalRemoveSub = function(s, f) { return dictSet(s, "subscribers", filter(function(sub) { return !isSxTruthy(isIdentical(sub, f)); }, get(s, "subscribers"))); }; - - // signal-deps - var signalDeps = function(s) { return get(s, "deps"); }; - - // signal-set-deps! - var signalSetDeps = function(s, deps) { return dictSet(s, "deps", deps); }; - - // signal - var signal = function(initialValue) { return makeSignal(initialValue); }; - - // deref - var deref = function(s) { return (isSxTruthy(!isSxTruthy(isSignal(s))) ? s : (function() { - var ctx = sxContext("sx-reactive", NIL); - if (isSxTruthy(ctx)) { - (function() { - var depList = get(ctx, "deps"); - var notifyFn = get(ctx, "notify"); - return (isSxTruthy(!isSxTruthy(contains(depList, s))) ? (append_b(depList, s), signalAddSub(s, notifyFn)) : NIL); -})(); -} - return signalValue(s); -})()); }; - - // reset! - var reset_b = function(s, value) { return (isSxTruthy(isSignal(s)) ? (function() { - var old = signalValue(s); - return (isSxTruthy(!isSxTruthy(isIdentical(old, value))) ? (signalSetValue(s, value), notifySubscribers(s)) : NIL); -})() : NIL); }; - - // swap! - var swap_b = function(s, f) { var args = Array.prototype.slice.call(arguments, 2); return (isSxTruthy(isSignal(s)) ? (function() { - var old = signalValue(s); - var newVal = apply(f, cons(old, args)); - return (isSxTruthy(!isSxTruthy(isIdentical(old, newVal))) ? (signalSetValue(s, newVal), notifySubscribers(s)) : NIL); -})() : NIL); }; - - // computed - var computed = function(computeFn) { return (function() { - var s = makeSignal(NIL); - var deps = []; - var computeCtx = NIL; - return (function() { - var recompute = function() { { var _c = signalDeps(s); for (var _i = 0; _i < _c.length; _i++) { var dep = _c[_i]; signalRemoveSub(dep, recompute); } } -signalSetDeps(s, []); -return (function() { - var ctx = {["deps"]: [], ["notify"]: recompute}; - scopePush("sx-reactive", ctx); - return (function() { - var newVal = invoke(computeFn); - scopePop("sx-reactive"); - signalSetDeps(s, get(ctx, "deps")); - return (function() { - var old = signalValue(s); - signalSetValue(s, newVal); - return (isSxTruthy(!isSxTruthy(isIdentical(old, newVal))) ? notifySubscribers(s) : NIL); -})(); -})(); -})(); }; - recompute(); - registerInScope(function() { return disposeComputed(s); }); - return s; -})(); -})(); }; - - // effect - var effect = function(effectFn) { return (function() { - var deps = []; - var disposed = false; - var cleanupFn = NIL; - return (function() { - var runEffect = function() { return (isSxTruthy(!isSxTruthy(disposed)) ? ((isSxTruthy(cleanupFn) ? invoke(cleanupFn) : NIL), forEach(function(dep) { return signalRemoveSub(dep, runEffect); }, deps), (deps = []), (function() { - var ctx = {["deps"]: [], ["notify"]: runEffect}; - scopePush("sx-reactive", ctx); - return (function() { - var result = invoke(effectFn); - scopePop("sx-reactive"); - deps = get(ctx, "deps"); - return (isSxTruthy(isCallable(result)) ? (cleanupFn = result) : NIL); -})(); -})()) : NIL); }; - runEffect(); - return (function() { - var disposeFn = function() { disposed = true; -if (isSxTruthy(cleanupFn)) { - invoke(cleanupFn); -} -{ var _c = deps; for (var _i = 0; _i < _c.length; _i++) { var dep = _c[_i]; signalRemoveSub(dep, runEffect); } } -return (deps = []); }; - registerInScope(disposeFn); - return disposeFn; -})(); -})(); -})(); }; - - // *batch-depth* - var _batchDepth = 0; - - // *batch-queue* - var _batchQueue = []; - - // batch - var batch = function(thunk) { _batchDepth = (_batchDepth + 1); -invoke(thunk); -_batchDepth = (_batchDepth - 1); -return (isSxTruthy((_batchDepth == 0)) ? (function() { - var queue = _batchQueue; - _batchQueue = []; - return (function() { - var seen = []; - var pending = []; - { var _c = queue; for (var _i = 0; _i < _c.length; _i++) { var s = _c[_i]; { var _c = signalSubscribers(s); for (var _i = 0; _i < _c.length; _i++) { var sub = _c[_i]; if (isSxTruthy(!isSxTruthy(contains(seen, sub)))) { - seen.push(sub); - pending.push(sub); -} } } } } - return forEach(function(sub) { return sub(); }, pending); -})(); -})() : NIL); }; - - // notify-subscribers - var notifySubscribers = function(s) { return (isSxTruthy((_batchDepth > 0)) ? (isSxTruthy(!isSxTruthy(contains(_batchQueue, s))) ? append_b(_batchQueue, s) : NIL) : flushSubscribers(s)); }; - - // flush-subscribers - var flushSubscribers = function(s) { return forEach(function(sub) { return sub(); }, signalSubscribers(s)); }; - - // dispose-computed - var disposeComputed = function(s) { return (isSxTruthy(isSignal(s)) ? (forEach(function(dep) { return signalRemoveSub(dep, NIL); }, signalDeps(s)), signalSetDeps(s, [])) : NIL); }; - - // with-island-scope - var withIslandScope = function(scopeFn, bodyFn) { scopePush("sx-island-scope", scopeFn); -return (function() { - var result = bodyFn(); - scopePop("sx-island-scope"); - return result; -})(); }; - - // register-in-scope - var registerInScope = function(disposable) { return (function() { - var collector = sxContext("sx-island-scope", NIL); - return (isSxTruthy(collector) ? invoke(collector, disposable) : NIL); -})(); }; - - // with-marsh-scope - var withMarshScope = function(marshEl, bodyFn) { return (function() { - var disposers = []; - withIslandScope(function(d) { return append_b(disposers, d); }, bodyFn); - return domSetData(marshEl, "sx-marsh-disposers", disposers); -})(); }; - - // dispose-marsh-scope - var disposeMarshScope = function(marshEl) { return (function() { - var disposers = domGetData(marshEl, "sx-marsh-disposers"); - return (isSxTruthy(disposers) ? (forEach(function(d) { return invoke(d); }, disposers), domSetData(marshEl, "sx-marsh-disposers", NIL)) : NIL); -})(); }; - - // *store-registry* - var _storeRegistry = {}; - - // def-store - var defStore = function(name, initFn) { return (function() { - var registry = _storeRegistry; - if (isSxTruthy(!isSxTruthy(dictHas(registry, name)))) { - _storeRegistry = assoc(registry, name, invoke(initFn)); -} - return get(_storeRegistry, name); -})(); }; - - // use-store - var useStore = function(name) { return (isSxTruthy(dictHas(_storeRegistry, name)) ? get(_storeRegistry, name) : error((String("Store not found: ") + String(name) + String(". Call (def-store ...) before (use-store ...).")))); }; - - // clear-stores - var clearStores = function() { return (_storeRegistry = {}); }; - - // emit-event - var emitEvent = function(el, eventName, detail) { return domDispatch(el, eventName, detail); }; - - // on-event - var onEvent = function(el, eventName, handler) { return domListen(el, eventName, handler); }; - - // bridge-event - var bridgeEvent = function(el, eventName, targetSignal, transformFn) { return effect(function() { return (function() { - var remove = domListen(el, eventName, function(e) { return (function() { - var detail = eventDetail(e); - var newVal = (isSxTruthy(transformFn) ? invoke(transformFn, detail) : detail); - return reset_b(targetSignal, newVal); -})(); }); - return remove; -})(); }); }; - - // resource - var resource = function(fetchFn) { return (function() { - var state = signal({["loading"]: true, ["data"]: NIL, ["error"]: NIL}); - promiseThen(invoke(fetchFn), function(data) { return reset_b(state, {["loading"]: false, ["data"]: data, ["error"]: NIL}); }, function(err) { return reset_b(state, {["loading"]: false, ["data"]: NIL, ["error"]: err}); }); - return state; -})(); }; - - // === Transpiled from cek (explicit CEK machine evaluator) === // cek-run @@ -4743,6 +4555,12 @@ return (function() { // step-sf-deref var stepSfDeref = function(args, env, kont) { return makeCekState(first(args), env, kontPush(makeDerefFrame(env), kont)); }; + // cek-call + var cekCall = function(f, args) { return (function() { + var a = (isSxTruthy(isNil(args)) ? [] : args); + return (isSxTruthy(isNil(f)) ? NIL : (isSxTruthy(isLambda(f)) ? cekRun(continueWithCall(f, a, {}, a, [])) : (isSxTruthy(isCallable(f)) ? apply(f, a) : NIL))); +})(); }; + // reactive-shift-deref var reactiveShiftDeref = function(sig, env, kont) { return (function() { var scanResult = kontCaptureToReactiveReset(kont); @@ -4753,7 +4571,7 @@ return (function() { return (function() { var subDisposers = []; return (function() { - var subscriber = function() { { var _c = subDisposers; for (var _i = 0; _i < _c.length; _i++) { var d = _c[_i]; invoke(d); } } + var subscriber = function() { { var _c = subDisposers; for (var _i = 0; _i < _c.length; _i++) { var d = _c[_i]; cekCall(d, NIL); } } subDisposers = []; return (function() { var newReset = makeReactiveResetFrame(env, updateFn, false); @@ -4762,7 +4580,7 @@ return (function() { })(); }; signalAddSub(sig, subscriber); registerInScope(function() { signalRemoveSub(sig, subscriber); -return forEach(function(d) { return invoke(d); }, subDisposers); }); +return forEach(function(d) { return cekCall(d, NIL); }, subDisposers); }); return (function() { var initialKont = concat(capturedFrames, [resetFrame], remainingKont); return makeCekValue(signalValue(sig), env, initialKont); @@ -4926,7 +4744,7 @@ return forEach(function(d) { return invoke(d); }, subDisposers); }); var updateFn = get(frame, "update-fn"); var first_p = get(frame, "first-render"); if (isSxTruthy((isSxTruthy(updateFn) && !isSxTruthy(first_p)))) { - invoke(updateFn, value); + cekCall(updateFn, [value]); } return makeCekValue(value, env, restK); })() : (isSxTruthy((ft == "scope")) ? (function() { @@ -4980,6 +4798,224 @@ return forEach(function(d) { return invoke(d); }, subDisposers); }); var trampolineCek = function(val) { return (isSxTruthy(isThunk(val)) ? evalExprCek(thunkExpr(val), thunkEnv(val)) : val); }; + // === Transpiled from signals (reactive signal runtime) === + + // make-signal + var makeSignal = function(value) { return {["__signal"]: true, ["value"]: value, ["subscribers"]: [], ["deps"]: []}; }; + + // signal? + var isSignal = function(x) { return (isSxTruthy(isDict(x)) && dictHas(x, "__signal")); }; + + // signal-value + var signalValue = function(s) { return get(s, "value"); }; + + // signal-set-value! + var signalSetValue = function(s, v) { return dictSet(s, "value", v); }; + + // signal-subscribers + var signalSubscribers = function(s) { return get(s, "subscribers"); }; + + // signal-add-sub! + var signalAddSub = function(s, f) { return (isSxTruthy(!isSxTruthy(contains(get(s, "subscribers"), f))) ? append_b(get(s, "subscribers"), f) : NIL); }; + + // signal-remove-sub! + var signalRemoveSub = function(s, f) { return dictSet(s, "subscribers", filter(function(sub) { return !isSxTruthy(isIdentical(sub, f)); }, get(s, "subscribers"))); }; + + // signal-deps + var signalDeps = function(s) { return get(s, "deps"); }; + + // signal-set-deps! + var signalSetDeps = function(s, deps) { return dictSet(s, "deps", deps); }; + + // signal + var signal = function(initialValue) { return makeSignal(initialValue); }; + + // deref + var deref = function(s) { return (isSxTruthy(!isSxTruthy(isSignal(s))) ? s : (function() { + var ctx = sxContext("sx-reactive", NIL); + if (isSxTruthy(ctx)) { + (function() { + var depList = get(ctx, "deps"); + var notifyFn = get(ctx, "notify"); + return (isSxTruthy(!isSxTruthy(contains(depList, s))) ? (append_b(depList, s), signalAddSub(s, notifyFn)) : NIL); +})(); +} + return signalValue(s); +})()); }; + + // reset! + var reset_b = function(s, value) { return (isSxTruthy(isSignal(s)) ? (function() { + var old = signalValue(s); + return (isSxTruthy(!isSxTruthy(isIdentical(old, value))) ? (signalSetValue(s, value), notifySubscribers(s)) : NIL); +})() : NIL); }; + + // swap! + var swap_b = function(s, f) { var args = Array.prototype.slice.call(arguments, 2); return (isSxTruthy(isSignal(s)) ? (function() { + var old = signalValue(s); + var newVal = apply(f, cons(old, args)); + return (isSxTruthy(!isSxTruthy(isIdentical(old, newVal))) ? (signalSetValue(s, newVal), notifySubscribers(s)) : NIL); +})() : NIL); }; + + // computed + var computed = function(computeFn) { return (function() { + var s = makeSignal(NIL); + var deps = []; + var computeCtx = NIL; + return (function() { + var recompute = function() { { var _c = signalDeps(s); for (var _i = 0; _i < _c.length; _i++) { var dep = _c[_i]; signalRemoveSub(dep, recompute); } } +signalSetDeps(s, []); +return (function() { + var ctx = {["deps"]: [], ["notify"]: recompute}; + scopePush("sx-reactive", ctx); + return (function() { + var newVal = cekCall(computeFn, NIL); + scopePop("sx-reactive"); + signalSetDeps(s, get(ctx, "deps")); + return (function() { + var old = signalValue(s); + signalSetValue(s, newVal); + return (isSxTruthy(!isSxTruthy(isIdentical(old, newVal))) ? notifySubscribers(s) : NIL); +})(); +})(); +})(); }; + recompute(); + registerInScope(function() { return disposeComputed(s); }); + return s; +})(); +})(); }; + + // effect + var effect = function(effectFn) { return (function() { + var deps = []; + var disposed = false; + var cleanupFn = NIL; + return (function() { + var runEffect = function() { return (isSxTruthy(!isSxTruthy(disposed)) ? ((isSxTruthy(cleanupFn) ? cekCall(cleanupFn, NIL) : NIL), forEach(function(dep) { return signalRemoveSub(dep, runEffect); }, deps), (deps = []), (function() { + var ctx = {["deps"]: [], ["notify"]: runEffect}; + scopePush("sx-reactive", ctx); + return (function() { + var result = cekCall(effectFn, NIL); + scopePop("sx-reactive"); + deps = get(ctx, "deps"); + return (isSxTruthy(isCallable(result)) ? (cleanupFn = result) : NIL); +})(); +})()) : NIL); }; + runEffect(); + return (function() { + var disposeFn = function() { disposed = true; +if (isSxTruthy(cleanupFn)) { + cekCall(cleanupFn, NIL); +} +{ var _c = deps; for (var _i = 0; _i < _c.length; _i++) { var dep = _c[_i]; signalRemoveSub(dep, runEffect); } } +return (deps = []); }; + registerInScope(disposeFn); + return disposeFn; +})(); +})(); +})(); }; + + // *batch-depth* + var _batchDepth = 0; + + // *batch-queue* + var _batchQueue = []; + + // batch + var batch = function(thunk) { _batchDepth = (_batchDepth + 1); +cekCall(thunk, NIL); +_batchDepth = (_batchDepth - 1); +return (isSxTruthy((_batchDepth == 0)) ? (function() { + var queue = _batchQueue; + _batchQueue = []; + return (function() { + var seen = []; + var pending = []; + { var _c = queue; for (var _i = 0; _i < _c.length; _i++) { var s = _c[_i]; { var _c = signalSubscribers(s); for (var _i = 0; _i < _c.length; _i++) { var sub = _c[_i]; if (isSxTruthy(!isSxTruthy(contains(seen, sub)))) { + seen.push(sub); + pending.push(sub); +} } } } } + return forEach(function(sub) { return sub(); }, pending); +})(); +})() : NIL); }; + + // notify-subscribers + var notifySubscribers = function(s) { return (isSxTruthy((_batchDepth > 0)) ? (isSxTruthy(!isSxTruthy(contains(_batchQueue, s))) ? append_b(_batchQueue, s) : NIL) : flushSubscribers(s)); }; + + // flush-subscribers + var flushSubscribers = function(s) { return forEach(function(sub) { return sub(); }, signalSubscribers(s)); }; + + // dispose-computed + var disposeComputed = function(s) { return (isSxTruthy(isSignal(s)) ? (forEach(function(dep) { return signalRemoveSub(dep, NIL); }, signalDeps(s)), signalSetDeps(s, [])) : NIL); }; + + // with-island-scope + var withIslandScope = function(scopeFn, bodyFn) { scopePush("sx-island-scope", scopeFn); +return (function() { + var result = bodyFn(); + scopePop("sx-island-scope"); + return result; +})(); }; + + // register-in-scope + var registerInScope = function(disposable) { return (function() { + var collector = sxContext("sx-island-scope", NIL); + return (isSxTruthy(collector) ? cekCall(collector, [disposable]) : NIL); +})(); }; + + // with-marsh-scope + var withMarshScope = function(marshEl, bodyFn) { return (function() { + var disposers = []; + withIslandScope(function(d) { return append_b(disposers, d); }, bodyFn); + return domSetData(marshEl, "sx-marsh-disposers", disposers); +})(); }; + + // dispose-marsh-scope + var disposeMarshScope = function(marshEl) { return (function() { + var disposers = domGetData(marshEl, "sx-marsh-disposers"); + return (isSxTruthy(disposers) ? (forEach(function(d) { return cekCall(d, NIL); }, disposers), domSetData(marshEl, "sx-marsh-disposers", NIL)) : NIL); +})(); }; + + // *store-registry* + var _storeRegistry = {}; + + // def-store + var defStore = function(name, initFn) { return (function() { + var registry = _storeRegistry; + if (isSxTruthy(!isSxTruthy(dictHas(registry, name)))) { + _storeRegistry = assoc(registry, name, cekCall(initFn, NIL)); +} + return get(_storeRegistry, name); +})(); }; + + // use-store + var useStore = function(name) { return (isSxTruthy(dictHas(_storeRegistry, name)) ? get(_storeRegistry, name) : error((String("Store not found: ") + String(name) + String(". Call (def-store ...) before (use-store ...).")))); }; + + // clear-stores + var clearStores = function() { return (_storeRegistry = {}); }; + + // emit-event + var emitEvent = function(el, eventName, detail) { return domDispatch(el, eventName, detail); }; + + // on-event + var onEvent = function(el, eventName, handler) { return domListen(el, eventName, handler); }; + + // bridge-event + var bridgeEvent = function(el, eventName, targetSignal, transformFn) { return effect(function() { return (function() { + var remove = domListen(el, eventName, function(e) { return (function() { + var detail = eventDetail(e); + var newVal = (isSxTruthy(transformFn) ? cekCall(transformFn, [detail]) : detail); + return reset_b(targetSignal, newVal); +})(); }); + return remove; +})(); }); }; + + // resource + var resource = function(fetchFn) { return (function() { + var state = signal({["loading"]: true, ["data"]: NIL, ["error"]: NIL}); + promiseThen(cekCall(fetchFn, NIL), function(data) { return reset_b(state, {["loading"]: false, ["data"]: data, ["error"]: NIL}); }, function(err) { return reset_b(state, {["loading"]: false, ["data"]: NIL, ["error"]: err}); }); + return state; +})(); }; + + // ========================================================================= // Platform interface — DOM adapter (browser-only) // ========================================================================= @@ -5787,6 +5823,10 @@ return forEach(function(d) { return invoke(d); }, subDisposers); }); } function scheduleIdle(fn) { var cb = _wrapSxFn(fn); + if (typeof cb !== "function") { + console.error("[sx-ref] scheduleIdle: callback not callable, fn type:", typeof fn, "fn:", fn, "_lambda:", fn && fn._lambda); + return; + } if (typeof requestIdleCallback !== "undefined") requestIdleCallback(cb); else setTimeout(cb, 0); } @@ -5876,8 +5916,12 @@ return forEach(function(d) { return invoke(d); }, subDisposers); }); e.preventDefault(); // Re-read href from element at click time (not closed-over value) var liveHref = el.getAttribute("href") || _href; + console.log("[sx-debug] bindBoostLink click:", liveHref, "el:", el.tagName, el.textContent.slice(0,30)); executeRequest(el, { method: "GET", url: liveHref }).then(function() { + console.log("[sx-debug] boost fetch OK, pushState:", liveHref); try { history.pushState({ sxUrl: liveHref, scrollY: window.scrollY }, "", liveHref); } catch (err) {} + }).catch(function(err) { + console.error("[sx-debug] boost fetch ERROR:", err); }); }); } @@ -5902,21 +5946,25 @@ return forEach(function(d) { return invoke(d); }, subDisposers); }); // Re-read href from element at click time (not closed-over value) var liveHref = link.getAttribute("href") || _href; var pathname = urlPathname(liveHref); + console.log("[sx-debug] bindClientRouteClick:", pathname, "el:", link.tagName, link.textContent.slice(0,30)); // Find target selector: sx-boost ancestor, explicit sx-target, or #main-panel var boostEl = link.closest("[sx-boost]"); var targetSel = boostEl ? boostEl.getAttribute("sx-boost") : null; if (!targetSel || targetSel === "true") { targetSel = link.getAttribute("sx-target") || "#main-panel"; } + console.log("[sx-debug] targetSel:", targetSel, "trying client route..."); if (tryClientRoute(pathname, targetSel)) { + console.log("[sx-debug] client route SUCCESS, pushState:", liveHref); try { history.pushState({ sxUrl: liveHref, scrollY: window.scrollY }, "", liveHref); } catch (err) {} if (typeof window !== "undefined") window.scrollTo(0, 0); } else { - logInfo("sx:route server " + pathname); + console.log("[sx-debug] client route FAILED, server fetch:", liveHref); executeRequest(link, { method: "GET", url: liveHref }).then(function() { + console.log("[sx-debug] server fetch OK, pushState:", liveHref); try { history.pushState({ sxUrl: liveHref, scrollY: window.scrollY }, "", liveHref); } catch (err) {} }).catch(function(err) { - logWarn("sx:route server fetch error: " + (err && err.message ? err.message : err)); + console.error("[sx-debug] server fetch ERROR:", err); }); } }); @@ -6290,27 +6338,6 @@ return forEach(function(d) { return invoke(d); }, subDisposers); }); - // ========================================================================= - // Platform: CEK module — explicit CEK machine - // ========================================================================= - - // Standalone aliases for primitives used by cek.sx / frames.sx - var inc = PRIMITIVES["inc"]; - var dec = PRIMITIVES["dec"]; - var zip_pairs = PRIMITIVES["zip-pairs"]; - - var continuation_p = PRIMITIVES["continuation?"]; - - function makeCekContinuation(captured, restKont) { - var c = new Continuation(function(v) { return v !== undefined ? v : NIL; }); - c._cek_data = {"captured": captured, "rest-kont": restKont}; - return c; - } - function continuationData(c) { - return (c && c._cek_data) ? c._cek_data : {}; - } - - // ========================================================================= // Post-transpilation fixups // ========================================================================= diff --git a/shared/sx/boundary.py b/shared/sx/boundary.py index 20e3352..c468514 100644 --- a/shared/sx/boundary.py +++ b/shared/sx/boundary.py @@ -39,10 +39,10 @@ def _load_declarations() -> None: len(_DECLARED_PURE), len(_DECLARED_IO), len(_DECLARED_HELPERS), ) except Exception as e: - logger.warning("Failed to load boundary declarations: %s", e) - _DECLARED_PURE = frozenset() - _DECLARED_IO = frozenset() - _DECLARED_HELPERS = {} + # Don't cache failure — parser may not be ready yet (circular import + # during startup). Will retry on next call. Validation functions + # skip checks when declarations aren't loaded. + logger.debug("Boundary declarations not ready yet: %s", e) def _is_strict() -> bool: @@ -63,7 +63,8 @@ def _report(message: str) -> None: def validate_primitive(name: str) -> None: """Validate that a pure primitive is declared in primitives.sx.""" _load_declarations() - assert _DECLARED_PURE is not None + if _DECLARED_PURE is None: + return # Not ready yet (circular import during startup), skip if name not in _DECLARED_PURE: _report(f"Undeclared pure primitive: {name!r}. Add to primitives.sx.") @@ -71,7 +72,8 @@ def validate_primitive(name: str) -> None: def validate_io(name: str) -> None: """Validate that an I/O primitive is declared in boundary.sx or boundary-app.sx.""" _load_declarations() - assert _DECLARED_IO is not None + if _DECLARED_IO is None: + return # Not ready yet, skip if name not in _DECLARED_IO: _report( f"Undeclared I/O primitive: {name!r}. " @@ -82,7 +84,8 @@ def validate_io(name: str) -> None: def validate_helper(service: str, name: str) -> None: """Validate that a page helper is declared in {service}/sx/boundary.sx.""" _load_declarations() - assert _DECLARED_HELPERS is not None + if _DECLARED_HELPERS is None: + return # Not ready yet, skip svc_helpers = _DECLARED_HELPERS.get(service, frozenset()) if name not in svc_helpers: _report( @@ -129,17 +132,14 @@ def validate_boundary_value(value: Any, context: str = "") -> None: def declared_pure() -> frozenset[str]: _load_declarations() - assert _DECLARED_PURE is not None - return _DECLARED_PURE + return _DECLARED_PURE or frozenset() def declared_io() -> frozenset[str]: _load_declarations() - assert _DECLARED_IO is not None - return _DECLARED_IO + return _DECLARED_IO or frozenset() def declared_helpers() -> dict[str, frozenset[str]]: _load_declarations() - assert _DECLARED_HELPERS is not None - return dict(_DECLARED_HELPERS) + return dict(_DECLARED_HELPERS) if _DECLARED_HELPERS else {} diff --git a/shared/sx/ref/bootstrap_py.py b/shared/sx/ref/bootstrap_py.py index 16f9e9f..2116cd3 100644 --- a/shared/sx/ref/bootstrap_py.py +++ b/shared/sx/ref/bootstrap_py.py @@ -760,7 +760,16 @@ class PyEmitter: self._current_cell_vars = old_cells | nested_set_vars if is_async: self._in_async = True - self._emit_body_stmts(body, lines, indent + 1) + # Self-tail-recursive 0-param functions: wrap body in while True + if (not param_names and not is_async + and self._has_self_tail_call(body, name)): + lines.append(f"{pad} while True:") + old_loop = getattr(self, '_current_loop_name', None) + self._current_loop_name = name + self._emit_body_stmts(body, lines, indent + 2) + self._current_loop_name = old_loop + else: + self._emit_body_stmts(body, lines, indent + 1) self._current_cell_vars = old_cells self._in_async = old_async return "\n".join(lines) @@ -799,14 +808,20 @@ class PyEmitter: Handles let as local variable declarations, and returns the last expression. Control flow in tail position (if, cond, case, when) is flattened to if/elif statements with returns in each branch. + + Detects self-tail-recursive (define name (fn () ...)) followed by + (name) and emits as while True loop instead of recursive def. """ pad = " " * indent - for i, expr in enumerate(body): - is_last = (i == len(body) - 1) + idx = 0 + while idx < len(body): + expr = body[idx] + is_last = (idx == len(body) - 1) if isinstance(expr, list) and expr and isinstance(expr[0], Symbol): name = expr[0].name if name in ("let", "let*"): self._emit_let_as_stmts(expr, lines, indent, is_last) + idx += 1 continue if name in ("do", "begin"): sub_body = expr[1:] @@ -815,15 +830,172 @@ class PyEmitter: else: for sub in sub_body: lines.append(self.emit_statement(sub, indent)) + idx += 1 continue + # Detect self-tail-recursive loop pattern: + # (define loop-name (fn () body...)) + # (loop-name) + # Emit as: while True: + if (name == "define" and not is_last + and idx + 1 < len(body)): + loop_info = self._detect_tail_loop(expr, body[idx + 1]) + if loop_info: + loop_name, fn_body = loop_info + remaining = body[idx + 2:] + # Only optimize if the function isn't called again later + if not self._name_in_exprs(loop_name, remaining): + self._emit_while_loop(loop_name, fn_body, lines, indent) + # Skip the invocation; emit remaining body + for j, rem in enumerate(remaining): + if j == len(remaining) - 1: + self._emit_return_expr(rem, lines, indent) + else: + self._emit_stmt_recursive(rem, lines, indent) + return if is_last: self._emit_return_expr(expr, lines, indent) else: self._emit_stmt_recursive(expr, lines, indent) + idx += 1 + + def _detect_tail_loop(self, define_expr, next_expr): + """Detect pattern: (define name (fn () body...)) followed by (name). + + Returns (loop_name, fn_body) if tail-recursive, else None. + The function must have 0 params and body must end with self-call + in all tail positions. + """ + # Extract name and fn from define + dname = define_expr[1].name if isinstance(define_expr[1], Symbol) else None + if not dname: + return None + # Skip :effects annotation + if (len(define_expr) >= 5 and isinstance(define_expr[2], Keyword) + and define_expr[2].name == "effects"): + val_expr = define_expr[4] + else: + val_expr = define_expr[2] if len(define_expr) > 2 else None + if not (isinstance(val_expr, list) and val_expr + and isinstance(val_expr[0], Symbol) + and val_expr[0].name in ("fn", "lambda")): + return None + params = val_expr[1] + if not isinstance(params, list) or len(params) != 0: + return None # Must be 0-param function + fn_body = val_expr[2:] + # Check next expression is (name) — invocation + if not (isinstance(next_expr, list) and len(next_expr) == 1 + and isinstance(next_expr[0], Symbol) + and next_expr[0].name == dname): + return None + # Check that fn_body has self-call in tail position(s) + if not self._has_self_tail_call(fn_body, dname): + return None + return (dname, fn_body) + + def _has_self_tail_call(self, body, name): + """Check if body is safe for while-loop optimization. + + Returns True only when ALL tail positions are either: + - self-calls (name) → will become continue + - nil/void returns → will become break + - error() calls → raise, don't return + - when blocks → implicit nil else is fine + No tail position may return a computed value, since while-loop + break discards return values. + """ + if not body: + return False + last = body[-1] + # Non-list terminal: nil is ok, anything else is a value return + if not isinstance(last, list) or not last: + return (last is None or last is SX_NIL + or (isinstance(last, Symbol) and last.name == "nil")) + head = last[0] if isinstance(last[0], Symbol) else None + if not head: + return False + # Direct self-call in tail position + if head.name == name and len(last) == 1: + return True + # error() — raises, safe + if head.name == "error": + return True + # if — ALL branches must be safe + if head.name == "if": + then_ok = self._has_self_tail_call( + [last[2]] if len(last) > 2 else [None], name) + else_ok = self._has_self_tail_call( + [last[3]] if len(last) > 3 else [None], name) + return then_ok and else_ok + # do/begin — check last expression + if head.name in ("do", "begin"): + return self._has_self_tail_call(last[1:], name) + # when — body must be safe (implicit nil else is ok) + if head.name == "when": + return self._has_self_tail_call(last[2:], name) + # let/let* — check body (skip bindings) + if head.name in ("let", "let*"): + return self._has_self_tail_call(last[2:], name) + # cond — ALL branches must be safe + if head.name == "cond": + clauses = last[1:] + is_scheme = ( + all(isinstance(c, list) and len(c) == 2 for c in clauses) + and not any(isinstance(c, Keyword) for c in clauses) + ) + if is_scheme: + for clause in clauses: + if not self._has_self_tail_call([clause[1]], name): + return False + return True + else: + i = 0 + while i < len(clauses) - 1: + if not self._has_self_tail_call([clauses[i + 1]], name): + return False + i += 2 + return True + return False + + def _name_in_exprs(self, name, exprs): + """Check if a symbol name appears anywhere in a list of expressions.""" + for expr in exprs: + if isinstance(expr, Symbol) and expr.name == name: + return True + if isinstance(expr, list): + if self._name_in_exprs(name, expr): + return True + return False + + def _emit_while_loop(self, loop_name, fn_body, lines, indent): + """Emit a self-tail-recursive function body as a while True loop.""" + pad = " " * indent + lines.append(f"{pad}while True:") + # Track the loop name so _emit_return_expr can emit 'continue' + old_loop = getattr(self, '_current_loop_name', None) + self._current_loop_name = loop_name + self._emit_body_stmts(fn_body, lines, indent + 1) + self._current_loop_name = old_loop + + def _emit_nil_return(self, lines: list, indent: int) -> None: + """Emit 'return NIL' or 'break' depending on while-loop context.""" + pad = " " * indent + if getattr(self, '_current_loop_name', None): + lines.append(f"{pad}break") + else: + lines.append(f"{pad}return NIL") def _emit_return_expr(self, expr, lines: list, indent: int) -> None: """Emit an expression in return position, flattening control flow.""" pad = " " * indent + # Inside a while loop (self-tail-recursive define optimization): + # self-call → continue + loop_name = getattr(self, '_current_loop_name', None) + if loop_name: + if (isinstance(expr, list) and len(expr) == 1 + and isinstance(expr[0], Symbol) and expr[0].name == loop_name): + lines.append(f"{pad}continue") + return if isinstance(expr, list) and expr and isinstance(expr[0], Symbol): name = expr[0].name if name == "if": @@ -845,11 +1017,17 @@ class PyEmitter: self._emit_body_stmts(expr[1:], lines, indent) return if name == "for-each": - # for-each in return position: emit as statement, return NIL + # for-each in return position: emit as statement, then return/break lines.append(self._emit_for_each_stmt(expr, indent)) - lines.append(f"{pad}return NIL") + self._emit_nil_return(lines, indent) return - lines.append(f"{pad}return {self.emit(expr)}") + if loop_name: + emitted = self.emit(expr) + if emitted != "NIL": + lines.append(f"{pad}{emitted}") + lines.append(f"{pad}break") + else: + lines.append(f"{pad}return {self.emit(expr)}") def _emit_if_return(self, expr, lines: list, indent: int) -> None: """Emit if as statement with returns in each branch.""" @@ -860,7 +1038,7 @@ class PyEmitter: lines.append(f"{pad}else:") self._emit_return_expr(expr[3], lines, indent + 1) else: - lines.append(f"{pad}return NIL") + self._emit_nil_return(lines, indent) def _emit_when_return(self, expr, lines: list, indent: int) -> None: """Emit when as statement with return in body, else return NIL.""" @@ -873,7 +1051,7 @@ class PyEmitter: for b in body_parts[:-1]: lines.append(self.emit_statement(b, indent + 1)) self._emit_return_expr(body_parts[-1], lines, indent + 1) - lines.append(f"{pad}return NIL") + self._emit_nil_return(lines, indent) def _emit_cond_return(self, expr, lines: list, indent: int) -> None: """Emit cond as if/elif/else with returns in each branch.""" @@ -915,7 +1093,7 @@ class PyEmitter: self._emit_return_expr(body, lines, indent + 1) i += 2 if not has_else: - lines.append(f"{pad}return NIL") + self._emit_nil_return(lines, indent) def _emit_case_return(self, expr, lines: list, indent: int) -> None: """Emit case as if/elif/else with returns in each branch.""" @@ -940,7 +1118,7 @@ class PyEmitter: self._emit_return_expr(body, lines, indent + 1) i += 2 if not has_else: - lines.append(f"{pad}return NIL") + self._emit_nil_return(lines, indent) def _emit_let_as_stmts(self, expr, lines: list, indent: int, is_last: bool) -> None: """Emit a let expression as local variable declarations.""" diff --git a/shared/sx/ref/boundary_parser.py b/shared/sx/ref/boundary_parser.py index f6fe7b5..82a931c 100644 --- a/shared/sx/ref/boundary_parser.py +++ b/shared/sx/ref/boundary_parser.py @@ -20,17 +20,21 @@ logger = logging.getLogger("sx.boundary_parser") # Allow standalone use (from bootstrappers) or in-project imports try: - from shared.sx.parser import parse_all from shared.sx.types import Symbol, Keyword, NIL as SX_NIL except ImportError: import sys _HERE = os.path.dirname(os.path.abspath(__file__)) _PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) sys.path.insert(0, _PROJECT) - from shared.sx.parser import parse_all from shared.sx.types import Symbol, Keyword, NIL as SX_NIL +def _get_parse_all(): + """Lazy import to avoid circular dependency when parser.py loads sx_ref.py.""" + from shared.sx.parser import parse_all + return parse_all + + def _ref_dir() -> str: return os.path.dirname(os.path.abspath(__file__)) @@ -81,7 +85,7 @@ def _extract_declarations( Returns (io_names, {service: helper_names}). """ - exprs = parse_all(source) + exprs = _get_parse_all()(source) io_names: set[str] = set() helpers: dict[str, set[str]] = {} @@ -144,7 +148,7 @@ def parse_primitives_sx() -> frozenset[str]: def parse_primitives_by_module() -> dict[str, frozenset[str]]: """Parse primitives.sx and return primitives grouped by module.""" source = _read_file("primitives.sx") - exprs = parse_all(source) + exprs = _get_parse_all()(source) modules: dict[str, set[str]] = {} current_module = "_unscoped" @@ -204,7 +208,7 @@ def parse_primitive_param_types() -> dict[str, dict]: type of the &rest parameter (or None if no &rest, or None if untyped &rest). """ source = _read_file("primitives.sx") - exprs = parse_all(source) + exprs = _get_parse_all()(source) result: dict[str, dict] = {} for expr in exprs: @@ -293,7 +297,7 @@ def parse_boundary_effects() -> dict[str, list[str]]: Pure primitives from primitives.sx are not included (they have no effects). """ source = _read_file("boundary.sx") - exprs = parse_all(source) + exprs = _get_parse_all()(source) result: dict[str, list[str]] = {} _DECL_FORMS = { @@ -338,7 +342,7 @@ def parse_boundary_effects() -> dict[str, list[str]]: def parse_boundary_types() -> frozenset[str]: """Parse boundary.sx and return the declared boundary type names.""" source = _read_file("boundary.sx") - exprs = parse_all(source) + exprs = _get_parse_all()(source) for expr in exprs: if (isinstance(expr, list) and len(expr) >= 2 and isinstance(expr[0], Symbol) diff --git a/shared/sx/ref/cek.sx b/shared/sx/ref/cek.sx index 3a2c97d..118ce7c 100644 --- a/shared/sx/ref/cek.sx +++ b/shared/sx/ref/cek.sx @@ -407,6 +407,16 @@ (first args) env (kont-push (make-deref-frame env) kont)))) +;; cek-call — call a function via CEK (replaces invoke) +(define cek-call + (fn (f args) + (let ((a (if (nil? args) (list) args))) + (cond + (nil? f) nil + (lambda? f) (cek-run (continue-with-call f a (dict) a (list))) + (callable? f) (apply f a) + :else nil)))) + ;; reactive-shift-deref: the heart of deref-as-shift ;; When deref encounters a signal inside a reactive-reset boundary, ;; capture the continuation up to the reactive-reset as the subscriber. @@ -422,7 +432,7 @@ (let ((subscriber (fn () ;; Dispose previous nested subscribers - (for-each (fn (d) (invoke d)) sub-disposers) + (for-each (fn (d) (cek-call d nil)) sub-disposers) (set! sub-disposers (list)) ;; Re-invoke: push fresh ReactiveResetFrame (first-render=false) (let ((new-reset (make-reactive-reset-frame env update-fn false)) @@ -440,7 +450,7 @@ (register-in-scope (fn () (signal-remove-sub! sig subscriber) - (for-each (fn (d) (invoke d)) sub-disposers))) + (for-each (fn (d) (cek-call d nil)) sub-disposers))) ;; Initial render: value flows through captured frames + reset (first-render=true) ;; so the full expression completes normally (let ((initial-kont (concat captured-frames @@ -782,7 +792,7 @@ (first? (get frame "first-render"))) ;; On re-render (not first), call update-fn with new value (when (and update-fn (not first?)) - (invoke update-fn value)) + (cek-call update-fn (list value))) (make-cek-value value env rest-k)) ;; --- ScopeFrame: body result --- diff --git a/shared/sx/ref/js.sx b/shared/sx/ref/js.sx index 62e5a91..97ba049 100644 --- a/shared/sx/ref/js.sx +++ b/shared/sx/ref/js.sx @@ -655,7 +655,7 @@ (fn ((s :as string)) (str "\"" (replace (replace (replace (replace (replace (replace - s "\\" "\\\\") "\"" "\\\"") "\n" "\\n") "\r" "\\r") "\t" "\\t") "\0" "\\0") + s "\\" "\\\\") "\"" "\\\"") "\n" "\\n") "\r" "\\r") "\t" "\\t") (char-from-code 0) "\\u0000") "\""))) diff --git a/shared/sx/ref/parser.sx b/shared/sx/ref/parser.sx index 1b055ca..2fb9b2d 100644 --- a/shared/sx/ref/parser.sx +++ b/shared/sx/ref/parser.sx @@ -25,6 +25,7 @@ ;; comment → ';' to end of line (discarded) ;; ;; Quote sugar: +;; 'expr → (quote expr) ;; `expr → (quasiquote expr) ;; ,expr → (unquote expr) ;; ,@expr → (splice-unquote expr) @@ -267,6 +268,11 @@ (= ch ":") (read-keyword) + ;; Quote sugar + (= ch "'") + (do (set! pos (inc pos)) + (list (make-symbol "quote") (read-expr))) + ;; Quasiquote sugar (= ch "`") (do (set! pos (inc pos)) @@ -395,7 +401,7 @@ ;; True for: a-z A-Z _ ~ * + - > < = / ! ? & ;; ;; (ident-char? ch) → boolean -;; True for: ident-start chars plus: 0-9 . : / [ ] # , +;; True for: ident-start chars plus: 0-9 . : / # , ;; ;; Constructors (provided by the SX runtime): ;; (make-symbol name) → Symbol value diff --git a/shared/sx/ref/platform_js.py b/shared/sx/ref/platform_js.py index f606d20..fc90b11 100644 --- a/shared/sx/ref/platform_js.py +++ b/shared/sx/ref/platform_js.py @@ -52,7 +52,7 @@ SPEC_MODULES = { # Explicit ordering for spec modules with dependencies. # Modules listed here are emitted in this order; any not listed use alphabetical. -SPEC_MODULE_ORDER = ["deps", "frames", "page-helpers", "router", "signals", "cek"] +SPEC_MODULE_ORDER = ["deps", "frames", "page-helpers", "router", "cek", "signals"] EXTENSION_NAMES = {"continuations"} @@ -1004,7 +1004,7 @@ PRIMITIVES_JS_MODULES: dict[str, str] = { PRIMITIVES["rest"] = function(c) { if (c && typeof c.slice !== "function") { console.error("[sx-debug] rest called on non-sliceable:", typeof c, c, new Error().stack); return []; } return c ? c.slice(1) : []; }; PRIMITIVES["nth"] = function(c, n) { return c && n >= 0 && n < c.length ? c[n] : NIL; }; PRIMITIVES["cons"] = function(x, c) { return [x].concat(c || []); }; - PRIMITIVES["append"] = function(c, x) { return (c || []).concat([x]); }; + PRIMITIVES["append"] = function(c, x) { return (c || []).concat(Array.isArray(x) ? x : [x]); }; PRIMITIVES["append!"] = function(arr, x) { arr.push(x); return arr; }; PRIMITIVES["chunk-every"] = function(c, n) { var r = []; for (var i = 0; i < c.length; i += n) r.push(c.slice(i, i + n)); return r; @@ -1487,6 +1487,14 @@ PLATFORM_CEK_JS = ''' // Platform: CEK module — explicit CEK machine // ========================================================================= + // Continuation type (needed by CEK even without the tree-walk shift/reset extension) + if (typeof Continuation === "undefined") { + function Continuation(fn) { this.fn = fn; } + Continuation.prototype._continuation = true; + Continuation.prototype.call = function(value) { return this.fn(value !== undefined ? value : NIL); }; + PRIMITIVES["continuation?"] = function(x) { return x != null && x._continuation === true; }; + } + // Standalone aliases for primitives used by cek.sx / frames.sx var inc = PRIMITIVES["inc"]; var dec = PRIMITIVES["dec"]; @@ -1608,10 +1616,10 @@ PLATFORM_PARSER_JS = r""" // ========================================================================= // Character classification derived from the grammar: // ident-start → [a-zA-Z_~*+\-><=/!?&] - // ident-char → ident-start + [0-9.:\/\[\]#,] + // ident-char → ident-start + [0-9.:\/\#,] var _identStartRe = /[a-zA-Z_~*+\-><=/!?&]/; - var _identCharRe = /[a-zA-Z0-9_~*+\-><=/!?.:&/\[\]#,]/; + var _identCharRe = /[a-zA-Z0-9_~*+\-><=/!?.:&/#,]/; function isIdentStart(ch) { return _identStartRe.test(ch); } function isIdentChar(ch) { return _identCharRe.test(ch); } @@ -2436,6 +2444,10 @@ PLATFORM_ORCHESTRATION_JS = """ } function scheduleIdle(fn) { var cb = _wrapSxFn(fn); + if (typeof cb !== "function") { + console.error("[sx-ref] scheduleIdle: callback not callable, fn type:", typeof fn, "fn:", fn, "_lambda:", fn && fn._lambda); + return; + } if (typeof requestIdleCallback !== "undefined") requestIdleCallback(cb); else setTimeout(cb, 0); } @@ -2525,8 +2537,12 @@ PLATFORM_ORCHESTRATION_JS = """ e.preventDefault(); // Re-read href from element at click time (not closed-over value) var liveHref = el.getAttribute("href") || _href; + console.log("[sx-debug] bindBoostLink click:", liveHref, "el:", el.tagName, el.textContent.slice(0,30)); executeRequest(el, { method: "GET", url: liveHref }).then(function() { + console.log("[sx-debug] boost fetch OK, pushState:", liveHref); try { history.pushState({ sxUrl: liveHref, scrollY: window.scrollY }, "", liveHref); } catch (err) {} + }).catch(function(err) { + console.error("[sx-debug] boost fetch ERROR:", err); }); }); } @@ -2551,21 +2567,25 @@ PLATFORM_ORCHESTRATION_JS = """ // Re-read href from element at click time (not closed-over value) var liveHref = link.getAttribute("href") || _href; var pathname = urlPathname(liveHref); + console.log("[sx-debug] bindClientRouteClick:", pathname, "el:", link.tagName, link.textContent.slice(0,30)); // Find target selector: sx-boost ancestor, explicit sx-target, or #main-panel var boostEl = link.closest("[sx-boost]"); var targetSel = boostEl ? boostEl.getAttribute("sx-boost") : null; if (!targetSel || targetSel === "true") { targetSel = link.getAttribute("sx-target") || "#main-panel"; } + console.log("[sx-debug] targetSel:", targetSel, "trying client route..."); if (tryClientRoute(pathname, targetSel)) { + console.log("[sx-debug] client route SUCCESS, pushState:", liveHref); try { history.pushState({ sxUrl: liveHref, scrollY: window.scrollY }, "", liveHref); } catch (err) {} if (typeof window !== "undefined") window.scrollTo(0, 0); } else { - logInfo("sx:route server " + pathname); + console.log("[sx-debug] client route FAILED, server fetch:", liveHref); executeRequest(link, { method: "GET", url: liveHref }).then(function() { + console.log("[sx-debug] server fetch OK, pushState:", liveHref); try { history.pushState({ sxUrl: liveHref, scrollY: window.scrollY }, "", liveHref); } catch (err) {} }).catch(function(err) { - logWarn("sx:route server fetch error: " + (err && err.message ? err.message : err)); + console.error("[sx-debug] server fetch ERROR:", err); }); } }); diff --git a/shared/sx/ref/platform_py.py b/shared/sx/ref/platform_py.py index 47e1030..c0f1be1 100644 --- a/shared/sx/ref/platform_py.py +++ b/shared/sx/ref/platform_py.py @@ -1038,7 +1038,7 @@ PLATFORM_PARSER_PY = ''' import re as _re_parser _IDENT_START_RE = _re_parser.compile(r"[a-zA-Z_~*+\\-><=/!?&]") -_IDENT_CHAR_RE = _re_parser.compile(r"[a-zA-Z0-9_~*+\\-><=/!?.:&/\\[\\]#,]") +_IDENT_CHAR_RE = _re_parser.compile(r"[a-zA-Z0-9_~*+\\-><=/!?.:&/#,]") def ident_start_p(ch): @@ -1626,7 +1626,7 @@ SPEC_MODULES = { # Explicit ordering for spec modules with dependencies. # Modules listed here are emitted in this order; any not listed use alphabetical. SPEC_MODULE_ORDER = [ - "deps", "engine", "frames", "page-helpers", "router", "signals", "types", "cek", + "deps", "engine", "frames", "page-helpers", "router", "cek", "signals", "types", ] EXTENSION_NAMES = {"continuations"} diff --git a/shared/sx/ref/run_cek_tests.py b/shared/sx/ref/run_cek_tests.py index bdb25bc..9ea2df8 100644 --- a/shared/sx/ref/run_cek_tests.py +++ b/shared/sx/ref/run_cek_tests.py @@ -7,7 +7,7 @@ _HERE = os.path.dirname(os.path.abspath(__file__)) _PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) sys.path.insert(0, _PROJECT) -from shared.sx.parser import parse_all +from shared.sx.ref.sx_ref import sx_parse as parse_all from shared.sx.ref import sx_ref from shared.sx.ref.sx_ref import ( make_env, env_get, env_has, env_set, diff --git a/shared/sx/ref/run_continuation_tests.py b/shared/sx/ref/run_continuation_tests.py index 1164266..80f547a 100644 --- a/shared/sx/ref/run_continuation_tests.py +++ b/shared/sx/ref/run_continuation_tests.py @@ -32,8 +32,8 @@ try: finally: os.unlink(tmp.name) -from shared.sx.parser import parse_all from shared.sx.types import NIL +parse_all = mod.sx_parse # Use tree-walk evaluator for interpreting .sx test files. # CEK is now the default, but test runners need tree-walk so that diff --git a/shared/sx/ref/run_js_sx.py b/shared/sx/ref/run_js_sx.py index 50cbaf9..9a6a1e7 100644 --- a/shared/sx/ref/run_js_sx.py +++ b/shared/sx/ref/run_js_sx.py @@ -190,6 +190,10 @@ def compile_ref_to_js( if has_parser: parts.append(adapter_platform["parser"]) + # CEK platform aliases must come before transpiled cek.sx (which uses them) + if has_cek: + parts.append(PLATFORM_CEK_JS) + # Translate each spec file using js.sx for filename, label in sx_files: filepath = os.path.join(ref_dir, filename) @@ -216,9 +220,6 @@ def compile_ref_to_js( if name in adapter_set and name in adapter_platform: parts.append(adapter_platform[name]) - if has_cek: - parts.append(PLATFORM_CEK_JS) - parts.append(fixups_js(has_html, has_sx, has_dom, has_signals, has_deps, has_page_helpers)) if has_cek: parts.append(CEK_FIXUPS_JS) diff --git a/shared/sx/ref/run_signal_tests.py b/shared/sx/ref/run_signal_tests.py index abcfb5d..3e7e21b 100644 --- a/shared/sx/ref/run_signal_tests.py +++ b/shared/sx/ref/run_signal_tests.py @@ -12,7 +12,7 @@ _HERE = os.path.dirname(os.path.abspath(__file__)) _PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) sys.path.insert(0, _PROJECT) -from shared.sx.parser import parse_all +from shared.sx.ref.sx_ref import sx_parse as parse_all from shared.sx.ref import sx_ref from shared.sx.ref.sx_ref import make_env, scope_push, scope_pop, sx_context from shared.sx.types import NIL, Island, Lambda diff --git a/shared/sx/ref/run_type_tests.py b/shared/sx/ref/run_type_tests.py index 7ff2339..59c39be 100644 --- a/shared/sx/ref/run_type_tests.py +++ b/shared/sx/ref/run_type_tests.py @@ -7,7 +7,7 @@ _HERE = os.path.dirname(os.path.abspath(__file__)) _PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) sys.path.insert(0, _PROJECT) -from shared.sx.parser import parse_all +from shared.sx.ref.sx_ref import sx_parse as parse_all from shared.sx.ref import sx_ref from shared.sx.ref.sx_ref import make_env, env_get, env_has, env_set from shared.sx.types import NIL, Component diff --git a/shared/sx/ref/signals.sx b/shared/sx/ref/signals.sx index 01e1a91..015b26d 100644 --- a/shared/sx/ref/signals.sx +++ b/shared/sx/ref/signals.sx @@ -23,14 +23,11 @@ ;; (scope-pop! "sx-reactive") → void ;; (context "sx-reactive" nil) → dict or nil ;; -;; Runtime callable dispatch: -;; (invoke f &rest args) → any — call f with args; handles both -;; native host functions AND SX lambdas -;; from runtime-evaluated code (islands). -;; Transpiled code emits direct calls -;; f(args) which fail on SX lambdas. -;; invoke goes through the evaluator's -;; dispatch (call-fn) so either works. +;; CEK callable dispatch: +;; (cek-call f args) → any — call f with args list via CEK. +;; Dispatches through cek-run for SX +;; lambdas, apply for native callables. +;; Defined in cek.sx. ;; ;; ========================================================================== @@ -150,7 +147,7 @@ ;; Push scope-based tracking context for this computed (let ((ctx (dict "deps" (list) "notify" recompute))) (scope-push! "sx-reactive" ctx) - (let ((new-val (invoke compute-fn))) + (let ((new-val (cek-call compute-fn nil))) (scope-pop! "sx-reactive") ;; Save discovered deps (signal-set-deps! s (get ctx "deps")) @@ -184,7 +181,7 @@ (fn () (when (not disposed) ;; Run previous cleanup if any - (when cleanup-fn (invoke cleanup-fn)) + (when cleanup-fn (cek-call cleanup-fn nil)) ;; Unsubscribe from old deps (for-each @@ -195,7 +192,7 @@ ;; Push scope-based tracking context (let ((ctx (dict "deps" (list) "notify" run-effect))) (scope-push! "sx-reactive" ctx) - (let ((result (invoke effect-fn))) + (let ((result (cek-call effect-fn nil))) (scope-pop! "sx-reactive") (set! deps (get ctx "deps")) ;; If effect returns a function, it's the cleanup @@ -209,7 +206,7 @@ (let ((dispose-fn (fn () (set! disposed true) - (when cleanup-fn (invoke cleanup-fn)) + (when cleanup-fn (cek-call cleanup-fn nil)) (for-each (fn ((dep :as signal)) (signal-remove-sub! dep run-effect)) deps) @@ -232,7 +229,7 @@ (define batch :effects [mutation] (fn ((thunk :as lambda)) (set! *batch-depth* (+ *batch-depth* 1)) - (invoke thunk) + (cek-call thunk nil) (set! *batch-depth* (- *batch-depth* 1)) (when (= *batch-depth* 0) (let ((queue *batch-queue*)) @@ -323,7 +320,7 @@ (fn ((disposable :as lambda)) (let ((collector (context "sx-island-scope" nil))) (when collector - (invoke collector disposable))))) + (cek-call collector (list disposable)))))) ;; ========================================================================== @@ -362,7 +359,7 @@ ;; Parent island scope and sibling marshes are unaffected. (let ((disposers (dom-get-data marsh-el "sx-marsh-disposers"))) (when disposers - (for-each (fn ((d :as lambda)) (invoke d)) disposers) + (for-each (fn ((d :as lambda)) (cek-call d nil)) disposers) (dom-set-data marsh-el "sx-marsh-disposers" nil))))) @@ -384,7 +381,7 @@ (let ((registry *store-registry*)) ;; Only create the store once — subsequent calls return existing (when (not (has-key? registry name)) - (set! *store-registry* (assoc registry name (invoke init-fn)))) + (set! *store-registry* (assoc registry name (cek-call init-fn nil)))) (get *store-registry* name)))) (define use-store :effects [] @@ -443,7 +440,7 @@ (fn (e) (let ((detail (event-detail e)) (new-val (if transform-fn - (invoke transform-fn detail) + (cek-call transform-fn (list detail)) detail))) (reset! target-signal new-val)))))) ;; Return cleanup — removes listener on dispose/re-run @@ -474,7 +471,7 @@ (fn ((fetch-fn :as lambda)) (let ((state (signal (dict "loading" true "data" nil "error" nil)))) ;; Kick off the async operation - (promise-then (invoke fetch-fn) + (promise-then (cek-call fetch-fn nil) (fn (data) (reset! state (dict "loading" false "data" data "error" nil))) (fn (err) (reset! state (dict "loading" false "data" nil "error" err)))) state))) diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index c9fbd1e..36aefd3 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -952,7 +952,7 @@ char_from_code = PRIMITIVES["char-from-code"] import re as _re_parser _IDENT_START_RE = _re_parser.compile(r"[a-zA-Z_~*+\-><=/!?&]") -_IDENT_CHAR_RE = _re_parser.compile(r"[a-zA-Z0-9_~*+\-><=/!?.:&/\[\]#,]") +_IDENT_CHAR_RE = _re_parser.compile(r"[a-zA-Z0-9_~*+\-><=/!?.:&/#,]") def ident_start_p(ch): @@ -2179,36 +2179,39 @@ def sx_parse(source): _cells['pos'] = 0 len_src = len(source) def skip_comment(): - if sx_truthy(((_cells['pos'] < len_src) if not sx_truthy((_cells['pos'] < len_src)) else (not sx_truthy((nth(source, _cells['pos']) == '\n'))))): - _cells['pos'] = (_cells['pos'] + 1) - return skip_comment() - return NIL + while True: + if sx_truthy(((_cells['pos'] < len_src) if not sx_truthy((_cells['pos'] < len_src)) else (not sx_truthy((nth(source, _cells['pos']) == '\n'))))): + _cells['pos'] = (_cells['pos'] + 1) + continue + break def skip_ws(): - if sx_truthy((_cells['pos'] < len_src)): - ch = nth(source, _cells['pos']) - if sx_truthy(((ch == ' ') if sx_truthy((ch == ' ')) else ((ch == '\t') if sx_truthy((ch == '\t')) else ((ch == '\n') if sx_truthy((ch == '\n')) else (ch == '\r'))))): - _cells['pos'] = (_cells['pos'] + 1) - return skip_ws() - elif sx_truthy((ch == ';')): - _cells['pos'] = (_cells['pos'] + 1) - skip_comment() - return skip_ws() - else: - return NIL - return NIL + while True: + if sx_truthy((_cells['pos'] < len_src)): + ch = nth(source, _cells['pos']) + if sx_truthy(((ch == ' ') if sx_truthy((ch == ' ')) else ((ch == '\t') if sx_truthy((ch == '\t')) else ((ch == '\n') if sx_truthy((ch == '\n')) else (ch == '\r'))))): + _cells['pos'] = (_cells['pos'] + 1) + continue + elif sx_truthy((ch == ';')): + _cells['pos'] = (_cells['pos'] + 1) + skip_comment() + continue + else: + break + break def hex_digit_value(ch): return index_of('0123456789abcdef', lower(ch)) def read_string(): _cells['pos'] = (_cells['pos'] + 1) _cells['buf'] = '' - def read_str_loop(): + while True: if sx_truthy((_cells['pos'] >= len_src)): - return error('Unterminated string') + error('Unterminated string') + break else: ch = nth(source, _cells['pos']) if sx_truthy((ch == '"')): _cells['pos'] = (_cells['pos'] + 1) - return NIL + break elif sx_truthy((ch == '\\')): _cells['pos'] = (_cells['pos'] + 1) esc = nth(source, _cells['pos']) @@ -2223,25 +2226,23 @@ def sx_parse(source): d3 = hex_digit_value(nth(source, _cells['pos'])) _ = _sx_cell_set(_cells, 'pos', (_cells['pos'] + 1)) _cells['buf'] = sx_str(_cells['buf'], char_from_code(((d0 * 4096) + (d1 * 256)))) - return read_str_loop() + continue else: _cells['buf'] = sx_str(_cells['buf'], ('\n' if sx_truthy((esc == 'n')) else ('\t' if sx_truthy((esc == 't')) else ('\r' if sx_truthy((esc == 'r')) else esc)))) _cells['pos'] = (_cells['pos'] + 1) - return read_str_loop() + continue else: _cells['buf'] = sx_str(_cells['buf'], ch) _cells['pos'] = (_cells['pos'] + 1) - return read_str_loop() - read_str_loop() + continue return _cells['buf'] def read_ident(): start = _cells['pos'] - def read_ident_loop(): + while True: if sx_truthy(((_cells['pos'] < len_src) if not sx_truthy((_cells['pos'] < len_src)) else ident_char_p(nth(source, _cells['pos'])))): _cells['pos'] = (_cells['pos'] + 1) - return read_ident_loop() - return NIL - read_ident_loop() + continue + break return slice(source, start, _cells['pos']) def read_keyword(): _cells['pos'] = (_cells['pos'] + 1) @@ -2251,10 +2252,11 @@ def sx_parse(source): if sx_truthy(((_cells['pos'] < len_src) if not sx_truthy((_cells['pos'] < len_src)) else (nth(source, _cells['pos']) == '-'))): _cells['pos'] = (_cells['pos'] + 1) def read_digits(): - if sx_truthy(((_cells['pos'] < len_src) if not sx_truthy((_cells['pos'] < len_src)) else (lambda c: ((c >= '0') if not sx_truthy((c >= '0')) else (c <= '9')))(nth(source, _cells['pos'])))): - _cells['pos'] = (_cells['pos'] + 1) - return read_digits() - return NIL + while True: + if sx_truthy(((_cells['pos'] < len_src) if not sx_truthy((_cells['pos'] < len_src)) else (lambda c: ((c >= '0') if not sx_truthy((c >= '0')) else (c <= '9')))(nth(source, _cells['pos'])))): + _cells['pos'] = (_cells['pos'] + 1) + continue + break read_digits() if sx_truthy(((_cells['pos'] < len_src) if not sx_truthy((_cells['pos'] < len_src)) else (nth(source, _cells['pos']) == '.'))): _cells['pos'] = (_cells['pos'] + 1) @@ -2277,52 +2279,52 @@ def sx_parse(source): return make_symbol(name) def read_list(close_ch): items = [] - def read_list_loop(): + while True: skip_ws() if sx_truthy((_cells['pos'] >= len_src)): - return error('Unterminated list') + error('Unterminated list') + break else: if sx_truthy((nth(source, _cells['pos']) == close_ch)): _cells['pos'] = (_cells['pos'] + 1) - return NIL + break else: items.append(read_expr()) - return read_list_loop() - read_list_loop() + continue return items def read_map(): result = {} - def read_map_loop(): + while True: skip_ws() if sx_truthy((_cells['pos'] >= len_src)): - return error('Unterminated map') + error('Unterminated map') + break else: if sx_truthy((nth(source, _cells['pos']) == '}')): _cells['pos'] = (_cells['pos'] + 1) - return NIL + break else: key_expr = read_expr() key_str = (keyword_name(key_expr) if sx_truthy((type_of(key_expr) == 'keyword')) else sx_str(key_expr)) val_expr = read_expr() result[key_str] = val_expr - return read_map_loop() - read_map_loop() + continue return result def read_raw_string(): _cells['buf'] = '' - def raw_loop(): + while True: if sx_truthy((_cells['pos'] >= len_src)): - return error('Unterminated raw string') + error('Unterminated raw string') + break else: ch = nth(source, _cells['pos']) if sx_truthy((ch == '|')): _cells['pos'] = (_cells['pos'] + 1) - return NIL + break else: _cells['buf'] = sx_str(_cells['buf'], ch) _cells['pos'] = (_cells['pos'] + 1) - return raw_loop() - raw_loop() + continue return _cells['buf'] def read_expr(): skip_ws() @@ -2343,6 +2345,9 @@ def sx_parse(source): return read_string() elif sx_truthy((ch == ':')): return read_keyword() + elif sx_truthy((ch == "'")): + _cells['pos'] = (_cells['pos'] + 1) + return [make_symbol('quote'), read_expr()] elif sx_truthy((ch == '`')): _cells['pos'] = (_cells['pos'] + 1) return [make_symbol('quasiquote'), read_expr()] @@ -2388,13 +2393,12 @@ def sx_parse(source): else: return error(sx_str('Unexpected character: ', ch)) exprs = [] - def parse_loop(): + while True: skip_ws() if sx_truthy((_cells['pos'] < len_src)): exprs.append(read_expr()) - return parse_loop() - return NIL - parse_loop() + continue + break return exprs # sx-serialize @@ -3862,234 +3866,6 @@ def prepare_url_expr(url_path, env): return auto_quote_unknowns(expr, env) -# === Transpiled from signals (reactive signal runtime) === - -# make-signal -def make_signal(value): - return {'__signal': True, 'value': value, 'subscribers': [], 'deps': []} - -# signal? -def is_signal(x): - return (dict_p(x) if not sx_truthy(dict_p(x)) else has_key_p(x, '__signal')) - -# signal-value -def signal_value(s): - return get(s, 'value') - -# signal-set-value! -def signal_set_value(s, v): - return _sx_dict_set(s, 'value', v) - -# signal-subscribers -def signal_subscribers(s): - return get(s, 'subscribers') - -# signal-add-sub! -def signal_add_sub(s, f): - if sx_truthy((not sx_truthy(contains_p(get(s, 'subscribers'), f)))): - return _sx_append(get(s, 'subscribers'), f) - return NIL - -# signal-remove-sub! -def signal_remove_sub(s, f): - return _sx_dict_set(s, 'subscribers', filter(lambda sub: (not sx_truthy(is_identical(sub, f))), get(s, 'subscribers'))) - -# signal-deps -def signal_deps(s): - return get(s, 'deps') - -# signal-set-deps! -def signal_set_deps(s, deps): - return _sx_dict_set(s, 'deps', deps) - -# signal -def signal(initial_value): - return make_signal(initial_value) - -# deref -def deref(s): - if sx_truthy((not sx_truthy(is_signal(s)))): - return s - else: - ctx = sx_context('sx-reactive', NIL) - if sx_truthy(ctx): - dep_list = get(ctx, 'deps') - notify_fn = get(ctx, 'notify') - if sx_truthy((not sx_truthy(contains_p(dep_list, s)))): - dep_list.append(s) - signal_add_sub(s, notify_fn) - return signal_value(s) - -# reset! -def reset_b(s, value): - if sx_truthy(is_signal(s)): - old = signal_value(s) - if sx_truthy((not sx_truthy(is_identical(old, value)))): - signal_set_value(s, value) - return notify_subscribers(s) - return NIL - return NIL - -# swap! -def swap_b(s, f, *args): - if sx_truthy(is_signal(s)): - old = signal_value(s) - new_val = apply(f, cons(old, args)) - if sx_truthy((not sx_truthy(is_identical(old, new_val)))): - signal_set_value(s, new_val) - return notify_subscribers(s) - return NIL - return NIL - -# computed -def computed(compute_fn): - s = make_signal(NIL) - deps = [] - compute_ctx = NIL - recompute = _sx_fn(lambda : ( - for_each(lambda dep: signal_remove_sub(dep, recompute), signal_deps(s)), - signal_set_deps(s, []), - (lambda ctx: _sx_begin(scope_push('sx-reactive', ctx), (lambda new_val: _sx_begin(scope_pop('sx-reactive'), signal_set_deps(s, get(ctx, 'deps')), (lambda old: _sx_begin(signal_set_value(s, new_val), (notify_subscribers(s) if sx_truthy((not sx_truthy(is_identical(old, new_val)))) else NIL)))(signal_value(s))))(invoke(compute_fn))))({'deps': [], 'notify': recompute}) -)[-1]) - recompute() - register_in_scope(lambda : dispose_computed(s)) - return s - -# effect -def effect(effect_fn): - _cells = {} - _cells['deps'] = [] - _cells['disposed'] = False - _cells['cleanup_fn'] = NIL - run_effect = lambda : (_sx_begin((invoke(_cells['cleanup_fn']) if sx_truthy(_cells['cleanup_fn']) else NIL), for_each(lambda dep: signal_remove_sub(dep, run_effect), _cells['deps']), _sx_cell_set(_cells, 'deps', []), (lambda ctx: _sx_begin(scope_push('sx-reactive', ctx), (lambda result: _sx_begin(scope_pop('sx-reactive'), _sx_cell_set(_cells, 'deps', get(ctx, 'deps')), (_sx_cell_set(_cells, 'cleanup_fn', result) if sx_truthy(is_callable(result)) else NIL)))(invoke(effect_fn))))({'deps': [], 'notify': run_effect})) if sx_truthy((not sx_truthy(_cells['disposed']))) else NIL) - run_effect() - dispose_fn = _sx_fn(lambda : ( - _sx_cell_set(_cells, 'disposed', True), - (invoke(_cells['cleanup_fn']) if sx_truthy(_cells['cleanup_fn']) else NIL), - for_each(lambda dep: signal_remove_sub(dep, run_effect), _cells['deps']), - _sx_cell_set(_cells, 'deps', []) -)[-1]) - register_in_scope(dispose_fn) - return dispose_fn - -# *batch-depth* -_batch_depth = 0 - -# *batch-queue* -_batch_queue = [] - -# batch -def batch(thunk): - _batch_depth = (_batch_depth + 1) - invoke(thunk) - _batch_depth = (_batch_depth - 1) - if sx_truthy((_batch_depth == 0)): - queue = _batch_queue - _batch_queue = [] - seen = [] - pending = [] - for s in queue: - for sub in signal_subscribers(s): - if sx_truthy((not sx_truthy(contains_p(seen, sub)))): - seen.append(sub) - pending.append(sub) - for sub in pending: - sub() - return NIL - return NIL - -# notify-subscribers -def notify_subscribers(s): - if sx_truthy((_batch_depth > 0)): - if sx_truthy((not sx_truthy(contains_p(_batch_queue, s)))): - return _sx_append(_batch_queue, s) - return NIL - else: - return flush_subscribers(s) - -# flush-subscribers -def flush_subscribers(s): - for sub in signal_subscribers(s): - sub() - return NIL - -# dispose-computed -def dispose_computed(s): - if sx_truthy(is_signal(s)): - for dep in signal_deps(s): - signal_remove_sub(dep, NIL) - return signal_set_deps(s, []) - return NIL - -# with-island-scope -def with_island_scope(scope_fn, body_fn): - scope_push('sx-island-scope', scope_fn) - result = body_fn() - scope_pop('sx-island-scope') - return result - -# register-in-scope -def register_in_scope(disposable): - collector = sx_context('sx-island-scope', NIL) - if sx_truthy(collector): - return invoke(collector, disposable) - return NIL - -# with-marsh-scope -def with_marsh_scope(marsh_el, body_fn): - disposers = [] - with_island_scope(lambda d: _sx_append(disposers, d), body_fn) - return dom_set_data(marsh_el, 'sx-marsh-disposers', disposers) - -# dispose-marsh-scope -def dispose_marsh_scope(marsh_el): - disposers = dom_get_data(marsh_el, 'sx-marsh-disposers') - if sx_truthy(disposers): - for d in disposers: - invoke(d) - return dom_set_data(marsh_el, 'sx-marsh-disposers', NIL) - return NIL - -# *store-registry* -_store_registry = {} - -# def-store -def def_store(name, init_fn): - registry = _store_registry - if sx_truthy((not sx_truthy(has_key_p(registry, name)))): - _store_registry = assoc(registry, name, invoke(init_fn)) - return get(_store_registry, name) - -# use-store -def use_store(name): - if sx_truthy(has_key_p(_store_registry, name)): - return get(_store_registry, name) - else: - return error(sx_str('Store not found: ', name, '. Call (def-store ...) before (use-store ...).')) - -# clear-stores -def clear_stores(): - return _sx_cell_set(_cells, '_store_registry', {}) - -# emit-event -def emit_event(el, event_name, detail): - return dom_dispatch(el, event_name, detail) - -# on-event -def on_event(el, event_name, handler): - return dom_listen(el, event_name, handler) - -# bridge-event -def bridge_event(el, event_name, target_signal, transform_fn): - return effect(lambda : (lambda remove: remove)(dom_listen(el, event_name, lambda e: (lambda detail: (lambda new_val: reset_b(target_signal, new_val))((invoke(transform_fn, detail) if sx_truthy(transform_fn) else detail)))(event_detail(e))))) - -# resource -def resource(fetch_fn): - state = signal({'loading': True, 'data': NIL, 'error': NIL}) - promise_then(invoke(fetch_fn), lambda data: reset_b(state, {'loading': False, 'data': data, 'error': NIL}), lambda err: reset_b(state, {'loading': False, 'data': NIL, 'error': err})) - return state - - # === Transpiled from cek (explicit CEK machine evaluator) === # cek-run @@ -4371,6 +4147,18 @@ def step_sf_shift(args, env, kont): def step_sf_deref(args, env, kont): return make_cek_state(first(args), env, kont_push(make_deref_frame(env), kont)) +# cek-call +def cek_call(f, args): + a = ([] if sx_truthy(is_nil(args)) else args) + if sx_truthy(is_nil(f)): + return NIL + elif sx_truthy(is_lambda(f)): + return cek_run(continue_with_call(f, a, {}, a, [])) + elif sx_truthy(is_callable(f)): + return apply(f, a) + else: + return NIL + # reactive-shift-deref def reactive_shift_deref(sig, env, kont): _cells = {} @@ -4381,14 +4169,14 @@ def reactive_shift_deref(sig, env, kont): update_fn = get(reset_frame, 'update-fn') _cells['sub_disposers'] = [] subscriber = _sx_fn(lambda : ( - for_each(lambda d: invoke(d), _cells['sub_disposers']), + for_each(lambda d: cek_call(d, NIL), _cells['sub_disposers']), _sx_cell_set(_cells, 'sub_disposers', []), (lambda new_reset: (lambda new_kont: with_island_scope(lambda d: _sx_append(_cells['sub_disposers'], d), lambda : cek_run(make_cek_value(signal_value(sig), env, new_kont))))(concat(captured_frames, [new_reset], remaining_kont)))(make_reactive_reset_frame(env, update_fn, False)) )[-1]) signal_add_sub(sig, subscriber) register_in_scope(_sx_fn(lambda : ( signal_remove_sub(sig, subscriber), - for_each(lambda d: invoke(d), _cells['sub_disposers']) + for_each(lambda d: cek_call(d, NIL), _cells['sub_disposers']) )[-1])) initial_kont = concat(captured_frames, [reset_frame], remaining_kont) return make_cek_value(signal_value(sig), env, initial_kont) @@ -4610,7 +4398,7 @@ def step_continue(state): update_fn = get(frame, 'update-fn') first_p = get(frame, 'first-render') if sx_truthy((update_fn if not sx_truthy(update_fn) else (not sx_truthy(first_p)))): - invoke(update_fn, value) + cek_call(update_fn, [value]) return make_cek_value(value, env, rest_k) elif sx_truthy((ft == 'scope')): name = get(frame, 'name') @@ -4686,6 +4474,234 @@ def trampoline_cek(val): return val +# === Transpiled from signals (reactive signal runtime) === + +# make-signal +def make_signal(value): + return {'__signal': True, 'value': value, 'subscribers': [], 'deps': []} + +# signal? +def is_signal(x): + return (dict_p(x) if not sx_truthy(dict_p(x)) else has_key_p(x, '__signal')) + +# signal-value +def signal_value(s): + return get(s, 'value') + +# signal-set-value! +def signal_set_value(s, v): + return _sx_dict_set(s, 'value', v) + +# signal-subscribers +def signal_subscribers(s): + return get(s, 'subscribers') + +# signal-add-sub! +def signal_add_sub(s, f): + if sx_truthy((not sx_truthy(contains_p(get(s, 'subscribers'), f)))): + return _sx_append(get(s, 'subscribers'), f) + return NIL + +# signal-remove-sub! +def signal_remove_sub(s, f): + return _sx_dict_set(s, 'subscribers', filter(lambda sub: (not sx_truthy(is_identical(sub, f))), get(s, 'subscribers'))) + +# signal-deps +def signal_deps(s): + return get(s, 'deps') + +# signal-set-deps! +def signal_set_deps(s, deps): + return _sx_dict_set(s, 'deps', deps) + +# signal +def signal(initial_value): + return make_signal(initial_value) + +# deref +def deref(s): + if sx_truthy((not sx_truthy(is_signal(s)))): + return s + else: + ctx = sx_context('sx-reactive', NIL) + if sx_truthy(ctx): + dep_list = get(ctx, 'deps') + notify_fn = get(ctx, 'notify') + if sx_truthy((not sx_truthy(contains_p(dep_list, s)))): + dep_list.append(s) + signal_add_sub(s, notify_fn) + return signal_value(s) + +# reset! +def reset_b(s, value): + if sx_truthy(is_signal(s)): + old = signal_value(s) + if sx_truthy((not sx_truthy(is_identical(old, value)))): + signal_set_value(s, value) + return notify_subscribers(s) + return NIL + return NIL + +# swap! +def swap_b(s, f, *args): + if sx_truthy(is_signal(s)): + old = signal_value(s) + new_val = apply(f, cons(old, args)) + if sx_truthy((not sx_truthy(is_identical(old, new_val)))): + signal_set_value(s, new_val) + return notify_subscribers(s) + return NIL + return NIL + +# computed +def computed(compute_fn): + s = make_signal(NIL) + deps = [] + compute_ctx = NIL + recompute = _sx_fn(lambda : ( + for_each(lambda dep: signal_remove_sub(dep, recompute), signal_deps(s)), + signal_set_deps(s, []), + (lambda ctx: _sx_begin(scope_push('sx-reactive', ctx), (lambda new_val: _sx_begin(scope_pop('sx-reactive'), signal_set_deps(s, get(ctx, 'deps')), (lambda old: _sx_begin(signal_set_value(s, new_val), (notify_subscribers(s) if sx_truthy((not sx_truthy(is_identical(old, new_val)))) else NIL)))(signal_value(s))))(cek_call(compute_fn, NIL))))({'deps': [], 'notify': recompute}) +)[-1]) + recompute() + register_in_scope(lambda : dispose_computed(s)) + return s + +# effect +def effect(effect_fn): + _cells = {} + _cells['deps'] = [] + _cells['disposed'] = False + _cells['cleanup_fn'] = NIL + run_effect = lambda : (_sx_begin((cek_call(_cells['cleanup_fn'], NIL) if sx_truthy(_cells['cleanup_fn']) else NIL), for_each(lambda dep: signal_remove_sub(dep, run_effect), _cells['deps']), _sx_cell_set(_cells, 'deps', []), (lambda ctx: _sx_begin(scope_push('sx-reactive', ctx), (lambda result: _sx_begin(scope_pop('sx-reactive'), _sx_cell_set(_cells, 'deps', get(ctx, 'deps')), (_sx_cell_set(_cells, 'cleanup_fn', result) if sx_truthy(is_callable(result)) else NIL)))(cek_call(effect_fn, NIL))))({'deps': [], 'notify': run_effect})) if sx_truthy((not sx_truthy(_cells['disposed']))) else NIL) + run_effect() + dispose_fn = _sx_fn(lambda : ( + _sx_cell_set(_cells, 'disposed', True), + (cek_call(_cells['cleanup_fn'], NIL) if sx_truthy(_cells['cleanup_fn']) else NIL), + for_each(lambda dep: signal_remove_sub(dep, run_effect), _cells['deps']), + _sx_cell_set(_cells, 'deps', []) +)[-1]) + register_in_scope(dispose_fn) + return dispose_fn + +# *batch-depth* +_batch_depth = 0 + +# *batch-queue* +_batch_queue = [] + +# batch +def batch(thunk): + _batch_depth = (_batch_depth + 1) + cek_call(thunk, NIL) + _batch_depth = (_batch_depth - 1) + if sx_truthy((_batch_depth == 0)): + queue = _batch_queue + _batch_queue = [] + seen = [] + pending = [] + for s in queue: + for sub in signal_subscribers(s): + if sx_truthy((not sx_truthy(contains_p(seen, sub)))): + seen.append(sub) + pending.append(sub) + for sub in pending: + sub() + return NIL + return NIL + +# notify-subscribers +def notify_subscribers(s): + if sx_truthy((_batch_depth > 0)): + if sx_truthy((not sx_truthy(contains_p(_batch_queue, s)))): + return _sx_append(_batch_queue, s) + return NIL + else: + return flush_subscribers(s) + +# flush-subscribers +def flush_subscribers(s): + for sub in signal_subscribers(s): + sub() + return NIL + +# dispose-computed +def dispose_computed(s): + if sx_truthy(is_signal(s)): + for dep in signal_deps(s): + signal_remove_sub(dep, NIL) + return signal_set_deps(s, []) + return NIL + +# with-island-scope +def with_island_scope(scope_fn, body_fn): + scope_push('sx-island-scope', scope_fn) + result = body_fn() + scope_pop('sx-island-scope') + return result + +# register-in-scope +def register_in_scope(disposable): + collector = sx_context('sx-island-scope', NIL) + if sx_truthy(collector): + return cek_call(collector, [disposable]) + return NIL + +# with-marsh-scope +def with_marsh_scope(marsh_el, body_fn): + disposers = [] + with_island_scope(lambda d: _sx_append(disposers, d), body_fn) + return dom_set_data(marsh_el, 'sx-marsh-disposers', disposers) + +# dispose-marsh-scope +def dispose_marsh_scope(marsh_el): + disposers = dom_get_data(marsh_el, 'sx-marsh-disposers') + if sx_truthy(disposers): + for d in disposers: + cek_call(d, NIL) + return dom_set_data(marsh_el, 'sx-marsh-disposers', NIL) + return NIL + +# *store-registry* +_store_registry = {} + +# def-store +def def_store(name, init_fn): + registry = _store_registry + if sx_truthy((not sx_truthy(has_key_p(registry, name)))): + _store_registry = assoc(registry, name, cek_call(init_fn, NIL)) + return get(_store_registry, name) + +# use-store +def use_store(name): + if sx_truthy(has_key_p(_store_registry, name)): + return get(_store_registry, name) + else: + return error(sx_str('Store not found: ', name, '. Call (def-store ...) before (use-store ...).')) + +# clear-stores +def clear_stores(): + return _sx_cell_set(_cells, '_store_registry', {}) + +# emit-event +def emit_event(el, event_name, detail): + return dom_dispatch(el, event_name, detail) + +# on-event +def on_event(el, event_name, handler): + return dom_listen(el, event_name, handler) + +# bridge-event +def bridge_event(el, event_name, target_signal, transform_fn): + return effect(lambda : (lambda remove: remove)(dom_listen(el, event_name, lambda e: (lambda detail: (lambda new_val: reset_b(target_signal, new_val))((cek_call(transform_fn, [detail]) if sx_truthy(transform_fn) else detail)))(event_detail(e))))) + +# resource +def resource(fetch_fn): + state = signal({'loading': True, 'data': NIL, 'error': NIL}) + promise_then(cek_call(fetch_fn, NIL), lambda data: reset_b(state, {'loading': False, 'data': data, 'error': NIL}), lambda err: reset_b(state, {'loading': False, 'data': NIL, 'error': err})) + return state + + # === Transpiled from adapter-async === # async-render diff --git a/shared/sx/types.py b/shared/sx/types.py index b5e325f..d581cb0 100644 --- a/shared/sx/types.py +++ b/shared/sx/types.py @@ -401,6 +401,43 @@ class EvalError(Exception): pass +# --------------------------------------------------------------------------- +# SxExpr +# --------------------------------------------------------------------------- + +class SxExpr(str): + """Pre-built sx source that serialize() outputs unquoted. + + ``SxExpr`` is a ``str`` subclass, so it works everywhere a plain + string does (join, startswith, f-strings, isinstance checks). The + only difference: ``serialize()`` emits it unquoted instead of + wrapping it in double-quotes. + + Use this to nest sx call strings inside other sx_call() invocations + without them being quoted as strings:: + + sx_call("parent", child=sx_call("child", x=1)) + # => (~parent :child (~child :x 1)) + """ + + def __new__(cls, source: str = "") -> "SxExpr": + return str.__new__(cls, source) + + @property + def source(self) -> str: + """The raw SX source string (backward compat).""" + return str.__str__(self) + + def __repr__(self) -> str: + return f"SxExpr({str.__repr__(self)})" + + def __add__(self, other: object) -> "SxExpr": + return SxExpr(str.__add__(self, str(other))) + + def __radd__(self, other: object) -> "SxExpr": + return SxExpr(str.__add__(str(other), self)) + + # --------------------------------------------------------------------------- # Type alias # --------------------------------------------------------------------------- diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index c87837a..54f49d9 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -236,7 +236,9 @@ (dict :label "Deref as Shift" :href "/sx/(etc.(plan.cek-reactive))" :summary "Phase B: replace explicit effect wrapping with implicit continuation capture. Deref inside reactive-reset performs shift, capturing the rest of the expression as the subscriber.") (dict :label "Reactive Runtime" :href "/sx/(etc.(plan.reactive-runtime))" - :summary "Seven feature layers — ref, foreign FFI, state machines, commands with undo/redo, render loops, keyed lists, client-first app shell. Zero new platform primitives."))) + :summary "Seven feature layers — ref, foreign FFI, state machines, commands with undo/redo, render loops, keyed lists, client-first app shell. Zero new platform primitives.") + (dict :label "Rust/WASM Host" :href "/sx/(etc.(plan.rust-wasm-host))" + :summary "Bootstrap the SX spec to Rust, compile to WASM, replace sx-browser.js. Shared platform layer for DOM, phased rollout from parse to full parity."))) (define reactive-islands-nav-items (list (dict :label "Overview" :href "/sx/(geography.(reactive))" diff --git a/sx/sx/page-functions.sx b/sx/sx/page-functions.sx index b730508..ef26392 100644 --- a/sx/sx/page-functions.sx +++ b/sx/sx/page-functions.sx @@ -60,6 +60,18 @@ "phase2" '(~reactive-islands/phase2/reactive-islands-phase2-content) :else '(~reactive-islands/index/reactive-islands-index-content))))) +(define cek + (fn (slug) + (if (nil? slug) + '(~geography/cek/cek-content) + (case slug + "demo" '(~geography/cek/cek-demo-content) + :else '(~geography/cek/cek-content))))) + +(define provide + (fn (content) + (if (nil? content) '(~geography/provide-content) content))) + (define scopes (fn (content) (if (nil? content) '(~geography/scopes-content) content))) diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index 9586cb1..c7240d5 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -580,6 +580,7 @@ "foundations" (~plans/foundations/plan-foundations-content) "cek-reactive" (~plans/cek-reactive/plan-cek-reactive-content) "reactive-runtime" (~plans/reactive-runtime/plan-reactive-runtime-content) + "rust-wasm-host" (~plans/rust-wasm-host/plan-rust-wasm-host-content) :else (~plans/index/plans-index-content)))) ;; ---------------------------------------------------------------------------