feat(stdlib): namespaced standard library (issue #2)

Implement the bulk of the namespaced-stdlib proposal (workshopsoft/ludic#2):
156 namespace methods across Math, Text, List, Ease, Collide, World, Net,
Sys, Save, Mem, extended Screen, Color functions, extended Random, and Time.
All deterministic fixed-point; self-hosting (C-free bootstrap fixpoint holds).

Compiler (selfhost/):
- Math.*: sqrt/sin/cos/tan/atan2/asin/acos (fixed-point runtime prelude —
  bit-by-bit isqrt, 256-entry interpolated sine table, Ross atan2), plus
  hypot/dist/dist2/deg_to_rad/rad_to_deg/posmod/wrap/ping_pong/snapped/
  move_toward/smoothstep/lerp/remap/sign/floor/ceil/round.
- Text.* (complete): upper/lower/trim/repeat/pad, split/join/replace,
  and the libc-backed queries.
- List.* (complete): insert/remove_at/remove/sort plus the earlier ops.
- Ease.* (in/out/in_out/back/bounce) and Collide.* (rects/point_rect/
  circles/rect_circle).
- Phase 3: World/Net/Sys/Save namespaced over the bare builtins (byte-
  identical IR) and Mem.* (bytes/words/copy/fill/peek/poke).
- Screen.* extended (line/circle/fill_circle/triangle/fill_triangle via new
  runtime primitives; sprite/sprite_scaled aliases), Color.* functions,
  Random.* (value/int/sign), Time.* (frame/delta/elapsed/now — new
  game-loop frame counter).
- Fix a lexer bug: fixed-point literals with >4 fractional digits overflowed.

Docs & tooling:
- 129 new per-symbol doc pages; gen.py made data-driven (namespaces
  discovered from the docs, no hardcoded list); new check-impl.py enforces
  that every implemented namespace method / keyword / type / phase has a
  doc page, wired into `x test-tools`. Document the previously-undocumented
  keywords (break/continue/where/entry/new/public + and/or/not tokens).
- LSP: namespaced signature help (ns_method_sig) covering every namespace.

Tests: 12 new self-host/regression tests + a golden render for the drawing
primitives. All suites green (selfhost 21, regression 45, tools 29).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-30 00:26:19 +03:00
parent ff15c4e01d
commit a38195128f
235 changed files with 24676 additions and 7762 deletions

83
tools/x/build.ludic Normal file
View file

@ -0,0 +1,83 @@
# build.ludic — building the toolchain binaries and user programs.
#
# Replaces build-cli.sh (the ludicc/ludic front-end) and build.sh (compiling a
# Ludic program to a native app). All toolchain binaries land in bin/.
# compile a Ludic source to a native binary in bin/ via ludicc + clang (-O2).
# Returns true on success. Used for x itself and the editor tools.
fn build_tool(name: ptr, src: ptr) -> bool {
let ll = `build/{name}.ll`
if not shq(`bin/ludicc {src} --emit-llvm -o {ll} 2>/dev/null`) { print(`build failed: {name} (compile)`); return false }
# write to a temp then move, so a running bin/x can rebuild itself in place
if not shq(`{cc()} -O2 {ll} -o bin/{name}.tmp`) { print(`build failed: {name} (link)`); return false }
run(`mv -f bin/{name}.tmp bin/{name} && rm -f {ll}`)
return true
}
# ---- build-cli: the self-hosted front-end binaries ---------------------------
# Both ludicc and ludic are the SAME multi-call binary assembled from the IR seed
# with clang alone; invoked as `ludic` it compiles-and-runs, as `ludicc` it just
# compiles.
fn cmd_build_cli() -> int {
run("mkdir -p bin build")
print("cc: selfhost/ludicc.seed.ll -> bin/ludicc, bin/ludic (from the IR seed, no C compiler)")
if not shq(`{cc()} selfhost/ludicc.seed.ll -o bin/ludicc`) { err("x: seed did not assemble\n"); return 1 }
run("cp bin/ludicc bin/ludic && chmod +x bin/ludicc bin/ludic")
print("done. built bin/ludicc and bin/ludic")
return 0
}
# ---- build: the whole toolchain (ludicc, ludic, x, ludic-fmt, ludic-lsp) -----
fn cmd_build() -> int {
if cmd_build_cli() != 0 { return 1 }
print("ludicc: tools/x/main.ludic -> bin/x (the task runner rebuilds itself)")
if not build_tool("x", "tools/x/main.ludic") { return 1 }
print("ludicc: tools/ludic-tools/fmt.ludic -> bin/ludic-fmt")
if not build_tool("ludic-fmt", "tools/ludic-tools/fmt.ludic") { return 1 }
print("ludicc: tools/ludic-tools/lsp.ludic -> bin/ludic-lsp")
if not build_tool("ludic-lsp", "tools/ludic-tools/lsp.ludic") { return 1 }
sync_vscode_grammar()
print("done. toolchain in bin/ (ludicc, ludic, x, ludic-fmt, ludic-lsp)")
return 0
}
# ---- app: compile a Ludic program to a native app ----------------------------
# usage: x app <file.ludic> [--headless|--windowed] [--save-temps]
# windowed is the default for a game; headless renders the last frame to out.ppm.
fn cmd_app() -> int {
ensure_ludicc()
var src = null
var mode = 1 # 1 = windowed, 2 = headless
var save = false
var ai = 2
while ai < arg_count() {
let a = arg(ai)
if (a == "--headless") { mode = 2 }
else if (a == "--windowed") { mode = 1 }
else if (a == "--save-temps") { save = true }
else if (a[0] != 45) { src = a } # 45 = '-', so skip other flags
ai = ai + 1
}
if (src == null) { err("usage: x app <file.ludic> [--headless] [--save-temps]\n"); return 1 }
let name = capture_line(`basename {src} .ludic`)
if (mode == 2) {
let outbin = `build/{name}_headless`
let ll = `{outbin}.ll`
print(`ludicc: {src} -> {outbin} (renders the last frame to out.ppm)`)
if not shq(`bin/ludicc --headless {src} > {ll}`) { return 1 }
if not shq(`{cc()} -O2 {ll} -o {outbin}`) { return 1 }
if not save { run(`rm -f {ll}`) }
print(`done. run: printf 'ddss' | ./{outbin} && open out.ppm`)
return 0
}
let outbin = `build/{name}`
let ll = `{outbin}.ll`
print(`ludicc: {src} -> {outbin} (Ludic-written compiler -> LLVM IR -> windowed binary)`)
if not shq(`bin/ludicc --windowed {src} > {ll}`) { return 1 }
if not shq(`{cc()} -O2 {ll} runtime/native/cocoa.ll -framework Cocoa -Wl,-rpath,@loader_path -o {outbin}`) { return 1 }
if not save { run(`rm -f {ll}`) }
print(`done. run: ./{outbin} (from the repo root, so assets/ resolves)`)
return 0
}

80
tools/x/main.ludic Normal file
View file

@ -0,0 +1,80 @@
# x — the Ludic task runner. One native binary (bin/x) that replaces every
# build/test/bootstrap shell script in the repo. It is written in Ludic and
# compiled by Ludic; the unix tools and clang it drives are invoked through the
# `run` intrinsic, the same way the old *.sh files shelled out.
#
# Bootstrap it once from a clean checkout (the only step Ludic cannot do for
# itself, since compiling Ludic needs a compiler):
#
# clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc tools/x/main.ludic -o bin/x
#
# Thereafter `bin/x build` rebuilds the whole toolchain — including bin/x itself.
# Always run x from the repository root.
program X {
import "prelude.ludic"
import "selfhost.ludic"
import "build.ludic"
import "tools.ludic"
import "selfhost_test.ludic"
import "test.ludic"
fn usage() -> void {
print("x — the Ludic task runner (run from the repository root)")
print("")
print("build & run:")
print(" x build build the whole toolchain into bin/ (ludicc, ludic, x, ludic-fmt, ludic-lsp)")
print(" x build-cli build just bin/ludicc and bin/ludic from the IR seed")
print(" x app <file.ludic> [--headless] [--save-temps]")
print(" compile a Ludic program to a native app in build/")
print(" x tools [--install] [--test] build the editor toolchain (ludic-fmt, ludic-lsp)")
print("")
print("test:")
print(" x test the full regression suite")
print(" x selfhost-test the self-hosting suite (correctness + bootstrap fixpoints)")
print(" x test-tools the editor-toolchain suite")
print("")
print("self-host internals:")
print(" x selfhost-build [ludicc] [out] assemble + compile the self-host compiler")
print(" x bootstrap the self-hosting fixpoint proof (seeded from bin/ludicc)")
print(" x bootstrap-cfree rebuild the compiler from the seed with no C compiler")
print(" x reseed regenerate selfhost/ludicc.seed.ll after a compiler change")
print(" x game-build <ludicc> <game.ludic> <out> compile a Ludic game (headless)")
print(" x sh-compile <ludicc> <in.ludic> <out> compile + link one .ludic file")
}
# a positional argument, or a default when absent
fn argn(i: int, dflt: ptr) -> ptr {
if (i < arg_count()) { return arg(i) }
return dflt
}
entry {
if (arg_count() < 2) { usage(); exit(1) }
let cmd = arg(1)
if (cmd == "build") { exit(cmd_build()) }
if (cmd == "build-cli") { exit(cmd_build_cli()) }
if (cmd == "app") { exit(cmd_app()) }
if (cmd == "tools") { exit(cmd_tools()) }
if (cmd == "test") { exit(cmd_test()) }
if (cmd == "selfhost-test") { exit(cmd_selfhost_test()) }
if (cmd == "test-tools") { exit(cmd_test_tools()) }
if (cmd == "bootstrap") { exit(cmd_bootstrap()) }
if (cmd == "bootstrap-cfree") { exit(cmd_bootstrap_cfree()) }
if (cmd == "reseed") { exit(cmd_reseed()) }
if (cmd == "selfhost-build") { exit(cmd_selfhost_build(argn(2, "bin/ludicc"), argn(3, "build/selfhost"))) }
if (cmd == "sh-compile") {
if (arg_count() < 5) { err("usage: x sh-compile <ludicc> <in.ludic> <out>\n"); exit(1) }
exit(cmd_sh_compile(arg(2), arg(3), arg(4)))
}
if (cmd == "game-build") {
if (arg_count() < 5) { err("usage: x game-build <ludicc> <game.ludic> <out>\n"); exit(1) }
exit(cmd_game_build(arg(2), arg(3), arg(4)))
}
if (cmd == "help") or (cmd == "--help") or (cmd == "-h") { usage(); exit(0) }
err(`x: unknown command '{cmd}'\n`)
usage()
exit(1)
}
}

119
tools/x/prelude.ludic Normal file
View file

@ -0,0 +1,119 @@
# prelude.ludic — the shared runtime for `x`, the Ludic task runner.
#
# `x` replaces every build/test/bootstrap shell script in the repo: it is a
# single native binary (bin/x) that drives clang, the self-host compiler and the
# unix tools the same way the old *.sh files did — only now it is written in
# Ludic and compiled by Ludic. This fragment is the tiny standard library the
# commands lean on: process control, file IO, string trimming and a colored
# PASS/FAIL test harness. It carries no ECS, so it links as a plain CLI program.
#
# Everything runs relative to the current directory, so `x` must be invoked from
# the repository root (the one-line bootstrap in README.md does exactly that).
# ---- file IO ----------------------------------------------------------------
# read a whole file into a fresh NUL-terminated buffer (null if it cannot open)
fn read_file(path: ptr) -> ptr {
let f = file_open(path, "rb")
if (f == null) { return null }
file_seek(f, 0, 2)
let n = file_tell(f)
file_seek(f, 0, 0)
let buf = bytes(n + 1)
file_read(f, buf, n)
buf[n] = 0
file_close(f)
return buf
}
# overwrite `path` with `s`; returns false if it could not be opened
fn write_file(path: ptr, s: ptr) -> bool {
let f = file_open(path, "wb")
if (f == null) { return false }
file_write(f, s, len(s))
file_close(f)
return true
}
fn file_exists(path: ptr) -> bool { return shq(`test -e {path}`) }
fn is_exec(path: ptr) -> bool { return shq(`test -x {path}`) }
# is `a` newer than `b` (like the shell's `-nt`)?
fn newer(a: ptr, b: ptr) -> bool { return shq(`test {a} -nt {b}`) }
# ---- process control --------------------------------------------------------
# `run` returns the raw wait status; the program's exit code is the high byte.
fn exit_code(st: int) -> int { return (st >> 8) & 255 }
# run a command, returning its exit code (0 = success)
fn sh(cmd: ptr) -> int { return exit_code(run(cmd)) }
# run a command, true when it succeeded
fn shq(cmd: ptr) -> bool { return exit_code(run(cmd)) == 0 }
# run `cmd` and return its stdout (stderr discarded). Never null.
fn capture(cmd: ptr) -> ptr {
let tmp = "/tmp/x_capture.out"
run(`{cmd} > {tmp} 2>/dev/null`)
let s = read_file(tmp)
if (s == null) { return "" }
return s
}
# run `cmd`, join its output lines with single spaces and trim — the Ludic twin
# of the shell idiom `$(cmd | tr '\n' ' ' | sed 's/ *$//')`.
fn capture_line(cmd: ptr) -> ptr {
return capture(`{cmd} | tr '\n' ' ' | sed 's/ *$//'`)
}
# the last line of a command's output (for one-line error messages)
fn capture_tail(cmd: ptr) -> ptr {
return capture(`{cmd} 2>&1 | tail -1`)
}
fn getenv_or(name: ptr, dflt: ptr) -> ptr {
let v = getenv(name)
if (v == null) { return dflt }
return v
}
# ---- stdout helpers ---------------------------------------------------------
# write `s` with no trailing newline (print() always adds one)
fn out(s: ptr) -> void { file_write(file_stdout(), s, len(s)) }
fn err(s: ptr) -> void { file_write(file_stderr(), s, len(s)) }
# an ESC byte — the lexer has no \033, so build it by hand
fn esc() -> ptr { let b = bytes(2); b[0] = 27; b[1] = 0; return b }
fn c_green() -> ptr { return esc() + "[32m" }
fn c_red() -> ptr { return esc() + "[31m" }
fn c_reset() -> ptr { return esc() + "[0m" }
# ---- the PASS/FAIL test harness ---------------------------------------------
var PASS: int = 0
var FAIL: int = 0
fn ok(msg: ptr) -> void {
PASS = PASS + 1
print(` {c_green()}PASS{c_reset()} {msg}`)
}
fn bad(msg: ptr) -> void {
FAIL = FAIL + 1
print(` {c_red()}FAIL{c_reset()} {msg}`)
}
fn bad2(msg: ptr, detail: ptr) -> void {
bad(msg)
print(` {detail}`)
}
# assert two strings equal, reporting the mismatch
fn check(label: ptr, got: ptr, want: ptr) -> void {
if (got == want) { ok(label) }
else { bad2(label, `expected [{want}] got [{got}]`) }
}
# print the "== N passed, M failed ==" footer and return the process exit code
fn report() -> int {
print("")
print(`== {str(PASS)} passed, {str(FAIL)} failed ==`)
if (FAIL == 0) { return 0 }
return 1
}

201
tools/x/selfhost.ludic Normal file
View file

@ -0,0 +1,201 @@
# selfhost.ludic — building and bootstrapping the self-host compiler.
#
# Replaces selfhost/build.sh, selfhost/bootstrap.sh, selfhost/bootstrap-cfree.sh,
# selfhost/reseed.sh, selfhost/compile.sh and selfhost/game-build.sh. The one
# canonical fragment list — copied by hand into three of those scripts before —
# now lives here once, in selfhost_frags().
# the C toolchain driver (clang) that assembles and links the emitted IR
fn cc() -> ptr { return getenv_or("LUDIC_CC", "clang") }
# the self-host compiler's source fragments, in link order. This is THE list;
# the old scripts each carried their own copy.
fn selfhost_frags() -> []ptr {
let f = new []ptr
push(f, "selfhost/str.ludic")
push(f, "selfhost/buf.ludic")
push(f, "selfhost/io.ludic")
push(f, "selfhost/ast.ludic")
push(f, "selfhost/lex.ludic")
push(f, "selfhost/parse.ludic")
push(f, "selfhost/parse_game.ludic")
push(f, "selfhost/emit_core.ludic")
push(f, "selfhost/emit_head.ludic")
push(f, "selfhost/emit_addr.ludic")
push(f, "selfhost/emit_intrin.ludic")
push(f, "selfhost/emit_intrin2.ludic")
push(f, "selfhost/emit_math.ludic")
push(f, "selfhost/emit_text.ludic")
push(f, "selfhost/emit_list.ludic")
push(f, "selfhost/emit_ease.ludic")
push(f, "selfhost/emit_collide.ludic")
push(f, "selfhost/emit_mem.ludic")
push(f, "selfhost/emit_time.ludic")
push(f, "selfhost/emit_colorfn.ludic")
push(f, "selfhost/emit_color.ludic")
push(f, "selfhost/emit_new.ludic")
push(f, "selfhost/emit_expr.ludic")
push(f, "selfhost/emit_stmt.ludic")
push(f, "selfhost/emit_ecs.ludic")
push(f, "selfhost/emit_query.ludic")
push(f, "selfhost/emit_spawn.ludic")
push(f, "selfhost/emit_game.ludic")
push(f, "selfhost/emit_machine.ludic")
push(f, "selfhost/emit_save.ludic")
push(f, "selfhost/emit_net.ludic")
push(f, "selfhost/emit_ui.ludic")
push(f, "selfhost/emit_decl.ludic")
push(f, "selfhost/main.ludic")
return f
}
# concatenate the fragments into `program SelfHost { ... }` at `outpath`.
# This is the pure-Ludic equivalent of the scripts' `{ echo; for; cat; }` block.
fn write_selfhost_src(outpath: ptr) -> bool {
let f = file_open(outpath, "wb")
if (f == null) { return false }
let hdr = "program SelfHost {\n"
file_write(f, hdr, len(hdr))
let frags = selfhost_frags()
var i = 0
while i < len(frags) {
let s = read_file(frags[i])
if (s != null) { file_write(f, s, len(s)) }
file_write(f, "\n", 1)
i = i + 1
}
let ftr = "}\n"
file_write(f, ftr, len(ftr))
file_close(f)
return true
}
# count the lines in a file (for the progress notes the scripts printed)
fn line_count(path: ptr) -> ptr {
return capture_line(`wc -l < {path}`)
}
# ensure bin/ludicc exists and is current with the seed. Built from the checked-in
# IR seed with clang alone — no C compiler is ever involved.
fn ensure_ludicc() -> void {
run("mkdir -p bin build")
if (not is_exec("bin/ludicc")) or newer("selfhost/ludicc.seed.ll", "bin/ludicc") {
print("cc: selfhost/ludicc.seed.ll -> bin/ludicc (from the IR seed, no C compiler)")
if not shq(`{cc()} selfhost/ludicc.seed.ll -o bin/ludicc`) {
err("x: could not assemble the seed\n"); exit(1)
}
}
}
# ---- selfhost-build: assemble + compile the self-host compiler ---------------
# usage: x selfhost-build [ludicc] [outbin] (defaults: bin/ludicc, build/selfhost)
fn cmd_selfhost_build(lc: ptr, outbin: ptr) -> int {
run("mkdir -p build")
let src = "build/selfhost.ludic"
if not write_selfhost_src(src) { err("x: cannot write build/selfhost.ludic\n"); return 1 }
let ll = `{outbin}.ll`
if not shq(`{lc} {src} > {ll} 2>/dev/null`) { run(`rm -f {ll}`); return 1 }
let rc = shq(`{cc()} {ll} -o {outbin} 2>/dev/null`)
run(`rm -f {ll}`)
if rc { return 0 }
return 1
}
# ---- sh-compile: compile one .ludic with a given selfhost binary and link ----
# usage: x sh-compile <selfhost-binary> <input.ludic> <output-binary>
fn cmd_sh_compile(shbin: ptr, in: ptr, outbin: ptr) -> int {
if not shq(`{shbin} {in} > {outbin}.ll`) { return 1 }
if not shq(`{cc()} {outbin}.ll -o {outbin}`) { return 1 }
return 0
}
# ---- game-build: compile a Ludic GAME with the self-host compiler ------------
# The compiler resolves the game's imports and auto-splices the runtime; -O2
# strips the dead windowing branches so a headless link needs no win_* defs.
# the quiet core, reused by the test suites; returns true on success. On failure
# the self-host/link diagnostics are left in /tmp/x_gb.err.
fn game_build_ok(shbin: ptr, game: ptr, outbin: ptr) -> bool {
let ll = `{outbin}.ll`
if not shq(`{shbin} {game} > {ll} 2>/tmp/x_gb.err`) { return false }
if not shq(`{cc()} -O2 {ll} -o {outbin} 2>/tmp/x_gb.err`) { return false }
run(`rm -f {ll}`)
return true
}
# usage: x game-build <selfhost-binary> <game.ludic> <out-binary>
fn cmd_game_build(shbin: ptr, game: ptr, outbin: ptr) -> int {
if game_build_ok(shbin, game, outbin) { print(`built {outbin}`); return 0 }
print("build failed:")
out(capture("grep -i error /tmp/x_gb.err | head -5"))
return 1
}
# ---- bootstrap: the self-hosting fixpoint proof (seeded from bin/ludicc) ------
# stage0 bin/ludicc compiles the self-host source -> gen1 (built by a different
# compiler, so its IR legitimately differs); gen1 -> gen2, gen2 -> gen3, and
# gen2.ll == gen3.ll is the fixpoint that proves the compiler reproduces itself.
fn cmd_bootstrap() -> int {
ensure_ludicc()
run("mkdir -p build/boot")
if cmd_selfhost_build("bin/ludicc", "build/boot/gen1") != 0 { print("FAIL: stage0 build"); return 1 }
print(" stage0: bin/ludicc -> gen1 (self-host compiler)")
if not shq("build/boot/gen1 build/selfhost.ludic > build/boot/gen2.ll 2>/dev/null") { print("FAIL: gen1 self-compile"); return 1 }
if not shq(`{cc()} build/boot/gen2.ll -o build/boot/gen2 2>/dev/null`) { print("FAIL: gen2 assemble"); return 1 }
print(` stage1: gen1 -> gen2.ll ({line_count("build/boot/gen2.ll")} lines) -> gen2`)
if not shq("build/boot/gen2 build/selfhost.ludic > build/boot/gen3.ll 2>/dev/null") { print("FAIL: gen2 self-compile"); return 1 }
print(` stage2: gen2 -> gen3.ll ({line_count("build/boot/gen3.ll")} lines)`)
if shq("cmp -s build/boot/gen2.ll build/boot/gen3.ll") {
run("rm -f build/selfhost.ludic")
print(" FIXPOINT: gen2.ll == gen3.ll — the self-hosted compiler reproduces itself")
return 0
}
print(" gen2.ll != gen3.ll")
out(capture("diff build/boot/gen2.ll build/boot/gen3.ll | head"))
return 1
}
# ---- bootstrap-cfree: rebuild the compiler with NO C compiler -----------------
# The seed is the compiler's own IR, a proven fixed point: clang assembles it,
# the seed-built compiler compiles its own source, and the result must equal the
# seed byte for byte.
fn cmd_bootstrap_cfree() -> int {
run("mkdir -p build/cfree")
if not shq(`{cc()} selfhost/ludicc.seed.ll -o build/cfree/sh_seed 2>/dev/null`) { print("FAIL: assemble seed"); return 1 }
print(" seed.ll --clang--> sh_seed (no C compiler used)")
if not write_selfhost_src("build/cfree/selfhost.ludic") { print("FAIL: write source"); return 1 }
if not shq("build/cfree/sh_seed build/cfree/selfhost.ludic > build/cfree/out.ll 2>/dev/null") { print("FAIL: seed compiler self-compile"); return 1 }
print(` sh_seed compiles selfhost.ludic -> out.ll ({line_count("build/cfree/out.ll")} lines)`)
if shq("cmp -s build/cfree/out.ll selfhost/ludicc.seed.ll") {
print(" out.ll == seed.ll — the compiler rebuilds itself with no C compiler")
return 0
}
print(" out.ll != seed.ll (seed is stale — regenerate with: x reseed)")
out(capture("diff build/cfree/out.ll selfhost/ludicc.seed.ll | head"))
return 1
}
# ---- reseed: regenerate the checked-in IR seed (C-free when possible) ---------
# Uses the CURRENT seed to build the new one, compiling twice so the seed is a
# fixed point of the NEW compiler, not a one-step image of the old. Falls back to
# bin/ludicc only if the seed itself no longer assembles. Verify with
# `x bootstrap-cfree` afterwards.
fn cmd_reseed() -> int {
run("mkdir -p build/cfree")
if not write_selfhost_src("build/cfree/selfhost.ludic") { print("FAIL: write source"); return 1 }
if shq(`{cc()} selfhost/ludicc.seed.ll -o build/cfree/sh_old 2>/dev/null`) {
if not shq("build/cfree/sh_old build/cfree/selfhost.ludic > build/cfree/step1.ll") { print("FAIL: step1"); return 1 }
if not shq(`{cc()} build/cfree/step1.ll -o build/cfree/sh_new`) { print("FAIL: assemble sh_new"); return 1 }
if not shq("build/cfree/sh_new build/cfree/selfhost.ludic > selfhost/ludicc.seed.ll") { print("FAIL: reseed"); return 1 }
} else {
print("seed does not build; reseeding from bin/ludicc")
ensure_ludicc()
if cmd_selfhost_build("bin/ludicc", "build/cfree/sh_c") != 0 { print("FAIL: build from bin/ludicc"); return 1 }
if not shq("build/cfree/sh_c build/cfree/selfhost.ludic > selfhost/ludicc.seed.ll") { print("FAIL: reseed"); return 1 }
}
print(`reseeded: {line_count("selfhost/ludicc.seed.ll")} lines`)
return 0
}

View file

@ -0,0 +1,68 @@
# selfhost_test.ludic — the self-hosting suite: the compiler is correct (compiles
# diverse programs to working binaries) and self-reproducing (the bootstrap
# fixpoint). Replaces selfhost/test.sh.
# compile selfhost/tests/<name>.ludic with bin/ludicc, assemble, run, compare
fn sh_case(name: ptr, exp: ptr) -> void {
let ll = `/tmp/x_sh_{name}.ll`
let er = `/tmp/x_sh_{name}.err`
let bn = `/tmp/x_sh_{name}`
if not shq(`bin/ludicc selfhost/tests/{name}.ludic > {ll} 2>{er}`) {
bad2(`{name}: self-host errored`, capture_line(`head -1 {er}`)); return
}
if not shq(`{cc()} {ll} -o {bn} 2>/dev/null`) { bad(`{name}: IR did not assemble`); return }
let got = capture_line(`{bn} < /dev/null`)
if (got == exp) { ok(`{name} ({got})`) } else { bad2(name, `got [{got}] want [{exp}]`) }
}
# compile examples/<name>.ludic as a game and diff its render against the golden
fn game_case(name: ptr, keys: ptr) -> void {
if not game_build_ok("bin/ludicc", `examples/{name}.ludic`, `/tmp/x_g_{name}`) { bad(`{name}: build`); return }
run(`printf '%s' '{keys}' | /tmp/x_g_{name} >/dev/null 2>&1`)
if shq(`cmp -s selfhost/golden/{name}.ppm out.ppm`) { ok(`{name} matches the golden render`) }
else { bad(`{name}: differs from golden`) }
}
fn cmd_selfhost_test() -> int {
PASS = 0
FAIL = 0
print("== build the self-host compiler (from the IR seed, no C) ==")
ensure_ludicc()
if is_exec("bin/ludicc") { ok("compiler builds from the IR seed (no C)") }
else { bad("self-host build"); return report() }
print("== the self-host compiler is correct ==")
sh_case("structs", "7 9 109 2 42")
sh_case("slices", "0 20 361 777")
sh_case("control", "55 4 15 1")
sh_case("match_bits", "1 2 9 16 4 15")
sh_case("fixed", "2 3 0 6 1")
sh_case("math", "3 7 5 10 -1 0 1 2 3 3 2 5 25 50")
sh_case("math2", "1414 3 999 999 499 5 5 25 3142 179 4 2 7 25 30 500")
sh_case("textlist", "3 10 30 1 0 2 -1 30 10 10 30 30 2 0 11 104 1 0 1 0 1 0 6 -1 42 5 1 4 4")
sh_case("textlist2", "3 1 1 1 2 1 6 1 1 3 1 1 3 1 4 1 2 99 3 2 0 3 1 9 3")
sh_case("easecollide", "250 750 500 1000 1000 999 765 -88 1000 1 0 1 0 0 1 0 1 0")
sh_case("memsys", "65 66 0 99 12345 1")
sh_case("math3", "45 90 180 -135 30 60 90")
sh_case("textsplit", "1 1 1 3 1 1 1 1 1 1 1")
sh_case("color", "16744512 1090486336 1090486336 8355711 8355711 8355711")
print("== the self-host compiler compiles real games (vs golden output) ==")
# the golden PPMs were produced by the original C compiler; the self-hosted
# compiler must reproduce them exactly.
game_case("snake", "ddssaawwddss")
game_case("menu", "ssss")
game_case("chronorift", "ddddwwwwaassK")
game_case("draw_prims", "")
print("== the bootstrap fixpoint (seeded from bin/ludicc) ==")
if shq("bin/x bootstrap > /tmp/x_boot.out 2>&1") { ok("gen2.ll == gen3.ll (compiler reproduces itself)") }
else { bad("fixpoint not reached"); out(capture("cat /tmp/x_boot.out")) }
print("== the C-free bootstrap (from the checked-in IR seed, no C compiler) ==")
if shq("bin/x bootstrap-cfree > /tmp/x_cfree.out 2>&1") { ok("seed.ll rebuilds the compiler and is its own fixed point") }
else { bad("C-free bootstrap failed (seed stale? run: x reseed)"); out(capture("cat /tmp/x_cfree.out")) }
return report()
}

143
tools/x/test.ludic Normal file
View file

@ -0,0 +1,143 @@
# 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.
fn feat_case(name: ptr, keys: ptr, exp: ptr, label: ptr) -> void {
if not game_build_ok("bin/ludicc", `examples/{name}.ludic`, `/tmp/x_e_{name}`) {
bad2(label, capture_line("grep -i error /tmp/x_gb.err | head -1")); return
}
var got = ""
if (keys == "") { got = capture_line(`/tmp/x_e_{name} < /dev/null`) }
else { got = capture_line(`printf '{keys}' | /tmp/x_e_{name}`) }
if (got == exp) { ok(label) } else { bad2(label, `got [{got}] want [{exp}]`) }
}
# a "does it still compile" smoke test (parse -> lower -> link), no run
fn qsmoke(name: ptr) -> void {
if game_build_ok("bin/ludicc", `examples/{name}.ludic`, `/tmp/x_q_{name}`) {
ok(`{name} compiles (@Queries desugars to S_QUERY)`)
} else { bad2(name, capture_line("grep -i error /tmp/x_gb.err | head -1")) }
}
# 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.
fn net_case(name: ptr, exp: ptr) -> void {
let ll = `/tmp/x_n_{name}.ll`
let log = `/tmp/x_n_{name}.out`
if not shq(`bin/ludicc --headless examples/{name}.ludic --emit-llvm -o {ll} > {log} 2>&1`) {
let t = capture_line(`tail -1 {log}`)
bad2(name, `build ({t})`); return
}
if not shq(`{cc()} -O2 {ll} -o /tmp/x_nb_{name} >> {log} 2>&1`) {
let t = capture_line(`tail -1 {log}`)
bad2(name, `build ({t})`); return
}
let got = capture_line(`/tmp/x_nb_{name} < /dev/null`)
if (got == exp) { ok(`{name} ({got})`) } else { bad2(name, `got [{got}] want [{exp}]`) }
}
fn 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/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("snake", "ddssaawwddss")
game_case("menu", "ssss")
game_case("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("qdecl")
feat_case("annotations", "", "3 25 0 0", "annotations.ludic (@Queries / @Computed / @OnSpawn / @Handles)")
feat_case("lifecycle", "", "1 700 50 950 2", "lifecycle.ludic (@OnStart/@OnAttach/@OnSpawn/@OnDespawn/@OnQuit in order)")
feat_case("toggle", "", "6 0 7 1 0", "toggle.ludic (enable/disable + @OnDisable/@OnEnable across property/model)")
feat_case("strings", "", "1 2 3 4 5 6 7 8 9", "strings.ludic (str ops, interpolation, slicing)")
feat_case("rng_demo", "", "69 89 6 -1 0", "rng_demo.ludic (Random.value/int/sign)")
feat_case("time_demo", "aaaaaa", "0 1 2 3 4 16", "time_demo.ludic (Time.frame/elapsed/delta)")
feat_case("scenes", "aaaaaaaaaa", "1000 1 101 102 2 3 900 201 900 202 900", "scenes.ludic (scene/layer/on enter/on exit/become)")
feat_case("detach", "", "15 1 25 0", "detach.ludic (attach/detach + @OnAttach/@OnDetach)")
feat_case("reason", "", "503 1009", "reason.ludic (@OnDespawn reason: Despawned vs Quit)")
# 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("mod_events", "10 32 42")
net_case("net_echo", "4 10 20 30 42")
net_case("net_snapshot", "50 7 50")
net_case("net_sync", "12 3 4 50 999")
net_case("net_owner", "-1 7 0 1")
net_case("net_rpc", "0 8")
net_case("net_roles", "1 102")
net_case("net_demo", "5 999 5")
feat_case("promote", "", "100 101 200 201", "promote.ludic (@Public -> model_<M>_spawn/despawn events)")
feat_case("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("world_get", "50 1 7")
net_case("world_scan", "2 110")
net_case("world_spawn", "1 42 1")
net_case("world_mixed", "99")
net_case("world_query", "2 110")
feat_case("recurse", "", "16", "recurse.ludic (EV6: re-entrant emit is depth-bounded, no runaway cycle)")
net_case("scoped", "2")
net_case("world_dyn", "0 1 30 100 1 30")
feat_case("prop_events", "", "300 400", "prop_events.ludic (@Public -> prop_<P>_attach/detach events)")
feat_case("scene_events", "", "10 1 20 2 30 3", "scene_events.ludic (public scene -> scene_<S>_enter/exit events)")
feat_case("program_events", "", "1 100 2 200", "program_events.ludic (@Public @OnStart/@OnQuit -> program_start/quit)")
feat_case("layer_events", "aaaa", "50 2 1 50", "layer_events.ludic (layer toggle + public layer -> layer_<L>_show/hide)")
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).
if shq("LUDIC_HOME=. bin/ludicc examples/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")) }
# --emit-llvm stops at the IR.
if shq("bin/ludicc examples/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 {str(rc)}`) }
return report()
}

186
tools/x/tools.ludic Normal file
View file

@ -0,0 +1,186 @@
# tools.ludic — the Ludic editor toolchain: building ludic-fmt / ludic-lsp and
# their regression suite. Replaces tools/build-tools.sh and tools/test-tools.sh.
#
# The formatter and language server are themselves written in Ludic
# (tools/ludic-tools/*.ludic) and compiled by ludicc; no C is compiled. The LSP
# protocol test, the TextMate grammar test and the vocabulary check are still
# driven through python3/node (they are not project shell scripts), invoked here.
# refresh one editor-copy of a shared grammar file if it has drifted
fn sync_one(shared: ptr, dst: ptr) -> void {
if not shq(`cmp -s {shared} {dst}`) {
run(`cp {shared} {dst}`)
print(`sync: {shared} -> {dst}`)
}
}
# VS Code needs the grammar inside its own extension directory, so it gets a
# copy that build refreshes rather than trusts (test-tools asserts they match).
fn sync_vscode_grammar() -> void {
if not shq("test -d tools/editors/vscode") { return }
run("mkdir -p tools/editors/vscode/syntaxes")
sync_one("tools/editors/shared/ludic.tmLanguage.json", "tools/editors/vscode/syntaxes/ludic.tmLanguage.json")
sync_one("tools/editors/shared/ludic.markdown-injection.json", "tools/editors/vscode/syntaxes/ludic.markdown-injection.json")
sync_one("tools/editors/shared/language-configuration.json", "tools/editors/vscode/language-configuration.json")
}
# is `flag` present anywhere in argv?
fn has_flag(flag: ptr) -> bool {
var i = 2
while i < arg_count() { if (arg(i) == flag) { return true }; i = i + 1 }
return false
}
# ---- tools: build the editor toolchain --------------------------------------
# usage: x tools [--install] [--test]
fn cmd_tools() -> int {
ensure_ludicc()
print("ludicc: tools/ludic-tools/fmt.ludic -> bin/ludic-fmt (Ludic -> LLVM IR -> binary, no C)")
if not build_tool("ludic-fmt", "tools/ludic-tools/fmt.ludic") { return 1 }
print("ludicc: tools/ludic-tools/lsp.ludic -> bin/ludic-lsp (Ludic -> LLVM IR -> binary, no C)")
if not build_tool("ludic-lsp", "tools/ludic-tools/lsp.ludic") { return 1 }
sync_vscode_grammar()
if has_flag("--install") {
run("mkdir -p \"$HOME/.local/bin\"")
run("ln -sf \"$PWD/bin/ludic-fmt\" \"$HOME/.local/bin/ludic-fmt\"")
run("ln -sf \"$PWD/bin/ludic-lsp\" \"$HOME/.local/bin/ludic-lsp\"")
print("linked into ~/.local/bin (make sure it is on your PATH)")
}
if has_flag("--test") { return cmd_test_tools() }
print("done. editors: see tools/editors/README.md")
return 0
}
# ---- test-tools: the editor-toolchain regression suite -----------------------
fn cmd_test_tools() -> int {
PASS = 0
FAIL = 0
let fmt = "bin/ludic-fmt"
if not is_exec(fmt) { err("bin/ludic-fmt missing — run: x tools\n"); return 2 }
if not is_exec("bin/ludic-lsp") { err("bin/ludic-lsp missing — run: x tools\n"); return 2 }
print("formatter")
# --- idempotence: formatting twice equals formatting once ------------------
let nsrc = capture_line("find examples runtime -name '*.ludic' | wc -l")
if shq(`fail=0; for f in $(find examples runtime -name '*.ludic' | sort); do {fmt} "$f" > /tmp/x_a 2>/dev/null; {fmt} /tmp/x_a > /tmp/x_b 2>/dev/null; cmp -s /tmp/x_a /tmp/x_b || fail=1; done; test $fail -eq 0`) {
ok(`idempotent on {nsrc} files`)
} else { bad("idempotent on the source tree") }
# --- meaning preserved: reformatting a formatted file must be a no-op -------
if shq(`fail=0; for f in examples/hello.ludic examples/snake.ludic examples/menu.ludic examples/qdecl.ludic examples/scenes.ludic; do test -f "$f" || continue; {fmt} "$f" > /tmp/x_f1 2>/dev/null; {fmt} /tmp/x_f1 > /tmp/x_f2 2>/dev/null; cmp -s /tmp/x_f1 /tmp/x_f2 || fail=1; done; test $fail -eq 0`) {
ok("formatting is idempotent (fmt(fmt(x)) == fmt(x))")
} else { bad("formatting is not idempotent") }
# --- the strongest invariant: only whitespace ever changes -----------------
# every token is re-emitted in order, so the input and output must be
# byte-identical once all whitespace is stripped.
if shq(`fail=0; for f in $(find examples runtime -name '*.ludic') README.md LANGUAGE.md COMPILING.md; do test -f "$f" || continue; a=$(tr -d ' \t\n' < "$f" | shasum | cut -d' ' -f1); b=$({fmt} "$f" 2>/dev/null | tr -d ' \t\n' | shasum | cut -d' ' -f1); test "$a" = "$b" || fail=1; done; test $fail -eq 0`) {
ok("only whitespace changes (byte-identical modulo spacing)")
} else { bad("formatter altered non-whitespace bytes") }
# --- multi-byte characters must survive intact -----------------------------
write_file("/tmp/x_utf8.ludic", "program E {\n # … ellipsis in a comment\n const S: str = \"café → naïve\"\n}\n")
if shq(`{fmt} /tmp/x_utf8.ludic > /tmp/x_utf8.out 2>/dev/null; cmp -s /tmp/x_utf8.ludic /tmp/x_utf8.out`) {
ok("UTF-8 preserved byte for byte")
} else { bad("UTF-8 mangled") }
write_file("/tmp/x_utf8b.ludic", "program F {\nfn f() -> int { return … }\n}\n")
let ua = capture_line("tr -d ' \t\n' < /tmp/x_utf8b.ludic | shasum | cut -d' ' -f1")
let ub = capture_line(`{fmt} /tmp/x_utf8b.ludic | tr -d ' \t\n' | shasum | cut -d' ' -f1`)
check("unrecognised characters pass through whole", ub, ua)
# --- comments survive, the whole reason this is not `ludicc --fmt` ----------
write_file("/tmp/x_c.ludic", "program C {\n # keep me\n property P { x: int = 0 } # and me\n}\n")
let ncmt = capture_line(`{fmt} /tmp/x_c.ludic | grep -c '#'`)
check("comments preserved", ncmt, "2")
# --- messy input is normalised ---------------------------------------------
write_file("/tmp/x_m.ludic", "program M{\nproperty P{x:int=0}\nhandler S phase Update{let a=1+2\nif a== -1 {a=-a}}\n}\n")
run(`{fmt} /tmp/x_m.ludic > /tmp/x_m.out 2>/dev/null`)
if shq("grep -q '^ property P { x: int = 0 }$' /tmp/x_m.out") { ok("reindents and respaces") } else { bad("reindents and respaces") }
if shq("grep -q 'a = -a' /tmp/x_m.out") { ok("unary minus not spaced") } else { bad("unary minus not spaced") }
# --- language conventions are respected, not overwritten -------------------
write_file("/tmp/x_u.ludic", "program U {\n ui M {\n panel id=Root w=10 {\n button id=Go text=\"go\"\n }\n }\n}\n")
if shq(`{fmt} /tmp/x_u.ludic | grep -q 'id=Root'`) { ok("widget props stay tight (id=Root)") } else { bad("widget props stay tight") }
write_file("/tmp/x_q.ludic", "program Q {\n handler S phase Update {\n for (p) in query [Pos, {Foe}] { }\n }\n property Pos { x: int = 0 }\n property Foe { }\n}\n")
if shq(`{fmt} /tmp/x_q.ludic | grep -q '\\[Pos, {{Foe}}\\]'`) { ok("query tags stay tight ({Foe})") } else { bad("query tags stay tight") }
write_file("/tmp/x_al.ludic", "program A {\n const R_A: int = 0 # one\n const R_BBB: int = 1 # two\n}\n")
if shq(`{fmt} /tmp/x_al.ludic | grep -q 'R_A: int = 0 # one'`) { ok("hand alignment preserved") } else { bad("hand alignment preserved") }
# --- --check exit status, for CI and pre-commit hooks ----------------------
let chk1 = sh(`{fmt} --check -q /tmp/x_m.ludic`)
check("--check exits 1 on unformatted input", str(chk1), "1")
run(`{fmt} -w /tmp/x_m.ludic`)
let chk2 = sh(`{fmt} --check -q /tmp/x_m.ludic`)
check("--check exits 0 after -w", str(chk2), "0")
# --- markdown: fences formatted, prose untouched ---------------------------
write_file("/tmp/x_d.md", "# T\n\nprose with spaces\n\n```ludic\nprogram D{property P{x:int=0}}\n```\n\n```python\nz = 1\n```\n")
run(`{fmt} /tmp/x_d.md > /tmp/x_d.out 2>/dev/null`)
if shq("grep -q 'prose with spaces' /tmp/x_d.out") { ok("markdown prose untouched") } else { bad("markdown prose untouched") }
if shq("grep -q 'z = 1' /tmp/x_d.out") { ok("non-ludic fences untouched") } else { bad("non-ludic fences untouched") }
if shq("grep -q 'program D { property P { x: int = 0 } }' /tmp/x_d.out") { ok("ludic fences formatted") } else { bad("ludic fences formatted") }
print("language server")
if shq("python3 tools/test-lsp.py") { ok("language server protocol") } else { bad("language server protocol") }
print("")
print("editor assets")
test_json("tools/editors/shared/ludic.tmLanguage.json")
test_json("tools/editors/shared/ludic.markdown-injection.json")
test_json("tools/editors/shared/language-configuration.json")
test_json("tools/editors/vscode/package.json")
test_json("tools/editors/vscode/snippets/ludic.json")
test_xml("tools/editors/jetbrains/src/main/resources/META-INF/plugin.xml")
test_xml("tools/editors/jetbrains/src/main/resources/META-INF/ludic-markdown.xml")
if shq("command -v node >/dev/null 2>&1") {
if shq("node --check tools/editors/vscode/src/extension.js 2>/dev/null") { ok("extension.js parses") } else { bad("extension.js parses") }
print("textmate grammar")
if shq("node tools/test-grammar.js") { ok("textmate grammar") } else { bad("textmate grammar") }
} else {
print(" skip extension.js and grammar tests (node not installed)")
}
# VS Code carries its own copy of the shared grammar; catch an edit to the copy
if shq("cmp -s tools/editors/shared/ludic.tmLanguage.json tools/editors/vscode/syntaxes/ludic.tmLanguage.json") and shq("cmp -s tools/editors/shared/ludic.markdown-injection.json tools/editors/vscode/syntaxes/ludic.markdown-injection.json") and shq("cmp -s tools/editors/shared/language-configuration.json tools/editors/vscode/language-configuration.json") {
ok("vscode grammar copies match tools/editors/shared")
} else { bad2("vscode grammar copies have drifted", "run: x tools to resync") }
# the JetBrains plugin is off by default (its first build pulls a gigabyte of
# IntelliJ SDK). One env var away: LUDIC_TEST_JETBRAINS=1 x test-tools
if (getenv_or("LUDIC_TEST_JETBRAINS", "0") == "1") {
if shq("test -x tools/editors/jetbrains/gradlew") {
if shq("cd tools/editors/jetbrains && ./gradlew buildPlugin verifyPluginStructure --console=plain -q >/dev/null 2>&1 && test -f build/distributions/Ludic-1.0.0.zip") { ok("jetbrains plugin builds and verifies") } else { bad("jetbrains plugin build") }
if shq("cd tools/editors/jetbrains && ./gradlew test --console=plain -q >/dev/null 2>&1") { ok("jetbrains platform tests (lexer, file type, PSI, folding)") } else { bad("jetbrains platform tests") }
} else { print(" skip jetbrains plugin (no gradle wrapper)") }
} else {
print(" skip jetbrains plugin build (set LUDIC_TEST_JETBRAINS=1)")
}
# the vocabulary lives in one place; drift between it and its copies (the
# TextMate grammar, the Kotlin lexer) is the failure mode this layout prevents.
if shq("python3 tools/check-vocabulary.py") { ok("vocabulary in sync across grammar/lexer/header") } else { bad("vocabulary drifted") }
# every feature the compiler actually implements — namespace methods, keywords,
# types, phases — must have a docs/language page. This reads the implementation
# (emit_ns_call + ludic_syntax.h), so shipping a feature without docs fails here.
if shq("python3 tools/docgen/check-impl.py") { ok("docs cover every implemented feature") } else { bad("docs drifted from the implementation") }
return report()
}
fn test_json(path: ptr) -> void {
let bn = capture_line(`basename {path}`)
if shq(`python3 -c 'import json,sys;json.load(open(sys.argv[1]))' {path} 2>/dev/null`) {
ok(`valid JSON: {bn}`)
} else { bad(`invalid JSON: {path}`) }
}
fn test_xml(path: ptr) -> void {
let bn = capture_line(`basename {path}`)
if shq(`python3 -c 'import xml.dom.minidom,sys;xml.dom.minidom.parse(sys.argv[1])' {path} 2>/dev/null`) {
ok(`valid XML: {bn}`)
} else { bad(`invalid XML: {path}`) }
}