feat(stdlib): namespaced standard library (issue #2)

Implement the bulk of the namespaced-stdlib proposal (workshopsoft/ludic#2):
156 namespace methods across Math, Text, List, Ease, Collide, World, Net,
Sys, Save, Mem, extended Screen, Color functions, extended Random, and Time.
All deterministic fixed-point; self-hosting (C-free bootstrap fixpoint holds).

Compiler (selfhost/):
- Math.*: sqrt/sin/cos/tan/atan2/asin/acos (fixed-point runtime prelude —
  bit-by-bit isqrt, 256-entry interpolated sine table, Ross atan2), plus
  hypot/dist/dist2/deg_to_rad/rad_to_deg/posmod/wrap/ping_pong/snapped/
  move_toward/smoothstep/lerp/remap/sign/floor/ceil/round.
- Text.* (complete): upper/lower/trim/repeat/pad, split/join/replace,
  and the libc-backed queries.
- List.* (complete): insert/remove_at/remove/sort plus the earlier ops.
- Ease.* (in/out/in_out/back/bounce) and Collide.* (rects/point_rect/
  circles/rect_circle).
- Phase 3: World/Net/Sys/Save namespaced over the bare builtins (byte-
  identical IR) and Mem.* (bytes/words/copy/fill/peek/poke).
- Screen.* extended (line/circle/fill_circle/triangle/fill_triangle via new
  runtime primitives; sprite/sprite_scaled aliases), Color.* functions,
  Random.* (value/int/sign), Time.* (frame/delta/elapsed/now — new
  game-loop frame counter).
- Fix a lexer bug: fixed-point literals with >4 fractional digits overflowed.

Docs & tooling:
- 129 new per-symbol doc pages; gen.py made data-driven (namespaces
  discovered from the docs, no hardcoded list); new check-impl.py enforces
  that every implemented namespace method / keyword / type / phase has a
  doc page, wired into `x test-tools`. Document the previously-undocumented
  keywords (break/continue/where/entry/new/public + and/or/not tokens).
- LSP: namespaced signature help (ns_method_sig) covering every namespace.

Tests: 12 new self-host/regression tests + a golden render for the drawing
primitives. All suites green (selfhost 21, regression 45, tools 29).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-30 00:26:19 +03:00
parent ff15c4e01d
commit a38195128f
235 changed files with 24676 additions and 7762 deletions

View file

@ -1,70 +0,0 @@
#!/bin/bash
# build-tools.sh — build the Ludic editor toolchain.
#
# ./tools/build-tools.sh -> build/ludic-fmt, build/ludic-lsp
# ./tools/build-tools.sh --test build, then run tools/test-tools.sh
# ./tools/build-tools.sh --install also symlink both into ~/.local/bin
#
# Both binaries are written in Ludic (tools/ludic-tools/fmt.ludic, lsp.ludic) and
# compiled by the Ludic compiler itself — no C is compiled. clang only assembles
# the emitted LLVM IR, the same floor the compiler stands on. The vocabulary the
# grammar/lexer share still lives in tools/ludic-tools/ludic_syntax.h (data, read
# by check-vocabulary.py); the tools carry their own copy of it in Ludic.
set -e
cd "$(dirname "$0")/.."
CC="${LUDIC_CC:-clang}"
SRC=tools/ludic-tools
OUT=build
mkdir -p "$OUT"
# the Ludic compiler, assembled from the checked-in IR seed (C-free)
if [ ! -x "$OUT/ludicc" ] || [ selfhost/ludicc.seed.ll -nt "$OUT/ludicc" ]; then
echo "cc: selfhost/ludicc.seed.ll -> $OUT/ludicc (from the IR seed, no C compiler)"
$CC selfhost/ludicc.seed.ll -o "$OUT/ludicc"
fi
build_ludic() {
local name="$1" src="$2"
if [ -x "$OUT/$name" ] && [ ! "$src" -nt "$OUT/$name" ] && [ ! "$OUT/ludicc" -nt "$OUT/$name" ]; then
echo "$name: up to date"; return
fi
echo "ludicc: $src -> $OUT/$name (Ludic -> LLVM IR -> binary, no C)"
"$OUT/ludicc" "$src" --emit-llvm -o "$OUT/$name.ll" >/dev/null 2>&1 \
&& $CC -O2 "$OUT/$name.ll" -o "$OUT/$name" \
&& rm -f "$OUT/$name.ll" || { echo "build failed: $name"; exit 1; }
}
build_ludic ludic-fmt "$SRC/fmt.ludic"
build_ludic ludic-lsp "$SRC/lsp.ludic"
# VS Code needs the grammar inside its own extension directory, so it gets a
# copy. A copy that can drift is the whole failure mode this toolchain is built
# to avoid, so it is refreshed here rather than maintained by hand, and
# test-tools.sh asserts the two are identical.
SHARED=tools/editors/shared
VSC=tools/editors/vscode
if [ -d "$VSC" ]; then
mkdir -p "$VSC/syntaxes"
for f in ludic.tmLanguage.json ludic.markdown-injection.json; do
cmp -s "$SHARED/$f" "$VSC/syntaxes/$f" || { cp "$SHARED/$f" "$VSC/syntaxes/$f"; echo "sync: $f -> $VSC/syntaxes/"; }
done
cmp -s "$SHARED/language-configuration.json" "$VSC/language-configuration.json" || {
cp "$SHARED/language-configuration.json" "$VSC/language-configuration.json"
echo "sync: language-configuration.json -> $VSC/"
}
fi
if [ "$1" = "--install" ] || [ "$2" = "--install" ]; then
mkdir -p "$HOME/.local/bin"
ln -sf "$PWD/$OUT/ludic-fmt" "$HOME/.local/bin/ludic-fmt"
ln -sf "$PWD/$OUT/ludic-lsp" "$HOME/.local/bin/ludic-lsp"
echo "linked into ~/.local/bin (make sure it is on your PATH)"
fi
if [ "$1" = "--test" ] || [ "$2" = "--test" ]; then
exec ./tools/test-tools.sh
fi
echo "done. editors: see tools/editors/README.md"

160
tools/docgen/check-impl.py Normal file
View file

@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Docs coverage, anchored to the implementation — not a hand-kept list.
The failure mode this prevents: you add a feature to the language and forget to
document it. Nobody notices until someone goes looking for the docs that were
never written. So the set of things that MUST have a doc page is read straight
from the implementation, and every one is required to have a page (and every
namespace-method page is required to correspond to something real):
* namespace methods selfhost/emit_expr.ludic `emit_ns_call` + the
`is_<ns>_ns` predicates it delegates to (Math/Text/List)
* keywords tools/ludic-tools/ludic_syntax.h LUDIC_KW_* (minus
LUDIC_KW_RESERVED, which is not-yet-implemented)
* primitive types ludic_syntax.h LUDIC_TYPES
* phases ludic_syntax.h LUDIC_PHASES
Coverage means: the symbol appears in the `tokens:` of some docs/language page.
For namespace methods that page is docs/language/<ns>/<ns>-<method>.md with
token `<Ns>.<method>`. Run: python3 tools/docgen/check-impl.py
"""
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
LANG = os.path.join(ROOT, "docs", "language")
problems = []
def read(*parts):
with open(os.path.join(ROOT, *parts), encoding="utf-8") as fh:
return fh.read()
def fn_body(text, name):
"""The source of `fn <name>` up to the next top-level `fn ` (or EOF)."""
m = re.search(r"^fn %s\b" % re.escape(name), text, re.M)
if not m:
return ""
rest = text[m.end():]
nxt = re.search(r"^fn ", rest, re.M)
return text[m.start(): m.end() + (nxt.start() if nxt else len(rest))]
def all_selfhost_source():
src = ""
d = os.path.join(ROOT, "selfhost")
for fn in sorted(os.listdir(d)):
if fn.endswith(".ludic"):
src += read("selfhost", fn) + "\n"
return src
def compiler_ns_methods():
"""{Namespace: set(method)} the compiler actually dispatches on."""
expr = read("selfhost", "emit_expr.ludic")
body = fn_body(expr, "emit_ns_call")
if not body:
problems.append("emit_ns_call not found in selfhost/emit_expr.ludic")
return {}
allsrc = all_selfhost_source()
result = {}
# each `if (ns == "X")` opens a block that runs to the next such marker
chunks = re.split(r'if \(ns == "', body)
for chunk in chunks[1:]:
mn = re.match(r'(\w+)"', chunk)
if not mn:
continue
ns = mn.group(1)
methods = set(re.findall(r'meth == "([A-Za-z_][A-Za-z0-9_]*)"', chunk))
if not methods:
# delegated form: `if is_<ns>_ns(meth) { return emit_<ns>_ns(...) }`
pm = re.search(r"is_(\w+)_ns\(meth\)", chunk)
if pm:
pred = fn_body(allsrc, "is_%s_ns" % pm.group(1))
methods = set(re.findall(r'meth == "([A-Za-z_][A-Za-z0-9_]*)"', pred))
if methods:
result.setdefault(ns, set()).update(methods)
return result
def documented():
"""(tokens set, {(ns, member): id} for namespace-method pages)."""
tokens = set()
nsmethods = {}
for cat in sorted(os.listdir(LANG)):
cdir = os.path.join(LANG, cat)
if not os.path.isdir(cdir):
continue
for fn in os.listdir(cdir):
if not fn.endswith(".md") or fn == "_section.md":
continue
meta = {}
for line in read("docs", "language", cat, fn).split("\n"):
m = re.match(r"^(\w+):\s*(.*)$", line)
if m:
meta[m.group(1)] = m.group(2).strip()
if line.strip() == "---" and meta:
break
tokens.update(meta.get("tokens", "").split())
if meta.get("kind") == "namespace-method" and meta.get("ns") and meta.get("member"):
nsmethods[(meta["ns"], meta["member"])] = meta.get("id", fn[:-3])
return tokens, nsmethods
def vocab_sets():
h = read("tools", "ludic-tools", "ludic_syntax.h")
def clist(name):
m = re.search(r"\b" + name + r"\s*\[\s*\]\s*=\s*\{(.*?)\}\s*;", h, re.S)
if not m:
problems.append("ludic_syntax.h: %s not found" % name)
return set()
return set(re.findall(r'"([^"]+)"', m.group(1)))
reserved = clist("LUDIC_KW_RESERVED")
keywords = (clist("LUDIC_KW_DECL") | clist("LUDIC_KW_CLAUSE")
| clist("LUDIC_KW_STMT")) - reserved
return keywords, clist("LUDIC_TYPES"), clist("LUDIC_PHASES")
def main():
ns_impl = compiler_ns_methods()
tokens, ns_doc = documented()
# 1) every implemented namespace method has a page with the right token
for ns, methods in sorted(ns_impl.items()):
for meth in sorted(methods):
page = os.path.join(LANG, ns.lower(), "%s-%s.md" % (ns.lower(), meth))
if (ns, meth) not in ns_doc:
problems.append("undocumented %s.%s — add %s (tokens: %s.%s)"
% (ns, meth, os.path.relpath(page, ROOT), ns, meth))
elif "%s.%s" % (ns, meth) not in tokens:
problems.append("%s.%s documented but its page lacks that `tokens:` entry" % (ns, meth))
# 2) every documented namespace method corresponds to real dispatch
for (ns, meth), sid in sorted(ns_doc.items()):
if meth not in ns_impl.get(ns, set()):
problems.append("stale doc %s: %s.%s is not dispatched by the compiler" % (sid, ns, meth))
# 3) every implemented keyword / type / phase is documented
keywords, types, phases = vocab_sets()
for label, names in (("keyword", keywords), ("type", types), ("phase", phases)):
for n in sorted(names):
if n not in tokens:
problems.append("undocumented %s: %s (no docs/language page lists it in `tokens:`)" % (label, n))
n = sum(len(v) for v in ns_impl.values())
if problems:
print("docs-vs-implementation drift:", file=sys.stderr)
for p in problems:
print(" -", p, file=sys.stderr)
return 1
print("docs cover the implementation: %d namespace methods, %d keywords, %d types, %d phases"
% (n, len(keywords), len(types), len(phases)))
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -106,9 +106,14 @@ def load_sections():
meta["related_list"] = meta.get("related", "").split()
entries.append(meta)
entries.sort(key=lambda e: (int(e.get("order", 999)), e.get("name", "")))
# A namespace section is discovered, not hardcoded: it is any section
# whose symbols are `namespace-method`s, named by their `ns:` field. So
# adding a namespace is just adding its docs dir — gen.py needs no edit.
ns_name = next((e["ns"] for e in entries
if e.get("kind") == "namespace-method" and e.get("ns")), None)
sections.append({"id": smeta.get("id", cat), "title": smeta.get("title", cat.title()),
"order": int(smeta.get("order", 999)), "blurb": sblurb,
"cat": cat, "entries": entries})
"cat": cat, "entries": entries, "ns_name": ns_name})
sections.sort(key=lambda s: (s["order"], s["title"]))
return sections
@ -142,7 +147,10 @@ def build_symbols(sections, palette):
elif kind == "annotation":
sym["annotations"][tok] = eid
if tip: sym["tips"][tok] = tip
# namespaces: Screen/Input/Random/Map from ns methods, plus Color
# namespaces are discovered from the ns methods present, plus Color; each
# namespace's tip falls back to its section blurb so no hardcoded list of
# namespaces is needed here when a new one is added.
ns_blurb = {s["ns_name"]: plain_text(s["blurb"]) for s in sections if s.get("ns_name")}
nsnames = set()
for tok in sym["nsmethods"]:
if "." in tok: nsnames.add(tok.split(".", 1)[0])
@ -150,13 +158,26 @@ def build_symbols(sections, palette):
for nn in sorted(nsnames):
sym["namespaces"][nn] = ns_page(nn)
sym["cards"][ns_page(nn)] = {"name": nn, "sig": nn + ".*", "kind": "namespace",
"tip": NS_TIP.get(nn, ""), "page": ns_page(nn) + ".html",
"tip": ns_tip(nn, ns_blurb), "page": ns_page(nn) + ".html",
"section": "Namespaces"}
return sym, items
NS_TIP = {"Screen": "The 2D drawing surface.", "Color": "The named color palette.",
"Input": "Reading the keyboard.", "Random": "The seeded, deterministic RNG.",
"Map": "The character-grid tilemap."}
def plain_text(s, limit=160):
"""The first sentence of a blurb, stripped of tags — for use as a one-line tip."""
t = re.sub(r"<[^>]+>", "", s or "").strip()
m = re.match(r"(.+?[.!?])(\s|$)", t)
if m: t = m.group(1)
return t if len(t) <= limit else t[:limit].rsplit(" ", 1)[0] + "…"
def ns_tip(nsname, ns_blurb):
"""A namespace's tip: an optional curated override, else its section blurb."""
return NS_TIP.get(nsname) or ns_blurb.get(nsname, "")
# Optional short overrides for a namespace's one-line tip. A namespace NOT
# listed here falls back to the first sentence of its section blurb (see
# ns_tip), so a newly added namespace needs no edit here. Color has no docs
# section of its own, so its override is required.
NS_TIP = {"Color": "The named color palette."}
# ---------------------------------------------------------------------------
# shared chrome
@ -238,7 +259,7 @@ def item_page(e, section, cfg, items):
<script>Ludic.highlightAll(); Ludic.installCards(); Ludic.flashTarget();</script>
</body></html>""", {
"TOPBAR": topbar(cfg),
"SECHREF": "ns-" + section["cat"] + ".html" if section["cat"] in ("screen","input","random","map") else "api.html#" + section["id"],
"SECHREF": ns_page(section["ns_name"]) + ".html" if section.get("ns_name") else "api.html#" + section["id"],
"SECTION": esc(section["title"]),
"NAME": esc(e.get("name","")),
"KIND": e.get("kind","").replace("namespace-method","method"),
@ -270,7 +291,7 @@ def ns_overview_page(nsname, section, cfg):
<script src="ludic-highlight.js"></script>
<script>Ludic.installCards(); Ludic.flashTarget();</script>
</body></html>""", {"TOPBAR": topbar(cfg), "NAME": esc(nsname), "BLURB": section["blurb"], "ROWS": rows})
return head_html(nsname + " — Ludic", NS_TIP.get(nsname,""), "item.css") + "\n<body>\n" + body
return head_html(nsname + " — Ludic", NS_TIP.get(nsname) or plain_text(section["blurb"]), "item.css") + "\n<body>\n" + body
def color_page(palette, cfg):
groups = ""
@ -416,11 +437,10 @@ def main():
continue
for e in s["entries"]:
W(e["id"] + ".html", item_page(e, s, cfg, items)); npages += 1
# namespace overview pages
by_cat = {s["cat"]: s for s in sections}
for nsname, cat in (("Screen","screen"),("Input","input"),("Random","random"),("Map","map")):
if cat in by_cat:
W(ns_page(nsname) + ".html", ns_overview_page(nsname, by_cat[cat], cfg)); npages += 1
# namespace overview pages — one per discovered namespace section
for s in sections:
if s["ns_name"]:
W(ns_page(s["ns_name"]) + ".html", ns_overview_page(s["ns_name"], s, cfg)); npages += 1
W("ns-color.html", color_page(palette, cfg)); npages += 1
# index + landing + assets
W("api.html", api_index(sections, cfg))

View file

@ -50,6 +50,20 @@
"fn-file_stdout",
"fn-file_stderr"
],
"collide": [
"collide-rects",
"collide-point_rect",
"collide-circles",
"collide-rect_circle"
],
"color": [
"color-rgb",
"color-rgba",
"color-lerp",
"color-darken",
"color-lighten",
"color-with_alpha"
],
"control": [
"kw-if",
"kw-while",
@ -60,6 +74,13 @@
"kw-state",
"kw-become"
],
"ease": [
"ease-in",
"ease-out",
"ease-in_out",
"ease-back",
"ease-bounce"
],
"ecs": [
"kw-spawn",
"kw-despawn",
@ -95,11 +116,77 @@
"input": [
"input-key"
],
"list": [
"list-len",
"list-push",
"list-clear",
"list-first",
"list-last",
"list-pop",
"list-swap",
"list-contains",
"list-index_of",
"list-reverse",
"list-insert",
"list-remove_at",
"list-remove",
"list-sort"
],
"map": [
"map-size",
"map-row",
"map-tile"
],
"math": [
"math-min",
"math-max",
"math-abs",
"math-clamp",
"math-sign",
"math-floor",
"math-ceil",
"math-round",
"math-lerp",
"math-inverse_lerp",
"math-remap",
"math-sqrt",
"math-sin",
"math-cos",
"math-tan",
"math-hypot",
"math-dist",
"math-dist2",
"math-deg_to_rad",
"math-rad_to_deg",
"math-posmod",
"math-wrap",
"math-ping_pong",
"math-snapped",
"math-move_toward",
"math-smoothstep",
"math-atan2",
"math-asin",
"math-acos"
],
"mem": [
"mem-bytes",
"mem-words",
"mem-copy",
"mem-fill",
"mem-peek",
"mem-poke"
],
"net": [
"net-send",
"net-poll",
"net-serialize",
"net-apply",
"net-owner",
"net-set_owner",
"net-is_server",
"net-is_owner",
"net-local_id"
],
"networking": [
"fn-net_send",
"fn-net_poll",
@ -134,7 +221,14 @@
"random": [
"random-range",
"random-chance",
"random-seed"
"random-seed",
"random-value",
"random-int",
"random-sign"
],
"save": [
"save-write",
"save-read"
],
"scenes": [
"kw-scene",
@ -154,7 +248,14 @@
"screen-show",
"screen-width",
"screen-height",
"screen-status"
"screen-status",
"screen-line",
"screen-circle",
"screen-fill_circle",
"screen-triangle",
"screen-fill_triangle",
"screen-sprite",
"screen-sprite_scaled"
],
"structure": [
"kw-program",
@ -172,6 +273,50 @@
"kw-ui",
"kw-enum"
],
"sys": [
"sys-arg",
"sys-arg_count",
"sys-exit",
"sys-run",
"sys-env",
"sys-read_char",
"sys-file_open",
"sys-file_read",
"sys-file_write",
"sys-file_seek",
"sys-file_tell",
"sys-file_close",
"sys-stdout",
"sys-stderr"
],
"text": [
"text-length",
"text-char_at",
"text-slice",
"text-starts_with",
"text-ends_with",
"text-contains",
"text-index_of",
"text-equals",
"text-concat",
"text-to_int",
"text-from_int",
"text-upper",
"text-lower",
"text-trim",
"text-repeat",
"text-pad_left",
"text-pad_right",
"text-split",
"text-join",
"text-replace"
],
"time": [
"time-frame",
"time-delta",
"time-elapsed",
"time-now"
],
"types": [
"type-bool",
"type-byte",
@ -185,5 +330,23 @@
"type-str",
"type-void",
"type-words"
],
"world": [
"world-get",
"world-set",
"world-has",
"world-count",
"world-size",
"world-spawn",
"world-save",
"world-load",
"world-prop_id",
"world-field_id",
"world-model_id",
"world-kind",
"world-register_prop",
"world-attach",
"world-detach",
"world-query_next"
]
}

View file

@ -13,8 +13,8 @@ tools/ludic-tools/ the actual language knowledge, in C, no dependenci
ludic_index.h error-tolerant reader: declarations, bindings, scopes
ludic_workspace.h multi-file model: imports, compilation units, Markdown
ludic_json.h just enough JSON for LSP
ludic_fmt_main.c -> build/ludic-fmt
ludic_lsp.c -> build/ludic-lsp
ludic_fmt_main.c -> bin/ludic-fmt
ludic_lsp.c -> bin/ludic-lsp
tools/editors/
shared/ TextMate grammar + Markdown injection + language config
@ -26,11 +26,11 @@ tools/editors/
## Build it
```bash
./tools/build-tools.sh
bin/x tools
```
Produces `build/ludic-fmt` and `build/ludic-lsp`. Add `--install` to symlink both
into `~/.local/bin`, `--test` to run `tools/test-tools.sh` afterwards.
Produces `bin/ludic-fmt` and `bin/ludic-lsp`. Add `--install` to symlink both
into `~/.local/bin`, `--test` to run `bin/x test-tools` afterwards.
## What you get, in any editor
@ -53,7 +53,7 @@ and otherwise keywords, builtins, and everything in scope.
## Per-editor setup
- **VS Code** — [`vscode/`](vscode/). `npm install && npx @vscode/vsce package`, then
install the `.vsix`. Finds the binaries under `build/` on its own.
install the `.vsix`. Finds the binaries under `bin/` on its own.
- **JetBrains** — [`jetbrains/`](jetbrains/). `./gradlew buildPlugin`, then install the
zip from disk. Uses LSP4IJ rather than the paid-IDE LSP API, so it works in
Community editions too.
@ -65,9 +65,9 @@ and otherwise keywords, builtins, and everything in scope.
- **Anything else** that speaks LSP:
```
command: build/ludic-lsp --stdio
command: bin/ludic-lsp --stdio
languages: ludic, markdown
initializationOptions: { "compilerPath": "build/ludicc", "compilerDiagnostics": true, "indentSize": 2 }
initializationOptions: { "compilerPath": "bin/ludicc", "compilerDiagnostics": true, "indentSize": 2 }
```
## Markdown
@ -98,8 +98,8 @@ prose:
The formatter handles Markdown too, which keeps documentation honest:
```bash
build/ludic-fmt --check LANGUAGE.md README.md # CI: fail if a fence is unformatted
build/ludic-fmt -w LANGUAGE.md # reformat the fences, leave the prose
bin/ludic-fmt --check LANGUAGE.md README.md # CI: fail if a fence is unformatted
bin/ludic-fmt -w LANGUAGE.md # reformat the fences, leave the prose
```
## Keeping a tree formatted
@ -109,8 +109,8 @@ 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 `build/ludic-fmt` has not been built — so it never
blocks a commit on a machine that has not run `build-tools.sh`.
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`.
## Why the formatter is not `ludicc --fmt`
@ -132,7 +132,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.
`tools/test-tools.sh` checks the property that matters: formatting every file in
`bin/x 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.
@ -142,7 +142,7 @@ 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 `tools/check-vocabulary.py` compares all five and
`tools/test-tools.sh` runs it.
`bin/x test-tools` runs it.
When you add a keyword or builtin: put it in `ludic_syntax.h`, then run
`./tools/test-tools.sh` and let it tell you which copies still need it.
`bin/x test-tools` and let it tell you which copies still need it.

View file

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

View file

@ -37,11 +37,11 @@ class LudicConfigurable(private val project: Project) : Configurable {
panel = FormBuilder.createFormBuilder()
.addLabeledComponent(JBLabel("Language server (ludic-lsp):"), serverField, 1, false)
.addComponentToRightColumn(
JBLabel("Leave empty to use build/ludic-lsp under the project, then PATH.")
JBLabel("Leave empty to use bin/ludic-lsp under the project, then PATH.")
)
.addLabeledComponent(JBLabel("Compiler (ludicc):"), compilerField, 1, false)
.addComponentToRightColumn(
JBLabel("Used for authoritative diagnostics. Empty means build/ludicc.")
JBLabel("Used for authoritative diagnostics. Empty means bin/ludicc.")
)
.addComponent(diagnostics)
.addLabeledComponent(JBLabel("Formatter indent:"), indent, 1, false)

View file

@ -146,8 +146,8 @@ internal fun serverCommand(root: String?, configured: String): List<String>? {
}
/** Where the toolchain is looked for, relative to the project root. */
internal val SERVER_SUBDIRS = listOf("build", "tools/build", "bin", ".")
internal val COMPILER_SUBDIRS = listOf("build", "bin", ".")
internal val SERVER_SUBDIRS = listOf("bin", "build", "tools/build", ".")
internal val COMPILER_SUBDIRS = listOf("bin", "build", ".")
class LudicLanguageServerFactory : LanguageServerFactory {
override fun createConnectionProvider(project: Project): StreamConnectionProvider =

View file

@ -15,7 +15,7 @@
<li>Highlighting of <code>```ludic</code> fenced blocks in Markdown.</li>
</ul>
<p>Set the paths under <b>Settings &rarr; Languages &amp; Frameworks &rarr; Ludic</b>, or leave
them blank to use <code>build/ludic-lsp</code> and <code>build/ludicc</code> under the
them blank to use <code>bin/ludic-lsp</code> and <code>bin/ludicc</code> under the
project root.</p>
]]></description>

View file

@ -23,10 +23,10 @@ for the language reference.
Build the toolchain once, from the repository root:
```bash
./tools/build-tools.sh
bin/x tools
```
That produces `build/ludic-lsp` and `build/ludic-fmt`, which the extension finds
That produces `bin/ludic-lsp` and `bin/ludic-fmt`, which the extension finds
on its own. If you keep them elsewhere, set `ludic.server.path`.
Format on save:
@ -37,7 +37,7 @@ Format on save:
## Settings
- `ludic.server.path` — where `ludic-lsp` lives. Empty = search `build/`, then `PATH`.
- `ludic.server.path` — where `ludic-lsp` lives. Empty = search `bin/`, then `PATH`.
- `ludic.server.enabled` — turn the server off and keep only static highlighting.
- `ludic.compilerPath` — where `ludicc` lives, for on-save diagnostics.
- `ludic.compilerDiagnostics` — whether to run the compiler at all.

View file

@ -24,7 +24,7 @@ function resolveServer(folders) {
const names = ['ludic-lsp', 'ludic-lsp.exe'];
for (const folder of folders) {
const root = folder.uri.fsPath;
for (const rel of ['build', 'tools/build', 'bin', '.']) {
for (const rel of ['bin', 'build', 'tools/build', '.']) {
for (const name of names) {
const p = path.join(root, rel, name);
if (isExecutable(p)) return p;
@ -45,8 +45,10 @@ function resolveCompiler(folders) {
const configured = vscode.workspace.getConfiguration('ludic').get('compilerPath');
if (configured) return expand(configured);
for (const folder of folders) {
const p = path.join(folder.uri.fsPath, 'build', 'ludicc');
if (isExecutable(p)) return p;
for (const rel of ['bin', 'build']) {
const p = path.join(folder.uri.fsPath, rel, 'ludicc');
if (isExecutable(p)) return p;
}
}
return '';
}
@ -74,7 +76,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 tools/build-tools.sh, or set "ludic.server.path". ' +
'ludic-lsp not found. Build it with `bin/x tools`, or set "ludic.server.path". ' +
'Syntax highlighting works without it; completion and diagnostics do not.'
);
return;

View file

@ -1188,6 +1188,203 @@ program LudicLsp {
fn badd(name: ptr, sig: ptr) -> void { push(g_builtins, name); push(g_bsigs, sig) }
fn is_builtin_name(name: ptr) -> bool { var i = 0; while i < len(g_builtins) { if (g_builtins[i] == name) { return true }; i = i + 1 }; return false }
fn builtin_sig(name: ptr) -> ptr { var i = 0; while i < len(g_builtins) { if (g_builtins[i] == name) { return g_bsigs[i] }; i = i + 1 }; return null }
# The namespaced standard-library surface: `Namespace.method`, mirroring the
# compiler's emit_ns_call (selfhost/emit_expr.ludic). Screen/Random/Input/Map
# methods resolve to the underlying bare builtin's signature; Math/Text/List
# are pure fixed-point / string / slice ops with their own signatures. Returns
# the signature label for signature help, or null if it is not a known method.
fn ns_method_sig(ns: ptr, meth: ptr) -> ptr {
if (ns == "Screen") {
if (meth == "clear") { return builtin_sig("clear") }
if (meth == "fill_rectangle") { return builtin_sig("fill_rect") }
if (meth == "draw_rectangle") { return builtin_sig("frame_rect") }
if (meth == "put_pixel") { return builtin_sig("put_px") }
if (meth == "draw_text") { return builtin_sig("text") }
if (meth == "draw_number") { return builtin_sig("text_int") }
if (meth == "show") { return builtin_sig("present") }
if (meth == "width") { return "Screen.width() -> int" }
if (meth == "height") { return "Screen.height() -> int" }
if (meth == "status") { return builtin_sig("status") }
if (meth == "line") { return "Screen.line(x1, y1, x2, y2, color)" }
if (meth == "circle") { return "Screen.circle(x, y, radius, color)" }
if (meth == "fill_circle") { return "Screen.fill_circle(x, y, radius, color)" }
if (meth == "triangle") { return "Screen.triangle(x1, y1, x2, y2, x3, y3, color)" }
if (meth == "fill_triangle") { return "Screen.fill_triangle(x1, y1, x2, y2, x3, y3, color)" }
if (meth == "sprite") { return "Screen.sprite(id, x, y)" }
if (meth == "sprite_scaled") { return "Screen.sprite_scaled(id, x, y, scale)" }
}
if (ns == "Map") {
if (meth == "size") { return builtin_sig("map_size") }
if (meth == "row") { return builtin_sig("map_row") }
if (meth == "tile") { return builtin_sig("tile") }
}
if (ns == "Random") {
if (meth == "range") { return builtin_sig("rng_range") }
if (meth == "chance") { return builtin_sig("rng_chance") }
if (meth == "seed") { return builtin_sig("seed") }
if (meth == "value") { return "Random.value() -> fixed" }
if (meth == "int") { return "Random.int(max) -> int" }
if (meth == "sign") { return "Random.sign() -> int" }
}
if (ns == "Color") {
if (meth == "rgb") { return "Color.rgb(r, g, b) -> int" }
if (meth == "rgba") { return "Color.rgba(r, g, b, a) -> int" }
if (meth == "lerp") { return "Color.lerp(c0, c1, t) -> int" }
if (meth == "darken") { return "Color.darken(c, amount) -> int" }
if (meth == "lighten") { return "Color.lighten(c, amount) -> int" }
if (meth == "with_alpha") { return "Color.with_alpha(c, a) -> int" }
}
if (ns == "Input") {
if (meth == "key") { return builtin_sig("key") }
}
if (ns == "Math") {
if (meth == "min") { return "Math.min(a, b) -> int" }
if (meth == "max") { return "Math.max(a, b) -> int" }
if (meth == "abs") { return "Math.abs(x) -> int" }
if (meth == "clamp") { return "Math.clamp(v, lo, hi) -> int" }
if (meth == "sign") { return "Math.sign(x) -> int" }
if (meth == "floor") { return "Math.floor(x) -> int" }
if (meth == "ceil") { return "Math.ceil(x) -> int" }
if (meth == "round") { return "Math.round(x) -> int" }
if (meth == "lerp") { return "Math.lerp(a, b, t) -> fixed" }
if (meth == "inverse_lerp") { return "Math.inverse_lerp(a, b, v) -> fixed" }
if (meth == "remap") { return "Math.remap(v, in0, in1, out0, out1) -> fixed" }
if (meth == "sqrt") { return "Math.sqrt(x) -> fixed" }
if (meth == "sin") { return "Math.sin(radians) -> fixed" }
if (meth == "cos") { return "Math.cos(radians) -> fixed" }
if (meth == "tan") { return "Math.tan(radians) -> fixed" }
if (meth == "atan2") { return "Math.atan2(y, x) -> fixed" }
if (meth == "asin") { return "Math.asin(x) -> fixed" }
if (meth == "acos") { return "Math.acos(x) -> fixed" }
if (meth == "hypot") { return "Math.hypot(x, y) -> fixed" }
if (meth == "dist") { return "Math.dist(x0, y0, x1, y1) -> fixed" }
if (meth == "dist2") { return "Math.dist2(x0, y0, x1, y1) -> fixed" }
if (meth == "deg_to_rad") { return "Math.deg_to_rad(degrees) -> fixed" }
if (meth == "rad_to_deg") { return "Math.rad_to_deg(radians) -> fixed" }
if (meth == "posmod") { return "Math.posmod(a, m) -> int" }
if (meth == "wrap") { return "Math.wrap(v, lo, hi) -> int" }
if (meth == "ping_pong") { return "Math.ping_pong(t, len) -> int" }
if (meth == "snapped") { return "Math.snapped(v, step) -> fixed" }
if (meth == "move_toward") { return "Math.move_toward(from, to, delta) -> fixed" }
if (meth == "smoothstep") { return "Math.smoothstep(e0, e1, x) -> fixed" }
}
if (ns == "Text") {
if (meth == "length") { return "Text.length(s) -> int" }
if (meth == "char_at") { return "Text.char_at(s, i) -> int" }
if (meth == "slice") { return "Text.slice(s, a, b) -> str" }
if (meth == "starts_with") { return "Text.starts_with(s, prefix) -> bool" }
if (meth == "ends_with") { return "Text.ends_with(s, suffix) -> bool" }
if (meth == "contains") { return "Text.contains(s, sub) -> bool" }
if (meth == "index_of") { return "Text.index_of(s, sub) -> int" }
if (meth == "equals") { return "Text.equals(a, b) -> bool" }
if (meth == "concat") { return "Text.concat(a, b) -> str" }
if (meth == "to_int") { return "Text.to_int(s) -> int" }
if (meth == "from_int") { return "Text.from_int(n) -> str" }
if (meth == "upper") { return "Text.upper(s) -> str" }
if (meth == "lower") { return "Text.lower(s) -> str" }
if (meth == "trim") { return "Text.trim(s) -> str" }
if (meth == "repeat") { return "Text.repeat(s, n) -> str" }
if (meth == "pad_left") { return "Text.pad_left(s, width) -> str" }
if (meth == "pad_right") { return "Text.pad_right(s, width) -> str" }
if (meth == "split") { return "Text.split(s, sep) -> []str" }
if (meth == "join") { return "Text.join(parts, sep) -> str" }
if (meth == "replace") { return "Text.replace(s, from, to) -> str" }
}
if (ns == "List") {
if (meth == "len") { return "List.len(s) -> int" }
if (meth == "push") { return "List.push(s, v)" }
if (meth == "clear") { return "List.clear(s)" }
if (meth == "first") { return "List.first(s) -> T" }
if (meth == "last") { return "List.last(s) -> T" }
if (meth == "pop") { return "List.pop(s) -> T" }
if (meth == "swap") { return "List.swap(s, i, j)" }
if (meth == "contains") { return "List.contains(s, v) -> bool" }
if (meth == "index_of") { return "List.index_of(s, v) -> int" }
if (meth == "reverse") { return "List.reverse(s)" }
if (meth == "insert") { return "List.insert(s, i, v)" }
if (meth == "remove_at") { return "List.remove_at(s, i)" }
if (meth == "remove") { return "List.remove(s, v)" }
if (meth == "sort") { return "List.sort(s)" }
}
if (ns == "Ease") {
if (meth == "in") { return "Ease.in(t) -> fixed" }
if (meth == "out") { return "Ease.out(t) -> fixed" }
if (meth == "in_out") { return "Ease.in_out(t) -> fixed" }
if (meth == "back") { return "Ease.back(t) -> fixed" }
if (meth == "bounce") { return "Ease.bounce(t) -> fixed" }
}
if (ns == "Collide") {
if (meth == "rects") { return "Collide.rects(ax, ay, aw, ah, bx, by, bw, bh) -> bool" }
if (meth == "point_rect") { return "Collide.point_rect(px, py, rx, ry, rw, rh) -> bool" }
if (meth == "circles") { return "Collide.circles(ax, ay, ar, bx, by, br) -> bool" }
if (meth == "rect_circle") { return "Collide.rect_circle(rx, ry, rw, rh, cx, cy, cr) -> bool" }
}
if (ns == "World") {
if (meth == "get") { return "World.get(prop_id, field_id, entity) -> int" }
if (meth == "set") { return "World.set(prop_id, field_id, entity, value)" }
if (meth == "has") { return "World.has(entity, prop_id) -> int" }
if (meth == "count") { return "World.count() -> int" }
if (meth == "size") { return "World.size() -> int" }
if (meth == "spawn") { return "World.spawn(model_id) -> entity" }
if (meth == "save") { return "World.save(buf) -> int" }
if (meth == "load") { return "World.load(buf, len)" }
if (meth == "prop_id") { return "World.prop_id(name) -> int" }
if (meth == "field_id") { return "World.field_id(prop_id, name) -> int" }
if (meth == "model_id") { return "World.model_id(name) -> int" }
if (meth == "kind") { return "World.kind(entity) -> int" }
if (meth == "register_prop") { return "World.register_prop(name, size) -> int" }
if (meth == "attach") { return "World.attach(entity, prop_id)" }
if (meth == "detach") { return "World.detach(entity, prop_id)" }
if (meth == "query_next") { return "World.query_next(query_id, cursor) -> int" }
}
if (ns == "Net") {
if (meth == "send") { return "Net.send(peer, buf, len)" }
if (meth == "poll") { return "Net.poll(buf, cap) -> int" }
if (meth == "serialize") { return "Net.serialize(entity, buf) -> int" }
if (meth == "apply") { return "Net.apply(entity, buf) -> int" }
if (meth == "owner") { return "Net.owner(entity) -> int" }
if (meth == "set_owner") { return "Net.set_owner(entity, peer)" }
if (meth == "is_server") { return "Net.is_server() -> bool" }
if (meth == "is_owner") { return "Net.is_owner(entity) -> bool" }
if (meth == "local_id") { return "Net.local_id() -> int" }
}
if (ns == "Sys") {
if (meth == "arg") { return "Sys.arg(i) -> str" }
if (meth == "arg_count") { return "Sys.arg_count() -> int" }
if (meth == "exit") { return "Sys.exit(code)" }
if (meth == "run") { return "Sys.run(command) -> int" }
if (meth == "env") { return "Sys.env(name) -> str" }
if (meth == "read_char") { return "Sys.read_char() -> int" }
if (meth == "file_open") { return "Sys.file_open(path, mode) -> ptr" }
if (meth == "file_read") { return "Sys.file_read(file, buf, n) -> int" }
if (meth == "file_write") { return "Sys.file_write(file, buf, n) -> int" }
if (meth == "file_seek") { return "Sys.file_seek(file, offset, whence) -> int" }
if (meth == "file_tell") { return "Sys.file_tell(file) -> int" }
if (meth == "file_close") { return "Sys.file_close(file)" }
if (meth == "stdout") { return "Sys.stdout() -> ptr" }
if (meth == "stderr") { return "Sys.stderr() -> ptr" }
}
if (ns == "Save") {
if (meth == "write") { return "Save.write()" }
if (meth == "read") { return "Save.read() -> bool" }
}
if (ns == "Time") {
if (meth == "frame") { return "Time.frame() -> int" }
if (meth == "delta") { return "Time.delta() -> fixed" }
if (meth == "elapsed") { return "Time.elapsed() -> fixed" }
if (meth == "now") { return "Time.now() -> int" }
}
if (ns == "Mem") {
if (meth == "bytes") { return "Mem.bytes(n) -> ptr" }
if (meth == "words") { return "Mem.words(n) -> words" }
if (meth == "copy") { return "Mem.copy(dst, src, n)" }
if (meth == "fill") { return "Mem.fill(buf, value, n)" }
if (meth == "peek") { return "Mem.peek(buf, i) -> int" }
if (meth == "poke") { return "Mem.poke(buf, i, value)" }
}
return null
}
fn init_builtins() -> void {
g_builtins = new []ptr; g_bsigs = new []ptr
badd("min", "min(a: int, b: int) -> int")
@ -2185,7 +2382,14 @@ program LudicLsp {
}
if callee < 0 or D.tk_kind[callee] != LT_ID { send_result(id, "null"); return }
let name = ttext(D, callee)
var sig = builtin_sig(name)
var sig: ptr = null
# a namespaced call `Namespace.method(` resolves through the ns surface first
let dot = prev_sig(D, callee)
if dot >= 0 and tis(D, dot, ".") {
let nsi = prev_sig(D, dot)
if nsi >= 0 and D.tk_kind[nsi] == LT_ID { sig = ns_method_sig(ttext(D, nsi), name) }
}
if (sig == null) { sig = builtin_sig(name) }
var docstr = ""
if (sig == null) {
let t = find_top(D, name)

View file

@ -11,7 +11,8 @@ import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SERVER = os.path.join(ROOT, "build", "ludic-lsp")
# the toolchain binaries live in bin/ (built by `x tools`); LUDIC_LSP overrides.
SERVER = os.environ.get("LUDIC_LSP", os.path.join(ROOT, "bin", "ludic-lsp"))
passed = 0
failed = 0
@ -171,9 +172,10 @@ def main():
"end": {"line": 9999, "character": 0}}})
check_true("inlay hints name query binding types", any(": Stats" == h["label"] for h in hints))
# a namespaced call resolves to its underlying bare-builtin signature
sig = c.request("textDocument/signatureHelp",
{"textDocument": uri, "position": position_of(text, "rng_chance(", 11)})
check_true("signature help for a builtin",
{"textDocument": uri, "position": position_of(text, "Random.chance(", 14)})
check_true("signature help for a namespaced builtin",
sig and sig["signatures"][0]["label"].startswith("rng_chance("))
toks = c.request("textDocument/semanticTokens/full", {"textDocument": uri})

View file

@ -1,198 +0,0 @@
#!/bin/bash
# test-tools.sh — regression suite for the Ludic editor toolchain.
#
# The two properties that matter most are checked against the real source tree
# rather than toy inputs:
#
# 1. the formatter is a no-op on already-formatted code, and idempotent;
# 2. formatting never changes the program — proved by comparing the
# COMPILER's own canonical AST dump before and after.
#
# Then the language server is driven over real LSP traffic on real files.
set -u
cd "$(dirname "$0")/.."
FMT=build/ludic-fmt
LSP=build/ludic-lsp
LUDICC=build/ludicc
PASS=0
FAIL=0
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; }
bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; [ $# -gt 1 ] && printf ' %s\n' "$2"; }
check() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "expected [$3] got [$2]"; fi; }
[ -x "$FMT" ] || { echo "build/ludic-fmt missing — run ./tools/build-tools.sh"; exit 2; }
[ -x "$LSP" ] || { echo "build/ludic-lsp missing — run ./tools/build-tools.sh"; exit 2; }
SOURCES=$(find examples runtime -name '*.ludic' | sort)
echo "formatter"
# --- idempotence: formatting twice equals formatting once -------------------
for f in $SOURCES; do
"$FMT" "$f" > "$TMP/a" 2>/dev/null
"$FMT" "$TMP/a" > "$TMP/b" 2>/dev/null
if ! cmp -s "$TMP/a" "$TMP/b"; then bad "idempotent: $f"; fi
done
[ $FAIL -eq 0 ] && ok "idempotent on $(echo "$SOURCES" | wc -l | tr -d ' ') files"
# --- meaning preserved: reformatting a formatted file must be a no-op --------
# (The compiler is now written in Ludic and has no --fmt AST-dump mode, so the
# formatter is validated by its own idempotence rather than against ludicc.)
MISMATCH=""
for f in examples/hello.ludic examples/snake.ludic examples/menu.ludic \
examples/qdecl.ludic examples/scenes.ludic; do
[ -f "$f" ] || continue
"$FMT" "$f" > "$TMP/f1.ludic"
"$FMT" "$TMP/f1.ludic" > "$TMP/f2.ludic"
cmp -s "$TMP/f1.ludic" "$TMP/f2.ludic" || MISMATCH="$MISMATCH $f"
done
if [ -z "$MISMATCH" ]; then ok "formatting is idempotent (fmt(fmt(x)) == fmt(x))"
else bad "formatting is not idempotent" "$MISMATCH"; fi
# --- the strongest invariant: only whitespace ever changes -----------------
# Every token is re-emitted in order, so stripping whitespace from the input and
# from the output must give identical bytes. This is what makes the formatter
# safe to run on save: it cannot lose, reorder or corrupt anything.
DRIFT=""
for f in $SOURCES README.md LANGUAGE.md COMPILING.md; do
[ -f "$f" ] || continue
"$FMT" "$f" > "$TMP/f" 2>/dev/null
a=$(tr -d ' \t\n' < "$f" | shasum | cut -d' ' -f1)
b=$(tr -d ' \t\n' < "$TMP/f" | shasum | cut -d' ' -f1)
[ "$a" = "$b" ] || DRIFT="$DRIFT $f"
done
if [ -z "$DRIFT" ]; then ok "only whitespace changes (byte-identical modulo spacing)"
else bad "formatter altered non-whitespace bytes" "$DRIFT"; fi
# --- multi-byte characters must survive intact ------------------------------
# A UTF-8 character outside a string is an error to the compiler, but the
# formatter must still not split it: doing so silently corrupts the file.
printf 'program E {\n # \xe2\x80\xa6 ellipsis in a comment\n const S: str = "caf\xc3\xa9 \xe2\x86\x92 na\xc3\xafve"\n}\n' > "$TMP/utf8.ludic"
"$FMT" "$TMP/utf8.ludic" > "$TMP/utf8.out"
if cmp -s "$TMP/utf8.ludic" "$TMP/utf8.out"; then ok "UTF-8 preserved byte for byte"
else bad "UTF-8 mangled" "$(diff "$TMP/utf8.ludic" "$TMP/utf8.out" | head -2)"; fi
# the same character where the compiler cannot make sense of it at all: it must
# still come out as one character, not as three spaced-out replacement bytes
printf 'program F {\nfn f() -> int { return \xe2\x80\xa6 }\n}\n' > "$TMP/utf8b.ludic"
a=$(tr -d ' \t\n' < "$TMP/utf8b.ludic" | shasum | cut -d' ' -f1)
b=$("$FMT" "$TMP/utf8b.ludic" | tr -d ' \t\n' | shasum | cut -d' ' -f1)
check "unrecognised characters pass through whole" "$b" "$a"
# --- comments survive, which is the whole reason this is not ludicc --fmt ---
printf 'program C {\n # keep me\n property P { x: int = 0 } # and me\n}\n' > "$TMP/c.ludic"
N=$("$FMT" "$TMP/c.ludic" | grep -c '#')
check "comments preserved" "$N" "2"
# --- messy input is normalised ----------------------------------------------
printf 'program M{\nproperty P{x:int=0}\nhandler S phase Update{let a=1+2\nif a== -1 {a=-a}}\n}\n' > "$TMP/m.ludic"
"$FMT" "$TMP/m.ludic" > "$TMP/m.out"
grep -q '^ property P { x: int = 0 }$' "$TMP/m.out" && ok "reindents and respaces" || bad "reindents and respaces"
grep -q 'a = -a' "$TMP/m.out" && ok "unary minus not spaced" || bad "unary minus not spaced"
# --- language conventions are respected, not overwritten --------------------
printf 'program U {\n ui M {\n panel id=Root w=10 {\n button id=Go text="go"\n }\n }\n}\n' > "$TMP/u.ludic"
"$FMT" "$TMP/u.ludic" | grep -q 'id=Root' && ok "widget props stay tight (id=Root)" || bad "widget props stay tight"
printf 'program Q {\n handler S phase Update {\n for (p) in query [Pos, {Foe}] { }\n }\n property Pos { x: int = 0 }\n property Foe { }\n}\n' > "$TMP/q.ludic"
"$FMT" "$TMP/q.ludic" | grep -q '\[Pos, {Foe}\]' && ok "query tags stay tight ({Foe})" || bad "query tags stay tight"
printf 'program A {\n const R_A: int = 0 # one\n const R_BBB: int = 1 # two\n}\n' > "$TMP/a.ludic"
"$FMT" "$TMP/a.ludic" | grep -q 'R_A: int = 0 # one' && ok "hand alignment preserved" || bad "hand alignment preserved"
# --- --check exit status, for CI and pre-commit hooks -----------------------
"$FMT" --check -q "$TMP/m.ludic" >/dev/null 2>&1
check "--check exits 1 on unformatted input" "$?" "1"
"$FMT" -w "$TMP/m.ludic" >/dev/null 2>&1
"$FMT" --check -q "$TMP/m.ludic" >/dev/null 2>&1
check "--check exits 0 after -w" "$?" "0"
# --- markdown: fences formatted, prose untouched ----------------------------
printf '# T\n\nprose with spaces\n\n```ludic\nprogram D{property P{x:int=0}}\n```\n\n```python\nz = 1\n```\n' > "$TMP/d.md"
"$FMT" "$TMP/d.md" > "$TMP/d.out"
grep -q 'prose with spaces' "$TMP/d.out" && ok "markdown prose untouched" || bad "markdown prose untouched"
grep -q 'z = 1' "$TMP/d.out" && ok "non-ludic fences untouched" || bad "non-ludic fences untouched"
grep -q 'program D { property P { x: int = 0 } }' "$TMP/d.out" && ok "ludic fences formatted" || bad "ludic fences formatted"
echo "language server"
python3 tools/test-lsp.py
LSPRC=$?
if [ $LSPRC -eq 0 ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); fi
echo
echo "editor assets"
for f in tools/editors/shared/ludic.tmLanguage.json \
tools/editors/shared/ludic.markdown-injection.json \
tools/editors/shared/language-configuration.json \
tools/editors/vscode/package.json \
tools/editors/vscode/snippets/ludic.json; do
if python3 -c "import json,sys;json.load(open(sys.argv[1]))" "$f" 2>/dev/null; then ok "valid JSON: $(basename "$f")"
else bad "invalid JSON: $f"; fi
done
for f in tools/editors/jetbrains/src/main/resources/META-INF/plugin.xml \
tools/editors/jetbrains/src/main/resources/META-INF/ludic-markdown.xml; do
if python3 -c "import xml.dom.minidom,sys;xml.dom.minidom.parse(sys.argv[1])" "$f" 2>/dev/null; then ok "valid XML: $(basename "$f")"
else bad "invalid XML: $f"; fi
done
if command -v node >/dev/null 2>&1; then
node --check tools/editors/vscode/src/extension.js 2>/dev/null && ok "extension.js parses" || bad "extension.js parses"
# A TextMate grammar is unvalidated JSON full of regexes; run it through the
# engine VS Code actually uses rather than assuming it still works.
echo "textmate grammar"
node tools/test-grammar.js
if [ $? -eq 0 ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); fi
else
echo " skip extension.js and grammar tests (node not installed)"
fi
# VS Code carries its own copy of the shared grammar because an extension must
# be self-contained. build-tools.sh refreshes it; this catches an edit made to
# the copy instead of the original.
SYNCED=1
for f in ludic.tmLanguage.json ludic.markdown-injection.json; do
cmp -s "tools/editors/shared/$f" "tools/editors/vscode/syntaxes/$f" || SYNCED=0
done
cmp -s tools/editors/shared/language-configuration.json tools/editors/vscode/language-configuration.json || SYNCED=0
if [ $SYNCED -eq 1 ]; then ok "vscode grammar copies match tools/editors/shared"
else bad "vscode grammar copies have drifted" "run ./tools/build-tools.sh to resync"; fi
# The JetBrains plugin is off by default: the first build downloads over a
# gigabyte of IntelliJ SDK, which does not belong in a test run. Once the Gradle
# cache is warm it takes seconds, so it is one env var away.
# LUDIC_TEST_JETBRAINS=1 ./tools/test-tools.sh
if [ "${LUDIC_TEST_JETBRAINS:-0}" = "1" ]; then
JB=tools/editors/jetbrains
if [ -x "$JB/gradlew" ]; then
( cd "$JB" && ./gradlew buildPlugin verifyPluginStructure --console=plain -q ) >/dev/null 2>&1 \
&& [ -f "$JB/build/distributions/Ludic-1.0.0.zip" ] \
&& ok "jetbrains plugin builds and verifies" \
|| bad "jetbrains plugin build" "cd $JB && ./gradlew buildPlugin (needs JDK 17-21)"
# the lexer/highlighter/PSI tests run headless in a real platform fixture
( cd "$JB" && ./gradlew test --console=plain -q ) >/dev/null 2>&1 \
&& ok "jetbrains platform tests (lexer, file type, PSI, folding)" \
|| bad "jetbrains platform tests" "cd $JB && ./gradlew test"
# the Plugin Verifier is what catches API breakage against a real IDE build.
# -q swallows its console output, so read the verdict it writes to disk.
( cd "$JB" && ./gradlew verifyPlugin --console=plain -q ) >/dev/null 2>&1
VERDICT=$(cat "$JB"/build/reports/pluginVerifier/*/plugins/*/*/verification-verdict.txt 2>/dev/null | head -1)
case "$VERDICT" in
Compatible) ok "plugin verified Compatible against the target IDE" ;;
Compatible*) bad "plugin verification: $VERDICT" "cd $JB && ./gradlew verifyPlugin" ;;
*) bad "plugin verification: ${VERDICT:-no verdict}" "cd $JB && ./gradlew verifyPlugin" ;;
esac
else
echo " skip jetbrains plugin (no gradle wrapper)"
fi
else
echo " skip jetbrains plugin build (set LUDIC_TEST_JETBRAINS=1)"
fi
# The vocabulary lives in one place; drift between it and the copies that cannot
# include it (the TextMate grammar, the Kotlin lexer) is the failure mode this
# whole layout is meant to prevent, so it is checked rather than trusted.
python3 tools/check-vocabulary.py && ok "vocabulary in sync across grammar/lexer/header" || bad "vocabulary drifted"
echo
echo "$PASS passed, $FAIL failed"
[ $FAIL -eq 0 ]

83
tools/x/build.ludic Normal file
View file

@ -0,0 +1,83 @@
# 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.
fn build_tool(name: ptr, src: ptr) -> 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.
fn 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) -----
fn cmd_build() -> int {
if cmd_build_cli() != 0 { return 1 }
print("ludicc: tools/x/main.ludic -> bin/x (the task runner rebuilds itself)")
if not build_tool("x", "tools/x/main.ludic") { return 1 }
print("ludicc: tools/ludic-tools/fmt.ludic -> bin/ludic-fmt")
if not build_tool("ludic-fmt", "tools/ludic-tools/fmt.ludic") { return 1 }
print("ludicc: tools/ludic-tools/lsp.ludic -> bin/ludic-lsp")
if not build_tool("ludic-lsp", "tools/ludic-tools/lsp.ludic") { return 1 }
sync_vscode_grammar()
print("done. toolchain in bin/ (ludicc, ludic, x, ludic-fmt, ludic-lsp)")
return 0
}
# ---- app: compile a Ludic program to a native app ----------------------------
# usage: x app <file.ludic> [--headless|--windowed] [--save-temps]
# windowed is the default for a game; headless renders the last frame to out.ppm.
fn 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] != 45) { src = a } # 45 = '-', so skip other flags
ai = ai + 1
}
if (src == null) { err("usage: x app <file.ludic> [--headless] [--save-temps]\n"); return 1 }
let name = capture_line(`basename {src} .ludic`)
if (mode == 2) {
let outbin = `build/{name}_headless`
let ll = `{outbin}.ll`
print(`ludicc: {src} -> {outbin} (renders the last frame to out.ppm)`)
if not shq(`bin/ludicc --headless {src} > {ll}`) { return 1 }
if not shq(`{cc()} -O2 {ll} -o {outbin}`) { return 1 }
if not save { run(`rm -f {ll}`) }
print(`done. run: printf 'ddss' | ./{outbin} && open 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 }
if not shq(`{cc()} -O2 {ll} runtime/native/cocoa.ll -framework Cocoa -Wl,-rpath,@loader_path -o {outbin}`) { return 1 }
if not save { run(`rm -f {ll}`) }
print(`done. run: ./{outbin} (from the repo root, so assets/ resolves)`)
return 0
}

80
tools/x/main.ludic Normal file
View file

@ -0,0 +1,80 @@
# 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):
#
# 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"
fn usage() -> void {
print("x — the Ludic task runner (run from the repository root)")
print("")
print("build & run:")
print(" x build build the whole toolchain into bin/ (ludicc, ludic, x, ludic-fmt, ludic-lsp)")
print(" x build-cli build just bin/ludicc and bin/ludic from the IR seed")
print(" x app <file.ludic> [--headless] [--save-temps]")
print(" compile a Ludic program to a native app in build/")
print(" x tools [--install] [--test] build the editor toolchain (ludic-fmt, ludic-lsp)")
print("")
print("test:")
print(" x test the full regression suite")
print(" x selfhost-test the self-hosting suite (correctness + bootstrap fixpoints)")
print(" x test-tools the editor-toolchain suite")
print("")
print("self-host internals:")
print(" x selfhost-build [ludicc] [out] assemble + compile the self-host compiler")
print(" x bootstrap the self-hosting fixpoint proof (seeded from bin/ludicc)")
print(" x bootstrap-cfree rebuild the compiler from the seed with no C compiler")
print(" x reseed regenerate selfhost/ludicc.seed.ll after a compiler change")
print(" x game-build <ludicc> <game.ludic> <out> compile a Ludic game (headless)")
print(" x sh-compile <ludicc> <in.ludic> <out> compile + link one .ludic file")
}
# a positional argument, or a default when absent
fn argn(i: int, dflt: ptr) -> ptr {
if (i < arg_count()) { return arg(i) }
return dflt
}
entry {
if (arg_count() < 2) { usage(); exit(1) }
let cmd = arg(1)
if (cmd == "build") { exit(cmd_build()) }
if (cmd == "build-cli") { exit(cmd_build_cli()) }
if (cmd == "app") { exit(cmd_app()) }
if (cmd == "tools") { exit(cmd_tools()) }
if (cmd == "test") { exit(cmd_test()) }
if (cmd == "selfhost-test") { exit(cmd_selfhost_test()) }
if (cmd == "test-tools") { exit(cmd_test_tools()) }
if (cmd == "bootstrap") { exit(cmd_bootstrap()) }
if (cmd == "bootstrap-cfree") { exit(cmd_bootstrap_cfree()) }
if (cmd == "reseed") { exit(cmd_reseed()) }
if (cmd == "selfhost-build") { exit(cmd_selfhost_build(argn(2, "bin/ludicc"), argn(3, "build/selfhost"))) }
if (cmd == "sh-compile") {
if (arg_count() < 5) { err("usage: x sh-compile <ludicc> <in.ludic> <out>\n"); exit(1) }
exit(cmd_sh_compile(arg(2), arg(3), arg(4)))
}
if (cmd == "game-build") {
if (arg_count() < 5) { err("usage: x game-build <ludicc> <game.ludic> <out>\n"); exit(1) }
exit(cmd_game_build(arg(2), arg(3), arg(4)))
}
if (cmd == "help") or (cmd == "--help") or (cmd == "-h") { usage(); exit(0) }
err(`x: unknown command '{cmd}'\n`)
usage()
exit(1)
}
}

119
tools/x/prelude.ludic Normal file
View file

@ -0,0 +1,119 @@
# prelude.ludic — the shared runtime for `x`, the Ludic task runner.
#
# `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.
#
# 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).
# ---- file IO ----------------------------------------------------------------
# read a whole file into a fresh NUL-terminated buffer (null if it cannot open)
fn read_file(path: ptr) -> ptr {
let f = file_open(path, "rb")
if (f == null) { return null }
file_seek(f, 0, 2)
let n = file_tell(f)
file_seek(f, 0, 0)
let buf = bytes(n + 1)
file_read(f, buf, n)
buf[n] = 0
file_close(f)
return buf
}
# overwrite `path` with `s`; returns false if it could not be opened
fn write_file(path: ptr, s: ptr) -> bool {
let f = file_open(path, "wb")
if (f == null) { return false }
file_write(f, s, len(s))
file_close(f)
return true
}
fn file_exists(path: ptr) -> bool { return shq(`test -e {path}`) }
fn is_exec(path: ptr) -> bool { return shq(`test -x {path}`) }
# is `a` newer than `b` (like the shell's `-nt`)?
fn newer(a: ptr, b: ptr) -> bool { return shq(`test {a} -nt {b}`) }
# ---- process control --------------------------------------------------------
# `run` returns the raw wait status; the program's exit code is the high byte.
fn exit_code(st: int) -> int { return (st >> 8) & 255 }
# run a command, returning its exit code (0 = success)
fn sh(cmd: ptr) -> int { return exit_code(run(cmd)) }
# run a command, true when it succeeded
fn shq(cmd: ptr) -> bool { return exit_code(run(cmd)) == 0 }
# run `cmd` and return its stdout (stderr discarded). Never null.
fn capture(cmd: ptr) -> ptr {
let tmp = "/tmp/x_capture.out"
run(`{cmd} > {tmp} 2>/dev/null`)
let s = read_file(tmp)
if (s == null) { return "" }
return s
}
# run `cmd`, join its output lines with single spaces and trim — the Ludic twin
# of the shell idiom `$(cmd | tr '\n' ' ' | sed 's/ *$//')`.
fn capture_line(cmd: ptr) -> ptr {
return capture(`{cmd} | tr '\n' ' ' | sed 's/ *$//'`)
}
# the last line of a command's output (for one-line error messages)
fn capture_tail(cmd: ptr) -> ptr {
return capture(`{cmd} 2>&1 | tail -1`)
}
fn getenv_or(name: ptr, dflt: ptr) -> ptr {
let v = getenv(name)
if (v == null) { return dflt }
return v
}
# ---- stdout helpers ---------------------------------------------------------
# write `s` with no trailing newline (print() always adds one)
fn out(s: ptr) -> void { file_write(file_stdout(), s, len(s)) }
fn err(s: ptr) -> void { file_write(file_stderr(), s, len(s)) }
# an ESC byte — the lexer has no \033, so build it by hand
fn esc() -> ptr { let b = bytes(2); b[0] = 27; b[1] = 0; return b }
fn c_green() -> ptr { return esc() + "[32m" }
fn c_red() -> ptr { return esc() + "[31m" }
fn c_reset() -> ptr { return esc() + "[0m" }
# ---- the PASS/FAIL test harness ---------------------------------------------
var PASS: int = 0
var FAIL: int = 0
fn ok(msg: ptr) -> void {
PASS = PASS + 1
print(` {c_green()}PASS{c_reset()} {msg}`)
}
fn bad(msg: ptr) -> void {
FAIL = FAIL + 1
print(` {c_red()}FAIL{c_reset()} {msg}`)
}
fn bad2(msg: ptr, detail: ptr) -> void {
bad(msg)
print(` {detail}`)
}
# assert two strings equal, reporting the mismatch
fn check(label: ptr, got: ptr, want: ptr) -> void {
if (got == want) { ok(label) }
else { bad2(label, `expected [{want}] got [{got}]`) }
}
# print the "== N passed, M failed ==" footer and return the process exit code
fn report() -> int {
print("")
print(`== {str(PASS)} passed, {str(FAIL)} failed ==`)
if (FAIL == 0) { return 0 }
return 1
}

201
tools/x/selfhost.ludic Normal file
View file

@ -0,0 +1,201 @@
# selfhost.ludic — building and bootstrapping the self-host compiler.
#
# Replaces selfhost/build.sh, selfhost/bootstrap.sh, selfhost/bootstrap-cfree.sh,
# selfhost/reseed.sh, selfhost/compile.sh and selfhost/game-build.sh. The one
# canonical fragment list — copied by hand into three of those scripts before —
# now lives here once, in selfhost_frags().
# the C toolchain driver (clang) that assembles and links the emitted IR
fn cc() -> ptr { return getenv_or("LUDIC_CC", "clang") }
# the self-host compiler's source fragments, in link order. This is THE list;
# the old scripts each carried their own copy.
fn selfhost_frags() -> []ptr {
let f = new []ptr
push(f, "selfhost/str.ludic")
push(f, "selfhost/buf.ludic")
push(f, "selfhost/io.ludic")
push(f, "selfhost/ast.ludic")
push(f, "selfhost/lex.ludic")
push(f, "selfhost/parse.ludic")
push(f, "selfhost/parse_game.ludic")
push(f, "selfhost/emit_core.ludic")
push(f, "selfhost/emit_head.ludic")
push(f, "selfhost/emit_addr.ludic")
push(f, "selfhost/emit_intrin.ludic")
push(f, "selfhost/emit_intrin2.ludic")
push(f, "selfhost/emit_math.ludic")
push(f, "selfhost/emit_text.ludic")
push(f, "selfhost/emit_list.ludic")
push(f, "selfhost/emit_ease.ludic")
push(f, "selfhost/emit_collide.ludic")
push(f, "selfhost/emit_mem.ludic")
push(f, "selfhost/emit_time.ludic")
push(f, "selfhost/emit_colorfn.ludic")
push(f, "selfhost/emit_color.ludic")
push(f, "selfhost/emit_new.ludic")
push(f, "selfhost/emit_expr.ludic")
push(f, "selfhost/emit_stmt.ludic")
push(f, "selfhost/emit_ecs.ludic")
push(f, "selfhost/emit_query.ludic")
push(f, "selfhost/emit_spawn.ludic")
push(f, "selfhost/emit_game.ludic")
push(f, "selfhost/emit_machine.ludic")
push(f, "selfhost/emit_save.ludic")
push(f, "selfhost/emit_net.ludic")
push(f, "selfhost/emit_ui.ludic")
push(f, "selfhost/emit_decl.ludic")
push(f, "selfhost/main.ludic")
return f
}
# concatenate the fragments into `program SelfHost { ... }` at `outpath`.
# This is the pure-Ludic equivalent of the scripts' `{ echo; for; cat; }` block.
fn write_selfhost_src(outpath: ptr) -> bool {
let f = file_open(outpath, "wb")
if (f == null) { return false }
let hdr = "program SelfHost {\n"
file_write(f, hdr, len(hdr))
let frags = selfhost_frags()
var i = 0
while i < len(frags) {
let s = read_file(frags[i])
if (s != null) { file_write(f, s, len(s)) }
file_write(f, "\n", 1)
i = i + 1
}
let ftr = "}\n"
file_write(f, ftr, len(ftr))
file_close(f)
return true
}
# count the lines in a file (for the progress notes the scripts printed)
fn line_count(path: ptr) -> ptr {
return capture_line(`wc -l < {path}`)
}
# ensure bin/ludicc exists and is current with the seed. Built from the checked-in
# IR seed with clang alone — no C compiler is ever involved.
fn ensure_ludicc() -> void {
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)
}
}
}
# ---- selfhost-build: assemble + compile the self-host compiler ---------------
# usage: x selfhost-build [ludicc] [outbin] (defaults: bin/ludicc, build/selfhost)
fn cmd_selfhost_build(lc: ptr, outbin: ptr) -> int {
run("mkdir -p build")
let src = "build/selfhost.ludic"
if not write_selfhost_src(src) { err("x: cannot write build/selfhost.ludic\n"); return 1 }
let ll = `{outbin}.ll`
if not shq(`{lc} {src} > {ll} 2>/dev/null`) { run(`rm -f {ll}`); return 1 }
let rc = shq(`{cc()} {ll} -o {outbin} 2>/dev/null`)
run(`rm -f {ll}`)
if rc { return 0 }
return 1
}
# ---- sh-compile: compile one .ludic with a given selfhost binary and link ----
# usage: x sh-compile <selfhost-binary> <input.ludic> <output-binary>
fn cmd_sh_compile(shbin: ptr, in: ptr, outbin: ptr) -> int {
if not shq(`{shbin} {in} > {outbin}.ll`) { return 1 }
if not shq(`{cc()} {outbin}.ll -o {outbin}`) { return 1 }
return 0
}
# ---- game-build: compile a Ludic GAME with the self-host compiler ------------
# The compiler resolves the game's imports and auto-splices the runtime; -O2
# strips the dead windowing branches so a headless link needs no win_* defs.
# the quiet core, reused by the test suites; returns true on success. On failure
# the self-host/link diagnostics are left in /tmp/x_gb.err.
fn game_build_ok(shbin: ptr, game: ptr, outbin: ptr) -> bool {
let ll = `{outbin}.ll`
if not shq(`{shbin} {game} > {ll} 2>/tmp/x_gb.err`) { return false }
if not shq(`{cc()} -O2 {ll} -o {outbin} 2>/tmp/x_gb.err`) { return false }
run(`rm -f {ll}`)
return true
}
# usage: x game-build <selfhost-binary> <game.ludic> <out-binary>
fn cmd_game_build(shbin: ptr, game: ptr, outbin: ptr) -> int {
if game_build_ok(shbin, game, outbin) { print(`built {outbin}`); return 0 }
print("build failed:")
out(capture("grep -i error /tmp/x_gb.err | head -5"))
return 1
}
# ---- bootstrap: the self-hosting fixpoint proof (seeded from bin/ludicc) ------
# stage0 bin/ludicc compiles the self-host source -> gen1 (built by a different
# compiler, so its IR legitimately differs); gen1 -> gen2, gen2 -> gen3, and
# gen2.ll == gen3.ll is the fixpoint that proves the compiler reproduces itself.
fn cmd_bootstrap() -> int {
ensure_ludicc()
run("mkdir -p build/boot")
if cmd_selfhost_build("bin/ludicc", "build/boot/gen1") != 0 { print("FAIL: stage0 build"); return 1 }
print(" stage0: bin/ludicc -> gen1 (self-host compiler)")
if not shq("build/boot/gen1 build/selfhost.ludic > build/boot/gen2.ll 2>/dev/null") { print("FAIL: gen1 self-compile"); return 1 }
if not shq(`{cc()} build/boot/gen2.ll -o build/boot/gen2 2>/dev/null`) { print("FAIL: gen2 assemble"); return 1 }
print(` stage1: gen1 -> gen2.ll ({line_count("build/boot/gen2.ll")} lines) -> gen2`)
if not shq("build/boot/gen2 build/selfhost.ludic > build/boot/gen3.ll 2>/dev/null") { print("FAIL: gen2 self-compile"); return 1 }
print(` stage2: gen2 -> gen3.ll ({line_count("build/boot/gen3.ll")} lines)`)
if shq("cmp -s build/boot/gen2.ll build/boot/gen3.ll") {
run("rm -f build/selfhost.ludic")
print(" FIXPOINT: gen2.ll == gen3.ll — the self-hosted compiler reproduces itself")
return 0
}
print(" gen2.ll != gen3.ll")
out(capture("diff build/boot/gen2.ll build/boot/gen3.ll | head"))
return 1
}
# ---- bootstrap-cfree: rebuild the compiler with NO C compiler -----------------
# The seed is the compiler's own IR, a proven fixed point: clang assembles it,
# the seed-built compiler compiles its own source, and the result must equal the
# seed byte for byte.
fn cmd_bootstrap_cfree() -> int {
run("mkdir -p build/cfree")
if not shq(`{cc()} selfhost/ludicc.seed.ll -o build/cfree/sh_seed 2>/dev/null`) { print("FAIL: assemble seed"); return 1 }
print(" seed.ll --clang--> sh_seed (no C compiler used)")
if not write_selfhost_src("build/cfree/selfhost.ludic") { print("FAIL: write source"); return 1 }
if not shq("build/cfree/sh_seed build/cfree/selfhost.ludic > build/cfree/out.ll 2>/dev/null") { print("FAIL: seed compiler self-compile"); return 1 }
print(` sh_seed compiles selfhost.ludic -> out.ll ({line_count("build/cfree/out.ll")} lines)`)
if shq("cmp -s build/cfree/out.ll selfhost/ludicc.seed.ll") {
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)")
out(capture("diff build/cfree/out.ll selfhost/ludicc.seed.ll | head"))
return 1
}
# ---- reseed: regenerate the checked-in IR seed (C-free when possible) ---------
# 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.
fn cmd_reseed() -> int {
run("mkdir -p build/cfree")
if not write_selfhost_src("build/cfree/selfhost.ludic") { print("FAIL: write source"); return 1 }
if shq(`{cc()} selfhost/ludicc.seed.ll -o build/cfree/sh_old 2>/dev/null`) {
if not shq("build/cfree/sh_old build/cfree/selfhost.ludic > build/cfree/step1.ll") { print("FAIL: step1"); return 1 }
if not shq(`{cc()} build/cfree/step1.ll -o build/cfree/sh_new`) { print("FAIL: assemble sh_new"); return 1 }
if not shq("build/cfree/sh_new build/cfree/selfhost.ludic > selfhost/ludicc.seed.ll") { print("FAIL: reseed"); return 1 }
} else {
print("seed does not build; reseeding from bin/ludicc")
ensure_ludicc()
if cmd_selfhost_build("bin/ludicc", "build/cfree/sh_c") != 0 { print("FAIL: build from bin/ludicc"); return 1 }
if not shq("build/cfree/sh_c build/cfree/selfhost.ludic > selfhost/ludicc.seed.ll") { print("FAIL: reseed"); return 1 }
}
print(`reseeded: {line_count("selfhost/ludicc.seed.ll")} lines`)
return 0
}

View file

@ -0,0 +1,68 @@
# selfhost_test.ludic — the self-hosting suite: the compiler is correct (compiles
# diverse programs to working binaries) and self-reproducing (the bootstrap
# fixpoint). Replaces selfhost/test.sh.
# compile selfhost/tests/<name>.ludic with bin/ludicc, assemble, run, compare
fn sh_case(name: ptr, exp: ptr) -> void {
let ll = `/tmp/x_sh_{name}.ll`
let er = `/tmp/x_sh_{name}.err`
let bn = `/tmp/x_sh_{name}`
if not shq(`bin/ludicc selfhost/tests/{name}.ludic > {ll} 2>{er}`) {
bad2(`{name}: self-host errored`, capture_line(`head -1 {er}`)); return
}
if not shq(`{cc()} {ll} -o {bn} 2>/dev/null`) { bad(`{name}: IR did not assemble`); return }
let got = capture_line(`{bn} < /dev/null`)
if (got == exp) { ok(`{name} ({got})`) } else { bad2(name, `got [{got}] want [{exp}]`) }
}
# compile examples/<name>.ludic as a game and diff its render against the golden
fn game_case(name: ptr, keys: ptr) -> void {
if not game_build_ok("bin/ludicc", `examples/{name}.ludic`, `/tmp/x_g_{name}`) { bad(`{name}: build`); return }
run(`printf '%s' '{keys}' | /tmp/x_g_{name} >/dev/null 2>&1`)
if shq(`cmp -s selfhost/golden/{name}.ppm out.ppm`) { ok(`{name} matches the golden render`) }
else { bad(`{name}: differs from golden`) }
}
fn cmd_selfhost_test() -> int {
PASS = 0
FAIL = 0
print("== build the self-host compiler (from the IR seed, no C) ==")
ensure_ludicc()
if is_exec("bin/ludicc") { ok("compiler builds from the IR seed (no C)") }
else { bad("self-host build"); return report() }
print("== the self-host compiler is correct ==")
sh_case("structs", "7 9 109 2 42")
sh_case("slices", "0 20 361 777")
sh_case("control", "55 4 15 1")
sh_case("match_bits", "1 2 9 16 4 15")
sh_case("fixed", "2 3 0 6 1")
sh_case("math", "3 7 5 10 -1 0 1 2 3 3 2 5 25 50")
sh_case("math2", "1414 3 999 999 499 5 5 25 3142 179 4 2 7 25 30 500")
sh_case("textlist", "3 10 30 1 0 2 -1 30 10 10 30 30 2 0 11 104 1 0 1 0 1 0 6 -1 42 5 1 4 4")
sh_case("textlist2", "3 1 1 1 2 1 6 1 1 3 1 1 3 1 4 1 2 99 3 2 0 3 1 9 3")
sh_case("easecollide", "250 750 500 1000 1000 999 765 -88 1000 1 0 1 0 0 1 0 1 0")
sh_case("memsys", "65 66 0 99 12345 1")
sh_case("math3", "45 90 180 -135 30 60 90")
sh_case("textsplit", "1 1 1 3 1 1 1 1 1 1 1")
sh_case("color", "16744512 1090486336 1090486336 8355711 8355711 8355711")
print("== the self-host compiler compiles real games (vs golden output) ==")
# the golden PPMs were produced by the original C compiler; the self-hosted
# compiler must reproduce them exactly.
game_case("snake", "ddssaawwddss")
game_case("menu", "ssss")
game_case("chronorift", "ddddwwwwaassK")
game_case("draw_prims", "")
print("== the bootstrap fixpoint (seeded from bin/ludicc) ==")
if shq("bin/x bootstrap > /tmp/x_boot.out 2>&1") { ok("gen2.ll == gen3.ll (compiler reproduces itself)") }
else { bad("fixpoint not reached"); out(capture("cat /tmp/x_boot.out")) }
print("== the C-free bootstrap (from the checked-in IR seed, no C compiler) ==")
if shq("bin/x bootstrap-cfree > /tmp/x_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/x_cfree.out")) }
return report()
}

143
tools/x/test.ludic Normal file
View file

@ -0,0 +1,143 @@
# test.ludic — the top-level regression suite. Replaces test.sh. The compiler is
# written in Ludic (selfhost/) and built from a checked-in LLVM-IR seed with
# clang; there is no C compiler in the repo. This suite proves the compiler
# 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 <sub>` 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
# its stdout against the expected space-joined output.
fn feat_case(name: ptr, keys: ptr, exp: ptr, label: ptr) -> void {
if not game_build_ok("bin/ludicc", `examples/{name}.ludic`, `/tmp/x_e_{name}`) {
bad2(label, capture_line("grep -i error /tmp/x_gb.err | head -1")); return
}
var got = ""
if (keys == "") { got = capture_line(`/tmp/x_e_{name} < /dev/null`) }
else { got = capture_line(`printf '{keys}' | /tmp/x_e_{name}`) }
if (got == exp) { ok(label) } else { bad2(label, `got [{got}] want [{exp}]`) }
}
# a "does it still compile" smoke test (parse -> lower -> link), no run
fn qsmoke(name: ptr) -> void {
if game_build_ok("bin/ludicc", `examples/{name}.ludic`, `/tmp/x_q_{name}`) {
ok(`{name} compiles (@Queries desugars to S_QUERY)`)
} else { bad2(name, capture_line("grep -i error /tmp/x_gb.err | head -1")) }
}
# a pure-Ludic example that drives and asserts itself: compile headless to IR,
# let clang assemble it, run, and compare stdout. No C host and no C compiled.
fn net_case(name: ptr, exp: ptr) -> void {
let ll = `/tmp/x_n_{name}.ll`
let log = `/tmp/x_n_{name}.out`
if not shq(`bin/ludicc --headless examples/{name}.ludic --emit-llvm -o {ll} > {log} 2>&1`) {
let t = capture_line(`tail -1 {log}`)
bad2(name, `build ({t})`); return
}
if not shq(`{cc()} -O2 {ll} -o /tmp/x_nb_{name} >> {log} 2>&1`) {
let t = capture_line(`tail -1 {log}`)
bad2(name, `build ({t})`); return
}
let got = capture_line(`/tmp/x_nb_{name} < /dev/null`)
if (got == exp) { ok(`{name} ({got})`) } else { bad2(name, `got [{got}] want [{exp}]`) }
}
fn cmd_test() -> int {
PASS = 0
FAIL = 0
print("== the compiler is written in Ludic, not C ==")
if shq("test -e compiler") or shq("ls compiler/*.c >/dev/null 2>&1") {
bad("compiler/ C sources still present")
} else { ok("no C compiler in the repo (the compiler is selfhost/*.ludic)") }
let cleft = capture_line("find tools -name '*.c' 2>/dev/null | wc -l")
ok(`the only C in the tree is any editor tooling ({cleft} .c files)`)
print("== the compiler builds from the IR seed (no C compiler) ==")
run("rm -f bin/ludicc")
if shq("bin/x app examples/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") }
print("== every example compiles to its golden render ==")
game_case("snake", "ddssaawwddss")
game_case("menu", "ssss")
game_case("chronorift", "ddddwwwwaassK")
print("== self-hosting and the bootstrap fixpoints ==")
run("bin/x selfhost-test > /tmp/x_sh.out 2>&1")
if shq("grep -q 'gen2.ll == gen3.ll' /tmp/x_sh.out") { ok("the compiler reproduces itself (fixpoint)") } else { bad("fixpoint") }
if shq("grep -q 'no C compiler' /tmp/x_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/x_sh.out")
ok(`self-host suite: {corr} checks passed (see: x selfhost-test)`)
print("== documented syntax stays compilable (guards against spec/compiler drift) ==")
qsmoke("qdecl")
feat_case("annotations", "", "3 25 0 0", "annotations.ludic (@Queries / @Computed / @OnSpawn / @Handles)")
feat_case("lifecycle", "", "1 700 50 950 2", "lifecycle.ludic (@OnStart/@OnAttach/@OnSpawn/@OnDespawn/@OnQuit in order)")
feat_case("toggle", "", "6 0 7 1 0", "toggle.ludic (enable/disable + @OnDisable/@OnEnable across property/model)")
feat_case("strings", "", "1 2 3 4 5 6 7 8 9", "strings.ludic (str ops, interpolation, slicing)")
feat_case("rng_demo", "", "69 89 6 -1 0", "rng_demo.ludic (Random.value/int/sign)")
feat_case("time_demo", "aaaaaa", "0 1 2 3 4 16", "time_demo.ludic (Time.frame/elapsed/delta)")
feat_case("scenes", "aaaaaaaaaa", "1000 1 101 102 2 3 900 201 900 202 900", "scenes.ludic (scene/layer/on enter/on exit/become)")
feat_case("detach", "", "15 1 25 0", "detach.ludic (attach/detach + @OnAttach/@OnDetach)")
feat_case("reason", "", "503 1009", "reason.ludic (@OnDespawn reason: Despawned vs Quit)")
# EV0b + NETWORKING N0–N6: each a self-contained pure-Ludic program, driven and
# asserted from its own `entry`. The transport is the compiler's built-in
# loopback, so a networked game runs with zero foreign code.
net_case("mod_events", "10 32 42")
net_case("net_echo", "4 10 20 30 42")
net_case("net_snapshot", "50 7 50")
net_case("net_sync", "12 3 4 50 999")
net_case("net_owner", "-1 7 0 1")
net_case("net_rpc", "0 8")
net_case("net_roles", "1 102")
net_case("net_demo", "5 999 5")
feat_case("promote", "", "100 101 200 201", "promote.ludic (@Public -> model_<M>_spawn/despawn events)")
feat_case("cancel", "", "0 1 92", "cancel.ludic (cancellable event + cancel + emit-as-expression)")
# EV2 the world table: the mod reflection ABI, callable from Ludic by name.
net_case("world_get", "50 1 7")
net_case("world_scan", "2 110")
net_case("world_spawn", "1 42 1")
net_case("world_mixed", "99")
net_case("world_query", "2 110")
feat_case("recurse", "", "16", "recurse.ludic (EV6: re-entrant emit is depth-bounded, no runaway cycle)")
net_case("scoped", "2")
net_case("world_dyn", "0 1 30 100 1 30")
feat_case("prop_events", "", "300 400", "prop_events.ludic (@Public -> prop_<P>_attach/detach events)")
feat_case("scene_events", "", "10 1 20 2 30 3", "scene_events.ludic (public scene -> scene_<S>_enter/exit events)")
feat_case("program_events", "", "1 100 2 200", "program_events.ludic (@Public @OnStart/@OnQuit -> program_start/quit)")
feat_case("layer_events", "aaaa", "50 2 1 50", "layer_events.ludic (layer toggle + public layer -> layer_<L>_show/hide)")
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/x_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/x_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).
if shq("LUDIC_HOME=. bin/ludicc examples/snake.ludic -o /tmp/x_cli_snake > /tmp/x_cli.out 2>&1") and shq("file /tmp/x_cli_snake 2>/dev/null | grep -q Mach-O") {
ok("ludicc app.ludic -o bin -> native executable")
} else { bad2("ludicc -o", capture_line("tail -1 /tmp/x_cli.out")) }
# --emit-llvm stops at the IR.
if shq("bin/ludicc examples/snake.ludic --emit-llvm -o /tmp/x_cli_snake.ll > /tmp/x_cli.out 2>&1") and shq("head -1 /tmp/x_cli_snake.ll | grep -q 'LLVM IR'") {
ok("ludicc --emit-llvm -> LLVM IR")
} else { bad2("ludicc --emit-llvm", capture_line("tail -1 /tmp/x_cli.out")) }
# ludic compiles, runs, and forwards the program's exit code.
write_file("/tmp/x_cli_exit.ludic", "entry { exit(42) }\n")
let rc = sh("LUDIC_HOME=. bin/ludic /tmp/x_cli_exit.ludic > /tmp/x_cli.out 2>&1")
if (rc == 42) { ok("ludic app.ludic -> compiles, runs, forwards exit code") }
else { bad2("ludic run: expected exit 42", `got {str(rc)}`) }
return report()
}

186
tools/x/tools.ludic Normal file
View file

@ -0,0 +1,186 @@
# tools.ludic — the Ludic editor toolchain: building ludic-fmt / ludic-lsp and
# their regression suite. Replaces tools/build-tools.sh and tools/test-tools.sh.
#
# The formatter and language server are themselves written in Ludic
# (tools/ludic-tools/*.ludic) and compiled by ludicc; no C is compiled. The LSP
# protocol test, the TextMate grammar test and the vocabulary check are still
# driven through python3/node (they are not project shell scripts), invoked here.
# refresh one editor-copy of a shared grammar file if it has drifted
fn sync_one(shared: ptr, dst: ptr) -> void {
if not shq(`cmp -s {shared} {dst}`) {
run(`cp {shared} {dst}`)
print(`sync: {shared} -> {dst}`)
}
}
# VS Code needs the grammar inside its own extension directory, so it gets a
# copy that build refreshes rather than trusts (test-tools asserts they match).
fn sync_vscode_grammar() -> void {
if not shq("test -d tools/editors/vscode") { return }
run("mkdir -p tools/editors/vscode/syntaxes")
sync_one("tools/editors/shared/ludic.tmLanguage.json", "tools/editors/vscode/syntaxes/ludic.tmLanguage.json")
sync_one("tools/editors/shared/ludic.markdown-injection.json", "tools/editors/vscode/syntaxes/ludic.markdown-injection.json")
sync_one("tools/editors/shared/language-configuration.json", "tools/editors/vscode/language-configuration.json")
}
# is `flag` present anywhere in argv?
fn has_flag(flag: ptr) -> bool {
var i = 2
while i < arg_count() { if (arg(i) == flag) { return true }; i = i + 1 }
return false
}
# ---- tools: build the editor toolchain --------------------------------------
# usage: x tools [--install] [--test]
fn cmd_tools() -> int {
ensure_ludicc()
print("ludicc: tools/ludic-tools/fmt.ludic -> bin/ludic-fmt (Ludic -> LLVM IR -> binary, no C)")
if not build_tool("ludic-fmt", "tools/ludic-tools/fmt.ludic") { return 1 }
print("ludicc: tools/ludic-tools/lsp.ludic -> bin/ludic-lsp (Ludic -> LLVM IR -> binary, no C)")
if not build_tool("ludic-lsp", "tools/ludic-tools/lsp.ludic") { return 1 }
sync_vscode_grammar()
if has_flag("--install") {
run("mkdir -p \"$HOME/.local/bin\"")
run("ln -sf \"$PWD/bin/ludic-fmt\" \"$HOME/.local/bin/ludic-fmt\"")
run("ln -sf \"$PWD/bin/ludic-lsp\" \"$HOME/.local/bin/ludic-lsp\"")
print("linked into ~/.local/bin (make sure it is on your PATH)")
}
if has_flag("--test") { return cmd_test_tools() }
print("done. editors: see tools/editors/README.md")
return 0
}
# ---- test-tools: the editor-toolchain regression suite -----------------------
fn 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 }
print("formatter")
# --- idempotence: formatting twice equals formatting once ------------------
let nsrc = capture_line("find examples runtime -name '*.ludic' | wc -l")
if shq(`fail=0; for f in $(find examples runtime -name '*.ludic' | sort); do {fmt} "$f" > /tmp/x_a 2>/dev/null; {fmt} /tmp/x_a > /tmp/x_b 2>/dev/null; cmp -s /tmp/x_a /tmp/x_b || fail=1; done; test $fail -eq 0`) {
ok(`idempotent on {nsrc} files`)
} else { bad("idempotent on the source tree") }
# --- meaning preserved: reformatting a formatted file must be a no-op -------
if shq(`fail=0; for f in examples/hello.ludic examples/snake.ludic examples/menu.ludic examples/qdecl.ludic examples/scenes.ludic; do test -f "$f" || continue; {fmt} "$f" > /tmp/x_f1 2>/dev/null; {fmt} /tmp/x_f1 > /tmp/x_f2 2>/dev/null; cmp -s /tmp/x_f1 /tmp/x_f2 || fail=1; done; test $fail -eq 0`) {
ok("formatting is idempotent (fmt(fmt(x)) == fmt(x))")
} else { bad("formatting is not idempotent") }
# --- the strongest invariant: only whitespace ever changes -----------------
# every token is re-emitted in order, so the input and output must be
# byte-identical once all whitespace is stripped.
if shq(`fail=0; for f in $(find examples runtime -name '*.ludic') README.md LANGUAGE.md COMPILING.md; do test -f "$f" || continue; a=$(tr -d ' \t\n' < "$f" | shasum | cut -d' ' -f1); b=$({fmt} "$f" 2>/dev/null | tr -d ' \t\n' | shasum | cut -d' ' -f1); test "$a" = "$b" || fail=1; done; test $fail -eq 0`) {
ok("only whitespace changes (byte-identical modulo spacing)")
} else { bad("formatter altered non-whitespace bytes") }
# --- multi-byte characters must survive intact -----------------------------
write_file("/tmp/x_utf8.ludic", "program E {\n # … ellipsis in a comment\n const S: str = \"café → naïve\"\n}\n")
if shq(`{fmt} /tmp/x_utf8.ludic > /tmp/x_utf8.out 2>/dev/null; cmp -s /tmp/x_utf8.ludic /tmp/x_utf8.out`) {
ok("UTF-8 preserved byte for byte")
} else { bad("UTF-8 mangled") }
write_file("/tmp/x_utf8b.ludic", "program F {\nfn f() -> int { return … }\n}\n")
let ua = capture_line("tr -d ' \t\n' < /tmp/x_utf8b.ludic | shasum | cut -d' ' -f1")
let ub = capture_line(`{fmt} /tmp/x_utf8b.ludic | tr -d ' \t\n' | shasum | cut -d' ' -f1`)
check("unrecognised characters pass through whole", ub, ua)
# --- comments survive, the whole reason this is not `ludicc --fmt` ----------
write_file("/tmp/x_c.ludic", "program C {\n # keep me\n property P { x: int = 0 } # and me\n}\n")
let ncmt = capture_line(`{fmt} /tmp/x_c.ludic | grep -c '#'`)
check("comments preserved", ncmt, "2")
# --- messy input is normalised ---------------------------------------------
write_file("/tmp/x_m.ludic", "program M{\nproperty P{x:int=0}\nhandler S phase Update{let a=1+2\nif a== -1 {a=-a}}\n}\n")
run(`{fmt} /tmp/x_m.ludic > /tmp/x_m.out 2>/dev/null`)
if shq("grep -q '^ property P { x: int = 0 }$' /tmp/x_m.out") { ok("reindents and respaces") } else { bad("reindents and respaces") }
if shq("grep -q 'a = -a' /tmp/x_m.out") { ok("unary minus not spaced") } else { bad("unary minus not spaced") }
# --- language conventions are respected, not overwritten -------------------
write_file("/tmp/x_u.ludic", "program U {\n ui M {\n panel id=Root w=10 {\n button id=Go text=\"go\"\n }\n }\n}\n")
if shq(`{fmt} /tmp/x_u.ludic | grep -q 'id=Root'`) { ok("widget props stay tight (id=Root)") } else { bad("widget props stay tight") }
write_file("/tmp/x_q.ludic", "program Q {\n handler S phase Update {\n for (p) in query [Pos, {Foe}] { }\n }\n property Pos { x: int = 0 }\n property Foe { }\n}\n")
if shq(`{fmt} /tmp/x_q.ludic | grep -q '\\[Pos, {{Foe}}\\]'`) { ok("query tags stay tight ({Foe})") } else { bad("query tags stay tight") }
write_file("/tmp/x_al.ludic", "program A {\n const R_A: int = 0 # one\n const R_BBB: int = 1 # two\n}\n")
if shq(`{fmt} /tmp/x_al.ludic | grep -q 'R_A: int = 0 # one'`) { ok("hand alignment preserved") } else { bad("hand alignment preserved") }
# --- --check exit status, for CI and pre-commit hooks ----------------------
let chk1 = sh(`{fmt} --check -q /tmp/x_m.ludic`)
check("--check exits 1 on unformatted input", str(chk1), "1")
run(`{fmt} -w /tmp/x_m.ludic`)
let chk2 = sh(`{fmt} --check -q /tmp/x_m.ludic`)
check("--check exits 0 after -w", str(chk2), "0")
# --- markdown: fences formatted, prose untouched ---------------------------
write_file("/tmp/x_d.md", "# T\n\nprose with spaces\n\n```ludic\nprogram D{property P{x:int=0}}\n```\n\n```python\nz = 1\n```\n")
run(`{fmt} /tmp/x_d.md > /tmp/x_d.out 2>/dev/null`)
if shq("grep -q 'prose with spaces' /tmp/x_d.out") { ok("markdown prose untouched") } else { bad("markdown prose untouched") }
if shq("grep -q 'z = 1' /tmp/x_d.out") { ok("non-ludic fences untouched") } else { bad("non-ludic fences untouched") }
if shq("grep -q 'program D { property P { x: int = 0 } }' /tmp/x_d.out") { ok("ludic fences formatted") } else { bad("ludic fences formatted") }
print("language server")
if shq("python3 tools/test-lsp.py") { ok("language server protocol") } else { bad("language server protocol") }
print("")
print("editor assets")
test_json("tools/editors/shared/ludic.tmLanguage.json")
test_json("tools/editors/shared/ludic.markdown-injection.json")
test_json("tools/editors/shared/language-configuration.json")
test_json("tools/editors/vscode/package.json")
test_json("tools/editors/vscode/snippets/ludic.json")
test_xml("tools/editors/jetbrains/src/main/resources/META-INF/plugin.xml")
test_xml("tools/editors/jetbrains/src/main/resources/META-INF/ludic-markdown.xml")
if shq("command -v node >/dev/null 2>&1") {
if shq("node --check tools/editors/vscode/src/extension.js 2>/dev/null") { ok("extension.js parses") } else { bad("extension.js parses") }
print("textmate grammar")
if shq("node tools/test-grammar.js") { ok("textmate grammar") } else { bad("textmate grammar") }
} else {
print(" skip extension.js and grammar tests (node not installed)")
}
# 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") }
# 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
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") }
if shq("cd tools/editors/jetbrains && ./gradlew test --console=plain -q >/dev/null 2>&1") { ok("jetbrains platform tests (lexer, file type, PSI, folding)") } else { bad("jetbrains platform tests") }
} else { print(" skip jetbrains plugin (no gradle wrapper)") }
} else {
print(" skip jetbrains plugin build (set LUDIC_TEST_JETBRAINS=1)")
}
# 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.
if shq("python3 tools/check-vocabulary.py") { 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.
if shq("python3 tools/docgen/check-impl.py") { ok("docs cover every implemented feature") } else { bad("docs drifted from the implementation") }
return report()
}
fn test_json(path: ptr) -> void {
let bn = capture_line(`basename {path}`)
if shq(`python3 -c 'import json,sys;json.load(open(sys.argv[1]))' {path} 2>/dev/null`) {
ok(`valid JSON: {bn}`)
} else { bad(`invalid JSON: {path}`) }
}
fn test_xml(path: ptr) -> void {
let bn = capture_line(`basename {path}`)
if shq(`python3 -c 'import xml.dom.minidom,sys;xml.dom.minidom.parse(sys.argv[1])' {path} 2>/dev/null`) {
ok(`valid XML: {bn}`)
} else { bad(`invalid XML: {path}`) }
}