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

@ -8,13 +8,13 @@ Closes #
## Checklist
- [ ] `bin/ludic dev test` passes.
- [ ] For compiler/runtime changes: `bin/ludic dev reseed && bin/ludic dev bootstrap-cfree`
- [ ] `bin/ludic-dev test` passes.
- [ ] For compiler/runtime changes: `bin/ludic-dev reseed && bin/ludic-dev bootstrap-cfree`
still reaches the self-hosting fixpoint with no C compiler in the loop.
- [ ] `ludic-fmt` leaves the touched files unchanged (2-space, LF, UTF-8).
- [ ] New/changed stdlib symbols are documented under `docs/language/**` and
registered in `tools/docgen/inventory.json`
(`bin/ludic dev docs-gen && bin/ludic dev docs-check build/pages` passes).
(`bin/ludic-dev docs-gen && bin/ludic-dev docs-check build/pages` passes).
- [ ] Commits follow [Conventional Commits](https://www.conventionalcommits.org).
- [ ] No new C / Python / JS in tooling (Ludic only), and no generated
artifacts committed outside `build/` / `bin/`.

View file

@ -43,11 +43,11 @@ jobs:
set -eu
mkdir -p bin
clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc
bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic
bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev
- name: Rebuild the compiler from the seed and assert byte-identity
# `ludic dev bootstrap-cfree` assembles the seed with clang, has that seed
# `ludic-dev bootstrap-cfree` assembles the seed with clang, has that seed
# compiler recompile selfhost.ludic to out.ll, and `cmp`s out.ll against
# the checked-in seed. It returns non-zero if they differ — i.e. if the
# seed is stale relative to the compiler source.
run: bin/ludic dev bootstrap-cfree
run: bin/ludic-dev bootstrap-cfree

View file

@ -2,7 +2,7 @@ name: ci
# Build the language toolchain from its IR seed and run the regression suites on
# every push to main and every pull request. Until this landed the only workflow
# was docs.yml, so nothing gated a change on `ludic dev test` / `ludic dev test-tools` or on the
# was docs.yml, so nothing gated a change on `ludic-dev test` / `ludic-dev test-tools` or on the
# compiler even building from the seed. See also bootstrap.yml, which proves the
# C-free self-rebuild reproduces the seed byte-for-byte.
on:
@ -45,7 +45,7 @@ jobs:
# The toolchain is macOS-first; on this Linux runner it links against a
# tiny C-free IR shim that supplies the Darwin standard-stream globals
# (__stdoutp/__stderrp) over glibc's stdout/stderr. Injected through
# LUDIC_CC so every clang invocation — the seed bootstrap, `ludic dev build`,
# LUDIC_CC so every clang invocation — the seed bootstrap, `ludic-dev build`,
# and each compiled test program — picks it up. Absolute path so it
# still resolves if a step changes directory.
echo "LUDIC_CC=clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll" >> "$GITHUB_ENV"
@ -60,20 +60,20 @@ jobs:
# pre-built binaries: the language builds itself from source + seed.
mkdir -p bin
clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc
bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic
bin/ludic dev build
bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev
bin/ludic-dev build
- name: Regression suite (ludic dev test)
run: bin/ludic dev test
- name: Regression suite (ludic-dev test)
run: bin/ludic-dev test
- name: Editor-toolchain suite (ludic dev test-tools)
- name: Editor-toolchain suite (ludic-dev test-tools)
# Grammar/lexer/vocabulary sync, ludic-fmt idempotence (the project's
# formatting contract — hand alignment is deliberately preserved, so the
# gate is fmt(fmt(x)) == fmt(x), not fmt(x) == x), and the JSON/XML editor
# assets. Cross-file LSP behaviour and the golden renders are macOS-ABI
# bound and skip here — visibly — until the runtime's directory walk and
# windowing are portable.
run: bin/ludic dev test-tools
run: bin/ludic-dev test-tools
- name: Docs cover the implementation
run: |
@ -81,9 +81,9 @@ jobs:
# The whole docs toolchain is written in Ludic and runs through x —
# no Python anywhere. check-impl / check-vocabulary / check-docs guard
# the sources; docs-gen builds the site and docs-check is its coverage
# + integrity guard. (check-vocabulary also runs in `ludic dev test-tools`.)
bin/ludic dev check-impl
bin/ludic dev check-vocabulary
bin/ludic dev check-docs
bin/ludic dev docs-gen --out build/pages
bin/ludic dev docs-check build/pages
# + integrity guard. (check-vocabulary also runs in `ludic-dev test-tools`.)
bin/ludic-dev check-impl
bin/ludic-dev check-vocabulary
bin/ludic-dev check-docs
bin/ludic-dev docs-gen --out build/pages
bin/ludic-dev docs-check build/pages

View file

@ -38,7 +38,7 @@ jobs:
runs-on: docker
# The generator is now Ludic, so this builds the toolchain from its IR seed
# (clang assembles the seed into bin/ludicc, which compiles bin/ludic) exactly
# like the ci workflow, then runs `ludic dev docs-gen`. node:20-bookworm carries git
# like the ci workflow, then runs `ludic-dev docs-gen`. node:20-bookworm carries git
# for the clone + publish; clang-16 is the only extra the bootstrap needs.
container: node:20-bookworm
steps:
@ -67,9 +67,9 @@ jobs:
export LUDIC_HOME="$(pwd)"
mkdir -p bin
clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc
bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic
bin/ludic dev docs-gen --out ../public
bin/ludic dev docs-check ../public
bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev
bin/ludic-dev docs-gen --out ../public
bin/ludic-dev docs-check ../public
cd ..
echo "--- generated files ---"
ls -la public

View file

@ -1,6 +1,6 @@
name: release
# Cutting a release is `ludic dev release` + `git push --tags`; everything after that
# Cutting a release is `ludic-dev release` + `git push --tags`; everything after that
# happens here. Before this workflow existed the artifacts were built on whatever
# machine the maintainer happened to be sitting at, from whatever was in bin/ at
# the time, with no checksums and nothing proving the tagged tree even passed its
@ -71,15 +71,15 @@ jobs:
set -eu
mkdir -p bin
clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc
bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic
bin/ludic dev build
bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev
bin/ludic-dev build
- name: The tagged tree must pass its own suites
run: |
set -eu
bin/ludic dev test
bin/ludic dev test-tools
bin/ludic dev bootstrap-cfree
bin/ludic-dev test
bin/ludic-dev test-tools
bin/ludic-dev bootstrap-cfree
- name: Publish the release
env:
@ -91,9 +91,9 @@ jobs:
echo "::error::No FORGEJO_TOKEN secret; cannot create the release."
exit 1
fi
# ludic dev publish builds dist/ (source tarball from the tag, this host's
# ludic-dev publish builds dist/ (source tarball from the tag, this host's
# toolchain, SHA256SUMS), takes the notes from the CHANGELOG section,
# and creates the release. Re-running it only adds missing assets, so
# a maintainer can afterwards attach the macOS toolchain from a Mac
# with the same command.
bin/ludic dev publish "$TAG"
bin/ludic-dev publish "$TAG"

8
.gitignore vendored
View file

@ -1,5 +1,5 @@
# Generated build tree: LLVM IR, objects, compiled apps, the headless render
# (build/out.ppm) and the docs site all land under build/ (see `bin/ludic dev build` /
# (build/out.ppm) and the docs site all land under build/ (see `bin/ludic-dev build` /
# `bin/ludic clean`). Root-anchored so a source dir named "build" elsewhere is never
# accidentally ignored. Nothing is written to the repo root any more.
/build/
@ -9,8 +9,8 @@
# packaged plugin .zip are local-only build inputs/outputs.
*.zip
# the toolchain binaries (ludicc, ludic, ludic-fmt, ludic-lsp) — all built
# into bin/ by the one-line bootstrap + `bin/ludic dev build`; never checked in. The
# the toolchain binaries (ludicc, ludic, ludic-dev, ludic-fmt, ludic-lsp) — all built
# into bin/ by the one-line bootstrap + `bin/ludic-dev build`; never checked in. The
# only thing published is the source and the LLVM-IR seed (selfhost/ludicc.seed.ll).
/bin/
@ -39,7 +39,7 @@ tools/editors/jetbrains/build/
__pycache__/
*.pyc
# Release artifacts produced by `ludic dev release`
# Release artifacts produced by `ludic-dev release`
/dist/
# Build/release tarballs anywhere in the tree. `git -C <repo> archive -o foo.tgz`

View file

@ -25,8 +25,8 @@
> ```bash
> # one-time bootstrap: clang assembles the seed, then ludicc compiles bin/ludic
> 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 # the whole toolchain into bin/
> bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev
> bin/ludic-dev build # the whole toolchain into bin/
> # (ludicc, ludic, ludic-fmt, ludic-lsp)
> bin/ludicc examples/games/snake.ludic -o bin/snake # the compiler, directly
> bin/ludic build examples/games/snake.ludic # or through the CLI
@ -320,7 +320,7 @@ node tools/ludic-web/run.mjs build/web/snake_headless.wasm --stdin=ddss
```
Because Ludic is fixed-point and its RNG is seeded, the native headless binary
and the wasm one must render byte-identical frames from the same input. `bin/ludic dev test`
and the wasm one must render byte-identical frames from the same input. `bin/ludic-dev test`
asserts exactly that, which is a much stronger check on the backend than
"it started".
@ -334,13 +334,13 @@ entity allocator, save/load snapshots, the frame loop, the window, and the whole
graphics stack — framebuffer, PNG decoding, sprites, 9-slice, TrueType text and
the retained UI.
None of it goes through C. `bin/ludic dev test` asserts that directly: no C source
None of it goes through C. `bin/ludic-dev test` asserts that directly: no C source
survives in `runtime/`, no C emitter survives in `ludicc`, and the examples all
build, run and render from IR alone.
## Every flag
The self-hosted `ludicc`/`ludic` (built with `bin/ludic dev build-cli`) accept:
The self-hosted `ludicc`/`ludic` (built with `bin/ludic-dev build-cli`) accept:
```
<file.ludic> the program to compile (first non-flag argument)

View file

@ -19,23 +19,24 @@ From a clean checkout, one line lifts the toolchain off the seed:
```bash
mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc
bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic
bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev
```
That gives you `bin/ludic`, the CLI — the same binary users install, which also
carries the toolchain's own tasks under `ludic dev` and replaces every build/test
shell script in the repo. From then on it builds everything — including itself:
That gives you `bin/ludic-dev`, the contributor tool: it replaces every
build/test shell script in the repo and builds everything, including itself and
`bin/ludic`. It is deliberately a separate binary from the `ludic` users install
— that one carries none of these tasks and is never asked to.
```bash
bin/ludic dev build # the whole toolchain into bin/ (ludicc, ludic, ludic-fmt, ludic-lsp)
bin/ludic dev help # every contributor task
bin/ludic-dev build # the whole toolchain into bin/ (ludicc, ludic, ludic-dev, ludic-fmt, ludic-lsp)
bin/ludic-dev help # every contributor task
bin/ludic help # what a user of the language sees
```
Always run `ludic dev` from the repository root, so `assets/` and `selfhost/`
resolve. (A checkout is an install root: `bin/` beside `runtime/` and
`packages/`, exactly the shape `install.sh` lays down under `~/.ludic`. That is
why the same binary serves both.)
Always run `ludic-dev` from the repository root, so `assets/` and `selfhost/`
resolve. (A checkout is also an install root: `bin/` beside `runtime/` and
`packages/`, exactly the shape `install.sh` lays down under `~/.ludic`, which is
why `bin/ludic` behaves there exactly as an installed one does.)
## The development loop
@ -43,17 +44,17 @@ When you change the compiler or runtime, prove the self-hosting fixpoint still
holds before you push:
```bash
bin/ludic dev reseed # regenerate selfhost/ludicc.seed.ll after a compiler change
bin/ludic dev bootstrap-cfree # rebuild the compiler from the seed with NO C compiler in the loop
bin/ludic dev test # the full regression suite
bin/ludic-dev reseed # regenerate selfhost/ludicc.seed.ll after a compiler change
bin/ludic-dev bootstrap-cfree # rebuild the compiler from the seed with NO C compiler in the loop
bin/ludic-dev test # the full regression suite
```
Other useful targets:
```bash
bin/ludic build <file.ludic> [--headless] # compile a program to a native app in build/
bin/ludic dev selfhost-test # correctness + bootstrap fixpoints
bin/ludic dev test-tools # the editor-toolchain suite (ludic-fmt, ludic-lsp)
bin/ludic-dev selfhost-test # correctness + bootstrap fixpoints
bin/ludic-dev test-tools # the editor-toolchain suite (ludic-fmt, ludic-lsp)
bin/ludic clean # remove build/, out.ppm and stray artifacts
```
@ -67,7 +68,7 @@ The stdlib lives in the runtime (`runtime/`) and is surfaced as namespaces
and register its id in `tools/docgen/inventory.json`. Each documented
namespace gets exactly **one** directory (the docs check enforces this).
3. Add or extend an example under `examples/` and a case in the test suite.
4. Run `bin/ludic dev docs-gen --out build/pages && bin/ludic dev docs-check build/pages` — the
4. Run `bin/ludic-dev docs-gen --out build/pages && bin/ludic-dev docs-check build/pages` — the
check fails if any inventory symbol lacks a page or is still seed text.
5. Add a **changeset** for the user-facing change: a small file under
[`changes/`](changes/README.md) with a `bump:` level and a one-line summary.
@ -82,22 +83,22 @@ Releases are changeset-driven. Every user-facing change ships with a changeset
(step 5 above). Read the next release before cutting it:
```bash
ludic dev release --dry-run # render the CHANGELOG section, write nothing
ludic-dev release --dry-run # render the CHANGELOG section, write nothing
```
Then cut it:
```bash
ludic dev release [major|minor|patch] # omit the level to derive it from the changesets
ludic-dev release [major|minor|patch] # omit the level to derive it from the changesets
git push origin main --follow-tags
```
`ludic dev release` aggregates the pending changesets into a new `CHANGELOG.md` section
`ludic-dev release` aggregates the pending changesets into a new `CHANGELOG.md` section
— grouped by change type, with each changeset's markdown kept intact — bumps
`VERSION`, commits `chore(release): vX.Y.Z`, and tags it.
**Pushing the tag is what publishes.** The `release` workflow builds the
toolchain from the IR seed, runs `ludic dev test`, `ludic dev test-tools` and `ludic dev bootstrap-cfree`
toolchain from the IR seed, runs `ludic-dev test`, `ludic-dev test-tools` and `ludic-dev bootstrap-cfree`
against the tagged tree, and only then creates the Forgejo release — with the
source tarball, a Linux toolchain build, a `.sha256` beside each, and that version's
`CHANGELOG.md` section as the notes. It refuses to publish if the tag and
@ -113,10 +114,10 @@ macOS artifacts cannot be produced on the Linux runner — a `darwin-arm64` buil
needs a macOS host, and there is no cross-compile path (it would need the Xcode
SDK and a Mach-O linker). Attaching one therefore means either registering a
macOS runner and giving it a job, or running the same command CI runs from a
Mac. Either way it is `ludic dev publish`, which only adds assets the release is missing:
Mac. Either way it is `ludic-dev publish`, which only adds assets the release is missing:
```bash
FORGEJO_TOKEN=… ludic dev publish v0.4.0
FORGEJO_TOKEN=… ludic-dev publish v0.4.0
```
Checksums are one `.sha256` file per artifact rather than a single `SHA256SUMS`,
@ -138,14 +139,14 @@ broken link at a time. Everything host-shaped has an environment override, so a
move can be rehearsed before it is committed.
**Hosts and URLs.** The install one-liner is served from the documentation site,
which publishes `install.sh` beside the pages that quote it (`ludic dev docs-gen`
which publishes `install.sh` beside the pages that quote it (`ludic-dev docs-gen`
copies it in; `docs-check` fails without it). Change the host in:
| Where | What |
|---|---|
| `install.sh` | `REPO_API`, `REPO_URL`, `INSTALL_URL` — each `${LUDIC_…:-default}`, so `LUDIC_REPO_URL=… sh install.sh` tests a move without editing anything |
| `tools/ludic-cli/project.ludic` | `install_url()` (`$LUDIC_INSTALL_URL`), used by `ludic upgrade` and `ludic doctor` |
| `tools/ludic-cli/forgejo.ludic` | `FORGEJO_API_DEFAULT` (`$LUDIC_FORGEJO_API`), used by `ludic dev publish` |
| `tools/ludic-cli/forgejo.ludic` | `FORGEJO_API_DEFAULT` (`$LUDIC_FORGEJO_API`), used by `ludic-dev publish` |
| `docs/site/site.json` | `repo_url`, the `start.terminal` one-liner, and the doc links in `nav_links` |
| Prose | `README.md`, `COMPILING.md`, `tools/editors/README.md`, and the two editor plugins' "server not found" messages |
@ -155,9 +156,10 @@ copies it in; `docs-check` fails without it). Change the host in:
`is_ludic_file`), every editor asset (`tools/editors/shared/*.json`,
`vscode/package.json`, the JetBrains `LudicFileType`), and every source file
in the tree.
- **The binaries** `ludic`, `ludicc`, `ludic-fmt`, `ludic-lsp` — `build.ludic`'s
`cmd_dev_build`, the release staging in `release.ludic`, `install.sh`, the
editors' executable-name lists.
- **The binaries** `ludic`, `ludicc`, `ludic-dev`, `ludic-fmt`, `ludic-lsp` —
`cmd_dev_build` in `toolchain.ludic`, the release staging in `release.ludic`,
`install.sh`, the editors' executable-name lists. Only the first, third and
fourth of those ship: `ludic-dev` is built from a checkout and stays there.
- **The install root** `~/.ludic` and the source directories `tools/ludic-cli/`,
`tools/ludic-tools/`, `packages/ludic.*`.
- **The environment variables** `LUDIC_HOME`, `LUDIC_CC`, `LUDIC_MODULES`,
@ -167,11 +169,11 @@ copies it in; `docs-check` fails without it). Change the host in:
- **Identifiers that are contracts with other software**: the TextMate scope
`source.ludic`, the VS Code language id `ludic`, the JetBrains plugin id
`io.ludic.ide`, and the `ludic` code-fence tag understood by the Markdown
injection and by `ludic dev check-docs`.
injection and by `ludic-dev check-docs`.
- **The prose**: `README.md`, `LANGUAGE.md`, `COMPILING.md`, `docs/**`, and
`docs/site/site.json`'s `brand`/`meta`.
`ludic dev test` is the safety net for the mechanical part — it builds the
`ludic-dev test` is the safety net for the mechanical part — it builds the
toolchain, stages an install, and runs `new` → `build` → `test` through it, so a
half-finished rename fails there rather than in someone's terminal.
@ -239,7 +241,7 @@ non-destructive version of "tidy the history" without touching a single commit.
## Pull requests
- Base your branch on `main`.
- Ensure `bin/ludic dev test` (and `bin/ludic dev bootstrap-cfree` for compiler/runtime changes)
- Ensure `bin/ludic-dev test` (and `bin/ludic-dev bootstrap-cfree` for compiler/runtime changes)
pass, and that `ludic-fmt` leaves your files unchanged.
- Fill in the PR template checklist. Reference the issue you close with
`Closes #NN` in the description or a commit message.

View file

@ -1031,11 +1031,11 @@ ludicc app.ludic --emit-llvm -o app.ll # stop at LLVM IR
```
`ludic` is the CLI (`ludic help`); `ludicc` is the compiler it drives, built from
the IR seed by `bin/ludic dev build-cli`. **[COMPILING.md](COMPILING.md) is the
the IR seed by `bin/ludic-dev build-cli`. **[COMPILING.md](COMPILING.md) is the
authoritative CLI reference** — the full flag set (`-o`, `--windowed`,
`--headless`, `--emit-llvm`, `--save-temps`, `--run`), the `LUDIC_HOME` /
`LUDIC_CC` environment variables, and the IR-to-stdout bootstrap contract (no
`-o`) that `bin/ludic build` / `bin/ludic dev reseed` rely on. The default mode
`-o`) that `bin/ludic build` / `bin/ludic-dev reseed` rely on. The default mode
is auto: a file with `handler`s links windowed, otherwise headless; an explicit
flag always wins.
@ -1053,7 +1053,7 @@ duplicate types, unknown fields, arity) are future work.
### Editors
```bash
bin/ludic dev tools # -> bin/ludic-fmt, bin/ludic-lsp
bin/ludic-dev tools # -> bin/ludic-fmt, bin/ludic-lsp
bin/ludic-fmt -w src/ # format in place (keeps comments)
bin/ludic-fmt --check . # CI: exit 1 if anything is unformatted
bin/ludic-lsp --stdio # the language server, for any editor
@ -1107,7 +1107,7 @@ self-hosting; their lowerings are in
> **Implemented (S0).** `scene`, `layer`, and the `on enter` / `on exit` hooks
> compile; [`examples/lang/scenes.ludic`](examples/lang/scenes.ludic) runs and is checked
> by `bin/ludic dev test`. A scene lowers to a `machine` the compiler writes for you: one
> by `bin/ludic-dev test`. A scene lowers to a `machine` the compiler writes for you: one
> implicit active-scene register, states numbered by declaration order, and
> `become` as two direct calls plus a store. Richer scene features (the overlay
> stack, scene-owned entities, scene-local state, transition parameters) are

View file

@ -77,16 +77,18 @@ of them with `ludic build examples/games/snake.ludic`.
### Building from a checkout
Contributors work from the repository, where the same CLI carries the toolchain's
own tasks under `ludic dev`. Bootstrapping is the only step Ludic cannot do for
itself, since compiling Ludic needs a compiler — clang assembles the checked-in
IR seed, and that compiler builds the rest:
Contributors also get `ludic-dev`, a second binary carrying the toolchain's own
tasks — building the compiler, the suites, the docs site, releases. It is built
from a checkout and is not part of an install, so nothing a user runs is mixed
up with it. Bootstrapping is the only step Ludic cannot do for itself, since
compiling Ludic needs a compiler — clang assembles the checked-in IR seed, and
that compiler builds the rest:
```bash
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 # -> bin/{ludicc,ludic,ludic-fmt,ludic-lsp}
bin/ludic dev test # the regression suite
bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev
bin/ludic-dev build # -> bin/{ludicc,ludic,ludic-dev,ludic-fmt,ludic-lsp}
bin/ludic-dev test # the regression suite
```
## The language
@ -154,7 +156,7 @@ capability of the retired C compiler and has not been re-wired on the
self-hosted toolchain. `--target` cross-compilation and `--shared` libraries are
in the same position. See [COMPILING.md](COMPILING.md).
Releases follow SemVer and are cut from changesets by `ludic dev release`, then built
Releases follow SemVer and are cut from changesets by `ludic-dev release`, then built
and published by CI from the tag; see [CHANGELOG.md](CHANGELOG.md).
## Contributing

View file

@ -1,7 +1,7 @@
# Changesets
A **changeset** is one small Markdown file describing a single user-facing change,
dropped in this directory. `ludic dev release` consumes every changeset here into a new
dropped in this directory. `ludic-dev release` consumes every changeset here into a new
`CHANGELOG.md` section, bumps `VERSION`, and deletes the consumed files.
## Format
@ -14,7 +14,7 @@ the changelog. Markdown is fine.
```
- `bump:` — `major`, `minor`, or `patch` (SemVer). The release version is bumped
by the **highest** level among the pending changesets (unless `ludic dev release <level>`
by the **highest** level among the pending changesets (unless `ludic-dev release <level>`
overrides it).
- `type:` — the Conventional Commit type (`feat`, `fix`, `perf`, `docs`, …). It
decides which group the change lands in: `feat` → **Features**, `fix` →
@ -47,5 +47,5 @@ filename works except this `README.md`, which the release step always skips.
Preview how the next release will read before cutting it — this writes nothing:
```bash
ludic dev release --dry-run
ludic-dev release --dry-run
```

16
changes/split-dev-cli.md Normal file
View file

@ -0,0 +1,16 @@
bump: minor
type: refactor
**`ludic` is only the language's command line now.** The toolchain's own tasks —
building the compiler from its IR seed, the regression suites, the docs site,
releases — moved out of it into a separate `ludic-dev` binary that is built from
a checkout and is not part of an install.
- **`ludic help` is what a user can actually do**: `new`, `run`, `build`, `test`,
`add`, `fmt`, `lsp`, `doctor`, `upgrade`. No section about a repository they do
not have. Typing `ludic dev …` says where those tasks went rather than failing
as an unknown command.
- **`ludic dev <task>` becomes `ludic-dev <task>`** for contributors; every task
is otherwise unchanged. The bootstrap is now
`bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev && bin/ludic-dev build`.
- The shipped binary drops from ~880 KB to ~190 KB, since none of the release,
docs-generation or bootstrap machinery is linked into it any more.

View file

@ -74,7 +74,7 @@ LUDIC_MODULES=packages ludicc examples/games/platformer_demo.ludic -o platformer
Worked, self-checking examples for every controller live in `examples/games/`
(`platformer_demo`, `platformer_scaffolding`, `shooter_demo`, `npcai_demo`, `rpg_demo`)
and `examples/library/gameplay_foundation.ludic`; each is a deterministic regression
case in `ludic dev test`.
case in `ludic-dev test`.
## What we deliberately do NOT do

View file

@ -19,10 +19,10 @@ Assertions (each records a failure and prints <code>file:line: … failed</code>
Run a spec file directly with the compiler-runner — `ludic mymath_test.ludic` compiles it to a native binary, runs it, and forwards the pass/fail exit code — so it drops straight into `bin/ludic` and CI.
<strong>Line coverage.</strong> Compile with the <code>--coverage</code> flag and the compiler instruments every statement with a per-source-line hit counter; at exit the counts are written to the file named by <code>$LUDIC_COVERAGE</code> (default <code>ludic.cov</code>) as a <code>FILE &lt;name&gt;</code> header followed by one <code>&lt;line&gt; &lt;hits&gt;</code> row per instrumented line. The instrumentation is flag-gated and additive, so an ordinary build — and the compiler's own self-compile — stays byte-identical. <code>bin/ludic dev test --coverage</code> compiles the test specs this way, runs them, and aggregates the dumps into a per-file report that names the lines your tests never reached:
<strong>Line coverage.</strong> Compile with the <code>--coverage</code> flag and the compiler instruments every statement with a per-source-line hit counter; at exit the counts are written to the file named by <code>$LUDIC_COVERAGE</code> (default <code>ludic.cov</code>) as a <code>FILE &lt;name&gt;</code> header followed by one <code>&lt;line&gt; &lt;hits&gt;</code> row per instrumented line. The instrumentation is flag-gated and additive, so an ordinary build — and the compiler's own self-compile — stays byte-identical. <code>bin/ludic-dev test --coverage</code> compiles the test specs this way, runs them, and aggregates the dumps into a per-file report that names the lines your tests never reached:
```
== line coverage (bin/ludic dev test --coverage) ==
== line coverage (bin/ludic-dev test --coverage) ==
examples/library/coverage.ludic 16/17 lines 94% uncovered: 33
examples/library/testing.ludic 17/17 lines 100%
----

View file

@ -1,7 +1,7 @@
# Examples
A curated tour of Ludic, grouped by intent. Every example here is exercised by
the test suite (`bin/ludic dev test` / `bin/ludic dev selfhost-test`), so nothing in this
the test suite (`bin/ludic-dev test` / `bin/ludic-dev selfhost-test`), so nothing in this
directory silently rots.
Run any program straight from the repository root (so `assets/` resolves):

View file

@ -11,7 +11,7 @@
# 25 @OnDetach(Shield): reads the outgoing amount 5, prints 5 + 20
# 0 the Shield is gone — nothing matches
#
# bin/ludic dev game-build bin/ludicc examples/lang/detach.ludic /tmp/detach
# bin/ludic-dev game-build bin/ludicc examples/lang/detach.ludic /tmp/detach
# /tmp/detach </dev/null
program Detach {
property Tag { v: int = 0 }

View file

@ -9,7 +9,7 @@
# 503 Enemy A despawned in-world (Despawned): drop its loot, 3 + 500
# 1009 Enemy B outlived the run; at quit (Quit) it skips loot, 9 + 1000
#
# bin/ludic dev game-build bin/ludicc examples/lang/reason.ludic /tmp/reason
# bin/ludic-dev game-build bin/ludicc examples/lang/reason.ludic /tmp/reason
# /tmp/reason </dev/null
program Reasons {
property Health { hp: int = 0 }

View file

@ -12,7 +12,7 @@
# 201 900 frame 3: Play.World.Step, then Hud.Draw
# 202 900 frame 4: Step reaches 2 -> quit(); Hud.Draw paints the last frame
#
# bin/ludic dev game-build bin/ludicc examples/lang/scenes.ludic /tmp/scenes
# bin/ludic-dev game-build bin/ludicc examples/lang/scenes.ludic /tmp/scenes
# printf 'aaaa' | /tmp/scenes
program SceneDemo {
var counter: int = 0

View file

@ -1,6 +1,6 @@
# coverage.ludic — a spec built to be measured by `bin/ludic dev test --coverage` (issue
# coverage.ludic — a spec built to be measured by `bin/ludic-dev test --coverage` (issue
# #45). Compiled with `--coverage`, every statement bumps a per-source-line hit
# counter; at exit the counts are dumped and `bin/ludic dev test --coverage` turns them
# counter; at exit the counts are dumped and `bin/ludic-dev test --coverage` turns them
# into a per-file line-coverage report.
#
# The tests below exercise `sign` fully but only the taken branches of `grade`,

View file

@ -199,15 +199,22 @@ install_from_source() {
git clone --quiet --depth 1 --branch "v${VERSION}" "${REPO_URL}.git" "$_src" 2>/dev/null \
|| git clone --quiet --depth 1 "${REPO_URL}.git" "$_src" \
|| die "could not clone ${REPO_URL}"
# A release older than the CLI cannot produce one, and the build would fail
# deep inside clang with nothing pointing at the cause.
[ -f "${_src}/tools/ludic-cli/main.ludic" ] || die "release v${VERSION} predates the ludic CLI; install a newer one with --version X.Y.Z"
# Which entry point builds the toolchain depends on the age of the tag: the
# contributor tool was split out of the CLI after 0.5.2, so an older release
# still bootstraps through main.ludic. A release older than either predates the
# CLI entirely, and saying so beats failing deep inside clang.
if [ -f "${_src}/tools/ludic-cli/dev.ludic" ]; then
_boot='bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev && bin/ludic-dev build'
elif [ -f "${_src}/tools/ludic-cli/main.ludic" ]; then
_boot='bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic && bin/ludic dev build'
else
die "release v${VERSION} predates the ludic CLI; install a newer one with --version X.Y.Z"
fi
CC=clang; have clang || CC=cc
( cd "$_src" \
&& mkdir -p bin \
&& "$CC" selfhost/ludicc.seed.ll -o bin/ludicc 2>/dev/null \
&& bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic \
&& bin/ludic dev build >/dev/null ) || die "the source build failed"
&& eval "$_boot" >/dev/null ) || die "the source build failed"
_stage="${TMP}/stage"
mkdir -p "${_stage}/bin"

View file

@ -5,7 +5,7 @@
# no allocation, identical codegen to writing the hex by hand. Unknown names are
# a compile error (color_lookup returns -1, which emit_expr reports).
#
# GENERATED by `ludic dev docs-palette` from the single source-of-truth palette table
# GENERATED by `ludic-dev docs-palette` from the single source-of-truth palette table
# in tools/ludic-cli/docgen.ludic. Edit the palette there and regenerate; do not hand-edit.
# ============================================================================

View file

@ -12,7 +12,7 @@
#
# This is the compiler, not the command line a user of the language meets: that
# is `ludic` (tools/ludic-cli), which drives this. With no -o and no --run, IR
# still goes to stdout — the contract `ludic dev reseed` and `ludic dev
# still goes to stdout — the contract `ludic-dev reseed` and `ludic dev
# bootstrap` rely on, so the bootstrap is untouched.
# basename: the part of a path after the last '/'.
@ -124,7 +124,7 @@ entry {
# a game gets a window by default; a plain program stays headless. An explicit
# flag always wins. The stdout-IR path (no target) also stays headless, which
# is what `ludic dev reseed` compiles the compiler itself with.
# is what `ludic-dev reseed` compiles the compiler itself with.
let has_target = run or (out != null)
if want == 1 { g_windowed = true }
else if want == 2 { g_windowed = false }

View file

@ -10,7 +10,7 @@ docs/
language/<category>/<id>.md one file per symbol — keyword, type, phase,
builtin, namespace method, operator, annotation
language/<category>/_section.md section title + blurb + order
language/colors/palette.json the 221 named colors (generated by `ludic dev docs-palette`)
language/colors/palette.json the 221 named colors (generated by `ludic-dev docs-palette`)
site/site.json landing-page messaging (hero, features, …)
site/snippets/*.ludic the code shown on the landing page (real programs)
tools/docgen/inventory.json the authoritative symbol set the coverage guard checks
@ -66,9 +66,9 @@ no Python in the pipeline. `assets/` (the CSS/HTML/JS templates) and
`inventory.json` are the only inputs the tools here still read directly.
```bash
bin/ludic dev docs-gen --out build/pages # generate the whole site
bin/ludic dev docs-check build/pages # coverage + duplicate-token + link guard
bin/ludic dev check-docs # parse every ```ludic doc fence
bin/ludic-dev docs-gen --out build/pages # generate the whole site
bin/ludic-dev docs-check build/pages # coverage + duplicate-token + link guard
bin/ludic-dev check-docs # parse every ```ludic doc fence
```
`docs-check` fails CI if any symbol in `inventory.json` lacks a page, if a token
@ -79,13 +79,13 @@ built `bin/ludic` (bootstrapped from the IR seed with clang alone).
## Publish
`.forgejo/workflows/docs.yml` bootstraps the toolchain from the IR seed and runs
`ludic dev docs-gen` + `ludic dev docs-check` on every push to `main` that touches `docs/**`,
`ludic-dev docs-gen` + `ludic-dev docs-check` on every push to `main` that touches `docs/**`,
`tools/docgen/**` or `tools/ludic-cli/**`, and publishes the result to the `pages` branch
root. `index.html` + `.nojekyll` always stay at the root.
## Colors
`palette.json` and `selfhost/backend/stdlib/emit_color.ludic` are both generated
by `bin/ludic dev docs-palette` from a single palette table — the `pal_add(...)` rows in
by `bin/ludic-dev docs-palette` from a single palette table — the `pal_add(...)` rows in
`tools/ludic-cli/docgen.ludic`. Edit the table there and regenerate; do not hand-edit the
generated files.

View file

@ -31,11 +31,11 @@ it needs no path configuration.
From a checkout, build them with:
```bash
bin/ludic dev tools
bin/ludic-dev tools
```
Produces `bin/ludic-fmt` and `bin/ludic-lsp`. Add `--install` to symlink both
into `~/.local/bin`, `--test` to run `bin/ludic dev test-tools` afterwards.
into `~/.local/bin`, `--test` to run `bin/ludic-dev test-tools` afterwards.
## What you get, in any editor
@ -118,7 +118,7 @@ ln -sf ../../tools/git-hooks/pre-commit .git/hooks/pre-commit
The hook runs `ludic-fmt --check` over the staged `.ludic` and `.md` files only,
and does nothing at all when `bin/ludic-fmt` has not been built — so it never
blocks a commit on a machine that has not run `bin/ludic dev tools`.
blocks a commit on a machine that has not run `bin/ludic-dev tools`.
## Why the formatter is not `ludicc --fmt`
@ -140,7 +140,7 @@ both spellings are what the language documents and uses:
(`const R_DIR: int = 0 # 0 up`) survive a save;
- `id=Root` inside a `ui` block and `{Enemy}` inside a query stay tight.
`bin/ludic dev test-tools` checks the property that matters: formatting every file in
`bin/ludic-dev test-tools` checks the property that matters: formatting every file in
the tree and re-running the *compiler's* canonical dump produces byte-identical
output. The formatter cannot change what a program means.
@ -149,8 +149,8 @@ output. The formatter cannot change what a program means.
The vocabulary is written down in five places that cannot include each other —
the compiler's two tables, `ludic_syntax.h`, the TextMate grammar (JSON), and the
JetBrains lexer (Kotlin). Adding a builtin and forgetting the rest is silent
failure, so `bin/ludic dev check-vocabulary` (written in Ludic) compares all five, and
`bin/ludic dev test-tools` runs it.
failure, so `bin/ludic-dev check-vocabulary` (written in Ludic) compares all five, and
`bin/ludic-dev test-tools` runs it.
When you add a keyword or builtin: put it in `ludic_syntax.h`, then run
`bin/ludic dev test-tools` and let it tell you which copies still need it.
`bin/ludic-dev test-tools` and let it tell you which copies still need it.

View file

@ -43,7 +43,7 @@ The first build downloads an IntelliJ IDEA Community distribution (over 1 GB),
so expect it to take a while; later builds take seconds. Once the cache is warm,
```bash
LUDIC_TEST_JETBRAINS=1 bin/ludic dev test-tools
LUDIC_TEST_JETBRAINS=1 bin/ludic-dev test-tools
```
includes the plugin build in the toolchain's own test run.
@ -55,4 +55,4 @@ LSP4IJ is a required dependency; the IDE offers to install it for you.
**Settings -> Languages & Frameworks -> Ludic** — the path to `ludic-lsp` and to
`ludicc`. Both default to `bin/` under the project root, which is where
`bin/ludic dev tools` puts them.
`bin/ludic-dev tools` puts them.

View file

@ -17,7 +17,7 @@ import java.io.File
/**
* Where to find the toolchain. Both paths may be left blank, in which case the
* plugin looks under the project root for what `bin/ludic dev tools` produces —
* plugin looks under the project root for what `bin/ludic-dev tools` produces —
* which is the case for anyone working in the Ludic repository itself.
*/
@Service(Service.Level.PROJECT)
@ -121,7 +121,7 @@ class LudicConnectionProvider(private val project: Project) : ProcessStreamConne
append(" on PATH, and in ~/.ludic/bin.\n")
}
append("\nInstall the toolchain (curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh), ")
append("build it with `bin/ludic dev tools` from a checkout, ")
append("build it with `bin/ludic-dev tools` from a checkout, ")
append("or set the path in Settings | Languages & Frameworks | Ludic. ")
append("Syntax highlighting works without it; completion and diagnostics do not.")
}

View file

@ -67,7 +67,7 @@
name="Ludic"
factoryClass="io.ludic.ide.LudicLanguageServerFactory">
<description><![CDATA[
The Ludic language server. Built by <code>bin/ludic dev tools</code>.
The Ludic language server. Built by <code>bin/ludic-dev tools</code>.
]]></description>
</server>
<languageMapping language="Ludic" serverId="ludic"/>

View file

@ -23,7 +23,7 @@ for the language reference.
Build the toolchain once, from the repository root:
```bash
bin/ludic dev tools
bin/ludic-dev tools
```
That produces `bin/ludic-lsp` and `bin/ludic-fmt`, which the extension finds

View file

@ -98,7 +98,7 @@ async function start(context) {
// Not an error worth a modal: the grammar still highlights, and plenty of
// people open a .ludic file without having built the toolchain.
output.appendLine(
'ludic-lsp not found. Install the toolchain (curl -fsSL https://ludic.dev/install.sh | sh), build it from a checkout with `bin/ludic dev tools`, or set "ludic.server.path". ' +
'ludic-lsp not found. Install the toolchain (curl -fsSL https://ludic.dev/install.sh | sh), build it from a checkout with `bin/ludic-dev tools`, or set "ludic.server.path". ' +
'Syntax highlighting works without it; completion and diagnostics do not.'
);
return;

View file

@ -1,56 +1,30 @@
# build.ludic — compiling: a user's program (ludic build / ludic run) and the
# toolchain's own binaries (ludic dev build).
# build.ludic — turning a .ludic file into an executable.
#
# compile_app is the one place a .ludic file becomes an executable, so a project
# build and the repo's own example builds cannot drift apart. Everything the
# toolchain owns — the compiler, the engine runtime it splices — is addressed
# through ludic_home(), so the same code path works from a checkout and from an
# installed ~/.ludic.
# compile_app is the one place that happens, shared by `ludic build` / `ludic
# run` and by the contributor tool, so a user's build and the repo's own example
# builds cannot drift apart. Everything the toolchain owns — the compiler, the
# engine runtime it splices — is addressed through ludic_home(), so the same code
# path works from a checkout and from an installed ~/.ludic.
# 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.
function build_tool(name: pointer, src: pointer) -> 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/ludic 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.
function cmd_dev_build_cli() -> int {
# 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.
function ensure_ludicc() -> void {
if not in_toolchain_repo() { return }
run("mkdir -p bin build")
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("ludic: the seed did not assemble\n"); return 1 }
run("chmod +x bin/ludicc")
print("done. built bin/ludicc")
return 0
}
# ---- build: the whole toolchain (ludicc, ludic, ludic-fmt, ludic-lsp) -------
function cmd_dev_build() -> int {
if cmd_dev_build_cli() != 0 { return 1 }
print("ludicc: tools/ludic-cli/main.ludic -> bin/ludic (the CLI rebuilds itself)")
if not build_tool("ludic", "tools/ludic-cli/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, ludic-fmt, ludic-lsp)")
return 0
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("ludic: could not assemble the seed\n"); exit(1)
}
}
}
# ---- compiling one program to a native binary -------------------------------
#
# mode 1 = windowed (a game opens a real window), 2 = headless (the last frame is
# rendered to build/out.ppm from piped input — deterministic output for tests).
# Returns true on success. `ludic build`, `ludic run` and `ludic dev app` all go
# through here.
# Returns true on success. `ludic build`, `ludic run` and the contributor tool all
# go through here.
function compile_app(src: pointer, out: pointer, mode: int, save: bool) -> bool {
ensure_ludicc() # in a checkout: assemble the seed if bin/ludicc is missing or stale
let home = ludic_home()
@ -91,7 +65,7 @@ function dir_of_path(p: pointer) -> pointer {
# build/out.ppm), and any stray *.tmp/*.ll left behind by a failed build. A
# legacy out.ppm in the root (from an older toolchain) is swept too. Binaries in
# bin/ are kept so the running CLI survives; in the toolchain repo, rebuild them
# any time with `ludic dev build`.
# any time with `ludic-dev build`.
function cmd_clean() -> int {
run("rm -rf build")
run("rm -f out.ppm bin/*.tmp")

View file

@ -9,75 +9,6 @@
# plain indexing; `==` on pointers is a byte-string compare (the whole toolchain
# leans on this).
# ---- small string helpers ---------------------------------------------------
# length of a NUL-terminated buffer
function slen(s: pointer) -> int { var n = 0; while s[n] != 0 { n += 1 }; return n }
# a fresh NUL-terminated copy of s[start .. end) (end exclusive)
function sslice(s: pointer, start: int, end: int) -> pointer {
if end < start { return "" }
let n = end - start
let b = bytes(n + 1)
var i = 0
while i < n { b[i] = s[start + i]; i += 1 }
b[n] = 0
return b
}
# index of the first byte of `needle` in `hay` at or after `from`, else -1
function s_index(hay: pointer, needle: pointer, from: int) -> int {
let hn = slen(hay)
let nn = slen(needle)
if nn == 0 { return from }
var i = from
while i + nn <= hn {
var j = 0
while j < nn and hay[i + j] == needle[j] { j += 1 }
if j == nn { return i }
i += 1
}
return -1
}
function s_contains(hay: pointer, needle: pointer) -> bool { return s_index(hay, needle, 0) >= 0 }
# does `hay` contain `needle` exactly at position `at`?
function s_starts_at(hay: pointer, at: int, needle: pointer) -> bool {
let nn = slen(needle)
var i = 0
while i < nn { if hay[at + i] != needle[i] { return false }; i += 1 }
return true
}
# does `s` (a whole line) begin with `pre`?
function s_starts(s: pointer, pre: pointer) -> bool {
let pn = slen(pre)
var i = 0
while i < pn { if s[i] != pre[i] { return false }; i += 1 }
return true
}
# is byte c an ASCII space/tab?
function is_ws(c: int) -> bool { return c == ' ' or c == '\t' }
# the substring from `start` up to the next '\n' (or end)
function line_at(s: pointer, start: int) -> pointer {
var e = start
while s[e] != 0 and s[e] != '\n' { e += 1 }
return sslice(s, start, e)
}
# trim leading/trailing ASCII whitespace (space, tab, cr, nl)
function s_trim(s: pointer) -> pointer {
let n = slen(s)
var a = 0
while a < n and (is_ws(s[a]) or s[a] == '\n' or s[a] == '\r') { a += 1 }
var b = n
while b > a and (is_ws(s[b - 1]) or s[b - 1] == '\n' or s[b - 1] == '\r') { b -= 1 }
return sslice(s, a, b)
}
# ============================================================================
# check-docs — every ```ludic fence in the docs must parse (or be marked)
# ============================================================================
@ -177,7 +108,7 @@ function count_nl(s: pointer, upto: int) -> int {
return n
}
# usage: ludic dev check-docs (scans the user-facing docs + docs/language/**)
# usage: ludic-dev check-docs (scans the user-facing docs + docs/language/**)
function cmd_check_docs() -> int {
ensure_ludicc()
DC_OK = 0; DC_BAD = 0; DC_SKIP = 0; DC_FAILS = ""
@ -361,7 +292,7 @@ var CI_PROB: pointer = ""
var CI_NPROB: int = 0
function ci_problem(msg: pointer) -> void { CI_PROB = CI_PROB + " - " + msg + "\n"; CI_NPROB += 1 }
# usage: ludic dev check-impl
# usage: ludic-dev check-impl
function cmd_check_impl() -> int {
CI_PROB = ""; CI_NPROB = 0
let allsrc = read_all_selfhost()
@ -568,7 +499,7 @@ function set_union(a: []pointer, b: []pointer) -> []pointer {
return out
}
# usage: ludic dev check-vocabulary
# usage: ludic-dev check-vocabulary
function cmd_check_vocab() -> int {
CV_PROB = ""; CV_N = 0
let h = read_file("tools/ludic-tools/ludic_syntax.h")
@ -702,10 +633,10 @@ function xml_valid(text: pointer) -> bool {
return sp == 0
}
# usage: ludic dev lint-asset <file> (validates one .json or .xml editor asset)
# usage: ludic-dev lint-asset <file> (validates one .json or .xml editor asset)
function cmd_lint_asset() -> int {
if arg_total() < 3 { err("usage: ludic dev lint-asset <file.json|file.xml>\n"); return 2 }
let path = arg_n(2)
if arg_count() < 3 { err("usage: ludic-dev lint-asset <file.json|file.xml>\n"); return 2 }
let path = arg(2)
let t = read_file(path)
if t == null { err("lint-asset: cannot read " + path + "\n"); return 1 }
if s_index(path, ".json", 0) >= 0 {

137
tools/ludic-cli/dev.ludic Normal file
View file

@ -0,0 +1,137 @@
# ludic-dev — the contributor tool for the Ludic toolchain itself: building the
# compiler from its IR seed, the regression suites, the docs site, releases.
#
# It is a separate binary from `ludic` on purpose. Those two have different
# audiences and nothing in common but the compile step: a person who installed
# the language cannot bootstrap a compiler they do not have the source of, and
# should not have to read past a screen of tasks that only mean something inside
# this repository. So `ludic` ships and `ludic-dev` does not — it is built from a
# checkout, lives in bin/, and is left out of every release artifact.
#
# From a clean checkout (the one step Ludic cannot do for itself, since compiling
# Ludic needs a compiler):
#
# mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc
# bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev
# bin/ludic-dev build
#
# Thereafter `ludic-dev build` rebuilds the whole toolchain — bin/ludic, this
# tool, the compiler, the editor tools. Run it from the repository root.
program LudicDev {
import "prelude.ludic"
import "build.ludic"
import "toolchain.ludic"
import "selfhost.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-dev — tasks for a checkout of the Ludic toolchain (run from the repository root)")
print("")
print("This is the contributor tool. The language's own command line is `ludic`,")
print("which ships to users and carries none of the below.")
print("")
print("build:")
print(" build build the whole toolchain into bin/ (ludicc, ludic, ludic-dev, ludic-fmt, ludic-lsp)")
print(" build-cli build just bin/ludicc from the IR seed")
print(" tools [--install] [--test] build the editor toolchain (ludic-fmt, ludic-lsp)")
print(" clean remove build/")
print("")
print("test:")
print(" test the full regression suite")
print(" test --coverage per-file line coverage over the test specs")
print(" test-pkg the package-manager suite (hermetic git repos, offline)")
print(" selfhost-test the self-hosting suite (correctness + bootstrap fixpoints)")
print(" test-tools the editor-toolchain suite")
print(" test-lsp drive bin/ludic-lsp over real LSP traffic (exit 2 = no server)")
print(" golden regenerate selfhost/golden/renders.sha256 (review with git diff)")
print("")
print("doc / lint checks (Ludic, no Python):")
print(" check-docs every ```ludic doc fence parses (or is marked skip/expect-error)")
print(" check-impl every implemented feature has a docs/language page")
print(" check-vocabulary the vocabulary is in sync across grammar / lexer / header / parser")
print(" lint-asset <file> validate one editor .json / .xml asset")
print(" docs-gen [--out DIR] generate the documentation site (default build/pages)")
print(" docs-check [DIR] coverage/integrity guard over a generated docs site")
print(" docs-palette [--check] regenerate emit_color.ludic + palette.json from the palette table")
print("")
print("release:")
print(" release [major|minor|patch] [--dry-run] [--publish]")
print(" cut a release: CHANGELOG + VERSION bump + tag (+ Forgejo release)")
print(" publish [vX.Y.Z] publish an already-tagged release; what CI runs on a tag push")
print(" changelog-section <ver> print that release's CHANGELOG.md section")
print(" changelog-render <ver> <date> <dir>")
print(" render a CHANGELOG section from a directory of changesets")
print("")
print("self-host internals:")
print(" selfhost-build [ludicc] [out] assemble + compile the self-host compiler")
print(" bootstrap the self-hosting fixpoint proof (seeded from bin/ludicc)")
print(" bootstrap-cfree rebuild the compiler from the seed with no C compiler")
print(" reseed regenerate selfhost/ludicc.seed.ll after a compiler change")
print(" game-build <ludicc> <game.ludic> <out> compile a Ludic game (headless)")
print(" sh-compile <ludicc> <in.ludic> <out> compile + link one .ludic file")
}
function dispatch(cmd: pointer) -> int {
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(2, "") == "--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(2, "bin/ludicc"), argn(3, "build/selfhost")) }
if (cmd == "sh-compile") {
if (arg_count() < 5) { err("usage: ludic-dev sh-compile <ludicc> <in.ludic> <out>\n"); exit(1) }
return cmd_sh_compile(arg(2), arg(3), arg(4))
}
if (cmd == "game-build") {
if (arg_count() < 5) { err("usage: ludic-dev game-build <ludicc> <game.ludic> <out>\n"); exit(1) }
return cmd_game_build(arg(2), arg(3), arg(4))
}
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") { usage(); return 0 }
return -1
}
entry {
if (arg_count() < 2) { usage(); exit(1) }
let cmd = arg(1)
let code = dispatch(cmd)
tmp_cleanup()
if code >= 0 { exit(code) }
err(`ludic-dev: unknown task '{cmd}'\n`)
usage()
exit(1)
}
}

View file

@ -1,17 +1,17 @@
# docgen.ludic — the Ludic documentation-site generator and its guards, ported
# off Python (gen.py / check.py / palette.py). Three x subcommands:
#
# ludic dev docs-palette emit selfhost/backend/stdlib/emit_color.ludic +
# ludic-dev docs-palette emit selfhost/backend/stdlib/emit_color.ludic +
# docs/language/colors/palette.json from the palette
# table below (the named-colour source of truth).
# ludic dev docs-gen [--out DIR] parse docs/language/** + docs/site/*, read the asset
# ludic-dev docs-gen [--out DIR] parse docs/language/** + docs/site/*, read the asset
# templates, emit every page + symbols.json + the
# highlighter + .nojekyll into DIR (default build/pages).
# ludic dev docs-check [DIR] the coverage/integrity guard over a generated site.
# ludic-dev docs-check [DIR] the coverage/integrity guard over a generated site.
#
# String work is over NUL-terminated byte buffers; helpers here mirror gen.py's
# html.escape / json.dumps / front-matter parsing closely enough to reproduce the
# Python generator's output byte-for-byte (verified against the oracle in ludic dev test).
# Python generator's output byte-for-byte (verified against the oracle in ludic-dev test).
# ---- a growable byte buffer (avoids O(n^2) string building) ------------------
property Sb { data: pointer = null, len: int = 0, cap: int = 0 }
@ -87,40 +87,6 @@ function lstrip_nl(s: pointer) -> pointer {
while s[i] == '\n' { i += 1 }
return sslice(s, i, slen(s))
}
# lowercase ASCII A-Z
function lower_ascii(s: pointer) -> pointer {
let n = slen(s)
let b = bytes(n + 1)
var i = 0
while i < n {
var c = s[i]
if c >= 'A' and c <= 'Z' { c += 32 }
b[i] = c
i += 1
}
b[n] = 0
return b
}
# Python str.title(): capitalise the first letter of each alpha run, lower the rest
function title_case(s: pointer) -> pointer {
let n = slen(s)
let b = bytes(n + 1)
var i = 0
var prev_alpha = false
while i < n {
var c = s[i]
let al = (c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z')
if al {
if prev_alpha { if c >= 'A' and c <= 'Z' { c += 32 } }
else { if c >= 'a' and c <= 'z' { c -= 32 } }
}
b[i] = c
prev_alpha = al
i += 1
}
b[n] = 0
return b
}
# lexicographic byte compare: <0, 0, >0
function str_cmp(a: pointer, b: pointer) -> int {
var i = 0
@ -563,7 +529,7 @@ function palette_emit_ludic(path: pointer) -> bool {
sb_puts(b, "# no allocation, identical codegen to writing the hex by hand. Unknown names are\n")
sb_puts(b, "# a compile error (color_lookup returns -1, which emit_expr reports).\n")
sb_puts(b, "#\n")
sb_puts(b, "# GENERATED by `ludic dev docs-palette` from the single source-of-truth palette table\n")
sb_puts(b, "# GENERATED by `ludic-dev docs-palette` from the single source-of-truth palette table\n")
sb_puts(b, "# in tools/ludic-cli/docgen.ludic. Edit the palette there and regenerate; do not hand-edit.\n")
sb_puts(b, "# ============================================================================\n")
sb_puts(b, "\n")
@ -620,11 +586,11 @@ function palette_emit_json(path: pointer) -> bool {
return write_file(path, sb_str(b))
}
# `ludic dev docs-palette` rewrites the two tracked outputs; `ludic dev docs-palette --check`
# `ludic-dev docs-palette` rewrites the two tracked outputs; `ludic-dev docs-palette --check`
# regenerates into scratch files and compares them to the tracked ones instead,
# so the drift guard judges the working tree, not git HEAD.
function cmd_docs_palette() -> int {
let check = arg_total() > 2 and arg_n(2) == "--check"
let check = arg_count() > 2 and arg(2) == "--check"
palette_init()
# guard: duplicate names
let seen = new []pointer
@ -640,8 +606,8 @@ function cmd_docs_palette() -> int {
if not palette_emit_ludic(ludic_out) { err("cannot write emit_color.ludic\n"); return 1 }
if not palette_emit_json(json_out) { err("cannot write palette.json\n"); return 1 }
if check {
if not shq(`cmp -s {ludic_out} selfhost/backend/stdlib/emit_color.ludic`) { err("emit_color.ludic drifted from the palette table (run: ludic dev docs-palette)\n"); return 1 }
if not shq(`cmp -s {json_out} docs/language/colors/palette.json`) { err("palette.json drifted from the palette table (run: ludic dev docs-palette)\n"); return 1 }
if not shq(`cmp -s {ludic_out} selfhost/backend/stdlib/emit_color.ludic`) { err("emit_color.ludic drifted from the palette table (run: ludic-dev docs-palette)\n"); return 1 }
if not shq(`cmp -s {json_out} docs/language/colors/palette.json`) { err("palette.json drifted from the palette table (run: ludic-dev docs-palette)\n"); return 1 }
}
print(`OK {string(len(pal_names))} colors`)
return 0

View file

@ -1,4 +1,4 @@
# docgen_check.ludic — `ludic dev docs-check [DIR]`: the coverage / integrity guard over
# docgen_check.ludic — `ludic-dev docs-check [DIR]`: the coverage / integrity guard over
# a generated docs site, ported from tools/docgen/check.py. Fails (exit 1) if the
# pages contract is broken, an inventory symbol lacks a source file or page, a
# token is documented on two pages, a namespace/section is split across dirs, or
@ -94,7 +94,7 @@ function drop_md(fn: pointer) -> pointer {
function cmd_docs_check() -> int {
var site = "build/pages"
if arg_total() >= 3 { site = arg_n(2) }
if arg_count() >= 3 { site = arg(2) }
let problems = new []pointer
let warnings = new []pointer

View file

@ -1,9 +1,9 @@
# docgen_gen.ludic — `ludic dev docs-gen`: the documentation-site generator, ported from
# docgen_gen.ludic — `ludic-dev docs-gen`: the documentation-site generator, ported from
# tools/docgen/gen.py. Parses docs/language/** (front-matter + bodies) and
# docs/site/*, reads the asset templates in tools/docgen/assets, and emits the
# whole pages-branch payload (per-symbol pages, namespace/color/api pages, the
# landing page, ludic-highlight.js, symbols.json, .nojekyll) into --out. Output
# is byte-for-byte identical to the Python generator (gated in ludic dev test).
# is byte-for-byte identical to the Python generator (gated in ludic-dev test).
#
# Shared helpers (Sb, hesc/hescattr, the JSON writer, codeify, string utils)
# live in docgen.ludic; the JSON reader (json_parse/j_get) in json.ludic.
@ -951,11 +951,11 @@ function render_highlighter(sym: JVal) -> pointer {
function cmd_docs_gen() -> int {
var out = "build/pages"
var i = 2
while i < arg_total() {
let a = arg_n(i)
while i < arg_count() {
let a = arg(i)
if a == "--out" {
i += 1
if i < arg_total() { out = arg_n(i) }
if i < arg_count() { out = arg(i) }
}
i += 1
}

View file

@ -1,5 +1,5 @@
# lsp_test.ludic — drive bin/ludic-lsp over real LSP traffic against the real
# source tree (`ludic dev test-lsp`; replaces tools/test-lsp.py). Every assertion is
# source tree (`ludic-dev test-lsp`; replaces tools/test-lsp.py). Every assertion is
# about a feature an editor actually calls: if this passes, VS Code, JetBrains,
# Neovim and the rest are exercising code paths that have been checked, because
# they all speak to this one binary.

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) {

View file

@ -539,8 +539,8 @@ function set_require(module: pointer, ver: pointer) -> void {
# ludic add <module>[@version] — add/update a dependency then install
function cmd_pkg_add() -> int {
if arg_total() < 3 { err("usage: ludic add <module>[@version]\n"); return 1 }
let spec = split_spec(arg_n(2))
if arg_count() < 3 { err("usage: ludic add <module>[@version]\n"); return 1 }
let spec = split_spec(arg(2))
let module = spec[0]
var ver = spec[1]
if slen(ver) == 0 {

View file

@ -1,16 +1,14 @@
# prelude.ludic — the shared runtime for `ludic`, the command-line interface.
# prelude.ludic — the shared runtime for both command-line programs: `ludic`,
# which a user of the language runs, and `ludic-dev`, the contributor tool that
# builds the toolchain itself. Both are native binaries written in Ludic and
# compiled by Ludic, driving clang, the compiler and the unix tools through
# `run`. This fragment is the tiny standard library their commands lean on:
# process control, file IO, strings and a colored PASS/FAIL test harness. It
# carries no ECS, so both link as plain CLI programs.
#
# `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.
# `ludic`'s commands work from any directory, against whatever toolchain is
# installed. `ludic-dev`'s tasks run relative to the current directory and expect
# the toolchain repo root.
# ---- file IO ----------------------------------------------------------------
@ -53,7 +51,7 @@ 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
# (`$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
@ -175,17 +173,11 @@ function report() -> int {
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 }
# a positional argument, or a default when it is absent
function argn(i: int, dflt: pointer) -> pointer {
if (i < arg_count()) { return arg(i) }
return dflt
}
# ---- the toolchain install ---------------------------------------------------
#
@ -249,8 +241,121 @@ function tool(name: pointer) -> pointer {
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.
# true in a checkout of the toolchain repo itself, where a seed is present to
# assemble the compiler from.
function in_toolchain_repo() -> bool {
return file_exists("selfhost/ludicc.seed.ll") and file_exists("tools/ludic-cli/main.ludic")
}
# ---- strings ----------------------------------------------------------------
# Shared by every command, so they live here rather than in whichever file
# happened to need them first.
# length of a NUL-terminated buffer
function slen(s: pointer) -> int { var n = 0; while s[n] != 0 { n += 1 }; return n }
# a fresh NUL-terminated copy of s[start .. end) (end exclusive)
function sslice(s: pointer, start: int, end: int) -> pointer {
if end < start { return "" }
let n = end - start
let b = bytes(n + 1)
var i = 0
while i < n { b[i] = s[start + i]; i += 1 }
b[n] = 0
return b
}
# index of the first byte of `needle` in `hay` at or after `from`, else -1
function s_index(hay: pointer, needle: pointer, from: int) -> int {
let hn = slen(hay)
let nn = slen(needle)
if nn == 0 { return from }
var i = from
while i + nn <= hn {
var j = 0
while j < nn and hay[i + j] == needle[j] { j += 1 }
if j == nn { return i }
i += 1
}
return -1
}
function s_contains(hay: pointer, needle: pointer) -> bool { return s_index(hay, needle, 0) >= 0 }
# does `hay` contain `needle` exactly at position `at`?
function s_starts_at(hay: pointer, at: int, needle: pointer) -> bool {
let nn = slen(needle)
var i = 0
while i < nn { if hay[at + i] != needle[i] { return false }; i += 1 }
return true
}
# does `s` (a whole line) begin with `pre`?
function s_starts(s: pointer, pre: pointer) -> bool {
let pn = slen(pre)
var i = 0
while i < pn { if s[i] != pre[i] { return false }; i += 1 }
return true
}
# is byte c an ASCII space/tab?
function is_ws(c: int) -> bool { return c == ' ' or c == '\t' }
# the substring from `start` up to the next '\n' (or end)
function line_at(s: pointer, start: int) -> pointer {
var e = start
while s[e] != 0 and s[e] != '\n' { e += 1 }
return sslice(s, start, e)
}
# trim leading/trailing ASCII whitespace (space, tab, cr, nl)
function s_trim(s: pointer) -> pointer {
let n = slen(s)
var a = 0
while a < n and (is_ws(s[a]) or s[a] == '\n' or s[a] == '\r') { a += 1 }
var b = n
while b > a and (is_ws(s[b - 1]) or s[b - 1] == '\n' or s[b - 1] == '\r') { b -= 1 }
return sslice(s, a, b)
}
# lowercase ASCII A-Z
function lower_ascii(s: pointer) -> pointer {
let n = slen(s)
let b = bytes(n + 1)
var i = 0
while i < n {
var c = s[i]
if c >= 'A' and c <= 'Z' { c += 32 }
b[i] = c
i += 1
}
b[n] = 0
return b
}
# Python str.title(): capitalise the first letter of each alpha run, lower the rest
function title_case(s: pointer) -> pointer {
let n = slen(s)
let b = bytes(n + 1)
var i = 0
var prev_alpha = false
while i < n {
var c = s[i]
let al = (c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z')
if al {
if prev_alpha { if c >= 'A' and c <= 'Z' { c += 32 } }
else { if c >= 'a' and c <= 'z' { c -= 32 } }
}
b[i] = c
prev_alpha = al
i += 1
}
b[n] = 0
return b
}
# ---- the C toolchain --------------------------------------------------------
# clang assembles the emitted IR and drives the linker. -Wno-override-module for
# the same reason the compiler itself passes it (see selfhost/main.ludic): the IR
# names no target triple, so clang substitutes the host's and warns every time.
function cc() -> pointer { return getenv_or("LUDIC_CC", "clang") + " -Wno-override-module" }

View file

@ -131,11 +131,11 @@ function template_readme(name: pointer) -> pointer {
# ludic new <name> — scaffold a project that builds and runs as it stands.
function cmd_new() -> int {
if arg_total() < 3 {
if arg_count() < 3 {
err("usage: ludic new <name>\n")
return 1
}
let name = arg_n(2)
let name = arg(2)
if file_exists(name) {
err(`ludic new: {name} already exists\n`)
return 1
@ -173,12 +173,12 @@ function parse_build_args(start: int) -> pointer {
g_out = ""
g_save = false
var ai = start
while ai < arg_total() {
let a = arg_n(ai)
while ai < arg_count() {
let a = arg(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 == "-o" { ai += 1; if ai < arg_count() { g_out = arg(ai) } }
else if a[0] != '-' { src = a }
ai += 1
}
@ -245,7 +245,7 @@ function test_files() -> []pointer {
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 }
while ai < arg_count() { if arg(ai)[0] != '-' { push(files, arg(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")
@ -298,7 +298,7 @@ function strip_ext(p: pointer) -> pointer {
function cmd_fmt() -> int {
var args = ""
var ai = 2
while ai < arg_total() { args = `{args} {arg_n(ai)}`; ai += 1 }
while ai < arg_count() { args = `{args} {arg(ai)}`; ai += 1 }
if args == "" {
let found = capture_line("find src tests -name '*.ludic' 2>/dev/null | sort")
if found == "" {
@ -315,7 +315,7 @@ function cmd_fmt() -> int {
function cmd_lsp() -> int {
var args = ""
var ai = 2
while ai < arg_total() { args = `{args} {arg_n(ai)}`; ai += 1 }
while ai < arg_count() { args = `{args} {arg(ai)}`; ai += 1 }
return sh(`exec {tool("ludic-lsp")}{args}`)
}
@ -377,6 +377,25 @@ function cmd_doctor() -> int {
return 1
}
# ludic version — report the version of the toolchain this CLI belongs to.
#
# It asks the compiler (proving that path works), addressed through ludic_home()
# rather than as a relative bin/ludicc: `ludic version` is run from a user's
# project, not from the toolchain's own directory, and looking beside the current
# directory found nothing there and reported "(version unknown)" for a perfectly
# good install.
function cmd_version() -> int {
let cc = ludicc()
if is_exec(cc) {
run(`{cc} --version`)
return 0
}
let v = read_file(`{ludic_home()}VERSION`)
if v != null { print(`ludic {s_trim(v)}`); return 0 }
print("ludic (version unknown)")
return 0
}
# ---- ludic upgrade ----------------------------------------------------------
function install_url() -> pointer { return getenv_or("LUDIC_INSTALL_URL", "https://workshopsoft.pages.workshopsoft.io/ludic/install.sh") }
@ -386,7 +405,7 @@ function install_url() -> pointer { return getenv_or("LUDIC_INSTALL_URL", "https
# drift from a fresh install.
function cmd_upgrade() -> int {
var ver = ""
if arg_total() >= 3 { ver = arg_n(2) }
if arg_count() >= 3 { ver = arg(2) }
if not shq("command -v curl >/dev/null 2>&1") {
err("ludic upgrade: needs curl\n")
return 1

View file

@ -1,13 +1,13 @@
# release.ludic — versioning + release cutting for the toolchain.
#
# ludic version print the toolchain version (from the VERSION file)
# ludic dev release [level] cut a release: aggregate changes/ into CHANGELOG.md,
# ludic-dev release [level] cut a release: aggregate changes/ into CHANGELOG.md,
# bump VERSION, commit, and tag vX.Y.Z. `level` is
# major|minor|patch; omitted, it is derived from the
# highest `bump:` among the pending changesets.
# ludic dev release [level] --dry-run render the changelog section to stdout and
# ludic-dev release [level] --dry-run render the changelog section to stdout and
# stop: nothing is written, committed, tagged or pushed.
# ludic dev release [level] --publish ...then push main + the tag and create a
# ludic-dev release [level] --publish ...then push main + the tag and create a
# Forgejo release with source + toolchain tarballs.
# Needs FORGEJO_TOKEN in the environment.
#
@ -49,24 +49,6 @@ function compute_next(cur: pointer, level: pointer) -> pointer {
return `{maj}.{min}.{p2}`
}
# ludic version — report the version of the toolchain this CLI belongs to.
#
# It asks the compiler (proving that path works), addressed through ludic_home()
# rather than as a relative bin/ludicc: `ludic version` is run from a user's
# project, not from the toolchain's own directory, and looking beside the current
# directory found nothing there and reported "(version unknown)" for a perfectly
# good install.
function cmd_version() -> int {
let cc = ludicc()
if is_exec(cc) {
run(`{cc} --version`)
return 0
}
let v = read_file(`{ludic_home()}VERSION`)
if v != null { print(`ludic {s_trim(v)}`); return 0 }
print(`ludic {read_version_or("(version unknown)")}`)
return 0
}
# ---- changelog rendering ----------------------------------------------------
#
@ -241,16 +223,16 @@ function build_section(ver: pointer) -> void {
write_file(tmp_path("rel_section.md"), render_section(ver, date, "changes"))
}
# ludic dev changelog-render <version> <date> <dir> — print the CHANGELOG section that
# ludic-dev changelog-render <version> <date> <dir> — print the CHANGELOG section that
# `dir`'s changesets would produce. Used to re-render the sections of releases
# cut before the renderer preserved markdown structure: check the changesets out
# of the tag's parent commit, point this at them, and splice the result back in.
function cmd_changelog_render() -> int {
if arg_total() < 5 {
err("usage: ludic dev changelog-render <version> <date> <changesets-dir>\n")
if arg_count() < 5 {
err("usage: ludic-dev changelog-render <version> <date> <changesets-dir>\n")
return 1
}
out(render_section(arg_n(2), arg_n(3), arg_n(4)))
out(render_section(arg(2), arg(3), arg(4)))
return 0
}
@ -258,7 +240,7 @@ function cmd_changelog_render() -> int {
# release section (or at the end of the header if this is the first release).
function prepend_changelog() -> void {
if not file_exists("CHANGELOG.md") {
run("printf '# Changelog\n\nAll notable changes to the Ludic toolchain, newest first. Generated from the\nchangesets under changes/ by ludic dev release; do not edit released sections by hand.\n\n' > CHANGELOG.md")
run("printf '# Changelog\n\nAll notable changes to the Ludic toolchain, newest first. Generated from the\nchangesets under changes/ by ludic-dev release; do not edit released sections by hand.\n\n' > CHANGELOG.md")
}
let ln = capture_line("grep -n '^## ' CHANGELOG.md | head -1 | cut -d: -f1")
if (ln == "") {
@ -297,11 +279,11 @@ function changelog_section(ver: pointer) -> pointer {
return sslice(text, start, n)
}
# ludic dev changelog-section <version> — print that release's CHANGELOG section.
# ludic-dev changelog-section <version> — print that release's CHANGELOG section.
function cmd_changelog_section() -> int {
if arg_total() < 3 { err("usage: ludic dev changelog-section <version>\n"); return 1 }
let sec = changelog_section(arg_n(2))
if slen(sec) == 0 { err(`changelog-section: no section for v{arg_n(2)} in CHANGELOG.md\n`); return 1 }
if arg_count() < 3 { err("usage: ludic-dev changelog-section <version>\n"); return 1 }
let sec = changelog_section(arg(2))
if slen(sec) == 0 { err(`changelog-section: no section for v{arg(2)} in CHANGELOG.md\n`); return 1 }
out(sec)
return 0
}
@ -343,7 +325,7 @@ function build_artifacts(ver: pointer) -> bool {
let stage = `{tmp_dir()}/{name}`
run(`rm -rf {stage} && mkdir -p {stage}/bin`)
if not shq(`cp bin/ludic bin/ludicc bin/ludic-fmt bin/ludic-lsp {stage}/bin/`) {
err("release: the toolchain is not built (run: ludic dev build)\n")
err("release: the toolchain is not built (run: ludic-dev build)\n")
return false
}
run(`cp -R runtime {stage}/runtime`)
@ -366,10 +348,10 @@ function build_artifacts(ver: pointer) -> bool {
return true
}
# ludic dev publish [vX.Y.Z] — publish an already-tagged release: build the artifacts,
# ludic-dev publish [vX.Y.Z] — publish an already-tagged release: build the artifacts,
# take the notes from that version's CHANGELOG section, and create the Forgejo
# release. Defaults to the version in VERSION. This is what CI runs on a tag
# push, and what `ludic dev release --publish` calls once it has tagged.
# push, and what `ludic-dev release --publish` calls once it has tagged.
function publish_tag(ver: pointer) -> int {
if getenv_or("FORGEJO_TOKEN", "") == "" { err("publish: set FORGEJO_TOKEN (a Forgejo access token)\n"); return 1 }
let body = tmp_path("rel_notes.md")
@ -386,8 +368,8 @@ function publish_tag(ver: pointer) -> int {
function cmd_publish() -> int {
var ver = read_version_or("")
if arg_total() >= 3 {
var a = arg_n(2)
if arg_count() >= 3 {
var a = arg(2)
if s_starts(a, "v") { a = sslice(a, 1, slen(a)) }
ver = a
}
@ -409,8 +391,8 @@ function cmd_release() -> int {
var publish = false
var dry = false
var ai = 2
while ai < arg_total() {
let a = arg_n(ai)
while ai < arg_count() {
let a = arg(ai)
if (a == "--publish") { publish = true }
else { if (a == "--dry-run") { dry = true }
else { if (a == "major") or (a == "minor") or (a == "patch") { level = a }
@ -437,7 +419,7 @@ function cmd_release() -> int {
let sec = read_file(tmp_path("rel_section.md"))
if sec != null { out(sec) }
print("")
print(` dry run — nothing written. cut it with: ludic dev release {level}`)
print(` dry run — nothing written. cut it with: ludic-dev release {level}`)
return 0
}
prepend_changelog()
@ -452,7 +434,7 @@ function cmd_release() -> int {
print(` committed + tagged v{ver}`)
if publish { return publish_release(ver) }
print(` local release ready. publish with: FORGEJO_TOKEN=… ludic dev release {level} --publish`)
print(` local release ready. publish with: FORGEJO_TOKEN=… ludic-dev release {level} --publish`)
print(` (or push: git push origin HEAD && git push origin v{ver})`)
return 0
}

View file

@ -6,10 +6,6 @@
# now lives here once, in selfhost_frags().
# the C toolchain driver (clang) that assembles and links the emitted IR
# The C toolchain used to assemble and link IR. -Wno-override-module for the
# same reason the compiler itself passes it (see selfhost/main.ludic): the IR
# names no target triple, so clang substitutes the host's and warns every time.
function cc() -> pointer { return getenv_or("LUDIC_CC", "clang") + " -Wno-override-module" }
# the self-host compiler's source fragments, in link order. This is THE list;
# the old scripts each carried their own copy.
@ -95,21 +91,9 @@ function line_count(path: pointer) -> pointer {
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.
function ensure_ludicc() -> void {
if not in_toolchain_repo() { return }
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("ludic: could not assemble the seed\n"); exit(1)
}
}
}
# ---- selfhost-build: assemble + compile the self-host compiler ---------------
# usage: ludic dev selfhost-build [ludicc] [outbin] (defaults: bin/ludicc, build/selfhost)
# usage: ludic-dev selfhost-build [ludicc] [outbin] (defaults: bin/ludicc, build/selfhost)
function cmd_selfhost_build(lc: pointer, outbin: pointer) -> int {
run("mkdir -p build")
let src = "build/selfhost.ludic"
@ -123,7 +107,7 @@ function cmd_selfhost_build(lc: pointer, outbin: pointer) -> int {
}
# ---- sh-compile: compile one .ludic with a given selfhost binary and link ----
# usage: ludic dev sh-compile <selfhost-binary> <input.ludic> <output-binary>
# usage: ludic-dev sh-compile <selfhost-binary> <input.ludic> <output-binary>
function cmd_sh_compile(shbin: pointer, in: pointer, outbin: pointer) -> int {
if not shq(`{shbin} {in} > {outbin}.ll`) { return 1 }
if not shq(`{cc()} {outbin}.ll -o {outbin}`) { return 1 }
@ -144,7 +128,7 @@ function game_build_ok(shbin: pointer, game: pointer, outbin: pointer) -> bool {
return true
}
# usage: ludic dev game-build <selfhost-binary> <game.ludic> <out-binary>
# usage: ludic-dev game-build <selfhost-binary> <game.ludic> <out-binary>
function cmd_game_build(shbin: pointer, game: pointer, outbin: pointer) -> int {
if game_build_ok(shbin, game, outbin) { print(`built {outbin}`); return 0 }
print("build failed:")
@ -194,7 +178,7 @@ function cmd_bootstrap_cfree() -> int {
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: ludic dev reseed)")
print(" out.ll != seed.ll (seed is stale — regenerate with: ludic-dev reseed)")
out(capture("diff build/cfree/out.ll selfhost/ludicc.seed.ll | head"))
return 1
}
@ -203,7 +187,7 @@ function cmd_bootstrap_cfree() -> int {
# 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
# `ludic dev bootstrap-cfree` afterwards.
# `ludic-dev bootstrap-cfree` afterwards.
function cmd_reseed() -> int {
run("mkdir -p build/cfree")
if not write_selfhost_src("build/cfree/selfhost.ludic") { print("FAIL: write source"); return 1 }

View file

@ -19,7 +19,7 @@ function sh_case(name: pointer, exp: pointer) -> void {
# 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).
# `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 }
@ -32,13 +32,13 @@ function game_case(path: pointer, keys: pointer) -> void {
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 (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.
# 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}`) {
@ -117,12 +117,12 @@ function cmd_selfhost_test() -> int {
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)") }
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`)) }
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()
}

View file

@ -91,6 +91,16 @@ function install_layout_case() -> void {
if ver != `ludic {s_trim(read_file("VERSION"))}` {
bad2("an installed `ludic version` reports the install's VERSION", `got [{ver}]`); return
}
# and the shipped CLI carries none of the contributor tasks: `ludic dev …` is
# not a command there, it is a pointer at the tool that has them.
let devlog = `{work}/dev.out`
let devrc = sh(`cd {work}/demo && {envp} ludic dev build > {devlog} 2>&1`)
var devout = read_file(devlog)
if devout == null { devout = "" }
if devrc == 0 or not s_contains(devout, "ludic-dev") {
bad2("an installed `ludic` must not carry the toolchain's own tasks", `rc {string(devrc)}, said [{s_trim(devout)}]`)
return
}
ok(lbl)
}
@ -161,7 +171,7 @@ function panic_case() -> void {
else { bad2("panic", `rc={string(rc)} err=[{msg}]`) }
}
# issue #45: `bin/ludic dev test --coverage`. Compile each test-spec with `--coverage`,
# issue #45: `bin/ludic-dev test --coverage`. Compile each test-spec with `--coverage`,
# run it with LUDIC_COVERAGE pointed at a per-file dump, then aggregate the dumps
# into a clean per-file line-coverage report. The instrumentation is flag-gated,
# so this reuses the same seed-built bin/ludicc the rest of the suite does.
@ -211,7 +221,7 @@ function cmd_test_coverage() -> int {
}
COV_COVERED = 0
COV_TOTAL = 0
print("== line coverage (bin/ludic dev test --coverage) ==")
print("== line coverage (bin/ludic-dev test --coverage) ==")
cov_one("library/coverage", `{tmp_dir()}/cov_out`)
cov_one("library/testing", `{tmp_dir()}/cov_out`)
var pct = 100
@ -244,11 +254,11 @@ function cmd_dev_test() -> int {
game_case("games/chronorift", "ddddwwwwaassK")
print("== self-hosting and the bootstrap fixpoints ==")
run(`bin/ludic dev selfhost-test > {tmp_dir()}/sh.out 2>&1`)
run(`bin/ludic-dev selfhost-test > {tmp_dir()}/sh.out 2>&1`)
if shq(`grep -q 'gen2.ll == gen3.ll' {tmp_dir()}/sh.out`) { ok("the compiler reproduces itself (fixpoint)") } else { bad("fixpoint") }
if shq(`grep -q 'no C compiler' {tmp_dir()}/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_dir()}/sh.out`)
ok(`self-host suite: {corr} checks passed (see: ludic dev selfhost-test)`)
ok(`self-host suite: {corr} checks passed (see: ludic-dev selfhost-test)`)
print("== documented syntax stays compilable (guards against spec/compiler drift) ==")
qsmoke("lang/qdecl")
@ -405,9 +415,9 @@ function cmd_dev_test() -> int {
print("== the compiler and the CLI (ludicc / ludic) ==")
# ludicc comes out of the IR seed with clang alone; the CLI is then compiled
# by it, from Ludic.
if shq(`bin/ludic dev build-cli > {tmp_dir()}/cli.out 2>&1`) and is_exec("bin/ludicc") {
ok("ludic dev build-cli builds bin/ludicc from the seed (clang-only)")
} else { bad2("ludic dev build-cli", capture_line(`tail -1 {tmp_dir()}/cli.out`)) }
if shq(`bin/ludic-dev build-cli > {tmp_dir()}/cli.out 2>&1`) and is_exec("bin/ludicc") {
ok("ludic-dev build-cli builds bin/ludicc from the seed (clang-only)")
} else { bad2("ludic-dev build-cli", capture_line(`tail -1 {tmp_dir()}/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) — that path is
@ -456,27 +466,27 @@ function cmd_dev_test() -> int {
# store + ludic_modules/ links + build, run as its own hermetic suite (it
# stands up throwaway git repos, so like selfhost-test it runs as a sub-process
# and this stage checks its footer).
print("== package manager (ludic dev test-pkg) ==")
run(`bin/ludic dev test-pkg > {tmp_dir()}/pkg.out 2>&1`)
print("== package manager (ludic-dev test-pkg) ==")
run(`bin/ludic-dev test-pkg > {tmp_dir()}/pkg.out 2>&1`)
if shq(`grep -q '0 failed' {tmp_dir()}/pkg.out`) {
let pc = capture_line(`grep -c PASS {tmp_dir()}/pkg.out`)
ok(`package-manager suite: {pc} checks passed (see: ludic dev test-pkg)`)
} else { bad2("ludic dev test-pkg", capture_line(`grep -i fail {tmp_dir()}/pkg.out | head -1`)) }
ok(`package-manager suite: {pc} checks passed (see: ludic-dev test-pkg)`)
} else { bad2("ludic-dev test-pkg", capture_line(`grep -i fail {tmp_dir()}/pkg.out | head -1`)) }
# docs site generator + guard, in Ludic (no Python). docs-gen emits the whole
# pages payload; docs-check is its coverage/integrity guard; docs-palette is
# the named-colour source of truth (its --check mode regenerates the tracked
# emit_color.ludic + palette.json into scratch files and compares).
print("== docs site (Ludic generator, no Python) ==")
if shq(`bin/ludic dev docs-gen --out {tmp_dir()}/docs > {tmp_dir()}/docs.out 2>&1`) {
ok("ludic dev docs-gen -> the pages-branch payload")
} else { bad2("ludic dev docs-gen", capture_line(`tail -1 {tmp_dir()}/docs.out`)) }
if shq(`bin/ludic dev docs-check {tmp_dir()}/docs > {tmp_dir()}/docs_chk.out 2>&1`) {
ok("ludic dev docs-check passes on the generated site")
} else { bad2("ludic dev docs-check", capture_line(`tail -1 {tmp_dir()}/docs_chk.out`)) }
if shq(`bin/ludic dev docs-palette --check > {tmp_dir()}/pal.out 2>&1`) {
ok("ludic dev docs-palette regenerates emit_color.ludic + palette.json byte-identically")
} else { bad2("ludic dev docs-palette --check", capture_line(`tail -1 {tmp_dir()}/pal.out`)) }
if shq(`bin/ludic-dev docs-gen --out {tmp_dir()}/docs > {tmp_dir()}/docs.out 2>&1`) {
ok("ludic-dev docs-gen -> the pages-branch payload")
} else { bad2("ludic-dev docs-gen", capture_line(`tail -1 {tmp_dir()}/docs.out`)) }
if shq(`bin/ludic-dev docs-check {tmp_dir()}/docs > {tmp_dir()}/docs_chk.out 2>&1`) {
ok("ludic-dev docs-check passes on the generated site")
} else { bad2("ludic-dev docs-check", capture_line(`tail -1 {tmp_dir()}/docs_chk.out`)) }
if shq(`bin/ludic-dev docs-palette --check > {tmp_dir()}/pal.out 2>&1`) {
ok("ludic-dev docs-palette regenerates emit_color.ludic + palette.json byte-identically")
} else { bad2("ludic-dev docs-palette --check", capture_line(`tail -1 {tmp_dir()}/pal.out`)) }
return report()
}

View file

@ -0,0 +1,44 @@
# toolchain.ludic — building the toolchain's own binaries. Contributor-only:
# this is what `ludic-dev build` does, and none of it ships to a user.
# compile a Ludic source to a native binary in bin/ via ludicc + clang (-O2).
# Returns true on success. Used for the CLI, the contributor tool and the editor
# tools — every binary the toolchain builds of itself.
function build_tool(name: pointer, src: pointer) -> 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/ludic 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.
function cmd_dev_build_cli() -> int {
run("mkdir -p bin build")
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("ludic: the seed did not assemble\n"); return 1 }
run("chmod +x bin/ludicc")
print("done. built bin/ludicc")
return 0
}
# ---- build: the whole toolchain --------------------------------------------
# bin/ludic is what a user installs; bin/ludic-dev is this tool, which exists
# only in a checkout and is never part of a release.
function cmd_dev_build() -> int {
if cmd_dev_build_cli() != 0 { return 1 }
print("ludicc: tools/ludic-cli/main.ludic -> bin/ludic (the CLI users install)")
if not build_tool("ludic", "tools/ludic-cli/main.ludic") { return 1 }
print("ludicc: tools/ludic-cli/dev.ludic -> bin/ludic-dev (this tool, rebuilding itself)")
if not build_tool("ludic-dev", "tools/ludic-cli/dev.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, ludic-dev, ludic-fmt, ludic-lsp)")
return 0
}

View file

@ -27,12 +27,12 @@ function sync_vscode_grammar() -> void {
# is `flag` present anywhere in argv?
function has_flag(flag: pointer) -> bool {
var i = 2
while i < arg_total() { if (arg_n(i) == flag) { return true }; i += 1 }
while i < arg_count() { if (arg(i) == flag) { return true }; i += 1 }
return false
}
# ---- tools: build the editor toolchain --------------------------------------
# usage: ludic dev tools [--install] [--test]
# usage: ludic-dev tools [--install] [--test]
function cmd_tools() -> int {
ensure_ludicc()
print("ludicc: tools/ludic-tools/fmt.ludic -> bin/ludic-fmt (Ludic -> LLVM IR -> binary, no C)")
@ -57,8 +57,8 @@ function cmd_test_tools() -> int {
PASS = 0
FAIL = 0
let fmt = "bin/ludic-fmt"
if not is_exec(fmt) { err("bin/ludic-fmt missing — run: ludic dev tools\n"); return 2 }
if not is_exec("bin/ludic-lsp") { err("bin/ludic-lsp missing — run: ludic dev tools\n"); return 2 }
if not is_exec(fmt) { err("bin/ludic-fmt missing — run: ludic-dev tools\n"); return 2 }
if not is_exec("bin/ludic-lsp") { err("bin/ludic-lsp missing — run: ludic-dev tools\n"); return 2 }
print("formatter")
@ -129,7 +129,7 @@ function cmd_test_tools() -> int {
# features (go-to-def, find-usages, workspace symbols) can't resolve. Gate the
# end-to-end protocol test on Darwin until the directory walk is portable.
if is_darwin() {
let lrc = sh("bin/ludic dev test-lsp")
let lrc = sh("bin/ludic-dev test-lsp")
if lrc == 0 { ok("language server protocol") }
else if lrc == 2 { skip("language server protocol (bin/ludic-lsp not built)") }
else { bad("language server protocol") }
@ -160,10 +160,10 @@ function cmd_test_tools() -> int {
# 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: ludic dev tools to resync") }
} else { bad2("vscode grammar copies have drifted", "run: ludic-dev 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 ludic dev test-tools
# IntelliJ SDK). One env var away: LUDIC_TEST_JETBRAINS=1 ludic-dev 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") }
@ -177,17 +177,17 @@ function cmd_test_tools() -> int {
# TextMate grammar, the Kotlin lexer) is the failure mode this layout prevents.
# The check is written in Ludic (tools/ludic-cli/checks.ludic) and runs through x — no
# Python in the loop.
if shq("bin/ludic dev check-vocabulary 2>/dev/null") { ok("vocabulary in sync across grammar/lexer/header") } else { bad("vocabulary drifted") }
if shq("bin/ludic-dev check-vocabulary 2>/dev/null") { 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.
# Ported to Ludic; runs through x.
if shq("bin/ludic dev check-impl > /dev/null 2>&1") { ok("docs cover every implemented feature") } else { bad("docs drifted from the implementation") }
if shq("bin/ludic-dev check-impl > /dev/null 2>&1") { ok("docs cover every implemented feature") } else { bad("docs drifted from the implementation") }
# every ```ludic fence in the docs must parse (or be marked skip/expect-error),
# so a documented snippet can't drift from what the compiler accepts.
if shq("bin/ludic dev check-docs > /dev/null 2>&1") { ok("every doc fence parses") } else { bad2("a doc fence drifted", "run: ludic dev check-docs") }
if shq("bin/ludic-dev check-docs > /dev/null 2>&1") { ok("every doc fence parses") } else { bad2("a doc fence drifted", "run: ludic-dev check-docs") }
return report()
}
@ -195,9 +195,9 @@ function cmd_test_tools() -> int {
# JSON/XML validity, checked by the Ludic validators in checks.ludic (no Python).
function test_json(path: pointer) -> void {
let bn = capture_line(`basename {path}`)
if shq(`bin/ludic dev lint-asset {path}`) { ok(`valid JSON: {bn}`) } else { bad(`invalid JSON: {path}`) }
if shq(`bin/ludic-dev lint-asset {path}`) { ok(`valid JSON: {bn}`) } else { bad(`invalid JSON: {path}`) }
}
function test_xml(path: pointer) -> void {
let bn = capture_line(`basename {path}`)
if shq(`bin/ludic dev lint-asset {path}`) { ok(`valid XML: {bn}`) } else { bad(`invalid XML: {path}`) }
if shq(`bin/ludic-dev lint-asset {path}`) { ok(`valid XML: {bn}`) } else { bad(`invalid XML: {path}`) }
}