From 4c97b03ddadb32410a8949381c70aa928cf0349c Mon Sep 17 00:00:00 2001 From: giles Date: Fri, 6 Mar 2026 11:55:32 +0000 Subject: [PATCH] Wire deps.sx into both bootstrappers, rebootstrap Python + JS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- shared/static/scripts/sx-ref.js | 241 ++++++++++++++++++++++++++++++++ shared/sx/deps.py | 115 ++++++++------- shared/sx/ref/bootstrap_js.py | 134 +++++++++++++++++- shared/sx/ref/bootstrap_py.py | 96 ++++++++++++- shared/sx/ref/deps.sx | 56 ++++---- shared/sx/ref/sx_ref.py | 132 ++++++++++++++++- shared/sx/tests/test_deps.py | 2 +- 7 files changed, 692 insertions(+), 84 deletions(-) diff --git a/shared/static/scripts/sx-ref.js b/shared/static/scripts/sx-ref.js index fe97aea..8ca8260 100644 --- a/shared/static/scripts/sx-ref.js +++ b/shared/static/scripts/sx-ref.js @@ -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)" }; diff --git a/shared/sx/deps.py b/shared/sx/deps.py index 31a53ad..488fb4c 100644 --- a/shared/sx/deps.py +++ b/shared/sx/deps.py @@ -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) diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index 9111338..3efe0f0 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -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: diff --git a/shared/sx/ref/bootstrap_py.py b/shared/sx/ref/bootstrap_py.py index 4dae337..01630a2 100644 --- a/shared/sx/ref/bootstrap_py.py +++ b/shared/sx/ref/bootstrap_py.py @@ -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__": diff --git a/shared/sx/ref/deps.sx b/shared/sx/ref/deps.sx index ca174e9..c0ff265 100644 --- a/shared/sx/ref/deps.sx +++ b/shared/sx/ref/deps.sx @@ -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 diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index 77ac737..64a250c 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -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 # ========================================================================= diff --git a/shared/sx/tests/test_deps.py b/shared/sx/tests/test_deps.py index 8c88082..c5ca526 100644 --- a/shared/sx/tests/test_deps.py +++ b/shared/sx/tests/test_deps.py @@ -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"))'