feat(cli): install in one command, and call the CLI ludic

Getting started meant cloning the repository, bootstrapping a compiler and
learning a task runner called `x`. That is a contributor's workflow handed to
everyone who wants to try the language.

Installing is now one command:

    curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh

install.sh puts a complete toolchain — compiler, CLI, engine runtime, bundled
ludic.* packages, formatter, language server — in ~/.ludic and adds it to PATH.
Prebuilt artifacts are checksum-verified; where a platform has none, or the
release predates this layout, it bootstraps from the compiler's own IR seed with
clang. The docs site publishes the script beside the pages that quote it, so the
page and the script can never come from different releases.

`x` becomes `ludic`, and the surface splits by audience. A user of the language
sees `new`, `run`, `build`, `test`, `add`, `fmt`, `lsp`, `doctor`, `upgrade`;
`ludic new` scaffolds a project that builds and plays as it stands. Everything
the toolchain repo needs moved under `ludic dev` — build, test, reseed,
bootstrap-cfree, docs-gen, release — unchanged apart from the namespace. Those
tasks read arguments one position further along, so dispatch_dev sets a shift
and commands use arg_n()/arg_total() rather than each knowing its own depth.

Release artifacts become complete install roots (bin/ beside runtime/, packages/
and VERSION) rather than bare binaries, which is what the installer unpacks.
`ludic dev test` asserts the whole shape: it stages an install, puts it on PATH
with no LUDIC_HOME, and runs new -> build -> test through it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-05 22:01:52 +03:00
parent 005cc39394
commit aca263642d
54 changed files with 1802 additions and 670 deletions

View file

@ -0,0 +1,128 @@
# 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
function sh_case(name: pointer, exp: pointer) -> void {
let ll = `{tmp_dir()}/sh_{name}.ll`
let er = `{tmp_dir()}/sh_{name}.err`
let bn = `{tmp_dir()}/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/<path>.ludic as a game, render it headless, and compare the
# framebuffer's SHA-256 against the text baseline in selfhost/golden/renders.sha256.
# A hash is tiny and text-diffable — a regression shows as a changed hex line in a
# review, not an opaque "binary files differ". Regenerate the baseline with
# `bin/ludic dev golden` (see cmd_golden below).
function game_case(path: pointer, keys: pointer) -> void {
let nm = flat(path)
if not game_build_ok("bin/ludicc", `examples/{path}.ludic`, `{tmp_dir()}/g_{nm}`) { bad(`{path}: build`); return }
run("mkdir -p build")
run(`printf '%s' '{keys}' | {tmp_dir()}/g_{nm} >/dev/null 2>&1`)
# The build + headless run above guard against a compile or runtime break on
# every host. The pixel-exact golden comparison, though, is against a render
# blessed on macOS; text rasterization differs by a hair on other hosts, so
# off Darwin we stop at "it built and ran" rather than fail on the hash.
if not is_darwin() { skip(`{path} render (golden hash blessed on macOS)`); return }
let got = capture_line("shasum -a 256 build/out.ppm | cut -d' ' -f1")
let want = capture_line(`grep '^{path} ' selfhost/golden/renders.sha256 | cut -d' ' -f2`)
if (want == "") { bad2(path, "no golden hash (run: bin/ludic dev golden)"); return }
if (got == want) { ok(`{path} matches the golden render`) }
else { bad2(path, `render hash {got} != golden {want}`) }
}
# render one golden game and return its "<path> <sha256>\n" manifest line (empty
# string on build failure). Shared by `bin/ludic dev golden` and nothing else.
function bless_render(path: pointer, keys: pointer) -> pointer {
let nm = flat(path)
if not game_build_ok("bin/ludicc", `examples/{path}.ludic`, `{tmp_dir()}/bg_{nm}`) {
err(`golden: {path} failed to build\n`); return ""
}
run("mkdir -p build")
run(`printf '%s' '{keys}' | {tmp_dir()}/bg_{nm} >/dev/null 2>&1`)
let h = capture_line("shasum -a 256 build/out.ppm | cut -d' ' -f1")
return `{path} {h}\n`
}
# regenerate the text render baseline. The keys here MUST match game_case's so
# the baseline and the checks render the same frame. Deliberate, reviewable:
# run it, then `git diff selfhost/golden/renders.sha256` shows exactly which
# render changed before you commit the new hash.
function cmd_golden() -> int {
ensure_ludicc()
if not is_exec("bin/ludicc") { err("golden: bin/ludicc unavailable\n"); return 1 }
var manifest = ""
manifest += bless_render("games/snake", "ddssaawwddss")
manifest += bless_render("games/menu", "ssss")
manifest += bless_render("games/chronorift", "ddddwwwwaassK")
manifest += bless_render("rendering/draw_prims", "")
if not write_file("selfhost/golden/renders.sha256", manifest) {
err("golden: could not write selfhost/golden/renders.sha256\n"); return 1
}
print("wrote selfhost/golden/renders.sha256:")
out(manifest)
print("review the change: git diff selfhost/golden/renders.sha256")
return 0
}
function 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("sort", "0 39 20 1 2 3 3 1 30 10 20 40 2 8")
sh_case("control", "55 4 15 1")
sh_case("match_bits", "1 2 9 16 4 15")
sh_case("enums", "1 0 15 7 19 18 0 80 17")
sh_case("fixed", "2 3 0 6 1")
sh_case("const", "1 0 10 5 20 0 10 7 1 0")
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("transcend", "2718 1 148 1000 0 2303 1024 1414 3 250 0 1000 1016")
sh_case("vector", "3 4 5 4 6 2 2 11 2 6 999 999 999 1570 5 10")
sh_case("types2d", "3 4 4 6 2 2 9 12 11 4 0 1 0 4 10 20 30 40 40 60 25 40 1 0 0 1 0")
sh_case("datetime", "10800 330 172800 3 2 0 10957 2026 8 30 4 6 1 0 1 29 28 31 2 1 365 946729815 2000 1 1 12 30 15 6 10957 1")
sh_case("datetime2", "2026-08-30 07:05:09 30/08/26 0 -1 3600 5400 30 0")
sh_case("textsplit", "1 1 1 3 1 1 1 1 1 1 1")
sh_case("hash", "-2128831035 -468965076 114400290 114400290 0 -873187034 1095738169 0 1364076727 -2114883783 -845898438 -845898438 -78065325 -78057399 -3750763034362895579 -5808556873153909620 -4100651535478758590 -4100651535478758590 0 -5451962507482445012 7256831767414464289")
sh_case("crypto", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592 9f4390f8d30c2dd92ec9f095b65e2b9ae9b0a925a5258e241c9f1e910f734318 b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8 5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843 1 0 0 1 0 0 616263")
sh_case("long", "1000000000000 1000000000001 1000000000005 3000000000000 1 1 1 -1000000000000 1000000 13")
sh_case("color", "16744512 1090486336 1090486336 8355711 8355711 8355711")
print("== the self-host compiler compiles real games (vs golden output) ==")
# the golden render hashes were first produced by the original C compiler; the
# self-hosted compiler must reproduce the same framebuffer, byte for byte, so
# its SHA-256 matches the checked-in baseline in selfhost/golden/renders.sha256.
game_case("games/snake", "ddssaawwddss")
game_case("games/menu", "ssss")
game_case("games/chronorift", "ddddwwwwaassK")
game_case("rendering/draw_prims", "")
print("== the bootstrap fixpoint (seeded from bin/ludicc) ==")
if shq(`bin/ludic dev bootstrap > {tmp_dir()}/boot.out 2>&1`) { ok("gen2.ll == gen3.ll (compiler reproduces itself)") }
else { bad("fixpoint not reached"); out(capture(`cat {tmp_dir()}/boot.out`)) }
print("== the C-free bootstrap (from the checked-in IR seed, no C compiler) ==")
if shq(`bin/ludic dev bootstrap-cfree > {tmp_dir()}/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: ludic dev reseed)"); out(capture(`cat {tmp_dir()}/cfree.out`)) }
return report()
}