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,397 @@
# project.ludic — the commands a user of the language runs on their own project:
# new, build, run, test, fmt, lsp, doctor, upgrade.
#
# The shape of a project is a convention, not a configuration file: a manifest
# (package.ludic), the program under src/, tests under tests/, assets under
# assets/, output in build/. `ludic build` in such a directory needs no
# arguments; given a path it compiles that file instead, so a single .ludic
# lying around is still a one-command build.
#
# None of this assumes a checkout of the toolchain — the compiler, the engine
# runtime and the bundled ludic.* packages are found through ludic_home().
# ---- project discovery ------------------------------------------------------
# the `package "name"` line of ./package.ludic, or "" when there is no manifest
function manifest_name() -> pointer {
let txt = read_file("package.ludic")
if txt == null { return "" }
let m = parse_manifest(txt)
return m.module
}
# the name to give the built binary: the manifest's module (its last dotted
# segment, so ludic.snake builds `snake`), else the entry file's base name.
function project_name(entry: pointer) -> pointer {
let mod = manifest_name()
if mod != "" {
var last = -1
var i = 0
while mod[i] != 0 { if mod[i] == '.' { last = i }; i += 1 }
if last >= 0 { return mod[last + 1..i] }
return mod
}
return capture_line(`basename {entry} .ludic`)
}
# The program to compile. An explicit path always wins; otherwise the
# conventional entry points are tried in order, and finally a lone .ludic file in
# the current directory (so a scratch directory needs no ceremony). "" when
# nothing was found.
function find_entry(explicit: pointer) -> pointer {
if explicit != "" { return explicit }
if file_exists("src/main.ludic") { return "src/main.ludic" }
if file_exists("main.ludic") { return "main.ludic" }
let one = capture_line("ls *.ludic 2>/dev/null")
if one != "" and not shq("ls *.ludic 2>/dev/null | tail -n +2 | grep -q .") { return one }
return ""
}
# report the "no entry point" case the same way everywhere
function no_entry() -> int {
err("ludic: no program to build here.\n")
err(" expected src/main.ludic (or main.ludic), or name a file: ludic build path/to/game.ludic\n")
err(" start a new project with: ludic new <name>\n")
return 1
}
# ---- ludic new --------------------------------------------------------------
# The smallest program worth running: a window, an entity moving under the ECS,
# text, and a key to quit. A new project should do something the moment it is
# created — an empty main() teaches nothing about the language.
#
# Backticks interpolate, and this text is full of braces, so it is built by
# concatenation. The template lives here rather than in a data file because the
# CLI must scaffold from an install where no repo tree exists.
function template_main(name: pointer) -> pointer {
var b = "# src/main.ludic — the program. Build and play it with: ludic run\n"
b = b + "program " + name + " {\n"
b = b + "\n"
b = b + " property Pos { x: int = 0, y: int = 0 }\n"
b = b + " property Vel { dx: int = 0, dy: int = 0 }\n"
b = b + "\n"
b = b + " handler Boot phase Start {\n"
b = b + " spawn Ball { Pos { x: 152, y: 112 }, Vel { dx: 2, dy: 1 } }\n"
b = b + " }\n"
b = b + "\n"
b = b + " handler Move phase FixedUpdate {\n"
b = b + " for (p, v) in query [Pos, Vel] {\n"
b = b + " p.x = p.x + v.dx\n"
b = b + " p.y = p.y + v.dy\n"
b = b + " if p.x < 0 or p.x > 304 { v.dx = 0 - v.dx }\n"
b = b + " if p.y < 0 or p.y > 224 { v.dy = 0 - v.dy }\n"
b = b + " }\n"
b = b + " }\n"
b = b + "\n"
b = b + " handler Draw phase Render {\n"
b = b + " Screen.clear(0x0d1020)\n"
b = b + " for (p) in query [Pos] {\n"
b = b + " Screen.fill_rectangle(x: p.x, y: p.y, width: 16, height: 16, color: Color.Crimson)\n"
b = b + " }\n"
b = b + " Screen.draw_text(x: 8, y: 8, text: \"HELLO LUDIC\", color: Color.White, scale: 1)\n"
b = b + " Screen.show()\n"
b = b + " }\n"
b = b + "\n"
b = b + " handler Keys phase Input {\n"
b = b + " if Input.key() == 'q' { quit() }\n"
b = b + " }\n"
b = b + "}\n"
return b
}
function template_test(name: pointer) -> pointer {
var b = "# A test program is a set of test blocks; ludic test compiles and runs every\n"
b = b + "# tests/*.ludic and reports the results.\n"
b = b + "program " + name + "Spec {\n"
b = b + " test \"arithmetic still works\" {\n"
b = b + " expect_eq(2 + 2, 4)\n"
b = b + " }\n"
b = b + "}\n"
return b
}
function template_manifest(name: pointer) -> pointer {
return "package \"" + name + "\"\nversion \"0.1.0\"\nkind source\n"
}
function template_gitignore() -> pointer {
return "build/\nludic_modules/\nvendor/\n*.ll\n"
}
function template_readme(name: pointer) -> pointer {
var b = "# " + name + "\n"
b = b + "\n"
b = b + "A game written in Ludic (https://workshopsoft.pages.workshopsoft.io/ludic/).\n"
b = b + "\n"
b = b + " ludic run # build and play\n"
b = b + " ludic test # run the tests\n"
return b
}
# ludic new <name> — scaffold a project that builds and runs as it stands.
function cmd_new() -> int {
if arg_total() < 3 {
err("usage: ludic new <name>\n")
return 1
}
let name = arg_n(2)
if file_exists(name) {
err(`ludic new: {name} already exists\n`)
return 1
}
run(`mkdir -p {name}/src {name}/tests {name}/assets`)
if not write_file(`{name}/package.ludic`, template_manifest(name)) {
err(`ludic new: cannot write {name}/package.ludic\n`)
return 1
}
write_file(`{name}/src/main.ludic`, template_main(title_case(name)))
write_file(`{name}/tests/smoke.ludic`, template_test(title_case(name)))
write_file(`{name}/.gitignore`, template_gitignore())
write_file(`{name}/README.md`, template_readme(name))
print(`created {name}/`)
print(" package.ludic the manifest — name, version, dependencies")
print(" src/main.ludic the program")
print(" tests/smoke.ludic a test to grow")
print(" assets/ sprites, fonts and sounds")
print("")
print(`next: cd {name} && ludic run`)
return 0
}
# ---- ludic build / run ------------------------------------------------------
# shared flag parsing for build and run: returns the entry file ("" = none),
# filling the globals below.
var g_mode: int = 1 # 1 = windowed, 2 = headless
var g_out: pointer = "" # -o
var g_save: bool = false # --save-temps
function parse_build_args(start: int) -> pointer {
var src = ""
g_mode = 1
g_out = ""
g_save = false
var ai = start
while ai < arg_total() {
let a = arg_n(ai)
if a == "--headless" { g_mode = 2 }
else if a == "--windowed" { g_mode = 1 }
else if a == "--save-temps" { g_save = true }
else if a == "-o" { ai += 1; if ai < arg_total() { g_out = arg_n(ai) } }
else if a[0] != '-' { src = a }
ai += 1
}
return find_entry(src)
}
# where the binary lands: -o if given, else build/<name> (build/<name>_headless
# for a headless build, so the two can coexist).
function output_path(entry: pointer) -> pointer {
if g_out != "" { return g_out }
let name = project_name(entry)
if g_mode == 2 { return `build/{name}_headless` }
return `build/{name}`
}
# ludic build [file] [--headless] [-o out] [--save-temps]
function cmd_build() -> int {
let entry = parse_build_args(2)
if entry == "" { return no_entry() }
let out = output_path(entry)
if not compile_app(entry, out, g_mode, g_save) { return 1 }
if g_mode == 2 {
print(`built {out} (headless: reads one key per frame from stdin, writes build/out.ppm)`)
} else {
print(`built {out}`)
}
return 0
}
# ludic run [file] [--headless] — build, then run it from the project directory
# so assets/ resolves relative to the game.
function cmd_run() -> int {
let entry = parse_build_args(2)
if entry == "" { return no_entry() }
let out = output_path(entry)
if not compile_app(entry, out, g_mode, g_save) { return 1 }
return sh(`./{out}`)
}
# `ludic mygame.ludic` — the file is argv[1], so the scan starts there.
function cmd_run_file() -> int {
let entry = parse_build_args(1)
if entry == "" { return no_entry() }
let out = output_path(entry)
if not compile_app(entry, out, g_mode, g_save) { return 1 }
return sh(`./{out}`)
}
# ---- ludic test -------------------------------------------------------------
# every test program in the project: tests/*.ludic plus any *_test.ludic under
# src/ (both conventions are in use, and neither is worth arguing about).
function test_files() -> []pointer {
var files = split_lines(capture("ls tests/*.ludic 2>/dev/null"))
let more = split_lines(capture("find src -name '*_test.ludic' 2>/dev/null | sort"))
var i = 0
while i < len(more) { push(files, more[i]); i += 1 }
return files
}
# ludic test [file...] — compile each test program headlessly and run it. A test
# program's own runner prints ok/FAIL per test block and exits non-zero if any
# failed, so this reports one line per file and forwards the failure.
function cmd_test() -> int {
var files = new []pointer
var ai = 2
while ai < arg_total() { if arg_n(ai)[0] != '-' { push(files, arg_n(ai)) }; ai += 1 }
if len(files) == 0 { files = test_files() }
if len(files) == 0 {
err("ludic test: no tests found (expected tests/*.ludic or src/**/*_test.ludic)\n")
return 1
}
run("mkdir -p build")
var failed = 0
var i = 0
while i < len(files) {
let f = files[i]
let bin = `{tmp_dir()}/{flat(strip_ext(f))}`
if not compile_app(f, bin, 2, false) {
print(` {c_red()}FAIL{c_reset()} {f} (did not compile)`)
failed += 1
} else {
# a test program prints one line per test block; that belongs on screen
# when something failed and nowhere when everything passed.
let log = `{bin}.out`
let rc = sh(`{bin} < /dev/null > {log} 2>&1`)
if rc == 0 { print(` {c_green()}PASS{c_reset()} {f}`) }
else {
print(` {c_red()}FAIL{c_reset()} {f} (exit {string(rc)})`)
let txt = read_file(log)
if txt != null { out(txt) }
failed += 1
}
}
i += 1
}
print("")
if failed == 0 {
print(`== {string(len(files))} test files passed ==`)
return 0
}
print(`== {string(failed)} of {string(len(files))} test files failed ==`)
return 1
}
# drop the extension from a path ("tests/combat.ludic" -> "tests/combat")
function strip_ext(p: pointer) -> pointer {
let n = len(p)
if n > 6 and p[n - 6..n] == ".ludic" { return p[0..n - 6] }
return p
}
# ---- ludic fmt / lsp --------------------------------------------------------
# ludic fmt [paths...] — the formatter over the project (src/ and tests/ by
# default), or over the paths named.
function cmd_fmt() -> int {
var args = ""
var ai = 2
while ai < arg_total() { args = `{args} {arg_n(ai)}`; ai += 1 }
if args == "" {
let found = capture_line("find src tests -name '*.ludic' 2>/dev/null | sort")
if found == "" {
err("ludic fmt: nothing to format (no src/ or tests/ here — name the files instead)\n")
return 1
}
args = ` {found}`
}
return sh(`{tool("ludic-fmt")}{args}`)
}
# ludic lsp — the language server on stdio. Editors are configured to run this,
# so the server's location is the CLI's problem rather than the user's.
function cmd_lsp() -> int {
var args = ""
var ai = 2
while ai < arg_total() { args = `{args} {arg_n(ai)}`; ai += 1 }
return sh(`exec {tool("ludic-lsp")}{args}`)
}
# ---- ludic doctor -----------------------------------------------------------
function doctor_line(label: pointer, good: bool, detail: pointer) -> void {
if good { print(` {c_green()}ok{c_reset()} {label} {detail}`) }
else { print(` {c_red()}no{c_reset()} {label} {detail}`) }
}
# ludic doctor — answer "is my install healthy?" without making the user guess.
# Every line is something that has actually gone wrong for someone: a missing
# clang, a half-unpacked install, a PATH that finds nothing.
function cmd_doctor() -> int {
let home = ludic_home()
var shown = home
if shown == "" { shown = "(current directory)" }
print(`ludic doctor`)
print("")
doctor_line("install root ", true, shown)
var bad_count = 0
# $LUDIC_CC may carry flags (the Linux build injects a shim); the first word
# is the program to look for.
let cc_name = getenv_or("LUDIC_CC", "clang")
let cc_prog = capture_line(`printf '%s' '{cc_name}' | cut -d' ' -f1`)
let have_cc = shq(`command -v {cc_prog} >/dev/null 2>&1`)
doctor_line("C toolchain ", have_cc, `{cc_name} (assembles and links the emitted IR)`)
if not have_cc { bad_count += 1 }
let have_cc2 = is_exec(ludicc()) or shq("command -v ludicc >/dev/null 2>&1")
doctor_line("compiler ", have_cc2, ludicc())
if not have_cc2 { bad_count += 1 }
let have_rt = file_exists(`{home}runtime/native/cocoa.ll`)
doctor_line("runtime ", have_rt, `{home}runtime/native/ (the engine, spliced into a game)`)
if not have_rt { bad_count += 1 }
let have_pkgs = file_exists(`{home}packages/ludic.core/package.ludic`)
doctor_line("packages ", have_pkgs, `{home}packages/ (the bundled ludic.* modules)`)
let ver = read_file(`{home}VERSION`)
var vs = "(unknown)"
if ver != null { vs = s_trim(ver) }
doctor_line("version ", ver != null, vs)
let on_path = capture_line("command -v ludic")
doctor_line("on PATH ", on_path != "", on_path)
if not is_darwin() {
print("")
print(" note windowing is macOS-only today; on this host a game builds headless")
print(" (ludic build --headless) but cannot open a window.")
}
print("")
if bad_count == 0 { print("everything checks out."); return 0 }
print(`{string(bad_count)} problem(s) above. Reinstall with:`)
print(` curl -fsSL {install_url()} | sh`)
return 1
}
# ---- ludic upgrade ----------------------------------------------------------
function install_url() -> pointer { return getenv_or("LUDIC_INSTALL_URL", "https://workshopsoft.pages.workshopsoft.io/ludic/install.sh") }
# ludic upgrade [version] — re-run the installer, which replaces the install in
# place. One code path for installing and updating means an upgrade can never
# drift from a fresh install.
function cmd_upgrade() -> int {
var ver = ""
if arg_total() >= 3 { ver = arg_n(2) }
if not shq("command -v curl >/dev/null 2>&1") {
err("ludic upgrade: needs curl\n")
return 1
}
print(`upgrading from {install_url()}`)
if ver == "" { return sh(`curl -fsSL {install_url()} | sh`) }
return sh(`curl -fsSL {install_url()} | sh -s -- --version {ver}`)
}