Add spec explorer: structured interactive view of SX spec files
- _spec_explorer_data() helper: parses spec files into sections, defines, effects, params, source blocks, and Python translations via PyEmitter - specs-explorer.sx: 10 defcomp components for explorer UI — cards with effect badges, typed param lists, collapsible SX/Python translation panels - Route at /language/specs/explore/<slug> via docs.sx - "Explore" link on existing spec detail pages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user