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:
parent
ff15c4e01d
commit
a38195128f
235 changed files with 24676 additions and 7762 deletions
|
|
@ -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
160
tools/docgen/check-impl.py
Normal 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())
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
<li>Highlighting of <code>```ludic</code> fenced blocks in Markdown.</li>
|
||||
</ul>
|
||||
<p>Set the paths under <b>Settings → Languages & Frameworks → 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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
83
tools/x/build.ludic
Normal 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
80
tools/x/main.ludic
Normal 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
119
tools/x/prelude.ludic
Normal 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
201
tools/x/selfhost.ludic
Normal 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
|
||||
}
|
||||
68
tools/x/selfhost_test.ludic
Normal file
68
tools/x/selfhost_test.ludic
Normal 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
143
tools/x/test.ludic
Normal 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
186
tools/x/tools.ludic
Normal 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}`) }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue