Compare commits
25 Commits
9cde15c3ce
...
6aa2f3f6bd
| Author | SHA1 | Date | |
|---|---|---|---|
| 6aa2f3f6bd | |||
| 6c27ebd3b4 | |||
| f77d7350dd | |||
| ca8de3be1a | |||
| 31ace8768e | |||
| f34e55aa9b | |||
| 102a27e845 | |||
| 12fe93bb55 | |||
| 0693586e6f | |||
| cfde5bc491 | |||
| abeb4551da | |||
| 04366990ec | |||
| 54adc9c216 | |||
| 38f1f82988 | |||
| bb5c7e8444 | |||
| a40dd06811 | |||
| ef04beba00 | |||
| 4ed879bc84 | |||
| d076fc1465 | |||
| 17767ed8c4 | |||
| 5aa13a99d1 | |||
| 6328e3d680 | |||
| 7982a07f94 | |||
| 4534fb9fee | |||
| c43f774992 |
@@ -10,7 +10,8 @@
|
||||
|
||||
(defquery posts-by-ids (&key ids)
|
||||
"Fetch multiple blog posts by comma-separated IDs."
|
||||
(service "blog" "get-posts-by-ids" :ids (split-ids ids)))
|
||||
(service "blog" "get-posts-by-ids"
|
||||
:ids (map parse-int (filter (fn (s) (not (empty? s))) (split (str ids) ",")))))
|
||||
|
||||
(defquery search-posts (&key query page per-page)
|
||||
"Search blog posts by text query, paginated."
|
||||
@@ -35,4 +36,5 @@
|
||||
(defquery page-configs-batch (&key container-type ids)
|
||||
"Return PageConfigs for multiple container IDs (comma-separated)."
|
||||
(service "page-config" "get-batch"
|
||||
:container-type container-type :ids (split-ids ids)))
|
||||
:container-type container-type
|
||||
:ids (map parse-int (filter (fn (s) (not (empty? s))) (split (str ids) ",")))))
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
x-dev-env: &dev-env
|
||||
RELOAD: "true"
|
||||
WORKERS: "1"
|
||||
SX_USE_REF: "1"
|
||||
SX_BOUNDARY_STRICT: "1"
|
||||
|
||||
x-sibling-models: &sibling-models
|
||||
# Every app needs all sibling __init__.py + models/ for cross-domain SQLAlchemy imports
|
||||
|
||||
@@ -56,6 +56,7 @@ x-app-env: &app-env
|
||||
AP_DOMAIN_MARKET: market.rose-ash.com
|
||||
AP_DOMAIN_EVENTS: events.rose-ash.com
|
||||
EXTERNAL_INBOXES: "artdag|https://celery-artdag.rose-ash.com/inbox"
|
||||
SX_BOUNDARY_STRICT: "1"
|
||||
|
||||
services:
|
||||
blog:
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
|
||||
(defquery products-by-ids (&key ids)
|
||||
"Return product details for comma-separated IDs."
|
||||
(service "market-data" "products-by-ids" :ids (split-ids ids)))
|
||||
(service "market-data" "products-by-ids"
|
||||
:ids (map parse-int (filter (fn (s) (not (empty? s))) (split (str ids) ",")))))
|
||||
|
||||
(defquery marketplaces-by-ids (&key ids)
|
||||
"Return marketplace data for comma-separated IDs."
|
||||
(service "market-data" "marketplaces-by-ids" :ids (split-ids ids)))
|
||||
(service "market-data" "marketplaces-by-ids"
|
||||
:ids (map parse-int (filter (fn (s) (not (empty? s))) (split (str ids) ",")))))
|
||||
|
||||
@@ -366,7 +366,7 @@ def create_base_app(
|
||||
return await base_context()
|
||||
|
||||
# --- event processor ---
|
||||
_event_processor = EventProcessor(app_name=name)
|
||||
_event_processor = None if no_db else EventProcessor(app_name=name)
|
||||
|
||||
# --- startup ---
|
||||
@app.before_serving
|
||||
@@ -375,11 +375,13 @@ def create_base_app(
|
||||
register_shared_handlers()
|
||||
await init_config()
|
||||
print(pretty())
|
||||
await _event_processor.start()
|
||||
if _event_processor:
|
||||
await _event_processor.start()
|
||||
|
||||
@app.after_serving
|
||||
async def _stop_event_processor():
|
||||
await _event_processor.stop()
|
||||
if _event_processor:
|
||||
await _event_processor.stop()
|
||||
from shared.infrastructure.auth_redis import close_auth_redis
|
||||
await close_auth_redis()
|
||||
|
||||
|
||||
@@ -508,6 +508,25 @@
|
||||
return NIL;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Platform interface — Parser
|
||||
// =========================================================================
|
||||
// Character classification derived from the grammar:
|
||||
// ident-start → [a-zA-Z_~*+\-><=/!?&]
|
||||
// ident-char → ident-start + [0-9.:\/\[\]#,]
|
||||
|
||||
var _identStartRe = /[a-zA-Z_~*+\-><=/!?&]/;
|
||||
var _identCharRe = /[a-zA-Z0-9_~*+\-><=/!?.:&/\[\]#,]/;
|
||||
|
||||
function isIdentStart(ch) { return _identStartRe.test(ch); }
|
||||
function isIdentChar(ch) { return _identCharRe.test(ch); }
|
||||
function parseNumber(s) { return Number(s); }
|
||||
function escapeString(s) {
|
||||
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t");
|
||||
}
|
||||
function sxExprSource(e) { return typeof e === "string" ? e : String(e); }
|
||||
|
||||
|
||||
// === Transpiled from eval ===
|
||||
|
||||
// trampoline
|
||||
@@ -528,10 +547,10 @@
|
||||
var args = rest(expr);
|
||||
return (isSxTruthy(!sxOr((typeOf(head) == "symbol"), (typeOf(head) == "lambda"), (typeOf(head) == "list"))) ? map(function(x) { return trampoline(evalExpr(x, env)); }, expr) : (isSxTruthy((typeOf(head) == "symbol")) ? (function() {
|
||||
var name = symbolName(head);
|
||||
return (isSxTruthy((name == "if")) ? sfIf(args, env) : (isSxTruthy((name == "when")) ? sfWhen(args, env) : (isSxTruthy((name == "cond")) ? sfCond(args, env) : (isSxTruthy((name == "case")) ? sfCase(args, env) : (isSxTruthy((name == "and")) ? sfAnd(args, env) : (isSxTruthy((name == "or")) ? sfOr(args, env) : (isSxTruthy((name == "let")) ? sfLet(args, env) : (isSxTruthy((name == "let*")) ? sfLet(args, env) : (isSxTruthy((name == "lambda")) ? sfLambda(args, env) : (isSxTruthy((name == "fn")) ? sfLambda(args, env) : (isSxTruthy((name == "define")) ? sfDefine(args, env) : (isSxTruthy((name == "defcomp")) ? sfDefcomp(args, env) : (isSxTruthy((name == "defmacro")) ? sfDefmacro(args, env) : (isSxTruthy((name == "defstyle")) ? sfDefstyle(args, env) : (isSxTruthy((name == "defkeyframes")) ? sfDefkeyframes(args, env) : (isSxTruthy((name == "defhandler")) ? sfDefine(args, env) : (isSxTruthy((name == "begin")) ? sfBegin(args, env) : (isSxTruthy((name == "do")) ? sfBegin(args, env) : (isSxTruthy((name == "quote")) ? sfQuote(args, env) : (isSxTruthy((name == "quasiquote")) ? sfQuasiquote(args, env) : (isSxTruthy((name == "->")) ? sfThreadFirst(args, env) : (isSxTruthy((name == "set!")) ? sfSetBang(args, env) : (isSxTruthy((name == "map")) ? hoMap(args, env) : (isSxTruthy((name == "map-indexed")) ? hoMapIndexed(args, env) : (isSxTruthy((name == "filter")) ? hoFilter(args, env) : (isSxTruthy((name == "reduce")) ? hoReduce(args, env) : (isSxTruthy((name == "some")) ? hoSome(args, env) : (isSxTruthy((name == "every?")) ? hoEvery(args, env) : (isSxTruthy((name == "for-each")) ? hoForEach(args, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? (function() {
|
||||
return (isSxTruthy((name == "if")) ? sfIf(args, env) : (isSxTruthy((name == "when")) ? sfWhen(args, env) : (isSxTruthy((name == "cond")) ? sfCond(args, env) : (isSxTruthy((name == "case")) ? sfCase(args, env) : (isSxTruthy((name == "and")) ? sfAnd(args, env) : (isSxTruthy((name == "or")) ? sfOr(args, env) : (isSxTruthy((name == "let")) ? sfLet(args, env) : (isSxTruthy((name == "let*")) ? sfLet(args, env) : (isSxTruthy((name == "lambda")) ? sfLambda(args, env) : (isSxTruthy((name == "fn")) ? sfLambda(args, env) : (isSxTruthy((name == "define")) ? sfDefine(args, env) : (isSxTruthy((name == "defcomp")) ? sfDefcomp(args, env) : (isSxTruthy((name == "defmacro")) ? sfDefmacro(args, env) : (isSxTruthy((name == "defstyle")) ? sfDefstyle(args, env) : (isSxTruthy((name == "defkeyframes")) ? sfDefkeyframes(args, env) : (isSxTruthy((name == "defhandler")) ? sfDefhandler(args, env) : (isSxTruthy((name == "defpage")) ? sfDefpage(args, env) : (isSxTruthy((name == "defquery")) ? sfDefquery(args, env) : (isSxTruthy((name == "defaction")) ? sfDefaction(args, env) : (isSxTruthy((name == "begin")) ? sfBegin(args, env) : (isSxTruthy((name == "do")) ? sfBegin(args, env) : (isSxTruthy((name == "quote")) ? sfQuote(args, env) : (isSxTruthy((name == "quasiquote")) ? sfQuasiquote(args, env) : (isSxTruthy((name == "->")) ? sfThreadFirst(args, env) : (isSxTruthy((name == "set!")) ? sfSetBang(args, env) : (isSxTruthy((name == "map")) ? hoMap(args, env) : (isSxTruthy((name == "map-indexed")) ? hoMapIndexed(args, env) : (isSxTruthy((name == "filter")) ? hoFilter(args, env) : (isSxTruthy((name == "reduce")) ? hoReduce(args, env) : (isSxTruthy((name == "some")) ? hoSome(args, env) : (isSxTruthy((name == "every?")) ? hoEvery(args, env) : (isSxTruthy((name == "for-each")) ? hoForEach(args, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? (function() {
|
||||
var mac = envGet(env, name);
|
||||
return makeThunk(expandMacro(mac, args, env), env);
|
||||
})() : (isSxTruthy(isRenderExpr(expr)) ? renderExpr(expr, env) : evalCall(head, args, env))))))))))))))))))))))))))))))));
|
||||
})() : (isSxTruthy(isRenderExpr(expr)) ? renderExpr(expr, env) : evalCall(head, args, env)))))))))))))))))))))))))))))))))));
|
||||
})() : evalCall(head, args, env)));
|
||||
})(); };
|
||||
|
||||
@@ -788,25 +807,28 @@
|
||||
return trampoline(evalExpr(macroBody(mac), local));
|
||||
})(); };
|
||||
|
||||
// call-fn
|
||||
var callFn = function(f, args, env) { return (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, args, env)) : (isSxTruthy(isCallable(f)) ? apply(f, args) : error((String("Not callable in HO form: ") + String(inspect(f)))))); };
|
||||
|
||||
// ho-map
|
||||
var hoMap = function(args, env) { return (function() {
|
||||
var f = trampoline(evalExpr(first(args), env));
|
||||
var coll = trampoline(evalExpr(nth(args, 1), env));
|
||||
return map(function(item) { return trampoline(callLambda(f, [item], env)); }, coll);
|
||||
return map(function(item) { return callFn(f, [item], env); }, coll);
|
||||
})(); };
|
||||
|
||||
// ho-map-indexed
|
||||
var hoMapIndexed = function(args, env) { return (function() {
|
||||
var f = trampoline(evalExpr(first(args), env));
|
||||
var coll = trampoline(evalExpr(nth(args, 1), env));
|
||||
return mapIndexed(function(i, item) { return trampoline(callLambda(f, [i, item], env)); }, coll);
|
||||
return mapIndexed(function(i, item) { return callFn(f, [i, item], env); }, coll);
|
||||
})(); };
|
||||
|
||||
// ho-filter
|
||||
var hoFilter = function(args, env) { return (function() {
|
||||
var f = trampoline(evalExpr(first(args), env));
|
||||
var coll = trampoline(evalExpr(nth(args, 1), env));
|
||||
return filter(function(item) { return trampoline(callLambda(f, [item], env)); }, coll);
|
||||
return filter(function(item) { return callFn(f, [item], env); }, coll);
|
||||
})(); };
|
||||
|
||||
// ho-reduce
|
||||
@@ -814,28 +836,28 @@
|
||||
var f = trampoline(evalExpr(first(args), env));
|
||||
var init = trampoline(evalExpr(nth(args, 1), env));
|
||||
var coll = trampoline(evalExpr(nth(args, 2), env));
|
||||
return reduce(function(acc, item) { return trampoline(callLambda(f, [acc, item], env)); }, init, coll);
|
||||
return reduce(function(acc, item) { return callFn(f, [acc, item], env); }, init, coll);
|
||||
})(); };
|
||||
|
||||
// ho-some
|
||||
var hoSome = function(args, env) { return (function() {
|
||||
var f = trampoline(evalExpr(first(args), env));
|
||||
var coll = trampoline(evalExpr(nth(args, 1), env));
|
||||
return some(function(item) { return trampoline(callLambda(f, [item], env)); }, coll);
|
||||
return some(function(item) { return callFn(f, [item], env); }, coll);
|
||||
})(); };
|
||||
|
||||
// ho-every
|
||||
var hoEvery = function(args, env) { return (function() {
|
||||
var f = trampoline(evalExpr(first(args), env));
|
||||
var coll = trampoline(evalExpr(nth(args, 1), env));
|
||||
return isEvery(function(item) { return trampoline(callLambda(f, [item], env)); }, coll);
|
||||
return isEvery(function(item) { return callFn(f, [item], env); }, coll);
|
||||
})(); };
|
||||
|
||||
// ho-for-each
|
||||
var hoForEach = function(args, env) { return (function() {
|
||||
var f = trampoline(evalExpr(first(args), env));
|
||||
var coll = trampoline(evalExpr(nth(args, 1), env));
|
||||
return forEach(function(item) { return trampoline(callLambda(f, [item], env)); }, coll);
|
||||
return forEach(function(item) { return callFn(f, [item], env); }, coll);
|
||||
})(); };
|
||||
|
||||
|
||||
@@ -875,6 +897,117 @@
|
||||
})(); }, keys(attrs))); };
|
||||
|
||||
|
||||
// === Transpiled from parser ===
|
||||
|
||||
// sx-parse
|
||||
var sxParse = function(source) { return (function() {
|
||||
var pos = 0;
|
||||
var lenSrc = len(source);
|
||||
var skipComment = function() { while(true) { if (isSxTruthy((isSxTruthy((pos < lenSrc)) && !(nth(source, pos) == "\n")))) { pos = (pos + 1);
|
||||
continue; } else { return NIL; } } };
|
||||
var skipWs = function() { while(true) { if (isSxTruthy((pos < lenSrc))) { { var ch = nth(source, pos);
|
||||
if (isSxTruthy(sxOr((ch == " "), (ch == "\t"), (ch == "\n"), (ch == "\\r")))) { pos = (pos + 1);
|
||||
continue; } else if (isSxTruthy((ch == ";"))) { pos = (pos + 1);
|
||||
skipComment();
|
||||
continue; } else { return NIL; } } } else { return NIL; } } };
|
||||
var readString = function() { pos = (pos + 1);
|
||||
return (function() {
|
||||
var buf = "";
|
||||
var readStrLoop = function() { while(true) { if (isSxTruthy((pos >= lenSrc))) { return error("Unterminated string"); } else { { var ch = nth(source, pos);
|
||||
if (isSxTruthy((ch == "\""))) { pos = (pos + 1);
|
||||
return NIL; } else if (isSxTruthy((ch == "\\"))) { pos = (pos + 1);
|
||||
{ var esc = nth(source, pos);
|
||||
buf = (String(buf) + String((isSxTruthy((esc == "n")) ? "\n" : (isSxTruthy((esc == "t")) ? "\t" : (isSxTruthy((esc == "r")) ? "\\r" : esc)))));
|
||||
pos = (pos + 1);
|
||||
continue; } } else { buf = (String(buf) + String(ch));
|
||||
pos = (pos + 1);
|
||||
continue; } } } } };
|
||||
readStrLoop();
|
||||
return buf;
|
||||
})(); };
|
||||
var readIdent = function() { return (function() {
|
||||
var start = pos;
|
||||
var readIdentLoop = function() { while(true) { if (isSxTruthy((isSxTruthy((pos < lenSrc)) && isIdentChar(nth(source, pos))))) { pos = (pos + 1);
|
||||
continue; } else { return NIL; } } };
|
||||
readIdentLoop();
|
||||
return slice(source, start, pos);
|
||||
})(); };
|
||||
var readKeyword = function() { pos = (pos + 1);
|
||||
return makeKeyword(readIdent()); };
|
||||
var readNumber = function() { return (function() {
|
||||
var start = pos;
|
||||
if (isSxTruthy((isSxTruthy((pos < lenSrc)) && (nth(source, pos) == "-")))) {
|
||||
pos = (pos + 1);
|
||||
}
|
||||
var readDigits = function() { while(true) { if (isSxTruthy((isSxTruthy((pos < lenSrc)) && (function() {
|
||||
var c = nth(source, pos);
|
||||
return (isSxTruthy((c >= "0")) && (c <= "9"));
|
||||
})()))) { pos = (pos + 1);
|
||||
continue; } else { return NIL; } } };
|
||||
readDigits();
|
||||
if (isSxTruthy((isSxTruthy((pos < lenSrc)) && (nth(source, pos) == ".")))) {
|
||||
pos = (pos + 1);
|
||||
readDigits();
|
||||
}
|
||||
if (isSxTruthy((isSxTruthy((pos < lenSrc)) && sxOr((nth(source, pos) == "e"), (nth(source, pos) == "E"))))) {
|
||||
pos = (pos + 1);
|
||||
if (isSxTruthy((isSxTruthy((pos < lenSrc)) && sxOr((nth(source, pos) == "+"), (nth(source, pos) == "-"))))) {
|
||||
pos = (pos + 1);
|
||||
}
|
||||
readDigits();
|
||||
}
|
||||
return parseNumber(slice(source, start, pos));
|
||||
})(); };
|
||||
var readSymbol = function() { return (function() {
|
||||
var name = readIdent();
|
||||
return (isSxTruthy((name == "true")) ? true : (isSxTruthy((name == "false")) ? false : (isSxTruthy((name == "nil")) ? NIL : makeSymbol(name))));
|
||||
})(); };
|
||||
var readList = function(closeCh) { return (function() {
|
||||
var items = [];
|
||||
var readListLoop = function() { while(true) { skipWs();
|
||||
if (isSxTruthy((pos >= lenSrc))) { return error("Unterminated list"); } else { if (isSxTruthy((nth(source, pos) == closeCh))) { pos = (pos + 1);
|
||||
return NIL; } else { items.push(readExpr());
|
||||
continue; } } } };
|
||||
readListLoop();
|
||||
return items;
|
||||
})(); };
|
||||
var readMap = function() { return (function() {
|
||||
var result = {};
|
||||
var readMapLoop = function() { while(true) { skipWs();
|
||||
if (isSxTruthy((pos >= lenSrc))) { return error("Unterminated map"); } else { if (isSxTruthy((nth(source, pos) == "}"))) { pos = (pos + 1);
|
||||
return NIL; } else { { var keyExpr = readExpr();
|
||||
var keyStr = (isSxTruthy((typeOf(keyExpr) == "keyword")) ? keywordName(keyExpr) : (String(keyExpr)));
|
||||
var valExpr = readExpr();
|
||||
result[keyStr] = valExpr;
|
||||
continue; } } } } };
|
||||
readMapLoop();
|
||||
return result;
|
||||
})(); };
|
||||
var readExpr = function() { skipWs();
|
||||
return (isSxTruthy((pos >= lenSrc)) ? error("Unexpected end of input") : (function() {
|
||||
var ch = nth(source, pos);
|
||||
return (isSxTruthy((ch == "(")) ? ((pos = (pos + 1)), readList(")")) : (isSxTruthy((ch == "[")) ? ((pos = (pos + 1)), readList("]")) : (isSxTruthy((ch == "{")) ? ((pos = (pos + 1)), readMap()) : (isSxTruthy((ch == "\"")) ? readString() : (isSxTruthy((ch == ":")) ? readKeyword() : (isSxTruthy((ch == "`")) ? ((pos = (pos + 1)), [makeSymbol("quasiquote"), readExpr()]) : (isSxTruthy((ch == ",")) ? ((pos = (pos + 1)), (isSxTruthy((isSxTruthy((pos < lenSrc)) && (nth(source, pos) == "@"))) ? ((pos = (pos + 1)), [makeSymbol("splice-unquote"), readExpr()]) : [makeSymbol("unquote"), readExpr()])) : (isSxTruthy(sxOr((isSxTruthy((ch >= "0")) && (ch <= "9")), (isSxTruthy((ch == "-")) && isSxTruthy(((pos + 1) < lenSrc)) && (function() {
|
||||
var nextCh = nth(source, (pos + 1));
|
||||
return (isSxTruthy((nextCh >= "0")) && (nextCh <= "9"));
|
||||
})()))) ? readNumber() : (isSxTruthy(isIdentStart(ch)) ? readSymbol() : error((String("Unexpected character: ") + String(ch))))))))))));
|
||||
})()); };
|
||||
return (function() {
|
||||
var exprs = [];
|
||||
var parseLoop = function() { while(true) { skipWs();
|
||||
if (isSxTruthy((pos < lenSrc))) { exprs.push(readExpr());
|
||||
continue; } else { return NIL; } } };
|
||||
parseLoop();
|
||||
return exprs;
|
||||
})();
|
||||
})(); };
|
||||
|
||||
// sx-serialize
|
||||
var sxSerialize = function(val) { return (function() { var _m = typeOf(val); if (_m == "nil") return "nil"; if (_m == "boolean") return (isSxTruthy(val) ? "true" : "false"); if (_m == "number") return (String(val)); if (_m == "string") return (String("\"") + String(escapeString(val)) + String("\"")); if (_m == "symbol") return symbolName(val); if (_m == "keyword") return (String(":") + String(keywordName(val))); if (_m == "list") return (String("(") + String(join(" ", map(sxSerialize, val))) + String(")")); if (_m == "dict") return sxSerializeDict(val); if (_m == "sx-expr") return sxExprSource(val); return (String(val)); })(); };
|
||||
|
||||
// sx-serialize-dict
|
||||
var sxSerializeDict = function(d) { return (String("{") + String(join(" ", reduce(function(acc, key) { return concat(acc, [(String(":") + String(key)), sxSerialize(dictGet(d, key))]); }, [], keys(d)))) + String("}")); };
|
||||
|
||||
|
||||
// === Transpiled from adapter-dom ===
|
||||
|
||||
// SVG_NS
|
||||
@@ -1284,7 +1417,7 @@ return forEach(function(attr) { return (isSxTruthy(!domHasAttr(newEl, first(attr
|
||||
var _preloadCache = {};
|
||||
|
||||
// _css-hash
|
||||
var _cssHash = "";
|
||||
var _cssHash = NIL;
|
||||
|
||||
// dispatch-trigger-events
|
||||
var dispatchTriggerEvents = function(el, headerVal) { return (isSxTruthy(headerVal) ? (function() {
|
||||
@@ -1747,7 +1880,7 @@ return (_styleCache = {}); };
|
||||
return (isSxTruthy(decls) ? ((isSxTruthy(startsWith(base, "animate-")) ? (function() {
|
||||
var kfName = slice(base, 8);
|
||||
return (isSxTruthy(dictHas(_styleKeyframes, kfName)) ? append_b(kfNeeded, [kfName, dictGet(_styleKeyframes, kfName)]) : NIL);
|
||||
})() : NIL), (isSxTruthy(isNil(variant)) ? append_b(baseDecls, decls) : (isSxTruthy(dictHas(_responsiveBreakpoints, variant)) ? append_b(mediaRules, [dictGet(_responsiveBreakpoints, variant), decls]) : (isSxTruthy(dictHas(_pseudoVariants, variant)) ? append_b(pseudoRules, [dictGet(_pseudoVariants, variant), decls]) : (function() {
|
||||
})() : NIL), (isSxTruthy(isNil(variant)) ? (isSxTruthy(isChildSelectorAtom(base)) ? append_b(pseudoRules, [">:not(:first-child)", decls]) : append_b(baseDecls, decls)) : (isSxTruthy(dictHas(_responsiveBreakpoints, variant)) ? append_b(mediaRules, [dictGet(_responsiveBreakpoints, variant), decls]) : (isSxTruthy(dictHas(_pseudoVariants, variant)) ? append_b(pseudoRules, [dictGet(_pseudoVariants, variant), decls]) : (function() {
|
||||
var vparts = split(variant, ":");
|
||||
var mediaPart = NIL;
|
||||
var pseudoPart = NIL;
|
||||
@@ -1766,12 +1899,12 @@ return (_styleCache = {}); };
|
||||
} } }
|
||||
return (function() {
|
||||
var hashInput = join(";", baseDecls);
|
||||
{ var _c = chunkEvery(mediaRules, 2); for (var _i = 0; _i < _c.length; _i++) { var mr = _c[_i]; hashInput = (String(hashInput) + String("@") + String(first(mr)) + String("{") + String(nth(mr, 1)) + String("}")); } }
|
||||
{ var _c = chunkEvery(pseudoRules, 2); for (var _i = 0; _i < _c.length; _i++) { var pr = _c[_i]; hashInput = (String(hashInput) + String(first(pr)) + String("{") + String(nth(pr, 1)) + String("}")); } }
|
||||
{ var _c = chunkEvery(kfNeeded, 2); for (var _i = 0; _i < _c.length; _i++) { var kf = _c[_i]; hashInput = (String(hashInput) + String(nth(kf, 1))); } }
|
||||
{ var _c = mediaRules; for (var _i = 0; _i < _c.length; _i++) { var mr = _c[_i]; hashInput = (String(hashInput) + String("@") + String(first(mr)) + String("{") + String(nth(mr, 1)) + String("}")); } }
|
||||
{ var _c = pseudoRules; for (var _i = 0; _i < _c.length; _i++) { var pr = _c[_i]; hashInput = (String(hashInput) + String(first(pr)) + String("{") + String(nth(pr, 1)) + String("}")); } }
|
||||
{ var _c = kfNeeded; for (var _i = 0; _i < _c.length; _i++) { var kf = _c[_i]; hashInput = (String(hashInput) + String(nth(kf, 1))); } }
|
||||
return (function() {
|
||||
var cn = (String("sx-") + String(hashStyle(hashInput)));
|
||||
var sv = makeStyleValue_(cn, join(";", baseDecls), chunkEvery(mediaRules, 2), chunkEvery(pseudoRules, 2), chunkEvery(kfNeeded, 2));
|
||||
var sv = makeStyleValue_(cn, join(";", baseDecls), mediaRules, pseudoRules, kfNeeded);
|
||||
_styleCache[key] = sv;
|
||||
injectStyleValue(sv, atoms);
|
||||
return sv;
|
||||
@@ -2740,18 +2873,11 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
if (!cssTarget) return;
|
||||
|
||||
var rules = [];
|
||||
// Child-selector atoms are now routed to pseudoRules by the resolver
|
||||
// with selector ">:not(:first-child)", so base declarations are always
|
||||
// applied directly to the class.
|
||||
if (sv.declarations) {
|
||||
var hasChild = false;
|
||||
if (atoms) {
|
||||
for (var ai = 0; ai < atoms.length; ai++) {
|
||||
if (isChildSelectorAtom(atoms[ai])) { hasChild = true; break; }
|
||||
}
|
||||
}
|
||||
if (hasChild) {
|
||||
rules.push("." + sv.className + ">:not(:first-child){" + sv.declarations + "}");
|
||||
} else {
|
||||
rules.push("." + sv.className + "{" + sv.declarations + "}");
|
||||
}
|
||||
rules.push("." + sv.className + "{" + sv.declarations + "}");
|
||||
}
|
||||
for (var pi = 0; pi < sv.pseudoRules.length; pi++) {
|
||||
var sel = sv.pseudoRules[pi][0], decls = sv.pseudoRules[pi][1];
|
||||
@@ -2952,107 +3078,8 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
// Expose render functions as primitives so SX code can call them
|
||||
if (typeof renderToDom === "function") PRIMITIVES["render-to-dom"] = renderToDom;
|
||||
|
||||
// =========================================================================
|
||||
// Parser
|
||||
// =========================================================================
|
||||
|
||||
function parse(text) {
|
||||
var pos = 0;
|
||||
function skipWs() {
|
||||
while (pos < text.length) {
|
||||
var ch = text[pos];
|
||||
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { pos++; continue; }
|
||||
if (ch === ";") { while (pos < text.length && text[pos] !== "\n") pos++; continue; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
function readExpr() {
|
||||
skipWs();
|
||||
if (pos >= text.length) return undefined;
|
||||
var ch = text[pos];
|
||||
if (ch === "(") { pos++; return readList(")"); }
|
||||
if (ch === "[") { pos++; return readList("]"); }
|
||||
if (ch === "{") { pos++; return readMap(); }
|
||||
if (ch === '"') return readString();
|
||||
if (ch === ":") return readKeyword();
|
||||
if (ch === "`") { pos++; return [new Symbol("quasiquote"), readExpr()]; }
|
||||
if (ch === ",") {
|
||||
pos++;
|
||||
if (pos < text.length && text[pos] === "@") { pos++; return [new Symbol("splice-unquote"), readExpr()]; }
|
||||
return [new Symbol("unquote"), readExpr()];
|
||||
}
|
||||
if (ch === "-" && pos + 1 < text.length && text[pos + 1] >= "0" && text[pos + 1] <= "9") return readNumber();
|
||||
if (ch >= "0" && ch <= "9") return readNumber();
|
||||
return readSymbol();
|
||||
}
|
||||
function readList(close) {
|
||||
var items = [];
|
||||
while (true) {
|
||||
skipWs();
|
||||
if (pos >= text.length) throw new Error("Unterminated list");
|
||||
if (text[pos] === close) { pos++; return items; }
|
||||
items.push(readExpr());
|
||||
}
|
||||
}
|
||||
function readMap() {
|
||||
var result = {};
|
||||
while (true) {
|
||||
skipWs();
|
||||
if (pos >= text.length) throw new Error("Unterminated map");
|
||||
if (text[pos] === "}") { pos++; return result; }
|
||||
var key = readExpr();
|
||||
var keyStr = (key && key._kw) ? key.name : String(key);
|
||||
result[keyStr] = readExpr();
|
||||
}
|
||||
}
|
||||
function readString() {
|
||||
pos++; // skip "
|
||||
var s = "";
|
||||
while (pos < text.length) {
|
||||
var ch = text[pos];
|
||||
if (ch === '"') { pos++; return s; }
|
||||
if (ch === "\\") { pos++; var esc = text[pos]; s += esc === "n" ? "\n" : esc === "t" ? "\t" : esc === "r" ? "\r" : esc; pos++; continue; }
|
||||
s += ch; pos++;
|
||||
}
|
||||
throw new Error("Unterminated string");
|
||||
}
|
||||
function readKeyword() {
|
||||
pos++; // skip :
|
||||
var name = readIdent();
|
||||
return new Keyword(name);
|
||||
}
|
||||
function readNumber() {
|
||||
var start = pos;
|
||||
if (text[pos] === "-") pos++;
|
||||
while (pos < text.length && text[pos] >= "0" && text[pos] <= "9") pos++;
|
||||
if (pos < text.length && text[pos] === ".") { pos++; while (pos < text.length && text[pos] >= "0" && text[pos] <= "9") pos++; }
|
||||
if (pos < text.length && (text[pos] === "e" || text[pos] === "E")) {
|
||||
pos++;
|
||||
if (pos < text.length && (text[pos] === "+" || text[pos] === "-")) pos++;
|
||||
while (pos < text.length && text[pos] >= "0" && text[pos] <= "9") pos++;
|
||||
}
|
||||
return Number(text.slice(start, pos));
|
||||
}
|
||||
function readIdent() {
|
||||
var start = pos;
|
||||
while (pos < text.length && /[a-zA-Z0-9_~*+\-><=/!?.:&]/.test(text[pos])) pos++;
|
||||
return text.slice(start, pos);
|
||||
}
|
||||
function readSymbol() {
|
||||
var name = readIdent();
|
||||
if (name === "true") return true;
|
||||
if (name === "false") return false;
|
||||
if (name === "nil") return NIL;
|
||||
return new Symbol(name);
|
||||
}
|
||||
var exprs = [];
|
||||
while (true) {
|
||||
skipWs();
|
||||
if (pos >= text.length) break;
|
||||
exprs.push(readExpr());
|
||||
}
|
||||
return exprs;
|
||||
}
|
||||
// Parser — compiled from parser.sx (see PLATFORM_PARSER_JS for ident char classes)
|
||||
var parse = sxParse;
|
||||
|
||||
// =========================================================================
|
||||
// Public API
|
||||
@@ -3104,7 +3131,7 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null,
|
||||
getEnv: function() { return componentEnv; },
|
||||
init: typeof bootInit === "function" ? bootInit : null,
|
||||
_version: "ref-2.0 (boot+cssx+dom+engine+orchestration, bootstrap-compiled)"
|
||||
_version: "ref-2.0 (boot+cssx+dom+engine+orchestration+parser, bootstrap-compiled)"
|
||||
};
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -87,7 +87,7 @@
|
||||
var RE_COMMENT = /;[^\n]*/y;
|
||||
var RE_STRING = /"(?:[^"\\]|\\[\s\S])*"/y;
|
||||
var RE_NUMBER = /-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/y;
|
||||
var RE_KEYWORD = /:[a-zA-Z_][a-zA-Z0-9_>:\-]*/y;
|
||||
var RE_KEYWORD = /:[a-zA-Z_~*+\-><=/!?&\[][a-zA-Z0-9_~*+\-><=/!?.:&/\[\]#,]*/y;
|
||||
var RE_SYMBOL = /[a-zA-Z_~*+\-><=/!?&][a-zA-Z0-9_~*+\-><=/!?.:&]*/y;
|
||||
|
||||
function Tokenizer(text) {
|
||||
|
||||
@@ -31,16 +31,27 @@ from .parser import (
|
||||
parse_all,
|
||||
serialize,
|
||||
)
|
||||
from .evaluator import (
|
||||
EvalError,
|
||||
evaluate,
|
||||
make_env,
|
||||
)
|
||||
import os as _os
|
||||
|
||||
if _os.environ.get("SX_USE_REF") == "1":
|
||||
from .ref.sx_ref import (
|
||||
EvalError,
|
||||
evaluate,
|
||||
make_env,
|
||||
)
|
||||
else:
|
||||
from .evaluator import (
|
||||
EvalError,
|
||||
evaluate,
|
||||
make_env,
|
||||
)
|
||||
|
||||
from .primitives import (
|
||||
all_primitives,
|
||||
get_primitive,
|
||||
register_primitive,
|
||||
)
|
||||
from . import primitives_stdlib # noqa: F401 — registers stdlib primitives
|
||||
from .env import Env
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -279,6 +279,10 @@ async def _asf_or(expr, env, ctx):
|
||||
|
||||
|
||||
async def _asf_let(expr, env, ctx):
|
||||
# Named let: (let name ((x 0) ...) body)
|
||||
if isinstance(expr[1], Symbol):
|
||||
return await _asf_named_let(expr, env, ctx)
|
||||
|
||||
bindings = expr[1]
|
||||
local = dict(env)
|
||||
if isinstance(bindings, list):
|
||||
@@ -299,6 +303,98 @@ async def _asf_let(expr, env, ctx):
|
||||
return NIL
|
||||
|
||||
|
||||
async def _asf_named_let(expr, env, ctx):
|
||||
"""Async named let: (let name ((x 0) ...) body)"""
|
||||
loop_name = expr[1].name
|
||||
bindings = expr[2]
|
||||
body = expr[3:]
|
||||
|
||||
params: list[str] = []
|
||||
inits: list = []
|
||||
|
||||
if isinstance(bindings, list):
|
||||
if bindings and isinstance(bindings[0], list):
|
||||
for binding in bindings:
|
||||
var = binding[0]
|
||||
params.append(var.name if isinstance(var, Symbol) else var)
|
||||
inits.append(binding[1])
|
||||
elif len(bindings) % 2 == 0:
|
||||
for i in range(0, len(bindings), 2):
|
||||
var = bindings[i]
|
||||
params.append(var.name if isinstance(var, Symbol) else var)
|
||||
inits.append(bindings[i + 1])
|
||||
|
||||
loop_body = body[0] if len(body) == 1 else [Symbol("begin")] + list(body)
|
||||
loop_fn = Lambda(params, loop_body, dict(env), name=loop_name)
|
||||
loop_fn.closure[loop_name] = loop_fn
|
||||
|
||||
init_vals = [await _async_trampoline(await _async_eval(init, env, ctx)) for init in inits]
|
||||
return await _async_call_lambda(loop_fn, init_vals, env, ctx)
|
||||
|
||||
|
||||
async def _asf_letrec(expr, env, ctx):
|
||||
"""Async letrec: (letrec ((name1 val1) ...) body)"""
|
||||
bindings = expr[1]
|
||||
local = dict(env)
|
||||
|
||||
names: list[str] = []
|
||||
val_exprs: list = []
|
||||
|
||||
if isinstance(bindings, list):
|
||||
if bindings and isinstance(bindings[0], list):
|
||||
for binding in bindings:
|
||||
var = binding[0]
|
||||
vname = var.name if isinstance(var, Symbol) else var
|
||||
names.append(vname)
|
||||
val_exprs.append(binding[1])
|
||||
local[vname] = NIL
|
||||
elif len(bindings) % 2 == 0:
|
||||
for i in range(0, len(bindings), 2):
|
||||
var = bindings[i]
|
||||
vname = var.name if isinstance(var, Symbol) else var
|
||||
names.append(vname)
|
||||
val_exprs.append(bindings[i + 1])
|
||||
local[vname] = NIL
|
||||
|
||||
values = [await _async_trampoline(await _async_eval(ve, local, ctx)) for ve in val_exprs]
|
||||
for name, val in zip(names, values):
|
||||
local[name] = val
|
||||
for val in values:
|
||||
if isinstance(val, Lambda):
|
||||
for name in names:
|
||||
val.closure[name] = local[name]
|
||||
|
||||
for body_expr in expr[2:-1]:
|
||||
await _async_trampoline(await _async_eval(body_expr, local, ctx))
|
||||
if len(expr) > 2:
|
||||
return _AsyncThunk(expr[-1], local, ctx)
|
||||
return NIL
|
||||
|
||||
|
||||
async def _asf_dynamic_wind(expr, env, ctx):
|
||||
"""Async dynamic-wind: (dynamic-wind before body after)"""
|
||||
before = await _async_trampoline(await _async_eval(expr[1], env, ctx))
|
||||
body_fn = await _async_trampoline(await _async_eval(expr[2], env, ctx))
|
||||
after = await _async_trampoline(await _async_eval(expr[3], env, ctx))
|
||||
|
||||
async def _call_thunk(fn):
|
||||
if isinstance(fn, Lambda):
|
||||
return await _async_trampoline(await _async_call_lambda(fn, [], env, ctx))
|
||||
if callable(fn):
|
||||
r = fn()
|
||||
if inspect.iscoroutine(r):
|
||||
return await r
|
||||
return r
|
||||
raise EvalError(f"dynamic-wind: expected thunk, got {type(fn).__name__}")
|
||||
|
||||
await _call_thunk(before)
|
||||
try:
|
||||
result = await _call_thunk(body_fn)
|
||||
finally:
|
||||
await _call_thunk(after)
|
||||
return result
|
||||
|
||||
|
||||
async def _asf_lambda(expr, env, ctx):
|
||||
params_expr = expr[1]
|
||||
param_names = []
|
||||
@@ -467,6 +563,7 @@ _ASYNC_SPECIAL_FORMS: dict[str, Any] = {
|
||||
"or": _asf_or,
|
||||
"let": _asf_let,
|
||||
"let*": _asf_let,
|
||||
"letrec": _asf_letrec,
|
||||
"lambda": _asf_lambda,
|
||||
"fn": _asf_lambda,
|
||||
"define": _asf_define,
|
||||
@@ -481,9 +578,52 @@ _ASYNC_SPECIAL_FORMS: dict[str, Any] = {
|
||||
"quasiquote": _asf_quasiquote,
|
||||
"->": _asf_thread_first,
|
||||
"set!": _asf_set_bang,
|
||||
"dynamic-wind": _asf_dynamic_wind,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async delimited continuations — shift / reset
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ASYNC_RESET_RESUME: list = []
|
||||
|
||||
|
||||
async def _asf_reset(expr, env, ctx):
|
||||
"""(reset body) — async version."""
|
||||
from .types import Continuation, _ShiftSignal
|
||||
body = expr[1]
|
||||
try:
|
||||
return await async_eval(body, env, ctx)
|
||||
except _ShiftSignal as sig:
|
||||
def cont_fn(value=None):
|
||||
from .types import NIL
|
||||
_ASYNC_RESET_RESUME.append(value if value is not None else NIL)
|
||||
try:
|
||||
# Sync re-evaluation; the async caller will trampoline
|
||||
from .evaluator import _eval as sync_eval, _trampoline
|
||||
return _trampoline(sync_eval(body, env))
|
||||
finally:
|
||||
_ASYNC_RESET_RESUME.pop()
|
||||
k = Continuation(cont_fn)
|
||||
sig_env = dict(sig.env)
|
||||
sig_env[sig.k_name] = k
|
||||
return await async_eval(sig.body, sig_env, ctx)
|
||||
|
||||
|
||||
async def _asf_shift(expr, env, ctx):
|
||||
"""(shift k body) — async version."""
|
||||
from .types import _ShiftSignal
|
||||
if _ASYNC_RESET_RESUME:
|
||||
return _ASYNC_RESET_RESUME[-1]
|
||||
k_name = expr[1].name
|
||||
body = expr[2]
|
||||
raise _ShiftSignal(k_name, body, env)
|
||||
|
||||
_ASYNC_SPECIAL_FORMS["reset"] = _asf_reset
|
||||
_ASYNC_SPECIAL_FORMS["shift"] = _asf_shift
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async higher-order forms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
144
shared/sx/boundary.py
Normal file
144
shared/sx/boundary.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
SX Boundary Enforcement — runtime validation.
|
||||
|
||||
Reads declarations from boundary.sx + primitives.sx and validates
|
||||
that all registered primitives, I/O handlers, and page helpers
|
||||
are declared in the spec.
|
||||
|
||||
Controlled by SX_BOUNDARY_STRICT env var:
|
||||
- "1": validation raises errors (fail fast)
|
||||
- anything else: validation logs warnings
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("sx.boundary")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lazy-loaded declaration sets (populated on first use)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DECLARED_PURE: frozenset[str] | None = None
|
||||
_DECLARED_IO: frozenset[str] | None = None
|
||||
_DECLARED_HELPERS: dict[str, frozenset[str]] | None = None
|
||||
|
||||
|
||||
def _load_declarations() -> None:
|
||||
global _DECLARED_PURE, _DECLARED_IO, _DECLARED_HELPERS
|
||||
if _DECLARED_PURE is not None:
|
||||
return
|
||||
try:
|
||||
from .ref.boundary_parser import parse_primitives_sx, parse_boundary_sx
|
||||
_DECLARED_PURE = parse_primitives_sx()
|
||||
_DECLARED_IO, _DECLARED_HELPERS = parse_boundary_sx()
|
||||
logger.debug(
|
||||
"Boundary loaded: %d pure, %d io, %d services",
|
||||
len(_DECLARED_PURE), len(_DECLARED_IO), len(_DECLARED_HELPERS),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load boundary declarations: %s", e)
|
||||
_DECLARED_PURE = frozenset()
|
||||
_DECLARED_IO = frozenset()
|
||||
_DECLARED_HELPERS = {}
|
||||
|
||||
|
||||
def _is_strict() -> bool:
|
||||
return os.environ.get("SX_BOUNDARY_STRICT") == "1"
|
||||
|
||||
|
||||
def _report(message: str) -> None:
|
||||
if _is_strict():
|
||||
raise RuntimeError(f"SX boundary violation: {message}")
|
||||
else:
|
||||
logger.warning("SX boundary: %s", message)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def validate_primitive(name: str) -> None:
|
||||
"""Validate that a pure primitive is declared in primitives.sx."""
|
||||
_load_declarations()
|
||||
assert _DECLARED_PURE is not None
|
||||
if name not in _DECLARED_PURE:
|
||||
_report(f"Undeclared pure primitive: {name!r}. Add to primitives.sx.")
|
||||
|
||||
|
||||
def validate_io(name: str) -> None:
|
||||
"""Validate that an I/O primitive is declared in boundary.sx."""
|
||||
_load_declarations()
|
||||
assert _DECLARED_IO is not None
|
||||
if name not in _DECLARED_IO:
|
||||
_report(f"Undeclared I/O primitive: {name!r}. Add to boundary.sx.")
|
||||
|
||||
|
||||
def validate_helper(service: str, name: str) -> None:
|
||||
"""Validate that a page helper is declared in boundary.sx."""
|
||||
_load_declarations()
|
||||
assert _DECLARED_HELPERS is not None
|
||||
svc_helpers = _DECLARED_HELPERS.get(service, frozenset())
|
||||
if name not in svc_helpers:
|
||||
_report(
|
||||
f"Undeclared page helper: {name!r} for service {service!r}. "
|
||||
f"Add to boundary.sx."
|
||||
)
|
||||
|
||||
|
||||
def validate_boundary_value(value: Any, context: str = "") -> None:
|
||||
"""Validate that a value is an allowed SX boundary type.
|
||||
|
||||
Allowed: int, float, str, bool, None/NIL, list, dict, SxExpr, StyleValue.
|
||||
NOT allowed: datetime, ORM models, Quart objects, raw callables.
|
||||
"""
|
||||
from .types import NIL, StyleValue
|
||||
from .parser import SxExpr
|
||||
|
||||
if value is None or value is NIL:
|
||||
return
|
||||
if isinstance(value, (int, float, str, bool)):
|
||||
return
|
||||
if isinstance(value, SxExpr):
|
||||
return
|
||||
if isinstance(value, StyleValue):
|
||||
return
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
validate_boundary_value(item, context)
|
||||
return
|
||||
if isinstance(value, dict):
|
||||
for k, v in value.items():
|
||||
validate_boundary_value(v, context)
|
||||
return
|
||||
|
||||
type_name = type(value).__name__
|
||||
ctx_msg = f" (in {context})" if context else ""
|
||||
_report(
|
||||
f"Non-SX type crossing boundary{ctx_msg}: {type_name}. "
|
||||
f"Convert to dict/string at the edge."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Declaration accessors (for introspection / bootstrapper use)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def declared_pure() -> frozenset[str]:
|
||||
_load_declarations()
|
||||
assert _DECLARED_PURE is not None
|
||||
return _DECLARED_PURE
|
||||
|
||||
|
||||
def declared_io() -> frozenset[str]:
|
||||
_load_declarations()
|
||||
assert _DECLARED_IO is not None
|
||||
return _DECLARED_IO
|
||||
|
||||
|
||||
def declared_helpers() -> dict[str, frozenset[str]]:
|
||||
_load_declarations()
|
||||
assert _DECLARED_HELPERS is not None
|
||||
return dict(_DECLARED_HELPERS)
|
||||
@@ -33,7 +33,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .types import Component, HandlerDef, Keyword, Lambda, Macro, NIL, PageDef, RelationDef, Symbol
|
||||
from .types import Component, Continuation, HandlerDef, Keyword, Lambda, Macro, NIL, PageDef, RelationDef, Symbol, _ShiftSignal
|
||||
from .primitives import _PRIMITIVES
|
||||
|
||||
|
||||
@@ -306,6 +306,11 @@ def _sf_or(expr: list, env: dict) -> Any:
|
||||
def _sf_let(expr: list, env: dict) -> Any:
|
||||
if len(expr) < 3:
|
||||
raise EvalError("let requires bindings and body")
|
||||
|
||||
# Named let: (let name ((x 0) ...) body)
|
||||
if isinstance(expr[1], Symbol):
|
||||
return _sf_named_let(expr, env)
|
||||
|
||||
bindings = expr[1]
|
||||
local = dict(env)
|
||||
|
||||
@@ -336,6 +341,127 @@ def _sf_let(expr: list, env: dict) -> Any:
|
||||
return _Thunk(body[-1], local)
|
||||
|
||||
|
||||
def _sf_named_let(expr: list, env: dict) -> Any:
|
||||
"""``(let name ((x 0) (y 1)) body...)`` — self-recursive loop.
|
||||
|
||||
Desugars to a lambda bound to *name* whose closure includes itself,
|
||||
called with the initial values. Tail calls to *name* produce TCO thunks.
|
||||
"""
|
||||
loop_name = expr[1].name
|
||||
bindings = expr[2]
|
||||
body = expr[3:]
|
||||
|
||||
params: list[str] = []
|
||||
inits: list[Any] = []
|
||||
|
||||
if isinstance(bindings, list):
|
||||
if bindings and isinstance(bindings[0], list):
|
||||
for binding in bindings:
|
||||
var = binding[0]
|
||||
params.append(var.name if isinstance(var, Symbol) else var)
|
||||
inits.append(binding[1])
|
||||
elif len(bindings) % 2 == 0:
|
||||
for i in range(0, len(bindings), 2):
|
||||
var = bindings[i]
|
||||
params.append(var.name if isinstance(var, Symbol) else var)
|
||||
inits.append(bindings[i + 1])
|
||||
|
||||
# Build loop body (wrap in begin if multiple expressions)
|
||||
loop_body = body[0] if len(body) == 1 else [Symbol("begin")] + list(body)
|
||||
|
||||
# Create self-recursive lambda
|
||||
loop_fn = Lambda(params, loop_body, dict(env), name=loop_name)
|
||||
loop_fn.closure[loop_name] = loop_fn
|
||||
|
||||
# Evaluate initial values in enclosing env, then call
|
||||
init_vals = [_trampoline(_eval(init, env)) for init in inits]
|
||||
return _call_lambda(loop_fn, init_vals, env)
|
||||
|
||||
|
||||
def _sf_letrec(expr: list, env: dict) -> Any:
|
||||
"""``(letrec ((name1 val1) ...) body)`` — mutually recursive bindings.
|
||||
|
||||
All names are bound to NIL first, then values are evaluated (so they
|
||||
can reference each other), then lambda closures are patched.
|
||||
"""
|
||||
if len(expr) < 3:
|
||||
raise EvalError("letrec requires bindings and body")
|
||||
bindings = expr[1]
|
||||
local = dict(env)
|
||||
|
||||
names: list[str] = []
|
||||
val_exprs: list[Any] = []
|
||||
|
||||
if isinstance(bindings, list):
|
||||
if bindings and isinstance(bindings[0], list):
|
||||
for binding in bindings:
|
||||
var = binding[0]
|
||||
vname = var.name if isinstance(var, Symbol) else var
|
||||
names.append(vname)
|
||||
val_exprs.append(binding[1])
|
||||
local[vname] = NIL
|
||||
elif len(bindings) % 2 == 0:
|
||||
for i in range(0, len(bindings), 2):
|
||||
var = bindings[i]
|
||||
vname = var.name if isinstance(var, Symbol) else var
|
||||
names.append(vname)
|
||||
val_exprs.append(bindings[i + 1])
|
||||
local[vname] = NIL
|
||||
|
||||
# Evaluate all values — they can see each other's names (initially NIL)
|
||||
values = [_trampoline(_eval(ve, local)) for ve in val_exprs]
|
||||
|
||||
# Bind final values
|
||||
for name, val in zip(names, values):
|
||||
local[name] = val
|
||||
|
||||
# Patch lambda closures so they see the final bindings
|
||||
for val in values:
|
||||
if isinstance(val, Lambda):
|
||||
for name in names:
|
||||
val.closure[name] = local[name]
|
||||
|
||||
body = expr[2:]
|
||||
for body_expr in body[:-1]:
|
||||
_trampoline(_eval(body_expr, local))
|
||||
return _Thunk(body[-1], local)
|
||||
|
||||
|
||||
def _sf_dynamic_wind(expr: list, env: dict) -> Any:
|
||||
"""``(dynamic-wind before body after)`` — entry/exit guards.
|
||||
|
||||
All three arguments are thunks (zero-arg functions).
|
||||
*before* is called on entry, *after* is always called on exit (even on
|
||||
error). The wind stack is maintained for future continuation support.
|
||||
"""
|
||||
if len(expr) != 4:
|
||||
raise EvalError("dynamic-wind requires 3 arguments (before, body, after)")
|
||||
before = _trampoline(_eval(expr[1], env))
|
||||
body_fn = _trampoline(_eval(expr[2], env))
|
||||
after = _trampoline(_eval(expr[3], env))
|
||||
|
||||
def _call_thunk(fn: Any) -> Any:
|
||||
if isinstance(fn, Lambda):
|
||||
return _trampoline(_call_lambda(fn, [], env))
|
||||
if callable(fn):
|
||||
return fn()
|
||||
raise EvalError(f"dynamic-wind: expected thunk, got {type(fn).__name__}")
|
||||
|
||||
# Entry
|
||||
_call_thunk(before)
|
||||
_WIND_STACK.append((before, after))
|
||||
try:
|
||||
result = _call_thunk(body_fn)
|
||||
finally:
|
||||
_WIND_STACK.pop()
|
||||
_call_thunk(after)
|
||||
return result
|
||||
|
||||
|
||||
# Wind stack for dynamic-wind (thread-safe enough for sync evaluator)
|
||||
_WIND_STACK: list[tuple] = []
|
||||
|
||||
|
||||
def _sf_lambda(expr: list, env: dict) -> Lambda:
|
||||
if len(expr) < 3:
|
||||
raise EvalError("lambda requires params and body")
|
||||
@@ -874,6 +1000,42 @@ def _sf_defpage(expr: list, env: dict) -> PageDef:
|
||||
return page
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Delimited continuations — shift / reset
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_RESET_RESUME = [] # stack of resume values; empty = not resuming
|
||||
|
||||
_RESET_SENTINEL = object()
|
||||
|
||||
|
||||
def _sf_reset(expr, env):
|
||||
"""(reset body) — establish a continuation delimiter."""
|
||||
body = expr[1]
|
||||
try:
|
||||
return _trampoline(_eval(body, env))
|
||||
except _ShiftSignal as sig:
|
||||
def cont_fn(value=NIL):
|
||||
_RESET_RESUME.append(value)
|
||||
try:
|
||||
return _trampoline(_eval(body, env))
|
||||
finally:
|
||||
_RESET_RESUME.pop()
|
||||
k = Continuation(cont_fn)
|
||||
sig_env = dict(sig.env)
|
||||
sig_env[sig.k_name] = k
|
||||
return _trampoline(_eval(sig.body, sig_env))
|
||||
|
||||
|
||||
def _sf_shift(expr, env):
|
||||
"""(shift k body) — capture continuation to nearest reset."""
|
||||
if _RESET_RESUME:
|
||||
return _RESET_RESUME[-1]
|
||||
k_name = expr[1].name # symbol
|
||||
body = expr[2]
|
||||
raise _ShiftSignal(k_name, body, env)
|
||||
|
||||
|
||||
_SPECIAL_FORMS: dict[str, Any] = {
|
||||
"if": _sf_if,
|
||||
"when": _sf_when,
|
||||
@@ -883,6 +1045,7 @@ _SPECIAL_FORMS: dict[str, Any] = {
|
||||
"or": _sf_or,
|
||||
"let": _sf_let,
|
||||
"let*": _sf_let,
|
||||
"letrec": _sf_letrec,
|
||||
"lambda": _sf_lambda,
|
||||
"fn": _sf_lambda,
|
||||
"define": _sf_define,
|
||||
@@ -895,12 +1058,15 @@ _SPECIAL_FORMS: dict[str, Any] = {
|
||||
"quote": _sf_quote,
|
||||
"->": _sf_thread_first,
|
||||
"set!": _sf_set_bang,
|
||||
"dynamic-wind": _sf_dynamic_wind,
|
||||
"defmacro": _sf_defmacro,
|
||||
"quasiquote": _sf_quasiquote,
|
||||
"defhandler": _sf_defhandler,
|
||||
"defpage": _sf_defpage,
|
||||
"defquery": _sf_defquery,
|
||||
"defaction": _sf_defaction,
|
||||
"reset": _sf_reset,
|
||||
"shift": _sf_shift,
|
||||
}
|
||||
|
||||
|
||||
@@ -913,9 +1079,11 @@ def _ho_map(expr: list, env: dict) -> list:
|
||||
raise EvalError("map requires fn and collection")
|
||||
fn = _trampoline(_eval(expr[1], env))
|
||||
coll = _trampoline(_eval(expr[2], env))
|
||||
if not isinstance(fn, Lambda):
|
||||
raise EvalError(f"map requires lambda, got {type(fn).__name__}")
|
||||
return [_trampoline(_call_lambda(fn, [item], env)) for item in coll]
|
||||
if isinstance(fn, Lambda):
|
||||
return [_trampoline(_call_lambda(fn, [item], env)) for item in coll]
|
||||
if callable(fn):
|
||||
return [fn(item) for item in coll]
|
||||
raise EvalError(f"map requires lambda, got {type(fn).__name__}")
|
||||
|
||||
|
||||
def _ho_map_indexed(expr: list, env: dict) -> list:
|
||||
|
||||
@@ -69,7 +69,11 @@ def clear_handlers(service: str | None = None) -> None:
|
||||
def load_handler_file(filepath: str, service_name: str) -> list[HandlerDef]:
|
||||
"""Parse an .sx file, evaluate it, and register any HandlerDef values."""
|
||||
from .parser import parse_all
|
||||
from .evaluator import _eval as _raw_eval, _trampoline
|
||||
import os
|
||||
if os.environ.get("SX_USE_REF") == "1":
|
||||
from .ref.sx_ref import eval_expr as _raw_eval, trampoline as _trampoline
|
||||
else:
|
||||
from .evaluator import _eval as _raw_eval, _trampoline
|
||||
_eval = lambda expr, env: _trampoline(_raw_eval(expr, env))
|
||||
from .jinja_bridge import get_component_env
|
||||
|
||||
@@ -126,7 +130,11 @@ async def execute_handler(
|
||||
4. Return ``SxExpr`` wire format
|
||||
"""
|
||||
from .jinja_bridge import get_component_env, _get_request_context
|
||||
from .async_eval import async_eval_to_sx
|
||||
import os
|
||||
if os.environ.get("SX_USE_REF") == "1":
|
||||
from .ref.async_eval_ref import async_eval_to_sx
|
||||
else:
|
||||
from .async_eval import async_eval_to_sx
|
||||
from .types import NIL
|
||||
|
||||
if args is None:
|
||||
|
||||
@@ -16,6 +16,16 @@ from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
|
||||
from .parser import SxExpr
|
||||
|
||||
|
||||
def _sx_fragment(*parts: str) -> SxExpr:
|
||||
"""Wrap pre-rendered SX wire format strings in a fragment.
|
||||
|
||||
Infrastructure utility for composing already-serialized SX strings.
|
||||
NOT for building SX from Python data — use sx_call() or _render_to_sx().
|
||||
"""
|
||||
joined = " ".join(p for p in parts if p)
|
||||
return SxExpr(f"(<> {joined})") if joined else SxExpr("")
|
||||
|
||||
|
||||
def call_url(ctx: dict, key: str, path: str = "/") -> str:
|
||||
"""Call a URL helper from context (e.g., blog_url, account_url)."""
|
||||
fn = ctx.get(key)
|
||||
@@ -51,8 +61,7 @@ def _as_sx(val: Any) -> SxExpr | None:
|
||||
if isinstance(val, SxExpr):
|
||||
return val if val.source else None
|
||||
html = str(val)
|
||||
escaped = html.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return SxExpr(f'(~rich-text :html "{escaped}")')
|
||||
return sx_call("rich-text", html=html)
|
||||
|
||||
|
||||
async def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
@@ -77,7 +86,7 @@ async def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
def mobile_menu_sx(*sections: str) -> SxExpr:
|
||||
"""Assemble mobile menu from pre-built sections (deepest first)."""
|
||||
parts = [s for s in sections if s]
|
||||
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
|
||||
return _sx_fragment(*parts) if parts else SxExpr("")
|
||||
|
||||
|
||||
async def mobile_root_nav_sx(ctx: dict) -> str:
|
||||
@@ -130,7 +139,7 @@ async def _post_nav_items_sx(ctx: dict) -> SxExpr:
|
||||
is_admin_page=is_admin_page or None)
|
||||
if admin_nav:
|
||||
parts.append(admin_nav)
|
||||
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
|
||||
return _sx_fragment(*parts) if parts else SxExpr("")
|
||||
|
||||
|
||||
async def _post_admin_nav_items_sx(ctx: dict, slug: str,
|
||||
@@ -158,7 +167,7 @@ async def _post_admin_nav_items_sx(ctx: dict, slug: str,
|
||||
parts.append(await _render_to_sx("nav-link", href=href, label=label,
|
||||
select_colours=select_colours,
|
||||
is_selected=is_sel or None))
|
||||
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
|
||||
return _sx_fragment(*parts) if parts else SxExpr("")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -265,7 +274,7 @@ async def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
|
||||
async def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str:
|
||||
"""Wrap inner sx in a header-child div."""
|
||||
return await _render_to_sx("header-child-sx",
|
||||
id=id, inner=SxExpr(f"(<> {inner_sx})"),
|
||||
id=id, inner=_sx_fragment(inner_sx),
|
||||
)
|
||||
|
||||
|
||||
@@ -273,7 +282,7 @@ async def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
|
||||
content: str = "", menu: str = "") -> str:
|
||||
"""Build OOB response as sx wire format."""
|
||||
return await _render_to_sx("oob-sx",
|
||||
oobs=SxExpr(f"(<> {oobs})") if oobs else None,
|
||||
oobs=_sx_fragment(oobs) if oobs else None,
|
||||
filter=SxExpr(filter) if filter else None,
|
||||
aside=SxExpr(aside) if aside else None,
|
||||
menu=SxExpr(menu) if menu else None,
|
||||
@@ -294,7 +303,7 @@ async def full_page_sx(ctx: dict, *, header_rows: str,
|
||||
if not menu:
|
||||
menu = await mobile_root_nav_sx(ctx)
|
||||
body_sx = await _render_to_sx("app-body",
|
||||
header_rows=SxExpr(f"(<> {header_rows})") if header_rows else None,
|
||||
header_rows=_sx_fragment(header_rows) if header_rows else None,
|
||||
filter=SxExpr(filter) if filter else None,
|
||||
aside=SxExpr(aside) if aside else None,
|
||||
menu=SxExpr(menu) if menu else None,
|
||||
@@ -303,7 +312,7 @@ async def full_page_sx(ctx: dict, *, header_rows: str,
|
||||
if meta:
|
||||
# Wrap body + meta in a fragment so sx.js renders both;
|
||||
# auto-hoist moves meta/title/link elements to <head>.
|
||||
body_sx = "(<> " + meta + " " + body_sx + ")"
|
||||
body_sx = _sx_fragment(meta, body_sx)
|
||||
return sx_page(ctx, body_sx, meta_html=meta_html)
|
||||
|
||||
|
||||
@@ -346,7 +355,11 @@ async def _render_to_sx_with_env(__name: str, extra_env: dict, **kwargs: Any) ->
|
||||
**Private** — service code should use ``sx_call()`` or defmacros instead.
|
||||
"""
|
||||
from .jinja_bridge import get_component_env, _get_request_context
|
||||
from .async_eval import async_eval_slot_to_sx
|
||||
import os
|
||||
if os.environ.get("SX_USE_REF") == "1":
|
||||
from .ref.async_eval_ref import async_eval_slot_to_sx
|
||||
else:
|
||||
from .async_eval import async_eval_slot_to_sx
|
||||
from .types import Symbol, Keyword, NIL as _NIL
|
||||
|
||||
# Build AST with extra_env entries as keyword args so _aser_component
|
||||
@@ -377,7 +390,11 @@ async def _render_to_sx(__name: str, **kwargs: Any) -> str:
|
||||
Only infrastructure code (helpers.py, layouts.py) should call this.
|
||||
"""
|
||||
from .jinja_bridge import get_component_env, _get_request_context
|
||||
from .async_eval import async_eval_to_sx
|
||||
import os
|
||||
if os.environ.get("SX_USE_REF") == "1":
|
||||
from .ref.async_eval_ref import async_eval_to_sx
|
||||
else:
|
||||
from .async_eval import async_eval_to_sx
|
||||
|
||||
ast = _build_component_ast(__name, **kwargs)
|
||||
env = dict(get_component_env())
|
||||
@@ -397,7 +414,11 @@ async def render_to_html(__name: str, **kwargs: Any) -> str:
|
||||
format. Used by route renders that need HTML (full pages, fragments).
|
||||
"""
|
||||
from .jinja_bridge import get_component_env, _get_request_context
|
||||
from .async_eval import async_render
|
||||
import os
|
||||
if os.environ.get("SX_USE_REF") == "1":
|
||||
from .ref.async_eval_ref import async_render
|
||||
else:
|
||||
from .async_eval import async_render
|
||||
|
||||
ast = _build_component_ast(__name, **kwargs)
|
||||
env = dict(get_component_env())
|
||||
|
||||
@@ -27,7 +27,11 @@ from typing import Any
|
||||
|
||||
from .types import NIL, Component, Keyword, Macro, Symbol
|
||||
from .parser import parse
|
||||
from .html import render as html_render, _render_component
|
||||
import os as _os
|
||||
if _os.environ.get("SX_USE_REF") == "1":
|
||||
from .ref.sx_ref import render as html_render, render_html_component as _render_component
|
||||
else:
|
||||
from .html import render as html_render, _render_component
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -169,7 +173,10 @@ def register_components(sx_source: str) -> None:
|
||||
(div :class "..." (div :class "..." title)))))
|
||||
''')
|
||||
"""
|
||||
from .evaluator import _eval as _raw_eval, _trampoline
|
||||
if _os.environ.get("SX_USE_REF") == "1":
|
||||
from .ref.sx_ref import eval_expr as _raw_eval, trampoline as _trampoline
|
||||
else:
|
||||
from .evaluator import _eval as _raw_eval, _trampoline
|
||||
_eval = lambda expr, env: _trampoline(_raw_eval(expr, env))
|
||||
from .parser import parse_all
|
||||
from .css_registry import scan_classes_from_sx
|
||||
|
||||
@@ -79,9 +79,34 @@ def register_page_helpers(service: str, helpers: dict[str, Any]) -> None:
|
||||
:auth :public
|
||||
:content (docs-content slug))
|
||||
"""
|
||||
from .boundary import validate_helper, validate_boundary_value
|
||||
import asyncio
|
||||
import functools
|
||||
|
||||
for name in helpers:
|
||||
validate_helper(service, name)
|
||||
|
||||
# Wrap helpers to validate return values at the boundary
|
||||
wrapped: dict[str, Any] = {}
|
||||
for name, fn in helpers.items():
|
||||
if asyncio.iscoroutinefunction(fn):
|
||||
@functools.wraps(fn)
|
||||
async def _async_wrap(*a, _fn=fn, _name=name, **kw):
|
||||
result = await _fn(*a, **kw)
|
||||
validate_boundary_value(result, context=f"helper {_name!r}")
|
||||
return result
|
||||
wrapped[name] = _async_wrap
|
||||
else:
|
||||
@functools.wraps(fn)
|
||||
def _sync_wrap(*a, _fn=fn, _name=name, **kw):
|
||||
result = _fn(*a, **kw)
|
||||
validate_boundary_value(result, context=f"helper {_name!r}")
|
||||
return result
|
||||
wrapped[name] = _sync_wrap
|
||||
|
||||
if service not in _PAGE_HELPERS:
|
||||
_PAGE_HELPERS[service] = {}
|
||||
_PAGE_HELPERS[service].update(helpers)
|
||||
_PAGE_HELPERS[service].update(wrapped)
|
||||
|
||||
|
||||
def get_page_helpers(service: str) -> dict[str, Any]:
|
||||
|
||||
@@ -83,7 +83,7 @@ class Tokenizer:
|
||||
COMMENT = re.compile(r";[^\n]*")
|
||||
STRING = re.compile(r'"(?:[^"\\]|\\.)*"')
|
||||
NUMBER = re.compile(r"-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?")
|
||||
KEYWORD = re.compile(r":[a-zA-Z_][a-zA-Z0-9_>:-]*")
|
||||
KEYWORD = re.compile(r":[a-zA-Z_~*+\-><=/!?&\[]{1}[a-zA-Z0-9_~*+\-><=/!?.:&/\[\]#,]*")
|
||||
# Symbols may start with alpha, _, or common operator chars, plus ~ for components,
|
||||
# <> for the fragment symbol, and & for &key/&rest.
|
||||
SYMBOL = re.compile(r"[a-zA-Z_~*+\-><=/!?&][a-zA-Z0-9_~*+\-><=/!?.:&]*")
|
||||
@@ -170,6 +170,11 @@ class Tokenizer:
|
||||
return float(num_str)
|
||||
return int(num_str)
|
||||
|
||||
# Ellipsis (... as a symbol, used in spec declarations)
|
||||
if char == "." and self.text[self.pos:self.pos + 3] == "...":
|
||||
self._advance(3)
|
||||
return Symbol("...")
|
||||
|
||||
# Symbol
|
||||
m = self.SYMBOL.match(self.text, self.pos)
|
||||
if m:
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
import math
|
||||
from typing import Any, Callable
|
||||
|
||||
from .types import Keyword, Lambda, NIL
|
||||
from .types import Keyword, NIL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -30,6 +30,8 @@ def register_primitive(name: str):
|
||||
return "".join(str(a) for a in args)
|
||||
"""
|
||||
def decorator(fn: Callable) -> Callable:
|
||||
from .boundary import validate_primitive
|
||||
validate_primitive(name)
|
||||
_PRIMITIVES[name] = fn
|
||||
return fn
|
||||
return decorator
|
||||
@@ -132,6 +134,27 @@ def prim_eq(a: Any, b: Any) -> bool:
|
||||
def prim_neq(a: Any, b: Any) -> bool:
|
||||
return a != b
|
||||
|
||||
@register_primitive("eq?")
|
||||
def prim_eq_identity(a: Any, b: Any) -> bool:
|
||||
"""Identity equality — true only if a and b are the same object."""
|
||||
return a is b
|
||||
|
||||
@register_primitive("eqv?")
|
||||
def prim_eqv(a: Any, b: Any) -> bool:
|
||||
"""Equivalent: identity for compound types, value for atoms."""
|
||||
if a is b:
|
||||
return True
|
||||
if isinstance(a, (int, float, str, bool)) and isinstance(b, type(a)):
|
||||
return a == b
|
||||
if (a is None or a is NIL) and (b is None or b is NIL):
|
||||
return True
|
||||
return False
|
||||
|
||||
@register_primitive("equal?")
|
||||
def prim_equal(a: Any, b: Any) -> bool:
|
||||
"""Deep structural equality (same as =)."""
|
||||
return a == b
|
||||
|
||||
@register_primitive("<")
|
||||
def prim_lt(a: Any, b: Any) -> bool:
|
||||
return a < b
|
||||
@@ -185,6 +208,11 @@ def prim_is_list(x: Any) -> bool:
|
||||
def prim_is_dict(x: Any) -> bool:
|
||||
return isinstance(x, dict)
|
||||
|
||||
@register_primitive("continuation?")
|
||||
def prim_is_continuation(x: Any) -> bool:
|
||||
from .types import Continuation
|
||||
return isinstance(x, Continuation)
|
||||
|
||||
@register_primitive("empty?")
|
||||
def prim_is_empty(coll: Any) -> bool:
|
||||
if coll is None or coll is NIL:
|
||||
@@ -263,12 +291,6 @@ def prim_join(sep: str, coll: list) -> str:
|
||||
def prim_replace(s: str, old: str, new: str) -> str:
|
||||
return s.replace(old, new)
|
||||
|
||||
@register_primitive("strip-tags")
|
||||
def prim_strip_tags(s: str) -> str:
|
||||
"""Strip HTML tags from a string."""
|
||||
import re
|
||||
return re.sub(r"<[^>]+>", "", s)
|
||||
|
||||
@register_primitive("slice")
|
||||
def prim_slice(coll: Any, start: int, end: Any = None) -> Any:
|
||||
"""Slice a string or list: (slice coll start end?)."""
|
||||
@@ -430,186 +452,3 @@ def prim_into(target: Any, coll: Any) -> Any:
|
||||
return result
|
||||
raise ValueError(f"into: unsupported target type {type(target).__name__}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# URL helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@register_primitive("app-url")
|
||||
def prim_app_url(service: str, path: str = "/") -> str:
|
||||
"""``(app-url "blog" "/my-post/")`` → full URL for service."""
|
||||
from shared.infrastructure.urls import app_url
|
||||
return app_url(service, path)
|
||||
|
||||
|
||||
@register_primitive("url-for")
|
||||
def prim_url_for(endpoint: str, **kwargs: Any) -> str:
|
||||
"""``(url-for "endpoint")`` → quart.url_for."""
|
||||
from quart import url_for
|
||||
return url_for(endpoint, **kwargs)
|
||||
|
||||
|
||||
@register_primitive("asset-url")
|
||||
def prim_asset_url(path: str = "") -> str:
|
||||
"""``(asset-url "/img/logo.png")`` → versioned static URL."""
|
||||
from shared.infrastructure.urls import asset_url
|
||||
return asset_url(path)
|
||||
|
||||
|
||||
@register_primitive("config")
|
||||
def prim_config(key: str) -> Any:
|
||||
"""``(config "key")`` → shared.config.config()[key]."""
|
||||
from shared.config import config
|
||||
cfg = config()
|
||||
return cfg.get(key)
|
||||
|
||||
|
||||
@register_primitive("jinja-global")
|
||||
def prim_jinja_global(key: str, default: Any = None) -> Any:
|
||||
"""``(jinja-global "key")`` → current_app.jinja_env.globals[key]."""
|
||||
from quart import current_app
|
||||
return current_app.jinja_env.globals.get(key, default)
|
||||
|
||||
|
||||
@register_primitive("relations-from")
|
||||
def prim_relations_from(entity_type: str) -> list[dict]:
|
||||
"""``(relations-from "page")`` → list of RelationDef dicts."""
|
||||
from shared.sx.relations import relations_from
|
||||
return [
|
||||
{
|
||||
"name": d.name, "from_type": d.from_type, "to_type": d.to_type,
|
||||
"cardinality": d.cardinality, "nav": d.nav,
|
||||
"nav_icon": d.nav_icon, "nav_label": d.nav_label,
|
||||
}
|
||||
for d in relations_from(entity_type)
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Format helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@register_primitive("format-date")
|
||||
def prim_format_date(date_str: Any, fmt: str) -> str:
|
||||
"""``(format-date date-str fmt)`` → formatted date string."""
|
||||
from datetime import datetime
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(date_str))
|
||||
return dt.strftime(fmt)
|
||||
except (ValueError, TypeError):
|
||||
return str(date_str) if date_str else ""
|
||||
|
||||
|
||||
@register_primitive("format-decimal")
|
||||
def prim_format_decimal(val: Any, places: Any = 2) -> str:
|
||||
"""``(format-decimal val places)`` → formatted decimal string."""
|
||||
try:
|
||||
return f"{float(val):.{int(places)}f}"
|
||||
except (ValueError, TypeError):
|
||||
return "0." + "0" * int(places)
|
||||
|
||||
|
||||
@register_primitive("parse-int")
|
||||
def prim_parse_int(val: Any, default: Any = 0) -> int | Any:
|
||||
"""``(parse-int val default?)`` → int(val) with fallback."""
|
||||
try:
|
||||
return int(val)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
@register_primitive("parse-datetime")
|
||||
def prim_parse_datetime(val: Any) -> Any:
|
||||
"""``(parse-datetime "2024-01-15T10:00:00")`` → datetime object."""
|
||||
from datetime import datetime
|
||||
if not val or val is NIL:
|
||||
return NIL
|
||||
return datetime.fromisoformat(str(val))
|
||||
|
||||
|
||||
@register_primitive("split-ids")
|
||||
def prim_split_ids(val: Any) -> list[int]:
|
||||
"""``(split-ids "1,2,3")`` → [1, 2, 3]. Parse comma-separated int IDs."""
|
||||
if not val or val is NIL:
|
||||
return []
|
||||
return [int(x.strip()) for x in str(val).split(",") if x.strip()]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@register_primitive("assert")
|
||||
def prim_assert(condition: Any, message: str = "Assertion failed") -> bool:
|
||||
if not condition:
|
||||
raise RuntimeError(f"Assertion error: {message}")
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@register_primitive("pluralize")
|
||||
def prim_pluralize(count: Any, singular: str = "", plural: str = "s") -> str:
|
||||
"""``(pluralize count)`` → "s" if count != 1, else "".
|
||||
``(pluralize count "item" "items")`` → "item" or "items"."""
|
||||
try:
|
||||
n = int(count)
|
||||
except (ValueError, TypeError):
|
||||
n = 0
|
||||
if singular or plural != "s":
|
||||
return singular if n == 1 else plural
|
||||
return "" if n == 1 else "s"
|
||||
|
||||
|
||||
@register_primitive("escape")
|
||||
def prim_escape(s: Any) -> str:
|
||||
"""``(escape val)`` → HTML-escaped string."""
|
||||
from markupsafe import escape as _escape
|
||||
return str(_escape(str(s) if s is not None and s is not NIL else ""))
|
||||
|
||||
|
||||
@register_primitive("route-prefix")
|
||||
def prim_route_prefix() -> str:
|
||||
"""``(route-prefix)`` → service URL prefix for dev/prod routing."""
|
||||
from shared.utils import route_prefix
|
||||
return route_prefix()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Style primitives
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@register_primitive("css")
|
||||
def prim_css(*args: Any) -> Any:
|
||||
"""``(css :flex :gap-4 :hover:bg-sky-200)`` → StyleValue.
|
||||
|
||||
Accepts keyword atoms (strings without colon prefix) and runtime
|
||||
strings. Returns a StyleValue with a content-addressed class name
|
||||
and all resolved CSS declarations.
|
||||
"""
|
||||
from .style_resolver import resolve_style
|
||||
atoms = tuple(
|
||||
(a.name if isinstance(a, Keyword) else str(a))
|
||||
for a in args if a is not None and a is not NIL and a is not False
|
||||
)
|
||||
if not atoms:
|
||||
return NIL
|
||||
return resolve_style(atoms)
|
||||
|
||||
|
||||
@register_primitive("merge-styles")
|
||||
def prim_merge_styles(*styles: Any) -> Any:
|
||||
"""``(merge-styles style1 style2)`` → merged StyleValue.
|
||||
|
||||
Merges multiple StyleValues; later declarations win.
|
||||
"""
|
||||
from .types import StyleValue
|
||||
from .style_resolver import merge_styles
|
||||
valid = [s for s in styles if isinstance(s, StyleValue)]
|
||||
if not valid:
|
||||
return NIL
|
||||
if len(valid) == 1:
|
||||
return valid[0]
|
||||
return merge_styles(valid)
|
||||
|
||||
544
shared/sx/primitives_ctx.py
Normal file
544
shared/sx/primitives_ctx.py
Normal file
@@ -0,0 +1,544 @@
|
||||
"""
|
||||
Service-specific page context IO handlers.
|
||||
|
||||
These are application-specific (rose-ash), not part of the generic SX
|
||||
framework. Each handler builds a dict of template data from Quart request
|
||||
context for use by .sx page components.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .primitives_io import register_io_handler
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Root / post headers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@register_io_handler("root-header-ctx")
|
||||
async def _io_root_header_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: Any
|
||||
) -> dict[str, Any]:
|
||||
"""``(root-header-ctx)`` → dict with all root header values.
|
||||
|
||||
Fetches cart-mini, auth-menu, nav-tree fragments and computes
|
||||
settings-url / is-admin from rights. Result is cached on ``g``
|
||||
per request so multiple calls (e.g. header + mobile) are free.
|
||||
"""
|
||||
from quart import g, current_app, request
|
||||
cached = getattr(g, "_root_header_ctx", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
from shared.infrastructure.fragments import fetch_fragments
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from shared.infrastructure.urls import app_url
|
||||
from shared.config import config
|
||||
from .types import NIL
|
||||
|
||||
user = getattr(g, "user", None)
|
||||
ident = current_cart_identity()
|
||||
|
||||
cart_params: dict[str, Any] = {}
|
||||
if ident["user_id"] is not None:
|
||||
cart_params["user_id"] = ident["user_id"]
|
||||
if ident["session_id"] is not None:
|
||||
cart_params["session_id"] = ident["session_id"]
|
||||
|
||||
auth_params: dict[str, Any] = {}
|
||||
if user and getattr(user, "email", None):
|
||||
auth_params["email"] = user.email
|
||||
|
||||
nav_params = {"app_name": current_app.name, "path": request.path}
|
||||
|
||||
cart_mini, auth_menu, nav_tree = await fetch_fragments([
|
||||
("cart", "cart-mini", cart_params or None),
|
||||
("account", "auth-menu", auth_params or None),
|
||||
("blog", "nav-tree", nav_params),
|
||||
])
|
||||
|
||||
rights = getattr(g, "rights", None) or {}
|
||||
is_admin = (
|
||||
rights.get("admin", False)
|
||||
if isinstance(rights, dict)
|
||||
else getattr(rights, "admin", False)
|
||||
)
|
||||
|
||||
result = {
|
||||
"cart-mini": cart_mini or NIL,
|
||||
"blog-url": app_url("blog", ""),
|
||||
"site-title": config()["title"],
|
||||
"app-label": current_app.name,
|
||||
"nav-tree": nav_tree or NIL,
|
||||
"auth-menu": auth_menu or NIL,
|
||||
"nav-panel": NIL,
|
||||
"settings-url": app_url("blog", "/settings/") if is_admin else "",
|
||||
"is-admin": is_admin,
|
||||
}
|
||||
g._root_header_ctx = result
|
||||
return result
|
||||
|
||||
|
||||
@register_io_handler("post-header-ctx")
|
||||
async def _io_post_header_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: Any
|
||||
) -> dict[str, Any]:
|
||||
"""``(post-header-ctx)`` → dict with post-level header values."""
|
||||
from quart import g, request
|
||||
cached = getattr(g, "_post_header_ctx", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
from shared.infrastructure.urls import app_url
|
||||
from .types import NIL
|
||||
from .parser import SxExpr
|
||||
|
||||
dctx = getattr(g, "_defpage_ctx", None) or {}
|
||||
post = dctx.get("post") or {}
|
||||
slug = post.get("slug", "")
|
||||
if not slug:
|
||||
result: dict[str, Any] = {"slug": ""}
|
||||
g._post_header_ctx = result
|
||||
return result
|
||||
|
||||
title = (post.get("title") or "")[:160]
|
||||
feature_image = post.get("feature_image") or NIL
|
||||
|
||||
# Container nav (pre-fetched by page helper into defpage ctx)
|
||||
raw_nav = dctx.get("container_nav") or ""
|
||||
container_nav: Any = NIL
|
||||
nav_str = str(raw_nav).strip()
|
||||
if nav_str and nav_str.replace("(<>", "").replace(")", "").strip():
|
||||
if isinstance(raw_nav, SxExpr):
|
||||
container_nav = raw_nav
|
||||
else:
|
||||
container_nav = SxExpr(nav_str)
|
||||
|
||||
page_cart_count = dctx.get("page_cart_count", 0) or 0
|
||||
|
||||
rights = getattr(g, "rights", None) or {}
|
||||
is_admin = (
|
||||
rights.get("admin", False)
|
||||
if isinstance(rights, dict)
|
||||
else getattr(rights, "admin", False)
|
||||
)
|
||||
|
||||
is_admin_page = dctx.get("is_admin_section") or "/admin" in request.path
|
||||
|
||||
from quart import current_app
|
||||
select_colours = current_app.jinja_env.globals.get("select_colours", "")
|
||||
|
||||
result = {
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
"feature-image": feature_image,
|
||||
"link-href": app_url("blog", f"/{slug}/"),
|
||||
"container-nav": container_nav,
|
||||
"page-cart-count": page_cart_count,
|
||||
"cart-href": app_url("cart", f"/{slug}/") if page_cart_count else "",
|
||||
"admin-href": app_url("blog", f"/{slug}/admin/"),
|
||||
"is-admin": is_admin,
|
||||
"is-admin-page": is_admin_page or NIL,
|
||||
"select-colours": select_colours,
|
||||
}
|
||||
g._post_header_ctx = result
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cart
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@register_io_handler("cart-page-ctx")
|
||||
async def _io_cart_page_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: Any
|
||||
) -> dict[str, Any]:
|
||||
"""``(cart-page-ctx)`` → dict with cart page header values."""
|
||||
from quart import g
|
||||
from .types import NIL
|
||||
from shared.infrastructure.urls import app_url
|
||||
|
||||
page_post = getattr(g, "page_post", None)
|
||||
if not page_post:
|
||||
return {"slug": "", "title": "", "feature-image": NIL, "cart-url": "/"}
|
||||
|
||||
slug = getattr(page_post, "slug", "") or ""
|
||||
title = (getattr(page_post, "title", "") or "")[:160]
|
||||
feature_image = getattr(page_post, "feature_image", None) or NIL
|
||||
|
||||
return {
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
"feature-image": feature_image,
|
||||
"page-cart-url": app_url("cart", f"/{slug}/"),
|
||||
"cart-url": app_url("cart", "/"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@register_io_handler("events-calendar-ctx")
|
||||
async def _io_events_calendar_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: Any
|
||||
) -> dict[str, Any]:
|
||||
"""``(events-calendar-ctx)`` → dict with events calendar header values."""
|
||||
from quart import g
|
||||
cal = getattr(g, "calendar", None)
|
||||
if not cal:
|
||||
dctx = getattr(g, "_defpage_ctx", None) or {}
|
||||
cal = dctx.get("calendar")
|
||||
if not cal:
|
||||
return {"slug": ""}
|
||||
return {
|
||||
"slug": getattr(cal, "slug", "") or "",
|
||||
"name": getattr(cal, "name", "") or "",
|
||||
"description": getattr(cal, "description", "") or "",
|
||||
}
|
||||
|
||||
|
||||
@register_io_handler("events-day-ctx")
|
||||
async def _io_events_day_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: Any
|
||||
) -> dict[str, Any]:
|
||||
"""``(events-day-ctx)`` → dict with events day header values."""
|
||||
from quart import g, url_for
|
||||
from .types import NIL
|
||||
from .parser import SxExpr
|
||||
|
||||
dctx = getattr(g, "_defpage_ctx", None) or {}
|
||||
cal = getattr(g, "calendar", None) or dctx.get("calendar")
|
||||
day_date = dctx.get("day_date") or getattr(g, "day_date", None)
|
||||
if not cal or not day_date:
|
||||
return {"date-str": ""}
|
||||
|
||||
cal_slug = getattr(cal, "slug", "") or ""
|
||||
|
||||
# Build confirmed entries nav
|
||||
confirmed = dctx.get("confirmed_entries") or []
|
||||
rights = getattr(g, "rights", None) or {}
|
||||
is_admin = (
|
||||
rights.get("admin", False)
|
||||
if isinstance(rights, dict)
|
||||
else getattr(rights, "admin", False)
|
||||
)
|
||||
|
||||
from .helpers import sx_call
|
||||
nav_parts: list[str] = []
|
||||
if confirmed:
|
||||
entry_links = []
|
||||
for entry in confirmed:
|
||||
href = url_for(
|
||||
"calendar.day.calendar_entries.calendar_entry.get",
|
||||
calendar_slug=cal_slug,
|
||||
year=day_date.year, month=day_date.month, day=day_date.day,
|
||||
entry_id=entry.id,
|
||||
)
|
||||
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
|
||||
end = (
|
||||
f" \u2013 {entry.end_at.strftime('%H:%M')}"
|
||||
if entry.end_at else ""
|
||||
)
|
||||
entry_links.append(sx_call(
|
||||
"events-day-entry-link",
|
||||
href=href, name=entry.name, time_str=f"{start}{end}",
|
||||
))
|
||||
inner = "".join(entry_links)
|
||||
nav_parts.append(sx_call(
|
||||
"events-day-entries-nav", inner=SxExpr(inner),
|
||||
))
|
||||
|
||||
if is_admin and day_date:
|
||||
admin_href = url_for(
|
||||
"defpage_day_admin", calendar_slug=cal_slug,
|
||||
year=day_date.year, month=day_date.month, day=day_date.day,
|
||||
)
|
||||
nav_parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog"))
|
||||
|
||||
return {
|
||||
"date-str": day_date.strftime("%A %d %B %Y"),
|
||||
"year": day_date.year,
|
||||
"month": day_date.month,
|
||||
"day": day_date.day,
|
||||
"nav": SxExpr("".join(nav_parts)) if nav_parts else NIL,
|
||||
}
|
||||
|
||||
|
||||
@register_io_handler("events-entry-ctx")
|
||||
async def _io_events_entry_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: Any
|
||||
) -> dict[str, Any]:
|
||||
"""``(events-entry-ctx)`` → dict with events entry header values."""
|
||||
from quart import g, url_for
|
||||
from .types import NIL
|
||||
from .parser import SxExpr
|
||||
|
||||
dctx = getattr(g, "_defpage_ctx", None) or {}
|
||||
cal = getattr(g, "calendar", None) or dctx.get("calendar")
|
||||
entry = getattr(g, "entry", None) or dctx.get("entry")
|
||||
if not cal or not entry:
|
||||
return {"id": ""}
|
||||
|
||||
cal_slug = getattr(cal, "slug", "") or ""
|
||||
day = dctx.get("day")
|
||||
month = dctx.get("month")
|
||||
year = dctx.get("year")
|
||||
|
||||
# Times
|
||||
start = entry.start_at
|
||||
end = entry.end_at
|
||||
time_str = ""
|
||||
if start:
|
||||
time_str = start.strftime("%H:%M")
|
||||
if end:
|
||||
time_str += f" \u2192 {end.strftime('%H:%M')}"
|
||||
|
||||
link_href = url_for(
|
||||
"calendar.day.calendar_entries.calendar_entry.get",
|
||||
calendar_slug=cal_slug,
|
||||
year=year, month=month, day=day, entry_id=entry.id,
|
||||
)
|
||||
|
||||
# Build nav: associated posts + admin link
|
||||
entry_posts = dctx.get("entry_posts") or []
|
||||
rights = getattr(g, "rights", None) or {}
|
||||
is_admin = (
|
||||
rights.get("admin", False)
|
||||
if isinstance(rights, dict)
|
||||
else getattr(rights, "admin", False)
|
||||
)
|
||||
|
||||
from .helpers import sx_call
|
||||
from shared.infrastructure.urls import app_url
|
||||
|
||||
nav_parts: list[str] = []
|
||||
if entry_posts:
|
||||
post_links = ""
|
||||
for ep in entry_posts:
|
||||
ep_slug = getattr(ep, "slug", "")
|
||||
ep_title = getattr(ep, "title", "")
|
||||
feat = getattr(ep, "feature_image", None)
|
||||
href = app_url("blog", f"/{ep_slug}/")
|
||||
if feat:
|
||||
img_html = sx_call("events-post-img", src=feat, alt=ep_title)
|
||||
else:
|
||||
img_html = sx_call("events-post-img-placeholder")
|
||||
post_links += sx_call(
|
||||
"events-entry-nav-post-link",
|
||||
href=href, img=SxExpr(img_html), title=ep_title,
|
||||
)
|
||||
nav_parts.append(
|
||||
sx_call("events-entry-posts-nav-oob", items=SxExpr(post_links))
|
||||
.replace(' :hx-swap-oob "true"', '')
|
||||
)
|
||||
|
||||
if is_admin:
|
||||
admin_url = url_for(
|
||||
"calendar.day.calendar_entries.calendar_entry.admin.admin",
|
||||
calendar_slug=cal_slug,
|
||||
day=day, month=month, year=year, entry_id=entry.id,
|
||||
)
|
||||
nav_parts.append(sx_call("events-entry-admin-link", href=admin_url))
|
||||
|
||||
# Entry admin nav (ticket_types link)
|
||||
admin_href = url_for(
|
||||
"calendar.day.calendar_entries.calendar_entry.admin.admin",
|
||||
calendar_slug=cal_slug,
|
||||
day=day, month=month, year=year, entry_id=entry.id,
|
||||
) if is_admin else ""
|
||||
|
||||
ticket_types_href = url_for(
|
||||
"calendar.day.calendar_entries.calendar_entry.ticket_types.get",
|
||||
calendar_slug=cal_slug, entry_id=entry.id,
|
||||
year=year, month=month, day=day,
|
||||
)
|
||||
|
||||
from quart import current_app
|
||||
select_colours = current_app.jinja_env.globals.get("select_colours", "")
|
||||
|
||||
return {
|
||||
"id": str(entry.id),
|
||||
"name": entry.name or "",
|
||||
"time-str": time_str,
|
||||
"link-href": link_href,
|
||||
"nav": SxExpr("".join(nav_parts)) if nav_parts else NIL,
|
||||
"admin-href": admin_href,
|
||||
"ticket-types-href": ticket_types_href,
|
||||
"is-admin": is_admin,
|
||||
"select-colours": select_colours,
|
||||
}
|
||||
|
||||
|
||||
@register_io_handler("events-slot-ctx")
|
||||
async def _io_events_slot_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: Any
|
||||
) -> dict[str, Any]:
|
||||
"""``(events-slot-ctx)`` → dict with events slot header values."""
|
||||
from quart import g
|
||||
dctx = getattr(g, "_defpage_ctx", None) or {}
|
||||
slot = getattr(g, "slot", None) or dctx.get("slot")
|
||||
if not slot:
|
||||
return {"name": ""}
|
||||
return {
|
||||
"name": getattr(slot, "name", "") or "",
|
||||
"description": getattr(slot, "description", "") or "",
|
||||
}
|
||||
|
||||
|
||||
@register_io_handler("events-ticket-type-ctx")
|
||||
async def _io_events_ticket_type_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: Any
|
||||
) -> dict[str, Any]:
|
||||
"""``(events-ticket-type-ctx)`` → dict with ticket type header values."""
|
||||
from quart import g, url_for
|
||||
|
||||
dctx = getattr(g, "_defpage_ctx", None) or {}
|
||||
cal = getattr(g, "calendar", None) or dctx.get("calendar")
|
||||
entry = getattr(g, "entry", None) or dctx.get("entry")
|
||||
ticket_type = getattr(g, "ticket_type", None) or dctx.get("ticket_type")
|
||||
if not cal or not entry or not ticket_type:
|
||||
return {"id": ""}
|
||||
|
||||
cal_slug = getattr(cal, "slug", "") or ""
|
||||
day = dctx.get("day")
|
||||
month = dctx.get("month")
|
||||
year = dctx.get("year")
|
||||
|
||||
link_href = url_for(
|
||||
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get",
|
||||
calendar_slug=cal_slug, year=year, month=month, day=day,
|
||||
entry_id=entry.id, ticket_type_id=ticket_type.id,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": str(ticket_type.id),
|
||||
"name": getattr(ticket_type, "name", "") or "",
|
||||
"link-href": link_href,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Market
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@register_io_handler("market-header-ctx")
|
||||
async def _io_market_header_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: Any
|
||||
) -> dict[str, Any]:
|
||||
"""``(market-header-ctx)`` → dict with market header data."""
|
||||
from quart import g, url_for
|
||||
from shared.config import config as get_config
|
||||
from .parser import SxExpr
|
||||
|
||||
cfg = get_config()
|
||||
market_title = cfg.get("market_title", "")
|
||||
link_href = url_for("defpage_market_home")
|
||||
|
||||
# Get categories if market is loaded
|
||||
market = getattr(g, "market", None)
|
||||
categories = {}
|
||||
if market:
|
||||
from bp.browse.services.nav import get_nav
|
||||
nav_data = await get_nav(g.s, market_id=market.id)
|
||||
categories = nav_data.get("cats", {})
|
||||
|
||||
# Build minimal ctx for existing helper functions
|
||||
select_colours = getattr(g, "select_colours", "")
|
||||
if not select_colours:
|
||||
from quart import current_app
|
||||
select_colours = current_app.jinja_env.globals.get("select_colours", "")
|
||||
rights = getattr(g, "rights", None) or {}
|
||||
|
||||
mini_ctx: dict[str, Any] = {
|
||||
"market_title": market_title,
|
||||
"top_slug": "",
|
||||
"sub_slug": "",
|
||||
"categories": categories,
|
||||
"qs": "",
|
||||
"hx_select_search": "#main-panel",
|
||||
"select_colours": select_colours,
|
||||
"rights": rights,
|
||||
"category_label": "",
|
||||
}
|
||||
|
||||
# Build header + mobile nav data via new data-driven helpers
|
||||
from sxc.pages.layouts import _market_header_data, _mobile_nav_panel_sx
|
||||
header_data = _market_header_data(mini_ctx)
|
||||
mobile_nav = _mobile_nav_panel_sx(mini_ctx)
|
||||
|
||||
return {
|
||||
"market-title": market_title,
|
||||
"link-href": link_href,
|
||||
"top-slug": "",
|
||||
"sub-slug": "",
|
||||
"categories": header_data.get("categories", []),
|
||||
"hx-select": header_data.get("hx-select", "#main-panel"),
|
||||
"select-colours": header_data.get("select-colours", ""),
|
||||
"all-href": header_data.get("all-href", ""),
|
||||
"all-active": header_data.get("all-active", False),
|
||||
"admin-href": header_data.get("admin-href", ""),
|
||||
"mobile-nav": SxExpr(mobile_nav) if mobile_nav else "",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Federation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@register_io_handler("federation-actor-ctx")
|
||||
async def _io_federation_actor_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: Any
|
||||
) -> dict[str, Any] | None:
|
||||
"""``(federation-actor-ctx)`` → serialized actor dict or None."""
|
||||
from quart import g
|
||||
actor = getattr(g, "_social_actor", None)
|
||||
if not actor:
|
||||
return None
|
||||
return {
|
||||
"id": actor.id,
|
||||
"preferred_username": actor.preferred_username,
|
||||
"display_name": getattr(actor, "display_name", None),
|
||||
"icon_url": getattr(actor, "icon_url", None),
|
||||
"actor_url": getattr(actor, "actor_url", ""),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Misc UI contexts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@register_io_handler("select-colours")
|
||||
async def _io_select_colours(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: Any
|
||||
) -> str:
|
||||
"""``(select-colours)`` → the shared select/hover CSS class string."""
|
||||
from quart import current_app
|
||||
return current_app.jinja_env.globals.get("select_colours", "")
|
||||
|
||||
|
||||
@register_io_handler("account-nav-ctx")
|
||||
async def _io_account_nav_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: Any
|
||||
) -> Any:
|
||||
"""``(account-nav-ctx)`` → account nav fragments as SxExpr, or NIL."""
|
||||
from quart import g
|
||||
from .types import NIL
|
||||
from .parser import SxExpr
|
||||
from .helpers import sx_call
|
||||
val = getattr(g, "account_nav", None)
|
||||
if not val:
|
||||
return NIL
|
||||
if isinstance(val, SxExpr):
|
||||
return val
|
||||
return sx_call("rich-text", html=str(val))
|
||||
|
||||
|
||||
@register_io_handler("app-rights")
|
||||
async def _io_app_rights(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: Any
|
||||
) -> dict[str, Any]:
|
||||
"""``(app-rights)`` → user rights dict from ``g.rights``."""
|
||||
from quart import g
|
||||
return getattr(g, "rights", None) or {}
|
||||
File diff suppressed because it is too large
Load Diff
131
shared/sx/primitives_stdlib.py
Normal file
131
shared/sx/primitives_stdlib.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Standard library primitives — isomorphic, opt-in modules.
|
||||
|
||||
Augment core with format, text, style, and debug primitives.
|
||||
These are registered into the same _PRIMITIVES registry as core.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .primitives import register_primitive
|
||||
from .types import NIL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stdlib.format
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@register_primitive("format-date")
|
||||
def prim_format_date(date_str: Any, fmt: str) -> str:
|
||||
"""``(format-date date-str fmt)`` → formatted date string."""
|
||||
from datetime import datetime
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(date_str))
|
||||
return dt.strftime(fmt)
|
||||
except (ValueError, TypeError):
|
||||
return str(date_str) if date_str else ""
|
||||
|
||||
|
||||
@register_primitive("format-decimal")
|
||||
def prim_format_decimal(val: Any, places: Any = 2) -> str:
|
||||
"""``(format-decimal val places)`` → formatted decimal string."""
|
||||
try:
|
||||
return f"{float(val):.{int(places)}f}"
|
||||
except (ValueError, TypeError):
|
||||
return "0." + "0" * int(places)
|
||||
|
||||
|
||||
@register_primitive("parse-int")
|
||||
def prim_parse_int(val: Any, default: Any = 0) -> int | Any:
|
||||
"""``(parse-int val default?)`` → int(val) with fallback."""
|
||||
try:
|
||||
return int(val)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
@register_primitive("parse-datetime")
|
||||
def prim_parse_datetime(val: Any) -> Any:
|
||||
"""``(parse-datetime "2024-01-15T10:00:00")`` → ISO string or nil."""
|
||||
from datetime import datetime
|
||||
if not val or val is NIL:
|
||||
return NIL
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(val))
|
||||
return dt.isoformat()
|
||||
except (ValueError, TypeError):
|
||||
return NIL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stdlib.text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@register_primitive("pluralize")
|
||||
def prim_pluralize(count: Any, singular: str = "", plural: str = "s") -> str:
|
||||
"""``(pluralize count)`` → "s" if count != 1, else "".
|
||||
``(pluralize count "item" "items")`` → "item" or "items"."""
|
||||
try:
|
||||
n = int(count)
|
||||
except (ValueError, TypeError):
|
||||
n = 0
|
||||
if singular or plural != "s":
|
||||
return singular if n == 1 else plural
|
||||
return "" if n == 1 else "s"
|
||||
|
||||
|
||||
@register_primitive("escape")
|
||||
def prim_escape(s: Any) -> str:
|
||||
"""``(escape val)`` → HTML-escaped string."""
|
||||
from markupsafe import escape as _escape
|
||||
return str(_escape(str(s) if s is not None and s is not NIL else ""))
|
||||
|
||||
|
||||
@register_primitive("strip-tags")
|
||||
def prim_strip_tags(s: str) -> str:
|
||||
"""Strip HTML tags from a string."""
|
||||
import re
|
||||
return re.sub(r"<[^>]+>", "", s)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stdlib.style
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@register_primitive("css")
|
||||
def prim_css(*args: Any) -> Any:
|
||||
"""``(css :flex :gap-4 :hover:bg-sky-200)`` → StyleValue."""
|
||||
from .types import Keyword
|
||||
from .style_resolver import resolve_style
|
||||
atoms = tuple(
|
||||
(a.name if isinstance(a, Keyword) else str(a))
|
||||
for a in args if a is not None and a is not NIL and a is not False
|
||||
)
|
||||
if not atoms:
|
||||
return NIL
|
||||
return resolve_style(atoms)
|
||||
|
||||
|
||||
@register_primitive("merge-styles")
|
||||
def prim_merge_styles(*styles: Any) -> Any:
|
||||
"""``(merge-styles style1 style2)`` → merged StyleValue."""
|
||||
from .types import StyleValue
|
||||
from .style_resolver import merge_styles
|
||||
valid = [s for s in styles if isinstance(s, StyleValue)]
|
||||
if not valid:
|
||||
return NIL
|
||||
if len(valid) == 1:
|
||||
return valid[0]
|
||||
return merge_styles(valid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stdlib.debug
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@register_primitive("assert")
|
||||
def prim_assert(condition: Any, message: str = "Assertion failed") -> bool:
|
||||
if not condition:
|
||||
raise RuntimeError(f"Assertion error: {message}")
|
||||
return True
|
||||
@@ -20,7 +20,11 @@ async def execute_query(query_def: QueryDef, params: dict[str, str]) -> Any:
|
||||
Parameters are bound from request query string args.
|
||||
"""
|
||||
from .jinja_bridge import get_component_env, _get_request_context
|
||||
from .async_eval import async_eval
|
||||
import os
|
||||
if os.environ.get("SX_USE_REF") == "1":
|
||||
from .ref.async_eval_ref import async_eval
|
||||
else:
|
||||
from .async_eval import async_eval
|
||||
|
||||
env = dict(get_component_env())
|
||||
env.update(query_def.closure)
|
||||
@@ -45,7 +49,11 @@ async def execute_action(action_def: ActionDef, payload: dict[str, Any]) -> Any:
|
||||
Parameters are bound from the JSON request body.
|
||||
"""
|
||||
from .jinja_bridge import get_component_env, _get_request_context
|
||||
from .async_eval import async_eval
|
||||
import os
|
||||
if os.environ.get("SX_USE_REF") == "1":
|
||||
from .ref.async_eval_ref import async_eval
|
||||
else:
|
||||
from .async_eval import async_eval
|
||||
|
||||
env = dict(get_component_env())
|
||||
env.update(action_def.closure)
|
||||
|
||||
85
shared/sx/ref/BOUNDARY.md
Normal file
85
shared/sx/ref/BOUNDARY.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# SX Boundary Enforcement
|
||||
|
||||
## Principle
|
||||
|
||||
SX is an uninterrupted island of pure evaluation. Host code (Python, JavaScript, Rust, etc.) interacts with it only through declared entry points. The specification enforces this — violations are errors, not style suggestions.
|
||||
|
||||
## The Three Tiers
|
||||
|
||||
### Tier 1: Pure Primitives
|
||||
|
||||
Declared in `primitives.sx`. Stateless, synchronous, no side effects. Available in every SX environment on every target.
|
||||
|
||||
Examples: `+`, `str`, `map`, `get`, `concat`, `merge`
|
||||
|
||||
### Tier 2: I/O Primitives
|
||||
|
||||
Declared in `boundary.sx`. Async, side-effectful, require host context (request, config, services). Server-side only.
|
||||
|
||||
Examples: `frag`, `query`, `current-user`, `csrf-token`, `request-arg`
|
||||
|
||||
### Tier 3: Page Helpers
|
||||
|
||||
Declared in `boundary.sx` with a `:service` scope. Registered per-app, return data that `.sx` components render. Server-side only.
|
||||
|
||||
Examples: `highlight` (sx), `editor-data` (blog), `all-markets-data` (market)
|
||||
|
||||
## Boundary Types
|
||||
|
||||
Only these types may cross the host-SX boundary:
|
||||
|
||||
| Type | Python | JavaScript | Rust (future) |
|
||||
|------|--------|-----------|----------------|
|
||||
| number | `int`, `float` | `number` | `f64` |
|
||||
| string | `str` | `string` | `String` |
|
||||
| boolean | `bool` | `boolean` | `bool` |
|
||||
| nil | `NIL` sentinel | `NIL` sentinel | `SxValue::Nil` |
|
||||
| keyword | `str` (colon-prefixed) | `string` | `String` |
|
||||
| list | `list` | `Array` | `Vec<SxValue>` |
|
||||
| dict | `dict` | `Object` / `Map` | `HashMap<String, SxValue>` |
|
||||
| sx-source | `SxExpr` wrapper | `string` | `String` |
|
||||
| style-value | `StyleValue` | `StyleValue` | `StyleValue` |
|
||||
|
||||
**NOT allowed:** ORM models, datetime objects, request objects, raw callables, framework types. Convert at the edge before crossing.
|
||||
|
||||
## Enforcement Mechanism
|
||||
|
||||
The bootstrappers (`bootstrap_js.py`, `bootstrap_py.py`, future `bootstrap_rs.py`, etc.) read `boundary.sx` and emit target-native validation:
|
||||
|
||||
- **Typed targets (Rust, Haskell, TypeScript):** Boundary types become an enum/ADT/discriminated union. Registration functions have type signatures that reject non-SX values at compile time. You literally cannot register a primitive that returns a `datetime` — it won't typecheck.
|
||||
|
||||
- **Python + mypy:** Boundary types become a `Protocol`/`Union` type. `validate_boundary_value()` checks at runtime; mypy catches most violations statically.
|
||||
|
||||
- **JavaScript:** Runtime validation only. `registerPrimitive()` checks the name against the declared set. Boundary type checking is runtime.
|
||||
|
||||
## The Contract
|
||||
|
||||
1. **Spec-first.** Every primitive, I/O function, and page helper must be declared in `primitives.sx` or `boundary.sx` before it can be registered. Undeclared registration = error.
|
||||
|
||||
2. **SX types only.** Values crossing the boundary must be SX-typed. Host-native types (datetime, ORM models, request objects) must be converted to dicts/strings at the edge.
|
||||
|
||||
3. **Data in, markup out.** Python returns data (dicts, lists, strings). `.sx` files compose markup. No SX source construction in Python — no f-strings, no string concatenation, no `SxExpr(f"...")`.
|
||||
|
||||
4. **Closed island.** SX code can only call symbols in its env + declared primitives. There is no FFI, no `eval-python`, no escape hatch from inside SX.
|
||||
|
||||
5. **Fail fast.** Violations are runtime errors (startup crash), not warnings. For typed targets, they're compile errors.
|
||||
|
||||
## Adding a New Primitive
|
||||
|
||||
1. Add declaration to `primitives.sx` (pure) or `boundary.sx` (I/O / page helper)
|
||||
2. Implement in the target language's primitive file
|
||||
3. The bootstrapper-emitted validator will accept it on next rebuild/restart
|
||||
4. If you skip step 1, the app crashes on startup telling you exactly what's missing
|
||||
|
||||
## File Map
|
||||
|
||||
```
|
||||
shared/sx/ref/
|
||||
primitives.sx — Pure primitive declarations
|
||||
boundary.sx — I/O primitive + page helper + boundary type declarations
|
||||
bootstrap_js.py — JS bootstrapper (reads both, emits validation)
|
||||
bootstrap_py.py — Python bootstrapper (reads both, emits validation)
|
||||
eval.sx — Evaluator spec (symbol resolution, env model)
|
||||
parser.sx — Parser spec
|
||||
render.sx — Renderer spec (shared registries)
|
||||
```
|
||||
7
shared/sx/ref/__init__.py
Normal file
7
shared/sx/ref/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Reference SX evaluator — transpiled from canonical .sx spec files.
|
||||
|
||||
This package provides the bootstrap-compiled evaluator as an alternative
|
||||
backend to the hand-written evaluator.py / html.py / async_eval.py.
|
||||
|
||||
Enable by setting SX_USE_REF=1 environment variable.
|
||||
"""
|
||||
999
shared/sx/ref/async_eval_ref.py
Normal file
999
shared/sx/ref/async_eval_ref.py
Normal file
@@ -0,0 +1,999 @@
|
||||
"""Async evaluation wrapper for the transpiled reference evaluator.
|
||||
|
||||
Wraps the sync sx_ref.py evaluator with async I/O support, mirroring
|
||||
the hand-written async_eval.py. Provides the same public API:
|
||||
|
||||
async_eval() — evaluate with I/O primitives
|
||||
async_render() — render to HTML with I/O
|
||||
async_eval_to_sx() — evaluate to SX wire format with I/O
|
||||
async_eval_slot_to_sx() — expand components server-side, then serialize
|
||||
|
||||
The sync transpiled evaluator handles all control flow, special forms,
|
||||
and lambda/component dispatch. This wrapper adds:
|
||||
|
||||
- RequestContext threading
|
||||
- I/O primitive interception (query, service, request-arg, etc.)
|
||||
- Async trampoline for thunks
|
||||
- SxExpr wrapping for wire format output
|
||||
|
||||
DO NOT EDIT by hand — this is a thin wrapper; the actual eval logic
|
||||
lives in sx_ref.py (generated) and the I/O primitives in primitives_io.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import inspect
|
||||
from typing import Any
|
||||
|
||||
from ..types import Component, Keyword, Lambda, Macro, NIL, StyleValue, Symbol
|
||||
from ..parser import SxExpr, serialize
|
||||
from ..primitives_io import IO_PRIMITIVES, RequestContext, execute_io
|
||||
from ..html import (
|
||||
HTML_TAGS, VOID_ELEMENTS, BOOLEAN_ATTRS,
|
||||
escape_text, escape_attr, _RawHTML, css_class_collector, _svg_context,
|
||||
)
|
||||
|
||||
from . import sx_ref
|
||||
|
||||
# Re-export EvalError from sx_ref
|
||||
EvalError = sx_ref.EvalError
|
||||
|
||||
# When True, _aser expands known components server-side
|
||||
_expand_components: contextvars.ContextVar[bool] = contextvars.ContextVar(
|
||||
"_expand_components_ref", default=False
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async TCO
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _AsyncThunk:
|
||||
__slots__ = ("expr", "env", "ctx")
|
||||
def __init__(self, expr, env, ctx):
|
||||
self.expr = expr
|
||||
self.env = env
|
||||
self.ctx = ctx
|
||||
|
||||
|
||||
async def _async_trampoline(val):
|
||||
while isinstance(val, _AsyncThunk):
|
||||
val = await _async_eval(val.expr, val.env, val.ctx)
|
||||
return val
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async evaluate — wraps transpiled sync eval with I/O support
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def async_eval(expr, env, ctx=None):
|
||||
"""Public entry point: evaluate with I/O primitives."""
|
||||
if ctx is None:
|
||||
ctx = RequestContext()
|
||||
result = await _async_eval(expr, env, ctx)
|
||||
while isinstance(result, _AsyncThunk):
|
||||
result = await _async_eval(result.expr, result.env, result.ctx)
|
||||
return result
|
||||
|
||||
|
||||
async def _async_eval(expr, env, ctx):
|
||||
"""Internal async evaluator. Intercepts I/O primitives,
|
||||
delegates everything else to the sync transpiled evaluator."""
|
||||
# Intercept I/O primitive calls
|
||||
if isinstance(expr, list) and expr:
|
||||
head = expr[0]
|
||||
if isinstance(head, Symbol) and head.name in IO_PRIMITIVES:
|
||||
args, kwargs = await _parse_io_args(expr[1:], env, ctx)
|
||||
return await execute_io(head.name, args, kwargs, ctx)
|
||||
|
||||
# For everything else, use the sync transpiled evaluator
|
||||
result = sx_ref.eval_expr(expr, env)
|
||||
return sx_ref.trampoline(result)
|
||||
|
||||
|
||||
async def _parse_io_args(exprs, env, ctx):
|
||||
"""Parse and evaluate I/O node args (keyword + positional)."""
|
||||
args = []
|
||||
kwargs = {}
|
||||
i = 0
|
||||
while i < len(exprs):
|
||||
item = exprs[i]
|
||||
if isinstance(item, Keyword) and i + 1 < len(exprs):
|
||||
kwargs[item.name] = await async_eval(exprs[i + 1], env, ctx)
|
||||
i += 2
|
||||
else:
|
||||
args.append(await async_eval(item, env, ctx))
|
||||
i += 1
|
||||
return args, kwargs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async HTML renderer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def async_render(expr, env, ctx=None):
|
||||
"""Render to HTML, awaiting I/O primitives inline."""
|
||||
if ctx is None:
|
||||
ctx = RequestContext()
|
||||
return await _arender(expr, env, ctx)
|
||||
|
||||
|
||||
async def _arender(expr, env, ctx):
|
||||
if expr is None or expr is NIL or expr is False or expr is True:
|
||||
return ""
|
||||
if isinstance(expr, _RawHTML):
|
||||
return expr.html
|
||||
if isinstance(expr, str):
|
||||
return escape_text(expr)
|
||||
if isinstance(expr, (int, float)):
|
||||
return escape_text(str(expr))
|
||||
if isinstance(expr, Symbol):
|
||||
val = await async_eval(expr, env, ctx)
|
||||
return await _arender(val, env, ctx)
|
||||
if isinstance(expr, Keyword):
|
||||
return escape_text(expr.name)
|
||||
if isinstance(expr, list):
|
||||
if not expr:
|
||||
return ""
|
||||
return await _arender_list(expr, env, ctx)
|
||||
if isinstance(expr, dict):
|
||||
return ""
|
||||
return escape_text(str(expr))
|
||||
|
||||
|
||||
async def _arender_list(expr, env, ctx):
|
||||
head = expr[0]
|
||||
if isinstance(head, Symbol):
|
||||
name = head.name
|
||||
|
||||
# I/O primitive
|
||||
if name in IO_PRIMITIVES:
|
||||
result = await async_eval(expr, env, ctx)
|
||||
return await _arender(result, env, ctx)
|
||||
|
||||
# raw!
|
||||
if name == "raw!":
|
||||
parts = []
|
||||
for arg in expr[1:]:
|
||||
val = await async_eval(arg, env, ctx)
|
||||
if isinstance(val, _RawHTML):
|
||||
parts.append(val.html)
|
||||
elif isinstance(val, str):
|
||||
parts.append(val)
|
||||
elif val is not None and val is not NIL:
|
||||
parts.append(str(val))
|
||||
return "".join(parts)
|
||||
|
||||
# Fragment
|
||||
if name == "<>":
|
||||
parts = [await _arender(c, env, ctx) for c in expr[1:]]
|
||||
return "".join(parts)
|
||||
|
||||
# html: prefix
|
||||
if name.startswith("html:"):
|
||||
return await _arender_element(name[5:], expr[1:], env, ctx)
|
||||
|
||||
# Render-aware special forms
|
||||
arsf = _ASYNC_RENDER_FORMS.get(name)
|
||||
if arsf is not None:
|
||||
if name in HTML_TAGS and (
|
||||
(len(expr) > 1 and isinstance(expr[1], Keyword))
|
||||
or _svg_context.get(False)
|
||||
):
|
||||
return await _arender_element(name, expr[1:], env, ctx)
|
||||
return await arsf(expr, env, ctx)
|
||||
|
||||
# Macro expansion
|
||||
if name in env:
|
||||
val = env[name]
|
||||
if isinstance(val, Macro):
|
||||
expanded = sx_ref.trampoline(
|
||||
sx_ref.expand_macro(val, expr[1:], env)
|
||||
)
|
||||
return await _arender(expanded, env, ctx)
|
||||
|
||||
# HTML tag
|
||||
if name in HTML_TAGS:
|
||||
return await _arender_element(name, expr[1:], env, ctx)
|
||||
|
||||
# Component
|
||||
if name.startswith("~"):
|
||||
val = env.get(name)
|
||||
if isinstance(val, Component):
|
||||
return await _arender_component(val, expr[1:], env, ctx)
|
||||
|
||||
# Custom element
|
||||
if "-" in name and len(expr) > 1 and isinstance(expr[1], Keyword):
|
||||
return await _arender_element(name, expr[1:], env, ctx)
|
||||
|
||||
# SVG context
|
||||
if _svg_context.get(False):
|
||||
return await _arender_element(name, expr[1:], env, ctx)
|
||||
|
||||
# Fallback — evaluate then render
|
||||
result = await async_eval(expr, env, ctx)
|
||||
return await _arender(result, env, ctx)
|
||||
|
||||
if isinstance(head, (Lambda, list)):
|
||||
result = await async_eval(expr, env, ctx)
|
||||
return await _arender(result, env, ctx)
|
||||
|
||||
# Data list
|
||||
parts = [await _arender(item, env, ctx) for item in expr]
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
async def _arender_element(tag, args, env, ctx):
|
||||
attrs = {}
|
||||
children = []
|
||||
i = 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
if isinstance(arg, Keyword) and i + 1 < len(args):
|
||||
attrs[arg.name] = await async_eval(args[i + 1], env, ctx)
|
||||
i += 2
|
||||
else:
|
||||
children.append(arg)
|
||||
i += 1
|
||||
|
||||
# StyleValue → class
|
||||
style_val = attrs.get("style")
|
||||
if isinstance(style_val, StyleValue):
|
||||
from ..css_registry import register_generated_rule
|
||||
register_generated_rule(style_val)
|
||||
existing = attrs.get("class")
|
||||
if existing and existing is not NIL and existing is not False:
|
||||
attrs["class"] = f"{existing} {style_val.class_name}"
|
||||
else:
|
||||
attrs["class"] = style_val.class_name
|
||||
del attrs["style"]
|
||||
|
||||
class_val = attrs.get("class")
|
||||
if class_val is not None and class_val is not NIL and class_val is not False:
|
||||
collector = css_class_collector.get(None)
|
||||
if collector is not None:
|
||||
collector.update(str(class_val).split())
|
||||
|
||||
parts = [f"<{tag}"]
|
||||
for attr_name, attr_val in attrs.items():
|
||||
if attr_val is None or attr_val is NIL or attr_val is False:
|
||||
continue
|
||||
if attr_name in BOOLEAN_ATTRS:
|
||||
if attr_val:
|
||||
parts.append(f" {attr_name}")
|
||||
elif attr_val is True:
|
||||
parts.append(f" {attr_name}")
|
||||
else:
|
||||
parts.append(f' {attr_name}="{escape_attr(str(attr_val))}"')
|
||||
parts.append(">")
|
||||
opening = "".join(parts)
|
||||
|
||||
if tag in VOID_ELEMENTS:
|
||||
return opening
|
||||
|
||||
token = None
|
||||
if tag in ("svg", "math"):
|
||||
token = _svg_context.set(True)
|
||||
try:
|
||||
child_parts = [await _arender(c, env, ctx) for c in children]
|
||||
finally:
|
||||
if token is not None:
|
||||
_svg_context.reset(token)
|
||||
|
||||
return f"{opening}{''.join(child_parts)}</{tag}>"
|
||||
|
||||
|
||||
async def _arender_component(comp, args, env, ctx):
|
||||
kwargs = {}
|
||||
children = []
|
||||
i = 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
if isinstance(arg, Keyword) and i + 1 < len(args):
|
||||
kwargs[arg.name] = await async_eval(args[i + 1], env, ctx)
|
||||
i += 2
|
||||
else:
|
||||
children.append(arg)
|
||||
i += 1
|
||||
local = dict(comp.closure)
|
||||
local.update(env)
|
||||
for p in comp.params:
|
||||
local[p] = kwargs.get(p, NIL)
|
||||
if comp.has_children:
|
||||
child_html = [await _arender(c, env, ctx) for c in children]
|
||||
local["children"] = _RawHTML("".join(child_html))
|
||||
return await _arender(comp.body, local, ctx)
|
||||
|
||||
|
||||
async def _arender_lambda(fn, args, env, ctx):
|
||||
local = dict(fn.closure)
|
||||
local.update(env)
|
||||
for p, v in zip(fn.params, args):
|
||||
local[p] = v
|
||||
return await _arender(fn.body, local, ctx)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Render-aware special forms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _arsf_if(expr, env, ctx):
|
||||
cond = await async_eval(expr[1], env, ctx)
|
||||
if cond and cond is not NIL:
|
||||
return await _arender(expr[2], env, ctx)
|
||||
return await _arender(expr[3], env, ctx) if len(expr) > 3 else ""
|
||||
|
||||
|
||||
async def _arsf_when(expr, env, ctx):
|
||||
cond = await async_eval(expr[1], env, ctx)
|
||||
if cond and cond is not NIL:
|
||||
return "".join([await _arender(b, env, ctx) for b in expr[2:]])
|
||||
return ""
|
||||
|
||||
|
||||
async def _arsf_cond(expr, env, ctx):
|
||||
clauses = expr[1:]
|
||||
if not clauses:
|
||||
return ""
|
||||
if isinstance(clauses[0], list) and len(clauses[0]) == 2:
|
||||
for clause in clauses:
|
||||
test = clause[0]
|
||||
if isinstance(test, Symbol) and test.name in ("else", ":else"):
|
||||
return await _arender(clause[1], env, ctx)
|
||||
if isinstance(test, Keyword) and test.name == "else":
|
||||
return await _arender(clause[1], env, ctx)
|
||||
if await async_eval(test, env, ctx):
|
||||
return await _arender(clause[1], env, ctx)
|
||||
else:
|
||||
i = 0
|
||||
while i < len(clauses) - 1:
|
||||
test, result = clauses[i], clauses[i + 1]
|
||||
if isinstance(test, Keyword) and test.name == "else":
|
||||
return await _arender(result, env, ctx)
|
||||
if isinstance(test, Symbol) and test.name in (":else", "else"):
|
||||
return await _arender(result, env, ctx)
|
||||
if await async_eval(test, env, ctx):
|
||||
return await _arender(result, env, ctx)
|
||||
i += 2
|
||||
return ""
|
||||
|
||||
|
||||
async def _arsf_let(expr, env, ctx):
|
||||
bindings = expr[1]
|
||||
local = dict(env)
|
||||
if isinstance(bindings, list):
|
||||
if bindings and isinstance(bindings[0], list):
|
||||
for b in bindings:
|
||||
var = b[0]
|
||||
vname = var.name if isinstance(var, Symbol) else var
|
||||
local[vname] = await async_eval(b[1], local, ctx)
|
||||
elif len(bindings) % 2 == 0:
|
||||
for i in range(0, len(bindings), 2):
|
||||
var = bindings[i]
|
||||
vname = var.name if isinstance(var, Symbol) else var
|
||||
local[vname] = await async_eval(bindings[i + 1], local, ctx)
|
||||
return "".join([await _arender(b, local, ctx) for b in expr[2:]])
|
||||
|
||||
|
||||
async def _arsf_begin(expr, env, ctx):
|
||||
return "".join([await _arender(sub, env, ctx) for sub in expr[1:]])
|
||||
|
||||
|
||||
async def _arsf_define(expr, env, ctx):
|
||||
await async_eval(expr, env, ctx)
|
||||
return ""
|
||||
|
||||
|
||||
async def _arsf_map(expr, env, ctx):
|
||||
fn = await async_eval(expr[1], env, ctx)
|
||||
coll = await async_eval(expr[2], env, ctx)
|
||||
parts = []
|
||||
for item in coll:
|
||||
if isinstance(fn, Lambda):
|
||||
parts.append(await _arender_lambda(fn, (item,), env, ctx))
|
||||
elif callable(fn):
|
||||
r = fn(item)
|
||||
if inspect.iscoroutine(r):
|
||||
r = await r
|
||||
parts.append(await _arender(r, env, ctx))
|
||||
else:
|
||||
parts.append(await _arender(item, env, ctx))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
async def _arsf_map_indexed(expr, env, ctx):
|
||||
fn = await async_eval(expr[1], env, ctx)
|
||||
coll = await async_eval(expr[2], env, ctx)
|
||||
parts = []
|
||||
for i, item in enumerate(coll):
|
||||
if isinstance(fn, Lambda):
|
||||
parts.append(await _arender_lambda(fn, (i, item), env, ctx))
|
||||
elif callable(fn):
|
||||
r = fn(i, item)
|
||||
if inspect.iscoroutine(r):
|
||||
r = await r
|
||||
parts.append(await _arender(r, env, ctx))
|
||||
else:
|
||||
parts.append(await _arender(item, env, ctx))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
async def _arsf_filter(expr, env, ctx):
|
||||
result = await async_eval(expr, env, ctx)
|
||||
return await _arender(result, env, ctx)
|
||||
|
||||
|
||||
async def _arsf_for_each(expr, env, ctx):
|
||||
fn = await async_eval(expr[1], env, ctx)
|
||||
coll = await async_eval(expr[2], env, ctx)
|
||||
parts = []
|
||||
for item in coll:
|
||||
if isinstance(fn, Lambda):
|
||||
parts.append(await _arender_lambda(fn, (item,), env, ctx))
|
||||
elif callable(fn):
|
||||
r = fn(item)
|
||||
if inspect.iscoroutine(r):
|
||||
r = await r
|
||||
parts.append(await _arender(r, env, ctx))
|
||||
else:
|
||||
parts.append(await _arender(item, env, ctx))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
_ASYNC_RENDER_FORMS = {
|
||||
"if": _arsf_if,
|
||||
"when": _arsf_when,
|
||||
"cond": _arsf_cond,
|
||||
"let": _arsf_let,
|
||||
"let*": _arsf_let,
|
||||
"begin": _arsf_begin,
|
||||
"do": _arsf_begin,
|
||||
"define": _arsf_define,
|
||||
"defstyle": _arsf_define,
|
||||
"defkeyframes": _arsf_define,
|
||||
"defcomp": _arsf_define,
|
||||
"defmacro": _arsf_define,
|
||||
"defhandler": _arsf_define,
|
||||
"map": _arsf_map,
|
||||
"map-indexed": _arsf_map_indexed,
|
||||
"filter": _arsf_filter,
|
||||
"for-each": _arsf_for_each,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async SX wire format (aser)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def async_eval_to_sx(expr, env, ctx=None):
|
||||
"""Evaluate and produce SX source string (wire format)."""
|
||||
if ctx is None:
|
||||
ctx = RequestContext()
|
||||
result = await _aser(expr, env, ctx)
|
||||
if isinstance(result, SxExpr):
|
||||
return result
|
||||
if result is None or result is NIL:
|
||||
return SxExpr("")
|
||||
if isinstance(result, str):
|
||||
return SxExpr(result)
|
||||
return SxExpr(serialize(result))
|
||||
|
||||
|
||||
async def async_eval_slot_to_sx(expr, env, ctx=None):
|
||||
"""Like async_eval_to_sx but expands component calls server-side."""
|
||||
if ctx is None:
|
||||
ctx = RequestContext()
|
||||
token = _expand_components.set(True)
|
||||
try:
|
||||
return await _eval_slot_inner(expr, env, ctx)
|
||||
finally:
|
||||
_expand_components.reset(token)
|
||||
|
||||
|
||||
async def _eval_slot_inner(expr, env, ctx):
|
||||
if isinstance(expr, list) and expr:
|
||||
head = expr[0]
|
||||
if isinstance(head, Symbol) and head.name.startswith("~"):
|
||||
comp = env.get(head.name)
|
||||
if isinstance(comp, Component):
|
||||
result = await _aser_component(comp, expr[1:], env, ctx)
|
||||
if isinstance(result, SxExpr):
|
||||
return result
|
||||
if result is None or result is NIL:
|
||||
return SxExpr("")
|
||||
if isinstance(result, str):
|
||||
return SxExpr(result)
|
||||
return SxExpr(serialize(result))
|
||||
result = await _aser(expr, env, ctx)
|
||||
result = await _maybe_expand_component_result(result, env, ctx)
|
||||
if isinstance(result, SxExpr):
|
||||
return result
|
||||
if result is None or result is NIL:
|
||||
return SxExpr("")
|
||||
if isinstance(result, str):
|
||||
return SxExpr(result)
|
||||
return SxExpr(serialize(result))
|
||||
|
||||
|
||||
async def _maybe_expand_component_result(result, env, ctx):
|
||||
raw = None
|
||||
if isinstance(result, SxExpr):
|
||||
raw = str(result).strip()
|
||||
elif isinstance(result, str):
|
||||
raw = result.strip()
|
||||
if raw and raw.startswith("(~"):
|
||||
from ..parser import parse_all
|
||||
parsed = parse_all(raw)
|
||||
if parsed:
|
||||
return await async_eval_slot_to_sx(parsed[0], env, ctx)
|
||||
return result
|
||||
|
||||
|
||||
async def _aser(expr, env, ctx):
|
||||
"""Evaluate for SX wire format — serialize rendering forms, evaluate control flow."""
|
||||
if isinstance(expr, (int, float, bool)):
|
||||
return expr
|
||||
if isinstance(expr, SxExpr):
|
||||
return expr
|
||||
if isinstance(expr, str):
|
||||
return expr
|
||||
if expr is None or expr is NIL:
|
||||
return NIL
|
||||
|
||||
if isinstance(expr, Symbol):
|
||||
name = expr.name
|
||||
if name in env:
|
||||
return env[name]
|
||||
if sx_ref.is_primitive(name):
|
||||
return sx_ref.get_primitive(name)
|
||||
if name == "true":
|
||||
return True
|
||||
if name == "false":
|
||||
return False
|
||||
if name == "nil":
|
||||
return NIL
|
||||
raise EvalError(f"Undefined symbol: {name}")
|
||||
|
||||
if isinstance(expr, Keyword):
|
||||
return expr.name
|
||||
|
||||
if isinstance(expr, dict):
|
||||
return {k: await _aser(v, env, ctx) for k, v in expr.items()}
|
||||
|
||||
if not isinstance(expr, list):
|
||||
return expr
|
||||
if not expr:
|
||||
return []
|
||||
|
||||
head = expr[0]
|
||||
if not isinstance(head, (Symbol, Lambda, list)):
|
||||
return [await _aser(x, env, ctx) for x in expr]
|
||||
|
||||
if isinstance(head, Symbol):
|
||||
name = head.name
|
||||
|
||||
# I/O primitives
|
||||
if name in IO_PRIMITIVES:
|
||||
args, kwargs = await _parse_io_args(expr[1:], env, ctx)
|
||||
return await execute_io(name, args, kwargs, ctx)
|
||||
|
||||
# Fragment
|
||||
if name == "<>":
|
||||
return await _aser_fragment(expr[1:], env, ctx)
|
||||
|
||||
# raw!
|
||||
if name == "raw!":
|
||||
return await _aser_call("raw!", expr[1:], env, ctx)
|
||||
|
||||
# html: prefix
|
||||
if name.startswith("html:"):
|
||||
return await _aser_call(name[5:], expr[1:], env, ctx)
|
||||
|
||||
# Component call
|
||||
if name.startswith("~"):
|
||||
val = env.get(name)
|
||||
if isinstance(val, Macro):
|
||||
expanded = sx_ref.trampoline(
|
||||
sx_ref.expand_macro(val, expr[1:], env)
|
||||
)
|
||||
return await _aser(expanded, env, ctx)
|
||||
if isinstance(val, Component) and _expand_components.get():
|
||||
return await _aser_component(val, expr[1:], env, ctx)
|
||||
return await _aser_call(name, expr[1:], env, ctx)
|
||||
|
||||
# Serialize-mode special/HO forms
|
||||
sf = _ASER_FORMS.get(name)
|
||||
if sf is not None:
|
||||
if name in HTML_TAGS and (
|
||||
(len(expr) > 1 and isinstance(expr[1], Keyword))
|
||||
or _svg_context.get(False)
|
||||
):
|
||||
return await _aser_call(name, expr[1:], env, ctx)
|
||||
return await sf(expr, env, ctx)
|
||||
|
||||
# HTML tag
|
||||
if name in HTML_TAGS:
|
||||
return await _aser_call(name, expr[1:], env, ctx)
|
||||
|
||||
# Macro
|
||||
if name in env:
|
||||
val = env[name]
|
||||
if isinstance(val, Macro):
|
||||
expanded = sx_ref.trampoline(
|
||||
sx_ref.expand_macro(val, expr[1:], env)
|
||||
)
|
||||
return await _aser(expanded, env, ctx)
|
||||
|
||||
# Custom element
|
||||
if "-" in name and len(expr) > 1 and isinstance(expr[1], Keyword):
|
||||
return await _aser_call(name, expr[1:], env, ctx)
|
||||
|
||||
# SVG context
|
||||
if _svg_context.get(False):
|
||||
return await _aser_call(name, expr[1:], env, ctx)
|
||||
|
||||
# Function/lambda call
|
||||
fn = await async_eval(head, env, ctx)
|
||||
args = [await async_eval(a, env, ctx) for a in expr[1:]]
|
||||
|
||||
if callable(fn) and not isinstance(fn, (Lambda, Component)):
|
||||
result = fn(*args)
|
||||
if inspect.iscoroutine(result):
|
||||
return await result
|
||||
return result
|
||||
if isinstance(fn, Lambda):
|
||||
local = dict(fn.closure)
|
||||
local.update(env)
|
||||
for p, v in zip(fn.params, args):
|
||||
local[p] = v
|
||||
return await _aser(fn.body, local, ctx)
|
||||
if isinstance(fn, Component):
|
||||
return await _aser_call(f"~{fn.name}", expr[1:], env, ctx)
|
||||
raise EvalError(f"Not callable: {fn!r}")
|
||||
|
||||
|
||||
async def _aser_fragment(children, env, ctx):
|
||||
parts = []
|
||||
for child in children:
|
||||
result = await _aser(child, env, ctx)
|
||||
if isinstance(result, list):
|
||||
for item in result:
|
||||
if item is not NIL and item is not None:
|
||||
parts.append(serialize(item))
|
||||
elif result is not NIL and result is not None:
|
||||
parts.append(serialize(result))
|
||||
if not parts:
|
||||
return SxExpr("")
|
||||
return SxExpr("(<> " + " ".join(parts) + ")")
|
||||
|
||||
|
||||
async def _aser_component(comp, args, env, ctx):
|
||||
kwargs = {}
|
||||
children = []
|
||||
i = 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
if isinstance(arg, Keyword) and i + 1 < len(args):
|
||||
kwargs[arg.name] = await _aser(args[i + 1], env, ctx)
|
||||
i += 2
|
||||
else:
|
||||
children.append(arg)
|
||||
i += 1
|
||||
local = dict(comp.closure)
|
||||
local.update(env)
|
||||
for p in comp.params:
|
||||
local[p] = kwargs.get(p, NIL)
|
||||
if comp.has_children:
|
||||
child_parts = [serialize(await _aser(c, env, ctx)) for c in children]
|
||||
local["children"] = SxExpr("(<> " + " ".join(child_parts) + ")")
|
||||
return await _aser(comp.body, local, ctx)
|
||||
|
||||
|
||||
async def _aser_call(name, args, env, ctx):
|
||||
token = None
|
||||
if name in ("svg", "math"):
|
||||
token = _svg_context.set(True)
|
||||
try:
|
||||
parts = [name]
|
||||
extra_class = None
|
||||
i = 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
if isinstance(arg, Keyword) and i + 1 < len(args):
|
||||
val = await _aser(args[i + 1], env, ctx)
|
||||
if val is not NIL and val is not None:
|
||||
if arg.name == "style" and isinstance(val, StyleValue):
|
||||
from ..css_registry import register_generated_rule
|
||||
register_generated_rule(val)
|
||||
extra_class = val.class_name
|
||||
else:
|
||||
parts.append(f":{arg.name}")
|
||||
if isinstance(val, list):
|
||||
live = [v for v in val if v is not NIL and v is not None]
|
||||
items = [serialize(v) for v in live]
|
||||
if not items:
|
||||
parts.append("nil")
|
||||
elif any(isinstance(v, SxExpr) for v in live):
|
||||
parts.append("(<> " + " ".join(items) + ")")
|
||||
else:
|
||||
parts.append("(list " + " ".join(items) + ")")
|
||||
else:
|
||||
parts.append(serialize(val))
|
||||
i += 2
|
||||
else:
|
||||
result = await _aser(arg, env, ctx)
|
||||
if result is not NIL and result is not None:
|
||||
if isinstance(result, list):
|
||||
for item in result:
|
||||
if item is not NIL and item is not None:
|
||||
parts.append(serialize(item))
|
||||
else:
|
||||
parts.append(serialize(result))
|
||||
i += 1
|
||||
if extra_class:
|
||||
_merge_class_into_parts(parts, extra_class)
|
||||
return SxExpr("(" + " ".join(parts) + ")")
|
||||
finally:
|
||||
if token is not None:
|
||||
_svg_context.reset(token)
|
||||
|
||||
|
||||
def _merge_class_into_parts(parts, class_name):
|
||||
for i, p in enumerate(parts):
|
||||
if p == ":class" and i + 1 < len(parts):
|
||||
existing = parts[i + 1]
|
||||
if existing.startswith('"') and existing.endswith('"'):
|
||||
parts[i + 1] = existing[:-1] + " " + class_name + '"'
|
||||
else:
|
||||
parts[i + 1] = f'(str {existing} " {class_name}")'
|
||||
return
|
||||
parts.insert(1, f'"{class_name}"')
|
||||
parts.insert(1, ":class")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aser-mode special forms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _assf_if(expr, env, ctx):
|
||||
cond = await async_eval(expr[1], env, ctx)
|
||||
if cond and cond is not NIL:
|
||||
return await _aser(expr[2], env, ctx)
|
||||
return await _aser(expr[3], env, ctx) if len(expr) > 3 else NIL
|
||||
|
||||
|
||||
async def _assf_when(expr, env, ctx):
|
||||
cond = await async_eval(expr[1], env, ctx)
|
||||
if cond and cond is not NIL:
|
||||
result = NIL
|
||||
for body_expr in expr[2:]:
|
||||
result = await _aser(body_expr, env, ctx)
|
||||
return result
|
||||
return NIL
|
||||
|
||||
|
||||
async def _assf_let(expr, env, ctx):
|
||||
bindings = expr[1]
|
||||
local = dict(env)
|
||||
if isinstance(bindings, list):
|
||||
if bindings and isinstance(bindings[0], list):
|
||||
for b in bindings:
|
||||
var = b[0]
|
||||
vname = var.name if isinstance(var, Symbol) else var
|
||||
local[vname] = await _aser(b[1], local, ctx)
|
||||
elif len(bindings) % 2 == 0:
|
||||
for i in range(0, len(bindings), 2):
|
||||
var = bindings[i]
|
||||
vname = var.name if isinstance(var, Symbol) else var
|
||||
local[vname] = await _aser(bindings[i + 1], local, ctx)
|
||||
result = NIL
|
||||
for body_expr in expr[2:]:
|
||||
result = await _aser(body_expr, local, ctx)
|
||||
return result
|
||||
|
||||
|
||||
async def _assf_cond(expr, env, ctx):
|
||||
clauses = expr[1:]
|
||||
if not clauses:
|
||||
return NIL
|
||||
if isinstance(clauses[0], list) and len(clauses[0]) == 2:
|
||||
for clause in clauses:
|
||||
test = clause[0]
|
||||
if isinstance(test, Symbol) and test.name in ("else", ":else"):
|
||||
return await _aser(clause[1], env, ctx)
|
||||
if isinstance(test, Keyword) and test.name == "else":
|
||||
return await _aser(clause[1], env, ctx)
|
||||
if await async_eval(test, env, ctx):
|
||||
return await _aser(clause[1], env, ctx)
|
||||
else:
|
||||
i = 0
|
||||
while i < len(clauses) - 1:
|
||||
test, result = clauses[i], clauses[i + 1]
|
||||
if isinstance(test, Keyword) and test.name == "else":
|
||||
return await _aser(result, env, ctx)
|
||||
if isinstance(test, Symbol) and test.name in (":else", "else"):
|
||||
return await _aser(result, env, ctx)
|
||||
if await async_eval(test, env, ctx):
|
||||
return await _aser(result, env, ctx)
|
||||
i += 2
|
||||
return NIL
|
||||
|
||||
|
||||
async def _assf_case(expr, env, ctx):
|
||||
match_val = await async_eval(expr[1], env, ctx)
|
||||
clauses = expr[2:]
|
||||
i = 0
|
||||
while i < len(clauses) - 1:
|
||||
test, result = clauses[i], clauses[i + 1]
|
||||
if isinstance(test, Keyword) and test.name == "else":
|
||||
return await _aser(result, env, ctx)
|
||||
if isinstance(test, Symbol) and test.name in (":else", "else"):
|
||||
return await _aser(result, env, ctx)
|
||||
if match_val == await async_eval(test, env, ctx):
|
||||
return await _aser(result, env, ctx)
|
||||
i += 2
|
||||
return NIL
|
||||
|
||||
|
||||
async def _assf_begin(expr, env, ctx):
|
||||
result = NIL
|
||||
for sub in expr[1:]:
|
||||
result = await _aser(sub, env, ctx)
|
||||
return result
|
||||
|
||||
|
||||
async def _assf_define(expr, env, ctx):
|
||||
await async_eval(expr, env, ctx)
|
||||
return NIL
|
||||
|
||||
|
||||
async def _assf_and(expr, env, ctx):
|
||||
result = True
|
||||
for arg in expr[1:]:
|
||||
result = await async_eval(arg, env, ctx)
|
||||
if not result:
|
||||
return result
|
||||
return result
|
||||
|
||||
|
||||
async def _assf_or(expr, env, ctx):
|
||||
result = False
|
||||
for arg in expr[1:]:
|
||||
result = await async_eval(arg, env, ctx)
|
||||
if result:
|
||||
return result
|
||||
return result
|
||||
|
||||
|
||||
async def _assf_lambda(expr, env, ctx):
|
||||
params_expr = expr[1]
|
||||
param_names = []
|
||||
for p in params_expr:
|
||||
if isinstance(p, Symbol):
|
||||
param_names.append(p.name)
|
||||
elif isinstance(p, str):
|
||||
param_names.append(p)
|
||||
return Lambda(param_names, expr[2], dict(env))
|
||||
|
||||
|
||||
async def _assf_quote(expr, env, ctx):
|
||||
return expr[1] if len(expr) > 1 else NIL
|
||||
|
||||
|
||||
async def _assf_thread_first(expr, env, ctx):
|
||||
result = await async_eval(expr[1], env, ctx)
|
||||
for form in expr[2:]:
|
||||
if isinstance(form, list):
|
||||
fn = await async_eval(form[0], env, ctx)
|
||||
fn_args = [result] + [await async_eval(a, env, ctx) for a in form[1:]]
|
||||
else:
|
||||
fn = await async_eval(form, env, ctx)
|
||||
fn_args = [result]
|
||||
if callable(fn) and not isinstance(fn, (Lambda, Component)):
|
||||
result = fn(*fn_args)
|
||||
if inspect.iscoroutine(result):
|
||||
result = await result
|
||||
elif isinstance(fn, Lambda):
|
||||
local = dict(fn.closure)
|
||||
local.update(env)
|
||||
for p, v in zip(fn.params, fn_args):
|
||||
local[p] = v
|
||||
result = await async_eval(fn.body, local, ctx)
|
||||
else:
|
||||
raise EvalError(f"-> form not callable: {fn!r}")
|
||||
return result
|
||||
|
||||
|
||||
async def _assf_set_bang(expr, env, ctx):
|
||||
value = await async_eval(expr[2], env, ctx)
|
||||
env[expr[1].name] = value
|
||||
return value
|
||||
|
||||
|
||||
# Aser-mode HO forms
|
||||
|
||||
async def _asho_map(expr, env, ctx):
|
||||
fn = await async_eval(expr[1], env, ctx)
|
||||
coll = await async_eval(expr[2], env, ctx)
|
||||
results = []
|
||||
for item in coll:
|
||||
if isinstance(fn, Lambda):
|
||||
local = dict(fn.closure)
|
||||
local.update(env)
|
||||
local[fn.params[0]] = item
|
||||
results.append(await _aser(fn.body, local, ctx))
|
||||
elif callable(fn):
|
||||
r = fn(item)
|
||||
results.append(await r if inspect.iscoroutine(r) else r)
|
||||
else:
|
||||
raise EvalError(f"map requires callable, got {type(fn).__name__}")
|
||||
return results
|
||||
|
||||
|
||||
async def _asho_map_indexed(expr, env, ctx):
|
||||
fn = await async_eval(expr[1], env, ctx)
|
||||
coll = await async_eval(expr[2], env, ctx)
|
||||
results = []
|
||||
for i, item in enumerate(coll):
|
||||
if isinstance(fn, Lambda):
|
||||
local = dict(fn.closure)
|
||||
local.update(env)
|
||||
local[fn.params[0]] = i
|
||||
local[fn.params[1]] = item
|
||||
results.append(await _aser(fn.body, local, ctx))
|
||||
elif callable(fn):
|
||||
r = fn(i, item)
|
||||
results.append(await r if inspect.iscoroutine(r) else r)
|
||||
else:
|
||||
raise EvalError(f"map-indexed requires callable, got {type(fn).__name__}")
|
||||
return results
|
||||
|
||||
|
||||
async def _asho_filter(expr, env, ctx):
|
||||
return await async_eval(expr, env, ctx)
|
||||
|
||||
|
||||
async def _asho_for_each(expr, env, ctx):
|
||||
fn = await async_eval(expr[1], env, ctx)
|
||||
coll = await async_eval(expr[2], env, ctx)
|
||||
results = []
|
||||
for item in coll:
|
||||
if isinstance(fn, Lambda):
|
||||
local = dict(fn.closure)
|
||||
local.update(env)
|
||||
local[fn.params[0]] = item
|
||||
results.append(await _aser(fn.body, local, ctx))
|
||||
elif callable(fn):
|
||||
r = fn(item)
|
||||
results.append(await r if inspect.iscoroutine(r) else r)
|
||||
return results
|
||||
|
||||
|
||||
_ASER_FORMS = {
|
||||
"if": _assf_if,
|
||||
"when": _assf_when,
|
||||
"cond": _assf_cond,
|
||||
"case": _assf_case,
|
||||
"and": _assf_and,
|
||||
"or": _assf_or,
|
||||
"let": _assf_let,
|
||||
"let*": _assf_let,
|
||||
"lambda": _assf_lambda,
|
||||
"fn": _assf_lambda,
|
||||
"define": _assf_define,
|
||||
"defstyle": _assf_define,
|
||||
"defkeyframes": _assf_define,
|
||||
"defcomp": _assf_define,
|
||||
"defmacro": _assf_define,
|
||||
"defhandler": _assf_define,
|
||||
"begin": _assf_begin,
|
||||
"do": _assf_begin,
|
||||
"quote": _assf_quote,
|
||||
"->": _assf_thread_first,
|
||||
"set!": _assf_set_bang,
|
||||
"map": _asho_map,
|
||||
"map-indexed": _asho_map_indexed,
|
||||
"filter": _asho_filter,
|
||||
"for-each": _asho_for_each,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
2069
shared/sx/ref/bootstrap_py.py
Normal file
2069
shared/sx/ref/bootstrap_py.py
Normal file
File diff suppressed because it is too large
Load Diff
461
shared/sx/ref/boundary.sx
Normal file
461
shared/sx/ref/boundary.sx
Normal file
@@ -0,0 +1,461 @@
|
||||
;; ==========================================================================
|
||||
;; boundary.sx — SX boundary contract
|
||||
;;
|
||||
;; Declares everything allowed to cross the host-SX boundary:
|
||||
;; I/O primitives (Tier 2) and page helpers (Tier 3).
|
||||
;;
|
||||
;; Pure primitives (Tier 1) are declared in primitives.sx.
|
||||
;; This file declares what primitives.sx does NOT cover:
|
||||
;; async/side-effectful host functions that need request context.
|
||||
;;
|
||||
;; Format:
|
||||
;; (define-io-primitive "name"
|
||||
;; :params (param1 param2 &key ...)
|
||||
;; :returns "type"
|
||||
;; :async true
|
||||
;; :doc "description"
|
||||
;; :context :request)
|
||||
;;
|
||||
;; (define-page-helper "name"
|
||||
;; :params (param1 param2)
|
||||
;; :returns "type"
|
||||
;; :service "service-name")
|
||||
;;
|
||||
;; Bootstrappers read this file and emit frozen sets + validation
|
||||
;; functions for the target language.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Tier 1: Pure primitives — declared in primitives.sx
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(declare-tier :pure :source "primitives.sx")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Tier 2: I/O primitives — async, side-effectful, need host context
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-io-primitive "frag"
|
||||
:params (service frag-type &key)
|
||||
:returns "string"
|
||||
:async true
|
||||
:doc "Fetch cross-service HTML fragment."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "query"
|
||||
:params (service query-name &key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Fetch data from another service via internal HTTP."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "action"
|
||||
:params (service action-name &key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Call an action on another service via internal HTTP."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "current-user"
|
||||
:params ()
|
||||
:returns "dict?"
|
||||
:async true
|
||||
:doc "Current authenticated user dict, or nil."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "htmx-request?"
|
||||
:params ()
|
||||
:returns "boolean"
|
||||
:async true
|
||||
:doc "True if current request has HX-Request header."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "service"
|
||||
:params (service-or-method &rest args &key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Call a domain service method. Two-arg: (service svc method). One-arg: (service method) uses bound handler service."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-arg"
|
||||
:params (name &rest default)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Read a query string argument from the current request."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-path"
|
||||
:params ()
|
||||
:returns "string"
|
||||
:async true
|
||||
:doc "Current request path."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "nav-tree"
|
||||
:params ()
|
||||
:returns "list"
|
||||
:async true
|
||||
:doc "Navigation tree as list of node dicts."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "get-children"
|
||||
:params (&key parent-type parent-id)
|
||||
:returns "list"
|
||||
:async true
|
||||
:doc "Fetch child entities for a parent."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "g"
|
||||
:params (key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Read a value from the Quart request-local g object."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "csrf-token"
|
||||
:params ()
|
||||
:returns "string"
|
||||
:async true
|
||||
:doc "Current CSRF token string."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "abort"
|
||||
:params (status &rest message)
|
||||
:returns "nil"
|
||||
:async true
|
||||
:doc "Raise HTTP error from SX."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "url-for"
|
||||
:params (endpoint &key)
|
||||
:returns "string"
|
||||
:async true
|
||||
:doc "Generate URL for a named endpoint."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "route-prefix"
|
||||
:params ()
|
||||
:returns "string"
|
||||
:async true
|
||||
:doc "Service URL prefix for dev/prod routing."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "root-header-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with root header values (cart-mini, auth-menu, nav-tree, etc.)."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "post-header-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with post-level header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "select-colours"
|
||||
:params ()
|
||||
:returns "string"
|
||||
:async true
|
||||
:doc "Shared select/hover CSS class string."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "account-nav-ctx"
|
||||
:params ()
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Account nav fragments, or nil."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "app-rights"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "User rights dict from g.rights."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "federation-actor-ctx"
|
||||
:params ()
|
||||
:returns "dict?"
|
||||
:async true
|
||||
:doc "Serialized ActivityPub actor dict or nil."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-view-args"
|
||||
:params (key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Read a URL view argument from the current request."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "cart-page-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with cart page header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "events-calendar-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with events calendar header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "events-day-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with events day header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "events-entry-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with events entry header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "events-slot-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with events slot header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "events-ticket-type-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with ticket type header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "market-header-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with market header data."
|
||||
:context :request)
|
||||
|
||||
;; Moved from primitives.py — these need host context (infra/config/Quart)
|
||||
|
||||
(define-io-primitive "app-url"
|
||||
:params (service &rest path)
|
||||
:returns "string"
|
||||
:async false
|
||||
:doc "Full URL for a service: (app-url \"blog\" \"/my-post/\")."
|
||||
:context :config)
|
||||
|
||||
(define-io-primitive "asset-url"
|
||||
:params (&rest path)
|
||||
:returns "string"
|
||||
:async false
|
||||
:doc "Versioned static asset URL."
|
||||
:context :config)
|
||||
|
||||
(define-io-primitive "config"
|
||||
:params (key)
|
||||
:returns "any"
|
||||
:async false
|
||||
:doc "Read a value from app-config.yaml."
|
||||
:context :config)
|
||||
|
||||
(define-io-primitive "jinja-global"
|
||||
:params (key &rest default)
|
||||
:returns "any"
|
||||
:async false
|
||||
:doc "Read a Jinja environment global."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "relations-from"
|
||||
:params (entity-type)
|
||||
:returns "list"
|
||||
:async false
|
||||
:doc "List of RelationDef dicts for an entity type."
|
||||
:context :config)
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Tier 3: Page helpers — service-scoped, registered per app
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; SX docs service
|
||||
(define-page-helper "highlight"
|
||||
:params (code lang)
|
||||
:returns "sx-source"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "primitives-data"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "special-forms-data"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "reference-data"
|
||||
:params (slug)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "attr-detail-data"
|
||||
:params (slug)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "header-detail-data"
|
||||
:params (slug)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "event-detail-data"
|
||||
:params (slug)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "read-spec-file"
|
||||
:params (filename)
|
||||
:returns "string"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "bootstrapper-data"
|
||||
:params (target)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
;; Blog service
|
||||
(define-page-helper "editor-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "editor-page-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-admin-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-data-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-preview-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-entries-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-settings-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-edit-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
;; Events service
|
||||
(define-page-helper "calendar-admin-data"
|
||||
:params (&key calendar-slug)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "day-admin-data"
|
||||
:params (&key calendar-slug year month day)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "slots-data"
|
||||
:params (&key calendar-slug)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "slot-data"
|
||||
:params (&key calendar-slug slot-id)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "entry-data"
|
||||
:params (&key calendar-slug entry-id)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "entry-admin-data"
|
||||
:params (&key calendar-slug entry-id year month day)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "ticket-types-data"
|
||||
:params (&key calendar-slug entry-id year month day)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "ticket-type-data"
|
||||
:params (&key calendar-slug entry-id ticket-type-id year month day)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "tickets-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "ticket-detail-data"
|
||||
:params (&key code)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "ticket-admin-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "markets-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
;; Market service
|
||||
(define-page-helper "all-markets-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "market")
|
||||
|
||||
(define-page-helper "page-markets-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "market")
|
||||
|
||||
(define-page-helper "page-admin-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "market")
|
||||
|
||||
(define-page-helper "market-home-data"
|
||||
:params (&key page-slug market-slug)
|
||||
:returns "dict"
|
||||
:service "market")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Boundary types — what's allowed to cross the host-SX boundary
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-boundary-types
|
||||
(list "number" "string" "boolean" "nil" "keyword"
|
||||
"list" "dict" "sx-source" "style-value"))
|
||||
134
shared/sx/ref/boundary_parser.py
Normal file
134
shared/sx/ref/boundary_parser.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Parse boundary.sx and primitives.sx to extract declared names.
|
||||
|
||||
Shared by both bootstrap_py.py and bootstrap_js.py, and used at runtime
|
||||
by the validation module.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
# Allow standalone use (from bootstrappers) or in-project imports
|
||||
try:
|
||||
from shared.sx.parser import parse_all
|
||||
from shared.sx.types import Symbol, Keyword, NIL as SX_NIL
|
||||
except ImportError:
|
||||
import sys
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", ".."))
|
||||
sys.path.insert(0, _PROJECT)
|
||||
from shared.sx.parser import parse_all
|
||||
from shared.sx.types import Symbol, Keyword, NIL as SX_NIL
|
||||
|
||||
|
||||
def _ref_dir() -> str:
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _read_file(filename: str) -> str:
|
||||
filepath = os.path.join(_ref_dir(), filename)
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _extract_keyword_arg(expr: list, key: str) -> Any:
|
||||
"""Extract :key value from a flat keyword-arg list."""
|
||||
for i, item in enumerate(expr):
|
||||
if isinstance(item, Keyword) and item.name == key and i + 1 < len(expr):
|
||||
return expr[i + 1]
|
||||
return None
|
||||
|
||||
|
||||
def parse_primitives_sx() -> frozenset[str]:
|
||||
"""Parse primitives.sx and return frozenset of declared pure primitive names."""
|
||||
by_module = parse_primitives_by_module()
|
||||
all_names: set[str] = set()
|
||||
for names in by_module.values():
|
||||
all_names.update(names)
|
||||
return frozenset(all_names)
|
||||
|
||||
|
||||
def parse_primitives_by_module() -> dict[str, frozenset[str]]:
|
||||
"""Parse primitives.sx and return primitives grouped by module.
|
||||
|
||||
Returns:
|
||||
Dict mapping module name (e.g. "core.arithmetic") to frozenset of
|
||||
primitive names declared under that module.
|
||||
"""
|
||||
source = _read_file("primitives.sx")
|
||||
exprs = parse_all(source)
|
||||
modules: dict[str, set[str]] = {}
|
||||
current_module = "_unscoped"
|
||||
|
||||
for expr in exprs:
|
||||
if not isinstance(expr, list) or len(expr) < 2:
|
||||
continue
|
||||
if not isinstance(expr[0], Symbol):
|
||||
continue
|
||||
|
||||
if expr[0].name == "define-module":
|
||||
mod_name = expr[1]
|
||||
if isinstance(mod_name, Keyword):
|
||||
current_module = mod_name.name
|
||||
elif isinstance(mod_name, str):
|
||||
current_module = mod_name
|
||||
|
||||
elif expr[0].name == "define-primitive":
|
||||
name = expr[1]
|
||||
if isinstance(name, str):
|
||||
modules.setdefault(current_module, set()).add(name)
|
||||
|
||||
return {mod: frozenset(names) for mod, names in modules.items()}
|
||||
|
||||
|
||||
def parse_boundary_sx() -> tuple[frozenset[str], dict[str, frozenset[str]]]:
|
||||
"""Parse boundary.sx and return (io_names, {service: helper_names}).
|
||||
|
||||
Returns:
|
||||
io_names: frozenset of declared I/O primitive names
|
||||
helpers: dict mapping service name to frozenset of helper names
|
||||
"""
|
||||
source = _read_file("boundary.sx")
|
||||
exprs = parse_all(source)
|
||||
io_names: set[str] = set()
|
||||
helpers: dict[str, set[str]] = {}
|
||||
|
||||
for expr in exprs:
|
||||
if not isinstance(expr, list) or not expr:
|
||||
continue
|
||||
head = expr[0]
|
||||
if not isinstance(head, Symbol):
|
||||
continue
|
||||
|
||||
if head.name == "define-io-primitive":
|
||||
name = expr[1]
|
||||
if isinstance(name, str):
|
||||
io_names.add(name)
|
||||
|
||||
elif head.name == "define-page-helper":
|
||||
name = expr[1]
|
||||
service = _extract_keyword_arg(expr, "service")
|
||||
if isinstance(name, str) and isinstance(service, str):
|
||||
helpers.setdefault(service, set()).add(name)
|
||||
|
||||
frozen_helpers = {svc: frozenset(names) for svc, names in helpers.items()}
|
||||
return frozenset(io_names), frozen_helpers
|
||||
|
||||
|
||||
def parse_boundary_types() -> frozenset[str]:
|
||||
"""Parse boundary.sx and return the declared boundary type names."""
|
||||
source = _read_file("boundary.sx")
|
||||
exprs = parse_all(source)
|
||||
for expr in exprs:
|
||||
if (isinstance(expr, list) and len(expr) >= 2
|
||||
and isinstance(expr[0], Symbol)
|
||||
and expr[0].name == "define-boundary-types"):
|
||||
type_list = expr[1]
|
||||
if isinstance(type_list, list):
|
||||
# (list "number" "string" ...)
|
||||
return frozenset(
|
||||
item for item in type_list
|
||||
if isinstance(item, str)
|
||||
)
|
||||
return frozenset()
|
||||
245
shared/sx/ref/callcc.sx
Normal file
245
shared/sx/ref/callcc.sx
Normal file
@@ -0,0 +1,245 @@
|
||||
;; ==========================================================================
|
||||
;; callcc.sx — Full first-class continuations (call/cc)
|
||||
;;
|
||||
;; OPTIONAL EXTENSION — not required by the core evaluator.
|
||||
;; Bootstrappers include this only when the target supports it naturally.
|
||||
;;
|
||||
;; Full call/cc (call-with-current-continuation) captures the ENTIRE
|
||||
;; remaining computation as a first-class function — not just up to a
|
||||
;; delimiter, but all the way to the top level. Invoking a continuation
|
||||
;; captured by call/cc abandons the current computation entirely and
|
||||
;; resumes from where the continuation was captured.
|
||||
;;
|
||||
;; This is strictly more powerful than delimited continuations (shift/reset)
|
||||
;; but harder to implement in targets that don't support it natively.
|
||||
;; Recommended only for targets where it's natural:
|
||||
;; - Scheme/Racket (native call/cc)
|
||||
;; - Haskell (ContT monad transformer)
|
||||
;;
|
||||
;; For targets like Python, JavaScript, and Rust, delimited continuations
|
||||
;; (continuations.sx) are more practical and cover the same use cases
|
||||
;; without requiring a global CPS transform.
|
||||
;;
|
||||
;; One new special form:
|
||||
;; (call/cc f) — call f with the current continuation
|
||||
;;
|
||||
;; One new type:
|
||||
;; continuation — same type as in continuations.sx
|
||||
;;
|
||||
;; If both extensions are loaded, the continuation type is shared.
|
||||
;; Delimited and undelimited continuations are the same type —
|
||||
;; the difference is in how they are captured, not what they are.
|
||||
;;
|
||||
;; Platform requirements:
|
||||
;; (make-continuation fn) — wrap a function as a continuation value
|
||||
;; (continuation? x) — type predicate
|
||||
;; (type-of continuation) → "continuation"
|
||||
;; (call-with-cc f env) — target-specific call/cc implementation
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. Semantics
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; (call/cc f)
|
||||
;;
|
||||
;; Evaluates f (which must be a function of one argument), passing it the
|
||||
;; current continuation as a continuation value. f can:
|
||||
;;
|
||||
;; a) Return normally — call/cc returns whatever f returns
|
||||
;; b) Invoke the continuation — abandons f's computation, call/cc
|
||||
;; "returns" the value passed to the continuation
|
||||
;; c) Store the continuation — invoke it later, possibly multiple times
|
||||
;;
|
||||
;; Key difference from shift/reset: invoking an undelimited continuation
|
||||
;; NEVER RETURNS to the caller. It abandons the current computation and
|
||||
;; jumps back to where call/cc was originally called.
|
||||
;;
|
||||
;; ;; Delimited (shift/reset) — k returns a value:
|
||||
;; (reset (+ 1 (shift k (+ (k 10) (k 20)))))
|
||||
;; ;; (k 10) → 11, returns to the (+ ... (k 20)) expression
|
||||
;; ;; (k 20) → 21, returns to the (+ 11 ...) expression
|
||||
;; ;; result: 32
|
||||
;;
|
||||
;; ;; Undelimited (call/cc) — k does NOT return:
|
||||
;; (+ 1 (call/cc (fn (k)
|
||||
;; (+ (k 10) (k 20)))))
|
||||
;; ;; (k 10) abandons (+ (k 10) (k 20)) entirely
|
||||
;; ;; jumps back to (+ 1 _) with 10
|
||||
;; ;; result: 11
|
||||
;; ;; (k 20) is never reached
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. call/cc — call with current continuation
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define sf-callcc
|
||||
(fn (args env)
|
||||
;; Single argument: a function to call with the current continuation.
|
||||
(let ((f-expr (first args))
|
||||
(f (trampoline (eval-expr f-expr env))))
|
||||
(call-with-cc f env))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. Derived forms
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; With call/cc available, several patterns become expressible:
|
||||
;;
|
||||
;; --- Early return ---
|
||||
;;
|
||||
;; (define find-first
|
||||
;; (fn (pred items)
|
||||
;; (call/cc (fn (return)
|
||||
;; (for-each (fn (item)
|
||||
;; (when (pred item)
|
||||
;; (return item)))
|
||||
;; items)
|
||||
;; nil))))
|
||||
;;
|
||||
;; --- Exception-like flow ---
|
||||
;;
|
||||
;; (define try-catch
|
||||
;; (fn (body handler)
|
||||
;; (call/cc (fn (throw)
|
||||
;; (body throw)))))
|
||||
;;
|
||||
;; (try-catch
|
||||
;; (fn (throw)
|
||||
;; (let ((result (dangerous-operation)))
|
||||
;; (when (not result) (throw "failed"))
|
||||
;; result))
|
||||
;; (fn (error) (str "Caught: " error)))
|
||||
;;
|
||||
;; --- Coroutines ---
|
||||
;;
|
||||
;; Two call/cc captures that alternate control between two
|
||||
;; computations. Each captures its own continuation, then invokes
|
||||
;; the other's. This gives cooperative multitasking without threads.
|
||||
;;
|
||||
;; --- Undo ---
|
||||
;;
|
||||
;; (define with-undo
|
||||
;; (fn (action)
|
||||
;; (call/cc (fn (restore)
|
||||
;; (action)
|
||||
;; restore))))
|
||||
;;
|
||||
;; ;; (let ((undo (with-undo (fn () (delete-item 42)))))
|
||||
;; ;; (undo "anything")) → item 42 is back
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. Interaction with delimited continuations
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; If both callcc.sx and continuations.sx are loaded:
|
||||
;;
|
||||
;; - The continuation type is shared. (continuation? k) returns true
|
||||
;; for both delimited and undelimited continuations.
|
||||
;;
|
||||
;; - shift inside a call/cc body captures up to the nearest reset,
|
||||
;; not up to the call/cc. The two mechanisms compose.
|
||||
;;
|
||||
;; - call/cc inside a reset body captures the entire continuation
|
||||
;; (past the reset). This is the expected behavior — call/cc is
|
||||
;; undelimited by definition.
|
||||
;;
|
||||
;; - A delimited continuation (from shift) returns a value when invoked.
|
||||
;; An undelimited continuation (from call/cc) does not return.
|
||||
;; Both are callable with the same syntax: (k value).
|
||||
;; The caller cannot distinguish them by type — only by behavior.
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. Interaction with I/O and state
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Full call/cc has well-known interactions with side effects:
|
||||
;;
|
||||
;; Re-entry:
|
||||
;; Invoking a saved continuation re-enters a completed computation.
|
||||
;; If that computation mutated state (set!, I/O writes), the mutations
|
||||
;; are NOT undone. The continuation resumes in the current state,
|
||||
;; not the state at the time of capture.
|
||||
;;
|
||||
;; I/O:
|
||||
;; Same as delimited continuations — I/O executes at invocation time.
|
||||
;; A continuation containing (current-user) will call current-user
|
||||
;; when invoked, in whatever request context exists then.
|
||||
;;
|
||||
;; Dynamic extent:
|
||||
;; call/cc captures the continuation, not the dynamic environment.
|
||||
;; Host-language context (Python's Quart request context, JavaScript's
|
||||
;; async context) may not be valid when a saved continuation is invoked
|
||||
;; later. Typed targets can enforce this; dynamic targets fail at runtime.
|
||||
;;
|
||||
;; Recommendation:
|
||||
;; Use call/cc for pure control flow (early return, coroutines,
|
||||
;; backtracking). Use delimited continuations for effectful patterns
|
||||
;; (suspense, cooperative scheduling) where the delimiter provides
|
||||
;; a natural boundary.
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. Implementation notes per target
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Scheme / Racket:
|
||||
;; Native call/cc. Zero implementation effort.
|
||||
;;
|
||||
;; Haskell:
|
||||
;; ContT monad transformer. The evaluator runs in ContT, and call/cc
|
||||
;; is callCC from Control.Monad.Cont. Natural and type-safe.
|
||||
;;
|
||||
;; Python:
|
||||
;; Requires full CPS transform of the evaluator, or greenlet-based
|
||||
;; stack capture. Significantly more invasive than delimited
|
||||
;; continuations. NOT RECOMMENDED — use continuations.sx instead.
|
||||
;;
|
||||
;; JavaScript:
|
||||
;; Requires full CPS transform. Cannot be implemented with generators
|
||||
;; alone (generators only support delimited yield, not full escape).
|
||||
;; NOT RECOMMENDED — use continuations.sx instead.
|
||||
;;
|
||||
;; Rust:
|
||||
;; Full CPS transform at compile time. Possible but adds significant
|
||||
;; complexity. Delimited continuations are more natural (enum-based).
|
||||
;; Consider only if the target genuinely needs undelimited escape.
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 7. Platform interface — what each target must provide
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; (call-with-cc f env)
|
||||
;; Call f with the current continuation. f is a function of one
|
||||
;; argument (the continuation). If f returns normally, call-with-cc
|
||||
;; returns f's result. If f invokes the continuation, the computation
|
||||
;; jumps to the call-with-cc call site with the provided value.
|
||||
;;
|
||||
;; (make-continuation fn)
|
||||
;; Wrap a native function as a continuation value.
|
||||
;; (Shared with continuations.sx if both are loaded.)
|
||||
;;
|
||||
;; (continuation? x)
|
||||
;; Type predicate.
|
||||
;; (Shared with continuations.sx if both are loaded.)
|
||||
;;
|
||||
;; Continuations must be callable via the standard function-call
|
||||
;; dispatch in eval-list (same path as lambda calls).
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
248
shared/sx/ref/continuations.sx
Normal file
248
shared/sx/ref/continuations.sx
Normal file
@@ -0,0 +1,248 @@
|
||||
;; ==========================================================================
|
||||
;; continuations.sx — Delimited continuations (shift/reset)
|
||||
;;
|
||||
;; OPTIONAL EXTENSION — not required by the core evaluator.
|
||||
;; Bootstrappers include this only when the target requests it.
|
||||
;;
|
||||
;; Delimited continuations capture "the rest of the computation up to
|
||||
;; a delimiter." They are strictly less powerful than full call/cc but
|
||||
;; cover the practical use cases: suspendable rendering, cooperative
|
||||
;; scheduling, linear async flows, wizard forms, and undo.
|
||||
;;
|
||||
;; Two new special forms:
|
||||
;; (reset body) — establish a delimiter
|
||||
;; (shift k body) — capture the continuation to the nearest reset
|
||||
;;
|
||||
;; One new type:
|
||||
;; continuation — a captured delimited continuation, callable
|
||||
;;
|
||||
;; The captured continuation is a function of one argument. Invoking it
|
||||
;; provides the value that the shift expression "returns" within the
|
||||
;; delimited context, then completes the rest of the reset body.
|
||||
;;
|
||||
;; Continuations are composable — invoking a continuation returns a
|
||||
;; value (the result of the reset body), which can be used normally.
|
||||
;; This is the key difference from undelimited call/cc, where invoking
|
||||
;; a continuation never returns.
|
||||
;;
|
||||
;; Platform requirements:
|
||||
;; (make-continuation fn) — wrap a function as a continuation value
|
||||
;; (continuation? x) — type predicate
|
||||
;; (type-of continuation) → "continuation"
|
||||
;; Continuations are callable (same dispatch as lambda).
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. Type
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; A continuation is a callable value of one argument.
|
||||
;;
|
||||
;; (continuation? k) → true if k is a captured continuation
|
||||
;; (type-of k) → "continuation"
|
||||
;; (k value) → invoke: resume the captured computation with value
|
||||
;;
|
||||
;; Continuations are first-class: they can be stored in variables, passed
|
||||
;; as arguments, returned from functions, and put in data structures.
|
||||
;;
|
||||
;; Invoking a delimited continuation RETURNS a value — the result of the
|
||||
;; reset body. This makes them composable:
|
||||
;;
|
||||
;; (+ 1 (reset (+ 10 (shift k (k 5)))))
|
||||
;; ;; k is "add 10 to _ and return from reset"
|
||||
;; ;; (k 5) → 15, which is returned from reset
|
||||
;; ;; (+ 1 15) → 16
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. reset — establish a continuation delimiter
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; (reset body)
|
||||
;;
|
||||
;; Evaluates body in the current environment. If no shift occurs during
|
||||
;; evaluation of body, reset simply returns the value of body.
|
||||
;;
|
||||
;; If shift occurs, reset is the boundary — the continuation captured by
|
||||
;; shift extends from the shift point back to (and including) this reset.
|
||||
;;
|
||||
;; reset is the "prompt" — it marks where the continuation stops.
|
||||
;;
|
||||
;; Semantics:
|
||||
;; (reset expr) where expr contains no shift
|
||||
;; → (eval expr env) ;; just evaluates normally
|
||||
;;
|
||||
;; (reset ... (shift k body) ...)
|
||||
;; → captures continuation, evaluates shift's body
|
||||
;; → the result of the shift body is the result of the reset
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define sf-reset
|
||||
(fn (args env)
|
||||
;; Single argument: the body expression.
|
||||
;; Install a continuation delimiter, then evaluate body.
|
||||
;; The implementation is target-specific:
|
||||
;; - In Scheme: native reset/shift
|
||||
;; - In Haskell: Control.Monad.CC or delimited continuations library
|
||||
;; - In Python: coroutine/generator-based (see implementation notes)
|
||||
;; - In JavaScript: generator-based or CPS transform
|
||||
;; - In Rust: CPS transform at compile time
|
||||
(let ((body (first args)))
|
||||
(eval-with-delimiter body env))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. shift — capture the continuation to the nearest reset
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; (shift k body)
|
||||
;;
|
||||
;; Captures the continuation from this point back to the nearest enclosing
|
||||
;; reset and binds it to k. Then evaluates body in the current environment
|
||||
;; extended with k. The result of body becomes the result of the enclosing
|
||||
;; reset.
|
||||
;;
|
||||
;; k is a function of one argument. Calling (k value) resumes the captured
|
||||
;; computation with value standing in for the shift expression.
|
||||
;;
|
||||
;; The continuation k is composable: (k value) returns a value (the result
|
||||
;; of the reset body when resumed with value). This means k can be called
|
||||
;; multiple times, and its result can be used in further computation.
|
||||
;;
|
||||
;; Examples:
|
||||
;;
|
||||
;; ;; Basic: shift provides a value to the surrounding computation
|
||||
;; (reset (+ 1 (shift k (k 41))))
|
||||
;; ;; k = "add 1 to _", (k 41) → 42, reset returns 42
|
||||
;;
|
||||
;; ;; Abort: shift can discard the continuation entirely
|
||||
;; (reset (+ 1 (shift k "aborted")))
|
||||
;; ;; k is never called, reset returns "aborted"
|
||||
;;
|
||||
;; ;; Multiple invocations: k can be called more than once
|
||||
;; (reset (+ 1 (shift k (list (k 10) (k 20)))))
|
||||
;; ;; (k 10) → 11, (k 20) → 21, reset returns (11 21)
|
||||
;;
|
||||
;; ;; Stored for later: k can be saved and invoked outside reset
|
||||
;; (define saved nil)
|
||||
;; (reset (+ 1 (shift k (set! saved k) 0)))
|
||||
;; ;; reset returns 0, saved holds the continuation
|
||||
;; (saved 99) ;; → 100
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define sf-shift
|
||||
(fn (args env)
|
||||
;; Two arguments: the continuation variable name, and the body.
|
||||
(let ((k-name (symbol-name (first args)))
|
||||
(body (second args)))
|
||||
;; Capture the current continuation up to the nearest reset.
|
||||
;; Bind it to k-name in the environment, then evaluate body.
|
||||
;; The result of body is returned to the reset.
|
||||
(capture-continuation k-name body env))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. Interaction with other features
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; TCO (trampoline):
|
||||
;; Continuations interact naturally with the trampoline. A shift inside
|
||||
;; a tail-call position captures the continuation including the pending
|
||||
;; return. The trampoline resolves thunks before the continuation is
|
||||
;; delimited.
|
||||
;;
|
||||
;; Macros:
|
||||
;; shift/reset are special forms, not macros. Macros expand before
|
||||
;; evaluation, so shift inside a macro-expanded form works correctly —
|
||||
;; it captures the continuation of the expanded code.
|
||||
;;
|
||||
;; Components:
|
||||
;; shift inside a component body captures the continuation of that
|
||||
;; component's render. The enclosing reset determines the delimiter.
|
||||
;; This is the foundation for suspendable rendering — a component can
|
||||
;; shift to suspend, and the server resumes it when data arrives.
|
||||
;;
|
||||
;; I/O primitives:
|
||||
;; I/O primitives execute at invocation time, in whatever context
|
||||
;; exists then. A continuation that captures a computation containing
|
||||
;; I/O will re-execute that I/O when invoked. If the I/O requires
|
||||
;; request context (e.g. current-user), invoking the continuation
|
||||
;; outside a request will fail — same as calling the I/O directly.
|
||||
;; This is consistent, not a restriction.
|
||||
;;
|
||||
;; In typed targets (Haskell, Rust), the type system can enforce that
|
||||
;; continuations containing I/O are only invoked in appropriate contexts.
|
||||
;; In dynamic targets (Python, JS), it fails at runtime.
|
||||
;;
|
||||
;; Lexical scope:
|
||||
;; Continuations capture the dynamic extent (what happens next) but
|
||||
;; close over the lexical environment at the point of capture. Variable
|
||||
;; bindings in the continuation refer to the same environment — mutations
|
||||
;; via set! are visible.
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. Implementation notes per target
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; The bootstrapper emits target-specific continuation machinery.
|
||||
;; The spec defines semantics; each target chooses representation.
|
||||
;;
|
||||
;; Scheme / Racket:
|
||||
;; Native shift/reset. No transformation needed. The bootstrapper
|
||||
;; emits (require racket/control) or equivalent.
|
||||
;;
|
||||
;; Haskell:
|
||||
;; Control.Monad.CC provides delimited continuations in the CC monad.
|
||||
;; Alternatively, the evaluator can be CPS-transformed at compile time.
|
||||
;; Continuations become first-class functions naturally.
|
||||
;;
|
||||
;; Python:
|
||||
;; Generator-based: reset creates a generator, shift yields from it.
|
||||
;; The trampoline loop drives the generator. Each yield is a shift
|
||||
;; point, and send() provides the resume value.
|
||||
;; Alternative: greenlet-based (stackful coroutines).
|
||||
;;
|
||||
;; JavaScript:
|
||||
;; Generator-based (function* / yield). Similar to Python.
|
||||
;; Alternative: CPS transform at bootstrap time — the bootstrapper
|
||||
;; rewrites the evaluator into continuation-passing style, making
|
||||
;; shift/reset explicit function arguments.
|
||||
;;
|
||||
;; Rust:
|
||||
;; CPS transform at compile time. Continuations become enum variants
|
||||
;; or boxed closures. The type system ensures continuations are used
|
||||
;; linearly if desired (affine types via ownership).
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. Platform interface — what each target must provide
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; (eval-with-delimiter expr env)
|
||||
;; Install a reset delimiter, evaluate expr, return result.
|
||||
;; If expr calls shift, the continuation is captured up to here.
|
||||
;;
|
||||
;; (capture-continuation k-name body env)
|
||||
;; Capture the current continuation up to the nearest delimiter.
|
||||
;; Bind it to k-name in env, evaluate body, return result to delimiter.
|
||||
;;
|
||||
;; (make-continuation fn)
|
||||
;; Wrap a native function as a continuation value.
|
||||
;;
|
||||
;; (continuation? x)
|
||||
;; Type predicate.
|
||||
;;
|
||||
;; Continuations must be callable via the standard function-call
|
||||
;; dispatch in eval-list (same path as lambda calls).
|
||||
;;
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -185,7 +185,10 @@
|
||||
|
||||
(cond
|
||||
(nil? variant)
|
||||
(append! base-decls decls)
|
||||
(if (is-child-selector-atom? base)
|
||||
(append! pseudo-rules
|
||||
(list ">:not(:first-child)" decls))
|
||||
(append! base-decls decls))
|
||||
|
||||
(dict-has? _responsive-breakpoints variant)
|
||||
(append! media-rules
|
||||
@@ -222,23 +225,23 @@
|
||||
(fn (mr)
|
||||
(set! hash-input
|
||||
(str hash-input "@" (first mr) "{" (nth mr 1) "}")))
|
||||
(chunk-every media-rules 2))
|
||||
media-rules)
|
||||
(for-each
|
||||
(fn (pr)
|
||||
(set! hash-input
|
||||
(str hash-input (first pr) "{" (nth pr 1) "}")))
|
||||
(chunk-every pseudo-rules 2))
|
||||
pseudo-rules)
|
||||
(for-each
|
||||
(fn (kf)
|
||||
(set! hash-input (str hash-input (nth kf 1))))
|
||||
(chunk-every kf-needed 2))
|
||||
kf-needed)
|
||||
|
||||
(let ((cn (str "sx-" (hash-style hash-input)))
|
||||
(sv (make-style-value cn
|
||||
(join ";" base-decls)
|
||||
(chunk-every media-rules 2)
|
||||
(chunk-every pseudo-rules 2)
|
||||
(chunk-every kf-needed 2))))
|
||||
media-rules
|
||||
pseudo-rules
|
||||
kf-needed)))
|
||||
(dict-set! _style-cache key sv)
|
||||
;; Inject CSS rules
|
||||
(inject-style-value sv atoms)
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
(= name "or") (sf-or args env)
|
||||
(= name "let") (sf-let args env)
|
||||
(= name "let*") (sf-let args env)
|
||||
(= name "letrec") (sf-letrec args env)
|
||||
(= name "lambda") (sf-lambda args env)
|
||||
(= name "fn") (sf-lambda args env)
|
||||
(= name "define") (sf-define args env)
|
||||
@@ -143,13 +144,19 @@
|
||||
(= name "defmacro") (sf-defmacro args env)
|
||||
(= name "defstyle") (sf-defstyle args env)
|
||||
(= name "defkeyframes") (sf-defkeyframes args env)
|
||||
(= name "defhandler") (sf-define args env)
|
||||
(= name "defhandler") (sf-defhandler args env)
|
||||
(= name "defpage") (sf-defpage args env)
|
||||
(= name "defquery") (sf-defquery args env)
|
||||
(= name "defaction") (sf-defaction args env)
|
||||
(= name "begin") (sf-begin args env)
|
||||
(= name "do") (sf-begin args env)
|
||||
(= name "quote") (sf-quote args env)
|
||||
(= name "quasiquote") (sf-quasiquote args env)
|
||||
(= name "->") (sf-thread-first args env)
|
||||
(= name "set!") (sf-set! args env)
|
||||
(= name "reset") (sf-reset args env)
|
||||
(= name "shift") (sf-shift args env)
|
||||
(= name "dynamic-wind") (sf-dynamic-wind args env)
|
||||
|
||||
;; Higher-order forms
|
||||
(= name "map") (ho-map args env)
|
||||
@@ -381,36 +388,83 @@
|
||||
|
||||
(define sf-let
|
||||
(fn (args env)
|
||||
(let ((bindings (first args))
|
||||
(body (rest args))
|
||||
(local (env-extend env)))
|
||||
;; Parse bindings — support both ((name val) ...) and (name val name val ...)
|
||||
;; Detect named let: (let name ((x 0) ...) body)
|
||||
;; If first arg is a symbol, delegate to sf-named-let.
|
||||
(if (= (type-of (first args)) "symbol")
|
||||
(sf-named-let args env)
|
||||
(let ((bindings (first args))
|
||||
(body (rest args))
|
||||
(local (env-extend env)))
|
||||
;; Parse bindings — support both ((name val) ...) and (name val name val ...)
|
||||
(if (and (= (type-of (first bindings)) "list")
|
||||
(= (len (first bindings)) 2))
|
||||
;; Scheme-style
|
||||
(for-each
|
||||
(fn (binding)
|
||||
(let ((vname (if (= (type-of (first binding)) "symbol")
|
||||
(symbol-name (first binding))
|
||||
(first binding))))
|
||||
(env-set! local vname (trampoline (eval-expr (nth binding 1) local)))))
|
||||
bindings)
|
||||
;; Clojure-style
|
||||
(let ((i 0))
|
||||
(reduce
|
||||
(fn (acc pair-idx)
|
||||
(let ((vname (if (= (type-of (nth bindings (* pair-idx 2))) "symbol")
|
||||
(symbol-name (nth bindings (* pair-idx 2)))
|
||||
(nth bindings (* pair-idx 2))))
|
||||
(val-expr (nth bindings (inc (* pair-idx 2)))))
|
||||
(env-set! local vname (trampoline (eval-expr val-expr local)))))
|
||||
nil
|
||||
(range 0 (/ (len bindings) 2)))))
|
||||
;; Evaluate body — last expression in tail position
|
||||
(for-each
|
||||
(fn (e) (trampoline (eval-expr e local)))
|
||||
(slice body 0 (dec (len body))))
|
||||
(make-thunk (last body) local)))))
|
||||
|
||||
|
||||
;; Named let: (let name ((x 0) (y 1)) body...)
|
||||
;; Desugars to a self-recursive lambda called with initial values.
|
||||
;; The loop name is bound in the body so recursive calls produce TCO thunks.
|
||||
(define sf-named-let
|
||||
(fn (args env)
|
||||
(let ((loop-name (symbol-name (first args)))
|
||||
(bindings (nth args 1))
|
||||
(body (slice args 2))
|
||||
(params (list))
|
||||
(inits (list)))
|
||||
;; Extract param names and init expressions
|
||||
(if (and (= (type-of (first bindings)) "list")
|
||||
(= (len (first bindings)) 2))
|
||||
;; Scheme-style
|
||||
;; Scheme-style: ((x 0) (y 1))
|
||||
(for-each
|
||||
(fn (binding)
|
||||
(let ((vname (if (= (type-of (first binding)) "symbol")
|
||||
(symbol-name (first binding))
|
||||
(first binding))))
|
||||
(env-set! local vname (trampoline (eval-expr (nth binding 1) local)))))
|
||||
(append! params (if (= (type-of (first binding)) "symbol")
|
||||
(symbol-name (first binding))
|
||||
(first binding)))
|
||||
(append! inits (nth binding 1)))
|
||||
bindings)
|
||||
;; Clojure-style
|
||||
(let ((i 0))
|
||||
(reduce
|
||||
(fn (acc pair-idx)
|
||||
(let ((vname (if (= (type-of (nth bindings (* pair-idx 2))) "symbol")
|
||||
(symbol-name (nth bindings (* pair-idx 2)))
|
||||
(nth bindings (* pair-idx 2))))
|
||||
(val-expr (nth bindings (inc (* pair-idx 2)))))
|
||||
(env-set! local vname (trampoline (eval-expr val-expr local)))))
|
||||
nil
|
||||
(range 0 (/ (len bindings) 2)))))
|
||||
;; Evaluate body — last expression in tail position
|
||||
(for-each
|
||||
(fn (e) (trampoline (eval-expr e local)))
|
||||
(slice body 0 (dec (len body))))
|
||||
(make-thunk (last body) local))))
|
||||
;; Clojure-style: (x 0 y 1)
|
||||
(reduce
|
||||
(fn (acc pair-idx)
|
||||
(do
|
||||
(append! params (if (= (type-of (nth bindings (* pair-idx 2))) "symbol")
|
||||
(symbol-name (nth bindings (* pair-idx 2)))
|
||||
(nth bindings (* pair-idx 2))))
|
||||
(append! inits (nth bindings (inc (* pair-idx 2))))))
|
||||
nil
|
||||
(range 0 (/ (len bindings) 2))))
|
||||
;; Build loop body (wrap in begin if multiple exprs)
|
||||
(let ((loop-body (if (= (len body) 1) (first body)
|
||||
(cons (make-symbol "begin") body)))
|
||||
(loop-fn (make-lambda params loop-body env)))
|
||||
;; Self-reference: loop can call itself by name
|
||||
(set-lambda-name! loop-fn loop-name)
|
||||
(env-set! (lambda-closure loop-fn) loop-name loop-fn)
|
||||
;; Evaluate initial values in enclosing env, then call
|
||||
(let ((init-vals (map (fn (e) (trampoline (eval-expr e env))) inits)))
|
||||
(call-lambda loop-fn init-vals env))))))
|
||||
|
||||
|
||||
(define sf-lambda
|
||||
@@ -602,6 +656,109 @@
|
||||
value)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6c. letrec — mutually recursive local bindings
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; (letrec ((even? (fn (n) (if (= n 0) true (odd? (- n 1)))))
|
||||
;; (odd? (fn (n) (if (= n 0) false (even? (- n 1))))))
|
||||
;; (even? 10))
|
||||
;;
|
||||
;; All bindings are first set to nil in the local env, then all values
|
||||
;; are evaluated (so they can see each other's names), then lambda
|
||||
;; closures are patched to include the final bindings.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define sf-letrec
|
||||
(fn (args env)
|
||||
(let ((bindings (first args))
|
||||
(body (rest args))
|
||||
(local (env-extend env))
|
||||
(names (list))
|
||||
(val-exprs (list)))
|
||||
;; First pass: bind all names to nil
|
||||
(if (and (= (type-of (first bindings)) "list")
|
||||
(= (len (first bindings)) 2))
|
||||
;; Scheme-style
|
||||
(for-each
|
||||
(fn (binding)
|
||||
(let ((vname (if (= (type-of (first binding)) "symbol")
|
||||
(symbol-name (first binding))
|
||||
(first binding))))
|
||||
(append! names vname)
|
||||
(append! val-exprs (nth binding 1))
|
||||
(env-set! local vname nil)))
|
||||
bindings)
|
||||
;; Clojure-style
|
||||
(reduce
|
||||
(fn (acc pair-idx)
|
||||
(let ((vname (if (= (type-of (nth bindings (* pair-idx 2))) "symbol")
|
||||
(symbol-name (nth bindings (* pair-idx 2)))
|
||||
(nth bindings (* pair-idx 2))))
|
||||
(val-expr (nth bindings (inc (* pair-idx 2)))))
|
||||
(append! names vname)
|
||||
(append! val-exprs val-expr)
|
||||
(env-set! local vname nil)))
|
||||
nil
|
||||
(range 0 (/ (len bindings) 2))))
|
||||
;; Second pass: evaluate values (they can see each other's names)
|
||||
(let ((values (map (fn (e) (trampoline (eval-expr e local))) val-exprs)))
|
||||
;; Bind final values
|
||||
(for-each
|
||||
(fn (pair) (env-set! local (first pair) (nth pair 1)))
|
||||
(zip names values))
|
||||
;; Patch lambda closures so they see the final bindings
|
||||
(for-each
|
||||
(fn (val)
|
||||
(when (lambda? val)
|
||||
(for-each
|
||||
(fn (n) (env-set! (lambda-closure val) n (env-get local n)))
|
||||
names)))
|
||||
values))
|
||||
;; Evaluate body
|
||||
(for-each
|
||||
(fn (e) (trampoline (eval-expr e local)))
|
||||
(slice body 0 (dec (len body))))
|
||||
(make-thunk (last body) local))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6d. dynamic-wind — entry/exit guards
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; (dynamic-wind before-thunk body-thunk after-thunk)
|
||||
;;
|
||||
;; All three are zero-argument functions (thunks):
|
||||
;; 1. Call before-thunk
|
||||
;; 2. Call body-thunk, capture result
|
||||
;; 3. Call after-thunk (always, even on error)
|
||||
;; 4. Return body result
|
||||
;;
|
||||
;; The wind stack is maintained so that when continuations jump across
|
||||
;; dynamic-wind boundaries, the correct before/after thunks fire.
|
||||
;; Without active continuations, this is equivalent to try/finally.
|
||||
;;
|
||||
;; Platform requirements:
|
||||
;; (push-wind! before after) — push wind record onto stack
|
||||
;; (pop-wind!) — pop wind record from stack
|
||||
;; (call-thunk f env) — call a zero-arg function
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define sf-dynamic-wind
|
||||
(fn (args env)
|
||||
(let ((before (trampoline (eval-expr (first args) env)))
|
||||
(body (trampoline (eval-expr (nth args 1) env)))
|
||||
(after (trampoline (eval-expr (nth args 2) env))))
|
||||
;; Call entry thunk
|
||||
(call-thunk before env)
|
||||
;; Push wind record, run body, pop, call exit
|
||||
(push-wind! before after)
|
||||
(let ((result (call-thunk body env)))
|
||||
(pop-wind!)
|
||||
(call-thunk after env)
|
||||
result))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6b. Macro expansion
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -629,18 +786,26 @@
|
||||
;; 7. Higher-order forms
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; call-fn: unified caller for HO forms — handles both Lambda and native callable
|
||||
(define call-fn
|
||||
(fn (f args env)
|
||||
(cond
|
||||
(lambda? f) (trampoline (call-lambda f args env))
|
||||
(callable? f) (apply f args)
|
||||
:else (error (str "Not callable in HO form: " (inspect f))))))
|
||||
|
||||
(define ho-map
|
||||
(fn (args env)
|
||||
(let ((f (trampoline (eval-expr (first args) env)))
|
||||
(coll (trampoline (eval-expr (nth args 1) env))))
|
||||
(map (fn (item) (trampoline (call-lambda f (list item) env))) coll))))
|
||||
(map (fn (item) (call-fn f (list item) env)) coll))))
|
||||
|
||||
(define ho-map-indexed
|
||||
(fn (args env)
|
||||
(let ((f (trampoline (eval-expr (first args) env)))
|
||||
(coll (trampoline (eval-expr (nth args 1) env))))
|
||||
(map-indexed
|
||||
(fn (i item) (trampoline (call-lambda f (list i item) env)))
|
||||
(fn (i item) (call-fn f (list i item) env))
|
||||
coll))))
|
||||
|
||||
(define ho-filter
|
||||
@@ -648,7 +813,7 @@
|
||||
(let ((f (trampoline (eval-expr (first args) env)))
|
||||
(coll (trampoline (eval-expr (nth args 1) env))))
|
||||
(filter
|
||||
(fn (item) (trampoline (call-lambda f (list item) env)))
|
||||
(fn (item) (call-fn f (list item) env))
|
||||
coll))))
|
||||
|
||||
(define ho-reduce
|
||||
@@ -657,7 +822,7 @@
|
||||
(init (trampoline (eval-expr (nth args 1) env)))
|
||||
(coll (trampoline (eval-expr (nth args 2) env))))
|
||||
(reduce
|
||||
(fn (acc item) (trampoline (call-lambda f (list acc item) env)))
|
||||
(fn (acc item) (call-fn f (list acc item) env))
|
||||
init
|
||||
coll))))
|
||||
|
||||
@@ -666,7 +831,7 @@
|
||||
(let ((f (trampoline (eval-expr (first args) env)))
|
||||
(coll (trampoline (eval-expr (nth args 1) env))))
|
||||
(some
|
||||
(fn (item) (trampoline (call-lambda f (list item) env)))
|
||||
(fn (item) (call-fn f (list item) env))
|
||||
coll))))
|
||||
|
||||
(define ho-every
|
||||
@@ -674,7 +839,7 @@
|
||||
(let ((f (trampoline (eval-expr (first args) env)))
|
||||
(coll (trampoline (eval-expr (nth args 1) env))))
|
||||
(every?
|
||||
(fn (item) (trampoline (call-lambda f (list item) env)))
|
||||
(fn (item) (call-fn f (list item) env))
|
||||
coll))))
|
||||
|
||||
|
||||
@@ -683,7 +848,7 @@
|
||||
(let ((f (trampoline (eval-expr (first args) env)))
|
||||
(coll (trampoline (eval-expr (nth args 1) env))))
|
||||
(for-each
|
||||
(fn (item) (trampoline (call-lambda f (list item) env)))
|
||||
(fn (item) (call-fn f (list item) env))
|
||||
coll))))
|
||||
|
||||
|
||||
@@ -765,6 +930,12 @@
|
||||
;; (apply f args) → call f with args list
|
||||
;; (zip lists...) → list of tuples
|
||||
;;
|
||||
;;
|
||||
;; CSSX (style system):
|
||||
;; (build-keyframes name steps env) → StyleValue (platform builds @keyframes)
|
||||
;;
|
||||
;; Dynamic wind (for dynamic-wind):
|
||||
;; (push-wind! before after) → void (push wind record onto stack)
|
||||
;; (pop-wind!) → void (pop wind record from stack)
|
||||
;; (call-thunk f env) → value (call a zero-arg function)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
118
shared/sx/ref/forms.sx
Normal file
118
shared/sx/ref/forms.sx
Normal file
@@ -0,0 +1,118 @@
|
||||
;; ==========================================================================
|
||||
;; forms.sx — Server-side definition forms
|
||||
;;
|
||||
;; Platform-specific special forms for declaring handlers, pages, queries,
|
||||
;; and actions. These parse &key parameter lists and create typed definition
|
||||
;; objects that the server runtime uses for routing and execution.
|
||||
;;
|
||||
;; When SX moves to isomorphic execution, these forms will have different
|
||||
;; platform bindings on client vs server. The spec stays the same — only
|
||||
;; the constructors (make-handler-def, make-query-def, etc.) change.
|
||||
;;
|
||||
;; Platform functions required:
|
||||
;; make-handler-def(name, params, body, env) → HandlerDef
|
||||
;; make-query-def(name, params, doc, body, env) → QueryDef
|
||||
;; make-action-def(name, params, doc, body, env) → ActionDef
|
||||
;; make-page-def(name, slots, env) → PageDef
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Shared: parse (&key param1 param2 ...) → list of param name strings
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define parse-key-params
|
||||
(fn (params-expr)
|
||||
(let ((params (list))
|
||||
(in-key false))
|
||||
(for-each
|
||||
(fn (p)
|
||||
(when (= (type-of p) "symbol")
|
||||
(let ((name (symbol-name p)))
|
||||
(cond
|
||||
(= name "&key") (set! in-key true)
|
||||
in-key (append! params name)
|
||||
:else (append! params name)))))
|
||||
params-expr)
|
||||
params)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; defhandler — (defhandler name (&key param...) body)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define sf-defhandler
|
||||
(fn (args env)
|
||||
(let ((name-sym (first args))
|
||||
(params-raw (nth args 1))
|
||||
(body (nth args 2))
|
||||
(name (symbol-name name-sym))
|
||||
(params (parse-key-params params-raw)))
|
||||
(let ((hdef (make-handler-def name params body env)))
|
||||
(env-set! env (str "handler:" name) hdef)
|
||||
hdef))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; defquery — (defquery name (&key param...) "docstring" body)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define sf-defquery
|
||||
(fn (args env)
|
||||
(let ((name-sym (first args))
|
||||
(params-raw (nth args 1))
|
||||
(name (symbol-name name-sym))
|
||||
(params (parse-key-params params-raw))
|
||||
;; Optional docstring before body
|
||||
(has-doc (and (>= (len args) 4) (= (type-of (nth args 2)) "string")))
|
||||
(doc (if has-doc (nth args 2) ""))
|
||||
(body (if has-doc (nth args 3) (nth args 2))))
|
||||
(let ((qdef (make-query-def name params doc body env)))
|
||||
(env-set! env (str "query:" name) qdef)
|
||||
qdef))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; defaction — (defaction name (&key param...) "docstring" body)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define sf-defaction
|
||||
(fn (args env)
|
||||
(let ((name-sym (first args))
|
||||
(params-raw (nth args 1))
|
||||
(name (symbol-name name-sym))
|
||||
(params (parse-key-params params-raw))
|
||||
(has-doc (and (>= (len args) 4) (= (type-of (nth args 2)) "string")))
|
||||
(doc (if has-doc (nth args 2) ""))
|
||||
(body (if has-doc (nth args 3) (nth args 2))))
|
||||
(let ((adef (make-action-def name params doc body env)))
|
||||
(env-set! env (str "action:" name) adef)
|
||||
adef))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; defpage — (defpage name :path "/..." :auth :public :content expr ...)
|
||||
;;
|
||||
;; Keyword-slot form: all values after the name are :key value pairs.
|
||||
;; Values are stored as unevaluated AST — resolved at request time.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define sf-defpage
|
||||
(fn (args env)
|
||||
(let ((name-sym (first args))
|
||||
(name (symbol-name name-sym))
|
||||
(slots {}))
|
||||
;; Parse keyword slots from remaining args
|
||||
(let ((i 1)
|
||||
(max-i (len args)))
|
||||
(for-each
|
||||
(fn (idx)
|
||||
(when (and (< idx max-i)
|
||||
(= (type-of (nth args idx)) "keyword"))
|
||||
(when (< (+ idx 1) max-i)
|
||||
(dict-set! slots (keyword-name (nth args idx))
|
||||
(nth args (+ idx 1))))))
|
||||
(range 1 max-i 2)))
|
||||
(let ((pdef (make-page-def name slots env)))
|
||||
(env-set! env (str "page:" name) pdef)
|
||||
pdef))))
|
||||
@@ -4,10 +4,16 @@
|
||||
;; Defines how SX source text is tokenized and parsed into AST.
|
||||
;; The parser is intentionally simple — s-expressions need minimal parsing.
|
||||
;;
|
||||
;; Single-pass recursive descent: reads source text directly into AST,
|
||||
;; no separate tokenization phase. All mutable cursor state lives inside
|
||||
;; the parse closure.
|
||||
;;
|
||||
;; Grammar:
|
||||
;; program → expr*
|
||||
;; expr → atom | list | quote-sugar
|
||||
;; expr → atom | list | vector | map | quote-sugar
|
||||
;; list → '(' expr* ')'
|
||||
;; vector → '[' expr* ']' (sugar for list)
|
||||
;; map → '{' (key expr)* '}'
|
||||
;; atom → string | number | keyword | symbol | boolean | nil
|
||||
;; string → '"' (char | escape)* '"'
|
||||
;; number → '-'? digit+ ('.' digit+)? ([eE] [+-]? digit+)?
|
||||
@@ -15,316 +21,264 @@
|
||||
;; symbol → ident
|
||||
;; boolean → 'true' | 'false'
|
||||
;; nil → 'nil'
|
||||
;; ident → [a-zA-Z_~*+\-><=/!?&] [a-zA-Z0-9_~*+\-><=/!?.:&]*
|
||||
;; ident → ident-start ident-char*
|
||||
;; comment → ';' to end of line (discarded)
|
||||
;;
|
||||
;; Dict literal:
|
||||
;; {key val ...} → dict object (keys are keywords or expressions)
|
||||
;;
|
||||
;; Quote sugar:
|
||||
;; `(expr) → (quasiquote expr)
|
||||
;; ,(expr) → (unquote expr)
|
||||
;; ,@(expr) → (splice-unquote expr)
|
||||
;; `expr → (quasiquote expr)
|
||||
;; ,expr → (unquote expr)
|
||||
;; ,@expr → (splice-unquote expr)
|
||||
;;
|
||||
;; Platform interface (each target implements natively):
|
||||
;; (ident-start? ch) → boolean
|
||||
;; (ident-char? ch) → boolean
|
||||
;; (make-symbol name) → Symbol value
|
||||
;; (make-keyword name) → Keyword value
|
||||
;; (escape-string s) → string with " and \ escaped for serialization
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Tokenizer
|
||||
;; Parser — single-pass recursive descent
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Produces a flat stream of tokens from source text.
|
||||
;; Each token is a (type value line col) tuple.
|
||||
;; Returns a list of top-level AST expressions.
|
||||
|
||||
(define tokenize
|
||||
(define sx-parse
|
||||
(fn (source)
|
||||
(let ((pos 0)
|
||||
(line 1)
|
||||
(col 1)
|
||||
(tokens (list))
|
||||
(len-src (len source)))
|
||||
;; Main loop — bootstrap compilers convert to while
|
||||
(define scan-next
|
||||
|
||||
;; -- Cursor helpers (closure over pos, source, len-src) --
|
||||
|
||||
(define skip-comment
|
||||
(fn ()
|
||||
(when (and (< pos len-src) (not (= (nth source pos) "\n")))
|
||||
(set! pos (inc pos))
|
||||
(skip-comment))))
|
||||
|
||||
(define skip-ws
|
||||
(fn ()
|
||||
(when (< pos len-src)
|
||||
(let ((ch (nth source pos)))
|
||||
(cond
|
||||
;; Whitespace — skip
|
||||
(whitespace? ch)
|
||||
(do (advance-pos!) (scan-next))
|
||||
|
||||
;; Whitespace
|
||||
(or (= ch " ") (= ch "\t") (= ch "\n") (= ch "\r"))
|
||||
(do (set! pos (inc pos)) (skip-ws))
|
||||
;; Comment — skip to end of line
|
||||
(= ch ";")
|
||||
(do (skip-to-eol!) (scan-next))
|
||||
(do (set! pos (inc pos))
|
||||
(skip-comment)
|
||||
(skip-ws))
|
||||
;; Not whitespace or comment — stop
|
||||
:else nil)))))
|
||||
|
||||
;; -- Atom readers --
|
||||
|
||||
(define read-string
|
||||
(fn ()
|
||||
(set! pos (inc pos)) ;; skip opening "
|
||||
(let ((buf ""))
|
||||
(define read-str-loop
|
||||
(fn ()
|
||||
(if (>= pos len-src)
|
||||
(error "Unterminated string")
|
||||
(let ((ch (nth source pos)))
|
||||
(cond
|
||||
(= ch "\"")
|
||||
(do (set! pos (inc pos)) nil) ;; done
|
||||
(= ch "\\")
|
||||
(do (set! pos (inc pos))
|
||||
(let ((esc (nth source pos)))
|
||||
(set! buf (str buf
|
||||
(cond
|
||||
(= esc "n") "\n"
|
||||
(= esc "t") "\t"
|
||||
(= esc "r") "\r"
|
||||
:else esc)))
|
||||
(set! pos (inc pos))
|
||||
(read-str-loop)))
|
||||
:else
|
||||
(do (set! buf (str buf ch))
|
||||
(set! pos (inc pos))
|
||||
(read-str-loop)))))))
|
||||
(read-str-loop)
|
||||
buf)))
|
||||
|
||||
(define read-ident
|
||||
(fn ()
|
||||
(let ((start pos))
|
||||
(define read-ident-loop
|
||||
(fn ()
|
||||
(when (and (< pos len-src)
|
||||
(ident-char? (nth source pos)))
|
||||
(set! pos (inc pos))
|
||||
(read-ident-loop))))
|
||||
(read-ident-loop)
|
||||
(slice source start pos))))
|
||||
|
||||
(define read-keyword
|
||||
(fn ()
|
||||
(set! pos (inc pos)) ;; skip :
|
||||
(make-keyword (read-ident))))
|
||||
|
||||
(define read-number
|
||||
(fn ()
|
||||
(let ((start pos))
|
||||
;; Optional leading minus
|
||||
(when (and (< pos len-src) (= (nth source pos) "-"))
|
||||
(set! pos (inc pos)))
|
||||
;; Integer digits
|
||||
(define read-digits
|
||||
(fn ()
|
||||
(when (and (< pos len-src)
|
||||
(let ((c (nth source pos)))
|
||||
(and (>= c "0") (<= c "9"))))
|
||||
(set! pos (inc pos))
|
||||
(read-digits))))
|
||||
(read-digits)
|
||||
;; Decimal part
|
||||
(when (and (< pos len-src) (= (nth source pos) "."))
|
||||
(set! pos (inc pos))
|
||||
(read-digits))
|
||||
;; Exponent
|
||||
(when (and (< pos len-src)
|
||||
(or (= (nth source pos) "e")
|
||||
(= (nth source pos) "E")))
|
||||
(set! pos (inc pos))
|
||||
(when (and (< pos len-src)
|
||||
(or (= (nth source pos) "+")
|
||||
(= (nth source pos) "-")))
|
||||
(set! pos (inc pos)))
|
||||
(read-digits))
|
||||
(parse-number (slice source start pos)))))
|
||||
|
||||
(define read-symbol
|
||||
(fn ()
|
||||
(let ((name (read-ident)))
|
||||
(cond
|
||||
(= name "true") true
|
||||
(= name "false") false
|
||||
(= name "nil") nil
|
||||
:else (make-symbol name)))))
|
||||
|
||||
;; -- Composite readers --
|
||||
|
||||
(define read-list
|
||||
(fn (close-ch)
|
||||
(let ((items (list)))
|
||||
(define read-list-loop
|
||||
(fn ()
|
||||
(skip-ws)
|
||||
(if (>= pos len-src)
|
||||
(error "Unterminated list")
|
||||
(if (= (nth source pos) close-ch)
|
||||
(do (set! pos (inc pos)) nil) ;; done
|
||||
(do (append! items (read-expr))
|
||||
(read-list-loop))))))
|
||||
(read-list-loop)
|
||||
items)))
|
||||
|
||||
(define read-map
|
||||
(fn ()
|
||||
(let ((result (dict)))
|
||||
(define read-map-loop
|
||||
(fn ()
|
||||
(skip-ws)
|
||||
(if (>= pos len-src)
|
||||
(error "Unterminated map")
|
||||
(if (= (nth source pos) "}")
|
||||
(do (set! pos (inc pos)) nil) ;; done
|
||||
(let ((key-expr (read-expr))
|
||||
(key-str (if (= (type-of key-expr) "keyword")
|
||||
(keyword-name key-expr)
|
||||
(str key-expr)))
|
||||
(val-expr (read-expr)))
|
||||
(dict-set! result key-str val-expr)
|
||||
(read-map-loop))))))
|
||||
(read-map-loop)
|
||||
result)))
|
||||
|
||||
;; -- Main expression reader --
|
||||
|
||||
(define read-expr
|
||||
(fn ()
|
||||
(skip-ws)
|
||||
(if (>= pos len-src)
|
||||
(error "Unexpected end of input")
|
||||
(let ((ch (nth source pos)))
|
||||
(cond
|
||||
;; Lists
|
||||
(= ch "(")
|
||||
(do (set! pos (inc pos)) (read-list ")"))
|
||||
(= ch "[")
|
||||
(do (set! pos (inc pos)) (read-list "]"))
|
||||
|
||||
;; Map
|
||||
(= ch "{")
|
||||
(do (set! pos (inc pos)) (read-map))
|
||||
|
||||
;; String
|
||||
(= ch "\"")
|
||||
(do (append! tokens (scan-string)) (scan-next))
|
||||
|
||||
;; Open paren
|
||||
(= ch "(")
|
||||
(do (append! tokens (list "lparen" "(" line col))
|
||||
(advance-pos!)
|
||||
(scan-next))
|
||||
|
||||
;; Close paren
|
||||
(= ch ")")
|
||||
(do (append! tokens (list "rparen" ")" line col))
|
||||
(advance-pos!)
|
||||
(scan-next))
|
||||
|
||||
;; Open bracket (list sugar)
|
||||
(= ch "[")
|
||||
(do (append! tokens (list "lbracket" "[" line col))
|
||||
(advance-pos!)
|
||||
(scan-next))
|
||||
|
||||
;; Close bracket
|
||||
(= ch "]")
|
||||
(do (append! tokens (list "rbracket" "]" line col))
|
||||
(advance-pos!)
|
||||
(scan-next))
|
||||
|
||||
;; Open brace (dict literal)
|
||||
(= ch "{")
|
||||
(do (append! tokens (list "lbrace" "{" line col))
|
||||
(advance-pos!)
|
||||
(scan-next))
|
||||
|
||||
;; Close brace
|
||||
(= ch "}")
|
||||
(do (append! tokens (list "rbrace" "}" line col))
|
||||
(advance-pos!)
|
||||
(scan-next))
|
||||
|
||||
;; Quasiquote sugar
|
||||
(= ch "`")
|
||||
(do (advance-pos!)
|
||||
(let ((inner (scan-next-expr)))
|
||||
(append! tokens (list "quasiquote" inner line col))
|
||||
(scan-next)))
|
||||
|
||||
;; Unquote / splice-unquote
|
||||
(= ch ",")
|
||||
(do (advance-pos!)
|
||||
(if (and (< pos len-src) (= (nth source pos) "@"))
|
||||
(do (advance-pos!)
|
||||
(let ((inner (scan-next-expr)))
|
||||
(append! tokens (list "splice-unquote" inner line col))
|
||||
(scan-next)))
|
||||
(let ((inner (scan-next-expr)))
|
||||
(append! tokens (list "unquote" inner line col))
|
||||
(scan-next))))
|
||||
(read-string)
|
||||
|
||||
;; Keyword
|
||||
(= ch ":")
|
||||
(do (append! tokens (scan-keyword)) (scan-next))
|
||||
(read-keyword)
|
||||
|
||||
;; Quasiquote sugar
|
||||
(= ch "`")
|
||||
(do (set! pos (inc pos))
|
||||
(list (make-symbol "quasiquote") (read-expr)))
|
||||
|
||||
;; Unquote / splice-unquote
|
||||
(= ch ",")
|
||||
(do (set! pos (inc pos))
|
||||
(if (and (< pos len-src) (= (nth source pos) "@"))
|
||||
(do (set! pos (inc pos))
|
||||
(list (make-symbol "splice-unquote") (read-expr)))
|
||||
(list (make-symbol "unquote") (read-expr))))
|
||||
|
||||
;; Number (or negative number)
|
||||
(or (digit? ch)
|
||||
(and (= ch "-") (< (inc pos) len-src)
|
||||
(digit? (nth source (inc pos)))))
|
||||
(do (append! tokens (scan-number)) (scan-next))
|
||||
(or (and (>= ch "0") (<= ch "9"))
|
||||
(and (= ch "-")
|
||||
(< (inc pos) len-src)
|
||||
(let ((next-ch (nth source (inc pos))))
|
||||
(and (>= next-ch "0") (<= next-ch "9")))))
|
||||
(read-number)
|
||||
|
||||
;; Symbol
|
||||
;; Ellipsis (... as a symbol)
|
||||
(and (= ch ".")
|
||||
(< (+ pos 2) len-src)
|
||||
(= (nth source (+ pos 1)) ".")
|
||||
(= (nth source (+ pos 2)) "."))
|
||||
(do (set! pos (+ pos 3))
|
||||
(make-symbol "..."))
|
||||
|
||||
;; Symbol (must be ident-start char)
|
||||
(ident-start? ch)
|
||||
(do (append! tokens (scan-symbol)) (scan-next))
|
||||
(read-symbol)
|
||||
|
||||
;; Unknown — skip
|
||||
;; Unexpected
|
||||
:else
|
||||
(do (advance-pos!) (scan-next)))))))
|
||||
(scan-next)
|
||||
tokens)))
|
||||
(error (str "Unexpected character: " ch)))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Token scanners (pseudo-code — each target implements natively)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define scan-string
|
||||
(fn ()
|
||||
;; Scan from opening " to closing ", handling escape sequences.
|
||||
;; Returns ("string" value line col).
|
||||
;; Escape sequences: \" \\ \n \t \r
|
||||
(let ((start-line line)
|
||||
(start-col col)
|
||||
(result ""))
|
||||
(advance-pos!) ;; skip opening "
|
||||
(define scan-str-loop
|
||||
(fn ()
|
||||
(if (>= pos (len source))
|
||||
(error "Unterminated string")
|
||||
(let ((ch (nth source pos)))
|
||||
(cond
|
||||
(= ch "\"")
|
||||
(do (advance-pos!) nil) ;; done
|
||||
(= ch "\\")
|
||||
(do (advance-pos!)
|
||||
(let ((esc (nth source pos)))
|
||||
(set! result (str result
|
||||
(case esc
|
||||
"n" "\n"
|
||||
"t" "\t"
|
||||
"r" "\r"
|
||||
:else esc)))
|
||||
(advance-pos!)
|
||||
(scan-str-loop)))
|
||||
:else
|
||||
(do (set! result (str result ch))
|
||||
(advance-pos!)
|
||||
(scan-str-loop)))))))
|
||||
(scan-str-loop)
|
||||
(list "string" result start-line start-col))))
|
||||
|
||||
|
||||
(define scan-keyword
|
||||
(fn ()
|
||||
;; Scan :identifier
|
||||
(let ((start-line line) (start-col col))
|
||||
(advance-pos!) ;; skip :
|
||||
(let ((name (scan-ident-chars)))
|
||||
(list "keyword" name start-line start-col)))))
|
||||
|
||||
|
||||
(define scan-number
|
||||
(fn ()
|
||||
;; Scan integer or float literal
|
||||
(let ((start-line line) (start-col col) (buf ""))
|
||||
(when (= (nth source pos) "-")
|
||||
(set! buf "-")
|
||||
(advance-pos!))
|
||||
;; Integer part
|
||||
(define scan-digits
|
||||
(fn ()
|
||||
(when (and (< pos (len source)) (digit? (nth source pos)))
|
||||
(set! buf (str buf (nth source pos)))
|
||||
(advance-pos!)
|
||||
(scan-digits))))
|
||||
(scan-digits)
|
||||
;; Decimal part
|
||||
(when (and (< pos (len source)) (= (nth source pos) "."))
|
||||
(set! buf (str buf "."))
|
||||
(advance-pos!)
|
||||
(scan-digits))
|
||||
;; Exponent
|
||||
(when (and (< pos (len source))
|
||||
(or (= (nth source pos) "e") (= (nth source pos) "E")))
|
||||
(set! buf (str buf (nth source pos)))
|
||||
(advance-pos!)
|
||||
(when (and (< pos (len source))
|
||||
(or (= (nth source pos) "+") (= (nth source pos) "-")))
|
||||
(set! buf (str buf (nth source pos)))
|
||||
(advance-pos!))
|
||||
(scan-digits))
|
||||
(list "number" (parse-number buf) start-line start-col))))
|
||||
|
||||
|
||||
(define scan-symbol
|
||||
(fn ()
|
||||
;; Scan identifier, check for true/false/nil
|
||||
(let ((start-line line)
|
||||
(start-col col)
|
||||
(name (scan-ident-chars)))
|
||||
(cond
|
||||
(= name "true") (list "boolean" true start-line start-col)
|
||||
(= name "false") (list "boolean" false start-line start-col)
|
||||
(= name "nil") (list "nil" nil start-line start-col)
|
||||
:else (list "symbol" name start-line start-col)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Parser — tokens → AST
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define parse
|
||||
(fn (tokens)
|
||||
;; Parse all top-level expressions from token stream.
|
||||
(let ((pos 0)
|
||||
(exprs (list)))
|
||||
(define parse-loop
|
||||
(fn ()
|
||||
(when (< pos (len tokens))
|
||||
(let ((result (parse-expr tokens)))
|
||||
(append! exprs result)
|
||||
(parse-loop)))))
|
||||
(parse-loop)
|
||||
exprs)))
|
||||
|
||||
|
||||
(define parse-expr
|
||||
(fn (tokens)
|
||||
;; Parse a single expression.
|
||||
(let ((tok (nth tokens pos)))
|
||||
(case (first tok) ;; token type
|
||||
"lparen"
|
||||
(do (set! pos (inc pos))
|
||||
(parse-list tokens "rparen"))
|
||||
|
||||
"lbracket"
|
||||
(do (set! pos (inc pos))
|
||||
(parse-list tokens "rbracket"))
|
||||
|
||||
"lbrace"
|
||||
(do (set! pos (inc pos))
|
||||
(parse-dict tokens))
|
||||
|
||||
"string" (do (set! pos (inc pos)) (nth tok 1))
|
||||
"number" (do (set! pos (inc pos)) (nth tok 1))
|
||||
"boolean" (do (set! pos (inc pos)) (nth tok 1))
|
||||
"nil" (do (set! pos (inc pos)) nil)
|
||||
|
||||
"keyword"
|
||||
(do (set! pos (inc pos))
|
||||
(make-keyword (nth tok 1)))
|
||||
|
||||
"symbol"
|
||||
(do (set! pos (inc pos))
|
||||
(make-symbol (nth tok 1)))
|
||||
|
||||
:else (error (str "Unexpected token: " (inspect tok)))))))
|
||||
|
||||
|
||||
(define parse-list
|
||||
(fn (tokens close-type)
|
||||
;; Parse expressions until close-type token.
|
||||
(let ((items (list)))
|
||||
(define parse-list-loop
|
||||
(fn ()
|
||||
(if (>= pos (len tokens))
|
||||
(error "Unterminated list")
|
||||
(if (= (first (nth tokens pos)) close-type)
|
||||
(do (set! pos (inc pos)) nil) ;; done
|
||||
(do (append! items (parse-expr tokens))
|
||||
(parse-list-loop))))))
|
||||
(parse-list-loop)
|
||||
items)))
|
||||
|
||||
|
||||
(define parse-dict
|
||||
(fn (tokens)
|
||||
;; Parse {key val key val ...} until "rbrace" token.
|
||||
;; Returns a dict (plain object).
|
||||
(let ((result (dict)))
|
||||
(define parse-dict-loop
|
||||
(fn ()
|
||||
(if (>= pos (len tokens))
|
||||
(error "Unterminated dict")
|
||||
(if (= (first (nth tokens pos)) "rbrace")
|
||||
(do (set! pos (inc pos)) nil) ;; done
|
||||
(let ((key-expr (parse-expr tokens))
|
||||
(key-str (if (= (type-of key-expr) "keyword")
|
||||
(keyword-name key-expr)
|
||||
(str key-expr)))
|
||||
(val-expr (parse-expr tokens)))
|
||||
(dict-set! result key-str val-expr)
|
||||
(parse-dict-loop))))))
|
||||
(parse-dict-loop)
|
||||
result)))
|
||||
;; -- Entry point: parse all top-level expressions --
|
||||
(let ((exprs (list)))
|
||||
(define parse-loop
|
||||
(fn ()
|
||||
(skip-ws)
|
||||
(when (< pos len-src)
|
||||
(append! exprs (read-expr))
|
||||
(parse-loop))))
|
||||
(parse-loop)
|
||||
exprs))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Serializer — AST → SX source text
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define serialize
|
||||
(define sx-serialize
|
||||
(fn (val)
|
||||
(case (type-of val)
|
||||
"nil" "nil"
|
||||
@@ -333,46 +287,41 @@
|
||||
"string" (str "\"" (escape-string val) "\"")
|
||||
"symbol" (symbol-name val)
|
||||
"keyword" (str ":" (keyword-name val))
|
||||
"list" (str "(" (join " " (map serialize val)) ")")
|
||||
"dict" (serialize-dict val)
|
||||
"list" (str "(" (join " " (map sx-serialize val)) ")")
|
||||
"dict" (sx-serialize-dict val)
|
||||
"sx-expr" (sx-expr-source val)
|
||||
:else (str val))))
|
||||
|
||||
|
||||
(define serialize-dict
|
||||
(define sx-serialize-dict
|
||||
(fn (d)
|
||||
(str "(dict "
|
||||
(str "{"
|
||||
(join " "
|
||||
(reduce
|
||||
(fn (acc key)
|
||||
(concat acc (list (str ":" key) (serialize (dict-get d key)))))
|
||||
(concat acc (list (str ":" key) (sx-serialize (dict-get d key)))))
|
||||
(list)
|
||||
(keys d)))
|
||||
")")))
|
||||
"}")))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Platform parser interface
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Character classification:
|
||||
;; (whitespace? ch) → boolean
|
||||
;; (digit? ch) → boolean
|
||||
;; (ident-start? ch) → boolean (letter, _, ~, *, +, -, etc.)
|
||||
;; (ident-char? ch) → boolean (ident-start + digits, ., :)
|
||||
;; Character classification (implemented natively per target):
|
||||
;; (ident-start? ch) → boolean
|
||||
;; True for: a-z A-Z _ ~ * + - > < = / ! ? &
|
||||
;;
|
||||
;; Constructors:
|
||||
;; (make-symbol name) → Symbol value
|
||||
;; (make-keyword name) → Keyword value
|
||||
;; (parse-number s) → number (int or float from string)
|
||||
;; (ident-char? ch) → boolean
|
||||
;; True for: ident-start chars plus: 0-9 . : / [ ] # ,
|
||||
;;
|
||||
;; Constructors (provided by the SX runtime):
|
||||
;; (make-symbol name) → Symbol value
|
||||
;; (make-keyword name) → Keyword value
|
||||
;; (parse-number s) → number (int or float from string)
|
||||
;;
|
||||
;; String utilities:
|
||||
;; (escape-string s) → string with " and \ escaped
|
||||
;; (sx-expr-source e) → unwrap SxExpr to its source string
|
||||
;;
|
||||
;; Cursor state (mutable — each target manages its own way):
|
||||
;; pos, line, col — current position in source
|
||||
;; (advance-pos!) → increment pos, update line/col
|
||||
;; (skip-to-eol!) → advance past end of line
|
||||
;; (scan-ident-chars) → consume and return identifier string
|
||||
;; (escape-string s) → string with " and \ escaped
|
||||
;; (sx-expr-source e) → unwrap SxExpr to its source string
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -18,13 +18,19 @@
|
||||
;; The :body is optional — when provided, it gives a reference
|
||||
;; implementation in SX that bootstrap compilers MAY use for testing
|
||||
;; or as a fallback. Most targets will implement natively for performance.
|
||||
;;
|
||||
;; Modules: (define-module :name) scopes subsequent define-primitive
|
||||
;; entries until the next define-module. Bootstrappers use this to
|
||||
;; selectively include primitive groups.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Arithmetic
|
||||
;; Core — Arithmetic
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :core.arithmetic)
|
||||
|
||||
(define-primitive "+"
|
||||
:params (&rest args)
|
||||
:returns "number"
|
||||
@@ -115,13 +121,15 @@
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Comparison
|
||||
;; Core — Comparison
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :core.comparison)
|
||||
|
||||
(define-primitive "="
|
||||
:params (a b)
|
||||
:returns "boolean"
|
||||
:doc "Equality (value equality, not identity).")
|
||||
:doc "Deep structural equality. Alias for equal?.")
|
||||
|
||||
(define-primitive "!="
|
||||
:params (a b)
|
||||
@@ -129,6 +137,27 @@
|
||||
:doc "Inequality."
|
||||
:body (not (= a b)))
|
||||
|
||||
(define-primitive "eq?"
|
||||
:params (a b)
|
||||
:returns "boolean"
|
||||
:doc "Identity equality. True only if a and b are the exact same object.
|
||||
For immutable atoms (numbers, strings, booleans, nil) this may or
|
||||
may not match — use eqv? for reliable atom comparison.")
|
||||
|
||||
(define-primitive "eqv?"
|
||||
:params (a b)
|
||||
:returns "boolean"
|
||||
:doc "Equivalent value for atoms, identity for compound objects.
|
||||
Returns true for identical objects (eq?), and also for numbers,
|
||||
strings, booleans, and nil with the same value. For lists, dicts,
|
||||
lambdas, and components, only true if same identity.")
|
||||
|
||||
(define-primitive "equal?"
|
||||
:params (a b)
|
||||
:returns "boolean"
|
||||
:doc "Deep structural equality. Recursively compares lists and dicts.
|
||||
Same semantics as = but explicit Scheme name.")
|
||||
|
||||
(define-primitive "<"
|
||||
:params (a b)
|
||||
:returns "boolean"
|
||||
@@ -151,9 +180,11 @@
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Predicates
|
||||
;; Core — Predicates
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :core.predicates)
|
||||
|
||||
(define-primitive "odd?"
|
||||
:params (n)
|
||||
:returns "boolean"
|
||||
@@ -197,6 +228,11 @@
|
||||
:returns "boolean"
|
||||
:doc "True if x is a dict/map.")
|
||||
|
||||
(define-primitive "continuation?"
|
||||
:params (x)
|
||||
:returns "boolean"
|
||||
:doc "True if x is a captured continuation.")
|
||||
|
||||
(define-primitive "empty?"
|
||||
:params (coll)
|
||||
:returns "boolean"
|
||||
@@ -209,9 +245,11 @@
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Logic
|
||||
;; Core — Logic
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :core.logic)
|
||||
|
||||
(define-primitive "not"
|
||||
:params (x)
|
||||
:returns "boolean"
|
||||
@@ -219,9 +257,11 @@
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Strings
|
||||
;; Core — Strings
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :core.strings)
|
||||
|
||||
(define-primitive "str"
|
||||
:params (&rest args)
|
||||
:returns "string"
|
||||
@@ -279,9 +319,11 @@
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Collections — construction
|
||||
;; Core — Collections
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :core.collections)
|
||||
|
||||
(define-primitive "list"
|
||||
:params (&rest args)
|
||||
:returns "list"
|
||||
@@ -297,11 +339,6 @@
|
||||
:returns "list"
|
||||
:doc "Integer range [start, end) with optional step.")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Collections — access
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-primitive "get"
|
||||
:params (coll key &rest default)
|
||||
:returns "any"
|
||||
@@ -354,9 +391,11 @@
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Collections — dict operations
|
||||
;; Core — Dict operations
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :core.dict)
|
||||
|
||||
(define-primitive "keys"
|
||||
:params (d)
|
||||
:returns "list"
|
||||
@@ -389,9 +428,11 @@
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Format helpers
|
||||
;; Stdlib — Format
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :stdlib.format)
|
||||
|
||||
(define-primitive "format-date"
|
||||
:params (date-str fmt)
|
||||
:returns "string"
|
||||
@@ -407,11 +448,18 @@
|
||||
:returns "number"
|
||||
:doc "Parse string to integer with optional default on failure.")
|
||||
|
||||
(define-primitive "parse-datetime"
|
||||
:params (s)
|
||||
:returns "string"
|
||||
:doc "Parse datetime string — identity passthrough (returns string or nil).")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Text helpers
|
||||
;; Stdlib — Text
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :stdlib.text)
|
||||
|
||||
(define-primitive "pluralize"
|
||||
:params (count &rest forms)
|
||||
:returns "string"
|
||||
@@ -429,23 +477,10 @@
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Date & parsing helpers
|
||||
;; Stdlib — Style
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-primitive "parse-datetime"
|
||||
:params (s)
|
||||
:returns "string"
|
||||
:doc "Parse datetime string — identity passthrough (returns string or nil).")
|
||||
|
||||
(define-primitive "split-ids"
|
||||
:params (s)
|
||||
:returns "list"
|
||||
:doc "Split comma-separated ID string into list of trimmed non-empty strings.")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; CSSX — style system primitives
|
||||
;; --------------------------------------------------------------------------
|
||||
(define-module :stdlib.style)
|
||||
|
||||
(define-primitive "css"
|
||||
:params (&rest atoms)
|
||||
@@ -457,3 +492,15 @@
|
||||
:params (&rest styles)
|
||||
:returns "style-value"
|
||||
:doc "Merge multiple StyleValues into one combined StyleValue.")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Stdlib — Debug
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-module :stdlib.debug)
|
||||
|
||||
(define-primitive "assert"
|
||||
:params (condition &rest message)
|
||||
:returns "boolean"
|
||||
:doc "Assert condition is truthy; raise error with message if not.")
|
||||
|
||||
412
shared/sx/ref/special-forms.sx
Normal file
412
shared/sx/ref/special-forms.sx
Normal file
@@ -0,0 +1,412 @@
|
||||
;; ==========================================================================
|
||||
;; special-forms.sx — Specification of all SX special forms
|
||||
;;
|
||||
;; Special forms are syntactic constructs whose arguments are NOT evaluated
|
||||
;; before dispatch. Each form has its own evaluation rules — unlike primitives,
|
||||
;; which receive pre-evaluated values.
|
||||
;;
|
||||
;; This file is a SPECIFICATION, not executable code. Bootstrap compilers
|
||||
;; consume these declarations but implement special forms natively.
|
||||
;;
|
||||
;; Format:
|
||||
;; (define-special-form "name"
|
||||
;; :syntax (name arg1 arg2 ...)
|
||||
;; :doc "description"
|
||||
;; :tail-position "which subexpressions are in tail position"
|
||||
;; :example "(name ...)")
|
||||
;;
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Control flow
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-special-form "if"
|
||||
:syntax (if condition then-expr else-expr)
|
||||
:doc "If condition is truthy, evaluate then-expr; otherwise evaluate else-expr.
|
||||
Both branches are in tail position. The else branch is optional and
|
||||
defaults to nil."
|
||||
:tail-position "then-expr, else-expr"
|
||||
:example "(if (> x 10) \"big\" \"small\")")
|
||||
|
||||
(define-special-form "when"
|
||||
:syntax (when condition body ...)
|
||||
:doc "If condition is truthy, evaluate all body expressions sequentially.
|
||||
Returns the value of the last body expression, or nil if condition
|
||||
is falsy. Only the last body expression is in tail position."
|
||||
:tail-position "last body expression"
|
||||
:example "(when (logged-in? user)
|
||||
(render-dashboard user))")
|
||||
|
||||
(define-special-form "cond"
|
||||
:syntax (cond test1 result1 test2 result2 ... :else default)
|
||||
:doc "Multi-way conditional. Tests are evaluated in order; the result
|
||||
paired with the first truthy test is returned. The :else keyword
|
||||
(or the symbol else) matches unconditionally. Supports both
|
||||
Clojure-style flat pairs and Scheme-style nested pairs:
|
||||
Clojure: (cond test1 result1 test2 result2 :else default)
|
||||
Scheme: (cond (test1 result1) (test2 result2) (else default))"
|
||||
:tail-position "all result expressions"
|
||||
:example "(cond
|
||||
(= status \"active\") (render-active item)
|
||||
(= status \"draft\") (render-draft item)
|
||||
:else (render-unknown item))")
|
||||
|
||||
(define-special-form "case"
|
||||
:syntax (case expr val1 result1 val2 result2 ... :else default)
|
||||
:doc "Match expr against values using equality. Like cond but tests
|
||||
a single expression against multiple values. The :else keyword
|
||||
matches if no values match."
|
||||
:tail-position "all result expressions"
|
||||
:example "(case (get request \"method\")
|
||||
\"GET\" (handle-get request)
|
||||
\"POST\" (handle-post request)
|
||||
:else (method-not-allowed))")
|
||||
|
||||
(define-special-form "and"
|
||||
:syntax (and expr ...)
|
||||
:doc "Short-circuit logical AND. Evaluates expressions left to right.
|
||||
Returns the first falsy value, or the last value if all are truthy.
|
||||
Returns true if given no arguments."
|
||||
:tail-position "last expression"
|
||||
:example "(and (valid? input) (authorized? user) (process input))")
|
||||
|
||||
(define-special-form "or"
|
||||
:syntax (or expr ...)
|
||||
:doc "Short-circuit logical OR. Evaluates expressions left to right.
|
||||
Returns the first truthy value, or the last value if all are falsy.
|
||||
Returns false if given no arguments."
|
||||
:tail-position "last expression"
|
||||
:example "(or (get cache key) (fetch-from-db key) \"default\")")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Binding
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-special-form "let"
|
||||
:syntax (let bindings body ...)
|
||||
:doc "Create local bindings and evaluate body in the extended environment.
|
||||
Bindings can be Scheme-style ((name val) ...) or Clojure-style
|
||||
(name val name val ...). Each binding can see previous bindings.
|
||||
Only the last body expression is in tail position.
|
||||
|
||||
Named let: (let name ((x init) ...) body) creates a loop. The name
|
||||
is bound to a function that takes the same params and recurses with
|
||||
tail-call optimization."
|
||||
:tail-position "last body expression; recursive call in named let"
|
||||
:example ";; Basic let
|
||||
(let ((x 10) (y 20))
|
||||
(+ x y))
|
||||
|
||||
;; Clojure-style
|
||||
(let (x 10 y 20)
|
||||
(+ x y))
|
||||
|
||||
;; Named let (loop)
|
||||
(let loop ((i 0) (acc 0))
|
||||
(if (= i 100)
|
||||
acc
|
||||
(loop (+ i 1) (+ acc i))))")
|
||||
|
||||
(define-special-form "let*"
|
||||
:syntax (let* bindings body ...)
|
||||
:doc "Alias for let. In SX, let is already sequential (each binding
|
||||
sees previous ones), so let* is identical to let."
|
||||
:tail-position "last body expression"
|
||||
:example "(let* ((x 10) (y (* x 2)))
|
||||
(+ x y)) ;; → 30")
|
||||
|
||||
(define-special-form "letrec"
|
||||
:syntax (letrec bindings body ...)
|
||||
:doc "Mutually recursive local bindings. All names are bound to nil first,
|
||||
then all values are evaluated (so they can reference each other),
|
||||
then lambda closures are patched to include the final bindings.
|
||||
Used for defining mutually recursive local functions."
|
||||
:tail-position "last body expression"
|
||||
:example "(letrec ((even? (fn (n) (if (= n 0) true (odd? (- n 1)))))
|
||||
(odd? (fn (n) (if (= n 0) false (even? (- n 1))))))
|
||||
(even? 10)) ;; → true")
|
||||
|
||||
(define-special-form "define"
|
||||
:syntax (define name value)
|
||||
:doc "Bind name to value in the current environment. If value is a lambda
|
||||
and has no name, the lambda's name is set to the symbol name.
|
||||
Returns the value."
|
||||
:tail-position "none (value is eagerly evaluated)"
|
||||
:example "(define greeting \"hello\")
|
||||
(define double (fn (x) (* x 2)))")
|
||||
|
||||
(define-special-form "set!"
|
||||
:syntax (set! name value)
|
||||
:doc "Mutate an existing binding. The name must already be bound in the
|
||||
current environment. Returns the new value."
|
||||
:tail-position "none (value is eagerly evaluated)"
|
||||
:example "(let (count 0)
|
||||
(set! count (+ count 1)))")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Functions and components
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-special-form "lambda"
|
||||
:syntax (lambda params body)
|
||||
:doc "Create a function. Params is a list of parameter names. Body is
|
||||
a single expression (the return value). The lambda captures the
|
||||
current environment as its closure."
|
||||
:tail-position "body"
|
||||
:example "(lambda (x y) (+ x y))")
|
||||
|
||||
(define-special-form "fn"
|
||||
:syntax (fn params body)
|
||||
:doc "Alias for lambda."
|
||||
:tail-position "body"
|
||||
:example "(fn (x) (* x x))")
|
||||
|
||||
(define-special-form "defcomp"
|
||||
:syntax (defcomp ~name (&key param1 param2 &rest children) body)
|
||||
:doc "Define a component. Components are called with keyword arguments
|
||||
and optional positional children. The &key marker introduces
|
||||
keyword parameters. The &rest (or &children) marker captures
|
||||
remaining positional arguments as a list.
|
||||
|
||||
Component names conventionally start with ~ to distinguish them
|
||||
from HTML elements. Components are evaluated with a merged
|
||||
environment: closure + caller-env + bound-params."
|
||||
:tail-position "body"
|
||||
:example "(defcomp ~card (&key title subtitle &rest children)
|
||||
(div :class \"card\"
|
||||
(h2 title)
|
||||
(when subtitle (p subtitle))
|
||||
children))")
|
||||
|
||||
(define-special-form "defmacro"
|
||||
:syntax (defmacro name (params ...) body)
|
||||
:doc "Define a macro. Macros receive their arguments unevaluated (as raw
|
||||
AST) and return a new expression that is then evaluated. The
|
||||
returned expression replaces the macro call. Use quasiquote for
|
||||
template construction."
|
||||
:tail-position "none (expansion is evaluated separately)"
|
||||
:example "(defmacro unless (condition &rest body)
|
||||
`(when (not ~condition) ~@body))")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Sequencing and threading
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-special-form "begin"
|
||||
:syntax (begin expr ...)
|
||||
:doc "Evaluate expressions sequentially. Returns the value of the last
|
||||
expression. Used when multiple side-effecting expressions need
|
||||
to be grouped."
|
||||
:tail-position "last expression"
|
||||
:example "(begin
|
||||
(log \"starting\")
|
||||
(process data)
|
||||
(log \"done\"))")
|
||||
|
||||
(define-special-form "do"
|
||||
:syntax (do expr ...)
|
||||
:doc "Alias for begin."
|
||||
:tail-position "last expression"
|
||||
:example "(do (set! x 1) (set! y 2) (+ x y))")
|
||||
|
||||
(define-special-form "->"
|
||||
:syntax (-> value form1 form2 ...)
|
||||
:doc "Thread-first macro. Threads value through a series of function calls,
|
||||
inserting it as the first argument of each form. Nested lists are
|
||||
treated as function calls; bare symbols become unary calls."
|
||||
:tail-position "last form"
|
||||
:example "(-> user
|
||||
(get \"name\")
|
||||
upper
|
||||
(str \" says hello\"))
|
||||
;; Expands to: (str (upper (get user \"name\")) \" says hello\")")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Quoting
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-special-form "quote"
|
||||
:syntax (quote expr)
|
||||
:doc "Return expr as data, without evaluating it. Symbols remain symbols,
|
||||
lists remain lists. The reader shorthand is the ' prefix."
|
||||
:tail-position "none (not evaluated)"
|
||||
:example "'(+ 1 2) ;; → the list (+ 1 2), not the number 3")
|
||||
|
||||
(define-special-form "quasiquote"
|
||||
:syntax (quasiquote expr)
|
||||
:doc "Template construction. Like quote, but allows unquoting with ~ and
|
||||
splicing with ~@. The reader shorthand is the ` prefix.
|
||||
`(a ~b ~@c)
|
||||
Quotes everything except: ~expr evaluates expr and inserts the
|
||||
result; ~@expr evaluates to a list and splices its elements."
|
||||
:tail-position "none (template is constructed, not evaluated)"
|
||||
:example "`(div :class \"card\" ~title ~@children)")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Continuations
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-special-form "reset"
|
||||
:syntax (reset body)
|
||||
:doc "Establish a continuation delimiter. Evaluates body normally unless
|
||||
a shift is encountered, in which case the continuation (the rest
|
||||
of the computation up to this reset) is captured and passed to
|
||||
the shift's body. Without shift, reset is a no-op wrapper."
|
||||
:tail-position "body"
|
||||
:example "(reset (+ 1 (shift k (k 10)))) ;; → 11")
|
||||
|
||||
(define-special-form "shift"
|
||||
:syntax (shift k body)
|
||||
:doc "Capture the continuation to the nearest reset as k, then evaluate
|
||||
body with k bound. If k is never called, the value of body is
|
||||
returned from the reset (abort). If k is called with a value,
|
||||
the reset body is re-evaluated with shift returning that value.
|
||||
k can be called multiple times."
|
||||
:tail-position "body"
|
||||
:example ";; Abort: shift body becomes the reset result
|
||||
(reset (+ 1 (shift k 42))) ;; → 42
|
||||
|
||||
;; Resume: k re-enters the computation
|
||||
(reset (+ 1 (shift k (k 10)))) ;; → 11
|
||||
|
||||
;; Multiple invocations
|
||||
(reset (* 2 (shift k (+ (k 1) (k 10))))) ;; → 24")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Guards
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-special-form "dynamic-wind"
|
||||
:syntax (dynamic-wind before-thunk body-thunk after-thunk)
|
||||
:doc "Entry/exit guards. All three arguments are zero-argument functions
|
||||
(thunks). before-thunk is called on entry, body-thunk is called
|
||||
for the result, and after-thunk is always called on exit (even on
|
||||
error). The wind stack is maintained so that when continuations
|
||||
jump across dynamic-wind boundaries, the correct before/after
|
||||
thunks fire."
|
||||
:tail-position "none (all thunks are eagerly called)"
|
||||
:example "(dynamic-wind
|
||||
(fn () (log \"entering\"))
|
||||
(fn () (do-work))
|
||||
(fn () (log \"exiting\")))")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Higher-order forms
|
||||
;;
|
||||
;; These are syntactic forms (not primitives) because the evaluator
|
||||
;; handles them directly for performance — avoiding the overhead of
|
||||
;; constructing argument lists and doing generic dispatch. They could
|
||||
;; be implemented as primitives but are special-cased in eval-list.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-special-form "map"
|
||||
:syntax (map fn coll)
|
||||
:doc "Apply fn to each element of coll, returning a list of results."
|
||||
:tail-position "none"
|
||||
:example "(map (fn (x) (* x x)) (list 1 2 3 4)) ;; → (1 4 9 16)")
|
||||
|
||||
(define-special-form "map-indexed"
|
||||
:syntax (map-indexed fn coll)
|
||||
:doc "Like map, but fn receives two arguments: (index element)."
|
||||
:tail-position "none"
|
||||
:example "(map-indexed (fn (i x) (str i \": \" x)) (list \"a\" \"b\" \"c\"))")
|
||||
|
||||
(define-special-form "filter"
|
||||
:syntax (filter fn coll)
|
||||
:doc "Return elements of coll for which fn returns truthy."
|
||||
:tail-position "none"
|
||||
:example "(filter (fn (x) (> x 3)) (list 1 5 2 8 3)) ;; → (5 8)")
|
||||
|
||||
(define-special-form "reduce"
|
||||
:syntax (reduce fn init coll)
|
||||
:doc "Reduce coll to a single value. fn receives (accumulator element)
|
||||
and returns the new accumulator. init is the initial value."
|
||||
:tail-position "none"
|
||||
:example "(reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3 4)) ;; → 10")
|
||||
|
||||
(define-special-form "some"
|
||||
:syntax (some fn coll)
|
||||
:doc "Return the first truthy result of applying fn to elements of coll,
|
||||
or nil if none match. Short-circuits on first truthy result."
|
||||
:tail-position "none"
|
||||
:example "(some (fn (x) (> x 3)) (list 1 2 5 3)) ;; → true")
|
||||
|
||||
(define-special-form "every?"
|
||||
:syntax (every? fn coll)
|
||||
:doc "Return true if fn returns truthy for every element of coll.
|
||||
Short-circuits on first falsy result."
|
||||
:tail-position "none"
|
||||
:example "(every? (fn (x) (> x 0)) (list 1 2 3)) ;; → true")
|
||||
|
||||
(define-special-form "for-each"
|
||||
:syntax (for-each fn coll)
|
||||
:doc "Apply fn to each element of coll for side effects. Returns nil."
|
||||
:tail-position "none"
|
||||
:example "(for-each (fn (x) (log x)) (list 1 2 3))")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Definition forms (domain-specific)
|
||||
;;
|
||||
;; These define named entities in the environment. They are special forms
|
||||
;; because their arguments have domain-specific structure that the
|
||||
;; evaluator parses directly.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-special-form "defstyle"
|
||||
:syntax (defstyle name atoms ...)
|
||||
:doc "Define a named style. Evaluates atoms to a StyleValue and binds
|
||||
it to name in the environment."
|
||||
:tail-position "none"
|
||||
:example "(defstyle card-style :rounded-lg :shadow-md :p-4 :bg-white)")
|
||||
|
||||
(define-special-form "defkeyframes"
|
||||
:syntax (defkeyframes name steps ...)
|
||||
:doc "Define a CSS @keyframes animation. Steps are (percentage properties ...)
|
||||
pairs. Produces a StyleValue with the animation name and keyframe rules."
|
||||
:tail-position "none"
|
||||
:example "(defkeyframes fade-in
|
||||
(0 :opacity-0)
|
||||
(100 :opacity-100))")
|
||||
|
||||
(define-special-form "defhandler"
|
||||
:syntax (defhandler name (&key params ...) body)
|
||||
:doc "Define an event handler function. Used by the SxEngine for
|
||||
client-side event handling."
|
||||
:tail-position "body"
|
||||
:example "(defhandler toggle-menu (&key target)
|
||||
(toggle-class target \"hidden\"))")
|
||||
|
||||
(define-special-form "defpage"
|
||||
:syntax (defpage name &key route method content ...)
|
||||
:doc "Define a page route. Declares the URL pattern, HTTP method, and
|
||||
content component for server-side page routing."
|
||||
:tail-position "none"
|
||||
:example "(defpage dashboard-page
|
||||
:route \"/dashboard\"
|
||||
:content (~dashboard-content))")
|
||||
|
||||
(define-special-form "defquery"
|
||||
:syntax (defquery name (&key params ...) body)
|
||||
:doc "Define a named query for data fetching. Used by the resolver
|
||||
system to declare data dependencies."
|
||||
:tail-position "body"
|
||||
:example "(defquery user-profile (&key user-id)
|
||||
(fetch-user user-id))")
|
||||
|
||||
(define-special-form "defaction"
|
||||
:syntax (defaction name (&key params ...) body)
|
||||
:doc "Define a named action for mutations. Like defquery but for
|
||||
write operations."
|
||||
:tail-position "body"
|
||||
:example "(defaction update-profile (&key user-id name email)
|
||||
(save-user user-id name email))")
|
||||
1259
shared/sx/ref/sx_ref.py
Normal file
1259
shared/sx/ref/sx_ref.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,7 @@ STYLE_ATOMS: dict[str, str] = {
|
||||
"flex": "display:flex",
|
||||
"inline-flex": "display:inline-flex",
|
||||
"table": "display:table",
|
||||
"table-row": "display:table-row",
|
||||
"grid": "display:grid",
|
||||
"contents": "display:contents",
|
||||
"hidden": "display:none",
|
||||
@@ -84,6 +85,7 @@ STYLE_ATOMS: dict[str, str] = {
|
||||
"flex-row": "flex-direction:row",
|
||||
"flex-col": "flex-direction:column",
|
||||
"flex-wrap": "flex-wrap:wrap",
|
||||
"flex-0": "flex:0",
|
||||
"flex-1": "flex:1 1 0%",
|
||||
"flex-shrink-0": "flex-shrink:0",
|
||||
"shrink-0": "flex-shrink:0",
|
||||
@@ -149,6 +151,7 @@ STYLE_ATOMS: dict[str, str] = {
|
||||
"mt-2": "margin-top:.5rem",
|
||||
"mt-3": "margin-top:.75rem",
|
||||
"mt-4": "margin-top:1rem",
|
||||
"mt-5": "margin-top:1.25rem",
|
||||
"mt-6": "margin-top:1.5rem",
|
||||
"mt-8": "margin-top:2rem",
|
||||
"mt-[8px]": "margin-top:8px",
|
||||
@@ -196,6 +199,7 @@ STYLE_ATOMS: dict[str, str] = {
|
||||
"pb-8": "padding-bottom:2rem",
|
||||
"pb-[48px]": "padding-bottom:48px",
|
||||
"pl-2": "padding-left:.5rem",
|
||||
"pl-3": "padding-left:.75rem",
|
||||
"pl-5": "padding-left:1.25rem",
|
||||
"pl-6": "padding-left:1.5rem",
|
||||
"pr-1": "padding-right:.25rem",
|
||||
@@ -216,11 +220,15 @@ STYLE_ATOMS: dict[str, str] = {
|
||||
"w-10": "width:2.5rem",
|
||||
"w-11": "width:2.75rem",
|
||||
"w-12": "width:3rem",
|
||||
"w-14": "width:3.5rem",
|
||||
"w-16": "width:4rem",
|
||||
"w-20": "width:5rem",
|
||||
"w-24": "width:6rem",
|
||||
"w-28": "width:7rem",
|
||||
"w-32": "width:8rem",
|
||||
"w-40": "width:10rem",
|
||||
"w-48": "width:12rem",
|
||||
"w-56": "width:14rem",
|
||||
"w-1/2": "width:50%",
|
||||
"w-1/3": "width:33.333333%",
|
||||
"w-1/4": "width:25%",
|
||||
@@ -241,6 +249,7 @@ STYLE_ATOMS: dict[str, str] = {
|
||||
"h-10": "height:2.5rem",
|
||||
"h-12": "height:3rem",
|
||||
"h-14": "height:3.5rem",
|
||||
"h-14": "height:3.5rem",
|
||||
"h-16": "height:4rem",
|
||||
"h-24": "height:6rem",
|
||||
"h-28": "height:7rem",
|
||||
@@ -268,11 +277,15 @@ STYLE_ATOMS: dict[str, str] = {
|
||||
"max-w-3xl": "max-width:48rem",
|
||||
"max-w-4xl": "max-width:56rem",
|
||||
"max-w-full": "max-width:100%",
|
||||
"max-w-0": "max-width:0",
|
||||
"max-w-none": "max-width:none",
|
||||
"max-w-screen-2xl": "max-width:1536px",
|
||||
"max-w-[360px]": "max-width:360px",
|
||||
"max-w-[768px]": "max-width:768px",
|
||||
"max-w-[640px]": "max-width:640px",
|
||||
"max-h-32": "max-height:8rem",
|
||||
"max-h-64": "max-height:16rem",
|
||||
"max-h-72": "max-height:18rem",
|
||||
"max-h-96": "max-height:24rem",
|
||||
"max-h-none": "max-height:none",
|
||||
"max-h-[448px]": "max-height:448px",
|
||||
@@ -282,6 +295,7 @@ STYLE_ATOMS: dict[str, str] = {
|
||||
"text-xs": "font-size:.75rem;line-height:1rem",
|
||||
"text-sm": "font-size:.875rem;line-height:1.25rem",
|
||||
"text-base": "font-size:1rem;line-height:1.5rem",
|
||||
"text-md": "font-size:1rem;line-height:1.5rem", # alias for text-base
|
||||
"text-lg": "font-size:1.125rem;line-height:1.75rem",
|
||||
"text-xl": "font-size:1.25rem;line-height:1.75rem",
|
||||
"text-2xl": "font-size:1.5rem;line-height:2rem",
|
||||
@@ -345,6 +359,7 @@ STYLE_ATOMS: dict[str, str] = {
|
||||
"text-rose-500": "color:rgb(244 63 94)",
|
||||
"text-rose-600": "color:rgb(225 29 72)",
|
||||
"text-rose-700": "color:rgb(190 18 60)",
|
||||
"text-rose-800": "color:rgb(159 18 57)",
|
||||
"text-rose-800/80": "color:rgba(159,18,57,.8)",
|
||||
"text-rose-900": "color:rgb(136 19 55)",
|
||||
"text-orange-600": "color:rgb(234 88 12)",
|
||||
@@ -355,6 +370,10 @@ STYLE_ATOMS: dict[str, str] = {
|
||||
"text-yellow-700": "color:rgb(161 98 7)",
|
||||
"text-green-600": "color:rgb(22 163 74)",
|
||||
"text-green-800": "color:rgb(22 101 52)",
|
||||
"text-green-900": "color:rgb(20 83 45)",
|
||||
"text-neutral-400": "color:rgb(163 163 163)",
|
||||
"text-neutral-500": "color:rgb(115 115 115)",
|
||||
"text-neutral-600": "color:rgb(82 82 82)",
|
||||
"text-emerald-500": "color:rgb(16 185 129)",
|
||||
"text-emerald-600": "color:rgb(5 150 105)",
|
||||
"text-emerald-700": "color:rgb(4 120 87)",
|
||||
@@ -371,6 +390,7 @@ STYLE_ATOMS: dict[str, str] = {
|
||||
"text-violet-600": "color:rgb(124 58 237)",
|
||||
"text-violet-700": "color:rgb(109 40 217)",
|
||||
"text-violet-800": "color:rgb(91 33 182)",
|
||||
"text-violet-900": "color:rgb(76 29 149)",
|
||||
|
||||
# ── Background Colors ────────────────────────────────────────────────
|
||||
"bg-transparent": "background-color:transparent",
|
||||
@@ -413,6 +433,9 @@ STYLE_ATOMS: dict[str, str] = {
|
||||
"bg-yellow-300": "background-color:rgb(253 224 71)",
|
||||
"bg-green-50": "background-color:rgb(240 253 244)",
|
||||
"bg-green-100": "background-color:rgb(220 252 231)",
|
||||
"bg-green-200": "background-color:rgb(187 247 208)",
|
||||
"bg-neutral-50/70": "background-color:rgba(250,250,250,.7)",
|
||||
"bg-black/70": "background-color:rgba(0,0,0,.7)",
|
||||
"bg-emerald-50": "background-color:rgb(236 253 245)",
|
||||
"bg-emerald-50/80": "background-color:rgba(236,253,245,.8)",
|
||||
"bg-emerald-100": "background-color:rgb(209 250 229)",
|
||||
@@ -435,6 +458,12 @@ STYLE_ATOMS: dict[str, str] = {
|
||||
"bg-violet-400": "background-color:rgb(167 139 250)",
|
||||
"bg-violet-500": "background-color:rgb(139 92 246)",
|
||||
"bg-violet-600": "background-color:rgb(124 58 237)",
|
||||
"bg-violet-700": "background-color:rgb(109 40 217)",
|
||||
"bg-amber-200": "background-color:rgb(253 230 138)",
|
||||
"bg-blue-700": "background-color:rgb(29 78 216)",
|
||||
"bg-emerald-700": "background-color:rgb(4 120 87)",
|
||||
"bg-purple-700": "background-color:rgb(126 34 206)",
|
||||
"bg-stone-50/60": "background-color:rgba(250,250,249,.6)",
|
||||
|
||||
# ── Border ───────────────────────────────────────────────────────────
|
||||
"border": "border-width:1px",
|
||||
@@ -445,6 +474,7 @@ STYLE_ATOMS: dict[str, str] = {
|
||||
"border-b": "border-bottom-width:1px",
|
||||
"border-b-2": "border-bottom-width:2px",
|
||||
"border-r": "border-right-width:1px",
|
||||
"border-l": "border-left-width:1px",
|
||||
"border-l-4": "border-left-width:4px",
|
||||
"border-dashed": "border-style:dashed",
|
||||
"border-none": "border-style:none",
|
||||
@@ -472,6 +502,9 @@ STYLE_ATOMS: dict[str, str] = {
|
||||
"border-violet-200": "border-color:rgb(221 214 254)",
|
||||
"border-violet-300": "border-color:rgb(196 181 253)",
|
||||
"border-violet-400": "border-color:rgb(167 139 250)",
|
||||
"border-neutral-200": "border-color:rgb(229 229 229)",
|
||||
"border-red-400": "border-color:rgb(248 113 113)",
|
||||
"border-stone-400": "border-color:rgb(168 162 158)",
|
||||
"border-t-white": "border-top-color:rgb(255 255 255)",
|
||||
"border-t-stone-600": "border-top-color:rgb(87 83 78)",
|
||||
"border-l-stone-400": "border-left-color:rgb(168 162 158)",
|
||||
@@ -499,17 +532,26 @@ STYLE_ATOMS: dict[str, str] = {
|
||||
"opacity-0": "opacity:0",
|
||||
"opacity-40": "opacity:.4",
|
||||
"opacity-50": "opacity:.5",
|
||||
"opacity-90": "opacity:.9",
|
||||
"opacity-100": "opacity:1",
|
||||
|
||||
# ── Ring / Outline ───────────────────────────────────────────────────
|
||||
"outline-none": "outline:2px solid transparent;outline-offset:2px",
|
||||
"ring-2": "box-shadow:0 0 0 2px var(--tw-ring-color,rgb(59 130 246))",
|
||||
"ring-offset-2": "box-shadow:0 0 0 2px rgb(255 255 255),0 0 0 4px var(--tw-ring-color,rgb(59 130 246))",
|
||||
"ring-stone-300": "--tw-ring-color:rgb(214 211 209)",
|
||||
"ring-stone-500": "--tw-ring-color:rgb(120 113 108)",
|
||||
"ring-violet-500": "--tw-ring-color:rgb(139 92 246)",
|
||||
"ring-blue-500": "--tw-ring-color:rgb(59 130 246)",
|
||||
"ring-green-500": "--tw-ring-color:rgb(22 163 74)",
|
||||
"ring-purple-500": "--tw-ring-color:rgb(147 51 234)",
|
||||
|
||||
# ── Overflow ─────────────────────────────────────────────────────────
|
||||
"overflow-hidden": "overflow:hidden",
|
||||
"overflow-x-auto": "overflow-x:auto",
|
||||
"overflow-y-auto": "overflow-y:auto",
|
||||
"overflow-visible": "overflow:visible",
|
||||
"overflow-y-visible": "overflow-y:visible",
|
||||
"overscroll-contain": "overscroll-behavior:contain",
|
||||
|
||||
# ── Text Decoration ──────────────────────────────────────────────────
|
||||
@@ -655,8 +697,13 @@ PSEUDO_VARIANTS: dict[str, str] = {
|
||||
"placeholder": "::placeholder",
|
||||
"file": "::file-selector-button",
|
||||
"aria-selected": "[aria-selected=true]",
|
||||
"invalid": ":invalid",
|
||||
"placeholder-shown": ":placeholder-shown",
|
||||
"group-hover": ":is(.group:hover) &",
|
||||
"group-open": ":is(.group[open]) &",
|
||||
"group-open/cat": ":is(.group\\/cat[open]) &",
|
||||
"group-open/filter": ":is(.group\\/filter[open]) &",
|
||||
"group-open/root": ":is(.group\\/root[open]) &",
|
||||
}
|
||||
|
||||
|
||||
|
||||
201
shared/sx/tests/test_continuations.py
Normal file
201
shared/sx/tests/test_continuations.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""Tests for delimited continuations (shift/reset).
|
||||
|
||||
Tests run against both the hand-written evaluator and the transpiled
|
||||
sx_ref evaluator to verify both implementations match.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from shared.sx import parse, evaluate, EvalError, NIL
|
||||
from shared.sx.types import Continuation
|
||||
from shared.sx.ref import sx_ref
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ev(text, env=None):
|
||||
"""Parse and evaluate via hand-written evaluator."""
|
||||
return evaluate(parse(text), env)
|
||||
|
||||
|
||||
def ev_ref(text, env=None):
|
||||
"""Parse and evaluate via transpiled sx_ref."""
|
||||
return sx_ref.evaluate(parse(text), env)
|
||||
|
||||
|
||||
EVALUATORS = [
|
||||
pytest.param(ev, id="hand-written"),
|
||||
pytest.param(ev_ref, id="sx_ref"),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basic shift/reset
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBasicReset:
|
||||
"""Reset without shift is a no-op wrapper."""
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_reset_passthrough(self, evaluate):
|
||||
assert evaluate("(reset 42)") == 42
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_reset_expression(self, evaluate):
|
||||
assert evaluate("(reset (+ 1 2))") == 3
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_reset_with_let(self, evaluate):
|
||||
assert evaluate("(reset (let (x 10) (+ x 5)))") == 15
|
||||
|
||||
|
||||
class TestShiftAbort:
|
||||
"""Shift without invoking k aborts to the reset boundary."""
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_abort_returns_shift_body(self, evaluate):
|
||||
# (reset (+ 1 (shift k 42))) → shift body 42 is returned, + 1 is abandoned
|
||||
assert evaluate("(reset (+ 1 (shift k 42)))") == 42
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_abort_string(self, evaluate):
|
||||
assert evaluate('(reset (+ 1 (shift k "aborted")))') == "aborted"
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_abort_with_computation(self, evaluate):
|
||||
assert evaluate("(reset (+ 1 (shift k (* 6 7))))") == 42
|
||||
|
||||
|
||||
class TestContinuationInvoke:
|
||||
"""Invoking the captured continuation re-enters the reset body."""
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_invoke_once(self, evaluate):
|
||||
# (reset (+ 1 (shift k (k 10)))) → k resumes with 10, so + 1 10 = 11
|
||||
assert evaluate("(reset (+ 1 (shift k (k 10))))") == 11
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_invoke_with_zero(self, evaluate):
|
||||
assert evaluate("(reset (+ 1 (shift k (k 0))))") == 1
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_invoke_twice(self, evaluate):
|
||||
# k invoked twice: (+ (k 1) (k 10)) → (+ 1 1) + ... → (+ 2 11) = 13
|
||||
# Actually: (k 1) re-evaluates (+ 1 <shift>) where shift returns 1 → 2
|
||||
# Then (k 10) re-evaluates (+ 1 <shift>) where shift returns 10 → 11
|
||||
# Then (+ 2 11) = 13
|
||||
assert evaluate("(reset (+ 1 (shift k (+ (k 1) (k 10)))))") == 13
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_invoke_transforms_value(self, evaluate):
|
||||
# k wraps: (reset (* 2 (shift k (k (k 3)))))
|
||||
# k(3) → (* 2 3) = 6, k(6) → (* 2 6) = 12
|
||||
assert evaluate("(reset (* 2 (shift k (k (k 3)))))") == 12
|
||||
|
||||
|
||||
class TestContinuationPredicate:
|
||||
"""The continuation? predicate identifies captured continuations."""
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_continuation_is_true(self, evaluate):
|
||||
result = evaluate("(reset (shift k (continuation? k)))")
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_non_continuation(self, evaluate):
|
||||
assert evaluate("(continuation? 42)") is False
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_nil_not_continuation(self, evaluate):
|
||||
assert evaluate("(continuation? nil)") is False
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_lambda_not_continuation(self, evaluate):
|
||||
assert evaluate("(continuation? (fn (x) x))") is False
|
||||
|
||||
|
||||
class TestStoredContinuation:
|
||||
"""Continuations can be stored and invoked later."""
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_stored_in_variable(self, evaluate):
|
||||
code = """
|
||||
(let (saved nil)
|
||||
(reset (+ 1 (shift k (do (set! saved k) "captured"))))
|
||||
)
|
||||
"""
|
||||
# The reset returns "captured" (abort path)
|
||||
assert evaluate(code) == "captured"
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_continuation_type(self, evaluate):
|
||||
"""Verify that a captured continuation is identified by continuation?."""
|
||||
code = '(reset (shift k (continuation? k)))'
|
||||
result = evaluate(code)
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestNestedReset:
|
||||
"""Nested reset blocks delimit independently."""
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_inner_reset(self, evaluate):
|
||||
code = "(reset (+ 1 (reset (+ 2 (shift k (k 10))))))"
|
||||
# Inner reset: (+ 2 (shift k (k 10))) → k(10) → (+ 2 10) = 12
|
||||
# Outer reset: (+ 1 12) = 13
|
||||
assert evaluate(code) == 13
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_inner_abort_outer_continues(self, evaluate):
|
||||
code = "(reset (+ 1 (reset (shift k 99))))"
|
||||
# Inner reset aborts with 99
|
||||
# Outer reset: (+ 1 99) = 100
|
||||
assert evaluate(code) == 100
|
||||
|
||||
|
||||
class TestPracticalPatterns:
|
||||
"""Practical uses of delimited continuations."""
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_early_return(self, evaluate):
|
||||
"""Shift without invoking k acts as early return."""
|
||||
code = """
|
||||
(reset
|
||||
(let (x 5)
|
||||
(if (> x 3)
|
||||
(shift k "too big")
|
||||
(* x x))))
|
||||
"""
|
||||
assert evaluate(code) == "too big"
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_normal_path(self, evaluate):
|
||||
"""When condition doesn't trigger shift, normal result."""
|
||||
code = """
|
||||
(reset
|
||||
(let (x 2)
|
||||
(if (> x 3)
|
||||
(shift k "too big")
|
||||
(* x x))))
|
||||
"""
|
||||
assert evaluate(code) == 4
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_continuation_as_function(self, evaluate):
|
||||
"""Map over a continuation to apply it to multiple values."""
|
||||
code = """
|
||||
(reset
|
||||
(+ 10 (shift k
|
||||
(map k (list 1 2 3)))))
|
||||
"""
|
||||
result = evaluate(code)
|
||||
assert result == [11, 12, 13]
|
||||
|
||||
@pytest.mark.parametrize("evaluate", EVALUATORS)
|
||||
def test_default_value(self, evaluate):
|
||||
"""Calling k with no args passes NIL."""
|
||||
code = '(reset (shift k (nil? (k))))'
|
||||
# k() passes NIL, reset body re-evals: (shift k ...) returns NIL
|
||||
# Then the outer shift body checks: (nil? NIL) = true
|
||||
assert evaluate(code) is True
|
||||
327
shared/sx/tests/test_scheme_forms.py
Normal file
327
shared/sx/tests/test_scheme_forms.py
Normal file
@@ -0,0 +1,327 @@
|
||||
"""Tests for Scheme-inspired forms: named let, letrec, dynamic-wind, eq?/eqv?/equal?."""
|
||||
|
||||
import pytest
|
||||
from shared.sx import parse, evaluate, EvalError, Symbol, NIL
|
||||
from shared.sx.types import Lambda
|
||||
|
||||
|
||||
def ev(text, env=None):
|
||||
"""Parse and evaluate a single expression."""
|
||||
return evaluate(parse(text), env)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Named let
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNamedLet:
|
||||
def test_basic_loop(self):
|
||||
"""Named let as a simple counter loop."""
|
||||
result = ev("""
|
||||
(let loop ((i 0) (acc 0))
|
||||
(if (> i 5)
|
||||
acc
|
||||
(loop (+ i 1) (+ acc i))))
|
||||
""")
|
||||
assert result == 15 # 0+1+2+3+4+5
|
||||
|
||||
def test_factorial(self):
|
||||
result = ev("""
|
||||
(let fact ((n 10) (acc 1))
|
||||
(if (<= n 1)
|
||||
acc
|
||||
(fact (- n 1) (* acc n))))
|
||||
""")
|
||||
assert result == 3628800
|
||||
|
||||
def test_tco_deep_recursion(self):
|
||||
"""Named let should use TCO — no stack overflow on deep loops."""
|
||||
result = ev("""
|
||||
(let loop ((i 0))
|
||||
(if (>= i 10000)
|
||||
i
|
||||
(loop (+ i 1))))
|
||||
""")
|
||||
assert result == 10000
|
||||
|
||||
def test_clojure_style_bindings(self):
|
||||
"""Named let with Clojure-style flat bindings."""
|
||||
result = ev("""
|
||||
(let loop (i 0 acc (list))
|
||||
(if (>= i 3)
|
||||
acc
|
||||
(loop (+ i 1) (append acc i))))
|
||||
""")
|
||||
assert result == [0, 1, 2]
|
||||
|
||||
def test_scheme_style_bindings(self):
|
||||
"""Named let with Scheme-style paired bindings."""
|
||||
result = ev("""
|
||||
(let loop ((i 3) (result (list)))
|
||||
(if (= i 0)
|
||||
result
|
||||
(loop (- i 1) (cons i result))))
|
||||
""")
|
||||
assert result == [1, 2, 3]
|
||||
|
||||
def test_accumulator_pattern(self):
|
||||
"""Named let accumulating a result — pure functional, no set! needed."""
|
||||
result = ev("""
|
||||
(let loop ((i 0) (acc (list)))
|
||||
(if (>= i 3)
|
||||
acc
|
||||
(loop (+ i 1) (append acc (* i i)))))
|
||||
""")
|
||||
assert result == [0, 1, 4]
|
||||
|
||||
def test_init_evaluated_in_outer_env(self):
|
||||
"""Initial values are evaluated in the enclosing environment."""
|
||||
result = ev("""
|
||||
(let ((x 10))
|
||||
(let loop ((a x) (b (* x 2)))
|
||||
(+ a b)))
|
||||
""")
|
||||
assert result == 30 # 10 + 20
|
||||
|
||||
def test_build_list_with_named_let(self):
|
||||
"""Idiomatic Scheme pattern: build a list with named let."""
|
||||
result = ev("""
|
||||
(let collect ((items (list 1 2 3 4 5)) (acc (list)))
|
||||
(if (empty? items)
|
||||
acc
|
||||
(collect (rest items)
|
||||
(if (even? (first items))
|
||||
(append acc (first items))
|
||||
acc))))
|
||||
""")
|
||||
assert result == [2, 4]
|
||||
|
||||
def test_fibonacci(self):
|
||||
"""Fibonacci via named let."""
|
||||
result = ev("""
|
||||
(let fib ((n 10) (a 0) (b 1))
|
||||
(if (= n 0) a
|
||||
(fib (- n 1) b (+ a b))))
|
||||
""")
|
||||
assert result == 55
|
||||
|
||||
def test_string_building(self):
|
||||
"""Named let for building strings."""
|
||||
result = ev("""
|
||||
(let build ((items (list "a" "b" "c")) (acc ""))
|
||||
(if (empty? items)
|
||||
acc
|
||||
(build (rest items)
|
||||
(str acc (if (= acc "") "" ",") (first items)))))
|
||||
""")
|
||||
assert result == "a,b,c"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# letrec
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLetrec:
|
||||
def test_basic(self):
|
||||
"""Simple letrec with a self-referencing lambda."""
|
||||
result = ev("""
|
||||
(letrec ((double (fn (x) (* x 2))))
|
||||
(double 21))
|
||||
""")
|
||||
assert result == 42
|
||||
|
||||
def test_mutual_recursion(self):
|
||||
"""Classic even?/odd? mutual recursion."""
|
||||
result = ev("""
|
||||
(letrec ((my-even? (fn (n)
|
||||
(if (= n 0) true (my-odd? (- n 1)))))
|
||||
(my-odd? (fn (n)
|
||||
(if (= n 0) false (my-even? (- n 1))))))
|
||||
(list (my-even? 10) (my-odd? 10)
|
||||
(my-even? 7) (my-odd? 7)))
|
||||
""")
|
||||
assert result == [True, False, False, True]
|
||||
|
||||
def test_clojure_style(self):
|
||||
"""letrec with flat bindings."""
|
||||
result = ev("""
|
||||
(letrec (f (fn (x) (if (= x 0) 1 (* x (f (- x 1))))))
|
||||
(f 5))
|
||||
""")
|
||||
assert result == 120
|
||||
|
||||
def test_closures_see_each_other(self):
|
||||
"""Lambdas in letrec see each other's final values."""
|
||||
result = ev("""
|
||||
(letrec ((a (fn () (b)))
|
||||
(b (fn () 42)))
|
||||
(a))
|
||||
""")
|
||||
assert result == 42
|
||||
|
||||
def test_non_forward_ref(self):
|
||||
"""letrec with non-lambda values that don't reference each other."""
|
||||
result = ev("""
|
||||
(letrec ((x 10) (y 20))
|
||||
(+ x y))
|
||||
""")
|
||||
assert result == 30
|
||||
|
||||
def test_three_way_mutual(self):
|
||||
"""Three mutually recursive functions."""
|
||||
result = ev("""
|
||||
(letrec ((f (fn (n) (if (= n 0) 1 (g (- n 1)))))
|
||||
(g (fn (n) (if (= n 0) 2 (h (- n 1)))))
|
||||
(h (fn (n) (if (= n 0) 3 (f (- n 1))))))
|
||||
(list (f 0) (f 1) (f 2) (f 3)
|
||||
(g 0) (g 1) (g 2)))
|
||||
""")
|
||||
# f(0)=1, f(1)=g(0)=2, f(2)=g(1)=h(0)=3, f(3)=g(2)=h(1)=f(0)=1
|
||||
# g(0)=2, g(1)=h(0)=3, g(2)=h(1)=f(0)=1
|
||||
assert result == [1, 2, 3, 1, 2, 3, 1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dynamic-wind
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDynamicWind:
|
||||
def _make_log_env(self):
|
||||
"""Create env with a log! function that appends to a Python list."""
|
||||
log = []
|
||||
env = {"log!": lambda msg: log.append(msg) or NIL}
|
||||
return env, log
|
||||
|
||||
def test_basic_flow(self):
|
||||
"""Entry and exit thunks called around body."""
|
||||
env, log = self._make_log_env()
|
||||
result = ev("""
|
||||
(dynamic-wind
|
||||
(fn () (log! "enter"))
|
||||
(fn () (do (log! "body") 42))
|
||||
(fn () (log! "exit")))
|
||||
""", env)
|
||||
assert result == 42
|
||||
assert log == ["enter", "body", "exit"]
|
||||
|
||||
def test_after_called_on_error(self):
|
||||
"""Exit thunk is called even when body raises an error."""
|
||||
env, log = self._make_log_env()
|
||||
with pytest.raises(Exception):
|
||||
ev("""
|
||||
(dynamic-wind
|
||||
(fn () (log! "enter"))
|
||||
(fn () (do (log! "body") (error "boom")))
|
||||
(fn () (log! "exit")))
|
||||
""", env)
|
||||
assert log == ["enter", "body", "exit"]
|
||||
|
||||
def test_nested(self):
|
||||
"""Nested dynamic-wind calls entry/exit in correct order."""
|
||||
env, log = self._make_log_env()
|
||||
ev("""
|
||||
(dynamic-wind
|
||||
(fn () (log! "outer-in"))
|
||||
(fn ()
|
||||
(dynamic-wind
|
||||
(fn () (log! "inner-in"))
|
||||
(fn () (log! "body"))
|
||||
(fn () (log! "inner-out"))))
|
||||
(fn () (log! "outer-out")))
|
||||
""", env)
|
||||
assert log == [
|
||||
"outer-in", "inner-in", "body", "inner-out", "outer-out"
|
||||
]
|
||||
|
||||
def test_return_value(self):
|
||||
"""Body return value is propagated."""
|
||||
result = ev("""
|
||||
(dynamic-wind
|
||||
(fn () nil)
|
||||
(fn () (+ 20 22))
|
||||
(fn () nil))
|
||||
""")
|
||||
assert result == 42
|
||||
|
||||
def test_before_after_are_thunks(self):
|
||||
"""Before and after must be zero-arg functions."""
|
||||
env, log = self._make_log_env()
|
||||
# Verify it works with native Python callables too
|
||||
env["enter"] = lambda: log.append("in") or NIL
|
||||
env["leave"] = lambda: log.append("out") or NIL
|
||||
result = ev("""
|
||||
(dynamic-wind enter (fn () 99) leave)
|
||||
""", env)
|
||||
assert result == 99
|
||||
assert log == ["in", "out"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Three-tier equality
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEquality:
|
||||
def test_eq_identity_same_object(self):
|
||||
"""eq? is true for the same object."""
|
||||
env = {}
|
||||
ev("(define x (list 1 2 3))", env)
|
||||
assert ev("(eq? x x)", env) is True
|
||||
|
||||
def test_eq_identity_different_objects(self):
|
||||
"""eq? is false for different objects with same value."""
|
||||
assert ev("(eq? (list 1 2) (list 1 2))") is False
|
||||
|
||||
def test_eq_numbers(self):
|
||||
"""eq? on small ints — Python interns them, so identity holds."""
|
||||
assert ev("(eq? 42 42)") is True
|
||||
|
||||
def test_eqv_numbers(self):
|
||||
"""eqv? compares numbers by value."""
|
||||
assert ev("(eqv? 42 42)") is True
|
||||
assert ev("(eqv? 42 43)") is False
|
||||
|
||||
def test_eqv_strings(self):
|
||||
"""eqv? compares strings by value."""
|
||||
assert ev('(eqv? "hello" "hello")') is True
|
||||
assert ev('(eqv? "hello" "world")') is False
|
||||
|
||||
def test_eqv_nil(self):
|
||||
"""eqv? on nil values."""
|
||||
assert ev("(eqv? nil nil)") is True
|
||||
|
||||
def test_eqv_booleans(self):
|
||||
assert ev("(eqv? true true)") is True
|
||||
assert ev("(eqv? true false)") is False
|
||||
|
||||
def test_eqv_different_lists(self):
|
||||
"""eqv? is false for different list objects."""
|
||||
assert ev("(eqv? (list 1 2) (list 1 2))") is False
|
||||
|
||||
def test_equal_deep(self):
|
||||
"""equal? does deep structural comparison."""
|
||||
assert ev("(equal? (list 1 2 3) (list 1 2 3))") is True
|
||||
assert ev("(equal? (list 1 2) (list 1 2 3))") is False
|
||||
|
||||
def test_equal_nested(self):
|
||||
"""equal? recursively compares nested structures."""
|
||||
assert ev("(equal? {:a (list 1 2)} {:a (list 1 2)})") is True
|
||||
|
||||
def test_equal_is_same_as_equals(self):
|
||||
"""equal? and = have the same semantics."""
|
||||
assert ev("(equal? 42 42)") is True
|
||||
assert ev("(= 42 42)") is True
|
||||
assert ev("(equal? (list 1) (list 1))") is True
|
||||
assert ev("(= (list 1) (list 1))") is True
|
||||
|
||||
def test_eq_eqv_equal_hierarchy(self):
|
||||
"""eq? ⊂ eqv? ⊂ equal? — each is progressively looser."""
|
||||
env = {}
|
||||
ev("(define x (list 1 2 3))", env)
|
||||
# Same object: all three true
|
||||
assert ev("(eq? x x)", env) is True
|
||||
assert ev("(eqv? x x)", env) is True
|
||||
assert ev("(equal? x x)", env) is True
|
||||
# Different objects, same value: eq? false, eqv? false, equal? true
|
||||
assert ev("(eq? (list 1 2) (list 1 2))", env) is False
|
||||
assert ev("(eqv? (list 1 2) (list 1 2))", env) is False
|
||||
assert ev("(equal? (list 1 2) (list 1 2))", env) is True
|
||||
415
shared/sx/tests/test_sx_ref.py
Normal file
415
shared/sx/tests/test_sx_ref.py
Normal file
@@ -0,0 +1,415 @@
|
||||
"""Tests for the transpiled sx_ref.py evaluator.
|
||||
|
||||
Runs the same test cases as test_evaluator.py and test_html.py but
|
||||
against the bootstrap-compiled evaluator to verify correctness.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from shared.sx.parser import parse
|
||||
from shared.sx.types import Symbol, Keyword, NIL, Lambda, Component, Macro
|
||||
from shared.sx.ref import sx_ref
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ev(text, env=None):
|
||||
"""Parse and evaluate a single expression via sx_ref."""
|
||||
return sx_ref.evaluate(parse(text), env)
|
||||
|
||||
|
||||
def render(text, env=None):
|
||||
"""Parse and render via sx_ref."""
|
||||
return sx_ref.render(parse(text), env)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Literals and lookups
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLiterals:
|
||||
def test_int(self):
|
||||
assert ev("42") == 42
|
||||
|
||||
def test_string(self):
|
||||
assert ev('"hello"') == "hello"
|
||||
|
||||
def test_true(self):
|
||||
assert ev("true") is True
|
||||
|
||||
def test_nil(self):
|
||||
assert ev("nil") is NIL
|
||||
|
||||
def test_symbol_lookup(self):
|
||||
assert ev("x", {"x": 10}) == 10
|
||||
|
||||
def test_undefined_symbol(self):
|
||||
with pytest.raises(sx_ref.EvalError, match="Undefined symbol"):
|
||||
ev("xyz")
|
||||
|
||||
def test_keyword_evaluates_to_name(self):
|
||||
assert ev(":foo") == "foo"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Arithmetic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestArithmetic:
|
||||
def test_add(self):
|
||||
assert ev("(+ 1 2 3)") == 6
|
||||
|
||||
def test_sub(self):
|
||||
assert ev("(- 10 3)") == 7
|
||||
|
||||
def test_mul(self):
|
||||
assert ev("(* 2 3 4)") == 24
|
||||
|
||||
def test_div(self):
|
||||
assert ev("(/ 10 4)") == 2.5
|
||||
|
||||
def test_mod(self):
|
||||
assert ev("(mod 7 3)") == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Special forms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSpecialForms:
|
||||
def test_if_true(self):
|
||||
assert ev("(if true 1 2)") == 1
|
||||
|
||||
def test_if_false(self):
|
||||
assert ev("(if false 1 2)") == 2
|
||||
|
||||
def test_if_no_else(self):
|
||||
assert ev("(if false 1)") is NIL
|
||||
|
||||
def test_when_true(self):
|
||||
assert ev("(when true 42)") == 42
|
||||
|
||||
def test_when_false(self):
|
||||
assert ev("(when false 42)") is NIL
|
||||
|
||||
def test_and_short_circuit(self):
|
||||
assert ev("(and true true 3)") == 3
|
||||
assert ev("(and true false 3)") is False
|
||||
|
||||
def test_or_short_circuit(self):
|
||||
assert ev("(or false false 3)") == 3
|
||||
assert ev("(or false 2 3)") == 2
|
||||
|
||||
def test_let_scheme_style(self):
|
||||
assert ev("(let ((x 10) (y 20)) (+ x y))") == 30
|
||||
|
||||
def test_let_clojure_style(self):
|
||||
assert ev("(let (x 10 y 20) (+ x y))") == 30
|
||||
|
||||
def test_let_sequential(self):
|
||||
assert ev("(let ((x 1) (y (+ x 1))) y)") == 2
|
||||
|
||||
def test_begin(self):
|
||||
assert ev("(begin 1 2 3)") == 3
|
||||
|
||||
def test_quote(self):
|
||||
result = ev("(quote (a b c))")
|
||||
assert result == [Symbol("a"), Symbol("b"), Symbol("c")]
|
||||
|
||||
def test_cond_clojure(self):
|
||||
assert ev("(cond false 1 true 2 :else 3)") == 2
|
||||
|
||||
def test_cond_else(self):
|
||||
assert ev("(cond false 1 false 2 :else 99)") == 99
|
||||
|
||||
def test_case(self):
|
||||
assert ev('(case 2 1 "one" 2 "two" :else "other")') == "two"
|
||||
|
||||
def test_thread_first(self):
|
||||
assert ev("(-> 5 (+ 3) (* 2))") == 16
|
||||
|
||||
def test_define(self):
|
||||
env = {}
|
||||
ev("(define x 42)", env)
|
||||
assert env["x"] == 42
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lambda
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLambda:
|
||||
def test_create_and_call(self):
|
||||
assert ev("((fn (x) (* x x)) 5)") == 25
|
||||
|
||||
def test_closure(self):
|
||||
result = ev("(let ((a 10)) ((fn (x) (+ x a)) 5))")
|
||||
assert result == 15
|
||||
|
||||
def test_higher_order(self):
|
||||
result = ev("(let ((double (fn (x) (* x 2)))) (double 7))")
|
||||
assert result == 14
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collections
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCollections:
|
||||
def test_list_constructor(self):
|
||||
assert ev("(list 1 2 3)") == [1, 2, 3]
|
||||
|
||||
def test_dict_constructor(self):
|
||||
assert ev("(dict :a 1 :b 2)") == {"a": 1, "b": 2}
|
||||
|
||||
def test_get_dict(self):
|
||||
assert ev('(get {:a 1 :b 2} "a")') == 1
|
||||
|
||||
def test_get_list(self):
|
||||
assert ev("(get (list 10 20 30) 1)") == 20
|
||||
|
||||
def test_first_last_rest(self):
|
||||
assert ev("(first (list 1 2 3))") == 1
|
||||
assert ev("(last (list 1 2 3))") == 3
|
||||
assert ev("(rest (list 1 2 3))") == [2, 3]
|
||||
|
||||
def test_len(self):
|
||||
assert ev("(len (list 1 2 3))") == 3
|
||||
|
||||
def test_concat(self):
|
||||
assert ev("(concat (list 1 2) (list 3 4))") == [1, 2, 3, 4]
|
||||
|
||||
def test_cons(self):
|
||||
assert ev("(cons 0 (list 1 2))") == [0, 1, 2]
|
||||
|
||||
def test_merge(self):
|
||||
assert ev("(merge {:a 1} {:b 2} {:a 3})") == {"a": 3, "b": 2}
|
||||
|
||||
def test_empty(self):
|
||||
assert ev("(empty? (list))") is True
|
||||
assert ev("(empty? (list 1))") is False
|
||||
assert ev("(empty? nil)") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Higher-order forms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHigherOrder:
|
||||
def test_map(self):
|
||||
assert ev("(map (fn (x) (* x x)) (list 1 2 3 4))") == [1, 4, 9, 16]
|
||||
|
||||
def test_filter(self):
|
||||
assert ev("(filter (fn (x) (> x 2)) (list 1 2 3 4))") == [3, 4]
|
||||
|
||||
def test_reduce(self):
|
||||
assert ev("(reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3))") == 6
|
||||
|
||||
def test_some(self):
|
||||
assert ev("(some (fn (x) (> x 3)) (list 1 2 3 4 5))") is True
|
||||
|
||||
def test_for_each(self):
|
||||
result = ev("(for-each (fn (x) x) (list 1 2 3))")
|
||||
assert result is NIL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# String ops
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStrings:
|
||||
def test_str(self):
|
||||
assert ev('(str "hello" " " "world")') == "hello world"
|
||||
|
||||
def test_upper_lower(self):
|
||||
assert ev('(upper "hello")') == "HELLO"
|
||||
assert ev('(lower "HELLO")') == "hello"
|
||||
|
||||
def test_split_join(self):
|
||||
assert ev('(split "a,b,c" ",")') == ["a", "b", "c"]
|
||||
assert ev('(join "-" (list "a" "b"))') == "a-b"
|
||||
|
||||
def test_starts_ends(self):
|
||||
assert ev('(starts-with? "hello" "hel")') is True
|
||||
assert ev('(ends-with? "hello" "llo")') is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Components
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComponents:
|
||||
def test_defcomp_and_render(self):
|
||||
env = {}
|
||||
ev("(defcomp ~box (&key title) (div :class \"box\" title))", env)
|
||||
result = render("(~box :title \"hi\")", env)
|
||||
assert result == '<div class="box">hi</div>'
|
||||
|
||||
def test_defcomp_with_children(self):
|
||||
env = {}
|
||||
ev("(defcomp ~wrap (&rest children) (div children))", env)
|
||||
result = render('(~wrap (span "a") (span "b"))', env)
|
||||
assert result == '<div><span>a</span><span>b</span></div>'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHTMLRendering:
|
||||
def test_basic_element(self):
|
||||
assert render("(div)") == "<div></div>"
|
||||
|
||||
def test_text_content(self):
|
||||
assert render('(p "hello")') == "<p>hello</p>"
|
||||
|
||||
def test_attributes(self):
|
||||
result = render('(a :href "/about" "link")')
|
||||
assert result == '<a href="/about">link</a>'
|
||||
|
||||
def test_void_element(self):
|
||||
result = render('(br)')
|
||||
assert result == "<br />"
|
||||
|
||||
def test_nested(self):
|
||||
result = render('(div (p "a") (p "b"))')
|
||||
assert result == "<div><p>a</p><p>b</p></div>"
|
||||
|
||||
def test_fragment(self):
|
||||
result = render('(<> (span "a") (span "b"))')
|
||||
assert result == "<span>a</span><span>b</span>"
|
||||
|
||||
def test_conditional_rendering(self):
|
||||
result = render('(if true (span "yes") (span "no"))')
|
||||
assert result == "<span>yes</span>"
|
||||
|
||||
def test_map_rendering(self):
|
||||
result = render('(map (fn (x) (li x)) (list "a" "b"))')
|
||||
assert result == "<li>a</li><li>b</li>"
|
||||
|
||||
def test_html_escaping(self):
|
||||
result = render('(span "<b>bold</b>")')
|
||||
assert result == "<span><b>bold</b></span>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aser (SX wire format)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAser:
|
||||
def test_render_to_sx_basic(self):
|
||||
expr = parse("(div :class \"foo\" \"hello\")")
|
||||
result = sx_ref.render_to_sx(expr, {})
|
||||
assert result == '(div :class "foo" "hello")'
|
||||
|
||||
def test_component_not_expanded(self):
|
||||
expr = parse('(~card :title "hi")')
|
||||
result = sx_ref.render_to_sx(expr, {})
|
||||
assert result == '(~card :title "hi")'
|
||||
|
||||
def test_fragment(self):
|
||||
expr = parse('(<> "a" "b")')
|
||||
result = sx_ref.render_to_sx(expr, {})
|
||||
assert result == '(<> "a" "b")'
|
||||
|
||||
def test_let_evaluates(self):
|
||||
expr = parse('(let ((x 5)) x)')
|
||||
result = sx_ref.render_to_sx(expr, {})
|
||||
assert result == "5"
|
||||
|
||||
def test_if_evaluates(self):
|
||||
expr = parse('(if true "yes" "no")')
|
||||
result = sx_ref.render_to_sx(expr, {})
|
||||
assert result == 'yes' # strings pass through unserialized
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Macros
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDefcomp:
|
||||
def test_defcomp_basic(self):
|
||||
"""defcomp should parse &key params without trying to eval &key as a symbol."""
|
||||
env = {}
|
||||
ev('(defcomp ~card (&key title) (div title))', env)
|
||||
assert isinstance(env.get("~card"), Component)
|
||||
|
||||
def test_defcomp_render(self):
|
||||
env = {}
|
||||
ev('(defcomp ~card (&key title) (div title))', env)
|
||||
result = render('(~card :title "hello")', env)
|
||||
assert result == '<div>hello</div>'
|
||||
|
||||
def test_defcomp_with_children(self):
|
||||
env = {}
|
||||
ev('(defcomp ~wrap (&key title &rest children) (div title children))', env)
|
||||
comp = env["~wrap"]
|
||||
assert comp.has_children is True
|
||||
assert "title" in comp.params
|
||||
|
||||
def test_defcomp_multiple_params(self):
|
||||
env = {}
|
||||
ev('(defcomp ~box (&key a b c) (div a b c))', env)
|
||||
result = render('(~box :a "1" :b "2" :c "3")', env)
|
||||
assert result == '<div>123</div>'
|
||||
|
||||
|
||||
class TestDefhandler:
|
||||
def test_defhandler_basic(self):
|
||||
"""defhandler should parse &key params and create a HandlerDef."""
|
||||
from shared.sx.types import HandlerDef
|
||||
env = {}
|
||||
ev('(defhandler link-card (&key slug keys) (div slug))', env)
|
||||
hdef = env.get("handler:link-card")
|
||||
assert isinstance(hdef, HandlerDef)
|
||||
assert hdef.name == "link-card"
|
||||
assert hdef.params == ["slug", "keys"]
|
||||
|
||||
def test_defquery_basic(self):
|
||||
from shared.sx.types import QueryDef
|
||||
env = {}
|
||||
ev('(defquery get-post (&key slug) "Fetch a post" (list slug))', env)
|
||||
qdef = env.get("query:get-post")
|
||||
assert isinstance(qdef, QueryDef)
|
||||
assert qdef.params == ["slug"]
|
||||
assert qdef.doc == "Fetch a post"
|
||||
|
||||
def test_defaction_basic(self):
|
||||
from shared.sx.types import ActionDef
|
||||
env = {}
|
||||
ev('(defaction save-post (&key title body) (list title body))', env)
|
||||
adef = env.get("action:save-post")
|
||||
assert isinstance(adef, ActionDef)
|
||||
assert adef.params == ["title", "body"]
|
||||
|
||||
|
||||
class TestHOWithNativeCallable:
|
||||
def test_map_with_native_fn(self):
|
||||
"""map should work with native callables (primitives), not just Lambda."""
|
||||
result = ev('(map str (list 1 2 3))')
|
||||
assert result == ["1", "2", "3"]
|
||||
|
||||
def test_filter_with_native_fn(self):
|
||||
result = ev('(filter number? (list 1 "a" 2 "b" 3))')
|
||||
assert result == [1, 2, 3]
|
||||
|
||||
def test_map_with_env_fn(self):
|
||||
"""map should work with Python functions registered in env."""
|
||||
env = {"double": lambda x: x * 2}
|
||||
result = ev('(map double (list 1 2 3))', env)
|
||||
assert result == [2, 4, 6]
|
||||
|
||||
def test_ho_non_callable_fails_fast(self):
|
||||
"""Passing a non-callable to map should error clearly."""
|
||||
import pytest
|
||||
with pytest.raises(sx_ref.EvalError, match="Not callable"):
|
||||
ev('(map 42 (list 1 2 3))')
|
||||
|
||||
|
||||
class TestMacros:
|
||||
def test_defmacro_and_expand(self):
|
||||
env = {}
|
||||
ev("(defmacro unless (test body) (list (quote if) (list (quote not) test) body))", env)
|
||||
result = ev('(unless false "ran")', env)
|
||||
assert result == "ran"
|
||||
@@ -302,9 +302,45 @@ class StyleValue:
|
||||
return self.class_name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Continuation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Continuation:
|
||||
"""A captured delimited continuation (shift/reset).
|
||||
|
||||
Callable with one argument — provides the value that the shift
|
||||
expression "returns" within the delimited context.
|
||||
"""
|
||||
__slots__ = ("fn",)
|
||||
|
||||
def __init__(self, fn):
|
||||
self.fn = fn
|
||||
|
||||
def __call__(self, value=NIL):
|
||||
return self.fn(value)
|
||||
|
||||
def __repr__(self):
|
||||
return "<continuation>"
|
||||
|
||||
|
||||
class _ShiftSignal(BaseException):
|
||||
"""Raised by shift to unwind to the nearest reset.
|
||||
|
||||
Inherits from BaseException (not Exception) to avoid being caught
|
||||
by generic except clauses in user code.
|
||||
"""
|
||||
__slots__ = ("k_name", "body", "env")
|
||||
|
||||
def __init__(self, k_name, body, env):
|
||||
self.k_name = k_name
|
||||
self.body = body
|
||||
self.env = env
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type alias
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# An s-expression value after evaluation
|
||||
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | HandlerDef | RelationDef | PageDef | QueryDef | ActionDef | StyleValue | list | dict | _Nil | None
|
||||
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | Continuation | HandlerDef | RelationDef | PageDef | QueryDef | ActionDef | StyleValue | list | dict | _Nil | None
|
||||
|
||||
@@ -238,7 +238,7 @@ def _tokenize_bash(code: str) -> list[tuple[str, str]]:
|
||||
|
||||
def highlight(code: str, language: str = "lisp"):
|
||||
"""Highlight code in the given language. Returns SxExpr for wire format."""
|
||||
from shared.sx.parser import SxExpr
|
||||
from shared.sx.parser import SxExpr, serialize
|
||||
if language in ("lisp", "sx", "sexp"):
|
||||
return SxExpr(highlight_sx(code))
|
||||
elif language in ("python", "py"):
|
||||
@@ -246,5 +246,4 @@ def highlight(code: str, language: str = "lisp"):
|
||||
elif language in ("bash", "sh", "shell"):
|
||||
return SxExpr(highlight_bash(code))
|
||||
# Fallback: no highlighting, just escaped text
|
||||
escaped = code.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return SxExpr(f'(span "{escaped}")')
|
||||
return SxExpr("(span " + serialize(code) + ")")
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
(~doc-section :title "What sx is not" :id "not"
|
||||
(ul :class "space-y-2 text-stone-600"
|
||||
(li "Not a general-purpose programming language — it's a UI rendering language")
|
||||
(li "Not a full Lisp — it has macros and TCO, but no continuations or call/cc")
|
||||
(li "Not a full Lisp — it has macros, TCO, and delimited continuations, but no full call/cc")
|
||||
(li "Not production-hardened at scale — it runs one website")))))
|
||||
|
||||
(defcomp ~docs-getting-started-content ()
|
||||
@@ -76,6 +76,15 @@
|
||||
"sx provides ~80 built-in pure functions. They work identically on server (Python) and client (JavaScript).")
|
||||
(div :class "space-y-6" prims))))
|
||||
|
||||
(defcomp ~docs-special-forms-content (&key forms)
|
||||
(~doc-page :title "Special Forms"
|
||||
(~doc-section :title "Syntactic constructs" :id "special-forms"
|
||||
(p :class "text-stone-600"
|
||||
"Special forms are syntactic constructs whose arguments are NOT evaluated before dispatch. Each form has its own evaluation rules — unlike primitives, which receive pre-evaluated values. Together with primitives, special forms define the complete language surface.")
|
||||
(p :class "text-stone-600"
|
||||
"Forms marked with a tail position enable " (a :href "/essays/tco" :class "text-violet-600 hover:underline" "tail-call optimization") " — recursive calls in tail position use constant stack space.")
|
||||
(div :class "space-y-10" forms))))
|
||||
|
||||
(defcomp ~docs-css-content ()
|
||||
(~doc-page :title "On-Demand CSS"
|
||||
(~doc-section :title "How it works" :id "how"
|
||||
|
||||
@@ -123,3 +123,40 @@
|
||||
:category cat
|
||||
:primitives (get primitives cat)))
|
||||
(keys primitives))))
|
||||
|
||||
;; Build all special form category sections from a {category: [form, ...]} dict.
|
||||
(defcomp ~doc-special-forms-tables (&key forms)
|
||||
(<> (map (fn (cat)
|
||||
(~doc-special-forms-category
|
||||
:category cat
|
||||
:forms (get forms cat)))
|
||||
(keys forms))))
|
||||
|
||||
(defcomp ~doc-special-forms-category (&key category forms)
|
||||
(div :class "space-y-4"
|
||||
(h3 :class "text-xl font-semibold text-stone-800 border-b border-stone-200 pb-2" category)
|
||||
(div :class "space-y-4"
|
||||
(map (fn (f)
|
||||
(~doc-special-form-card
|
||||
:name (get f "name")
|
||||
:syntax (get f "syntax")
|
||||
:doc (get f "doc")
|
||||
:tail-position (get f "tail-position")
|
||||
:example (get f "example")))
|
||||
forms))))
|
||||
|
||||
(defcomp ~doc-special-form-card (&key name syntax doc tail-position example)
|
||||
(div :class "border border-stone-200 rounded-lg p-4 space-y-3"
|
||||
(div :class "flex items-baseline gap-3"
|
||||
(code :class "text-lg font-bold text-violet-700" name)
|
||||
(when (not (= tail-position "none"))
|
||||
(span :class "text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700" "TCO")))
|
||||
(when (not (= syntax ""))
|
||||
(pre :class "bg-stone-50 rounded px-3 py-2 text-sm font-mono text-stone-700 overflow-x-auto"
|
||||
syntax))
|
||||
(p :class "text-stone-600 text-sm whitespace-pre-line" doc)
|
||||
(when (not (= tail-position ""))
|
||||
(p :class "text-xs text-stone-500"
|
||||
(span :class "font-semibold" "Tail position: ") tail-position))
|
||||
(when (not (= example ""))
|
||||
(~doc-code :code (highlight example "lisp")))))
|
||||
|
||||
113
sx/sx/essays.sx
113
sx/sx/essays.sx
File diff suppressed because one or more lines are too long
@@ -8,6 +8,7 @@
|
||||
(dict :label "Components" :href "/docs/components")
|
||||
(dict :label "Evaluator" :href "/docs/evaluator")
|
||||
(dict :label "Primitives" :href "/docs/primitives")
|
||||
(dict :label "Special Forms" :href "/docs/special-forms")
|
||||
(dict :label "CSS" :href "/docs/css")
|
||||
(dict :label "Server Rendering" :href "/docs/server-rendering")))
|
||||
|
||||
@@ -75,6 +76,8 @@
|
||||
:summary "Self-reference, and the tangled hierarchy of a language that defines itself.")
|
||||
(dict :label "The Reflexive Web" :href "/essays/reflexive-web"
|
||||
: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 "sx sucks" :href "/essays/sx-sucks"
|
||||
:summary "An honest accounting of everything wrong with SX and why you probably shouldn't use it.")))
|
||||
|
||||
@@ -84,6 +87,7 @@
|
||||
(dict :label "Parser" :href "/specs/parser")
|
||||
(dict :label "Evaluator" :href "/specs/evaluator")
|
||||
(dict :label "Primitives" :href "/specs/primitives")
|
||||
(dict :label "Special Forms" :href "/specs/special-forms")
|
||||
(dict :label "Renderer" :href "/specs/renderer")
|
||||
(dict :label "Adapters" :href "/specs/adapters")
|
||||
(dict :label "DOM Adapter" :href "/specs/adapter-dom")
|
||||
@@ -93,11 +97,14 @@
|
||||
(dict :label "SxEngine" :href "/specs/engine")
|
||||
(dict :label "Orchestration" :href "/specs/orchestration")
|
||||
(dict :label "Boot" :href "/specs/boot")
|
||||
(dict :label "CSSX" :href "/specs/cssx")))
|
||||
(dict :label "CSSX" :href "/specs/cssx")
|
||||
(dict :label "Continuations" :href "/specs/continuations")
|
||||
(dict :label "call/cc" :href "/specs/callcc")))
|
||||
|
||||
(define bootstrappers-nav-items (list
|
||||
(dict :label "Overview" :href "/bootstrappers/")
|
||||
(dict :label "JavaScript" :href "/bootstrappers/javascript")))
|
||||
(dict :label "JavaScript" :href "/bootstrappers/javascript")
|
||||
(dict :label "Python" :href "/bootstrappers/python")))
|
||||
|
||||
;; Spec file registry — canonical metadata for spec viewer pages.
|
||||
;; Python only handles file I/O (read-spec-file); all metadata lives here.
|
||||
@@ -114,6 +121,9 @@
|
||||
(dict :slug "primitives" :filename "primitives.sx" :title "Primitives"
|
||||
:desc "All built-in pure functions and their signatures."
|
||||
:prose "Primitives are the built-in functions available in every SX environment. Each entry declares a name, parameter signature, and semantics. Bootstrap compilers implement these natively per target (JavaScript, Python, etc.). The registry covers arithmetic, comparison, string manipulation, list operations, dict operations, type predicates, and control flow helpers. All primitives are pure — they take values and return values with no side effects. Platform-specific operations (DOM access, HTTP, file I/O) are provided separately via platform bridge functions, not primitives.")
|
||||
(dict :slug "special-forms" :filename "special-forms.sx" :title "Special Forms"
|
||||
:desc "All special forms — syntactic constructs with custom evaluation rules."
|
||||
:prose "Special forms are the syntactic constructs whose arguments are NOT evaluated before dispatch. Each form has its own evaluation rules — unlike primitives, which receive pre-evaluated values. Together with primitives, special forms define the complete language surface. The registry covers control flow (if, when, cond, case, and, or), binding (let, letrec, define, set!), functions (lambda, defcomp, defmacro), sequencing (begin, do, thread-first), quoting (quote, quasiquote), continuations (reset, shift), guards (dynamic-wind), higher-order forms (map, filter, reduce), and domain-specific definitions (defstyle, defhandler, defpage, defquery, defaction).")
|
||||
(dict :slug "renderer" :filename "render.sx" :title "Renderer"
|
||||
:desc "Shared rendering registries and utilities used by all adapters."
|
||||
:prose "The renderer defines what is renderable and how arguments are parsed, but not the output format. It maintains registries of known HTML tags, SVG tags, void elements, and boolean attributes. It specifies how keyword arguments on elements become HTML attributes, how children are collected, and how special attributes (class, style, data-*) are handled. All three adapters (DOM, HTML, SX wire) share these definitions so they agree on what constitutes valid markup. The renderer also defines the StyleValue type used by the CSSX on-demand CSS system.")))
|
||||
@@ -143,7 +153,15 @@
|
||||
:desc "On-demand CSS: style dictionary, keyword resolution, rule injection."
|
||||
:prose "CSSX is the on-demand CSS system. It resolves keyword atoms (:flex, :gap-4, :hover:bg-sky-200) into StyleValue objects with content-addressed class names, injecting CSS rules into the document on first use. The style dictionary is a JSON blob containing: atoms (keyword to CSS declarations), pseudo-variants (hover:, focus:, etc.), responsive breakpoints (md:, lg:, etc.), keyframe animations, arbitrary value patterns, and child selector prefixes (space-x-, space-y-). Classes are only emitted when used, keeping the CSS payload minimal. The dictionary is typically served inline in a <script type=\"text/sx-styles\"> tag.")))
|
||||
|
||||
(define all-spec-items (concat core-spec-items (concat adapter-spec-items browser-spec-items)))
|
||||
(define extension-spec-items (list
|
||||
(dict :slug "continuations" :filename "continuations.sx" :title "Continuations"
|
||||
:desc "Delimited continuations — shift/reset for suspendable rendering and cooperative scheduling."
|
||||
:prose "Delimited continuations capture the rest of a computation up to a delimiter. shift captures the continuation to the nearest reset as a first-class callable value. Unlike full call/cc, delimited continuations are composable — invoking one returns a value. This covers the practical use cases: suspendable server rendering, cooperative scheduling, linear async flows, wizard-style multi-step UIs, and undo. Each bootstrapper target implements the mechanism differently — generators in Python/JS, native shift/reset in Scheme, ContT in Haskell, CPS transform in Rust — but the semantics are identical. Optional extension: code that doesn't use continuations pays zero cost.")
|
||||
(dict :slug "callcc" :filename "callcc.sx" :title "call/cc"
|
||||
:desc "Full first-class continuations — call-with-current-continuation."
|
||||
:prose "Full call/cc captures the entire remaining computation as a first-class function — not just up to a delimiter, but all the way to the top level. Invoking the continuation abandons the current computation entirely and resumes from where it was captured. Strictly more powerful than delimited continuations, but harder to implement in targets that don't support it natively. Recommended for Scheme and Haskell targets where it's natural. Python, JavaScript, and Rust targets should prefer delimited continuations (continuations.sx) unless full escape semantics are genuinely needed. Optional extension: the continuation type is shared with continuations.sx if both are loaded.")))
|
||||
|
||||
(define all-spec-items (concat core-spec-items (concat adapter-spec-items (concat browser-spec-items extension-spec-items))))
|
||||
|
||||
(define find-spec
|
||||
(fn (slug)
|
||||
|
||||
@@ -47,6 +47,13 @@
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
"primitives.sx"))
|
||||
(td :class "px-3 py-2 text-stone-700" "All built-in pure functions and their signatures"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700"
|
||||
(a :href "/specs/special-forms" :class "hover:underline"
|
||||
:sx-get "/specs/special-forms" :sx-target "#main-panel" :sx-select "#main-panel"
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
"special-forms.sx"))
|
||||
(td :class "px-3 py-2 text-stone-700" "All special forms — syntactic constructs with custom evaluation rules"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700"
|
||||
(a :href "/specs/renderer" :class "hover:underline"
|
||||
@@ -157,7 +164,8 @@
|
||||
(pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words font-mono text-stone-700"
|
||||
"parser.sx (standalone — no dependencies)
|
||||
primitives.sx (standalone — declarative registry)
|
||||
eval.sx depends on: parser, primitives
|
||||
special-forms.sx (standalone — declarative registry)
|
||||
eval.sx depends on: parser, primitives, special-forms
|
||||
render.sx (standalone — shared registries)
|
||||
|
||||
adapter-dom.sx depends on: render, eval
|
||||
@@ -167,7 +175,39 @@ adapter-sx.sx depends on: render, eval
|
||||
engine.sx depends on: eval, adapter-dom
|
||||
orchestration.sx depends on: engine, adapter-dom
|
||||
cssx.sx depends on: render
|
||||
boot.sx depends on: cssx, orchestration, adapter-dom, render")))
|
||||
boot.sx depends on: cssx, orchestration, adapter-dom, render
|
||||
|
||||
;; Extensions (optional — loaded only when target requests them)
|
||||
continuations.sx depends on: eval (optional)
|
||||
callcc.sx depends on: eval (optional)")))
|
||||
|
||||
(div :class "space-y-3"
|
||||
(h2 :class "text-2xl font-semibold text-stone-800" "Extensions")
|
||||
(p :class "text-stone-600"
|
||||
"Optional bolt-on specifications that extend the core language. Bootstrappers include them only when the target requests them. Code that doesn't use extensions pays zero cost.")
|
||||
(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" "File")
|
||||
(th :class "px-3 py-2 font-medium text-stone-600" "Role")
|
||||
(th :class "px-3 py-2 font-medium text-stone-600" "Recommended targets")))
|
||||
(tbody
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700"
|
||||
(a :href "/specs/continuations" :class "hover:underline"
|
||||
:sx-get "/specs/continuations" :sx-target "#main-panel" :sx-select "#main-panel"
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
"continuations.sx"))
|
||||
(td :class "px-3 py-2 text-stone-700" "Delimited continuations — shift/reset")
|
||||
(td :class "px-3 py-2 text-stone-500" "All targets"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700"
|
||||
(a :href "/specs/callcc" :class "hover:underline"
|
||||
:sx-get "/specs/callcc" :sx-target "#main-panel" :sx-select "#main-panel"
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
"callcc.sx"))
|
||||
(td :class "px-3 py-2 text-stone-700" "Full first-class continuations — call/cc")
|
||||
(td :class "px-3 py-2 text-stone-500" "Scheme, Haskell"))))))
|
||||
|
||||
(div :class "space-y-3"
|
||||
(h2 :class "text-2xl font-semibold text-stone-800" "Self-hosting")
|
||||
@@ -268,9 +308,11 @@ boot.sx depends on: cssx, orchestration, adapter-dom, render")))
|
||||
(td :class "px-3 py-2 text-green-600" "Live"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "Python")
|
||||
(td :class "px-3 py-2 font-mono text-sm text-stone-400" "bootstrap_py.py")
|
||||
(td :class "px-3 py-2 font-mono text-sm text-stone-400" "shared/sx/")
|
||||
(td :class "px-3 py-2 text-stone-400" "Planned"))
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700"
|
||||
(a :href "/bootstrappers/python" :class "hover:underline"
|
||||
"bootstrap_py.py"))
|
||||
(td :class "px-3 py-2 font-mono text-sm text-stone-500" "sx_ref.py")
|
||||
(td :class "px-3 py-2 text-green-600" "Live"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "Rust")
|
||||
(td :class "px-3 py-2 font-mono text-sm text-stone-400" "bootstrap_rs.py")
|
||||
@@ -320,6 +362,47 @@ boot.sx depends on: cssx, orchestration, adapter-dom, render")))
|
||||
(pre :class "text-xs leading-relaxed whitespace-pre-wrap break-words"
|
||||
(code (highlight bootstrapped-output "javascript"))))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Python bootstrapper detail
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~bootstrapper-py-content (&key bootstrapper-source bootstrapped-output)
|
||||
(~doc-page :title "Python Bootstrapper"
|
||||
(div :class "space-y-8"
|
||||
|
||||
(div :class "space-y-3"
|
||||
(p :class "text-stone-600"
|
||||
"This page reads the canonical " (code :class "text-violet-700 text-sm" ".sx")
|
||||
" spec files, runs the Python bootstrapper, and displays both the compiler source and its generated Python output. "
|
||||
"The generated code below is live — it was produced by the bootstrapper at page load time.")
|
||||
(p :class "text-xs text-stone-400 italic"
|
||||
"With SX_USE_REF=1, the server-side SX evaluator running this page IS the bootstrapped output. "
|
||||
"This page re-runs the bootstrapper to display the source and result."))
|
||||
|
||||
(div :class "space-y-3"
|
||||
(div :class "flex items-baseline gap-3"
|
||||
(h2 :class "text-2xl font-semibold text-stone-800" "Bootstrapper")
|
||||
(span :class "text-sm text-stone-400 font-mono" "bootstrap_py.py"))
|
||||
(p :class "text-sm text-stone-500"
|
||||
"The compiler reads " (code :class "text-violet-700 text-sm" ".sx")
|
||||
" spec files (eval, primitives, render, adapter-html) "
|
||||
"and emits a standalone Python module. Platform bridge functions (type constructors, environment ops) "
|
||||
"are emitted as native Python implementations.")
|
||||
(div :class "bg-stone-100 rounded-lg p-5 max-h-96 overflow-y-auto border border-stone-200"
|
||||
(pre :class "text-xs leading-relaxed whitespace-pre-wrap break-words"
|
||||
(code (highlight bootstrapper-source "python")))))
|
||||
|
||||
(div :class "space-y-3"
|
||||
(div :class "flex items-baseline gap-3"
|
||||
(h2 :class "text-2xl font-semibold text-stone-800" "Generated Output")
|
||||
(span :class "text-sm text-stone-400 font-mono" "sx_ref.py"))
|
||||
(p :class "text-sm text-stone-500"
|
||||
"The Python below was generated by running the bootstrapper against the current spec files. "
|
||||
"It is a complete server-side SX evaluator — eval, primitives, and HTML renderer.")
|
||||
(div :class "bg-stone-100 rounded-lg p-5 max-h-96 overflow-y-auto border border-violet-300"
|
||||
(pre :class "text-xs leading-relaxed whitespace-pre-wrap break-words"
|
||||
(code (highlight bootstrapped-output "python"))))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Not found
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
"evaluator" (~docs-evaluator-content)
|
||||
"primitives" (~docs-primitives-content
|
||||
:prims (~doc-primitives-tables :primitives (primitives-data)))
|
||||
"special-forms" (~docs-special-forms-content
|
||||
:forms (~doc-special-forms-tables :forms (special-forms-data)))
|
||||
"css" (~docs-css-content)
|
||||
"server-rendering" (~docs-server-rendering-content)
|
||||
:else (~docs-introduction-content)))
|
||||
@@ -278,6 +280,7 @@
|
||||
"continuations" (~essay-continuations)
|
||||
"godel-escher-bach" (~essay-godel-escher-bach)
|
||||
"reflexive-web" (~essay-reflexive-web)
|
||||
"server-architecture" (~essay-server-architecture)
|
||||
:else (~essays-index-content)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
@@ -330,6 +333,14 @@
|
||||
:filename (get item "filename") :href (str "/specs/" (get item "slug"))
|
||||
:source (read-spec-file (get item "filename"))))
|
||||
browser-spec-items))
|
||||
"extensions" (~spec-overview-content
|
||||
:spec-title "Extensions"
|
||||
:spec-files (map (fn (item)
|
||||
(dict :title (get item "title") :desc (get item "desc")
|
||||
:prose (get item "prose")
|
||||
:filename (get item "filename") :href (str "/specs/" (get item "slug"))
|
||||
:source (read-spec-file (get item "filename"))))
|
||||
extension-spec-items))
|
||||
:else (let ((spec (find-spec slug)))
|
||||
(if spec
|
||||
(~spec-detail-content
|
||||
@@ -368,6 +379,10 @@
|
||||
:data (bootstrapper-data slug)
|
||||
:content (if bootstrapper-not-found
|
||||
(~spec-not-found :slug slug)
|
||||
(~bootstrapper-js-content
|
||||
:bootstrapper-source bootstrapper-source
|
||||
:bootstrapped-output bootstrapped-output)))
|
||||
(if (= slug "python")
|
||||
(~bootstrapper-py-content
|
||||
:bootstrapper-source bootstrapper-source
|
||||
:bootstrapped-output bootstrapped-output)
|
||||
(~bootstrapper-js-content
|
||||
:bootstrapper-source bootstrapper-source
|
||||
:bootstrapped-output bootstrapped-output))))
|
||||
|
||||
@@ -14,6 +14,7 @@ def _register_sx_helpers() -> None:
|
||||
register_page_helpers("sx", {
|
||||
"highlight": _highlight,
|
||||
"primitives-data": _primitives_data,
|
||||
"special-forms-data": _special_forms_data,
|
||||
"reference-data": _reference_data,
|
||||
"attr-detail-data": _attr_detail_data,
|
||||
"header-detail-data": _header_detail_data,
|
||||
@@ -29,6 +30,89 @@ def _primitives_data() -> dict:
|
||||
return PRIMITIVES
|
||||
|
||||
|
||||
def _special_forms_data() -> dict:
|
||||
"""Parse special-forms.sx and return categorized form data.
|
||||
|
||||
Returns a dict of category → list of form dicts, each with:
|
||||
name, syntax, doc, tail_position, example
|
||||
"""
|
||||
import os
|
||||
from shared.sx.parser import parse_all, serialize
|
||||
from shared.sx.types import Symbol, Keyword
|
||||
|
||||
spec_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"..", "..", "..", "shared", "sx", "ref", "special-forms.sx",
|
||||
)
|
||||
with open(spec_path) as f:
|
||||
exprs = parse_all(f.read())
|
||||
|
||||
# Categories inferred from comment sections in the file.
|
||||
# We assign forms to categories based on their order in the spec.
|
||||
categories: dict[str, list[dict]] = {}
|
||||
current_category = "Other"
|
||||
|
||||
# Map form names to categories
|
||||
category_map = {
|
||||
"if": "Control Flow", "when": "Control Flow", "cond": "Control Flow",
|
||||
"case": "Control Flow", "and": "Control Flow", "or": "Control Flow",
|
||||
"let": "Binding", "let*": "Binding", "letrec": "Binding",
|
||||
"define": "Binding", "set!": "Binding",
|
||||
"lambda": "Functions & Components", "fn": "Functions & Components",
|
||||
"defcomp": "Functions & Components", "defmacro": "Functions & Components",
|
||||
"begin": "Sequencing & Threading", "do": "Sequencing & Threading",
|
||||
"->": "Sequencing & Threading",
|
||||
"quote": "Quoting", "quasiquote": "Quoting",
|
||||
"reset": "Continuations", "shift": "Continuations",
|
||||
"dynamic-wind": "Guards",
|
||||
"map": "Higher-Order Forms", "map-indexed": "Higher-Order Forms",
|
||||
"filter": "Higher-Order Forms", "reduce": "Higher-Order Forms",
|
||||
"some": "Higher-Order Forms", "every?": "Higher-Order Forms",
|
||||
"for-each": "Higher-Order Forms",
|
||||
"defstyle": "Domain Definitions", "defkeyframes": "Domain Definitions",
|
||||
"defhandler": "Domain Definitions", "defpage": "Domain Definitions",
|
||||
"defquery": "Domain Definitions", "defaction": "Domain Definitions",
|
||||
}
|
||||
|
||||
for expr in exprs:
|
||||
if not isinstance(expr, list) or len(expr) < 2:
|
||||
continue
|
||||
head = expr[0]
|
||||
if not isinstance(head, Symbol) or head.name != "define-special-form":
|
||||
continue
|
||||
|
||||
name = expr[1]
|
||||
# Extract keyword args
|
||||
kwargs: dict[str, str] = {}
|
||||
i = 2
|
||||
while i < len(expr) - 1:
|
||||
if isinstance(expr[i], Keyword):
|
||||
key = expr[i].name
|
||||
val = expr[i + 1]
|
||||
if isinstance(val, list):
|
||||
# For :syntax, avoid quote sugar (quasiquote → `x)
|
||||
items = [serialize(item) for item in val]
|
||||
kwargs[key] = "(" + " ".join(items) + ")"
|
||||
else:
|
||||
kwargs[key] = str(val)
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
category = category_map.get(name, "Other")
|
||||
if category not in categories:
|
||||
categories[category] = []
|
||||
categories[category].append({
|
||||
"name": name,
|
||||
"syntax": kwargs.get("syntax", ""),
|
||||
"doc": kwargs.get("doc", ""),
|
||||
"tail-position": kwargs.get("tail-position", ""),
|
||||
"example": kwargs.get("example", ""),
|
||||
})
|
||||
|
||||
return categories
|
||||
|
||||
|
||||
def _reference_data(slug: str) -> dict:
|
||||
"""Return reference table data for a given slug.
|
||||
|
||||
@@ -135,29 +219,44 @@ def _bootstrapper_data(target: str) -> dict:
|
||||
"""
|
||||
import os
|
||||
|
||||
if target != "javascript":
|
||||
if target not in ("javascript", "python"):
|
||||
return {"bootstrapper-not-found": True}
|
||||
|
||||
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"
|
||||
|
||||
# Read bootstrapper source
|
||||
bs_path = os.path.join(ref_dir, "bootstrap_js.py")
|
||||
try:
|
||||
with open(bs_path, encoding="utf-8") as f:
|
||||
bootstrapper_source = f.read()
|
||||
except FileNotFoundError:
|
||||
bootstrapper_source = "# bootstrapper source not found"
|
||||
if target == "javascript":
|
||||
# Read bootstrapper source
|
||||
bs_path = os.path.join(ref_dir, "bootstrap_js.py")
|
||||
try:
|
||||
with open(bs_path, encoding="utf-8") as f:
|
||||
bootstrapper_source = f.read()
|
||||
except FileNotFoundError:
|
||||
bootstrapper_source = "# bootstrapper source not found"
|
||||
|
||||
# Run the bootstrap to generate JS
|
||||
from shared.sx.ref.bootstrap_js import compile_ref_to_js
|
||||
try:
|
||||
bootstrapped_output = compile_ref_to_js(
|
||||
adapters=["dom", "engine", "orchestration", "boot", "cssx"]
|
||||
)
|
||||
except Exception as e:
|
||||
bootstrapped_output = f"// bootstrap error: {e}"
|
||||
# Run the bootstrap to generate JS
|
||||
from shared.sx.ref.bootstrap_js import compile_ref_to_js
|
||||
try:
|
||||
bootstrapped_output = compile_ref_to_js(
|
||||
adapters=["dom", "engine", "orchestration", "boot", "cssx"]
|
||||
)
|
||||
except Exception as e:
|
||||
bootstrapped_output = f"// bootstrap error: {e}"
|
||||
|
||||
elif target == "python":
|
||||
bs_path = os.path.join(ref_dir, "bootstrap_py.py")
|
||||
try:
|
||||
with open(bs_path, encoding="utf-8") as f:
|
||||
bootstrapper_source = f.read()
|
||||
except FileNotFoundError:
|
||||
bootstrapper_source = "# bootstrapper source not found"
|
||||
|
||||
from shared.sx.ref.bootstrap_py import compile_ref_to_py
|
||||
try:
|
||||
bootstrapped_output = compile_ref_to_py()
|
||||
except Exception as e:
|
||||
bootstrapped_output = f"# bootstrap error: {e}"
|
||||
|
||||
return {
|
||||
"bootstrapper-not-found": None,
|
||||
@@ -176,7 +275,7 @@ def _attr_detail_data(slug: str) -> dict:
|
||||
- attr-not-found (truthy if not found)
|
||||
"""
|
||||
from content.pages import ATTR_DETAILS
|
||||
from shared.sx.helpers import SxExpr
|
||||
from shared.sx.helpers import sx_call
|
||||
|
||||
detail = ATTR_DETAILS.get(slug)
|
||||
if not detail:
|
||||
@@ -193,7 +292,7 @@ def _attr_detail_data(slug: str) -> dict:
|
||||
"attr-description": detail["description"],
|
||||
"attr-example": detail["example"],
|
||||
"attr-handler": detail.get("handler"),
|
||||
"attr-demo": SxExpr(f"(~{demo_name})") if demo_name else None,
|
||||
"attr-demo": sx_call(demo_name) if demo_name else None,
|
||||
"attr-wire-id": wire_id,
|
||||
}
|
||||
|
||||
@@ -201,7 +300,7 @@ def _attr_detail_data(slug: str) -> dict:
|
||||
def _header_detail_data(slug: str) -> dict:
|
||||
"""Return header detail data for a specific header slug."""
|
||||
from content.pages import HEADER_DETAILS
|
||||
from shared.sx.helpers import SxExpr
|
||||
from shared.sx.helpers import sx_call
|
||||
|
||||
detail = HEADER_DETAILS.get(slug)
|
||||
if not detail:
|
||||
@@ -214,14 +313,14 @@ def _header_detail_data(slug: str) -> dict:
|
||||
"header-direction": detail["direction"],
|
||||
"header-description": detail["description"],
|
||||
"header-example": detail.get("example"),
|
||||
"header-demo": SxExpr(f"(~{demo_name})") if demo_name else None,
|
||||
"header-demo": sx_call(demo_name) if demo_name else None,
|
||||
}
|
||||
|
||||
|
||||
def _event_detail_data(slug: str) -> dict:
|
||||
"""Return event detail data for a specific event slug."""
|
||||
from content.pages import EVENT_DETAILS
|
||||
from shared.sx.helpers import SxExpr
|
||||
from shared.sx.helpers import sx_call
|
||||
|
||||
detail = EVENT_DETAILS.get(slug)
|
||||
if not detail:
|
||||
@@ -233,5 +332,5 @@ def _event_detail_data(slug: str) -> dict:
|
||||
"event-title": slug,
|
||||
"event-description": detail["description"],
|
||||
"event-example": detail.get("example"),
|
||||
"event-demo": SxExpr(f"(~{demo_name})") if demo_name else None,
|
||||
"event-demo": sx_call(demo_name) if demo_name else None,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user