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:
parent
ff15c4e01d
commit
a38195128f
235 changed files with 24676 additions and 7762 deletions
83
tools/x/build.ludic
Normal file
83
tools/x/build.ludic
Normal 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
80
tools/x/main.ludic
Normal 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
119
tools/x/prelude.ludic
Normal 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
201
tools/x/selfhost.ludic
Normal 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
|
||||
}
|
||||
68
tools/x/selfhost_test.ludic
Normal file
68
tools/x/selfhost_test.ludic
Normal 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
143
tools/x/test.ludic
Normal 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
186
tools/x/tools.ludic
Normal 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}`) }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue