Phase 6a (cont.): reconcile editor toolchain with the rename

Updated the LSP indexer (ludic_index.h), completion snippets (ludic_lsp.c),
check-docs wrapper, and the fmt/LSP test fixtures (test-tools.sh, test-lsp.py)
to the new keywords. Fixed two pre-existing bugs surfaced along the way:
build-tools.sh had a set -e bug (a failing find|while pipeline in a command
substitution) that silently prevented ALL rebuilds since the first build, so the
editor binaries were stale; and an LSP test offset was calibrated to the old
keyword length. test.sh 14/14, test-tools 28/0, check-docs + check-vocabulary
green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-27 18:34:57 +03:00
parent 42f955fd22
commit 69fe39bff1
5 changed files with 40 additions and 40 deletions

View file

@ -25,8 +25,8 @@ build_one() {
if [ -x "$OUT/$name" ]; then if [ -x "$OUT/$name" ]; then
newest=$(find "$SRC" -name '*.h' -o -name "$(basename "$main")" | while read -r f; do newest=$(find "$SRC" -name '*.h' -o -name "$(basename "$main")" | while read -r f; do
[ "$f" -nt "$OUT/$name" ] && echo new [ "$f" -nt "$OUT/$name" ] && echo new
done) done) || true
[ -z "$newest" ] && { echo "$name: up to date"; return; } if [ -z "$newest" ]; then echo "$name: up to date"; return; fi
fi fi
echo "cc: $main -> $OUT/$name" echo "cc: $main -> $OUT/$name"
$CC $CFLAGS "$main" -o "$OUT/$name" $CC $CFLAGS "$main" -o "$OUT/$name"

View file

@ -421,14 +421,14 @@ static void lp_decl(LP* p, int parent){
} }
return; return;
} }
if (!strcmp(w, "component") || !strcmp(w, "archetype")){ if (!strcmp(w, "property") || !strcmp(w, "model")){
int is_comp = !strcmp(w, "component"); int is_comp = !strcmp(w, "property");
lp_adv(p); int nt = lp_name(p); if (nt < 0) return; lp_adv(p); int nt = lp_name(p); if (nt < 0) return;
int s = lsym_add(D, is_comp ? LS_COMPONENT : LS_ARCHETYPE, nt, parent); int s = lsym_add(D, is_comp ? LS_COMPONENT : LS_ARCHETYPE, nt, parent);
ldoc_collect_doc(D, decl_tok, D->sym[s].doc, sizeof(D->sym[s].doc)); ldoc_collect_doc(D, decl_tok, D->sym[s].doc, sizeof(D->sym[s].doc));
while (lp_kind(p) == LT_ANNO) lp_adv(p); while (lp_kind(p) == LT_ANNO) lp_adv(p);
snprintf(D->sym[s].detail, sizeof(D->sym[s].detail), "%s %s {", snprintf(D->sym[s].detail, sizeof(D->sym[s].detail), "%s %s {",
is_comp ? "component" : "archetype", D->sym[s].name); is_comp ? "property" : "model", D->sym[s].name);
if (!lp_is(p, "{")) return; if (!lp_is(p, "{")) return;
int close = D->match[p->i]; int close = D->match[p->i];
D->sym[s].body_start = D->lex.v[p->i].start; D->sym[s].body_start = D->lex.v[p->i].start;
@ -518,8 +518,8 @@ static void lp_decl(LP* p, int parent){
} }
return; return;
} }
if (!strcmp(w, "system") || !strcmp(w, "edge")){ if (!strcmp(w, "handler") || !strcmp(w, "edge")){
if (!strcmp(w, "edge")){ lp_adv(p); if (!lp_is(p, "system")) return; } if (!strcmp(w, "edge")){ lp_adv(p); if (!lp_is(p, "handler")) return; }
lp_adv(p); lp_adv(p);
int nt = lp_name(p); if (nt < 0) return; int nt = lp_name(p); if (nt < 0) return;
int s = lsym_add(D, LS_SYSTEM, nt, parent); int s = lsym_add(D, LS_SYSTEM, nt, parent);
@ -638,15 +638,15 @@ static void ldoc_parse(LDoc* D){
lp_skipnl(&p); lp_skipnl(&p);
while (lp_is(&p, "import")) { lp_decl(&p, -1); lp_skipnl(&p); } while (lp_is(&p, "import")) { lp_decl(&p, -1); lp_skipnl(&p); }
int unit_parent = -1; int unit_parent = -1;
if (lp_is(&p, "game") || lp_is(&p, "module")){ if (lp_is(&p, "program") || lp_is(&p, "program")){
D->is_module = lp_is(&p, "module"); D->is_module = lp_is(&p, "program");
D->is_unit = 1; D->is_unit = 1;
lp_adv(&p); lp_adv(&p);
int nt = lp_name(&p); int nt = lp_name(&p);
if (nt >= 0){ if (nt >= 0){
unit_parent = lsym_add(D, LS_UNIT, nt, -1); unit_parent = lsym_add(D, LS_UNIT, nt, -1);
lcpy(D->unit, sizeof(D->unit), D->sym[unit_parent].name, (int)strlen(D->sym[unit_parent].name)); lcpy(D->unit, sizeof(D->unit), D->sym[unit_parent].name, (int)strlen(D->sym[unit_parent].name));
lcatf(D->sym[unit_parent].detail, sizeof(D->sym[unit_parent].detail), "%s %s", D->is_module ? "module" : "game", D->unit); lcatf(D->sym[unit_parent].detail, sizeof(D->sym[unit_parent].detail), "%s %s", D->is_module ? "program" : "program", D->unit);
} }
if (lp_is(&p, "{")){ if (lp_is(&p, "{")){
int close = D->match[p.i]; int close = D->match[p.i];

View file

@ -1080,13 +1080,13 @@ static void on_completion(JVal* msg, JVal* id){
comp_builtins(&c); comp_builtins(&c);
comp_scope(&c, D, off, 0); comp_scope(&c, D, off, 0);
/* the shapes people actually type */ /* the shapes people actually type */
comp_item(&c, "system", CI_SNIPPET, "system … phase … { }", "A system with a phase and a body.", comp_item(&c, "handler", CI_SNIPPET, "handler … phase … { }", "A system with a phase and a body.",
"system ${1:Name} phase ${2|Start,Input,FixedUpdate,Update,LateUpdate,Render|} {\n $0\n}"); "system ${1:Name} phase ${2|Start,Input,FixedUpdate,Update,LateUpdate,Render|} {\n $0\n}");
comp_item(&c, "system+query", CI_SNIPPET, "system … query … { }", "A system that runs once per matching entity.", comp_item(&c, "system+query", CI_SNIPPET, "handler … query … { }", "A system that runs once per matching entity.",
"system ${1:Name} phase ${2|Update,FixedUpdate,LateUpdate,Render,Input|}\n query (${3:p}) [${4:Pos}]\n{\n $0\n}"); "system ${1:Name} phase ${2|Update,FixedUpdate,LateUpdate,Render,Input|}\n query (${3:p}) [${4:Pos}]\n{\n $0\n}");
comp_item(&c, "component", CI_SNIPPET, "component … { }", "A component with typed fields.", comp_item(&c, "property", CI_SNIPPET, "property … { }", "A component with typed fields.",
"component ${1:Name} { ${2:x}: ${3:int} = ${4:0} }"); "component ${1:Name} { ${2:x}: ${3:int} = ${4:0} }");
comp_item(&c, "archetype", CI_SNIPPET, "archetype … { }", "An entity kind bundling components.", comp_item(&c, "model", CI_SNIPPET, "archetype … { }", "An entity kind bundling components.",
"archetype ${1:Name} {\n ${2:Pos}\n}"); "archetype ${1:Name} {\n ${2:Pos}\n}");
comp_item(&c, "for-query", CI_SNIPPET, "for (…) in query […] { }", "Iterate matching entities.", comp_item(&c, "for-query", CI_SNIPPET, "for (…) in query […] { }", "Iterate matching entities.",
"for (${1:p}) in query [${2:Pos}] {\n $0\n}"); "for (${1:p}) in query [${2:Pos}] {\n $0\n}");

View file

@ -109,7 +109,7 @@ def main():
"signatureHelpProvider", "inlayHintProvider", "documentLinkProvider"]: "signatureHelpProvider", "inlayHintProvider", "documentLinkProvider"]:
check_true(f"advertises {cap}", cap in caps) check_true(f"advertises {cap}", cap in caps)
# ---- a fragment file: no `game` block, only reachable through an import -- # ---- a fragment file: no `program` block, only reachable through an import --
frag = os.path.join(ROOT, "examples/chronorift/combat.ludic") frag = os.path.join(ROOT, "examples/chronorift/combat.ludic")
text = c.open(frag) text = c.open(frag)
uri = {"uri": "file://" + frag} uri = {"uri": "file://" + frag}
@ -137,13 +137,13 @@ def main():
comp = c.request("textDocument/completion", {"textDocument": uri, comp = c.request("textDocument/completion", {"textDocument": uri,
"position": position_of(text, "st.guard", 3)}) "position": position_of(text, "st.guard", 3)})
labels = [i["label"] for i in comp["items"]] labels = [i["label"] for i in comp["items"]]
check_true("completion after '.' offers only that component's fields", check_true("completion after '.' offers only that property's fields",
"guard" in labels and "hp" in labels and "clear" not in labels) "guard" in labels and "hp" in labels and "clear" not in labels)
# a query's term list admits components and archetypes and nothing else — # a query's term list admits propertys and models and nothing else —
# not the loop variables, not builtins, not keywords # not the loop variables, not builtins, not keywords
qsrc = ('game QT {\n component Pos { x: int = 0 }\n component Vel { dx: int = 0 }\n' qsrc = ('program QT {\n property Pos { x: int = 0 }\n property Vel { dx: int = 0 }\n'
' system S phase Update {\n for (a, b) in query [Pos, ] { }\n }\n}\n') ' handler S phase Update {\n for (a, b) in query [Pos, ] { }\n }\n}\n')
qfile = "/tmp/ludic_lsp_test_query.ludic" qfile = "/tmp/ludic_lsp_test_query.ludic"
with open(qfile, "w") as fh: with open(qfile, "w") as fh:
fh.write(qsrc) fh.write(qsrc)
@ -152,7 +152,7 @@ def main():
qcomp = c.request("textDocument/completion", qcomp = c.request("textDocument/completion",
{"textDocument": quri, "position": position_of(qsrc, "[Pos, ]", 6)}) {"textDocument": quri, "position": position_of(qsrc, "[Pos, ]", 6)})
qlabels = sorted(i["label"] for i in qcomp["items"]) qlabels = sorted(i["label"] for i in qcomp["items"])
check("completion inside query terms lists components only", qlabels, ["Pos", "Vel"]) check("completion inside query terms lists propertys only", qlabels, ["Pos", "Vel"])
body = c.request("textDocument/completion", body = c.request("textDocument/completion",
{"textDocument": quri, "position": position_of(qsrc, "] { }", 3)}) {"textDocument": quri, "position": position_of(qsrc, "] { }", 3)})
blabels = [i["label"] for i in body["items"]] blabels = [i["label"] for i in body["items"]]
@ -196,7 +196,7 @@ def main():
# any file and answers structural queries without a compiler present.) # any file and answers structural queries without a compiler present.)
bad = "/tmp/ludic_lsp_test_bad.ludic" bad = "/tmp/ludic_lsp_test_bad.ludic"
with open(bad, "w") as fh: with open(bad, "w") as fh:
fh.write("game Bad {\n component P { x: nosuchtype = 0 }\n}\n") fh.write("program Bad {\n property P { x: nosuchtype = 0 }\n}\n")
c.open(bad) c.open(bad)
syms = c.request("textDocument/documentSymbol", {"textDocument": {"uri": "file://" + bad}}) syms = c.request("textDocument/documentSymbol", {"textDocument": {"uri": "file://" + bad}})
check_true("LSP opens and analyses a file with no compiler present", check_true("LSP opens and analyses a file with no compiler present",
@ -206,7 +206,7 @@ def main():
# ---- markdown: ```ludic fences are real Ludic --------------------------- # ---- markdown: ```ludic fences are real Ludic ---------------------------
md = "/tmp/ludic_lsp_test.md" md = "/tmp/ludic_lsp_test.md"
with open(md, "w") as fh: with open(md, "w") as fh:
fh.write("# Title\n\nprose\n\n```ludic\ngame D { component Pt { x: int = 0 } }\n```\n\n" fh.write("# Title\n\nprose\n\n```ludic\nprogram D { property Pt { x: int = 0 } }\n```\n\n"
"```python\nz = 1\n```\n") "```python\nz = 1\n```\n")
c.open(md, "markdown") c.open(md, "markdown")
mduri = {"uri": "file://" + md} mduri = {"uri": "file://" + md}
@ -241,17 +241,17 @@ def main():
# Renaming any one of them must leave the other three alone. # Renaming any one of them must leave the other three alone.
amb = "/tmp/ludic_lsp_test_rename.ludic" amb = "/tmp/ludic_lsp_test_rename.ludic"
with open(amb, "w") as fh: with open(amb, "w") as fh:
fh.write("game R {\n" fh.write("program R {\n"
" component Pos { x: int = 0, y: int = 0 }\n" " property Pos { x: int = 0, y: int = 0 }\n"
" component Vel { x: int = 0 }\n" " property Vel { x: int = 0 }\n"
" fn helper(x: int) -> int { return x + 1 }\n" " fn helper(x: int) -> int { return x + 1 }\n"
" system A phase Update {\n" " handler A phase Update {\n"
" for (p) in query [Pos] {\n" " for (p) in query [Pos] {\n"
" let x = 5\n" " let x = 5\n"
" p.x = p.x + x\n" " p.x = p.x + x\n"
" }\n" " }\n"
" }\n" " }\n"
" system B phase Update {\n" " handler B phase Update {\n"
" for (v) in query [Vel] { v.x = v.x + 1 }\n" " for (v) in query [Vel] { v.x = v.x + 1 }\n"
" }\n" " }\n"
"}\n") "}\n")
@ -268,7 +268,7 @@ def main():
def lines_touched(edits): def lines_touched(edits):
return sorted({e["range"]["start"]["line"] for e in edits}) return sorted({e["range"]["start"]["line"] for e in edits})
field = rename_at(position_of(atext, "component Pos { x", 16)) field = rename_at(position_of(atext, "property Pos { x", 15))
check("renaming Pos.x touches only its declaration and its uses", check("renaming Pos.x touches only its declaration and its uses",
lines_touched(field), [1, 7]) lines_touched(field), [1, 7])
local = rename_at(position_of(atext, "let x = 5", 4)) local = rename_at(position_of(atext, "let x = 5", 4))
@ -280,7 +280,7 @@ def main():
# find-usages must be equally precise # find-usages must be equally precise
refs_x = c.request("textDocument/references", refs_x = c.request("textDocument/references",
{"textDocument": auri, {"textDocument": auri,
"position": position_of(atext, "component Vel { x", 16), "position": position_of(atext, "property Vel { x", 15),
"context": {"includeDeclaration": True}}) "context": {"includeDeclaration": True}})
check("find-usages of Vel.x does not pick up Pos.x", check("find-usages of Vel.x does not pick up Pos.x",
sorted({r["range"]["start"]["line"] for r in refs_x}), [2, 11]) sorted({r["range"]["start"]["line"] for r in refs_x}), [2, 11])
@ -288,7 +288,7 @@ def main():
# ---- workspace-wide symbol search --------------------------------------- # ---- workspace-wide symbol search ---------------------------------------
ws = c.request("workspace/symbol", {"query": "Stats"}) ws = c.request("workspace/symbol", {"query": "Stats"})
check_true("workspace symbol search finds a component", any(s["name"] == "Stats" for s in ws)) check_true("workspace symbol search finds a property", any(s["name"] == "Stats" for s in ws))
c.close() c.close()
print(f" {passed} passed, {failed} failed") print(f" {passed} passed, {failed} failed")

View file

@ -71,34 +71,34 @@ else bad "formatter altered non-whitespace bytes" "$DRIFT"; fi
# --- multi-byte characters must survive intact ------------------------------ # --- multi-byte characters must survive intact ------------------------------
# A UTF-8 character outside a string is an error to the compiler, but the # 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. # formatter must still not split it: doing so silently corrupts the file.
printf 'game 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" 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" "$FMT" "$TMP/utf8.ludic" > "$TMP/utf8.out"
if cmp -s "$TMP/utf8.ludic" "$TMP/utf8.out"; then ok "UTF-8 preserved byte for byte" 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 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 # 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 # still come out as one character, not as three spaced-out replacement bytes
printf 'game F {\nfn f() -> int { return \xe2\x80\xa6 }\n}\n' > "$TMP/utf8b.ludic" 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) 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) b=$("$FMT" "$TMP/utf8b.ludic" | tr -d ' \t\n' | shasum | cut -d' ' -f1)
check "unrecognised characters pass through whole" "$b" "$a" check "unrecognised characters pass through whole" "$b" "$a"
# --- comments survive, which is the whole reason this is not ludicc --fmt --- # --- comments survive, which is the whole reason this is not ludicc --fmt ---
printf 'game C {\n # keep me\n component P { x: int = 0 } # and me\n}\n' > "$TMP/c.ludic" 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 '#') N=$("$FMT" "$TMP/c.ludic" | grep -c '#')
check "comments preserved" "$N" "2" check "comments preserved" "$N" "2"
# --- messy input is normalised ---------------------------------------------- # --- messy input is normalised ----------------------------------------------
printf 'game M{\ncomponent P{x:int=0}\nsystem S phase Update{let a=1+2\nif a== -1 {a=-a}}\n}\n' > "$TMP/m.ludic" 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" "$FMT" "$TMP/m.ludic" > "$TMP/m.out"
grep -q '^ component P { x: int = 0 }$' "$TMP/m.out" && ok "reindents and respaces" || bad "reindents and respaces" 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" grep -q 'a = -a' "$TMP/m.out" && ok "unary minus not spaced" || bad "unary minus not spaced"
# --- language conventions are respected, not overwritten -------------------- # --- language conventions are respected, not overwritten --------------------
printf 'game U {\n ui M {\n panel id=Root w=10 {\n button id=Go text="go"\n }\n }\n}\n' > "$TMP/u.ludic" 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" "$FMT" "$TMP/u.ludic" | grep -q 'id=Root' && ok "widget props stay tight (id=Root)" || bad "widget props stay tight"
printf 'game Q {\n system S phase Update {\n for (p) in query [Pos, {Foe}] { }\n }\n component Pos { x: int = 0 }\n component Foe { }\n}\n' > "$TMP/q.ludic" 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" "$FMT" "$TMP/q.ludic" | grep -q '\[Pos, {Foe}\]' && ok "query tags stay tight ({Foe})" || bad "query tags stay tight"
printf 'game A {\n const R_A: int = 0 # one\n const R_BBB: int = 1 # two\n}\n' > "$TMP/a.ludic" 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" "$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 ----------------------- # --- --check exit status, for CI and pre-commit hooks -----------------------
@ -109,11 +109,11 @@ check "--check exits 1 on unformatted input" "$?" "1"
check "--check exits 0 after -w" "$?" "0" check "--check exits 0 after -w" "$?" "0"
# --- markdown: fences formatted, prose untouched ---------------------------- # --- markdown: fences formatted, prose untouched ----------------------------
printf '# T\n\nprose with spaces\n\n```ludic\ngame D{component P{x:int=0}}\n```\n\n```python\nz = 1\n```\n' > "$TMP/d.md" 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" "$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 '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 'z = 1' "$TMP/d.out" && ok "non-ludic fences untouched" || bad "non-ludic fences untouched"
grep -q 'game D { component P { x: int = 0 } }' "$TMP/d.out" && ok "ludic fences formatted" || bad "ludic fences formatted" grep -q 'program D { property P { x: int = 0 } }' "$TMP/d.out" && ok "ludic fences formatted" || bad "ludic fences formatted"
echo "language server" echo "language server"
python3 tools/test-lsp.py python3 tools/test-lsp.py