feat(cli): install in one command, and call the CLI ludic
Getting started meant cloning the repository, bootstrapping a compiler and
learning a task runner called `x`. That is a contributor's workflow handed to
everyone who wants to try the language.
Installing is now one command:
curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh
install.sh puts a complete toolchain — compiler, CLI, engine runtime, bundled
ludic.* packages, formatter, language server — in ~/.ludic and adds it to PATH.
Prebuilt artifacts are checksum-verified; where a platform has none, or the
release predates this layout, it bootstraps from the compiler's own IR seed with
clang. The docs site publishes the script beside the pages that quote it, so the
page and the script can never come from different releases.
`x` becomes `ludic`, and the surface splits by audience. A user of the language
sees `new`, `run`, `build`, `test`, `add`, `fmt`, `lsp`, `doctor`, `upgrade`;
`ludic new` scaffolds a project that builds and plays as it stands. Everything
the toolchain repo needs moved under `ludic dev` — build, test, reseed,
bootstrap-cfree, docs-gen, release — unchanged apart from the namespace. Those
tasks read arguments one position further along, so dispatch_dev sets a shift
and commands use arg_n()/arg_total() rather than each knowing its own depth.
Release artifacts become complete install roots (bin/ beside runtime/, packages/
and VERSION) rather than bare binaries, which is what the installer unpacks.
`ludic dev test` asserts the whole shape: it stages an install, puts it on PATH
with no LUDIC_HOME, and runs new -> build -> test through it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
005cc39394
commit
aca263642d
54 changed files with 1802 additions and 670 deletions
100
tools/ludic-cli/build.ludic
Normal file
100
tools/ludic-cli/build.ludic
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# build.ludic — compiling: a user's program (ludic build / ludic run) and the
|
||||
# toolchain's own binaries (ludic dev build).
|
||||
#
|
||||
# compile_app is the one place a .ludic file becomes an executable, so a project
|
||||
# build and the repo's own example builds cannot drift apart. Everything the
|
||||
# toolchain owns — the compiler, the engine runtime it splices — is addressed
|
||||
# through ludic_home(), so the same code path works from a checkout and from an
|
||||
# installed ~/.ludic.
|
||||
|
||||
# compile a Ludic source to a native binary in bin/ via ludicc + clang (-O2).
|
||||
# Returns true on success. Used for x itself and the editor tools.
|
||||
function build_tool(name: pointer, src: pointer) -> bool {
|
||||
let ll = `build/{name}.ll`
|
||||
if not shq(`bin/ludicc {src} --emit-llvm -o {ll} 2>/dev/null`) { print(`build failed: {name} (compile)`); return false }
|
||||
# write to a temp then move, so a running bin/ludic can rebuild itself in place
|
||||
if not shq(`{cc()} -O2 {ll} -o bin/{name}.tmp`) { print(`build failed: {name} (link)`); return false }
|
||||
run(`mv -f bin/{name}.tmp bin/{name} && rm -f {ll}`)
|
||||
return true
|
||||
}
|
||||
|
||||
# ---- build-cli: the self-hosted front-end binaries ---------------------------
|
||||
# Both ludicc and ludic are the SAME multi-call binary assembled from the IR seed
|
||||
# with clang alone; invoked as `ludic` it compiles-and-runs, as `ludicc` it just
|
||||
# compiles.
|
||||
function cmd_dev_build_cli() -> int {
|
||||
run("mkdir -p bin build")
|
||||
print("cc: selfhost/ludicc.seed.ll -> bin/ludicc (from the IR seed, no C compiler)")
|
||||
if not shq(`{cc()} selfhost/ludicc.seed.ll -o bin/ludicc`) { err("ludic: the seed did not assemble\n"); return 1 }
|
||||
run("chmod +x bin/ludicc")
|
||||
print("done. built bin/ludicc")
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---- build: the whole toolchain (ludicc, ludic, ludic-fmt, ludic-lsp) -------
|
||||
function cmd_dev_build() -> int {
|
||||
if cmd_dev_build_cli() != 0 { return 1 }
|
||||
print("ludicc: tools/ludic-cli/main.ludic -> bin/ludic (the CLI rebuilds itself)")
|
||||
if not build_tool("ludic", "tools/ludic-cli/main.ludic") { return 1 }
|
||||
print("ludicc: tools/ludic-tools/fmt.ludic -> bin/ludic-fmt")
|
||||
if not build_tool("ludic-fmt", "tools/ludic-tools/fmt.ludic") { return 1 }
|
||||
print("ludicc: tools/ludic-tools/lsp.ludic -> bin/ludic-lsp")
|
||||
if not build_tool("ludic-lsp", "tools/ludic-tools/lsp.ludic") { return 1 }
|
||||
sync_vscode_grammar()
|
||||
print("done. toolchain in bin/ (ludicc, ludic, ludic-fmt, ludic-lsp)")
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---- compiling one program to a native binary -------------------------------
|
||||
#
|
||||
# mode 1 = windowed (a game opens a real window), 2 = headless (the last frame is
|
||||
# rendered to build/out.ppm from piped input — deterministic output for tests).
|
||||
# Returns true on success. `ludic build`, `ludic run` and `ludic dev app` all go
|
||||
# through here.
|
||||
function compile_app(src: pointer, out: pointer, mode: int, save: bool) -> bool {
|
||||
ensure_ludicc() # in a checkout: assemble the seed if bin/ludicc is missing or stale
|
||||
let home = ludic_home()
|
||||
let pbf = prebuilt_link_flags() # #64: link resolved prebuilt module dylibs, if any
|
||||
let odir = dir_of_path(out)
|
||||
if len(odir) > 0 { run(`mkdir -p {odir}`) }
|
||||
run("mkdir -p build")
|
||||
let ll = `{out}.ll`
|
||||
|
||||
if mode == 2 {
|
||||
if not shq(`{ludicc()} --headless {src} --emit-llvm -o {ll}`) { return false }
|
||||
if not shq(`{cc()} -O2 {ll}{pbf} -o {out}`) { return false }
|
||||
if not save { run(`rm -f {ll}`) }
|
||||
return true
|
||||
}
|
||||
|
||||
if not shq(`{ludicc()} --windowed {src} --emit-llvm -o {ll}`) { return false }
|
||||
# audio.ll (#22) is always linked here — unused snd_* are dead-stripped; the
|
||||
# canonical `ludicc -o` path links it only when Audio.* is used.
|
||||
let cocoa = `{home}runtime/native/cocoa.ll`
|
||||
let audio = `{home}runtime/native/audio.ll`
|
||||
if not shq(`{cc()} -O2 {ll} {cocoa} {audio} -framework Cocoa -Wl,-needed_framework,GameController -Wl,-needed_framework,AVFoundation -Wl,-rpath,@loader_path{pbf} -o {out}`) { return false }
|
||||
if not save { run(`rm -f {ll}`) }
|
||||
return true
|
||||
}
|
||||
|
||||
# the directory part of a path, without the trailing '/' ("" when there is none)
|
||||
function dir_of_path(p: pointer) -> pointer {
|
||||
var last = -1
|
||||
var i = 0
|
||||
while p[i] != 0 { if p[i] == '/' { last = i }; i += 1 }
|
||||
if last < 0 { return "" }
|
||||
return p[0..last]
|
||||
}
|
||||
|
||||
# Remove every generated artifact and leave the tracked source untouched: the
|
||||
# whole build/ tree (IR, objects, compiled apps, the headless render at
|
||||
# build/out.ppm), and any stray *.tmp/*.ll left behind by a failed build. A
|
||||
# legacy out.ppm in the root (from an older toolchain) is swept too. Binaries in
|
||||
# bin/ are kept so the running CLI survives; in the toolchain repo, rebuild them
|
||||
# any time with `ludic dev build`.
|
||||
function cmd_clean() -> int {
|
||||
run("rm -rf build")
|
||||
run("rm -f out.ppm bin/*.tmp")
|
||||
print("cleaned: build/ (incl. build/out.ppm) and stray artifacts (bin/ is left alone)")
|
||||
return 0
|
||||
}
|
||||
721
tools/ludic-cli/checks.ludic
Normal file
721
tools/ludic-cli/checks.ludic
Normal file
|
|
@ -0,0 +1,721 @@
|
|||
# checks.ludic — the documentation / vocabulary lint suite, in Ludic.
|
||||
#
|
||||
# Ports the Python guards that used to live under tools/ (check-docs.py,
|
||||
# check-vocabulary.py, tools/docgen/check-impl.py) so the doc/lint tooling runs
|
||||
# through `x` with no Python in the loop. Each is a plain `x` subcommand and
|
||||
# reuses the prelude (read_file / shq / capture / the PASS/FAIL harness).
|
||||
#
|
||||
# String work is over NUL-terminated byte buffers (read_file), reached with
|
||||
# plain indexing; `==` on pointers is a byte-string compare (the whole toolchain
|
||||
# leans on this).
|
||||
|
||||
# ---- small string helpers ---------------------------------------------------
|
||||
|
||||
# length of a NUL-terminated buffer
|
||||
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 {
|
||||
if end < start { return "" }
|
||||
let n = end - start
|
||||
let b = bytes(n + 1)
|
||||
var i = 0
|
||||
while i < n { b[i] = s[start + i]; i += 1 }
|
||||
b[n] = 0
|
||||
return b
|
||||
}
|
||||
|
||||
# index of the first byte of `needle` in `hay` at or after `from`, else -1
|
||||
function s_index(hay: pointer, needle: pointer, from: int) -> int {
|
||||
let hn = slen(hay)
|
||||
let nn = slen(needle)
|
||||
if nn == 0 { return from }
|
||||
var i = from
|
||||
while i + nn <= hn {
|
||||
var j = 0
|
||||
while j < nn and hay[i + j] == needle[j] { j += 1 }
|
||||
if j == nn { return i }
|
||||
i += 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function s_contains(hay: pointer, needle: pointer) -> bool { return s_index(hay, needle, 0) >= 0 }
|
||||
|
||||
# does `hay` contain `needle` exactly at position `at`?
|
||||
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 += 1 }
|
||||
return true
|
||||
}
|
||||
|
||||
# does `s` (a whole line) begin with `pre`?
|
||||
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 += 1 }
|
||||
return true
|
||||
}
|
||||
|
||||
# is byte c an ASCII space/tab?
|
||||
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] != '\n' { e += 1 }
|
||||
return sslice(s, start, e)
|
||||
}
|
||||
|
||||
# trim leading/trailing ASCII whitespace (space, tab, cr, nl)
|
||||
function s_trim(s: pointer) -> pointer {
|
||||
let n = slen(s)
|
||||
var a = 0
|
||||
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] == '\n' or s[b - 1] == '\r') { b -= 1 }
|
||||
return sslice(s, a, b)
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# check-docs — every ```ludic fence in the docs must parse (or be marked)
|
||||
# ============================================================================
|
||||
# A fence declares its intent inline (# is a Ludic comment, so the marker is
|
||||
# valid code): `# doc-check: skip` (not checked), `# doc-check: expect-error`
|
||||
# (must FAIL to parse). Everything else must parse under `ludicc --fmt` (lex +
|
||||
# parse, no identifier resolution), after wrapping a bare fragment.
|
||||
|
||||
# the declaration heads that mean "these are top-level decls, wrap in program"
|
||||
function is_decl_head(h: pointer) -> bool {
|
||||
if h == "program" or h == "property" or h == "model" or h == "handler" { return true }
|
||||
if h == "enum" or h == "function" or h == "extern" or h == "const" { return true }
|
||||
if h == "var" or h == "ui" or h == "struct" or h == "import" { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
# classify a fence body: "whole" (a full program), "decls", or "stmts"
|
||||
function classify_fence(body: pointer) -> pointer {
|
||||
let n = slen(body)
|
||||
var i = 0
|
||||
while i < n {
|
||||
let ln = line_at(body, i)
|
||||
let t = s_trim(ln)
|
||||
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] != '(' 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" }
|
||||
return "stmts"
|
||||
}
|
||||
i = i + slen(ln) + 1
|
||||
}
|
||||
return "stmts"
|
||||
}
|
||||
|
||||
# does the wrapped source at `path` parse? (ludicc --fmt = lex+parse gate)
|
||||
function fence_parses(src: pointer) -> bool {
|
||||
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
|
||||
function fence_body_parses(body: pointer, kind: pointer) -> bool {
|
||||
if kind == "whole" { return fence_parses(body) }
|
||||
let as_decls = "program DocCheck {\n" + body + "\n}\n"
|
||||
let as_stmts = "program DocCheck {\n handler DocS phase Start {\n" + body + "\n }\n}\n"
|
||||
if kind == "decls" {
|
||||
if fence_parses(as_decls) { return true }
|
||||
return fence_parses(as_stmts)
|
||||
}
|
||||
if fence_parses(as_stmts) { return true }
|
||||
return fence_parses(as_decls)
|
||||
}
|
||||
|
||||
var DC_OK: int = 0
|
||||
var DC_BAD: int = 0
|
||||
var DC_SKIP: int = 0
|
||||
var DC_FAILS: pointer = ""
|
||||
|
||||
# scan one markdown file's ```ludic fences
|
||||
function check_docs_file(path: pointer) -> void {
|
||||
let t = read_file(path)
|
||||
if t == null { return }
|
||||
var i = 0
|
||||
while true {
|
||||
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] != '\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 += 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 += 1
|
||||
} else {
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 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] == '\n' { n += 1 }; i += 1 }
|
||||
return n
|
||||
}
|
||||
|
||||
# usage: ludic dev check-docs (scans the user-facing docs + docs/language/**)
|
||||
function cmd_check_docs() -> int {
|
||||
ensure_ludicc()
|
||||
DC_OK = 0; DC_BAD = 0; DC_SKIP = 0; DC_FAILS = ""
|
||||
# the corpus: root references + every per-symbol page
|
||||
let list = capture("{ ls LANGUAGE.md COMPILING.md README.md CONTRIBUTING.md 2>/dev/null; find docs -name '*.md' 2>/dev/null; }")
|
||||
let n = slen(list)
|
||||
var i = 0
|
||||
while i < n {
|
||||
let ln = line_at(list, i)
|
||||
i = i + slen(ln) + 1
|
||||
let path = s_trim(ln)
|
||||
if slen(path) > 0 { check_docs_file(path) }
|
||||
}
|
||||
print(` doc fences: {string(DC_OK)} as documented, {string(DC_BAD)} drifted, {string(DC_SKIP)} skipped`)
|
||||
if DC_BAD > 0 { out(DC_FAILS) }
|
||||
if DC_BAD > 0 { return 1 }
|
||||
return 0
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# check-impl — every implemented feature has a docs/language page
|
||||
# ============================================================================
|
||||
# Reads the implementation (emit_ns_call dispatch + the is_<ns>_ns predicates,
|
||||
# and ludic_syntax.h's keyword/type/phase tables) and the per-symbol docs, then
|
||||
# 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] = '"'; 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 += 1 }
|
||||
return false
|
||||
}
|
||||
function set_add(l: []pointer, s: pointer) -> void { if not set_has(l, s) { push(l, s) } }
|
||||
|
||||
# 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 = -1
|
||||
if s_starts(src, needle) { p = 0 }
|
||||
else {
|
||||
let q = s_index(src, "\n" + needle, 0)
|
||||
if q >= 0 { p = q + 1 }
|
||||
}
|
||||
if p < 0 { return "" }
|
||||
let nxt = s_index(src, "\nfunction ", p + 1)
|
||||
if nxt < 0 { return sslice(src, p, slen(src)) }
|
||||
return sslice(src, p, nxt)
|
||||
}
|
||||
|
||||
# every identifier string following each occurrence of `marker` (read to a quote)
|
||||
function collect_after(src: pointer, marker: pointer) -> []pointer {
|
||||
let out = new []pointer
|
||||
let mn = slen(marker)
|
||||
var i = 0
|
||||
while true {
|
||||
let p = s_index(src, marker, i)
|
||||
if p < 0 { break }
|
||||
var e = p + mn
|
||||
while src[e] != 0 and src[e] != '"' { e += 1 }
|
||||
push(out, sslice(src, p + mn, e))
|
||||
i = e + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
# quoted strings inside the `NAME[] = { ... };` C table in `h`
|
||||
function table_set(h: pointer, name: pointer) -> []pointer {
|
||||
let out = new []pointer
|
||||
let p = s_index(h, name + "[]", 0)
|
||||
if p < 0 { return out }
|
||||
let b = s_index(h, "{", p)
|
||||
let e = s_index(h, "};", b)
|
||||
if b < 0 or e < 0 { return out }
|
||||
var i = b
|
||||
while i < e {
|
||||
if h[i] == '"' {
|
||||
var j = i + 1
|
||||
while j < e and h[j] != '"' { j += 1 }
|
||||
set_add(out, sslice(h, i + 1, j))
|
||||
i = j + 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_dir()}/allsh.txt 2>/dev/null`)
|
||||
let s = read_file(`{tmp_dir()}/allsh.txt`)
|
||||
if s == null { return "" }
|
||||
return s
|
||||
}
|
||||
|
||||
# {ns}.{method} pairs the compiler actually dispatches on -> into `pairs`
|
||||
function collect_impl_pairs(allsrc: pointer, pairs: []pointer) -> void {
|
||||
let body = fn_body(allsrc, "emit_ns_call")
|
||||
let mns = "if (ns == " + dq()
|
||||
let mmeth = "meth == " + dq()
|
||||
let mnslen = slen(mns)
|
||||
var i = 0
|
||||
while true {
|
||||
let p = s_index(body, mns, i)
|
||||
if p < 0 { break }
|
||||
var ne = p + mnslen
|
||||
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)
|
||||
if nxt >= 0 { chunkEnd = nxt }
|
||||
let chunk = sslice(body, p, chunkEnd)
|
||||
var methods = collect_after(chunk, mmeth)
|
||||
if len(methods) == 0 {
|
||||
let dp = s_index(chunk, "is_", 0)
|
||||
if dp >= 0 {
|
||||
var pe = dp
|
||||
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 += 1 }
|
||||
if nxt < 0 { break }
|
||||
i = nxt
|
||||
}
|
||||
}
|
||||
|
||||
# gather the docs facts: token set, and the (ns.member) pairs of ns-method pages
|
||||
function collect_docs(tokens: []pointer, docpairs: []pointer) -> void {
|
||||
let list = capture("find docs/language -name '*.md' ! -name '_section.md' 2>/dev/null")
|
||||
let n = slen(list)
|
||||
var i = 0
|
||||
while i < n {
|
||||
let ln = line_at(list, i)
|
||||
i = i + slen(ln) + 1
|
||||
let path = s_trim(ln)
|
||||
if slen(path) == 0 { continue }
|
||||
let t = read_file(path)
|
||||
if t == null { continue }
|
||||
if not s_starts(t, "---") { continue }
|
||||
let end = s_index(t, "\n---", 3)
|
||||
if end < 0 { continue }
|
||||
let fm = sslice(t, 3, end)
|
||||
# walk front-matter lines
|
||||
var kind = ""; var ns = ""; var member = ""
|
||||
let fn2 = slen(fm)
|
||||
var j = 0
|
||||
while j < fn2 {
|
||||
let fl = line_at(fm, j)
|
||||
j = j + slen(fl) + 1
|
||||
let c = s_index(fl, ":", 0)
|
||||
if c < 0 { continue }
|
||||
let key = s_trim(sslice(fl, 0, c))
|
||||
let val = s_trim(sslice(fl, c + 1, slen(fl)))
|
||||
if key == "tokens" {
|
||||
# split val on spaces
|
||||
var a = 0
|
||||
let vn = slen(val)
|
||||
while a < vn {
|
||||
while a < vn and val[a] == ' ' { a += 1 }
|
||||
var e = a
|
||||
while e < vn and val[e] != ' ' { e += 1 }
|
||||
if e > a { set_add(tokens, sslice(val, a, e)) }
|
||||
a = e
|
||||
}
|
||||
}
|
||||
if key == "kind" { kind = val }
|
||||
if key == "ns" { ns = val }
|
||||
if key == "member" { member = val }
|
||||
}
|
||||
if kind == "namespace-method" and slen(ns) > 0 and slen(member) > 0 {
|
||||
set_add(docpairs, ns + "." + member)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var CI_PROB: pointer = ""
|
||||
var CI_NPROB: int = 0
|
||||
function ci_problem(msg: pointer) -> void { CI_PROB = CI_PROB + " - " + msg + "\n"; CI_NPROB += 1 }
|
||||
|
||||
# usage: ludic dev check-impl
|
||||
function cmd_check_impl() -> int {
|
||||
CI_PROB = ""; CI_NPROB = 0
|
||||
let allsrc = read_all_selfhost()
|
||||
let impl = new []pointer
|
||||
collect_impl_pairs(allsrc, impl)
|
||||
let tokens = new []pointer
|
||||
let docpairs = new []pointer
|
||||
collect_docs(tokens, docpairs)
|
||||
|
||||
# 1) every implemented ns-method has a page with the right token
|
||||
var i = 0
|
||||
while i < len(impl) {
|
||||
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 += 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 += 1
|
||||
}
|
||||
# 3) every implemented keyword / type / phase is documented
|
||||
let h = read_file("tools/ludic-tools/ludic_syntax.h")
|
||||
let reserved = table_set(h, "LUDIC_KW_RESERVED")
|
||||
let kws = new []pointer
|
||||
add_all_except(kws, table_set(h, "LUDIC_KW_DECL"), reserved)
|
||||
add_all_except(kws, table_set(h, "LUDIC_KW_CLAUSE"), reserved)
|
||||
add_all_except(kws, table_set(h, "LUDIC_KW_STMT"), reserved)
|
||||
let types = table_set(h, "LUDIC_TYPES")
|
||||
let phases = table_set(h, "LUDIC_PHASES")
|
||||
check_all_documented(kws, tokens, "keyword")
|
||||
check_all_documented(types, tokens, "type")
|
||||
check_all_documented(phases, tokens, "phase")
|
||||
|
||||
if CI_NPROB > 0 {
|
||||
err("docs-vs-implementation drift:\n")
|
||||
err(CI_PROB)
|
||||
return 1
|
||||
}
|
||||
print("docs cover the implementation: " + string(len(impl)) + " namespace methods, " + string(len(kws)) + " keywords, " + string(len(types)) + " types, " + string(len(phases)) + " phases")
|
||||
return 0
|
||||
}
|
||||
|
||||
# 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 += 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 += 1
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# check-vocabulary — the language vocabulary is written down in several places
|
||||
# that cannot include each other (ludic_syntax.h, the JetBrains Kotlin lexer,
|
||||
# the TextMate grammar, the self-host parser); drift between them is silent, so
|
||||
# it is checked. Replaces the former tools/check-vocabulary.py.
|
||||
# ============================================================================
|
||||
|
||||
# a 3-byte needle as a string
|
||||
function bx3(a: int, b: int, c: int) -> pointer { let z = bytes(4); z[0] = a; z[1] = b; z[2] = c; z[3] = 0; return z }
|
||||
|
||||
function all_lower(s: pointer) -> bool {
|
||||
let n = slen(s)
|
||||
if n == 0 { return false }
|
||||
var i = 0
|
||||
while i < n { if s[i] < 'a' or s[i] > 'z' { return false }; i += 1 }
|
||||
return true
|
||||
}
|
||||
|
||||
# split `s` on '|' into a set
|
||||
function split_pipe(s: pointer) -> []pointer {
|
||||
let out = new []pointer
|
||||
let n = slen(s)
|
||||
var a = 0
|
||||
while a <= n {
|
||||
var e = a
|
||||
while e < n and s[e] != '|' { e += 1 }
|
||||
set_add(out, sslice(s, a, e))
|
||||
a = e + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
# first quoted string after each row-opening '{' in the `name[]` C table
|
||||
function c_table_names(h: pointer, name: pointer) -> []pointer {
|
||||
let out = new []pointer
|
||||
let p = s_index(h, name + "[]", 0)
|
||||
if p < 0 { return out }
|
||||
let b = s_index(h, "{", p)
|
||||
let e = s_index(h, "};", b)
|
||||
if b < 0 or e < 0 { return out }
|
||||
var i = b + 1
|
||||
while i < e {
|
||||
if h[i] == '{' { # '{' opens a row
|
||||
var j = i + 1
|
||||
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] != '"' { k += 1 }
|
||||
set_add(out, sslice(h, j + 1, k))
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
# names in a Kotlin `val NAME = setOf( ... )` (parens matched, // comments cut)
|
||||
function kotlin_set(text: pointer, name: pointer) -> []pointer {
|
||||
let out = new []pointer
|
||||
let marker = "val " + name + " = setOf("
|
||||
let p = s_index(text, marker, 0)
|
||||
if p < 0 { return out }
|
||||
var i = p + slen(marker)
|
||||
var depth = 1
|
||||
let region_start = i
|
||||
let tn = slen(text)
|
||||
while i < tn and depth > 0 {
|
||||
if text[i] == '(' { depth += 1 }
|
||||
if text[i] == ')' { depth -= 1 }
|
||||
if depth == 0 { break }
|
||||
i += 1
|
||||
}
|
||||
# collect quoted strings in [region_start, i), skipping //-comments
|
||||
var k = region_start
|
||||
while k < i {
|
||||
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] != '"' { e += 1 }
|
||||
set_add(out, sslice(text, k + 1, e))
|
||||
k = e + 1
|
||||
} else { k += 1 } }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
# the alternation inside repository.<node>.patterns' first match containing marker
|
||||
function grammar_alt(root: JVal, node: pointer, marker: pointer) -> []pointer {
|
||||
let out = new []pointer
|
||||
let pats = j_get(j_get(j_get(root, "repository"), node), "patterns")
|
||||
if pats.t != JV_ARR { return out }
|
||||
var i = 0
|
||||
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('\\', 'b', '('), 0) # \b(
|
||||
if op >= 0 {
|
||||
let cl = s_index(m.s, bx3(')', '\\', 'b'), op) # )\b
|
||||
if cl >= 0 { return split_pipe(sslice(m.s, op + 3, cl)) }
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
# keywords the self-host parser dispatches on: is_id("x") + streq(t.text, "x")
|
||||
function parser_keywords() -> []pointer {
|
||||
let out = new []pointer
|
||||
add_parser_kw(out, "selfhost/frontend/parse.ludic")
|
||||
add_parser_kw(out, "selfhost/frontend/parse_game.ludic")
|
||||
return out
|
||||
}
|
||||
function add_parser_kw(out: []pointer, path: pointer) -> void {
|
||||
let t = read_file(path)
|
||||
if t == null { return }
|
||||
add_lower(out, collect_after(t, "is_id(" + dq()))
|
||||
add_lower(out, collect_after(t, "streq(t.text, " + dq()))
|
||||
}
|
||||
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 += 1 }
|
||||
}
|
||||
|
||||
var CV_PROB: pointer = ""
|
||||
var CV_N: int = 0
|
||||
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 += 1 }
|
||||
i = 0
|
||||
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 += 1 }
|
||||
i = 0
|
||||
while i < len(b) { set_add(out, b[i]); i += 1 }
|
||||
return out
|
||||
}
|
||||
|
||||
# usage: ludic dev check-vocabulary
|
||||
function cmd_check_vocab() -> int {
|
||||
CV_PROB = ""; CV_N = 0
|
||||
let h = read_file("tools/ludic-tools/ludic_syntax.h")
|
||||
if h == null { err("check-vocabulary: ludic_syntax.h missing\n"); return 2 }
|
||||
let h_decl = table_set(h, "LUDIC_KW_DECL")
|
||||
let h_clause = table_set(h, "LUDIC_KW_CLAUSE")
|
||||
let h_stmt = table_set(h, "LUDIC_KW_STMT")
|
||||
let h_types = table_set(h, "LUDIC_TYPES")
|
||||
let h_phases = table_set(h, "LUDIC_PHASES")
|
||||
let h_widgets = table_set(h, "LUDIC_WIDGETS")
|
||||
let h_builtins = c_table_names(h, "LUDIC_BUILTINS")
|
||||
let h_intrinsics = c_table_names(h, "LUDIC_INTRINSICS")
|
||||
let h_reserved = table_set(h, "LUDIC_KW_RESERVED")
|
||||
|
||||
# --- against the JetBrains lexer ---
|
||||
let kt = read_file("tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/LudicTokens.kt")
|
||||
if kt != null {
|
||||
cmp_sets("declaration keywords", h_decl, kotlin_set(kt, "DECL"), "LudicTokens.kt")
|
||||
cmp_sets("clause keywords", h_clause, kotlin_set(kt, "CLAUSE"), "LudicTokens.kt")
|
||||
cmp_sets("statement keywords", h_stmt, kotlin_set(kt, "STMT"), "LudicTokens.kt")
|
||||
cmp_sets("primitive types", h_types, kotlin_set(kt, "PRIMITIVES"), "LudicTokens.kt")
|
||||
cmp_sets("phases", h_phases, kotlin_set(kt, "PHASES"), "LudicTokens.kt")
|
||||
cmp_sets("widgets", h_widgets, kotlin_set(kt, "WIDGETS"), "LudicTokens.kt")
|
||||
cmp_sets("builtins", set_union(h_builtins, h_intrinsics), kotlin_set(kt, "BUILTINS"), "LudicTokens.kt")
|
||||
}
|
||||
|
||||
# --- against the TextMate grammar ---
|
||||
let gt = read_file("tools/editors/shared/ludic.tmLanguage.json")
|
||||
if gt != null {
|
||||
let g = json_parse(gt)
|
||||
cmp_sets("builtins", h_builtins, grammar_alt(g, "builtin", "rng_chance"), "ludic.tmLanguage.json")
|
||||
cmp_sets("intrinsics", h_intrinsics, grammar_alt(g, "builtin", "as_fixed"), "ludic.tmLanguage.json")
|
||||
cmp_sets("phases", h_phases, grammar_alt(g, "keyword", "FixedUpdate"), "ludic.tmLanguage.json")
|
||||
cmp_sets("primitive types", h_types, grammar_alt(g, "keyword", "fixed"), "ludic.tmLanguage.json")
|
||||
}
|
||||
|
||||
# --- against the self-host parser ---
|
||||
let pkw = parser_keywords()
|
||||
if len(pkw) == 0 {
|
||||
cv_problem("could not extract any keywords from selfhost/frontend/parse*.ludic")
|
||||
} else {
|
||||
cmp_unparsed("declaration keywords", h_decl, pkw, h_reserved)
|
||||
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 += 1 }
|
||||
}
|
||||
|
||||
if CV_N > 0 {
|
||||
err("vocabulary drift:\n")
|
||||
err(CV_PROB)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
# keywords in `kws` the parser never dispatches on (minus reserved) are a problem
|
||||
function cmp_unparsed(label: pointer, kws: []pointer, pkw: []pointer, reserved: []pointer) -> void {
|
||||
var i = 0
|
||||
while i < len(kws) {
|
||||
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 += 1
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# json/xml asset validation — replaces the python3 json.load / xml.dom checks
|
||||
# the editor-toolchain suite used on the shared grammar + plugin assets.
|
||||
# ============================================================================
|
||||
|
||||
# minimal XML well-formedness: tags balance and nest, quotes respected.
|
||||
function xml_valid(text: pointer) -> bool {
|
||||
let n = slen(text)
|
||||
let stack = new []pointer
|
||||
var sp = 0
|
||||
var i = 0
|
||||
while i < n {
|
||||
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] == '!' { # <! 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
|
||||
}
|
||||
if s_starts_at(text, i, "<![CDATA[") { # <![CDATA[ ... ]]>
|
||||
let e = s_index(text, "]]>", i)
|
||||
if e < 0 { return false }
|
||||
i = e + 3; continue
|
||||
}
|
||||
let e = s_index(text, ">", i)
|
||||
if e < 0 { return false }
|
||||
i = e + 1; continue
|
||||
}
|
||||
if text[i + 1] == '/' { # closing </name>
|
||||
var j = i + 2
|
||||
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 -= 1
|
||||
i = e + 1; continue
|
||||
}
|
||||
# opening tag: read the name
|
||||
var j = i + 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] != '>' {
|
||||
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] != '/' { # not self-closing -> push
|
||||
if sp < len(stack) { stack[sp] = name } else { push(stack, name) }
|
||||
sp += 1
|
||||
}
|
||||
i = k + 1
|
||||
}
|
||||
return sp == 0
|
||||
}
|
||||
|
||||
# usage: ludic dev lint-asset <file> (validates one .json or .xml editor asset)
|
||||
function cmd_lint_asset() -> int {
|
||||
if arg_total() < 3 { err("usage: ludic dev lint-asset <file.json|file.xml>\n"); return 2 }
|
||||
let path = arg_n(2)
|
||||
let t = read_file(path)
|
||||
if t == null { err("lint-asset: cannot read " + path + "\n"); return 1 }
|
||||
if s_index(path, ".json", 0) >= 0 {
|
||||
if json_valid(t) { return 0 }
|
||||
return 1
|
||||
}
|
||||
if s_index(path, ".xml", 0) >= 0 {
|
||||
if xml_valid(t) { return 0 }
|
||||
return 1
|
||||
}
|
||||
err("lint-asset: unknown asset type " + path + "\n")
|
||||
return 2
|
||||
}
|
||||
648
tools/ludic-cli/docgen.ludic
Normal file
648
tools/ludic-cli/docgen.ludic
Normal file
|
|
@ -0,0 +1,648 @@
|
|||
# docgen.ludic — the Ludic documentation-site generator and its guards, ported
|
||||
# off Python (gen.py / check.py / palette.py). Three x subcommands:
|
||||
#
|
||||
# ludic dev docs-palette emit selfhost/backend/stdlib/emit_color.ludic +
|
||||
# docs/language/colors/palette.json from the palette
|
||||
# table below (the named-colour source of truth).
|
||||
# ludic dev docs-gen [--out DIR] parse docs/language/** + docs/site/*, read the asset
|
||||
# templates, emit every page + symbols.json + the
|
||||
# highlighter + .nojekyll into DIR (default build/pages).
|
||||
# ludic dev docs-check [DIR] the coverage/integrity guard over a generated site.
|
||||
#
|
||||
# String work is over NUL-terminated byte buffers; helpers here mirror gen.py's
|
||||
# html.escape / json.dumps / front-matter parsing closely enough to reproduce the
|
||||
# Python generator's output byte-for-byte (verified against the oracle in ludic dev test).
|
||||
|
||||
# ---- a growable byte buffer (avoids O(n^2) string building) ------------------
|
||||
property Sb { data: pointer = null, len: int = 0, cap: int = 0 }
|
||||
|
||||
function sb_new() -> Sb {
|
||||
let s = new Sb
|
||||
s.cap = 256
|
||||
s.data = bytes(256)
|
||||
s.len = 0
|
||||
return s
|
||||
}
|
||||
function sb_grow(s: Sb, need: int) -> void {
|
||||
if s.len + need + 1 <= s.cap { return }
|
||||
var nc = s.cap * 2
|
||||
while nc < s.len + need + 1 { nc *= 2 }
|
||||
let nd = bytes(nc)
|
||||
var i = 0
|
||||
while i < s.len { nd[i] = s.data[i]; i += 1 }
|
||||
s.data = nd
|
||||
s.cap = nc
|
||||
}
|
||||
function sb_puts(s: Sb, t: pointer) -> void {
|
||||
let n = slen(t)
|
||||
sb_grow(s, n)
|
||||
var i = 0
|
||||
while i < n { s.data[s.len] = t[i]; s.len += 1; i += 1 }
|
||||
}
|
||||
function sb_putc(s: Sb, c: int) -> void {
|
||||
sb_grow(s, 1)
|
||||
s.data[s.len] = c
|
||||
s.len += 1
|
||||
}
|
||||
function sb_str(s: Sb) -> pointer {
|
||||
s.data[s.len] = 0
|
||||
return s.data
|
||||
}
|
||||
|
||||
# ---- HTML escaping (html.escape) --------------------------------------------
|
||||
# quote=false escapes & < > ; quote=true also escapes " and ' (Python entities:
|
||||
# & < > " '). Two-pass so a big code block stays O(n).
|
||||
function esc_q(s: pointer, quote: bool) -> pointer {
|
||||
if s == null { return "" }
|
||||
let n = slen(s)
|
||||
let b = sb_new()
|
||||
var i = 0
|
||||
while i < n {
|
||||
let c = s[i]
|
||||
if c == '&' { sb_puts(b, "&") }
|
||||
else if c == '<' { sb_puts(b, "<") }
|
||||
else if c == '>' { sb_puts(b, ">") }
|
||||
else if quote and c == '"' { sb_puts(b, """) }
|
||||
else if quote and c == '\'' { sb_puts(b, "'") }
|
||||
else { sb_putc(b, c) }
|
||||
i += 1
|
||||
}
|
||||
return sb_str(b)
|
||||
}
|
||||
function hesc(s: pointer) -> pointer { return esc_q(s, false) }
|
||||
function hescattr(s: pointer) -> pointer { return esc_q(s, true) }
|
||||
|
||||
# ---- generic byte/character predicates --------------------------------------
|
||||
# Python \s = [ \t\n\r\f\v]
|
||||
function is_space_all(c: int) -> bool {
|
||||
return c == ' ' or c == '\t' or c == '\n' or c == '\r' or c == 12 or c == 11
|
||||
}
|
||||
# Python \w = [A-Za-z0-9_]
|
||||
function is_word(c: int) -> bool {
|
||||
return (c >= '0' and c <= '9') or (c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z') or c == '_'
|
||||
}
|
||||
# strip only leading '\n' (Python str.lstrip("\n"))
|
||||
function lstrip_nl(s: pointer) -> pointer {
|
||||
var i = 0
|
||||
while s[i] == '\n' { i += 1 }
|
||||
return sslice(s, i, slen(s))
|
||||
}
|
||||
# lowercase ASCII A-Z
|
||||
function lower_ascii(s: pointer) -> pointer {
|
||||
let n = slen(s)
|
||||
let b = bytes(n + 1)
|
||||
var i = 0
|
||||
while i < n {
|
||||
var c = s[i]
|
||||
if c >= 'A' and c <= 'Z' { c += 32 }
|
||||
b[i] = c
|
||||
i += 1
|
||||
}
|
||||
b[n] = 0
|
||||
return b
|
||||
}
|
||||
# Python str.title(): capitalise the first letter of each alpha run, lower the rest
|
||||
function title_case(s: pointer) -> pointer {
|
||||
let n = slen(s)
|
||||
let b = bytes(n + 1)
|
||||
var i = 0
|
||||
var prev_alpha = false
|
||||
while i < n {
|
||||
var c = s[i]
|
||||
let al = (c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z')
|
||||
if al {
|
||||
if prev_alpha { if c >= 'A' and c <= 'Z' { c += 32 } }
|
||||
else { if c >= 'a' and c <= 'z' { c -= 32 } }
|
||||
}
|
||||
b[i] = c
|
||||
prev_alpha = al
|
||||
i += 1
|
||||
}
|
||||
b[n] = 0
|
||||
return b
|
||||
}
|
||||
# lexicographic byte compare: <0, 0, >0
|
||||
function str_cmp(a: pointer, b: pointer) -> int {
|
||||
var i = 0
|
||||
while a[i] != 0 and b[i] != 0 {
|
||||
let ca = a[i] & 255
|
||||
let cb = b[i] & 255
|
||||
if ca != cb { return ca - cb }
|
||||
i += 1
|
||||
}
|
||||
return (a[i] & 255) - (b[i] & 255)
|
||||
}
|
||||
function str_gt(a: pointer, b: pointer) -> bool { return str_cmp(a, b) > 0 }
|
||||
# does s end with suffix suf?
|
||||
function s_ends(s: pointer, suf: pointer) -> bool {
|
||||
let ls = slen(s)
|
||||
let lf = slen(suf)
|
||||
if lf > ls { return false }
|
||||
return s_starts_at(s, ls - lf, suf)
|
||||
}
|
||||
# split on runs of ASCII whitespace (Python str.split() with no arg)
|
||||
function split_ws(s: pointer) -> []pointer {
|
||||
let out = new []pointer
|
||||
let n = slen(s)
|
||||
var i = 0
|
||||
while i < n {
|
||||
while i < n and is_space_all(s[i]) { i += 1 }
|
||||
var e = i
|
||||
while e < n and not is_space_all(s[e]) { e += 1 }
|
||||
if e > i { push(out, sslice(s, i, e)) }
|
||||
i = e
|
||||
}
|
||||
return out
|
||||
}
|
||||
# stable insertion sort of a []pointer, ascending byte order (Python sorted())
|
||||
function strs_sort(a: []pointer) -> void {
|
||||
var i = 1
|
||||
while i < len(a) {
|
||||
let cur = a[i]
|
||||
var j = i - 1
|
||||
while j >= 0 and str_gt(a[j], cur) { a[j + 1] = a[j]; j -= 1 }
|
||||
a[j + 1] = cur
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
# `<code>` backtick spans: `x` -> <code>x</code> (inner text NOT escaped).
|
||||
# Mirrors codeify's re.sub(r"`([^`]+)`", ...): needs >=1 non-backtick inside.
|
||||
function codeify(s: pointer) -> pointer {
|
||||
let n = slen(s)
|
||||
let b = sb_new()
|
||||
var i = 0
|
||||
while i < n {
|
||||
if s[i] == '`' {
|
||||
var j = i + 1
|
||||
while j < n and s[j] != '`' { j += 1 }
|
||||
if j < n and j > i + 1 {
|
||||
sb_puts(b, "<code>")
|
||||
var k = i + 1
|
||||
while k < j { sb_putc(b, s[k]); k += 1 }
|
||||
sb_puts(b, "</code>")
|
||||
i = j + 1
|
||||
} else {
|
||||
sb_putc(b, '`')
|
||||
i += 1
|
||||
}
|
||||
} else {
|
||||
sb_putc(b, s[i])
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return sb_str(b)
|
||||
}
|
||||
|
||||
# ---- a Python-json.dumps-compatible JSON writer over the JVal tree ----------
|
||||
function jvs(s: pointer) -> JVal { let v = jval_new(JV_STR); v.s = s; return v }
|
||||
function jvi(n: int) -> JVal { let v = jval_new(JV_NUM); v.num = n; return v }
|
||||
function jvo() -> JVal { return jval_new(JV_OBJ) }
|
||||
function jva() -> JVal { return jval_new(JV_ARR) }
|
||||
# like Python dict assignment: update in place at an existing key (keeping its
|
||||
# first-insertion position, taking the new value), else append. Matters where a
|
||||
# token collides across kinds (e.g. `exit`/`string`/`fixed`) in sym["tips"].
|
||||
function jvput(o: JVal, k: pointer, v: JVal) -> void {
|
||||
var i = 0
|
||||
while i < len(o.keys) { if o.keys[i] == k { o.kids[i] = v; return }; i += 1 }
|
||||
push(o.keys, k)
|
||||
push(o.kids, v)
|
||||
}
|
||||
function jvpush(a: JVal, v: JVal) -> void { push(a.kids, v) }
|
||||
|
||||
function hex_lower(n: int) -> int {
|
||||
if n < 10 { return 48 + n }
|
||||
return 87 + n
|
||||
}
|
||||
function jemit_u(b: Sb, cp: int) -> void {
|
||||
sb_putc(b, '\\'); sb_putc(b, 'u') # \u
|
||||
sb_putc(b, hex_lower((cp >> 12) & 15))
|
||||
sb_putc(b, hex_lower((cp >> 8) & 15))
|
||||
sb_putc(b, hex_lower((cp >> 4) & 15))
|
||||
sb_putc(b, hex_lower(cp & 15))
|
||||
}
|
||||
# emit the JSON-escaped content of s (no surrounding quotes). ensure_ascii: emit
|
||||
# non-ASCII as \uXXXX (UTF-8 decoded, surrogate pairs for astral); else raw bytes.
|
||||
function json_escape_into(b: Sb, s: pointer, ensure_ascii: bool) -> void {
|
||||
let n = slen(s)
|
||||
var i = 0
|
||||
while i < n {
|
||||
let c = s[i] & 255
|
||||
if c == '"' { sb_putc(b, '\\'); sb_putc(b, '"'); i += 1 }
|
||||
else if c == '\\' { sb_putc(b, '\\'); sb_putc(b, '\\'); i += 1 }
|
||||
else if c == 8 { sb_putc(b, '\\'); sb_putc(b, 'b'); i += 1 }
|
||||
else if c == '\t' { sb_putc(b, '\\'); sb_putc(b, 't'); i += 1 }
|
||||
else if c == '\n' { sb_putc(b, '\\'); sb_putc(b, 'n'); i += 1 }
|
||||
else if c == 12 { sb_putc(b, '\\'); sb_putc(b, 'f'); i += 1 }
|
||||
else if c == '\r' { sb_putc(b, '\\'); sb_putc(b, 'r'); i += 1 }
|
||||
else if c < ' ' {
|
||||
sb_putc(b, '\\'); sb_putc(b, 'u'); sb_putc(b, '0'); sb_putc(b, '0')
|
||||
sb_putc(b, hex_lower((c >> 4) & 15)); sb_putc(b, hex_lower(c & 15))
|
||||
i += 1
|
||||
}
|
||||
else if c < 128 { sb_putc(b, c); i += 1 }
|
||||
else if not ensure_ascii { sb_putc(b, c); i += 1 }
|
||||
else {
|
||||
var cp = 0
|
||||
var nb = 1
|
||||
if c < 224 { cp = c - 192; nb = 2 }
|
||||
else if c < 240 { cp = c - 224; nb = 3 }
|
||||
else { cp = c - 240; nb = 4 }
|
||||
var k = 1
|
||||
while k < nb and i + k < n { cp = cp * 64 + ((s[i + k] & 255) - 128); k += 1 }
|
||||
i += nb
|
||||
if cp <= 65535 { jemit_u(b, cp) }
|
||||
else {
|
||||
let c2 = cp - 65536
|
||||
jemit_u(b, 55296 + (c2 >> 10))
|
||||
jemit_u(b, 56320 + (c2 & 1023))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function json_indent(b: Sb, depth: int) -> void {
|
||||
var i = 0
|
||||
while i < depth { sb_putc(b, ' '); sb_putc(b, ' '); i += 1 }
|
||||
}
|
||||
# pretty=true reproduces json.dumps(indent=2); pretty=false the compact default
|
||||
# (item sep ", ", key sep ": "). ensure_ascii toggles \uXXXX for non-ASCII.
|
||||
function jwrite(b: Sb, v: JVal, pretty: bool, depth: int, ea: bool) -> void {
|
||||
if v.t == JV_NULL { sb_puts(b, "null"); return }
|
||||
if v.t == JV_BOOL {
|
||||
if v.b == 1 { sb_puts(b, "true") } else { sb_puts(b, "false") }
|
||||
return
|
||||
}
|
||||
if v.t == JV_NUM { sb_puts(b, string(v.num)); return }
|
||||
if v.t == JV_STR { sb_putc(b, '"'); json_escape_into(b, v.s, ea); sb_putc(b, '"'); return }
|
||||
if v.t == JV_ARR {
|
||||
let m = len(v.kids)
|
||||
if m == 0 { sb_puts(b, "[]"); return }
|
||||
sb_putc(b, '[')
|
||||
var i = 0
|
||||
while i < m {
|
||||
if pretty { sb_putc(b, '\n'); json_indent(b, depth + 1) }
|
||||
jwrite(b, v.kids[i], pretty, depth + 1, ea)
|
||||
if i < m - 1 { sb_putc(b, ','); if not pretty { sb_putc(b, ' ') } }
|
||||
i += 1
|
||||
}
|
||||
if pretty { sb_putc(b, '\n'); json_indent(b, depth) }
|
||||
sb_putc(b, ']')
|
||||
return
|
||||
}
|
||||
let m = len(v.keys)
|
||||
if m == 0 { sb_puts(b, "{}"); return }
|
||||
sb_putc(b, '{')
|
||||
var i = 0
|
||||
while i < m {
|
||||
if pretty { sb_putc(b, '\n'); json_indent(b, depth + 1) }
|
||||
sb_putc(b, '"'); json_escape_into(b, v.keys[i], ea); sb_putc(b, '"')
|
||||
sb_putc(b, ':'); sb_putc(b, ' ')
|
||||
jwrite(b, v.kids[i], pretty, depth + 1, ea)
|
||||
if i < m - 1 { sb_putc(b, ','); if not pretty { sb_putc(b, ' ') } }
|
||||
i += 1
|
||||
}
|
||||
if pretty { sb_putc(b, '\n'); json_indent(b, depth) }
|
||||
sb_putc(b, '}')
|
||||
}
|
||||
function json_dumps(v: JVal, pretty: bool, ea: bool) -> pointer {
|
||||
let b = sb_new()
|
||||
jwrite(b, v, pretty, 0, ea)
|
||||
return sb_str(b)
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# palette — the named-colour source of truth (ported from palette.py)
|
||||
# ============================================================================
|
||||
var pal_names: []pointer = new []pointer
|
||||
var pal_hexes: []pointer = new []pointer # 6-digit uppercase, no prefix
|
||||
var pal_groups: []pointer = new []pointer
|
||||
var pal_ready: int = 0
|
||||
|
||||
function pal_add(nm: pointer, hx: pointer, grp: pointer) -> void {
|
||||
push(pal_names, nm)
|
||||
push(pal_hexes, hx)
|
||||
push(pal_groups, grp)
|
||||
}
|
||||
|
||||
function palette_init() -> void {
|
||||
if pal_ready == 1 { return }
|
||||
pal_ready = 1
|
||||
pal_names = new []pointer
|
||||
pal_hexes = new []pointer
|
||||
pal_groups = new []pointer
|
||||
pal_add("White", "FFFFFF", "Whites")
|
||||
pal_add("Snow", "FFFAFA", "Whites")
|
||||
pal_add("Ivory", "FFFFF0", "Whites")
|
||||
pal_add("EggShellWhite", "F0EAD6", "Whites")
|
||||
pal_add("FloralWhite", "FFFAF0", "Whites")
|
||||
pal_add("SeaShell", "FFF5EE", "Whites")
|
||||
pal_add("Linen", "FAF0E6", "Whites")
|
||||
pal_add("AntiqueWhite", "FAEBD7", "Whites")
|
||||
pal_add("OldLace", "FDF5E6", "Whites")
|
||||
pal_add("Beige", "F5F5DC", "Whites")
|
||||
pal_add("Cream", "FFFDD0", "Whites")
|
||||
pal_add("Honeydew", "F0FFF0", "Whites")
|
||||
pal_add("MintCream", "F5FFFA", "Whites")
|
||||
pal_add("Azure", "F0FFFF", "Whites")
|
||||
pal_add("AliceBlue", "F0F8FF", "Whites")
|
||||
pal_add("GhostWhite", "F8F8FF", "Whites")
|
||||
pal_add("WhiteSmoke", "F5F5F5", "Whites")
|
||||
pal_add("Lavender", "E6E6FA", "Whites")
|
||||
pal_add("Bone", "E3DAC9", "Whites")
|
||||
pal_add("Parchment", "F1E9D2", "Whites")
|
||||
pal_add("Gainsboro", "DCDCDC", "Grays")
|
||||
pal_add("LightGray", "D3D3D3", "Grays")
|
||||
pal_add("Silver", "C0C0C0", "Grays")
|
||||
pal_add("Ash", "B2BEB5", "Grays")
|
||||
pal_add("DarkGray", "A9A9A9", "Grays")
|
||||
pal_add("Gray", "808080", "Grays")
|
||||
pal_add("DimGray", "696969", "Grays")
|
||||
pal_add("Nickel", "727472", "Grays")
|
||||
pal_add("Slate", "708090", "Grays")
|
||||
pal_add("SlateGray", "708090", "Grays")
|
||||
pal_add("LightSlateGray", "778899", "Grays")
|
||||
pal_add("Gunmetal", "2A3439", "Grays")
|
||||
pal_add("Charcoal", "36454F", "Grays")
|
||||
pal_add("Graphite", "1C1C1C", "Grays")
|
||||
pal_add("Onyx", "353839", "Grays")
|
||||
pal_add("Jet", "343434", "Grays")
|
||||
pal_add("Black", "000000", "Grays")
|
||||
pal_add("EerieBlack", "1B1B1B", "Grays")
|
||||
pal_add("RaisinBlack", "242124", "Grays")
|
||||
pal_add("Ebony", "555D50", "Grays")
|
||||
pal_add("Red", "FF0000", "Reds")
|
||||
pal_add("Crimson", "DC143C", "Reds")
|
||||
pal_add("Scarlet", "FF2400", "Reds")
|
||||
pal_add("Vermilion", "E34234", "Reds")
|
||||
pal_add("FireBrick", "B22222", "Reds")
|
||||
pal_add("Cinnabar", "E44D2E", "Reds")
|
||||
pal_add("DarkRed", "8B0000", "Reds")
|
||||
pal_add("Maroon", "800000", "Reds")
|
||||
pal_add("Ruby", "E0115F", "Reds")
|
||||
pal_add("Cardinal", "C41E3A", "Reds")
|
||||
pal_add("IndianRed", "CD5C5C", "Reds")
|
||||
pal_add("Rust", "B7410E", "Reds")
|
||||
pal_add("Sangria", "92000A", "Reds")
|
||||
pal_add("Redwood", "A45A52", "Reds")
|
||||
pal_add("Cerise", "DE3163", "Reds")
|
||||
pal_add("Amaranth", "E52B50", "Reds")
|
||||
pal_add("Carmine", "960018", "Reds")
|
||||
pal_add("Chestnut", "954535", "Reds")
|
||||
pal_add("Brick", "CB4154", "Reds")
|
||||
pal_add("TerraCotta", "E2725B", "Reds")
|
||||
pal_add("Pink", "FFC0CB", "Pinks")
|
||||
pal_add("LightPink", "FFB6C1", "Pinks")
|
||||
pal_add("HotPink", "FF69B4", "Pinks")
|
||||
pal_add("DeepPink", "FF1493", "Pinks")
|
||||
pal_add("PaleVioletRed", "DB7093", "Pinks")
|
||||
pal_add("Rose", "FF007F", "Pinks")
|
||||
pal_add("Blush", "DE5D83", "Pinks")
|
||||
pal_add("Salmon", "FA8072", "Pinks")
|
||||
pal_add("LightSalmon", "FFA07A", "Pinks")
|
||||
pal_add("DarkSalmon", "E9967A", "Pinks")
|
||||
pal_add("Coral", "FF7F50", "Pinks")
|
||||
pal_add("Watermelon", "FC6C85", "Pinks")
|
||||
pal_add("Flamingo", "FC8EAC", "Pinks")
|
||||
pal_add("Bubblegum", "FFC1CC", "Pinks")
|
||||
pal_add("Fuchsia", "FF00FF", "Pinks")
|
||||
pal_add("Magenta", "FF00FF", "Pinks")
|
||||
pal_add("Mauve", "E0B0FF", "Pinks")
|
||||
pal_add("Puce", "CC8899", "Pinks")
|
||||
pal_add("Thistle", "D8BFD8", "Pinks")
|
||||
pal_add("Orchid", "DA70D6", "Pinks")
|
||||
pal_add("Orange", "FFA500", "Oranges")
|
||||
pal_add("DarkOrange", "FF8C00", "Oranges")
|
||||
pal_add("Tangerine", "F28500", "Oranges")
|
||||
pal_add("Pumpkin", "FF7518", "Oranges")
|
||||
pal_add("Apricot", "FBCEB1", "Oranges")
|
||||
pal_add("Peach", "FFE5B4", "Oranges")
|
||||
pal_add("Cantaloupe", "FFA62B", "Oranges")
|
||||
pal_add("Amber", "FFBF00", "Oranges")
|
||||
pal_add("Bronze", "CD7F32", "Oranges")
|
||||
pal_add("Copper", "B87333", "Oranges")
|
||||
pal_add("Marigold", "EAA221", "Oranges")
|
||||
pal_add("Carrot", "ED9121", "Oranges")
|
||||
pal_add("Persimmon", "EC5800", "Oranges")
|
||||
pal_add("Papaya", "FF9E2C", "Oranges")
|
||||
pal_add("Sunset", "FAD6A5", "Oranges")
|
||||
pal_add("Yellow", "FFFF00", "Yellows")
|
||||
pal_add("LightYellow", "FFFFE0", "Yellows")
|
||||
pal_add("Gold", "FFD700", "Yellows")
|
||||
pal_add("Goldenrod", "DAA520", "Yellows")
|
||||
pal_add("Lemon", "FFF700", "Yellows")
|
||||
pal_add("Canary", "FFEF00", "Yellows")
|
||||
pal_add("Mustard", "FFDB58", "Yellows")
|
||||
pal_add("Flax", "EEDC82", "Yellows")
|
||||
pal_add("Wheat", "F5DEB3", "Yellows")
|
||||
pal_add("Corn", "FBEC5D", "Yellows")
|
||||
pal_add("Dandelion", "F0E130", "Yellows")
|
||||
pal_add("Saffron", "F4C430", "Yellows")
|
||||
pal_add("Khaki", "F0E68C", "Yellows")
|
||||
pal_add("DarkKhaki", "BDB76B", "Yellows")
|
||||
pal_add("Straw", "E4D96F", "Yellows")
|
||||
pal_add("Brown", "8B4513", "Browns")
|
||||
pal_add("SaddleBrown", "8B4513", "Browns")
|
||||
pal_add("Sienna", "A0522D", "Browns")
|
||||
pal_add("Chocolate", "D2691E", "Browns")
|
||||
pal_add("Peru", "CD853F", "Browns")
|
||||
pal_add("Tan", "D2B48C", "Browns")
|
||||
pal_add("BurlyWood", "DEB887", "Browns")
|
||||
pal_add("Sand", "C2B280", "Browns")
|
||||
pal_add("Coffee", "6F4E37", "Browns")
|
||||
pal_add("Espresso", "4B3621", "Browns")
|
||||
pal_add("Mahogany", "C04000", "Browns")
|
||||
pal_add("Walnut", "773F1A", "Browns")
|
||||
pal_add("Umber", "635147", "Browns")
|
||||
pal_add("Sepia", "704214", "Browns")
|
||||
pal_add("Taupe", "483C32", "Browns")
|
||||
pal_add("Fawn", "E5AA70", "Browns")
|
||||
pal_add("Caramel", "C68E17", "Browns")
|
||||
pal_add("Cocoa", "D2691E", "Browns")
|
||||
pal_add("Hazel", "8E7618", "Browns")
|
||||
pal_add("Wenge", "645452", "Browns")
|
||||
pal_add("Green", "008000", "Greens")
|
||||
pal_add("Lime", "00FF00", "Greens")
|
||||
pal_add("LimeGreen", "32CD32", "Greens")
|
||||
pal_add("LawnGreen", "7CFC00", "Greens")
|
||||
pal_add("Chartreuse", "7FFF00", "Greens")
|
||||
pal_add("GreenYellow", "ADFF2F", "Greens")
|
||||
pal_add("SpringGreen", "00FF7F", "Greens")
|
||||
pal_add("MintGreen", "98FF98", "Greens")
|
||||
pal_add("SeaGreen", "2E8B57", "Greens")
|
||||
pal_add("MediumSeaGreen", "3CB371", "Greens")
|
||||
pal_add("ForestGreen", "228B22", "Greens")
|
||||
pal_add("DarkGreen", "006400", "Greens")
|
||||
pal_add("OliveDrab", "6B8E23", "Greens")
|
||||
pal_add("Olive", "808000", "Greens")
|
||||
pal_add("Moss", "8A9A5B", "Greens")
|
||||
pal_add("Fern", "4F7942", "Greens")
|
||||
pal_add("Emerald", "50C878", "Greens")
|
||||
pal_add("Jade", "00A86B", "Greens")
|
||||
pal_add("Malachite", "0BDA51", "Greens")
|
||||
pal_add("Shamrock", "009E60", "Greens")
|
||||
pal_add("Pistachio", "93C572", "Greens")
|
||||
pal_add("Avocado", "568203", "Greens")
|
||||
pal_add("Pine", "01796F", "Greens")
|
||||
pal_add("Sage", "9CAF88", "Greens")
|
||||
pal_add("Kelly", "4CBB17", "Greens")
|
||||
pal_add("Hunter", "355E3B", "Greens")
|
||||
pal_add("Basil", "579229", "Greens")
|
||||
pal_add("Clover", "2E8B57", "Greens")
|
||||
pal_add("Juniper", "6D9A79", "Greens")
|
||||
pal_add("Neon", "39FF14", "Greens")
|
||||
pal_add("Cyan", "00FFFF", "Cyans")
|
||||
pal_add("Aqua", "00FFFF", "Cyans")
|
||||
pal_add("LightCyan", "E0FFFF", "Cyans")
|
||||
pal_add("PaleTurquoise", "AFEEEE", "Cyans")
|
||||
pal_add("Aquamarine", "7FFFD4", "Cyans")
|
||||
pal_add("Turquoise", "40E0D0", "Cyans")
|
||||
pal_add("MediumTurquoise", "48D1CC", "Cyans")
|
||||
pal_add("DarkTurquoise", "00CED1", "Cyans")
|
||||
pal_add("Teal", "008080", "Cyans")
|
||||
pal_add("DarkCyan", "008B8B", "Cyans")
|
||||
pal_add("CadetBlue", "5F9EA0", "Cyans")
|
||||
pal_add("Lagoon", "018E8E", "Cyans")
|
||||
pal_add("Seafoam", "93E9BE", "Cyans")
|
||||
pal_add("Cerulean", "007BA7", "Cyans")
|
||||
pal_add("SkyBlueLight", "80DAEB", "Cyans")
|
||||
pal_add("Robin", "00CCCC", "Cyans")
|
||||
pal_add("Verdigris", "43B3AE", "Cyans")
|
||||
pal_add("Celadon", "ACE1AF", "Cyans")
|
||||
pal_add("Blue", "0000FF", "Blues")
|
||||
pal_add("LightBlue", "ADD8E6", "Blues")
|
||||
pal_add("PowderBlue", "B0E0E6", "Blues")
|
||||
pal_add("SkyBlue", "87CEEB", "Blues")
|
||||
pal_add("LightSkyBlue", "87CEFA", "Blues")
|
||||
pal_add("DeepSkyBlue", "00BFFF", "Blues")
|
||||
pal_add("DodgerBlue", "1E90FF", "Blues")
|
||||
pal_add("CornflowerBlue", "6495ED", "Blues")
|
||||
pal_add("SteelBlue", "4682B4", "Blues")
|
||||
pal_add("RoyalBlue", "4169E1", "Blues")
|
||||
pal_add("MediumBlue", "0000CD", "Blues")
|
||||
pal_add("DarkBlue", "00008B", "Blues")
|
||||
pal_add("Navy", "000080", "Blues")
|
||||
pal_add("MidnightBlue", "191970", "Blues")
|
||||
pal_add("Cobalt", "0047AB", "Blues")
|
||||
pal_add("Sapphire", "0F52BA", "Blues")
|
||||
pal_add("Denim", "1560BD", "Blues")
|
||||
pal_add("Indigo", "4B0082", "Blues")
|
||||
pal_add("Prussian", "003153", "Blues")
|
||||
pal_add("Ultramarine", "3F00FF", "Blues")
|
||||
pal_add("Periwinkle", "CCCCFF", "Blues")
|
||||
pal_add("Iris", "5A4FCF", "Blues")
|
||||
pal_add("Glaucous", "6082B6", "Blues")
|
||||
pal_add("Zaffre", "0014A8", "Blues")
|
||||
pal_add("Berry", "2E2D88", "Blues")
|
||||
pal_add("Purple", "800080", "Purples")
|
||||
pal_add("Violet", "EE82EE", "Purples")
|
||||
pal_add("DarkViolet", "9400D3", "Purples")
|
||||
pal_add("BlueViolet", "8A2BE2", "Purples")
|
||||
pal_add("MediumPurple", "9370DB", "Purples")
|
||||
pal_add("Amethyst", "9966CC", "Purples")
|
||||
pal_add("Plum", "8E4585", "Purples")
|
||||
pal_add("Eggplant", "614051", "Purples")
|
||||
pal_add("Grape", "6F2DA8", "Purples")
|
||||
pal_add("Wine", "722F37", "Purples")
|
||||
pal_add("Mulberry", "C54B8C", "Purples")
|
||||
pal_add("Lilac", "C8A2C8", "Purples")
|
||||
pal_add("Wisteria", "C9A0DC", "Purples")
|
||||
pal_add("Heliotrope", "DF73FF", "Purples")
|
||||
pal_add("Byzantium", "702963", "Purples")
|
||||
pal_add("Tyrian", "66023C", "Purples")
|
||||
pal_add("RebeccaPurple", "663399", "Purples")
|
||||
pal_add("Orchid2", "AF69EF", "Purples")
|
||||
}
|
||||
|
||||
# emit selfhost/backend/stdlib/emit_color.ludic
|
||||
function palette_emit_ludic(path: pointer) -> bool {
|
||||
let b = sb_new()
|
||||
sb_puts(b, "# ============================================================================\n")
|
||||
sb_puts(b, "# emit_color.ludic — the named-color palette, resolved at compile time.\n")
|
||||
sb_puts(b, "#\n")
|
||||
sb_puts(b, "# `Color.Name` in a game lowers to a plain 0xRRGGBB int here: no runtime cost,\n")
|
||||
sb_puts(b, "# no allocation, identical codegen to writing the hex by hand. Unknown names are\n")
|
||||
sb_puts(b, "# a compile error (color_lookup returns -1, which emit_expr reports).\n")
|
||||
sb_puts(b, "#\n")
|
||||
sb_puts(b, "# GENERATED by `ludic dev docs-palette` from the single source-of-truth palette table\n")
|
||||
sb_puts(b, "# in tools/ludic-cli/docgen.ludic. Edit the palette there and regenerate; do not hand-edit.\n")
|
||||
sb_puts(b, "# ============================================================================\n")
|
||||
sb_puts(b, "\n")
|
||||
sb_puts(b, "function color_lookup(name: pointer) -> int {\n")
|
||||
var i = 0
|
||||
while i < len(pal_names) {
|
||||
sb_puts(b, ` if (name == "{pal_names[i]}") {{ return 0x{pal_hexes[i]} }}\n`)
|
||||
i += 1
|
||||
}
|
||||
sb_puts(b, " return -1\n")
|
||||
sb_puts(b, "}\n")
|
||||
return write_file(path, sb_str(b))
|
||||
}
|
||||
|
||||
# emit docs/language/colors/palette.json (json.dump indent=2, no trailing nl)
|
||||
function palette_emit_json(path: pointer) -> bool {
|
||||
let b = sb_new()
|
||||
sb_puts(b, "{\n")
|
||||
sb_puts(b, ` "count": {string(len(pal_names))},\n`)
|
||||
sb_puts(b, ` "groups": [` + "\n")
|
||||
# groups in first-appearance order
|
||||
let seen = new []pointer
|
||||
var gi = 0
|
||||
var firstg = true
|
||||
while gi < len(pal_groups) {
|
||||
let g = pal_groups[gi]
|
||||
if not set_has(seen, g) {
|
||||
push(seen, g)
|
||||
if not firstg { sb_puts(b, ",\n") }
|
||||
firstg = false
|
||||
sb_puts(b, " {\n")
|
||||
sb_puts(b, ` "name": "{g}",\n`)
|
||||
sb_puts(b, ` "colors": [` + "\n")
|
||||
var ci = 0
|
||||
var firstc = true
|
||||
while ci < len(pal_names) {
|
||||
if pal_groups[ci] == g {
|
||||
if not firstc { sb_puts(b, ",\n") }
|
||||
firstc = false
|
||||
sb_puts(b, " {\n")
|
||||
sb_puts(b, ` "name": "{pal_names[ci]}",\n`)
|
||||
sb_puts(b, ` "hex": "{pal_hexes[ci]}"\n`)
|
||||
sb_puts(b, " }")
|
||||
}
|
||||
ci += 1
|
||||
}
|
||||
sb_puts(b, "\n ]\n")
|
||||
sb_puts(b, " }")
|
||||
}
|
||||
gi += 1
|
||||
}
|
||||
sb_puts(b, "\n ]\n")
|
||||
sb_puts(b, "}")
|
||||
return write_file(path, sb_str(b))
|
||||
}
|
||||
|
||||
# `ludic dev docs-palette` rewrites the two tracked outputs; `ludic dev docs-palette --check`
|
||||
# regenerates into scratch files and compares them to the tracked ones instead,
|
||||
# so the drift guard judges the working tree, not git HEAD.
|
||||
function cmd_docs_palette() -> int {
|
||||
let check = arg_total() > 2 and arg_n(2) == "--check"
|
||||
palette_init()
|
||||
# guard: duplicate names
|
||||
let seen = new []pointer
|
||||
var i = 0
|
||||
while i < len(pal_names) {
|
||||
if set_has(seen, pal_names[i]) { err(`DUPLICATE NAME: {pal_names[i]}\n`); return 1 }
|
||||
push(seen, pal_names[i])
|
||||
i += 1
|
||||
}
|
||||
var ludic_out = "selfhost/backend/stdlib/emit_color.ludic"
|
||||
var json_out = "docs/language/colors/palette.json"
|
||||
if check { ludic_out = tmp_path("pal_emit_color.ludic"); json_out = tmp_path("pal_palette.json") }
|
||||
if not palette_emit_ludic(ludic_out) { err("cannot write emit_color.ludic\n"); return 1 }
|
||||
if not palette_emit_json(json_out) { err("cannot write palette.json\n"); return 1 }
|
||||
if check {
|
||||
if not shq(`cmp -s {ludic_out} selfhost/backend/stdlib/emit_color.ludic`) { err("emit_color.ludic drifted from the palette table (run: ludic dev docs-palette)\n"); return 1 }
|
||||
if not shq(`cmp -s {json_out} docs/language/colors/palette.json`) { err("palette.json drifted from the palette table (run: ludic dev docs-palette)\n"); return 1 }
|
||||
}
|
||||
print(`OK {string(len(pal_names))} colors`)
|
||||
return 0
|
||||
}
|
||||
291
tools/ludic-cli/docgen_check.ludic
Normal file
291
tools/ludic-cli/docgen_check.ludic
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
# docgen_check.ludic — `ludic dev docs-check [DIR]`: the coverage / integrity guard over
|
||||
# a generated docs site, ported from tools/docgen/check.py. Fails (exit 1) if the
|
||||
# pages contract is broken, an inventory symbol lacks a source file or page, a
|
||||
# token is documented on two pages, a namespace/section is split across dirs, or
|
||||
# a highlighter link target was not generated. Thin (un-expanded) symbols warn.
|
||||
|
||||
# a key -> set-of-values bucket (for token/dir conflict detection)
|
||||
property Bucket { k1: pointer = "", k2: pointer = "", vals: []pointer }
|
||||
|
||||
function bucket_get(bs: []Bucket, k1: pointer, k2: pointer) -> Bucket {
|
||||
var i = 0
|
||||
while i < len(bs) {
|
||||
if bs[i].k1 == k1 and bs[i].k2 == k2 { return bs[i] }
|
||||
i += 1
|
||||
}
|
||||
let b = new Bucket
|
||||
b.k1 = k1
|
||||
b.k2 = k2
|
||||
b.vals = new []pointer
|
||||
push(bs, b)
|
||||
return b
|
||||
}
|
||||
function bucket_add(bs: []Bucket, k1: pointer, k2: pointer, v: pointer) -> void {
|
||||
let b = bucket_get(bs, k1, k2)
|
||||
var i = 0
|
||||
while i < len(b.vals) { if b.vals[i] == v { return }; i += 1 }
|
||||
push(b.vals, v)
|
||||
}
|
||||
# ", ".join(sorted(vals))
|
||||
function join_sorted(vals: []pointer) -> pointer {
|
||||
strs_sort(vals)
|
||||
let b = sb_new()
|
||||
var i = 0
|
||||
while i < len(vals) {
|
||||
if i > 0 { sb_puts(b, ", ") }
|
||||
sb_puts(b, vals[i])
|
||||
i += 1
|
||||
}
|
||||
return sb_str(b)
|
||||
}
|
||||
|
||||
# parse_front: like parse_doc but body is stripped on both ends (check.py)
|
||||
function parse_front(path: pointer) -> Doc {
|
||||
let d = new Doc
|
||||
d.m = meta_new()
|
||||
let text = read_file(path)
|
||||
if text == null { d.body = ""; return d }
|
||||
d.body = text
|
||||
if not s_starts(text, "---") { return d }
|
||||
let end = s_index(text, "\n---", 3)
|
||||
if end < 0 { return d }
|
||||
let fm = sslice(text, 3, end)
|
||||
let fn2 = slen(fm)
|
||||
var j = 0
|
||||
while j < fn2 {
|
||||
let ln = line_at(fm, j)
|
||||
j = j + slen(ln) + 1
|
||||
let t = s_trim(ln)
|
||||
if slen(t) == 0 { continue }
|
||||
let c = s_index(ln, ":", 0)
|
||||
if c < 0 { continue }
|
||||
meta_put(d.m, s_trim(sslice(ln, 0, c)), s_trim(sslice(ln, c + 1, slen(ln))))
|
||||
}
|
||||
d.body = s_trim(sslice(text, end + 4, slen(text)))
|
||||
return d
|
||||
}
|
||||
|
||||
# count Unicode codepoints (a byte is a lead unless (b & 0xC0) == 0x80)
|
||||
function cp_len(s: pointer) -> int {
|
||||
let n = slen(s)
|
||||
var c = 0
|
||||
var i = 0
|
||||
while i < n { if (s[i] & 192) != 128 { c += 1 }; i += 1 }
|
||||
return c
|
||||
}
|
||||
function replace_all(hay: pointer, needle: pointer, repl: pointer) -> pointer {
|
||||
if slen(needle) == 0 { return hay }
|
||||
let b = sb_new()
|
||||
let nl = slen(needle)
|
||||
let n = slen(hay)
|
||||
var i = 0
|
||||
while i < n {
|
||||
if s_starts_at(hay, i, needle) { sb_puts(b, repl); i += nl }
|
||||
else { sb_putc(b, hay[i]); i += 1 }
|
||||
}
|
||||
return sb_str(b)
|
||||
}
|
||||
# strip a filename's trailing ".md" -> the fallback id (fn[:-3])
|
||||
function drop_md(fn: pointer) -> pointer {
|
||||
let n = slen(fn)
|
||||
if n >= 3 { return sslice(fn, 0, n - 3) }
|
||||
return fn
|
||||
}
|
||||
|
||||
function cmd_docs_check() -> int {
|
||||
var site = "build/pages"
|
||||
if arg_total() >= 3 { site = arg_n(2) }
|
||||
let problems = new []pointer
|
||||
let warnings = new []pointer
|
||||
|
||||
# 1) pages contract
|
||||
if not file_exists(`{site}/index.html`) { push(problems, "missing required file: index.html") }
|
||||
if not file_exists(`{site}/api.html`) { push(problems, "missing required file: api.html") }
|
||||
if not file_exists(`{site}/.nojekyll`) { push(problems, "missing required file: .nojekyll") }
|
||||
# the landing page tells people to curl this; a site without it publishes a
|
||||
# broken install command
|
||||
if not file_exists(`{site}/install.sh`) { push(problems, "missing required file: install.sh") }
|
||||
|
||||
# 2) coverage + thin detection over docs/language/**
|
||||
let id2src = meta_new()
|
||||
let thin = new []pointer
|
||||
let cats = list_sorted("docs/language")
|
||||
var ci = 0
|
||||
while ci < len(cats) {
|
||||
let cat = cats[ci]
|
||||
ci += 1
|
||||
let cdir = "docs/language/" + cat
|
||||
if not is_dir(cdir) { continue }
|
||||
let files = list_sorted(cdir)
|
||||
var fi = 0
|
||||
while fi < len(files) {
|
||||
let fn = files[fi]
|
||||
fi += 1
|
||||
if not s_ends(fn, ".md") { continue }
|
||||
if fn == "_section.md" { continue }
|
||||
let d = parse_front(cdir + "/" + fn)
|
||||
let sid = meta_get(d.m, "id", drop_md(fn))
|
||||
meta_put(id2src, sid, cdir + "/" + fn)
|
||||
# thin: drop a Parameters: block (to end, case-insensitive) + the tip line
|
||||
let tipline = meta_get(d.m, "tip", "")
|
||||
let low = lower_ascii(d.body)
|
||||
var btext = d.body
|
||||
let pp = s_index(low, "parameters:", 0)
|
||||
if pp >= 0 { btext = sslice(d.body, 0, pp) }
|
||||
btext = s_trim(btext)
|
||||
if slen(tipline) > 0 { btext = s_trim(replace_all(btext, tipline, "")) }
|
||||
if cp_len(btext) < 40 { push(thin, sid) }
|
||||
}
|
||||
}
|
||||
# coverage against the authoritative inventory
|
||||
let invtext = read_file("tools/docgen/inventory.json")
|
||||
if invtext != null {
|
||||
let inv = json_parse(invtext)
|
||||
if inv.t == JV_OBJ {
|
||||
var k = 0
|
||||
while k < len(inv.keys) {
|
||||
let cat = inv.keys[k]
|
||||
let ids = inv.kids[k]
|
||||
k += 1
|
||||
if ids.t != JV_ARR { continue }
|
||||
var m = 0
|
||||
while m < len(ids.kids) {
|
||||
let sid = ids.kids[m].s
|
||||
m += 1
|
||||
if meta_get(id2src, sid, "") == "" { push(problems, `no source file for inventory symbol: {sid} ({cat})`) }
|
||||
else if not file_exists(`{site}/{sid}.html`) { push(problems, `no generated page for symbol: {sid}.html`) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 2b/2c) duplicate tokens + one-dir-per-namespace/section
|
||||
let tok2ids = new []Bucket # k1=kind, k2=tok -> ids
|
||||
let ns2dirs = new []Bucket # k1="", k2=ns -> dirs
|
||||
let id2dirs = new []Bucket # k1="", k2=section id -> dirs
|
||||
let title2dirs = new []Bucket # k1="", k2=lower(title) -> dirs
|
||||
ci = 0
|
||||
while ci < len(cats) {
|
||||
let cat = cats[ci]
|
||||
ci += 1
|
||||
let cdir = "docs/language/" + cat
|
||||
if not is_dir(cdir) { continue }
|
||||
let secp = cdir + "/_section.md"
|
||||
if file_exists(secp) {
|
||||
let sd = parse_front(secp)
|
||||
bucket_add(id2dirs, "", meta_get(sd.m, "id", cat), cat)
|
||||
bucket_add(title2dirs, "", lower_ascii(s_trim(meta_get(sd.m, "title", ""))), cat)
|
||||
}
|
||||
let files = list_sorted(cdir)
|
||||
var fi = 0
|
||||
while fi < len(files) {
|
||||
let fn = files[fi]
|
||||
fi += 1
|
||||
if not s_ends(fn, ".md") { continue }
|
||||
if fn == "_section.md" { continue }
|
||||
let d = parse_front(cdir + "/" + fn)
|
||||
let kind = meta_get(d.m, "kind", "")
|
||||
let id = meta_get(d.m, "id", "")
|
||||
let toks = split_ws(meta_get(d.m, "tokens", ""))
|
||||
var ti = 0
|
||||
while ti < len(toks) { bucket_add(tok2ids, kind, toks[ti], id); ti += 1 }
|
||||
let ns = meta_get(d.m, "ns", "")
|
||||
if slen(ns) > 0 { bucket_add(ns2dirs, "", ns, cat) }
|
||||
}
|
||||
}
|
||||
var bi = 0
|
||||
while bi < len(tok2ids) {
|
||||
let b = tok2ids[bi]
|
||||
bi += 1
|
||||
if len(b.vals) > 1 { push(problems, `token '{b.k2}' ({b.k1}) documented on multiple pages: {join_sorted(b.vals)}`) }
|
||||
}
|
||||
bi = 0
|
||||
while bi < len(ns2dirs) {
|
||||
let b = ns2dirs[bi]
|
||||
bi += 1
|
||||
if len(b.vals) > 1 { push(problems, `namespace '{b.k2}' documented from multiple dirs: {join_sorted(b.vals)}`) }
|
||||
}
|
||||
bi = 0
|
||||
while bi < len(id2dirs) {
|
||||
let b = id2dirs[bi]
|
||||
bi += 1
|
||||
if len(b.vals) > 1 { push(problems, `section id '{b.k2}' declared by multiple dirs: {join_sorted(b.vals)}`) }
|
||||
}
|
||||
bi = 0
|
||||
while bi < len(title2dirs) {
|
||||
let b = title2dirs[bi]
|
||||
bi += 1
|
||||
if slen(b.k2) > 0 and len(b.vals) > 1 { push(problems, `section title '{b.k2}' shared by multiple dirs: {join_sorted(b.vals)}`) }
|
||||
}
|
||||
|
||||
# 3) thin warning
|
||||
if len(thin) > 0 {
|
||||
strs_sort(thin)
|
||||
let b = sb_new()
|
||||
var i = 0
|
||||
while i < len(thin) and i < 12 {
|
||||
if i > 0 { sb_puts(b, ", ") }
|
||||
sb_puts(b, thin[i])
|
||||
i += 1
|
||||
}
|
||||
var tail = ""
|
||||
if len(thin) > 12 { tail = " …" }
|
||||
push(warnings, `{string(len(thin))} symbols still have only seed text: {sb_str(b)}{tail}`)
|
||||
}
|
||||
|
||||
# 4) highlighter targets exist
|
||||
let sjtext = read_file(`{site}/symbols.json`)
|
||||
if sjtext != null {
|
||||
let sj = json_parse(sjtext)
|
||||
let hl = j_get(sj, "highlight")
|
||||
let targets = new []pointer
|
||||
collect_targets_str(j_get(hl, "keywords"), targets)
|
||||
collect_targets_str(j_get(hl, "types"), targets)
|
||||
collect_targets_str(j_get(hl, "phases"), targets)
|
||||
collect_targets_str(j_get(hl, "annotations"), targets)
|
||||
collect_targets_id(j_get(hl, "builtins"), targets)
|
||||
collect_targets_id(j_get(hl, "nsmethods"), targets)
|
||||
collect_targets_str(j_get(hl, "namespaces"), targets)
|
||||
strs_sort(targets)
|
||||
var i = 0
|
||||
while i < len(targets) {
|
||||
let tgt = targets[i]
|
||||
i += 1
|
||||
if not file_exists(`{site}/{tgt}.html`) { push(problems, `highlighter links to {tgt}.html but it was not generated`) }
|
||||
}
|
||||
}
|
||||
|
||||
# report
|
||||
var wi = 0
|
||||
while wi < len(warnings) { print(` warning: {warnings[wi]}`); wi += 1 }
|
||||
if len(problems) > 0 {
|
||||
print("docs check FAILED:")
|
||||
var pi = 0
|
||||
while pi < len(problems) { print(` - {problems[pi]}`); pi += 1 }
|
||||
return 1
|
||||
}
|
||||
print(`docs check OK: {site} ({string(len(id2src.keys))} symbols documented)`)
|
||||
return 0
|
||||
}
|
||||
|
||||
# add every string value of an object's members (set semantics via the caller)
|
||||
function collect_targets_str(o: JVal, out: []pointer) -> void {
|
||||
if o.t != JV_OBJ { return }
|
||||
var i = 0
|
||||
while i < len(o.kids) {
|
||||
let v = o.kids[i]
|
||||
i += 1
|
||||
if v.t == JV_STR { set_add(out, v.s) }
|
||||
}
|
||||
}
|
||||
# add each member value's ["id"] (builtins / nsmethods)
|
||||
function collect_targets_id(o: JVal, out: []pointer) -> void {
|
||||
if o.t != JV_OBJ { return }
|
||||
var i = 0
|
||||
while i < len(o.kids) {
|
||||
let v = o.kids[i]
|
||||
i += 1
|
||||
let id = j_get(v, "id")
|
||||
if id.t == JV_STR { set_add(out, id.s) }
|
||||
}
|
||||
}
|
||||
1025
tools/ludic-cli/docgen_gen.ludic
Normal file
1025
tools/ludic-cli/docgen_gen.ludic
Normal file
File diff suppressed because it is too large
Load diff
93
tools/ludic-cli/forgejo.ludic
Normal file
93
tools/ludic-cli/forgejo.ludic
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# forgejo.ludic — publish a release on the Forgejo instance: create the release
|
||||
# for a tag (or reuse it), then attach every file from an assets directory.
|
||||
# Replaces tools/ci/forgejo_release.py: the one piece of the release flow that
|
||||
# speaks HTTP now does so through curl, driven from Ludic like every other
|
||||
# external tool the runner uses.
|
||||
#
|
||||
# The API root comes from $LUDIC_FORGEJO_API (default: this project's repo);
|
||||
# the token from $FORGEJO_TOKEN, passed to curl through a 0600 header file so
|
||||
# it never appears on a command line.
|
||||
#
|
||||
# Idempotent-ish: an existing release for the tag is reused, and an asset whose
|
||||
# name is already attached is skipped, not fatal.
|
||||
|
||||
const FORGEJO_API_DEFAULT: string = "https://git.workshopsoft.io/api/v1/repos/workshopsoft/ludic"
|
||||
|
||||
function forgejo_api() -> pointer { return getenv_or("LUDIC_FORGEJO_API", FORGEJO_API_DEFAULT) }
|
||||
|
||||
# the curl header file carrying the token (written once per process)
|
||||
var forgejo_hdr: pointer = null
|
||||
function forgejo_headers() -> pointer {
|
||||
if forgejo_hdr == null {
|
||||
forgejo_hdr = tmp_path("forgejo_headers.txt")
|
||||
run(`umask 077 && printf 'Authorization: token %s\n' "$FORGEJO_TOKEN" > {forgejo_hdr}`)
|
||||
}
|
||||
return forgejo_hdr
|
||||
}
|
||||
|
||||
# one API call: `extra` carries the method/body flags; returns the HTTP status
|
||||
# and leaves the response body in `out`
|
||||
function forgejo_call(path: pointer, extra: pointer, out: pointer) -> int {
|
||||
let code = capture_line(`curl -sS -o {out} -w '%{{http_code}}' -H @{forgejo_headers()} {extra} '{forgejo_api()}{path}'`)
|
||||
return str_to_int(code)
|
||||
}
|
||||
|
||||
# create-or-reuse the release for `tag`, with `body_file` as its notes; returns
|
||||
# the release JSON, or null after printing why
|
||||
function forgejo_release(tag: pointer, body_file: pointer) -> JVal {
|
||||
let out = tmp_path("forgejo_release.json")
|
||||
let code = forgejo_call(`/releases/tags/{tag}`, "", out)
|
||||
if code == 200 {
|
||||
let rel = json_parse(read_file(out))
|
||||
print(`release {tag} already exists (id {string(j_get(rel, "id").num)}); uploading assets`)
|
||||
return rel
|
||||
}
|
||||
if code != 404 { err(`forgejo: GET /releases/tags/{tag} -> HTTP {string(code)}\n`); return null }
|
||||
var body = ""
|
||||
if shq(`test -f {body_file}`) { body = read_file(body_file) }
|
||||
var prerelease = "false"
|
||||
if Text.starts_with(tag, "v0.") { prerelease = "true" }
|
||||
let req = tmp_path("forgejo_create.json")
|
||||
write_file(req, `{{"tag_name":{j_quote(tag)},"name":{j_quote(tag)},"body":{j_quote(body)},"draft":false,"prerelease":{prerelease}}}`)
|
||||
let ccode = forgejo_call("/releases", `-X POST -H 'Content-Type: application/json' --data-binary @{req}`, out)
|
||||
if ccode != 201 { err(`forgejo: POST /releases -> HTTP {string(ccode)}\n`); return null }
|
||||
let rel = json_parse(read_file(out))
|
||||
print(`created release {tag} id {string(j_get(rel, "id").num)}`)
|
||||
return rel
|
||||
}
|
||||
|
||||
# attach every regular file in `dir` that the release does not already carry
|
||||
function forgejo_upload_assets(rel: JVal, dir: pointer) -> bool {
|
||||
if not shq(`test -d {dir}`) { return true }
|
||||
let rid = j_get(rel, "id").num
|
||||
let existing = new []pointer
|
||||
let assets = j_get(rel, "assets")
|
||||
var i = 0
|
||||
while i < len(assets.kids) { push(existing, j_get(assets.kids[i], "name").s); i += 1 }
|
||||
let names = Text.split(capture(`ls -1 {dir}`), "\n")
|
||||
var ok_all = true
|
||||
i = 0
|
||||
while i < len(names) {
|
||||
let name = Text.trim(names[i])
|
||||
i += 1
|
||||
if name == "" { continue }
|
||||
if set_has(existing, name) { print(` skip (exists): {name}`); continue }
|
||||
let path = `{dir}/{name}`
|
||||
if not shq(`test -f {path}`) { continue }
|
||||
let out = tmp_path("forgejo_asset.json")
|
||||
let code = forgejo_call(`/releases/{string(rid)}/assets?name={name}`, `-X POST -F 'attachment=@{path}'`, out)
|
||||
if code == 201 { print(` uploaded: {name} (HTTP {string(code)})`) }
|
||||
else { err(` upload failed: {name} (HTTP {string(code)})\n`); ok_all = false }
|
||||
}
|
||||
return ok_all
|
||||
}
|
||||
|
||||
# the whole flow; 0 on success
|
||||
function forgejo_publish(tag: pointer, body_file: pointer, assets_dir: pointer) -> int {
|
||||
if getenv_or("FORGEJO_TOKEN", "") == "" { err("forgejo: FORGEJO_TOKEN is not set\n"); return 1 }
|
||||
let rel = forgejo_release(tag, body_file)
|
||||
if rel == null { return 1 }
|
||||
if not forgejo_upload_assets(rel, assets_dir) { return 1 }
|
||||
print(`done: {forgejo_api()}/releases/tag/{tag}`)
|
||||
return 0
|
||||
}
|
||||
200
tools/ludic-cli/json.ludic
Normal file
200
tools/ludic-cli/json.ludic
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
# json.ludic — a small JSON reader for the Ludic tooling (vocabulary check,
|
||||
# docs generator). Enough of JSON to read the editor grammar and the docs
|
||||
# config: objects, arrays, strings (with \uXXXX + surrogates), numbers, the
|
||||
# literals. Values are a JVal tree; object member order is preserved.
|
||||
|
||||
property JVal { t: int = 0, num: int = 0, b: int = 0, s: pointer = null, kids: []JVal, keys: []pointer }
|
||||
|
||||
const JV_NULL: int = 0
|
||||
const JV_BOOL: int = 1
|
||||
const JV_NUM: int = 2
|
||||
const JV_STR: int = 3
|
||||
const JV_ARR: int = 4
|
||||
const JV_OBJ: int = 5
|
||||
|
||||
function jval_new(t: int) -> JVal {
|
||||
let v = new JVal
|
||||
v.t = t
|
||||
v.s = ""
|
||||
v.kids = new []JVal
|
||||
v.keys = new []pointer
|
||||
return v
|
||||
}
|
||||
|
||||
var jbuf: pointer = ""
|
||||
var jpos: int = 0
|
||||
var jerr: bool = false
|
||||
|
||||
function j_ws() -> void {
|
||||
while jbuf[jpos] == ' ' or jbuf[jpos] == '\t' or jbuf[jpos] == '\n' or jbuf[jpos] == '\r' { jpos += 1 }
|
||||
}
|
||||
|
||||
function j_hex(c: int) -> int {
|
||||
if c >= '0' and c <= '9' { return c - 48 }
|
||||
if c >= 'a' and c <= 'f' { return c - 97 + 10 }
|
||||
if c >= 'A' and c <= 'F' { return c - 65 + 10 }
|
||||
return 0
|
||||
}
|
||||
function j_hex4() -> int {
|
||||
var v = 0; var i = 0
|
||||
while i < 4 { v = v * 16 + j_hex(jbuf[jpos]); jpos += 1; i += 1 }
|
||||
return v
|
||||
}
|
||||
|
||||
# append codepoint cp to buffer b at *n (UTF-8); returns new n
|
||||
function utf8_put(b: pointer, n: int, cp: int) -> int {
|
||||
if cp < 128 { b[n] = cp; return n + 1 }
|
||||
if cp < 2048 { b[n] = 192 + (cp >> 6); b[n + 1] = 128 + (cp & 63); return n + 2 }
|
||||
if cp < 65536 { b[n] = 224 + (cp >> 12); b[n + 1] = 128 + ((cp >> 6) & 63); b[n + 2] = 128 + (cp & 63); return n + 3 }
|
||||
b[n] = 240 + (cp >> 18); b[n + 1] = 128 + ((cp >> 12) & 63); b[n + 2] = 128 + ((cp >> 6) & 63); b[n + 3] = 128 + (cp & 63)
|
||||
return n + 4
|
||||
}
|
||||
|
||||
# parse a JSON string starting at the opening quote; returns a fresh buffer
|
||||
function j_string() -> pointer {
|
||||
jpos += 1 # opening quote
|
||||
# over-allocate: escapes only ever shrink the byte count
|
||||
let cap = slen(jbuf) - jpos + 1
|
||||
let out = bytes(cap + 4)
|
||||
var n = 0
|
||||
while jbuf[jpos] != 0 and jbuf[jpos] != '"' {
|
||||
if jbuf[jpos] == '\\' {
|
||||
jpos += 1
|
||||
let c = jbuf[jpos]; jpos += 1
|
||||
if c == 'n' { out[n] = '\n'; n += 1 }
|
||||
else { if c == 't' { out[n] = '\t'; n += 1 }
|
||||
else { if c == 'r' { out[n] = '\r'; n += 1 }
|
||||
else { if c == 'b' { out[n] = 8; n += 1 }
|
||||
else { if c == 'f' { out[n] = 12; n += 1 }
|
||||
else { if c == 'u' {
|
||||
var cp = j_hex4()
|
||||
if cp >= 55296 and cp < 56320 and jbuf[jpos] == '\\' and jbuf[jpos + 1] == 'u' {
|
||||
jpos += 2
|
||||
let lo = j_hex4()
|
||||
cp = 65536 + ((cp - 55296) << 10) + (lo - 56320)
|
||||
}
|
||||
n = utf8_put(out, n, cp)
|
||||
} else { out[n] = c; n += 1 } } } } } }
|
||||
} else { out[n] = jbuf[jpos]; n += 1; jpos += 1 }
|
||||
}
|
||||
if jbuf[jpos] == '"' { jpos += 1 }
|
||||
out[n] = 0
|
||||
return out
|
||||
}
|
||||
|
||||
function j_value() -> JVal {
|
||||
j_ws()
|
||||
let c = jbuf[jpos]
|
||||
if c == '{' { # '{'
|
||||
jpos += 1
|
||||
let v = jval_new(JV_OBJ)
|
||||
j_ws()
|
||||
if jbuf[jpos] == '}' { jpos += 1; return v }
|
||||
while true {
|
||||
j_ws()
|
||||
var key = ""
|
||||
if jbuf[jpos] == '"' { key = j_string() } else { jerr = true }
|
||||
j_ws(); if jbuf[jpos] == ':' { jpos += 1 } else { jerr = true }
|
||||
let kid = j_value()
|
||||
push(v.keys, key); push(v.kids, kid)
|
||||
j_ws()
|
||||
if jbuf[jpos] == ',' { jpos += 1 } else { if jbuf[jpos] == '}' { jpos += 1; return v } else { return v } }
|
||||
}
|
||||
return v
|
||||
}
|
||||
if c == '[' { # '['
|
||||
jpos += 1
|
||||
let v = jval_new(JV_ARR)
|
||||
j_ws()
|
||||
if jbuf[jpos] == ']' { jpos += 1; return v }
|
||||
while true {
|
||||
let kid = j_value()
|
||||
push(v.kids, kid)
|
||||
j_ws()
|
||||
if jbuf[jpos] == ',' { jpos += 1 } else { if jbuf[jpos] == ']' { jpos += 1; return v } else { return v } }
|
||||
}
|
||||
return v
|
||||
}
|
||||
if c == '"' { # '"'
|
||||
let v = jval_new(JV_STR)
|
||||
v.s = j_string()
|
||||
return v
|
||||
}
|
||||
if c == 't' { # true
|
||||
jpos += 4; let v = jval_new(JV_BOOL); v.b = 1; return v
|
||||
}
|
||||
if c == 'f' { # false
|
||||
jpos += 5; let v = jval_new(JV_BOOL); v.b = 0; return v
|
||||
}
|
||||
if c == 'n' { # null
|
||||
jpos += 4; return jval_new(JV_NULL)
|
||||
}
|
||||
# number
|
||||
let start = jpos
|
||||
if jbuf[jpos] == '-' { jpos += 1 }
|
||||
if jbuf[jpos] < '0' or jbuf[jpos] > '9' { jerr = true; if jbuf[jpos] != 0 { jpos += 1 }; return jval_new(JV_NULL) }
|
||||
while (jbuf[jpos] >= '0' and jbuf[jpos] <= '9') or jbuf[jpos] == '.' or jbuf[jpos] == 'e' or jbuf[jpos] == 'E' or jbuf[jpos] == '+' or jbuf[jpos] == '-' { jpos += 1 }
|
||||
let v = jval_new(JV_NUM)
|
||||
v.s = sslice(jbuf, start, jpos)
|
||||
v.num = str_to_int(v.s)
|
||||
return v
|
||||
}
|
||||
|
||||
# parse whole `text`; returns the root JVal (JV_NULL on empty)
|
||||
function json_parse(text: pointer) -> JVal {
|
||||
jbuf = text; jpos = 0; jerr = false
|
||||
return j_value()
|
||||
}
|
||||
|
||||
# is `text` well-formed JSON that consumes to the end?
|
||||
function json_valid(text: pointer) -> bool {
|
||||
let v = json_parse(text)
|
||||
j_ws()
|
||||
if jerr { return false }
|
||||
if jpos != slen(text) { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
# integer value of a decimal string (ignores any fractional part)
|
||||
function str_to_int(s: pointer) -> int {
|
||||
var i = 0; var neg = false
|
||||
if s[0] == '-' { neg = true; i = 1 }
|
||||
var v = 0
|
||||
while s[i] >= '0' and s[i] <= '9' { v = v * 10 + (s[i] - 48); i += 1 }
|
||||
if neg { return -v }
|
||||
return v
|
||||
}
|
||||
|
||||
# the lowercase hex digit for 0..15
|
||||
function j_hexch(v: int) -> int {
|
||||
if v < 10 { return '0' + v }
|
||||
return 'a' + (v - 10)
|
||||
}
|
||||
|
||||
# `s` as a JSON string literal: quoted, with " \\ and control bytes escaped
|
||||
function j_quote(s: pointer) -> pointer {
|
||||
let b = sb_new()
|
||||
sb_putc(b, '"')
|
||||
var i = 0
|
||||
while s[i] != 0 {
|
||||
let c = s[i]
|
||||
if c == '"' { sb_puts(b, "\\\"") }
|
||||
else if c == '\\' { sb_puts(b, "\\\\") }
|
||||
else if c == '\n' { sb_puts(b, "\\n") }
|
||||
else if c == '\r' { sb_puts(b, "\\r") }
|
||||
else if c == '\t' { sb_puts(b, "\\t") }
|
||||
else if c >= 0 and c < ' ' { sb_puts(b, "\\u00"); sb_putc(b, j_hexch(c / 16)); sb_putc(b, j_hexch(c % 16)) }
|
||||
else { sb_putc(b, c) }
|
||||
i += 1
|
||||
}
|
||||
sb_putc(b, '"')
|
||||
return sb_str(b)
|
||||
}
|
||||
|
||||
# object member lookup by key (JV_NULL if absent or not an object)
|
||||
function j_get(v: JVal, key: pointer) -> JVal {
|
||||
if v.t != JV_OBJ { return jval_new(JV_NULL) }
|
||||
var i = 0
|
||||
while i < len(v.keys) { if v.keys[i] == key { return v.kids[i] }; i += 1 }
|
||||
return jval_new(JV_NULL)
|
||||
}
|
||||
373
tools/ludic-cli/lsp_test.ludic
Normal file
373
tools/ludic-cli/lsp_test.ludic
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
# lsp_test.ludic — drive bin/ludic-lsp over real LSP traffic against the real
|
||||
# source tree (`ludic dev test-lsp`; replaces tools/test-lsp.py). Every assertion is
|
||||
# about a feature an editor actually calls: if this passes, VS Code, JetBrains,
|
||||
# Neovim and the rest are exercising code paths that have been checked, because
|
||||
# they all speak to this one binary.
|
||||
#
|
||||
# The server is driven in one batch: every request is framed into a single
|
||||
# stdin stream (positions are computed from the same files the server reads),
|
||||
# the server runs to `exit`, and its stdout is parsed back into a result table
|
||||
# keyed by request id plus the last publishDiagnostics per file — exactly what
|
||||
# an interactive client would have observed, without a bidirectional pipe.
|
||||
|
||||
# ---- the request stream -------------------------------------------------------
|
||||
var lsp_req: Sb = null
|
||||
var lsp_next_id: int = 0
|
||||
|
||||
# one framed JSON-RPC message appended to the stream
|
||||
function lsp_frame(body: pointer) -> void {
|
||||
sb_puts(lsp_req, "Content-Length: "); sb_puts(lsp_req, string(len(body)))
|
||||
sb_puts(lsp_req, "\r\n\r\n"); sb_puts(lsp_req, body)
|
||||
}
|
||||
|
||||
# queue a request; returns its id (the key into lsp_res after the run)
|
||||
function lsp_request(method: pointer, params: pointer) -> int {
|
||||
lsp_next_id += 1
|
||||
lsp_frame(`{{"jsonrpc":"2.0","id":{lsp_next_id},"method":"{method}","params":{params}}}`)
|
||||
return lsp_next_id
|
||||
}
|
||||
|
||||
function lsp_notify(method: pointer, params: pointer) -> void {
|
||||
lsp_frame(`{{"jsonrpc":"2.0","method":"{method}","params":{params}}}`)
|
||||
}
|
||||
|
||||
# didOpen for a file on disk; returns its text (for position lookups)
|
||||
function lsp_open(path: pointer, lang: pointer) -> pointer {
|
||||
let text = read_file(path)
|
||||
lsp_notify("textDocument/didOpen", `{{"textDocument":{{"uri":"file://{path}","languageId":"{lang}","version":1,"text":{j_quote(text)}}}}}`)
|
||||
return text
|
||||
}
|
||||
|
||||
function lsp_doc(path: pointer) -> pointer { return `{{"uri":"file://{path}"}}` }
|
||||
|
||||
# the LSP position of the first occurrence of `needle` (+ `off` characters)
|
||||
function position_of(text: pointer, needle: pointer, off: int) -> pointer {
|
||||
let n = len(text)
|
||||
var line = 0; var a = 0
|
||||
while a <= n {
|
||||
var b = a
|
||||
while b < n and text[b] != '\n' { b += 1 }
|
||||
let row = text[a..b]
|
||||
let at = Text.index_of(row, needle)
|
||||
if at >= 0 { return `{{"line":{line},"character":{at + off}}}` }
|
||||
line += 1; a = b + 1
|
||||
}
|
||||
err(`lsp_test: {needle} not found\n`)
|
||||
exit(1)
|
||||
return ""
|
||||
}
|
||||
|
||||
# ---- the response stream ------------------------------------------------------
|
||||
property DiagSet { file: pointer = "", msgs: []pointer }
|
||||
var lsp_res: []JVal = new []JVal # index = request id (0 unused)
|
||||
var lsp_diags: []DiagSet = new []DiagSet # the last publish per file basename
|
||||
|
||||
function lsp_result(id: int) -> JVal {
|
||||
if id < len(lsp_res) { return lsp_res[id] }
|
||||
return jval_new(JV_NULL)
|
||||
}
|
||||
|
||||
function lsp_diag_set(file: pointer) -> DiagSet {
|
||||
var i = 0
|
||||
while i < len(lsp_diags) { if lsp_diags[i].file == file { return lsp_diags[i] }; i += 1 }
|
||||
return null
|
||||
}
|
||||
|
||||
# messages published for `file` (an empty slice when nothing was published)
|
||||
function lsp_diag_msgs(file: pointer) -> []pointer {
|
||||
let d = lsp_diag_set(file)
|
||||
if d == null { return new []pointer }
|
||||
return d.msgs
|
||||
}
|
||||
|
||||
function basename_of(uri: pointer) -> pointer {
|
||||
var i = len(uri)
|
||||
while i > 0 and uri[i - 1] != '/' { i -= 1 }
|
||||
return uri[i..len(uri)]
|
||||
}
|
||||
|
||||
function lsp_record(msg: JVal) -> void {
|
||||
let id = j_get(msg, "id")
|
||||
if id.t == JV_NUM {
|
||||
while len(lsp_res) <= id.num { push(lsp_res, jval_new(JV_NULL)) }
|
||||
lsp_res[id.num] = j_get(msg, "result")
|
||||
return
|
||||
}
|
||||
if j_get(msg, "method").s == "textDocument/publishDiagnostics" {
|
||||
let p = j_get(msg, "params")
|
||||
let file = basename_of(j_get(p, "uri").s)
|
||||
let msgs = new []pointer
|
||||
let list = j_get(p, "diagnostics")
|
||||
var i = 0
|
||||
while i < len(list.kids) { push(msgs, j_get(list.kids[i], "message").s); i += 1 }
|
||||
var d = lsp_diag_set(file)
|
||||
if d == null { d = new DiagSet; d.file = file; push(lsp_diags, d) }
|
||||
d.msgs = msgs
|
||||
}
|
||||
}
|
||||
|
||||
# split a Content-Length framed stream into messages
|
||||
function lsp_parse_stream(s: pointer) -> void {
|
||||
let n = len(s)
|
||||
var p = 0
|
||||
while p < n {
|
||||
let h = Text.index_of(s[p..n], "Content-Length:")
|
||||
if h < 0 { return }
|
||||
var q = p + h + 15
|
||||
while s[q] == ' ' { q += 1 }
|
||||
var size = 0
|
||||
while s[q] >= '0' and s[q] <= '9' { size = size * 10 + (s[q] - '0'); q += 1 }
|
||||
let body_at = Text.index_of(s[q..n], "\r\n\r\n")
|
||||
if body_at < 0 { return }
|
||||
let start = q + body_at + 4
|
||||
lsp_record(json_parse(s[start..start + size]))
|
||||
p = start + size
|
||||
}
|
||||
}
|
||||
|
||||
# ---- JVal conveniences --------------------------------------------------------
|
||||
function j_has_key(v: JVal, key: pointer) -> bool { return j_get(v, key).t != JV_NULL }
|
||||
|
||||
# does any element of array `arr` have field `key` equal to `want`?
|
||||
function j_any_field(arr: JVal, key: pointer, want: pointer) -> bool {
|
||||
var i = 0
|
||||
while i < len(arr.kids) { if j_get(arr.kids[i], key).s == want { return true }; i += 1 }
|
||||
return false
|
||||
}
|
||||
|
||||
# the sorted, de-duplicated start lines of a TextEdit array, as "1,7"
|
||||
function lines_key(edits: JVal) -> pointer {
|
||||
let lines = new []int
|
||||
var i = 0
|
||||
while i < len(edits.kids) {
|
||||
let l = j_get(j_get(j_get(edits.kids[i], "range"), "start"), "line").num
|
||||
if not List.contains(lines, l) { push(lines, l) }
|
||||
i += 1
|
||||
}
|
||||
List.sort(lines)
|
||||
var out = ""
|
||||
i = 0
|
||||
while i < len(lines) { if i > 0 { out += "," }; out += string(lines[i]); i += 1 }
|
||||
return out
|
||||
}
|
||||
|
||||
# the sorted, de-duplicated start lines of a Location array, as "2,11"
|
||||
function loc_lines_key(locs: JVal) -> pointer {
|
||||
let lines = new []int
|
||||
var i = 0
|
||||
while i < len(locs.kids) {
|
||||
let l = j_get(j_get(j_get(locs.kids[i], "range"), "start"), "line").num
|
||||
if not List.contains(lines, l) { push(lines, l) }
|
||||
i += 1
|
||||
}
|
||||
List.sort(lines)
|
||||
var out = ""
|
||||
i = 0
|
||||
while i < len(lines) { if i > 0 { out += "," }; out += string(lines[i]); i += 1 }
|
||||
return out
|
||||
}
|
||||
|
||||
# the edits of a WorkspaceEdit's first (only) changed document
|
||||
function first_change(ws: JVal) -> JVal {
|
||||
let changes = j_get(ws, "changes")
|
||||
if len(changes.kids) == 0 { return jval_new(JV_ARR) }
|
||||
return changes.kids[0]
|
||||
}
|
||||
|
||||
# how many distinct file basenames a Location array spans
|
||||
function distinct_files(locs: JVal) -> int {
|
||||
let seen = new []pointer
|
||||
var i = 0
|
||||
while i < len(locs.kids) {
|
||||
let f = basename_of(j_get(locs.kids[i], "uri").s)
|
||||
if not set_has(seen, f) { push(seen, f) }
|
||||
i += 1
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
function check_true(label: pointer, cond: bool) -> void {
|
||||
if cond { ok(label) } else { bad(label) }
|
||||
}
|
||||
|
||||
# ---- the suite ----------------------------------------------------------------
|
||||
# Exit 2 when there is no server to test (the caller reports a skip, not a pass).
|
||||
function cmd_test_lsp() -> int {
|
||||
let server = getenv_or("LUDIC_LSP", "bin/ludic-lsp")
|
||||
if not is_exec(server) { skip("ludic-lsp missing"); return 2 }
|
||||
let root = capture_line("pwd")
|
||||
lsp_req = sb_new()
|
||||
|
||||
let init = lsp_request("initialize", `{{"rootUri":"file://{root}","capabilities":{{}},"initializationOptions":{{"compilerDiagnostics":true,"compilerPath":"{root}/bin/ludicc"}}}}`)
|
||||
lsp_notify("initialized", "{}")
|
||||
|
||||
# ---- a fragment file: no `program` block, only reachable through an import --
|
||||
let frag = `{root}/examples/games/chronorift/combat.ludic`
|
||||
let text = lsp_open(frag, "ludic")
|
||||
let uri = lsp_doc(frag)
|
||||
let syms = lsp_request("textDocument/documentSymbol", `{{"textDocument":{uri}}}`)
|
||||
# a field reached through a query binding, declared in ANOTHER file
|
||||
let pos = position_of(text, "st.guard", 3)
|
||||
let hov = lsp_request("textDocument/hover", `{{"textDocument":{uri},"position":{pos}}}`)
|
||||
let def = lsp_request("textDocument/definition", `{{"textDocument":{uri},"position":{pos}}}`)
|
||||
# a binding is not yet inside its own scope at the point it is written, so
|
||||
# resolving it has to go through the declaration table, not scope lookup
|
||||
let qpos = position_of(text, "for (e) in query", 5)
|
||||
let qhov = lsp_request("textDocument/hover", `{{"textDocument":{uri},"position":{qpos}}}`)
|
||||
let comp = lsp_request("textDocument/completion", `{{"textDocument":{uri},"position":{pos}}}`)
|
||||
|
||||
# a query's term list admits properties and models and nothing else —
|
||||
# not the loop variables, not builtins, not keywords
|
||||
let qsrc = "program QT {\n property Pos { x: int = 0 }\n property Vel { dx: int = 0 }\n handler S phase Update {\n for (a, b) in query [Pos, ] { }\n }\n}\n"
|
||||
let qfile = tmp_path("lsp_query.ludic")
|
||||
write_file(qfile, qsrc)
|
||||
lsp_open(qfile, "ludic")
|
||||
let quri = lsp_doc(qfile)
|
||||
let qcomp = lsp_request("textDocument/completion", `{{"textDocument":{quri},"position":{position_of(qsrc, "[Pos, ]", 6)}}}`)
|
||||
let body = lsp_request("textDocument/completion", `{{"textDocument":{quri},"position":{position_of(qsrc, "] { }", 3)}}}`)
|
||||
|
||||
let refs = lsp_request("textDocument/references", `{{"textDocument":{uri},"position":{position_of(text, "query [Stats", 7)},"context":{{"includeDeclaration":true}}}}`)
|
||||
let hints = lsp_request("textDocument/inlayHint", `{{"textDocument":{uri},"range":{{"start":{{"line":0,"character":0}},"end":{{"line":9999,"character":0}}}}}}`)
|
||||
# a namespaced call resolves to its underlying bare-builtin signature
|
||||
let sig = lsp_request("textDocument/signatureHelp", `{{"textDocument":{uri},"position":{position_of(text, "Random.chance(", 14)}}}`)
|
||||
let toks = lsp_request("textDocument/semanticTokens/full", `{{"textDocument":{uri}}}`)
|
||||
let folds = lsp_request("textDocument/foldingRange", `{{"textDocument":{uri}}}`)
|
||||
|
||||
# ---- structural diagnostics arrive while typing --------------------------
|
||||
lsp_notify("textDocument/didChange", `{{"textDocument":{{"uri":"file://{frag}","version":2}},"contentChanges":[{{"text":{j_quote(text + "\n{ oops")}}}]}}`)
|
||||
lsp_request("textDocument/documentSymbol", `{{"textDocument":{uri}}}`)
|
||||
|
||||
# ---- the LSP analyses opened files on its own ----------------------------
|
||||
let badf = tmp_path("lsp_bad.ludic")
|
||||
write_file(badf, "program Bad {\n property P { x: nosuchtype = 0 }\n}\n")
|
||||
lsp_open(badf, "ludic")
|
||||
let bsyms = lsp_request("textDocument/documentSymbol", `{{"textDocument":{lsp_doc(badf)}}}`)
|
||||
|
||||
# ---- markdown: ```ludic fences are real Ludic ---------------------------
|
||||
let md = tmp_path("lsp_test.md")
|
||||
write_file(md, "# Title\n\nprose\n\n```ludic\nprogram D { property Pt { x: int = 0 } }\n```\n\n```python\nz = 1\n```\n")
|
||||
lsp_open(md, "markdown")
|
||||
let mduri = lsp_doc(md)
|
||||
let mdsyms = lsp_request("textDocument/documentSymbol", `{{"textDocument":{mduri}}}`)
|
||||
let mdtoks = lsp_request("textDocument/semanticTokens/full", `{{"textDocument":{mduri}}}`)
|
||||
let fmt = lsp_request("textDocument/formatting", `{{"textDocument":{mduri},"options":{{"tabSize":2,"insertSpaces":true}}}}`)
|
||||
|
||||
# the repository's own documentation must not light up with false errors
|
||||
let docs = ["LANGUAGE.md", "README.md", "COMPILING.md"]
|
||||
var di = 0
|
||||
while di < len(docs) {
|
||||
let path = `{root}/{docs[di]}`
|
||||
if shq(`test -f {path}`) {
|
||||
lsp_open(path, "markdown")
|
||||
lsp_request("textDocument/documentSymbol", `{{"textDocument":{lsp_doc(path)}}}`)
|
||||
}
|
||||
di += 1
|
||||
}
|
||||
let langdoc = lsp_doc(`{root}/LANGUAGE.md`)
|
||||
let langtoks = lsp_request("textDocument/semanticTokens/full", `{{"textDocument":{langdoc}}}`)
|
||||
|
||||
# ---- rename has to be resolution-based, not textual ----------------------
|
||||
# `x` here is a field of Pos, a field of Vel, a local, and a parameter.
|
||||
# Renaming any one of them must leave the other three alone.
|
||||
let amb = tmp_path("lsp_rename.ludic")
|
||||
write_file(amb, "program R {\n property Pos { x: int = 0, y: int = 0 }\n property Vel { x: int = 0 }\n function helper(x: int) -> int { return x + 1 }\n handler A phase Update {\n for (p) in query [Pos] {\n let x = 5\n p.x = p.x + x\n }\n }\n handler B phase Update {\n for (v) in query [Vel] { v.x = v.x + 1 }\n }\n}\n")
|
||||
let atext = lsp_open(amb, "ludic")
|
||||
let auri = lsp_doc(amb)
|
||||
let field = lsp_request("textDocument/rename", `{{"textDocument":{auri},"position":{position_of(atext, "property Pos { x", 15)},"newName":"ZZZ"}}`)
|
||||
let local = lsp_request("textDocument/rename", `{{"textDocument":{auri},"position":{position_of(atext, "let x = 5", 4)},"newName":"ZZZ"}}`)
|
||||
let param = lsp_request("textDocument/rename", `{{"textDocument":{auri},"position":{position_of(atext, "function helper(x", 16)},"newName":"ZZZ"}}`)
|
||||
# find-usages must be equally precise
|
||||
let refs_x = lsp_request("textDocument/references", `{{"textDocument":{auri},"position":{position_of(atext, "property Vel { x", 15)},"context":{{"includeDeclaration":true}}}}`)
|
||||
|
||||
# ---- the compiler's own errors arrive on save ----------------------------
|
||||
let ccf = tmp_path("lsp_cc.ludic")
|
||||
write_file(ccf, "program CcErr {\n entry {\n let x = (5 +\n }\n}\n")
|
||||
lsp_open(ccf, "ludic")
|
||||
lsp_notify("textDocument/didSave", `{{"textDocument":{lsp_doc(ccf)}}}`)
|
||||
lsp_request("textDocument/documentSymbol", `{{"textDocument":{lsp_doc(ccf)}}}`)
|
||||
|
||||
# ---- workspace-wide symbol search ---------------------------------------
|
||||
let ws = lsp_request("workspace/symbol", `{{"query":"Stats"}}`)
|
||||
|
||||
lsp_request("shutdown", "null")
|
||||
lsp_notify("exit", "null")
|
||||
|
||||
# ---- run the server over the whole stream --------------------------------
|
||||
let req_path = tmp_path("lsp_req.bin")
|
||||
let res_path = tmp_path("lsp_res.bin")
|
||||
write_file(req_path, sb_str(lsp_req))
|
||||
run(`{server} --stdio < {req_path} > {res_path} 2>{tmp_path("lsp_err.txt")}`)
|
||||
let stream = read_file(res_path)
|
||||
if stream == null { bad("ludic-lsp produced no output"); return report() }
|
||||
lsp_parse_stream(stream)
|
||||
|
||||
# ---- and judge ----------------------------------------------------------
|
||||
let caps = j_get(lsp_result(init), "capabilities")
|
||||
let wanted = ["completionProvider", "hoverProvider", "definitionProvider", "referencesProvider", "renameProvider", "documentSymbolProvider", "workspaceSymbolProvider", "documentFormattingProvider", "semanticTokensProvider", "foldingRangeProvider", "signatureHelpProvider", "inlayHintProvider", "documentLinkProvider"]
|
||||
var wi = 0
|
||||
while wi < len(wanted) { check_true(`advertises {wanted[wi]}`, j_has_key(caps, wanted[wi])); wi += 1 }
|
||||
|
||||
check_true("document symbols in an imported fragment", len(lsp_result(syms).kids) >= 3)
|
||||
let hov_text = j_get(j_get(lsp_result(hov), "contents"), "value").s
|
||||
check_true("hover resolves a field through a query binding", Text.contains(hov_text, "Stats.guard: int"))
|
||||
check_true("go-to-definition crosses files", Text.ends_with(j_get(lsp_result(def), "uri").s, "world.ludic"))
|
||||
let qhov_text = j_get(j_get(lsp_result(qhov), "contents"), "value").s
|
||||
check_true("hover works on a query binding at its declaration site", Text.contains(qhov_text, "query binding"))
|
||||
let items = j_get(lsp_result(comp), "items")
|
||||
check_true("completion after '.' offers only that property's fields", j_any_field(items, "label", "guard") and j_any_field(items, "label", "hp") and not j_any_field(items, "label", "clear"))
|
||||
let qitems = j_get(lsp_result(qcomp), "items")
|
||||
check_true("completion inside query terms lists properties only", len(qitems.kids) == 2 and j_any_field(qitems, "label", "Pos") and j_any_field(qitems, "label", "Vel"))
|
||||
let bitems = j_get(lsp_result(body), "items")
|
||||
check_true("completion in the loop body still has locals and builtins", j_any_field(bitems, "label", "a") and j_any_field(bitems, "label", "clear"))
|
||||
check_true("find-usages spans the compilation unit", distinct_files(lsp_result(refs)) >= 3)
|
||||
check_true("inlay hints name query binding types", j_any_field(lsp_result(hints), "label", ": Stats"))
|
||||
let sigs = j_get(lsp_result(sig), "signatures")
|
||||
check_true("signature help for a namespaced builtin", len(sigs.kids) > 0 and Text.starts_with(j_get(sigs.kids[0], "label").s, "rng_chance("))
|
||||
let tdata = j_get(lsp_result(toks), "data")
|
||||
check_true("semantic tokens produced", len(tdata.kids) / 5 > 100)
|
||||
check_true("semantic token stream is well formed", len(tdata.kids) % 5 == 0)
|
||||
check_true("folding ranges produced", len(lsp_result(folds).kids) > 5)
|
||||
|
||||
let cmsgs = lsp_diag_msgs("combat.ludic")
|
||||
var unclosed = false
|
||||
var ci = 0
|
||||
while ci < len(cmsgs) { if Text.contains(cmsgs[ci], "unclosed") { unclosed = true }; ci += 1 }
|
||||
check_true("unbalanced brace reported while typing", unclosed)
|
||||
|
||||
check_true("LSP opens and analyses a file with no compiler present", j_any_field(lsp_result(bsyms), "name", "Bad"))
|
||||
|
||||
let mdnames = lsp_result(mdsyms)
|
||||
check_true("symbols found inside a markdown fence", len(mdnames.kids) == 1 and j_get(mdnames.kids[0], "name").s == "D")
|
||||
let mdata = j_get(lsp_result(mdtoks), "data")
|
||||
var first_tok = -1
|
||||
if len(mdata.kids) > 0 { first_tok = mdata.kids[0].num }
|
||||
check("markdown tokens start on the fence body line", string(first_tok), "5")
|
||||
# A fence is a snippet: elisions and half-shown blocks are the documentation
|
||||
# doing its job, so errors there would be noise. Highlighting still applies.
|
||||
check_true("no diagnostics are reported for markdown", lsp_diag_set("lsp_test.md") != null and len(lsp_diag_msgs("lsp_test.md")) == 0)
|
||||
let fedits = lsp_result(fmt)
|
||||
var ftext = ""
|
||||
if len(fedits.kids) > 0 { ftext = j_get(fedits.kids[0], "newText").s }
|
||||
check_true("markdown formatting leaves prose and other fences alone", Text.contains(ftext, "prose") and Text.contains(ftext, "z = 1"))
|
||||
|
||||
di = 0
|
||||
while di < len(docs) {
|
||||
check(`{docs[di]} opens with no diagnostics`, string(len(lsp_diag_msgs(docs[di]))), "0")
|
||||
di += 1
|
||||
}
|
||||
check_true("LANGUAGE.md fences are highlighted", len(j_get(lsp_result(langtoks), "data").kids) / 5 > 100)
|
||||
|
||||
check("renaming Pos.x touches only its declaration and its uses", lines_key(first_change(lsp_result(field))), "1,7")
|
||||
check("renaming a local touches only that local", lines_key(first_change(lsp_result(local))), "6,7")
|
||||
check("renaming a parameter touches only that function", lines_key(first_change(lsp_result(param))), "3")
|
||||
check("renaming a parameter makes exactly two edits", string(len(first_change(lsp_result(param)).kids)), "2")
|
||||
check("find-usages of Vel.x does not pick up Pos.x", loc_lines_key(lsp_result(refs_x)), "2,11")
|
||||
check_true("workspace symbol search finds a property", j_any_field(lsp_result(ws), "name", "Stats"))
|
||||
let ccmsgs = lsp_diag_msgs("lsp_cc.ludic")
|
||||
var cc_seen = false
|
||||
ci = 0
|
||||
while ci < len(ccmsgs) { if Text.contains(ccmsgs[ci], "expected expression") { cc_seen = true }; ci += 1 }
|
||||
check_true("ludicc's own error is published on save (compilerDiagnostics)", cc_seen)
|
||||
|
||||
return report()
|
||||
}
|
||||
230
tools/ludic-cli/main.ludic
Normal file
230
tools/ludic-cli/main.ludic
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
# ludic — the command-line interface to the Ludic toolchain. One native binary
|
||||
# (bin/ludic) written in Ludic and compiled by Ludic; the unix tools, clang and
|
||||
# the compiler it drives are invoked through the `run` intrinsic.
|
||||
#
|
||||
# Two audiences, one binary. A user of the language sees a small surface —
|
||||
# new, build, run, test, add, fmt — that works from any directory against an
|
||||
# installed toolchain. A contributor to the toolchain itself gets everything
|
||||
# else under `ludic dev`: bootstrapping the compiler from its IR seed, the
|
||||
# regression suites, the docs site, releases. Splitting them by namespace keeps
|
||||
# `ludic help` about the language rather than about the repository.
|
||||
#
|
||||
# Installing (macOS, Linux):
|
||||
#
|
||||
# curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh
|
||||
#
|
||||
# Building from a checkout — the one step Ludic cannot do for itself, since
|
||||
# compiling Ludic needs a compiler:
|
||||
#
|
||||
# mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc
|
||||
# bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic
|
||||
# bin/ludic dev build
|
||||
#
|
||||
# Thereafter `ludic dev build` rebuilds the whole toolchain — including the CLI
|
||||
# itself. The `dev` tasks run from the repository root.
|
||||
program Ludic {
|
||||
import "prelude.ludic"
|
||||
import "selfhost.ludic"
|
||||
import "build.ludic"
|
||||
import "project.ludic"
|
||||
import "tools.ludic"
|
||||
import "selfhost_test.ludic"
|
||||
import "test.ludic"
|
||||
import "json.ludic"
|
||||
import "lsp_test.ludic"
|
||||
import "forgejo.ludic"
|
||||
import "checks.ludic"
|
||||
import "docgen.ludic"
|
||||
import "docgen_gen.ludic"
|
||||
import "docgen_check.ludic"
|
||||
import "release.ludic"
|
||||
import "pkg.ludic"
|
||||
import "pkg_test.ludic"
|
||||
|
||||
function usage() -> void {
|
||||
print("ludic — the toolchain for the Ludic language")
|
||||
print("")
|
||||
print("usage: ludic <command> [arguments]")
|
||||
print("")
|
||||
print("start a game:")
|
||||
print(" new <name> create a project that builds and runs as it stands")
|
||||
print(" run [file] [--headless] build the project and run it")
|
||||
print(" build [file] [--headless] [-o out]")
|
||||
print(" compile to build/<name> (a native binary, nothing to ship beside it)")
|
||||
print(" test [file...] compile and run the project's tests")
|
||||
print(" clean remove build/")
|
||||
print("")
|
||||
print("packages:")
|
||||
print(" add <module>[@version] add a dependency, then resolve + fetch + link")
|
||||
print(" get resolve every package.ludic dependency and write the lock")
|
||||
print(" update [module] bump a dependency (or all) to its latest published version")
|
||||
print(" verify check every locked package against the store by content hash")
|
||||
print(" vendor copy the resolved packages into ./vendor for offline builds")
|
||||
print(" build-lib <module.ludic> compile a package's module to a prebuilt dylib in lib/<target>/")
|
||||
print(" link-flags print the clang flags to link this project's prebuilt module dylibs")
|
||||
print("")
|
||||
print("the toolchain:")
|
||||
print(" version print the toolchain version")
|
||||
print(" upgrade [version] reinstall from the docs site (the same script that installed it)")
|
||||
print(" doctor check that the install is complete and usable")
|
||||
print(" fmt [paths...] format Ludic source in place")
|
||||
print(" lsp run the language server on stdio (what editors spawn)")
|
||||
print(" help this message")
|
||||
print("")
|
||||
print("contributing to the toolchain itself:")
|
||||
print(" dev <task> build, test, bootstrap, docs, release (ludic dev help)")
|
||||
print("")
|
||||
print("A file argument runs it: ludic mygame.ludic")
|
||||
}
|
||||
|
||||
function dev_usage() -> void {
|
||||
print("ludic dev — tasks for a checkout of the Ludic toolchain (run from the repository root)")
|
||||
print("")
|
||||
print("build:")
|
||||
print(" dev build build the whole toolchain into bin/ (ludicc, ludic, ludic-fmt, ludic-lsp)")
|
||||
print(" dev build-cli build just bin/ludicc from the IR seed")
|
||||
print(" dev tools [--install] [--test] build the editor toolchain (ludic-fmt, ludic-lsp)")
|
||||
print("")
|
||||
print("test:")
|
||||
print(" dev test the full regression suite")
|
||||
print(" dev test --coverage per-file line coverage over the test specs")
|
||||
print(" dev test-pkg the package-manager suite (hermetic git repos, offline)")
|
||||
print(" dev selfhost-test the self-hosting suite (correctness + bootstrap fixpoints)")
|
||||
print(" dev test-tools the editor-toolchain suite")
|
||||
print(" dev test-lsp drive bin/ludic-lsp over real LSP traffic (exit 2 = no server)")
|
||||
print(" dev golden regenerate selfhost/golden/renders.sha256 (review with git diff)")
|
||||
print("")
|
||||
print("doc / lint checks (Ludic, no Python):")
|
||||
print(" dev check-docs every ```ludic doc fence parses (or is marked skip/expect-error)")
|
||||
print(" dev check-impl every implemented feature has a docs/language page")
|
||||
print(" dev check-vocabulary the vocabulary is in sync across grammar / lexer / header / parser")
|
||||
print(" dev lint-asset <file> validate one editor .json / .xml asset")
|
||||
print(" dev docs-gen [--out DIR] generate the documentation site (default build/pages)")
|
||||
print(" dev docs-check [DIR] coverage/integrity guard over a generated docs site")
|
||||
print(" dev docs-palette [--check] regenerate emit_color.ludic + palette.json from the palette table")
|
||||
print("")
|
||||
print("release:")
|
||||
print(" dev release [major|minor|patch] [--dry-run] [--publish]")
|
||||
print(" cut a release: CHANGELOG + VERSION bump + tag (+ Forgejo release)")
|
||||
print(" dev publish [vX.Y.Z] publish an already-tagged release; what CI runs on a tag push")
|
||||
print(" dev changelog-section <ver> print that release's CHANGELOG.md section")
|
||||
print(" dev changelog-render <ver> <date> <dir>")
|
||||
print(" render a CHANGELOG section from a directory of changesets")
|
||||
print("")
|
||||
print("self-host internals:")
|
||||
print(" dev selfhost-build [ludicc] [out] assemble + compile the self-host compiler")
|
||||
print(" dev bootstrap the self-hosting fixpoint proof (seeded from bin/ludicc)")
|
||||
print(" dev bootstrap-cfree rebuild the compiler from the seed with no C compiler")
|
||||
print(" dev reseed regenerate selfhost/ludicc.seed.ll after a compiler change")
|
||||
print(" dev game-build <ludicc> <game.ludic> <out> compile a Ludic game (headless)")
|
||||
print(" dev sh-compile <ludicc> <in.ludic> <out> compile + link one .ludic file")
|
||||
}
|
||||
|
||||
# a positional argument, or a default when absent
|
||||
function argn(i: int, dflt: pointer) -> pointer {
|
||||
if (i < arg_count()) { return arg(i) }
|
||||
return dflt
|
||||
}
|
||||
|
||||
# does `s` end in ".ludic"? — `ludic mygame.ludic` runs that file, the shape
|
||||
# the compiler-runner had before the CLI grew subcommands.
|
||||
function is_ludic_file(s: pointer) -> bool {
|
||||
let n = slen(s)
|
||||
if n <= 6 { return false }
|
||||
return sslice(s, n - 6, n) == ".ludic"
|
||||
}
|
||||
|
||||
# ---- ludic dev <task> — the toolchain repo's own tasks ---------------------
|
||||
# Every task here reads its arguments one position further along than a
|
||||
# top-level command, so the shift is set once, here.
|
||||
function dispatch_dev(cmd: pointer) -> int {
|
||||
g_shift = 1
|
||||
if (cmd == "build") { return cmd_dev_build() }
|
||||
if (cmd == "build-cli") { return cmd_dev_build_cli() }
|
||||
if (cmd == "tools") { return cmd_tools() }
|
||||
if (cmd == "clean") { return cmd_clean() }
|
||||
if (cmd == "test") {
|
||||
if (argn(3, "") == "--coverage") { return cmd_test_coverage() }
|
||||
return cmd_dev_test()
|
||||
}
|
||||
if (cmd == "selfhost-test") { return cmd_selfhost_test() }
|
||||
if (cmd == "test-tools") { return cmd_test_tools() }
|
||||
if (cmd == "test-lsp") { return cmd_test_lsp() }
|
||||
if (cmd == "test-pkg") { return cmd_test_pkg() }
|
||||
if (cmd == "check-docs") { return cmd_check_docs() }
|
||||
if (cmd == "check-impl") { return cmd_check_impl() }
|
||||
if (cmd == "check-vocabulary") { return cmd_check_vocab() }
|
||||
if (cmd == "lint-asset") { return cmd_lint_asset() }
|
||||
if (cmd == "docs-palette") { return cmd_docs_palette() }
|
||||
if (cmd == "docs-gen") { return cmd_docs_gen() }
|
||||
if (cmd == "docs-check") { return cmd_docs_check() }
|
||||
if (cmd == "golden") { return cmd_golden() }
|
||||
if (cmd == "bootstrap") { return cmd_bootstrap() }
|
||||
if (cmd == "bootstrap-cfree") { return cmd_bootstrap_cfree() }
|
||||
if (cmd == "reseed") { return cmd_reseed() }
|
||||
if (cmd == "selfhost-build") { return cmd_selfhost_build(argn(3, "bin/ludicc"), argn(4, "build/selfhost")) }
|
||||
if (cmd == "sh-compile") {
|
||||
if (arg_count() < 6) { err("usage: ludic dev sh-compile <ludicc> <in.ludic> <out>\n"); exit(1) }
|
||||
return cmd_sh_compile(arg(3), arg(4), arg(5))
|
||||
}
|
||||
if (cmd == "game-build") {
|
||||
if (arg_count() < 6) { err("usage: ludic dev game-build <ludicc> <game.ludic> <out>\n"); exit(1) }
|
||||
return cmd_game_build(arg(3), arg(4), arg(5))
|
||||
}
|
||||
if (cmd == "release") { return cmd_release() }
|
||||
if (cmd == "changelog-render") { return cmd_changelog_render() }
|
||||
if (cmd == "changelog-section") { return cmd_changelog_section() }
|
||||
if (cmd == "publish") { return cmd_publish() }
|
||||
if (cmd == "help") or (cmd == "--help") or (cmd == "-h") or (cmd == "") { dev_usage(); return 0 }
|
||||
# reported here rather than by entry, so the message names the dev task the
|
||||
# user actually typed and the usage shown is the dev one
|
||||
err(`ludic dev: unknown task '{cmd}'\n`)
|
||||
dev_usage()
|
||||
return 1
|
||||
}
|
||||
|
||||
# route one subcommand to its implementation and return its exit code; -1 when
|
||||
# the name is unknown. Returning (rather than exiting in place) lets entry tear
|
||||
# down the per-process scratch directory before the process ends.
|
||||
function dispatch(cmd: pointer) -> int {
|
||||
if (cmd == "new") { return cmd_new() }
|
||||
if (cmd == "build") { return cmd_build() }
|
||||
if (cmd == "run") { return cmd_run() }
|
||||
if (cmd == "test") { return cmd_test() }
|
||||
if (cmd == "clean") { return cmd_clean() }
|
||||
if (cmd == "fmt") { return cmd_fmt() }
|
||||
if (cmd == "lsp") { return cmd_lsp() }
|
||||
if (cmd == "doctor") { return cmd_doctor() }
|
||||
if (cmd == "upgrade") { return cmd_upgrade() }
|
||||
|
||||
if (cmd == "add") { return cmd_pkg_add() }
|
||||
if (cmd == "get") { return cmd_pkg_get() }
|
||||
if (cmd == "update") { return cmd_pkg_update() }
|
||||
if (cmd == "verify") { return cmd_pkg_verify() }
|
||||
if (cmd == "vendor") { return cmd_pkg_vendor() }
|
||||
if (cmd == "build-lib") { return cmd_pkg_build_lib() }
|
||||
if (cmd == "link-flags") { return cmd_pkg_link_flags() }
|
||||
|
||||
if (cmd == "version") or (cmd == "--version") or (cmd == "-v") { return cmd_version() }
|
||||
if (cmd == "help") or (cmd == "--help") or (cmd == "-h") { usage(); return 0 }
|
||||
if (cmd == "dev") { return dispatch_dev(argn(2, "")) }
|
||||
return -1
|
||||
}
|
||||
|
||||
entry {
|
||||
if (arg_count() < 2) { usage(); exit(1) }
|
||||
let cmd = arg(1)
|
||||
# `ludic mygame.ludic` — build and run that file
|
||||
if is_ludic_file(cmd) {
|
||||
let rc = cmd_run_file()
|
||||
tmp_cleanup()
|
||||
exit(rc)
|
||||
}
|
||||
let code = dispatch(cmd)
|
||||
tmp_cleanup()
|
||||
if code >= 0 { exit(code) }
|
||||
err(`ludic: unknown command '{cmd}'\n`)
|
||||
usage()
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
717
tools/ludic-cli/pkg.ludic
Normal file
717
tools/ludic-cli/pkg.ludic
Normal file
|
|
@ -0,0 +1,717 @@
|
|||
# pkg.ludic — the Ludic package manager (issue #63), a set of `x` subcommands.
|
||||
#
|
||||
# It realises the v1 direction decided in the RFC:
|
||||
#
|
||||
# * URL-as-identity, no registry — the import path IS the git location
|
||||
# (git.workshopsoft.io/user/pkg). A `git tag` is publishing.
|
||||
# * Minimum Version Selection (MVS, Go-style) — a require is a *minimum*; the
|
||||
# resolver picks, per module, the greatest of the required minimums, then
|
||||
# the reachable closure at those versions. Deterministic, no SAT solver.
|
||||
# * A content-addressed global store + per-project symlinks (pnpm-style): one
|
||||
# immutable store keyed by a content hash, each project a light linked view
|
||||
# under ludic_modules/ (which the compiler resolves imports against).
|
||||
# * package.ludic (declared deps + provided namespaces + kind + targets) and
|
||||
# package.lock.ludic (resolved versions + content hashes) as the manifest
|
||||
# and lockfile.
|
||||
#
|
||||
# Fetching drives plain `git` through the `run`/`capture` intrinsics — no new
|
||||
# infrastructure. A source package's Ludic is compiled together with the
|
||||
# consumer via the module-root import fallback in the compiler (do_import); a
|
||||
# prebuilt lib declares its shipped targets and is resolved against the build
|
||||
# target. Everything is content-verified against the lockfile by `ludic verify`.
|
||||
|
||||
# ---- the manifest / lock model ----------------------------------------------
|
||||
|
||||
property Dep { module: pointer = "", ver: pointer = "" }
|
||||
|
||||
property Manifest {
|
||||
module: pointer = "",
|
||||
ver: pointer = "",
|
||||
kind: pointer = "source", # source | prebuilt
|
||||
hash: pointer = "", # content hash, filled in after a snapshot
|
||||
provides: []pointer, # the Foo.* namespace(s) this package registers
|
||||
targets: []pointer, # prebuilt: the targets it ships (native-arm64, wasm32, …)
|
||||
deps: []Dep
|
||||
}
|
||||
|
||||
function manifest_new() -> Manifest {
|
||||
let m = new Manifest
|
||||
m.module = ""
|
||||
m.ver = ""
|
||||
m.kind = "source"
|
||||
m.hash = ""
|
||||
m.provides = new []pointer
|
||||
m.targets = new []pointer
|
||||
m.deps = new []Dep
|
||||
return m
|
||||
}
|
||||
|
||||
# a bare newline string (the lexer has no reliable \n inside interpolation)
|
||||
function nl() -> pointer { let b = bytes(2); b[0] = '\n'; b[1] = 0; return b }
|
||||
|
||||
# ---- version arithmetic (semver major.minor.patch, pre-release ignored) ------
|
||||
|
||||
# drop a leading 'v' (118) from a tag/version string
|
||||
function strip_v(s: pointer) -> pointer {
|
||||
if slen(s) > 0 and s[0] == 'v' { return sslice(s, 1, slen(s)) }
|
||||
return s
|
||||
}
|
||||
|
||||
# major/minor/patch as three ints (missing fields are 0); stops at -/+ metadata
|
||||
function ver_nums(s: pointer) -> []int {
|
||||
let v = strip_v(s)
|
||||
var parts = new []int
|
||||
var cur = 0
|
||||
let n = slen(v)
|
||||
var i = 0
|
||||
while i < n {
|
||||
let c = v[i]
|
||||
if c >= '0' and c <= '9' { cur = cur * 10 + (c - 48) }
|
||||
else if c == '.' { push(parts, cur); cur = 0 }
|
||||
else { i = n } # a '-'/'+' ends the numeric core
|
||||
i += 1
|
||||
}
|
||||
push(parts, cur)
|
||||
while len(parts) < 3 { push(parts, 0) }
|
||||
return parts
|
||||
}
|
||||
|
||||
# -1 / 0 / 1 for a < b / a == b / a > b
|
||||
function ver_cmp(a: pointer, b: pointer) -> int {
|
||||
let pa = ver_nums(a)
|
||||
let pb = ver_nums(b)
|
||||
var i = 0
|
||||
while i < 3 {
|
||||
if pa[i] < pb[i] { return -1 }
|
||||
if pa[i] > pb[i] { return 1 }
|
||||
i += 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
function ver_gt(a: pointer, b: pointer) -> bool { return ver_cmp(a, b) > 0 }
|
||||
|
||||
# is `s` a version tag (optional leading v, then a digit)?
|
||||
function is_ver_tag(s: pointer) -> bool {
|
||||
let v = strip_v(s)
|
||||
if slen(v) == 0 { return false }
|
||||
return v[0] >= '0' and v[0] <= '9'
|
||||
}
|
||||
|
||||
# ---- tiny line/token scanners for the manifest & lock ------------------------
|
||||
|
||||
# split a manifest/lock line into tokens: "quoted strings" (unquoted) or bare
|
||||
# words. A '#' outside a quote starts a comment that ends the line.
|
||||
function tok_line(line: pointer) -> []pointer {
|
||||
var toks = new []pointer
|
||||
let n = slen(line)
|
||||
var i = 0
|
||||
while i < n {
|
||||
let c = line[i]
|
||||
if c == '#' { i = n } # '#' comment
|
||||
else if c == ' ' or c == '\t' { i += 1 } # whitespace
|
||||
else if c == '"' { # "quoted"
|
||||
i += 1
|
||||
let start = i
|
||||
while i < n and line[i] != '"' { i += 1 }
|
||||
push(toks, sslice(line, start, i))
|
||||
if i < n { i += 1 }
|
||||
} else { # bare word
|
||||
let start = i
|
||||
while i < n and line[i] != ' ' and line[i] != '\t' and line[i] != '#' { i += 1 }
|
||||
push(toks, sslice(line, start, i))
|
||||
}
|
||||
}
|
||||
return toks
|
||||
}
|
||||
|
||||
# split a captured multi-line string into trimmed non-empty lines
|
||||
function split_lines(s: pointer) -> []pointer {
|
||||
var out = new []pointer
|
||||
let n = slen(s)
|
||||
var i = 0
|
||||
while i < n {
|
||||
let line = line_at(s, i)
|
||||
i = i + slen(line) + 1
|
||||
let t = s_trim(line)
|
||||
if slen(t) > 0 { push(out, t) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
# ---- manifest / lock parsing -------------------------------------------------
|
||||
|
||||
function parse_manifest(text: pointer) -> Manifest {
|
||||
let m = manifest_new()
|
||||
let n = slen(text)
|
||||
var i = 0
|
||||
while i < n {
|
||||
let line = line_at(text, i)
|
||||
i = i + slen(line) + 1
|
||||
let ts = tok_line(line)
|
||||
if len(ts) > 0 {
|
||||
let head = ts[0]
|
||||
if head == "package" { if len(ts) > 1 { m.module = ts[1] } }
|
||||
else if head == "version" { if len(ts) > 1 { m.ver = ts[1] } }
|
||||
else if head == "kind" { if len(ts) > 1 { m.kind = ts[1] } }
|
||||
else if head == "provides" { var k = 1; while k < len(ts) { push(m.provides, ts[k]); k += 1 } }
|
||||
else if head == "targets" { var k = 1; while k < len(ts) { push(m.targets, ts[k]); k += 1 } }
|
||||
else if head == "require" {
|
||||
if len(ts) > 2 { let d = new Dep; d.module = ts[1]; d.ver = ts[2]; push(m.deps, d) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
# read a package.lock.ludic body into a list of pinned entries
|
||||
function parse_lock(text: pointer) -> []Manifest {
|
||||
var out = new []Manifest
|
||||
let n = slen(text)
|
||||
var i = 0
|
||||
while i < n {
|
||||
let line = line_at(text, i)
|
||||
i = i + slen(line) + 1
|
||||
let ts = tok_line(line)
|
||||
if len(ts) > 1 and ts[0] == "module" {
|
||||
let m = manifest_new()
|
||||
m.module = ts[1]
|
||||
var k = 2
|
||||
while k < len(ts) {
|
||||
let key = ts[k]
|
||||
if key == "version" and k + 1 < len(ts) { m.ver = ts[k + 1]; k += 2 }
|
||||
else if key == "hash" and k + 1 < len(ts) { m.hash = ts[k + 1]; k += 2 }
|
||||
else if key == "kind" and k + 1 < len(ts) { m.kind = ts[k + 1]; k += 2 }
|
||||
else if key == "provides" and k + 1 < len(ts) { push(m.provides, ts[k + 1]); k += 2 }
|
||||
else { k += 1 }
|
||||
}
|
||||
push(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
# ---- the content-addressed store & git fetch layer --------------------------
|
||||
|
||||
# the global store root: $LUDIC_STORE, else ~/.ludic/store. Trailing slash.
|
||||
function store_root() -> pointer {
|
||||
let s = getenv("LUDIC_STORE")
|
||||
if s != null { return ensure_dir_slash(s) }
|
||||
return ensure_dir_slash(`{getenv_or("HOME", "/tmp")}/.ludic/store`)
|
||||
}
|
||||
function ensure_dir_slash(d: pointer) -> pointer {
|
||||
let n = slen(d)
|
||||
if n == 0 { return d }
|
||||
if d[n - 1] == '/' { return d }
|
||||
return d + "/"
|
||||
}
|
||||
|
||||
# the git URL for a module: a local $LUDIC_PKG_PROXY tree (used by tests and for
|
||||
# mirrors/offline) keyed by the import path, else https://<import-path>.
|
||||
function repo_url(module: pointer) -> pointer {
|
||||
let proxy = getenv("LUDIC_PKG_PROXY")
|
||||
if proxy != null { return `{proxy}/{module}` }
|
||||
return `https://{module}`
|
||||
}
|
||||
|
||||
# ensure a clone cache for `module` under the store; refresh tags if present.
|
||||
# Returns the cache dir, or "" if the clone failed.
|
||||
function ensure_clone(module: pointer) -> pointer {
|
||||
let cache = `{store_root()}cache/{flat(module)}`
|
||||
if file_exists(cache) {
|
||||
run(`git -C {cache} fetch -q --tags 2>/dev/null`)
|
||||
return cache
|
||||
}
|
||||
run(`mkdir -p {store_root()}cache`)
|
||||
if not shq(`git clone -q {repo_url(module)} {cache} 2>/dev/null`) {
|
||||
err(`x: cannot fetch {module} (git clone {repo_url(module)} failed)\n`)
|
||||
return ""
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
# check out version `ver` in the cache — a v-prefixed tag first, then bare.
|
||||
function checkout_ver(cache: pointer, ver: pointer) -> bool {
|
||||
if shq(`git -C {cache} checkout -q v{ver} 2>/dev/null`) { return true }
|
||||
return shq(`git -C {cache} checkout -q {ver} 2>/dev/null`)
|
||||
}
|
||||
|
||||
# the greatest published version tag of `module` (normalised, no leading v)
|
||||
function latest_version(module: pointer) -> pointer {
|
||||
let cache = ensure_clone(module)
|
||||
if cache == "" { return "" }
|
||||
let lines = split_lines(capture(`git -C {cache} tag`))
|
||||
var best = ""
|
||||
var k = 0
|
||||
while k < len(lines) {
|
||||
let tg = lines[k]
|
||||
if is_ver_tag(tg) {
|
||||
let cand = strip_v(tg)
|
||||
if best == "" or ver_gt(cand, best) { best = cand }
|
||||
}
|
||||
k += 1
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
# read a package's manifest at `ver`; a package may omit package.ludic, in which
|
||||
# case it is a leaf source package with no declared deps.
|
||||
function fetch_manifest(module: pointer, ver: pointer) -> Manifest {
|
||||
let cache = ensure_clone(module)
|
||||
var m = manifest_new()
|
||||
m.module = module
|
||||
m.ver = ver
|
||||
if cache == "" { return m }
|
||||
if not checkout_ver(cache, ver) {
|
||||
err(`x: {module} has no version {ver}\n`)
|
||||
return m
|
||||
}
|
||||
let txt = read_file(`{cache}/package.ludic`)
|
||||
if txt != null { m = parse_manifest(txt) }
|
||||
m.module = module
|
||||
m.ver = ver
|
||||
return m
|
||||
}
|
||||
|
||||
# the sha256 pick — shasum on macOS, sha256sum elsewhere. Both print "<hash> f".
|
||||
function sha_cmd() -> pointer {
|
||||
if shq("command -v shasum >/dev/null 2>&1") { return "shasum -a 256" }
|
||||
return "sha256sum"
|
||||
}
|
||||
|
||||
# a deterministic content hash of a directory tree (its files' names + bytes),
|
||||
# excluding .git. Independent of file metadata, so it is stable across machines.
|
||||
function dir_hash(dir: pointer) -> pointer {
|
||||
let sc = sha_cmd()
|
||||
return capture_line(`( cd {dir} && find . -type f -not -path './.git/*' | LC_ALL=C sort | while IFS= read -r f; do {sc} "$f"; done | {sc} | cut -d' ' -f1 )`)
|
||||
}
|
||||
|
||||
# snapshot `module`@`ver` into the content-addressed store; returns its hash.
|
||||
# The store entry is immutable and shared: if the hash is already present the
|
||||
# copy is skipped. The .git directory is never stored.
|
||||
function snapshot(module: pointer, ver: pointer) -> pointer {
|
||||
let cache = ensure_clone(module)
|
||||
if cache == "" { return "" }
|
||||
if not checkout_ver(cache, ver) { return "" }
|
||||
let h = dir_hash(cache)
|
||||
if slen(h) == 0 { return "" }
|
||||
let dest = `{store_root()}{h}`
|
||||
if not file_exists(dest) {
|
||||
run(`rm -rf {dest}.tmp`)
|
||||
run(`cp -R {cache} {dest}.tmp`)
|
||||
run(`rm -rf {dest}.tmp/.git`)
|
||||
run(`mv {dest}.tmp {dest}`)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
# link a project's ludic_modules/<module> view at the store entry `hash`
|
||||
function link_module(module: pointer, hash: pointer) -> void {
|
||||
let dest = `{store_root()}{hash}`
|
||||
let link = `ludic_modules/{module}`
|
||||
run(`mkdir -p "$(dirname {link})"`)
|
||||
run(`rm -rf {link}`)
|
||||
run(`ln -s {dest} {link}`)
|
||||
}
|
||||
|
||||
# ---- MVS resolution ----------------------------------------------------------
|
||||
|
||||
function find_mod(mods: []pointer, m: pointer) -> int {
|
||||
var i = 0
|
||||
while i < len(mods) { if mods[i] == m { return i }; i += 1 }
|
||||
return -1
|
||||
}
|
||||
|
||||
# resolve a root manifest to the selected build list (one Manifest per module,
|
||||
# each at its chosen version). MVS: a module's version is the greatest of every
|
||||
# required minimum; the returned list is the reachable closure at those versions.
|
||||
function resolve(root: Manifest) -> []Manifest {
|
||||
var mods = new []pointer # module -> selected version (parallel arrays)
|
||||
var vers = new []pointer
|
||||
var fm = new []pointer # the current requirement frontier
|
||||
var fv = new []pointer
|
||||
var di = 0
|
||||
while di < len(root.deps) { push(fm, root.deps[di].module); push(fv, root.deps[di].ver); di += 1 }
|
||||
|
||||
while len(fm) > 0 {
|
||||
var nm = new []pointer
|
||||
var nv = new []pointer
|
||||
var i = 0
|
||||
while i < len(fm) {
|
||||
let m = fm[i]
|
||||
let v = fv[i]
|
||||
let idx = find_mod(mods, m)
|
||||
var take = false
|
||||
if idx < 0 { push(mods, m); push(vers, v); take = true }
|
||||
else if ver_gt(v, vers[idx]) { vers[idx] = v; take = true }
|
||||
if take {
|
||||
let man = fetch_manifest(m, v) # expand the chosen version's requires
|
||||
var k = 0
|
||||
while k < len(man.deps) { push(nm, man.deps[k].module); push(nv, man.deps[k].ver); k += 1 }
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
fm = nm
|
||||
fv = nv
|
||||
}
|
||||
|
||||
# reachability closure: keep only modules reachable from the root, each pinned
|
||||
# to its selected version (a module required only by a version later bumped
|
||||
# away drops out — the minimal MVS build list).
|
||||
var out = new []Manifest
|
||||
var seen = new []pointer
|
||||
var q = new []pointer
|
||||
var d2 = 0
|
||||
while d2 < len(root.deps) { push(q, root.deps[d2].module); d2 += 1 }
|
||||
var qi = 0
|
||||
while qi < len(q) {
|
||||
let m = q[qi]
|
||||
qi += 1
|
||||
if find_mod(seen, m) < 0 {
|
||||
push(seen, m)
|
||||
let idx = find_mod(mods, m)
|
||||
if idx >= 0 {
|
||||
let ver = vers[idx]
|
||||
let man = fetch_manifest(m, ver)
|
||||
push(out, man)
|
||||
var k = 0
|
||||
while k < len(man.deps) { push(q, man.deps[k].module); k += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
# sort a build list by module path (stable enough — insertion sort)
|
||||
function sort_mans(a: []Manifest) -> []Manifest {
|
||||
var i = 1
|
||||
while i < len(a) {
|
||||
let x = a[i]
|
||||
var j = i - 1
|
||||
while j >= 0 and (a[j].module > x.module) { a[j + 1] = a[j]; j -= 1 }
|
||||
a[j + 1] = x
|
||||
i += 1
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
# ---- namespace-collision policy (v1: hard error) ----------------------------
|
||||
# Two packages may not both register the same Foo.* namespace. Returns "" when
|
||||
# clean, else a human message naming the conflicting modules.
|
||||
function collision(sels: []Manifest) -> pointer {
|
||||
var ns = new []pointer
|
||||
var owner = new []pointer
|
||||
var i = 0
|
||||
while i < len(sels) {
|
||||
var p = 0
|
||||
while p < len(sels[i].provides) {
|
||||
let name = sels[i].provides[p]
|
||||
let at = find_mod(ns, name)
|
||||
if at >= 0 { return `namespace {name} provided by both {owner[at]} and {sels[i].module}` }
|
||||
push(ns, name)
|
||||
push(owner, sels[i].module)
|
||||
p += 1
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# ---- lockfile writing --------------------------------------------------------
|
||||
|
||||
function write_lock(sels: []Manifest) -> bool {
|
||||
var body = "# package.lock.ludic — generated by `ludic get`. Do not edit by hand." + nl()
|
||||
body = body + "lock 1" + nl()
|
||||
let sorted = sort_mans(sels)
|
||||
var i = 0
|
||||
while i < len(sorted) {
|
||||
let m = sorted[i]
|
||||
var line = `module "{m.module}" version "{m.ver}" hash "{m.hash}" kind "{m.kind}"`
|
||||
var p = 0
|
||||
while p < len(m.provides) { line += ` provides "{m.provides[p]}"`; p += 1 }
|
||||
body = body + line + nl()
|
||||
i += 1
|
||||
}
|
||||
return write_file("package.lock.ludic", body)
|
||||
}
|
||||
|
||||
# ---- the shared get/install pipeline ----------------------------------------
|
||||
# resolve -> collision check -> snapshot each into the store -> link the project
|
||||
# view -> write the lockfile. Returns 0 on success.
|
||||
function do_install(root: Manifest) -> int {
|
||||
let sels = resolve(root)
|
||||
if len(sels) == 0 {
|
||||
print("no dependencies to resolve")
|
||||
write_lock(sels)
|
||||
return 0
|
||||
}
|
||||
let clash = collision(sels)
|
||||
if slen(clash) > 0 { err(`x: namespace collision — {clash}\n`); return 1 }
|
||||
|
||||
var i = 0
|
||||
while i < len(sels) {
|
||||
let m = sels[i]
|
||||
if m.kind == "prebuilt" {
|
||||
let t = target_id()
|
||||
if not has_target(m, t) {
|
||||
err(`x: {m.module}@{m.ver} is a prebuilt lib and ships no artifact for target {t}\n`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
let h = snapshot(m.module, m.ver)
|
||||
if slen(h) == 0 { err(`x: failed to snapshot {m.module}@{m.ver}\n`); return 1 }
|
||||
m.hash = `sha256:{h}`
|
||||
link_module(m.module, h)
|
||||
print(` {m.module} {m.ver} ({m.kind}) sha256:{sslice(h, 0, 12)}…`)
|
||||
i += 1
|
||||
}
|
||||
if not write_lock(sels) { err("x: cannot write package.lock.ludic\n"); return 1 }
|
||||
print(`resolved {string(len(sels))} package(s) — see package.lock.ludic; linked under ludic_modules/`)
|
||||
return 0
|
||||
}
|
||||
|
||||
# the build target id a prebuilt lib is matched against
|
||||
function target_id() -> pointer {
|
||||
let t = getenv("LUDIC_TARGET")
|
||||
if t != null { return t }
|
||||
let os = capture_line("uname -s")
|
||||
let arch = capture_line("uname -m")
|
||||
if os == "Darwin" { return `native-{arch}` }
|
||||
return `native-{arch}`
|
||||
}
|
||||
function has_target(m: Manifest, t: pointer) -> bool {
|
||||
var i = 0
|
||||
while i < len(m.targets) { if m.targets[i] == t { return true }; i += 1 }
|
||||
return false
|
||||
}
|
||||
|
||||
# ---- reading / editing the project manifest ---------------------------------
|
||||
|
||||
# the project manifest in the current directory, or a fresh default one
|
||||
function read_root_manifest() -> Manifest {
|
||||
let txt = read_file("package.ludic")
|
||||
if txt == null { return manifest_new() }
|
||||
return parse_manifest(txt)
|
||||
}
|
||||
|
||||
# split "module@version" into a 2-element list [module, version]; version is ""
|
||||
# when absent
|
||||
function split_spec(spec: pointer) -> []pointer {
|
||||
var out = new []pointer
|
||||
let n = slen(spec)
|
||||
var at = -1
|
||||
var i = 0
|
||||
while i < n { if spec[i] == '@' { at = i }; i += 1 } # '@' = 64
|
||||
if at < 0 { push(out, spec); push(out, "") }
|
||||
else { push(out, sslice(spec, 0, at)); push(out, sslice(spec, at + 1, n)) }
|
||||
return out
|
||||
}
|
||||
|
||||
# rewrite package.ludic so `module` requires exactly `ver`, creating the file
|
||||
# with sane defaults when it does not exist yet.
|
||||
function set_require(module: pointer, ver: pointer) -> void {
|
||||
var txt = read_file("package.ludic")
|
||||
if txt == null {
|
||||
txt = "# package.ludic — Ludic package manifest" + nl()
|
||||
txt = txt + `package "app"` + nl()
|
||||
txt = txt + `version "0.0.0"` + nl()
|
||||
txt = txt + "kind source" + nl()
|
||||
}
|
||||
var out = ""
|
||||
let n = slen(txt)
|
||||
var i = 0
|
||||
var replaced = false
|
||||
while i < n {
|
||||
let line = line_at(txt, i)
|
||||
i = i + slen(line) + 1
|
||||
let ts = tok_line(line)
|
||||
if len(ts) >= 2 and ts[0] == "require" and ts[1] == module {
|
||||
out = out + `require "{module}" "{ver}"` + nl()
|
||||
replaced = true
|
||||
} else {
|
||||
out = out + line + nl()
|
||||
}
|
||||
}
|
||||
if not replaced { out = out + `require "{module}" "{ver}"` + nl() }
|
||||
write_file("package.ludic", out)
|
||||
}
|
||||
|
||||
# ---- the commands ------------------------------------------------------------
|
||||
|
||||
# ludic add <module>[@version] — add/update a dependency then install
|
||||
function cmd_pkg_add() -> int {
|
||||
if arg_total() < 3 { err("usage: ludic add <module>[@version]\n"); return 1 }
|
||||
let spec = split_spec(arg_n(2))
|
||||
let module = spec[0]
|
||||
var ver = spec[1]
|
||||
if slen(ver) == 0 {
|
||||
ver = latest_version(module)
|
||||
if slen(ver) == 0 { err(`x: {module} has no published version tags (git tag vX.Y.Z to publish)\n`); return 1 }
|
||||
print(`ludic add: {module} -> latest v{ver}`)
|
||||
}
|
||||
set_require(module, ver)
|
||||
return do_install(read_root_manifest())
|
||||
}
|
||||
|
||||
# ludic get — resolve + fetch + link every dependency in package.ludic, write lock
|
||||
function cmd_pkg_get() -> int {
|
||||
let txt = read_file("package.ludic")
|
||||
if txt == null { err("x: no package.ludic in the current directory (ludic add <module> to start one)\n"); return 1 }
|
||||
print("resolving dependencies (MVS)…")
|
||||
return do_install(parse_manifest(txt))
|
||||
}
|
||||
|
||||
# ludic update [module] — bump a dep (or all) to its latest published version, relock
|
||||
function cmd_pkg_update() -> int {
|
||||
let root = read_root_manifest()
|
||||
if len(root.deps) == 0 { err("x: package.ludic declares no dependencies\n"); return 1 }
|
||||
let only = argn(2, "")
|
||||
var i = 0
|
||||
while i < len(root.deps) {
|
||||
let d = root.deps[i]
|
||||
if only == "" or only == d.module {
|
||||
let latest = latest_version(d.module)
|
||||
if slen(latest) > 0 and ver_gt(latest, d.ver) {
|
||||
print(`ludic update: {d.module} {d.ver} -> {latest}`)
|
||||
set_require(d.module, latest)
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return do_install(read_root_manifest())
|
||||
}
|
||||
|
||||
# ludic verify — check every locked package against the store by content hash and
|
||||
# confirm the project view links to it
|
||||
function cmd_pkg_verify() -> int {
|
||||
let txt = read_file("package.lock.ludic")
|
||||
if txt == null { err("x: no package.lock.ludic (run ludic get first)\n"); return 1 }
|
||||
let locked = parse_lock(txt)
|
||||
if len(locked) == 0 { print("lockfile lists no packages"); return 0 }
|
||||
var bad_count = 0
|
||||
var i = 0
|
||||
while i < len(locked) {
|
||||
let m = locked[i]
|
||||
let want = m.hash
|
||||
let raw = strip_prefix(want, "sha256:")
|
||||
let dest = `{store_root()}{raw}`
|
||||
if not file_exists(dest) {
|
||||
print(` MISSING {m.module}@{m.ver} (store entry {want} absent)`)
|
||||
bad_count += 1
|
||||
} else {
|
||||
let got = dir_hash(dest)
|
||||
if got == raw {
|
||||
let link = `ludic_modules/{m.module}`
|
||||
if file_exists(link) { print(` ok {m.module}@{m.ver}`) }
|
||||
else { print(` UNLINKED {m.module}@{m.ver} (ludic_modules view missing — run ludic get)`); bad_count += 1 }
|
||||
} else {
|
||||
print(` TAMPERED {m.module}@{m.ver} (want sha256:{sslice(raw, 0, 12)}… got sha256:{sslice(got, 0, 12)}…)`)
|
||||
bad_count += 1
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
if bad_count == 0 { print(`verified {string(len(locked))} package(s) against the store`); return 0 }
|
||||
err(`x: {string(bad_count)} package(s) failed verification\n`)
|
||||
return 1
|
||||
}
|
||||
|
||||
# ludic vendor — copy the resolved packages into ./vendor for hermetic/offline
|
||||
# builds. Build against them with LUDIC_MODULES=vendor.
|
||||
function cmd_pkg_vendor() -> int {
|
||||
let txt = read_file("package.lock.ludic")
|
||||
if txt == null { err("x: no package.lock.ludic (run ludic get first)\n"); return 1 }
|
||||
let locked = parse_lock(txt)
|
||||
run("rm -rf vendor")
|
||||
var i = 0
|
||||
while i < len(locked) {
|
||||
let m = locked[i]
|
||||
let raw = strip_prefix(m.hash, "sha256:")
|
||||
let dest = `{store_root()}{raw}`
|
||||
if not file_exists(dest) { err(`x: {m.module}@{m.ver} not in the store — run ludic get\n`); return 1 }
|
||||
let vdir = `vendor/{m.module}`
|
||||
run(`mkdir -p "$(dirname {vdir})"`)
|
||||
run(`cp -R {dest} {vdir}`)
|
||||
print(` vendored {m.module}@{m.ver}`)
|
||||
i += 1
|
||||
}
|
||||
print(`vendored {string(len(locked))} package(s) into ./vendor — build offline with LUDIC_MODULES=vendor`)
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---- prebuilt binary packages (issue #64) -----------------------------------
|
||||
|
||||
# ludic build-lib [module.ludic] — compile a package's module to a prebuilt dylib for
|
||||
# the current target, under lib/<target>/. The developer runs this in the package
|
||||
# repo, commits the artifact, and `git tag`s — consumers fetch and link it.
|
||||
# The compiler is $LUDICC (default bin/ludicc) so this works both in-repo and,
|
||||
# with the flag set, from an installed toolchain.
|
||||
function cmd_pkg_build_lib() -> int {
|
||||
var src = argn(2, "")
|
||||
if src == "" { src = capture_line("ls *.ludic 2>/dev/null | grep -v package.ludic | head -1") }
|
||||
if src == "" or not file_exists(src) { err("usage: ludic build-lib <module.ludic> (run in the package directory)\n"); return 1 }
|
||||
let man = read_root_manifest()
|
||||
var name = ""
|
||||
if man.module != "" { name = capture_line(`basename {man.module}`) }
|
||||
if name == "" { name = capture_line(`basename {src} .ludic`) }
|
||||
let t = target_id()
|
||||
run(`mkdir -p lib/{t}`)
|
||||
let ludicc = getenv_or("LUDICC", "bin/ludicc")
|
||||
let ll = `{tmp_dir()}/buildlib.ll`
|
||||
if not shq(`{ludicc} --emit-module {src} -o {ll} 2>{tmp_dir()}/bl.err`) {
|
||||
err(`x: build-lib compile failed — {capture_line("tail -1 {tmp_dir()}/bl.err")}\n`); return 1
|
||||
}
|
||||
let out = `lib/{t}/lib{name}.dylib`
|
||||
# @rpath install name so a consumer resolves it via -rpath to the store dir
|
||||
if not shq(`{cc()} -O2 -Wno-override-module -dynamiclib -undefined dynamic_lookup -Wl,-install_name,@rpath/lib{name}.dylib {ll} -o {out} 2>{tmp_dir()}/bl.err`) {
|
||||
err(`x: build-lib link failed — {capture_line("tail -1 {tmp_dir()}/bl.err")}\n`); return 1
|
||||
}
|
||||
run(`rm -f {ll}`)
|
||||
print(`built {out} (target {t})`)
|
||||
print(`add to package.ludic: kind prebuilt / targets "{t}" — then commit lib/ and git tag`)
|
||||
return 0
|
||||
}
|
||||
|
||||
# ludic link-flags — print the clang link flags a consumer build needs to pull in the
|
||||
# project's prebuilt module dylibs (for the current target). Portable escape from
|
||||
# `ludic build` (which is in-repo only): any build system can splice `$(ludic link-flags)`
|
||||
# into its link step.
|
||||
function cmd_pkg_link_flags() -> int {
|
||||
let f = prebuilt_link_flags()
|
||||
print(s_trim(f))
|
||||
return 0
|
||||
}
|
||||
|
||||
# clang link flags that pull every locked prebuilt package's dylib (for the
|
||||
# current target) into a consumer build: the dylib itself, an rpath to its store
|
||||
# directory, and -export_dynamic so the module resolves the host's ludic_* ABI at
|
||||
# load. Empty when the project has no prebuilt dependencies.
|
||||
function prebuilt_link_flags() -> pointer {
|
||||
let txt = read_file("package.lock.ludic")
|
||||
if txt == null { return "" }
|
||||
let locked = parse_lock(txt)
|
||||
let t = target_id()
|
||||
var flags = ""
|
||||
var any = false
|
||||
var i = 0
|
||||
while i < len(locked) {
|
||||
let m = locked[i]
|
||||
if m.kind == "prebuilt" {
|
||||
let raw = strip_prefix(m.hash, "sha256:")
|
||||
let dir = `{store_root()}{raw}/lib/{t}`
|
||||
let libs = split_lines(capture(`ls {dir}/*.dylib 2>/dev/null`))
|
||||
var k = 0
|
||||
while k < len(libs) { flags = flags + " " + libs[k]; any = true; k += 1 }
|
||||
if len(libs) > 0 { flags += ` -Wl,-rpath,{dir}` }
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
if any { flags += " -Wl,-export_dynamic" }
|
||||
return flags
|
||||
}
|
||||
|
||||
# drop a leading `prefix` from `s` if present
|
||||
function strip_prefix(s: pointer, prefix: pointer) -> pointer {
|
||||
let pn = slen(prefix)
|
||||
if pn <= slen(s) and s_starts(s, prefix) { return sslice(s, pn, slen(s)) }
|
||||
return s
|
||||
}
|
||||
232
tools/ludic-cli/pkg_test.ludic
Normal file
232
tools/ludic-cli/pkg_test.ludic
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
# pkg_test.ludic — the package-manager suite (issue #63), hermetic and offline.
|
||||
#
|
||||
# It stands up throwaway git repositories under /tmp and points the package
|
||||
# manager at them with LUDIC_PKG_PROXY, so the whole fetch → MVS resolve →
|
||||
# content-addressed store → project link → compile → run → verify pipeline runs
|
||||
# with no network. Every check drives a fresh `bin/ludic` / `bin/ludicc` process the
|
||||
# way a real project would.
|
||||
|
||||
# write `body` to `path`, creating parent directories
|
||||
function pt_write(path: pointer, body: pointer) -> void {
|
||||
run(`mkdir -p "$(dirname {path})"`)
|
||||
write_file(path, body)
|
||||
}
|
||||
|
||||
# commit the current tree of `dir` and tag it `tag`
|
||||
function pt_commit_tag(dir: pointer, tag: pointer) -> void {
|
||||
run(`git -C {dir} add -A`)
|
||||
run(`git -C {dir} -c user.email=t@t.test -c user.name=tester commit -q -m {tag}`)
|
||||
run(`git -C {dir} tag {tag}`)
|
||||
}
|
||||
|
||||
function cmd_test_pkg() -> int {
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
print("== package manager (issue #63): fetch + MVS resolve + store + link + build ==")
|
||||
|
||||
if not shq("command -v git >/dev/null 2>&1") {
|
||||
skip("git unavailable — package-manager suite needs git")
|
||||
return report()
|
||||
}
|
||||
|
||||
let root = capture_line("pwd")
|
||||
let work = `{tmp_dir()}/pkg_test`
|
||||
let proxy = `{work}/proxy`
|
||||
let store = `{work}/store`
|
||||
let proj = `{work}/proj`
|
||||
run(`rm -rf {work}`)
|
||||
run(`mkdir -p {proxy} {store} {proj}`)
|
||||
|
||||
# ---- fixture packages -----------------------------------------------------
|
||||
# example.test/util: a leaf source package, two published versions.
|
||||
let util = `{proxy}/example.test/util`
|
||||
run(`git -C {util} init -q 2>/dev/null || ( mkdir -p {util} && git -C {util} init -q )`)
|
||||
pt_write(`{util}/package.ludic`, `package "example.test/util"` + nl() + `version "1.0.0"` + nl() + "kind source" + nl() + `provides "Util"` + nl())
|
||||
pt_write(`{util}/util.ludic`, "function util_tag() -> pointer { return \"u1\" }\n")
|
||||
pt_commit_tag(util, "v1.0.0")
|
||||
pt_write(`{util}/package.ludic`, `package "example.test/util"` + nl() + `version "1.2.0"` + nl() + "kind source" + nl() + `provides "Util"` + nl())
|
||||
pt_write(`{util}/util.ludic`, "function util_tag() -> pointer { return \"u12\" }\n")
|
||||
pt_commit_tag(util, "v1.2.0")
|
||||
|
||||
# example.test/greeter: requires util >=1.0.0, provides Greet.
|
||||
let greeter = `{proxy}/example.test/greeter`
|
||||
run(`mkdir -p {greeter} && git -C {greeter} init -q`)
|
||||
pt_write(`{greeter}/package.ludic`, `package "example.test/greeter"` + nl() + `version "1.0.0"` + nl() + "kind source" + nl() + `provides "Greet"` + nl() + `require "example.test/util" "1.0.0"` + nl())
|
||||
pt_write(`{greeter}/greet.ludic`, "function greet_hello() -> pointer { return \"hi\" }\n")
|
||||
pt_commit_tag(greeter, "v1.0.0")
|
||||
|
||||
# ---- the consumer project -------------------------------------------------
|
||||
# requires util 1.2.0 directly; greeter (which requires util 1.0.0) is added
|
||||
# too. MVS must pick util 1.2.0 — the greatest required minimum.
|
||||
pt_write(`{proj}/package.ludic`, `package "app"` + nl() + `version "0.0.0"` + nl() + `require "example.test/greeter" "1.0.0"` + nl() + `require "example.test/util" "1.2.0"` + nl())
|
||||
pt_write(`{proj}/app.ludic`, "program App {\n import \"example.test/greeter/greet.ludic\"\n import \"example.test/util/util.ludic\"\n entry { print(greet_hello()); print(util_tag()) }\n}\n")
|
||||
|
||||
let envp = `LUDIC_PKG_PROXY={proxy} LUDIC_STORE={store}`
|
||||
|
||||
# ---- ludic get: resolve, fetch, store, link, lock -----------------------------
|
||||
if shq(`( cd {proj} && {envp} {root}/bin/ludic get > {work}/get.out 2>&1 )`) {
|
||||
ok("ludic get resolves + fetches the dependency graph")
|
||||
} else {
|
||||
bad2("ludic get failed", capture_line(`tail -2 {work}/get.out`))
|
||||
}
|
||||
|
||||
var lock = read_file(`{proj}/package.lock.ludic`)
|
||||
if lock == null { lock = "" }
|
||||
if s_contains(lock, "example.test/greeter") and s_contains(lock, "example.test/util") {
|
||||
ok("package.lock.ludic pins the whole build list")
|
||||
} else { bad("lockfile is missing a resolved package") }
|
||||
|
||||
# MVS: util resolves to 1.2.0 (max of the 1.0.0 and 1.2.0 minimums), not 1.0.0
|
||||
if s_contains(lock, `"example.test/util" version "1.2.0"`) {
|
||||
ok("MVS selects util 1.2.0 (greatest required minimum)")
|
||||
} else { bad2("MVS picked the wrong util version", capture_line(`grep util {proj}/package.lock.ludic`)) }
|
||||
|
||||
if s_contains(lock, "sha256:") { ok("lockfile records content hashes") } else { bad("lockfile has no content hash") }
|
||||
|
||||
# the project view links into the content-addressed store
|
||||
if file_exists(`{proj}/ludic_modules/example.test/greeter/greet.ludic`) and file_exists(`{proj}/ludic_modules/example.test/util/util.ludic`) {
|
||||
ok("ludic_modules/ links the store entries into the project")
|
||||
} else { bad("ludic_modules view was not linked") }
|
||||
|
||||
# ---- compile + run the consumer against the fetched packages --------------
|
||||
if shq(`( cd {proj} && LUDIC_MODULES=ludic_modules {root}/bin/ludicc app.ludic -o app > {work}/build.out 2>&1 )`) {
|
||||
ok("consumer compiles against the linked packages (do_import module-root fallback)")
|
||||
let got = capture_line(`( cd {proj} && ./app )`)
|
||||
if got == "hi u12" { ok("the running program uses the MVS-selected package code (\"hi u12\")") }
|
||||
else { bad2("program output mismatch", `got [{got}] want [hi u12]`) }
|
||||
} else {
|
||||
bad2("consumer build failed", capture_line(`tail -2 {work}/build.out`))
|
||||
}
|
||||
|
||||
# ---- ludic verify: content integrity against the store ------------------------
|
||||
if shq(`( cd {proj} && {envp} {root}/bin/ludic verify > {work}/verify.out 2>&1 )`) {
|
||||
ok("ludic verify passes on an untouched store")
|
||||
} else { bad2("ludic verify failed unexpectedly", capture_line(`tail -2 {work}/verify.out`)) }
|
||||
|
||||
# tamper with a stored file — verify must now flag it
|
||||
let uhash = capture_line(`grep 'example.test/util' {proj}/package.lock.ludic | sed 's/.*sha256://; s/\".*//'`)
|
||||
run(`echo tampered >> {store}/{uhash}/util.ludic`)
|
||||
if not shq(`( cd {proj} && {envp} {root}/bin/ludic verify > {work}/verify2.out 2>&1 )`) {
|
||||
ok("ludic verify detects a tampered store entry")
|
||||
} else { bad("ludic verify missed a tampered store entry") }
|
||||
# heal the store: a content-addressed entry is keyed by its hash-named dir, so
|
||||
# `ludic get` trusts an existing one — drop it first, then re-fetch a clean copy.
|
||||
run(`rm -rf {store}/{uhash}`)
|
||||
run(`( cd {proj} && {envp} {root}/bin/ludic get > /dev/null 2>&1 )`)
|
||||
if shq(`( cd {proj} && {envp} {root}/bin/ludic verify > {work}/verify3.out 2>&1 )`) {
|
||||
ok("re-fetching heals a tampered store entry (ludic verify green again)")
|
||||
} else { bad2("store did not heal after re-fetch", capture_line(`tail -1 {work}/verify3.out`)) }
|
||||
|
||||
# ---- a package that exports ECS: a component, a model and a system --------
|
||||
# Ludic is AOT, so a source package's declarations compile straight into the
|
||||
# consumer's compile-time ECS — no ABI seam. This proves a consumer *game* can
|
||||
# use a package's property (component), model, @OnSpawn handler (system) and a
|
||||
# plain function together. The engine runtime (runtime/native/*.ludic) resolves
|
||||
# relative to the compile CWD, so — exactly like `ludic build` — the game is compiled
|
||||
# from the repo root with the package linked in via an absolute LUDIC_MODULES.
|
||||
let epkg = `{proxy}/example.test/enginepkg`
|
||||
run(`mkdir -p {epkg} && git -C {epkg} init -q`)
|
||||
pt_write(`{epkg}/package.ludic`, `package "example.test/enginepkg"` + nl() + `version "1.0.0"` + nl() + "kind source" + nl() + `provides "EnginePkg"` + nl())
|
||||
pt_write(`{epkg}/lib.ludic`, "property Health { hp: int = 0, max: int = 0 }\nmodel Unit { Health }\n@Public @OnSpawn(Unit) handler Init { Health.hp = Health.max }\nfunction bonus() -> int { return 7 }\n")
|
||||
pt_commit_tag(epkg, "v1.0.0")
|
||||
let proj2 = `{work}/proj2`
|
||||
pt_write(`{proj2}/package.ludic`, `package "game"` + nl() + `version "0.0.0"` + nl() + `require "example.test/enginepkg" "1.0.0"` + nl())
|
||||
pt_write(`{proj2}/game.ludic`, "program G {\n import \"example.test/enginepkg/lib.ludic\"\n entry {\n spawn Unit { Health { max: 50 } }\n let H = world_prop_id(\"Health\")\n let hp = world_field_id(H, \"hp\")\n let e = world_query_next(H, 0)\n print(world_get(e, H, hp))\n print(bonus())\n }\n}\n")
|
||||
if shq(`( cd {proj2} && {envp} {root}/bin/ludic get > {work}/get2.out 2>&1 )`) {
|
||||
if shq(`( cd {root} && LUDIC_MODULES={proj2}/ludic_modules {root}/bin/ludicc --headless {proj2}/game.ludic -o {proj2}/game > {work}/gbuild.out 2>&1 )`) {
|
||||
let got = capture_line(`{proj2}/game`)
|
||||
if got == "50 7" {
|
||||
ok("a consumer game uses the package's component + model + system + function (\"50 7\")")
|
||||
} else { bad2("imported ECS produced wrong output", `got [{got}] want [50 7]`) }
|
||||
} else { bad2("game consuming an ECS package failed to build", capture_line(`tail -2 {work}/gbuild.out`)) }
|
||||
} else { bad2("ludic get for the ECS package failed", capture_line(`tail -1 {work}/get2.out`)) }
|
||||
|
||||
# ---- namespace collision policy (v1: hard error) --------------------------
|
||||
let dupe = `{proxy}/example.test/dupe`
|
||||
run(`mkdir -p {dupe} && git -C {dupe} init -q`)
|
||||
pt_write(`{dupe}/package.ludic`, `package "example.test/dupe"` + nl() + `version "1.0.0"` + nl() + "kind source" + nl() + `provides "Util"` + nl())
|
||||
pt_write(`{dupe}/dupe.ludic`, "function dupe_tag() -> pointer { return \"d\" }\n")
|
||||
pt_commit_tag(dupe, "v1.0.0")
|
||||
if not shq(`( cd {proj} && {envp} {root}/bin/ludic add example.test/dupe@1.0.0 > {work}/dupe.out 2>&1 )`) and shq(`grep -q collision {work}/dupe.out`) {
|
||||
ok("two packages claiming the same Foo.* namespace is a hard error")
|
||||
} else { bad2("collision not rejected", capture_line(`tail -1 {work}/dupe.out`)) }
|
||||
# drop the bad dep again so the manifest is clean
|
||||
pt_write(`{proj}/package.ludic`, `package "app"` + nl() + `version "0.0.0"` + nl() + `require "example.test/greeter" "1.0.0"` + nl() + `require "example.test/util" "1.2.0"` + nl())
|
||||
|
||||
# ---- prebuilt target matrix ----------------------------------------------
|
||||
let pb = `{proxy}/example.test/prebuilt`
|
||||
run(`mkdir -p {pb} && git -C {pb} init -q`)
|
||||
pt_write(`{pb}/package.ludic`, `package "example.test/prebuilt"` + nl() + `version "1.0.0"` + nl() + "kind prebuilt" + nl() + `provides "Blob"` + nl() + `targets "made-up-target"` + nl())
|
||||
pt_write(`{pb}/README`, "a prebuilt lib shipping no artifact for this host\n")
|
||||
pt_commit_tag(pb, "v1.0.0")
|
||||
if not shq(`( cd {proj} && {envp} {root}/bin/ludic add example.test/prebuilt@1.0.0 > {work}/pb.out 2>&1 )`) and shq(`grep -q 'prebuilt lib' {work}/pb.out`) {
|
||||
ok("a prebuilt lib with no artifact for the build target is a hard error")
|
||||
} else { bad2("prebuilt target matrix not enforced", capture_line(`tail -1 {work}/pb.out`)) }
|
||||
pt_write(`{proj}/package.ludic`, `package "app"` + nl() + `version "0.0.0"` + nl() + `require "example.test/greeter" "1.0.0"` + nl() + `require "example.test/util" "1.2.0"` + nl())
|
||||
|
||||
# ---- ludic vendor: hermetic copy ---------------------------------------------
|
||||
run(`( cd {proj} && {envp} {root}/bin/ludic get > /dev/null 2>&1 )`)
|
||||
if shq(`( cd {proj} && {envp} {root}/bin/ludic vendor > {work}/vendor.out 2>&1 )`) and file_exists(`{proj}/vendor/example.test/util/util.ludic`) {
|
||||
ok("ludic vendor copies the resolved packages into ./vendor")
|
||||
} else { bad2("ludic vendor failed", capture_line(`tail -1 {work}/vendor.out`)) }
|
||||
|
||||
# ---- package-declarable engine-system + namespace (issue #62) -------------
|
||||
# A source package registers a compile-time engine system (@EngineSystem) and a
|
||||
# Foo.* namespace (@Namespace) with no compiler edit; a consumer game imports it
|
||||
# and both take effect. Source path → runs everywhere (not dylib/macOS-gated).
|
||||
let cp = `{proxy}/example.test/coachpack`
|
||||
run(`mkdir -p {cp} && git -C {cp} init -q`)
|
||||
pt_write(`{cp}/package.ludic`, `package "example.test/coachpack"` + nl() + `version "1.0.0"` + nl() + "kind source" + nl() + `provides "Coach"` + nl())
|
||||
pt_write(`{cp}/coach.ludic`, "property Score { value: int = 0 }\n@EngineSystem(Score, Update) function esys_score() -> void {\n let P = world_prop_id(\"Score\")\n let e = world_query_next(P, 0)\n if e >= 0 { world_set(e, P, 0, world_get(e, P, 0) + 1) }\n}\n@Namespace(Coach) function coach_bonus() -> int { return 99 }\n")
|
||||
pt_commit_tag(cp, "v1.0.0")
|
||||
let proj4 = `{work}/proj4`
|
||||
pt_write(`{proj4}/package.ludic`, `package "game"` + nl() + `version "0.0.0"` + nl() + `require "example.test/coachpack" "1.0.0"` + nl())
|
||||
pt_write(`{proj4}/game.ludic`, "program Game {\n import \"example.test/coachpack/coach.ludic\"\n model Player { Score }\n @OnSpawn(Player) handler Init { }\n var frames: int = 0\n handler Boot phase Start { let e = world_spawn(world_model_id(\"Player\")) }\n handler Run phase Update {\n frames = frames + 1\n if frames >= 5 {\n let P = world_prop_id(\"Score\")\n let e = world_query_next(P, 0)\n print(world_get(e, P, 0))\n print(Coach.bonus())\n quit()\n }\n }\n}\n")
|
||||
if shq(`( cd {proj4} && {envp} {root}/bin/ludic get > {work}/get4.out 2>&1 )`) {
|
||||
run(`LUDIC_HOME={root} LUDIC_MODULES={proj4}/ludic_modules {root}/bin/ludicc --headless {proj4}/game.ludic -o {proj4}/game 2>{work}/gc4.err`)
|
||||
if file_exists(`{proj4}/game`) {
|
||||
let got = capture_line(`printf 'aaaaa' | {proj4}/game`)
|
||||
if got == "4 99" {
|
||||
ok("a package registers an engine-system (@EngineSystem) + a namespace (@Namespace) with no compiler edit (\"4 99\")")
|
||||
} else { bad2("package hooks produced wrong output", `got [{got}] want [4 99]`) }
|
||||
} else { bad2("consumer of a @EngineSystem/@Namespace package failed to build", capture_line(`tail -1 {work}/gc4.err`)) }
|
||||
} else { bad2("ludic get for the coach package failed", capture_line(`tail -1 {work}/get4.out`)) }
|
||||
|
||||
# ---- prebuilt binary package (issue #64): exported function + system + component
|
||||
# A module is compiled to a dylib (ludic build-lib), fetched as a `kind prebuilt`
|
||||
# dependency, and linked into a consumer game that never saw its source. At load
|
||||
# the module registers a dynamic component and an @System(Update); the consumer
|
||||
# also calls an exported function via `extern`. Native/dylib only → macOS-gated.
|
||||
if not is_darwin() {
|
||||
skip("prebuilt binary packages (dylib) — native/macOS only")
|
||||
return report()
|
||||
}
|
||||
let tgt = `native-{capture_line("uname -m")}`
|
||||
let mp = `{proxy}/example.test/manapack`
|
||||
run(`mkdir -p {mp} && git -C {mp} init -q`)
|
||||
pt_write(`{mp}/package.ludic`, `package "example.test/manapack"` + nl() + `version "1.0.0"` + nl() + "kind prebuilt" + nl() + `provides "Mana"` + nl() + `targets "{tgt}"` + nl())
|
||||
pt_write(`{mp}/manapack.ludic`, "program ManaPack {\n function module_init() -> void { world_register_prop(\"Mana\", 2) }\n @System(Update) function mana_regen() -> void {\n let M = world_prop_id(\"Mana\")\n let e = world_query_next(M, 0)\n if e >= 0 { let v = world_get(e, M, 0); world_set(e, M, 0, v + 1) }\n }\n function mana_bonus() -> int { return 42 }\n}\n")
|
||||
if shq(`( cd {mp} && LUDICC={root}/bin/ludicc {root}/bin/ludic build-lib manapack.ludic > {work}/bl.out 2>&1 )`) and file_exists(`{mp}/lib/{tgt}/libmanapack.dylib`) {
|
||||
ok("ludic build-lib compiles a package module to a per-target dylib")
|
||||
} else { bad2("ludic build-lib failed", capture_line(`tail -1 {work}/bl.out`)) }
|
||||
pt_commit_tag(mp, "v1.0.0")
|
||||
|
||||
let proj3 = `{work}/proj3`
|
||||
pt_write(`{proj3}/package.ludic`, `package "game"` + nl() + `version "0.0.0"` + nl() + `require "example.test/manapack" "1.0.0"` + nl())
|
||||
pt_write(`{proj3}/game.ludic`, "program Game {\n property Dummy { x: int = 0 }\n model Ent { Dummy }\n @OnSpawn(Ent) handler Init { }\n extern function mana_bonus() -> int = \"fn_mana_bonus\"\n var frames: int = 0\n handler Boot phase Start {\n let e = world_spawn(world_model_id(\"Ent\"))\n let M = world_prop_id(\"Mana\")\n world_attach_dyn(e, M)\n world_set(e, M, 0, 0)\n }\n handler Run phase Update {\n frames = frames + 1\n if frames >= 4 {\n let M = world_prop_id(\"Mana\")\n let e = world_query_next(M, 0)\n print(world_get(e, M, 0))\n print(mana_bonus())\n quit()\n }\n }\n}\n")
|
||||
if shq(`( cd {proj3} && {envp} {root}/bin/ludic get > {work}/get3.out 2>&1 )`) and file_exists(`{proj3}/ludic_modules/example.test/manapack/lib/{tgt}/libmanapack.dylib`) {
|
||||
ok("ludic get fetches + links a prebuilt (kind prebuilt) package for the target")
|
||||
} else { bad2("prebuilt ludic get failed", capture_line(`tail -1 {work}/get3.out`)) }
|
||||
# compile the ECS consumer from the repo root (so runtime/native resolves), then
|
||||
# link the module dylib via `ludic link-flags` (a real external build's link step).
|
||||
run(`LUDIC_HOME={root} {root}/bin/ludicc --headless {proj3}/game.ludic --emit-llvm -o {proj3}/game.ll 2>{work}/gc.err`)
|
||||
let flags = capture_line(`( cd {proj3} && LUDIC_STORE={store} {root}/bin/ludic link-flags )`)
|
||||
if shq(`{cc()} -O2 -Wno-override-module {proj3}/game.ll {flags} -o {proj3}/game 2>{work}/gl.err`) {
|
||||
let got = capture_line(`printf 'aaaaaa' | {proj3}/game`)
|
||||
if got == "3 42" {
|
||||
ok("a consumer links the prebuilt module: its system mutates the world + its function is callable (\"3 42\")")
|
||||
} else { bad2("prebuilt consumer output mismatch", `got [{got}] want [3 42]`) }
|
||||
} else { bad2("prebuilt consumer link failed", capture_line(`tail -1 {work}/gl.err`)) }
|
||||
|
||||
return report()
|
||||
}
|
||||
256
tools/ludic-cli/prelude.ludic
Normal file
256
tools/ludic-cli/prelude.ludic
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
# prelude.ludic — the shared runtime for `ludic`, the command-line interface.
|
||||
#
|
||||
# `ludic` is the one tool a user of the language ever runs: it creates projects,
|
||||
# compiles and runs them, resolves packages, formats, tests, and (under
|
||||
# `ludic dev`) drives every build/bootstrap/release task of the toolchain repo
|
||||
# itself. It is a single native binary written in Ludic and compiled by Ludic,
|
||||
# driving clang, the compiler and the unix tools through `run`. This fragment is
|
||||
# the tiny standard library the commands lean on: process control, file IO,
|
||||
# string trimming and a colored PASS/FAIL test harness. It carries no ECS, so it
|
||||
# links as a plain CLI program.
|
||||
#
|
||||
# The user-facing commands work from any directory. The `ludic dev` tasks run
|
||||
# relative to the current directory and expect the toolchain repo root.
|
||||
|
||||
# ---- file IO ----------------------------------------------------------------
|
||||
|
||||
# read a whole file into a fresh NUL-terminated buffer (null if it cannot open)
|
||||
function read_file(path: pointer) -> pointer {
|
||||
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
|
||||
function write_file(path: pointer, s: pointer) -> bool {
|
||||
let f = file_open(path, "wb")
|
||||
if (f == null) { return false }
|
||||
file_write(f, s, len(s))
|
||||
file_close(f)
|
||||
return true
|
||||
}
|
||||
|
||||
function file_exists(path: pointer) -> bool { return shq(`test -e {path}`) }
|
||||
function is_exec(path: pointer) -> bool { return shq(`test -x {path}`) }
|
||||
# is `a` newer than `b` (like the shell's `-nt`)?
|
||||
function newer(a: pointer, b: pointer) -> bool { return shq(`test {a} -nt {b}`) }
|
||||
|
||||
# ---- process control --------------------------------------------------------
|
||||
|
||||
# `run` returns the raw wait status; the program's exit code is the high byte.
|
||||
function exit_code(st: int) -> int { return (st >> 8) & 255 }
|
||||
# run a command, returning its exit code (0 = success)
|
||||
function sh(cmd: pointer) -> int { return exit_code(run(cmd)) }
|
||||
# run a command, true when it succeeded
|
||||
function shq(cmd: pointer) -> bool { return exit_code(run(cmd)) == 0 }
|
||||
|
||||
# ---- scratch files ------------------------------------------------------------
|
||||
# Every scratch file the runner writes lives under one per-process directory
|
||||
# (`$TMPDIR/x_<pid>`), so `ludic dev test` and an `x check-*` can run side by side without
|
||||
# clobbering each other's captures. main removes it on the way out.
|
||||
var x_tmp: pointer = null
|
||||
|
||||
function tmp_dir() -> pointer {
|
||||
if x_tmp == null {
|
||||
var base = Os.temp_dir()
|
||||
if len(base) > 1 and base[len(base) - 1] == '/' { base = base[0..len(base) - 1] } # macOS: TMPDIR ends in '/'
|
||||
x_tmp = `{base}/ludic_{Os.pid()}`
|
||||
run(`mkdir -p {x_tmp}`)
|
||||
}
|
||||
return x_tmp
|
||||
}
|
||||
|
||||
# a scratch path under tmp_dir(): tmp_path("foo.out")
|
||||
function tmp_path(name: pointer) -> pointer { return `{tmp_dir()}/{name}` }
|
||||
|
||||
# remove the scratch directory (idempotent; a no-op when nothing was written).
|
||||
# LUDIC_KEEP_TMP=1 leaves it in place, and says where, for debugging a failing case.
|
||||
function tmp_cleanup() -> void {
|
||||
if x_tmp == null { return }
|
||||
if getenv_or("LUDIC_KEEP_TMP", "0") == "1" { print(`scratch kept: {x_tmp}`) }
|
||||
else { run(`rm -rf {x_tmp}`) }
|
||||
x_tmp = null
|
||||
}
|
||||
|
||||
# run `cmd` and return its stdout (stderr discarded). Never null.
|
||||
function capture(cmd: pointer) -> pointer {
|
||||
let tmp = tmp_path("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/ *$//')`.
|
||||
function capture_line(cmd: pointer) -> pointer {
|
||||
return capture(`{cmd} | tr '\n' ' ' | sed 's/ *$//'`)
|
||||
}
|
||||
|
||||
# the last line of a command's output (for one-line error messages)
|
||||
function capture_tail(cmd: pointer) -> pointer {
|
||||
return capture(`{cmd} 2>&1 | tail -1`)
|
||||
}
|
||||
|
||||
function getenv_or(name: pointer, dflt: pointer) -> pointer {
|
||||
let v = getenv(name)
|
||||
if (v == null) { return dflt }
|
||||
return v
|
||||
}
|
||||
|
||||
# flatten a relative example path into a filesystem-safe token: '/' -> '_', so a
|
||||
# categorised path like "games/snake" yields a single-segment temp name
|
||||
# ("games_snake") that never implies a missing /tmp subdirectory.
|
||||
function flat(p: pointer) -> pointer {
|
||||
let n = len(p)
|
||||
let b = bytes(n + 1)
|
||||
for i in 0 .. n {
|
||||
var c = p[i]
|
||||
if (c == '/') { c = 95 } # '/' (47) -> '_' (95)
|
||||
b[i] = c
|
||||
}
|
||||
b[n] = 0
|
||||
return b
|
||||
}
|
||||
|
||||
# ---- stdout helpers ---------------------------------------------------------
|
||||
|
||||
# write `s` with no trailing newline (print() always adds one)
|
||||
function out(s: pointer) -> void { file_write(file_stdout(), s, len(s)) }
|
||||
function err(s: pointer) -> void { file_write(file_stderr(), s, len(s)) }
|
||||
|
||||
# an ESC byte — the lexer has no \033, so build it by hand
|
||||
function esc() -> pointer { let b = bytes(2); b[0] = 27; b[1] = 0; return b }
|
||||
function c_green() -> pointer { return esc() + "[32m" }
|
||||
function c_red() -> pointer { return esc() + "[31m" }
|
||||
function c_reset() -> pointer { return esc() + "[0m" }
|
||||
|
||||
# ---- the PASS/FAIL test harness ---------------------------------------------
|
||||
|
||||
var PASS: int = 0
|
||||
var FAIL: int = 0
|
||||
|
||||
function ok(msg: pointer) -> void {
|
||||
PASS += 1
|
||||
print(` {c_green()}PASS{c_reset()} {msg}`)
|
||||
}
|
||||
function bad(msg: pointer) -> void {
|
||||
FAIL += 1
|
||||
print(` {c_red()}FAIL{c_reset()} {msg}`)
|
||||
}
|
||||
function bad2(msg: pointer, detail: pointer) -> void {
|
||||
bad(msg)
|
||||
print(` {detail}`)
|
||||
}
|
||||
# assert two strings equal, reporting the mismatch
|
||||
function check(label: pointer, got: pointer, want: pointer) -> void {
|
||||
if (got == want) { ok(label) }
|
||||
else { bad2(label, `expected [{want}] got [{got}]`) }
|
||||
}
|
||||
|
||||
# ---- host platform ----------------------------------------------------------
|
||||
# A few cases exercise macOS-specific runtime ABI — Cocoa windowing, the BSD
|
||||
# utsname/dirent layout — or compare against renders blessed on macOS. The
|
||||
# self-hosted compiler and its C-free bootstrap are host-neutral (they produce
|
||||
# byte-identical IR on any host), so the bulk of the suite runs anywhere; only
|
||||
# these platform-bound cases are skipped — visibly, never silently — when the
|
||||
# suite runs off Darwin. That lets a Linux CI runner gate every portable
|
||||
# guarantee without red from the parts that are macOS-only today.
|
||||
function host_os() -> pointer { return capture_line("uname -s") }
|
||||
function is_darwin() -> bool { return host_os() == "Darwin" }
|
||||
function skip(msg: pointer) -> void { print(` skip {msg}`) }
|
||||
|
||||
# print the "== N passed, M failed ==" footer and return the process exit code
|
||||
function report() -> int {
|
||||
print("")
|
||||
print(`== {string(PASS)} passed, {string(FAIL)} failed ==`)
|
||||
if (FAIL == 0) { return 0 }
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---- command arguments -------------------------------------------------------
|
||||
#
|
||||
# A command reads its own arguments as arg_n(1), arg_n(2)… whether it was
|
||||
# reached as `ludic get` or as `ludic dev docs-check DIR`. main sets the shift
|
||||
# once per dispatch, so no command has to know how deep its namespace is —
|
||||
# getting that wrong is how `docs-check DIR` silently checked the wrong
|
||||
# directory.
|
||||
var g_shift: int = 0
|
||||
|
||||
function arg_n(i: int) -> pointer { return arg(i + g_shift) }
|
||||
function arg_total() -> int { return arg_count() - g_shift }
|
||||
|
||||
# ---- the toolchain install ---------------------------------------------------
|
||||
#
|
||||
# The user-facing commands run anywhere, so they cannot assume a `bin/` in the
|
||||
# current directory the way the old repo-root-only task runner did. They ask
|
||||
# here instead, and get the same answer in both layouts that exist: an install
|
||||
# (~/.ludic/bin/ludic, root ~/.ludic) and a checkout of the toolchain repo
|
||||
# (bin/ludic, root the repo). The compiler derives its own root by the identical
|
||||
# rule (see ludic_home() in selfhost/frontend/parse.ludic), so the two never
|
||||
# disagree about where the runtime and the bundled packages live.
|
||||
|
||||
var g_home: pointer = null
|
||||
|
||||
# the directory holding this binary, with a trailing '/'. argv[0] carries no
|
||||
# directory when the CLI was found on $PATH, which is the normal case for an
|
||||
# install — ask the shell where it found it.
|
||||
function self_dir() -> pointer {
|
||||
var a0 = arg(0)
|
||||
var slash = -1
|
||||
var i = 0
|
||||
while a0[i] != 0 { if a0[i] == '/' { slash = i }; i += 1 }
|
||||
if slash < 0 {
|
||||
let found = capture_line(`command -v {a0}`)
|
||||
if found == "" { return "" }
|
||||
a0 = found
|
||||
slash = -1
|
||||
i = 0
|
||||
while a0[i] != 0 { if a0[i] == '/' { slash = i }; i += 1 }
|
||||
if slash < 0 { return "" }
|
||||
}
|
||||
return a0[0..slash + 1]
|
||||
}
|
||||
|
||||
# The toolchain install root, with a trailing '/', or "" for the current
|
||||
# directory. $LUDIC_HOME wins; otherwise the binary's own directory decides, and
|
||||
# a directory named `bin` means the root is its parent.
|
||||
function ludic_home() -> pointer {
|
||||
if g_home != null { return g_home }
|
||||
let env = getenv("LUDIC_HOME")
|
||||
if env != null {
|
||||
var e = env
|
||||
if len(e) > 0 and e[len(e) - 1] != '/' { e = e + "/" }
|
||||
g_home = e
|
||||
return g_home
|
||||
}
|
||||
var d = self_dir()
|
||||
if len(d) >= 4 and d[len(d) - 4..len(d)] == "bin/" { d = d[0..len(d) - 4] }
|
||||
# a source checkout the CLI was run from elsewhere in: prefer a ./bin here
|
||||
if not is_exec(`{d}bin/ludicc`) and is_exec("bin/ludicc") { d = "" }
|
||||
g_home = d
|
||||
return g_home
|
||||
}
|
||||
|
||||
# the path to a toolchain binary (ludicc, ludic-fmt, ludic-lsp). Falls back to
|
||||
# the bare name — on $PATH — when the install root has no bin/ of its own.
|
||||
function tool(name: pointer) -> pointer {
|
||||
let p = `{ludic_home()}bin/{name}`
|
||||
if is_exec(p) { return p }
|
||||
return name
|
||||
}
|
||||
|
||||
function ludicc() -> pointer { return tool("ludicc") }
|
||||
|
||||
# true in a checkout of the toolchain repo itself, where the `ludic dev` tasks
|
||||
# have something to work on.
|
||||
function in_toolchain_repo() -> bool {
|
||||
return file_exists("selfhost/ludicc.seed.ll") and file_exists("tools/ludic-cli/main.ludic")
|
||||
}
|
||||
397
tools/ludic-cli/project.ludic
Normal file
397
tools/ludic-cli/project.ludic
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
# project.ludic — the commands a user of the language runs on their own project:
|
||||
# new, build, run, test, fmt, lsp, doctor, upgrade.
|
||||
#
|
||||
# The shape of a project is a convention, not a configuration file: a manifest
|
||||
# (package.ludic), the program under src/, tests under tests/, assets under
|
||||
# assets/, output in build/. `ludic build` in such a directory needs no
|
||||
# arguments; given a path it compiles that file instead, so a single .ludic
|
||||
# lying around is still a one-command build.
|
||||
#
|
||||
# None of this assumes a checkout of the toolchain — the compiler, the engine
|
||||
# runtime and the bundled ludic.* packages are found through ludic_home().
|
||||
|
||||
# ---- project discovery ------------------------------------------------------
|
||||
|
||||
# the `package "name"` line of ./package.ludic, or "" when there is no manifest
|
||||
function manifest_name() -> pointer {
|
||||
let txt = read_file("package.ludic")
|
||||
if txt == null { return "" }
|
||||
let m = parse_manifest(txt)
|
||||
return m.module
|
||||
}
|
||||
|
||||
# the name to give the built binary: the manifest's module (its last dotted
|
||||
# segment, so ludic.snake builds `snake`), else the entry file's base name.
|
||||
function project_name(entry: pointer) -> pointer {
|
||||
let mod = manifest_name()
|
||||
if mod != "" {
|
||||
var last = -1
|
||||
var i = 0
|
||||
while mod[i] != 0 { if mod[i] == '.' { last = i }; i += 1 }
|
||||
if last >= 0 { return mod[last + 1..i] }
|
||||
return mod
|
||||
}
|
||||
return capture_line(`basename {entry} .ludic`)
|
||||
}
|
||||
|
||||
# The program to compile. An explicit path always wins; otherwise the
|
||||
# conventional entry points are tried in order, and finally a lone .ludic file in
|
||||
# the current directory (so a scratch directory needs no ceremony). "" when
|
||||
# nothing was found.
|
||||
function find_entry(explicit: pointer) -> pointer {
|
||||
if explicit != "" { return explicit }
|
||||
if file_exists("src/main.ludic") { return "src/main.ludic" }
|
||||
if file_exists("main.ludic") { return "main.ludic" }
|
||||
let one = capture_line("ls *.ludic 2>/dev/null")
|
||||
if one != "" and not shq("ls *.ludic 2>/dev/null | tail -n +2 | grep -q .") { return one }
|
||||
return ""
|
||||
}
|
||||
|
||||
# report the "no entry point" case the same way everywhere
|
||||
function no_entry() -> int {
|
||||
err("ludic: no program to build here.\n")
|
||||
err(" expected src/main.ludic (or main.ludic), or name a file: ludic build path/to/game.ludic\n")
|
||||
err(" start a new project with: ludic new <name>\n")
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---- ludic new --------------------------------------------------------------
|
||||
|
||||
# The smallest program worth running: a window, an entity moving under the ECS,
|
||||
# text, and a key to quit. A new project should do something the moment it is
|
||||
# created — an empty main() teaches nothing about the language.
|
||||
#
|
||||
# Backticks interpolate, and this text is full of braces, so it is built by
|
||||
# concatenation. The template lives here rather than in a data file because the
|
||||
# CLI must scaffold from an install where no repo tree exists.
|
||||
function template_main(name: pointer) -> pointer {
|
||||
var b = "# src/main.ludic — the program. Build and play it with: ludic run\n"
|
||||
b = b + "program " + name + " {\n"
|
||||
b = b + "\n"
|
||||
b = b + " property Pos { x: int = 0, y: int = 0 }\n"
|
||||
b = b + " property Vel { dx: int = 0, dy: int = 0 }\n"
|
||||
b = b + "\n"
|
||||
b = b + " handler Boot phase Start {\n"
|
||||
b = b + " spawn Ball { Pos { x: 152, y: 112 }, Vel { dx: 2, dy: 1 } }\n"
|
||||
b = b + " }\n"
|
||||
b = b + "\n"
|
||||
b = b + " handler Move phase FixedUpdate {\n"
|
||||
b = b + " for (p, v) in query [Pos, Vel] {\n"
|
||||
b = b + " p.x = p.x + v.dx\n"
|
||||
b = b + " p.y = p.y + v.dy\n"
|
||||
b = b + " if p.x < 0 or p.x > 304 { v.dx = 0 - v.dx }\n"
|
||||
b = b + " if p.y < 0 or p.y > 224 { v.dy = 0 - v.dy }\n"
|
||||
b = b + " }\n"
|
||||
b = b + " }\n"
|
||||
b = b + "\n"
|
||||
b = b + " handler Draw phase Render {\n"
|
||||
b = b + " Screen.clear(0x0d1020)\n"
|
||||
b = b + " for (p) in query [Pos] {\n"
|
||||
b = b + " Screen.fill_rectangle(x: p.x, y: p.y, width: 16, height: 16, color: Color.Crimson)\n"
|
||||
b = b + " }\n"
|
||||
b = b + " Screen.draw_text(x: 8, y: 8, text: \"HELLO LUDIC\", color: Color.White, scale: 1)\n"
|
||||
b = b + " Screen.show()\n"
|
||||
b = b + " }\n"
|
||||
b = b + "\n"
|
||||
b = b + " handler Keys phase Input {\n"
|
||||
b = b + " if Input.key() == 'q' { quit() }\n"
|
||||
b = b + " }\n"
|
||||
b = b + "}\n"
|
||||
return b
|
||||
}
|
||||
|
||||
function template_test(name: pointer) -> pointer {
|
||||
var b = "# A test program is a set of test blocks; ludic test compiles and runs every\n"
|
||||
b = b + "# tests/*.ludic and reports the results.\n"
|
||||
b = b + "program " + name + "Spec {\n"
|
||||
b = b + " test \"arithmetic still works\" {\n"
|
||||
b = b + " expect_eq(2 + 2, 4)\n"
|
||||
b = b + " }\n"
|
||||
b = b + "}\n"
|
||||
return b
|
||||
}
|
||||
|
||||
function template_manifest(name: pointer) -> pointer {
|
||||
return "package \"" + name + "\"\nversion \"0.1.0\"\nkind source\n"
|
||||
}
|
||||
|
||||
function template_gitignore() -> pointer {
|
||||
return "build/\nludic_modules/\nvendor/\n*.ll\n"
|
||||
}
|
||||
|
||||
function template_readme(name: pointer) -> pointer {
|
||||
var b = "# " + name + "\n"
|
||||
b = b + "\n"
|
||||
b = b + "A game written in Ludic (https://workshopsoft.pages.workshopsoft.io/ludic/).\n"
|
||||
b = b + "\n"
|
||||
b = b + " ludic run # build and play\n"
|
||||
b = b + " ludic test # run the tests\n"
|
||||
return b
|
||||
}
|
||||
|
||||
# ludic new <name> — scaffold a project that builds and runs as it stands.
|
||||
function cmd_new() -> int {
|
||||
if arg_total() < 3 {
|
||||
err("usage: ludic new <name>\n")
|
||||
return 1
|
||||
}
|
||||
let name = arg_n(2)
|
||||
if file_exists(name) {
|
||||
err(`ludic new: {name} already exists\n`)
|
||||
return 1
|
||||
}
|
||||
run(`mkdir -p {name}/src {name}/tests {name}/assets`)
|
||||
if not write_file(`{name}/package.ludic`, template_manifest(name)) {
|
||||
err(`ludic new: cannot write {name}/package.ludic\n`)
|
||||
return 1
|
||||
}
|
||||
write_file(`{name}/src/main.ludic`, template_main(title_case(name)))
|
||||
write_file(`{name}/tests/smoke.ludic`, template_test(title_case(name)))
|
||||
write_file(`{name}/.gitignore`, template_gitignore())
|
||||
write_file(`{name}/README.md`, template_readme(name))
|
||||
print(`created {name}/`)
|
||||
print(" package.ludic the manifest — name, version, dependencies")
|
||||
print(" src/main.ludic the program")
|
||||
print(" tests/smoke.ludic a test to grow")
|
||||
print(" assets/ sprites, fonts and sounds")
|
||||
print("")
|
||||
print(`next: cd {name} && ludic run`)
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---- ludic build / run ------------------------------------------------------
|
||||
|
||||
# shared flag parsing for build and run: returns the entry file ("" = none),
|
||||
# filling the globals below.
|
||||
var g_mode: int = 1 # 1 = windowed, 2 = headless
|
||||
var g_out: pointer = "" # -o
|
||||
var g_save: bool = false # --save-temps
|
||||
|
||||
function parse_build_args(start: int) -> pointer {
|
||||
var src = ""
|
||||
g_mode = 1
|
||||
g_out = ""
|
||||
g_save = false
|
||||
var ai = start
|
||||
while ai < arg_total() {
|
||||
let a = arg_n(ai)
|
||||
if a == "--headless" { g_mode = 2 }
|
||||
else if a == "--windowed" { g_mode = 1 }
|
||||
else if a == "--save-temps" { g_save = true }
|
||||
else if a == "-o" { ai += 1; if ai < arg_total() { g_out = arg_n(ai) } }
|
||||
else if a[0] != '-' { src = a }
|
||||
ai += 1
|
||||
}
|
||||
return find_entry(src)
|
||||
}
|
||||
|
||||
# where the binary lands: -o if given, else build/<name> (build/<name>_headless
|
||||
# for a headless build, so the two can coexist).
|
||||
function output_path(entry: pointer) -> pointer {
|
||||
if g_out != "" { return g_out }
|
||||
let name = project_name(entry)
|
||||
if g_mode == 2 { return `build/{name}_headless` }
|
||||
return `build/{name}`
|
||||
}
|
||||
|
||||
# ludic build [file] [--headless] [-o out] [--save-temps]
|
||||
function cmd_build() -> int {
|
||||
let entry = parse_build_args(2)
|
||||
if entry == "" { return no_entry() }
|
||||
let out = output_path(entry)
|
||||
if not compile_app(entry, out, g_mode, g_save) { return 1 }
|
||||
if g_mode == 2 {
|
||||
print(`built {out} (headless: reads one key per frame from stdin, writes build/out.ppm)`)
|
||||
} else {
|
||||
print(`built {out}`)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
# ludic run [file] [--headless] — build, then run it from the project directory
|
||||
# so assets/ resolves relative to the game.
|
||||
function cmd_run() -> int {
|
||||
let entry = parse_build_args(2)
|
||||
if entry == "" { return no_entry() }
|
||||
let out = output_path(entry)
|
||||
if not compile_app(entry, out, g_mode, g_save) { return 1 }
|
||||
return sh(`./{out}`)
|
||||
}
|
||||
|
||||
# `ludic mygame.ludic` — the file is argv[1], so the scan starts there.
|
||||
function cmd_run_file() -> int {
|
||||
let entry = parse_build_args(1)
|
||||
if entry == "" { return no_entry() }
|
||||
let out = output_path(entry)
|
||||
if not compile_app(entry, out, g_mode, g_save) { return 1 }
|
||||
return sh(`./{out}`)
|
||||
}
|
||||
|
||||
# ---- ludic test -------------------------------------------------------------
|
||||
|
||||
# every test program in the project: tests/*.ludic plus any *_test.ludic under
|
||||
# src/ (both conventions are in use, and neither is worth arguing about).
|
||||
function test_files() -> []pointer {
|
||||
var files = split_lines(capture("ls tests/*.ludic 2>/dev/null"))
|
||||
let more = split_lines(capture("find src -name '*_test.ludic' 2>/dev/null | sort"))
|
||||
var i = 0
|
||||
while i < len(more) { push(files, more[i]); i += 1 }
|
||||
return files
|
||||
}
|
||||
|
||||
# ludic test [file...] — compile each test program headlessly and run it. A test
|
||||
# program's own runner prints ok/FAIL per test block and exits non-zero if any
|
||||
# failed, so this reports one line per file and forwards the failure.
|
||||
function cmd_test() -> int {
|
||||
var files = new []pointer
|
||||
var ai = 2
|
||||
while ai < arg_total() { if arg_n(ai)[0] != '-' { push(files, arg_n(ai)) }; ai += 1 }
|
||||
if len(files) == 0 { files = test_files() }
|
||||
if len(files) == 0 {
|
||||
err("ludic test: no tests found (expected tests/*.ludic or src/**/*_test.ludic)\n")
|
||||
return 1
|
||||
}
|
||||
run("mkdir -p build")
|
||||
var failed = 0
|
||||
var i = 0
|
||||
while i < len(files) {
|
||||
let f = files[i]
|
||||
let bin = `{tmp_dir()}/{flat(strip_ext(f))}`
|
||||
if not compile_app(f, bin, 2, false) {
|
||||
print(` {c_red()}FAIL{c_reset()} {f} (did not compile)`)
|
||||
failed += 1
|
||||
} else {
|
||||
# a test program prints one line per test block; that belongs on screen
|
||||
# when something failed and nowhere when everything passed.
|
||||
let log = `{bin}.out`
|
||||
let rc = sh(`{bin} < /dev/null > {log} 2>&1`)
|
||||
if rc == 0 { print(` {c_green()}PASS{c_reset()} {f}`) }
|
||||
else {
|
||||
print(` {c_red()}FAIL{c_reset()} {f} (exit {string(rc)})`)
|
||||
let txt = read_file(log)
|
||||
if txt != null { out(txt) }
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
print("")
|
||||
if failed == 0 {
|
||||
print(`== {string(len(files))} test files passed ==`)
|
||||
return 0
|
||||
}
|
||||
print(`== {string(failed)} of {string(len(files))} test files failed ==`)
|
||||
return 1
|
||||
}
|
||||
|
||||
# drop the extension from a path ("tests/combat.ludic" -> "tests/combat")
|
||||
function strip_ext(p: pointer) -> pointer {
|
||||
let n = len(p)
|
||||
if n > 6 and p[n - 6..n] == ".ludic" { return p[0..n - 6] }
|
||||
return p
|
||||
}
|
||||
|
||||
# ---- ludic fmt / lsp --------------------------------------------------------
|
||||
|
||||
# ludic fmt [paths...] — the formatter over the project (src/ and tests/ by
|
||||
# default), or over the paths named.
|
||||
function cmd_fmt() -> int {
|
||||
var args = ""
|
||||
var ai = 2
|
||||
while ai < arg_total() { args = `{args} {arg_n(ai)}`; ai += 1 }
|
||||
if args == "" {
|
||||
let found = capture_line("find src tests -name '*.ludic' 2>/dev/null | sort")
|
||||
if found == "" {
|
||||
err("ludic fmt: nothing to format (no src/ or tests/ here — name the files instead)\n")
|
||||
return 1
|
||||
}
|
||||
args = ` {found}`
|
||||
}
|
||||
return sh(`{tool("ludic-fmt")}{args}`)
|
||||
}
|
||||
|
||||
# ludic lsp — the language server on stdio. Editors are configured to run this,
|
||||
# so the server's location is the CLI's problem rather than the user's.
|
||||
function cmd_lsp() -> int {
|
||||
var args = ""
|
||||
var ai = 2
|
||||
while ai < arg_total() { args = `{args} {arg_n(ai)}`; ai += 1 }
|
||||
return sh(`exec {tool("ludic-lsp")}{args}`)
|
||||
}
|
||||
|
||||
# ---- ludic doctor -----------------------------------------------------------
|
||||
|
||||
function doctor_line(label: pointer, good: bool, detail: pointer) -> void {
|
||||
if good { print(` {c_green()}ok{c_reset()} {label} {detail}`) }
|
||||
else { print(` {c_red()}no{c_reset()} {label} {detail}`) }
|
||||
}
|
||||
|
||||
# ludic doctor — answer "is my install healthy?" without making the user guess.
|
||||
# Every line is something that has actually gone wrong for someone: a missing
|
||||
# clang, a half-unpacked install, a PATH that finds nothing.
|
||||
function cmd_doctor() -> int {
|
||||
let home = ludic_home()
|
||||
var shown = home
|
||||
if shown == "" { shown = "(current directory)" }
|
||||
print(`ludic doctor`)
|
||||
print("")
|
||||
doctor_line("install root ", true, shown)
|
||||
var bad_count = 0
|
||||
|
||||
# $LUDIC_CC may carry flags (the Linux build injects a shim); the first word
|
||||
# is the program to look for.
|
||||
let cc_name = getenv_or("LUDIC_CC", "clang")
|
||||
let cc_prog = capture_line(`printf '%s' '{cc_name}' | cut -d' ' -f1`)
|
||||
let have_cc = shq(`command -v {cc_prog} >/dev/null 2>&1`)
|
||||
doctor_line("C toolchain ", have_cc, `{cc_name} (assembles and links the emitted IR)`)
|
||||
if not have_cc { bad_count += 1 }
|
||||
|
||||
let have_cc2 = is_exec(ludicc()) or shq("command -v ludicc >/dev/null 2>&1")
|
||||
doctor_line("compiler ", have_cc2, ludicc())
|
||||
if not have_cc2 { bad_count += 1 }
|
||||
|
||||
let have_rt = file_exists(`{home}runtime/native/cocoa.ll`)
|
||||
doctor_line("runtime ", have_rt, `{home}runtime/native/ (the engine, spliced into a game)`)
|
||||
if not have_rt { bad_count += 1 }
|
||||
|
||||
let have_pkgs = file_exists(`{home}packages/ludic.core/package.ludic`)
|
||||
doctor_line("packages ", have_pkgs, `{home}packages/ (the bundled ludic.* modules)`)
|
||||
|
||||
let ver = read_file(`{home}VERSION`)
|
||||
var vs = "(unknown)"
|
||||
if ver != null { vs = s_trim(ver) }
|
||||
doctor_line("version ", ver != null, vs)
|
||||
|
||||
let on_path = capture_line("command -v ludic")
|
||||
doctor_line("on PATH ", on_path != "", on_path)
|
||||
|
||||
if not is_darwin() {
|
||||
print("")
|
||||
print(" note windowing is macOS-only today; on this host a game builds headless")
|
||||
print(" (ludic build --headless) but cannot open a window.")
|
||||
}
|
||||
print("")
|
||||
if bad_count == 0 { print("everything checks out."); return 0 }
|
||||
print(`{string(bad_count)} problem(s) above. Reinstall with:`)
|
||||
print(` curl -fsSL {install_url()} | sh`)
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---- ludic upgrade ----------------------------------------------------------
|
||||
|
||||
function install_url() -> pointer { return getenv_or("LUDIC_INSTALL_URL", "https://workshopsoft.pages.workshopsoft.io/ludic/install.sh") }
|
||||
|
||||
# ludic upgrade [version] — re-run the installer, which replaces the install in
|
||||
# place. One code path for installing and updating means an upgrade can never
|
||||
# drift from a fresh install.
|
||||
function cmd_upgrade() -> int {
|
||||
var ver = ""
|
||||
if arg_total() >= 3 { ver = arg_n(2) }
|
||||
if not shq("command -v curl >/dev/null 2>&1") {
|
||||
err("ludic upgrade: needs curl\n")
|
||||
return 1
|
||||
}
|
||||
print(`upgrading from {install_url()}`)
|
||||
if ver == "" { return sh(`curl -fsSL {install_url()} | sh`) }
|
||||
return sh(`curl -fsSL {install_url()} | sh -s -- --version {ver}`)
|
||||
}
|
||||
450
tools/ludic-cli/release.ludic
Normal file
450
tools/ludic-cli/release.ludic
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
# release.ludic — versioning + release cutting for the toolchain.
|
||||
#
|
||||
# ludic version print the toolchain version (from the VERSION file)
|
||||
# ludic dev release [level] cut a release: aggregate changes/ into CHANGELOG.md,
|
||||
# bump VERSION, commit, and tag vX.Y.Z. `level` is
|
||||
# major|minor|patch; omitted, it is derived from the
|
||||
# highest `bump:` among the pending changesets.
|
||||
# ludic dev release [level] --dry-run render the changelog section to stdout and
|
||||
# stop: nothing is written, committed, tagged or pushed.
|
||||
# ludic dev release [level] --publish ...then push main + the tag and create a
|
||||
# Forgejo release with source + toolchain tarballs.
|
||||
# Needs FORGEJO_TOKEN in the environment.
|
||||
#
|
||||
# The scheme is SemVer. VERSION is the single source of truth (ludicc --version
|
||||
# reads it at runtime), so a bump touches one file and never reseeds the
|
||||
# compiler. Changesets live as one small Markdown file per change under changes/
|
||||
# (see changes/README.md); a release consumes them into a CHANGELOG.md section.
|
||||
|
||||
# the current version string, or a fallback when VERSION is absent
|
||||
function read_version_or(dflt: pointer) -> pointer {
|
||||
let v = capture_line("cat VERSION 2>/dev/null")
|
||||
if (v == "") { return dflt }
|
||||
return v
|
||||
}
|
||||
|
||||
# true when there is at least one pending changeset (changes/*.md, minus README)
|
||||
function has_changesets() -> bool {
|
||||
return capture_line("ls changes/*.md 2>/dev/null | grep -v '/README.md' | head -1") != ""
|
||||
}
|
||||
|
||||
# the highest bump level requested across the pending changesets ("" if none).
|
||||
# README.md is excluded — its format example carries a literal `bump:` line that
|
||||
# must not count as a real changeset.
|
||||
function highest_bump() -> pointer {
|
||||
if shq("grep -rhqE '^bump:[[:space:]]*major' --include='*.md' --exclude='README.md' changes 2>/dev/null") { return "major" }
|
||||
if shq("grep -rhqE '^bump:[[:space:]]*minor' --include='*.md' --exclude='README.md' changes 2>/dev/null") { return "minor" }
|
||||
if shq("grep -rhqE '^bump:[[:space:]]*patch' --include='*.md' --exclude='README.md' changes 2>/dev/null") { return "patch" }
|
||||
return ""
|
||||
}
|
||||
|
||||
# cur + a SemVer bump of `level` -> the next version string
|
||||
function compute_next(cur: pointer, level: pointer) -> pointer {
|
||||
let maj = capture_line(`printf '%s' '{cur}' | cut -d. -f1`)
|
||||
let min = capture_line(`printf '%s' '{cur}' | cut -d. -f2`)
|
||||
let pat = capture_line(`printf '%s' '{cur}' | cut -d. -f3`)
|
||||
if (level == "major") { let m2 = capture_line(`expr {maj} + 1`); return `{m2}.0.0` }
|
||||
if (level == "minor") { let n2 = capture_line(`expr {min} + 1`); return `{maj}.{n2}.0` }
|
||||
let p2 = capture_line(`expr {pat} + 1`)
|
||||
return `{maj}.{min}.{p2}`
|
||||
}
|
||||
|
||||
# ludic version — report the toolchain version. Prefer the compiler's own --version
|
||||
# (proving that path works); fall back to the file if ludicc is not built yet.
|
||||
function cmd_version() -> int {
|
||||
if is_exec("bin/ludicc") {
|
||||
run("LUDIC_HOME=. bin/ludicc --version")
|
||||
return 0
|
||||
}
|
||||
print(`ludic {read_version_or("(version unknown)")}`)
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---- changelog rendering ----------------------------------------------------
|
||||
#
|
||||
# One changeset becomes one bullet. The body is markdown and is kept as markdown:
|
||||
# the previous shell pipeline ran it through `tr '\n' ' '`, which collapsed every
|
||||
# multi-line changeset into a single paragraph — a nested list came out as a run
|
||||
# of inline " - " fragments, and a release with a dozen changesets read as one
|
||||
# unbroken wall. Continuation lines are indented two spaces instead, so nested
|
||||
# bullets and paragraphs stay inside their bullet.
|
||||
|
||||
property Changeset { typ: pointer = "", body: pointer = "" }
|
||||
|
||||
# The conventional-commit types, in the order a reader wants them: what is new,
|
||||
# what is fixed, what got faster, then the housekeeping. A type not listed here
|
||||
# still gets a group, appended after these in first-seen order.
|
||||
function type_heading(t: pointer) -> pointer {
|
||||
if t == "feat" { return "Features" }
|
||||
if t == "fix" { return "Fixes" }
|
||||
if t == "perf" { return "Performance" }
|
||||
if t == "refactor" { return "Refactoring" }
|
||||
if t == "docs" { return "Documentation" }
|
||||
if t == "build" { return "Build" }
|
||||
if t == "ci" { return "CI" }
|
||||
if t == "test" { return "Tests" }
|
||||
if t == "style" { return "Style" }
|
||||
if t == "revert" { return "Reverts" }
|
||||
if t == "chore" { return "Chores" }
|
||||
return title_case(t)
|
||||
}
|
||||
function type_rank(t: pointer) -> int {
|
||||
if t == "feat" { return 0 }
|
||||
if t == "fix" { return 1 }
|
||||
if t == "perf" { return 2 }
|
||||
if t == "refactor" { return 3 }
|
||||
if t == "docs" { return 4 }
|
||||
if t == "build" { return 5 }
|
||||
if t == "ci" { return 6 }
|
||||
if t == "test" { return 7 }
|
||||
if t == "style" { return 8 }
|
||||
if t == "revert" { return 9 }
|
||||
if t == "chore" { return 10 }
|
||||
return 50
|
||||
}
|
||||
|
||||
# strip trailing whitespace from a line
|
||||
function rstrip(s: pointer) -> pointer {
|
||||
var n = slen(s)
|
||||
while n > 0 and is_space_all(s[n - 1]) { n -= 1 }
|
||||
return sslice(s, 0, n)
|
||||
}
|
||||
|
||||
# Parse one changeset file: the `type:`/`bump:` headers, then everything else as
|
||||
# the body. Returns a Changeset with an empty body when the file is unreadable.
|
||||
function read_changeset(path: pointer) -> Changeset {
|
||||
let cs = new Changeset
|
||||
cs.typ = ""
|
||||
cs.body = ""
|
||||
let text = read_file(path)
|
||||
if text == null { return cs }
|
||||
let b = sb_new()
|
||||
let n = slen(text)
|
||||
var i = 0
|
||||
var seen_body = false
|
||||
while i < n {
|
||||
let ln = line_at(text, i)
|
||||
i = i + slen(ln) + 1
|
||||
if not seen_body and s_starts(ln, "type:") {
|
||||
cs.typ = s_trim(sslice(ln, 5, slen(ln)))
|
||||
continue
|
||||
}
|
||||
if not seen_body and s_starts(ln, "bump:") { continue }
|
||||
# a leading blank line between the headers and the body is not body content
|
||||
if not seen_body and slen(s_trim(ln)) == 0 { continue }
|
||||
seen_body = true
|
||||
sb_puts(b, rstrip(ln))
|
||||
sb_putc(b, '\n')
|
||||
}
|
||||
# drop trailing blank lines
|
||||
var body = sb_str(b)
|
||||
var m = slen(body)
|
||||
while m > 0 and is_space_all(body[m - 1]) { m -= 1 }
|
||||
cs.body = sslice(body, 0, m)
|
||||
if cs.typ == "" { cs.typ = "chore" }
|
||||
return cs
|
||||
}
|
||||
|
||||
# Render one changeset as a markdown list item: the first line after "- ", every
|
||||
# following line indented two spaces so it stays within the item. Blank lines
|
||||
# stay blank (an indented blank line is just trailing whitespace).
|
||||
function render_bullet(b: Sb, body: pointer) -> void {
|
||||
let n = slen(body)
|
||||
var i = 0
|
||||
var first = true
|
||||
while i < n {
|
||||
let ln = line_at(body, i)
|
||||
i = i + slen(ln) + 1
|
||||
if first { sb_puts(b, "- "); first = false }
|
||||
else if slen(ln) == 0 { sb_putc(b, '\n'); continue }
|
||||
else { sb_puts(b, " ") }
|
||||
sb_puts(b, ln)
|
||||
sb_putc(b, '\n')
|
||||
}
|
||||
}
|
||||
|
||||
# the changesets in `dir`, README.md excluded, in filename order
|
||||
function load_changesets(dir: pointer) -> []Changeset {
|
||||
let out = new []Changeset
|
||||
let names = list_sorted(dir)
|
||||
var i = 0
|
||||
while i < len(names) {
|
||||
let nm = names[i]
|
||||
i += 1
|
||||
if nm == "README.md" { continue }
|
||||
if not s_ends(nm, ".md") { continue }
|
||||
let cs = read_changeset(`{dir}/{nm}`)
|
||||
if slen(cs.body) > 0 { push(out, cs) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
# assemble the CHANGELOG.md section for `ver` from the pending changesets into
|
||||
# the scratch file rel_section.md: a dated header, then one "### <Heading>"
|
||||
# group per conventional-commit type, each holding its changesets as bullets.
|
||||
function render_section(ver: pointer, date: pointer, dir: pointer) -> pointer {
|
||||
let sets = load_changesets(dir)
|
||||
let b = sb_new()
|
||||
sb_puts(b, `## v{ver} — {date}\n`)
|
||||
|
||||
# walk the type groups in rank order, then any unranked type in first-seen order
|
||||
let done = new []pointer
|
||||
var emitted = 0
|
||||
while emitted < len(sets) {
|
||||
# pick the lowest-ranked type not yet emitted, ties broken by name
|
||||
var best = ""
|
||||
var bestrank = 0
|
||||
var si = 0
|
||||
while si < len(sets) {
|
||||
let t = sets[si].typ
|
||||
si += 1
|
||||
var already = false
|
||||
var di = 0
|
||||
while di < len(done) { if done[di] == t { already = true }; di += 1 }
|
||||
if already { continue }
|
||||
let r = type_rank(t)
|
||||
if best == "" or r < bestrank or (r == bestrank and str_gt(best, t)) { best = t; bestrank = r }
|
||||
}
|
||||
if best == "" { break }
|
||||
push(done, best)
|
||||
|
||||
# collect this group's bodies and sort them, so a release is reproducible
|
||||
let bodies = new []pointer
|
||||
var k = 0
|
||||
while k < len(sets) {
|
||||
if sets[k].typ == best { push(bodies, sets[k].body) }
|
||||
k += 1
|
||||
}
|
||||
strs_sort(bodies)
|
||||
|
||||
sb_puts(b, `\n### {type_heading(best)}\n\n`)
|
||||
var j = 0
|
||||
while j < len(bodies) {
|
||||
render_bullet(b, bodies[j])
|
||||
j += 1
|
||||
emitted += 1
|
||||
}
|
||||
}
|
||||
return sb_str(b)
|
||||
}
|
||||
|
||||
function build_section(ver: pointer) -> void {
|
||||
let date = capture_line("date +%Y-%m-%d")
|
||||
write_file(tmp_path("rel_section.md"), render_section(ver, date, "changes"))
|
||||
}
|
||||
|
||||
# ludic dev changelog-render <version> <date> <dir> — print the CHANGELOG section that
|
||||
# `dir`'s changesets would produce. Used to re-render the sections of releases
|
||||
# cut before the renderer preserved markdown structure: check the changesets out
|
||||
# of the tag's parent commit, point this at them, and splice the result back in.
|
||||
function cmd_changelog_render() -> int {
|
||||
if arg_total() < 5 {
|
||||
err("usage: ludic dev changelog-render <version> <date> <changesets-dir>\n")
|
||||
return 1
|
||||
}
|
||||
out(render_section(arg_n(2), arg_n(3), arg_n(4)))
|
||||
return 0
|
||||
}
|
||||
|
||||
# prepend the scratch rel_section.md into CHANGELOG.md, above the first existing
|
||||
# release section (or at the end of the header if this is the first release).
|
||||
function prepend_changelog() -> void {
|
||||
if not file_exists("CHANGELOG.md") {
|
||||
run("printf '# Changelog\n\nAll notable changes to the Ludic toolchain, newest first. Generated from the\nchangesets under changes/ by ludic dev release; do not edit released sections by hand.\n\n' > CHANGELOG.md")
|
||||
}
|
||||
let ln = capture_line("grep -n '^## ' CHANGELOG.md | head -1 | cut -d: -f1")
|
||||
if (ln == "") {
|
||||
run(`cat CHANGELOG.md {tmp_dir()}/rel_section.md > {tmp_dir()}/rel_new.md`)
|
||||
} else {
|
||||
run(`head -n $(expr {ln} - 1) CHANGELOG.md > {tmp_dir()}/rel_new.md`)
|
||||
run(`cat {tmp_dir()}/rel_section.md >> {tmp_dir()}/rel_new.md`)
|
||||
run(`tail -n +{ln} CHANGELOG.md >> {tmp_dir()}/rel_new.md`)
|
||||
}
|
||||
run(`cp {tmp_dir()}/rel_new.md CHANGELOG.md`)
|
||||
}
|
||||
|
||||
# ---- release artifacts + publishing -----------------------------------------
|
||||
|
||||
# Extract one release's section out of CHANGELOG.md: everything from its
|
||||
# "## vX.Y.Z" heading up to the next "## " heading. This is what a release's
|
||||
# notes are — the notes and the changelog can then never disagree.
|
||||
function changelog_section(ver: pointer) -> pointer {
|
||||
let text = read_file("CHANGELOG.md")
|
||||
if text == null { return "" }
|
||||
let head = `## v{ver} `
|
||||
let n = slen(text)
|
||||
var i = 0
|
||||
var start = -1
|
||||
while i < n {
|
||||
let ln = line_at(text, i)
|
||||
let next = i + slen(ln) + 1
|
||||
if start < 0 {
|
||||
if s_starts(ln, head) { start = i }
|
||||
} else {
|
||||
if s_starts(ln, "## ") { return sslice(text, start, i) }
|
||||
}
|
||||
i = next
|
||||
}
|
||||
if start < 0 { return "" }
|
||||
return sslice(text, start, n)
|
||||
}
|
||||
|
||||
# ludic dev changelog-section <version> — print that release's CHANGELOG section.
|
||||
function cmd_changelog_section() -> int {
|
||||
if arg_total() < 3 { err("usage: ludic dev changelog-section <version>\n"); return 1 }
|
||||
let sec = changelog_section(arg_n(2))
|
||||
if slen(sec) == 0 { err(`changelog-section: no section for v{arg_n(2)} in CHANGELOG.md\n`); return 1 }
|
||||
out(sec)
|
||||
return 0
|
||||
}
|
||||
|
||||
# the platform's SHA-256 tool (coreutils on Linux, shasum on macOS)
|
||||
function sha256_cmd() -> pointer {
|
||||
if shq("command -v sha256sum >/dev/null 2>&1") { return "sha256sum" }
|
||||
return "shasum -a 256"
|
||||
}
|
||||
|
||||
# Build the release artifacts into dist/: a reproducible source+seed tarball from
|
||||
# the tag, a ready-to-run toolchain built on this host, and one `.sha256` beside
|
||||
# each — a release without checksums asks everyone downstream to trust the
|
||||
# transport.
|
||||
#
|
||||
# The toolchain tarball is a complete install root, not just binaries: bin/ next
|
||||
# to the engine runtime, the bundled ludic.* packages and VERSION, laid out
|
||||
# exactly as ludic_home() expects. install.sh unpacks it and is done; anything
|
||||
# missing here is a game that will not link on a machine with no checkout.
|
||||
#
|
||||
# The checksums are per-artifact rather than a single SHA256SUMS on purpose. A
|
||||
# release is assembled from more than one host (a Linux runner cannot build the
|
||||
# macOS toolchain), and `forgejo_upload_assets` skips an asset whose name is
|
||||
# already attached — so a shared SHA256SUMS would be written by whichever host
|
||||
# published first and would then never cover anything added later. One file per
|
||||
# artifact has a unique name, so each host's contribution stands on its own.
|
||||
#
|
||||
# shasum -a 256 -c ludic-X.Y.Z-src.tar.gz.sha256
|
||||
function build_artifacts(ver: pointer) -> bool {
|
||||
run("rm -rf dist && mkdir -p dist")
|
||||
if not shq(`git archive --format=tar.gz --prefix=ludic-{ver}/ -o dist/ludic-{ver}-src.tar.gz v{ver}`) {
|
||||
err(`release: git archive of v{ver} failed (is the tag present?)\n`)
|
||||
return false
|
||||
}
|
||||
if is_exec("bin/ludicc") {
|
||||
let plat = capture_line("uname -s | tr '[:upper:]' '[:lower:]'")
|
||||
let arch = capture_line("uname -m")
|
||||
let name = `ludic-{ver}-{plat}-{arch}`
|
||||
let stage = `{tmp_dir()}/{name}`
|
||||
run(`rm -rf {stage} && mkdir -p {stage}/bin`)
|
||||
if not shq(`cp bin/ludic bin/ludicc bin/ludic-fmt bin/ludic-lsp {stage}/bin/`) {
|
||||
err("release: the toolchain is not built (run: ludic dev build)\n")
|
||||
return false
|
||||
}
|
||||
run(`cp -R runtime {stage}/runtime`)
|
||||
run(`cp -R packages {stage}/packages`)
|
||||
run(`cp VERSION LICENSE README.md {stage}/`)
|
||||
# the Linux link needs the Darwin-stdio shim injected through $LUDIC_CC; it
|
||||
# travels with the toolchain that needs it (see tools/ci/linux_stdio_shim.ll).
|
||||
if plat == "linux" { run(`mkdir -p {stage}/lib && cp tools/ci/linux_stdio_shim.ll {stage}/lib/`) }
|
||||
if not shq(`tar -czf dist/{name}.tar.gz -C {tmp_dir()} {name}`) {
|
||||
err("release: building the toolchain tarball failed\n")
|
||||
return false
|
||||
}
|
||||
run(`rm -rf {stage}`)
|
||||
}
|
||||
if not shq(`cd dist && for f in *.tar.gz; do {sha256_cmd()} "$f" > "$f.sha256" || exit 1; done`) {
|
||||
err("release: writing the per-artifact .sha256 files failed\n")
|
||||
return false
|
||||
}
|
||||
run("ls -l dist")
|
||||
return true
|
||||
}
|
||||
|
||||
# ludic dev publish [vX.Y.Z] — publish an already-tagged release: build the artifacts,
|
||||
# take the notes from that version's CHANGELOG section, and create the Forgejo
|
||||
# release. Defaults to the version in VERSION. This is what CI runs on a tag
|
||||
# push, and what `ludic dev release --publish` calls once it has tagged.
|
||||
function publish_tag(ver: pointer) -> int {
|
||||
if getenv_or("FORGEJO_TOKEN", "") == "" { err("publish: set FORGEJO_TOKEN (a Forgejo access token)\n"); return 1 }
|
||||
let body = tmp_path("rel_notes.md")
|
||||
let sec = changelog_section(ver)
|
||||
if slen(sec) == 0 { err(`publish: no CHANGELOG section for v{ver}\n`); return 1 }
|
||||
write_file(body, sec)
|
||||
if not build_artifacts(ver) { return 1 }
|
||||
if forgejo_publish(`v{ver}`, body, "dist") != 0 {
|
||||
err("publish: creating the Forgejo release failed\n")
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function cmd_publish() -> int {
|
||||
var ver = read_version_or("")
|
||||
if arg_total() >= 3 {
|
||||
var a = arg_n(2)
|
||||
if s_starts(a, "v") { a = sslice(a, 1, slen(a)) }
|
||||
ver = a
|
||||
}
|
||||
if ver == "" { err("publish: no version given and no VERSION file\n"); return 1 }
|
||||
return publish_tag(ver)
|
||||
}
|
||||
|
||||
# push main + the tag and create the Forgejo release with build artifacts.
|
||||
function publish_release(ver: pointer) -> int {
|
||||
if getenv_or("FORGEJO_TOKEN", "") == "" { err("release --publish: set FORGEJO_TOKEN (a Forgejo access token)\n"); return 1 }
|
||||
if not shq("git push origin HEAD") { err("release: git push (main) failed\n"); return 1 }
|
||||
if not shq(`git push origin v{ver}`) { err("release: git push (tag) failed\n"); return 1 }
|
||||
return publish_tag(ver)
|
||||
}
|
||||
|
||||
function cmd_release() -> int {
|
||||
# parse args: an optional level positional, and a --publish flag
|
||||
var level = ""
|
||||
var publish = false
|
||||
var dry = false
|
||||
var ai = 2
|
||||
while ai < arg_total() {
|
||||
let a = arg_n(ai)
|
||||
if (a == "--publish") { publish = true }
|
||||
else { if (a == "--dry-run") { dry = true }
|
||||
else { if (a == "major") or (a == "minor") or (a == "patch") { level = a }
|
||||
else { err(`release: unknown argument {a}\n`); return 1 } } }
|
||||
ai += 1
|
||||
}
|
||||
|
||||
if not has_changesets() {
|
||||
err("release: no changesets under changes/ — add one (see changes/README.md)\n"); return 1
|
||||
}
|
||||
if (level == "") { level = highest_bump() }
|
||||
if (level == "") { err("release: no bump: level in any changeset\n"); return 1 }
|
||||
|
||||
let cur = read_version_or("0.0.0")
|
||||
let ver = compute_next(cur, level)
|
||||
print(`releasing v{ver} ({level} bump from {cur})`)
|
||||
|
||||
build_section(ver)
|
||||
# --dry-run stops here: the section is rendered to stdout and nothing on disk,
|
||||
# in git, or on the remote is touched. This is the cheap way to read a release
|
||||
# before cutting it, which the old all-or-nothing command had no answer for.
|
||||
if dry {
|
||||
print("")
|
||||
let sec = read_file(tmp_path("rel_section.md"))
|
||||
if sec != null { out(sec) }
|
||||
print("")
|
||||
print(` dry run — nothing written. cut it with: ludic dev release {level}`)
|
||||
return 0
|
||||
}
|
||||
prepend_changelog()
|
||||
if not write_file("VERSION", `{ver}\n`) { err("release: cannot write VERSION\n"); return 1 }
|
||||
run("rm -f $(ls changes/*.md | grep -v '/README.md')")
|
||||
|
||||
# stage only the release artifacts — never a blanket `git add -A`, which would
|
||||
# sweep unrelated in-flight edits (this tree is worked on concurrently).
|
||||
if not shq("git add VERSION CHANGELOG.md changes") { err("release: git add failed\n"); return 1 }
|
||||
if not shq(`git commit -q -m 'chore(release): v{ver}'`) { err("release: git commit failed\n"); return 1 }
|
||||
if not shq(`git tag v{ver}`) { err("release: git tag failed (already exists?)\n"); return 1 }
|
||||
print(` committed + tagged v{ver}`)
|
||||
|
||||
if publish { return publish_release(ver) }
|
||||
print(` local release ready. publish with: FORGEJO_TOKEN=… ludic dev release {level} --publish`)
|
||||
print(` (or push: git push origin HEAD && git push origin v{ver})`)
|
||||
return 0
|
||||
}
|
||||
222
tools/ludic-cli/selfhost.ludic
Normal file
222
tools/ludic-cli/selfhost.ludic
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
# 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
|
||||
# The C toolchain used to assemble and link IR. -Wno-override-module for the
|
||||
# same reason the compiler itself passes it (see selfhost/main.ludic): the IR
|
||||
# names no target triple, so clang substitutes the host's and warns every time.
|
||||
function cc() -> pointer { return getenv_or("LUDIC_CC", "clang") + " -Wno-override-module" }
|
||||
|
||||
# the self-host compiler's source fragments, in link order. This is THE list;
|
||||
# the old scripts each carried their own copy.
|
||||
function selfhost_frags() -> []pointer {
|
||||
let f = new []pointer
|
||||
push(f, "selfhost/support/str.ludic")
|
||||
push(f, "selfhost/support/buf.ludic")
|
||||
push(f, "selfhost/support/io.ludic")
|
||||
push(f, "selfhost/frontend/ast.ludic")
|
||||
push(f, "selfhost/frontend/lex.ludic")
|
||||
push(f, "selfhost/frontend/parse.ludic")
|
||||
push(f, "selfhost/frontend/parse_game.ludic")
|
||||
push(f, "selfhost/backend/emit_core.ludic")
|
||||
push(f, "selfhost/backend/emit_head.ludic")
|
||||
push(f, "selfhost/backend/emit_addr.ludic")
|
||||
push(f, "selfhost/backend/emit_intrin.ludic")
|
||||
push(f, "selfhost/backend/emit_intrin2.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_math.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_vector.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_ivec.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_rect.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_text.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_text_prelude.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_hash.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_crypto.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_uuid.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_noise.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_log.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_os.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_unicode.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_fs.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_list.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_ease.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_anim.ludic")
|
||||
push(f, "selfhost/backend/game/emit_collide.ludic")
|
||||
push(f, "selfhost/backend/emit_mem.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_time.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_datetime.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_colorfn.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_color.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_key.ludic")
|
||||
push(f, "selfhost/backend/emit_new.ludic")
|
||||
push(f, "selfhost/backend/emit_expr.ludic")
|
||||
push(f, "selfhost/backend/emit_call.ludic")
|
||||
push(f, "selfhost/backend/emit_stmt.ludic")
|
||||
push(f, "selfhost/backend/game/emit_ecs.ludic")
|
||||
push(f, "selfhost/backend/game/emit_query.ludic")
|
||||
push(f, "selfhost/backend/game/emit_spawn.ludic")
|
||||
push(f, "selfhost/backend/game/emit_game.ludic")
|
||||
push(f, "selfhost/backend/game/emit_world.ludic")
|
||||
push(f, "selfhost/backend/game/emit_machine.ludic")
|
||||
push(f, "selfhost/backend/game/emit_save.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_net.ludic")
|
||||
push(f, "selfhost/backend/game/emit_ui.ludic")
|
||||
push(f, "selfhost/backend/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.
|
||||
function write_selfhost_src(outpath: pointer) -> 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 += 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)
|
||||
function line_count(path: pointer) -> pointer {
|
||||
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.
|
||||
function ensure_ludicc() -> void {
|
||||
if not in_toolchain_repo() { return }
|
||||
run("mkdir -p bin build")
|
||||
if (not is_exec("bin/ludicc")) or newer("selfhost/ludicc.seed.ll", "bin/ludicc") {
|
||||
print("cc: selfhost/ludicc.seed.ll -> bin/ludicc (from the IR seed, no C compiler)")
|
||||
if not shq(`{cc()} selfhost/ludicc.seed.ll -o bin/ludicc`) {
|
||||
err("ludic: could not assemble the seed\n"); exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ---- selfhost-build: assemble + compile the self-host compiler ---------------
|
||||
# usage: ludic dev selfhost-build [ludicc] [outbin] (defaults: bin/ludicc, build/selfhost)
|
||||
function cmd_selfhost_build(lc: pointer, outbin: pointer) -> int {
|
||||
run("mkdir -p build")
|
||||
let src = "build/selfhost.ludic"
|
||||
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: ludic dev sh-compile <selfhost-binary> <input.ludic> <output-binary>
|
||||
function cmd_sh_compile(shbin: pointer, in: pointer, outbin: pointer) -> int {
|
||||
if not shq(`{shbin} {in} > {outbin}.ll`) { return 1 }
|
||||
if not shq(`{cc()} {outbin}.ll -o {outbin}`) { return 1 }
|
||||
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_path("gb.err").
|
||||
function game_build_ok(shbin: pointer, game: pointer, outbin: pointer) -> bool {
|
||||
let ll = `{outbin}.ll`
|
||||
if not shq(`{shbin} {game} > {ll} 2>{tmp_dir()}/gb.err`) { return false }
|
||||
if not shq(`{cc()} -O2 {ll} -o {outbin} 2>{tmp_dir()}/gb.err`) { return false }
|
||||
run(`rm -f {ll}`)
|
||||
return true
|
||||
}
|
||||
|
||||
# usage: ludic dev game-build <selfhost-binary> <game.ludic> <out-binary>
|
||||
function cmd_game_build(shbin: pointer, game: pointer, outbin: pointer) -> int {
|
||||
if game_build_ok(shbin, game, outbin) { print(`built {outbin}`); return 0 }
|
||||
print("build failed:")
|
||||
out(capture(`grep -i error {tmp_dir()}/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.
|
||||
function 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.
|
||||
function 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: ludic dev 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
|
||||
# `ludic dev bootstrap-cfree` afterwards.
|
||||
function cmd_reseed() -> int {
|
||||
run("mkdir -p build/cfree")
|
||||
if not write_selfhost_src("build/cfree/selfhost.ludic") { print("FAIL: write source"); return 1 }
|
||||
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
|
||||
}
|
||||
128
tools/ludic-cli/selfhost_test.ludic
Normal file
128
tools/ludic-cli/selfhost_test.ludic
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# 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
|
||||
function sh_case(name: pointer, exp: pointer) -> void {
|
||||
let ll = `{tmp_dir()}/sh_{name}.ll`
|
||||
let er = `{tmp_dir()}/sh_{name}.err`
|
||||
let bn = `{tmp_dir()}/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/<path>.ludic as a game, render it headless, and compare the
|
||||
# framebuffer's SHA-256 against the text baseline in selfhost/golden/renders.sha256.
|
||||
# A hash is tiny and text-diffable — a regression shows as a changed hex line in a
|
||||
# review, not an opaque "binary files differ". Regenerate the baseline with
|
||||
# `bin/ludic dev golden` (see cmd_golden below).
|
||||
function game_case(path: pointer, keys: pointer) -> void {
|
||||
let nm = flat(path)
|
||||
if not game_build_ok("bin/ludicc", `examples/{path}.ludic`, `{tmp_dir()}/g_{nm}`) { bad(`{path}: build`); return }
|
||||
run("mkdir -p build")
|
||||
run(`printf '%s' '{keys}' | {tmp_dir()}/g_{nm} >/dev/null 2>&1`)
|
||||
# The build + headless run above guard against a compile or runtime break on
|
||||
# every host. The pixel-exact golden comparison, though, is against a render
|
||||
# blessed on macOS; text rasterization differs by a hair on other hosts, so
|
||||
# off Darwin we stop at "it built and ran" rather than fail on the hash.
|
||||
if not is_darwin() { skip(`{path} render (golden hash blessed on macOS)`); return }
|
||||
let got = capture_line("shasum -a 256 build/out.ppm | cut -d' ' -f1")
|
||||
let want = capture_line(`grep '^{path} ' selfhost/golden/renders.sha256 | cut -d' ' -f2`)
|
||||
if (want == "") { bad2(path, "no golden hash (run: bin/ludic dev golden)"); return }
|
||||
if (got == want) { ok(`{path} matches the golden render`) }
|
||||
else { bad2(path, `render hash {got} != golden {want}`) }
|
||||
}
|
||||
|
||||
# render one golden game and return its "<path> <sha256>\n" manifest line (empty
|
||||
# string on build failure). Shared by `bin/ludic dev golden` and nothing else.
|
||||
function bless_render(path: pointer, keys: pointer) -> pointer {
|
||||
let nm = flat(path)
|
||||
if not game_build_ok("bin/ludicc", `examples/{path}.ludic`, `{tmp_dir()}/bg_{nm}`) {
|
||||
err(`golden: {path} failed to build\n`); return ""
|
||||
}
|
||||
run("mkdir -p build")
|
||||
run(`printf '%s' '{keys}' | {tmp_dir()}/bg_{nm} >/dev/null 2>&1`)
|
||||
let h = capture_line("shasum -a 256 build/out.ppm | cut -d' ' -f1")
|
||||
return `{path} {h}\n`
|
||||
}
|
||||
|
||||
# regenerate the text render baseline. The keys here MUST match game_case's so
|
||||
# the baseline and the checks render the same frame. Deliberate, reviewable:
|
||||
# run it, then `git diff selfhost/golden/renders.sha256` shows exactly which
|
||||
# render changed before you commit the new hash.
|
||||
function cmd_golden() -> int {
|
||||
ensure_ludicc()
|
||||
if not is_exec("bin/ludicc") { err("golden: bin/ludicc unavailable\n"); return 1 }
|
||||
var manifest = ""
|
||||
manifest += bless_render("games/snake", "ddssaawwddss")
|
||||
manifest += bless_render("games/menu", "ssss")
|
||||
manifest += bless_render("games/chronorift", "ddddwwwwaassK")
|
||||
manifest += bless_render("rendering/draw_prims", "")
|
||||
if not write_file("selfhost/golden/renders.sha256", manifest) {
|
||||
err("golden: could not write selfhost/golden/renders.sha256\n"); return 1
|
||||
}
|
||||
print("wrote selfhost/golden/renders.sha256:")
|
||||
out(manifest)
|
||||
print("review the change: git diff selfhost/golden/renders.sha256")
|
||||
return 0
|
||||
}
|
||||
|
||||
function 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("sort", "0 39 20 1 2 3 3 1 30 10 20 40 2 8")
|
||||
sh_case("control", "55 4 15 1")
|
||||
sh_case("match_bits", "1 2 9 16 4 15")
|
||||
sh_case("enums", "1 0 15 7 19 18 0 80 17")
|
||||
sh_case("fixed", "2 3 0 6 1")
|
||||
sh_case("const", "1 0 10 5 20 0 10 7 1 0")
|
||||
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("transcend", "2718 1 148 1000 0 2303 1024 1414 3 250 0 1000 1016")
|
||||
sh_case("vector", "3 4 5 4 6 2 2 11 2 6 999 999 999 1570 5 10")
|
||||
sh_case("types2d", "3 4 4 6 2 2 9 12 11 4 0 1 0 4 10 20 30 40 40 60 25 40 1 0 0 1 0")
|
||||
sh_case("datetime", "10800 330 172800 3 2 0 10957 2026 8 30 4 6 1 0 1 29 28 31 2 1 365 946729815 2000 1 1 12 30 15 6 10957 1")
|
||||
sh_case("datetime2", "2026-08-30 07:05:09 30/08/26 0 -1 3600 5400 30 0")
|
||||
sh_case("textsplit", "1 1 1 3 1 1 1 1 1 1 1")
|
||||
sh_case("hash", "-2128831035 -468965076 114400290 114400290 0 -873187034 1095738169 0 1364076727 -2114883783 -845898438 -845898438 -78065325 -78057399 -3750763034362895579 -5808556873153909620 -4100651535478758590 -4100651535478758590 0 -5451962507482445012 7256831767414464289")
|
||||
sh_case("crypto", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592 9f4390f8d30c2dd92ec9f095b65e2b9ae9b0a925a5258e241c9f1e910f734318 b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8 5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843 1 0 0 1 0 0 616263")
|
||||
sh_case("long", "1000000000000 1000000000001 1000000000005 3000000000000 1 1 1 -1000000000000 1000000 13")
|
||||
sh_case("color", "16744512 1090486336 1090486336 8355711 8355711 8355711")
|
||||
|
||||
print("== the self-host compiler compiles real games (vs golden output) ==")
|
||||
# the golden render hashes were first produced by the original C compiler; the
|
||||
# self-hosted compiler must reproduce the same framebuffer, byte for byte, so
|
||||
# its SHA-256 matches the checked-in baseline in selfhost/golden/renders.sha256.
|
||||
game_case("games/snake", "ddssaawwddss")
|
||||
game_case("games/menu", "ssss")
|
||||
game_case("games/chronorift", "ddddwwwwaassK")
|
||||
game_case("rendering/draw_prims", "")
|
||||
|
||||
print("== the bootstrap fixpoint (seeded from bin/ludicc) ==")
|
||||
if shq(`bin/ludic dev bootstrap > {tmp_dir()}/boot.out 2>&1`) { ok("gen2.ll == gen3.ll (compiler reproduces itself)") }
|
||||
else { bad("fixpoint not reached"); out(capture(`cat {tmp_dir()}/boot.out`)) }
|
||||
|
||||
print("== the C-free bootstrap (from the checked-in IR seed, no C compiler) ==")
|
||||
if shq(`bin/ludic dev bootstrap-cfree > {tmp_dir()}/cfree.out 2>&1`) { ok("seed.ll rebuilds the compiler and is its own fixed point") }
|
||||
else { bad("C-free bootstrap failed (seed stale? run: ludic dev reseed)"); out(capture(`cat {tmp_dir()}/cfree.out`)) }
|
||||
|
||||
return report()
|
||||
}
|
||||
479
tools/ludic-cli/test.ludic
Normal file
479
tools/ludic-cli/test.ludic
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
# 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/ludic <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.
|
||||
function feat_case(path: pointer, keys: pointer, exp: pointer, label: pointer) -> void {
|
||||
let nm = flat(path)
|
||||
if not game_build_ok("bin/ludicc", `examples/{path}.ludic`, `{tmp_dir()}/e_{nm}`) {
|
||||
bad2(label, capture_line(`grep -i error {tmp_dir()}/gb.err | head -1`)); return
|
||||
}
|
||||
var got = ""
|
||||
if (keys == "") { got = capture_line(`{tmp_dir()}/e_{nm} < /dev/null`) }
|
||||
else { got = capture_line(`printf '{keys}' | {tmp_dir()}/e_{nm}`) }
|
||||
if (got == exp) { ok(label) } else { bad2(label, `got [{got}] want [{exp}]`) }
|
||||
}
|
||||
|
||||
# a feature example that consumes an in-repo controller package (packages/): same
|
||||
# as feat_case, but the compiler is pointed at packages/ via LUDIC_MODULES so a
|
||||
# `import "ludic.foo/bar.ludic"` resolves, and LUDIC_HOME anchors the engine
|
||||
# runtime. This is exactly how `ludic build` / an external project builds against a
|
||||
# fetched source package (the do_import module-root fallback, #63).
|
||||
function controller_case(path: pointer, keys: pointer, exp: pointer, label: pointer) -> void {
|
||||
let nm = flat(path)
|
||||
let root = capture_line("pwd")
|
||||
let ll = `{tmp_dir()}/c_{nm}.ll`
|
||||
if not shq(`LUDIC_HOME={root} LUDIC_MODULES={root}/packages bin/ludicc examples/{path}.ludic > {ll} 2>{tmp_dir()}/gb.err`) {
|
||||
bad2(label, capture_line(`grep -i error {tmp_dir()}/gb.err | head -1`)); return
|
||||
}
|
||||
if not shq(`{cc()} -O2 {ll} -o {tmp_dir()}/c_{nm} 2>{tmp_dir()}/gb.err`) {
|
||||
bad2(label, capture_line(`grep -i error {tmp_dir()}/gb.err | head -1`)); return
|
||||
}
|
||||
var got = ""
|
||||
if (keys == "") { got = capture_line(`{tmp_dir()}/c_{nm} < /dev/null`) }
|
||||
else { got = capture_line(`printf '{keys}' | {tmp_dir()}/c_{nm}`) }
|
||||
if (got == exp) { ok(label) } else { bad2(label, `got [{got}] want [{exp}]`) }
|
||||
}
|
||||
|
||||
# #75 — an external project (its CWD is not the toolchain repo, with no runtime/
|
||||
# under it and no ludic_modules) builds an ECS game by resolving the engine runtime
|
||||
# from $LUDIC_HOME (the toolchain install), independent of the package module root.
|
||||
function runtime_home_case() -> void {
|
||||
let root = capture_line("pwd")
|
||||
let dir = `{tmp_dir()}/ext75`
|
||||
shq(`rm -rf {dir} && mkdir -p {dir}`)
|
||||
write_file(`{dir}/game.ludic`, "program Ext {\n property Mob { hp: int = 0 }\n model M { Mob }\n entry {\n spawn M { Mob { hp: 7 } }\n let pm = World.prop_id(\"Mob\")\n let e = World.query_next(pm, 0)\n print(World.get(e, pm, World.field_id(pm, \"hp\")))\n }\n}\n")
|
||||
let lbl = "runtime ships with the toolchain, resolved from LUDIC_HOME (#75)"
|
||||
# compiled from the external CWD; there is deliberately no runtime/ or
|
||||
# ludic_modules/ under {dir}, so the engine runtime can only come from LUDIC_HOME.
|
||||
if not shq(`cd {dir} && LUDIC_HOME={root} {root}/bin/ludicc game.ludic -o {dir}/game 2>{dir}/err`) {
|
||||
bad2(lbl, capture_line(`grep -i 'error|cannot open' {dir}/err | head -1`)); return
|
||||
}
|
||||
let got = capture_line(`{dir}/game < /dev/null`)
|
||||
if (got == "7") { ok(lbl) } else { bad2(lbl, `got [{got}] want [7]`) }
|
||||
}
|
||||
|
||||
# The install layout: bin/ beside runtime/ and packages/, reached through $PATH
|
||||
# from an unrelated directory, with nothing in the environment. This is what
|
||||
# `curl https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh` lays down and the only shape most
|
||||
# users will ever run, so it is asserted rather than assumed: a staged copy of
|
||||
# the toolchain compiles and runs a project created by `ludic new`.
|
||||
function install_layout_case() -> void {
|
||||
let root = capture_line("pwd")
|
||||
let home = `{tmp_dir()}/install`
|
||||
let lbl = "an installed layout (bin/ + runtime/ + packages/, found on PATH) builds a new project"
|
||||
run(`rm -rf {home} && mkdir -p {home}/bin`)
|
||||
run(`cp bin/ludic bin/ludicc bin/ludic-fmt {home}/bin/`)
|
||||
run(`cp -R runtime {home}/runtime && cp -R packages {home}/packages && cp VERSION {home}/VERSION`)
|
||||
let work = `{tmp_dir()}/installwork`
|
||||
run(`rm -rf {work} && mkdir -p {work}`)
|
||||
# PATH only — no LUDIC_HOME, and the current directory has no bin/ of its own.
|
||||
let envp = `env -u LUDIC_HOME PATH={home}/bin:/usr/bin:/bin:/usr/sbin:/sbin`
|
||||
if not shq(`cd {work} && {envp} ludic new demo > {work}/new.out 2>&1`) {
|
||||
bad2(lbl, capture_line(`tail -1 {work}/new.out`)); return
|
||||
}
|
||||
if not shq(`cd {work}/demo && {envp} ludic build --headless > {work}/build.out 2>&1`) {
|
||||
bad2(lbl, capture_line(`tail -1 {work}/build.out`)); return
|
||||
}
|
||||
if not shq(`cd {work}/demo && {envp} ludic test > {work}/test.out 2>&1`) {
|
||||
bad2(lbl, capture_line(`tail -1 {work}/test.out`)); return
|
||||
}
|
||||
let ver = capture_line(`{envp} ludic version`)
|
||||
if ver != `ludic {s_trim(read_file("VERSION"))}` {
|
||||
bad2("an installed `ludic version` reports the install's VERSION", `got [{ver}]`); return
|
||||
}
|
||||
ok(lbl)
|
||||
}
|
||||
|
||||
# a "does it still compile" smoke test (parse -> lower -> link), no run
|
||||
function qsmoke(path: pointer) -> void {
|
||||
let nm = flat(path)
|
||||
if game_build_ok("bin/ludicc", `examples/{path}.ludic`, `{tmp_dir()}/q_{nm}`) {
|
||||
ok(`{path} compiles (@Queries desugars to S_QUERY)`)
|
||||
} else { bad2(path, capture_line(`grep -i error {tmp_dir()}/gb.err | head -1`)) }
|
||||
}
|
||||
|
||||
# a plain compile-only rot guard for a showcase example that has no self-asserting
|
||||
# `entry` (so net_case can't drive it): prove it still parses, lowers and links.
|
||||
function smoke(path: pointer) -> void {
|
||||
let nm = flat(path)
|
||||
if not shq(`bin/ludicc --headless examples/{path}.ludic --emit-llvm -o {tmp_dir()}/sm_{nm}.ll > {tmp_dir()}/sm_{nm}.err 2>&1`) {
|
||||
bad2(path, capture_line(`tail -1 {tmp_dir()}/sm_{nm}.err`)); return
|
||||
}
|
||||
ok(`{path} still compiles`)
|
||||
}
|
||||
|
||||
# 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.
|
||||
function net_case(path: pointer, exp: pointer) -> void {
|
||||
let nm = flat(path)
|
||||
let ll = `{tmp_dir()}/n_{nm}.ll`
|
||||
let log = `{tmp_dir()}/n_{nm}.out`
|
||||
if not shq(`bin/ludicc --headless examples/{path}.ludic --emit-llvm -o {ll} > {log} 2>&1`) {
|
||||
let t = capture_line(`tail -1 {log}`)
|
||||
bad2(path, `build ({t})`); return
|
||||
}
|
||||
if not shq(`{cc()} -O2 {ll} -o {tmp_dir()}/nb_{nm} >> {log} 2>&1`) {
|
||||
let t = capture_line(`tail -1 {log}`)
|
||||
bad2(path, `build ({t})`); return
|
||||
}
|
||||
let got = capture_line(`{tmp_dir()}/nb_{nm} < /dev/null`)
|
||||
if (got == exp) { ok(`{path} ({got})`) } else { bad2(path, `got [{got}] want [{exp}]`) }
|
||||
}
|
||||
|
||||
# a test spec (issue #12): `test "name" { expect… }` blocks with no `entry`. The
|
||||
# compiler synthesises a runner @main, so we just build, run, and require a clean
|
||||
# exit (0) plus the expected `== N passed, M failed ==` summary on the last line.
|
||||
function spec_case(path: pointer, exp: pointer) -> void {
|
||||
let nm = flat(path)
|
||||
let ll = `{tmp_dir()}/spec_{nm}.ll`
|
||||
let er = `{tmp_dir()}/spec_{nm}.err`
|
||||
if not shq(`bin/ludicc examples/{path}.ludic > {ll} 2>{er}`) {
|
||||
bad2(path, capture_line(`tail -1 {er}`)); return
|
||||
}
|
||||
if not shq(`{cc()} -O2 {ll} -o {tmp_dir()}/spec_{nm} 2>{er}`) {
|
||||
bad2(path, capture_line(`tail -1 {er}`)); return
|
||||
}
|
||||
let rc = sh(`{tmp_dir()}/spec_{nm} > {tmp_dir()}/spec_{nm}.out 2>&1`)
|
||||
let summary = capture_line(`tail -1 {tmp_dir()}/spec_{nm}.out`)
|
||||
if (rc == 0) and (summary == exp) { ok(`{path} ({summary})`) }
|
||||
else { bad2(path, `rc={string(rc)} last=[{summary}]`) }
|
||||
}
|
||||
|
||||
# issue #8: the failure path — a panic must abort with a non-zero exit and a
|
||||
# `file:line: panic: <msg>` line on stderr (stdout is unaffected up to the panic).
|
||||
function panic_case() -> void {
|
||||
write_file(`{tmp_dir()}/panic.ludic`, "program P { entry { print(7); panic(\"boom\") } }\n")
|
||||
if not shq(`bin/ludicc {tmp_dir()}/panic.ludic > {tmp_dir()}/panic.ll 2>/dev/null`) { bad("panic example did not compile"); return }
|
||||
if not shq(`{cc()} -O2 {tmp_dir()}/panic.ll -o {tmp_dir()}/panic 2>/dev/null`) { bad("panic example did not link"); return }
|
||||
let rc = sh(`{tmp_dir()}/panic > {tmp_dir()}/panic.out 2>{tmp_dir()}/panic.err`)
|
||||
let msg = capture_line(`cat {tmp_dir()}/panic.err`)
|
||||
if (rc != 0) and shq(`grep -q 'panic: boom' {tmp_dir()}/panic.err`) { ok(`panic aborts non-zero with a located stderr message ({msg})`) }
|
||||
else { bad2("panic", `rc={string(rc)} err=[{msg}]`) }
|
||||
}
|
||||
|
||||
# issue #45: `bin/ludic dev test --coverage`. Compile each test-spec with `--coverage`,
|
||||
# run it with LUDIC_COVERAGE pointed at a per-file dump, then aggregate the dumps
|
||||
# into a clean per-file line-coverage report. The instrumentation is flag-gated,
|
||||
# so this reuses the same seed-built bin/ludicc the rest of the suite does.
|
||||
function cov_one(path: pointer, dir: pointer) -> int {
|
||||
let nm = flat(path)
|
||||
let ll = `{tmp_dir()}/cov_{nm}.ll`
|
||||
let bin = `{tmp_dir()}/cov_{nm}`
|
||||
let cov = `{dir}/{nm}.cov`
|
||||
if not shq(`bin/ludicc --coverage examples/{path}.ludic > {ll} 2>{tmp_dir()}/cov.err`) {
|
||||
bad2(path, capture_line(`tail -1 {tmp_dir()}/cov.err`)); return 1
|
||||
}
|
||||
if not shq(`{cc()} -O2 {ll} -o {bin} 2>{tmp_dir()}/cov.err`) {
|
||||
bad2(path, capture_line(`tail -1 {tmp_dir()}/cov.err`)); return 1
|
||||
}
|
||||
run(`rm -f {ll}`)
|
||||
# run the spec; its atexit hook writes the dump to $LUDIC_COVERAGE
|
||||
if not shq(`LUDIC_COVERAGE={cov} {bin} > /dev/null 2>&1`) {
|
||||
bad2(path, "spec exited non-zero"); return 1
|
||||
}
|
||||
if not file_exists(cov) { bad2(path, "no coverage dump written"); return 1 }
|
||||
# aggregate: total instrumented lines, how many were hit, and which were missed.
|
||||
# each row is `<line> <hits>`, so an unhit line ends in " 0" — grep counts and
|
||||
# lists them (avoiding awk, whose braces collide with string interpolation).
|
||||
let total = str_to_int(capture_line(`grep -c '^[0-9]' {cov}`))
|
||||
let nmiss = str_to_int(capture_line(`grep -c ' 0$' {cov}`))
|
||||
let covered = total - nmiss
|
||||
let missed = capture_line(`grep ' 0$' {cov} | cut -d' ' -f1 | tr '\n' ' '`)
|
||||
var pct = 100
|
||||
if total > 0 { pct = (covered * 100) / total }
|
||||
var line = ` examples/{path}.ludic {string(covered)}/{string(total)} lines {string(pct)}%`
|
||||
if not (missed == "") { line = `{line} uncovered: {missed}` }
|
||||
print(line)
|
||||
COV_COVERED += covered
|
||||
COV_TOTAL += total
|
||||
return 0
|
||||
}
|
||||
|
||||
var COV_COVERED: int = 0
|
||||
var COV_TOTAL: int = 0
|
||||
|
||||
function cmd_test_coverage() -> int {
|
||||
# the suite normally rebuilds bin/ludicc from the seed; do the same here so the
|
||||
# instrumentation path is exactly what a clean checkout ships.
|
||||
run(`mkdir -p bin build {tmp_dir()}/cov_out`)
|
||||
if (not is_exec("bin/ludicc")) or newer("selfhost/ludicc.seed.ll", "bin/ludicc") {
|
||||
if not shq(`{cc()} selfhost/ludicc.seed.ll -o bin/ludicc`) { err("x: cannot build bin/ludicc from the seed\n"); return 1 }
|
||||
}
|
||||
COV_COVERED = 0
|
||||
COV_TOTAL = 0
|
||||
print("== line coverage (bin/ludic dev test --coverage) ==")
|
||||
cov_one("library/coverage", `{tmp_dir()}/cov_out`)
|
||||
cov_one("library/testing", `{tmp_dir()}/cov_out`)
|
||||
var pct = 100
|
||||
if COV_TOTAL > 0 { pct = (COV_COVERED * 100) / COV_TOTAL }
|
||||
print(` ----`)
|
||||
print(` total: {string(COV_COVERED)}/{string(COV_TOTAL)} lines {string(pct)}%`)
|
||||
return 0
|
||||
}
|
||||
|
||||
function cmd_dev_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/ludic build examples/games/snake.ludic --headless >/dev/null 2>&1") and is_exec("bin/ludicc") {
|
||||
ok("clang selfhost/ludicc.seed.ll -> bin/ludicc")
|
||||
} else { bad("compiler did not build from the seed") }
|
||||
|
||||
print("== every example compiles to its golden render ==")
|
||||
game_case("games/snake", "ddssaawwddss")
|
||||
game_case("games/menu", "ssss")
|
||||
game_case("games/chronorift", "ddddwwwwaassK")
|
||||
|
||||
print("== self-hosting and the bootstrap fixpoints ==")
|
||||
run(`bin/ludic dev selfhost-test > {tmp_dir()}/sh.out 2>&1`)
|
||||
if shq(`grep -q 'gen2.ll == gen3.ll' {tmp_dir()}/sh.out`) { ok("the compiler reproduces itself (fixpoint)") } else { bad("fixpoint") }
|
||||
if shq(`grep -q 'no C compiler' {tmp_dir()}/sh.out`) { ok("the compiler rebuilds itself from the seed, C-free") } else { bad("C-free bootstrap") }
|
||||
let corr = capture_line(`grep -c PASS {tmp_dir()}/sh.out`)
|
||||
ok(`self-host suite: {corr} checks passed (see: ludic dev selfhost-test)`)
|
||||
|
||||
print("== documented syntax stays compilable (guards against spec/compiler drift) ==")
|
||||
qsmoke("lang/qdecl")
|
||||
feat_case("lang/annotations", "", "3 25 0 0", "annotations.ludic (@Queries / @Computed / @OnSpawn / @Handles)")
|
||||
feat_case("lang/lifecycle", "", "1 700 50 950 2", "lifecycle.ludic (@OnStart/@OnAttach/@OnSpawn/@OnDespawn/@OnQuit in order)")
|
||||
feat_case("lang/toggle", "", "6 0 7 1 0", "toggle.ludic (enable/disable + @OnDisable/@OnEnable across property/model)")
|
||||
feat_case("lang/strings", "", "1 2 3 4 5 6 7 8 9", "strings.ludic (str ops, interpolation, slicing)")
|
||||
feat_case("lang/operators", "", "1 2 3 4 5 6 7 8 9 10", "operators.ludic (compound assignment on fixed/string/long, -fixed, char escapes, list literals)")
|
||||
feat_case("lang/rng_demo", "", "69 89 6 -1 0", "rng_demo.ludic (Random.value/int/sign)")
|
||||
feat_case("lang/time_demo", "aaaaaa", "0 1 2 3 4 16", "time_demo.ludic (Time.frame/elapsed/delta)")
|
||||
feat_case("lang/scenes", "aaaaaaaaaa", "1000 1 101 102 2 3 900 201 900 202 900", "scenes.ludic (scene/layer/on enter/on exit/become)")
|
||||
feat_case("lang/detach", "", "15 1 25 0", "detach.ludic (attach/detach + @OnAttach/@OnDetach)")
|
||||
feat_case("lang/reason", "", "503 1009", "reason.ludic (@OnDespawn reason: Despawned vs Quit)")
|
||||
|
||||
# standard-library namespaces, each a self-contained `entry` program asserted
|
||||
# against known-answer vectors (crypto) or structural invariants (uuid/noise).
|
||||
feat_case("library/crypto", "", "1 2 3 4 5 6 7 8 9", "crypto.ludic (Crypto SHA-256/HMAC/base64 KAT + CSPRNG shape)")
|
||||
feat_case("library/uuid", "", "1 2 3 4 5 6 7 8 9 10", "uuid.ludic (Uuid v4/v7 format, version/variant, parse/equals)")
|
||||
feat_case("library/noise", "", "1 2 3 4 5 6 7 8 9 10 11", "noise.ludic (Noise value/perlin/simplex/fbm/cellular determinism + range)")
|
||||
feat_case("library/bignum", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16", "bignum.ludic (BigInt arbitrary-precision + Decimal exact base-10 money)")
|
||||
feat_case("library/containers", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "containers.ludic (Dict string-keyed hash map + Set string set)")
|
||||
feat_case("library/numeric", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20", "numeric.ludic (Huge idle big-numbers + Angle wrapping radians + Percent clamped [0,1])")
|
||||
feat_case("library/jobs", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31", "jobs.ludic (Job background compute/defer/fulfill/cancel + Promise all/race/progress + Sync mutex/atomic/channel; issue #14)")
|
||||
feat_case("library/optionresult", "", "1 2 3 4 5 6 7 8 9 10 11 12", "optionresult.ludic (option some/none + result ok/err/try safety types)")
|
||||
feat_case("library/regex", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "regex.ludic (Regex match/find/groups/classes/quantifiers/replace + linear-time safety)")
|
||||
feat_case("library/grid", "", "1 2 3 4 5 6 7 8 9 10 11 12 13", "grid.ludic (Grid line/flood/line_of_sight + A* pathfinding over the tilemap)")
|
||||
feat_case("library/anim", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34", "anim.ludic (Anim frame/once/pingpong/cell + Tween progress/loop/yoyo/ease/number/round/point/tint)")
|
||||
feat_case("library/anim_sugar", "", "4 8 2 1 0 100 100 0 0 1 20 20 30 0 1", "anim_sugar.ludic (Anim.clip/play/on_frame/fired + Motion.to + fluent Tween.to/chain/delay/parallel handles; issue #48)")
|
||||
feat_case("library/namespace_block", "", "15 42 100", "namespace_block.ludic (#76 namespace Name { export/internal function } block — declares the namespace once, controls the public surface)")
|
||||
feat_case("library/preload", "", "3 0 0 0 1 33 66 1 100 1", "preload.ludic (#82 Assets.enqueue/pump/progress/ready — incremental asset preload for a loading scene)")
|
||||
feat_case("library/pool", "", "0 0 3 3 0 2 1 3 0 3 1", "pool.ludic (#80 Pool.live/free/reserved/capacity — the ECS freelist recycles despawned slots, reserved stays flat)")
|
||||
feat_case("library/atlas", "", "16 16 32 48 1 1 1 16 1", "atlas.ludic (#81 Sprite.sheet/cell/cell_span/define/named + Assets.image/get — namespaced spritesheet/atlas with multi-cell sprites)")
|
||||
feat_case("library/camera_zoom", "", "1 0 0 1 1", "camera_zoom.ludic (Camera.zoom deterministic Q16.16 render-time zoom about the screen centre, verified by pixel readback; issue #78)")
|
||||
feat_case("library/clear_color", "q", "1 1", "clear_color.ludic (@ClearColor: the Render phase auto-clears to the declared colour + auto-presents, no Screen.clear/show in the handler; issue #86)")
|
||||
feat_case("library/query", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "query.ludic (Query count/first/nearest/within — ECS spatial queries over the reflection ABI)")
|
||||
feat_case("library/reflect", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20", "reflect.ludic (Reflect prop/field enumeration + type + get/set/has/kind — runtime reflection over the world schema)")
|
||||
feat_case("library/serialize", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16", "serialize.ludic (Value tree + Json encode/parse + Reflect.serialize/apply — bit-exact save/load; issue #44)")
|
||||
feat_case("library/tiled_p0", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21", "tiled_p0.ludic (Tiled P0: Xml reader + Base64 decode/encode + gzip framing over inflate; issue #67)")
|
||||
feat_case("library/tiled_p05", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30", "tiled_p05.ludic (Tiled P0.5: TMX/TSX reader -> same Value-tree intermediate as TMJ; Kenney sampleMap.tmx; issue #68)")
|
||||
feat_case("library/tiled_p1", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p1.ludic (Tiled P1: rt_tmap model + GID resolver/flip decode + render; loads+draws Kenney map identically from .tmx and .tmj; issue #69)")
|
||||
feat_case("library/tiled_p2", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p2.ludic (Tiled P2: per-tile objectgroup / property-convention / collision-layer -> Solids feed; A* baseline; issue #70)")
|
||||
feat_case("library/tiled_p3", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p3.ludic (Tiled P3: animated tiles -> deterministic frame clock + tile objects w/ flips; beach_tileset 33 anims; issue #71)")
|
||||
feat_case("library/tiled_p4", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "tiled_p4.ludic (Tiled P4: object shapes/text + custom props/types + templates + opt-in spawn; issue #72)")
|
||||
feat_case("library/tiled_p5", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p5.ludic (Tiled P5: image/group layers + iso/hex/staggered coords + Wang GID resolution; issue #73)")
|
||||
feat_case("library/tiled_p6", "", "1 2 3 4 5 6 7 8 9 10 11 12", "tiled_p6.ludic (Tiled P6: infinite/chunked maps + .world stitching + base64+zstd decompression; issue #74)")
|
||||
feat_case("library/render", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "render.ludic (Screen pixel/oval/camera/clip/blend_mode/measure_text + Camera set/follow/shake, verified by pixel readback)")
|
||||
feat_case("library/lighting", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "lighting.ludic (Light ambient/point radial falloff + occluder hard shadows — 2D light accumulation, verified by pixel readback)")
|
||||
feat_case("library/light_tiers", "", "1 1 1 1 1 1 1 1 1", "light_tiers.ludic (Light spot/falloff/soft/gel/normal/time_of_day — render-quality tiers 3-4; issue #49)")
|
||||
feat_case("library/physics", "", "184 1 1 284 1 1 50 51 104 1 134 1 120 0 1 1 1 0 1 1 1", "physics.ludic (Body + Collider + esys_move: gravity/land, wall stop, sub-pixel, entity platform, one-way, triggers — entity broadphase; issue #65)")
|
||||
feat_case("library/physics_tiles", "", "112 1 80 1 48 1 60", "physics_tiles.ludic (esys_move tile-grid broadphase: tile floor/wall + one-way tile land-from-above / rise-through; issue #65)")
|
||||
runtime_home_case() # #75: engine runtime resolves from LUDIC_HOME, not the project
|
||||
# builtin gameplay controllers, consumed as in-repo packages (issues #57-#61)
|
||||
controller_case("library/core_components", "", "110 30 184 1", "core_components.ludic (ludic.core #77: canonical Position/Body/Collider imported from a package, driving esys_move, composed with a game Health component)")
|
||||
controller_case("library/sprite_render", "q", "1 0", "sprite_render.ludic (ludic.core #85: engine sprite-render system auto-draws a Sprite entity from its Position; respects hidden)")
|
||||
controller_case("library/sprite_atlas", "q", "32 1 1 1 0", "sprite_atlas.ludic (#90 Sprite { atlas: 1 } routes esys_sprite through atlas_draw_ex: a 16x32 cell_span draws both cells, tint applies, atlas: 0 stays a table id)")
|
||||
controller_case("library/world_bounds", "", "184 -16 184 1 0", "world_bounds.ludic (ludic.core #84: engine world-bounds system — clamp/wrap/bounce/kill keep a Body in the play area)")
|
||||
controller_case("library/gameplay_foundation", "", "2 0 1 5 24 0 2 1 15 85 7 1 1", "gameplay_foundation.ludic (ludic.gameplay #57: Cooldown timer + Stats/modifier stack + Faction table + Combat cancel/mutable pipeline)")
|
||||
controller_case("games/platformer_demo", "", "1 1 1 1 1 1 1 1", "platformer_demo.ludic (ludic.platformer #58: gravity/land, jump apex, coyote, veto-gated double jump, wall collision + disable-system lever)")
|
||||
controller_case("games/platformer_scaffolding", "", "1 1 1 1 1 1", "platformer_scaffolding.ludic (ludic.platformer #58 layers 4-5: moving-platform rider carry, pickup->score, spring, hazard+Life i-frames)")
|
||||
controller_case("games/shooter_demo", "", "1 1 1 1 1 1 1 1 1 1 1", "shooter_demo.ludic (ludic.shooter #60: decoupled move/aim, weapon registry, projectile faction-hit, spread/ring, homing, wave spawner)")
|
||||
controller_case("games/npcai_demo", "", "1 1 1 1 1 1 1 1", "npcai_demo.ludic (ludic.npcai #61: perception/spot, FSM chase+attack driving the shooter weapon, utility flee, companion follower)")
|
||||
controller_case("library/prefabs", "", "230159 30 0 7 44 -1 2", "prefabs.ludic (prefab chains, spawn as an expression, Prefab.spawn / spawn_at by name, self() in @OnSpawn)")
|
||||
controller_case("library/component_access", "xxxxxxxx", "9 15 10 33 1 0 0 1 0 1 101 100 102 1 2 1 0", "component_access.ludic (Prop.of/has/count/despawn_all, countdown fields, enum-typed machine)")
|
||||
controller_case("library/scene_menus", "xxxxxxxxxxxx", "100 200 201 1 202 300 6 7 8", "scene_menus.ludic (become from a listener, layer @Queries, scene shows / lasts, goto: buttons)")
|
||||
controller_case("library/managers", "xxxxxxxxxxxx", "35046 46 251 53 900 -1 20 324 0 10 102", "managers.ludic (Map cell API, Sprite move/face/flash/blink, IVec2 geometry, List.sample, Input.move_i, Fx, audio bank, Camera.shake_for)")
|
||||
controller_case("library/combat_kit", "xxxxxxxxxxxx", "6846 153196 31 107110 6000 5002 1 80 97 11 10 700 110", "combat_kit.ludic (Dash, Melee + MeleeHit, build stats in Combat.damage + Crit, Stats.add, Dungeon arena / exits / entry points, Prefs.max)")
|
||||
controller_case("games/rpg_demo", "", "1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1", "rpg_demo.ludic (ludic.rpg #59 all 7 modules: grid move+veto+interact, inventory+equip, crafting, quests, dialog graph, sokoban+signal gate, poison status)")
|
||||
spec_case("library/testing", "== 6 passed, 0 failed ==")
|
||||
spec_case("library/coverage", "== 3 passed, 0 failed ==")
|
||||
feat_case("library/errors", "", "5 10 0 7 1", "errors.ludic (assert guards an invariant, holds -> runs to the end; issue #8 success path)")
|
||||
feat_case("library/recover", "", "2600 100 1 1 42 7", "recover.ludic (try/else recovers a result value + fallback, error binding, is_ok/is_err; issue #46)")
|
||||
panic_case()
|
||||
feat_case("library/logging", "", "0 5 2 1", "logging.ludic (Log levels, set_level/level threshold, structured fields)")
|
||||
# Os known-folders/arch and Fs.list read the BSD utsname/dirent layout, so
|
||||
# their asserted values are macOS-specific; skip off Darwin (see is_darwin).
|
||||
if is_darwin() {
|
||||
feat_case("library/os", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "os.ludic (Os args/env round-trip, platform/arch, known dirs, pid)")
|
||||
} else { skip("os.ludic (Os known-folder / uname ABI is macOS/BSD-specific)") }
|
||||
feat_case("library/unicode", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "unicode.ludic (UTF-8 len/validate/char_at/chars/case/truncate/graphemes)")
|
||||
if is_darwin() {
|
||||
feat_case("library/fs", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32", "fs.ludic (Path join/dir/base/ext/normalize, Fs read/write/list/copy, Mime of/sniff)")
|
||||
} else { skip("fs.ludic (Fs.list reads the macOS/BSD dirent layout)") }
|
||||
|
||||
# issue #9: the Time/Date/Duration/Clock stdlib, driven from its own `entry`.
|
||||
net_case("lang/offline_rewards", "13 650 2026-08-30 0")
|
||||
|
||||
# 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("events/mod_events", "10 32 42")
|
||||
net_case("networking/net_echo", "4 10 20 30 42")
|
||||
net_case("networking/net_snapshot", "50 7 50")
|
||||
net_case("networking/net_sync", "12 3 4 50 999")
|
||||
net_case("networking/net_owner", "-1 7 0 1")
|
||||
net_case("networking/net_rpc", "0 8")
|
||||
net_case("networking/net_roles", "1 102")
|
||||
net_case("networking/net_demo", "5 999 5")
|
||||
|
||||
feat_case("events/promote", "", "100 101 200 201", "promote.ludic (@Public -> model_<M>_spawn/despawn events)")
|
||||
feat_case("events/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("ecs/world_get", "50 1 7")
|
||||
net_case("ecs/world_scan", "2 110")
|
||||
net_case("ecs/world_spawn", "1 42 1")
|
||||
net_case("ecs/world_mixed", "99")
|
||||
net_case("ecs/world_query", "2 110")
|
||||
|
||||
# #43: engine-owned systems auto-advance the SpriteAnim / Motion components a
|
||||
# game merely declares and carries — no handler wired. Entry-driven so it can
|
||||
# assert the auto-advanced fields at known tick counts.
|
||||
net_case("library/anim_ecs", "1 5 2 10 0 2 10 1 1")
|
||||
# #47: the engine light pass consumes Light2D / Occluder / Ambient components at
|
||||
# the end of the Render phase — ambient tint, additive glow, hard shadow — with
|
||||
# no Light.* calls wired. Sampled back off the framebuffer with Screen.pixel.
|
||||
net_case("library/light_ecs", "32 1 32 1")
|
||||
# #7: action maps (read named actions, not keys), runtime rebinding, and
|
||||
# deterministic input record/replay — fed one key per poll from stdin.
|
||||
feat_case("library/input_actions", " xwa", "1 0 1 1 0 1 0", "input_actions.ludic (#7 action maps + rebinding + deterministic replay)")
|
||||
feat_case("library/input_device", "", "1 1 0 1 0 1 71 -71 5 1 3 1 2 1 0 0 1", "input_device.ludic (#50 multi-key held state + analog axis/vector + mouse + gamepad/touch + full-state replay)")
|
||||
feat_case("library/input_manager", "", "1 1 1 0 1 0 0 1", "input_manager.ludic (#83 Input Manager: default bindings + device-agnostic actions across keyboard/pad + on-press/on-release edges)")
|
||||
feat_case("library/input_auto", "llq", "2", "input_auto.ludic (#83 the frame loop auto-commits the device layer: Input.active fires with no manual Input.poll)")
|
||||
feat_case("library/input_edge", "xkkxq", "0 11 1 100 0", "input_edge.ludic (#87 Input.key_pressed/key_released edges fire on the transition frame; no double-commit under the frame loop)")
|
||||
feat_case("library/input_movement", "", "1 0 -1 -1 0", "input_movement.ludic (#79 Input.axis_i turns WASD into a -1/0/1 movement intent, no bool->int glue)")
|
||||
feat_case("library/audio", "", "0 0 0 0 1", "audio.ludic (#22 Audio.* load/play/music/volume/pitch/stop/is_playing — headless no-op)")
|
||||
# #6 Http.* — the client links Foundation (macOS-only), so build it through the
|
||||
# canonical `ludicc -o` path (which wires the framework) and gate on Darwin. The
|
||||
# run exercises the pure-Ludic response parser; no network is touched.
|
||||
if is_darwin() {
|
||||
if shq(`LUDIC_HOME=. bin/ludicc examples/library/http.ludic -o {tmp_dir()}/http_demo > {tmp_dir()}/http.out 2>&1`) {
|
||||
let got = capture_line(`{tmp_dir()}/http_demo < /dev/null`)
|
||||
if (got == "200 1 5 hello 42 text/plain 404 0") { ok("http.ludic (#6 Http.parse/status/ok/text/header — pure response parser)") }
|
||||
else { bad2("http.ludic", `got [{got}]`) }
|
||||
} else { bad2("http.ludic build", capture_line(`tail -1 {tmp_dir()}/http.out`)) }
|
||||
}
|
||||
|
||||
feat_case("events/recurse", "", "16", "recurse.ludic (EV6: re-entrant emit is depth-bounded, no runaway cycle)")
|
||||
net_case("events/scoped", "2")
|
||||
net_case("ecs/world_dyn", "0 1 30 100 1 30")
|
||||
|
||||
feat_case("events/prop_events", "", "300 400", "prop_events.ludic (@Public -> prop_<P>_attach/detach events)")
|
||||
feat_case("events/scene_events", "", "10 1 20 2 30 3", "scene_events.ludic (public scene -> scene_<S>_enter/exit events)")
|
||||
feat_case("events/program_events", "", "1 100 2 200", "program_events.ludic (@Public @OnStart/@OnQuit -> program_start/quit)")
|
||||
feat_case("events/layer_events", "aaaa", "50 2 1 50", "layer_events.ludic (layer toggle + public layer -> layer_<L>_show/hide)")
|
||||
|
||||
# showcase examples with no self-asserting `entry` — compile-only so they can't
|
||||
# silently rot after a compiler or reorg change.
|
||||
print("== showcase examples still compile ==")
|
||||
smoke("ecs/hello")
|
||||
smoke("events/events")
|
||||
smoke("networking/net_rt")
|
||||
smoke("library/cursor_capture") # #89 Input.cursor_mode compiles (no-op headless; windowed links cocoa.ll)
|
||||
|
||||
print("== the compiler and the CLI (ludicc / ludic) ==")
|
||||
# ludicc comes out of the IR seed with clang alone; the CLI is then compiled
|
||||
# by it, from Ludic.
|
||||
if shq(`bin/ludic dev build-cli > {tmp_dir()}/cli.out 2>&1`) and is_exec("bin/ludicc") {
|
||||
ok("ludic dev build-cli builds bin/ludicc from the seed (clang-only)")
|
||||
} else { bad2("ludic dev build-cli", capture_line(`tail -1 {tmp_dir()}/cli.out`)) }
|
||||
|
||||
# ludicc -o drives clang to a native binary. A game links windowed by default
|
||||
# (LUDIC_HOME points the linker at runtime/native/cocoa.ll) — that path is
|
||||
# macOS-only (Cocoa), so off Darwin we prove the same "-o yields a runnable
|
||||
# native binary" with a headless link, which needs no window backend.
|
||||
if is_darwin() {
|
||||
if shq(`LUDIC_HOME=. bin/ludicc examples/games/snake.ludic -o {tmp_dir()}/cli_snake > {tmp_dir()}/cli.out 2>&1`) and shq(`file {tmp_dir()}/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_dir()}/cli.out`)) }
|
||||
# #51 — a windowed build must load GameController so the native gamepad
|
||||
# polling (win_pad, reached on the is_windowed branch) can see controllers.
|
||||
if shq(`otool -L {tmp_dir()}/cli_snake 2>/dev/null | grep -q GameController`) {
|
||||
ok("windowed link loads GameController (#51 gamepad binding)")
|
||||
} else { bad("windowed binary is missing the GameController load command") }
|
||||
} else {
|
||||
if shq(`LUDIC_HOME=. bin/ludicc --headless examples/games/snake.ludic -o {tmp_dir()}/cli_snake > {tmp_dir()}/cli.out 2>&1`) and shq(`test -x {tmp_dir()}/cli_snake`) {
|
||||
ok("ludicc app.ludic -o bin -> native executable (headless)")
|
||||
} else { bad2("ludicc -o", capture_line(`tail -1 {tmp_dir()}/cli.out`)) }
|
||||
}
|
||||
|
||||
# --emit-llvm stops at the IR.
|
||||
if shq(`bin/ludicc examples/games/snake.ludic --emit-llvm -o {tmp_dir()}/cli_snake.ll > {tmp_dir()}/cli.out 2>&1`) and shq(`head -1 {tmp_dir()}/cli_snake.ll | grep -q 'LLVM IR'`) {
|
||||
ok("ludicc --emit-llvm -> LLVM IR")
|
||||
} else { bad2("ludicc --emit-llvm", capture_line(`tail -1 {tmp_dir()}/cli.out`)) }
|
||||
|
||||
# `ludic <file>` compiles, runs, and forwards the program's exit code.
|
||||
write_file(`{tmp_dir()}/cli_exit.ludic`, "entry { exit(42) }\n")
|
||||
let rc = sh(`bin/ludic {tmp_dir()}/cli_exit.ludic --headless > {tmp_dir()}/cli.out 2>&1`)
|
||||
if (rc == 42) { ok("ludic app.ludic -> compiles, runs, forwards exit code") }
|
||||
else { bad2("ludic run: expected exit 42", `got {string(rc)}`) }
|
||||
|
||||
# The CLI has to work as an install does: from another directory, with no
|
||||
# ./bin, finding its compiler, the engine runtime and the bundled packages
|
||||
# from its own location. This is the shape `curl … | sh` produces.
|
||||
install_layout_case()
|
||||
|
||||
# install.sh is what the landing page tells people to pipe into sh, and it is
|
||||
# published with the docs site — so it is checked here rather than discovered
|
||||
# broken by a first-time user. `sh -n` parses it without running anything.
|
||||
if not file_exists("install.sh") { bad("install.sh is missing") }
|
||||
else if shq("sh -n install.sh 2>/dev/null") and shq("sh install.sh --help >/dev/null 2>&1") {
|
||||
ok("install.sh parses under POSIX sh and answers --help")
|
||||
} else { bad("install.sh does not parse under POSIX sh") }
|
||||
|
||||
# the package manager (issue #63): fetch + MVS resolve + content-addressed
|
||||
# store + ludic_modules/ links + build, run as its own hermetic suite (it
|
||||
# stands up throwaway git repos, so like selfhost-test it runs as a sub-process
|
||||
# and this stage checks its footer).
|
||||
print("== package manager (ludic dev test-pkg) ==")
|
||||
run(`bin/ludic dev test-pkg > {tmp_dir()}/pkg.out 2>&1`)
|
||||
if shq(`grep -q '0 failed' {tmp_dir()}/pkg.out`) {
|
||||
let pc = capture_line(`grep -c PASS {tmp_dir()}/pkg.out`)
|
||||
ok(`package-manager suite: {pc} checks passed (see: ludic dev test-pkg)`)
|
||||
} else { bad2("ludic dev test-pkg", capture_line(`grep -i fail {tmp_dir()}/pkg.out | head -1`)) }
|
||||
|
||||
# docs site generator + guard, in Ludic (no Python). docs-gen emits the whole
|
||||
# pages payload; docs-check is its coverage/integrity guard; docs-palette is
|
||||
# the named-colour source of truth (its --check mode regenerates the tracked
|
||||
# emit_color.ludic + palette.json into scratch files and compares).
|
||||
print("== docs site (Ludic generator, no Python) ==")
|
||||
if shq(`bin/ludic dev docs-gen --out {tmp_dir()}/docs > {tmp_dir()}/docs.out 2>&1`) {
|
||||
ok("ludic dev docs-gen -> the pages-branch payload")
|
||||
} else { bad2("ludic dev docs-gen", capture_line(`tail -1 {tmp_dir()}/docs.out`)) }
|
||||
if shq(`bin/ludic dev docs-check {tmp_dir()}/docs > {tmp_dir()}/docs_chk.out 2>&1`) {
|
||||
ok("ludic dev docs-check passes on the generated site")
|
||||
} else { bad2("ludic dev docs-check", capture_line(`tail -1 {tmp_dir()}/docs_chk.out`)) }
|
||||
if shq(`bin/ludic dev docs-palette --check > {tmp_dir()}/pal.out 2>&1`) {
|
||||
ok("ludic dev docs-palette regenerates emit_color.ludic + palette.json byte-identically")
|
||||
} else { bad2("ludic dev docs-palette --check", capture_line(`tail -1 {tmp_dir()}/pal.out`)) }
|
||||
|
||||
return report()
|
||||
}
|
||||
203
tools/ludic-cli/tools.ludic
Normal file
203
tools/ludic-cli/tools.ludic
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
# 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
|
||||
function sync_one(shared: pointer, dst: pointer) -> 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).
|
||||
function 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?
|
||||
function has_flag(flag: pointer) -> bool {
|
||||
var i = 2
|
||||
while i < arg_total() { if (arg_n(i) == flag) { return true }; i += 1 }
|
||||
return false
|
||||
}
|
||||
|
||||
# ---- tools: build the editor toolchain --------------------------------------
|
||||
# usage: ludic dev tools [--install] [--test]
|
||||
function cmd_tools() -> int {
|
||||
ensure_ludicc()
|
||||
print("ludicc: tools/ludic-tools/fmt.ludic -> bin/ludic-fmt (Ludic -> LLVM IR -> binary, no C)")
|
||||
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 -----------------------
|
||||
function cmd_test_tools() -> int {
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
let fmt = "bin/ludic-fmt"
|
||||
if not is_exec(fmt) { err("bin/ludic-fmt missing — run: ludic dev tools\n"); return 2 }
|
||||
if not is_exec("bin/ludic-lsp") { err("bin/ludic-lsp missing — run: ludic dev tools\n"); return 2 }
|
||||
|
||||
print("formatter")
|
||||
|
||||
# --- 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_dir()}/a 2>/dev/null; {fmt} {tmp_dir()}/a > {tmp_dir()}/b 2>/dev/null; cmp -s {tmp_dir()}/a {tmp_dir()}/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/ecs/hello.ludic examples/games/snake.ludic examples/games/menu.ludic examples/lang/qdecl.ludic examples/lang/scenes.ludic; do test -f "$f" || continue; {fmt} "$f" > {tmp_dir()}/f1 2>/dev/null; {fmt} {tmp_dir()}/f1 > {tmp_dir()}/f2 2>/dev/null; cmp -s {tmp_dir()}/f1 {tmp_dir()}/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_dir()}/utf8.ludic`, "program E {\n # … ellipsis in a comment\n const S: string = \"café → naïve\"\n}\n")
|
||||
if shq(`{fmt} {tmp_dir()}/utf8.ludic > {tmp_dir()}/utf8.out 2>/dev/null; cmp -s {tmp_dir()}/utf8.ludic {tmp_dir()}/utf8.out`) {
|
||||
ok("UTF-8 preserved byte for byte")
|
||||
} else { bad("UTF-8 mangled") }
|
||||
write_file(`{tmp_dir()}/utf8b.ludic`, "program F {\nfn f() -> int { return … }\n}\n")
|
||||
let ua = capture_line(`tr -d ' \t\n' < {tmp_dir()}/utf8b.ludic | shasum | cut -d' ' -f1`)
|
||||
let ub = capture_line(`{fmt} {tmp_dir()}/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_dir()}/c.ludic`, "program C {\n # keep me\n property P { x: int = 0 } # and me\n}\n")
|
||||
let ncmt = capture_line(`{fmt} {tmp_dir()}/c.ludic | grep -c '#'`)
|
||||
check("comments preserved", ncmt, "2")
|
||||
|
||||
# --- messy input is normalised ---------------------------------------------
|
||||
write_file(`{tmp_dir()}/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_dir()}/m.ludic > {tmp_dir()}/m.out 2>/dev/null`)
|
||||
if shq(`grep -q '^ property P {{ x: int = 0 }}$' {tmp_dir()}/m.out`) { ok("reindents and respaces") } else { bad("reindents and respaces") }
|
||||
if shq(`grep -q 'a = -a' {tmp_dir()}/m.out`) { ok("unary minus not spaced") } else { bad("unary minus not spaced") }
|
||||
|
||||
# --- language conventions are respected, not overwritten -------------------
|
||||
write_file(`{tmp_dir()}/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_dir()}/u.ludic | grep -q 'panel id: Root w: 10 {{'`) { ok("widget props keep their `key: value` spacing") } else { bad("widget props keep their `key: value` spacing") }
|
||||
write_file(`{tmp_dir()}/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_dir()}/q.ludic | grep -q '\\[Pos, {{Foe}}\\]'`) { ok("query tags stay tight ({Foe})") } else { bad("query tags stay tight") }
|
||||
write_file(`{tmp_dir()}/al.ludic`, "program A {\n const R_A: int = 0 # one\n const R_BBB: int = 1 # two\n}\n")
|
||||
if shq(`{fmt} {tmp_dir()}/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_dir()}/m.ludic`)
|
||||
check("--check exits 1 on unformatted input", string(chk1), "1")
|
||||
run(`{fmt} -w {tmp_dir()}/m.ludic`)
|
||||
let chk2 = sh(`{fmt} --check -q {tmp_dir()}/m.ludic`)
|
||||
check("--check exits 0 after -w", string(chk2), "0")
|
||||
|
||||
# --- markdown: fences formatted, prose untouched ---------------------------
|
||||
write_file(`{tmp_dir()}/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_dir()}/d.md > {tmp_dir()}/d.out 2>/dev/null`)
|
||||
if shq(`grep -q 'prose with spaces' {tmp_dir()}/d.out`) { ok("markdown prose untouched") } else { bad("markdown prose untouched") }
|
||||
if shq(`grep -q 'z = 1' {tmp_dir()}/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_dir()}/d.out`) { ok("ludic fences formatted") } else { bad("ludic fences formatted") }
|
||||
|
||||
print("language server")
|
||||
# The LSP indexes the workspace by walking directories, which reads the BSD
|
||||
# dirent layout; off Darwin that scan comes up empty and the cross-file
|
||||
# features (go-to-def, find-usages, workspace symbols) can't resolve. Gate the
|
||||
# end-to-end protocol test on Darwin until the directory walk is portable.
|
||||
if is_darwin() {
|
||||
let lrc = sh("bin/ludic dev test-lsp")
|
||||
if lrc == 0 { ok("language server protocol") }
|
||||
else if lrc == 2 { skip("language server protocol (bin/ludic-lsp not built)") }
|
||||
else { bad("language server protocol") }
|
||||
} else { skip("language server protocol (workspace scan uses the macOS/BSD dirent layout)") }
|
||||
|
||||
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")
|
||||
# exit 2 = the TextMate engine is not installed: a skip, never a silent pass
|
||||
let grc = sh("node tools/test-grammar.js")
|
||||
if grc == 0 { ok("textmate grammar") }
|
||||
else if grc == 2 { skip("textmate grammar (npm i vscode-textmate vscode-oniguruma, or set LUDIC_NODE_MODULES)") }
|
||||
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: ludic dev tools to resync") }
|
||||
|
||||
# the JetBrains plugin is off by default (its first build pulls a gigabyte of
|
||||
# IntelliJ SDK). One env var away: LUDIC_TEST_JETBRAINS=1 ludic dev test-tools
|
||||
if (getenv_or("LUDIC_TEST_JETBRAINS", "0") == "1") {
|
||||
if shq("test -x tools/editors/jetbrains/gradlew") {
|
||||
if shq("cd tools/editors/jetbrains && ./gradlew buildPlugin verifyPluginStructure --console=plain -q >/dev/null 2>&1 && test -f build/distributions/Ludic-1.0.0.zip") { ok("jetbrains plugin builds and verifies") } else { bad("jetbrains plugin build") }
|
||||
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.
|
||||
# The check is written in Ludic (tools/ludic-cli/checks.ludic) and runs through x — no
|
||||
# Python in the loop.
|
||||
if shq("bin/ludic dev check-vocabulary 2>/dev/null") { ok("vocabulary in sync across grammar/lexer/header") } else { bad("vocabulary drifted") }
|
||||
|
||||
# every feature the compiler actually implements — namespace methods, keywords,
|
||||
# types, phases — must have a docs/language page. This reads the implementation
|
||||
# (emit_ns_call + ludic_syntax.h), so shipping a feature without docs fails here.
|
||||
# Ported to Ludic; runs through x.
|
||||
if shq("bin/ludic dev check-impl > /dev/null 2>&1") { ok("docs cover every implemented feature") } else { bad("docs drifted from the implementation") }
|
||||
|
||||
# every ```ludic fence in the docs must parse (or be marked skip/expect-error),
|
||||
# so a documented snippet can't drift from what the compiler accepts.
|
||||
if shq("bin/ludic dev check-docs > /dev/null 2>&1") { ok("every doc fence parses") } else { bad2("a doc fence drifted", "run: ludic dev check-docs") }
|
||||
|
||||
return report()
|
||||
}
|
||||
|
||||
# JSON/XML validity, checked by the Ludic validators in checks.ludic (no Python).
|
||||
function test_json(path: pointer) -> void {
|
||||
let bn = capture_line(`basename {path}`)
|
||||
if shq(`bin/ludic dev lint-asset {path}`) { ok(`valid JSON: {bn}`) } else { bad(`invalid JSON: {path}`) }
|
||||
}
|
||||
function test_xml(path: pointer) -> void {
|
||||
let bn = capture_line(`basename {path}`)
|
||||
if shq(`bin/ludic dev lint-asset {path}`) { ok(`valid XML: {bn}`) } else { bad(`invalid XML: {path}`) }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue