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

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

Installing is now one command:

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

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

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

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

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

View file

@ -24,7 +24,7 @@ labels:
## Environment ## Environment
- Command used (e.g. `bin/x app foo.ludic --headless`): - Command used (e.g. `bin/ludic build foo.ludic --headless`):
- Target (native macOS / headless / web-wasm): - Target (native macOS / headless / web-wasm):
- Commit (`git rev-parse --short HEAD`): - Commit (`git rev-parse --short HEAD`):
- OS / arch: - OS / arch:

View file

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

View file

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

View file

@ -10,7 +10,7 @@ on:
paths: paths:
- 'docs/**' - 'docs/**'
- 'tools/docgen/**' - 'tools/docgen/**'
- 'tools/x/**' - 'tools/ludic-cli/**'
- '.forgejo/workflows/docs.yml' - '.forgejo/workflows/docs.yml'
workflow_dispatch: {} workflow_dispatch: {}
@ -33,8 +33,8 @@ jobs:
# "Waiting" forever with "no online runner found matching this label". # "Waiting" forever with "no online runner found matching this label".
runs-on: docker runs-on: docker
# The generator is now Ludic, so this builds the toolchain from its IR seed # The generator is now Ludic, so this builds the toolchain from its IR seed
# (clang assembles the seed into bin/ludicc, which compiles bin/x) exactly # (clang assembles the seed into bin/ludicc, which compiles bin/ludic) exactly
# like the ci workflow, then runs `x 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. # for the clone + publish; clang-16 is the only extra the bootstrap needs.
container: node:20-bookworm container: node:20-bookworm
steps: steps:
@ -63,9 +63,9 @@ jobs:
export LUDIC_HOME="$(pwd)" export LUDIC_HOME="$(pwd)"
mkdir -p bin mkdir -p bin
clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc
bin/ludicc tools/x/main.ludic -o bin/x bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic
bin/x docs-gen --out ../public bin/ludic dev docs-gen --out ../public
bin/x docs-check ../public bin/ludic dev docs-check ../public
cd .. cd ..
echo "--- generated files ---" echo "--- generated files ---"
ls -la public ls -la public

View file

@ -1,6 +1,6 @@
name: release name: release
# Cutting a release is `x 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 # 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 # 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 # the time, with no checksums and nothing proving the tagged tree even passed its
@ -71,15 +71,15 @@ jobs:
set -eu set -eu
mkdir -p bin mkdir -p bin
clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc
bin/ludicc tools/x/main.ludic -o bin/x bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic
bin/x build bin/ludic dev build
- name: The tagged tree must pass its own suites - name: The tagged tree must pass its own suites
run: | run: |
set -eu set -eu
bin/x test bin/ludic dev test
bin/x test-tools bin/ludic dev test-tools
bin/x bootstrap-cfree bin/ludic dev bootstrap-cfree
- name: Publish the release - name: Publish the release
env: env:
@ -91,9 +91,9 @@ jobs:
echo "::error::No FORGEJO_TOKEN secret; cannot create the release." echo "::error::No FORGEJO_TOKEN secret; cannot create the release."
exit 1 exit 1
fi fi
# x 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, # toolchain, SHA256SUMS), takes the notes from the CHANGELOG section,
# and creates the release. Re-running it only adds missing assets, so # and creates the release. Re-running it only adds missing assets, so
# a maintainer can afterwards attach the macOS toolchain from a Mac # a maintainer can afterwards attach the macOS toolchain from a Mac
# with the same command. # with the same command.
bin/x publish "$TAG" bin/ludic dev publish "$TAG"

14
.gitignore vendored
View file

@ -1,6 +1,6 @@
# Generated build tree: LLVM IR, objects, compiled apps, the headless render # Generated build tree: LLVM IR, objects, compiled apps, the headless render
# (build/out.ppm) and the docs site all land under build/ (see `bin/x build` / # (build/out.ppm) and the docs site all land under build/ (see `bin/ludic dev build` /
# `bin/x clean`). Root-anchored so a source dir named "build" elsewhere is never # `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. # accidentally ignored. Nothing is written to the repo root any more.
/build/ /build/
@ -9,14 +9,14 @@
# packaged plugin .zip are local-only build inputs/outputs. # packaged plugin .zip are local-only build inputs/outputs.
*.zip *.zip
# the toolchain binaries (ludicc, ludic, x, ludic-fmt, ludic-lsp) — all built # the toolchain binaries (ludicc, ludic, ludic-fmt, ludic-lsp) — all built
# into bin/ by the one-line bootstrap + `bin/x build`; never checked in. The # 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). # only thing published is the source and the LLVM-IR seed (selfhost/ludicc.seed.ll).
/bin/ /bin/
# package manager (issue #63): the per-project linked view into the global # package manager (issue #63): the per-project linked view into the global
# content-addressed store, and the optional hermetic copy from `x vendor`. Both # content-addressed store, and the optional hermetic copy from `ludic vendor`. Both
# are regenerated by `x get` / `x vendor` — package.ludic + package.lock.ludic # are regenerated by `ludic get` / `ludic vendor` — package.ludic + package.lock.ludic
# are the tracked source of truth, so these stay out of the tree. # are the tracked source of truth, so these stay out of the tree.
ludic_modules/ ludic_modules/
vendor/ vendor/
@ -39,7 +39,7 @@ tools/editors/jetbrains/build/
__pycache__/ __pycache__/
*.pyc *.pyc
# Release artifacts produced by `x release` # Release artifacts produced by `ludic dev release`
/dist/ /dist/
# Build/release tarballs anywhere in the tree. `git -C <repo> archive -o foo.tgz` # Build/release tarballs anywhere in the tree. `git -C <repo> archive -o foo.tgz`

View file

@ -1,37 +1,46 @@
# Compiling Ludic # Compiling Ludic
> **Note (2026-08-27):** `ludicc` is now **written in Ludic** (`selfhost/*.ludic`) > **Note:** `ludicc` is **written in Ludic** (`selfhost/*.ludic`) and built from a
> and built from a checked-in IR seed — the C compiler this document describes has > checked-in IR seed — the C compiler this document once described has been
> been deleted. The native pipeline below (Ludic → LLVM IR → object → binary) is > deleted. The native pipeline below (Ludic → LLVM IR → object → binary) is
> unchanged. `ludicc` now drives clang itself (via an `os_system` intrinsic), so > unchanged. `ludicc` drives clang itself (via an `os_system` intrinsic), so
> `ludicc app.ludic -o bin/app` and `--emit-llvm` work directly, and a sibling > `ludicc app.ludic -o bin/app` and `--emit-llvm` work directly. `--fmt` is
> command `ludic app.ludic` compiles to a temporary binary and runs it in one > reimplemented as a lex+parse gate (the doc-check hook). The `--target`/
> step. The whole toolchain is built by `bin/x build`; `bin/x app` remains as a > cross-compile and `--shared` paths are still features of the old C driver not
> convenience wrapper over the compiler. `--fmt` is reimplemented as a lex+parse > yet re-implemented on the self-hosted toolchain. See the
> gate (the doc-check hook). The `--target`/cross-compile and `--shared` paths are > [Bootstrap deep-dive](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Bootstrap) §5.7 on the wiki.
> still features of the old C driver not yet re-implemented on the self-hosted
> toolchain. See the [Bootstrap deep-dive](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Bootstrap) §5.7 on the wiki.
> >
> From a clean checkout, build the compiler and the task-runner in one line, then > Most people never invoke `ludicc` directly: the `ludic` CLI drives it.
> let `bin/x` do the rest (run it from the repository root):
> >
> ```bash > ```bash
> # one-time bootstrap: clang assembles the seed, then ludicc compiles bin/x > curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh # the toolchain, into ~/.ludic
> mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc tools/x/main.ludic -o bin/x > ludic new mygame && cd mygame
> bin/x build # rebuild the whole toolchain into bin/ > ludic run # compile + run
> # (ludicc, ludic, x, ludic-fmt, ludic-lsp) > ludic build --headless # compile, deterministic render
> bin/ludicc examples/games/snake.ludic -o bin/snake # compile
> bin/ludic examples/games/snake.ludic # compile + run
> bin/x help # list every command
> ``` > ```
> >
> The binaries are multi-call (one native binary under two names): invoked as > From a clean checkout, the compiler and the CLI come up in two lines and the
> `ludicc` it compiles, as `ludic` it compiles-and-runs. A `.ludic` file with > CLI does the rest (run it from the repository root):
> handlers is a game and links windowed by default; `--headless` and `--windowed` >
> force the mode. The runtime (`runtime/native/cocoa.ll`) is found via > ```bash
> `$LUDIC_HOME`, defaulting to the directory the binary sits in — keep them in > # one-time bootstrap: clang assembles the seed, then ludicc compiles bin/ludic
> `bin/`, or set `LUDIC_HOME` and put them on `PATH`. `$LUDIC_CC` overrides the > mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc
> assembler/linker (default `clang`). > bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic
> 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
> bin/ludic help # every command
> ```
>
> A `.ludic` file with handlers is a game and links windowed by default;
> `--headless` and `--windowed` force the mode. The engine runtime
> (`runtime/native/cocoa.ll`, the spliced `runtime/native/*.ludic`) and the
> bundled `ludic.*` packages are found under the **install root**: `$LUDIC_HOME`
> if set, otherwise derived from the binary's own location — the parent of its
> `bin/` directory, which is both `~/.ludic` for an install and the repository
> root for a checkout. `$LUDIC_CC` overrides the assembler/linker (default
> `clang`).
`ludicc` is a compiler, not a translator. It lexes, parses, checks and lowers `ludicc` is a compiler, not a translator. It lexes, parses, checks and lowers
@ -73,17 +82,17 @@ the old C driver and are **not yet re-implemented** on the self-hosted toolchain
(see the note at the top). The rows above the line work today via the (see the note at the top). The rows above the line work today via the
self-hosted `ludicc`. self-hosted `ludicc`.
`bin/x app` wraps the common cases: `bin/ludic build` wraps the common cases:
```bash ```bash
bin/x app examples/games/snake.ludic # -> build/snake (native) bin/ludic build examples/games/snake.ludic # -> build/snake (native)
bin/x app examples/library/combat.ludic --lib # -> build/libcombat.* (library) bin/ludic build examples/library/combat.ludic --lib # -> build/libcombat.* (library)
bin/x app examples/games/snake.ludic --headless # -> build/snake_headless (out.ppm) bin/ludic build examples/games/snake.ludic --headless # -> build/snake_headless (out.ppm)
bin/x app examples/games/snake.ludic --web # -> build/web/ (browser) bin/ludic build examples/games/snake.ludic --web # -> build/web/ (browser)
``` ```
The `--lib` and `--web` targets were part of the old C driver and are **not yet The `--lib` and `--web` targets were part of the old C driver and are **not yet
re-implemented** on the self-hosted toolchain — `bin/x app` supports the native re-implemented** on the self-hosted toolchain — `bin/ludic build` supports the native
windowed and `--headless` builds today. windowed and `--headless` builds today.
## Programs and libraries ## Programs and libraries
@ -223,7 +232,7 @@ only the triple changes.
``` ```
```bash ```bash
bin/x app examples/games/chronorift.ludic --web bin/ludic build examples/games/chronorift.ludic --web
python3 -m http.server -d build/web 8000 # then open http://localhost:8000/ python3 -m http.server -d build/web 8000 # then open http://localhost:8000/
``` ```
@ -311,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 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/x 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 asserts exactly that, which is a much stronger check on the backend than
"it started". "it started".
@ -325,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 graphics stack — framebuffer, PNG decoding, sprites, 9-slice, TrueType text and
the retained UI. the retained UI.
None of it goes through C. `bin/x 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 survives in `runtime/`, no C emitter survives in `ludicc`, and the examples all
build, run and render from IR alone. build, run and render from IR alone.
## Every flag ## Every flag
The self-hosted `ludicc`/`ludic` (built with `bin/x 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) <file.ludic> the program to compile (first non-flag argument)
@ -344,12 +353,14 @@ The self-hosted `ludicc`/`ludic` (built with `bin/x build-cli`) accept:
--fmt lex + parse only; exit 0 if it parses, 1 on a parse error --fmt lex + parse only; exit 0 if it parses, 1 on a parse error
(the check-docs gate; canonical formatting not yet restored) (the check-docs gate; canonical formatting not yet restored)
--save-temps keep the intermediate .ll --save-temps keep the intermediate .ll
--run compile then run (implicit when invoked as `ludic`) --run compile then run (what `ludic run` uses)
(unknown -flags are ignored with a warning, never taken as the input file) (unknown -flags are ignored with a warning, never taken as the input file)
environment: environment:
LUDIC_CC the LLVM that assembles IR and drives the linker (clang) LUDIC_CC the LLVM that assembles IR and drives the linker (clang)
LUDIC_HOME where runtime/native/ lives (default: the binary's dir) LUDIC_HOME the install root — runtime/, packages/, VERSION
(default: the parent of the binary's bin/ directory)
LUDIC_MODULES the project's fetched packages (default: ./ludic_modules)
``` ```
Mode is automatic when neither `--windowed` nor `--headless` is given: a program Mode is automatic when neither `--windowed` nor `--headless` is given: a program

View file

@ -18,18 +18,24 @@ runtime, and the tooling are all written in Ludic and built by Ludic.
From a clean checkout, one line lifts the toolchain off the seed: From a clean checkout, one line lifts the toolchain off the seed:
```bash ```bash
mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc tools/x/main.ludic -o bin/x mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc
bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic
``` ```
That gives you `bin/x`, the Ludic task runner that replaces every build/test 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: shell script in the repo. From then on it builds everything — including itself:
```bash ```bash
bin/x build # rebuild the whole toolchain into bin/ (ludicc, ludic, x, ludic-fmt, ludic-lsp) bin/ludic dev build # the whole toolchain into bin/ (ludicc, ludic, ludic-fmt, ludic-lsp)
bin/x help # list every command bin/ludic dev help # every contributor task
bin/ludic help # what a user of the language sees
``` ```
Always run `x` from the repository root, so `assets/` and `selfhost/` resolve. 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.)
## The development loop ## The development loop
@ -37,18 +43,18 @@ When you change the compiler or runtime, prove the self-hosting fixpoint still
holds before you push: holds before you push:
```bash ```bash
bin/x reseed # regenerate selfhost/ludicc.seed.ll after a compiler change bin/ludic dev reseed # regenerate selfhost/ludicc.seed.ll after a compiler change
bin/x bootstrap-cfree # rebuild the compiler from the seed with NO C compiler in the loop bin/ludic dev bootstrap-cfree # rebuild the compiler from the seed with NO C compiler in the loop
bin/x test # the full regression suite bin/ludic dev test # the full regression suite
``` ```
Other useful targets: Other useful targets:
```bash ```bash
bin/x app <file.ludic> [--headless] # compile a program to a native app in build/ bin/ludic build <file.ludic> [--headless] # compile a program to a native app in build/
bin/x selfhost-test # correctness + bootstrap fixpoints bin/ludic dev selfhost-test # correctness + bootstrap fixpoints
bin/x test-tools # the editor-toolchain suite (ludic-fmt, ludic-lsp) bin/ludic dev test-tools # the editor-toolchain suite (ludic-fmt, ludic-lsp)
bin/x clean # remove build/, out.ppm and stray artifacts bin/ludic clean # remove build/, out.ppm and stray artifacts
``` ```
## Adding to the standard library ## Adding to the standard library
@ -61,7 +67,7 @@ The stdlib lives in the runtime (`runtime/`) and is surfaced as namespaces
and register its id in `tools/docgen/inventory.json`. Each documented and register its id in `tools/docgen/inventory.json`. Each documented
namespace gets exactly **one** directory (the docs check enforces this). 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. 3. Add or extend an example under `examples/` and a case in the test suite.
4. Run `bin/x docs-gen --out build/pages && bin/x 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. 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 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. [`changes/`](changes/README.md) with a `bump:` level and a one-line summary.
@ -70,41 +76,47 @@ The stdlib lives in the runtime (`runtime/`) and is surfaced as namespaces
## Versioning & releases ## Versioning & releases
The toolchain is versioned with [SemVer](https://semver.org); `VERSION` is the The toolchain is versioned with [SemVer](https://semver.org); `VERSION` is the
single source of truth and `ludicc --version` (or `x version`) reports it. single source of truth and `ludicc --version` (or `ludic version`) reports it.
Releases are changeset-driven. Every user-facing change ships with a changeset Releases are changeset-driven. Every user-facing change ships with a changeset
(step 5 above). Read the next release before cutting it: (step 5 above). Read the next release before cutting it:
```bash ```bash
x release --dry-run # render the CHANGELOG section, write nothing ludic dev release --dry-run # render the CHANGELOG section, write nothing
``` ```
Then cut it: Then cut it:
```bash ```bash
x 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 git push origin main --follow-tags
``` ```
`x 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 — grouped by change type, with each changeset's markdown kept intact — bumps
`VERSION`, commits `chore(release): vX.Y.Z`, and tags it. `VERSION`, commits `chore(release): vX.Y.Z`, and tags it.
**Pushing the tag is what publishes.** The `release` workflow builds the **Pushing the tag is what publishes.** The `release` workflow builds the
toolchain from the IR seed, runs `x test`, `x test-tools` and `x 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 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 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 `CHANGELOG.md` section as the notes. It refuses to publish if the tag and
`VERSION` disagree or the changelog has no section for it. `VERSION` disagree or the changelog has no section for it.
Each toolchain artifact is a complete install root — `bin/` beside `runtime/`,
`packages/` and `VERSION` — which is exactly what `install.sh` unpacks into
`~/.ludic`. A release with no artifact for a platform is not a broken install
there: the installer falls back to bootstrapping from the source tarball's IR
seed. But the macOS artifacts are the ones most people get, so attach them.
macOS artifacts cannot be produced on the Linux runner — a `darwin-arm64` build macOS artifacts cannot be produced on the Linux runner — a `darwin-arm64` build
needs a macOS host, and there is no cross-compile path (it would need the Xcode 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 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 macOS runner and giving it a job, or running the same command CI runs from a
Mac. Either way it is `x 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 ```bash
FORGEJO_TOKEN=… x publish v0.4.0 FORGEJO_TOKEN=… ludic dev publish v0.4.0
``` ```
Checksums are one `.sha256` file per artifact rather than a single `SHA256SUMS`, Checksums are one `.sha256` file per artifact rather than a single `SHA256SUMS`,
@ -118,6 +130,51 @@ shasum -a 256 -c ludic-0.4.0-src.tar.gz.sha256
The tag doubles as the reproducible bootstrap point: the source archive plus its The tag doubles as the reproducible bootstrap point: the source archive plus its
checked-in seed rebuild that exact toolchain. checked-in seed rebuild that exact toolchain.
## Where the name and the URLs live
The language may yet be renamed and the project may yet move hosts, so the
things that carry a name are kept few and listed here rather than discovered one
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`
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` |
| `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 |
**The name itself.** A rename touches, in rough order of blast radius:
- **The file extension** `.ludic` — the compiler (`strip_ludic`, `do_import`,
`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 install root** `~/.ludic` and the source directories `tools/ludic-cli/`,
`tools/ludic-tools/`, `packages/ludic.*`.
- **The environment variables** `LUDIC_HOME`, `LUDIC_CC`, `LUDIC_MODULES`,
`LUDIC_STORE`, `LUDIC_PKG_PROXY`, `LUDIC_INSTALL_URL`, `LUDIC_KEEP_TMP`,
`LUDIC_COVERAGE` — keep the old names working for a release if anyone has them
in a script.
- **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`.
- **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
toolchain, stages an install, and runs `new` → `build` → `test` through it, so a
half-finished rename fails there rather than in someone's terminal.
## Conventions ## Conventions
- **Commits:** [Conventional Commits](https://www.conventionalcommits.org) — - **Commits:** [Conventional Commits](https://www.conventionalcommits.org) —
@ -132,7 +189,7 @@ checked-in seed rebuild that exact toolchain.
| `perf` | a performance improvement | | `perf` | a performance improvement |
| `docs` | documentation only (`docs/`, README, comments) | | `docs` | documentation only (`docs/`, README, comments) |
| `test` | tests only | | `test` | tests only |
| `build` | the build/bootstrap machinery (seed, `bin/x`, linking) | | `build` | the build/bootstrap machinery (seed, `bin/ludic`, linking) |
| `ci` | CI workflows under `.forgejo/` | | `ci` | CI workflows under `.forgejo/` |
| `style` | formatting/whitespace, no behaviour change | | `style` | formatting/whitespace, no behaviour change |
| `chore` | routine housekeeping with no other bucket | | `chore` | routine housekeeping with no other bucket |
@ -154,7 +211,7 @@ checked-in seed rebuild that exact toolchain.
so a green local commit is a green CI run. so a green local commit is a green CI run.
- **Formatting:** `ludic-fmt` is the source of truth (2-space indent, LF, UTF-8); - **Formatting:** `ludic-fmt` is the source of truth (2-space indent, LF, UTF-8);
the repo `.editorconfig` mirrors it. Run `bin/ludic-fmt -w` on files you touch. the repo `.editorconfig` mirrors it. Run `bin/ludic fmt` on files you touch.
The contract CI enforces is *idempotence* — `ludic-fmt` re-run on its own output The contract CI enforces is *idempotence* — `ludic-fmt` re-run on its own output
is a no-op — which leaves deliberate hand alignment in place; it is not a is a no-op — which leaves deliberate hand alignment in place; it is not a
blanket `fmt(x) == x`. blanket `fmt(x) == x`.
@ -182,7 +239,7 @@ non-destructive version of "tidy the history" without touching a single commit.
## Pull requests ## Pull requests
- Base your branch on `main`. - Base your branch on `main`.
- Ensure `bin/x test` (and `bin/x 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. pass, and that `ludic-fmt` leaves your files unchanged.
- Fill in the PR template checklist. Reference the issue you close with - Fill in the PR template checklist. Reference the issue you close with
`Closes #NN` in the description or a commit message. `Closes #NN` in the description or a commit message.

View file

@ -1021,24 +1021,28 @@ literal; test any pointer/record/slice with `x == null` / `x != null` (an unset
## Tooling ## Tooling
```bash ```bash
ludicc app.ludic -o build/app # native binary (windowed for a game) ludic new mygame # a project that builds and plays as it stands
ludicc app.ludic --headless -o app # headless build (renders out.ppm; reads stdin) ludic run # compile src/main.ludic and run it
ludic build --headless # headless build (renders out.ppm; reads stdin)
ludic test # compile and run the project's `test` blocks
ludicc app.ludic -o build/app # the compiler directly: a native binary
ludicc app.ludic --emit-llvm -o app.ll # stop at LLVM IR ludicc app.ludic --emit-llvm -o app.ll # stop at LLVM IR
ludic app.ludic # compile AND run (forwards the exit code)
``` ```
`ludicc` (compile) and `ludic` (compile-and-run) are one multi-call binary built `ludic` is the CLI (`ludic help`); `ludicc` is the compiler it drives, built from
by `bin/x build-cli`. **[COMPILING.md](COMPILING.md) is the authoritative CLI the IR seed by `bin/ludic dev build-cli`. **[COMPILING.md](COMPILING.md) is the
reference** — the full flag set (`-o`, `--windowed`, `--headless`, `--emit-llvm`, authoritative CLI reference** — the full flag set (`-o`, `--windowed`,
`--save-temps`, `--run`), the `LUDIC_HOME` / `LUDIC_CC` environment variables, `--headless`, `--emit-llvm`, `--save-temps`, `--run`), the `LUDIC_HOME` /
and the IR-to-stdout bootstrap contract (no `-o`, invoked as `ludicc`) that `LUDIC_CC` environment variables, and the IR-to-stdout bootstrap contract (no
`bin/x app` / `bin/x reseed` rely on. The default mode is auto: a file with `handler`s `-o`) that `bin/ludic build` / `bin/ludic dev reseed` rely on. The default mode
links windowed, otherwise headless; an explicit flag always wins. is auto: a file with `handler`s links windowed, otherwise headless; an explicit
flag always wins.
The retired C driver's `--shared`, `--fmt`, `-c`, cross-compile (`--target`) and The retired C driver's `--shared`, `--fmt`, `-c`, cross-compile (`--target`) and
wasm modes are **not** on the self-hosted toolchain (see "Not yet implemented"). wasm modes are **not** on the self-hosted toolchain (see "Not yet implemented").
Source formatting now lives in the standalone `bin/ludic-fmt` (below), not a Source formatting now lives in the standalone formatter — `ludic fmt` (below) —
compiler flag. not a compiler flag.
The self-hosted compiler is intentionally permissive: it has no separate The self-hosted compiler is intentionally permissive: it has no separate
validation pass yet, so unknown types lower to `ptr` and call arity is not validation pass yet, so unknown types lower to `ptr` and call arity is not
@ -1049,7 +1053,7 @@ duplicate types, unknown fields, arity) are future work.
### Editors ### Editors
```bash ```bash
bin/x 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 -w src/ # format in place (keeps comments)
bin/ludic-fmt --check . # CI: exit 1 if anything is unformatted bin/ludic-fmt --check . # CI: exit 1 if anything is unformatted
bin/ludic-lsp --stdio # the language server, for any editor bin/ludic-lsp --stdio # the language server, for any editor
@ -1076,7 +1080,7 @@ Emacs, Sublime and Zed, are in `tools/editors/` — see
- `examples/games/snake.ludic` — Snake, no assets — same compiler, proving generality. - `examples/games/snake.ludic` — Snake, no assets — same compiler, proving generality.
```bash ```bash
bin/x app examples/games/snake.ludic && ./build/snake bin/ludic build examples/games/snake.ludic && ./build/snake
``` ```
## Not yet implemented ## Not yet implemented
@ -1103,7 +1107,7 @@ self-hosting; their lowerings are in
> **Implemented (S0).** `scene`, `layer`, and the `on enter` / `on exit` hooks > **Implemented (S0).** `scene`, `layer`, and the `on enter` / `on exit` hooks
> compile; [`examples/lang/scenes.ludic`](examples/lang/scenes.ludic) runs and is checked > compile; [`examples/lang/scenes.ludic`](examples/lang/scenes.ludic) runs and is checked
> by `bin/x 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 > implicit active-scene register, states numbered by declaration order, and
> `become` as two direct calls plus a store. Richer scene features (the overlay > `become` as two direct calls plus a store. Richer scene features (the overlay
> stack, scene-owned entities, scene-local state, transition parameters) are > stack, scene-owned entities, scene-local state, transition parameters) are

View file

@ -35,34 +35,57 @@ program Hello {
## Getting started ## Getting started
`bin/x` is the project's task runner: one native binary, written in Ludic and Install the toolchain — the compiler, the `ludic` CLI, the engine runtime, the
compiled by Ludic, that replaces every build and test script. Bootstrapping it formatter and the language server — with one command:
is the only step Ludic cannot do for itself, since compiling Ludic needs a
compiler — clang assembles the checked-in IR seed:
```bash ```bash
mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc tools/x/main.ludic -o bin/x curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh
``` ```
Then build the toolchain and run a game: It installs into `~/.ludic` and puts `~/.ludic/bin` on your `PATH`; nothing else
on the machine is touched, and uninstalling is `rm -rf ~/.ludic`. Where a
prebuilt toolchain exists for your platform it is downloaded and verified against
a published checksum; where it does not, the installer bootstraps from the
compiler's own IR seed with clang. Either way you need clang (or Xcode's Command
Line Tools) to link, since Ludic emits LLVM IR and links it natively.
Then make a game:
```bash ```bash
bin/x build # -> bin/{ludicc,ludic,x,ludic-fmt,ludic-lsp} ludic new mygame
bin/x app examples/games/snake.ludic # compile and open a native window cd mygame
./build/snake ludic run # compiles src/main.ludic and opens a native window
``` ```
Rendering is deterministic, so a frame can be produced without a window — this `ludic new` writes a manifest, a program that already moves something on screen,
is what CI diffs: and a test. `ludic build` stops at the binary — one self-contained executable
with nothing to ship beside it. Rendering is deterministic, so a frame can be
produced without a window, which is what CI diffs:
```bash ```bash
bin/x app examples/games/snake.ludic --headless ludic test
printf 'ddddwww' | ./build/snake_headless # writes build/out.ppm ludic build --headless
printf 'ddddwww' | ./build/mygame_headless # writes build/out.ppm
``` ```
`bin/x help` lists every command. [`examples/`](examples/README.md) is a tour `ludic help` lists every command, and `ludic doctor` checks the install.
grouped by intent: games, rendering, ECS, events, networking, language features [`examples/`](examples/README.md) is a tour grouped by intent: games, rendering,
and the standard library. ECS, events, networking, language features and the standard library — compile any
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:
```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
```
## The language ## The language
@ -93,24 +116,25 @@ Dependencies are identified by URL, resolved with minimal version selection, and
cached in a content-addressed store: cached in a content-addressed store:
```bash ```bash
bin/x add git.workshopsoft.io/user/pkg # resolve, fetch, link into ludic_modules/ ludic add git.workshopsoft.io/user/pkg # resolve, fetch, link into ludic_modules/
bin/x get # install from package.ludic, write the lock ludic get # install from package.ludic, write the lock
bin/x verify # check locked packages against the store ludic verify # check locked packages against the store
``` ```
The `ludic.*` packages — canonical ECS components, the gameplay, platformer,
RPG, shooter and NPC-AI modules — ship with the toolchain, so importing one needs
no fetch step at all.
See [`docs/PACKAGES.md`](docs/PACKAGES.md) for the manifest and lockfile model. See [`docs/PACKAGES.md`](docs/PACKAGES.md) for the manifest and lockfile model.
## Editor support ## Editor support
```bash Editors spawn `ludic lsp`; the server ships with the toolchain, so there is
bin/x tools # -> bin/ludic-fmt, bin/ludic-lsp nothing extra to install. It speaks LSP 3.17 over stdio, so one binary serves
``` every editor: completion, diagnostics from the compiler itself, go-to-definition
and rename across imports, and comment-preserving formatting. `ludic fmt` runs
`ludic-lsp` speaks LSP 3.17 over stdio, so one binary serves every editor: the same formatter as a CLI, for pre-commit hooks. Both understand
completion, diagnostics from the compiler itself, go-to-definition and rename ```` ```ludic ```` fences in Markdown. Plugins and drop-in config for VS Code, JetBrains, Neovim,
across imports, and comment-preserving formatting. `ludic-fmt` is the same
formatter as a CLI, for pre-commit hooks. Both understand ```` ```ludic ````
fences in Markdown. Plugins and drop-in config for VS Code, JetBrains, Neovim,
Helix, Emacs, Sublime and Zed are in [`tools/editors/`](tools/editors/README.md). Helix, Emacs, Sublime and Zed are in [`tools/editors/`](tools/editors/README.md).
## Status ## Status
@ -128,7 +152,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 self-hosted toolchain. `--target` cross-compilation and `--shared` libraries are
in the same position. See [COMPILING.md](COMPILING.md). in the same position. See [COMPILING.md](COMPILING.md).
Releases follow SemVer and are cut from changesets by `x 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). and published by CI from the tag; see [CHANGELOG.md](CHANGELOG.md).
## Contributing ## Contributing

View file

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

View file

@ -0,0 +1,34 @@
bump: minor
type: feat
**One command installs Ludic, and `ludic` is the command you use.** Getting
started no longer means cloning the repository and learning a task runner called
`x`.
- **`curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh`** installs a complete
toolchain — compiler, CLI, engine runtime, bundled `ludic.*` packages,
formatter and language server — into `~/.ludic` and puts it on your `PATH`.
Prebuilt artifacts are verified against a published checksum; where none
exists for the platform, the installer bootstraps from the compiler's own IR
seed with clang. Uninstalling is `rm -rf ~/.ludic`, and `ludic upgrade`
re-runs the same script.
- **`ludic` replaces `x`** and is the only command a user of the language meets:
`ludic new` scaffolds a project that builds and plays as it stands, `ludic
run` / `ludic build` compile it (`--headless` for a deterministic render),
`ludic test` runs every `test` block in the project, and `ludic add` / `get` /
`update` / `verify` / `vendor` drive packages. `ludic fmt` and `ludic lsp` are
the formatter and language server, so an editor needs no path configuration.
`ludic doctor` reports whether the install is complete and usable.
- **The toolchain's own tasks moved under `ludic dev`** — `dev build`, `dev
test`, `dev reseed`, `dev bootstrap-cfree`, `dev docs-gen`, `dev release` and
the rest are unchanged apart from the namespace. `bin/x` is gone; the
bootstrap is now `clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc
tools/ludic-cli/main.ludic -o bin/ludic`.
- **An install root is a first-class layout.** The compiler derives it from its
own location — the parent of its `bin/` directory — so `~/.ludic` and a repo
checkout are the same shape, and `$LUDIC_HOME` is no longer needed to build a
windowed game outside the repo. The bundled `ludic.*` packages resolve from
`$LUDIC_HOME/packages`, so `import "ludic.core/components.ludic"` works with no
`ludic_modules/` to set up. Release artifacts are complete install roots
(`bin/` beside `runtime/`, `packages/` and `VERSION`) rather than bare
binaries, and the installer ships with the documentation site it is served
from.

View file

@ -2,7 +2,7 @@ bump: patch
type: fix type: fix
Release checksums are one `.sha256` file per artifact instead of a single Release checksums are one `.sha256` file per artifact instead of a single
`SHA256SUMS`. A release is assembled from more than one host — a Linux runner `SHA256SUMS`. A release is assembled from more than one host — a Linux runner
cannot build the macOS toolchain — and `x publish` never overwrites an asset that cannot build the macOS toolchain — and `ludic dev publish` never overwrites an asset that
is already attached, so a shared `SHA256SUMS` was written by whichever host is already attached, so a shared `SHA256SUMS` was written by whichever host
published first and then never covered anything added afterwards. Per-artifact published first and then never covered anything added afterwards. Per-artifact
names compose across hosts. Verify one with names compose across hosts. Verify one with

View file

@ -64,7 +64,7 @@ controller.
The genre packages build on the engine's `Body`/`Collider` + `esys_move` (swept-AABB The genre packages build on the engine's `Body`/`Collider` + `esys_move` (swept-AABB
tile collision, in `runtime/native/systems_move.ludic`) and on `ludic.gameplay`. In a tile collision, in `runtime/native/systems_move.ludic`) and on `ludic.gameplay`. In a
real project you `x add` them; in this repo the packages live under `packages/` and a real project you `ludic add` them; in this repo the packages live under `packages/` and a
game compiles against them with `LUDIC_MODULES`: game compiles against them with `LUDIC_MODULES`:
``` ```
@ -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/` Worked, self-checking examples for every controller live in `examples/games/`
(`platformer_demo`, `platformer_scaffolding`, `shooter_demo`, `npcai_demo`, `rpg_demo`) (`platformer_demo`, `platformer_scaffolding`, `shooter_demo`, `npcai_demo`, `rpg_demo`)
and `examples/library/gameplay_foundation.ludic`; each is a deterministic regression and `examples/library/gameplay_foundation.ludic`; each is a deterministic regression
case in `x test`. case in `ludic dev test`.
## What we deliberately do NOT do ## What we deliberately do NOT do

View file

@ -1,6 +1,6 @@
# Ludic packages # Ludic packages
Ludic has a package manager built into the task runner (`bin/x`). It fetches, Ludic has a package manager built into the task runner (`bin/ludic`). It fetches,
resolves, stores and links third-party packages with no new infrastructure to resolves, stores and links third-party packages with no new infrastructure to
run — it drives plain `git` and rides on the Forgejo host and its release tags. run — it drives plain `git` and rides on the Forgejo host and its release tags.
@ -18,14 +18,14 @@ This is the v1 implementation of the direction decided in issue #63.
## Commands ## Commands
``` ```
x add <module>[@version] add a dependency to package.ludic, then resolve + fetch + link ludic add <module>[@version] add a dependency to package.ludic, then resolve + fetch + link
x get resolve every dependency in package.ludic, link them, write the lock ludic get resolve every dependency in package.ludic, link them, write the lock
x update [module] bump a dependency (or all) to its latest published version, then relock ludic update [module] bump a dependency (or all) to its latest published version, then relock
x verify check every locked package against the store by content hash ludic verify check every locked package against the store by content hash
x vendor copy the resolved packages into ./vendor for hermetic/offline builds ludic vendor copy the resolved packages into ./vendor for hermetic/offline builds
``` ```
`x add` with no `@version` picks the latest published tag and records it as the `ludic add` with no `@version` picks the latest published tag and records it as the
minimum. All the install commands print the resolved build list and write minimum. All the install commands print the resolved build list and write
`package.lock.ludic`. `package.lock.ludic`.
@ -52,17 +52,17 @@ optional for a leaf application).
## The lockfile — `package.lock.ludic` ## The lockfile — `package.lock.ludic`
Generated by `x get`; do not edit by hand. One line per resolved module, pinning Generated by `ludic get`; do not edit by hand. One line per resolved module, pinning
its selected version, content hash, kind and provided namespaces: its selected version, content hash, kind and provided namespaces:
``` ```
# package.lock.ludic — generated by `x get`. Do not edit by hand. # package.lock.ludic — generated by `ludic get`. Do not edit by hand.
lock 1 lock 1
module "git.workshopsoft.io/orkun/greeter" version "1.2.0" hash "sha256:…" kind "source" provides "Greet" module "git.workshopsoft.io/orkun/greeter" version "1.2.0" hash "sha256:…" kind "source" provides "Greet"
module "git.workshopsoft.io/orkun/util" version "1.0.0" hash "sha256:…" kind "source" provides "Util" module "git.workshopsoft.io/orkun/util" version "1.0.0" hash "sha256:…" kind "source" provides "Util"
``` ```
`x verify` rehashes each store entry and confirms the project links to it, so a `ludic verify` rehashes each store entry and confirms the project links to it, so a
tampered or missing dependency is caught before it reaches a build. tampered or missing dependency is caught before it reaches a build.
## The store and the project view ## The store and the project view
@ -176,31 +176,31 @@ always emitted (unused parts dead-strip).
**Publishing.** In the package repo: **Publishing.** In the package repo:
``` ```
x build-lib module.ludic # -> lib/<target>/lib<name>.dylib ludic build-lib module.ludic # -> lib/<target>/lib<name>.dylib
# add `kind prebuilt` and `targets "<target>"` to package.ludic, commit lib/, git tag # add `kind prebuilt` and `targets "<target>"` to package.ludic, commit lib/, git tag
``` ```
**Consuming.** In the game project: **Consuming.** In the game project:
``` ```
x add git.host/user/module # kind prebuilt is resolved + the dylib linked into the view ludic add git.host/user/module # kind prebuilt is resolved + the dylib linked into the view
x get # links the artifact for the build target (hard error if the target is missing) ludic get # links the artifact for the build target (hard error if the target is missing)
``` ```
then link the module dylibs into the game build. `x link-flags` prints the exact then link the module dylibs into the game build. `ludic link-flags` prints the exact
clang flags (the dylib, an rpath to the store, `-export_dynamic`) for any build clang flags (the dylib, an rpath to the store, `-export_dynamic`) for any build
system to splice into its link step: system to splice into its link step:
``` ```
clang -O2 game.ll $(x link-flags) -o game clang -O2 game.ll $(ludic link-flags) -o game
``` ```
(`x app` links them automatically when building in-repo.) If the package does (`ludic build` links them automatically when building in-repo.) If the package does
not ship the build target, `x get` fails — build from source instead where the not ship the build target, `ludic get` fails — build from source instead where the
package offers it. package offers it.
## Offline / hermetic builds ## Offline / hermetic builds
`x vendor` copies the resolved packages out of the store into `./vendor`. Build `ludic vendor` copies the resolved packages out of the store into `./vendor`. Build
against the copy with `LUDIC_MODULES=vendor`, so the build needs neither the against the copy with `LUDIC_MODULES=vendor`, so the build needs neither the
network nor the global store. network nor the global store.

View file

@ -4,4 +4,4 @@ title: Testing
order: 34 order: 34
--- ---
A built-in testing framework in the spirit of Go's <code>go test</code>: tests live next to the code, run with one command, and report pass/fail — no harness to wire up. A <a href="kw-test"><code>test "name" { … }</code></a> block is discovered automatically and run by a synthetic entry point that prints <code>ok - name</code> or <code>FAIL - name</code> for each, a <code>== N passed, M failed ==</code> summary, and exits non-zero if anything failed (so CI and the <code>bin/x</code> runner catch it). Inside a test, the <code>expect</code>, <code>expect_eq</code> and <code>expect_near</code> assertions check a condition and, on failure, print <code>file:line: … failed (got …, want …)</code> and mark the test failed — without aborting, so one run reports every failure. <code>expect_near</code> takes a tolerance, which is what fixed-point and accumulated-integer game math need. Everything is deterministic and compiles to a native binary, so a suite runs in the same C-free toolchain as the rest of Ludic. Coverage instrumentation is a planned follow-up. Related: <a href="kw-function"><code>function</code></a>, <a href="fn-print"><code>print</code></a>. A built-in testing framework in the spirit of Go's <code>go test</code>: tests live next to the code, run with one command, and report pass/fail — no harness to wire up. A <a href="kw-test"><code>test "name" { … }</code></a> block is discovered automatically and run by a synthetic entry point that prints <code>ok - name</code> or <code>FAIL - name</code> for each, a <code>== N passed, M failed ==</code> summary, and exits non-zero if anything failed (so CI and the <code>bin/ludic</code> runner catch it). Inside a test, the <code>expect</code>, <code>expect_eq</code> and <code>expect_near</code> assertions check a condition and, on failure, print <code>file:line: … failed (got …, want …)</code> and mark the test failed — without aborting, so one run reports every failure. <code>expect_near</code> takes a tolerance, which is what fixed-point and accumulated-integer game math need. Everything is deterministic and compiles to a native binary, so a suite runs in the same C-free toolchain as the rest of Ludic. Coverage instrumentation is a planned follow-up. Related: <a href="kw-function"><code>function</code></a>, <a href="fn-print"><code>print</code></a>.

View file

@ -17,12 +17,12 @@ Assertions (each records a failure and prints <code>file:line: … failed</code>
- `expect_eq(a, b)` — `a` must equal `b`; on failure prints `(got a, want b)`. - `expect_eq(a, b)` — `a` must equal `b`; on failure prints `(got a, want b)`.
- `expect_near(a, b, tol)` — `a` must be within `tol` of `b` (absolute). Use it for `fixed`-point results and accumulated integer math, where an exact match is too brittle. - `expect_near(a, b, tol)` — `a` must be within `tol` of `b` (absolute). Use it for `fixed`-point results and accumulated integer math, where an exact match is too brittle.
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/x` and CI. 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/x 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/x test --coverage) == == line coverage (bin/ludic dev test --coverage) ==
examples/library/coverage.ludic 16/17 lines 94% uncovered: 33 examples/library/coverage.ludic 16/17 lines 94% uncovered: 33
examples/library/testing.ludic 17/17 lines 100% examples/library/testing.ludic 17/17 lines 100%
---- ----

View file

@ -152,63 +152,69 @@
}, },
"start": { "start": {
"kicker": "Get started", "kicker": "Get started",
"title": "From clone to a native window.", "title": "One command to install. One to play.",
"intro": "Bootstrap the task runner once from the checked-in IR seed, then build the toolchain and your game. Everything runs from the repo root.", "intro": "Install the toolchain with a single command — it brings the compiler, the <code>ludic</code> CLI, the engine runtime and the editor tooling, and needs nothing else on your machine but a C toolchain to link with. Then create a project and run it.",
"steps": [ "steps": [
{ {
"title": "Bootstrap from the seed", "title": "Install",
"html": "<code>bin/</code> is not checked in, so it is created first; then <code>clang</code> assembles the compiler's own checked-in LLVM IR seed, and that compiler builds <code>bin/x</code>, the task runner. This is the only step Ludic cannot do for itself." "html": "The installer downloads a verified toolchain for your platform into <code>~/.ludic</code> and puts it on your <code>PATH</code>. Nothing else is touched; uninstalling is <code>rm -rf ~/.ludic</code>. On a platform with no prebuilt toolchain it bootstraps from the compiler's own IR seed instead — same result, a few seconds longer."
}, },
{ {
"title": "Build the toolchain", "title": "Create a project",
"html": "<code>bin/x build</code> produces <code>ludicc</code>, <code>ludic</code>, <code>ludic-fmt</code> and <code>ludic-lsp</code> — all compiled by Ludic, from Ludic." "html": "<code>ludic new mygame</code> writes a manifest, a program that already moves something on screen, a test, and an <code>assets/</code> directory. There is no scaffolding to choose and no build file to write."
}, },
{ {
"title": "Compile &amp; run an example", "title": "Run it",
"html": "<code>bin/x app examples/games/snake.ludic</code> turns a <code>.ludic</code> file into a native binary. Run it to open a real window." "html": "<code>ludic run</code> compiles <code>src/main.ludic</code> to a native binary and launches it. <code>ludic build</code> stops at the binary — one self-contained executable, with nothing to ship beside it."
}, },
{ {
"title": "Go headless for tests", "title": "Test it, headlessly",
"html": "<code>--headless</code> renders frames to a <code>.ppm</code> from piped input — deterministic output you can diff in CI." "html": "<code>ludic test</code> compiles and runs every <code>test</code> block in the project. <code>--headless</code> renders frames to a <code>.ppm</code> from piped input, so a game is deterministic enough to diff in CI."
} }
], ],
"terminal_name": "zsh — ludic", "terminal_name": "zsh — ludic",
"terminal": [ "terminal": [
{ {
"comment": "bootstrap the task runner (clang + the IR seed, once)" "comment": "install the toolchain (macOS, Linux)"
}, },
{ {
"cmd": "mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc" "cmd": "curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh"
}, },
{ {
"cmd": "bin/ludicc tools/x/main.ludic -o bin/x" "out": "→ installed ludic 0.4.0 → ~/.ludic"
}, },
{ {
"blank": true "blank": true
}, },
{ {
"comment": "build the toolchain, then an example (opens a window)" "comment": "a project that builds and plays as it stands"
}, },
{ {
"cmd": "bin/x build" "cmd": "ludic new mygame"
}, },
{ {
"cmd": "bin/x app examples/games/snake.ludic" "cmd": "cd mygame"
}, },
{ {
"cmd": "./build/snake" "cmd": "ludic run"
},
{
"out": "→ a native window, running your game"
}, },
{ {
"blank": true "blank": true
}, },
{ {
"comment": "deterministic headless render for tests" "comment": "tests, and a deterministic headless render for CI"
}, },
{ {
"cmd": "bin/x app examples/games/snake.ludic --headless" "cmd": "ludic test"
}, },
{ {
"cmd": "printf 'ddddwww' | ./build/snake_headless" "cmd": "ludic build --headless"
},
{
"cmd": "printf 'ddddwww' | ./build/mygame_headless"
}, },
{ {
"out": "→ writes build/out.ppm" "out": "→ writes build/out.ppm"
@ -228,6 +234,6 @@
"Sublime Text", "Sublime Text",
"Zed" "Zed"
], ],
"note": "<code>bin/x tools</code> builds <span class=\"mono\">ludic-fmt</span> and <span class=\"mono\">ludic-lsp</span> — the same formatter runs as a CLI for pre-commit hooks and CI." "note": "Editors spawn <code>ludic lsp</code> — it ships with the toolchain, so there is nothing extra to build or install. The same formatter runs as <code>ludic fmt</code> for pre-commit hooks and CI."
} }
} }

View file

@ -1,14 +1,14 @@
# Examples # Examples
A curated tour of Ludic, grouped by intent. Every example here is exercised by A curated tour of Ludic, grouped by intent. Every example here is exercised by
the test suite (`bin/x test` / `bin/x 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. directory silently rots.
Run any program straight from the repository root (so `assets/` resolves): Run any program straight from the repository root (so `assets/` resolves):
```sh ```sh
bin/x app examples/games/snake.ludic # compile + open a real window bin/ludic build examples/games/snake.ludic # compile + open a real window
bin/x app examples/games/snake.ludic --headless # render one frame to build/out.ppm bin/ludic build examples/games/snake.ludic --headless # render one frame to build/out.ppm
bin/ludic examples/lang/offline_rewards.ludic # compile + run a plain program bin/ludic examples/lang/offline_rewards.ludic # compile + run a plain program
``` ```

View file

@ -1,6 +1,6 @@
# ============================================================================ # ============================================================================
# snake.ludic — a small, complete game in Ludic. No sprites or assets: every # snake.ludic — a small, complete game in Ludic. No sprites or assets: every
# pixel is drawn from primitives. Build: bin/x app examples/games/snake.ludic # pixel is drawn from primitives. Build: bin/ludic build examples/games/snake.ludic
# #
# It shows the everyday shape of a Ludic game: an ECS for the moving parts (one # It shows the everyday shape of a Ludic game: an ECS for the moving parts (one
# entity per snake segment), named program state for the rest, and a Render # entity per snake segment), named program state for the rest, and a Render

View file

@ -11,7 +11,7 @@
# 25 @OnDetach(Shield): reads the outgoing amount 5, prints 5 + 20 # 25 @OnDetach(Shield): reads the outgoing amount 5, prints 5 + 20
# 0 the Shield is gone — nothing matches # 0 the Shield is gone — nothing matches
# #
# bin/x 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 # /tmp/detach </dev/null
program Detach { program Detach {
property Tag { v: int = 0 } property Tag { v: int = 0 }

View file

@ -9,7 +9,7 @@
# 503 Enemy A despawned in-world (Despawned): drop its loot, 3 + 500 # 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 # 1009 Enemy B outlived the run; at quit (Quit) it skips loot, 9 + 1000
# #
# bin/x 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 # /tmp/reason </dev/null
program Reasons { program Reasons {
property Health { hp: int = 0 } property Health { hp: int = 0 }

View file

@ -12,7 +12,7 @@
# 201 900 frame 3: Play.World.Step, then Hud.Draw # 201 900 frame 3: Play.World.Step, then Hud.Draw
# 202 900 frame 4: Step reaches 2 -> quit(); Hud.Draw paints the last frame # 202 900 frame 4: Step reaches 2 -> quit(); Hud.Draw paints the last frame
# #
# bin/x 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 # printf 'aaaa' | /tmp/scenes
program SceneDemo { program SceneDemo {
var counter: int = 0 var counter: int = 0

View file

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

View file

@ -14,7 +14,7 @@
# 5. rt_receive() — the client reconciles to the authoritative x=5 # 5. rt_receive() — the client reconciles to the authoritative x=5
# #
# Prints 5 / 999 / 5. Build & run with the Ludic toolchain only: # Prints 5 / 999 / 5. Build & run with the Ludic toolchain only:
# bin/x app examples/networking/net_demo.ludic --headless && ./build/net_demo_headless # bin/ludic build examples/networking/net_demo.ludic --headless && ./build/net_demo_headless
import "net_rt.ludic" import "net_rt.ludic"
program NetDemo { program NetDemo {

335
install.sh Executable file
View file

@ -0,0 +1,335 @@
#!/bin/sh
# install.sh — install the Ludic toolchain.
#
# curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh
#
# That URL is the documentation site, which publishes this file alongside the
# pages it is quoted on (see cmd_docs_gen). Everything host-shaped here is a
# variable with an environment override, so moving the project — a new domain,
# a new forge, a new name for the language — is an edit to the block below and
# to the prose that quotes it (CONTRIBUTING.md has the checklist).
#
# It puts a complete toolchain — the compiler, the `ludic` CLI, the formatter,
# the language server, the engine runtime and the bundled ludic.* packages —
# under ~/.ludic, and puts ~/.ludic/bin on your PATH. Nothing else on the system
# is touched, and uninstalling is `rm -rf ~/.ludic`.
#
# Where a prebuilt toolchain exists for the platform it is downloaded and
# checksum-verified. Where it does not, the source is fetched and bootstrapped
# from the compiler's own checked-in LLVM IR seed, which needs only clang and
# takes a few seconds — the same path a contributor uses.
#
# Options (also usable through `ludic upgrade`):
# --version X.Y.Z install that release instead of the latest
# --dir PATH install root (default: $HOME/.ludic)
# --no-modify-path do not touch any shell profile
# --from-source skip the prebuilt artifacts and bootstrap from source
# --help
#
# POSIX sh on purpose: this is the first thing a new user runs, and it has to
# work on a stock macOS /bin/sh and on a minimal Linux image alike.
set -eu
REPO_API="${LUDIC_FORGEJO_API:-https://git.workshopsoft.io/api/v1/repos/workshopsoft/ludic}"
REPO_URL="${LUDIC_REPO_URL:-https://git.workshopsoft.io/workshopsoft/ludic}"
INSTALL_URL="${LUDIC_INSTALL_URL:-https://workshopsoft.pages.workshopsoft.io/ludic/install.sh}"
INSTALL_DIR="${LUDIC_INSTALL_DIR:-${HOME}/.ludic}"
VERSION=""
MODIFY_PATH=1
FROM_SOURCE=0
# ---- output -----------------------------------------------------------------
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
B=$(printf '\033[1m'); DIM=$(printf '\033[2m'); R=$(printf '\033[0m')
GREEN=$(printf '\033[32m'); RED=$(printf '\033[31m')
else
B=''; DIM=''; R=''; GREEN=''; RED=''
fi
say() { printf '%s\n' "$*"; }
step() { printf '%s==>%s %s\n' "$B" "$R" "$*"; }
warn() { printf '%swarning:%s %s\n' "$RED" "$R" "$*" >&2; }
die() { printf '%serror:%s %s\n' "$RED" "$R" "$*" >&2; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
usage() {
cat <<EOF
install.sh — install the Ludic toolchain.
curl -fsSL ${INSTALL_URL} | sh
--version X.Y.Z install that release instead of the latest
--dir PATH install root (default: $HOME/.ludic)
--no-modify-path do not touch any shell profile
--from-source skip the prebuilt artifacts and bootstrap from source
--help this message
EOF
exit 0
}
# ---- arguments --------------------------------------------------------------
while [ $# -gt 0 ]; do
case "$1" in
--version) VERSION="${2:-}"; [ -n "$VERSION" ] || die "--version needs a value"; shift 2 ;;
--version=*) VERSION="${1#*=}"; shift ;;
--dir) INSTALL_DIR="${2:-}"; [ -n "$INSTALL_DIR" ] || die "--dir needs a value"; shift 2 ;;
--dir=*) INSTALL_DIR="${1#*=}"; shift ;;
--no-modify-path) MODIFY_PATH=0; shift ;;
--from-source) FROM_SOURCE=1; shift ;;
-h|--help) usage ;;
*) die "unknown option: $1 (try --help)" ;;
esac
done
VERSION="${VERSION#v}"
# ---- platform ---------------------------------------------------------------
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$ARCH" in
aarch64) ARCH=arm64 ;;
amd64) ARCH=x86_64 ;;
esac
case "$OS" in
darwin|linux) ;;
*) die "unsupported platform: $OS ($ARCH). Ludic builds on macOS and Linux." ;;
esac
# clang assembles and links the IR the compiler emits, so it is required to
# build any Ludic program — not only to install from source.
CC_HINT=''
if ! have clang && ! have cc; then
if [ "$OS" = darwin ]; then
CC_HINT='install the Command Line Tools: xcode-select --install'
else
CC_HINT='install clang, e.g. apt-get install clang / dnf install clang'
fi
fi
have curl || die "this installer needs curl"
# ---- downloading ------------------------------------------------------------
fetch() { curl -fsSL "$1" -o "$2"; }
# The newest published release, e.g. "0.4.0".
#
# /releases/latest excludes pre-releases, and every 0.x release is marked one —
# so on a project that has not reached 1.0 that endpoint 404s and the installer
# would refuse to install anything at all. Fall back to the full list, which is
# newest-first and includes pre-releases.
latest_version() {
_v=$(curl -fsSL "${REPO_API}/releases/latest" 2>/dev/null | first_tag)
[ -n "$_v" ] || _v=$(curl -fsSL "${REPO_API}/releases?limit=1" 2>/dev/null | first_tag)
printf '%s\n' "$_v"
}
first_tag() {
sed -n 's/.*"tag_name" *: *"v\{0,1\}\([^"]*\)".*/\1/p' | head -1
}
sha256_of() {
if have sha256sum; then sha256sum "$1" | cut -d' ' -f1
elif have shasum; then shasum -a 256 "$1" | cut -d' ' -f1
else return 1
fi
}
# download the toolchain tarball for this platform into $TMP, verify it against
# the published checksum, and echo its path. Silent failure (returns 1) means
# "no prebuilt for this platform" — the caller falls back to source.
download_prebuilt() {
_name="ludic-${VERSION}-${OS}-${ARCH}.tar.gz"
_url="${REPO_URL}/releases/download/v${VERSION}/${_name}"
_out="${TMP}/${_name}"
fetch "$_url" "$_out" 2>/dev/null || return 1
if fetch "${_url}.sha256" "${_out}.sha256" 2>/dev/null; then
_want=$(cut -d' ' -f1 < "${_out}.sha256")
_got=$(sha256_of "$_out" || true)
if [ -n "$_got" ] && [ "$_want" != "$_got" ]; then
die "checksum mismatch for ${_name} (expected ${_want}, got ${_got})"
fi
[ -n "$_got" ] || warn "no sha256 tool found; the download was not verified"
else
warn "no published checksum for ${_name}; the download was not verified"
fi
printf '%s\n' "$_out"
}
# ---- installing -------------------------------------------------------------
# Replace the install root with the staged tree. The swap is done at the end so
# an interrupted download never leaves a half-installed toolchain behind, and a
# working install is only removed once the new one is ready to take its place.
# Is this directory a usable install root? A toolchain is not just binaries: a
# game links against the engine runtime and resolves the bundled packages from
# here, so an artifact missing them installs something that cannot build
# anything. Older releases predate this layout, which is exactly the case that
# must degrade to a source build rather than to a broken ~/.ludic.
valid_root() {
[ -x "${1}/bin/ludic" ] && [ -x "${1}/bin/ludicc" ] \
&& [ -d "${1}/runtime/native" ] && [ -f "${1}/VERSION" ]
}
install_staged() {
_staged="$1"
valid_root "$_staged" || die "the staged toolchain is incomplete"
mkdir -p "$(dirname "$INSTALL_DIR")"
if [ -d "$INSTALL_DIR" ]; then
rm -rf "${INSTALL_DIR}.old"
mv "$INSTALL_DIR" "${INSTALL_DIR}.old"
fi
mv "$_staged" "$INSTALL_DIR"
rm -rf "${INSTALL_DIR}.old"
}
# Build the toolchain from the source of a tag: clang assembles the compiler's
# own IR seed, that compiler builds the CLI, and the CLI builds the rest. This
# is the only step Ludic cannot do for itself, and it is also the whole story of
# how the language bootstraps.
install_from_source() {
have git || die "installing from source needs git"
have clang || have cc || die "installing from source needs clang. ${CC_HINT}"
step "building from source (no prebuilt toolchain for ${OS}-${ARCH})"
_src="${TMP}/src"
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"
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"
_stage="${TMP}/stage"
mkdir -p "${_stage}/bin"
cp "${_src}"/bin/ludic "${_src}"/bin/ludicc "${_src}"/bin/ludic-fmt "${_src}"/bin/ludic-lsp "${_stage}/bin/"
cp -R "${_src}/runtime" "${_stage}/runtime"
cp -R "${_src}/packages" "${_stage}/packages"
cp "${_src}/VERSION" "${_src}/LICENSE" "${_src}/README.md" "${_stage}/"
install_staged "$_stage"
}
# ---- PATH -------------------------------------------------------------------
# The shell profiles worth touching: the login shell's, plus ~/.profile as the
# catch-all. Appending a guarded line is the least surprising thing an installer
# can do to someone's dotfiles, and it is easy to find and delete.
profiles() {
case "${SHELL:-}" in
*/zsh) printf '%s\n' "${ZDOTDIR:-$HOME}/.zshrc" ;;
*/bash) printf '%s\n' "$HOME/.bashrc" ;;
*/fish) printf '%s\n' "$HOME/.config/fish/config.fish" ;;
esac
[ -f "$HOME/.profile" ] && printf '%s\n' "$HOME/.profile"
return 0
}
add_to_path() {
_bin="${INSTALL_DIR}/bin"
case ":${PATH}:" in *":${_bin}:"*) return 0 ;; esac
[ "$MODIFY_PATH" -eq 1 ] || return 0
_added=''
for _p in $(profiles); do
[ -e "$_p" ] || continue
if grep -q 'ludic/bin\|LUDIC_INSTALL' "$_p" 2>/dev/null; then _added="$_p"; continue; fi
case "$_p" in
*/config.fish) printf '\n# Ludic\nset -gx PATH %s $PATH\n' "$_bin" >> "$_p" ;;
*) printf '\n# Ludic\nexport PATH="%s:$PATH"\n' "$_bin" >> "$_p" ;;
esac
_added="$_p"
done
PATH_NOTE="$_added"
}
# ---- go ---------------------------------------------------------------------
TMP=$(mktemp -d "${TMPDIR:-/tmp}/ludic-install.XXXXXX")
trap 'rm -rf "$TMP"' EXIT INT TERM
if [ -z "$VERSION" ]; then
step "looking up the latest release"
VERSION=$(latest_version || true)
[ -n "$VERSION" ] || die "could not determine the latest version (set one with --version X.Y.Z)"
fi
say " ludic ${B}${VERSION}${R} ${DIM}${OS}-${ARCH} -> ${INSTALL_DIR}${R}"
if [ "$FROM_SOURCE" -eq 1 ]; then
install_from_source
else
step "downloading the toolchain"
if TARBALL=$(download_prebuilt); then
STAGE="${TMP}/stage"
mkdir -p "$STAGE"
tar -xzf "$TARBALL" -C "$STAGE"
# A current artifact unpacks to one top-level directory that is the install
# root; an older one unpacks its contents directly. Try both, and if neither
# is a usable root the release predates this layout — build from source
# instead of installing something that cannot compile a game.
ROOTDIR=""
if valid_root "$STAGE"; then
ROOTDIR="$STAGE"
else
for _d in "$STAGE"/*; do
if [ -d "$_d" ] && valid_root "$_d"; then ROOTDIR="$_d"; break; fi
done
fi
if [ -n "$ROOTDIR" ]; then
install_staged "$ROOTDIR"
else
warn "the ${VERSION} artifact for ${OS}-${ARCH} is not a complete toolchain; building from source"
install_from_source
fi
else
install_from_source
fi
fi
PATH_NOTE=''
add_to_path
# ---- report -----------------------------------------------------------------
INSTALLED=$("${INSTALL_DIR}/bin/ludic" version 2>/dev/null || echo "ludic ${VERSION}")
say ""
say "${GREEN}installed${R} ${INSTALLED} -> ${INSTALL_DIR}"
if [ -n "$CC_HINT" ]; then
say ""
warn "no clang found. Ludic emits LLVM IR and needs clang to assemble and link it."
say " ${CC_HINT}"
fi
if [ "$OS" != darwin ]; then
say ""
say " ${DIM}note: windowing is macOS-only today. On ${OS}, programs build and run"
say " headless (ludic build --headless); a window will not open yet.${R}"
fi
say ""
if [ -n "$PATH_NOTE" ]; then
say " ${DIM}added ${INSTALL_DIR}/bin to your PATH in ${PATH_NOTE} — open a new shell, or:${R}"
say " export PATH=\"${INSTALL_DIR}/bin:\$PATH\""
say ""
else
case ":${PATH}:" in
*":${INSTALL_DIR}/bin:"*) ;;
*)
say " ${DIM}add this to your shell profile:${R}"
say " export PATH=\"${INSTALL_DIR}/bin:\$PATH\""
say ""
;;
esac
fi
say "next:"
say " ${B}ludic new mygame${R} create a project"
say " ${B}cd mygame && ludic run${R} build it and play"
say " ${B}ludic help${R} everything else"

View file

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

View file

@ -21,14 +21,21 @@ tools/editors/
neovim/ helix/ emacs/ sublime/ zed/ configuration, no plugin needed neovim/ helix/ emacs/ sublime/ zed/ configuration, no plugin needed
``` ```
## Build it ## Getting the server
An installed toolchain already has it: `curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh`
puts `ludic-lsp` and `ludic-fmt` in `~/.ludic/bin`, on your `PATH`, and
`ludic lsp` runs the server on stdio — that is what an editor should spawn, since
it needs no path configuration.
From a checkout, build them with:
```bash ```bash
bin/x tools bin/ludic dev tools
``` ```
Produces `bin/ludic-fmt` and `bin/ludic-lsp`. Add `--install` to symlink both Produces `bin/ludic-fmt` and `bin/ludic-lsp`. Add `--install` to symlink both
into `~/.local/bin`, `--test` to run `bin/x test-tools` afterwards. into `~/.local/bin`, `--test` to run `bin/ludic dev test-tools` afterwards.
## What you get, in any editor ## What you get, in any editor
@ -63,11 +70,14 @@ and otherwise keywords, builtins, and everything in scope.
- **Anything else** that speaks LSP: - **Anything else** that speaks LSP:
``` ```
command: bin/ludic-lsp --stdio command: ludic lsp
languages: ludic, markdown languages: ludic, markdown
initializationOptions: { "compilerPath": "bin/ludicc", "compilerDiagnostics": true, "indentSize": 2 } initializationOptions: { "compilerDiagnostics": true, "indentSize": 2 }
``` ```
(`ludic lsp` is on `PATH` after an install; from a checkout it is
`bin/ludic lsp`, and the server finds `ludicc` next to itself.)
## Markdown ## Markdown
Markdown fenced-code highlighting is not one standard — it is three mechanisms, Markdown fenced-code highlighting is not one standard — it is three mechanisms,
@ -108,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, 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 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/x tools`. blocks a commit on a machine that has not run `bin/ludic dev tools`.
## Why the formatter is not `ludicc --fmt` ## Why the formatter is not `ludicc --fmt`
@ -130,7 +140,7 @@ both spellings are what the language documents and uses:
(`const R_DIR: int = 0 # 0 up`) survive a save; (`const R_DIR: int = 0 # 0 up`) survive a save;
- `id=Root` inside a `ui` block and `{Enemy}` inside a query stay tight. - `id=Root` inside a `ui` block and `{Enemy}` inside a query stay tight.
`bin/x 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 the tree and re-running the *compiler's* canonical dump produces byte-identical
output. The formatter cannot change what a program means. output. The formatter cannot change what a program means.
@ -139,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 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 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 JetBrains lexer (Kotlin). Adding a builtin and forgetting the rest is silent
failure, so `bin/x check-vocabulary` (written in Ludic) compares all five, and failure, so `bin/ludic dev check-vocabulary` (written in Ludic) compares all five, and
`bin/x test-tools` runs it. `bin/ludic dev test-tools` runs it.
When you add a keyword or builtin: put it in `ludic_syntax.h`, then run When you add a keyword or builtin: put it in `ludic_syntax.h`, then run
`bin/x 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, so expect it to take a while; later builds take seconds. Once the cache is warm,
```bash ```bash
LUDIC_TEST_JETBRAINS=1 bin/x test-tools LUDIC_TEST_JETBRAINS=1 bin/ludic dev test-tools
``` ```
includes the plugin build in the toolchain's own test run. 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 **Settings -> Languages & Frameworks -> Ludic** — the path to `ludic-lsp` and to
`ludicc`. Both default to `bin/` under the project root, which is where `ludicc`. Both default to `bin/` under the project root, which is where
`bin/x 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 * Where to find the toolchain. Both paths may be left blank, in which case the
* plugin looks under the project root for what `bin/x 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. * which is the case for anyone working in the Ludic repository itself.
*/ */
@Service(Service.Level.PROJECT) @Service(Service.Level.PROJECT)
@ -60,13 +60,23 @@ internal fun findExecutable(root: String?, configured: String, names: List<Strin
/** Fall back to PATH, so a system-wide install works with no configuration. */ /** Fall back to PATH, so a system-wide install works with no configuration. */
private fun searchPath(names: List<String>): String? { private fun searchPath(names: List<String>): String? {
val path = System.getenv("PATH") ?: return null val path = System.getenv("PATH")
if (path != null) {
for (dir in path.split(File.pathSeparator)) { for (dir in path.split(File.pathSeparator)) {
for (name in names) { for (name in names) {
val f = File(dir, name) val f = File(dir, name)
if (f.isFile && f.canExecute()) return f.absolutePath if (f.isFile && f.canExecute()) return f.absolutePath
} }
} }
}
// The install root, last: an IDE launched from the desktop does not inherit
// the PATH the installer wrote to the shell profile, so a working ~/.ludic
// install would otherwise look missing.
val home = System.getenv("LUDIC_HOME") ?: (System.getProperty("user.home") + "/.ludic")
for (name in names) {
val f = File(File(home, "bin"), name)
if (f.isFile && f.canExecute()) return f.absolutePath
}
return null return null
} }
@ -108,9 +118,10 @@ class LudicConnectionProvider(private val project: Project) : ProcessStreamConne
append(if (root != null) File(File(root, d), "ludic-lsp").path else "<no project root>/$d/ludic-lsp") append(if (root != null) File(File(root, d), "ludic-lsp").path else "<no project root>/$d/ludic-lsp")
append('\n') append('\n')
} }
append(" and on PATH.\n") append(" on PATH, and in ~/.ludic/bin.\n")
} }
append("\nBuild it with `bin/x tools` from the repository root, ") 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("or set the path in Settings | Languages & Frameworks | Ludic. ") append("or set the path in Settings | Languages & Frameworks | Ludic. ")
append("Syntax highlighting works without it; completion and diagnostics do not.") append("Syntax highlighting works without it; completion and diagnostics do not.")
} }

View file

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

View file

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

View file

@ -38,9 +38,21 @@ function resolveServer(folders) {
if (isExecutable(p)) return p; if (isExecutable(p)) return p;
} }
} }
// The install root, checked last: an editor launched from Finder or a desktop
// launcher does not inherit the PATH the installer wrote to the shell profile,
// so a perfectly good ~/.ludic install would otherwise look missing.
for (const name of names) {
const p = path.join(installRoot(), 'bin', name);
if (isExecutable(p)) return p;
}
return null; return null;
} }
/** The toolchain install root: $LUDIC_HOME, else ~/.ludic (what install.sh uses). */
function installRoot() {
return process.env.LUDIC_HOME || path.join(os.homedir(), '.ludic');
}
function resolveCompiler(folders) { function resolveCompiler(folders) {
const configured = vscode.workspace.getConfiguration('ludic').get('compilerPath'); const configured = vscode.workspace.getConfiguration('ludic').get('compilerPath');
if (configured) return expand(configured, folders); if (configured) return expand(configured, folders);
@ -54,6 +66,8 @@ function resolveCompiler(folders) {
const p = path.join(dir, 'ludicc'); const p = path.join(dir, 'ludicc');
if (isExecutable(p)) return p; if (isExecutable(p)) return p;
} }
const installed = path.join(installRoot(), 'bin', 'ludicc');
if (isExecutable(installed)) return installed;
return ''; return '';
} }
@ -84,7 +98,7 @@ async function start(context) {
// Not an error worth a modal: the grammar still highlights, and plenty of // Not an error worth a modal: the grammar still highlights, and plenty of
// people open a .ludic file without having built the toolchain. // people open a .ludic file without having built the toolchain.
output.appendLine( output.appendLine(
'ludic-lsp not found. Build it with `bin/x 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.' 'Syntax highlighting works without it; completion and diagnostics do not.'
); );
return; return;

100
tools/ludic-cli/build.ludic Normal file
View file

@ -0,0 +1,100 @@
# build.ludic — compiling: a user's program (ludic build / ludic run) and the
# toolchain's own binaries (ludic dev build).
#
# 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 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 {
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
}
# ---- 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.
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()
let pbf = prebuilt_link_flags() # #64: link resolved prebuilt module dylibs, if any
let odir = dir_of_path(out)
if len(odir) > 0 { run(`mkdir -p {odir}`) }
run("mkdir -p build")
let ll = `{out}.ll`
if mode == 2 {
if not shq(`{ludicc()} --headless {src} --emit-llvm -o {ll}`) { return false }
if not shq(`{cc()} -O2 {ll}{pbf} -o {out}`) { return false }
if not save { run(`rm -f {ll}`) }
return true
}
if not shq(`{ludicc()} --windowed {src} --emit-llvm -o {ll}`) { return false }
# audio.ll (#22) is always linked here — unused snd_* are dead-stripped; the
# canonical `ludicc -o` path links it only when Audio.* is used.
let cocoa = `{home}runtime/native/cocoa.ll`
let audio = `{home}runtime/native/audio.ll`
if not shq(`{cc()} -O2 {ll} {cocoa} {audio} -framework Cocoa -Wl,-needed_framework,GameController -Wl,-needed_framework,AVFoundation -Wl,-rpath,@loader_path{pbf} -o {out}`) { return false }
if not save { run(`rm -f {ll}`) }
return true
}
# the directory part of a path, without the trailing '/' ("" when there is none)
function dir_of_path(p: pointer) -> pointer {
var last = -1
var i = 0
while p[i] != 0 { if p[i] == '/' { last = i }; i += 1 }
if last < 0 { return "" }
return p[0..last]
}
# Remove every generated artifact and leave the tracked source untouched: the
# whole build/ tree (IR, objects, compiled apps, the headless render at
# 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`.
function cmd_clean() -> int {
run("rm -rf build")
run("rm -f out.ppm bin/*.tmp")
print("cleaned: build/ (incl. build/out.ppm) and stray artifacts (bin/ is left alone)")
return 0
}

View file

@ -177,7 +177,7 @@ function count_nl(s: pointer, upto: int) -> int {
return n return n
} }
# usage: x 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 { function cmd_check_docs() -> int {
ensure_ludicc() ensure_ludicc()
DC_OK = 0; DC_BAD = 0; DC_SKIP = 0; DC_FAILS = "" DC_OK = 0; DC_BAD = 0; DC_SKIP = 0; DC_FAILS = ""
@ -361,7 +361,7 @@ var CI_PROB: pointer = ""
var CI_NPROB: int = 0 var CI_NPROB: int = 0
function ci_problem(msg: pointer) -> void { CI_PROB = CI_PROB + " - " + msg + "\n"; CI_NPROB += 1 } function ci_problem(msg: pointer) -> void { CI_PROB = CI_PROB + " - " + msg + "\n"; CI_NPROB += 1 }
# usage: x check-impl # usage: ludic dev check-impl
function cmd_check_impl() -> int { function cmd_check_impl() -> int {
CI_PROB = ""; CI_NPROB = 0 CI_PROB = ""; CI_NPROB = 0
let allsrc = read_all_selfhost() let allsrc = read_all_selfhost()
@ -568,7 +568,7 @@ function set_union(a: []pointer, b: []pointer) -> []pointer {
return out return out
} }
# usage: x check-vocabulary # usage: ludic dev check-vocabulary
function cmd_check_vocab() -> int { function cmd_check_vocab() -> int {
CV_PROB = ""; CV_N = 0 CV_PROB = ""; CV_N = 0
let h = read_file("tools/ludic-tools/ludic_syntax.h") let h = read_file("tools/ludic-tools/ludic_syntax.h")
@ -702,10 +702,10 @@ function xml_valid(text: pointer) -> bool {
return sp == 0 return sp == 0
} }
# usage: x 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 { function cmd_lint_asset() -> int {
if arg_count() < 3 { err("usage: x lint-asset <file.json|file.xml>\n"); return 2 } if arg_total() < 3 { err("usage: ludic dev lint-asset <file.json|file.xml>\n"); return 2 }
let path = arg(2) let path = arg_n(2)
let t = read_file(path) let t = read_file(path)
if t == null { err("lint-asset: cannot read " + path + "\n"); return 1 } if t == null { err("lint-asset: cannot read " + path + "\n"); return 1 }
if s_index(path, ".json", 0) >= 0 { if s_index(path, ".json", 0) >= 0 {

View file

@ -1,17 +1,17 @@
# docgen.ludic — the Ludic documentation-site generator and its guards, ported # docgen.ludic — the Ludic documentation-site generator and its guards, ported
# off Python (gen.py / check.py / palette.py). Three x subcommands: # off Python (gen.py / check.py / palette.py). Three x subcommands:
# #
# x 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 # docs/language/colors/palette.json from the palette
# table below (the named-colour source of truth). # table below (the named-colour source of truth).
# x 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 # templates, emit every page + symbols.json + the
# highlighter + .nojekyll into DIR (default build/pages). # highlighter + .nojekyll into DIR (default build/pages).
# x 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 # 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 # html.escape / json.dumps / front-matter parsing closely enough to reproduce the
# Python generator's output byte-for-byte (verified against the oracle in x 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) ------------------ # ---- a growable byte buffer (avoids O(n^2) string building) ------------------
property Sb { data: pointer = null, len: int = 0, cap: int = 0 } property Sb { data: pointer = null, len: int = 0, cap: int = 0 }
@ -563,8 +563,8 @@ 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, "# 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, "# a compile error (color_lookup returns -1, which emit_expr reports).\n")
sb_puts(b, "#\n") sb_puts(b, "#\n")
sb_puts(b, "# GENERATED by `x 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/x/docgen.ludic. Edit the palette there and regenerate; do not hand-edit.\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")
sb_puts(b, "\n") sb_puts(b, "\n")
sb_puts(b, "function color_lookup(name: pointer) -> int {\n") sb_puts(b, "function color_lookup(name: pointer) -> int {\n")
@ -620,11 +620,11 @@ function palette_emit_json(path: pointer) -> bool {
return write_file(path, sb_str(b)) return write_file(path, sb_str(b))
} }
# `x docs-palette` rewrites the two tracked outputs; `x 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, # regenerates into scratch files and compares them to the tracked ones instead,
# so the drift guard judges the working tree, not git HEAD. # so the drift guard judges the working tree, not git HEAD.
function cmd_docs_palette() -> int { function cmd_docs_palette() -> int {
let check = arg_count() > 2 and arg(2) == "--check" let check = arg_total() > 2 and arg_n(2) == "--check"
palette_init() palette_init()
# guard: duplicate names # guard: duplicate names
let seen = new []pointer let seen = new []pointer
@ -640,8 +640,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_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 not palette_emit_json(json_out) { err("cannot write palette.json\n"); return 1 }
if check { 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: x 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: x 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`) print(`OK {string(len(pal_names))} colors`)
return 0 return 0

View file

@ -1,4 +1,4 @@
# docgen_check.ludic — `x 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 # 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 # 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 # 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 { function cmd_docs_check() -> int {
var site = "build/pages" var site = "build/pages"
if arg_count() >= 3 { site = arg(2) } if arg_total() >= 3 { site = arg_n(2) }
let problems = new []pointer let problems = new []pointer
let warnings = new []pointer let warnings = new []pointer
@ -102,6 +102,9 @@ function cmd_docs_check() -> int {
if not file_exists(`{site}/index.html`) { push(problems, "missing required file: index.html") } if not file_exists(`{site}/index.html`) { push(problems, "missing required file: index.html") }
if not file_exists(`{site}/api.html`) { push(problems, "missing required file: api.html") } if not file_exists(`{site}/api.html`) { push(problems, "missing required file: api.html") }
if not file_exists(`{site}/.nojekyll`) { push(problems, "missing required file: .nojekyll") } if not file_exists(`{site}/.nojekyll`) { push(problems, "missing required file: .nojekyll") }
# the landing page tells people to curl this; a site without it publishes a
# broken install command
if not file_exists(`{site}/install.sh`) { push(problems, "missing required file: install.sh") }
# 2) coverage + thin detection over docs/language/** # 2) coverage + thin detection over docs/language/**
let id2src = meta_new() let id2src = meta_new()

View file

@ -1,9 +1,9 @@
# docgen_gen.ludic — `x 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 # tools/docgen/gen.py. Parses docs/language/** (front-matter + bodies) and
# docs/site/*, reads the asset templates in tools/docgen/assets, and emits the # 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 # whole pages-branch payload (per-symbol pages, namespace/color/api pages, the
# landing page, ludic-highlight.js, symbols.json, .nojekyll) into --out. Output # landing page, ludic-highlight.js, symbols.json, .nojekyll) into --out. Output
# is byte-for-byte identical to the Python generator (gated in x 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) # 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. # 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 { function cmd_docs_gen() -> int {
var out = "build/pages" var out = "build/pages"
var i = 2 var i = 2
while i < arg_count() { while i < arg_total() {
let a = arg(i) let a = arg_n(i)
if a == "--out" { if a == "--out" {
i += 1 i += 1
if i < arg_count() { out = arg(i) } if i < arg_total() { out = arg_n(i) }
} }
i += 1 i += 1
} }
@ -1013,6 +1013,12 @@ function cmd_docs_gen() -> int {
jvput(root, "highlight", syms.sym) jvput(root, "highlight", syms.sym)
write_file(`{out}/symbols.json`, json_dumps(root, true, false)) write_file(`{out}/symbols.json`, json_dumps(root, true, false))
write_file(`{out}/.nojekyll`, "") write_file(`{out}/.nojekyll`, "")
# The install one-liner on the landing page points at this site, so the
# installer is published with it: the page and the script that page tells you
# to pipe into sh can then never be from different releases.
let inst = read_file("install.sh")
if inst == null { err("docs-gen: install.sh is missing (the landing page's one-liner serves it)\n"); return 1 }
write_file(`{out}/install.sh`, inst)
print(`docs generated -> {out}`) print(`docs generated -> {out}`)
print(` pages: {string(npages)}`) print(` pages: {string(npages)}`)
return 0 return 0

View file

@ -1,5 +1,5 @@
# lsp_test.ludic — drive bin/ludic-lsp over real LSP traffic against the real # lsp_test.ludic — drive bin/ludic-lsp over real LSP traffic against the real
# source tree (`x 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, # 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 # Neovim and the rest are exercising code paths that have been checked, because
# they all speak to this one binary. # they all speak to this one binary.

230
tools/ludic-cli/main.ludic Normal file
View file

@ -0,0 +1,230 @@
# ludic — the command-line interface to the Ludic toolchain. 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.
#
# Installing (macOS, Linux):
#
# curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh
#
# Building from a 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/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.
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")
print("")
print("usage: ludic <command> [arguments]")
print("")
print("start a game:")
print(" new <name> create a project that builds and runs as it stands")
print(" run [file] [--headless] build the project and run it")
print(" build [file] [--headless] [-o out]")
print(" compile to build/<name> (a native binary, nothing to ship beside it)")
print(" test [file...] compile and run the project's tests")
print(" clean remove build/")
print("")
print("packages:")
print(" add <module>[@version] add a dependency, then resolve + fetch + link")
print(" get resolve every package.ludic dependency and write the lock")
print(" update [module] bump a dependency (or all) to its latest published version")
print(" verify check every locked package against the store by content hash")
print(" vendor copy the resolved packages into ./vendor for offline builds")
print(" build-lib <module.ludic> compile a package's module to a prebuilt dylib in lib/<target>/")
print(" link-flags print the clang flags to link this project's prebuilt module dylibs")
print("")
print("the toolchain:")
print(" version print the toolchain version")
print(" upgrade [version] reinstall from the docs site (the same script that installed it)")
print(" doctor check that the install is complete and usable")
print(" fmt [paths...] format Ludic source in place")
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 {
let n = slen(s)
if n <= 6 { return false }
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.
function dispatch(cmd: pointer) -> int {
if (cmd == "new") { return cmd_new() }
if (cmd == "build") { return cmd_build() }
if (cmd == "run") { return cmd_run() }
if (cmd == "test") { return cmd_test() }
if (cmd == "clean") { return cmd_clean() }
if (cmd == "fmt") { return cmd_fmt() }
if (cmd == "lsp") { return cmd_lsp() }
if (cmd == "doctor") { return cmd_doctor() }
if (cmd == "upgrade") { return cmd_upgrade() }
if (cmd == "add") { return cmd_pkg_add() }
if (cmd == "get") { return cmd_pkg_get() }
if (cmd == "update") { return cmd_pkg_update() }
if (cmd == "verify") { return cmd_pkg_verify() }
if (cmd == "vendor") { return cmd_pkg_vendor() }
if (cmd == "build-lib") { return cmd_pkg_build_lib() }
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, "")) }
return -1
}
entry {
if (arg_count() < 2) { usage(); exit(1) }
let cmd = arg(1)
# `ludic mygame.ludic` — build and run that file
if is_ludic_file(cmd) {
let rc = cmd_run_file()
tmp_cleanup()
exit(rc)
}
let code = dispatch(cmd)
tmp_cleanup()
if code >= 0 { exit(code) }
err(`ludic: unknown command '{cmd}'\n`)
usage()
exit(1)
}
}

View file

@ -18,7 +18,7 @@
# infrastructure. A source package's Ludic is compiled together with the # infrastructure. A source package's Ludic is compiled together with the
# consumer via the module-root import fallback in the compiler (do_import); a # consumer via the module-root import fallback in the compiler (do_import); a
# prebuilt lib declares its shipped targets and is resolved against the build # prebuilt lib declares its shipped targets and is resolved against the build
# target. Everything is content-verified against the lockfile by `x verify`. # target. Everything is content-verified against the lockfile by `ludic verify`.
# ---- the manifest / lock model ---------------------------------------------- # ---- the manifest / lock model ----------------------------------------------
@ -419,7 +419,7 @@ function collision(sels: []Manifest) -> pointer {
# ---- lockfile writing -------------------------------------------------------- # ---- lockfile writing --------------------------------------------------------
function write_lock(sels: []Manifest) -> bool { function write_lock(sels: []Manifest) -> bool {
var body = "# package.lock.ludic — generated by `x get`. Do not edit by hand." + nl() var body = "# package.lock.ludic — generated by `ludic get`. Do not edit by hand." + nl()
body = body + "lock 1" + nl() body = body + "lock 1" + nl()
let sorted = sort_mans(sels) let sorted = sort_mans(sels)
var i = 0 var i = 0
@ -537,30 +537,30 @@ function set_require(module: pointer, ver: pointer) -> void {
# ---- the commands ------------------------------------------------------------ # ---- the commands ------------------------------------------------------------
# x add <module>[@version] — add/update a dependency then install # ludic add <module>[@version] — add/update a dependency then install
function cmd_pkg_add() -> int { function cmd_pkg_add() -> int {
if arg_count() < 3 { err("usage: x add <module>[@version]\n"); return 1 } if arg_total() < 3 { err("usage: ludic add <module>[@version]\n"); return 1 }
let spec = split_spec(arg(2)) let spec = split_spec(arg_n(2))
let module = spec[0] let module = spec[0]
var ver = spec[1] var ver = spec[1]
if slen(ver) == 0 { if slen(ver) == 0 {
ver = latest_version(module) ver = latest_version(module)
if slen(ver) == 0 { err(`x: {module} has no published version tags (git tag vX.Y.Z to publish)\n`); return 1 } if slen(ver) == 0 { err(`x: {module} has no published version tags (git tag vX.Y.Z to publish)\n`); return 1 }
print(`x add: {module} -> latest v{ver}`) print(`ludic add: {module} -> latest v{ver}`)
} }
set_require(module, ver) set_require(module, ver)
return do_install(read_root_manifest()) return do_install(read_root_manifest())
} }
# x get — resolve + fetch + link every dependency in package.ludic, write lock # ludic get — resolve + fetch + link every dependency in package.ludic, write lock
function cmd_pkg_get() -> int { function cmd_pkg_get() -> int {
let txt = read_file("package.ludic") let txt = read_file("package.ludic")
if txt == null { err("x: no package.ludic in the current directory (x add <module> to start one)\n"); return 1 } if txt == null { err("x: no package.ludic in the current directory (ludic add <module> to start one)\n"); return 1 }
print("resolving dependencies (MVS)…") print("resolving dependencies (MVS)…")
return do_install(parse_manifest(txt)) return do_install(parse_manifest(txt))
} }
# x update [module] — bump a dep (or all) to its latest published version, relock # ludic update [module] — bump a dep (or all) to its latest published version, relock
function cmd_pkg_update() -> int { function cmd_pkg_update() -> int {
let root = read_root_manifest() let root = read_root_manifest()
if len(root.deps) == 0 { err("x: package.ludic declares no dependencies\n"); return 1 } if len(root.deps) == 0 { err("x: package.ludic declares no dependencies\n"); return 1 }
@ -571,7 +571,7 @@ function cmd_pkg_update() -> int {
if only == "" or only == d.module { if only == "" or only == d.module {
let latest = latest_version(d.module) let latest = latest_version(d.module)
if slen(latest) > 0 and ver_gt(latest, d.ver) { if slen(latest) > 0 and ver_gt(latest, d.ver) {
print(`x update: {d.module} {d.ver} -> {latest}`) print(`ludic update: {d.module} {d.ver} -> {latest}`)
set_require(d.module, latest) set_require(d.module, latest)
} }
} }
@ -580,11 +580,11 @@ function cmd_pkg_update() -> int {
return do_install(read_root_manifest()) return do_install(read_root_manifest())
} }
# x verify — check every locked package against the store by content hash and # ludic verify — check every locked package against the store by content hash and
# confirm the project view links to it # confirm the project view links to it
function cmd_pkg_verify() -> int { function cmd_pkg_verify() -> int {
let txt = read_file("package.lock.ludic") let txt = read_file("package.lock.ludic")
if txt == null { err("x: no package.lock.ludic (run x get first)\n"); return 1 } if txt == null { err("x: no package.lock.ludic (run ludic get first)\n"); return 1 }
let locked = parse_lock(txt) let locked = parse_lock(txt)
if len(locked) == 0 { print("lockfile lists no packages"); return 0 } if len(locked) == 0 { print("lockfile lists no packages"); return 0 }
var bad_count = 0 var bad_count = 0
@ -602,7 +602,7 @@ function cmd_pkg_verify() -> int {
if got == raw { if got == raw {
let link = `ludic_modules/{m.module}` let link = `ludic_modules/{m.module}`
if file_exists(link) { print(` ok {m.module}@{m.ver}`) } if file_exists(link) { print(` ok {m.module}@{m.ver}`) }
else { print(` UNLINKED {m.module}@{m.ver} (ludic_modules view missing — run x get)`); bad_count += 1 } else { print(` UNLINKED {m.module}@{m.ver} (ludic_modules view missing — run ludic get)`); bad_count += 1 }
} else { } else {
print(` TAMPERED {m.module}@{m.ver} (want sha256:{sslice(raw, 0, 12)}… got sha256:{sslice(got, 0, 12)}…)`) print(` TAMPERED {m.module}@{m.ver} (want sha256:{sslice(raw, 0, 12)}… got sha256:{sslice(got, 0, 12)}…)`)
bad_count += 1 bad_count += 1
@ -615,11 +615,11 @@ function cmd_pkg_verify() -> int {
return 1 return 1
} }
# x vendor — copy the resolved packages into ./vendor for hermetic/offline # ludic vendor — copy the resolved packages into ./vendor for hermetic/offline
# builds. Build against them with LUDIC_MODULES=vendor. # builds. Build against them with LUDIC_MODULES=vendor.
function cmd_pkg_vendor() -> int { function cmd_pkg_vendor() -> int {
let txt = read_file("package.lock.ludic") let txt = read_file("package.lock.ludic")
if txt == null { err("x: no package.lock.ludic (run x get first)\n"); return 1 } if txt == null { err("x: no package.lock.ludic (run ludic get first)\n"); return 1 }
let locked = parse_lock(txt) let locked = parse_lock(txt)
run("rm -rf vendor") run("rm -rf vendor")
var i = 0 var i = 0
@ -627,7 +627,7 @@ function cmd_pkg_vendor() -> int {
let m = locked[i] let m = locked[i]
let raw = strip_prefix(m.hash, "sha256:") let raw = strip_prefix(m.hash, "sha256:")
let dest = `{store_root()}{raw}` let dest = `{store_root()}{raw}`
if not file_exists(dest) { err(`x: {m.module}@{m.ver} not in the store — run x get\n`); return 1 } if not file_exists(dest) { err(`x: {m.module}@{m.ver} not in the store — run ludic get\n`); return 1 }
let vdir = `vendor/{m.module}` let vdir = `vendor/{m.module}`
run(`mkdir -p "$(dirname {vdir})"`) run(`mkdir -p "$(dirname {vdir})"`)
run(`cp -R {dest} {vdir}`) run(`cp -R {dest} {vdir}`)
@ -640,7 +640,7 @@ function cmd_pkg_vendor() -> int {
# ---- prebuilt binary packages (issue #64) ----------------------------------- # ---- prebuilt binary packages (issue #64) -----------------------------------
# x build-lib [module.ludic] — compile a package's module to a prebuilt dylib for # ludic build-lib [module.ludic] — compile a package's module to a prebuilt dylib for
# the current target, under lib/<target>/. The developer runs this in the package # the current target, under lib/<target>/. The developer runs this in the package
# repo, commits the artifact, and `git tag`s — consumers fetch and link it. # repo, commits the artifact, and `git tag`s — consumers fetch and link it.
# The compiler is $LUDICC (default bin/ludicc) so this works both in-repo and, # The compiler is $LUDICC (default bin/ludicc) so this works both in-repo and,
@ -648,7 +648,7 @@ function cmd_pkg_vendor() -> int {
function cmd_pkg_build_lib() -> int { function cmd_pkg_build_lib() -> int {
var src = argn(2, "") var src = argn(2, "")
if src == "" { src = capture_line("ls *.ludic 2>/dev/null | grep -v package.ludic | head -1") } if src == "" { src = capture_line("ls *.ludic 2>/dev/null | grep -v package.ludic | head -1") }
if src == "" or not file_exists(src) { err("usage: x build-lib <module.ludic> (run in the package directory)\n"); return 1 } if src == "" or not file_exists(src) { err("usage: ludic build-lib <module.ludic> (run in the package directory)\n"); return 1 }
let man = read_root_manifest() let man = read_root_manifest()
var name = "" var name = ""
if man.module != "" { name = capture_line(`basename {man.module}`) } if man.module != "" { name = capture_line(`basename {man.module}`) }
@ -671,9 +671,9 @@ function cmd_pkg_build_lib() -> int {
return 0 return 0
} }
# x link-flags — print the clang link flags a consumer build needs to pull in the # ludic link-flags — print the clang link flags a consumer build needs to pull in the
# project's prebuilt module dylibs (for the current target). Portable escape from # project's prebuilt module dylibs (for the current target). Portable escape from
# `x app` (which is in-repo only): any build system can splice `$(x link-flags)` # `ludic build` (which is in-repo only): any build system can splice `$(ludic link-flags)`
# into its link step. # into its link step.
function cmd_pkg_link_flags() -> int { function cmd_pkg_link_flags() -> int {
let f = prebuilt_link_flags() let f = prebuilt_link_flags()

View file

@ -3,7 +3,7 @@
# It stands up throwaway git repositories under /tmp and points the package # It stands up throwaway git repositories under /tmp and points the package
# manager at them with LUDIC_PKG_PROXY, so the whole fetch → MVS resolve → # manager at them with LUDIC_PKG_PROXY, so the whole fetch → MVS resolve →
# content-addressed store → project link → compile → run → verify pipeline runs # content-addressed store → project link → compile → run → verify pipeline runs
# with no network. Every check drives a fresh `bin/x` / `bin/ludicc` process the # with no network. Every check drives a fresh `bin/ludic` / `bin/ludicc` process the
# way a real project would. # way a real project would.
# write `body` to `path`, creating parent directories # write `body` to `path`, creating parent directories
@ -63,11 +63,11 @@ function cmd_test_pkg() -> int {
let envp = `LUDIC_PKG_PROXY={proxy} LUDIC_STORE={store}` let envp = `LUDIC_PKG_PROXY={proxy} LUDIC_STORE={store}`
# ---- x get: resolve, fetch, store, link, lock ----------------------------- # ---- ludic get: resolve, fetch, store, link, lock -----------------------------
if shq(`( cd {proj} && {envp} {root}/bin/x get > {work}/get.out 2>&1 )`) { if shq(`( cd {proj} && {envp} {root}/bin/ludic get > {work}/get.out 2>&1 )`) {
ok("x get resolves + fetches the dependency graph") ok("ludic get resolves + fetches the dependency graph")
} else { } else {
bad2("x get failed", capture_line(`tail -2 {work}/get.out`)) bad2("ludic get failed", capture_line(`tail -2 {work}/get.out`))
} }
var lock = read_file(`{proj}/package.lock.ludic`) var lock = read_file(`{proj}/package.lock.ludic`)
@ -98,23 +98,23 @@ function cmd_test_pkg() -> int {
bad2("consumer build failed", capture_line(`tail -2 {work}/build.out`)) bad2("consumer build failed", capture_line(`tail -2 {work}/build.out`))
} }
# ---- x verify: content integrity against the store ------------------------ # ---- ludic verify: content integrity against the store ------------------------
if shq(`( cd {proj} && {envp} {root}/bin/x verify > {work}/verify.out 2>&1 )`) { if shq(`( cd {proj} && {envp} {root}/bin/ludic verify > {work}/verify.out 2>&1 )`) {
ok("x verify passes on an untouched store") ok("ludic verify passes on an untouched store")
} else { bad2("x verify failed unexpectedly", capture_line(`tail -2 {work}/verify.out`)) } } else { bad2("ludic verify failed unexpectedly", capture_line(`tail -2 {work}/verify.out`)) }
# tamper with a stored file — verify must now flag it # tamper with a stored file — verify must now flag it
let uhash = capture_line(`grep 'example.test/util' {proj}/package.lock.ludic | sed 's/.*sha256://; s/\".*//'`) let uhash = capture_line(`grep 'example.test/util' {proj}/package.lock.ludic | sed 's/.*sha256://; s/\".*//'`)
run(`echo tampered >> {store}/{uhash}/util.ludic`) run(`echo tampered >> {store}/{uhash}/util.ludic`)
if not shq(`( cd {proj} && {envp} {root}/bin/x verify > {work}/verify2.out 2>&1 )`) { if not shq(`( cd {proj} && {envp} {root}/bin/ludic verify > {work}/verify2.out 2>&1 )`) {
ok("x verify detects a tampered store entry") ok("ludic verify detects a tampered store entry")
} else { bad("x verify missed a tampered store entry") } } else { bad("ludic verify missed a tampered store entry") }
# heal the store: a content-addressed entry is keyed by its hash-named dir, so # heal the store: a content-addressed entry is keyed by its hash-named dir, so
# `x get` trusts an existing one — drop it first, then re-fetch a clean copy. # `ludic get` trusts an existing one — drop it first, then re-fetch a clean copy.
run(`rm -rf {store}/{uhash}`) run(`rm -rf {store}/{uhash}`)
run(`( cd {proj} && {envp} {root}/bin/x get > /dev/null 2>&1 )`) run(`( cd {proj} && {envp} {root}/bin/ludic get > /dev/null 2>&1 )`)
if shq(`( cd {proj} && {envp} {root}/bin/x verify > {work}/verify3.out 2>&1 )`) { if shq(`( cd {proj} && {envp} {root}/bin/ludic verify > {work}/verify3.out 2>&1 )`) {
ok("re-fetching heals a tampered store entry (x verify green again)") ok("re-fetching heals a tampered store entry (ludic verify green again)")
} else { bad2("store did not heal after re-fetch", capture_line(`tail -1 {work}/verify3.out`)) } } else { bad2("store did not heal after re-fetch", capture_line(`tail -1 {work}/verify3.out`)) }
# ---- a package that exports ECS: a component, a model and a system -------- # ---- a package that exports ECS: a component, a model and a system --------
@ -122,7 +122,7 @@ function cmd_test_pkg() -> int {
# consumer's compile-time ECS — no ABI seam. This proves a consumer *game* can # consumer's compile-time ECS — no ABI seam. This proves a consumer *game* can
# use a package's property (component), model, @OnSpawn handler (system) and a # use a package's property (component), model, @OnSpawn handler (system) and a
# plain function together. The engine runtime (runtime/native/*.ludic) resolves # plain function together. The engine runtime (runtime/native/*.ludic) resolves
# relative to the compile CWD, so — exactly like `x app` — the game is compiled # relative to the compile CWD, so — exactly like `ludic build` — the game is compiled
# from the repo root with the package linked in via an absolute LUDIC_MODULES. # from the repo root with the package linked in via an absolute LUDIC_MODULES.
let epkg = `{proxy}/example.test/enginepkg` let epkg = `{proxy}/example.test/enginepkg`
run(`mkdir -p {epkg} && git -C {epkg} init -q`) run(`mkdir -p {epkg} && git -C {epkg} init -q`)
@ -132,14 +132,14 @@ function cmd_test_pkg() -> int {
let proj2 = `{work}/proj2` let proj2 = `{work}/proj2`
pt_write(`{proj2}/package.ludic`, `package "game"` + nl() + `version "0.0.0"` + nl() + `require "example.test/enginepkg" "1.0.0"` + nl()) pt_write(`{proj2}/package.ludic`, `package "game"` + nl() + `version "0.0.0"` + nl() + `require "example.test/enginepkg" "1.0.0"` + nl())
pt_write(`{proj2}/game.ludic`, "program G {\n import \"example.test/enginepkg/lib.ludic\"\n entry {\n spawn Unit { Health { max: 50 } }\n let H = world_prop_id(\"Health\")\n let hp = world_field_id(H, \"hp\")\n let e = world_query_next(H, 0)\n print(world_get(e, H, hp))\n print(bonus())\n }\n}\n") pt_write(`{proj2}/game.ludic`, "program G {\n import \"example.test/enginepkg/lib.ludic\"\n entry {\n spawn Unit { Health { max: 50 } }\n let H = world_prop_id(\"Health\")\n let hp = world_field_id(H, \"hp\")\n let e = world_query_next(H, 0)\n print(world_get(e, H, hp))\n print(bonus())\n }\n}\n")
if shq(`( cd {proj2} && {envp} {root}/bin/x get > {work}/get2.out 2>&1 )`) { if shq(`( cd {proj2} && {envp} {root}/bin/ludic get > {work}/get2.out 2>&1 )`) {
if shq(`( cd {root} && LUDIC_MODULES={proj2}/ludic_modules {root}/bin/ludicc --headless {proj2}/game.ludic -o {proj2}/game > {work}/gbuild.out 2>&1 )`) { if shq(`( cd {root} && LUDIC_MODULES={proj2}/ludic_modules {root}/bin/ludicc --headless {proj2}/game.ludic -o {proj2}/game > {work}/gbuild.out 2>&1 )`) {
let got = capture_line(`{proj2}/game`) let got = capture_line(`{proj2}/game`)
if got == "50 7" { if got == "50 7" {
ok("a consumer game uses the package's component + model + system + function (\"50 7\")") ok("a consumer game uses the package's component + model + system + function (\"50 7\")")
} else { bad2("imported ECS produced wrong output", `got [{got}] want [50 7]`) } } else { bad2("imported ECS produced wrong output", `got [{got}] want [50 7]`) }
} else { bad2("game consuming an ECS package failed to build", capture_line(`tail -2 {work}/gbuild.out`)) } } else { bad2("game consuming an ECS package failed to build", capture_line(`tail -2 {work}/gbuild.out`)) }
} else { bad2("x get for the ECS package failed", capture_line(`tail -1 {work}/get2.out`)) } } else { bad2("ludic get for the ECS package failed", capture_line(`tail -1 {work}/get2.out`)) }
# ---- namespace collision policy (v1: hard error) -------------------------- # ---- namespace collision policy (v1: hard error) --------------------------
let dupe = `{proxy}/example.test/dupe` let dupe = `{proxy}/example.test/dupe`
@ -147,7 +147,7 @@ function cmd_test_pkg() -> int {
pt_write(`{dupe}/package.ludic`, `package "example.test/dupe"` + nl() + `version "1.0.0"` + nl() + "kind source" + nl() + `provides "Util"` + nl()) pt_write(`{dupe}/package.ludic`, `package "example.test/dupe"` + nl() + `version "1.0.0"` + nl() + "kind source" + nl() + `provides "Util"` + nl())
pt_write(`{dupe}/dupe.ludic`, "function dupe_tag() -> pointer { return \"d\" }\n") pt_write(`{dupe}/dupe.ludic`, "function dupe_tag() -> pointer { return \"d\" }\n")
pt_commit_tag(dupe, "v1.0.0") pt_commit_tag(dupe, "v1.0.0")
if not shq(`( cd {proj} && {envp} {root}/bin/x add example.test/dupe@1.0.0 > {work}/dupe.out 2>&1 )`) and shq(`grep -q collision {work}/dupe.out`) { if not shq(`( cd {proj} && {envp} {root}/bin/ludic add example.test/dupe@1.0.0 > {work}/dupe.out 2>&1 )`) and shq(`grep -q collision {work}/dupe.out`) {
ok("two packages claiming the same Foo.* namespace is a hard error") ok("two packages claiming the same Foo.* namespace is a hard error")
} else { bad2("collision not rejected", capture_line(`tail -1 {work}/dupe.out`)) } } else { bad2("collision not rejected", capture_line(`tail -1 {work}/dupe.out`)) }
# drop the bad dep again so the manifest is clean # drop the bad dep again so the manifest is clean
@ -159,16 +159,16 @@ function cmd_test_pkg() -> int {
pt_write(`{pb}/package.ludic`, `package "example.test/prebuilt"` + nl() + `version "1.0.0"` + nl() + "kind prebuilt" + nl() + `provides "Blob"` + nl() + `targets "made-up-target"` + nl()) pt_write(`{pb}/package.ludic`, `package "example.test/prebuilt"` + nl() + `version "1.0.0"` + nl() + "kind prebuilt" + nl() + `provides "Blob"` + nl() + `targets "made-up-target"` + nl())
pt_write(`{pb}/README`, "a prebuilt lib shipping no artifact for this host\n") pt_write(`{pb}/README`, "a prebuilt lib shipping no artifact for this host\n")
pt_commit_tag(pb, "v1.0.0") pt_commit_tag(pb, "v1.0.0")
if not shq(`( cd {proj} && {envp} {root}/bin/x add example.test/prebuilt@1.0.0 > {work}/pb.out 2>&1 )`) and shq(`grep -q 'prebuilt lib' {work}/pb.out`) { if not shq(`( cd {proj} && {envp} {root}/bin/ludic add example.test/prebuilt@1.0.0 > {work}/pb.out 2>&1 )`) and shq(`grep -q 'prebuilt lib' {work}/pb.out`) {
ok("a prebuilt lib with no artifact for the build target is a hard error") ok("a prebuilt lib with no artifact for the build target is a hard error")
} else { bad2("prebuilt target matrix not enforced", capture_line(`tail -1 {work}/pb.out`)) } } else { bad2("prebuilt target matrix not enforced", capture_line(`tail -1 {work}/pb.out`)) }
pt_write(`{proj}/package.ludic`, `package "app"` + nl() + `version "0.0.0"` + nl() + `require "example.test/greeter" "1.0.0"` + nl() + `require "example.test/util" "1.2.0"` + nl()) pt_write(`{proj}/package.ludic`, `package "app"` + nl() + `version "0.0.0"` + nl() + `require "example.test/greeter" "1.0.0"` + nl() + `require "example.test/util" "1.2.0"` + nl())
# ---- x vendor: hermetic copy --------------------------------------------- # ---- ludic vendor: hermetic copy ---------------------------------------------
run(`( cd {proj} && {envp} {root}/bin/x get > /dev/null 2>&1 )`) run(`( cd {proj} && {envp} {root}/bin/ludic get > /dev/null 2>&1 )`)
if shq(`( cd {proj} && {envp} {root}/bin/x vendor > {work}/vendor.out 2>&1 )`) and file_exists(`{proj}/vendor/example.test/util/util.ludic`) { if shq(`( cd {proj} && {envp} {root}/bin/ludic vendor > {work}/vendor.out 2>&1 )`) and file_exists(`{proj}/vendor/example.test/util/util.ludic`) {
ok("x vendor copies the resolved packages into ./vendor") ok("ludic vendor copies the resolved packages into ./vendor")
} else { bad2("x vendor failed", capture_line(`tail -1 {work}/vendor.out`)) } } else { bad2("ludic vendor failed", capture_line(`tail -1 {work}/vendor.out`)) }
# ---- package-declarable engine-system + namespace (issue #62) ------------- # ---- package-declarable engine-system + namespace (issue #62) -------------
# A source package registers a compile-time engine system (@EngineSystem) and a # A source package registers a compile-time engine system (@EngineSystem) and a
@ -182,7 +182,7 @@ function cmd_test_pkg() -> int {
let proj4 = `{work}/proj4` let proj4 = `{work}/proj4`
pt_write(`{proj4}/package.ludic`, `package "game"` + nl() + `version "0.0.0"` + nl() + `require "example.test/coachpack" "1.0.0"` + nl()) pt_write(`{proj4}/package.ludic`, `package "game"` + nl() + `version "0.0.0"` + nl() + `require "example.test/coachpack" "1.0.0"` + nl())
pt_write(`{proj4}/game.ludic`, "program Game {\n import \"example.test/coachpack/coach.ludic\"\n model Player { Score }\n @OnSpawn(Player) handler Init { }\n var frames: int = 0\n handler Boot phase Start { let e = world_spawn(world_model_id(\"Player\")) }\n handler Run phase Update {\n frames = frames + 1\n if frames >= 5 {\n let P = world_prop_id(\"Score\")\n let e = world_query_next(P, 0)\n print(world_get(e, P, 0))\n print(Coach.bonus())\n quit()\n }\n }\n}\n") pt_write(`{proj4}/game.ludic`, "program Game {\n import \"example.test/coachpack/coach.ludic\"\n model Player { Score }\n @OnSpawn(Player) handler Init { }\n var frames: int = 0\n handler Boot phase Start { let e = world_spawn(world_model_id(\"Player\")) }\n handler Run phase Update {\n frames = frames + 1\n if frames >= 5 {\n let P = world_prop_id(\"Score\")\n let e = world_query_next(P, 0)\n print(world_get(e, P, 0))\n print(Coach.bonus())\n quit()\n }\n }\n}\n")
if shq(`( cd {proj4} && {envp} {root}/bin/x get > {work}/get4.out 2>&1 )`) { if shq(`( cd {proj4} && {envp} {root}/bin/ludic get > {work}/get4.out 2>&1 )`) {
run(`LUDIC_HOME={root} LUDIC_MODULES={proj4}/ludic_modules {root}/bin/ludicc --headless {proj4}/game.ludic -o {proj4}/game 2>{work}/gc4.err`) run(`LUDIC_HOME={root} LUDIC_MODULES={proj4}/ludic_modules {root}/bin/ludicc --headless {proj4}/game.ludic -o {proj4}/game 2>{work}/gc4.err`)
if file_exists(`{proj4}/game`) { if file_exists(`{proj4}/game`) {
let got = capture_line(`printf 'aaaaa' | {proj4}/game`) let got = capture_line(`printf 'aaaaa' | {proj4}/game`)
@ -190,10 +190,10 @@ function cmd_test_pkg() -> int {
ok("a package registers an engine-system (@EngineSystem) + a namespace (@Namespace) with no compiler edit (\"4 99\")") ok("a package registers an engine-system (@EngineSystem) + a namespace (@Namespace) with no compiler edit (\"4 99\")")
} else { bad2("package hooks produced wrong output", `got [{got}] want [4 99]`) } } else { bad2("package hooks produced wrong output", `got [{got}] want [4 99]`) }
} else { bad2("consumer of a @EngineSystem/@Namespace package failed to build", capture_line(`tail -1 {work}/gc4.err`)) } } else { bad2("consumer of a @EngineSystem/@Namespace package failed to build", capture_line(`tail -1 {work}/gc4.err`)) }
} else { bad2("x get for the coach package failed", capture_line(`tail -1 {work}/get4.out`)) } } else { bad2("ludic get for the coach package failed", capture_line(`tail -1 {work}/get4.out`)) }
# ---- prebuilt binary package (issue #64): exported function + system + component # ---- prebuilt binary package (issue #64): exported function + system + component
# A module is compiled to a dylib (x build-lib), fetched as a `kind prebuilt` # A module is compiled to a dylib (ludic build-lib), fetched as a `kind prebuilt`
# dependency, and linked into a consumer game that never saw its source. At load # dependency, and linked into a consumer game that never saw its source. At load
# the module registers a dynamic component and an @System(Update); the consumer # the module registers a dynamic component and an @System(Update); the consumer
# also calls an exported function via `extern`. Native/dylib only → macOS-gated. # also calls an exported function via `extern`. Native/dylib only → macOS-gated.
@ -206,21 +206,21 @@ function cmd_test_pkg() -> int {
run(`mkdir -p {mp} && git -C {mp} init -q`) run(`mkdir -p {mp} && git -C {mp} init -q`)
pt_write(`{mp}/package.ludic`, `package "example.test/manapack"` + nl() + `version "1.0.0"` + nl() + "kind prebuilt" + nl() + `provides "Mana"` + nl() + `targets "{tgt}"` + nl()) pt_write(`{mp}/package.ludic`, `package "example.test/manapack"` + nl() + `version "1.0.0"` + nl() + "kind prebuilt" + nl() + `provides "Mana"` + nl() + `targets "{tgt}"` + nl())
pt_write(`{mp}/manapack.ludic`, "program ManaPack {\n function module_init() -> void { world_register_prop(\"Mana\", 2) }\n @System(Update) function mana_regen() -> void {\n let M = world_prop_id(\"Mana\")\n let e = world_query_next(M, 0)\n if e >= 0 { let v = world_get(e, M, 0); world_set(e, M, 0, v + 1) }\n }\n function mana_bonus() -> int { return 42 }\n}\n") pt_write(`{mp}/manapack.ludic`, "program ManaPack {\n function module_init() -> void { world_register_prop(\"Mana\", 2) }\n @System(Update) function mana_regen() -> void {\n let M = world_prop_id(\"Mana\")\n let e = world_query_next(M, 0)\n if e >= 0 { let v = world_get(e, M, 0); world_set(e, M, 0, v + 1) }\n }\n function mana_bonus() -> int { return 42 }\n}\n")
if shq(`( cd {mp} && LUDICC={root}/bin/ludicc {root}/bin/x build-lib manapack.ludic > {work}/bl.out 2>&1 )`) and file_exists(`{mp}/lib/{tgt}/libmanapack.dylib`) { if shq(`( cd {mp} && LUDICC={root}/bin/ludicc {root}/bin/ludic build-lib manapack.ludic > {work}/bl.out 2>&1 )`) and file_exists(`{mp}/lib/{tgt}/libmanapack.dylib`) {
ok("x build-lib compiles a package module to a per-target dylib") ok("ludic build-lib compiles a package module to a per-target dylib")
} else { bad2("x build-lib failed", capture_line(`tail -1 {work}/bl.out`)) } } else { bad2("ludic build-lib failed", capture_line(`tail -1 {work}/bl.out`)) }
pt_commit_tag(mp, "v1.0.0") pt_commit_tag(mp, "v1.0.0")
let proj3 = `{work}/proj3` let proj3 = `{work}/proj3`
pt_write(`{proj3}/package.ludic`, `package "game"` + nl() + `version "0.0.0"` + nl() + `require "example.test/manapack" "1.0.0"` + nl()) pt_write(`{proj3}/package.ludic`, `package "game"` + nl() + `version "0.0.0"` + nl() + `require "example.test/manapack" "1.0.0"` + nl())
pt_write(`{proj3}/game.ludic`, "program Game {\n property Dummy { x: int = 0 }\n model Ent { Dummy }\n @OnSpawn(Ent) handler Init { }\n extern function mana_bonus() -> int = \"fn_mana_bonus\"\n var frames: int = 0\n handler Boot phase Start {\n let e = world_spawn(world_model_id(\"Ent\"))\n let M = world_prop_id(\"Mana\")\n world_attach_dyn(e, M)\n world_set(e, M, 0, 0)\n }\n handler Run phase Update {\n frames = frames + 1\n if frames >= 4 {\n let M = world_prop_id(\"Mana\")\n let e = world_query_next(M, 0)\n print(world_get(e, M, 0))\n print(mana_bonus())\n quit()\n }\n }\n}\n") pt_write(`{proj3}/game.ludic`, "program Game {\n property Dummy { x: int = 0 }\n model Ent { Dummy }\n @OnSpawn(Ent) handler Init { }\n extern function mana_bonus() -> int = \"fn_mana_bonus\"\n var frames: int = 0\n handler Boot phase Start {\n let e = world_spawn(world_model_id(\"Ent\"))\n let M = world_prop_id(\"Mana\")\n world_attach_dyn(e, M)\n world_set(e, M, 0, 0)\n }\n handler Run phase Update {\n frames = frames + 1\n if frames >= 4 {\n let M = world_prop_id(\"Mana\")\n let e = world_query_next(M, 0)\n print(world_get(e, M, 0))\n print(mana_bonus())\n quit()\n }\n }\n}\n")
if shq(`( cd {proj3} && {envp} {root}/bin/x get > {work}/get3.out 2>&1 )`) and file_exists(`{proj3}/ludic_modules/example.test/manapack/lib/{tgt}/libmanapack.dylib`) { if shq(`( cd {proj3} && {envp} {root}/bin/ludic get > {work}/get3.out 2>&1 )`) and file_exists(`{proj3}/ludic_modules/example.test/manapack/lib/{tgt}/libmanapack.dylib`) {
ok("x get fetches + links a prebuilt (kind prebuilt) package for the target") ok("ludic get fetches + links a prebuilt (kind prebuilt) package for the target")
} else { bad2("prebuilt x get failed", capture_line(`tail -1 {work}/get3.out`)) } } else { bad2("prebuilt ludic get failed", capture_line(`tail -1 {work}/get3.out`)) }
# compile the ECS consumer from the repo root (so runtime/native resolves), then # compile the ECS consumer from the repo root (so runtime/native resolves), then
# link the module dylib via `x link-flags` (a real external build's link step). # link the module dylib via `ludic link-flags` (a real external build's link step).
run(`LUDIC_HOME={root} {root}/bin/ludicc --headless {proj3}/game.ludic --emit-llvm -o {proj3}/game.ll 2>{work}/gc.err`) run(`LUDIC_HOME={root} {root}/bin/ludicc --headless {proj3}/game.ludic --emit-llvm -o {proj3}/game.ll 2>{work}/gc.err`)
let flags = capture_line(`( cd {proj3} && LUDIC_STORE={store} {root}/bin/x link-flags )`) let flags = capture_line(`( cd {proj3} && LUDIC_STORE={store} {root}/bin/ludic link-flags )`)
if shq(`{cc()} -O2 -Wno-override-module {proj3}/game.ll {flags} -o {proj3}/game 2>{work}/gl.err`) { if shq(`{cc()} -O2 -Wno-override-module {proj3}/game.ll {flags} -o {proj3}/game 2>{work}/gl.err`) {
let got = capture_line(`printf 'aaaaaa' | {proj3}/game`) let got = capture_line(`printf 'aaaaaa' | {proj3}/game`)
if got == "3 42" { if got == "3 42" {

View file

@ -1,14 +1,16 @@
# prelude.ludic — the shared runtime for `x`, the Ludic task runner. # prelude.ludic — the shared runtime for `ludic`, the command-line interface.
# #
# `x` replaces every build/test/bootstrap shell script in the repo: it is a # `ludic` is the one tool a user of the language ever runs: it creates projects,
# single native binary (bin/x) that drives clang, the self-host compiler and the # compiles and runs them, resolves packages, formats, tests, and (under
# unix tools the same way the old *.sh files did — only now it is written in # `ludic dev`) drives every build/bootstrap/release task of the toolchain repo
# Ludic and compiled by Ludic. This fragment is the tiny standard library the # itself. It is a single native binary written in Ludic and compiled by Ludic,
# commands lean on: process control, file IO, string trimming and a colored # driving clang, the compiler and the unix tools through `run`. This fragment is
# PASS/FAIL test harness. It carries no ECS, so it links as a plain CLI program. # 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.
# #
# Everything runs relative to the current directory, so `x` must be invoked from # The user-facing commands work from any directory. The `ludic dev` tasks run
# the repository root (the one-line bootstrap in README.md does exactly that). # relative to the current directory and expect the toolchain repo root.
# ---- file IO ---------------------------------------------------------------- # ---- file IO ----------------------------------------------------------------
@ -51,7 +53,7 @@ function shq(cmd: pointer) -> bool { return exit_code(run(cmd)) == 0 }
# ---- scratch files ------------------------------------------------------------ # ---- scratch files ------------------------------------------------------------
# Every scratch file the runner writes lives under one per-process directory # Every scratch file the runner writes lives under one per-process directory
# (`$TMPDIR/x_<pid>`), so `x 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. # clobbering each other's captures. main removes it on the way out.
var x_tmp: pointer = null var x_tmp: pointer = null
@ -59,7 +61,7 @@ function tmp_dir() -> pointer {
if x_tmp == null { if x_tmp == null {
var base = Os.temp_dir() var base = Os.temp_dir()
if len(base) > 1 and base[len(base) - 1] == '/' { base = base[0..len(base) - 1] } # macOS: TMPDIR ends in '/' if len(base) > 1 and base[len(base) - 1] == '/' { base = base[0..len(base) - 1] } # macOS: TMPDIR ends in '/'
x_tmp = `{base}/x_{Os.pid()}` x_tmp = `{base}/ludic_{Os.pid()}`
run(`mkdir -p {x_tmp}`) run(`mkdir -p {x_tmp}`)
} }
return x_tmp return x_tmp
@ -69,10 +71,10 @@ function tmp_dir() -> pointer {
function tmp_path(name: pointer) -> pointer { return `{tmp_dir()}/{name}` } function tmp_path(name: pointer) -> pointer { return `{tmp_dir()}/{name}` }
# remove the scratch directory (idempotent; a no-op when nothing was written). # remove the scratch directory (idempotent; a no-op when nothing was written).
# X_KEEP_TMP=1 leaves it in place, and says where, for debugging a failing case. # LUDIC_KEEP_TMP=1 leaves it in place, and says where, for debugging a failing case.
function tmp_cleanup() -> void { function tmp_cleanup() -> void {
if x_tmp == null { return } if x_tmp == null { return }
if getenv_or("X_KEEP_TMP", "0") == "1" { print(`scratch kept: {x_tmp}`) } if getenv_or("LUDIC_KEEP_TMP", "0") == "1" { print(`scratch kept: {x_tmp}`) }
else { run(`rm -rf {x_tmp}`) } else { run(`rm -rf {x_tmp}`) }
x_tmp = null x_tmp = null
} }
@ -172,3 +174,83 @@ function report() -> int {
if (FAIL == 0) { return 0 } if (FAIL == 0) { return 0 }
return 1 return 1
} }
# ---- command arguments -------------------------------------------------------
#
# A command reads its own arguments as arg_n(1), arg_n(2)… whether it was
# reached as `ludic get` or as `ludic dev docs-check DIR`. main sets the shift
# once per dispatch, so no command has to know how deep its namespace is —
# getting that wrong is how `docs-check DIR` silently checked the wrong
# directory.
var g_shift: int = 0
function arg_n(i: int) -> pointer { return arg(i + g_shift) }
function arg_total() -> int { return arg_count() - g_shift }
# ---- the toolchain install ---------------------------------------------------
#
# The user-facing commands run anywhere, so they cannot assume a `bin/` in the
# current directory the way the old repo-root-only task runner did. They ask
# here instead, and get the same answer in both layouts that exist: an install
# (~/.ludic/bin/ludic, root ~/.ludic) and a checkout of the toolchain repo
# (bin/ludic, root the repo). The compiler derives its own root by the identical
# rule (see ludic_home() in selfhost/frontend/parse.ludic), so the two never
# disagree about where the runtime and the bundled packages live.
var g_home: pointer = null
# the directory holding this binary, with a trailing '/'. argv[0] carries no
# directory when the CLI was found on $PATH, which is the normal case for an
# install — ask the shell where it found it.
function self_dir() -> pointer {
var a0 = arg(0)
var slash = -1
var i = 0
while a0[i] != 0 { if a0[i] == '/' { slash = i }; i += 1 }
if slash < 0 {
let found = capture_line(`command -v {a0}`)
if found == "" { return "" }
a0 = found
slash = -1
i = 0
while a0[i] != 0 { if a0[i] == '/' { slash = i }; i += 1 }
if slash < 0 { return "" }
}
return a0[0..slash + 1]
}
# The toolchain install root, with a trailing '/', or "" for the current
# directory. $LUDIC_HOME wins; otherwise the binary's own directory decides, and
# a directory named `bin` means the root is its parent.
function ludic_home() -> pointer {
if g_home != null { return g_home }
let env = getenv("LUDIC_HOME")
if env != null {
var e = env
if len(e) > 0 and e[len(e) - 1] != '/' { e = e + "/" }
g_home = e
return g_home
}
var d = self_dir()
if len(d) >= 4 and d[len(d) - 4..len(d)] == "bin/" { d = d[0..len(d) - 4] }
# a source checkout the CLI was run from elsewhere in: prefer a ./bin here
if not is_exec(`{d}bin/ludicc`) and is_exec("bin/ludicc") { d = "" }
g_home = d
return g_home
}
# the path to a toolchain binary (ludicc, ludic-fmt, ludic-lsp). Falls back to
# the bare name — on $PATH — when the install root has no bin/ of its own.
function tool(name: pointer) -> pointer {
let p = `{ludic_home()}bin/{name}`
if is_exec(p) { return p }
return name
}
function ludicc() -> pointer { return tool("ludicc") }
# true in a checkout of the toolchain repo itself, where the `ludic dev` tasks
# have something to work on.
function in_toolchain_repo() -> bool {
return file_exists("selfhost/ludicc.seed.ll") and file_exists("tools/ludic-cli/main.ludic")
}

View file

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

View file

@ -1,13 +1,13 @@
# release.ludic — versioning + release cutting for the toolchain. # release.ludic — versioning + release cutting for the toolchain.
# #
# x version print the toolchain version (from the VERSION file) # ludic version print the toolchain version (from the VERSION file)
# x 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 # bump VERSION, commit, and tag vX.Y.Z. `level` is
# major|minor|patch; omitted, it is derived from the # major|minor|patch; omitted, it is derived from the
# highest `bump:` among the pending changesets. # highest `bump:` among the pending changesets.
# x 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. # stop: nothing is written, committed, tagged or pushed.
# x 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. # Forgejo release with source + toolchain tarballs.
# Needs FORGEJO_TOKEN in the environment. # Needs FORGEJO_TOKEN in the environment.
# #
@ -49,7 +49,7 @@ function compute_next(cur: pointer, level: pointer) -> pointer {
return `{maj}.{min}.{p2}` return `{maj}.{min}.{p2}`
} }
# x version — report the toolchain version. Prefer the compiler's own --version # ludic version — report the toolchain version. Prefer the compiler's own --version
# (proving that path works); fall back to the file if ludicc is not built yet. # (proving that path works); fall back to the file if ludicc is not built yet.
function cmd_version() -> int { function cmd_version() -> int {
if is_exec("bin/ludicc") { if is_exec("bin/ludicc") {
@ -233,16 +233,16 @@ function build_section(ver: pointer) -> void {
write_file(tmp_path("rel_section.md"), render_section(ver, date, "changes")) write_file(tmp_path("rel_section.md"), render_section(ver, date, "changes"))
} }
# x 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 # `dir`'s changesets would produce. Used to re-render the sections of releases
# cut before the renderer preserved markdown structure: check the changesets out # 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. # of the tag's parent commit, point this at them, and splice the result back in.
function cmd_changelog_render() -> int { function cmd_changelog_render() -> int {
if arg_count() < 5 { if arg_total() < 5 {
err("usage: x changelog-render <version> <date> <changesets-dir>\n") err("usage: ludic dev changelog-render <version> <date> <changesets-dir>\n")
return 1 return 1
} }
out(render_section(arg(2), arg(3), arg(4))) out(render_section(arg_n(2), arg_n(3), arg_n(4)))
return 0 return 0
} }
@ -250,7 +250,7 @@ function cmd_changelog_render() -> int {
# release section (or at the end of the header if this is the first release). # release section (or at the end of the header if this is the first release).
function prepend_changelog() -> void { function prepend_changelog() -> void {
if not file_exists("CHANGELOG.md") { 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 x 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") let ln = capture_line("grep -n '^## ' CHANGELOG.md | head -1 | cut -d: -f1")
if (ln == "") { if (ln == "") {
@ -289,11 +289,11 @@ function changelog_section(ver: pointer) -> pointer {
return sslice(text, start, n) return sslice(text, start, n)
} }
# x 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 { function cmd_changelog_section() -> int {
if arg_count() < 3 { err("usage: x changelog-section <version>\n"); return 1 } if arg_total() < 3 { err("usage: ludic dev changelog-section <version>\n"); return 1 }
let sec = changelog_section(arg(2)) let sec = changelog_section(arg_n(2))
if slen(sec) == 0 { err(`changelog-section: no section for v{arg(2)} in CHANGELOG.md\n`); return 1 } if slen(sec) == 0 { err(`changelog-section: no section for v{arg_n(2)} in CHANGELOG.md\n`); return 1 }
out(sec) out(sec)
return 0 return 0
} }
@ -305,8 +305,14 @@ function sha256_cmd() -> pointer {
} }
# Build the release artifacts into dist/: a reproducible source+seed tarball from # Build the release artifacts into dist/: a reproducible source+seed tarball from
# the tag, the toolchain built on this host, and one `.sha256` beside each — a # the tag, a ready-to-run toolchain built on this host, and one `.sha256` beside
# release without checksums asks everyone downstream to trust the transport. # each — a release without checksums asks everyone downstream to trust the
# transport.
#
# The toolchain tarball is a complete install root, not just binaries: bin/ next
# to the engine runtime, the bundled ludic.* packages and VERSION, laid out
# exactly as ludic_home() expects. install.sh unpacks it and is done; anything
# missing here is a game that will not link on a machine with no checkout.
# #
# The checksums are per-artifact rather than a single SHA256SUMS on purpose. A # The checksums are per-artifact rather than a single SHA256SUMS on purpose. A
# release is assembled from more than one host (a Linux runner cannot build the # release is assembled from more than one host (a Linux runner cannot build the
@ -325,10 +331,24 @@ function build_artifacts(ver: pointer) -> bool {
if is_exec("bin/ludicc") { if is_exec("bin/ludicc") {
let plat = capture_line("uname -s | tr '[:upper:]' '[:lower:]'") let plat = capture_line("uname -s | tr '[:upper:]' '[:lower:]'")
let arch = capture_line("uname -m") let arch = capture_line("uname -m")
if not shq(`tar -czf dist/ludic-{ver}-{plat}-{arch}.tar.gz bin selfhost/ludicc.seed.ll VERSION`) { let name = `ludic-{ver}-{plat}-{arch}`
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")
return false
}
run(`cp -R runtime {stage}/runtime`)
run(`cp -R packages {stage}/packages`)
run(`cp VERSION LICENSE README.md {stage}/`)
# the Linux link needs the Darwin-stdio shim injected through $LUDIC_CC; it
# travels with the toolchain that needs it (see tools/ci/linux_stdio_shim.ll).
if plat == "linux" { run(`mkdir -p {stage}/lib && cp tools/ci/linux_stdio_shim.ll {stage}/lib/`) }
if not shq(`tar -czf dist/{name}.tar.gz -C {tmp_dir()} {name}`) {
err("release: building the toolchain tarball failed\n") err("release: building the toolchain tarball failed\n")
return false return false
} }
run(`rm -rf {stage}`)
} }
if not shq(`cd dist && for f in *.tar.gz; do {sha256_cmd()} "$f" > "$f.sha256" || exit 1; done`) { if not shq(`cd dist && for f in *.tar.gz; do {sha256_cmd()} "$f" > "$f.sha256" || exit 1; done`) {
err("release: writing the per-artifact .sha256 files failed\n") err("release: writing the per-artifact .sha256 files failed\n")
@ -338,10 +358,10 @@ function build_artifacts(ver: pointer) -> bool {
return true return true
} }
# x 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 # 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 # release. Defaults to the version in VERSION. This is what CI runs on a tag
# push, and what `x 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 { function publish_tag(ver: pointer) -> int {
if getenv_or("FORGEJO_TOKEN", "") == "" { err("publish: set FORGEJO_TOKEN (a Forgejo access token)\n"); return 1 } if getenv_or("FORGEJO_TOKEN", "") == "" { err("publish: set FORGEJO_TOKEN (a Forgejo access token)\n"); return 1 }
let body = tmp_path("rel_notes.md") let body = tmp_path("rel_notes.md")
@ -358,8 +378,8 @@ function publish_tag(ver: pointer) -> int {
function cmd_publish() -> int { function cmd_publish() -> int {
var ver = read_version_or("") var ver = read_version_or("")
if arg_count() >= 3 { if arg_total() >= 3 {
var a = arg(2) var a = arg_n(2)
if s_starts(a, "v") { a = sslice(a, 1, slen(a)) } if s_starts(a, "v") { a = sslice(a, 1, slen(a)) }
ver = a ver = a
} }
@ -381,8 +401,8 @@ function cmd_release() -> int {
var publish = false var publish = false
var dry = false var dry = false
var ai = 2 var ai = 2
while ai < arg_count() { while ai < arg_total() {
let a = arg(ai) let a = arg_n(ai)
if (a == "--publish") { publish = true } if (a == "--publish") { publish = true }
else { if (a == "--dry-run") { dry = true } else { if (a == "--dry-run") { dry = true }
else { if (a == "major") or (a == "minor") or (a == "patch") { level = a } else { if (a == "major") or (a == "minor") or (a == "patch") { level = a }
@ -409,7 +429,7 @@ function cmd_release() -> int {
let sec = read_file(tmp_path("rel_section.md")) let sec = read_file(tmp_path("rel_section.md"))
if sec != null { out(sec) } if sec != null { out(sec) }
print("") print("")
print(` dry run — nothing written. cut it with: x release {level}`) print(` dry run — nothing written. cut it with: ludic dev release {level}`)
return 0 return 0
} }
prepend_changelog() prepend_changelog()
@ -424,7 +444,7 @@ function cmd_release() -> int {
print(` committed + tagged v{ver}`) print(` committed + tagged v{ver}`)
if publish { return publish_release(ver) } if publish { return publish_release(ver) }
print(` local release ready. publish with: FORGEJO_TOKEN=… x 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})`) print(` (or push: git push origin HEAD && git push origin v{ver})`)
return 0 return 0
} }

View file

@ -98,17 +98,18 @@ function line_count(path: pointer) -> pointer {
# ensure bin/ludicc exists and is current with the seed. Built from the checked-in # 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. # IR seed with clang alone — no C compiler is ever involved.
function ensure_ludicc() -> void { function ensure_ludicc() -> void {
if not in_toolchain_repo() { return }
run("mkdir -p bin build") run("mkdir -p bin build")
if (not is_exec("bin/ludicc")) or newer("selfhost/ludicc.seed.ll", "bin/ludicc") { 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)") 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`) { if not shq(`{cc()} selfhost/ludicc.seed.ll -o bin/ludicc`) {
err("x: could not assemble the seed\n"); exit(1) err("ludic: could not assemble the seed\n"); exit(1)
} }
} }
} }
# ---- selfhost-build: assemble + compile the self-host compiler --------------- # ---- selfhost-build: assemble + compile the self-host compiler ---------------
# usage: x 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 { function cmd_selfhost_build(lc: pointer, outbin: pointer) -> int {
run("mkdir -p build") run("mkdir -p build")
let src = "build/selfhost.ludic" let src = "build/selfhost.ludic"
@ -122,7 +123,7 @@ function cmd_selfhost_build(lc: pointer, outbin: pointer) -> int {
} }
# ---- sh-compile: compile one .ludic with a given selfhost binary and link ---- # ---- sh-compile: compile one .ludic with a given selfhost binary and link ----
# usage: x 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 { function cmd_sh_compile(shbin: pointer, in: pointer, outbin: pointer) -> int {
if not shq(`{shbin} {in} > {outbin}.ll`) { return 1 } if not shq(`{shbin} {in} > {outbin}.ll`) { return 1 }
if not shq(`{cc()} {outbin}.ll -o {outbin}`) { return 1 } if not shq(`{cc()} {outbin}.ll -o {outbin}`) { return 1 }
@ -143,7 +144,7 @@ function game_build_ok(shbin: pointer, game: pointer, outbin: pointer) -> bool {
return true return true
} }
# usage: x 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 { function cmd_game_build(shbin: pointer, game: pointer, outbin: pointer) -> int {
if game_build_ok(shbin, game, outbin) { print(`built {outbin}`); return 0 } if game_build_ok(shbin, game, outbin) { print(`built {outbin}`); return 0 }
print("build failed:") print("build failed:")
@ -193,7 +194,7 @@ function cmd_bootstrap_cfree() -> int {
print(" out.ll == seed.ll — the compiler rebuilds itself with no C compiler") print(" out.ll == seed.ll — the compiler rebuilds itself with no C compiler")
return 0 return 0
} }
print(" out.ll != seed.ll (seed is stale — regenerate with: x 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")) out(capture("diff build/cfree/out.ll selfhost/ludicc.seed.ll | head"))
return 1 return 1
} }
@ -202,7 +203,7 @@ function cmd_bootstrap_cfree() -> int {
# Uses the CURRENT seed to build the new one, compiling twice so the seed is a # 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 # 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 # bin/ludicc only if the seed itself no longer assembles. Verify with
# `x bootstrap-cfree` afterwards. # `ludic dev bootstrap-cfree` afterwards.
function cmd_reseed() -> int { function cmd_reseed() -> int {
run("mkdir -p build/cfree") run("mkdir -p build/cfree")
if not write_selfhost_src("build/cfree/selfhost.ludic") { print("FAIL: write source"); return 1 } 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. # 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 # 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 # review, not an opaque "binary files differ". Regenerate the baseline with
# `bin/x golden` (see cmd_golden below). # `bin/ludic dev golden` (see cmd_golden below).
function game_case(path: pointer, keys: pointer) -> void { function game_case(path: pointer, keys: pointer) -> void {
let nm = flat(path) let nm = flat(path)
if not game_build_ok("bin/ludicc", `examples/{path}.ludic`, `{tmp_dir()}/g_{nm}`) { bad(`{path}: build`); return } 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 } 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 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`) let want = capture_line(`grep '^{path} ' selfhost/golden/renders.sha256 | cut -d' ' -f2`)
if (want == "") { bad2(path, "no golden hash (run: bin/x golden)"); return } if (want == "") { bad2(path, "no golden hash (run: bin/ludic dev golden)"); return }
if (got == want) { ok(`{path} matches the golden render`) } if (got == want) { ok(`{path} matches the golden render`) }
else { bad2(path, `render hash {got} != golden {want}`) } else { bad2(path, `render hash {got} != golden {want}`) }
} }
# render one golden game and return its "<path> <sha256>\n" manifest line (empty # render one golden game and return its "<path> <sha256>\n" manifest line (empty
# string on build failure). Shared by `bin/x 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 { function bless_render(path: pointer, keys: pointer) -> pointer {
let nm = flat(path) let nm = flat(path)
if not game_build_ok("bin/ludicc", `examples/{path}.ludic`, `{tmp_dir()}/bg_{nm}`) { 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", "") game_case("rendering/draw_prims", "")
print("== the bootstrap fixpoint (seeded from bin/ludicc) ==") print("== the bootstrap fixpoint (seeded from bin/ludicc) ==")
if shq(`bin/x 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`)) } 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) ==") print("== the C-free bootstrap (from the checked-in IR seed, no C compiler) ==")
if shq(`bin/x bootstrap-cfree > {tmp_dir()}/cfree.out 2>&1`) { ok("seed.ll rebuilds the compiler and is its own fixed point") } 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: x reseed)"); out(capture(`cat {tmp_dir()}/cfree.out`)) } else { bad("C-free bootstrap failed (seed stale? run: ludic dev reseed)"); out(capture(`cat {tmp_dir()}/cfree.out`)) }
return report() return report()
} }

View file

@ -4,7 +4,7 @@
# builds from the seed, compiles every example to its byte-exact golden render, # builds from the seed, compiles every example to its byte-exact golden render,
# self-hosts to a fixpoint, and rebuilds itself with no C compiler. # self-hosts to a fixpoint, and rebuilds itself with no C compiler.
# #
# It drives the other commands through `bin/x <sub>` exactly where test.sh used # It drives the other commands through `bin/ludic <sub>` exactly where test.sh used
# to spawn a sibling script, so each stage stays an independent, observable run. # to spawn a sibling script, so each stage stays an independent, observable run.
# compile a feature example as a game, run it (optional keys on stdin) and match # compile a feature example as a game, run it (optional keys on stdin) and match
@ -23,7 +23,7 @@ function feat_case(path: pointer, keys: pointer, exp: pointer, label: pointer) -
# a feature example that consumes an in-repo controller package (packages/): same # a feature example that consumes an in-repo controller package (packages/): same
# as feat_case, but the compiler is pointed at packages/ via LUDIC_MODULES so a # as feat_case, but the compiler is pointed at packages/ via LUDIC_MODULES so a
# `import "ludic.foo/bar.ludic"` resolves, and LUDIC_HOME anchors the engine # `import "ludic.foo/bar.ludic"` resolves, and LUDIC_HOME anchors the engine
# runtime. This is exactly how `x app` / an external project builds against a # runtime. This is exactly how `ludic build` / an external project builds against a
# fetched source package (the do_import module-root fallback, #63). # fetched source package (the do_import module-root fallback, #63).
function controller_case(path: pointer, keys: pointer, exp: pointer, label: pointer) -> void { function controller_case(path: pointer, keys: pointer, exp: pointer, label: pointer) -> void {
let nm = flat(path) let nm = flat(path)
@ -59,6 +59,38 @@ function runtime_home_case() -> void {
if (got == "7") { ok(lbl) } else { bad2(lbl, `got [{got}] want [7]`) } if (got == "7") { ok(lbl) } else { bad2(lbl, `got [{got}] want [7]`) }
} }
# The install layout: bin/ beside runtime/ and packages/, reached through $PATH
# from an unrelated directory, with nothing in the environment. This is what
# `curl https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh` lays down and the only shape most
# users will ever run, so it is asserted rather than assumed: a staged copy of
# the toolchain compiles and runs a project created by `ludic new`.
function install_layout_case() -> void {
let root = capture_line("pwd")
let home = `{tmp_dir()}/install`
let lbl = "an installed layout (bin/ + runtime/ + packages/, found on PATH) builds a new project"
run(`rm -rf {home} && mkdir -p {home}/bin`)
run(`cp bin/ludic bin/ludicc bin/ludic-fmt {home}/bin/`)
run(`cp -R runtime {home}/runtime && cp -R packages {home}/packages && cp VERSION {home}/VERSION`)
let work = `{tmp_dir()}/installwork`
run(`rm -rf {work} && mkdir -p {work}`)
# PATH only — no LUDIC_HOME, and the current directory has no bin/ of its own.
let envp = `env -u LUDIC_HOME PATH={home}/bin:/usr/bin:/bin:/usr/sbin:/sbin`
if not shq(`cd {work} && {envp} ludic new demo > {work}/new.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/new.out`)); return
}
if not shq(`cd {work}/demo && {envp} ludic build --headless > {work}/build.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/build.out`)); return
}
if not shq(`cd {work}/demo && {envp} ludic test > {work}/test.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/test.out`)); return
}
let ver = capture_line(`{envp} ludic version`)
if ver != `ludic {s_trim(read_file("VERSION"))}` {
bad2("an installed `ludic version` reports the install's VERSION", `got [{ver}]`); return
}
ok(lbl)
}
# a "does it still compile" smoke test (parse -> lower -> link), no run # a "does it still compile" smoke test (parse -> lower -> link), no run
function qsmoke(path: pointer) -> void { function qsmoke(path: pointer) -> void {
let nm = flat(path) let nm = flat(path)
@ -126,7 +158,7 @@ function panic_case() -> void {
else { bad2("panic", `rc={string(rc)} err=[{msg}]`) } else { bad2("panic", `rc={string(rc)} err=[{msg}]`) }
} }
# issue #45: `bin/x 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 # 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, # 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. # so this reuses the same seed-built bin/ludicc the rest of the suite does.
@ -176,7 +208,7 @@ function cmd_test_coverage() -> int {
} }
COV_COVERED = 0 COV_COVERED = 0
COV_TOTAL = 0 COV_TOTAL = 0
print("== line coverage (bin/x test --coverage) ==") print("== line coverage (bin/ludic dev test --coverage) ==")
cov_one("library/coverage", `{tmp_dir()}/cov_out`) cov_one("library/coverage", `{tmp_dir()}/cov_out`)
cov_one("library/testing", `{tmp_dir()}/cov_out`) cov_one("library/testing", `{tmp_dir()}/cov_out`)
var pct = 100 var pct = 100
@ -186,7 +218,7 @@ function cmd_test_coverage() -> int {
return 0 return 0
} }
function cmd_test() -> int { function cmd_dev_test() -> int {
PASS = 0 PASS = 0
FAIL = 0 FAIL = 0
@ -199,7 +231,7 @@ function cmd_test() -> int {
print("== the compiler builds from the IR seed (no C compiler) ==") print("== the compiler builds from the IR seed (no C compiler) ==")
run("rm -f bin/ludicc") run("rm -f bin/ludicc")
if shq("bin/x app examples/games/snake.ludic --headless >/dev/null 2>&1") and is_exec("bin/ludicc") { if shq("bin/ludic build examples/games/snake.ludic --headless >/dev/null 2>&1") and is_exec("bin/ludicc") {
ok("clang selfhost/ludicc.seed.ll -> bin/ludicc") ok("clang selfhost/ludicc.seed.ll -> bin/ludicc")
} else { bad("compiler did not build from the seed") } } else { bad("compiler did not build from the seed") }
@ -209,11 +241,11 @@ function cmd_test() -> int {
game_case("games/chronorift", "ddddwwwwaassK") game_case("games/chronorift", "ddddwwwwaassK")
print("== self-hosting and the bootstrap fixpoints ==") print("== self-hosting and the bootstrap fixpoints ==")
run(`bin/x 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 '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") } 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`) let corr = capture_line(`grep -c PASS {tmp_dir()}/sh.out`)
ok(`self-host suite: {corr} checks passed (see: x selfhost-test)`) ok(`self-host suite: {corr} checks passed (see: ludic dev selfhost-test)`)
print("== documented syntax stays compilable (guards against spec/compiler drift) ==") print("== documented syntax stays compilable (guards against spec/compiler drift) ==")
qsmoke("lang/qdecl") qsmoke("lang/qdecl")
@ -367,12 +399,12 @@ function cmd_test() -> int {
smoke("networking/net_rt") smoke("networking/net_rt")
smoke("library/cursor_capture") # #89 Input.cursor_mode compiles (no-op headless; windowed links cocoa.ll) smoke("library/cursor_capture") # #89 Input.cursor_mode compiles (no-op headless; windowed links cocoa.ll)
print("== self-hosted front-end binaries (ludicc / ludic) ==") print("== the compiler and the CLI (ludicc / ludic) ==")
# the two commands are one multi-call native binary built from the seed with # ludicc comes out of the IR seed with clang alone; the CLI is then compiled
# clang alone. ludicc compiles; ludic compiles-and-runs. # by it, from Ludic.
if shq(`bin/x build-cli > {tmp_dir()}/cli.out 2>&1`) and is_exec("bin/ludicc") and is_exec("bin/ludic") { if shq(`bin/ludic dev build-cli > {tmp_dir()}/cli.out 2>&1`) and is_exec("bin/ludicc") {
ok("x build-cli builds bin/ludicc and bin/ludic from the seed (clang-only)") ok("ludic dev build-cli builds bin/ludicc from the seed (clang-only)")
} else { bad2("x build-cli", capture_line(`tail -1 {tmp_dir()}/cli.out`)) } } 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 # 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 # (LUDIC_HOME points the linker at runtime/native/cocoa.ll) — that path is
@ -398,37 +430,50 @@ function cmd_test() -> int {
ok("ludicc --emit-llvm -> LLVM IR") ok("ludicc --emit-llvm -> LLVM IR")
} else { bad2("ludicc --emit-llvm", capture_line(`tail -1 {tmp_dir()}/cli.out`)) } } else { bad2("ludicc --emit-llvm", capture_line(`tail -1 {tmp_dir()}/cli.out`)) }
# ludic compiles, runs, and forwards the program's exit code. # `ludic <file>` compiles, runs, and forwards the program's exit code.
write_file(`{tmp_dir()}/cli_exit.ludic`, "entry { exit(42) }\n") write_file(`{tmp_dir()}/cli_exit.ludic`, "entry { exit(42) }\n")
let rc = sh(`LUDIC_HOME=. bin/ludic {tmp_dir()}/cli_exit.ludic > {tmp_dir()}/cli.out 2>&1`) let rc = sh(`bin/ludic {tmp_dir()}/cli_exit.ludic --headless > {tmp_dir()}/cli.out 2>&1`)
if (rc == 42) { ok("ludic app.ludic -> compiles, runs, forwards exit code") } if (rc == 42) { ok("ludic app.ludic -> compiles, runs, forwards exit code") }
else { bad2("ludic run: expected exit 42", `got {string(rc)}`) } else { bad2("ludic run: expected exit 42", `got {string(rc)}`) }
# The CLI has to work as an install does: from another directory, with no
# ./bin, finding its compiler, the engine runtime and the bundled packages
# from its own location. This is the shape `curl … | sh` produces.
install_layout_case()
# install.sh is what the landing page tells people to pipe into sh, and it is
# published with the docs site — so it is checked here rather than discovered
# broken by a first-time user. `sh -n` parses it without running anything.
if not file_exists("install.sh") { bad("install.sh is missing") }
else if shq("sh -n install.sh 2>/dev/null") and shq("sh install.sh --help >/dev/null 2>&1") {
ok("install.sh parses under POSIX sh and answers --help")
} else { bad("install.sh does not parse under POSIX sh") }
# the package manager (issue #63): fetch + MVS resolve + content-addressed # the package manager (issue #63): fetch + MVS resolve + content-addressed
# store + ludic_modules/ links + build, run as its own hermetic suite (it # 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 # stands up throwaway git repos, so like selfhost-test it runs as a sub-process
# and this stage checks its footer). # and this stage checks its footer).
print("== package manager (x test-pkg) ==") print("== package manager (ludic dev test-pkg) ==")
run(`bin/x test-pkg > {tmp_dir()}/pkg.out 2>&1`) run(`bin/ludic dev test-pkg > {tmp_dir()}/pkg.out 2>&1`)
if shq(`grep -q '0 failed' {tmp_dir()}/pkg.out`) { if shq(`grep -q '0 failed' {tmp_dir()}/pkg.out`) {
let pc = capture_line(`grep -c PASS {tmp_dir()}/pkg.out`) let pc = capture_line(`grep -c PASS {tmp_dir()}/pkg.out`)
ok(`package-manager suite: {pc} checks passed (see: x test-pkg)`) ok(`package-manager suite: {pc} checks passed (see: ludic dev test-pkg)`)
} else { bad2("x test-pkg", capture_line(`grep -i fail {tmp_dir()}/pkg.out | head -1`)) } } 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 # 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 # pages payload; docs-check is its coverage/integrity guard; docs-palette is
# the named-colour source of truth (its --check mode regenerates the tracked # the named-colour source of truth (its --check mode regenerates the tracked
# emit_color.ludic + palette.json into scratch files and compares). # emit_color.ludic + palette.json into scratch files and compares).
print("== docs site (Ludic generator, no Python) ==") print("== docs site (Ludic generator, no Python) ==")
if shq(`bin/x docs-gen --out {tmp_dir()}/docs > {tmp_dir()}/docs.out 2>&1`) { if shq(`bin/ludic dev docs-gen --out {tmp_dir()}/docs > {tmp_dir()}/docs.out 2>&1`) {
ok("x docs-gen -> the pages-branch payload") ok("ludic dev docs-gen -> the pages-branch payload")
} else { bad2("x docs-gen", capture_line(`tail -1 {tmp_dir()}/docs.out`)) } } else { bad2("ludic dev docs-gen", capture_line(`tail -1 {tmp_dir()}/docs.out`)) }
if shq(`bin/x docs-check {tmp_dir()}/docs > {tmp_dir()}/docs_chk.out 2>&1`) { if shq(`bin/ludic dev docs-check {tmp_dir()}/docs > {tmp_dir()}/docs_chk.out 2>&1`) {
ok("x docs-check passes on the generated site") ok("ludic dev docs-check passes on the generated site")
} else { bad2("x docs-check", capture_line(`tail -1 {tmp_dir()}/docs_chk.out`)) } } else { bad2("ludic dev docs-check", capture_line(`tail -1 {tmp_dir()}/docs_chk.out`)) }
if shq(`bin/x docs-palette --check > {tmp_dir()}/pal.out 2>&1`) { if shq(`bin/ludic dev docs-palette --check > {tmp_dir()}/pal.out 2>&1`) {
ok("x docs-palette regenerates emit_color.ludic + palette.json byte-identically") ok("ludic dev docs-palette regenerates emit_color.ludic + palette.json byte-identically")
} else { bad2("x docs-palette --check", capture_line(`tail -1 {tmp_dir()}/pal.out`)) } } else { bad2("ludic dev docs-palette --check", capture_line(`tail -1 {tmp_dir()}/pal.out`)) }
return report() return report()
} }

View file

@ -27,12 +27,12 @@ function sync_vscode_grammar() -> void {
# is `flag` present anywhere in argv? # is `flag` present anywhere in argv?
function has_flag(flag: pointer) -> bool { function has_flag(flag: pointer) -> bool {
var i = 2 var i = 2
while i < arg_count() { if (arg(i) == flag) { return true }; i += 1 } while i < arg_total() { if (arg_n(i) == flag) { return true }; i += 1 }
return false return false
} }
# ---- tools: build the editor toolchain -------------------------------------- # ---- tools: build the editor toolchain --------------------------------------
# usage: x tools [--install] [--test] # usage: ludic dev tools [--install] [--test]
function cmd_tools() -> int { function cmd_tools() -> int {
ensure_ludicc() ensure_ludicc()
print("ludicc: tools/ludic-tools/fmt.ludic -> bin/ludic-fmt (Ludic -> LLVM IR -> binary, no C)") 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 PASS = 0
FAIL = 0 FAIL = 0
let fmt = "bin/ludic-fmt" let fmt = "bin/ludic-fmt"
if not is_exec(fmt) { err("bin/ludic-fmt missing — run: x 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: x 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") print("formatter")
@ -129,7 +129,7 @@ function cmd_test_tools() -> int {
# features (go-to-def, find-usages, workspace symbols) can't resolve. Gate the # 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. # end-to-end protocol test on Darwin until the directory walk is portable.
if is_darwin() { if is_darwin() {
let lrc = sh("bin/x test-lsp") let lrc = sh("bin/ludic dev test-lsp")
if lrc == 0 { ok("language server protocol") } if lrc == 0 { ok("language server protocol") }
else if lrc == 2 { skip("language server protocol (bin/ludic-lsp not built)") } else if lrc == 2 { skip("language server protocol (bin/ludic-lsp not built)") }
else { bad("language server protocol") } 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 # 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") { 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") ok("vscode grammar copies match tools/editors/shared")
} else { bad2("vscode grammar copies have drifted", "run: x 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 # the JetBrains plugin is off by default (its first build pulls a gigabyte of
# IntelliJ SDK). One env var away: LUDIC_TEST_JETBRAINS=1 x 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 (getenv_or("LUDIC_TEST_JETBRAINS", "0") == "1") {
if shq("test -x tools/editors/jetbrains/gradlew") { 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") } 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") }
@ -175,19 +175,19 @@ function cmd_test_tools() -> int {
# the vocabulary lives in one place; drift between it and its copies (the # the vocabulary lives in one place; drift between it and its copies (the
# TextMate grammar, the Kotlin lexer) is the failure mode this layout prevents. # TextMate grammar, the Kotlin lexer) is the failure mode this layout prevents.
# The check is written in Ludic (tools/x/checks.ludic) and runs through x — no # The check is written in Ludic (tools/ludic-cli/checks.ludic) and runs through x — no
# Python in the loop. # Python in the loop.
if shq("bin/x 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, # every feature the compiler actually implements — namespace methods, keywords,
# types, phases — must have a docs/language page. This reads the implementation # 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. # (emit_ns_call + ludic_syntax.h), so shipping a feature without docs fails here.
# Ported to Ludic; runs through x. # Ported to Ludic; runs through x.
if shq("bin/x 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), # 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. # so a documented snippet can't drift from what the compiler accepts.
if shq("bin/x check-docs > /dev/null 2>&1") { ok("every doc fence parses") } else { bad2("a doc fence drifted", "run: x 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() return report()
} }
@ -195,9 +195,9 @@ function cmd_test_tools() -> int {
# JSON/XML validity, checked by the Ludic validators in checks.ludic (no Python). # JSON/XML validity, checked by the Ludic validators in checks.ludic (no Python).
function test_json(path: pointer) -> void { function test_json(path: pointer) -> void {
let bn = capture_line(`basename {path}`) let bn = capture_line(`basename {path}`)
if shq(`bin/x 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 { function test_xml(path: pointer) -> void {
let bn = capture_line(`basename {path}`) let bn = capture_line(`basename {path}`)
if shq(`bin/x 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}`) }
} }

View file

@ -1,101 +0,0 @@
# build.ludic — building the toolchain binaries and user programs.
#
# Replaces build-cli.sh (the ludicc/ludic front-end) and build.sh (compiling a
# Ludic program to a native app). All toolchain binaries land in bin/.
# 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/x 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_build_cli() -> int {
run("mkdir -p bin build")
print("cc: selfhost/ludicc.seed.ll -> bin/ludicc, bin/ludic (from the IR seed, no C compiler)")
if not shq(`{cc()} selfhost/ludicc.seed.ll -o bin/ludicc`) { err("x: seed did not assemble\n"); return 1 }
run("cp bin/ludicc bin/ludic && chmod +x bin/ludicc bin/ludic")
print("done. built bin/ludicc and bin/ludic")
return 0
}
# ---- build: the whole toolchain (ludicc, ludic, x, ludic-fmt, ludic-lsp) -----
function cmd_build() -> int {
if cmd_build_cli() != 0 { return 1 }
print("ludicc: tools/x/main.ludic -> bin/x (the task runner rebuilds itself)")
if not build_tool("x", "tools/x/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, x, ludic-fmt, ludic-lsp)")
return 0
}
# ---- app: compile a Ludic program to a native app ----------------------------
# usage: x app <file.ludic> [--headless|--windowed] [--save-temps]
# windowed is the default for a game; headless renders the last frame to
# build/out.ppm (never the repo root).
function cmd_app() -> int {
ensure_ludicc()
var src = null
var mode = 1 # 1 = windowed, 2 = headless
var save = false
var ai = 2
while ai < arg_count() {
let a = arg(ai)
if (a == "--headless") { mode = 2 }
else if (a == "--windowed") { mode = 1 }
else if (a == "--save-temps") { save = true }
else if (a[0] != '-') { src = a } # 45 = '-', so skip other flags
ai += 1
}
if (src == null) { err("usage: x app <file.ludic> [--headless] [--save-temps]\n"); return 1 }
let name = capture_line(`basename {src} .ludic`)
let pbf = prebuilt_link_flags() # #64: link resolved prebuilt module dylibs, if any
if (mode == 2) {
let outbin = `build/{name}_headless`
let ll = `{outbin}.ll`
print(`ludicc: {src} -> {outbin} (renders the last frame to build/out.ppm)`)
if not shq(`bin/ludicc --headless {src} > {ll}`) { return 1 }
if not shq(`{cc()} -O2 {ll}{pbf} -o {outbin}`) { return 1 }
if not save { run(`rm -f {ll}`) }
print(`done. run: mkdir -p build && printf 'ddss' | ./{outbin} && open build/out.ppm`)
return 0
}
let outbin = `build/{name}`
let ll = `{outbin}.ll`
print(`ludicc: {src} -> {outbin} (Ludic-written compiler -> LLVM IR -> windowed binary)`)
if not shq(`bin/ludicc --windowed {src} > {ll}`) { return 1 }
# audio.ll (#22) is always linked here for the dev runner — unused snd_* are
# dead-stripped; the canonical `ludicc -o` path links it only when Audio.* is used.
if not shq(`{cc()} -O2 {ll} runtime/native/cocoa.ll runtime/native/audio.ll -framework Cocoa -Wl,-needed_framework,GameController -Wl,-needed_framework,AVFoundation -Wl,-rpath,@loader_path{pbf} -o {outbin}`) { return 1 }
if not save { run(`rm -f {ll}`) }
print(`done. run: ./{outbin} (from the repo root, so assets/ resolves)`)
return 0
}
# Remove every generated artifact and leave the tracked source untouched: the
# whole build/ tree (IR, objects, compiled apps, the headless render at
# 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. The
# toolchain binaries in bin/ are kept so the running `x` survives; rebuild them
# any time with `x build`.
function cmd_clean() -> int {
run("rm -rf build")
run("rm -f out.ppm bin/*.tmp")
print("cleaned: build/ (incl. build/out.ppm), bin/*.tmp (bin/ toolchain kept — `x build` to rebuild)")
return 0
}

View file

@ -1,157 +0,0 @@
# x — the Ludic task runner. One native binary (bin/x) that replaces every
# build/test/bootstrap shell script in the repo. It is written in Ludic and
# compiled by Ludic; the unix tools and clang it drives are invoked through the
# `run` intrinsic, the same way the old *.sh files shelled out.
#
# Bootstrap it once from a clean checkout (the only 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/x/main.ludic -o bin/x
#
# Thereafter `bin/x build` rebuilds the whole toolchain — including bin/x itself.
# Always run x from the repository root.
program X {
import "prelude.ludic"
import "selfhost.ludic"
import "build.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("x — the Ludic task runner (run from the repository root)")
print("")
print("build & run:")
print(" x build build the whole toolchain into bin/ (ludicc, ludic, x, ludic-fmt, ludic-lsp)")
print(" x build-cli build just bin/ludicc and bin/ludic from the IR seed")
print(" x app <file.ludic> [--headless] [--save-temps]")
print(" compile a Ludic program to a native app in build/")
print(" x tools [--install] [--test] build the editor toolchain (ludic-fmt, ludic-lsp)")
print(" x clean remove build/ (incl. build/out.ppm) and stray artifacts (keeps bin/)")
print("")
print("packages (issue #63 — MVS resolution, content-addressed store, ludic_modules/ links):")
print(" x add <module>[@version] add a dependency to package.ludic, then resolve + fetch + link")
print(" x get resolve every package.ludic dependency, link them, write the lock")
print(" x update [module] bump a dep (or all) to its latest published version, then relock")
print(" x verify check every locked package against the store by content hash")
print(" x vendor copy the resolved packages into ./vendor for offline builds")
print(" x build-lib <module.ludic> (#64) compile a package's module to a prebuilt dylib in lib/<target>/")
print(" x link-flags (#64) print the clang flags to link this project's prebuilt module dylibs")
print("")
print("test:")
print(" x test the full regression suite")
print(" x test-pkg the package-manager suite (hermetic git repos, offline)")
print(" x test --coverage per-file line coverage over the test specs")
print(" x selfhost-test the self-hosting suite (correctness + bootstrap fixpoints)")
print(" x test-tools the editor-toolchain suite")
print(" x test-lsp drive bin/ludic-lsp over real LSP traffic (exit 2 = no server)")
print(" x golden regenerate selfhost/golden/renders.sha256 (review with git diff)")
print("")
print("doc / lint checks (Ludic, no Python):")
print(" x check-docs every ```ludic doc fence parses (or is marked skip/expect-error)")
print(" x check-impl every implemented feature has a docs/language page")
print(" x check-vocabulary the vocabulary is in sync across grammar / lexer / header / parser")
print(" x lint-asset <file> validate one editor .json / .xml asset")
print(" x docs-gen [--out DIR] generate the documentation site (default build/pages)")
print(" x docs-check [DIR] coverage/integrity guard over a generated docs site")
print(" x docs-palette [--check] regenerate emit_color.ludic + palette.json from the palette table (--check: compare only)")
print("")
print("release:")
print(" x version print the toolchain version (ludicc --version)")
print(" x release [major|minor|patch] [--dry-run] [--publish]")
print(" cut a release: CHANGELOG + VERSION bump + tag (+ Forgejo release)")
print(" x publish [vX.Y.Z] publish an already-tagged release (artifacts + notes); what CI runs on a tag push")
print(" x changelog-section <ver> print that release's CHANGELOG.md section")
print(" x changelog-render <ver> <date> <dir>")
print(" render a CHANGELOG section from a directory of changesets")
print("")
print("self-host internals:")
print(" x selfhost-build [ludicc] [out] assemble + compile the self-host compiler")
print(" x bootstrap the self-hosting fixpoint proof (seeded from bin/ludicc)")
print(" x bootstrap-cfree rebuild the compiler from the seed with no C compiler")
print(" x reseed regenerate selfhost/ludicc.seed.ll after a compiler change")
print(" x game-build <ludicc> <game.ludic> <out> compile a Ludic game (headless)")
print(" x 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
}
# 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.
function dispatch(cmd: pointer) -> int {
if (cmd == "build") { return cmd_build() }
if (cmd == "build-cli") { return cmd_build_cli() }
if (cmd == "app") { return cmd_app() }
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_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 == "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: x 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: x game-build <ludicc> <game.ludic> <out>\n"); exit(1) }
return cmd_game_build(arg(2), arg(3), arg(4))
}
if (cmd == "add") { return cmd_pkg_add() }
if (cmd == "get") { return cmd_pkg_get() }
if (cmd == "update") { return cmd_pkg_update() }
if (cmd == "verify") { return cmd_pkg_verify() }
if (cmd == "vendor") { return cmd_pkg_vendor() }
if (cmd == "build-lib") { return cmd_pkg_build_lib() }
if (cmd == "link-flags") { return cmd_pkg_link_flags() }
if (cmd == "test-pkg") { return cmd_test_pkg() }
if (cmd == "version") or (cmd == "--version") or (cmd == "-v") { return cmd_version() }
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(`x: unknown command '{cmd}'\n`)
usage()
exit(1)
}
}