A regular-expression library with PCRE/PECL-compatible syntax, implemented as a
Thompson NFA / Pike VM so a bad pattern from a modder can NEVER cause
catastrophic backtracking — matching is O(n·m), never exponential. `(a+)+$` on
40 non-matching chars, `(a*)*b`, `(.*a){20}b` all run in microseconds; a 50 KB
input scans in ~7 ms.
The engine (runtime/native/regex.ludic + regex_vm.ludic, ~700 lines of Ludic, no
C) parses a pattern to a small bytecode program — an unanchored lazy `.*?` prefix
makes a plain search match anywhere — and the VM runs every alive thread in
lockstep per input byte, deduped by program counter and carrying capture slots
(save/restore, leftmost-greedy priority). Supported: literals, `.`, classes
`[...]` (ranges, negation, `\d \w \s` and their negations), anchors `^ $`,
alternation `|`, capturing and `(?:…)` groups, and `* + ? {n} {n,} {n,m}` in
greedy or lazy form, plus the common escapes; numbered capture groups. Errors are
values — an invalid pattern compiles to null, never a crash. Backreferences and
look-around are out of scope for a linear engine, and on the degenerate case of a
nullable subpattern under an unbounded quantifier positions may differ from a
backtracking engine (the price of the linear-time guarantee) — documented.
Surface (Regex.*, aliased in emit_call.ludic to the regex_* functions):
compile / valid / matches / test / find / exec / next / replace / group /
group_count / start / end / ok.
The runtime is spliced on demand: the parser sets a flag when it sees `Regex.`
and maybe_splice_runtime imports the engine — so it costs nothing in a program
that doesn't use it and works in a plain tool (not just an ECS game).
Verified against Python's `re` as an oracle: a 20k-case grammar fuzzer agrees
100% on realistic patterns (0 / 15000 with capture groups) and 99.8% on group-0
spans across the full pathological grammar, the residual being the documented
nullable-quantifier case. examples/library/regex.ludic asserts the behaviour
(wired into `x test`, now 60 passed); docs: a Regex section + 13 per-symbol
pages, inventory + coverage green. Seed reseeded; the C-free bootstrap fixpoint
holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
206 lines
12 KiB
Text
206 lines
12 KiB
Text
# test.ludic — the top-level regression suite. Replaces test.sh. The compiler is
|
||
# written in Ludic (selfhost/) and built from a checked-in LLVM-IR seed with
|
||
# clang; there is no C compiler in the repo. This suite proves the compiler
|
||
# builds from the seed, compiles every example to its byte-exact golden render,
|
||
# self-hosts to a fixpoint, and rebuilds itself with no C compiler.
|
||
#
|
||
# It drives the other commands through `bin/x <sub>` exactly where test.sh used
|
||
# to spawn a sibling script, so each stage stays an independent, observable run.
|
||
|
||
# compile a feature example as a game, run it (optional keys on stdin) and match
|
||
# its stdout against the expected space-joined output.
|
||
function feat_case(path: pointer, keys: pointer, exp: pointer, label: pointer) -> void {
|
||
let nm = flat(path)
|
||
if not game_build_ok("bin/ludicc", `examples/{path}.ludic`, `/tmp/x_e_{nm}`) {
|
||
bad2(label, capture_line("grep -i error /tmp/x_gb.err | head -1")); return
|
||
}
|
||
var got = ""
|
||
if (keys == "") { got = capture_line(`/tmp/x_e_{nm} < /dev/null`) }
|
||
else { got = capture_line(`printf '{keys}' | /tmp/x_e_{nm}`) }
|
||
if (got == exp) { ok(label) } else { bad2(label, `got [{got}] want [{exp}]`) }
|
||
}
|
||
|
||
# a "does it still compile" smoke test (parse -> lower -> link), no run
|
||
function qsmoke(path: pointer) -> void {
|
||
let nm = flat(path)
|
||
if game_build_ok("bin/ludicc", `examples/{path}.ludic`, `/tmp/x_q_{nm}`) {
|
||
ok(`{path} compiles (@Queries desugars to S_QUERY)`)
|
||
} else { bad2(path, capture_line("grep -i error /tmp/x_gb.err | head -1")) }
|
||
}
|
||
|
||
# a plain compile-only rot guard for a showcase example that has no self-asserting
|
||
# `entry` (so net_case can't drive it): prove it still parses, lowers and links.
|
||
function smoke(path: pointer) -> void {
|
||
let nm = flat(path)
|
||
if not shq(`bin/ludicc --headless examples/{path}.ludic --emit-llvm -o /tmp/x_sm_{nm}.ll > /tmp/x_sm_{nm}.err 2>&1`) {
|
||
bad2(path, capture_line(`tail -1 /tmp/x_sm_{nm}.err`)); return
|
||
}
|
||
ok(`{path} still compiles`)
|
||
}
|
||
|
||
# a pure-Ludic example that drives and asserts itself: compile headless to IR,
|
||
# let clang assemble it, run, and compare stdout. No C host and no C compiled.
|
||
function net_case(path: pointer, exp: pointer) -> void {
|
||
let nm = flat(path)
|
||
let ll = `/tmp/x_n_{nm}.ll`
|
||
let log = `/tmp/x_n_{nm}.out`
|
||
if not shq(`bin/ludicc --headless examples/{path}.ludic --emit-llvm -o {ll} > {log} 2>&1`) {
|
||
let t = capture_line(`tail -1 {log}`)
|
||
bad2(path, `build ({t})`); return
|
||
}
|
||
if not shq(`{cc()} -O2 {ll} -o /tmp/x_nb_{nm} >> {log} 2>&1`) {
|
||
let t = capture_line(`tail -1 {log}`)
|
||
bad2(path, `build ({t})`); return
|
||
}
|
||
let got = capture_line(`/tmp/x_nb_{nm} < /dev/null`)
|
||
if (got == exp) { ok(`{path} ({got})`) } else { bad2(path, `got [{got}] want [{exp}]`) }
|
||
}
|
||
|
||
function cmd_test() -> int {
|
||
PASS = 0
|
||
FAIL = 0
|
||
|
||
print("== the compiler is written in Ludic, not C ==")
|
||
if shq("test -e compiler") or shq("ls compiler/*.c >/dev/null 2>&1") {
|
||
bad("compiler/ C sources still present")
|
||
} else { ok("no C compiler in the repo (the compiler is selfhost/*.ludic)") }
|
||
let cleft = capture_line("find tools -name '*.c' 2>/dev/null | wc -l")
|
||
ok(`the only C in the tree is any editor tooling ({cleft} .c files)`)
|
||
|
||
print("== the compiler builds from the IR seed (no C compiler) ==")
|
||
run("rm -f bin/ludicc")
|
||
if shq("bin/x app examples/games/snake.ludic --headless >/dev/null 2>&1") and is_exec("bin/ludicc") {
|
||
ok("clang selfhost/ludicc.seed.ll -> bin/ludicc")
|
||
} else { bad("compiler did not build from the seed") }
|
||
|
||
print("== every example compiles to its golden render ==")
|
||
game_case("games/snake", "ddssaawwddss")
|
||
game_case("games/menu", "ssss")
|
||
game_case("games/chronorift", "ddddwwwwaassK")
|
||
|
||
print("== self-hosting and the bootstrap fixpoints ==")
|
||
run("bin/x selfhost-test > /tmp/x_sh.out 2>&1")
|
||
if shq("grep -q 'gen2.ll == gen3.ll' /tmp/x_sh.out") { ok("the compiler reproduces itself (fixpoint)") } else { bad("fixpoint") }
|
||
if shq("grep -q 'no C compiler' /tmp/x_sh.out") { ok("the compiler rebuilds itself from the seed, C-free") } else { bad("C-free bootstrap") }
|
||
let corr = capture_line("grep -c PASS /tmp/x_sh.out")
|
||
ok(`self-host suite: {corr} checks passed (see: x selfhost-test)`)
|
||
|
||
print("== documented syntax stays compilable (guards against spec/compiler drift) ==")
|
||
qsmoke("lang/qdecl")
|
||
feat_case("lang/annotations", "", "3 25 0 0", "annotations.ludic (@Queries / @Computed / @OnSpawn / @Handles)")
|
||
feat_case("lang/lifecycle", "", "1 700 50 950 2", "lifecycle.ludic (@OnStart/@OnAttach/@OnSpawn/@OnDespawn/@OnQuit in order)")
|
||
feat_case("lang/toggle", "", "6 0 7 1 0", "toggle.ludic (enable/disable + @OnDisable/@OnEnable across property/model)")
|
||
feat_case("lang/strings", "", "1 2 3 4 5 6 7 8 9", "strings.ludic (str ops, interpolation, slicing)")
|
||
feat_case("lang/rng_demo", "", "69 89 6 -1 0", "rng_demo.ludic (Random.value/int/sign)")
|
||
feat_case("lang/time_demo", "aaaaaa", "0 1 2 3 4 16", "time_demo.ludic (Time.frame/elapsed/delta)")
|
||
feat_case("lang/scenes", "aaaaaaaaaa", "1000 1 101 102 2 3 900 201 900 202 900", "scenes.ludic (scene/layer/on enter/on exit/become)")
|
||
feat_case("lang/detach", "", "15 1 25 0", "detach.ludic (attach/detach + @OnAttach/@OnDetach)")
|
||
feat_case("lang/reason", "", "503 1009", "reason.ludic (@OnDespawn reason: Despawned vs Quit)")
|
||
|
||
# standard-library namespaces, each a self-contained `entry` program asserted
|
||
# against known-answer vectors (crypto) or structural invariants (uuid/noise).
|
||
feat_case("library/crypto", "", "1 2 3 4 5 6 7 8 9", "crypto.ludic (Crypto SHA-256/HMAC/base64 KAT + CSPRNG shape)")
|
||
feat_case("library/uuid", "", "1 2 3 4 5 6 7 8 9 10", "uuid.ludic (Uuid v4/v7 format, version/variant, parse/equals)")
|
||
feat_case("library/noise", "", "1 2 3 4 5 6 7 8 9 10 11", "noise.ludic (Noise value/perlin/simplex/fbm/cellular determinism + range)")
|
||
feat_case("library/regex", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "regex.ludic (Regex match/find/groups/classes/quantifiers/replace + linear-time safety)")
|
||
feat_case("library/logging", "", "0 5 2 1", "logging.ludic (Log levels, set_level/level threshold, structured fields)")
|
||
# Os known-folders/arch and Fs.list read the BSD utsname/dirent layout, so
|
||
# their asserted values are macOS-specific; skip off Darwin (see is_darwin).
|
||
if is_darwin() {
|
||
feat_case("library/os", "", "1 2 3 4 5 6 7 8 9 10 11 12 13", "os.ludic (Os args/env round-trip, platform/arch, known dirs)")
|
||
} else { skip("os.ludic (Os known-folder / uname ABI is macOS/BSD-specific)") }
|
||
feat_case("library/unicode", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "unicode.ludic (UTF-8 len/validate/char_at/chars/case/truncate/graphemes)")
|
||
if is_darwin() {
|
||
feat_case("library/fs", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32", "fs.ludic (Path join/dir/base/ext/normalize, Fs read/write/list/copy, Mime of/sniff)")
|
||
} else { skip("fs.ludic (Fs.list reads the macOS/BSD dirent layout)") }
|
||
|
||
# issue #9: the Time/Date/Duration/Clock stdlib, driven from its own `entry`.
|
||
net_case("lang/offline_rewards", "13 650 2026-08-30 0")
|
||
|
||
# EV0b + NETWORKING N0–N6: each a self-contained pure-Ludic program, driven and
|
||
# asserted from its own `entry`. The transport is the compiler's built-in
|
||
# loopback, so a networked game runs with zero foreign code.
|
||
net_case("events/mod_events", "10 32 42")
|
||
net_case("networking/net_echo", "4 10 20 30 42")
|
||
net_case("networking/net_snapshot", "50 7 50")
|
||
net_case("networking/net_sync", "12 3 4 50 999")
|
||
net_case("networking/net_owner", "-1 7 0 1")
|
||
net_case("networking/net_rpc", "0 8")
|
||
net_case("networking/net_roles", "1 102")
|
||
net_case("networking/net_demo", "5 999 5")
|
||
|
||
feat_case("events/promote", "", "100 101 200 201", "promote.ludic (@Public -> model_<M>_spawn/despawn events)")
|
||
feat_case("events/cancel", "", "0 1 92", "cancel.ludic (cancellable event + cancel + emit-as-expression)")
|
||
|
||
# EV2 the world table: the mod reflection ABI, callable from Ludic by name.
|
||
net_case("ecs/world_get", "50 1 7")
|
||
net_case("ecs/world_scan", "2 110")
|
||
net_case("ecs/world_spawn", "1 42 1")
|
||
net_case("ecs/world_mixed", "99")
|
||
net_case("ecs/world_query", "2 110")
|
||
|
||
feat_case("events/recurse", "", "16", "recurse.ludic (EV6: re-entrant emit is depth-bounded, no runaway cycle)")
|
||
net_case("events/scoped", "2")
|
||
net_case("ecs/world_dyn", "0 1 30 100 1 30")
|
||
|
||
feat_case("events/prop_events", "", "300 400", "prop_events.ludic (@Public -> prop_<P>_attach/detach events)")
|
||
feat_case("events/scene_events", "", "10 1 20 2 30 3", "scene_events.ludic (public scene -> scene_<S>_enter/exit events)")
|
||
feat_case("events/program_events", "", "1 100 2 200", "program_events.ludic (@Public @OnStart/@OnQuit -> program_start/quit)")
|
||
feat_case("events/layer_events", "aaaa", "50 2 1 50", "layer_events.ludic (layer toggle + public layer -> layer_<L>_show/hide)")
|
||
|
||
# showcase examples with no self-asserting `entry` — compile-only so they can't
|
||
# silently rot after a compiler or reorg change.
|
||
print("== showcase examples still compile ==")
|
||
smoke("ecs/hello")
|
||
smoke("events/events")
|
||
smoke("networking/net_rt")
|
||
|
||
print("== self-hosted front-end binaries (ludicc / ludic) ==")
|
||
# the two commands are one multi-call native binary built from the seed with
|
||
# clang alone. ludicc compiles; ludic compiles-and-runs.
|
||
if shq("bin/x build-cli > /tmp/x_cli.out 2>&1") and is_exec("bin/ludicc") and is_exec("bin/ludic") {
|
||
ok("x build-cli builds bin/ludicc and bin/ludic from the seed (clang-only)")
|
||
} else { bad2("x build-cli", capture_line("tail -1 /tmp/x_cli.out")) }
|
||
|
||
# ludicc -o drives clang to a native binary. A game links windowed by default
|
||
# (LUDIC_HOME points the linker at runtime/native/cocoa.ll) — that path is
|
||
# macOS-only (Cocoa), so off Darwin we prove the same "-o yields a runnable
|
||
# native binary" with a headless link, which needs no window backend.
|
||
if is_darwin() {
|
||
if shq("LUDIC_HOME=. bin/ludicc examples/games/snake.ludic -o /tmp/x_cli_snake > /tmp/x_cli.out 2>&1") and shq("file /tmp/x_cli_snake 2>/dev/null | grep -q Mach-O") {
|
||
ok("ludicc app.ludic -o bin -> native executable")
|
||
} else { bad2("ludicc -o", capture_line("tail -1 /tmp/x_cli.out")) }
|
||
} else {
|
||
if shq("LUDIC_HOME=. bin/ludicc --headless examples/games/snake.ludic -o /tmp/x_cli_snake > /tmp/x_cli.out 2>&1") and shq("test -x /tmp/x_cli_snake") {
|
||
ok("ludicc app.ludic -o bin -> native executable (headless)")
|
||
} else { bad2("ludicc -o", capture_line("tail -1 /tmp/x_cli.out")) }
|
||
}
|
||
|
||
# --emit-llvm stops at the IR.
|
||
if shq("bin/ludicc examples/games/snake.ludic --emit-llvm -o /tmp/x_cli_snake.ll > /tmp/x_cli.out 2>&1") and shq("head -1 /tmp/x_cli_snake.ll | grep -q 'LLVM IR'") {
|
||
ok("ludicc --emit-llvm -> LLVM IR")
|
||
} else { bad2("ludicc --emit-llvm", capture_line("tail -1 /tmp/x_cli.out")) }
|
||
|
||
# ludic compiles, runs, and forwards the program's exit code.
|
||
write_file("/tmp/x_cli_exit.ludic", "entry { exit(42) }\n")
|
||
let rc = sh("LUDIC_HOME=. bin/ludic /tmp/x_cli_exit.ludic > /tmp/x_cli.out 2>&1")
|
||
if (rc == 42) { ok("ludic app.ludic -> compiles, runs, forwards exit code") }
|
||
else { bad2("ludic run: expected exit 42", `got {string(rc)}`) }
|
||
|
||
# docs site generator + guard, in Ludic (no Python). docs-gen emits the whole
|
||
# pages payload; docs-check is its coverage/integrity guard; docs-palette is
|
||
# the named-colour source of truth (its output is the tracked emit_color.ludic
|
||
# + palette.json, so a clean git diff proves it stayed byte-identical).
|
||
print("== docs site (Ludic generator, no Python) ==")
|
||
if shq("bin/x docs-gen --out /tmp/x_docs > /tmp/x_docs.out 2>&1") {
|
||
ok("x docs-gen -> the pages-branch payload")
|
||
} else { bad2("x docs-gen", capture_line("tail -1 /tmp/x_docs.out")) }
|
||
if shq("bin/x docs-check /tmp/x_docs > /tmp/x_docs_chk.out 2>&1") {
|
||
ok("x docs-check passes on the generated site")
|
||
} else { bad2("x docs-check", capture_line("tail -1 /tmp/x_docs_chk.out")) }
|
||
if shq("bin/x docs-palette > /dev/null 2>&1") and shq("git diff --quiet selfhost/backend/stdlib/emit_color.ludic docs/language/colors/palette.json") {
|
||
ok("x docs-palette regenerates emit_color.ludic + palette.json byte-identically")
|
||
} else { bad("x docs-palette drifted (git diff not empty)") }
|
||
|
||
return report()
|
||
}
|