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:
parent
da57b20156
commit
f92d7f89c6
75 changed files with 46802 additions and 40343 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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]) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
127
tools/ludic-cli/scripts.ludic
Normal file
127
tools/ludic-cli/scripts.ludic
Normal 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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue