diff --git a/.forgejo/issue_template/bug.md b/.forgejo/issue_template/bug.md index e5100d79..3995f0a1 100644 --- a/.forgejo/issue_template/bug.md +++ b/.forgejo/issue_template/bug.md @@ -24,7 +24,7 @@ labels: ## 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): - Commit (`git rev-parse --short HEAD`): - OS / arch: diff --git a/.forgejo/pull_request_template.md b/.forgejo/pull_request_template.md index b39680fc..20968e75 100644 --- a/.forgejo/pull_request_template.md +++ b/.forgejo/pull_request_template.md @@ -8,13 +8,13 @@ Closes # ## Checklist -- [ ] `bin/x test` passes. -- [ ] For compiler/runtime changes: `bin/x reseed && bin/x bootstrap-cfree` +- [ ] `bin/ludic dev test` passes. +- [ ] For compiler/runtime changes: `bin/ludic dev reseed && bin/ludic dev bootstrap-cfree` still reaches the self-hosting fixpoint with no C compiler in the loop. - [ ] `ludic-fmt` leaves the touched files unchanged (2-space, LF, UTF-8). - [ ] New/changed stdlib symbols are documented under `docs/language/**` and registered in `tools/docgen/inventory.json` - (`bin/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). - [ ] No new C / Python / JS in tooling (Ludic only), and no generated artifacts committed outside `build/` / `bin/`. diff --git a/.forgejo/workflows/bootstrap.yml b/.forgejo/workflows/bootstrap.yml index cedd7096..6de1bdcd 100644 --- a/.forgejo/workflows/bootstrap.yml +++ b/.forgejo/workflows/bootstrap.yml @@ -43,11 +43,11 @@ jobs: set -eu mkdir -p bin clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc - bin/ludicc tools/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 - # `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 # the checked-in seed. It returns non-zero if they differ — i.e. if the # seed is stale relative to the compiler source. - run: bin/x bootstrap-cfree + run: bin/ludic dev bootstrap-cfree diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 4eb2b108..877b81ef 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -2,7 +2,7 @@ name: ci # Build the language toolchain from its IR seed and run the regression suites on # every push to main and every pull request. Until this landed the only workflow -# was docs.yml, so nothing gated a change on `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 # C-free self-rebuild reproduces the seed byte-for-byte. on: @@ -45,7 +45,7 @@ jobs: # The toolchain is macOS-first; on this Linux runner it links against a # tiny C-free IR shim that supplies the Darwin standard-stream globals # (__stdoutp/__stderrp) over glibc's stdout/stderr. Injected through - # LUDIC_CC so every clang invocation — the seed bootstrap, `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 # still resolves if a step changes directory. echo "LUDIC_CC=clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll" >> "$GITHUB_ENV" @@ -60,20 +60,20 @@ jobs: # pre-built binaries: the language builds itself from source + seed. mkdir -p bin clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc - bin/ludicc tools/x/main.ludic -o bin/x - bin/x build + bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic + bin/ludic dev build - - name: Regression suite (x test) - run: bin/x test + - name: Regression suite (ludic dev 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 # formatting contract — hand alignment is deliberately preserved, so the # gate is fmt(fmt(x)) == fmt(x), not fmt(x) == x), and the JSON/XML editor # assets. Cross-file LSP behaviour and the golden renders are macOS-ABI # bound and skip here — visibly — until the runtime's directory walk and # windowing are portable. - run: bin/x test-tools + run: bin/ludic dev test-tools - name: Docs cover the implementation run: | @@ -81,9 +81,9 @@ jobs: # The whole docs toolchain is written in Ludic and runs through x — # no Python anywhere. check-impl / check-vocabulary / check-docs guard # the sources; docs-gen builds the site and docs-check is its coverage - # + integrity guard. (check-vocabulary also runs in `x test-tools`.) - bin/x check-impl - bin/x check-vocabulary - bin/x check-docs - bin/x docs-gen --out build/pages - bin/x docs-check build/pages + # + integrity guard. (check-vocabulary also runs in `ludic dev test-tools`.) + bin/ludic dev check-impl + bin/ludic dev check-vocabulary + bin/ludic dev check-docs + bin/ludic dev docs-gen --out build/pages + bin/ludic dev docs-check build/pages diff --git a/.forgejo/workflows/docs.yml b/.forgejo/workflows/docs.yml index 5097153c..3e9acf18 100644 --- a/.forgejo/workflows/docs.yml +++ b/.forgejo/workflows/docs.yml @@ -10,7 +10,7 @@ on: paths: - 'docs/**' - 'tools/docgen/**' - - 'tools/x/**' + - 'tools/ludic-cli/**' - '.forgejo/workflows/docs.yml' workflow_dispatch: {} @@ -33,8 +33,8 @@ jobs: # "Waiting" forever with "no online runner found matching this label". runs-on: docker # The generator is now Ludic, so this builds the toolchain from its IR seed - # (clang assembles the seed into bin/ludicc, which compiles bin/x) exactly - # like the ci workflow, then runs `x docs-gen`. node:20-bookworm carries git + # (clang assembles the seed into bin/ludicc, which compiles bin/ludic) exactly + # like the ci workflow, then runs `ludic dev docs-gen`. node:20-bookworm carries git # for the clone + publish; clang-16 is the only extra the bootstrap needs. container: node:20-bookworm steps: @@ -63,9 +63,9 @@ jobs: export LUDIC_HOME="$(pwd)" mkdir -p bin clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc - bin/ludicc tools/x/main.ludic -o bin/x - bin/x docs-gen --out ../public - bin/x docs-check ../public + bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic + bin/ludic dev docs-gen --out ../public + bin/ludic dev docs-check ../public cd .. echo "--- generated files ---" ls -la public diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 6c42c2f8..a55715fb 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -1,6 +1,6 @@ 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 # machine the maintainer happened to be sitting at, from whatever was in bin/ at # the time, with no checksums and nothing proving the tagged tree even passed its @@ -71,15 +71,15 @@ jobs: set -eu mkdir -p bin clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc - bin/ludicc tools/x/main.ludic -o bin/x - bin/x build + bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic + bin/ludic dev build - name: The tagged tree must pass its own suites run: | set -eu - bin/x test - bin/x test-tools - bin/x bootstrap-cfree + bin/ludic dev test + bin/ludic dev test-tools + bin/ludic dev bootstrap-cfree - name: Publish the release env: @@ -91,9 +91,9 @@ jobs: echo "::error::No FORGEJO_TOKEN secret; cannot create the release." exit 1 fi - # 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, # and creates the release. Re-running it only adds missing assets, so # a maintainer can afterwards attach the macOS toolchain from a Mac # with the same command. - bin/x publish "$TAG" + bin/ludic dev publish "$TAG" diff --git a/.gitignore b/.gitignore index 591a37c6..5ba1fb6d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # 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` / -# `bin/x clean`). Root-anchored so a source dir named "build" elsewhere is never +# (build/out.ppm) and the docs site all land under build/ (see `bin/ludic dev build` / +# `bin/ludic clean`). Root-anchored so a source dir named "build" elsewhere is never # accidentally ignored. Nothing is written to the repo root any more. /build/ @@ -9,14 +9,14 @@ # packaged plugin .zip are local-only build inputs/outputs. *.zip -# the toolchain binaries (ludicc, ludic, x, ludic-fmt, ludic-lsp) — all built -# into bin/ by the one-line bootstrap + `bin/x build`; never checked in. The +# the toolchain binaries (ludicc, ludic, ludic-fmt, ludic-lsp) — all built +# into bin/ by the one-line bootstrap + `bin/ludic dev build`; never checked in. The # only thing published is the source and the LLVM-IR seed (selfhost/ludicc.seed.ll). /bin/ # package manager (issue #63): the per-project linked view into the global -# content-addressed store, and the optional hermetic copy from `x vendor`. Both -# are regenerated by `x get` / `x vendor` — package.ludic + package.lock.ludic +# content-addressed store, and the optional hermetic copy from `ludic vendor`. Both +# 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. ludic_modules/ vendor/ @@ -39,7 +39,7 @@ tools/editors/jetbrains/build/ __pycache__/ *.pyc -# Release artifacts produced by `x release` +# Release artifacts produced by `ludic dev release` /dist/ # Build/release tarballs anywhere in the tree. `git -C archive -o foo.tgz` diff --git a/COMPILING.md b/COMPILING.md index e74861ad..f300416e 100644 --- a/COMPILING.md +++ b/COMPILING.md @@ -1,37 +1,46 @@ # Compiling Ludic -> **Note (2026-08-27):** `ludicc` is now **written in Ludic** (`selfhost/*.ludic`) -> and built from a checked-in IR seed — the C compiler this document describes has -> been deleted. The native pipeline below (Ludic → LLVM IR → object → binary) is -> unchanged. `ludicc` now drives clang itself (via an `os_system` intrinsic), so -> `ludicc app.ludic -o bin/app` and `--emit-llvm` work directly, and a sibling -> command `ludic app.ludic` compiles to a temporary binary and runs it in one -> step. The whole toolchain is built by `bin/x build`; `bin/x app` remains as a -> convenience wrapper over the compiler. `--fmt` is reimplemented as a lex+parse -> gate (the doc-check hook). The `--target`/cross-compile and `--shared` paths are -> 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. +> **Note:** `ludicc` is **written in Ludic** (`selfhost/*.ludic`) and built from a +> checked-in IR seed — the C compiler this document once described has been +> deleted. The native pipeline below (Ludic → LLVM IR → object → binary) is +> unchanged. `ludicc` drives clang itself (via an `os_system` intrinsic), so +> `ludicc app.ludic -o bin/app` and `--emit-llvm` work directly. `--fmt` is +> reimplemented as a lex+parse gate (the doc-check hook). The `--target`/ +> cross-compile and `--shared` paths are 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 -> let `bin/x` do the rest (run it from the repository root): +> Most people never invoke `ludicc` directly: the `ludic` CLI drives it. > > ```bash -> # one-time bootstrap: clang assembles the seed, then ludicc compiles bin/x -> mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc tools/x/main.ludic -o bin/x -> bin/x build # rebuild the whole toolchain into bin/ -> # (ludicc, ludic, x, ludic-fmt, ludic-lsp) -> bin/ludicc examples/games/snake.ludic -o bin/snake # compile -> bin/ludic examples/games/snake.ludic # compile + run -> bin/x help # list every command +> curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh # the toolchain, into ~/.ludic +> ludic new mygame && cd mygame +> ludic run # compile + run +> ludic build --headless # compile, deterministic render > ``` > -> The binaries are multi-call (one native binary under two names): invoked as -> `ludicc` it compiles, as `ludic` it compiles-and-runs. A `.ludic` file with -> handlers is a game and links windowed by default; `--headless` and `--windowed` -> force the mode. The runtime (`runtime/native/cocoa.ll`) is found via -> `$LUDIC_HOME`, defaulting to the directory the binary sits in — keep them in -> `bin/`, or set `LUDIC_HOME` and put them on `PATH`. `$LUDIC_CC` overrides the -> assembler/linker (default `clang`). +> From a clean checkout, the compiler and the CLI come up in two lines and the +> CLI does the rest (run it from the repository root): +> +> ```bash +> # one-time bootstrap: clang assembles the seed, then ludicc compiles bin/ludic +> mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc +> bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic +> bin/ludic dev build # the whole toolchain into bin/ +> # (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 @@ -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 self-hosted `ludicc`. -`bin/x app` wraps the common cases: +`bin/ludic build` wraps the common cases: ```bash -bin/x app examples/games/snake.ludic # -> build/snake (native) -bin/x app examples/library/combat.ludic --lib # -> build/libcombat.* (library) -bin/x app 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 # -> build/snake (native) +bin/ludic build examples/library/combat.ludic --lib # -> build/libcombat.* (library) +bin/ludic build examples/games/snake.ludic --headless # -> build/snake_headless (out.ppm) +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 -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. ## Programs and libraries @@ -223,7 +232,7 @@ only the triple changes. ``` ```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/ ``` @@ -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 -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 "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 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 build, run and render from IR alone. ## 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: ``` 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 (the check-docs gate; canonical formatting not yet restored) --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) environment: 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb4d23a7..9a63b40e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: ```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: ```bash -bin/x build # rebuild the whole toolchain into bin/ (ludicc, ludic, x, ludic-fmt, ludic-lsp) -bin/x help # list every command +bin/ludic dev build # the whole toolchain into bin/ (ludicc, ludic, ludic-fmt, ludic-lsp) +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 @@ -37,18 +43,18 @@ When you change the compiler or runtime, prove the self-hosting fixpoint still holds before you push: ```bash -bin/x 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/x test # the full regression suite +bin/ludic dev reseed # regenerate selfhost/ludicc.seed.ll after a compiler change +bin/ludic dev bootstrap-cfree # rebuild the compiler from the seed with NO C compiler in the loop +bin/ludic dev test # the full regression suite ``` Other useful targets: ```bash -bin/x app [--headless] # compile a program to a native app in build/ -bin/x selfhost-test # correctness + bootstrap fixpoints -bin/x test-tools # the editor-toolchain suite (ludic-fmt, ludic-lsp) -bin/x clean # remove build/, out.ppm and stray artifacts +bin/ludic build [--headless] # compile a program to a native app in build/ +bin/ludic dev selfhost-test # correctness + bootstrap fixpoints +bin/ludic dev test-tools # the editor-toolchain suite (ludic-fmt, ludic-lsp) +bin/ludic clean # remove build/, out.ppm and stray artifacts ``` ## 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 namespace gets exactly **one** directory (the docs check enforces this). 3. Add or extend an example under `examples/` and a case in the test suite. -4. Run `bin/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. 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. @@ -70,41 +76,47 @@ The stdlib lives in the runtime (`runtime/`) and is surfaced as namespaces ## Versioning & releases 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 (step 5 above). Read the next release before cutting it: ```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: ```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 ``` -`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 `VERSION`, commits `chore(release): vX.Y.Z`, and tags it. **Pushing the tag is what publishes.** The `release` workflow builds the -toolchain from the IR seed, runs `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 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 `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 needs a macOS host, and there is no cross-compile path (it would need the Xcode SDK and a Mach-O linker). Attaching one therefore means either registering a macOS runner and giving it a job, or running the same command CI runs from a -Mac. Either way it is `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 -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`, @@ -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 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 - **Commits:** [Conventional Commits](https://www.conventionalcommits.org) — @@ -132,7 +189,7 @@ checked-in seed rebuild that exact toolchain. | `perf` | a performance improvement | | `docs` | documentation only (`docs/`, README, comments) | | `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/` | | `style` | formatting/whitespace, no behaviour change | | `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. - **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 is a no-op — which leaves deliberate hand alignment in place; it is not a blanket `fmt(x) == x`. @@ -182,7 +239,7 @@ non-destructive version of "tidy the history" without touching a single commit. ## Pull requests - 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. - Fill in the PR template checklist. Reference the issue you close with `Closes #NN` in the description or a commit message. diff --git a/LANGUAGE.md b/LANGUAGE.md index ae43c8d1..658db1ad 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -1021,24 +1021,28 @@ literal; test any pointer/record/slice with `x == null` / `x != null` (an unset ## Tooling ```bash -ludicc app.ludic -o build/app # native binary (windowed for a game) -ludicc app.ludic --headless -o app # headless build (renders out.ppm; reads stdin) +ludic new mygame # a project that builds and plays as it stands +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 -ludic app.ludic # compile AND run (forwards the exit code) ``` -`ludicc` (compile) and `ludic` (compile-and-run) are one multi-call binary built -by `bin/x build-cli`. **[COMPILING.md](COMPILING.md) is the authoritative CLI -reference** — the full flag set (`-o`, `--windowed`, `--headless`, `--emit-llvm`, -`--save-temps`, `--run`), the `LUDIC_HOME` / `LUDIC_CC` environment variables, -and the IR-to-stdout bootstrap contract (no `-o`, invoked as `ludicc`) that -`bin/x app` / `bin/x reseed` rely on. The default mode is auto: a file with `handler`s -links windowed, otherwise headless; an explicit flag always wins. +`ludic` is the CLI (`ludic help`); `ludicc` is the compiler it drives, built from +the IR seed by `bin/ludic dev build-cli`. **[COMPILING.md](COMPILING.md) is the +authoritative CLI reference** — the full flag set (`-o`, `--windowed`, +`--headless`, `--emit-llvm`, `--save-temps`, `--run`), the `LUDIC_HOME` / +`LUDIC_CC` environment variables, and the IR-to-stdout bootstrap contract (no +`-o`) that `bin/ludic build` / `bin/ludic dev reseed` rely on. The default mode +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 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 -compiler flag. +Source formatting now lives in the standalone formatter — `ludic fmt` (below) — +not a compiler flag. 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 @@ -1049,7 +1053,7 @@ duplicate types, unknown fields, arity) are future work. ### Editors ```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 --check . # CI: exit 1 if anything is unformatted 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. ```bash -bin/x app examples/games/snake.ludic && ./build/snake +bin/ludic build examples/games/snake.ludic && ./build/snake ``` ## Not yet implemented @@ -1103,7 +1107,7 @@ self-hosting; their lowerings are in > **Implemented (S0).** `scene`, `layer`, and the `on enter` / `on exit` hooks > compile; [`examples/lang/scenes.ludic`](examples/lang/scenes.ludic) runs and is checked -> by `bin/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 > `become` as two direct calls plus a store. Richer scene features (the overlay > stack, scene-owned entities, scene-local state, transition parameters) are diff --git a/README.md b/README.md index c4e8c503..cf674c6d 100644 --- a/README.md +++ b/README.md @@ -35,34 +35,57 @@ program Hello { ## Getting started -`bin/x` is the project's task runner: one native binary, written in Ludic and -compiled by Ludic, that replaces every build and test script. Bootstrapping it -is the only step Ludic cannot do for itself, since compiling Ludic needs a -compiler — clang assembles the checked-in IR seed: +Install the toolchain — the compiler, the `ludic` CLI, the engine runtime, the +formatter and the language server — with one command: ```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 -bin/x build # -> bin/{ludicc,ludic,x,ludic-fmt,ludic-lsp} -bin/x app examples/games/snake.ludic # compile and open a native window -./build/snake +ludic new mygame +cd mygame +ludic run # compiles src/main.ludic and opens a native window ``` -Rendering is deterministic, so a frame can be produced without a window — this -is what CI diffs: +`ludic new` writes a manifest, a program that already moves something on screen, +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 -bin/x app examples/games/snake.ludic --headless -printf 'ddddwww' | ./build/snake_headless # writes build/out.ppm +ludic test +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 -grouped by intent: games, rendering, ECS, events, networking, language features -and the standard library. +`ludic help` lists every command, and `ludic doctor` checks the install. +[`examples/`](examples/README.md) is a tour grouped by intent: games, rendering, +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 @@ -93,24 +116,25 @@ Dependencies are identified by URL, resolved with minimal version selection, and cached in a content-addressed store: ```bash -bin/x add git.workshopsoft.io/user/pkg # resolve, fetch, link into ludic_modules/ -bin/x get # install from package.ludic, write the lock -bin/x verify # check locked packages against the store +ludic add git.workshopsoft.io/user/pkg # resolve, fetch, link into ludic_modules/ +ludic get # install from package.ludic, write the lock +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. ## Editor support -```bash -bin/x tools # -> bin/ludic-fmt, bin/ludic-lsp -``` - -`ludic-lsp` 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` 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, +Editors spawn `ludic lsp`; the server ships with the toolchain, so there is +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 +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). ## 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 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). ## Contributing diff --git a/changes/README.md b/changes/README.md index 10b708ec..edcdb02c 100644 --- a/changes/README.md +++ b/changes/README.md @@ -1,7 +1,7 @@ # Changesets 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. ## Format @@ -14,7 +14,7 @@ the changelog. Markdown is fine. ``` - `bump:` — `major`, `minor`, or `patch` (SemVer). The release version is bumped - by the **highest** level among the pending changesets (unless `x release ` + by the **highest** level among the pending changesets (unless `ludic dev release ` overrides it). - `type:` — the Conventional Commit type (`feat`, `fix`, `perf`, `docs`, …). It decides which group the change lands in: `feat` → **Features**, `fix` → @@ -47,5 +47,5 @@ filename works except this `README.md`, which the release step always skips. Preview how the next release will read before cutting it — this writes nothing: ```bash -x release --dry-run +ludic dev release --dry-run ``` diff --git a/changes/install-and-ludic-cli.md b/changes/install-and-ludic-cli.md new file mode 100644 index 00000000..3fb281f4 --- /dev/null +++ b/changes/install-and-ludic-cli.md @@ -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. diff --git a/changes/per-artifact-checksums.md b/changes/per-artifact-checksums.md index a94f83e5..d69db5d4 100644 --- a/changes/per-artifact-checksums.md +++ b/changes/per-artifact-checksums.md @@ -2,7 +2,7 @@ bump: patch type: fix 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 -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 published first and then never covered anything added afterwards. Per-artifact names compose across hosts. Verify one with diff --git a/docs/CONTROLLERS.md b/docs/CONTROLLERS.md index e12dd868..8ebb23af 100644 --- a/docs/CONTROLLERS.md +++ b/docs/CONTROLLERS.md @@ -64,7 +64,7 @@ controller. 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 -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`: ``` @@ -74,7 +74,7 @@ LUDIC_MODULES=packages ludicc examples/games/platformer_demo.ludic -o platformer Worked, self-checking examples for every controller live in `examples/games/` (`platformer_demo`, `platformer_scaffolding`, `shooter_demo`, `npcai_demo`, `rpg_demo`) and `examples/library/gameplay_foundation.ludic`; each is a deterministic regression -case in `x test`. +case in `ludic dev test`. ## What we deliberately do NOT do diff --git a/docs/PACKAGES.md b/docs/PACKAGES.md index 5351e67a..6676d8ec 100644 --- a/docs/PACKAGES.md +++ b/docs/PACKAGES.md @@ -1,6 +1,6 @@ # 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 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 ``` -x add [@version] add a dependency to package.ludic, then resolve + fetch + link -x 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 -x verify check every locked package against the store by content hash -x vendor copy the resolved packages into ./vendor for hermetic/offline builds +ludic add [@version] add a dependency to package.ludic, then resolve + fetch + link +ludic get resolve every dependency in package.ludic, link them, write the lock +ludic update [module] bump a dependency (or all) to its latest published version, then relock +ludic verify check every locked package against the store by content hash +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 `package.lock.ludic`. @@ -52,17 +52,17 @@ optional for a leaf application). ## 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: ``` -# 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 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" ``` -`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. ## The store and the project view @@ -176,31 +176,31 @@ always emitted (unused parts dead-strip). **Publishing.** In the package repo: ``` -x build-lib module.ludic # -> lib//lib.dylib +ludic build-lib module.ludic # -> lib//lib.dylib # add `kind prebuilt` and `targets ""` to package.ludic, commit lib/, git tag ``` **Consuming.** In the game project: ``` -x 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 add git.host/user/module # kind prebuilt is resolved + the dylib linked into the view +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 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 -not ship the build target, `x get` fails — build from source instead where the +(`ludic build` links them automatically when building in-repo.) If the package does +not ship the build target, `ludic get` fails — build from source instead where the package offers it. ## 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 network nor the global store. diff --git a/docs/language/testing/_section.md b/docs/language/testing/_section.md index 1aa8fae8..24552c99 100644 --- a/docs/language/testing/_section.md +++ b/docs/language/testing/_section.md @@ -4,4 +4,4 @@ title: Testing order: 34 --- -A built-in testing framework in the spirit of Go's go test: tests live next to the code, run with one command, and report pass/fail — no harness to wire up. A test "name" { … } block is discovered automatically and run by a synthetic entry point that prints ok - name or FAIL - name for each, a == N passed, M failed == summary, and exits non-zero if anything failed (so CI and the bin/x runner catch it). Inside a test, the expect, expect_eq and expect_near assertions check a condition and, on failure, print file:line: … failed (got …, want …) and mark the test failed — without aborting, so one run reports every failure. expect_near 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: function, print. +A built-in testing framework in the spirit of Go's go test: tests live next to the code, run with one command, and report pass/fail — no harness to wire up. A test "name" { … } block is discovered automatically and run by a synthetic entry point that prints ok - name or FAIL - name for each, a == N passed, M failed == summary, and exits non-zero if anything failed (so CI and the bin/ludic runner catch it). Inside a test, the expect, expect_eq and expect_near assertions check a condition and, on failure, print file:line: … failed (got …, want …) and mark the test failed — without aborting, so one run reports every failure. expect_near 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: function, print. diff --git a/docs/language/testing/kw-test.md b/docs/language/testing/kw-test.md index 868eca27..b0f88df8 100644 --- a/docs/language/testing/kw-test.md +++ b/docs/language/testing/kw-test.md @@ -17,12 +17,12 @@ Assertions (each records a failure and prints file:line: … failed - `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. -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. -Line coverage. Compile with the --coverage 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 $LUDIC_COVERAGE (default ludic.cov) as a FILE <name> header followed by one <line> <hits> 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. bin/x test --coverage 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. Compile with the --coverage 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 $LUDIC_COVERAGE (default ludic.cov) as a FILE <name> header followed by one <line> <hits> 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. bin/ludic dev test --coverage 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/testing.ludic 17/17 lines 100% ---- diff --git a/docs/site/site.json b/docs/site/site.json index 290012a2..fdcda08b 100644 --- a/docs/site/site.json +++ b/docs/site/site.json @@ -152,63 +152,69 @@ }, "start": { "kicker": "Get started", - "title": "From clone to a native window.", - "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.", + "title": "One command to install. One to play.", + "intro": "Install the toolchain with a single command — it brings the compiler, the ludic 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": [ { - "title": "Bootstrap from the seed", - "html": "bin/ is not checked in, so it is created first; then clang assembles the compiler's own checked-in LLVM IR seed, and that compiler builds bin/x, the task runner. This is the only step Ludic cannot do for itself." + "title": "Install", + "html": "The installer downloads a verified toolchain for your platform into ~/.ludic and puts it on your PATH. Nothing else is touched; uninstalling is rm -rf ~/.ludic. 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", - "html": "bin/x build produces ludicc, ludic, ludic-fmt and ludic-lsp — all compiled by Ludic, from Ludic." + "title": "Create a project", + "html": "ludic new mygame writes a manifest, a program that already moves something on screen, a test, and an assets/ directory. There is no scaffolding to choose and no build file to write." }, { - "title": "Compile & run an example", - "html": "bin/x app examples/games/snake.ludic turns a .ludic file into a native binary. Run it to open a real window." + "title": "Run it", + "html": "ludic run compiles src/main.ludic to a native binary and launches it. ludic build stops at the binary — one self-contained executable, with nothing to ship beside it." }, { - "title": "Go headless for tests", - "html": "--headless renders frames to a .ppm from piped input — deterministic output you can diff in CI." + "title": "Test it, headlessly", + "html": "ludic test compiles and runs every test block in the project. --headless renders frames to a .ppm from piped input, so a game is deterministic enough to diff in CI." } ], "terminal_name": "zsh — ludic", "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 }, { - "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 }, { - "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" @@ -228,6 +234,6 @@ "Sublime Text", "Zed" ], - "note": "bin/x tools builds ludic-fmt and ludic-lsp — the same formatter runs as a CLI for pre-commit hooks and CI." + "note": "Editors spawn ludic lsp — it ships with the toolchain, so there is nothing extra to build or install. The same formatter runs as ludic fmt for pre-commit hooks and CI." } } diff --git a/examples/README.md b/examples/README.md index 9e236cd8..71546d33 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,14 +1,14 @@ # Examples 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. Run any program straight from the repository root (so `assets/` resolves): ```sh -bin/x app 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 # compile + open a real window +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 ``` diff --git a/examples/games/snake.ludic b/examples/games/snake.ludic index 0032bc72..2cc24601 100644 --- a/examples/games/snake.ludic +++ b/examples/games/snake.ludic @@ -1,6 +1,6 @@ # ============================================================================ # 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 # entity per snake segment), named program state for the rest, and a Render diff --git a/examples/lang/detach.ludic b/examples/lang/detach.ludic index fd3ad8c5..5322e9e4 100644 --- a/examples/lang/detach.ludic +++ b/examples/lang/detach.ludic @@ -11,7 +11,7 @@ # 25 @OnDetach(Shield): reads the outgoing amount 5, prints 5 + 20 # 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 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 program SceneDemo { var counter: int = 0 diff --git a/examples/library/coverage.ludic b/examples/library/coverage.ludic index da9a933b..bbd903a9 100644 --- a/examples/library/coverage.ludic +++ b/examples/library/coverage.ludic @@ -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 -# 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. # # The tests below exercise `sign` fully but only the taken branches of `grade`, diff --git a/examples/networking/net_demo.ludic b/examples/networking/net_demo.ludic index f84974f2..dc156d16 100644 --- a/examples/networking/net_demo.ludic +++ b/examples/networking/net_demo.ludic @@ -14,7 +14,7 @@ # 5. rt_receive() — the client reconciles to the authoritative x=5 # # 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" program NetDemo { diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..71248777 --- /dev/null +++ b/install.sh @@ -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 </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" diff --git a/tools/docgen/README.md b/tools/docgen/README.md index 2378b137..afe0e5cb 100644 --- a/tools/docgen/README.md +++ b/tools/docgen/README.md @@ -10,7 +10,7 @@ docs/ language//.md one file per symbol — keyword, type, phase, builtin, namespace method, operator, annotation language//_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/snippets/*.ludic the code shown on the landing page (real programs) 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. ```bash -bin/x docs-gen --out build/pages # generate the whole site -bin/x docs-check build/pages # coverage + duplicate-token + link guard -bin/x check-docs # parse every ```ludic doc fence +bin/ludic dev docs-gen --out build/pages # generate the whole site +bin/ludic dev docs-check build/pages # coverage + duplicate-token + link guard +bin/ludic dev check-docs # parse every ```ludic doc fence ``` `docs-check` fails CI if any symbol in `inventory.json` lacks a page, if a token 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 -built `bin/x` (bootstrapped from the IR seed with clang alone). +built `bin/ludic` (bootstrapped from the IR seed with clang alone). ## Publish `.forgejo/workflows/docs.yml` bootstraps the toolchain from the IR seed and runs -`x docs-gen` + `x docs-check` on every push to `main` that touches `docs/**`, -`tools/docgen/**` or `tools/x/**`, and publishes the result to the `pages` branch +`ludic dev docs-gen` + `ludic dev docs-check` on every push to `main` that touches `docs/**`, +`tools/docgen/**` or `tools/ludic-cli/**`, and publishes the result to the `pages` branch root. `index.html` + `.nojekyll` always stay at the root. ## Colors `palette.json` and `selfhost/backend/stdlib/emit_color.ludic` are both generated -by `bin/x 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 +by `bin/ludic dev docs-palette` from a single palette table — the `pal_add(...)` rows in +`tools/ludic-cli/docgen.ludic`. Edit the table there and regenerate; do not hand-edit the generated files. diff --git a/tools/editors/README.md b/tools/editors/README.md index 234f507e..82016f63 100644 --- a/tools/editors/README.md +++ b/tools/editors/README.md @@ -21,14 +21,21 @@ tools/editors/ 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 -bin/x tools +bin/ludic dev tools ``` Produces `bin/ludic-fmt` and `bin/ludic-lsp`. Add `--install` to symlink both -into `~/.local/bin`, `--test` to run `bin/x test-tools` afterwards. +into `~/.local/bin`, `--test` to run `bin/ludic dev test-tools` afterwards. ## What you get, in any editor @@ -63,11 +70,14 @@ and otherwise keywords, builtins, and everything in scope. - **Anything else** that speaks LSP: ``` - command: bin/ludic-lsp --stdio + command: ludic lsp 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 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, 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` @@ -130,7 +140,7 @@ both spellings are what the language documents and uses: (`const R_DIR: int = 0 # 0 up`) survive a save; - `id=Root` inside a `ui` block and `{Enemy}` inside a query stay tight. -`bin/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 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 compiler's two tables, `ludic_syntax.h`, the TextMate grammar (JSON), and the JetBrains lexer (Kotlin). Adding a builtin and forgetting the rest is silent -failure, so `bin/x check-vocabulary` (written in Ludic) compares all five, and -`bin/x test-tools` runs it. +failure, so `bin/ludic dev check-vocabulary` (written in Ludic) compares all five, and +`bin/ludic dev test-tools` runs it. When you add a keyword or builtin: put it in `ludic_syntax.h`, then run -`bin/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. diff --git a/tools/editors/jetbrains/README.md b/tools/editors/jetbrains/README.md index 28c703bb..13049d1b 100644 --- a/tools/editors/jetbrains/README.md +++ b/tools/editors/jetbrains/README.md @@ -43,7 +43,7 @@ The first build downloads an IntelliJ IDEA Community distribution (over 1 GB), so expect it to take a while; later builds take seconds. Once the cache is warm, ```bash -LUDIC_TEST_JETBRAINS=1 bin/x test-tools +LUDIC_TEST_JETBRAINS=1 bin/ludic dev test-tools ``` includes the plugin build in the toolchain's own test run. @@ -55,4 +55,4 @@ LSP4IJ is a required dependency; the IDE offers to install it for you. **Settings -> Languages & Frameworks -> Ludic** — the path to `ludic-lsp` and to `ludicc`. Both default to `bin/` under the project root, which is where -`bin/x tools` puts them. +`bin/ludic dev tools` puts them. diff --git a/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/LudicServer.kt b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/LudicServer.kt index 642908df..cc1c4f4c 100644 --- a/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/LudicServer.kt +++ b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/LudicServer.kt @@ -17,7 +17,7 @@ import java.io.File /** * Where to find the toolchain. Both paths may be left blank, in which case the - * plugin looks under the project root for what `bin/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. */ @Service(Service.Level.PROJECT) @@ -60,13 +60,23 @@ internal fun findExecutable(root: String?, configured: String, names: List): String? { - val path = System.getenv("PATH") ?: return null - for (dir in path.split(File.pathSeparator)) { - for (name in names) { - val f = File(dir, name) - if (f.isFile && f.canExecute()) return f.absolutePath + val path = System.getenv("PATH") + if (path != null) { + for (dir in path.split(File.pathSeparator)) { + for (name in names) { + val f = File(dir, name) + 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 } @@ -108,9 +118,10 @@ class LudicConnectionProvider(private val project: Project) : ProcessStreamConne append(if (root != null) File(File(root, d), "ludic-lsp").path else "/$d/ludic-lsp") 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("Syntax highlighting works without it; completion and diagnostics do not.") } diff --git a/tools/editors/jetbrains/src/main/resources/META-INF/plugin.xml b/tools/editors/jetbrains/src/main/resources/META-INF/plugin.xml index a1377298..f2b11170 100644 --- a/tools/editors/jetbrains/src/main/resources/META-INF/plugin.xml +++ b/tools/editors/jetbrains/src/main/resources/META-INF/plugin.xml @@ -67,7 +67,7 @@ name="Ludic" factoryClass="io.ludic.ide.LudicLanguageServerFactory"> bin/x tools. + The Ludic language server. Built by bin/ludic dev tools. ]]> diff --git a/tools/editors/vscode/README.md b/tools/editors/vscode/README.md index 31a650e9..6bbfde27 100644 --- a/tools/editors/vscode/README.md +++ b/tools/editors/vscode/README.md @@ -23,7 +23,7 @@ for the language reference. Build the toolchain once, from the repository root: ```bash -bin/x tools +bin/ludic dev tools ``` That produces `bin/ludic-lsp` and `bin/ludic-fmt`, which the extension finds diff --git a/tools/editors/vscode/src/extension.js b/tools/editors/vscode/src/extension.js index 47f5c11b..b576913c 100644 --- a/tools/editors/vscode/src/extension.js +++ b/tools/editors/vscode/src/extension.js @@ -38,9 +38,21 @@ function resolveServer(folders) { 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; } +/** 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) { const configured = vscode.workspace.getConfiguration('ludic').get('compilerPath'); if (configured) return expand(configured, folders); @@ -54,6 +66,8 @@ function resolveCompiler(folders) { const p = path.join(dir, 'ludicc'); if (isExecutable(p)) return p; } + const installed = path.join(installRoot(), 'bin', 'ludicc'); + if (isExecutable(installed)) return installed; return ''; } @@ -84,7 +98,7 @@ async function start(context) { // Not an error worth a modal: the grammar still highlights, and plenty of // people open a .ludic file without having built the toolchain. output.appendLine( - 'ludic-lsp not found. 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.' ); return; diff --git a/tools/ludic-cli/build.ludic b/tools/ludic-cli/build.ludic new file mode 100644 index 00000000..640b27ce --- /dev/null +++ b/tools/ludic-cli/build.ludic @@ -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 +} diff --git a/tools/x/checks.ludic b/tools/ludic-cli/checks.ludic similarity index 98% rename from tools/x/checks.ludic rename to tools/ludic-cli/checks.ludic index 28f1e7ce..804375cf 100644 --- a/tools/x/checks.ludic +++ b/tools/ludic-cli/checks.ludic @@ -177,7 +177,7 @@ function count_nl(s: pointer, upto: int) -> int { 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 { ensure_ludicc() DC_OK = 0; DC_BAD = 0; DC_SKIP = 0; DC_FAILS = "" @@ -361,7 +361,7 @@ var CI_PROB: pointer = "" var CI_NPROB: int = 0 function ci_problem(msg: pointer) -> void { CI_PROB = CI_PROB + " - " + msg + "\n"; CI_NPROB += 1 } -# usage: x check-impl +# usage: ludic dev check-impl function cmd_check_impl() -> int { CI_PROB = ""; CI_NPROB = 0 let allsrc = read_all_selfhost() @@ -568,7 +568,7 @@ function set_union(a: []pointer, b: []pointer) -> []pointer { return out } -# usage: x check-vocabulary +# usage: ludic dev check-vocabulary function cmd_check_vocab() -> int { CV_PROB = ""; CV_N = 0 let h = read_file("tools/ludic-tools/ludic_syntax.h") @@ -702,10 +702,10 @@ function xml_valid(text: pointer) -> bool { return sp == 0 } -# usage: x lint-asset (validates one .json or .xml editor asset) +# usage: ludic dev lint-asset (validates one .json or .xml editor asset) function cmd_lint_asset() -> int { - if arg_count() < 3 { err("usage: x lint-asset \n"); return 2 } - let path = arg(2) + if arg_total() < 3 { err("usage: ludic dev lint-asset \n"); return 2 } + let path = arg_n(2) let t = read_file(path) if t == null { err("lint-asset: cannot read " + path + "\n"); return 1 } if s_index(path, ".json", 0) >= 0 { diff --git a/tools/x/docgen.ludic b/tools/ludic-cli/docgen.ludic similarity index 96% rename from tools/x/docgen.ludic rename to tools/ludic-cli/docgen.ludic index 55bcc166..fac191d2 100644 --- a/tools/x/docgen.ludic +++ b/tools/ludic-cli/docgen.ludic @@ -1,17 +1,17 @@ # docgen.ludic — the Ludic documentation-site generator and its guards, ported # off Python (gen.py / check.py / palette.py). Three x subcommands: # -# 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 # 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 # 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 # 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) ------------------ 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, "# a compile error (color_lookup returns -1, which emit_expr reports).\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, "# in tools/x/docgen.ludic. Edit the palette there and regenerate; do not hand-edit.\n") + sb_puts(b, "# GENERATED by `ludic dev docs-palette` from the single source-of-truth palette table\n") + sb_puts(b, "# in tools/ludic-cli/docgen.ludic. Edit the palette there and regenerate; do not hand-edit.\n") sb_puts(b, "# ============================================================================\n") sb_puts(b, "\n") 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)) } -# `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, # so the drift guard judges the working tree, not git HEAD. 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() # guard: duplicate names 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_json(json_out) { err("cannot write palette.json\n"); return 1 } if check { - if not shq(`cmp -s {ludic_out} selfhost/backend/stdlib/emit_color.ludic`) { err("emit_color.ludic drifted from the palette table (run: 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: 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: ludic dev docs-palette)\n"); return 1 } } print(`OK {string(len(pal_names))} colors`) return 0 diff --git a/tools/x/docgen_check.ludic b/tools/ludic-cli/docgen_check.ludic similarity index 96% rename from tools/x/docgen_check.ludic rename to tools/ludic-cli/docgen_check.ludic index 913a613f..1c3af2bb 100644 --- a/tools/x/docgen_check.ludic +++ b/tools/ludic-cli/docgen_check.ludic @@ -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 # pages contract is broken, an inventory symbol lacks a source file or page, a # token is documented on two pages, a namespace/section is split across dirs, or @@ -94,7 +94,7 @@ function drop_md(fn: pointer) -> pointer { function cmd_docs_check() -> int { var site = "build/pages" - if arg_count() >= 3 { site = arg(2) } + if arg_total() >= 3 { site = arg_n(2) } let problems = 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}/api.html`) { push(problems, "missing required file: api.html") } 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/** let id2src = meta_new() diff --git a/tools/x/docgen_gen.ludic b/tools/ludic-cli/docgen_gen.ludic similarity index 98% rename from tools/x/docgen_gen.ludic rename to tools/ludic-cli/docgen_gen.ludic index 32194fb4..ba62d8b7 100644 --- a/tools/x/docgen_gen.ludic +++ b/tools/ludic-cli/docgen_gen.ludic @@ -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 # docs/site/*, reads the asset templates in tools/docgen/assets, and emits the # whole pages-branch payload (per-symbol pages, namespace/color/api pages, the # landing page, ludic-highlight.js, symbols.json, .nojekyll) into --out. Output -# is byte-for-byte identical to the Python generator (gated in 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) # live in docgen.ludic; the JSON reader (json_parse/j_get) in json.ludic. @@ -951,11 +951,11 @@ function render_highlighter(sym: JVal) -> pointer { function cmd_docs_gen() -> int { var out = "build/pages" var i = 2 - while i < arg_count() { - let a = arg(i) + while i < arg_total() { + let a = arg_n(i) if a == "--out" { i += 1 - if i < arg_count() { out = arg(i) } + if i < arg_total() { out = arg_n(i) } } i += 1 } @@ -1013,6 +1013,12 @@ function cmd_docs_gen() -> int { jvput(root, "highlight", syms.sym) write_file(`{out}/symbols.json`, json_dumps(root, true, false)) 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(` pages: {string(npages)}`) return 0 diff --git a/tools/x/forgejo.ludic b/tools/ludic-cli/forgejo.ludic similarity index 100% rename from tools/x/forgejo.ludic rename to tools/ludic-cli/forgejo.ludic diff --git a/tools/x/json.ludic b/tools/ludic-cli/json.ludic similarity index 100% rename from tools/x/json.ludic rename to tools/ludic-cli/json.ludic diff --git a/tools/x/lsp_test.ludic b/tools/ludic-cli/lsp_test.ludic similarity index 99% rename from tools/x/lsp_test.ludic rename to tools/ludic-cli/lsp_test.ludic index 6a00894e..fa30de84 100644 --- a/tools/x/lsp_test.ludic +++ b/tools/ludic-cli/lsp_test.ludic @@ -1,5 +1,5 @@ # 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, # Neovim and the rest are exercising code paths that have been checked, because # they all speak to this one binary. diff --git a/tools/ludic-cli/main.ludic b/tools/ludic-cli/main.ludic new file mode 100644 index 00000000..7453b247 --- /dev/null +++ b/tools/ludic-cli/main.ludic @@ -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 [arguments]") + print("") + print("start a game:") + print(" new 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/ (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 [@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 compile a package's module to a prebuilt dylib in lib//") + 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 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 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 print that release's CHANGELOG.md section") + print(" dev changelog-render ") + 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 compile a Ludic game (headless)") + print(" dev sh-compile 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 — 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 \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 \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) + } +} diff --git a/tools/x/pkg.ludic b/tools/ludic-cli/pkg.ludic similarity index 94% rename from tools/x/pkg.ludic rename to tools/ludic-cli/pkg.ludic index dd52bfc0..1a89327f 100644 --- a/tools/x/pkg.ludic +++ b/tools/ludic-cli/pkg.ludic @@ -18,7 +18,7 @@ # infrastructure. A source package's Ludic is compiled together with the # 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 -# 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 ---------------------------------------------- @@ -419,7 +419,7 @@ function collision(sels: []Manifest) -> pointer { # ---- lockfile writing -------------------------------------------------------- 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() let sorted = sort_mans(sels) var i = 0 @@ -537,30 +537,30 @@ function set_require(module: pointer, ver: pointer) -> void { # ---- the commands ------------------------------------------------------------ -# x add [@version] — add/update a dependency then install +# ludic add [@version] — add/update a dependency then install function cmd_pkg_add() -> int { - if arg_count() < 3 { err("usage: x add [@version]\n"); return 1 } - let spec = split_spec(arg(2)) + if arg_total() < 3 { err("usage: ludic add [@version]\n"); return 1 } + let spec = split_spec(arg_n(2)) let module = spec[0] var ver = spec[1] if slen(ver) == 0 { 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 } - print(`x add: {module} -> latest v{ver}`) + print(`ludic add: {module} -> latest v{ver}`) } set_require(module, ver) 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 { let txt = read_file("package.ludic") - if txt == null { err("x: no package.ludic in the current directory (x add to start one)\n"); return 1 } + if txt == null { err("x: no package.ludic in the current directory (ludic add to start one)\n"); return 1 } print("resolving dependencies (MVS)…") 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 { let root = read_root_manifest() 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 { let latest = latest_version(d.module) 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) } } @@ -580,11 +580,11 @@ function cmd_pkg_update() -> int { 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 function cmd_pkg_verify() -> int { 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) if len(locked) == 0 { print("lockfile lists no packages"); return 0 } var bad_count = 0 @@ -602,7 +602,7 @@ function cmd_pkg_verify() -> int { if got == raw { let link = `ludic_modules/{m.module}` 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 { print(` TAMPERED {m.module}@{m.ver} (want sha256:{sslice(raw, 0, 12)}… got sha256:{sslice(got, 0, 12)}…)`) bad_count += 1 @@ -615,11 +615,11 @@ function cmd_pkg_verify() -> int { 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. function cmd_pkg_vendor() -> int { 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) run("rm -rf vendor") var i = 0 @@ -627,7 +627,7 @@ function cmd_pkg_vendor() -> int { let m = locked[i] let raw = strip_prefix(m.hash, "sha256:") 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}` run(`mkdir -p "$(dirname {vdir})"`) run(`cp -R {dest} {vdir}`) @@ -640,7 +640,7 @@ function cmd_pkg_vendor() -> int { # ---- 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//. The developer runs this in the package # 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, @@ -648,7 +648,7 @@ function cmd_pkg_vendor() -> int { function cmd_pkg_build_lib() -> int { var src = argn(2, "") 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 (run in the package directory)\n"); return 1 } + if src == "" or not file_exists(src) { err("usage: ludic build-lib (run in the package directory)\n"); return 1 } let man = read_root_manifest() var name = "" if man.module != "" { name = capture_line(`basename {man.module}`) } @@ -671,9 +671,9 @@ function cmd_pkg_build_lib() -> int { 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 -# `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. function cmd_pkg_link_flags() -> int { let f = prebuilt_link_flags() diff --git a/tools/x/pkg_test.ludic b/tools/ludic-cli/pkg_test.ludic similarity index 80% rename from tools/x/pkg_test.ludic rename to tools/ludic-cli/pkg_test.ludic index 11363af0..42fa5749 100644 --- a/tools/x/pkg_test.ludic +++ b/tools/ludic-cli/pkg_test.ludic @@ -3,7 +3,7 @@ # 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 → # 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. # 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}` - # ---- x get: resolve, fetch, store, link, lock ----------------------------- - if shq(`( cd {proj} && {envp} {root}/bin/x get > {work}/get.out 2>&1 )`) { - ok("x get resolves + fetches the dependency graph") + # ---- ludic get: resolve, fetch, store, link, lock ----------------------------- + if shq(`( cd {proj} && {envp} {root}/bin/ludic get > {work}/get.out 2>&1 )`) { + ok("ludic get resolves + fetches the dependency graph") } 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`) @@ -98,23 +98,23 @@ function cmd_test_pkg() -> int { bad2("consumer build failed", capture_line(`tail -2 {work}/build.out`)) } - # ---- x verify: content integrity against the store ------------------------ - if shq(`( cd {proj} && {envp} {root}/bin/x verify > {work}/verify.out 2>&1 )`) { - ok("x verify passes on an untouched store") - } else { bad2("x verify failed unexpectedly", capture_line(`tail -2 {work}/verify.out`)) } + # ---- ludic verify: content integrity against the store ------------------------ + if shq(`( cd {proj} && {envp} {root}/bin/ludic verify > {work}/verify.out 2>&1 )`) { + ok("ludic verify passes on an untouched store") + } else { bad2("ludic verify failed unexpectedly", capture_line(`tail -2 {work}/verify.out`)) } # 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/\".*//'`) run(`echo tampered >> {store}/{uhash}/util.ludic`) - if not shq(`( cd {proj} && {envp} {root}/bin/x verify > {work}/verify2.out 2>&1 )`) { - ok("x verify detects a tampered store entry") - } else { bad("x verify missed a tampered store entry") } + if not shq(`( cd {proj} && {envp} {root}/bin/ludic verify > {work}/verify2.out 2>&1 )`) { + ok("ludic verify detects 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 - # `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(`( cd {proj} && {envp} {root}/bin/x get > /dev/null 2>&1 )`) - if shq(`( cd {proj} && {envp} {root}/bin/x verify > {work}/verify3.out 2>&1 )`) { - ok("re-fetching heals a tampered store entry (x verify green again)") + run(`( cd {proj} && {envp} {root}/bin/ludic get > /dev/null 2>&1 )`) + if shq(`( cd {proj} && {envp} {root}/bin/ludic verify > {work}/verify3.out 2>&1 )`) { + 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`)) } # ---- 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 # use a package's property (component), model, @OnSpawn handler (system) and a # 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. let epkg = `{proxy}/example.test/enginepkg` run(`mkdir -p {epkg} && git -C {epkg} init -q`) @@ -132,14 +132,14 @@ function cmd_test_pkg() -> int { 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}/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 )`) { let got = capture_line(`{proj2}/game`) if got == "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("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) -------------------------- 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}/dupe.ludic`, "function dupe_tag() -> pointer { return \"d\" }\n") 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") } else { bad2("collision not rejected", capture_line(`tail -1 {work}/dupe.out`)) } # 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}/README`, "a prebuilt lib shipping no artifact for this host\n") 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") } 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()) - # ---- x vendor: hermetic copy --------------------------------------------- - run(`( cd {proj} && {envp} {root}/bin/x 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`) { - ok("x vendor copies the resolved packages into ./vendor") - } else { bad2("x vendor failed", capture_line(`tail -1 {work}/vendor.out`)) } + # ---- ludic vendor: hermetic copy --------------------------------------------- + run(`( cd {proj} && {envp} {root}/bin/ludic get > /dev/null 2>&1 )`) + 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("ludic vendor copies the resolved packages into ./vendor") + } else { bad2("ludic vendor failed", capture_line(`tail -1 {work}/vendor.out`)) } # ---- package-declarable engine-system + namespace (issue #62) ------------- # 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` 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") - 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`) if file_exists(`{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\")") } 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("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 - # 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 # the module registers a dynamic component and an @System(Update); the consumer # 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`) 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") - 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`) { - ok("x build-lib compiles a package module to a per-target dylib") - } else { bad2("x build-lib failed", capture_line(`tail -1 {work}/bl.out`)) } + 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("ludic build-lib compiles a package module to a per-target dylib") + } else { bad2("ludic build-lib failed", capture_line(`tail -1 {work}/bl.out`)) } pt_commit_tag(mp, "v1.0.0") 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}/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`) { - ok("x get fetches + links a prebuilt (kind prebuilt) package for the target") - } else { bad2("prebuilt x get failed", capture_line(`tail -1 {work}/get3.out`)) } + 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("ludic get fetches + links a prebuilt (kind prebuilt) package for the target") + } 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 - # 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`) - 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`) { let got = capture_line(`printf 'aaaaaa' | {proj3}/game`) if got == "3 42" { diff --git a/tools/x/prelude.ludic b/tools/ludic-cli/prelude.ludic similarity index 57% rename from tools/x/prelude.ludic rename to tools/ludic-cli/prelude.ludic index 8fa2176b..a8d78538 100644 --- a/tools/x/prelude.ludic +++ b/tools/ludic-cli/prelude.ludic @@ -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 -# single native binary (bin/x) that drives clang, the self-host compiler and the -# unix tools the same way the old *.sh files did — only now it is written in -# Ludic and compiled by Ludic. This fragment is the tiny standard library the -# commands lean on: process control, file IO, string trimming and a colored -# PASS/FAIL test harness. It carries no ECS, so it links as a plain CLI program. +# `ludic` is the one tool a user of the language ever runs: it creates projects, +# compiles and runs them, resolves packages, formats, tests, and (under +# `ludic dev`) drives every build/bootstrap/release task of the toolchain repo +# itself. It is a single native binary written in Ludic and compiled by Ludic, +# driving clang, the compiler and the unix tools through `run`. This fragment is +# the tiny standard library the commands lean on: process control, file IO, +# string trimming and a colored PASS/FAIL test harness. It carries no ECS, so it +# links as a plain CLI program. # -# Everything runs relative to the current directory, so `x` must be invoked from -# the repository root (the one-line bootstrap in README.md does exactly that). +# The user-facing commands work from any directory. The `ludic dev` tasks run +# relative to the current directory and expect the toolchain repo root. # ---- file IO ---------------------------------------------------------------- @@ -51,7 +53,7 @@ function shq(cmd: pointer) -> bool { return exit_code(run(cmd)) == 0 } # ---- scratch files ------------------------------------------------------------ # Every scratch file the runner writes lives under one per-process directory -# (`$TMPDIR/x_`), so `x test` and an `x check-*` can run side by side without +# (`$TMPDIR/x_`), so `ludic dev test` and an `x check-*` can run side by side without # clobbering each other's captures. main removes it on the way out. var x_tmp: pointer = null @@ -59,7 +61,7 @@ function tmp_dir() -> pointer { if x_tmp == null { var base = Os.temp_dir() if len(base) > 1 and base[len(base) - 1] == '/' { base = base[0..len(base) - 1] } # macOS: TMPDIR ends in '/' - x_tmp = `{base}/x_{Os.pid()}` + x_tmp = `{base}/ludic_{Os.pid()}` run(`mkdir -p {x_tmp}`) } return x_tmp @@ -69,10 +71,10 @@ function tmp_dir() -> pointer { function tmp_path(name: pointer) -> pointer { return `{tmp_dir()}/{name}` } # 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 { 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}`) } x_tmp = null } @@ -172,3 +174,83 @@ function report() -> int { if (FAIL == 0) { return 0 } return 1 } + +# ---- command arguments ------------------------------------------------------- +# +# A command reads its own arguments as arg_n(1), arg_n(2)… whether it was +# reached as `ludic get` or as `ludic dev docs-check DIR`. main sets the shift +# once per dispatch, so no command has to know how deep its namespace is — +# getting that wrong is how `docs-check DIR` silently checked the wrong +# directory. +var g_shift: int = 0 + +function arg_n(i: int) -> pointer { return arg(i + g_shift) } +function arg_total() -> int { return arg_count() - g_shift } + +# ---- the toolchain install --------------------------------------------------- +# +# The user-facing commands run anywhere, so they cannot assume a `bin/` in the +# current directory the way the old repo-root-only task runner did. They ask +# here instead, and get the same answer in both layouts that exist: an install +# (~/.ludic/bin/ludic, root ~/.ludic) and a checkout of the toolchain repo +# (bin/ludic, root the repo). The compiler derives its own root by the identical +# rule (see ludic_home() in selfhost/frontend/parse.ludic), so the two never +# disagree about where the runtime and the bundled packages live. + +var g_home: pointer = null + +# the directory holding this binary, with a trailing '/'. argv[0] carries no +# directory when the CLI was found on $PATH, which is the normal case for an +# install — ask the shell where it found it. +function self_dir() -> pointer { + var a0 = arg(0) + var slash = -1 + var i = 0 + while a0[i] != 0 { if a0[i] == '/' { slash = i }; i += 1 } + if slash < 0 { + let found = capture_line(`command -v {a0}`) + if found == "" { return "" } + a0 = found + slash = -1 + i = 0 + while a0[i] != 0 { if a0[i] == '/' { slash = i }; i += 1 } + if slash < 0 { return "" } + } + return a0[0..slash + 1] +} + +# The toolchain install root, with a trailing '/', or "" for the current +# directory. $LUDIC_HOME wins; otherwise the binary's own directory decides, and +# a directory named `bin` means the root is its parent. +function ludic_home() -> pointer { + if g_home != null { return g_home } + let env = getenv("LUDIC_HOME") + if env != null { + var e = env + if len(e) > 0 and e[len(e) - 1] != '/' { e = e + "/" } + g_home = e + return g_home + } + var d = self_dir() + if len(d) >= 4 and d[len(d) - 4..len(d)] == "bin/" { d = d[0..len(d) - 4] } + # a source checkout the CLI was run from elsewhere in: prefer a ./bin here + if not is_exec(`{d}bin/ludicc`) and is_exec("bin/ludicc") { d = "" } + g_home = d + return g_home +} + +# the path to a toolchain binary (ludicc, ludic-fmt, ludic-lsp). Falls back to +# the bare name — on $PATH — when the install root has no bin/ of its own. +function tool(name: pointer) -> pointer { + let p = `{ludic_home()}bin/{name}` + if is_exec(p) { return p } + return name +} + +function ludicc() -> pointer { return tool("ludicc") } + +# true in a checkout of the toolchain repo itself, where the `ludic dev` tasks +# have something to work on. +function in_toolchain_repo() -> bool { + return file_exists("selfhost/ludicc.seed.ll") and file_exists("tools/ludic-cli/main.ludic") +} diff --git a/tools/ludic-cli/project.ludic b/tools/ludic-cli/project.ludic new file mode 100644 index 00000000..a4c5e823 --- /dev/null +++ b/tools/ludic-cli/project.ludic @@ -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 \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 — scaffold a project that builds and runs as it stands. +function cmd_new() -> int { + if arg_total() < 3 { + err("usage: ludic new \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/ (build/_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}`) +} diff --git a/tools/x/release.ludic b/tools/ludic-cli/release.ludic similarity index 85% rename from tools/x/release.ludic rename to tools/ludic-cli/release.ludic index 295064bc..a88ca252 100644 --- a/tools/x/release.ludic +++ b/tools/ludic-cli/release.ludic @@ -1,13 +1,13 @@ # release.ludic — versioning + release cutting for the toolchain. # -# x version print the toolchain version (from the VERSION file) -# x release [level] cut a release: aggregate changes/ into CHANGELOG.md, +# ludic version print the toolchain version (from the VERSION file) +# ludic dev release [level] cut a release: aggregate changes/ into CHANGELOG.md, # bump VERSION, commit, and tag vX.Y.Z. `level` is # major|minor|patch; omitted, it is derived from the # highest `bump:` among the pending changesets. -# 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. -# 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. # Needs FORGEJO_TOKEN in the environment. # @@ -49,7 +49,7 @@ function compute_next(cur: pointer, level: pointer) -> pointer { 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. function cmd_version() -> int { 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")) } -# x changelog-render — print the CHANGELOG section that +# ludic dev changelog-render — print the CHANGELOG section that # `dir`'s changesets would produce. Used to re-render the sections of releases # cut before the renderer preserved markdown structure: check the changesets out # of the tag's parent commit, point this at them, and splice the result back in. function cmd_changelog_render() -> int { - if arg_count() < 5 { - err("usage: x changelog-render \n") + if arg_total() < 5 { + err("usage: ludic dev changelog-render \n") 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 } @@ -250,7 +250,7 @@ function cmd_changelog_render() -> int { # release section (or at the end of the header if this is the first release). function prepend_changelog() -> void { if not file_exists("CHANGELOG.md") { - run("printf '# Changelog\n\nAll notable changes to the Ludic toolchain, newest first. Generated from the\nchangesets under changes/ by 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") if (ln == "") { @@ -289,11 +289,11 @@ function changelog_section(ver: pointer) -> pointer { return sslice(text, start, n) } -# x changelog-section — print that release's CHANGELOG section. +# ludic dev changelog-section — print that release's CHANGELOG section. function cmd_changelog_section() -> int { - if arg_count() < 3 { err("usage: x changelog-section \n"); return 1 } - let sec = changelog_section(arg(2)) - if slen(sec) == 0 { err(`changelog-section: no section for v{arg(2)} in CHANGELOG.md\n`); return 1 } + if arg_total() < 3 { err("usage: ludic dev changelog-section \n"); return 1 } + let sec = changelog_section(arg_n(2)) + if slen(sec) == 0 { err(`changelog-section: no section for v{arg_n(2)} in CHANGELOG.md\n`); return 1 } out(sec) return 0 } @@ -305,8 +305,14 @@ function sha256_cmd() -> pointer { } # 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 -# release without checksums asks everyone downstream to trust the transport. +# the tag, a ready-to-run toolchain built on this host, and one `.sha256` beside +# 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 # 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") { let plat = capture_line("uname -s | tr '[:upper:]' '[:lower:]'") 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") 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`) { err("release: writing the per-artifact .sha256 files failed\n") @@ -338,10 +358,10 @@ function build_artifacts(ver: pointer) -> bool { 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 # 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 { if getenv_or("FORGEJO_TOKEN", "") == "" { err("publish: set FORGEJO_TOKEN (a Forgejo access token)\n"); return 1 } let body = tmp_path("rel_notes.md") @@ -358,8 +378,8 @@ function publish_tag(ver: pointer) -> int { function cmd_publish() -> int { var ver = read_version_or("") - if arg_count() >= 3 { - var a = arg(2) + if arg_total() >= 3 { + var a = arg_n(2) if s_starts(a, "v") { a = sslice(a, 1, slen(a)) } ver = a } @@ -381,8 +401,8 @@ function cmd_release() -> int { var publish = false var dry = false var ai = 2 - while ai < arg_count() { - let a = arg(ai) + while ai < arg_total() { + let a = arg_n(ai) if (a == "--publish") { publish = true } else { if (a == "--dry-run") { dry = true } else { if (a == "major") or (a == "minor") or (a == "patch") { level = a } @@ -409,7 +429,7 @@ function cmd_release() -> int { let sec = read_file(tmp_path("rel_section.md")) if sec != null { out(sec) } print("") - print(` dry run — nothing written. cut it with: x release {level}`) + print(` dry run — nothing written. cut it with: ludic dev release {level}`) return 0 } prepend_changelog() @@ -424,7 +444,7 @@ function cmd_release() -> int { print(` committed + tagged v{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})`) return 0 } diff --git a/tools/x/selfhost.ludic b/tools/ludic-cli/selfhost.ludic similarity index 96% rename from tools/x/selfhost.ludic rename to tools/ludic-cli/selfhost.ludic index a6dfc012..be911287 100644 --- a/tools/x/selfhost.ludic +++ b/tools/ludic-cli/selfhost.ludic @@ -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 # IR seed with clang alone — no C compiler is ever involved. function ensure_ludicc() -> void { + if not in_toolchain_repo() { return } run("mkdir -p bin build") if (not is_exec("bin/ludicc")) or newer("selfhost/ludicc.seed.ll", "bin/ludicc") { print("cc: selfhost/ludicc.seed.ll -> bin/ludicc (from the IR seed, no C compiler)") if not shq(`{cc()} selfhost/ludicc.seed.ll -o bin/ludicc`) { - err("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 --------------- -# 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 { run("mkdir -p build") 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 ---- -# usage: x sh-compile +# usage: ludic dev sh-compile function cmd_sh_compile(shbin: pointer, in: pointer, outbin: pointer) -> int { if not shq(`{shbin} {in} > {outbin}.ll`) { return 1 } if not shq(`{cc()} {outbin}.ll -o {outbin}`) { return 1 } @@ -143,7 +144,7 @@ function game_build_ok(shbin: pointer, game: pointer, outbin: pointer) -> bool { return true } -# usage: x game-build +# usage: ludic dev game-build function cmd_game_build(shbin: pointer, game: pointer, outbin: pointer) -> int { if game_build_ok(shbin, game, outbin) { print(`built {outbin}`); return 0 } print("build failed:") @@ -193,7 +194,7 @@ function cmd_bootstrap_cfree() -> int { print(" out.ll == seed.ll — the compiler rebuilds itself with no C compiler") return 0 } - print(" out.ll != seed.ll (seed is stale — regenerate with: 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")) 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 # 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 -# `x bootstrap-cfree` afterwards. +# `ludic dev bootstrap-cfree` afterwards. function cmd_reseed() -> int { run("mkdir -p build/cfree") if not write_selfhost_src("build/cfree/selfhost.ludic") { print("FAIL: write source"); return 1 } diff --git a/tools/x/selfhost_test.ludic b/tools/ludic-cli/selfhost_test.ludic similarity index 92% rename from tools/x/selfhost_test.ludic rename to tools/ludic-cli/selfhost_test.ludic index 7fca8b94..aa7223c6 100644 --- a/tools/x/selfhost_test.ludic +++ b/tools/ludic-cli/selfhost_test.ludic @@ -19,7 +19,7 @@ function sh_case(name: pointer, exp: pointer) -> void { # framebuffer's SHA-256 against the text baseline in selfhost/golden/renders.sha256. # A hash is tiny and text-diffable — a regression shows as a changed hex line in a # review, not an opaque "binary files differ". Regenerate the baseline with -# `bin/x golden` (see cmd_golden below). +# `bin/ludic dev golden` (see cmd_golden below). function game_case(path: pointer, keys: pointer) -> void { let nm = flat(path) if not game_build_ok("bin/ludicc", `examples/{path}.ludic`, `{tmp_dir()}/g_{nm}`) { bad(`{path}: build`); return } @@ -32,13 +32,13 @@ function game_case(path: pointer, keys: pointer) -> void { if not is_darwin() { skip(`{path} render (golden hash blessed on macOS)`); return } let got = capture_line("shasum -a 256 build/out.ppm | cut -d' ' -f1") let want = capture_line(`grep '^{path} ' selfhost/golden/renders.sha256 | cut -d' ' -f2`) - if (want == "") { bad2(path, "no golden hash (run: bin/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`) } else { bad2(path, `render hash {got} != golden {want}`) } } # render one golden game and return its " \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 { let nm = flat(path) if not game_build_ok("bin/ludicc", `examples/{path}.ludic`, `{tmp_dir()}/bg_{nm}`) { @@ -117,12 +117,12 @@ function cmd_selfhost_test() -> int { game_case("rendering/draw_prims", "") print("== the bootstrap fixpoint (seeded from bin/ludicc) ==") - if shq(`bin/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`)) } 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") } - else { bad("C-free bootstrap failed (seed stale? run: x reseed)"); out(capture(`cat {tmp_dir()}/cfree.out`)) } + if shq(`bin/ludic dev bootstrap-cfree > {tmp_dir()}/cfree.out 2>&1`) { ok("seed.ll rebuilds the compiler and is its own fixed point") } + else { bad("C-free bootstrap failed (seed stale? run: ludic dev reseed)"); out(capture(`cat {tmp_dir()}/cfree.out`)) } return report() } diff --git a/tools/x/test.ludic b/tools/ludic-cli/test.ludic similarity index 86% rename from tools/x/test.ludic rename to tools/ludic-cli/test.ludic index 22b908ed..2665ab30 100644 --- a/tools/x/test.ludic +++ b/tools/ludic-cli/test.ludic @@ -4,7 +4,7 @@ # 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. # -# It drives the other commands through `bin/x ` exactly where test.sh used +# It drives the other commands through `bin/ludic ` exactly where test.sh used # 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 @@ -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 # 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 -# 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). function controller_case(path: pointer, keys: pointer, exp: pointer, label: pointer) -> void { 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]`) } } +# 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 function qsmoke(path: pointer) -> void { let nm = flat(path) @@ -126,7 +158,7 @@ function panic_case() -> void { 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 # 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. @@ -176,7 +208,7 @@ function cmd_test_coverage() -> int { } COV_COVERED = 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/testing", `{tmp_dir()}/cov_out`) var pct = 100 @@ -186,7 +218,7 @@ function cmd_test_coverage() -> int { return 0 } -function cmd_test() -> int { +function cmd_dev_test() -> int { PASS = 0 FAIL = 0 @@ -199,7 +231,7 @@ function cmd_test() -> int { print("== the compiler builds from the IR seed (no C compiler) ==") 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") } else { bad("compiler did not build from the seed") } @@ -209,11 +241,11 @@ function cmd_test() -> int { game_case("games/chronorift", "ddddwwwwaassK") 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 'no C compiler' {tmp_dir()}/sh.out`) { ok("the compiler rebuilds itself from the seed, C-free") } else { bad("C-free bootstrap") } let corr = capture_line(`grep -c PASS {tmp_dir()}/sh.out`) - ok(`self-host suite: {corr} checks passed (see: 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) ==") qsmoke("lang/qdecl") @@ -367,12 +399,12 @@ function cmd_test() -> int { smoke("networking/net_rt") smoke("library/cursor_capture") # #89 Input.cursor_mode compiles (no-op headless; windowed links cocoa.ll) - print("== self-hosted front-end binaries (ludicc / ludic) ==") - # the two commands are one multi-call native binary built from the seed with - # clang alone. ludicc compiles; ludic compiles-and-runs. - if shq(`bin/x build-cli > {tmp_dir()}/cli.out 2>&1`) and is_exec("bin/ludicc") and is_exec("bin/ludic") { - ok("x build-cli builds bin/ludicc and bin/ludic from the seed (clang-only)") - } else { bad2("x build-cli", capture_line(`tail -1 {tmp_dir()}/cli.out`)) } + print("== the compiler and the CLI (ludicc / ludic) ==") + # ludicc comes out of the IR seed with clang alone; the CLI is then compiled + # by it, from Ludic. + if shq(`bin/ludic dev build-cli > {tmp_dir()}/cli.out 2>&1`) and is_exec("bin/ludicc") { + ok("ludic dev build-cli builds bin/ludicc from the seed (clang-only)") + } else { bad2("ludic dev build-cli", capture_line(`tail -1 {tmp_dir()}/cli.out`)) } # 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 @@ -398,37 +430,50 @@ function cmd_test() -> int { ok("ludicc --emit-llvm -> LLVM IR") } else { bad2("ludicc --emit-llvm", capture_line(`tail -1 {tmp_dir()}/cli.out`)) } - # ludic compiles, runs, and forwards the program's exit code. + # `ludic ` compiles, runs, and forwards the program's exit code. 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") } 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 # store + ludic_modules/ links + build, run as its own hermetic suite (it # stands up throwaway git repos, so like selfhost-test it runs as a sub-process # and this stage checks its footer). - print("== package manager (x test-pkg) ==") - run(`bin/x test-pkg > {tmp_dir()}/pkg.out 2>&1`) + print("== package manager (ludic dev test-pkg) ==") + run(`bin/ludic dev test-pkg > {tmp_dir()}/pkg.out 2>&1`) if shq(`grep -q '0 failed' {tmp_dir()}/pkg.out`) { let pc = capture_line(`grep -c PASS {tmp_dir()}/pkg.out`) - ok(`package-manager suite: {pc} checks passed (see: x test-pkg)`) - } else { bad2("x test-pkg", capture_line(`grep -i fail {tmp_dir()}/pkg.out | head -1`)) } + ok(`package-manager suite: {pc} checks passed (see: ludic dev test-pkg)`) + } else { bad2("ludic dev test-pkg", capture_line(`grep -i fail {tmp_dir()}/pkg.out | head -1`)) } # docs site generator + guard, in Ludic (no Python). docs-gen emits the whole # pages payload; docs-check is its coverage/integrity guard; docs-palette is # the named-colour source of truth (its --check mode regenerates the tracked # emit_color.ludic + palette.json into scratch files and compares). print("== docs site (Ludic generator, no Python) ==") - if shq(`bin/x docs-gen --out {tmp_dir()}/docs > {tmp_dir()}/docs.out 2>&1`) { - ok("x docs-gen -> the pages-branch payload") - } else { bad2("x 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`) { - ok("x docs-check passes on the generated site") - } else { bad2("x docs-check", capture_line(`tail -1 {tmp_dir()}/docs_chk.out`)) } - if shq(`bin/x docs-palette --check > {tmp_dir()}/pal.out 2>&1`) { - ok("x docs-palette regenerates emit_color.ludic + palette.json byte-identically") - } else { bad2("x docs-palette --check", capture_line(`tail -1 {tmp_dir()}/pal.out`)) } + if shq(`bin/ludic dev docs-gen --out {tmp_dir()}/docs > {tmp_dir()}/docs.out 2>&1`) { + ok("ludic dev docs-gen -> the pages-branch payload") + } else { bad2("ludic dev docs-gen", capture_line(`tail -1 {tmp_dir()}/docs.out`)) } + if shq(`bin/ludic dev docs-check {tmp_dir()}/docs > {tmp_dir()}/docs_chk.out 2>&1`) { + ok("ludic dev docs-check passes on the generated site") + } else { bad2("ludic dev docs-check", capture_line(`tail -1 {tmp_dir()}/docs_chk.out`)) } + if shq(`bin/ludic dev docs-palette --check > {tmp_dir()}/pal.out 2>&1`) { + ok("ludic dev docs-palette regenerates emit_color.ludic + palette.json byte-identically") + } else { bad2("ludic dev docs-palette --check", capture_line(`tail -1 {tmp_dir()}/pal.out`)) } return report() } diff --git a/tools/x/tools.ludic b/tools/ludic-cli/tools.ludic similarity index 90% rename from tools/x/tools.ludic rename to tools/ludic-cli/tools.ludic index 506f381d..42c00961 100644 --- a/tools/x/tools.ludic +++ b/tools/ludic-cli/tools.ludic @@ -27,12 +27,12 @@ function sync_vscode_grammar() -> void { # is `flag` present anywhere in argv? function has_flag(flag: pointer) -> bool { var i = 2 - while i < arg_count() { if (arg(i) == flag) { return true }; i += 1 } + while i < arg_total() { if (arg_n(i) == flag) { return true }; i += 1 } return false } # ---- tools: build the editor toolchain -------------------------------------- -# usage: x tools [--install] [--test] +# usage: ludic dev tools [--install] [--test] function cmd_tools() -> int { ensure_ludicc() print("ludicc: tools/ludic-tools/fmt.ludic -> bin/ludic-fmt (Ludic -> LLVM IR -> binary, no C)") @@ -57,8 +57,8 @@ function cmd_test_tools() -> int { PASS = 0 FAIL = 0 let fmt = "bin/ludic-fmt" - if not is_exec(fmt) { err("bin/ludic-fmt missing — run: x 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(fmt) { err("bin/ludic-fmt missing — run: ludic dev tools\n"); return 2 } + if not is_exec("bin/ludic-lsp") { err("bin/ludic-lsp missing — run: ludic dev tools\n"); return 2 } print("formatter") @@ -129,7 +129,7 @@ function cmd_test_tools() -> int { # features (go-to-def, find-usages, workspace symbols) can't resolve. Gate the # end-to-end protocol test on Darwin until the directory walk is portable. if is_darwin() { - let lrc = sh("bin/x test-lsp") + let lrc = sh("bin/ludic dev test-lsp") if lrc == 0 { ok("language server protocol") } else if lrc == 2 { skip("language server protocol (bin/ludic-lsp not built)") } else { bad("language server protocol") } @@ -160,10 +160,10 @@ function cmd_test_tools() -> int { # VS Code carries its own copy of the shared grammar; catch an edit to the copy if shq("cmp -s tools/editors/shared/ludic.tmLanguage.json tools/editors/vscode/syntaxes/ludic.tmLanguage.json") and shq("cmp -s tools/editors/shared/ludic.markdown-injection.json tools/editors/vscode/syntaxes/ludic.markdown-injection.json") and shq("cmp -s tools/editors/shared/language-configuration.json tools/editors/vscode/language-configuration.json") { ok("vscode grammar copies match tools/editors/shared") - } else { bad2("vscode grammar copies have drifted", "run: 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 - # 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 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") } @@ -175,19 +175,19 @@ function cmd_test_tools() -> int { # 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. - # 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. - 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, # types, phases — must have a docs/language page. This reads the implementation # (emit_ns_call + ludic_syntax.h), so shipping a feature without docs fails here. # Ported to Ludic; runs through x. - if shq("bin/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), # 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() } @@ -195,9 +195,9 @@ function cmd_test_tools() -> int { # JSON/XML validity, checked by the Ludic validators in checks.ludic (no Python). function test_json(path: pointer) -> void { let bn = capture_line(`basename {path}`) - if shq(`bin/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 { 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}`) } } diff --git a/tools/x/build.ludic b/tools/x/build.ludic deleted file mode 100644 index 444aa7fe..00000000 --- a/tools/x/build.ludic +++ /dev/null @@ -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 [--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 [--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 -} diff --git a/tools/x/main.ludic b/tools/x/main.ludic deleted file mode 100644 index 44503c7a..00000000 --- a/tools/x/main.ludic +++ /dev/null @@ -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 [--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 [@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 (#64) compile a package's module to a prebuilt dylib in lib//") - 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 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 print that release's CHANGELOG.md section") - print(" x changelog-render ") - 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 compile a Ludic game (headless)") - print(" x sh-compile 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 \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 \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) - } -}