refactor(x): per-process scratch dirs, Ludic ports of the LSP test and Forgejo release

- every scratch file lives in `$TMPDIR/x_<pid>/` (tmp_dir/tmp_path in
  prelude.ludic), removed by main's new dispatch() → tmp_cleanup();
  X_KEEP_TMP=1 keeps it. `x test` and `x check-*` may now run together.
- `x test-lsp` (tools/x/lsp_test.ludic) replaces tools/test-lsp.py: the
  whole request stream is framed into one stdin file, the server runs to
  `exit`, and the response stream is parsed back by request id; adds a
  check that ludicc's own error is published on save
- tools/x/forgejo.ludic replaces tools/ci/forgejo_release.py (curl with a
  0600 header file; the token is no longer on the command line;
  LUDIC_FORGEJO_API for forks)
- `x docs-palette --check` regenerates into scratch and compares, so the
  drift guard judges the working tree rather than git HEAD; the generator
  no longer emits a trailing blank line the formatter rejects
- `x test-tools`: exit 2 from test-lsp / test-grammar.js is a visible
  skip, never a pass; the widget-prop test uses the `id: Root` syntax
- tools/test-grammar.js: current vocabulary (property/model/handler/
  prefab/scene/event/become/@Queries), LUDIC_NODE_MODULES, exit 2 on skip
- tools/atlas.ludic rewritten in the current language (it did not compile)
- operators.ludic / os.ludic registered in the suite
- json.ludic: j_quote() writer helper

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-05 01:12:26 +03:00
parent 647dfec334
commit 8fed9add66
19 changed files with 1154 additions and 614 deletions

View file

@ -12,7 +12,7 @@
# ---- small string helpers ---------------------------------------------------
# length of a NUL-terminated buffer
function slen(s: pointer) -> int { var n = 0; while s[n] != 0 { n = n + 1 }; return n }
function slen(s: pointer) -> int { var n = 0; while s[n] != 0 { n += 1 }; return n }
# a fresh NUL-terminated copy of s[start .. end) (end exclusive)
function sslice(s: pointer, start: int, end: int) -> pointer {
@ -20,7 +20,7 @@ function sslice(s: pointer, start: int, end: int) -> pointer {
let n = end - start
let b = bytes(n + 1)
var i = 0
while i < n { b[i] = s[start + i]; i = i + 1 }
while i < n { b[i] = s[start + i]; i += 1 }
b[n] = 0
return b
}
@ -33,11 +33,11 @@ function s_index(hay: pointer, needle: pointer, from: int) -> int {
var i = from
while i + nn <= hn {
var j = 0
while j < nn and hay[i + j] == needle[j] { j = j + 1 }
while j < nn and hay[i + j] == needle[j] { j += 1 }
if j == nn { return i }
i = i + 1
i += 1
}
return 0 - 1
return -1
}
function s_contains(hay: pointer, needle: pointer) -> bool { return s_index(hay, needle, 0) >= 0 }
@ -46,7 +46,7 @@ function s_contains(hay: pointer, needle: pointer) -> bool { return s_index(hay,
function s_starts_at(hay: pointer, at: int, needle: pointer) -> bool {
let nn = slen(needle)
var i = 0
while i < nn { if hay[at + i] != needle[i] { return false }; i = i + 1 }
while i < nn { if hay[at + i] != needle[i] { return false }; i += 1 }
return true
}
@ -54,17 +54,17 @@ function s_starts_at(hay: pointer, at: int, needle: pointer) -> bool {
function s_starts(s: pointer, pre: pointer) -> bool {
let pn = slen(pre)
var i = 0
while i < pn { if s[i] != pre[i] { return false }; i = i + 1 }
while i < pn { if s[i] != pre[i] { return false }; i += 1 }
return true
}
# is byte c an ASCII space/tab?
function is_ws(c: int) -> bool { return c == 32 or c == 9 }
function is_ws(c: int) -> bool { return c == ' ' or c == '\t' }
# the substring from `start` up to the next '\n' (or end)
function line_at(s: pointer, start: int) -> pointer {
var e = start
while s[e] != 0 and s[e] != 10 { e = e + 1 }
while s[e] != 0 and s[e] != '\n' { e += 1 }
return sslice(s, start, e)
}
@ -72,9 +72,9 @@ function line_at(s: pointer, start: int) -> pointer {
function s_trim(s: pointer) -> pointer {
let n = slen(s)
var a = 0
while a < n and (is_ws(s[a]) or s[a] == 10 or s[a] == 13) { a = a + 1 }
while a < n and (is_ws(s[a]) or s[a] == '\n' or s[a] == '\r') { a += 1 }
var b = n
while b > a and (is_ws(s[b - 1]) or s[b - 1] == 10 or s[b - 1] == 13) { b = b - 1 }
while b > a and (is_ws(s[b - 1]) or s[b - 1] == '\n' or s[b - 1] == '\r') { b -= 1 }
return sslice(s, a, b)
}
@ -101,11 +101,11 @@ function classify_fence(body: pointer) -> pointer {
while i < n {
let ln = line_at(body, i)
let t = s_trim(ln)
if slen(t) > 0 and t[0] != 35 { # non-empty, not a '#' comment
if slen(t) > 0 and t[0] != '#' { # non-empty, not a '#' comment
# head = first token, split on '(' or whitespace
var e = 0
let tn = slen(t)
while e < tn and t[e] != 40 and not is_ws(t[e]) { e = e + 1 }
while e < tn and t[e] != '(' and not is_ws(t[e]) { e += 1 }
let head = sslice(t, 0, e)
if head == "program" { return "whole" }
if is_decl_head(head) { return "decls" }
@ -118,8 +118,8 @@ function classify_fence(body: pointer) -> pointer {
# does the wrapped source at `path` parse? (ludicc --fmt = lex+parse gate)
function fence_parses(src: pointer) -> bool {
write_file("/tmp/x_docchk.ludic", src)
return shq("bin/ludicc /tmp/x_docchk.ludic --fmt > /dev/null 2>&1")
write_file(`{tmp_dir()}/docchk.ludic`, src)
return shq(`bin/ludicc {tmp_dir()}/docchk.ludic --fmt > /dev/null 2>&1`)
}
# try the candidate framings for a fence; true if any parses
@ -149,21 +149,21 @@ function check_docs_file(path: pointer) -> void {
let open = s_index(t, "```ludic\n", i)
if open < 0 { break }
# require the fence to sit at a line start
if open != 0 and t[open - 1] != 10 { i = open + 1; continue }
if open != 0 and t[open - 1] != '\n' { i = open + 1; continue }
let bstart = open + 9 # past "```ludic\n"
let close = s_index(t, "\n```", bstart)
if close < 0 { break }
let body = sslice(t, bstart, close + 1) # include trailing newline
let line = 1 + count_nl(t, open)
i = close + 4
if s_contains(body, "# doc-check: skip") { DC_SKIP = DC_SKIP + 1; continue }
if s_contains(body, "# doc-check: skip") { DC_SKIP += 1; continue }
let expect_err = s_contains(body, "# doc-check: expect-error")
let kind = classify_fence(body)
let parsed = fence_body_parses(body, kind)
if parsed == (not expect_err) {
DC_OK = DC_OK + 1
DC_OK += 1
} else {
DC_BAD = DC_BAD + 1
DC_BAD += 1
if expect_err { DC_FAILS = DC_FAILS + " " + path + ":" + string(line) + " expected to be rejected\n" }
else { DC_FAILS = DC_FAILS + " " + path + ":" + string(line) + " expected to parse\n" }
}
@ -173,7 +173,7 @@ function check_docs_file(path: pointer) -> void {
# number of '\n' in s[0 .. upto)
function count_nl(s: pointer, upto: int) -> int {
var n = 0; var i = 0
while i < upto { if s[i] == 10 { n = n + 1 }; i = i + 1 }
while i < upto { if s[i] == '\n' { n += 1 }; i += 1 }
return n
}
@ -205,12 +205,12 @@ function cmd_check_docs() -> int {
# asserts they agree. Replaces the former tools/docgen/check-impl.py.
# a double-quote byte as a string (the lexer has no \" escape we rely on)
function dq() -> pointer { let b = bytes(2); b[0] = 34; b[1] = 0; return b }
function dq() -> pointer { let b = bytes(2); b[0] = '"'; b[1] = 0; return b }
# set-style string list
function set_has(l: []pointer, s: pointer) -> bool {
var i = 0
while i < len(l) { if l[i] == s { return true }; i = i + 1 }
while i < len(l) { if l[i] == s { return true }; i += 1 }
return false
}
function set_add(l: []pointer, s: pointer) -> void { if not set_has(l, s) { push(l, s) } }
@ -218,7 +218,7 @@ function set_add(l: []pointer, s: pointer) -> void { if not set_has(l, s) { push
# the body of `function <name>(` up to the next top-level `function ` (or EOF)
function fn_body(src: pointer, name: pointer) -> pointer {
let needle = "function " + name + "("
var p = 0 - 1
var p = -1
if s_starts(src, needle) { p = 0 }
else {
let q = s_index(src, "\n" + needle, 0)
@ -239,7 +239,7 @@ function collect_after(src: pointer, marker: pointer) -> []pointer {
let p = s_index(src, marker, i)
if p < 0 { break }
var e = p + mn
while src[e] != 0 and src[e] != 34 { e = e + 1 }
while src[e] != 0 and src[e] != '"' { e += 1 }
push(out, sslice(src, p + mn, e))
i = e + 1
}
@ -256,20 +256,20 @@ function table_set(h: pointer, name: pointer) -> []pointer {
if b < 0 or e < 0 { return out }
var i = b
while i < e {
if h[i] == 34 {
if h[i] == '"' {
var j = i + 1
while j < e and h[j] != 34 { j = j + 1 }
while j < e and h[j] != '"' { j += 1 }
set_add(out, sslice(h, i + 1, j))
i = j + 1
} else { i = i + 1 }
} else { i += 1 }
}
return out
}
# concatenate every selfhost/*.ludic into one buffer (via cat, then read)
function read_all_selfhost() -> pointer {
run("find selfhost -name '*.ludic' | sort | xargs cat > /tmp/x_allsh.txt 2>/dev/null")
let s = read_file("/tmp/x_allsh.txt")
run(`find selfhost -name '*.ludic' | sort | xargs cat > {tmp_dir()}/allsh.txt 2>/dev/null`)
let s = read_file(`{tmp_dir()}/allsh.txt`)
if s == null { return "" }
return s
}
@ -285,7 +285,7 @@ function collect_impl_pairs(allsrc: pointer, pairs: []pointer) -> void {
let p = s_index(body, mns, i)
if p < 0 { break }
var ne = p + mnslen
while body[ne] != 0 and body[ne] != 34 { ne = ne + 1 }
while body[ne] != 0 and body[ne] != '"' { ne += 1 }
let nsname = sslice(body, p + mnslen, ne)
let nxt = s_index(body, mns, ne)
var chunkEnd = slen(body)
@ -296,13 +296,13 @@ function collect_impl_pairs(allsrc: pointer, pairs: []pointer) -> void {
let dp = s_index(chunk, "is_", 0)
if dp >= 0 {
var pe = dp
while chunk[pe] != 0 and chunk[pe] != 40 { pe = pe + 1 }
while chunk[pe] != 0 and chunk[pe] != '(' { pe += 1 }
let predname = sslice(chunk, dp, pe)
methods = collect_after(fn_body(allsrc, predname), mmeth)
}
}
var k = 0
while k < len(methods) { set_add(pairs, nsname + "." + methods[k]); k = k + 1 }
while k < len(methods) { set_add(pairs, nsname + "." + methods[k]); k += 1 }
if nxt < 0 { break }
i = nxt
}
@ -340,9 +340,9 @@ function collect_docs(tokens: []pointer, docpairs: []pointer) -> void {
var a = 0
let vn = slen(val)
while a < vn {
while a < vn and val[a] == 32 { a = a + 1 }
while a < vn and val[a] == ' ' { a += 1 }
var e = a
while e < vn and val[e] != 32 { e = e + 1 }
while e < vn and val[e] != ' ' { e += 1 }
if e > a { set_add(tokens, sslice(val, a, e)) }
a = e
}
@ -359,7 +359,7 @@ function collect_docs(tokens: []pointer, docpairs: []pointer) -> void {
var CI_PROB: pointer = ""
var CI_NPROB: int = 0
function ci_problem(msg: pointer) -> void { CI_PROB = CI_PROB + " - " + msg + "\n"; CI_NPROB = CI_NPROB + 1 }
function ci_problem(msg: pointer) -> void { CI_PROB = CI_PROB + " - " + msg + "\n"; CI_NPROB += 1 }
# usage: x check-impl
function cmd_check_impl() -> int {
@ -377,13 +377,13 @@ function cmd_check_impl() -> int {
let pair = impl[i]
if not set_has(docpairs, pair) { ci_problem("undocumented " + pair + " — add a docs/language page (tokens: " + pair + ")") }
else { if not set_has(tokens, pair) { ci_problem(pair + " documented but its page lacks that tokens entry") } }
i = i + 1
i += 1
}
# 2) every documented ns-method corresponds to real dispatch
i = 0
while i < len(docpairs) {
if not set_has(impl, docpairs[i]) { ci_problem("stale doc: " + docpairs[i] + " is not dispatched by the compiler") }
i = i + 1
i += 1
}
# 3) every implemented keyword / type / phase is documented
let h = read_file("tools/ludic-tools/ludic_syntax.h")
@ -410,14 +410,14 @@ function cmd_check_impl() -> int {
# add every element of `src` not in `deny` to `dst` (set semantics)
function add_all_except(dst: []pointer, src: []pointer, deny: []pointer) -> void {
var i = 0
while i < len(src) { if not set_has(deny, src[i]) { set_add(dst, src[i]) }; i = i + 1 }
while i < len(src) { if not set_has(deny, src[i]) { set_add(dst, src[i]) }; i += 1 }
}
# every name must appear in the token set, else a problem
function check_all_documented(names: []pointer, tokens: []pointer, label: pointer) -> void {
var i = 0
while i < len(names) {
if not set_has(tokens, names[i]) { ci_problem("undocumented " + label + ": " + names[i] + " (no docs/language page lists it in tokens)") }
i = i + 1
i += 1
}
}
@ -435,7 +435,7 @@ function all_lower(s: pointer) -> bool {
let n = slen(s)
if n == 0 { return false }
var i = 0
while i < n { if s[i] < 97 or s[i] > 122 { return false }; i = i + 1 }
while i < n { if s[i] < 'a' or s[i] > 'z' { return false }; i += 1 }
return true
}
@ -446,7 +446,7 @@ function split_pipe(s: pointer) -> []pointer {
var a = 0
while a <= n {
var e = a
while e < n and s[e] != 124 { e = e + 1 }
while e < n and s[e] != '|' { e += 1 }
set_add(out, sslice(s, a, e))
a = e + 1
}
@ -463,16 +463,16 @@ function c_table_names(h: pointer, name: pointer) -> []pointer {
if b < 0 or e < 0 { return out }
var i = b + 1
while i < e {
if h[i] == 123 { # '{' opens a row
if h[i] == '{' { # '{' opens a row
var j = i + 1
while j < e and (h[j] == 32 or h[j] == 9 or h[j] == 10) { j = j + 1 }
if h[j] == 34 {
while j < e and (h[j] == ' ' or h[j] == '\t' or h[j] == '\n') { j += 1 }
if h[j] == '"' {
var k = j + 1
while k < e and h[k] != 34 { k = k + 1 }
while k < e and h[k] != '"' { k += 1 }
set_add(out, sslice(h, j + 1, k))
}
}
i = i + 1
i += 1
}
return out
}
@ -488,22 +488,22 @@ function kotlin_set(text: pointer, name: pointer) -> []pointer {
let region_start = i
let tn = slen(text)
while i < tn and depth > 0 {
if text[i] == 40 { depth = depth + 1 }
if text[i] == 41 { depth = depth - 1 }
if text[i] == '(' { depth += 1 }
if text[i] == ')' { depth -= 1 }
if depth == 0 { break }
i = i + 1
i += 1
}
# collect quoted strings in [region_start, i), skipping //-comments
var k = region_start
while k < i {
if text[k] == 47 and text[k + 1] == 47 { # '//'
while k < i and text[k] != 10 { k = k + 1 }
} else { if text[k] == 34 {
if text[k] == '/' and text[k + 1] == '/' { # '//'
while k < i and text[k] != '\n' { k += 1 }
} else { if text[k] == '"' {
var e = k + 1
while e < i and text[e] != 34 { e = e + 1 }
while e < i and text[e] != '"' { e += 1 }
set_add(out, sslice(text, k + 1, e))
k = e + 1
} else { k = k + 1 } }
} else { k += 1 } }
}
return out
}
@ -517,13 +517,13 @@ function grammar_alt(root: JVal, node: pointer, marker: pointer) -> []pointer {
while i < len(pats.kids) {
let m = j_get(pats.kids[i], "match")
if m.t == JV_STR and s_contains(m.s, marker) {
let op = s_index(m.s, bx3(92, 98, 40), 0) # \b(
let op = s_index(m.s, bx3('\\', 'b', '('), 0) # \b(
if op >= 0 {
let cl = s_index(m.s, bx3(41, 92, 98), op) # )\b
let cl = s_index(m.s, bx3(')', '\\', 'b'), op) # )\b
if cl >= 0 { return split_pipe(sslice(m.s, op + 3, cl)) }
}
}
i = i + 1
i += 1
}
return out
}
@ -543,28 +543,28 @@ function add_parser_kw(out: []pointer, path: pointer) -> void {
}
function add_lower(out: []pointer, src: []pointer) -> void {
var i = 0
while i < len(src) { if all_lower(src[i]) { set_add(out, src[i]) }; i = i + 1 }
while i < len(src) { if all_lower(src[i]) { set_add(out, src[i]) }; i += 1 }
}
var CV_PROB: pointer = ""
var CV_N: int = 0
function cv_problem(msg: pointer) -> void { CV_PROB = CV_PROB + " - " + msg + "\n"; CV_N = CV_N + 1 }
function cv_problem(msg: pointer) -> void { CV_PROB = CV_PROB + " - " + msg + "\n"; CV_N += 1 }
# report names in `ref` missing from `other`, and names in `other` not in `ref`
function cmp_sets(label: pointer, ref: []pointer, other: []pointer, other_label: pointer) -> void {
var i = 0
while i < len(ref) { if not set_has(other, ref[i]) { cv_problem(other_label + " is missing " + label + ": " + ref[i]) }; i = i + 1 }
while i < len(ref) { if not set_has(other, ref[i]) { cv_problem(other_label + " is missing " + label + ": " + ref[i]) }; i += 1 }
i = 0
while i < len(other) { if not set_has(ref, other[i]) { cv_problem(other_label + " has unknown " + label + ": " + other[i]) }; i = i + 1 }
while i < len(other) { if not set_has(ref, other[i]) { cv_problem(other_label + " has unknown " + label + ": " + other[i]) }; i += 1 }
}
# union of two sets
function set_union(a: []pointer, b: []pointer) -> []pointer {
let out = new []pointer
var i = 0
while i < len(a) { set_add(out, a[i]); i = i + 1 }
while i < len(a) { set_add(out, a[i]); i += 1 }
i = 0
while i < len(b) { set_add(out, b[i]); i = i + 1 }
while i < len(b) { set_add(out, b[i]); i += 1 }
return out
}
@ -614,7 +614,7 @@ function cmd_check_vocab() -> int {
cmp_unparsed("clause keywords", h_clause, pkw, h_reserved)
# reserved words the parser now accepts should be promoted
var i = 0
while i < len(h_reserved) { if set_has(pkw, h_reserved[i]) { cv_problem("LUDIC_KW_RESERVED lists a keyword the parser now accepts — promote it: " + h_reserved[i]) }; i = i + 1 }
while i < len(h_reserved) { if set_has(pkw, h_reserved[i]) { cv_problem("LUDIC_KW_RESERVED lists a keyword the parser now accepts — promote it: " + h_reserved[i]) }; i += 1 }
}
if CV_N > 0 {
@ -632,7 +632,7 @@ function cmp_unparsed(label: pointer, kws: []pointer, pkw: []pointer, reserved:
if not set_has(pkw, kws[i]) {
if not set_has(reserved, kws[i]) { cv_problem("ludic_syntax.h lists a " + label + " the selfhost parser never dispatches on: " + kws[i]) }
}
i = i + 1
i += 1
}
}
@ -648,14 +648,14 @@ function xml_valid(text: pointer) -> bool {
var sp = 0
var i = 0
while i < n {
if text[i] != 60 { i = i + 1; continue } # seek '<'
if text[i + 1] == 63 { # <? ... ?>
if text[i] != '<' { i += 1; continue } # seek '<'
if text[i + 1] == '?' { # <? ... ?>
let e = s_index(text, "?>", i)
if e < 0 { return false }
i = e + 2; continue
}
if text[i + 1] == 33 { # <! comment / cdata / doctype
if text[i + 2] == 45 and text[i + 3] == 45 { # <!-- ... -->
if text[i + 1] == '!' { # <! comment / cdata / doctype
if text[i + 2] == '-' and text[i + 3] == '-' { # <!-- ... -->
let e = s_index(text, "-->", i)
if e < 0 { return false }
i = e + 3; continue
@ -669,33 +669,33 @@ function xml_valid(text: pointer) -> bool {
if e < 0 { return false }
i = e + 1; continue
}
if text[i + 1] == 47 { # closing </name>
if text[i + 1] == '/' { # closing </name>
var j = i + 2
while j < n and text[j] != 62 and text[j] != 32 and text[j] != 9 and text[j] != 10 { j = j + 1 }
while j < n and text[j] != '>' and text[j] != ' ' and text[j] != '\t' and text[j] != '\n' { j += 1 }
let name = sslice(text, i + 2, j)
let e = s_index(text, ">", j)
if e < 0 { return false }
if sp == 0 { return false }
if stack[sp - 1] != name { return false }
sp = sp - 1
sp -= 1
i = e + 1; continue
}
# opening tag: read the name
var j = i + 1
while j < n and text[j] != 62 and text[j] != 32 and text[j] != 9 and text[j] != 10 and text[j] != 47 { j = j + 1 }
while j < n and text[j] != '>' and text[j] != ' ' and text[j] != '\t' and text[j] != '\n' and text[j] != '/' { j += 1 }
let name = sslice(text, i + 1, j)
if slen(name) == 0 { return false }
# advance to '>', skipping quoted attribute values
var k = j
while k < n and text[k] != 62 {
if text[k] == 34 { k = k + 1; while k < n and text[k] != 34 { k = k + 1 } }
else { if text[k] == 39 { k = k + 1; while k < n and text[k] != 39 { k = k + 1 } } }
k = k + 1
while k < n and text[k] != '>' {
if text[k] == '"' { k += 1; while k < n and text[k] != '"' { k += 1 } }
else { if text[k] == '\'' { k += 1; while k < n and text[k] != '\'' { k += 1 } } }
k += 1
}
if k >= n { return false }
if text[k - 1] != 47 { # not self-closing -> push
if text[k - 1] != '/' { # not self-closing -> push
if sp < len(stack) { stack[sp] = name } else { push(stack, name) }
sp = sp + 1
sp += 1
}
i = k + 1
}