diff --git a/sx/sx/specs-explorer.sx b/sx/sx/specs-explorer.sx new file mode 100644 index 0000000..60c91c0 --- /dev/null +++ b/sx/sx/specs-explorer.sx @@ -0,0 +1,194 @@ +;; --------------------------------------------------------------------------- +;; Spec Explorer — structured interactive view of SX spec files +;; --------------------------------------------------------------------------- + +(defcomp ~spec-explorer-content (&key data) + (~doc-page :title (str (get data "title") " — Explorer") + + ;; Header with filename and source link + (~spec-explorer-header + :filename (get data "filename") + :title (get data "title") + :desc (get data "desc") + :slug (replace (get data "filename") ".sx" "")) + + ;; Stats bar + (~spec-explorer-stats :stats (get data "stats")) + + ;; Sections + (map (fn (section) + (~spec-explorer-section :section section)) + (get data "sections")) + + ;; Platform interface + (when (not (empty? (get data "platform-interface"))) + (~spec-platform-interface :items (get data "platform-interface"))))) + + +;; --------------------------------------------------------------------------- +;; Header +;; --------------------------------------------------------------------------- + +(defcomp ~spec-explorer-header (&key filename title desc slug) + (div :class "mb-6" + (div :class "flex items-center justify-between" + (div + (h1 :class "text-2xl font-bold text-stone-800" title) + (p :class "text-sm text-stone-500 mt-1" desc)) + (a :href (str "/language/specs/" slug) + :sx-get (str "/language/specs/" slug) + :sx-target "#main-panel" :sx-select "#main-panel" + :sx-swap "outerHTML" :sx-push-url "true" + :class "text-sm text-violet-600 hover:text-violet-800 font-medium" + "View Source")) + (p :class "text-xs text-stone-400 font-mono mt-2" filename))) + + +;; --------------------------------------------------------------------------- +;; Stats bar +;; --------------------------------------------------------------------------- + +(defcomp ~spec-explorer-stats (&key stats) + (div :class "flex flex-wrap gap-2 mb-6 text-xs" + (span :class "bg-stone-100 text-stone-600 px-2 py-0.5 rounded font-medium" + (str (get stats "total-defines") " defines")) + (when (> (get stats "pure-count") 0) + (span :class "bg-green-100 text-green-700 px-2 py-0.5 rounded" + (str (get stats "pure-count") " pure"))) + (when (> (get stats "mutation-count") 0) + (span :class "bg-amber-100 text-amber-700 px-2 py-0.5 rounded" + (str (get stats "mutation-count") " mutation"))) + (when (> (get stats "io-count") 0) + (span :class "bg-orange-100 text-orange-700 px-2 py-0.5 rounded" + (str (get stats "io-count") " io"))) + (when (> (get stats "render-count") 0) + (span :class "bg-sky-100 text-sky-700 px-2 py-0.5 rounded" + (str (get stats "render-count") " render"))) + (span :class "bg-stone-100 text-stone-500 px-2 py-0.5 rounded" + (str (get stats "lines") " lines")))) + + +;; --------------------------------------------------------------------------- +;; Section +;; --------------------------------------------------------------------------- + +(defcomp ~spec-explorer-section (&key section) + (div :class "mb-8" + (h2 :class "text-lg font-semibold text-stone-700 border-b border-stone-200 pb-1 mb-3" + :id (replace (lower-case (get section "title")) " " "-") + (get section "title")) + (when (get section "comment") + (p :class "text-sm text-stone-500 mb-3" (get section "comment"))) + (div :class "space-y-4" + (map (fn (d) (~spec-explorer-define :d d)) + (get section "defines"))))) + + +;; --------------------------------------------------------------------------- +;; Define card — one function/constant +;; --------------------------------------------------------------------------- + +(defcomp ~spec-explorer-define (&key d) + (div :class "rounded border border-stone-200 p-4" + :id (str "fn-" (get d "name")) + + ;; Name + effect badges + (div :class "flex items-center gap-2 flex-wrap" + (span :class "font-mono font-semibold text-stone-800" (get d "name")) + (span :class "text-xs text-stone-400" (get d "kind")) + (if (empty? (get d "effects")) + (span :class "text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700" "pure") + (map (fn (eff) (~spec-effect-badge :effect eff)) + (get d "effects")))) + + ;; Params + (when (not (empty? (get d "params"))) + (~spec-param-list :params (get d "params"))) + + ;; Translation panels + (~spec-ring-translations + :source (get d "source") + :python (get d "python")))) + + +;; --------------------------------------------------------------------------- +;; Effect badge +;; --------------------------------------------------------------------------- + +(defcomp ~spec-effect-badge (&key effect) + (span :class (str "text-xs px-1.5 py-0.5 rounded " + (case effect + "mutation" "bg-amber-100 text-amber-700" + "io" "bg-orange-100 text-orange-700" + "render" "bg-sky-100 text-sky-700" + :else "bg-stone-100 text-stone-500")) + effect)) + + +;; --------------------------------------------------------------------------- +;; Param list +;; --------------------------------------------------------------------------- + +(defcomp ~spec-param-list (&key params) + (div :class "mt-1 flex flex-wrap gap-1" + (map (fn (p) + (let ((name (get p "name")) + (typ (get p "type"))) + (if (or (= name "&rest") (= name "&key")) + (span :class "text-xs font-mono text-violet-500" name) + (span :class "text-xs font-mono px-1 py-0.5 rounded bg-stone-50 border border-stone-200" + (if typ + (<> (span :class "text-stone-700" name) + (span :class "text-stone-400" " : ") + (span :class "text-violet-600" typ)) + (span :class "text-stone-700" name)))))) + params))) + + +;; --------------------------------------------------------------------------- +;; Translation panels (Ring 2) +;; --------------------------------------------------------------------------- + +(defcomp ~spec-ring-translations (&key source python) + (when (not (= source "")) + (div :class "mt-3 border border-stone-200 rounded-lg overflow-hidden" + ;; SX source — always open + (details :open "true" + (summary :class "px-3 py-1.5 bg-stone-50 text-xs font-medium text-stone-600 cursor-pointer" + "SX") + (pre :class "text-xs p-3 overflow-x-auto bg-white" + (code (highlight source "sx")))) + ;; Python + (when python + (details + (summary :class "px-3 py-1.5 bg-stone-50 text-xs font-medium text-stone-600 cursor-pointer border-t border-stone-200" + "Python") + (pre :class "text-xs p-3 overflow-x-auto bg-white" + (code (highlight python "python")))))))) + + +;; --------------------------------------------------------------------------- +;; Platform interface table (Ring 3) +;; --------------------------------------------------------------------------- + +(defcomp ~spec-platform-interface (&key items) + (div :class "mt-8" + (h2 :class "text-lg font-semibold text-stone-700 border-b border-stone-200 pb-1 mb-3" + "Platform Interface") + (p :class "text-sm text-stone-500 mb-3" + "Functions the host platform must provide.") + (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-50" + (th :class "px-3 py-2 font-medium text-stone-600" "Name") + (th :class "px-3 py-2 font-medium text-stone-600" "Params") + (th :class "px-3 py-2 font-medium text-stone-600" "Returns") + (th :class "px-3 py-2 font-medium text-stone-600" "Description"))) + (tbody + (map (fn (item) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" (get item "name")) + (td :class "px-3 py-2 font-mono text-xs text-stone-500" (get item "params")) + (td :class "px-3 py-2 font-mono text-xs text-stone-500" (get item "returns")) + (td :class "px-3 py-2 text-stone-600" (get item "doc")))) + items)))))) diff --git a/sx/sx/specs.sx b/sx/sx/specs.sx index 2f74b61..390e14f 100644 --- a/sx/sx/specs.sx +++ b/sx/sx/specs.sx @@ -334,9 +334,15 @@ deps.sx depends on: eval"))) (defcomp ~spec-detail-content (&key (spec-title :as string) (spec-desc :as string) (spec-filename :as string) (spec-source :as string) (spec-prose :as string?)) (~doc-page :title spec-title - (div :class "flex items-baseline gap-3 mb-4" + (div :class "flex items-center gap-3 mb-4" (span :class "text-sm text-stone-400 font-mono" spec-filename) - (span :class "text-sm text-stone-500" spec-desc)) + (span :class "text-sm text-stone-500 flex-1" spec-desc) + (a :href (str "/language/specs/explore/" (replace spec-filename ".sx" "")) + :sx-get (str "/language/specs/explore/" (replace spec-filename ".sx" "")) + :sx-target "#main-panel" :sx-select "#main-panel" + :sx-swap "outerHTML" :sx-push-url "true" + :class "text-sm text-violet-600 hover:text-violet-800 font-medium whitespace-nowrap" + "Explore")) (when spec-prose (div :class "mb-6 space-y-3" (p :class "text-stone-600 leading-relaxed" spec-prose) diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index 294c2a0..1150ad6 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -349,15 +349,22 @@ "extensions" (~spec-overview-content :spec-title "Extensions" :spec-files (make-spec-files extension-spec-items)) - :else (let ((spec (find-spec slug))) - (if spec - (~spec-detail-content - :spec-title (get spec "title") - :spec-desc (get spec "desc") - :spec-filename (get spec "filename") - :spec-source (read-spec-file (get spec "filename")) - :spec-prose (get spec "prose")) - (~spec-not-found :slug slug))))))) + :else (cond + (starts-with? slug "explore/") + (let ((spec-slug (slice slug 8 (string-length slug))) + (data (spec-explorer-data spec-slug))) + (if data + (~spec-explorer-content :data data) + (~spec-not-found :slug spec-slug))) + :else (let ((spec (find-spec slug))) + (if spec + (~spec-detail-content + :spec-title (get spec "title") + :spec-desc (get spec "desc") + :spec-filename (get spec "filename") + :spec-source (read-spec-file (get spec "filename")) + :spec-prose (get spec "prose")) + (~spec-not-found :slug slug)))))))) ;; --------------------------------------------------------------------------- ;; Bootstrappers section diff --git a/sx/sxc/pages/helpers.py b/sx/sxc/pages/helpers.py index a9c8cbb..1a33980 100644 --- a/sx/sxc/pages/helpers.py +++ b/sx/sxc/pages/helpers.py @@ -34,6 +34,7 @@ def _register_sx_helpers() -> None: "offline-demo-data": _offline_demo_data, "prove-data": _prove_data, "page-helpers-demo-data": _page_helpers_demo_data, + "spec-explorer-data": _spec_explorer_data, }) @@ -141,6 +142,298 @@ def _read_spec_file(filename: str) -> str: return ";; spec file not found" +def _spec_explorer_data(slug: str) -> dict | None: + """Parse a spec file into structured metadata for the spec explorer. + + Returns sections with defines, effects, params, source, and translations. + """ + import os + import re + from shared.sx.parser import parse_all + from shared.sx.types import Symbol, Keyword + + # Look up spec metadata from nav-data (via find-spec helper) + from shared.sx.jinja_bridge import get_component_env + env = get_component_env() + all_specs = env.get("all-spec-items", []) + spec_meta = None + for item in all_specs: + if isinstance(item, dict) and item.get("slug") == slug: + spec_meta = item + break + if not spec_meta: + return None + + filename = spec_meta.get("filename", "") + title = spec_meta.get("title", slug) + desc = spec_meta.get("desc", "") + + # Read the raw source + 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" + filepath = os.path.join(ref_dir, filename) + try: + with open(filepath, encoding="utf-8") as f: + source = f.read() + except FileNotFoundError: + return None + + lines = source.split("\n") + + # --- 1. Section splitting --- + sections: list[dict] = [] + current_section: dict | None = None + i = 0 + while i < len(lines): + line = lines[i] + # Detect section dividers: ;; ---... + if re.match(r"^;; -{10,}", line): + # Look for title in following comment lines + title_lines = [] + j = i + 1 + while j < len(lines) and lines[j].startswith(";;"): + content = lines[j][2:].strip() + if re.match(r"^-{10,}", content): + j += 1 + break + if content: + title_lines.append(content) + j += 1 + if title_lines: + section_title = title_lines[0] + # Collect comment block after section header + comment_lines = [] + k = j + while k < len(lines) and lines[k].startswith(";;"): + c = lines[k][2:].strip() + if re.match(r"^-{5,}", c) or re.match(r"^={5,}", c): + break + if c: + comment_lines.append(c) + k += 1 + current_section = { + "title": section_title, + "comment": " ".join(comment_lines) if comment_lines else None, + "defines": [], + } + sections.append(current_section) + i = j + continue + i += 1 + + # If no sections found, create a single implicit one + if not sections: + current_section = {"title": filename, "comment": None, "defines": []} + sections.append(current_section) + + # --- 2. Parse AST --- + try: + exprs = parse_all(source) + except Exception: + exprs = [] + + # --- 3. Process each top-level define --- + # Build a line-number index: find where each top-level form starts + def _find_source_block(name: str, form: str = "define") -> tuple[str, int]: + """Find the source text of a define form by scanning raw source.""" + patterns = [ + f"({form} {name} ", + f"({form} {name}\n", + ] + for pat in patterns: + idx = source.find(pat) + if idx >= 0: + # Count balanced parens from idx + depth = 0 + end = idx + for ci, ch in enumerate(source[idx:], idx): + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + end = ci + 1 + break + line_num = source[:idx].count("\n") + 1 + return source[idx:end], line_num + return "", 0 + + def _extract_effects(expr: list) -> list[str]: + """Extract :effects [...] from a define form.""" + if len(expr) >= 4 and isinstance(expr[2], Keyword) and expr[2].name == "effects": + eff_list = expr[3] + if isinstance(eff_list, list): + return [s.name if isinstance(s, Symbol) else str(s) for s in eff_list] + return [] + + def _extract_params(expr: list) -> list[dict]: + """Extract params from the fn/lambda body of a define.""" + # Find the fn/lambda form + val_expr = expr[4] if (len(expr) >= 5 and isinstance(expr[2], Keyword) + and expr[2].name == "effects") else expr[2] if len(expr) >= 3 else None + if not isinstance(val_expr, list) or not val_expr: + return [] + if not isinstance(val_expr[0], Symbol): + return [] + if val_expr[0].name not in ("fn", "lambda"): + return [] + if len(val_expr) < 2 or not isinstance(val_expr[1], list): + return [] + params_list = val_expr[1] + result = [] + i = 0 + while i < len(params_list): + p = params_list[i] + if isinstance(p, Symbol) and p.name in ("&rest", "&key"): + result.append({"name": p.name, "type": None}) + i += 1 + continue + if isinstance(p, Symbol): + result.append({"name": p.name, "type": None}) + elif isinstance(p, list) and len(p) == 3: + # (name :as type) + name_s, kw, type_s = p + if isinstance(name_s, Symbol) and isinstance(kw, Keyword) and kw.name == "as": + type_str = type_s.name if isinstance(type_s, Symbol) else str(type_s) + result.append({"name": name_s.name, "type": type_str}) + else: + result.append({"name": str(p), "type": None}) + else: + result.append({"name": str(p), "type": None}) + i += 1 + return result + + def _assign_to_section(line_num: int) -> dict: + """Find which section a line belongs to.""" + best = sections[0] + for s in sections: + # Section starts at its first define or earlier + if s["defines"]: + first_line = s["defines"][0].get("line", 0) + else: + first_line = 0 + # Use section order — defines after a section header belong to it + # Simple approach: find section by scanning source position + return best + + # Process defines + all_defines: list[dict] = [] + py_emitter = None + + for expr in exprs: + if not isinstance(expr, list) or len(expr) < 2: + continue + if not isinstance(expr[0], Symbol): + continue + + head = expr[0].name + if head not in ("define", "define-async"): + continue + + name_node = expr[1] + name = name_node.name if isinstance(name_node, Symbol) else str(name_node) + + effects = _extract_effects(expr) + params = _extract_params(expr) + src, line_num = _find_source_block(name, head) + + kind = "function" + # Check if it's a constant (no fn/lambda body) + val_idx = 4 if (len(expr) >= 5 and isinstance(expr[2], Keyword) + and expr[2].name == "effects") else 2 + if val_idx < len(expr): + val = expr[val_idx] + if isinstance(val, list) and val and isinstance(val[0], Symbol) and val[0].name in ("fn", "lambda"): + kind = "async-function" if head == "define-async" else "function" + else: + kind = "constant" + if head == "define-async": + kind = "async-function" + + # --- Python translation --- + py_code = None + try: + if py_emitter is None: + from shared.sx.ref.bootstrap_py import PyEmitter + py_emitter = PyEmitter() + if head == "define-async": + py_code = py_emitter._emit_define_async(expr) + else: + py_code = py_emitter._emit_define(expr) + except Exception: + pass + + define_entry = { + "name": name, + "kind": kind, + "effects": effects, + "params": params, + "source": src, + "line": line_num, + "python": py_code, + } + all_defines.append(define_entry) + + # --- Assign defines to sections --- + # Match by line number: each define belongs to the section whose header + # precedes it in the source + section_line_map: list[tuple[int, dict]] = [] + for s in sections: + # Find the line where section title appears + t = s["title"] + for li, line in enumerate(lines, 1): + if t in line: + section_line_map.append((li, s)) + break + section_line_map.sort(key=lambda x: x[0]) + + for d in all_defines: + dl = d.get("line", 0) + target_section = sections[0] + for sl, s in section_line_map: + if dl >= sl: + target_section = s + target_section["defines"].append(d) + + # --- Stats --- + pure_count = sum(1 for d in all_defines if not d["effects"]) + mutation_count = sum(1 for d in all_defines if "mutation" in d["effects"]) + io_count = sum(1 for d in all_defines if "io" in d["effects"]) + render_count = sum(1 for d in all_defines if "render" in d["effects"]) + + # --- Platform interface --- + platform_items = [] + for line in lines: + m = re.match(r"^;;\s+\((\S+)\s+(.*?)\)\s+→\s+(\S+)\s+—\s+(.+)", line) + if m: + platform_items.append({ + "name": m.group(1), + "params": m.group(2), + "returns": m.group(3), + "doc": m.group(4).strip(), + }) + + # Filter out empty sections + sections = [s for s in sections if s["defines"]] + + return { + "filename": filename, + "title": title, + "desc": desc, + "sections": sections, + "platform-interface": platform_items, + "stats": { + "total-defines": len(all_defines), + "pure-count": pure_count, + "mutation-count": mutation_count, + "io-count": io_count, + "render-count": render_count, + "lines": len(lines), + }, + } + + def _bootstrapper_data(target: str) -> dict: """Return bootstrapper source and generated output for a target.