17 Commits

Author SHA1 Message Date
72148fa4c0 Add Separation of Concerns essay
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
The web's HTML/CSS/JS split separates the framework's concerns,
not the application domain's. Real separation of concerns is
domain-specific and cannot be prescribed by a platform.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:24:27 +00:00
84f66557df Add production deploy warning to CLAUDE.md
Pushing to main triggers a production deploy — make this explicit
in the deployment section so it's never done accidentally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:18:06 +00:00
6f403c0c2d Add server-side test runner to /specs/testing page
Python evaluator runs test.sx at page load, results shown alongside
the browser runner. Both hosts prove the same 81 tests from the same
spec file — server on render, client on click.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:10:24 +00:00
9b3b2ea224 Add testing section to Strange Loops essay
SX testing SX is the strange loop made concrete — the language proves
its own correctness using its own macros. Links to /specs/testing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:06:06 +00:00
bec881acb3 Fix asset-url: use Jinja global instead of nonexistent urls.asset_url
The IO handler and bridge both imported asset_url from
shared.infrastructure.urls, but it doesn't exist there — it's a Jinja
global defined in jinja_setup.py. Use current_app.jinja_env.globals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:03:46 +00:00
7eb158c79f Add live browser test runner to /specs/testing page
sx-browser.js evaluates test.sx directly in the browser — click
"Run 81 tests" to see SX test itself. Uses the same Sx global that
rendered the page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:00:37 +00:00
e9d86d628b Make test.sx self-executing: evaluators run it directly, no codegen
test.sx now defines deftest/defsuite as macros. Any host that provides
5 platform functions (try-call, report-pass, report-fail, push-suite,
pop-suite) can evaluate the file directly — no bootstrap compilation
step needed for JS.

- Added defmacro for deftest (wraps body in thunk, catches via try-call)
- Added defmacro for defsuite (push/pop suite context stack)
- Created run.js: sx-browser.js evaluates test.sx directly (81/81 pass)
- Created run.py: Python evaluator evaluates test.sx directly (81/81 pass)
- Deleted bootstrap_test_js.py and generated test_sx_spec.js
- Updated testing docs page to reflect self-executing architecture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:50:28 +00:00
754e7557f5 Add self-hosting SX test spec: 81 tests bootstrap to Python + JS
The test framework is written in SX and tests SX — the language proves
its own correctness. test.sx defines assertion helpers (assert-equal,
assert-true, assert-type, etc.) and 15 test suites covering literals,
arithmetic, comparison, strings, lists, dicts, predicates, special forms,
lambdas, higher-order forms, components, macros, threading, truthiness,
and edge cases.

Two bootstrap compilers emit native tests from the same spec:
- bootstrap_test.py → pytest (81/81 pass)
- bootstrap_test_js.py → Node.js TAP using sx-browser.js (81/81 pass)

Also adds missing primitives to spec and Python evaluator: boolean?,
string-length, substring, string-contains?, upcase, downcase, reverse,
flatten, has-key?. Fixes number? to exclude booleans, append to
concatenate lists.

Includes testing docs page in SX app at /specs/testing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:41:53 +00:00
e09bc3b601 Fix test_sx_js: temp file for large scripts, globalThis for Node file mode
sx-browser.js grew past OS arg length limit for node -e. Write to
temp file instead. Also fix Sx global scope: Node file mode sets
`this` to module.exports, not globalThis, so the IIFE wrapper needs
.call(globalThis) to make Sx accessible to sx-test.js.

855 passed (2 pre-existing empty .sx file failures).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:06:00 +00:00
8366088ee1 Add Phase 5 unit tests: IO proxy, io-deps registry, SxExpr roundtrip
22 tests covering:
- io-deps page registry field (pure, IO, transitive, serialization)
- Dynamic IO allowlist construction from component io_refs
- SxExpr serialize→parse roundtrip (unquoted, fragments, nil, in-dict)
- IO proxy arg parsing (GET query string vs POST JSON body)
- Orchestration routing logic (io-deps truthiness, parsed entries)
- IO cache key determinism

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:01:35 +00:00
84ea5d4c16 IO proxy: client-side cache with 5min TTL, server Cache-Control
Client caches IO results by (name + args) in memory. In-flight
promises are cached too (dedup concurrent calls for same args).
Server adds Cache-Control: public, max-age=300 for HTTP caching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:51:51 +00:00
0d6b959045 Mark IO proxy endpoint as CSRF-exempt (read-only, no state mutation)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:50:23 +00:00
ff2ef29d8a Fix async map: use Lambda.params/body/closure (not _params/_body/_closure)
The Lambda constructor stores properties without underscore prefix,
but asyncRenderMap/asyncRenderMapIndexed accessed them with underscores.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:40:23 +00:00
aa67b036c7 IO proxy: POST for long payloads, network error resilience
- Switch to POST with JSON body when query string exceeds 1500 chars
  (highlight calls with large component sources hit URL length limits)
- Include CSRF token header on POST requests
- Add .catch() on fetch to gracefully handle network errors (return NIL)
- Upgrade async eval miss logs from logInfo to logWarn for visibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:23:20 +00:00
cb0990feb3 Dynamic IO proxy: derive proxied primitives from component io_refs
Replace hardcoded IO primitive lists on both client and server with
data-driven registration. Page registry entries carry :io-deps (list
of IO primitive names) instead of :has-io boolean. Client registers
proxied IO on demand per page via registerIoDeps(). Server builds
allowlist from component analysis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:13:53 +00:00
c2a85ed026 Fix async IO demo: use ~doc-code instead of raw!, fix JS highlight
highlight returns SxExpr (SX source with colored spans), not raw HTML.
Must render via evaluator (~doc-code :code), not (raw! ...). Also
replace JavaScript example with SX (no JS highlighter exists).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 08:48:48 +00:00
79fa1411dc Phase 5: async IO rendering — components call IO primitives client-side
Wire async rendering into client-side routing: pages whose component
trees reference IO primitives (highlight, current-user, etc.) now
render client-side via Promise-aware asyncRenderToDom. IO calls proxy
through /sx/io/<name> endpoint, which falls back to page helpers.

- Add has-io flag to page registry entries (helpers.py)
- Remove IO purity filter — include IO-dependent components in bundles
- Extend try-client-route with 4 paths: pure, data, IO, data+IO
- Convert tryAsyncEvalContent to callback style, add platform mapping
- IO proxy falls back to page helpers (highlight works via proxy)
- Demo page: /isomorphism/async-io with inline highlight calls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 08:12:42 +00:00
25 changed files with 4061 additions and 278 deletions

View File

@@ -5,6 +5,7 @@ Cooperative web platform: federated content, commerce, events, and media process
## Deployment
- **Do NOT push** until explicitly told to. Pushes reload code to dev automatically.
- **NEVER push to `main`** — pushing to main triggers a **PRODUCTION deploy**. Only push to main when the user explicitly requests a production deploy. Work on the `macros` branch by default; merge to main only with explicit permission.
## Project Structure

View File

@@ -14,7 +14,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-07T02:10:28Z";
var SX_VERSION = "2026-03-07T09:51:42Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -565,92 +565,6 @@
return makeThunk(componentBody(comp), local);
};
// =========================================================================
// Platform: deps module — component dependency analysis
// =========================================================================
function componentDeps(c) {
return c.deps ? c.deps.slice() : [];
}
function componentSetDeps(c, deps) {
c.deps = deps;
}
function componentCssClasses(c) {
return c.cssClasses ? c.cssClasses.slice() : [];
}
function envComponents(env) {
var names = [];
for (var k in env) {
var v = env[k];
if (v && (v._component || v._macro)) names.push(k);
}
return names;
}
function regexFindAll(pattern, source) {
var re = new RegExp(pattern, "g");
var results = [];
var m;
while ((m = re.exec(source)) !== null) {
if (m[1] !== undefined) results.push(m[1]);
else results.push(m[0]);
}
return results;
}
function scanCssClasses(source) {
var classes = {};
var result = [];
var m;
var re1 = /:class\s+"([^"]*)"/g;
while ((m = re1.exec(source)) !== null) {
var parts = m[1].split(/\s+/);
for (var i = 0; i < parts.length; i++) {
if (parts[i] && !classes[parts[i]]) {
classes[parts[i]] = true;
result.push(parts[i]);
}
}
}
var re2 = /:class\s+\(str\s+((?:"[^"]*"\s*)+)\)/g;
while ((m = re2.exec(source)) !== null) {
var re3 = /"([^"]*)"/g;
var m2;
while ((m2 = re3.exec(m[1])) !== null) {
var parts2 = m2[1].split(/\s+/);
for (var j = 0; j < parts2.length; j++) {
if (parts2[j] && !classes[parts2[j]]) {
classes[parts2[j]] = true;
result.push(parts2[j]);
}
}
}
}
var re4 = /;;\s*@css\s+(.+)/g;
while ((m = re4.exec(source)) !== null) {
var parts3 = m[1].split(/\s+/);
for (var k = 0; k < parts3.length; k++) {
if (parts3[k] && !classes[parts3[k]]) {
classes[parts3[k]] = true;
result.push(parts3[k]);
}
}
}
return result;
}
function componentIoRefs(c) {
return c.ioRefs ? c.ioRefs.slice() : [];
}
function componentSetIoRefs(c, refs) {
c.ioRefs = refs;
}
// =========================================================================
// Platform interface — Parser
// =========================================================================
@@ -2116,24 +2030,35 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\"
var pageName = get(match, "name");
return (isSxTruthy(sxOr(isNil(contentSrc), isEmpty(contentSrc))) ? (logWarn((String("sx:route no content for ") + String(pathname))), false) : (function() {
var target = resolveRouteTarget(targetSel);
return (isSxTruthy(isNil(target)) ? (logWarn((String("sx:route target not found: ") + String(targetSel))), false) : (isSxTruthy(!isSxTruthy(depsSatisfied_p(match))) ? (logInfo((String("sx:route deps miss for ") + String(pageName))), false) : (isSxTruthy(get(match, "has-data")) ? (function() {
return (isSxTruthy(isNil(target)) ? (logWarn((String("sx:route target not found: ") + String(targetSel))), false) : (isSxTruthy(!isSxTruthy(depsSatisfied_p(match))) ? (logInfo((String("sx:route deps miss for ") + String(pageName))), false) : (function() {
var ioDeps = get(match, "io-deps");
var hasIo = (isSxTruthy(ioDeps) && !isSxTruthy(isEmpty(ioDeps)));
if (isSxTruthy(hasIo)) {
registerIoDeps(ioDeps);
}
return (isSxTruthy(get(match, "has-data")) ? (function() {
var cacheKey = pageDataCacheKey(pageName, params);
var cached = pageDataCacheGet(cacheKey);
return (isSxTruthy(cached) ? (function() {
var env = merge(closure, params, cached);
return (isSxTruthy(hasIo) ? (logInfo((String("sx:route client+cache+async ") + String(pathname))), tryAsyncEvalContent(contentSrc, env, function(rendered) { return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route async eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname)); }), true) : (function() {
var rendered = tryEvalContent(contentSrc, env);
return (isSxTruthy(isNil(rendered)) ? (logWarn((String("sx:route cached eval failed for ") + String(pathname))), false) : (logInfo((String("sx:route client+cache ") + String(pathname))), swapRenderedContent(target, rendered, pathname), true));
})());
})() : (logInfo((String("sx:route client+data ") + String(pathname))), resolvePageData(pageName, params, function(data) { pageDataCacheSet(cacheKey, data);
return (function() {
var env = merge(closure, params, data);
return (isSxTruthy(hasIo) ? tryAsyncEvalContent(contentSrc, env, function(rendered) { return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route data+async eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname)); }) : (function() {
var rendered = tryEvalContent(contentSrc, env);
return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route data eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname));
})());
})(); }), true));
})() : (function() {
})() : (isSxTruthy(hasIo) ? (logInfo((String("sx:route client+async ") + String(pathname))), tryAsyncEvalContent(contentSrc, merge(closure, params), function(rendered) { return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route async eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname)); }), true) : (function() {
var env = merge(closure, params);
var rendered = tryEvalContent(contentSrc, env);
return (isSxTruthy(isNil(rendered)) ? (logInfo((String("sx:route server (eval failed) ") + String(pathname))), false) : (swapRenderedContent(target, rendered, pathname), true));
})())));
})()));
})()));
})());
})());
})(); };
@@ -2558,119 +2483,6 @@ callExpr.push(dictGet(kwargs, k)); } }
var bootInit = function() { return (logInfo((String("sx-browser ") + String(SX_VERSION))), initCssTracking(), initStyleDict(), processPageScripts(), processSxScripts(NIL), sxHydrateElements(NIL), processElements(NIL)); };
// === Transpiled from deps (component dependency analysis) ===
// scan-refs
var scanRefs = function(node) { return (function() {
var refs = [];
scanRefsWalk(node, refs);
return refs;
})(); };
// scan-refs-walk
var scanRefsWalk = function(node, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() {
var name = symbolName(node);
return (isSxTruthy(startsWith(name, "~")) ? (isSxTruthy(!isSxTruthy(contains(refs, name))) ? append_b(refs, name) : NIL) : NIL);
})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanRefsWalk(item, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanRefsWalk(dictGet(node, key), refs); }, keys(node)) : NIL))); };
// transitive-deps-walk
var transitiveDepsWalk = function(n, seen, env) { return (isSxTruthy(!isSxTruthy(contains(seen, n))) ? (append_b(seen, n), (function() {
var val = envGet(env, n);
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(componentBody(val))) : (isSxTruthy((typeOf(val) == "macro")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(macroBody(val))) : NIL));
})()) : NIL); };
// transitive-deps
var transitiveDeps = function(name, env) { return (function() {
var seen = [];
var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name)));
transitiveDepsWalk(key, seen, env);
return filter(function(x) { return !isSxTruthy((x == key)); }, seen);
})(); };
// compute-all-deps
var computeAllDeps = function(env) { return forEach(function(name) { return (function() {
var val = envGet(env, name);
return (isSxTruthy((typeOf(val) == "component")) ? componentSetDeps(val, transitiveDeps(name, env)) : NIL);
})(); }, envComponents(env)); };
// scan-components-from-source
var scanComponentsFromSource = function(source) { return (function() {
var matches = regexFindAll("\\(~([a-zA-Z_][a-zA-Z0-9_\\-]*)", source);
return map(function(m) { return (String("~") + String(m)); }, matches);
})(); };
// components-needed
var componentsNeeded = function(pageSource, env) { return (function() {
var direct = scanComponentsFromSource(pageSource);
var allNeeded = [];
{ var _c = direct; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; if (isSxTruthy(!isSxTruthy(contains(allNeeded, name)))) {
allNeeded.push(name);
}
(function() {
var val = envGet(env, name);
return (function() {
var deps = (isSxTruthy((isSxTruthy((typeOf(val) == "component")) && !isSxTruthy(isEmpty(componentDeps(val))))) ? componentDeps(val) : transitiveDeps(name, env));
return forEach(function(dep) { return (isSxTruthy(!isSxTruthy(contains(allNeeded, dep))) ? append_b(allNeeded, dep) : NIL); }, deps);
})();
})(); } }
return allNeeded;
})(); };
// page-component-bundle
var pageComponentBundle = function(pageSource, env) { return componentsNeeded(pageSource, env); };
// page-css-classes
var pageCssClasses = function(pageSource, env) { return (function() {
var needed = componentsNeeded(pageSource, env);
var classes = [];
{ var _c = needed; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; (function() {
var val = envGet(env, name);
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(cls) { return (isSxTruthy(!isSxTruthy(contains(classes, cls))) ? append_b(classes, cls) : NIL); }, componentCssClasses(val)) : NIL);
})(); } }
{ var _c = scanCssClasses(pageSource); for (var _i = 0; _i < _c.length; _i++) { var cls = _c[_i]; if (isSxTruthy(!isSxTruthy(contains(classes, cls)))) {
classes.push(cls);
} } }
return classes;
})(); };
// scan-io-refs-walk
var scanIoRefsWalk = function(node, ioNames, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() {
var name = symbolName(node);
return (isSxTruthy(contains(ioNames, name)) ? (isSxTruthy(!isSxTruthy(contains(refs, name))) ? append_b(refs, name) : NIL) : NIL);
})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanIoRefsWalk(item, ioNames, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanIoRefsWalk(dictGet(node, key), ioNames, refs); }, keys(node)) : NIL))); };
// scan-io-refs
var scanIoRefs = function(node, ioNames) { return (function() {
var refs = [];
scanIoRefsWalk(node, ioNames, refs);
return refs;
})(); };
// transitive-io-refs-walk
var transitiveIoRefsWalk = function(n, seen, allRefs, env, ioNames) { return (isSxTruthy(!isSxTruthy(contains(seen, n))) ? (append_b(seen, n), (function() {
var val = envGet(env, n);
return (isSxTruthy((typeOf(val) == "component")) ? (forEach(function(ref) { return (isSxTruthy(!isSxTruthy(contains(allRefs, ref))) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(componentBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(componentBody(val)))) : (isSxTruthy((typeOf(val) == "macro")) ? (forEach(function(ref) { return (isSxTruthy(!isSxTruthy(contains(allRefs, ref))) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(macroBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(macroBody(val)))) : NIL));
})()) : NIL); };
// transitive-io-refs
var transitiveIoRefs = function(name, env, ioNames) { return (function() {
var allRefs = [];
var seen = [];
var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name)));
transitiveIoRefsWalk(key, seen, allRefs, env, ioNames);
return allRefs;
})(); };
// compute-all-io-refs
var computeAllIoRefs = function(env, ioNames) { return forEach(function(name) { return (function() {
var val = envGet(env, name);
return (isSxTruthy((typeOf(val) == "component")) ? componentSetIoRefs(val, transitiveIoRefs(name, env, ioNames)) : NIL);
})(); }, envComponents(env)); };
// component-pure?
var componentPure_p = function(name, env, ioNames) { return isEmpty(transitiveIoRefs(name, env, ioNames)); };
// === Transpiled from router (client-side route matching) ===
// split-path-segments
@@ -3510,6 +3322,32 @@ callExpr.push(dictGet(kwargs, k)); } }
}
}
// Async eval with callback — used for pages with IO deps.
// Calls callback(rendered) when done, callback(null) on failure.
function tryAsyncEvalContent(source, env, callback) {
var merged = merge(componentEnv);
if (env && !isNil(env)) {
var ks = Object.keys(env);
for (var i = 0; i < ks.length; i++) merged[ks[i]] = env[ks[i]];
}
try {
var result = asyncSxRenderWithEnv(source, merged);
if (isPromise(result)) {
result.then(function(rendered) {
callback(rendered);
}).catch(function(e) {
logWarn("sx:async eval miss: " + (e && e.message ? e.message : e));
callback(null);
});
} else {
callback(result);
}
} catch (e) {
logInfo("sx:async eval miss: " + (e && e.message ? e.message : e));
callback(null);
}
}
function resolvePageData(pageName, params, callback) {
// Platform implementation: fetch page data via HTTP from /sx/data/ endpoint.
// The spec only knows about resolve-page-data(name, params, callback) —
@@ -3939,6 +3777,654 @@ callExpr.push(dictGet(kwargs, k)); } }
if (typeof aser === "function") PRIMITIVES["aser"] = aser;
if (typeof renderToDom === "function") PRIMITIVES["render-to-dom"] = renderToDom;
// =========================================================================
// Async IO: Promise-aware rendering for client-side IO primitives
// =========================================================================
//
// IO primitives (query, current-user, etc.) return Promises on the client.
// asyncRenderToDom walks the component tree; when it encounters an IO
// primitive, it awaits the Promise and continues rendering.
//
// The sync evaluator/renderer is untouched. This is a separate async path
// used only when a page's component tree contains IO references.
var IO_PRIMITIVES = {};
function registerIoPrimitive(name, fn) {
IO_PRIMITIVES[name] = fn;
}
function isPromise(x) {
return x != null && typeof x === "object" && typeof x.then === "function";
}
// Async trampoline: resolves thunks, awaits Promises
function asyncTrampoline(val) {
if (isPromise(val)) return val.then(asyncTrampoline);
if (isThunk(val)) return asyncTrampoline(evalExpr(thunkExpr(val), thunkEnv(val)));
return val;
}
// Async eval: like trampoline(evalExpr(...)) but handles IO primitives
function asyncEval(expr, env) {
// Intercept IO primitive calls at the AST level
if (Array.isArray(expr) && expr.length > 0) {
var head = expr[0];
if (head && head._sym) {
var name = head.name;
if (IO_PRIMITIVES[name]) {
// Evaluate args, then call the IO primitive
return asyncEvalIoCall(name, expr.slice(1), env);
}
}
}
// Non-IO: use sync eval, but result might be a thunk
var result = evalExpr(expr, env);
return asyncTrampoline(result);
}
function asyncEvalIoCall(name, rawArgs, env) {
// Parse keyword args and positional args, evaluating each (may be async)
var kwargs = {};
var args = [];
var promises = [];
var i = 0;
while (i < rawArgs.length) {
var arg = rawArgs[i];
if (arg && arg._kw && (i + 1) < rawArgs.length) {
var kName = arg.name;
var kVal = asyncEval(rawArgs[i + 1], env);
if (isPromise(kVal)) {
(function(k) { promises.push(kVal.then(function(v) { kwargs[k] = v; })); })(kName);
} else {
kwargs[kName] = kVal;
}
i += 2;
} else {
var aVal = asyncEval(arg, env);
if (isPromise(aVal)) {
(function(idx) { promises.push(aVal.then(function(v) { args[idx] = v; })); })(args.length);
args.push(null); // placeholder
} else {
args.push(aVal);
}
i++;
}
}
var ioFn = IO_PRIMITIVES[name];
if (promises.length > 0) {
return Promise.all(promises).then(function() { return ioFn(args, kwargs); });
}
return ioFn(args, kwargs);
}
// Async render-to-dom: returns Promise<Node> or Node
function asyncRenderToDom(expr, env, ns) {
// Literals
if (expr === NIL || expr === null || expr === undefined) return null;
if (expr === true || expr === false) return null;
if (typeof expr === "string") return document.createTextNode(expr);
if (typeof expr === "number") return document.createTextNode(String(expr));
// Symbol -> async eval then render
if (expr && expr._sym) {
var val = asyncEval(expr, env);
if (isPromise(val)) return val.then(function(v) { return asyncRenderToDom(v, env, ns); });
return asyncRenderToDom(val, env, ns);
}
// Keyword
if (expr && expr._kw) return document.createTextNode(expr.name);
// DocumentFragment / DOM nodes pass through
if (expr instanceof DocumentFragment || (expr && expr.nodeType)) return expr;
// Dict -> skip
if (expr && typeof expr === "object" && !Array.isArray(expr)) return null;
// List
if (!Array.isArray(expr) || expr.length === 0) return null;
var head = expr[0];
if (!head) return null;
// Symbol head
if (head._sym) {
var hname = head.name;
// IO primitive
if (IO_PRIMITIVES[hname]) {
var ioResult = asyncEval(expr, env);
if (isPromise(ioResult)) return ioResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
return asyncRenderToDom(ioResult, env, ns);
}
// Fragment
if (hname === "<>") return asyncRenderChildren(expr.slice(1), env, ns);
// raw!
if (hname === "raw!") {
return asyncEvalRaw(expr.slice(1), env);
}
// Special forms that need async handling
if (hname === "if") return asyncRenderIf(expr, env, ns);
if (hname === "when") return asyncRenderWhen(expr, env, ns);
if (hname === "cond") return asyncRenderCond(expr, env, ns);
if (hname === "case") return asyncRenderCase(expr, env, ns);
if (hname === "let" || hname === "let*") return asyncRenderLet(expr, env, ns);
if (hname === "begin" || hname === "do") return asyncRenderChildren(expr.slice(1), env, ns);
if (hname === "map") return asyncRenderMap(expr, env, ns);
if (hname === "map-indexed") return asyncRenderMapIndexed(expr, env, ns);
if (hname === "for-each") return asyncRenderMap(expr, env, ns);
// define/defcomp/defmacro — eval for side effects
if (hname === "define" || hname === "defcomp" || hname === "defmacro" ||
hname === "defstyle" || hname === "defkeyframes" || hname === "defhandler") {
trampoline(evalExpr(expr, env));
return null;
}
// quote
if (hname === "quote") return null;
// lambda/fn
if (hname === "lambda" || hname === "fn") {
trampoline(evalExpr(expr, env));
return null;
}
// and/or — eval and render result
if (hname === "and" || hname === "or" || hname === "->") {
var aoResult = asyncEval(expr, env);
if (isPromise(aoResult)) return aoResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
return asyncRenderToDom(aoResult, env, ns);
}
// set!
if (hname === "set!") {
asyncEval(expr, env);
return null;
}
// Component
if (hname.charAt(0) === "~") {
var comp = env[hname];
if (comp && comp._component) return asyncRenderComponent(comp, expr.slice(1), env, ns);
if (comp && comp._macro) {
var expanded = trampoline(expandMacro(comp, expr.slice(1), env));
return asyncRenderToDom(expanded, env, ns);
}
}
// Macro
if (env[hname] && env[hname]._macro) {
var mac = env[hname];
var expanded = trampoline(expandMacro(mac, expr.slice(1), env));
return asyncRenderToDom(expanded, env, ns);
}
// HTML tag
if (typeof renderDomElement === "function" && contains(HTML_TAGS, hname)) {
return asyncRenderElement(hname, expr.slice(1), env, ns);
}
// html: prefix
if (hname.indexOf("html:") === 0) {
return asyncRenderElement(hname.slice(5), expr.slice(1), env, ns);
}
// Custom element
if (hname.indexOf("-") >= 0 && expr.length > 1 && expr[1] && expr[1]._kw) {
return asyncRenderElement(hname, expr.slice(1), env, ns);
}
// SVG context
if (ns) return asyncRenderElement(hname, expr.slice(1), env, ns);
// Fallback: eval and render
var fResult = asyncEval(expr, env);
if (isPromise(fResult)) return fResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
return asyncRenderToDom(fResult, env, ns);
}
// Non-symbol head: eval call
var cResult = asyncEval(expr, env);
if (isPromise(cResult)) return cResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
return asyncRenderToDom(cResult, env, ns);
}
function asyncRenderChildren(exprs, env, ns) {
var frag = document.createDocumentFragment();
var pending = [];
for (var i = 0; i < exprs.length; i++) {
var result = asyncRenderToDom(exprs[i], env, ns);
if (isPromise(result)) {
// Insert placeholder, replace when resolved
var placeholder = document.createComment("async");
frag.appendChild(placeholder);
(function(ph) {
pending.push(result.then(function(node) {
if (node) ph.parentNode.replaceChild(node, ph);
else ph.parentNode.removeChild(ph);
}));
})(placeholder);
} else if (result) {
frag.appendChild(result);
}
}
if (pending.length > 0) {
return Promise.all(pending).then(function() { return frag; });
}
return frag;
}
function asyncRenderElement(tag, args, env, ns) {
var newNs = tag === "svg" ? SVG_NS : tag === "math" ? MATH_NS : ns;
var el = domCreateElement(tag, newNs);
var pending = [];
var isVoid = contains(VOID_ELEMENTS, tag);
for (var i = 0; i < args.length; i++) {
var arg = args[i];
if (arg && arg._kw && (i + 1) < args.length) {
var attrName = arg.name;
var attrVal = asyncEval(args[i + 1], env);
i++;
if (isPromise(attrVal)) {
(function(an, av) {
pending.push(av.then(function(v) {
if (!isNil(v) && v !== false) {
if (contains(BOOLEAN_ATTRS, an)) { if (isSxTruthy(v)) el.setAttribute(an, ""); }
else if (v === true) el.setAttribute(an, "");
else el.setAttribute(an, String(v));
}
}));
})(attrName, attrVal);
} else {
if (!isNil(attrVal) && attrVal !== false) {
if (attrName === "class" && attrVal && attrVal._styleValue) {
el.setAttribute("class", (el.getAttribute("class") || "") + " " + attrVal.className);
} else if (attrName === "style" && attrVal && attrVal._styleValue) {
el.setAttribute("class", (el.getAttribute("class") || "") + " " + attrVal.className);
} else if (contains(BOOLEAN_ATTRS, attrName)) {
if (isSxTruthy(attrVal)) el.setAttribute(attrName, "");
} else if (attrVal === true) {
el.setAttribute(attrName, "");
} else {
el.setAttribute(attrName, String(attrVal));
}
}
}
} else if (!isVoid) {
var child = asyncRenderToDom(arg, env, newNs);
if (isPromise(child)) {
var placeholder = document.createComment("async");
el.appendChild(placeholder);
(function(ph) {
pending.push(child.then(function(node) {
if (node) ph.parentNode.replaceChild(node, ph);
else ph.parentNode.removeChild(ph);
}));
})(placeholder);
} else if (child) {
el.appendChild(child);
}
}
}
if (pending.length > 0) return Promise.all(pending).then(function() { return el; });
return el;
}
function asyncRenderComponent(comp, args, env, ns) {
var kwargs = {};
var children = [];
var pending = [];
for (var i = 0; i < args.length; i++) {
var arg = args[i];
if (arg && arg._kw && (i + 1) < args.length) {
var kName = arg.name;
var kVal = asyncEval(args[i + 1], env);
if (isPromise(kVal)) {
(function(k) { pending.push(kVal.then(function(v) { kwargs[k] = v; })); })(kName);
} else {
kwargs[kName] = kVal;
}
i++;
} else {
children.push(arg);
}
}
function doRender() {
var local = Object.create(componentClosure(comp));
for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k];
var params = componentParams(comp);
for (var j = 0; j < params.length; j++) {
local[params[j]] = params[j] in kwargs ? kwargs[params[j]] : NIL;
}
if (componentHasChildren(comp)) {
var childResult = asyncRenderChildren(children, env, ns);
if (isPromise(childResult)) {
return childResult.then(function(childFrag) {
local["children"] = childFrag;
return asyncRenderToDom(componentBody(comp), local, ns);
});
}
local["children"] = childResult;
}
return asyncRenderToDom(componentBody(comp), local, ns);
}
if (pending.length > 0) return Promise.all(pending).then(doRender);
return doRender();
}
function asyncRenderIf(expr, env, ns) {
var cond = asyncEval(expr[1], env);
if (isPromise(cond)) {
return cond.then(function(v) {
return isSxTruthy(v)
? asyncRenderToDom(expr[2], env, ns)
: (expr.length > 3 ? asyncRenderToDom(expr[3], env, ns) : null);
});
}
return isSxTruthy(cond)
? asyncRenderToDom(expr[2], env, ns)
: (expr.length > 3 ? asyncRenderToDom(expr[3], env, ns) : null);
}
function asyncRenderWhen(expr, env, ns) {
var cond = asyncEval(expr[1], env);
if (isPromise(cond)) {
return cond.then(function(v) {
return isSxTruthy(v) ? asyncRenderChildren(expr.slice(2), env, ns) : null;
});
}
return isSxTruthy(cond) ? asyncRenderChildren(expr.slice(2), env, ns) : null;
}
function asyncRenderCond(expr, env, ns) {
var clauses = expr.slice(1);
function step(idx) {
if (idx >= clauses.length) return null;
var clause = clauses[idx];
if (!Array.isArray(clause) || clause.length < 2) return step(idx + 1);
var test = clause[0];
if ((test && test._sym && (test.name === "else" || test.name === ":else")) ||
(test && test._kw && test.name === "else")) {
return asyncRenderToDom(clause[1], env, ns);
}
var v = asyncEval(test, env);
if (isPromise(v)) return v.then(function(r) { return isSxTruthy(r) ? asyncRenderToDom(clause[1], env, ns) : step(idx + 1); });
return isSxTruthy(v) ? asyncRenderToDom(clause[1], env, ns) : step(idx + 1);
}
return step(0);
}
function asyncRenderCase(expr, env, ns) {
var matchVal = asyncEval(expr[1], env);
function doCase(mv) {
var clauses = expr.slice(2);
for (var i = 0; i < clauses.length - 1; i += 2) {
var test = clauses[i];
if ((test && test._kw && test.name === "else") ||
(test && test._sym && (test.name === "else" || test.name === ":else"))) {
return asyncRenderToDom(clauses[i + 1], env, ns);
}
var tv = trampoline(evalExpr(test, env));
if (mv === tv || (typeof mv === "string" && typeof tv === "string" && mv === tv)) {
return asyncRenderToDom(clauses[i + 1], env, ns);
}
}
return null;
}
if (isPromise(matchVal)) return matchVal.then(doCase);
return doCase(matchVal);
}
function asyncRenderLet(expr, env, ns) {
var bindings = expr[1];
var local = Object.create(env);
for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k];
function bindStep(idx) {
if (!Array.isArray(bindings)) return asyncRenderChildren(expr.slice(2), local, ns);
// Nested pairs: ((a 1) (b 2))
if (bindings.length > 0 && Array.isArray(bindings[0])) {
if (idx >= bindings.length) return asyncRenderChildren(expr.slice(2), local, ns);
var b = bindings[idx];
var vname = b[0]._sym ? b[0].name : String(b[0]);
var val = asyncEval(b[1], local);
if (isPromise(val)) return val.then(function(v) { local[vname] = v; return bindStep(idx + 1); });
local[vname] = val;
return bindStep(idx + 1);
}
// Flat pairs: (a 1 b 2)
if (idx >= bindings.length) return asyncRenderChildren(expr.slice(2), local, ns);
var vn = bindings[idx]._sym ? bindings[idx].name : String(bindings[idx]);
var vv = asyncEval(bindings[idx + 1], local);
if (isPromise(vv)) return vv.then(function(v) { local[vn] = v; return bindStep(idx + 2); });
local[vn] = vv;
return bindStep(idx + 2);
}
return bindStep(0);
}
function asyncRenderMap(expr, env, ns) {
var fn = asyncEval(expr[1], env);
var coll = asyncEval(expr[2], env);
function doMap(f, c) {
if (!Array.isArray(c)) return null;
var frag = document.createDocumentFragment();
var pending = [];
for (var i = 0; i < c.length; i++) {
var item = c[i];
var result;
if (f && f._lambda) {
var lenv = Object.create(f.closure || env);
for (var k in env) if (env.hasOwnProperty(k)) lenv[k] = env[k];
lenv[f.params[0]] = item;
result = asyncRenderToDom(f.body, lenv, null);
} else if (typeof f === "function") {
var r = f(item);
result = isPromise(r) ? r.then(function(v) { return asyncRenderToDom(v, env, null); }) : asyncRenderToDom(r, env, null);
} else {
result = asyncRenderToDom(item, env, null);
}
if (isPromise(result)) {
var ph = document.createComment("async");
frag.appendChild(ph);
(function(p) { pending.push(result.then(function(n) { if (n) p.parentNode.replaceChild(n, p); else p.parentNode.removeChild(p); })); })(ph);
} else if (result) {
frag.appendChild(result);
}
}
if (pending.length) return Promise.all(pending).then(function() { return frag; });
return frag;
}
if (isPromise(fn) || isPromise(coll)) {
return Promise.all([isPromise(fn) ? fn : Promise.resolve(fn), isPromise(coll) ? coll : Promise.resolve(coll)])
.then(function(r) { return doMap(r[0], r[1]); });
}
return doMap(fn, coll);
}
function asyncRenderMapIndexed(expr, env, ns) {
var fn = asyncEval(expr[1], env);
var coll = asyncEval(expr[2], env);
function doMap(f, c) {
if (!Array.isArray(c)) return null;
var frag = document.createDocumentFragment();
var pending = [];
for (var i = 0; i < c.length; i++) {
var item = c[i];
var result;
if (f && f._lambda) {
var lenv = Object.create(f.closure || env);
for (var k in env) if (env.hasOwnProperty(k)) lenv[k] = env[k];
lenv[f.params[0]] = i;
lenv[f.params[1]] = item;
result = asyncRenderToDom(f.body, lenv, null);
} else if (typeof f === "function") {
var r = f(i, item);
result = isPromise(r) ? r.then(function(v) { return asyncRenderToDom(v, env, null); }) : asyncRenderToDom(r, env, null);
} else {
result = asyncRenderToDom(item, env, null);
}
if (isPromise(result)) {
var ph = document.createComment("async");
frag.appendChild(ph);
(function(p) { pending.push(result.then(function(n) { if (n) p.parentNode.replaceChild(n, p); else p.parentNode.removeChild(p); })); })(ph);
} else if (result) {
frag.appendChild(result);
}
}
if (pending.length) return Promise.all(pending).then(function() { return frag; });
return frag;
}
if (isPromise(fn) || isPromise(coll)) {
return Promise.all([isPromise(fn) ? fn : Promise.resolve(fn), isPromise(coll) ? coll : Promise.resolve(coll)])
.then(function(r) { return doMap(r[0], r[1]); });
}
return doMap(fn, coll);
}
function asyncEvalRaw(args, env) {
var parts = [];
var pending = [];
for (var i = 0; i < args.length; i++) {
var val = asyncEval(args[i], env);
if (isPromise(val)) {
(function(idx) {
pending.push(val.then(function(v) { parts[idx] = v; }));
})(parts.length);
parts.push(null);
} else {
parts.push(val);
}
}
function assemble() {
var html = "";
for (var j = 0; j < parts.length; j++) {
var p = parts[j];
if (p && p._rawHtml) html += p.html;
else if (typeof p === "string") html += p;
else if (p != null && !isNil(p)) html += String(p);
}
var el = document.createElement("span");
el.innerHTML = html;
var frag = document.createDocumentFragment();
while (el.firstChild) frag.appendChild(el.firstChild);
return frag;
}
if (pending.length) return Promise.all(pending).then(assemble);
return assemble();
}
// Async version of sxRenderWithEnv — returns Promise<DocumentFragment>
function asyncSxRenderWithEnv(source, extraEnv) {
var env = extraEnv ? merge(componentEnv, extraEnv) : componentEnv;
var exprs = parse(source);
if (!_hasDom) return Promise.resolve(null);
return asyncRenderChildren(exprs, env, null);
}
// IO proxy cache: key → { value, expires }
var _ioCache = {};
var IO_CACHE_TTL = 300000; // 5 minutes
// Register a server-proxied IO primitive: fetches from /sx/io/<name>
// Uses GET for short args, POST for long payloads (URL length safety).
// Results are cached client-side by (name + args) with a TTL.
function registerProxiedIo(name) {
registerIoPrimitive(name, function(args, kwargs) {
// Cache key: name + serialized args
var cacheKey = name;
for (var ci = 0; ci < args.length; ci++) cacheKey += "" + String(args[ci]);
for (var ck in kwargs) {
if (kwargs.hasOwnProperty(ck)) cacheKey += "" + ck + "=" + String(kwargs[ck]);
}
var cached = _ioCache[cacheKey];
if (cached && cached.expires > Date.now()) return cached.value;
var url = "/sx/io/" + encodeURIComponent(name);
var qs = [];
for (var i = 0; i < args.length; i++) {
qs.push("_arg" + i + "=" + encodeURIComponent(String(args[i])));
}
for (var k in kwargs) {
if (kwargs.hasOwnProperty(k)) {
qs.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(kwargs[k])));
}
}
var queryStr = qs.join("&");
var fetchOpts;
if (queryStr.length > 1500) {
// POST with JSON body for long payloads
var sArgs = [];
for (var j = 0; j < args.length; j++) sArgs.push(String(args[j]));
var sKwargs = {};
for (var kk in kwargs) {
if (kwargs.hasOwnProperty(kk)) sKwargs[kk] = String(kwargs[kk]);
}
var postHeaders = { "SX-Request": "true", "Content-Type": "application/json" };
var csrf = csrfToken();
if (csrf && csrf !== NIL) postHeaders["X-CSRFToken"] = csrf;
fetchOpts = {
method: "POST",
headers: postHeaders,
body: JSON.stringify({ args: sArgs, kwargs: sKwargs })
};
} else {
if (queryStr) url += "?" + queryStr;
fetchOpts = { headers: { "SX-Request": "true" } };
}
var result = fetch(url, fetchOpts)
.then(function(resp) {
if (!resp.ok) {
logWarn("sx:io " + name + " failed " + resp.status);
return NIL;
}
return resp.text();
})
.then(function(text) {
if (!text || text === "nil") return NIL;
try {
var exprs = parse(text);
var val = exprs.length === 1 ? exprs[0] : exprs;
_ioCache[cacheKey] = { value: val, expires: Date.now() + IO_CACHE_TTL };
return val;
} catch (e) {
logWarn("sx:io " + name + " parse error: " + (e && e.message ? e.message : e));
return NIL;
}
})
.catch(function(e) {
logWarn("sx:io " + name + " network error: " + (e && e.message ? e.message : e));
return NIL;
});
// Cache the in-flight promise too (dedup concurrent calls for same args)
_ioCache[cacheKey] = { value: result, expires: Date.now() + IO_CACHE_TTL };
return result;
});
}
// Register IO deps as proxied primitives (idempotent, called per-page)
function registerIoDeps(names) {
if (!names || !names.length) return;
var registered = 0;
for (var i = 0; i < names.length; i++) {
var name = names[i];
if (!IO_PRIMITIVES[name]) {
registerProxiedIo(name);
registered++;
}
}
if (registered > 0) {
logInfo("sx:io registered " + registered + " proxied primitives: " + names.join(", "));
}
}
// Parser — compiled from parser.sx (see PLATFORM_PARSER_JS for ident char classes)
var parse = sxParse;
@@ -4007,20 +4493,14 @@ callExpr.push(dictGet(kwargs, k)); } }
renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null,
getEnv: function() { return componentEnv; },
init: typeof bootInit === "function" ? bootInit : null,
scanRefs: scanRefs,
transitiveDeps: transitiveDeps,
computeAllDeps: computeAllDeps,
componentsNeeded: componentsNeeded,
pageComponentBundle: pageComponentBundle,
pageCssClasses: pageCssClasses,
scanIoRefs: scanIoRefs,
transitiveIoRefs: transitiveIoRefs,
computeAllIoRefs: computeAllIoRefs,
componentPure_p: componentPure_p,
splitPathSegments: splitPathSegments,
parseRoutePattern: parseRoutePattern,
matchRoute: matchRoute,
findMatchingRoute: findMatchingRoute,
registerIo: typeof registerIoPrimitive === "function" ? registerIoPrimitive : null,
registerIoDeps: typeof registerIoDeps === "function" ? registerIoDeps : null,
asyncRender: typeof asyncSxRenderWithEnv === "function" ? asyncSxRenderWithEnv : null,
asyncRenderToDom: typeof asyncRenderToDom === "function" ? asyncRenderToDom : null,
_version: "ref-2.0 (boot+cssx+dom+engine+html+orchestration+parser+sx, bootstrap-compiled)"
};

View File

@@ -0,0 +1,96 @@
// sx-test-runner.js — Run test.sx in the browser using sx-browser.js.
// Loaded on the /specs/testing page. Uses the Sx global.
(function() {
var NIL = Sx.NIL;
function isNil(x) { return x === NIL || x === null || x === undefined; }
function deepEqual(a, b) {
if (a === b) return true;
if (isNil(a) && isNil(b)) return true;
if (typeof a !== typeof b) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (var i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
return true;
}
if (a && typeof a === "object" && b && typeof b === "object") {
var ka = Object.keys(a), kb = Object.keys(b);
if (ka.length !== kb.length) return false;
for (var j = 0; j < ka.length; j++) if (!deepEqual(a[ka[j]], b[ka[j]])) return false;
return true;
}
return false;
}
window.sxRunTests = function(srcId, outId, btnId) {
var src = document.getElementById(srcId).textContent;
var out = document.getElementById(outId);
var btn = document.getElementById(btnId);
var stack = [], passed = 0, failed = 0, num = 0, lines = [];
var env = {
"try-call": function(thunk) {
try {
Sx.eval([thunk], env);
return { ok: true };
} catch(e) {
return { ok: false, error: e.message || String(e) };
}
},
"report-pass": function(name) {
num++; passed++;
lines.push("ok " + num + " - " + stack.concat([name]).join(" > "));
},
"report-fail": function(name, error) {
num++; failed++;
lines.push("not ok " + num + " - " + stack.concat([name]).join(" > "));
lines.push(" # " + error);
},
"push-suite": function(name) { stack.push(name); },
"pop-suite": function() { stack.pop(); },
"equal?": function(a, b) { return deepEqual(a, b); },
"eq?": function(a, b) { return a === b; },
"boolean?": function(x) { return typeof x === "boolean"; },
"string-length": function(s) { return String(s).length; },
"substring": function(s, start, end) { return String(s).slice(start, end); },
"string-contains?": function(s, n) { return String(s).indexOf(n) !== -1; },
"upcase": function(s) { return String(s).toUpperCase(); },
"downcase": function(s) { return String(s).toLowerCase(); },
"reverse": function(c) { return c ? c.slice().reverse() : []; },
"flatten": function(c) {
var r = [];
for (var i = 0; i < (c||[]).length; i++) {
if (Array.isArray(c[i])) for (var j = 0; j < c[i].length; j++) r.push(c[i][j]);
else r.push(c[i]);
}
return r;
},
"has-key?": function(d, k) { return d && typeof d === "object" && k in d; },
"append": function(c, x) { return Array.isArray(x) ? (c||[]).concat(x) : (c||[]).concat([x]); },
};
try {
var t0 = performance.now();
var exprs = Sx.parseAll(src);
for (var i = 0; i < exprs.length; i++) Sx.eval(exprs[i], env);
var elapsed = Math.round(performance.now() - t0);
lines.push("");
lines.push("1.." + num);
lines.push("# tests " + (passed + failed));
lines.push("# pass " + passed);
if (failed > 0) lines.push("# fail " + failed);
lines.push("# time " + elapsed + "ms");
} catch(e) {
lines.push("");
lines.push("FATAL: " + (e.message || String(e)));
}
out.textContent = lines.join("\n");
out.style.display = "block";
btn.textContent = passed + "/" + (passed + failed) + " passed" + (failed === 0 ? "" : " (" + failed + " failed)");
btn.className = failed > 0
? "px-4 py-2 rounded-md bg-red-600 text-white font-medium text-sm cursor-default"
: "px-4 py-2 rounded-md bg-green-600 text-white font-medium text-sm cursor-default";
};
})();

View File

@@ -691,6 +691,18 @@ def _build_pages_sx(service: str) -> str:
deps = components_needed(content_src, _COMPONENT_ENV)
deps_sx = "(" + " ".join(_sx_literal(d) for d in sorted(deps)) + ")"
# Collect IO primitive names referenced by dep components
from .types import Component as _Comp
io_deps: set[str] = set()
for dep_name in deps:
comp = _COMPONENT_ENV.get(dep_name)
if isinstance(comp, _Comp) and comp.io_refs:
io_deps.update(comp.io_refs)
io_deps_sx = (
"(" + " ".join(_sx_literal(n) for n in sorted(io_deps)) + ")"
if io_deps else "()"
)
# Build closure as SX dict
closure_parts: list[str] = []
for k, v in page_def.closure.items():
@@ -703,6 +715,7 @@ def _build_pages_sx(service: str) -> str:
+ " :path " + _sx_literal(page_def.path)
+ " :auth " + _sx_literal(auth)
+ " :has-data " + has_data
+ " :io-deps " + io_deps_sx
+ " :content " + _sx_literal(content_src)
+ " :deps " + deps_sx
+ " :closure " + closure_sx + "}"

View File

@@ -349,23 +349,15 @@ def components_for_page(page_sx: str, service: str | None = None) -> tuple[str,
needed = components_needed(page_sx, _COMPONENT_ENV)
# Include deps for :data pages whose component trees are fully pure
# (no IO refs). Pages with IO deps must render server-side.
# Include deps for all :data pages so the client can render them.
# Pages with IO deps use the async render path (Phase 5) — the IO
# primitives are proxied via /sx/io/<name>.
if service:
from .pages import get_all_pages
for page_def in get_all_pages(service).values():
if page_def.data_expr is not None and page_def.content_expr is not None:
content_src = serialize(page_def.content_expr)
data_deps = components_needed(content_src, _COMPONENT_ENV)
# Check if any dep component has IO refs
has_io = False
for dep_name in data_deps:
comp = _COMPONENT_ENV.get(dep_name)
if isinstance(comp, Component) and comp.io_refs:
has_io = True
break
if not has_io:
needed |= data_deps
needed |= components_needed(content_src, _COMPONENT_ENV)
if not needed:
return "", ""
@@ -416,13 +408,7 @@ def css_classes_for_page(page_sx: str, service: str | None = None) -> set[str]:
for page_def in get_all_pages(service).values():
if page_def.data_expr is not None and page_def.content_expr is not None:
content_src = serialize(page_def.content_expr)
data_deps = components_needed(content_src, _COMPONENT_ENV)
has_io = any(
isinstance(_COMPONENT_ENV.get(d), Component) and _COMPONENT_ENV.get(d).io_refs
for d in data_deps
)
if not has_io:
needed |= data_deps
needed |= components_needed(content_src, _COMPONENT_ENV)
classes: set[str] = set()
for key, val in _COMPONENT_ENV.items():

View File

@@ -331,6 +331,9 @@ def auto_mount_pages(app: Any, service_name: str) -> None:
if has_data_pages:
auto_mount_page_data(app, service_name)
# Mount IO proxy endpoint for Phase 5: client-side IO primitives
mount_io_endpoint(app, service_name)
def mount_pages(bp: Any, service_name: str,
names: set[str] | list[str] | None = None) -> None:
@@ -535,3 +538,79 @@ def auto_mount_page_data(app: Any, service_name: str) -> None:
methods=["GET"],
)
logger.info("Mounted page data endpoint for %s at /sx/data/<page_name>", service_name)
def mount_io_endpoint(app: Any, service_name: str) -> None:
"""Mount /sx/io/<name> endpoint for client-side IO primitive calls.
The client can call any allowed IO primitive or page helper via GET/POST.
Result is returned as SX wire format (text/sx).
Falls back to page helpers when the name isn't a global IO primitive,
so service-specific functions like ``highlight`` work via the proxy.
"""
import asyncio as _asyncio
from quart import make_response, request, abort as quart_abort
from .primitives_io import IO_PRIMITIVES, execute_io
from .jinja_bridge import _get_request_context
from .parser import serialize
# Build allowlist from all component IO refs across this service
from .jinja_bridge import _COMPONENT_ENV
from .types import Component as _Comp
_ALLOWED_IO: set[str] = set()
for _val in _COMPONENT_ENV.values():
if isinstance(_val, _Comp) and _val.io_refs:
_ALLOWED_IO.update(_val.io_refs)
from shared.browser.app.csrf import csrf_exempt
@csrf_exempt
async def io_proxy(name: str) -> Any:
if name not in _ALLOWED_IO:
quart_abort(403)
# Parse args from query string or JSON body
args: list = []
kwargs: dict = {}
if request.method == "GET":
for k, v in request.args.items():
if k.startswith("_arg"):
args.append(v)
else:
kwargs[k] = v
else:
data = await request.get_json(silent=True) or {}
args = data.get("args", [])
kwargs = data.get("kwargs", {})
# Try global IO primitives first
if name in IO_PRIMITIVES:
ctx = _get_request_context()
result = await execute_io(name, args, kwargs, ctx)
else:
# Fall back to page helpers (service-specific functions)
helpers = get_page_helpers(service_name)
helper_fn = helpers.get(name)
if helper_fn is None:
quart_abort(404)
result = helper_fn(*args, **kwargs) if kwargs else helper_fn(*args)
if _asyncio.iscoroutine(result):
result = await result
result_sx = serialize(result) if result is not None else "nil"
resp = await make_response(result_sx, 200)
resp.content_type = "text/sx; charset=utf-8"
resp.headers["Cache-Control"] = "public, max-age=300"
return resp
io_proxy.__name__ = "sx_io_proxy"
io_proxy.__qualname__ = "sx_io_proxy"
app.add_url_rule(
"/sx/io/<name>",
endpoint="sx_io_proxy",
view_func=io_proxy,
methods=["GET", "POST"],
)
logger.info("Mounted IO proxy for %s: %s", service_name, sorted(_ALLOWED_IO))

View File

@@ -192,9 +192,13 @@ def prim_is_zero(n: Any) -> bool:
def prim_is_nil(x: Any) -> bool:
return x is None or x is NIL
@register_primitive("boolean?")
def prim_is_boolean(x: Any) -> bool:
return isinstance(x, bool)
@register_primitive("number?")
def prim_is_number(x: Any) -> bool:
return isinstance(x, (int, float))
return isinstance(x, (int, float)) and not isinstance(x, bool)
@register_primitive("string?")
def prim_is_string(x: Any) -> bool:
@@ -268,13 +272,27 @@ def prim_concat(*colls: Any) -> list:
return result
@register_primitive("upper")
@register_primitive("upcase")
def prim_upper(s: str) -> str:
return s.upper()
@register_primitive("lower")
@register_primitive("downcase")
def prim_lower(s: str) -> str:
return s.lower()
@register_primitive("string-length")
def prim_string_length(s: str) -> int:
return len(s)
@register_primitive("substring")
def prim_substring(s: str, start: int, end: int) -> str:
return s[int(start):int(end)]
@register_primitive("string-contains?")
def prim_string_contains(s: str, needle: str) -> bool:
return needle in s
@register_primitive("trim")
def prim_trim(s: str) -> str:
return s.strip()
@@ -384,8 +402,31 @@ def prim_cons(x: Any, coll: Any) -> list:
@register_primitive("append")
def prim_append(coll: Any, x: Any) -> list:
if isinstance(x, list):
return list(coll) + x if coll else list(x)
return list(coll) + [x] if coll else [x]
@register_primitive("reverse")
def prim_reverse(coll: Any) -> list:
return list(reversed(coll)) if coll else []
@register_primitive("flatten")
def prim_flatten(coll: Any) -> list:
result = []
for item in (coll or []):
if isinstance(item, list):
result.extend(item)
else:
result.append(item)
return result
@register_primitive("has-key?")
def prim_has_key(d: Any, key: Any) -> bool:
if not isinstance(d, dict):
return False
k = key.name if isinstance(key, Keyword) else key
return k in d
@register_primitive("append!")
def prim_append_mut(coll: Any, x: Any) -> list:
coll.append(x)

View File

@@ -377,7 +377,10 @@ async def _io_asset_url(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> str:
"""``(asset-url "/img/logo.png")`` → versioned static URL."""
from shared.infrastructure.urls import asset_url
from quart import current_app
asset_url = current_app.jinja_env.globals.get("asset_url")
if asset_url is None:
raise RuntimeError("asset_url Jinja global not registered")
path = str(args[0]) if args else ""
return asset_url(path)
@@ -458,7 +461,10 @@ def _bridge_app_url(service, *path_parts):
return app_url(str(service), path)
def _bridge_asset_url(*path_parts):
from shared.infrastructure.urls import asset_url
from quart import current_app
asset_url = current_app.jinja_env.globals.get("asset_url")
if asset_url is None:
raise RuntimeError("asset_url Jinja global not registered")
path = str(path_parts[0]) if path_parts else ""
return asset_url(path)

View File

@@ -384,6 +384,8 @@ class JSEmitter:
"bind-client-route-click": "bindClientRouteClick",
"try-client-route": "tryClientRoute",
"try-eval-content": "tryEvalContent",
"try-async-eval-content": "tryAsyncEvalContent",
"register-io-deps": "registerIoDeps",
"url-pathname": "urlPathname",
"bind-inline-handler": "bindInlineHandler",
"bind-preload": "bindPreload",
@@ -1144,6 +1146,656 @@ CONTINUATIONS_JS = '''
'''
ASYNC_IO_JS = '''
// =========================================================================
// Async IO: Promise-aware rendering for client-side IO primitives
// =========================================================================
//
// IO primitives (query, current-user, etc.) return Promises on the client.
// asyncRenderToDom walks the component tree; when it encounters an IO
// primitive, it awaits the Promise and continues rendering.
//
// The sync evaluator/renderer is untouched. This is a separate async path
// used only when a page's component tree contains IO references.
var IO_PRIMITIVES = {};
function registerIoPrimitive(name, fn) {
IO_PRIMITIVES[name] = fn;
}
function isPromise(x) {
return x != null && typeof x === "object" && typeof x.then === "function";
}
// Async trampoline: resolves thunks, awaits Promises
function asyncTrampoline(val) {
if (isPromise(val)) return val.then(asyncTrampoline);
if (isThunk(val)) return asyncTrampoline(evalExpr(thunkExpr(val), thunkEnv(val)));
return val;
}
// Async eval: like trampoline(evalExpr(...)) but handles IO primitives
function asyncEval(expr, env) {
// Intercept IO primitive calls at the AST level
if (Array.isArray(expr) && expr.length > 0) {
var head = expr[0];
if (head && head._sym) {
var name = head.name;
if (IO_PRIMITIVES[name]) {
// Evaluate args, then call the IO primitive
return asyncEvalIoCall(name, expr.slice(1), env);
}
}
}
// Non-IO: use sync eval, but result might be a thunk
var result = evalExpr(expr, env);
return asyncTrampoline(result);
}
function asyncEvalIoCall(name, rawArgs, env) {
// Parse keyword args and positional args, evaluating each (may be async)
var kwargs = {};
var args = [];
var promises = [];
var i = 0;
while (i < rawArgs.length) {
var arg = rawArgs[i];
if (arg && arg._kw && (i + 1) < rawArgs.length) {
var kName = arg.name;
var kVal = asyncEval(rawArgs[i + 1], env);
if (isPromise(kVal)) {
(function(k) { promises.push(kVal.then(function(v) { kwargs[k] = v; })); })(kName);
} else {
kwargs[kName] = kVal;
}
i += 2;
} else {
var aVal = asyncEval(arg, env);
if (isPromise(aVal)) {
(function(idx) { promises.push(aVal.then(function(v) { args[idx] = v; })); })(args.length);
args.push(null); // placeholder
} else {
args.push(aVal);
}
i++;
}
}
var ioFn = IO_PRIMITIVES[name];
if (promises.length > 0) {
return Promise.all(promises).then(function() { return ioFn(args, kwargs); });
}
return ioFn(args, kwargs);
}
// Async render-to-dom: returns Promise<Node> or Node
function asyncRenderToDom(expr, env, ns) {
// Literals
if (expr === NIL || expr === null || expr === undefined) return null;
if (expr === true || expr === false) return null;
if (typeof expr === "string") return document.createTextNode(expr);
if (typeof expr === "number") return document.createTextNode(String(expr));
// Symbol -> async eval then render
if (expr && expr._sym) {
var val = asyncEval(expr, env);
if (isPromise(val)) return val.then(function(v) { return asyncRenderToDom(v, env, ns); });
return asyncRenderToDom(val, env, ns);
}
// Keyword
if (expr && expr._kw) return document.createTextNode(expr.name);
// DocumentFragment / DOM nodes pass through
if (expr instanceof DocumentFragment || (expr && expr.nodeType)) return expr;
// Dict -> skip
if (expr && typeof expr === "object" && !Array.isArray(expr)) return null;
// List
if (!Array.isArray(expr) || expr.length === 0) return null;
var head = expr[0];
if (!head) return null;
// Symbol head
if (head._sym) {
var hname = head.name;
// IO primitive
if (IO_PRIMITIVES[hname]) {
var ioResult = asyncEval(expr, env);
if (isPromise(ioResult)) return ioResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
return asyncRenderToDom(ioResult, env, ns);
}
// Fragment
if (hname === "<>") return asyncRenderChildren(expr.slice(1), env, ns);
// raw!
if (hname === "raw!") {
return asyncEvalRaw(expr.slice(1), env);
}
// Special forms that need async handling
if (hname === "if") return asyncRenderIf(expr, env, ns);
if (hname === "when") return asyncRenderWhen(expr, env, ns);
if (hname === "cond") return asyncRenderCond(expr, env, ns);
if (hname === "case") return asyncRenderCase(expr, env, ns);
if (hname === "let" || hname === "let*") return asyncRenderLet(expr, env, ns);
if (hname === "begin" || hname === "do") return asyncRenderChildren(expr.slice(1), env, ns);
if (hname === "map") return asyncRenderMap(expr, env, ns);
if (hname === "map-indexed") return asyncRenderMapIndexed(expr, env, ns);
if (hname === "for-each") return asyncRenderMap(expr, env, ns);
// define/defcomp/defmacro — eval for side effects
if (hname === "define" || hname === "defcomp" || hname === "defmacro" ||
hname === "defstyle" || hname === "defkeyframes" || hname === "defhandler") {
trampoline(evalExpr(expr, env));
return null;
}
// quote
if (hname === "quote") return null;
// lambda/fn
if (hname === "lambda" || hname === "fn") {
trampoline(evalExpr(expr, env));
return null;
}
// and/or — eval and render result
if (hname === "and" || hname === "or" || hname === "->") {
var aoResult = asyncEval(expr, env);
if (isPromise(aoResult)) return aoResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
return asyncRenderToDom(aoResult, env, ns);
}
// set!
if (hname === "set!") {
asyncEval(expr, env);
return null;
}
// Component
if (hname.charAt(0) === "~") {
var comp = env[hname];
if (comp && comp._component) return asyncRenderComponent(comp, expr.slice(1), env, ns);
if (comp && comp._macro) {
var expanded = trampoline(expandMacro(comp, expr.slice(1), env));
return asyncRenderToDom(expanded, env, ns);
}
}
// Macro
if (env[hname] && env[hname]._macro) {
var mac = env[hname];
var expanded = trampoline(expandMacro(mac, expr.slice(1), env));
return asyncRenderToDom(expanded, env, ns);
}
// HTML tag
if (typeof renderDomElement === "function" && contains(HTML_TAGS, hname)) {
return asyncRenderElement(hname, expr.slice(1), env, ns);
}
// html: prefix
if (hname.indexOf("html:") === 0) {
return asyncRenderElement(hname.slice(5), expr.slice(1), env, ns);
}
// Custom element
if (hname.indexOf("-") >= 0 && expr.length > 1 && expr[1] && expr[1]._kw) {
return asyncRenderElement(hname, expr.slice(1), env, ns);
}
// SVG context
if (ns) return asyncRenderElement(hname, expr.slice(1), env, ns);
// Fallback: eval and render
var fResult = asyncEval(expr, env);
if (isPromise(fResult)) return fResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
return asyncRenderToDom(fResult, env, ns);
}
// Non-symbol head: eval call
var cResult = asyncEval(expr, env);
if (isPromise(cResult)) return cResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
return asyncRenderToDom(cResult, env, ns);
}
function asyncRenderChildren(exprs, env, ns) {
var frag = document.createDocumentFragment();
var pending = [];
for (var i = 0; i < exprs.length; i++) {
var result = asyncRenderToDom(exprs[i], env, ns);
if (isPromise(result)) {
// Insert placeholder, replace when resolved
var placeholder = document.createComment("async");
frag.appendChild(placeholder);
(function(ph) {
pending.push(result.then(function(node) {
if (node) ph.parentNode.replaceChild(node, ph);
else ph.parentNode.removeChild(ph);
}));
})(placeholder);
} else if (result) {
frag.appendChild(result);
}
}
if (pending.length > 0) {
return Promise.all(pending).then(function() { return frag; });
}
return frag;
}
function asyncRenderElement(tag, args, env, ns) {
var newNs = tag === "svg" ? SVG_NS : tag === "math" ? MATH_NS : ns;
var el = domCreateElement(tag, newNs);
var pending = [];
var isVoid = contains(VOID_ELEMENTS, tag);
for (var i = 0; i < args.length; i++) {
var arg = args[i];
if (arg && arg._kw && (i + 1) < args.length) {
var attrName = arg.name;
var attrVal = asyncEval(args[i + 1], env);
i++;
if (isPromise(attrVal)) {
(function(an, av) {
pending.push(av.then(function(v) {
if (!isNil(v) && v !== false) {
if (contains(BOOLEAN_ATTRS, an)) { if (isSxTruthy(v)) el.setAttribute(an, ""); }
else if (v === true) el.setAttribute(an, "");
else el.setAttribute(an, String(v));
}
}));
})(attrName, attrVal);
} else {
if (!isNil(attrVal) && attrVal !== false) {
if (attrName === "class" && attrVal && attrVal._styleValue) {
el.setAttribute("class", (el.getAttribute("class") || "") + " " + attrVal.className);
} else if (attrName === "style" && attrVal && attrVal._styleValue) {
el.setAttribute("class", (el.getAttribute("class") || "") + " " + attrVal.className);
} else if (contains(BOOLEAN_ATTRS, attrName)) {
if (isSxTruthy(attrVal)) el.setAttribute(attrName, "");
} else if (attrVal === true) {
el.setAttribute(attrName, "");
} else {
el.setAttribute(attrName, String(attrVal));
}
}
}
} else if (!isVoid) {
var child = asyncRenderToDom(arg, env, newNs);
if (isPromise(child)) {
var placeholder = document.createComment("async");
el.appendChild(placeholder);
(function(ph) {
pending.push(child.then(function(node) {
if (node) ph.parentNode.replaceChild(node, ph);
else ph.parentNode.removeChild(ph);
}));
})(placeholder);
} else if (child) {
el.appendChild(child);
}
}
}
if (pending.length > 0) return Promise.all(pending).then(function() { return el; });
return el;
}
function asyncRenderComponent(comp, args, env, ns) {
var kwargs = {};
var children = [];
var pending = [];
for (var i = 0; i < args.length; i++) {
var arg = args[i];
if (arg && arg._kw && (i + 1) < args.length) {
var kName = arg.name;
var kVal = asyncEval(args[i + 1], env);
if (isPromise(kVal)) {
(function(k) { pending.push(kVal.then(function(v) { kwargs[k] = v; })); })(kName);
} else {
kwargs[kName] = kVal;
}
i++;
} else {
children.push(arg);
}
}
function doRender() {
var local = Object.create(componentClosure(comp));
for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k];
var params = componentParams(comp);
for (var j = 0; j < params.length; j++) {
local[params[j]] = params[j] in kwargs ? kwargs[params[j]] : NIL;
}
if (componentHasChildren(comp)) {
var childResult = asyncRenderChildren(children, env, ns);
if (isPromise(childResult)) {
return childResult.then(function(childFrag) {
local["children"] = childFrag;
return asyncRenderToDom(componentBody(comp), local, ns);
});
}
local["children"] = childResult;
}
return asyncRenderToDom(componentBody(comp), local, ns);
}
if (pending.length > 0) return Promise.all(pending).then(doRender);
return doRender();
}
function asyncRenderIf(expr, env, ns) {
var cond = asyncEval(expr[1], env);
if (isPromise(cond)) {
return cond.then(function(v) {
return isSxTruthy(v)
? asyncRenderToDom(expr[2], env, ns)
: (expr.length > 3 ? asyncRenderToDom(expr[3], env, ns) : null);
});
}
return isSxTruthy(cond)
? asyncRenderToDom(expr[2], env, ns)
: (expr.length > 3 ? asyncRenderToDom(expr[3], env, ns) : null);
}
function asyncRenderWhen(expr, env, ns) {
var cond = asyncEval(expr[1], env);
if (isPromise(cond)) {
return cond.then(function(v) {
return isSxTruthy(v) ? asyncRenderChildren(expr.slice(2), env, ns) : null;
});
}
return isSxTruthy(cond) ? asyncRenderChildren(expr.slice(2), env, ns) : null;
}
function asyncRenderCond(expr, env, ns) {
var clauses = expr.slice(1);
function step(idx) {
if (idx >= clauses.length) return null;
var clause = clauses[idx];
if (!Array.isArray(clause) || clause.length < 2) return step(idx + 1);
var test = clause[0];
if ((test && test._sym && (test.name === "else" || test.name === ":else")) ||
(test && test._kw && test.name === "else")) {
return asyncRenderToDom(clause[1], env, ns);
}
var v = asyncEval(test, env);
if (isPromise(v)) return v.then(function(r) { return isSxTruthy(r) ? asyncRenderToDom(clause[1], env, ns) : step(idx + 1); });
return isSxTruthy(v) ? asyncRenderToDom(clause[1], env, ns) : step(idx + 1);
}
return step(0);
}
function asyncRenderCase(expr, env, ns) {
var matchVal = asyncEval(expr[1], env);
function doCase(mv) {
var clauses = expr.slice(2);
for (var i = 0; i < clauses.length - 1; i += 2) {
var test = clauses[i];
if ((test && test._kw && test.name === "else") ||
(test && test._sym && (test.name === "else" || test.name === ":else"))) {
return asyncRenderToDom(clauses[i + 1], env, ns);
}
var tv = trampoline(evalExpr(test, env));
if (mv === tv || (typeof mv === "string" && typeof tv === "string" && mv === tv)) {
return asyncRenderToDom(clauses[i + 1], env, ns);
}
}
return null;
}
if (isPromise(matchVal)) return matchVal.then(doCase);
return doCase(matchVal);
}
function asyncRenderLet(expr, env, ns) {
var bindings = expr[1];
var local = Object.create(env);
for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k];
function bindStep(idx) {
if (!Array.isArray(bindings)) return asyncRenderChildren(expr.slice(2), local, ns);
// Nested pairs: ((a 1) (b 2))
if (bindings.length > 0 && Array.isArray(bindings[0])) {
if (idx >= bindings.length) return asyncRenderChildren(expr.slice(2), local, ns);
var b = bindings[idx];
var vname = b[0]._sym ? b[0].name : String(b[0]);
var val = asyncEval(b[1], local);
if (isPromise(val)) return val.then(function(v) { local[vname] = v; return bindStep(idx + 1); });
local[vname] = val;
return bindStep(idx + 1);
}
// Flat pairs: (a 1 b 2)
if (idx >= bindings.length) return asyncRenderChildren(expr.slice(2), local, ns);
var vn = bindings[idx]._sym ? bindings[idx].name : String(bindings[idx]);
var vv = asyncEval(bindings[idx + 1], local);
if (isPromise(vv)) return vv.then(function(v) { local[vn] = v; return bindStep(idx + 2); });
local[vn] = vv;
return bindStep(idx + 2);
}
return bindStep(0);
}
function asyncRenderMap(expr, env, ns) {
var fn = asyncEval(expr[1], env);
var coll = asyncEval(expr[2], env);
function doMap(f, c) {
if (!Array.isArray(c)) return null;
var frag = document.createDocumentFragment();
var pending = [];
for (var i = 0; i < c.length; i++) {
var item = c[i];
var result;
if (f && f._lambda) {
var lenv = Object.create(f.closure || env);
for (var k in env) if (env.hasOwnProperty(k)) lenv[k] = env[k];
lenv[f.params[0]] = item;
result = asyncRenderToDom(f.body, lenv, null);
} else if (typeof f === "function") {
var r = f(item);
result = isPromise(r) ? r.then(function(v) { return asyncRenderToDom(v, env, null); }) : asyncRenderToDom(r, env, null);
} else {
result = asyncRenderToDom(item, env, null);
}
if (isPromise(result)) {
var ph = document.createComment("async");
frag.appendChild(ph);
(function(p) { pending.push(result.then(function(n) { if (n) p.parentNode.replaceChild(n, p); else p.parentNode.removeChild(p); })); })(ph);
} else if (result) {
frag.appendChild(result);
}
}
if (pending.length) return Promise.all(pending).then(function() { return frag; });
return frag;
}
if (isPromise(fn) || isPromise(coll)) {
return Promise.all([isPromise(fn) ? fn : Promise.resolve(fn), isPromise(coll) ? coll : Promise.resolve(coll)])
.then(function(r) { return doMap(r[0], r[1]); });
}
return doMap(fn, coll);
}
function asyncRenderMapIndexed(expr, env, ns) {
var fn = asyncEval(expr[1], env);
var coll = asyncEval(expr[2], env);
function doMap(f, c) {
if (!Array.isArray(c)) return null;
var frag = document.createDocumentFragment();
var pending = [];
for (var i = 0; i < c.length; i++) {
var item = c[i];
var result;
if (f && f._lambda) {
var lenv = Object.create(f.closure || env);
for (var k in env) if (env.hasOwnProperty(k)) lenv[k] = env[k];
lenv[f.params[0]] = i;
lenv[f.params[1]] = item;
result = asyncRenderToDom(f.body, lenv, null);
} else if (typeof f === "function") {
var r = f(i, item);
result = isPromise(r) ? r.then(function(v) { return asyncRenderToDom(v, env, null); }) : asyncRenderToDom(r, env, null);
} else {
result = asyncRenderToDom(item, env, null);
}
if (isPromise(result)) {
var ph = document.createComment("async");
frag.appendChild(ph);
(function(p) { pending.push(result.then(function(n) { if (n) p.parentNode.replaceChild(n, p); else p.parentNode.removeChild(p); })); })(ph);
} else if (result) {
frag.appendChild(result);
}
}
if (pending.length) return Promise.all(pending).then(function() { return frag; });
return frag;
}
if (isPromise(fn) || isPromise(coll)) {
return Promise.all([isPromise(fn) ? fn : Promise.resolve(fn), isPromise(coll) ? coll : Promise.resolve(coll)])
.then(function(r) { return doMap(r[0], r[1]); });
}
return doMap(fn, coll);
}
function asyncEvalRaw(args, env) {
var parts = [];
var pending = [];
for (var i = 0; i < args.length; i++) {
var val = asyncEval(args[i], env);
if (isPromise(val)) {
(function(idx) {
pending.push(val.then(function(v) { parts[idx] = v; }));
})(parts.length);
parts.push(null);
} else {
parts.push(val);
}
}
function assemble() {
var html = "";
for (var j = 0; j < parts.length; j++) {
var p = parts[j];
if (p && p._rawHtml) html += p.html;
else if (typeof p === "string") html += p;
else if (p != null && !isNil(p)) html += String(p);
}
var el = document.createElement("span");
el.innerHTML = html;
var frag = document.createDocumentFragment();
while (el.firstChild) frag.appendChild(el.firstChild);
return frag;
}
if (pending.length) return Promise.all(pending).then(assemble);
return assemble();
}
// Async version of sxRenderWithEnv — returns Promise<DocumentFragment>
function asyncSxRenderWithEnv(source, extraEnv) {
var env = extraEnv ? merge(componentEnv, extraEnv) : componentEnv;
var exprs = parse(source);
if (!_hasDom) return Promise.resolve(null);
return asyncRenderChildren(exprs, env, null);
}
// IO proxy cache: key → { value, expires }
var _ioCache = {};
var IO_CACHE_TTL = 300000; // 5 minutes
// Register a server-proxied IO primitive: fetches from /sx/io/<name>
// Uses GET for short args, POST for long payloads (URL length safety).
// Results are cached client-side by (name + args) with a TTL.
function registerProxiedIo(name) {
registerIoPrimitive(name, function(args, kwargs) {
// Cache key: name + serialized args
var cacheKey = name;
for (var ci = 0; ci < args.length; ci++) cacheKey += "\0" + String(args[ci]);
for (var ck in kwargs) {
if (kwargs.hasOwnProperty(ck)) cacheKey += "\0" + ck + "=" + String(kwargs[ck]);
}
var cached = _ioCache[cacheKey];
if (cached && cached.expires > Date.now()) return cached.value;
var url = "/sx/io/" + encodeURIComponent(name);
var qs = [];
for (var i = 0; i < args.length; i++) {
qs.push("_arg" + i + "=" + encodeURIComponent(String(args[i])));
}
for (var k in kwargs) {
if (kwargs.hasOwnProperty(k)) {
qs.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(kwargs[k])));
}
}
var queryStr = qs.join("&");
var fetchOpts;
if (queryStr.length > 1500) {
// POST with JSON body for long payloads
var sArgs = [];
for (var j = 0; j < args.length; j++) sArgs.push(String(args[j]));
var sKwargs = {};
for (var kk in kwargs) {
if (kwargs.hasOwnProperty(kk)) sKwargs[kk] = String(kwargs[kk]);
}
var postHeaders = { "SX-Request": "true", "Content-Type": "application/json" };
var csrf = csrfToken();
if (csrf && csrf !== NIL) postHeaders["X-CSRFToken"] = csrf;
fetchOpts = {
method: "POST",
headers: postHeaders,
body: JSON.stringify({ args: sArgs, kwargs: sKwargs })
};
} else {
if (queryStr) url += "?" + queryStr;
fetchOpts = { headers: { "SX-Request": "true" } };
}
var result = fetch(url, fetchOpts)
.then(function(resp) {
if (!resp.ok) {
logWarn("sx:io " + name + " failed " + resp.status);
return NIL;
}
return resp.text();
})
.then(function(text) {
if (!text || text === "nil") return NIL;
try {
var exprs = parse(text);
var val = exprs.length === 1 ? exprs[0] : exprs;
_ioCache[cacheKey] = { value: val, expires: Date.now() + IO_CACHE_TTL };
return val;
} catch (e) {
logWarn("sx:io " + name + " parse error: " + (e && e.message ? e.message : e));
return NIL;
}
})
.catch(function(e) {
logWarn("sx:io " + name + " network error: " + (e && e.message ? e.message : e));
return NIL;
});
// Cache the in-flight promise too (dedup concurrent calls for same args)
_ioCache[cacheKey] = { value: result, expires: Date.now() + IO_CACHE_TTL };
return result;
});
}
// Register IO deps as proxied primitives (idempotent, called per-page)
function registerIoDeps(names) {
if (!names || !names.length) return;
var registered = 0;
for (var i = 0; i < names.length; i++) {
var name = names[i];
if (!IO_PRIMITIVES[name]) {
registerProxiedIo(name);
registered++;
}
}
if (registered > 0) {
logInfo("sx:io registered " + registered + " proxied primitives: " + names.join(", "));
}
}
'''
def compile_ref_to_js(
adapters: list[str] | None = None,
modules: list[str] | None = None,
@@ -1290,6 +1942,8 @@ def compile_ref_to_js(
parts.append(fixups_js(has_html, has_sx, has_dom))
if has_continuations:
parts.append(CONTINUATIONS_JS)
if has_dom:
parts.append(ASYNC_IO_JS)
parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label, has_deps, has_router))
parts.append(EPILOGUE)
from datetime import datetime, timezone
@@ -2783,6 +3437,32 @@ PLATFORM_ORCHESTRATION_JS = """
}
}
// Async eval with callback — used for pages with IO deps.
// Calls callback(rendered) when done, callback(null) on failure.
function tryAsyncEvalContent(source, env, callback) {
var merged = merge(componentEnv);
if (env && !isNil(env)) {
var ks = Object.keys(env);
for (var i = 0; i < ks.length; i++) merged[ks[i]] = env[ks[i]];
}
try {
var result = asyncSxRenderWithEnv(source, merged);
if (isPromise(result)) {
result.then(function(rendered) {
callback(rendered);
}).catch(function(e) {
logWarn("sx:async eval miss: " + (e && e.message ? e.message : e));
callback(null);
});
} else {
callback(result);
}
} catch (e) {
logInfo("sx:async eval miss: " + (e && e.message ? e.message : e));
callback(null);
}
}
function resolvePageData(pageName, params, callback) {
// Platform implementation: fetch page data via HTTP from /sx/data/ endpoint.
// The spec only knows about resolve-page-data(name, params, callback) —
@@ -3360,6 +4040,11 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has
api_lines.append(' matchRoute: matchRoute,')
api_lines.append(' findMatchingRoute: findMatchingRoute,')
if has_dom:
api_lines.append(' registerIo: typeof registerIoPrimitive === "function" ? registerIoPrimitive : null,')
api_lines.append(' registerIoDeps: typeof registerIoDeps === "function" ? registerIoDeps : null,')
api_lines.append(' asyncRender: typeof asyncSxRenderWithEnv === "function" ? asyncSxRenderWithEnv : null,')
api_lines.append(' asyncRenderToDom: typeof asyncRenderToDom === "function" ? asyncRenderToDom : null,')
api_lines.append(f' _version: "{version}"')
api_lines.append(' };')
api_lines.append('')

View File

@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""
Bootstrap compiler: test.sx -> pytest test module.
Reads test.sx and emits a Python test file that runs each deftest
as a pytest test case, grouped into classes by defsuite.
The emitted tests use the SX evaluator to run SX test bodies,
verifying that the Python implementation matches the spec.
Usage:
python bootstrap_test.py --output shared/sx/tests/test_sx_spec.py
pytest shared/sx/tests/test_sx_spec.py -v
"""
from __future__ import annotations
import os
import re
import sys
import argparse
_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 _slugify(name: str) -> str:
"""Convert a test/suite name to a valid Python identifier."""
s = name.lower().strip()
s = re.sub(r'[^a-z0-9]+', '_', s)
s = s.strip('_')
return s
def _sx_to_source(expr) -> str:
"""Convert an SX AST node back to SX source string."""
if isinstance(expr, bool):
return "true" if expr else "false"
if isinstance(expr, (int, float)):
return str(expr)
if isinstance(expr, str):
escaped = expr.replace('\\', '\\\\').replace('"', '\\"')
return f'"{escaped}"'
if expr is None or expr is SX_NIL:
return "nil"
if isinstance(expr, Symbol):
return expr.name
if isinstance(expr, Keyword):
return f":{expr.name}"
if isinstance(expr, dict):
pairs = []
for k, v in expr.items():
pairs.append(f":{k} {_sx_to_source(v)}")
return "{" + " ".join(pairs) + "}"
if isinstance(expr, list):
if not expr:
return "()"
return "(" + " ".join(_sx_to_source(e) for e in expr) + ")"
return str(expr)
def _parse_test_sx(path: str) -> tuple[list[dict], list]:
"""Parse test.sx and return (suites, preamble_exprs).
Preamble exprs are define forms (assertion helpers) that must be
evaluated before tests run. Suites contain the actual test cases.
"""
with open(path) as f:
content = f.read()
exprs = parse_all(content)
suites = []
preamble = []
for expr in exprs:
if not isinstance(expr, list) or not expr:
continue
head = expr[0]
if isinstance(head, Symbol) and head.name == "defsuite":
suite = _parse_suite(expr)
if suite:
suites.append(suite)
elif isinstance(head, Symbol) and head.name == "define":
preamble.append(expr)
return suites, preamble
def _parse_suite(expr: list) -> dict | None:
"""Parse a (defsuite "name" ...) form."""
if len(expr) < 2:
return None
name = expr[1]
if not isinstance(name, str):
return None
tests = []
for child in expr[2:]:
if not isinstance(child, list) or not child:
continue
head = child[0]
if isinstance(head, Symbol):
if head.name == "deftest":
test = _parse_test(child)
if test:
tests.append(test)
elif head.name == "defsuite":
sub = _parse_suite(child)
if sub:
tests.append(sub)
return {"type": "suite", "name": name, "tests": tests}
def _parse_test(expr: list) -> dict | None:
"""Parse a (deftest "name" body ...) form."""
if len(expr) < 3:
return None
name = expr[1]
if not isinstance(name, str):
return None
body = expr[2:]
return {"type": "test", "name": name, "body": body}
def _emit_py(suites: list[dict], preamble: list) -> str:
"""Emit a pytest module from parsed suites."""
# Serialize preamble (assertion helpers) as SX source
preamble_sx = "\n".join(_sx_to_source(expr) for expr in preamble)
preamble_escaped = preamble_sx.replace('\\', '\\\\').replace("'", "\\'")
lines = []
lines.append('"""Auto-generated from test.sx — SX spec self-tests.')
lines.append('')
lines.append('DO NOT EDIT. Regenerate with:')
lines.append(' python shared/sx/ref/bootstrap_test.py --output shared/sx/tests/test_sx_spec.py')
lines.append('"""')
lines.append('from __future__ import annotations')
lines.append('')
lines.append('import pytest')
lines.append('from shared.sx.parser import parse_all')
lines.append('from shared.sx.evaluator import _eval, _trampoline')
lines.append('')
lines.append('')
lines.append(f"_PREAMBLE = '''{preamble_escaped}'''")
lines.append('')
lines.append('')
lines.append('def _make_env() -> dict:')
lines.append(' """Create a fresh env with assertion helpers loaded."""')
lines.append(' env = {}')
lines.append(' for expr in parse_all(_PREAMBLE):')
lines.append(' _trampoline(_eval(expr, env))')
lines.append(' return env')
lines.append('')
lines.append('')
lines.append('def _run(sx_source: str, env: dict | None = None) -> object:')
lines.append(' """Evaluate SX source and return the result."""')
lines.append(' if env is None:')
lines.append(' env = _make_env()')
lines.append(' exprs = parse_all(sx_source)')
lines.append(' result = None')
lines.append(' for expr in exprs:')
lines.append(' result = _trampoline(_eval(expr, env))')
lines.append(' return result')
lines.append('')
for suite in suites:
_emit_suite(suite, lines, indent=0)
return "\n".join(lines)
def _emit_suite(suite: dict, lines: list[str], indent: int):
"""Emit a pytest class for a suite."""
class_name = f"TestSpec{_slugify(suite['name']).title().replace('_', '')}"
pad = " " * indent
lines.append(f'{pad}class {class_name}:')
lines.append(f'{pad} """test.sx suite: {suite["name"]}"""')
lines.append('')
for item in suite["tests"]:
if item["type"] == "test":
_emit_test(item, lines, indent + 1)
elif item["type"] == "suite":
_emit_suite(item, lines, indent + 1)
lines.append('')
def _emit_test(test: dict, lines: list[str], indent: int):
"""Emit a pytest test method."""
method_name = f"test_{_slugify(test['name'])}"
pad = " " * indent
# Convert body expressions to SX source
body_parts = []
for expr in test["body"]:
body_parts.append(_sx_to_source(expr))
# Wrap in (do ...) if multiple expressions, or use single
if len(body_parts) == 1:
sx_source = body_parts[0]
else:
sx_source = "(do " + " ".join(body_parts) + ")"
# Escape for Python string
sx_escaped = sx_source.replace('\\', '\\\\').replace("'", "\\'")
lines.append(f"{pad}def {method_name}(self):")
lines.append(f"{pad} _run('{sx_escaped}')")
lines.append('')
def main():
parser = argparse.ArgumentParser(description="Bootstrap test.sx to pytest")
parser.add_argument("--output", "-o", help="Output file path")
parser.add_argument("--dry-run", action="store_true", help="Print to stdout")
args = parser.parse_args()
test_sx = os.path.join(_HERE, "test.sx")
suites, preamble = _parse_test_sx(test_sx)
print(f"Parsed {len(suites)} suites, {len(preamble)} preamble defines from test.sx", file=sys.stderr)
total_tests = sum(
sum(1 for t in s["tests"] if t["type"] == "test")
for s in suites
)
print(f"Total test cases: {total_tests}", file=sys.stderr)
output = _emit_py(suites, preamble)
if args.output and not args.dry_run:
with open(args.output, "w") as f:
f.write(output)
print(f"Wrote {args.output}", file=sys.stderr)
else:
print(output)
if __name__ == "__main__":
main()

View File

@@ -648,40 +648,74 @@
(do (log-warn (str "sx:route target not found: " target-sel)) false)
(if (not (deps-satisfied? match))
(do (log-info (str "sx:route deps miss for " page-name)) false)
(if (get match "has-data")
;; Data page: check cache, else resolve asynchronously
(let ((cache-key (page-data-cache-key page-name params))
(cached (page-data-cache-get cache-key)))
(if cached
;; Cache hit: render immediately
(let ((env (merge closure params cached))
(let ((io-deps (get match "io-deps"))
(has-io (and io-deps (not (empty? io-deps)))))
;; Ensure IO deps are registered as proxied primitives
(when has-io (register-io-deps io-deps))
(if (get match "has-data")
;; Data page: check cache, else resolve asynchronously
(let ((cache-key (page-data-cache-key page-name params))
(cached (page-data-cache-get cache-key)))
(if cached
;; Cache hit
(let ((env (merge closure params cached)))
(if has-io
;; Async render (data+IO)
(do
(log-info (str "sx:route client+cache+async " pathname))
(try-async-eval-content content-src env
(fn (rendered)
(if (nil? rendered)
(log-warn (str "sx:route async eval failed for " pathname))
(swap-rendered-content target rendered pathname))))
true)
;; Sync render (data only)
(let ((rendered (try-eval-content content-src env)))
(if (nil? rendered)
(do (log-warn (str "sx:route cached eval failed for " pathname)) false)
(do
(log-info (str "sx:route client+cache " pathname))
(swap-rendered-content target rendered pathname)
true)))))
;; Cache miss: fetch, cache, render
(do
(log-info (str "sx:route client+data " pathname))
(resolve-page-data page-name params
(fn (data)
(page-data-cache-set cache-key data)
(let ((env (merge closure params data)))
(if has-io
;; Async render (data+IO)
(try-async-eval-content content-src env
(fn (rendered)
(if (nil? rendered)
(log-warn (str "sx:route data+async eval failed for " pathname))
(swap-rendered-content target rendered pathname))))
;; Sync render (data only)
(let ((rendered (try-eval-content content-src env)))
(if (nil? rendered)
(log-warn (str "sx:route data eval failed for " pathname))
(swap-rendered-content target rendered pathname)))))))
true)))
;; Non-data page
(if has-io
;; Async render (IO only, no data)
(do
(log-info (str "sx:route client+async " pathname))
(try-async-eval-content content-src (merge closure params)
(fn (rendered)
(if (nil? rendered)
(log-warn (str "sx:route async eval failed for " pathname))
(swap-rendered-content target rendered pathname))))
true)
;; Pure page: render immediately
(let ((env (merge closure params))
(rendered (try-eval-content content-src env)))
(if (nil? rendered)
(do (log-warn (str "sx:route cached eval failed for " pathname)) false)
(do (log-info (str "sx:route server (eval failed) " pathname)) false)
(do
(log-info (str "sx:route client+cache " pathname))
(swap-rendered-content target rendered pathname)
true)))
;; Cache miss: fetch, cache, render
(do
(log-info (str "sx:route client+data " pathname))
(resolve-page-data page-name params
(fn (data)
(page-data-cache-set cache-key data)
(let ((env (merge closure params data))
(rendered (try-eval-content content-src env)))
(if (nil? rendered)
(log-warn (str "sx:route data eval failed for " pathname))
(swap-rendered-content target rendered pathname)))))
true)))
;; Pure page: render immediately
(let ((env (merge closure params))
(rendered (try-eval-content content-src env)))
(if (nil? rendered)
(do (log-info (str "sx:route server (eval failed) " pathname)) false)
(do
(swap-rendered-content target rendered pathname)
true)))))))))))))
true)))))))))))))))
(define bind-client-route-link
@@ -991,6 +1025,10 @@
;;
;; === Client-side routing ===
;; (try-eval-content source env) → DOM node or nil (catches eval errors)
;; (try-async-eval-content source env callback) → void; async render,
;; calls (callback rendered-or-nil). Used for pages with IO deps.
;; (register-io-deps names) → void; ensure each IO name is registered
;; as a proxied IO primitive on the client. Idempotent.
;; (url-pathname href) → extract pathname from URL string
;; (resolve-page-data name params cb) → void; resolves data for a named page.
;; Platform decides transport (HTTP, cache, IPC, etc). Calls (cb data-dict)

View File

@@ -208,10 +208,16 @@
:returns "boolean"
:doc "True if x is nil/null/None.")
(define-primitive "boolean?"
:params (x)
:returns "boolean"
:doc "True if x is a boolean (true or false). Must be checked before
number? on platforms where booleans are numeric subtypes.")
(define-primitive "number?"
:params (x)
:returns "boolean"
:doc "True if x is a number (int or float).")
:doc "True if x is a number (int or float). Excludes booleans.")
(define-primitive "string?"
:params (x)
@@ -277,11 +283,36 @@
:returns "string"
:doc "Uppercase string.")
(define-primitive "upcase"
:params (s)
:returns "string"
:doc "Alias for upper. Uppercase string.")
(define-primitive "lower"
:params (s)
:returns "string"
:doc "Lowercase string.")
(define-primitive "downcase"
:params (s)
:returns "string"
:doc "Alias for lower. Lowercase string.")
(define-primitive "string-length"
:params (s)
:returns "number"
:doc "Length of string in characters.")
(define-primitive "substring"
:params (s start end)
:returns "string"
:doc "Extract substring from start (inclusive) to end (exclusive).")
(define-primitive "string-contains?"
:params (s needle)
:returns "boolean"
:doc "True if string s contains substring needle.")
(define-primitive "trim"
:params (s)
:returns "string"
@@ -382,13 +413,23 @@
(define-primitive "append"
:params (coll x)
:returns "list"
:doc "Append x to end of coll (returns new list).")
:doc "If x is a list, concatenate. Otherwise append x as single element.")
(define-primitive "append!"
:params (coll x)
:returns "list"
:doc "Mutate coll by appending x in-place. Returns coll.")
(define-primitive "reverse"
:params (coll)
:returns "list"
:doc "Return coll in reverse order.")
(define-primitive "flatten"
:params (coll)
:returns "list"
:doc "Flatten one level of nesting. Nested lists become top-level elements.")
(define-primitive "chunk-every"
:params (coll n)
:returns "list"
@@ -421,6 +462,11 @@
:returns "dict"
:doc "Merge dicts left to right. Later keys win. Skips nil.")
(define-primitive "has-key?"
:params (d key)
:returns "boolean"
:doc "True if dict d contains key.")
(define-primitive "assoc"
:params (d &rest pairs)
:returns "dict"

597
shared/sx/ref/test.sx Normal file
View File

@@ -0,0 +1,597 @@
;; ==========================================================================
;; test.sx — Self-hosting SX test framework
;;
;; Defines a minimal test framework in SX that tests SX — the language
;; proves its own correctness. The framework is self-executing: any host
;; that provides 5 platform functions can evaluate this file directly.
;;
;; Platform functions required:
;; try-call (thunk) → {:ok true} | {:ok false :error "msg"}
;; report-pass (name) → platform-specific pass output
;; report-fail (name error) → platform-specific fail output
;; push-suite (name) → push suite name onto context stack
;; pop-suite () → pop suite name from context stack
;;
;; Usage:
;; ;; Host injects platform functions into env, then:
;; (eval-file "test.sx" env)
;;
;; The same test.sx runs on every host — Python, JavaScript, etc.
;; ==========================================================================
;; --------------------------------------------------------------------------
;; 1. Test framework macros
;; --------------------------------------------------------------------------
;;
;; deftest and defsuite are macros that make test.sx directly executable.
;; The host provides try-call (error catching), reporting, and suite
;; context — everything else is pure SX.
(defmacro deftest (name &rest body)
`(let ((result (try-call (fn () ,@body))))
(if (get result "ok")
(report-pass ,name)
(report-fail ,name (get result "error")))))
(defmacro defsuite (name &rest items)
`(do (push-suite ,name)
,@items
(pop-suite)))
;; --------------------------------------------------------------------------
;; 2. Assertion helpers — defined in SX, available in test bodies
;; --------------------------------------------------------------------------
;;
;; These are regular functions (not special forms). They use the `assert`
;; primitive underneath but provide better error messages.
(define assert-equal
(fn (expected actual)
(assert (equal? expected actual)
(str "Expected " (str expected) " but got " (str actual)))))
(define assert-not-equal
(fn (a b)
(assert (not (equal? a b))
(str "Expected values to differ but both are " (str a)))))
(define assert-true
(fn (val)
(assert val (str "Expected truthy but got " (str val)))))
(define assert-false
(fn (val)
(assert (not val) (str "Expected falsy but got " (str val)))))
(define assert-nil
(fn (val)
(assert (nil? val) (str "Expected nil but got " (str val)))))
(define assert-type
(fn (expected-type val)
;; Implemented via predicate dispatch since type-of is a platform
;; function not available in all hosts. Uses nested if to avoid
;; Scheme-style cond detection for 2-element predicate calls.
;; Boolean checked before number (subtypes on some platforms).
(let ((actual-type
(if (nil? val) "nil"
(if (boolean? val) "boolean"
(if (number? val) "number"
(if (string? val) "string"
(if (list? val) "list"
(if (dict? val) "dict"
"unknown"))))))))
(assert (= expected-type actual-type)
(str "Expected type " expected-type " but got " actual-type)))))
(define assert-length
(fn (expected-len col)
(assert (= (len col) expected-len)
(str "Expected length " expected-len " but got " (len col)))))
(define assert-contains
(fn (item col)
(assert (some (fn (x) (equal? x item)) col)
(str "Expected collection to contain " (str item)))))
(define assert-throws
(fn (thunk)
(let ((result (try-call thunk)))
(assert (not (get result "ok"))
"Expected an error to be thrown but none was"))))
;; ==========================================================================
;; 3. Test suites — SX testing SX
;; ==========================================================================
;; --------------------------------------------------------------------------
;; 3a. Literals and types
;; --------------------------------------------------------------------------
(defsuite "literals"
(deftest "numbers are numbers"
(assert-type "number" 42)
(assert-type "number" 3.14)
(assert-type "number" -1))
(deftest "strings are strings"
(assert-type "string" "hello")
(assert-type "string" ""))
(deftest "booleans are booleans"
(assert-type "boolean" true)
(assert-type "boolean" false))
(deftest "nil is nil"
(assert-type "nil" nil)
(assert-nil nil))
(deftest "lists are lists"
(assert-type "list" (list 1 2 3))
(assert-type "list" (list)))
(deftest "dicts are dicts"
(assert-type "dict" {:a 1 :b 2})))
;; --------------------------------------------------------------------------
;; 3b. Arithmetic
;; --------------------------------------------------------------------------
(defsuite "arithmetic"
(deftest "addition"
(assert-equal 3 (+ 1 2))
(assert-equal 0 (+ 0 0))
(assert-equal -1 (+ 1 -2))
(assert-equal 10 (+ 1 2 3 4)))
(deftest "subtraction"
(assert-equal 1 (- 3 2))
(assert-equal -1 (- 2 3)))
(deftest "multiplication"
(assert-equal 6 (* 2 3))
(assert-equal 0 (* 0 100))
(assert-equal 24 (* 1 2 3 4)))
(deftest "division"
(assert-equal 2 (/ 6 3))
(assert-equal 2.5 (/ 5 2)))
(deftest "modulo"
(assert-equal 1 (mod 7 3))
(assert-equal 0 (mod 6 3))))
;; --------------------------------------------------------------------------
;; 3c. Comparison
;; --------------------------------------------------------------------------
(defsuite "comparison"
(deftest "equality"
(assert-true (= 1 1))
(assert-false (= 1 2))
(assert-true (= "a" "a"))
(assert-false (= "a" "b")))
(deftest "deep equality"
(assert-true (equal? (list 1 2 3) (list 1 2 3)))
(assert-false (equal? (list 1 2) (list 1 3)))
(assert-true (equal? {:a 1} {:a 1}))
(assert-false (equal? {:a 1} {:a 2})))
(deftest "ordering"
(assert-true (< 1 2))
(assert-false (< 2 1))
(assert-true (> 2 1))
(assert-true (<= 1 1))
(assert-true (<= 1 2))
(assert-true (>= 2 2))
(assert-true (>= 3 2))))
;; --------------------------------------------------------------------------
;; 3d. String operations
;; --------------------------------------------------------------------------
(defsuite "strings"
(deftest "str concatenation"
(assert-equal "abc" (str "a" "b" "c"))
(assert-equal "hello world" (str "hello" " " "world"))
(assert-equal "42" (str 42))
(assert-equal "" (str)))
(deftest "string-length"
(assert-equal 5 (string-length "hello"))
(assert-equal 0 (string-length "")))
(deftest "substring"
(assert-equal "ell" (substring "hello" 1 4))
(assert-equal "hello" (substring "hello" 0 5)))
(deftest "string-contains?"
(assert-true (string-contains? "hello world" "world"))
(assert-false (string-contains? "hello" "xyz")))
(deftest "upcase and downcase"
(assert-equal "HELLO" (upcase "hello"))
(assert-equal "hello" (downcase "HELLO")))
(deftest "trim"
(assert-equal "hello" (trim " hello "))
(assert-equal "hello" (trim "hello")))
(deftest "split and join"
(assert-equal (list "a" "b" "c") (split "a,b,c" ","))
(assert-equal "a-b-c" (join "-" (list "a" "b" "c")))))
;; --------------------------------------------------------------------------
;; 3e. List operations
;; --------------------------------------------------------------------------
(defsuite "lists"
(deftest "constructors"
(assert-equal (list 1 2 3) (list 1 2 3))
(assert-equal (list) (list))
(assert-length 3 (list 1 2 3)))
(deftest "first and rest"
(assert-equal 1 (first (list 1 2 3)))
(assert-equal (list 2 3) (rest (list 1 2 3)))
(assert-nil (first (list)))
(assert-equal (list) (rest (list))))
(deftest "nth"
(assert-equal 1 (nth (list 1 2 3) 0))
(assert-equal 2 (nth (list 1 2 3) 1))
(assert-equal 3 (nth (list 1 2 3) 2)))
(deftest "last"
(assert-equal 3 (last (list 1 2 3)))
(assert-nil (last (list))))
(deftest "cons and append"
(assert-equal (list 0 1 2) (cons 0 (list 1 2)))
(assert-equal (list 1 2 3 4) (append (list 1 2) (list 3 4))))
(deftest "reverse"
(assert-equal (list 3 2 1) (reverse (list 1 2 3)))
(assert-equal (list) (reverse (list))))
(deftest "empty?"
(assert-true (empty? (list)))
(assert-false (empty? (list 1))))
(deftest "len"
(assert-equal 0 (len (list)))
(assert-equal 3 (len (list 1 2 3))))
(deftest "contains?"
(assert-true (contains? (list 1 2 3) 2))
(assert-false (contains? (list 1 2 3) 4)))
(deftest "flatten"
(assert-equal (list 1 2 3 4) (flatten (list (list 1 2) (list 3 4))))))
;; --------------------------------------------------------------------------
;; 3f. Dict operations
;; --------------------------------------------------------------------------
(defsuite "dicts"
(deftest "dict literal"
(assert-type "dict" {:a 1 :b 2})
(assert-equal 1 (get {:a 1} "a"))
(assert-equal 2 (get {:a 1 :b 2} "b")))
(deftest "assoc"
(assert-equal {:a 1 :b 2} (assoc {:a 1} "b" 2))
(assert-equal {:a 99} (assoc {:a 1} "a" 99)))
(deftest "dissoc"
(assert-equal {:b 2} (dissoc {:a 1 :b 2} "a")))
(deftest "keys and vals"
(let ((d {:a 1 :b 2}))
(assert-length 2 (keys d))
(assert-length 2 (vals d))
(assert-contains "a" (keys d))
(assert-contains "b" (keys d))))
(deftest "has-key?"
(assert-true (has-key? {:a 1} "a"))
(assert-false (has-key? {:a 1} "b")))
(deftest "merge"
(assert-equal {:a 1 :b 2 :c 3}
(merge {:a 1 :b 2} {:c 3}))
(assert-equal {:a 99 :b 2}
(merge {:a 1 :b 2} {:a 99}))))
;; --------------------------------------------------------------------------
;; 3g. Predicates
;; --------------------------------------------------------------------------
(defsuite "predicates"
(deftest "nil?"
(assert-true (nil? nil))
(assert-false (nil? 0))
(assert-false (nil? false))
(assert-false (nil? "")))
(deftest "number?"
(assert-true (number? 42))
(assert-true (number? 3.14))
(assert-false (number? "42")))
(deftest "string?"
(assert-true (string? "hello"))
(assert-false (string? 42)))
(deftest "list?"
(assert-true (list? (list 1 2)))
(assert-false (list? "not a list")))
(deftest "dict?"
(assert-true (dict? {:a 1}))
(assert-false (dict? (list 1))))
(deftest "boolean?"
(assert-true (boolean? true))
(assert-true (boolean? false))
(assert-false (boolean? nil))
(assert-false (boolean? 0)))
(deftest "not"
(assert-true (not false))
(assert-true (not nil))
(assert-false (not true))
(assert-false (not 1))
(assert-false (not "x"))))
;; --------------------------------------------------------------------------
;; 3h. Special forms
;; --------------------------------------------------------------------------
(defsuite "special-forms"
(deftest "if"
(assert-equal "yes" (if true "yes" "no"))
(assert-equal "no" (if false "yes" "no"))
(assert-equal "no" (if nil "yes" "no"))
(assert-nil (if false "yes")))
(deftest "when"
(assert-equal "yes" (when true "yes"))
(assert-nil (when false "yes")))
(deftest "cond"
(assert-equal "a" (cond true "a" :else "b"))
(assert-equal "b" (cond false "a" :else "b"))
(assert-equal "c" (cond
false "a"
false "b"
:else "c")))
(deftest "and"
(assert-true (and true true))
(assert-false (and true false))
(assert-false (and false true))
(assert-equal 3 (and 1 2 3)))
(deftest "or"
(assert-equal 1 (or 1 2))
(assert-equal 2 (or false 2))
(assert-equal "fallback" (or nil false "fallback"))
(assert-false (or false false)))
(deftest "let"
(assert-equal 3 (let ((x 1) (y 2)) (+ x y)))
(assert-equal "hello world"
(let ((a "hello") (b " world")) (str a b))))
(deftest "let clojure-style"
(assert-equal 3 (let (x 1 y 2) (+ x y))))
(deftest "do / begin"
(assert-equal 3 (do 1 2 3))
(assert-equal "last" (begin "first" "middle" "last")))
(deftest "define"
(define x 42)
(assert-equal 42 x))
(deftest "set!"
(define x 1)
(set! x 2)
(assert-equal 2 x)))
;; --------------------------------------------------------------------------
;; 3i. Lambda and closures
;; --------------------------------------------------------------------------
(defsuite "lambdas"
(deftest "basic lambda"
(let ((add (fn (a b) (+ a b))))
(assert-equal 3 (add 1 2))))
(deftest "closure captures env"
(let ((x 10))
(let ((add-x (fn (y) (+ x y))))
(assert-equal 15 (add-x 5)))))
(deftest "lambda as argument"
(assert-equal (list 2 4 6)
(map (fn (x) (* x 2)) (list 1 2 3))))
(deftest "recursive lambda via define"
(define factorial
(fn (n) (if (<= n 1) 1 (* n (factorial (- n 1))))))
(assert-equal 120 (factorial 5)))
(deftest "higher-order returns lambda"
(let ((make-adder (fn (n) (fn (x) (+ n x)))))
(let ((add5 (make-adder 5)))
(assert-equal 8 (add5 3))))))
;; --------------------------------------------------------------------------
;; 3j. Higher-order forms
;; --------------------------------------------------------------------------
(defsuite "higher-order"
(deftest "map"
(assert-equal (list 2 4 6)
(map (fn (x) (* x 2)) (list 1 2 3)))
(assert-equal (list) (map (fn (x) x) (list))))
(deftest "filter"
(assert-equal (list 2 4)
(filter (fn (x) (= (mod x 2) 0)) (list 1 2 3 4)))
(assert-equal (list)
(filter (fn (x) false) (list 1 2 3))))
(deftest "reduce"
(assert-equal 10 (reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3 4)))
(assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list))))
(deftest "some"
(assert-true (some (fn (x) (> x 3)) (list 1 2 3 4 5)))
(assert-false (some (fn (x) (> x 10)) (list 1 2 3))))
(deftest "every?"
(assert-true (every? (fn (x) (> x 0)) (list 1 2 3)))
(assert-false (every? (fn (x) (> x 2)) (list 1 2 3))))
(deftest "map-indexed"
(assert-equal (list "0:a" "1:b" "2:c")
(map-indexed (fn (i x) (str i ":" x)) (list "a" "b" "c")))))
;; --------------------------------------------------------------------------
;; 3k. Components
;; --------------------------------------------------------------------------
(defsuite "components"
(deftest "defcomp creates component"
(defcomp ~test-comp (&key title)
(div title))
;; Component is bound and not nil
(assert-true (not (nil? ~test-comp))))
(deftest "component renders with keyword args"
(defcomp ~greeting (&key name)
(span (str "Hello, " name "!")))
(assert-true (not (nil? ~greeting))))
(deftest "component with children"
(defcomp ~box (&key &rest children)
(div :class "box" children))
(assert-true (not (nil? ~box))))
(deftest "component with default via or"
(defcomp ~label (&key text)
(span (or text "default")))
(assert-true (not (nil? ~label)))))
;; --------------------------------------------------------------------------
;; 3l. Macros
;; --------------------------------------------------------------------------
(defsuite "macros"
(deftest "defmacro creates macro"
(defmacro unless (cond &rest body)
`(if (not ,cond) (do ,@body)))
(assert-equal "yes" (unless false "yes"))
(assert-nil (unless true "no")))
(deftest "quasiquote and unquote"
(let ((x 42))
(assert-equal (list 1 42 3) `(1 ,x 3))))
(deftest "splice-unquote"
(let ((xs (list 2 3 4)))
(assert-equal (list 1 2 3 4 5) `(1 ,@xs 5)))))
;; --------------------------------------------------------------------------
;; 3m. Threading macro
;; --------------------------------------------------------------------------
(defsuite "threading"
(deftest "thread-first"
(assert-equal 8 (-> 5 (+ 1) (+ 2)))
(assert-equal "HELLO" (-> "hello" upcase))
(assert-equal "HELLO WORLD"
(-> "hello"
(str " world")
upcase))))
;; --------------------------------------------------------------------------
;; 3n. Truthiness
;; --------------------------------------------------------------------------
(defsuite "truthiness"
(deftest "truthy values"
(assert-true (if 1 true false))
(assert-true (if "x" true false))
(assert-true (if (list 1) true false))
(assert-true (if true true false)))
(deftest "falsy values"
(assert-false (if false true false))
(assert-false (if nil true false)))
;; NOTE: empty list, zero, and empty string truthiness is
;; platform-dependent. Python treats all three as falsy.
;; JavaScript treats [] as truthy but 0 and "" as falsy.
;; These tests are omitted — each bootstrapper should emit
;; platform-specific truthiness tests instead.
)
;; --------------------------------------------------------------------------
;; 3o. Edge cases and regression tests
;; --------------------------------------------------------------------------
(defsuite "edge-cases"
(deftest "nested let scoping"
(let ((x 1))
(let ((x 2))
(assert-equal 2 x))
;; outer x should be unchanged by inner let
;; (this tests that let creates a new scope)
))
(deftest "recursive map"
(assert-equal (list (list 2 4) (list 6 8))
(map (fn (sub) (map (fn (x) (* x 2)) sub))
(list (list 1 2) (list 3 4)))))
(deftest "keyword as value"
(assert-equal "class" :class)
(assert-equal "id" :id))
(deftest "dict with evaluated values"
(let ((x 42))
(assert-equal 42 (get {:val x} "val"))))
(deftest "nil propagation"
(assert-nil (get {:a 1} "missing"))
(assert-equal "default" (or (get {:a 1} "missing") "default")))
(deftest "empty operations"
(assert-equal (list) (map (fn (x) x) (list)))
(assert-equal (list) (filter (fn (x) true) (list)))
(assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list)))
(assert-equal 0 (len (list)))
(assert-equal "" (str))))

108
shared/sx/tests/run.js Normal file
View File

@@ -0,0 +1,108 @@
// Run test.sx directly against sx-browser.js.
//
// sx-browser.js parses and evaluates test.sx — SX tests itself.
// This script provides only platform functions (error catching, reporting).
//
// Usage: node shared/sx/tests/run.js
Object.defineProperty(globalThis, "document", { value: undefined, writable: true });
var path = require("path");
var fs = require("fs");
var Sx = require(path.resolve(__dirname, "../../static/scripts/sx-browser.js"));
// --- Test state ---
var suiteStack = [];
var passed = 0, failed = 0, testNum = 0;
// --- Helpers ---
function isNil(x) { return x === Sx.NIL || x === null || x === undefined; }
function deepEqual(a, b) {
if (a === b) return true;
if (isNil(a) && isNil(b)) return true;
if (typeof a !== typeof b) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (var i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
return true;
}
if (a && typeof a === "object" && b && typeof b === "object") {
var ka = Object.keys(a), kb = Object.keys(b);
if (ka.length !== kb.length) return false;
for (var j = 0; j < ka.length; j++) if (!deepEqual(a[ka[j]], b[ka[j]])) return false;
return true;
}
return false;
}
// --- Platform functions injected into the SX env ---
var env = {
// Error catching — calls an SX thunk, returns result dict
"try-call": function(thunk) {
try {
Sx.eval([thunk], env);
return { ok: true };
} catch(e) {
return { ok: false, error: e.message || String(e) };
}
},
// Test reporting
"report-pass": function(name) {
testNum++;
passed++;
var fullName = suiteStack.concat([name]).join(" > ");
console.log("ok " + testNum + " - " + fullName);
},
"report-fail": function(name, error) {
testNum++;
failed++;
var fullName = suiteStack.concat([name]).join(" > ");
console.log("not ok " + testNum + " - " + fullName);
console.log(" # " + error);
},
// Suite context
"push-suite": function(name) { suiteStack.push(name); },
"pop-suite": function() { suiteStack.pop(); },
// Primitives that sx-browser.js has internally but doesn't expose through env
"equal?": function(a, b) { return deepEqual(a, b); },
"eq?": function(a, b) { return a === b; },
"boolean?": function(x) { return typeof x === "boolean"; },
"string-length": function(s) { return String(s).length; },
"substring": function(s, start, end) { return String(s).slice(start, end); },
"string-contains?": function(s, needle) { return String(s).indexOf(needle) !== -1; },
"upcase": function(s) { return String(s).toUpperCase(); },
"downcase": function(s) { return String(s).toLowerCase(); },
"reverse": function(c) { return c ? c.slice().reverse() : []; },
"flatten": function(c) {
var r = [];
for (var i = 0; i < (c||[]).length; i++) {
if (Array.isArray(c[i])) for (var j = 0; j < c[i].length; j++) r.push(c[i][j]);
else r.push(c[i]);
}
return r;
},
"has-key?": function(d, k) { return d && typeof d === "object" && k in d; },
"append": function(c, x) { return Array.isArray(x) ? (c||[]).concat(x) : (c||[]).concat([x]); },
};
// --- Read and evaluate test.sx ---
var src = fs.readFileSync(path.resolve(__dirname, "../ref/test.sx"), "utf8");
var exprs = Sx.parseAll(src);
console.log("TAP version 13");
for (var i = 0; i < exprs.length; i++) {
Sx.eval(exprs[i], env);
}
// --- Summary ---
console.log("");
console.log("1.." + testNum);
console.log("# tests " + (passed + failed));
console.log("# pass " + passed);
if (failed > 0) {
console.log("# fail " + failed);
process.exit(1);
}

92
shared/sx/tests/run.py Normal file
View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Run test.sx directly against the Python SX evaluator.
The Python evaluator parses and evaluates test.sx — SX tests itself.
This script provides only platform functions (error catching, reporting).
Usage: python shared/sx/tests/run.py
"""
from __future__ import annotations
import os
import sys
import traceback
_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.evaluator import _eval, _trampoline
# --- Test state ---
suite_stack: list[str] = []
passed = 0
failed = 0
test_num = 0
def try_call(thunk):
"""Call an SX thunk, catching errors."""
try:
_trampoline(_eval([thunk], {}))
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
def report_pass(name):
global passed, test_num
test_num += 1
passed += 1
full_name = " > ".join(suite_stack + [name])
print(f"ok {test_num} - {full_name}")
def report_fail(name, error):
global failed, test_num
test_num += 1
failed += 1
full_name = " > ".join(suite_stack + [name])
print(f"not ok {test_num} - {full_name}")
print(f" # {error}")
def push_suite(name):
suite_stack.append(name)
def pop_suite():
suite_stack.pop()
def main():
env = {
"try-call": try_call,
"report-pass": report_pass,
"report-fail": report_fail,
"push-suite": push_suite,
"pop-suite": pop_suite,
}
test_sx = os.path.join(_HERE, "..", "ref", "test.sx")
with open(test_sx) as f:
src = f.read()
exprs = parse_all(src)
print("TAP version 13")
for expr in exprs:
_trampoline(_eval(expr, env))
print()
print(f"1..{test_num}")
print(f"# tests {passed + failed}")
print(f"# pass {passed}")
if failed > 0:
print(f"# fail {failed}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,387 @@
"""Tests for Phase 5 async IO proxy infrastructure.
Tests the io-deps page registry field, SxExpr serialization through
the IO proxy pipeline, dynamic allowlist construction, and the
orchestration.sx routing logic for IO-dependent pages.
"""
import pytest
from shared.sx.parser import parse_all, serialize, SxExpr
from shared.sx.types import Component, Macro, Symbol, Keyword, NIL
from shared.sx.deps import (
_compute_all_io_refs_fallback,
components_needed,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_env(*sx_sources: str) -> dict:
"""Parse and evaluate component definitions into an env dict."""
from shared.sx.evaluator import _eval, _trampoline
env: dict = {}
for source in sx_sources:
exprs = parse_all(source)
for expr in exprs:
_trampoline(_eval(expr, env))
return env
IO_NAMES = {"highlight", "current-user", "app-url", "config", "fetch-data"}
# ---------------------------------------------------------------------------
# io-deps in page registry entries
# ---------------------------------------------------------------------------
class TestIoDepsSerialization:
"""The page registry should emit :io-deps as a list of IO primitive names."""
def test_pure_page_gets_empty_io_deps(self):
"""Pages with no IO-dependent components get :io-deps ()."""
env = make_env(
'(defcomp ~card (&key title) (div title))',
)
_compute_all_io_refs_fallback(env, IO_NAMES)
deps = {"~card"}
io_deps: set[str] = set()
for dep_name in deps:
comp = env.get(dep_name)
if isinstance(comp, Component) and comp.io_refs:
io_deps.update(comp.io_refs)
assert io_deps == set()
def test_io_page_gets_io_dep_names(self):
"""Pages with IO-dependent components get :io-deps ("highlight" ...)."""
env = make_env(
'(defcomp ~code-block (&key src) (pre (highlight src "lisp")))',
)
_compute_all_io_refs_fallback(env, IO_NAMES)
deps = {"~code-block"}
io_deps: set[str] = set()
for dep_name in deps:
comp = env.get(dep_name)
if isinstance(comp, Component) and comp.io_refs:
io_deps.update(comp.io_refs)
assert io_deps == {"highlight"}
def test_multiple_io_deps_collected(self):
"""Multiple distinct IO primitives from different components are unioned."""
env = make_env(
'(defcomp ~nav (&key) (nav (app-url "/")))',
'(defcomp ~page (&key) (div (~nav) (config "key")))',
)
_compute_all_io_refs_fallback(env, IO_NAMES)
deps = {"~nav", "~page"}
io_deps: set[str] = set()
for dep_name in deps:
comp = env.get(dep_name)
if isinstance(comp, Component) and comp.io_refs:
io_deps.update(comp.io_refs)
assert io_deps == {"app-url", "config"}
def test_transitive_io_deps_included(self):
"""IO deps from transitive component dependencies are included."""
env = make_env(
'(defcomp ~inner (&key) (div (highlight "code" "lisp")))',
'(defcomp ~outer (&key) (div (~inner)))',
)
_compute_all_io_refs_fallback(env, IO_NAMES)
deps = {"~inner", "~outer"}
io_deps: set[str] = set()
for dep_name in deps:
comp = env.get(dep_name)
if isinstance(comp, Component) and comp.io_refs:
io_deps.update(comp.io_refs)
# Both components transitively depend on highlight
assert "highlight" in io_deps
def test_io_deps_sx_format(self):
"""io-deps serializes as a proper SX list of strings."""
from shared.sx.helpers import _sx_literal
io_deps = {"highlight", "config"}
io_deps_sx = (
"(" + " ".join(_sx_literal(n) for n in sorted(io_deps)) + ")"
)
assert io_deps_sx == '("config" "highlight")'
# Parse it back
parsed = parse_all(io_deps_sx)
assert len(parsed) == 1
assert parsed[0] == ["config", "highlight"]
def test_empty_io_deps_sx_format(self):
io_deps_sx = "()"
parsed = parse_all(io_deps_sx)
assert len(parsed) == 1
assert parsed[0] == []
# ---------------------------------------------------------------------------
# Dynamic IO allowlist from component IO refs
# ---------------------------------------------------------------------------
class TestDynamicAllowlist:
"""The IO proxy allowlist should be built from component IO refs."""
def test_allowlist_from_env(self):
"""Union of all component io_refs gives the allowlist."""
env = make_env(
'(defcomp ~a (&key) (div (highlight "x" "lisp")))',
'(defcomp ~b (&key) (div (config "key")))',
'(defcomp ~c (&key) (div "pure"))',
)
_compute_all_io_refs_fallback(env, IO_NAMES)
allowed: set[str] = set()
for val in env.values():
if isinstance(val, Component) and val.io_refs:
allowed.update(val.io_refs)
assert "highlight" in allowed
assert "config" in allowed
assert len(allowed) == 2 # only these two
def test_pure_env_has_empty_allowlist(self):
"""An env with only pure components yields empty allowlist."""
env = make_env(
'(defcomp ~a (&key) (div "hello"))',
'(defcomp ~b (&key) (span "world"))',
)
_compute_all_io_refs_fallback(env, IO_NAMES)
allowed: set[str] = set()
for val in env.values():
if isinstance(val, Component) and val.io_refs:
allowed.update(val.io_refs)
assert allowed == set()
# ---------------------------------------------------------------------------
# SxExpr serialization through IO proxy pipeline
# ---------------------------------------------------------------------------
class TestSxExprIoRoundtrip:
"""SxExpr (from highlight etc.) must survive serialize → parse."""
def test_sxexpr_serializes_unquoted(self):
"""SxExpr is emitted as raw SX source, not as a quoted string."""
expr = SxExpr('(span :class "text-red-500" "hello")')
sx = serialize(expr)
assert sx == '(span :class "text-red-500" "hello")'
assert not sx.startswith('"')
def test_sxexpr_roundtrip(self):
"""SxExpr → serialize → parse → yields an AST list."""
expr = SxExpr('(span :class "text-violet-600" "keyword")')
sx = serialize(expr)
parsed = parse_all(sx)
assert len(parsed) == 1
# Should be a list: [Symbol("span"), Keyword("class"), "text-violet-600", "keyword"]
node = parsed[0]
assert isinstance(node, list)
assert isinstance(node[0], Symbol)
assert node[0].name == "span"
def test_fragment_sxexpr_roundtrip(self):
"""Fragment SxExpr with multiple children."""
expr = SxExpr(
'(<> (span :class "text-red-500" "if") '
'(span " ") '
'(span :class "text-green-500" "true"))'
)
sx = serialize(expr)
parsed = parse_all(sx)
assert len(parsed) == 1
node = parsed[0]
assert isinstance(node, list)
assert node[0].name == "<>"
def test_nil_serializes_as_nil(self):
"""None result from IO proxy serializes as 'nil'."""
sx = serialize(None)
assert sx == "nil"
parsed = parse_all(sx)
assert parsed[0] is NIL or parsed[0] is None
def test_sxexpr_in_dict_value(self):
"""SxExpr as a dict value serializes inline (not quoted)."""
expr = SxExpr('(span "hello")')
data = {"code": expr}
sx = serialize(data)
# Should be {:code (span "hello")} not {:code "(span \"hello\")"}
assert '(span "hello")' in sx
parsed = parse_all(sx)
d = parsed[0]
# The value should be a list (AST), not a string
assert isinstance(d["code"], list)
# ---------------------------------------------------------------------------
# IO proxy arg parsing (GET query string vs POST JSON body)
# ---------------------------------------------------------------------------
class TestIoProxyArgParsing:
"""Test the arg extraction logic used by the IO proxy."""
def test_get_args_from_query_string(self):
"""GET: _arg0, _arg1, ... become positional args."""
query = {"_arg0": "(defcomp ~card ...)", "_arg1": "lisp"}
args = []
kwargs = {}
for k, v in query.items():
if k.startswith("_arg"):
args.append(v)
else:
kwargs[k] = v
assert args == ["(defcomp ~card ...)", "lisp"]
assert kwargs == {}
def test_get_kwargs_from_query_string(self):
"""GET: non-_arg keys become kwargs."""
query = {"_arg0": "code", "language": "python"}
args = []
kwargs = {}
for k, v in query.items():
if k.startswith("_arg"):
args.append(v)
else:
kwargs[k] = v
assert args == ["code"]
assert kwargs == {"language": "python"}
def test_post_json_body(self):
"""POST: args and kwargs from JSON body."""
body = {"args": ["(defcomp ~card ...)", "lisp"], "kwargs": {}}
args = body.get("args", [])
kwargs = body.get("kwargs", {})
assert args == ["(defcomp ~card ...)", "lisp"]
assert kwargs == {}
# ---------------------------------------------------------------------------
# IO-aware client routing logic (orchestration.sx)
# ---------------------------------------------------------------------------
class TestIoRoutingLogic:
"""Test the orchestration.sx routing decisions for IO pages.
Uses the SX evaluator to run the actual routing logic.
"""
def _eval(self, src, env):
from shared.sx.evaluator import _eval, _trampoline
result = None
for expr in parse_all(src):
result = _trampoline(_eval(expr, env))
return result
def test_io_deps_list_truthiness(self):
"""A non-empty io-deps list is truthy, empty is falsy."""
env = make_env()
# Non-empty list — (and io-deps (not (empty? io-deps))) is truthy
result = self._eval(
'(let ((io-deps (list "highlight")))'
' (if (and io-deps (not (empty? io-deps))) true false))',
env,
)
assert result is True
# Empty list — (and io-deps (not (empty? io-deps))) is falsy
# (and short-circuits: empty list is falsy, returns [])
result = self._eval(
'(let ((io-deps (list)))'
' (if (and io-deps (not (empty? io-deps))) true false))',
env,
)
assert result is False
def test_io_deps_from_parsed_page_entry(self):
"""io-deps field round-trips through serialize → parse correctly."""
entry_sx = '{:name "test" :io-deps ("highlight" "config")}'
parsed = parse_all(entry_sx)
entry = parsed[0]
env = make_env()
env["entry"] = entry
io_deps = self._eval('(get entry "io-deps")', env)
assert io_deps == ["highlight", "config"]
has_io = self._eval(
'(let ((d (get entry "io-deps")))'
' (and d (not (empty? d))))',
env,
)
assert has_io is True
def test_empty_io_deps_from_parsed_page_entry(self):
"""Empty io-deps list means page is pure."""
entry_sx = '{:name "test" :io-deps ()}'
parsed = parse_all(entry_sx)
entry = parsed[0]
env = make_env()
env["entry"] = entry
has_io = self._eval(
'(let ((d (get entry "io-deps")))'
' (if (and d (not (empty? d))) true false))',
env,
)
assert has_io is False
# ---------------------------------------------------------------------------
# Cache key determinism for IO proxy
# ---------------------------------------------------------------------------
class TestIoCacheKey:
"""The client-side IO cache keys by name + args. Verify determinism."""
def test_same_args_same_key(self):
"""Identical calls produce identical cache keys."""
def make_key(name, args, kwargs=None):
key = name
for a in args:
key += "\0" + str(a)
if kwargs:
for k, v in sorted(kwargs.items()):
key += "\0" + k + "=" + str(v)
return key
k1 = make_key("highlight", ["(div 1)", "lisp"])
k2 = make_key("highlight", ["(div 1)", "lisp"])
assert k1 == k2
def test_different_args_different_key(self):
def make_key(name, args):
key = name
for a in args:
key += "\0" + str(a)
return key
k1 = make_key("highlight", ["(div 1)", "lisp"])
k2 = make_key("highlight", ["(div 2)", "lisp"])
assert k1 != k2
def test_different_name_different_key(self):
def make_key(name, args):
key = name
for a in args:
key += "\0" + str(a)
return key
k1 = make_key("highlight", ["code"])
k2 = make_key("config", ["code"])
assert k1 != k2

View File

@@ -20,19 +20,39 @@ SX_TEST_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sx-te
def _js_render(sx_text: str, components_text: str = "") -> str:
"""Run sx.js + sx-test.js in Node and return the renderToString result."""
# Build a small Node script
import tempfile, os
# Build a small Node script that requires the source files
script = f"""
global.document = undefined; // no DOM needed for string render
{SX_JS.read_text()}
globalThis.document = undefined; // no DOM needed for string render
// sx.js IIFE uses (typeof window !== "undefined" ? window : this).
// In Node file mode, `this` is module.exports, not globalThis.
// Patch: make the IIFE target globalThis so Sx is accessible.
var _origThis = this;
Object.defineProperty(globalThis, 'document', {{ value: undefined, writable: true }});
(function() {{
var _savedThis = globalThis;
{SX_JS.read_text()}
// Hoist Sx from module.exports to globalThis if needed
if (typeof Sx === 'undefined' && typeof module !== 'undefined' && module.exports && module.exports.Sx) {{
globalThis.Sx = module.exports.Sx;
}}
}}).call(globalThis);
{SX_TEST_JS.read_text()}
if ({json.dumps(components_text)}) Sx.loadComponents({json.dumps(components_text)});
var result = Sx.renderToString({json.dumps(sx_text)});
process.stdout.write(result);
"""
result = subprocess.run(
["node", "-e", script],
capture_output=True, text=True, timeout=5,
)
# Write to temp file to avoid OS arg length limits
fd, tmp = tempfile.mkstemp(suffix=".js")
try:
with os.fdopen(fd, "w") as f:
f.write(script)
result = subprocess.run(
["node", tmp],
capture_output=True, text=True, timeout=5,
)
finally:
os.unlink(tmp)
if result.returncode != 0:
pytest.fail(f"Node.js error:\n{result.stderr}")
return result.stdout

View File

@@ -0,0 +1,343 @@
"""Auto-generated from test.sx — SX spec self-tests.
DO NOT EDIT. Regenerate with:
python shared/sx/ref/bootstrap_test.py --output shared/sx/tests/test_sx_spec.py
"""
from __future__ import annotations
import pytest
from shared.sx.parser import parse_all
from shared.sx.evaluator import _eval, _trampoline
_PREAMBLE = '''(define assert-equal (fn (expected actual) (assert (equal? expected actual) (str "Expected " (str expected) " but got " (str actual)))))
(define assert-not-equal (fn (a b) (assert (not (equal? a b)) (str "Expected values to differ but both are " (str a)))))
(define assert-true (fn (val) (assert val (str "Expected truthy but got " (str val)))))
(define assert-false (fn (val) (assert (not val) (str "Expected falsy but got " (str val)))))
(define assert-nil (fn (val) (assert (nil? val) (str "Expected nil but got " (str val)))))
(define assert-type (fn (expected-type val) (let ((actual-type (if (nil? val) "nil" (if (boolean? val) "boolean" (if (number? val) "number" (if (string? val) "string" (if (list? val) "list" (if (dict? val) "dict" "unknown")))))))) (assert (= expected-type actual-type) (str "Expected type " expected-type " but got " actual-type)))))
(define assert-length (fn (expected-len col) (assert (= (len col) expected-len) (str "Expected length " expected-len " but got " (len col)))))
(define assert-contains (fn (item col) (assert (some (fn (x) (equal? x item)) col) (str "Expected collection to contain " (str item)))))
(define assert-throws (fn (thunk) (let ((result (try-call thunk))) (assert (not (get result "ok")) "Expected an error to be thrown but none was"))))'''
def _make_env() -> dict:
"""Create a fresh env with assertion helpers loaded."""
env = {}
for expr in parse_all(_PREAMBLE):
_trampoline(_eval(expr, env))
return env
def _run(sx_source: str, env: dict | None = None) -> object:
"""Evaluate SX source and return the result."""
if env is None:
env = _make_env()
exprs = parse_all(sx_source)
result = None
for expr in exprs:
result = _trampoline(_eval(expr, env))
return result
class TestSpecLiterals:
"""test.sx suite: literals"""
def test_numbers_are_numbers(self):
_run('(do (assert-type "number" 42) (assert-type "number" 3.14) (assert-type "number" -1))')
def test_strings_are_strings(self):
_run('(do (assert-type "string" "hello") (assert-type "string" ""))')
def test_booleans_are_booleans(self):
_run('(do (assert-type "boolean" true) (assert-type "boolean" false))')
def test_nil_is_nil(self):
_run('(do (assert-type "nil" nil) (assert-nil nil))')
def test_lists_are_lists(self):
_run('(do (assert-type "list" (list 1 2 3)) (assert-type "list" (list)))')
def test_dicts_are_dicts(self):
_run('(assert-type "dict" {:a 1 :b 2})')
class TestSpecArithmetic:
"""test.sx suite: arithmetic"""
def test_addition(self):
_run('(do (assert-equal 3 (+ 1 2)) (assert-equal 0 (+ 0 0)) (assert-equal -1 (+ 1 -2)) (assert-equal 10 (+ 1 2 3 4)))')
def test_subtraction(self):
_run('(do (assert-equal 1 (- 3 2)) (assert-equal -1 (- 2 3)))')
def test_multiplication(self):
_run('(do (assert-equal 6 (* 2 3)) (assert-equal 0 (* 0 100)) (assert-equal 24 (* 1 2 3 4)))')
def test_division(self):
_run('(do (assert-equal 2 (/ 6 3)) (assert-equal 2.5 (/ 5 2)))')
def test_modulo(self):
_run('(do (assert-equal 1 (mod 7 3)) (assert-equal 0 (mod 6 3)))')
class TestSpecComparison:
"""test.sx suite: comparison"""
def test_equality(self):
_run('(do (assert-true (= 1 1)) (assert-false (= 1 2)) (assert-true (= "a" "a")) (assert-false (= "a" "b")))')
def test_deep_equality(self):
_run('(do (assert-true (equal? (list 1 2 3) (list 1 2 3))) (assert-false (equal? (list 1 2) (list 1 3))) (assert-true (equal? {:a 1} {:a 1})) (assert-false (equal? {:a 1} {:a 2})))')
def test_ordering(self):
_run('(do (assert-true (< 1 2)) (assert-false (< 2 1)) (assert-true (> 2 1)) (assert-true (<= 1 1)) (assert-true (<= 1 2)) (assert-true (>= 2 2)) (assert-true (>= 3 2)))')
class TestSpecStrings:
"""test.sx suite: strings"""
def test_str_concatenation(self):
_run('(do (assert-equal "abc" (str "a" "b" "c")) (assert-equal "hello world" (str "hello" " " "world")) (assert-equal "42" (str 42)) (assert-equal "" (str)))')
def test_string_length(self):
_run('(do (assert-equal 5 (string-length "hello")) (assert-equal 0 (string-length "")))')
def test_substring(self):
_run('(do (assert-equal "ell" (substring "hello" 1 4)) (assert-equal "hello" (substring "hello" 0 5)))')
def test_string_contains(self):
_run('(do (assert-true (string-contains? "hello world" "world")) (assert-false (string-contains? "hello" "xyz")))')
def test_upcase_and_downcase(self):
_run('(do (assert-equal "HELLO" (upcase "hello")) (assert-equal "hello" (downcase "HELLO")))')
def test_trim(self):
_run('(do (assert-equal "hello" (trim " hello ")) (assert-equal "hello" (trim "hello")))')
def test_split_and_join(self):
_run('(do (assert-equal (list "a" "b" "c") (split "a,b,c" ",")) (assert-equal "a-b-c" (join "-" (list "a" "b" "c"))))')
class TestSpecLists:
"""test.sx suite: lists"""
def test_constructors(self):
_run('(do (assert-equal (list 1 2 3) (list 1 2 3)) (assert-equal (list) (list)) (assert-length 3 (list 1 2 3)))')
def test_first_and_rest(self):
_run('(do (assert-equal 1 (first (list 1 2 3))) (assert-equal (list 2 3) (rest (list 1 2 3))) (assert-nil (first (list))) (assert-equal (list) (rest (list))))')
def test_nth(self):
_run('(do (assert-equal 1 (nth (list 1 2 3) 0)) (assert-equal 2 (nth (list 1 2 3) 1)) (assert-equal 3 (nth (list 1 2 3) 2)))')
def test_last(self):
_run('(do (assert-equal 3 (last (list 1 2 3))) (assert-nil (last (list))))')
def test_cons_and_append(self):
_run('(do (assert-equal (list 0 1 2) (cons 0 (list 1 2))) (assert-equal (list 1 2 3 4) (append (list 1 2) (list 3 4))))')
def test_reverse(self):
_run('(do (assert-equal (list 3 2 1) (reverse (list 1 2 3))) (assert-equal (list) (reverse (list))))')
def test_empty(self):
_run('(do (assert-true (empty? (list))) (assert-false (empty? (list 1))))')
def test_len(self):
_run('(do (assert-equal 0 (len (list))) (assert-equal 3 (len (list 1 2 3))))')
def test_contains(self):
_run('(do (assert-true (contains? (list 1 2 3) 2)) (assert-false (contains? (list 1 2 3) 4)))')
def test_flatten(self):
_run('(assert-equal (list 1 2 3 4) (flatten (list (list 1 2) (list 3 4))))')
class TestSpecDicts:
"""test.sx suite: dicts"""
def test_dict_literal(self):
_run('(do (assert-type "dict" {:a 1 :b 2}) (assert-equal 1 (get {:a 1} "a")) (assert-equal 2 (get {:a 1 :b 2} "b")))')
def test_assoc(self):
_run('(do (assert-equal {:a 1 :b 2} (assoc {:a 1} "b" 2)) (assert-equal {:a 99} (assoc {:a 1} "a" 99)))')
def test_dissoc(self):
_run('(assert-equal {:b 2} (dissoc {:a 1 :b 2} "a"))')
def test_keys_and_vals(self):
_run('(let ((d {:a 1 :b 2})) (assert-length 2 (keys d)) (assert-length 2 (vals d)) (assert-contains "a" (keys d)) (assert-contains "b" (keys d)))')
def test_has_key(self):
_run('(do (assert-true (has-key? {:a 1} "a")) (assert-false (has-key? {:a 1} "b")))')
def test_merge(self):
_run('(do (assert-equal {:a 1 :b 2 :c 3} (merge {:a 1 :b 2} {:c 3})) (assert-equal {:a 99 :b 2} (merge {:a 1 :b 2} {:a 99})))')
class TestSpecPredicates:
"""test.sx suite: predicates"""
def test_nil(self):
_run('(do (assert-true (nil? nil)) (assert-false (nil? 0)) (assert-false (nil? false)) (assert-false (nil? "")))')
def test_number(self):
_run('(do (assert-true (number? 42)) (assert-true (number? 3.14)) (assert-false (number? "42")))')
def test_string(self):
_run('(do (assert-true (string? "hello")) (assert-false (string? 42)))')
def test_list(self):
_run('(do (assert-true (list? (list 1 2))) (assert-false (list? "not a list")))')
def test_dict(self):
_run('(do (assert-true (dict? {:a 1})) (assert-false (dict? (list 1))))')
def test_boolean(self):
_run('(do (assert-true (boolean? true)) (assert-true (boolean? false)) (assert-false (boolean? nil)) (assert-false (boolean? 0)))')
def test_not(self):
_run('(do (assert-true (not false)) (assert-true (not nil)) (assert-false (not true)) (assert-false (not 1)) (assert-false (not "x")))')
class TestSpecSpecialForms:
"""test.sx suite: special-forms"""
def test_if(self):
_run('(do (assert-equal "yes" (if true "yes" "no")) (assert-equal "no" (if false "yes" "no")) (assert-equal "no" (if nil "yes" "no")) (assert-nil (if false "yes")))')
def test_when(self):
_run('(do (assert-equal "yes" (when true "yes")) (assert-nil (when false "yes")))')
def test_cond(self):
_run('(do (assert-equal "a" (cond true "a" :else "b")) (assert-equal "b" (cond false "a" :else "b")) (assert-equal "c" (cond false "a" false "b" :else "c")))')
def test_and(self):
_run('(do (assert-true (and true true)) (assert-false (and true false)) (assert-false (and false true)) (assert-equal 3 (and 1 2 3)))')
def test_or(self):
_run('(do (assert-equal 1 (or 1 2)) (assert-equal 2 (or false 2)) (assert-equal "fallback" (or nil false "fallback")) (assert-false (or false false)))')
def test_let(self):
_run('(do (assert-equal 3 (let ((x 1) (y 2)) (+ x y))) (assert-equal "hello world" (let ((a "hello") (b " world")) (str a b))))')
def test_let_clojure_style(self):
_run('(assert-equal 3 (let (x 1 y 2) (+ x y)))')
def test_do_begin(self):
_run('(do (assert-equal 3 (do 1 2 3)) (assert-equal "last" (begin "first" "middle" "last")))')
def test_define(self):
_run('(do (define x 42) (assert-equal 42 x))')
def test_set(self):
_run('(do (define x 1) (set! x 2) (assert-equal 2 x))')
class TestSpecLambdas:
"""test.sx suite: lambdas"""
def test_basic_lambda(self):
_run('(let ((add (fn (a b) (+ a b)))) (assert-equal 3 (add 1 2)))')
def test_closure_captures_env(self):
_run('(let ((x 10)) (let ((add-x (fn (y) (+ x y)))) (assert-equal 15 (add-x 5))))')
def test_lambda_as_argument(self):
_run('(assert-equal (list 2 4 6) (map (fn (x) (* x 2)) (list 1 2 3)))')
def test_recursive_lambda_via_define(self):
_run('(do (define factorial (fn (n) (if (<= n 1) 1 (* n (factorial (- n 1)))))) (assert-equal 120 (factorial 5)))')
def test_higher_order_returns_lambda(self):
_run('(let ((make-adder (fn (n) (fn (x) (+ n x))))) (let ((add5 (make-adder 5))) (assert-equal 8 (add5 3))))')
class TestSpecHigherOrder:
"""test.sx suite: higher-order"""
def test_map(self):
_run('(do (assert-equal (list 2 4 6) (map (fn (x) (* x 2)) (list 1 2 3))) (assert-equal (list) (map (fn (x) x) (list))))')
def test_filter(self):
_run('(do (assert-equal (list 2 4) (filter (fn (x) (= (mod x 2) 0)) (list 1 2 3 4))) (assert-equal (list) (filter (fn (x) false) (list 1 2 3))))')
def test_reduce(self):
_run('(do (assert-equal 10 (reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3 4))) (assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list))))')
def test_some(self):
_run('(do (assert-true (some (fn (x) (> x 3)) (list 1 2 3 4 5))) (assert-false (some (fn (x) (> x 10)) (list 1 2 3))))')
def test_every(self):
_run('(do (assert-true (every? (fn (x) (> x 0)) (list 1 2 3))) (assert-false (every? (fn (x) (> x 2)) (list 1 2 3))))')
def test_map_indexed(self):
_run('(assert-equal (list "0:a" "1:b" "2:c") (map-indexed (fn (i x) (str i ":" x)) (list "a" "b" "c")))')
class TestSpecComponents:
"""test.sx suite: components"""
def test_defcomp_creates_component(self):
_run('(do (defcomp ~test-comp (&key title) (div title)) (assert-true (not (nil? ~test-comp))))')
def test_component_renders_with_keyword_args(self):
_run('(do (defcomp ~greeting (&key name) (span (str "Hello, " name "!"))) (assert-true (not (nil? ~greeting))))')
def test_component_with_children(self):
_run('(do (defcomp ~box (&key &rest children) (div :class "box" children)) (assert-true (not (nil? ~box))))')
def test_component_with_default_via_or(self):
_run('(do (defcomp ~label (&key text) (span (or text "default"))) (assert-true (not (nil? ~label))))')
class TestSpecMacros:
"""test.sx suite: macros"""
def test_defmacro_creates_macro(self):
_run('(do (defmacro unless (cond &rest body) (quasiquote (if (not (unquote cond)) (do (splice-unquote body))))) (assert-equal "yes" (unless false "yes")) (assert-nil (unless true "no")))')
def test_quasiquote_and_unquote(self):
_run('(let ((x 42)) (assert-equal (list 1 42 3) (quasiquote (1 (unquote x) 3))))')
def test_splice_unquote(self):
_run('(let ((xs (list 2 3 4))) (assert-equal (list 1 2 3 4 5) (quasiquote (1 (splice-unquote xs) 5))))')
class TestSpecThreading:
"""test.sx suite: threading"""
def test_thread_first(self):
_run('(do (assert-equal 8 (-> 5 (+ 1) (+ 2))) (assert-equal "HELLO" (-> "hello" upcase)) (assert-equal "HELLO WORLD" (-> "hello" (str " world") upcase)))')
class TestSpecTruthiness:
"""test.sx suite: truthiness"""
def test_truthy_values(self):
_run('(do (assert-true (if 1 true false)) (assert-true (if "x" true false)) (assert-true (if (list 1) true false)) (assert-true (if true true false)))')
def test_falsy_values(self):
_run('(do (assert-false (if false true false)) (assert-false (if nil true false)))')
class TestSpecEdgeCases:
"""test.sx suite: edge-cases"""
def test_nested_let_scoping(self):
_run('(let ((x 1)) (let ((x 2)) (assert-equal 2 x)))')
def test_recursive_map(self):
_run('(assert-equal (list (list 2 4) (list 6 8)) (map (fn (sub) (map (fn (x) (* x 2)) sub)) (list (list 1 2) (list 3 4))))')
def test_keyword_as_value(self):
_run('(do (assert-equal "class" :class) (assert-equal "id" :id))')
def test_dict_with_evaluated_values(self):
_run('(let ((x 42)) (assert-equal 42 (get {:val x} "val")))')
def test_nil_propagation(self):
_run('(do (assert-nil (get {:a 1} "missing")) (assert-equal "default" (or (get {:a 1} "missing") "default")))')
def test_empty_operations(self):
_run('(do (assert-equal (list) (map (fn (x) x) (list))) (assert-equal (list) (filter (fn (x) true) (list))) (assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list))) (assert-equal 0 (len (list))) (assert-equal "" (str)))')

67
sx/sx/async-io-demo.sx Normal file
View File

@@ -0,0 +1,67 @@
;; Async IO demo — Phase 5 client-side rendering with IO primitives.
;;
;; This component calls `highlight` inline — an IO primitive that runs
;; server-side Python. When rendered on the server, it executes
;; synchronously. When rendered client-side, the async renderer proxies
;; the call via /sx/io/highlight and awaits the result.
;;
;; `highlight` returns SxExpr — SX source with colored spans — which the
;; evaluator renders as DOM. The same SxExpr flows through the IO proxy:
;; server serializes → client parses → async renderer renders to DOM.
;;
;; Open browser console and look for:
;; "sx:route client+async" — async render with IO proxy
;; "sx:io registered N proxied primitives" — IO proxy initialization
(defcomp ~async-io-demo-content ()
(div :class "space-y-8"
(div :class "border-b border-stone-200 pb-6"
(h1 :class "text-2xl font-bold text-stone-900" "Async IO Demo")
(p :class "mt-2 text-stone-600"
"This page calls " (code :class "bg-stone-100 px-1 rounded text-violet-700" "highlight")
" inline — an IO primitive that returns SX source with colored spans. "
"On the server it runs Python directly. On the client it proxies via "
(code :class "bg-stone-100 px-1 rounded text-violet-700" "/sx/io/highlight")
" and the async renderer awaits the result."))
;; Live syntax-highlighted code blocks — each is an IO call
(div :class "space-y-6"
(h2 :class "text-lg font-semibold text-stone-800" "Live IO: syntax highlighting")
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "SX component definition")
(~doc-code :code
(highlight "(defcomp ~card (&key title subtitle &rest children)\n (div :class \"border rounded-lg p-4 shadow-sm\"\n (h2 :class \"text-lg font-bold\" title)\n (when subtitle\n (p :class \"text-stone-500 text-sm\" subtitle))\n (div :class \"mt-3\" children)))" "lisp")))
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Python server code")
(~doc-code :code
(highlight "from shared.sx.pages import mount_io_endpoint\n\n# The IO proxy serves any allowed primitive:\n# GET /sx/io/highlight?_arg0=code&_arg1=lisp\nasync def io_proxy(name):\n result = await execute_io(name, args, kwargs, ctx)\n return serialize(result)" "python")))
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "SX async rendering spec")
(~doc-code :code
(highlight ";; try-client-route reads io-deps from page registry\n(let ((io-deps (get match \"io-deps\"))\n (has-io (and io-deps (not (empty? io-deps)))))\n ;; Register IO deps as proxied primitives on demand\n (when has-io (register-io-deps io-deps))\n (if has-io\n ;; Async render: IO primitives proxied via /sx/io/<name>\n (do\n (try-async-eval-content content-src env\n (fn (rendered)\n (when rendered\n (swap-rendered-content target rendered pathname))))\n true)\n ;; Sync render: pure components, no IO\n (let ((rendered (try-eval-content content-src env)))\n (swap-rendered-content target rendered pathname))))" "lisp"))))
;; Architecture explanation
(div :class "rounded-lg border border-blue-200 bg-blue-50 p-5 space-y-3"
(h2 :class "text-lg font-semibold text-blue-900" "How it works")
(ol :class "list-decimal list-inside text-blue-800 space-y-2 text-sm"
(li "Server renders the page — " (code "highlight") " runs Python directly")
(li "Client receives component definitions including " (code "~async-io-demo-content"))
(li "On client navigation, " (code "io-deps") " list routes to async renderer")
(li (code "register-io-deps") " ensures each IO name is proxied via " (code "registerProxiedIo"))
(li "Proxied call: " (code "fetch(\"/sx/io/highlight?_arg0=...&_arg1=lisp\")"))
(li "Server runs highlight, returns SX source (colored span elements)")
(li "Client parses SX → AST, async renderer recursively renders to DOM")))
;; Verification instructions
(div :class "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2"
(p :class "font-semibold text-amber-800" "How to verify async IO rendering")
(ol :class "list-decimal list-inside text-amber-700 space-y-1"
(li "Open the browser console (F12)")
(li "Navigate to another page (e.g. Data Test)")
(li "Click back to this page")
(li "Look for: " (code :class "bg-amber-100 px-1 rounded" "sx:route client+async /isomorphism/async-io"))
(li "The code blocks should render identically — same syntax highlighting")
(li "Check Network tab: you'll see 3 requests to " (code :class "bg-amber-100 px-1 rounded" "/sx/io/highlight"))))))

View File

@@ -59,3 +59,8 @@
:params ()
:returns "dict"
:service "sx")
(define-page-helper "run-spec-tests"
:params ()
:returns "dict"
:service "sx")

File diff suppressed because one or more lines are too long

View File

@@ -78,6 +78,8 @@
:summary "A web where pages can inspect, modify, and extend their own rendering pipeline.")
(dict :label "Server Architecture" :href "/essays/server-architecture"
:summary "How SX enforces the boundary between host and embedded language, and what it looks like across targets.")
(dict :label "Separation of Concerns" :href "/essays/separation-of-concerns"
:summary "The web's HTML/CSS/JS split separates the framework's concerns, not your application's. Real separation is domain-specific.")
(dict :label "sx sucks" :href "/essays/sx-sucks"
:summary "An honest accounting of everything wrong with SX and why you probably shouldn't use it.")))
@@ -101,13 +103,15 @@
(dict :label "Continuations" :href "/specs/continuations")
(dict :label "call/cc" :href "/specs/callcc")
(dict :label "Deps" :href "/specs/deps")
(dict :label "Router" :href "/specs/router")))
(dict :label "Router" :href "/specs/router")
(dict :label "Testing" :href "/specs/testing")))
(define isomorphism-nav-items (list
(dict :label "Roadmap" :href "/isomorphism/")
(dict :label "Bundle Analyzer" :href "/isomorphism/bundle-analyzer")
(dict :label "Routing Analyzer" :href "/isomorphism/routing-analyzer")
(dict :label "Data Test" :href "/isomorphism/data-test")))
(dict :label "Data Test" :href "/isomorphism/data-test")
(dict :label "Async IO" :href "/isomorphism/async-io")))
(define plans-nav-items (list
(dict :label "Reader Macros" :href "/plans/reader-macros"
@@ -185,7 +189,10 @@
:prose "The deps module analyzes component dependency graphs and classifies components as pure or IO-dependent. Phase 1 (bundling): walks component AST bodies to find transitive ~component references, computes the minimal set needed per page, and collects per-page CSS classes from only the used components. Phase 2 (IO detection): scans component ASTs for references to IO primitive names (from boundary.sx declarations — frag, query, service, current-user, highlight, etc.), computes transitive IO refs through the component graph, and caches the result on each component. Components with no transitive IO refs are pure — they can render anywhere without server data. IO-dependent components must expand server-side. The spec provides the classification; each host's async partial evaluator acts on it (expand IO-dependent server-side, serialize pure for client). All functions are pure — each host bootstraps them to native code via --spec-modules deps. Platform functions (component-deps, component-set-deps!, component-css-classes, component-io-refs, component-set-io-refs!, env-components, regex-find-all, scan-css-classes) are implemented natively per target.")
(dict :slug "router" :filename "router.sx" :title "Router"
:desc "Client-side route matching — Flask-style pattern parsing, segment matching, route table search."
:prose "The router module provides pure functions for matching URL paths against Flask-style route patterns (e.g. /docs/<slug>). Used by client-side routing (Phase 3) to determine if a page can be rendered locally without a server roundtrip. split-path-segments breaks a path into segments, parse-route-pattern converts patterns into typed segment descriptors, match-route-segments tests a path against a parsed pattern returning extracted params, and find-matching-route searches a route table for the first match. No platform interface needed — uses only pure string and list primitives. Bootstrapped via --spec-modules deps,router.")))
:prose "The router module provides pure functions for matching URL paths against Flask-style route patterns (e.g. /docs/<slug>). Used by client-side routing (Phase 3) to determine if a page can be rendered locally without a server roundtrip. split-path-segments breaks a path into segments, parse-route-pattern converts patterns into typed segment descriptors, match-route-segments tests a path against a parsed pattern returning extracted params, and find-matching-route searches a route table for the first match. No platform interface needed — uses only pure string and list primitives. Bootstrapped via --spec-modules deps,router.")
(dict :slug "testing" :filename "test.sx" :title "Testing"
:desc "Self-hosting test framework — SX tests SX. Bootstraps to pytest and Node.js TAP."
:prose "The test spec defines a minimal test framework in SX that bootstraps to every host. Tests are written in SX and verify SX semantics — the language tests itself. The framework uses only primitives already in primitives.sx (assert, equal?, type-of, str, list, len) plus assertion helpers defined in SX (assert-equal, assert-true, assert-false, assert-nil, assert-type, assert-length, assert-contains). Two bootstrap compilers read test.sx and emit native test files: bootstrap_test.py produces a pytest module, bootstrap_test_js.py produces a Node.js TAP script. The same 81 tests run on both platforms, verifying cross-host parity.")))
(define all-spec-items (concat core-spec-items (concat adapter-spec-items (concat browser-spec-items (concat extension-spec-items module-spec-items)))))

246
sx/sx/testing.sx Normal file
View File

@@ -0,0 +1,246 @@
;; Testing spec page — SX tests SX.
(defcomp ~spec-testing-content (&key spec-source server-results)
(~doc-page :title "Testing"
(div :class "space-y-8"
;; Intro
(div :class "space-y-4"
(p :class "text-lg text-stone-600"
"SX tests itself. "
(code :class "text-violet-700 text-sm" "test.sx")
" is a self-executing test spec — it defines "
(code :class "text-violet-700 text-sm" "deftest")
" and "
(code :class "text-violet-700 text-sm" "defsuite")
" as macros, writes 81 test cases, and runs them. Any host that provides five platform functions can evaluate the file directly.")
(p :class "text-stone-600"
"This is not a test "
(em "of") " SX — it is a test " (em "in") " SX. The same s-expressions that define how "
(code :class "text-violet-700 text-sm" "if")
" works are used to verify that "
(code :class "text-violet-700 text-sm" "if")
" works. No code generation, no intermediate files — the evaluator runs the spec."))
;; Server-side results (ran when this page was rendered)
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Server: Python evaluator")
(p :class "text-stone-600"
"The Python evaluator ran "
(code :class "text-violet-700 text-sm" "test.sx")
" when this page loaded — "
(strong (str (get server-results "passed") "/" (get server-results "total") " passed"))
" in " (str (get server-results "elapsed-ms")) "ms.")
(pre :class "text-sm font-mono bg-stone-900 text-green-400 rounded-lg p-4 overflow-x-auto max-h-96 overflow-y-auto"
(get server-results "output")))
;; Client-side test runner
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Browser: JavaScript evaluator")
(p :class "text-stone-600"
"This page loaded "
(code :class "text-violet-700 text-sm" "sx-browser.js")
" to render itself. The same evaluator can run "
(code :class "text-violet-700 text-sm" "test.sx")
" right here:")
(div :class "flex items-center gap-4"
(button :id "test-btn"
:class "px-4 py-2 rounded-md bg-violet-600 text-white font-medium text-sm hover:bg-violet-700 cursor-pointer"
:onclick "sxRunTests('test-sx-source','test-output','test-btn')"
"Run 81 tests"))
(pre :id "test-output"
:class "text-sm font-mono bg-stone-900 text-green-400 rounded-lg p-4 overflow-x-auto max-h-96 overflow-y-auto"
:style "display:none"
"")
;; Hidden: raw test.sx source for the browser runner
(textarea :id "test-sx-source" :style "display:none" spec-source)
;; Load the test runner script
(script :src (asset-url "/scripts/sx-test-runner.js")))
;; How it works
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Architecture")
(p :class "text-stone-600"
"The test framework needs five platform functions. Everything else — macros, assertion helpers, test suites — is pure SX:")
(div :class "not-prose bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl"
(pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words font-mono text-stone-700"
"test.sx Self-executing: macros + helpers + 81 tests
|
|--- browser sx-browser.js evaluates test.sx in this page
|
|--- run.js Injects 5 platform fns, evaluates test.sx
| |
| +-> sx-browser.js JS evaluator (bootstrapped from spec)
|
|--- run.py Injects 5 platform fns, evaluates test.sx
|
+-> evaluator.py Python evaluator
Platform functions (5 total — everything else is pure SX):
try-call (thunk) -> {:ok true} | {:ok false :error \"msg\"}
report-pass (name) -> output pass
report-fail (name error) -> output fail
push-suite (name) -> push suite context
pop-suite () -> pop suite context")))
;; Framework
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "The test framework")
(p :class "text-stone-600"
"The framework defines two macros and nine assertion helpers, all in SX. The macros are the key — they make "
(code :class "text-violet-700 text-sm" "defsuite")
" and "
(code :class "text-violet-700 text-sm" "deftest")
" executable forms, not just declarations:")
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Macros")
(~doc-code :code
(highlight "(defmacro deftest (name &rest body)\n `(let ((result (try-call (fn () ,@body))))\n (if (get result \"ok\")\n (report-pass ,name)\n (report-fail ,name (get result \"error\")))))\n\n(defmacro defsuite (name &rest items)\n `(do (push-suite ,name)\n ,@items\n (pop-suite)))" "lisp")))
(p :class "text-stone-600 text-sm"
(code :class "text-violet-700 text-sm" "deftest")
" wraps the body in a thunk, passes it to "
(code :class "text-violet-700 text-sm" "try-call")
" (the one platform function that catches errors), then reports pass or fail. "
(code :class "text-violet-700 text-sm" "defsuite")
" pushes a name onto the context stack, runs its children, and pops.")
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Assertion helpers")
(~doc-code :code
(highlight "(define assert-equal\n (fn (expected actual)\n (assert (equal? expected actual)\n (str \"Expected \" (str expected) \" but got \" (str actual)))))\n\n(define assert-true (fn (val) (assert val ...)))\n(define assert-false (fn (val) (assert (not val) ...)))\n(define assert-nil (fn (val) (assert (nil? val) ...)))\n(define assert-type (fn (expected-type val) ...))\n(define assert-length (fn (expected-len col) ...))\n(define assert-contains (fn (item col) ...))\n(define assert-throws (fn (thunk) ...))" "lisp"))))
;; Example tests
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Example: SX testing SX")
(p :class "text-stone-600"
"The test suites cover every language feature. Here is the arithmetic suite testing the evaluator's arithmetic primitives:")
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "From test.sx")
(~doc-code :code
(highlight "(defsuite \"arithmetic\"\n (deftest \"addition\"\n (assert-equal 3 (+ 1 2))\n (assert-equal 0 (+ 0 0))\n (assert-equal -1 (+ 1 -2))\n (assert-equal 10 (+ 1 2 3 4)))\n\n (deftest \"subtraction\"\n (assert-equal 1 (- 3 2))\n (assert-equal -1 (- 2 3)))\n\n (deftest \"multiplication\"\n (assert-equal 6 (* 2 3))\n (assert-equal 0 (* 0 100))\n (assert-equal 24 (* 1 2 3 4)))\n\n (deftest \"division\"\n (assert-equal 2 (/ 6 3))\n (assert-equal 2.5 (/ 5 2)))\n\n (deftest \"modulo\"\n (assert-equal 1 (mod 7 3))\n (assert-equal 0 (mod 6 3))))" "lisp"))))
;; Running tests — JS
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "JavaScript: direct evaluation")
(p :class "text-stone-600"
(code :class "text-violet-700 text-sm" "sx-browser.js")
" evaluates "
(code :class "text-violet-700 text-sm" "test.sx")
" directly. The runner injects platform functions and calls "
(code :class "text-violet-700 text-sm" "Sx.eval")
" on each parsed expression:")
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "run.js")
(~doc-code :code
(highlight "var Sx = require('./sx-browser.js');\nvar src = fs.readFileSync('test.sx', 'utf8');\n\nvar env = {\n 'try-call': function(thunk) {\n try {\n Sx.eval([thunk], env); // call the SX lambda\n return { ok: true };\n } catch(e) {\n return { ok: false, error: e.message };\n }\n },\n 'report-pass': function(name) { console.log('ok - ' + name); },\n 'report-fail': function(name, err) { console.log('not ok - ' + name); },\n 'push-suite': function(n) { stack.push(n); },\n 'pop-suite': function() { stack.pop(); },\n};\n\nvar exprs = Sx.parseAll(src);\nfor (var i = 0; i < exprs.length; i++) Sx.eval(exprs[i], env);" "javascript")))
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Output")
(~doc-code :code
(highlight "$ node shared/sx/tests/run.js\nTAP version 13\nok 1 - literals > numbers are numbers\nok 2 - literals > strings are strings\n...\nok 81 - edge-cases > empty operations\n\n# tests 81\n# pass 81" "bash"))))
;; Running tests — Python
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Python: direct evaluation")
(p :class "text-stone-600"
"Same approach — the Python evaluator runs "
(code :class "text-violet-700 text-sm" "test.sx")
" directly:")
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "run.py")
(~doc-code :code
(highlight "from shared.sx.parser import parse_all\nfrom shared.sx.evaluator import _eval, _trampoline\n\ndef try_call(thunk):\n try:\n _trampoline(_eval([thunk], {}))\n return {'ok': True}\n except Exception as e:\n return {'ok': False, 'error': str(e)}\n\nenv = {\n 'try-call': try_call,\n 'report-pass': report_pass,\n 'report-fail': report_fail,\n 'push-suite': push_suite,\n 'pop-suite': pop_suite,\n}\n\nfor expr in parse_all(src):\n _trampoline(_eval(expr, env))" "python")))
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Output")
(~doc-code :code
(highlight "$ python shared/sx/tests/run.py\nTAP version 13\nok 1 - literals > numbers are numbers\n...\nok 81 - edge-cases > empty operations\n\n# tests 81\n# pass 81" "bash"))))
;; What it proves
(div :class "rounded-lg border border-blue-200 bg-blue-50 p-5 space-y-3"
(h2 :class "text-lg font-semibold text-blue-900" "What this proves")
(ol :class "list-decimal list-inside text-blue-800 space-y-2 text-sm"
(li "The test spec is " (strong "written in SX") " and " (strong "executed by SX") " — no code generation")
(li "The same 81 tests run on " (strong "Python, Node.js, and in the browser") " from the same file")
(li "Each host provides only " (strong "5 platform functions") " — everything else is pure SX")
(li "Adding a new host means implementing 5 functions, not rewriting tests")
(li "Platform divergences (truthiness of 0, [], \"\") are " (strong "documented, not hidden"))
(li "The spec is " (strong "executable") " — click the button above to prove it")))
;; Test suites
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "All 15 test suites")
(div :class "overflow-x-auto rounded border border-stone-200"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "Suite")
(th :class "px-3 py-2 font-medium text-stone-600" "Tests")
(th :class "px-3 py-2 font-medium text-stone-600" "Covers")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "literals")
(td :class "px-3 py-2" "6")
(td :class "px-3 py-2 text-stone-700" "number, string, boolean, nil, list, dict type checking"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "arithmetic")
(td :class "px-3 py-2" "5")
(td :class "px-3 py-2 text-stone-700" "+, -, *, /, mod with edge cases"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "comparison")
(td :class "px-3 py-2" "3")
(td :class "px-3 py-2 text-stone-700" "=, equal?, <, >, <=, >="))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "strings")
(td :class "px-3 py-2" "7")
(td :class "px-3 py-2 text-stone-700" "str, string-length, substring, contains?, upcase, trim, split/join"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "lists")
(td :class "px-3 py-2" "10")
(td :class "px-3 py-2 text-stone-700" "first, rest, nth, last, cons, append, reverse, empty?, contains?, flatten"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "dicts")
(td :class "px-3 py-2" "6")
(td :class "px-3 py-2 text-stone-700" "literals, get, assoc, dissoc, keys/vals, has-key?, merge"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "predicates")
(td :class "px-3 py-2" "7")
(td :class "px-3 py-2 text-stone-700" "nil?, number?, string?, list?, dict?, boolean?, not"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "special-forms")
(td :class "px-3 py-2" "10")
(td :class "px-3 py-2 text-stone-700" "if, when, cond, and, or, let, let (Clojure), do/begin, define, set!"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "lambdas")
(td :class "px-3 py-2" "5")
(td :class "px-3 py-2 text-stone-700" "basic, closures, as argument, recursion, higher-order returns"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "higher-order")
(td :class "px-3 py-2" "6")
(td :class "px-3 py-2 text-stone-700" "map, filter, reduce, some, every?, map-indexed"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "components")
(td :class "px-3 py-2" "4")
(td :class "px-3 py-2 text-stone-700" "defcomp, &key params, &rest children, defaults"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "macros")
(td :class "px-3 py-2" "3")
(td :class "px-3 py-2 text-stone-700" "defmacro, quasiquote/unquote, splice-unquote"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "threading")
(td :class "px-3 py-2" "1")
(td :class "px-3 py-2 text-stone-700" "-> thread-first macro"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "truthiness")
(td :class "px-3 py-2" "2")
(td :class "px-3 py-2 text-stone-700" "truthy/falsy values (platform-universal subset)"))
(tr
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "edge-cases")
(td :class "px-3 py-2" "6")
(td :class "px-3 py-2 text-stone-700" "nested scoping, recursive map, keywords, dict eval, nil propagation, empty ops"))))))
;; Full source
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Full specification source")
(p :class "text-xs text-stone-400 italic"
"The s-expression source below is the canonical test specification. "
"Any host that implements the five platform functions can evaluate it directly.")
(div :class "not-prose bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl"
(pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words"
(code (highlight spec-source "sx"))))))))

View File

@@ -281,6 +281,7 @@
"godel-escher-bach" (~essay-godel-escher-bach)
"reflexive-web" (~essay-reflexive-web)
"server-architecture" (~essay-server-architecture)
"separation-of-concerns" (~essay-separation-of-concerns)
:else (~essays-index-content)))
;; ---------------------------------------------------------------------------
@@ -341,6 +342,9 @@
:filename (get item "filename") :href (str "/specs/" (get item "slug"))
:source (read-spec-file (get item "filename"))))
extension-spec-items))
"testing" (~spec-testing-content
:spec-source (read-spec-file "test.sx")
:server-results (run-spec-tests))
:else (let ((spec (find-spec slug)))
(if spec
(~spec-detail-content
@@ -444,6 +448,17 @@
:server-time server-time :items items
:phase phase :transport transport))
(defpage async-io-demo
:path "/isomorphism/async-io"
:auth :public
:layout (:sx-section
:section "Isomorphism"
:sub-label "Isomorphism"
:sub-href "/isomorphism/"
:sub-nav (~section-nav :items isomorphism-nav-items :current "Async IO")
:selected "Async IO")
:content (~async-io-demo-content))
;; Wildcard must come AFTER specific routes (first-match routing)
(defpage isomorphism-page
:path "/isomorphism/<slug>"

View File

@@ -24,6 +24,7 @@ def _register_sx_helpers() -> None:
"bundle-analyzer-data": _bundle_analyzer_data,
"routing-analyzer-data": _routing_analyzer_data,
"data-test-data": _data_test_data,
"run-spec-tests": _run_spec_tests,
})
@@ -491,6 +492,76 @@ def _event_detail_data(slug: str) -> dict:
}
def _run_spec_tests() -> dict:
"""Run test.sx against the Python SX evaluator and return results."""
import os
import time
from shared.sx.parser import parse_all
from shared.sx.evaluator import _eval, _trampoline
ref_dir = os.path.join(os.path.dirname(__file__), "..", "..", "shared", "sx", "ref")
if not os.path.isdir(ref_dir):
ref_dir = "/app/shared/sx/ref"
test_path = os.path.join(ref_dir, "test.sx")
with open(test_path, encoding="utf-8") as f:
src = f.read()
suite_stack: list[str] = []
passed = 0
failed = 0
test_num = 0
lines: list[str] = []
def try_call(thunk):
try:
_trampoline(_eval([thunk], {}))
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
def report_pass(name):
nonlocal passed, test_num
test_num += 1
passed += 1
lines.append("ok " + str(test_num) + " - " + " > ".join(suite_stack + [name]))
def report_fail(name, error):
nonlocal failed, test_num
test_num += 1
failed += 1
full = " > ".join(suite_stack + [name])
lines.append("not ok " + str(test_num) + " - " + full)
lines.append(" # " + str(error))
def push_suite(name):
suite_stack.append(name)
def pop_suite():
suite_stack.pop()
env = {
"try-call": try_call,
"report-pass": report_pass,
"report-fail": report_fail,
"push-suite": push_suite,
"pop-suite": pop_suite,
}
t0 = time.monotonic()
exprs = parse_all(src)
for expr in exprs:
_trampoline(_eval(expr, env))
elapsed = round((time.monotonic() - t0) * 1000)
return {
"passed": passed,
"failed": failed,
"total": passed + failed,
"elapsed-ms": elapsed,
"output": "\n".join(lines),
}
def _data_test_data() -> dict:
"""Return test data for the client-side data rendering test page.