refactor(cli)!: split the contributor tool out of the ludic CLI

`ludic help` ended with a section titled "contributing to the toolchain itself",
listing bootstrap, reseed, docs-gen and release tasks. None of that is available
to someone who installed the language — those tasks need the repository — so the
shipped tool was advertising work its user cannot do, in a namespace they have to
read past to find `new` and `run`.

The tasks move to a second program, dev.ludic -> bin/ludic-dev, built from a
checkout and excluded from every release artifact. `ludic` keeps the project and
package commands and nothing else; `ludic dev …` now explains where the tasks
went instead of failing as an unknown command.

What this shook out: the two programs share prelude/build/project/pkg, so the
helpers each had accreted in whichever file first needed them — cc(),
ensure_ludicc, the string functions, title_case, cmd_version — moved to where
both can see them. The argument-shift indirection added for the `dev` namespace
is gone with the namespace, so commands read argv directly again.

`ludic-dev test` asserts the split rather than trusting it: the staged install
must build a project, and `ludic dev build` there must fail while naming
ludic-dev. install.sh keeps building older tags, whose bootstrap goes through
main.ludic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-05 23:15:12 +03:00
parent f369fbd227
commit e175619543
46 changed files with 630 additions and 558 deletions

View file

@ -1,13 +1,13 @@
# ludic — the command-line interface to the Ludic toolchain. One native binary
# ludic — the command-line interface to the Ludic language. One native binary
# (bin/ludic) written in Ludic and compiled by Ludic; the unix tools, clang and
# the compiler it drives are invoked through the `run` intrinsic.
#
# Two audiences, one binary. A user of the language sees a small surface —
# new, build, run, test, add, fmt — that works from any directory against an
# installed toolchain. A contributor to the toolchain itself gets everything
# else under `ludic dev`: bootstrapping the compiler from its IR seed, the
# regression suites, the docs site, releases. Splitting them by namespace keeps
# `ludic help` about the language rather than about the repository.
# This is everything a user of the language does: create a project, build it, run
# it, test it, add a package, format, upgrade. Nothing here is about the
# toolchain's own repository — building the compiler, reseeding it, generating
# the docs site, cutting a release live in a separate tool (dev.ludic ->
# bin/ludic-dev) that only exists in a checkout and is never installed. Someone
# who installs Ludic should not have to read past commands they cannot use.
#
# Installing (macOS, Linux):
#
@ -17,29 +17,13 @@
# compiling Ludic needs a compiler:
#
# mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc
# bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic
# bin/ludic dev build
#
# Thereafter `ludic dev build` rebuilds the whole toolchain — including the CLI
# itself. The `dev` tasks run from the repository root.
# bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev
# bin/ludic-dev build
program Ludic {
import "prelude.ludic"
import "selfhost.ludic"
import "build.ludic"
import "project.ludic"
import "tools.ludic"
import "selfhost_test.ludic"
import "test.ludic"
import "json.ludic"
import "lsp_test.ludic"
import "forgejo.ludic"
import "checks.ludic"
import "docgen.ludic"
import "docgen_gen.ludic"
import "docgen_check.ludic"
import "release.ludic"
import "pkg.ludic"
import "pkg_test.ludic"
function usage() -> void {
print("ludic — the toolchain for the Ludic language")
@ -71,61 +55,9 @@ program Ludic {
print(" lsp run the language server on stdio (what editors spawn)")
print(" help this message")
print("")
print("contributing to the toolchain itself:")
print(" dev <task> build, test, bootstrap, docs, release (ludic dev help)")
print("")
print("A file argument runs it: ludic mygame.ludic")
}
function dev_usage() -> void {
print("ludic dev — tasks for a checkout of the Ludic toolchain (run from the repository root)")
print("")
print("build:")
print(" dev build build the whole toolchain into bin/ (ludicc, ludic, ludic-fmt, ludic-lsp)")
print(" dev build-cli build just bin/ludicc from the IR seed")
print(" dev tools [--install] [--test] build the editor toolchain (ludic-fmt, ludic-lsp)")
print("")
print("test:")
print(" dev test the full regression suite")
print(" dev test --coverage per-file line coverage over the test specs")
print(" dev test-pkg the package-manager suite (hermetic git repos, offline)")
print(" dev selfhost-test the self-hosting suite (correctness + bootstrap fixpoints)")
print(" dev test-tools the editor-toolchain suite")
print(" dev test-lsp drive bin/ludic-lsp over real LSP traffic (exit 2 = no server)")
print(" dev golden regenerate selfhost/golden/renders.sha256 (review with git diff)")
print("")
print("doc / lint checks (Ludic, no Python):")
print(" dev check-docs every ```ludic doc fence parses (or is marked skip/expect-error)")
print(" dev check-impl every implemented feature has a docs/language page")
print(" dev check-vocabulary the vocabulary is in sync across grammar / lexer / header / parser")
print(" dev lint-asset <file> validate one editor .json / .xml asset")
print(" dev docs-gen [--out DIR] generate the documentation site (default build/pages)")
print(" dev docs-check [DIR] coverage/integrity guard over a generated docs site")
print(" dev docs-palette [--check] regenerate emit_color.ludic + palette.json from the palette table")
print("")
print("release:")
print(" dev release [major|minor|patch] [--dry-run] [--publish]")
print(" cut a release: CHANGELOG + VERSION bump + tag (+ Forgejo release)")
print(" dev publish [vX.Y.Z] publish an already-tagged release; what CI runs on a tag push")
print(" dev changelog-section <ver> print that release's CHANGELOG.md section")
print(" dev changelog-render <ver> <date> <dir>")
print(" render a CHANGELOG section from a directory of changesets")
print("")
print("self-host internals:")
print(" dev selfhost-build [ludicc] [out] assemble + compile the self-host compiler")
print(" dev bootstrap the self-hosting fixpoint proof (seeded from bin/ludicc)")
print(" dev bootstrap-cfree rebuild the compiler from the seed with no C compiler")
print(" dev reseed regenerate selfhost/ludicc.seed.ll after a compiler change")
print(" dev game-build <ludicc> <game.ludic> <out> compile a Ludic game (headless)")
print(" dev sh-compile <ludicc> <in.ludic> <out> compile + link one .ludic file")
}
# a positional argument, or a default when absent
function argn(i: int, dflt: pointer) -> pointer {
if (i < arg_count()) { return arg(i) }
return dflt
}
# does `s` end in ".ludic"? — `ludic mygame.ludic` runs that file, the shape
# the compiler-runner had before the CLI grew subcommands.
function is_ludic_file(s: pointer) -> bool {
@ -134,55 +66,6 @@ program Ludic {
return sslice(s, n - 6, n) == ".ludic"
}
# ---- ludic dev <task> — the toolchain repo's own tasks ---------------------
# Every task here reads its arguments one position further along than a
# top-level command, so the shift is set once, here.
function dispatch_dev(cmd: pointer) -> int {
g_shift = 1
if (cmd == "build") { return cmd_dev_build() }
if (cmd == "build-cli") { return cmd_dev_build_cli() }
if (cmd == "tools") { return cmd_tools() }
if (cmd == "clean") { return cmd_clean() }
if (cmd == "test") {
if (argn(3, "") == "--coverage") { return cmd_test_coverage() }
return cmd_dev_test()
}
if (cmd == "selfhost-test") { return cmd_selfhost_test() }
if (cmd == "test-tools") { return cmd_test_tools() }
if (cmd == "test-lsp") { return cmd_test_lsp() }
if (cmd == "test-pkg") { return cmd_test_pkg() }
if (cmd == "check-docs") { return cmd_check_docs() }
if (cmd == "check-impl") { return cmd_check_impl() }
if (cmd == "check-vocabulary") { return cmd_check_vocab() }
if (cmd == "lint-asset") { return cmd_lint_asset() }
if (cmd == "docs-palette") { return cmd_docs_palette() }
if (cmd == "docs-gen") { return cmd_docs_gen() }
if (cmd == "docs-check") { return cmd_docs_check() }
if (cmd == "golden") { return cmd_golden() }
if (cmd == "bootstrap") { return cmd_bootstrap() }
if (cmd == "bootstrap-cfree") { return cmd_bootstrap_cfree() }
if (cmd == "reseed") { return cmd_reseed() }
if (cmd == "selfhost-build") { return cmd_selfhost_build(argn(3, "bin/ludicc"), argn(4, "build/selfhost")) }
if (cmd == "sh-compile") {
if (arg_count() < 6) { err("usage: ludic dev sh-compile <ludicc> <in.ludic> <out>\n"); exit(1) }
return cmd_sh_compile(arg(3), arg(4), arg(5))
}
if (cmd == "game-build") {
if (arg_count() < 6) { err("usage: ludic dev game-build <ludicc> <game.ludic> <out>\n"); exit(1) }
return cmd_game_build(arg(3), arg(4), arg(5))
}
if (cmd == "release") { return cmd_release() }
if (cmd == "changelog-render") { return cmd_changelog_render() }
if (cmd == "changelog-section") { return cmd_changelog_section() }
if (cmd == "publish") { return cmd_publish() }
if (cmd == "help") or (cmd == "--help") or (cmd == "-h") or (cmd == "") { dev_usage(); return 0 }
# reported here rather than by entry, so the message names the dev task the
# user actually typed and the usage shown is the dev one
err(`ludic dev: unknown task '{cmd}'\n`)
dev_usage()
return 1
}
# route one subcommand to its implementation and return its exit code; -1 when
# the name is unknown. Returning (rather than exiting in place) lets entry tear
# down the per-process scratch directory before the process ends.
@ -206,13 +89,23 @@ program Ludic {
if (cmd == "link-flags") { return cmd_pkg_link_flags() }
if (cmd == "version") or (cmd == "--version") or (cmd == "-v") { return cmd_version() }
if (cmd == "help") or (cmd == "--help") or (cmd == "-h") { usage(); return 0 }
if (cmd == "dev") { return dispatch_dev(argn(2, "")) }
if (cmd == "help") or (cmd == "--help") or (cmd == "-h") { usage(); return 0 }
# The toolchain's own tasks used to live here under `ludic dev`. Say where
# they went rather than printing "unknown command" at someone who read an
# older README or a commit message.
if (cmd == "dev") {
err("ludic: the toolchain's own tasks live in `ludic-dev`, which is built from a\n")
err(" checkout of the Ludic repository and is not part of an install:\n")
err(" clang selfhost/ludicc.seed.ll -o bin/ludicc\n")
err(" bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev && bin/ludic-dev help\n")
return 1
}
return -1
}
entry {
if (arg_count() < 2) { usage(); exit(1) }
if (arg_count() < 2) { usage(); exit(1) }
let cmd = arg(1)
# `ludic mygame.ludic` — build and run that file
if is_ludic_file(cmd) {