ludic/tools/ludic-cli/prelude.ludic
Orkuncakilkaya aca263642d 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>
2026-09-05 22:01:52 +03:00

256 lines
9.7 KiB
Text

# prelude.ludic — the shared runtime for `ludic`, the command-line interface.
#
# `ludic` is the one tool a user of the language ever runs: it creates projects,
# compiles and runs them, resolves packages, formats, tests, and (under
# `ludic dev`) drives every build/bootstrap/release task of the toolchain repo
# itself. It is a single native binary written in Ludic and compiled by Ludic,
# driving clang, the compiler and the unix tools through `run`. 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.
#
# The user-facing commands work from any directory. The `ludic dev` tasks run
# relative to the current directory and expect the toolchain repo root.
# ---- file IO ----------------------------------------------------------------
# read a whole file into a fresh NUL-terminated buffer (null if it cannot open)
function read_file(path: pointer) -> pointer {
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
function write_file(path: pointer, s: pointer) -> bool {
let f = file_open(path, "wb")
if (f == null) { return false }
file_write(f, s, len(s))
file_close(f)
return true
}
function file_exists(path: pointer) -> bool { return shq(`test -e {path}`) }
function is_exec(path: pointer) -> bool { return shq(`test -x {path}`) }
# is `a` newer than `b` (like the shell's `-nt`)?
function newer(a: pointer, b: pointer) -> bool { return shq(`test {a} -nt {b}`) }
# ---- process control --------------------------------------------------------
# `run` returns the raw wait status; the program's exit code is the high byte.
function exit_code(st: int) -> int { return (st >> 8) & 255 }
# run a command, returning its exit code (0 = success)
function sh(cmd: pointer) -> int { return exit_code(run(cmd)) }
# run a command, true when it succeeded
function shq(cmd: pointer) -> bool { return exit_code(run(cmd)) == 0 }
# ---- scratch files ------------------------------------------------------------
# Every scratch file the runner writes lives under one per-process directory
# (`$TMPDIR/x_<pid>`), so `ludic dev test` and an `x check-*` can run side by side without
# clobbering each other's captures. main removes it on the way out.
var x_tmp: pointer = null
function tmp_dir() -> pointer {
if x_tmp == null {
var base = Os.temp_dir()
if len(base) > 1 and base[len(base) - 1] == '/' { base = base[0..len(base) - 1] } # macOS: TMPDIR ends in '/'
x_tmp = `{base}/ludic_{Os.pid()}`
run(`mkdir -p {x_tmp}`)
}
return x_tmp
}
# a scratch path under tmp_dir(): tmp_path("foo.out")
function tmp_path(name: pointer) -> pointer { return `{tmp_dir()}/{name}` }
# remove the scratch directory (idempotent; a no-op when nothing was written).
# LUDIC_KEEP_TMP=1 leaves it in place, and says where, for debugging a failing case.
function tmp_cleanup() -> void {
if x_tmp == null { return }
if getenv_or("LUDIC_KEEP_TMP", "0") == "1" { print(`scratch kept: {x_tmp}`) }
else { run(`rm -rf {x_tmp}`) }
x_tmp = null
}
# run `cmd` and return its stdout (stderr discarded). Never null.
function capture(cmd: pointer) -> pointer {
let tmp = tmp_path("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/ *$//')`.
function capture_line(cmd: pointer) -> pointer {
return capture(`{cmd} | tr '\n' ' ' | sed 's/ *$//'`)
}
# the last line of a command's output (for one-line error messages)
function capture_tail(cmd: pointer) -> pointer {
return capture(`{cmd} 2>&1 | tail -1`)
}
function getenv_or(name: pointer, dflt: pointer) -> pointer {
let v = getenv(name)
if (v == null) { return dflt }
return v
}
# flatten a relative example path into a filesystem-safe token: '/' -> '_', so a
# categorised path like "games/snake" yields a single-segment temp name
# ("games_snake") that never implies a missing /tmp subdirectory.
function flat(p: pointer) -> pointer {
let n = len(p)
let b = bytes(n + 1)
for i in 0 .. n {
var c = p[i]
if (c == '/') { c = 95 } # '/' (47) -> '_' (95)
b[i] = c
}
b[n] = 0
return b
}
# ---- stdout helpers ---------------------------------------------------------
# write `s` with no trailing newline (print() always adds one)
function out(s: pointer) -> void { file_write(file_stdout(), s, len(s)) }
function err(s: pointer) -> void { file_write(file_stderr(), s, len(s)) }
# an ESC byte — the lexer has no \033, so build it by hand
function esc() -> pointer { let b = bytes(2); b[0] = 27; b[1] = 0; return b }
function c_green() -> pointer { return esc() + "[32m" }
function c_red() -> pointer { return esc() + "[31m" }
function c_reset() -> pointer { return esc() + "[0m" }
# ---- the PASS/FAIL test harness ---------------------------------------------
var PASS: int = 0
var FAIL: int = 0
function ok(msg: pointer) -> void {
PASS += 1
print(` {c_green()}PASS{c_reset()} {msg}`)
}
function bad(msg: pointer) -> void {
FAIL += 1
print(` {c_red()}FAIL{c_reset()} {msg}`)
}
function bad2(msg: pointer, detail: pointer) -> void {
bad(msg)
print(` {detail}`)
}
# assert two strings equal, reporting the mismatch
function check(label: pointer, got: pointer, want: pointer) -> void {
if (got == want) { ok(label) }
else { bad2(label, `expected [{want}] got [{got}]`) }
}
# ---- host platform ----------------------------------------------------------
# A few cases exercise macOS-specific runtime ABI — Cocoa windowing, the BSD
# utsname/dirent layout — or compare against renders blessed on macOS. The
# self-hosted compiler and its C-free bootstrap are host-neutral (they produce
# byte-identical IR on any host), so the bulk of the suite runs anywhere; only
# these platform-bound cases are skipped — visibly, never silently — when the
# suite runs off Darwin. That lets a Linux CI runner gate every portable
# guarantee without red from the parts that are macOS-only today.
function host_os() -> pointer { return capture_line("uname -s") }
function is_darwin() -> bool { return host_os() == "Darwin" }
function skip(msg: pointer) -> void { print(` skip {msg}`) }
# print the "== N passed, M failed ==" footer and return the process exit code
function report() -> int {
print("")
print(`== {string(PASS)} passed, {string(FAIL)} failed ==`)
if (FAIL == 0) { return 0 }
return 1
}
# ---- command arguments -------------------------------------------------------
#
# A command reads its own arguments as arg_n(1), arg_n(2)… whether it was
# reached as `ludic get` or as `ludic dev docs-check DIR`. main sets the shift
# once per dispatch, so no command has to know how deep its namespace is —
# getting that wrong is how `docs-check DIR` silently checked the wrong
# directory.
var g_shift: int = 0
function arg_n(i: int) -> pointer { return arg(i + g_shift) }
function arg_total() -> int { return arg_count() - g_shift }
# ---- the toolchain install ---------------------------------------------------
#
# The user-facing commands run anywhere, so they cannot assume a `bin/` in the
# current directory the way the old repo-root-only task runner did. They ask
# here instead, and get the same answer in both layouts that exist: an install
# (~/.ludic/bin/ludic, root ~/.ludic) and a checkout of the toolchain repo
# (bin/ludic, root the repo). The compiler derives its own root by the identical
# rule (see ludic_home() in selfhost/frontend/parse.ludic), so the two never
# disagree about where the runtime and the bundled packages live.
var g_home: pointer = null
# the directory holding this binary, with a trailing '/'. argv[0] carries no
# directory when the CLI was found on $PATH, which is the normal case for an
# install — ask the shell where it found it.
function self_dir() -> pointer {
var a0 = arg(0)
var slash = -1
var i = 0
while a0[i] != 0 { if a0[i] == '/' { slash = i }; i += 1 }
if slash < 0 {
let found = capture_line(`command -v {a0}`)
if found == "" { return "" }
a0 = found
slash = -1
i = 0
while a0[i] != 0 { if a0[i] == '/' { slash = i }; i += 1 }
if slash < 0 { return "" }
}
return a0[0..slash + 1]
}
# The toolchain install root, with a trailing '/', or "" for the current
# directory. $LUDIC_HOME wins; otherwise the binary's own directory decides, and
# a directory named `bin` means the root is its parent.
function ludic_home() -> pointer {
if g_home != null { return g_home }
let env = getenv("LUDIC_HOME")
if env != null {
var e = env
if len(e) > 0 and e[len(e) - 1] != '/' { e = e + "/" }
g_home = e
return g_home
}
var d = self_dir()
if len(d) >= 4 and d[len(d) - 4..len(d)] == "bin/" { d = d[0..len(d) - 4] }
# a source checkout the CLI was run from elsewhere in: prefer a ./bin here
if not is_exec(`{d}bin/ludicc`) and is_exec("bin/ludicc") { d = "" }
g_home = d
return g_home
}
# the path to a toolchain binary (ludicc, ludic-fmt, ludic-lsp). Falls back to
# the bare name — on $PATH — when the install root has no bin/ of its own.
function tool(name: pointer) -> pointer {
let p = `{ludic_home()}bin/{name}`
if is_exec(p) { return p }
return name
}
function ludicc() -> pointer { return tool("ludicc") }
# true in a checkout of the toolchain repo itself, where the `ludic dev` tasks
# have something to work on.
function in_toolchain_repo() -> bool {
return file_exists("selfhost/ludicc.seed.ll") and file_exists("tools/ludic-cli/main.ludic")
}