#!/usr/bin/env python3 """ Bootstrap compiler: test.sx -> pytest test module. Reads test.sx and emits a Python test file that runs each deftest as a pytest test case, grouped into classes by defsuite. The emitted tests use the SX evaluator to run SX test bodies, verifying that the Python implementation matches the spec. Usage: python bootstrap_test.py --output shared/sx/tests/test_sx_spec.py pytest shared/sx/tests/test_sx_spec.py -v """ from __future__ import annotations import os import re import sys import argparse _HERE = os.path.dirname(os.path.abspath(__file__)) _PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) sys.path.insert(0, _PROJECT) from shared.sx.parser import parse_all from shared.sx.types import Symbol, Keyword, NIL as SX_NIL def _slugify(name: str) -> str: """Convert a test/suite name to a valid Python identifier.""" s = name.lower().strip() s = re.sub(r'[^a-z0-9]+', '_', s) s = s.strip('_') return s def _sx_to_source(expr) -> str: """Convert an SX AST node back to SX source string.""" if isinstance(expr, bool): return "true" if expr else "false" if isinstance(expr, (int, float)): return str(expr) if isinstance(expr, str): escaped = expr.replace('\\', '\\\\').replace('"', '\\"') return f'"{escaped}"' if expr is None or expr is SX_NIL: return "nil" if isinstance(expr, Symbol): return expr.name if isinstance(expr, Keyword): return f":{expr.name}" if isinstance(expr, dict): pairs = [] for k, v in expr.items(): pairs.append(f":{k} {_sx_to_source(v)}") return "{" + " ".join(pairs) + "}" if isinstance(expr, list): if not expr: return "()" return "(" + " ".join(_sx_to_source(e) for e in expr) + ")" return str(expr) def _parse_test_sx(path: str) -> tuple[list[dict], list]: """Parse test.sx and return (suites, preamble_exprs). Preamble exprs are define forms (assertion helpers) that must be evaluated before tests run. Suites contain the actual test cases. """ with open(path) as f: content = f.read() exprs = parse_all(content) suites = [] preamble = [] for expr in exprs: if not isinstance(expr, list) or not expr: continue head = expr[0] if isinstance(head, Symbol) and head.name == "defsuite": suite = _parse_suite(expr) if suite: suites.append(suite) elif isinstance(head, Symbol) and head.name == "define": preamble.append(expr) return suites, preamble def _parse_suite(expr: list) -> dict | None: """Parse a (defsuite "name" ...) form.""" if len(expr) < 2: return None name = expr[1] if not isinstance(name, str): return None tests = [] for child in expr[2:]: if not isinstance(child, list) or not child: continue head = child[0] if isinstance(head, Symbol): if head.name == "deftest": test = _parse_test(child) if test: tests.append(test) elif head.name == "defsuite": sub = _parse_suite(child) if sub: tests.append(sub) return {"type": "suite", "name": name, "tests": tests} def _parse_test(expr: list) -> dict | None: """Parse a (deftest "name" body ...) form.""" if len(expr) < 3: return None name = expr[1] if not isinstance(name, str): return None body = expr[2:] return {"type": "test", "name": name, "body": body} def _emit_py(suites: list[dict], preamble: list) -> str: """Emit a pytest module from parsed suites.""" # Serialize preamble (assertion helpers) as SX source preamble_sx = "\n".join(_sx_to_source(expr) for expr in preamble) preamble_escaped = preamble_sx.replace('\\', '\\\\').replace("'", "\\'") lines = [] lines.append('"""Auto-generated from test.sx — SX spec self-tests.') lines.append('') lines.append('DO NOT EDIT. Regenerate with:') lines.append(' python shared/sx/ref/bootstrap_test.py --output shared/sx/tests/test_sx_spec.py') lines.append('"""') lines.append('from __future__ import annotations') lines.append('') lines.append('import pytest') lines.append('from shared.sx.parser import parse_all') lines.append('from shared.sx.evaluator import _eval, _trampoline') lines.append('') lines.append('') lines.append(f"_PREAMBLE = '''{preamble_escaped}'''") lines.append('') lines.append('') lines.append('def _make_env() -> dict:') lines.append(' """Create a fresh env with assertion helpers loaded."""') lines.append(' env = {}') lines.append(' for expr in parse_all(_PREAMBLE):') lines.append(' _trampoline(_eval(expr, env))') lines.append(' return env') lines.append('') lines.append('') lines.append('def _run(sx_source: str, env: dict | None = None) -> object:') lines.append(' """Evaluate SX source and return the result."""') lines.append(' if env is None:') lines.append(' env = _make_env()') lines.append(' exprs = parse_all(sx_source)') lines.append(' result = None') lines.append(' for expr in exprs:') lines.append(' result = _trampoline(_eval(expr, env))') lines.append(' return result') lines.append('') for suite in suites: _emit_suite(suite, lines, indent=0) return "\n".join(lines) def _emit_suite(suite: dict, lines: list[str], indent: int): """Emit a pytest class for a suite.""" class_name = f"TestSpec{_slugify(suite['name']).title().replace('_', '')}" pad = " " * indent lines.append(f'{pad}class {class_name}:') lines.append(f'{pad} """test.sx suite: {suite["name"]}"""') lines.append('') for item in suite["tests"]: if item["type"] == "test": _emit_test(item, lines, indent + 1) elif item["type"] == "suite": _emit_suite(item, lines, indent + 1) lines.append('') def _emit_test(test: dict, lines: list[str], indent: int): """Emit a pytest test method.""" method_name = f"test_{_slugify(test['name'])}" pad = " " * indent # Convert body expressions to SX source body_parts = [] for expr in test["body"]: body_parts.append(_sx_to_source(expr)) # Wrap in (do ...) if multiple expressions, or use single if len(body_parts) == 1: sx_source = body_parts[0] else: sx_source = "(do " + " ".join(body_parts) + ")" # Escape for Python string sx_escaped = sx_source.replace('\\', '\\\\').replace("'", "\\'") lines.append(f"{pad}def {method_name}(self):") lines.append(f"{pad} _run('{sx_escaped}')") lines.append('') def main(): parser = argparse.ArgumentParser(description="Bootstrap test.sx to pytest") parser.add_argument("--output", "-o", help="Output file path") parser.add_argument("--dry-run", action="store_true", help="Print to stdout") args = parser.parse_args() test_sx = os.path.join(_HERE, "test.sx") suites, preamble = _parse_test_sx(test_sx) print(f"Parsed {len(suites)} suites, {len(preamble)} preamble defines from test.sx", file=sys.stderr) total_tests = sum( sum(1 for t in s["tests"] if t["type"] == "test") for s in suites ) print(f"Total test cases: {total_tests}", file=sys.stderr) output = _emit_py(suites, preamble) if args.output and not args.dry_run: with open(args.output, "w") as f: f.write(output) print(f"Wrote {args.output}", file=sys.stderr) else: print(output) if __name__ == "__main__": main()