feat(tooling): JetBrains IDE support, LSP navigation, barrel imports, package scripts and hooks

- JetBrains plugin 1.4.0: semantic colours (builtin / vendor / own), template strings,
  brace handling, run configurations and a test console, package.ludic and
  package.lock.ludic editing (completion, docs, app preview, colour previews, asset
  navigation), External Libraries for the runtime and packages, doc pages for built-ins
- ludic-lsp: go to definition for imports, document links, hover with inferred types,
  type definition, signature help with parameters, docs from docs/language
- `import "dir"` resolves a barrel `dir/index.ludic`
- package.ludic `entry`, `script` and `hook before|after <command>`; `ludic <script>`,
  `ludic script`, `ludic scripts`
- `ludic test --verbose` and `--test NAME`; the test runner filters by name

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-16 16:00:28 +03:00
parent da57b20156
commit f92d7f89c6
75 changed files with 46802 additions and 40343 deletions

View file

@ -289,6 +289,54 @@ function cmd_test_lsp() -> int {
# ---- workspace-wide symbol search ---------------------------------------
let ws = lsp_request("workspace/symbol", `{{"query":"Stats"}}`)
# ---- navigation an IDE leans on --------------------------------------------
# package imports resolve from the toolchain's packages/, import strings are
# links, locals of both kinds have a type, built-ins lead to their reference
# page, and a call's argument list completes its parameter names.
let nsrc = "program Nav {\n import \"ludic.render3d/fmath.ludic\"\n function area(width: int, height: int) -> int { return width * height }\n entry {\n var total = 0\n let name = \"x\"\n total = total + area(width: 2, )\n Screen.fill_circle(x: 1, y: f_clamp(1, 0, 2), )\n let s = Screen.\n }\n}\n"
let nfile = tmp_path("lsp_nav.ludic")
write_file(nfile, nsrc)
lsp_open(nfile, "ludic")
let nuri = lsp_doc(nfile)
let links = lsp_request("textDocument/documentLink", `{{"textDocument":{nuri}}}`)
let pkgdef = lsp_request("textDocument/definition", `{{"textDocument":{nuri},"position":{position_of(nsrc, "ludic.render3d", 3)}}}`)
let xdef = lsp_request("textDocument/definition", `{{"textDocument":{nuri},"position":{position_of(nsrc, "f_clamp", 2)}}}`)
let vhov = lsp_request("textDocument/hover", `{{"textDocument":{nuri},"position":{position_of(nsrc, "total = total", 10)}}}`)
let lhov = lsp_request("textDocument/hover", `{{"textDocument":{nuri},"position":{position_of(nsrc, "name = ", 1)}}}`)
let vrefs = lsp_request("textDocument/references", `{{"textDocument":{nuri},"position":{position_of(nsrc, "var total", 5)},"context":{{"includeDeclaration":true}}}}`)
let bdef = lsp_request("textDocument/definition", `{{"textDocument":{nuri},"position":{position_of(nsrc, "fill_circle", 2)}}}`)
let bhov = lsp_request("textDocument/hover", `{{"textDocument":{nuri},"position":{position_of(nsrc, "fill_circle", 2)}}}`)
let tdef = lsp_request("textDocument/definition", `{{"textDocument":{nuri},"position":{position_of(nsrc, "int, height", 1)}}}`)
let acomp = lsp_request("textDocument/completion", `{{"textDocument":{nuri},"position":{position_of(nsrc, "2, )", 3)}}}`)
let nscomp = lsp_request("textDocument/completion", `{{"textDocument":{nuri},"position":{position_of(nsrc, "= Screen.", 9)}}}`)
let ahelp = lsp_request("textDocument/signatureHelp", `{{"textDocument":{nuri},"position":{position_of(nsrc, "2), )", 4)}}}`)
# contextual keywords, highlights, barrels, semantic modifiers
let kdir = tmp_path("lsp_barrel")
shell(`mkdir -p {kdir}/camp`)
write_file(`{kdir}/camp/index.ludic`, "import \"tent.ludic\"\n")
write_file(`{kdir}/camp/tent.ludic`, "function pitch(size: int) -> int { return size }\n")
let ksrc = "program K {\n import \"camp\"\n import \"ludic.render3d/fmath.ludic\"\n function go() -> int {\n var model = pitch(2)\n model = model + f_clamp(1, 0, 2)\n Screen.clear(0)\n return model\n }\n}\n"
let kfile = `{kdir}/k.ludic`
write_file(kfile, ksrc)
lsp_open(kfile, "ludic")
let kuri = lsp_doc(kfile)
let khov = lsp_request("textDocument/hover", `{{"textDocument":{kuri},"position":{position_of(ksrc, "var model", 5)}}}`)
let krefs = lsp_request("textDocument/references", `{{"textDocument":{kuri},"position":{position_of(ksrc, "var model", 5)},"context":{{"includeDeclaration":true}}}}`)
let kbarrel = lsp_request("textDocument/definition", `{{"textDocument":{kuri},"position":{position_of(ksrc, "pitch(2)", 1)}}}`)
let khl = lsp_request("textDocument/documentHighlight", `{{"textDocument":{kuri},"position":{position_of(ksrc, "f_clamp", 1)}}}`)
let ktoks = lsp_request("textDocument/semanticTokens/full", `{{"textDocument":{kuri}}}`)
# names inside `template {holes}`, and positions after a multi-line template
let tsrc = "program TT {\n function tag(variant: int) -> pointer {\n let multi = `a\nb {variant}`\n return `rock_{variant}` + multi\n }\n}\n"
let tfile = tmp_path("lsp_tmpl.ludic")
write_file(tfile, tsrc)
lsp_open(tfile, "ludic")
let turi = lsp_doc(tfile)
let thov = lsp_request("textDocument/hover", `{{"textDocument":{turi},"position":{position_of(tsrc, "rock_{variant", 7)}}}`)
let trefs = lsp_request("textDocument/references", `{{"textDocument":{turi},"position":{position_of(tsrc, "tag(variant", 5)},"context":{{"includeDeclaration":true}}}}`)
let tafter = lsp_request("textDocument/hover", `{{"textDocument":{turi},"position":{position_of(tsrc, "+ multi", 3)}}}`)
lsp_request("shutdown", "null")
lsp_notify("exit", "null")
@ -322,7 +370,10 @@ function cmd_test_lsp() -> int {
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("))
# the documented, namespaced spelling (docs/language) when the reference is
# present, else the builtin the method lowers to
check_true("signature help for a namespaced builtin", len(sigs.kids) > 0 and (Text.starts_with(j_get(sigs.kids[0], "label").s, "Random.chance(") or Text.starts_with(j_get(sigs.kids[0], "label").s, "rng_chance(")))
check_true("signature help splits the parameters", len(sigs.kids) > 0 and len(j_get(sigs.kids[0], "parameters").kids) == 1)
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)
@ -369,5 +420,41 @@ function cmd_test_lsp() -> int {
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)
let link0 = lsp_result(links)
check_true("an import string is a link to its file", len(link0.kids) == 1 and Text.ends_with(j_get(link0.kids[0], "target").s, "packages/ludic.render3d/fmath.ludic"))
let pd = lsp_result(pkgdef)
check_true("go-to-definition on a package import opens the package file", len(pd.kids) == 1 and Text.ends_with(j_get(pd.kids[0], "uri").s, "packages/ludic.render3d/fmath.ludic"))
check_true("a name from an imported package resolves into it", Text.ends_with(j_get(lsp_result(xdef), "uri").s, "fmath.ludic"))
check_true("hover gives a var local its inferred type", Text.contains(j_get(j_get(lsp_result(vhov), "contents"), "value").s, "var total: int"))
check_true("hover gives a let local its inferred type", Text.contains(j_get(j_get(lsp_result(lhov), "contents"), "value").s, "let name: string"))
check("find-usages of a var local", loc_lines_key(lsp_result(vrefs)), "4,6")
check_true("go-to-definition on a namespace method opens its reference page", Text.ends_with(j_get(lsp_result(bdef), "uri").s, "screen-fill_circle.md"))
check_true("hover on a namespace method links its documentation", Text.contains(j_get(j_get(lsp_result(bhov), "contents"), "value").s, "screen-fill_circle.html"))
check_true("go-to-definition on a built-in type opens its reference page", Text.ends_with(j_get(lsp_result(tdef), "uri").s, "type-int.md"))
let aitems = j_get(lsp_result(acomp), "items")
check_true("an argument list completes the parameters not given yet", j_any_field(aitems, "label", "height:") and not j_any_field(aitems, "label", "width:") and not j_any_field(aitems, "label", "let"))
check_true("Namespace. completes its members", j_any_field(j_get(lsp_result(nscomp), "items"), "label", "fill_circle"))
let ah = lsp_result(ahelp)
check_true("signature help tracks the argument after a named one", j_get(ah, "activeParameter").num == 2 and len(j_get(j_get(ah, "signatures").kids[0], "parameters").kids) == 4)
check_true("a declaration word used as a variable is a variable", Text.contains(j_get(j_get(lsp_result(khov), "contents"), "value").s, "var model: int"))
check("find-usages of a local named like a keyword", loc_lines_key(lsp_result(krefs)), "4,5,7")
check_true("an import of a directory reads its index.ludic barrel", Text.ends_with(j_get(lsp_result(kbarrel), "uri").s, "camp/tent.ludic"))
let hl = lsp_result(khl)
check_true("highlight usages stays inside the document", len(hl.kids) == 1 and j_get(j_get(j_get(hl.kids[0], "range"), "start"), "line").num == 5)
let kd = j_get(lsp_result(ktoks), "data")
var vendor_seen = false
var lib_seen = false
var ti = 0
while ti + 4 < len(kd.kids) {
if (kd.kids[ti + 4].num & 8) != 0 { vendor_seen = true }
if (kd.kids[ti + 4].num & 4) != 0 and kd.kids[ti + 3].num == 5 { lib_seen = true }
ti += 5
}
check_true("semantic tokens mark package symbols as vendor", vendor_seen)
check_true("semantic tokens mark library calls as defaultLibrary", lib_seen)
check_true("a name inside a template hole resolves", Text.contains(j_get(j_get(lsp_result(thov), "contents"), "value").s, "param variant: int"))
check("find-usages reaches into template holes", loc_lines_key(lsp_result(trefs)), "1,3,4")
check_true("positions after a multi-line template stay on the right line", Text.contains(j_get(j_get(lsp_result(tafter), "contents"), "value").s, "let multi"))
return report()
}

View file

@ -28,6 +28,7 @@ program Ludic {
import "packignore.ludic"
import "pack.ludic"
import "bundle.ludic"
import "scripts.ludic"
function usage() -> void {
print("ludic — the toolchain for the Ludic language")
@ -39,7 +40,8 @@ program Ludic {
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(" test [file...] [-v] [--test NAME]")
print(" compile and run the project's tests (-v: every test's line)")
print(" clean remove build/")
print("")
print("packages:")
@ -67,6 +69,11 @@ program Ludic {
print(" lsp run the language server on stdio (what editors spawn)")
print(" help this message")
print("")
print("package scripts (package.ludic):")
print(" script <name> [args] run a `script \"name\" \"command\"` line (or just: ludic <name>)")
print(" scripts list the package's scripts and hooks")
print(" `hook before|after <command> \"…\"` wraps build, run, test, bundle, …")
print("")
print("A file argument runs it: ludic mygame.ludic")
}
@ -91,6 +98,8 @@ program Ludic {
if (cmd == "lsp") { return cmd_lsp() }
if (cmd == "doctor") { return cmd_doctor() }
if (cmd == "upgrade") { return cmd_upgrade() }
if (cmd == "script") { return cmd_script() }
if (cmd == "scripts") { return cmd_scripts() }
if (cmd == "add") { return cmd_pkg_add() }
if (cmd == "get") { return cmd_pkg_get() }
@ -128,7 +137,16 @@ program Ludic {
tmp_cleanup()
exit(rc)
}
let code = dispatch(cmd)
# package.ludic hooks wrap the built-in commands: `before` can veto,
# `after` runs only when the command succeeded
if is_hookable(cmd) {
let pre = run_hooks("before", cmd)
if pre != 0 { tmp_cleanup(); exit(pre) }
}
var code = dispatch(cmd)
if code == 0 and is_hookable(cmd) { code = run_hooks("after", cmd) }
# an unknown command may be one of the package's scripts
if code == -1 { code = run_script(cmd, 2) }
tmp_cleanup()
if code >= 0 { exit(code) }
err(`ludic: unknown command '{cmd}'\n`)

View file

@ -33,8 +33,11 @@ property Manifest {
provides: []pointer, # the Foo.* namespace(s) this package registers
targets: []pointer, # prebuilt: the targets it ships (native-arm64, wasm32, …)
deps: []Dep,
entry: pointer = "", # `entry "src/game.ludic"` — the program run/build/bundle compile
packs: []pointer, # `pack "<dir>"` — asset roots that go into the .lpak
app: []pointer # `app <key> "<value>"` — flattened key, value, key, value…
app: []pointer, # `app <key> "<value>"` — flattened key, value, key, value…
scripts: []pointer, # `script "<name>" "<command>"` — flattened name, command, …
hooks: []pointer # `hook <before|after> <event> "<command>"` — flattened triples
}
function manifest_new() -> Manifest {
@ -43,11 +46,14 @@ function manifest_new() -> Manifest {
m.ver = ""
m.kind = "source"
m.hash = ""
m.entry = ""
m.provides = new []pointer
m.targets = new []pointer
m.deps = new []Dep
m.packs = new []pointer
m.app = new []pointer
m.scripts = new []pointer
m.hooks = new []pointer
return m
}
@ -114,11 +120,18 @@ function tok_line(line: pointer) -> []pointer {
let c = line[i]
if c == '#' { i = n } # '#' comment
else if c == ' ' or c == '\t' { i += 1 } # whitespace
else if c == '"' { # "quoted"
else if c == '"' { # "quoted", with \" and \\ escapes
i += 1
let start = i
while i < n and line[i] != '"' { i += 1 }
push(toks, sslice(line, start, i))
var word = ""
var start = i
while i < n and line[i] != '"' {
if line[i] == 92 and i + 1 < n {
word = word + sslice(line, start, i) + sslice(line, i + 1, i + 2)
i += 2
start = i
} else { i += 1 }
}
push(toks, word + sslice(line, start, i))
if i < n { i += 1 }
} else { # bare word
let start = i
@ -158,6 +171,7 @@ function parse_manifest(text: pointer) -> Manifest {
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 == "entry" { if len(ts) > 1 { m.entry = 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" {
@ -169,6 +183,12 @@ function parse_manifest(text: pointer) -> Manifest {
# `app name "Maroon Lake"` — the metadata a macOS bundle is built from.
# Held as a flat key/value list rather than a property per key, so a new
# Info.plist field is a line in the bundler and nothing here.
else if head == "script" {
if len(ts) > 2 { push(m.scripts, ts[1]); push(m.scripts, ts[2]) }
}
else if head == "hook" {
if len(ts) > 3 { push(m.hooks, ts[1]); push(m.hooks, ts[2]); push(m.hooks, ts[3]) }
}
else if head == "app" {
if len(ts) > 2 { push(m.app, ts[1]); push(m.app, ts[2]) }
}

View file

@ -47,14 +47,25 @@ function project_name(entry: pointer) -> pointer {
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.
# The program to compile. An explicit path always wins; then package.ludic's
# `entry "…"`; then the conventional entry points in order; then the one file
# under src/ that declares a `program` (the rest being modules it imports); 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("package.ludic") {
let m = read_root_manifest()
if m.entry != "" {
if file_exists(m.entry) { return m.entry }
err(`ludic: package.ludic names entry "{m.entry}", which does not exist\n`)
return ""
}
}
if file_exists("src/main.ludic") { return "src/main.ludic" }
if file_exists("main.ludic") { return "main.ludic" }
let progs = split_lines(capture("grep -rlE --include='*.ludic' '^program[[:space:]]' src 2>/dev/null | sort"))
if len(progs) == 1 { return progs[0] }
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 ""
@ -63,7 +74,8 @@ function find_entry(explicit: pointer) -> pointer {
# 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(" expected `entry \"src/game.ludic\"` in package.ludic, src/main.ludic (or main.ludic),\n")
err(" a single program under src/, or name a file: ludic build path/to/game.ludic\n")
err(" start a new project with: ludic new <name>\n")
return 1
}
@ -337,13 +349,35 @@ function test_files() -> []pointer {
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.
# ludic test [file...] [--verbose] [--test NAME] — 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.
#
# --verbose streams every runner line, framed by `RUN <file>` ... `PASS|FAIL
# <file>`: the shape an IDE test view parses. --test NAME runs only the test
# block with exactly that name (the runner's argv[1]).
function cmd_test() -> int {
var files = new []pointer
var verbose = false
var only = ""
var ai = 2
while ai < arg_count() { if arg(ai)[0] != '-' { push(files, arg(ai)) }; ai += 1 }
while ai < arg_count() {
let a = arg(ai)
if (a == "--verbose") or (a == "-v") { verbose = true }
else if a == "--test" {
if ai + 1 >= arg_count() { err("ludic test: --test needs a test name\n"); return 1 }
ai += 1
only = arg(ai)
}
else if a[0] == '-' {
err(`ludic test: unknown option {a}\n`)
err(" usage: ludic test [file...] [--verbose] [--test NAME]\n")
return 1
}
else { push(files, a) }
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")
@ -355,19 +389,27 @@ function cmd_test() -> int {
mi += 1
}
shell("mkdir -p build")
var filter = ""
if only != "" { filter = ` {sh_single(only)}` }
var failed = 0
var i = 0
while i < len(files) {
let f = files[i]
let bin = `{tmp_dir()}/{flat(strip_ext(f))}`
if verbose { say(` RUN {f}`) }
if not compile_app(f, bin, 2, false) {
print(` {c_red()}FAIL{c_reset()} {f} (did not compile)`)
let line = ` {c_red()}FAIL{c_reset()} {f} (did not compile)`
if verbose { say(line) } else { print(line) }
failed += 1
} else if verbose {
let rc = sh(`{bin}{filter} < /dev/null 2>&1`)
if rc == 0 { say(` {c_green()}PASS{c_reset()} {f}`) }
else { say(` {c_red()}FAIL{c_reset()} {f} (exit {string(rc)})`); 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`)
let rc = sh(`{bin}{filter} < /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)})`)
@ -387,6 +429,22 @@ function cmd_test() -> int {
return 1
}
# print a line through the shell. The runner's own stdout is buffered, so a
# print() made between two child processes would surface after both of them;
# under --verbose the framing has to interleave with the runner output exactly.
function say(line: pointer) -> void { shell(`printf '%s\\n' {sh_single(line)}`) }
# one shell word, whatever it contains: 'it'\''s' for it's
function sh_single(s: pointer) -> pointer {
var o = "'"
var i = 0
while i < len(s) {
if s[i] == 39 { o = o + "'\\''" } else { o = o + s[i..i + 1] }
i += 1
}
return o + "'"
}
# drop the extension from a path ("tests/combat.ludic" -> "tests/combat")
function strip_ext(p: pointer) -> pointer {
let n = len(p)

View file

@ -330,6 +330,9 @@ function build_artifacts(ver: pointer) -> bool {
}
shell(`cp -R runtime {stage}/runtime`)
shell(`cp -R packages {stage}/packages`)
# the language reference: ludic-lsp indexes it for hover and go-to-definition
# on built-ins (docs/language/**.md, ~4 MB of markdown)
shell(`mkdir -p {stage}/docs && cp -R docs/language {stage}/docs/language`)
shell(`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).

View file

@ -0,0 +1,127 @@
# scripts.ludic — package.ludic scripts and lifecycle hooks.
#
# script "dev" "ludic run --headless" # ludic dev / ludic script dev
# script "shots" "tools/shots.sh" # extra arguments are passed on
# hook before build "tools/gen_atlas.sh" # runs first; failing stops the build
# hook after bundle "tools/notarize.sh" # runs only when the bundle succeeded
# hook before dev "ludic fmt --check" # a script is an event too
#
# Commands run through the shell in the package root, with the toolchain's bin/
# first on PATH and LUDIC_PACKAGE_ROOT, LUDIC_EVENT and LUDIC_PHASE set. Hooks of
# one event run in the order written.
# the built-in commands a hook can wrap (every user command that does work)
function is_hookable(cmd: pointer) -> bool {
return (cmd == "build") or (cmd == "run") or (cmd == "test") or (cmd == "bundle") or (cmd == "pack") or (cmd == "clean") or (cmd == "fmt") or (cmd == "get") or (cmd == "add") or (cmd == "update") or (cmd == "verify") or (cmd == "vendor") or (cmd == "assets") or (cmd == "build-lib")
}
# the directory this `ludic` binary lives in, with no trailing slash
function ludic_bin_dir() -> pointer {
let me = arg(0)
var i = slen(me)
while i > 0 and me[i - 1] != '/' { i -= 1 }
if i > 0 { return capture_line(`cd {sh_single(sslice(me, 0, i))} && pwd`) }
return capture_line(`dirname "$(command -v {sh_single(me)})"`)
}
# the shell prelude every script and hook runs under
function script_env(event: pointer, phase: pointer) -> pointer {
let bin = ludic_bin_dir()
var env = `export LUDIC_PACKAGE_ROOT="$(pwd)"; export LUDIC_EVENT={sh_single(event)}; export LUDIC_PHASE={sh_single(phase)}; `
if bin != "" { env = env + `export PATH={sh_single(bin)}:"$PATH"; ` }
return env
}
# run every `hook <phase> <event>` in order; the first failure's exit code, or 0
function run_hooks(phase: pointer, event: pointer) -> int {
if not file_exists("package.ludic") { return 0 }
let m = read_root_manifest()
var i = 0
while i + 2 < len(m.hooks) {
if m.hooks[i] == phase and m.hooks[i + 1] == event {
let cmd = m.hooks[i + 2]
say(`> {phase} {event}: {cmd}`)
let rc = sh(`{script_env(event, phase)}{cmd}`)
if rc != 0 {
err(`ludic: the {phase} {event} hook failed (exit {string(rc)}): {cmd}\n`)
return rc
}
}
i += 3
}
return 0
}
# the command a script names, or "" when package.ludic has no such script
function script_command(name: pointer) -> pointer {
if not file_exists("package.ludic") { return "" }
let m = read_root_manifest()
var i = 0
while i + 1 < len(m.scripts) {
if m.scripts[i] == name { return m.scripts[i + 1] }
i += 2
}
return ""
}
# run script `name` with the arguments from argv[first] on, wrapped in its hooks.
# -1 when there is no such script.
function run_script(name: pointer, first: int) -> int {
let cmd = script_command(name)
if cmd == "" { return -1 }
var extra = ""
var ai = first
while ai < arg_count() { extra = extra + " " + sh_single(arg(ai)); ai += 1 }
let pre = run_hooks("before", name)
if pre != 0 { return pre }
say(`> {name}: {cmd}{extra}`)
let rc = sh(`{script_env(name, "run")}{cmd}{extra}`)
if rc != 0 { return rc }
return run_hooks("after", name)
}
# ludic script [name [args…]] — run a script, or list them
function cmd_script() -> int {
if arg_count() < 3 { return cmd_scripts() }
let rc = run_script(arg(2), 3)
if rc == -1 {
err(`ludic script: package.ludic has no script '{arg(2)}'\n`)
cmd_scripts()
return 1
}
return rc
}
# ludic scripts — what package.ludic defines
function cmd_scripts() -> int {
if not file_exists("package.ludic") {
err("ludic scripts: no package.ludic here\n")
return 1
}
let m = read_root_manifest()
if len(m.scripts) == 0 and len(m.hooks) == 0 {
print("package.ludic defines no scripts or hooks")
print(" add one: script \"dev\" \"ludic run --headless\"")
print(" hook before build \"tools/prepare.sh\"")
return 0
}
if len(m.scripts) > 0 { print("scripts:") }
var i = 0
while i + 1 < len(m.scripts) {
print(` {pad_right(m.scripts[i], 14)} {m.scripts[i + 1]}`)
i += 2
}
if len(m.hooks) > 0 { print("hooks:") }
i = 0
while i + 2 < len(m.hooks) {
print(` {pad_right(m.hooks[i] + " " + m.hooks[i + 1], 20)} {m.hooks[i + 2]}`)
i += 3
}
return 0
}
function pad_right(s: pointer, w: int) -> pointer {
var o = s
while slen(o) < w { o = o + " " }
return o
}

View file

@ -400,6 +400,35 @@ function scaffold_names_case() -> void {
ok(lbl)
}
# package.ludic drives the CLI: `entry` (or the one program under src/) is what
# build compiles, scripts run by name, and hooks wrap commands — a failing
# `before` stops the command, `after` runs only on success.
function package_scripts_case() -> void {
let lbl = "package.ludic entry, scripts and hooks"
let work = `{tmp_dir()}/pkgscripts`
shell(`rm -rf {work} && mkdir -p {work}`)
let root = capture_line("pwd")
let L = `{root}/bin/ludic`
if not shq(`cd {work} && {L} new g > /dev/null 2>&1 && mv g/src/main.ludic g/src/game.ludic`) { bad2(lbl, "setup failed"); return }
# no src/main.ludic: the single program under src/ is found
if not shq(`cd {work}/g && {L} build --headless > {work}/b1.out 2>&1`) { bad2(lbl, "the lone src/ program was not found"); return }
# a second program makes it ambiguous; `entry` settles it
write_file(`{work}/g/src/other.ludic`, "program Other {\n entry { }\n}\n")
if shq(`cd {work}/g && {L} build --headless > {work}/b2.out 2>&1`) { bad2(lbl, "two programs and no entry should not build"); return }
var man = read_file(`{work}/g/package.ludic`)
man = man + "entry \"src/game.ludic\"\n"
man = man + "script \"greet\" \"echo hi-$LUDIC_EVENT\"\n"
man = man + "hook before build \"echo pre > hook.log\"\n"
man = man + "hook after build \"echo post >> hook.log\"\n"
man = man + "hook before test \"exit 7\"\n"
write_file(`{work}/g/package.ludic`, man)
if not shq(`cd {work}/g && {L} build --headless > {work}/b3.out 2>&1`) { bad2(lbl, "entry was not honoured"); return }
if not (capture_line(`tr '\n' ' ' < {work}/g/hook.log`) == "pre post") { bad2(lbl, "build hooks did not run in order"); return }
if not (capture_line(`cd {work}/g && {L} greet | tail -1`) == "hi-greet") { bad2(lbl, "a script did not run by name"); return }
if not (capture_line(`cd {work}/g && {L} test > /dev/null 2>&1; echo $?`) == "7") { bad2(lbl, "a failing before hook did not stop the command"); return }
ok(lbl)
}
# a "does it still compile" smoke test (parse -> lower -> link), no run
function qsmoke(path: pointer) -> void {
let nm = flat(path)
@ -762,6 +791,7 @@ function cmd_dev_test() -> int {
# from its own location. This is the shape `curl … | sh` produces.
install_layout_case()
scaffold_names_case()
package_scripts_case()
pack_roundtrip_case()
packignore_case()
os_dirs_case()

View file

@ -166,7 +166,7 @@ function cmd_test_tools() -> int {
# 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 buildPlugin verifyPluginStructure --console=plain -q >/dev/null 2>&1 && test -f build/distributions/Ludic-1.4.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 {