Wire deps.sx into both bootstrappers, rebootstrap Python + JS

deps.sx is now a spec module that both bootstrap_py.py and bootstrap_js.py
can include via --spec-modules deps. Platform functions (component-deps,
component-set-deps!, component-css-classes, env-components, regex-find-all,
scan-css-classes) implemented natively in both Python and JS.

- Fix deps.sx: env-get-or → env-get, extract nested define to top-level
- bootstrap_py.py: SPEC_MODULES, PLATFORM_DEPS_PY, mangle entries, CLI arg
- bootstrap_js.py: SPEC_MODULES, PLATFORM_DEPS_JS, mangle entries, CLI arg
- Regenerate sx_ref.py and sx-ref.js with deps module
- deps.py: thin dispatcher (SX_USE_REF=1 → bootstrapped, else fallback)
- scan_components_from_sx now returns ~prefixed names (consistent with spec)

Verified: 541 Python tests pass, JS deps tested with Node.js, both code
paths (fallback + bootstrapped) produce identical results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 11:55:32 +00:00
parent 6739343a06
commit 4c97b03dda
7 changed files with 692 additions and 84 deletions

View File

@@ -532,6 +532,84 @@
return NIL;
}
// =========================================================================
// Platform: deps module — component dependency analysis
// =========================================================================
function componentDeps(c) {
return c.deps ? c.deps.slice() : [];
}
function componentSetDeps(c, deps) {
c.deps = deps;
}
function componentCssClasses(c) {
return c.cssClasses ? c.cssClasses.slice() : [];
}
function envComponents(env) {
var names = [];
for (var k in env) {
var v = env[k];
if (v && (v._component || v._macro)) names.push(k);
}
return names;
}
function regexFindAll(pattern, source) {
var re = new RegExp(pattern, "g");
var results = [];
var m;
while ((m = re.exec(source)) !== null) {
if (m[1] !== undefined) results.push(m[1]);
else results.push(m[0]);
}
return results;
}
function scanCssClasses(source) {
var classes = {};
var result = [];
var m;
var re1 = /:class\s+"([^"]*)"/g;
while ((m = re1.exec(source)) !== null) {
var parts = m[1].split(/\s+/);
for (var i = 0; i < parts.length; i++) {
if (parts[i] && !classes[parts[i]]) {
classes[parts[i]] = true;
result.push(parts[i]);
}
}
}
var re2 = /:class\s+\(str\s+((?:"[^"]*"\s*)+)\)/g;
while ((m = re2.exec(source)) !== null) {
var re3 = /"([^"]*)"/g;
var m2;
while ((m2 = re3.exec(m[1])) !== null) {
var parts2 = m2[1].split(/\s+/);
for (var j = 0; j < parts2.length; j++) {
if (parts2[j] && !classes[parts2[j]]) {
classes[parts2[j]] = true;
result.push(parts2[j]);
}
}
}
}
var re4 = /;;\s*@css\s+(.+)/g;
while ((m = re4.exec(source)) !== null) {
var parts3 = m[1].split(/\s+/);
for (var k = 0; k < parts3.length; k++) {
if (parts3[k] && !classes[parts3[k]]) {
classes[parts3[k]] = true;
result.push(parts3[k]);
}
}
}
return result;
}
// =========================================================================
// Platform interface — Parser
// =========================================================================
@@ -2303,6 +2381,82 @@ callExpr.push(dictGet(kwargs, k)); } }
var bootInit = function() { return (initCssTracking(), initStyleDict(), processSxScripts(NIL), sxHydrateElements(NIL), processElements(NIL)); };
// === Transpiled from deps (component dependency analysis) ===
// scan-refs
var scanRefs = function(node) { return (function() {
var refs = [];
scanRefsWalk(node, refs);
return refs;
})(); };
// scan-refs-walk
var scanRefsWalk = function(node, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() {
var name = symbolName(node);
return (isSxTruthy(startsWith(name, "~")) ? (isSxTruthy(!contains(refs, name)) ? append_b(refs, name) : NIL) : NIL);
})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanRefsWalk(item, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanRefsWalk(dictGet(node, key), refs); }, keys(node)) : NIL))); };
// transitive-deps-walk
var transitiveDepsWalk = function(n, seen, env) { return (isSxTruthy(!contains(seen, n)) ? (append_b(seen, n), (function() {
var val = envGet(env, n);
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(componentBody(val))) : (isSxTruthy((typeOf(val) == "macro")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(macroBody(val))) : NIL));
})()) : NIL); };
// transitive-deps
var transitiveDeps = function(name, env) { return (function() {
var seen = [];
var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name)));
transitiveDepsWalk(key, seen, env);
return filter(function(x) { return !(x == key); }, seen);
})(); };
// compute-all-deps
var computeAllDeps = function(env) { return forEach(function(name) { return (function() {
var val = envGet(env, name);
return (isSxTruthy((typeOf(val) == "component")) ? componentSetDeps(val, transitiveDeps(name, env)) : NIL);
})(); }, envComponents(env)); };
// scan-components-from-source
var scanComponentsFromSource = function(source) { return (function() {
var matches = regexFindAll("\\(~([a-zA-Z_][a-zA-Z0-9_\\-]*)", source);
return map(function(m) { return (String("~") + String(m)); }, matches);
})(); };
// components-needed
var componentsNeeded = function(pageSource, env) { return (function() {
var direct = scanComponentsFromSource(pageSource);
var allNeeded = [];
{ var _c = direct; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; if (isSxTruthy(!contains(allNeeded, name))) {
allNeeded.push(name);
}
(function() {
var val = envGet(env, name);
return (function() {
var deps = (isSxTruthy((isSxTruthy((typeOf(val) == "component")) && !isEmpty(componentDeps(val)))) ? componentDeps(val) : transitiveDeps(name, env));
return forEach(function(dep) { return (isSxTruthy(!contains(allNeeded, dep)) ? append_b(allNeeded, dep) : NIL); }, deps);
})();
})(); } }
return allNeeded;
})(); };
// page-component-bundle
var pageComponentBundle = function(pageSource, env) { return componentsNeeded(pageSource, env); };
// page-css-classes
var pageCssClasses = function(pageSource, env) { return (function() {
var needed = componentsNeeded(pageSource, env);
var classes = [];
{ var _c = needed; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; (function() {
var val = envGet(env, name);
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(cls) { return (isSxTruthy(!contains(classes, cls)) ? append_b(classes, cls) : NIL); }, componentCssClasses(val)) : NIL);
})(); } }
{ var _c = scanCssClasses(pageSource); for (var _i = 0; _i < _c.length; _i++) { var cls = _c[_i]; if (isSxTruthy(!contains(classes, cls))) {
classes.push(cls);
} } }
return classes;
})(); };
// =========================================================================
// Platform interface — DOM adapter (browser-only)
// =========================================================================
@@ -3317,6 +3471,87 @@ callExpr.push(dictGet(kwargs, k)); } }
if (typeof aser === "function") PRIMITIVES["aser"] = aser;
if (typeof renderToDom === "function") PRIMITIVES["render-to-dom"] = renderToDom;
// =========================================================================
// Extension: Delimited continuations (shift/reset)
// =========================================================================
function Continuation(fn) { this.fn = fn; }
Continuation.prototype._continuation = true;
Continuation.prototype.call = function(value) { return this.fn(value !== undefined ? value : NIL); };
function ShiftSignal(kName, body, env) {
this.kName = kName;
this.body = body;
this.env = env;
}
PRIMITIVES["continuation?"] = function(x) { return x != null && x._continuation === true; };
var _resetResume = [];
function sfReset(args, env) {
var body = args[0];
try {
return trampoline(evalExpr(body, env));
} catch (e) {
if (e instanceof ShiftSignal) {
var sig = e;
var cont = new Continuation(function(value) {
if (value === undefined) value = NIL;
_resetResume.push(value);
try {
return trampoline(evalExpr(body, env));
} finally {
_resetResume.pop();
}
});
var sigEnv = merge(sig.env);
sigEnv[sig.kName] = cont;
return trampoline(evalExpr(sig.body, sigEnv));
}
throw e;
}
}
function sfShift(args, env) {
if (_resetResume.length > 0) {
return _resetResume[_resetResume.length - 1];
}
var kName = symbolName(args[0]);
var body = args[1];
throw new ShiftSignal(kName, body, env);
}
// Wrap evalList to intercept reset/shift
var _baseEvalList = evalList;
evalList = function(expr, env) {
var head = expr[0];
if (isSym(head)) {
var name = head.name;
if (name === "reset") return sfReset(expr.slice(1), env);
if (name === "shift") return sfShift(expr.slice(1), env);
}
return _baseEvalList(expr, env);
};
// Wrap aserSpecial to handle reset/shift in SX wire mode
if (typeof aserSpecial === "function") {
var _baseAserSpecial = aserSpecial;
aserSpecial = function(name, expr, env) {
if (name === "reset") return sfReset(expr.slice(1), env);
if (name === "shift") return sfShift(expr.slice(1), env);
return _baseAserSpecial(name, expr, env);
};
}
// Wrap typeOf to recognize continuations
var _baseTypeOf = typeOf;
typeOf = function(x) {
if (x != null && x._continuation) return "continuation";
return _baseTypeOf(x);
};
// Parser — compiled from parser.sx (see PLATFORM_PARSER_JS for ident char classes)
var parse = sxParse;
@@ -3385,6 +3620,12 @@ callExpr.push(dictGet(kwargs, k)); } }
renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null,
getEnv: function() { return componentEnv; },
init: typeof bootInit === "function" ? bootInit : null,
scanRefs: scanRefs,
transitiveDeps: transitiveDeps,
computeAllDeps: computeAllDeps,
componentsNeeded: componentsNeeded,
pageComponentBundle: pageComponentBundle,
pageCssClasses: pageCssClasses,
_version: "ref-2.0 (boot+cssx+dom+engine+html+orchestration+parser+sx, bootstrap-compiled)"
};

View File

@@ -1,56 +1,48 @@
"""
Component dependency analysis.
Walks component AST bodies to compute transitive dependency sets.
A component's deps are all other components (~name references) it
can potentially render, including through control flow branches.
Thin host wrapper over bootstrapped deps module from shared/sx/ref/deps.sx.
The canonical logic lives in the spec; this module provides Python-typed
entry points for the rest of the codebase.
"""
from __future__ import annotations
import os
from typing import Any
from .types import Component, Macro, Symbol
def _scan_ast(node: Any) -> set[str]:
"""Scan an AST node for ~component references.
def _use_ref() -> bool:
return os.environ.get("SX_USE_REF") == "1"
Walks all branches of control flow (if/when/cond/case) to find
every component that *could* be rendered. Returns a set of
component names (with ~ prefix).
"""
# ---------------------------------------------------------------------------
# Hand-written fallback (used when SX_USE_REF != 1)
# ---------------------------------------------------------------------------
def _scan_ast(node: Any) -> set[str]:
refs: set[str] = set()
_walk(node, refs)
return refs
def _walk(node: Any, refs: set[str]) -> None:
"""Recursively walk an AST node collecting ~name references."""
if isinstance(node, Symbol):
if node.name.startswith("~"):
refs.add(node.name)
return
if isinstance(node, list):
for item in node:
_walk(item, refs)
return
if isinstance(node, dict):
for v in node.values():
_walk(v, refs)
return
# Literals (str, int, float, bool, None, Keyword) — no refs
return
def transitive_deps(name: str, env: dict[str, Any]) -> set[str]:
"""Compute transitive component dependencies for *name*.
Returns the set of all component names (with ~ prefix) that
*name* can transitively render, NOT including *name* itself.
"""
def _transitive_deps_fallback(name: str, env: dict[str, Any]) -> set[str]:
seen: set[str] = set()
def walk(n: str) -> None:
@@ -70,37 +62,19 @@ def transitive_deps(name: str, env: dict[str, Any]) -> set[str]:
return seen - {key}
def compute_all_deps(env: dict[str, Any]) -> None:
"""Compute and cache deps for all Component entries in *env*.
Mutates each Component's ``deps`` field in place.
"""
def _compute_all_deps_fallback(env: dict[str, Any]) -> None:
for key, val in env.items():
if isinstance(val, Component):
val.deps = transitive_deps(key, env)
val.deps = _transitive_deps_fallback(key, env)
def scan_components_from_sx(source: str) -> set[str]:
"""Extract component names referenced in SX source text.
Uses regex to find (~name patterns in serialized SX wire format.
Returns names with ~ prefix, e.g. {"~card", "~nav-link"}.
"""
def _scan_components_from_sx_fallback(source: str) -> set[str]:
import re
return set(re.findall(r'\(~([a-zA-Z_][a-zA-Z0-9_\-]*)', source))
return {f"~{m}" for m in re.findall(r'\(~([a-zA-Z_][a-zA-Z0-9_\-]*)', source)}
def components_needed(page_sx: str, env: dict[str, Any]) -> set[str]:
"""Compute the full set of component names needed for a page.
Scans *page_sx* for direct component references, then computes
the transitive closure over the component dependency graph.
Returns names with ~ prefix.
"""
# Direct refs from the page source
direct = {f"~{n}" for n in scan_components_from_sx(page_sx)}
# Transitive closure
def _components_needed_fallback(page_sx: str, env: dict[str, Any]) -> set[str]:
direct = _scan_components_from_sx_fallback(page_sx)
all_needed: set[str] = set()
for name in direct:
all_needed.add(name)
@@ -108,7 +82,52 @@ def components_needed(page_sx: str, env: dict[str, Any]) -> set[str]:
if isinstance(val, Component) and val.deps:
all_needed.update(val.deps)
else:
# deps not cached yet — compute on the fly
all_needed.update(transitive_deps(name, env))
all_needed.update(_transitive_deps_fallback(name, env))
return all_needed
# ---------------------------------------------------------------------------
# Public API — dispatches to bootstrapped or fallback
# ---------------------------------------------------------------------------
def transitive_deps(name: str, env: dict[str, Any]) -> set[str]:
"""Compute transitive component dependencies for *name*.
Returns the set of all component names (with ~ prefix) that
*name* can transitively render, NOT including *name* itself.
"""
if _use_ref():
from .ref.sx_ref import transitive_deps as _ref_td
return set(_ref_td(name, env))
return _transitive_deps_fallback(name, env)
def compute_all_deps(env: dict[str, Any]) -> None:
"""Compute and cache deps for all Component entries in *env*."""
if _use_ref():
from .ref.sx_ref import compute_all_deps as _ref_cad
_ref_cad(env)
return
_compute_all_deps_fallback(env)
def scan_components_from_sx(source: str) -> set[str]:
"""Extract component names referenced in SX source text.
Returns names with ~ prefix, e.g. {"~card", "~nav-link"}.
"""
if _use_ref():
from .ref.sx_ref import scan_components_from_source as _ref_sc
return set(_ref_sc(source))
return _scan_components_from_sx_fallback(source)
def components_needed(page_sx: str, env: dict[str, Any]) -> set[str]:
"""Compute the full set of component names needed for a page.
Returns names with ~ prefix.
"""
if _use_ref():
from .ref.sx_ref import components_needed as _ref_cn
return set(_ref_cn(page_sx, env))
return _components_needed_fallback(page_sx, env)

View File

@@ -490,6 +490,21 @@ class JSEmitter:
"log-info": "logInfo",
"log-parse-error": "logParseError",
"parse-and-load-style-dict": "parseAndLoadStyleDict",
# deps.sx
"scan-refs": "scanRefs",
"scan-refs-walk": "scanRefsWalk",
"transitive-deps": "transitiveDeps",
"compute-all-deps": "computeAllDeps",
"scan-components-from-source": "scanComponentsFromSource",
"components-needed": "componentsNeeded",
"page-component-bundle": "pageComponentBundle",
"page-css-classes": "pageCssClasses",
"component-deps": "componentDeps",
"component-set-deps!": "componentSetDeps",
"component-css-classes": "componentCssClasses",
"env-components": "envComponents",
"regex-find-all": "regexFindAll",
"scan-css-classes": "scanCssClasses",
}
if name in RENAMES:
return RENAMES[name]
@@ -1001,6 +1016,10 @@ ADAPTER_DEPS = {
"parser": [],
}
SPEC_MODULES = {
"deps": ("deps.sx", "deps (component dependency analysis)"),
}
EXTENSION_NAMES = {"continuations"}
@@ -1091,6 +1110,7 @@ def compile_ref_to_js(
adapters: list[str] | None = None,
modules: list[str] | None = None,
extensions: list[str] | None = None,
spec_modules: list[str] | None = None,
) -> str:
"""Read reference .sx files and emit JavaScript.
@@ -1104,6 +1124,9 @@ def compile_ref_to_js(
extensions: List of optional extensions to include.
Valid names: continuations.
None = no extensions.
spec_modules: List of spec module names to include.
Valid names: deps.
None = no spec modules.
"""
ref_dir = os.path.dirname(os.path.abspath(__file__))
emitter = JSEmitter()
@@ -1131,7 +1154,16 @@ def compile_ref_to_js(
for dep in ADAPTER_DEPS.get(a, []):
adapter_set.add(dep)
# Core files always included, then selected adapters
# Resolve spec modules
spec_mod_set = set()
if spec_modules:
for sm in spec_modules:
if sm not in SPEC_MODULES:
raise ValueError(f"Unknown spec module: {sm!r}. Valid: {', '.join(SPEC_MODULES)}")
spec_mod_set.add(sm)
has_deps = "deps" in spec_mod_set
# Core files always included, then selected adapters, then spec modules
sx_files = [
("eval.sx", "eval"),
("render.sx", "render (core)"),
@@ -1139,6 +1171,8 @@ def compile_ref_to_js(
for name in ("parser", "html", "sx", "dom", "engine", "orchestration", "cssx", "boot"):
if name in adapter_set:
sx_files.append(ADAPTER_FILES[name])
for name in sorted(spec_mod_set):
sx_files.append(SPEC_MODULES[name])
all_sections = []
for filename, label in sx_files:
@@ -1190,6 +1224,9 @@ def compile_ref_to_js(
parts.append(_assemble_primitives_js(prim_modules))
parts.append(PLATFORM_JS_POST)
if has_deps:
parts.append(PLATFORM_DEPS_JS)
# Parser platform must come before compiled parser.sx
if has_parser:
parts.append(adapter_platform["parser"])
@@ -1211,7 +1248,7 @@ def compile_ref_to_js(
parts.append(fixups_js(has_html, has_sx, has_dom))
if has_continuations:
parts.append(CONTINUATIONS_JS)
parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label))
parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label, has_deps))
parts.append(EPILOGUE)
return "\n".join(parts)
@@ -1790,6 +1827,85 @@ PLATFORM_JS_POST = '''
return NIL;
}'''
PLATFORM_DEPS_JS = '''
// =========================================================================
// Platform: deps module — component dependency analysis
// =========================================================================
function componentDeps(c) {
return c.deps ? c.deps.slice() : [];
}
function componentSetDeps(c, deps) {
c.deps = deps;
}
function componentCssClasses(c) {
return c.cssClasses ? c.cssClasses.slice() : [];
}
function envComponents(env) {
var names = [];
for (var k in env) {
var v = env[k];
if (v && (v._component || v._macro)) names.push(k);
}
return names;
}
function regexFindAll(pattern, source) {
var re = new RegExp(pattern, "g");
var results = [];
var m;
while ((m = re.exec(source)) !== null) {
if (m[1] !== undefined) results.push(m[1]);
else results.push(m[0]);
}
return results;
}
function scanCssClasses(source) {
var classes = {};
var result = [];
var m;
var re1 = /:class\\s+"([^"]*)"/g;
while ((m = re1.exec(source)) !== null) {
var parts = m[1].split(/\\s+/);
for (var i = 0; i < parts.length; i++) {
if (parts[i] && !classes[parts[i]]) {
classes[parts[i]] = true;
result.push(parts[i]);
}
}
}
var re2 = /:class\\s+\\(str\\s+((?:"[^"]*"\\s*)+)\\)/g;
while ((m = re2.exec(source)) !== null) {
var re3 = /"([^"]*)"/g;
var m2;
while ((m2 = re3.exec(m[1])) !== null) {
var parts2 = m2[1].split(/\\s+/);
for (var j = 0; j < parts2.length; j++) {
if (parts2[j] && !classes[parts2[j]]) {
classes[parts2[j]] = true;
result.push(parts2[j]);
}
}
}
}
var re4 = /;;\\s*@css\\s+(.+)/g;
while ((m = re4.exec(source)) !== null) {
var parts3 = m[1].split(/\\s+/);
for (var k = 0; k < parts3.length; k++) {
if (parts3[k] && !classes[parts3[k]]) {
classes[parts3[k]] = true;
result.push(parts3[k]);
}
}
}
return result;
}
'''
PLATFORM_PARSER_JS = r"""
// =========================================================================
// Platform interface — Parser
@@ -2836,7 +2952,7 @@ def fixups_js(has_html, has_sx, has_dom):
return "\n".join(lines)
def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label):
def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label, has_deps=False):
# Parser: use compiled sxParse from parser.sx, or inline a minimal fallback
if has_parser:
parser = '''
@@ -2958,6 +3074,13 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has
api_lines.append(' init: typeof bootInit === "function" ? bootInit : null,')
elif has_orch:
api_lines.append(' init: typeof engineInit === "function" ? engineInit : null,')
if has_deps:
api_lines.append(' scanRefs: scanRefs,')
api_lines.append(' transitiveDeps: transitiveDeps,')
api_lines.append(' computeAllDeps: computeAllDeps,')
api_lines.append(' componentsNeeded: componentsNeeded,')
api_lines.append(' pageComponentBundle: pageComponentBundle,')
api_lines.append(' pageCssClasses: pageCssClasses,')
api_lines.append(f' _version: "{version}"')
api_lines.append(' };')
@@ -3015,6 +3138,8 @@ if __name__ == "__main__":
help="Comma-separated primitive modules (core.* always included). Default: all")
p.add_argument("--extensions",
help="Comma-separated extensions (continuations). Default: none.")
p.add_argument("--spec-modules",
help="Comma-separated spec modules (deps). Default: none.")
p.add_argument("--output", "-o",
help="Output file (default: stdout)")
args = p.parse_args()
@@ -3022,7 +3147,8 @@ if __name__ == "__main__":
adapters = args.adapters.split(",") if args.adapters else None
modules = args.modules.split(",") if args.modules else None
extensions = args.extensions.split(",") if args.extensions else None
js = compile_ref_to_js(adapters, modules, extensions)
spec_modules = args.spec_modules.split(",") if args.spec_modules else None
js = compile_ref_to_js(adapters, modules, extensions, spec_modules)
if args.output:
with open(args.output, "w") as f:

View File

@@ -235,6 +235,21 @@ class PyEmitter:
"map-dict": "map_dict",
"eval-cond": "eval_cond",
"process-bindings": "process_bindings",
# deps.sx
"scan-refs": "scan_refs",
"scan-refs-walk": "scan_refs_walk",
"transitive-deps": "transitive_deps",
"compute-all-deps": "compute_all_deps",
"scan-components-from-source": "scan_components_from_source",
"components-needed": "components_needed",
"page-component-bundle": "page_component_bundle",
"page-css-classes": "page_css_classes",
"component-deps": "component_deps",
"component-set-deps!": "component_set_deps",
"component-css-classes": "component_css_classes",
"env-components": "env_components",
"regex-find-all": "regex_find_all",
"scan-css-classes": "scan_css_classes",
}
if name in RENAMES:
return RENAMES[name]
@@ -803,6 +818,11 @@ ADAPTER_FILES = {
}
SPEC_MODULES = {
"deps": ("deps.sx", "deps (component dependency analysis)"),
}
EXTENSION_NAMES = {"continuations"}
# Extension-provided special forms (not in eval.sx core)
@@ -889,6 +909,7 @@ def compile_ref_to_py(
adapters: list[str] | None = None,
modules: list[str] | None = None,
extensions: list[str] | None = None,
spec_modules: list[str] | None = None,
) -> str:
"""Read reference .sx files and emit Python.
@@ -902,6 +923,9 @@ def compile_ref_to_py(
extensions: List of optional extensions to include.
Valid names: continuations.
None = no extensions.
spec_modules: List of spec module names to include.
Valid names: deps.
None = no spec modules.
"""
# Determine which primitive modules to include
prim_modules = None # None = all
@@ -926,7 +950,16 @@ def compile_ref_to_py(
raise ValueError(f"Unknown adapter: {a!r}. Valid: {', '.join(ADAPTER_FILES)}")
adapter_set.add(a)
# Core files always included, then selected adapters
# Resolve spec modules
spec_mod_set = set()
if spec_modules:
for sm in spec_modules:
if sm not in SPEC_MODULES:
raise ValueError(f"Unknown spec module: {sm!r}. Valid: {', '.join(SPEC_MODULES)}")
spec_mod_set.add(sm)
has_deps = "deps" in spec_mod_set
# Core files always included, then selected adapters, then spec modules
sx_files = [
("eval.sx", "eval"),
("forms.sx", "forms (server definition forms)"),
@@ -935,6 +968,8 @@ def compile_ref_to_py(
for name in ("html", "sx"):
if name in adapter_set:
sx_files.append(ADAPTER_FILES[name])
for name in sorted(spec_mod_set):
sx_files.append(SPEC_MODULES[name])
all_sections = []
for filename, label in sx_files:
@@ -969,6 +1004,9 @@ def compile_ref_to_py(
parts.append(_assemble_primitives_py(prim_modules))
parts.append(PRIMITIVES_PY_POST)
if has_deps:
parts.append(PLATFORM_DEPS_PY)
for label, defines in all_sections:
parts.append(f"\n# === Transpiled from {label} ===\n")
for name, expr in defines:
@@ -979,7 +1017,7 @@ def compile_ref_to_py(
parts.append(FIXUPS_PY)
if has_continuations:
parts.append(CONTINUATIONS_PY)
parts.append(public_api_py(has_html, has_sx))
parts.append(public_api_py(has_html, has_sx, has_deps))
return "\n".join(parts)
@@ -1903,6 +1941,50 @@ assoc = PRIMITIVES["assoc"]
concat = PRIMITIVES["concat"]
'''
PLATFORM_DEPS_PY = (
'\n'
'# =========================================================================\n'
'# Platform: deps module — component dependency analysis\n'
'# =========================================================================\n'
'\n'
'import re as _re\n'
'\n'
'def component_deps(c):\n'
' """Return cached deps list for a component (may be empty)."""\n'
' return list(c.deps) if hasattr(c, "deps") and c.deps else []\n'
'\n'
'def component_set_deps(c, deps):\n'
' """Cache deps on a component."""\n'
' c.deps = set(deps) if not isinstance(deps, set) else deps\n'
'\n'
'def component_css_classes(c):\n'
' """Return pre-scanned CSS class list for a component."""\n'
' return list(c.css_classes) if hasattr(c, "css_classes") and c.css_classes else []\n'
'\n'
'def env_components(env):\n'
' """Return list of component/macro names in an environment."""\n'
' return [k for k, v in env.items()\n'
' if isinstance(v, (Component, Macro))]\n'
'\n'
'def regex_find_all(pattern, source):\n'
' """Return list of capture group 1 matches."""\n'
' return [m.group(1) for m in _re.finditer(pattern, source)]\n'
'\n'
'def scan_css_classes(source):\n'
' """Extract CSS class strings from SX source."""\n'
' classes = set()\n'
' for m in _re.finditer(r\':class\\s+"([^"]*)"\', source):\n'
' classes.update(m.group(1).split())\n'
' for m in _re.finditer(r\':class\\s+\\(str\\s+((?:"[^"]*"\\s*)+)\\)\', source):\n'
' for s in _re.findall(r\'"([^"]*)"\', m.group(1)):\n'
' classes.update(s.split())\n'
' for m in _re.finditer(r\';;\\s*@css\\s+(.+)\', source):\n'
' classes.update(m.group(1).split())\n'
' return list(classes)\n'
)
FIXUPS_PY = '''
# =========================================================================
# Fixups -- wire up render adapter dispatch
@@ -1996,7 +2078,7 @@ aser_special = _aser_special_with_continuations
'''
def public_api_py(has_html: bool, has_sx: bool) -> str:
def public_api_py(has_html: bool, has_sx: bool, has_deps: bool = False) -> str:
lines = [
'',
'# =========================================================================',
@@ -2059,11 +2141,17 @@ def main():
default=None,
help="Comma-separated extensions (continuations). Default: none.",
)
parser.add_argument(
"--spec-modules",
default=None,
help="Comma-separated spec modules (deps). Default: none.",
)
args = parser.parse_args()
adapters = args.adapters.split(",") if args.adapters else None
modules = args.modules.split(",") if args.modules else None
extensions = args.extensions.split(",") if args.extensions else None
print(compile_ref_to_py(adapters, modules, extensions))
spec_modules = args.spec_modules.split(",") if args.spec_modules else None
print(compile_ref_to_py(adapters, modules, extensions, spec_modules))
if __name__ == "__main__":

View File

@@ -8,19 +8,21 @@
;; All functions are pure — no IO, no platform-specific operations.
;; Each host bootstraps this to native code alongside eval.sx/render.sx.
;;
;; Platform interface (provided by host):
;; From eval.sx platform (already provided by every host):
;; (type-of x) → type string
;; (symbol-name s) → string name of symbol
;; (component-body c) → unevaluated AST of component body
;; (component-name c) → string name (without ~)
;;
;; Already available from eval.sx platform:
;; (type-of x), (symbol-name s)
;;
;; New platform functions for deps:
;; (component-body c) → component body AST
;; (component-name c) → component name string
;; (macro-body m) → macro body AST
;; (env-get env k) → value or nil
;;
;; New platform functions for deps (each host implements):
;; (component-deps c) → cached deps list (may be empty)
;; (component-set-deps! c d)→ cache deps on component
;; (component-css-classes c)→ pre-scanned CSS class list
;; (env-components env) → list of component/macro names in env
;; (regex-find-all pat src) → list of capture group 1 matches
;; (scan-css-classes src) → list of CSS class strings from source
;; ==========================================================================
@@ -66,24 +68,26 @@
;; Given a component name and an environment, compute all components
;; that it can transitively render. Handles cycles via seen-set.
(define transitive-deps-walk
(fn (n seen env)
(when (not (contains? seen n))
(append! seen n)
(let ((val (env-get env n)))
(cond
(= (type-of val) "component")
(for-each (fn (ref) (transitive-deps-walk ref seen env))
(scan-refs (component-body val)))
(= (type-of val) "macro")
(for-each (fn (ref) (transitive-deps-walk ref seen env))
(scan-refs (macro-body val)))
:else nil)))))
(define transitive-deps
(fn (name env)
(let ((seen (list))
(key (if (starts-with? name "~") name (str "~" name))))
(define walk
(fn (n)
(when (not (contains? seen n))
(append! seen n)
(let ((val (env-get-or env n nil)))
(cond
(= (type-of val) "component")
(for-each walk (scan-refs (component-body val)))
(= (type-of val) "macro")
(for-each walk (scan-refs (macro-body val)))
:else nil)))))
(walk key)
(transitive-deps-walk key seen env)
(filter (fn (x) (not (= x key))) seen))))
@@ -101,7 +105,7 @@
(fn (env)
(for-each
(fn (name)
(let ((val (env-get-or env name nil)))
(let ((val (env-get env name)))
(when (= (type-of val) "component")
(component-set-deps! val (transitive-deps name env)))))
(env-components env))))
@@ -138,7 +142,7 @@
(fn (name)
(when (not (contains? all-needed name))
(append! all-needed name))
(let ((val (env-get-or env name nil)))
(let ((val (env-get env name)))
(let ((deps (if (and (= (type-of val) "component")
(not (empty? (component-deps val))))
(component-deps val)
@@ -185,7 +189,7 @@
;; Collect classes from needed components
(for-each
(fn (name)
(let ((val (env-get-or env name nil)))
(let ((val (env-get env name)))
(when (= (type-of val) "component")
(for-each
(fn (cls)
@@ -211,7 +215,7 @@
;; From eval.sx (already provided):
;; (type-of x) → type string
;; (symbol-name s) → string name of symbol
;; (env-get-or env k d) → value or default
;; (env-get env k) → value or nil
;;
;; New for deps.sx (each host implements):
;; (component-body c) → AST body of component

View File

@@ -1,4 +1,3 @@
# WARNING: special-forms.sx declares forms not in eval.sx: reset, shift
"""
sx_ref.py -- Generated from reference SX evaluator specification.
@@ -879,6 +878,46 @@ assoc = PRIMITIVES["assoc"]
concat = PRIMITIVES["concat"]
# =========================================================================
# Platform: deps module — component dependency analysis
# =========================================================================
import re as _re
def component_deps(c):
"""Return cached deps list for a component (may be empty)."""
return list(c.deps) if hasattr(c, "deps") and c.deps else []
def component_set_deps(c, deps):
"""Cache deps on a component."""
c.deps = set(deps) if not isinstance(deps, set) else deps
def component_css_classes(c):
"""Return pre-scanned CSS class list for a component."""
return list(c.css_classes) if hasattr(c, "css_classes") and c.css_classes else []
def env_components(env):
"""Return list of component/macro names in an environment."""
return [k for k, v in env.items()
if isinstance(v, (Component, Macro))]
def regex_find_all(pattern, source):
"""Return list of capture group 1 matches."""
return [m.group(1) for m in _re.finditer(pattern, source)]
def scan_css_classes(source):
"""Extract CSS class strings from SX source."""
classes = set()
for m in _re.finditer(r':class\s+"([^"]*)"', source):
classes.update(m.group(1).split())
for m in _re.finditer(r':class\s+\(str\s+((?:"[^"]*"\s*)+)\)', source):
for s in _re.findall(r'"([^"]*)"', m.group(1)):
classes.update(s.split())
for m in _re.finditer(r';;\s*@css\s+(.+)', source):
classes.update(m.group(1).split())
return list(classes)
# === Transpiled from eval ===
# trampoline
@@ -1139,6 +1178,39 @@ aser_fragment = lambda children, env: (lambda parts: ('' if sx_truthy(empty_p(pa
aser_call = lambda name, args, env: (lambda parts: _sx_begin(reduce(lambda state, arg: (lambda skip: (assoc(state, 'skip', False, 'i', (get(state, 'i') + 1)) if sx_truthy(skip) else ((lambda val: _sx_begin((_sx_begin(_sx_append(parts, sx_str(':', keyword_name(arg))), _sx_append(parts, serialize(val))) if sx_truthy((not sx_truthy(is_nil(val)))) else NIL), assoc(state, 'skip', True, 'i', (get(state, 'i') + 1))))(aser(nth(args, (get(state, 'i') + 1)), env)) if sx_truthy(((type_of(arg) == 'keyword') if not sx_truthy((type_of(arg) == 'keyword')) else ((get(state, 'i') + 1) < len(args)))) else (lambda val: _sx_begin((_sx_append(parts, serialize(val)) if sx_truthy((not sx_truthy(is_nil(val)))) else NIL), assoc(state, 'i', (get(state, 'i') + 1))))(aser(arg, env)))))(get(state, 'skip')), {'i': 0, 'skip': False}, args), sx_str('(', join(' ', parts), ')')))([name])
# === Transpiled from deps (component dependency analysis) ===
# scan-refs
scan_refs = lambda node: (lambda refs: _sx_begin(scan_refs_walk(node, refs), refs))([])
# scan-refs-walk
scan_refs_walk = lambda node, refs: ((lambda name: ((_sx_append(refs, name) if sx_truthy((not sx_truthy(contains_p(refs, name)))) else NIL) if sx_truthy(starts_with_p(name, '~')) else NIL))(symbol_name(node)) if sx_truthy((type_of(node) == 'symbol')) else (for_each(lambda item: scan_refs_walk(item, refs), node) if sx_truthy((type_of(node) == 'list')) else (for_each(lambda key: scan_refs_walk(dict_get(node, key), refs), keys(node)) if sx_truthy((type_of(node) == 'dict')) else NIL)))
# transitive-deps-walk
transitive_deps_walk = lambda n, seen, env: (_sx_begin(_sx_append(seen, n), (lambda val: (for_each(lambda ref: transitive_deps_walk(ref, seen, env), scan_refs(component_body(val))) if sx_truthy((type_of(val) == 'component')) else (for_each(lambda ref: transitive_deps_walk(ref, seen, env), scan_refs(macro_body(val))) if sx_truthy((type_of(val) == 'macro')) else NIL)))(env_get(env, n))) if sx_truthy((not sx_truthy(contains_p(seen, n)))) else NIL)
# transitive-deps
transitive_deps = lambda name, env: (lambda seen: (lambda key: _sx_begin(transitive_deps_walk(key, seen, env), filter(lambda x: (not sx_truthy((x == key))), seen)))((name if sx_truthy(starts_with_p(name, '~')) else sx_str('~', name))))([])
# compute-all-deps
compute_all_deps = lambda env: for_each(lambda name: (lambda val: (component_set_deps(val, transitive_deps(name, env)) if sx_truthy((type_of(val) == 'component')) else NIL))(env_get(env, name)), env_components(env))
# scan-components-from-source
scan_components_from_source = lambda source: (lambda matches: map(lambda m: sx_str('~', m), matches))(regex_find_all('\\(~([a-zA-Z_][a-zA-Z0-9_\\-]*)', source))
# components-needed
components_needed = lambda page_source, env: (lambda direct: (lambda all_needed: _sx_begin(for_each(_sx_fn(lambda name: (
(_sx_append(all_needed, name) if sx_truthy((not sx_truthy(contains_p(all_needed, name)))) else NIL),
(lambda val: (lambda deps: for_each(lambda dep: (_sx_append(all_needed, dep) if sx_truthy((not sx_truthy(contains_p(all_needed, dep)))) else NIL), deps))((component_deps(val) if sx_truthy(((type_of(val) == 'component') if not sx_truthy((type_of(val) == 'component')) else (not sx_truthy(empty_p(component_deps(val)))))) else transitive_deps(name, env))))(env_get(env, name))
)[-1]), direct), all_needed))([]))(scan_components_from_source(page_source))
# page-component-bundle
page_component_bundle = lambda page_source, env: components_needed(page_source, env)
# page-css-classes
page_css_classes = lambda page_source, env: (lambda needed: (lambda classes: _sx_begin(for_each(lambda name: (lambda val: (for_each(lambda cls: (_sx_append(classes, cls) if sx_truthy((not sx_truthy(contains_p(classes, cls)))) else NIL), component_css_classes(val)) if sx_truthy((type_of(val) == 'component')) else NIL))(env_get(env, name)), needed), for_each(lambda cls: (_sx_append(classes, cls) if sx_truthy((not sx_truthy(contains_p(classes, cls)))) else NIL), scan_css_classes(page_source)), classes))([]))(components_needed(page_source, env))
# =========================================================================
# Fixups -- wire up render adapter dispatch
# =========================================================================
@@ -1171,6 +1243,64 @@ def _wrap_aser_outputs():
aser_fragment = _aser_fragment_wrapped
# =========================================================================
# Extension: delimited continuations (shift/reset)
# =========================================================================
_RESET_RESUME = [] # stack of resume values; empty = not resuming
_SPECIAL_FORM_NAMES = _SPECIAL_FORM_NAMES | frozenset(["reset", "shift"])
def sf_reset(args, env):
"""(reset body) -- establish a continuation delimiter."""
body = first(args)
try:
return trampoline(eval_expr(body, env))
except _ShiftSignal as sig:
def cont_fn(value=NIL):
_RESET_RESUME.append(value)
try:
return trampoline(eval_expr(body, env))
finally:
_RESET_RESUME.pop()
k = Continuation(cont_fn)
sig_env = dict(sig.env)
sig_env[sig.k_name] = k
return trampoline(eval_expr(sig.body, sig_env))
def sf_shift(args, env):
"""(shift k body) -- capture continuation to nearest reset."""
if _RESET_RESUME:
return _RESET_RESUME[-1]
k_name = symbol_name(first(args))
body = nth(args, 1)
raise _ShiftSignal(k_name, body, env)
# Wrap eval_list to inject shift/reset dispatch
_base_eval_list = eval_list
def _eval_list_with_continuations(expr, env):
head = first(expr)
if type_of(head) == "symbol":
name = symbol_name(head)
args = rest(expr)
if name == "reset":
return sf_reset(args, env)
if name == "shift":
return sf_shift(args, env)
return _base_eval_list(expr, env)
eval_list = _eval_list_with_continuations
# Inject into aser_special
_base_aser_special = aser_special
def _aser_special_with_continuations(name, expr, env):
if name == "reset":
return sf_reset(expr[1:], env)
if name == "shift":
return sf_shift(expr[1:], env)
return _base_aser_special(name, expr, env)
aser_special = _aser_special_with_continuations
# =========================================================================
# Public API
# =========================================================================

View File

@@ -147,7 +147,7 @@ class TestScanComponentsFromSx:
def test_basic(self):
source = '(~card :title "hi" (~badge :label "new"))'
refs = scan_components_from_sx(source)
assert refs == {"card", "badge"}
assert refs == {"~card", "~badge"}
def test_no_components(self):
source = '(div :class "p-4" (p "hello"))'