--emit-schema FILE writes the compiler's resolved view once the program type-checks: every
record (fields, types, defaults as written, docs, places, attributes), every registry with its
entries in their final order after the open-registry merge (key, constant, index, file:line:col
of the entry and of each field value, and which file contributed which keys), every const, and
the zero-argument functions a fn value can name. Deterministic, schema_version 1; the runtime is
left out. `ludic schema [file] [-o FILE]` wraps it.
--check --diagnostics=json prints every error as one JSON array on stdout: the checker's and the
module rules' all, a parse or lowering error as the last. Tokens and nodes now carry a column.
Fields take several @attributes; @Ref(Registry), @OneOf(PREFIX_), @Range(lo, hi), @Unit("..."),
@Asset("..."), @Color on a field and @AppendOnly / @ByKey on a registry change nothing but go into
the schema, and @Ref naming no registry is an error (every one reported). Fixtures:
examples/lang/attributes.ludic, examples/rejected/ref_unknown.ludic, cases in ludic-dev test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
682 lines
25 KiB
Text
682 lines
25 KiB
Text
# 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.
|
|
# The built binary's name, from the manifest's module path when there is one.
|
|
#
|
|
# A module path is a URL — `git.host/user/maroon-lake` — so the last path segment comes
|
|
# first: taking the last DOT of that would have cut inside the host and produced
|
|
# `io/user/maroon-lake`, which git-hosted names all share and which `build/{name}` then
|
|
# turned into directories. Within the segment a dot still separates a namespace from the
|
|
# package (`ludic.render3d` builds as `render3d`).
|
|
function project_name(entry: pointer) -> pointer {
|
|
var mod = manifest_name()
|
|
if mod != "" {
|
|
var n = 0
|
|
while mod[n] != 0 { n += 1 }
|
|
var slash = -1
|
|
var i = 0
|
|
while i < n { if mod[i] == '/' { slash = i }; i += 1 }
|
|
if slash >= 0 { mod = mod[slash + 1..n]; n = n - slash - 1 }
|
|
var last = -1
|
|
i = 0
|
|
while i < n { if mod[i] == '.' { last = i }; i += 1 }
|
|
if last >= 0 { return mod[last + 1..n] }
|
|
if n > 0 { return mod }
|
|
}
|
|
return capture_line(`basename {entry} .ludic`)
|
|
}
|
|
|
|
# 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 ""
|
|
}
|
|
|
|
# report the "no entry point" case the same way everywhere
|
|
function no_entry() -> int {
|
|
err("ludic: no program to build here.\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
|
|
}
|
|
|
|
# ---- ludic new ---------------------------------------------------------------
|
|
|
|
# A project name is a directory name and a package identifier, so it is held to
|
|
# what both accept: letters, digits, '-', '_' and '.'. Anything else — a space, a
|
|
# slash, a quote — either breaks the shell commands that create the tree or
|
|
# produces a manifest nobody can depend on, and failing here with the rule beats
|
|
# failing later with something obscure.
|
|
function valid_project_name(name: pointer) -> bool {
|
|
let n = slen(name)
|
|
if n == 0 { return false }
|
|
var i = 0
|
|
while i < n {
|
|
let c = name[i]
|
|
let alnum = (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9')
|
|
let punct = c == '-' or c == '_' or c == '.'
|
|
if not (alnum or punct) { return false }
|
|
i += 1
|
|
}
|
|
return true
|
|
}
|
|
|
|
# The `program <Name>` identifier for a project called `name`.
|
|
#
|
|
# The name is a directory name and the identifier is Ludic source, and they do
|
|
# not accept the same characters: `ludic new my-game` wrote `program My-Game`,
|
|
# which is a subtraction, and `ludic new 2048` wrote an identifier starting with
|
|
# a digit. Both scaffolded a project that would not compile — the first thing the
|
|
# user did with it. So: split on anything that is not a letter or digit,
|
|
# capitalise each piece, and join. A leading digit gets a `Game` prefix, since an
|
|
# identifier cannot start with one.
|
|
function ident_of(name: pointer) -> pointer {
|
|
let n = slen(name)
|
|
let b = bytes(n + 8)
|
|
var out = 0
|
|
var at_start = true
|
|
var i = 0
|
|
while i < n {
|
|
var c = name[i]
|
|
let alpha = (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z')
|
|
let digit = c >= '0' and c <= '9'
|
|
if alpha or digit {
|
|
if at_start {
|
|
if c >= 'a' and c <= 'z' { c -= 32 }
|
|
at_start = false
|
|
} else {
|
|
if c >= 'A' and c <= 'Z' { c += 32 }
|
|
}
|
|
b[out] = c
|
|
out += 1
|
|
} else {
|
|
at_start = true # the next letter starts a new word
|
|
}
|
|
i += 1
|
|
}
|
|
b[out] = 0
|
|
if out == 0 { return "Game" }
|
|
if b[0] >= '0' and b[0] <= '9' { return "Game" + sslice(b, 0, out) }
|
|
return sslice(b, 0, out)
|
|
}
|
|
|
|
# ---- 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_count() < 3 {
|
|
err("usage: ludic new <name>\n")
|
|
return 1
|
|
}
|
|
let name = arg(2)
|
|
if not valid_project_name(name) {
|
|
err(`ludic new: '{name}' is not a usable project name\n`)
|
|
err(" use letters, digits, '-', '_' or '.' (it names a directory and a package)\n")
|
|
return 1
|
|
}
|
|
if file_exists(name) {
|
|
err(`ludic new: {name} already exists\n`)
|
|
return 1
|
|
}
|
|
shell(`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(ident_of(name)))
|
|
write_file(`{name}/tests/smoke.ludic`, template_test(ident_of(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
|
|
|
|
# g_argerr is set when the command line itself was wrong — an unknown flag, or
|
|
# -o with nothing after it. Silently ignoring those meant `ludic build --headles`
|
|
# quietly produced a windowed binary and `ludic build -o` quietly ignored the
|
|
# request, which is the kind of thing you only notice much later.
|
|
var g_argerr: bool = false
|
|
|
|
function parse_build_args(start: int) -> pointer {
|
|
var src = ""
|
|
g_mode = 1
|
|
g_out = ""
|
|
g_save = false
|
|
g_argerr = false
|
|
var ai = start
|
|
while ai < arg_count() {
|
|
let a = arg(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 == "--unsafe" { g_unsafe_build = true }
|
|
else if a == "--check" { g_check_build = true }
|
|
else if a == "--diagnostics=json" { g_check_build = true; g_diag_json_build = true } # every error, as JSON on stdout
|
|
else if a == "-o" {
|
|
ai += 1
|
|
if ai < arg_count() { g_out = arg(ai) }
|
|
else { err("ludic: -o needs a path\n"); g_argerr = true }
|
|
}
|
|
else if a[0] == '-' {
|
|
err(`ludic: unknown option {a}\n`)
|
|
err(" build/run take: [file] [--headless|--windowed] [-o out] [--save-temps] [--check [--diagnostics=json]]\n")
|
|
g_argerr = true
|
|
}
|
|
else { src = a }
|
|
ai += 1
|
|
}
|
|
if g_argerr { return "" }
|
|
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]
|
|
# --check: parse, types, modules, uses, ports - everything the compiler checks before it emits -
|
|
# and nothing emitted or linked; a fraction of a build's time, for iterating on uses lines
|
|
var g_check_build: bool = false
|
|
var g_diag_json_build: bool = false # --diagnostics=json: the compiler's JSON array is the whole stdout
|
|
function check_app(src: pointer) -> int {
|
|
ensure_ludicc()
|
|
if g_diag_json_build {
|
|
if not shq(`{ludicc()} --check --diagnostics=json{title_flag()}{unsafe_flag()} {src}`) { return 1 }
|
|
return 0
|
|
}
|
|
if not shq(`{ludicc()} --check{title_flag()}{unsafe_flag()} {src}`) { return 1 }
|
|
print(`checked {src}`)
|
|
return 0
|
|
}
|
|
function cmd_build() -> int {
|
|
let entry = parse_build_args(2)
|
|
if g_argerr { return 1 }
|
|
if entry == "" { return no_entry() }
|
|
if g_check_build { return check_app(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 g_argerr { return 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(`./{exe_name(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 g_argerr { return 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(`./{exe_name(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
|
|
}
|
|
|
|
# the test programs under a directory - a package's: every *_test.ludic, and every file straight
|
|
# inside a tests/ directory, anywhere under it
|
|
function test_files_in(dir: pointer) -> []pointer {
|
|
let q = sh_single(dir)
|
|
return split_lines(capture(`{{ find {q} -name '*_test.ludic' -type f; find {q} -path '*/tests/*.ludic' -type f | grep -v '/tests/.*/'; }} 2>/dev/null | sort -u`))
|
|
}
|
|
|
|
# ludic test [file|dir...] [--verbose] [--test NAME] — compile each test program
|
|
# headlessly and run every test block in a process of its own, so no test sees
|
|
# what another left in a global. A directory stands for the test programs under
|
|
# it. One line per file; a failure prints what its tests printed.
|
|
#
|
|
# --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() {
|
|
let a = arg(ai)
|
|
if (a == "--verbose") or (a == "-v") { verbose = true }
|
|
else if a == "-j" or a == "--jobs" {
|
|
if ai + 1 >= arg_count() { err("ludic test: -j needs a number\n"); return 1 }
|
|
ai += 1
|
|
g_test_jobs = s_to_int(arg(ai))
|
|
if g_test_jobs < 1 { g_test_jobs = 1 }
|
|
}
|
|
else if s_starts(a, "-j") and slen(a) > 2 {
|
|
g_test_jobs = s_to_int(sslice(a, 2, slen(a)))
|
|
if g_test_jobs < 1 { g_test_jobs = 1 }
|
|
}
|
|
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|dir...] [-j N] [--verbose] [--test NAME]\n")
|
|
return 1
|
|
}
|
|
else if shq(`test -d {sh_single(a)}`) {
|
|
let found = test_files_in(a)
|
|
if len(found) == 0 { err(`ludic test: no tests under {a} (expected *_test.ludic or tests/*.ludic)\n`); return 1 }
|
|
var k = 0
|
|
while k < len(found) { push(files, found[k]); k += 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")
|
|
return 1
|
|
}
|
|
var mi = 0
|
|
while mi < len(files) {
|
|
if not file_exists(files[mi]) { err(`ludic test: no such file: {files[mi]}\n`); return 1 }
|
|
mi += 1
|
|
}
|
|
shell("mkdir -p build")
|
|
if not host_windows() { return test_parallel(files, only, verbose) }
|
|
var failed = 0
|
|
var ntests = 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) {
|
|
let line = ` {c_red()}FAIL{c_reset()} {f} (did not compile)`
|
|
if verbose { say(line) } else { print(line) }
|
|
failed += 1
|
|
} else {
|
|
let r = test_run_file(bin, only, verbose)
|
|
ntests += g_tr_ran
|
|
var what = `({string(g_tr_ran)} tests)`
|
|
if g_tr_ran == 1 { what = "(1 test)" }
|
|
if r == 0 {
|
|
let line = ` {c_green()}PASS{c_reset()} {f} {what}`
|
|
if verbose { say(line) } else { print(line) }
|
|
} else {
|
|
var line = ` {c_red()}FAIL{c_reset()} {f} ({string(g_tr_bad)} of {string(g_tr_ran)} tests failed)`
|
|
if g_tr_ran == 0 { line = ` {c_red()}FAIL{c_reset()} {f} (no test named "{only}")` }
|
|
if verbose { say(line) } else {
|
|
print(line)
|
|
out(g_tr_log)
|
|
}
|
|
failed += 1
|
|
}
|
|
}
|
|
i += 1
|
|
}
|
|
print("")
|
|
if failed == 0 {
|
|
print(`== {string(len(files))} test files passed ({string(ntests)} tests) ==`)
|
|
return 0
|
|
}
|
|
print(`== {string(failed)} of {string(len(files))} test files failed ==`)
|
|
return 1
|
|
}
|
|
|
|
var g_tr_ran: int = 0
|
|
var g_tr_bad: int = 0
|
|
var g_tr_log: pointer = ""
|
|
# every test block of one compiled test program, each in a child process of its own: the runner
|
|
# names them with --list and runs just one when given its name. Non-zero when any failed (or
|
|
# none matched --test).
|
|
function test_run_file(bin: pointer, only: pointer, verbose: bool) -> int {
|
|
g_tr_ran = 0
|
|
g_tr_bad = 0
|
|
g_tr_log = ""
|
|
let names = split_lines(capture(`{bin} --list < /dev/null`))
|
|
var n = 0
|
|
while n < len(names) {
|
|
let nm = names[n]
|
|
n += 1
|
|
if nm == "" { continue }
|
|
if only != "" and not (nm == only) { continue }
|
|
g_tr_ran += 1
|
|
let log = `{bin}.out`
|
|
let rc = sh(`{bin} {sh_single(nm)} < /dev/null > {log} 2>&1`)
|
|
# the child's own summary line counts one test; the file's line says it better
|
|
let txt = capture(`grep -v '^== ' {log}`)
|
|
if verbose { shell(`grep -v '^== ' {log}`) }
|
|
if rc != 0 {
|
|
g_tr_bad += 1
|
|
g_tr_log = g_tr_log + txt
|
|
var ends = false
|
|
if len(txt) > 0 { ends = txt[len(txt) - 1] == 10 }
|
|
if not ends { g_tr_log = g_tr_log + "\n" }
|
|
if not s_contains(txt, "FAIL - ") {
|
|
g_tr_log = g_tr_log + `FAIL - {nm} (exit {string(rc)})\n`
|
|
}
|
|
}
|
|
}
|
|
if only != "" and g_tr_ran == 0 {
|
|
g_tr_log = `no test named "{only}"\n`
|
|
if verbose { say(`no test named "{only}"`) }
|
|
return 1
|
|
}
|
|
if g_tr_bad > 0 { return 1 }
|
|
return 0
|
|
}
|
|
|
|
# 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)
|
|
if n > 6 and p[n - 6..n] == ".ludic" { return p[0..n - 6] }
|
|
return p
|
|
}
|
|
|
|
# ---- ludic fmt / lsp --------------------------------------------------------
|
|
|
|
# ludic fmt [--check] [paths...] — format the project's source in place (src/ and
|
|
# tests/ by default), or the paths named. --check writes nothing and exits
|
|
# non-zero if anything is unformatted, which is what a pre-commit hook or CI
|
|
# wants.
|
|
#
|
|
# In place is the default because that is what `fmt` means everywhere else and
|
|
# what this command's own help promised; the underlying ludic-fmt defaults to
|
|
# printing to stdout, which as a project-level command would just scroll the file
|
|
# past you and change nothing.
|
|
function cmd_fmt() -> int {
|
|
var mode = "-w"
|
|
var args = ""
|
|
var ai = 2
|
|
while ai < arg_count() {
|
|
let a = arg(ai)
|
|
if a == "--check" { mode = "--check" }
|
|
else if a[0] == '-' {
|
|
err(`ludic fmt: unknown option {a}\n`)
|
|
err(" usage: ludic fmt [--check] [paths...]\n")
|
|
return 1
|
|
}
|
|
else { args = `{args} {a}` }
|
|
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")} {mode}{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_count() { args = `{args} {arg(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 version — report the version of the toolchain this CLI belongs to.
|
|
#
|
|
# It asks the compiler (proving that path works), addressed through ludic_home()
|
|
# rather than as a relative bin/ludicc: `ludic version` is run from a user's
|
|
# project, not from the toolchain's own directory, and looking beside the current
|
|
# directory found nothing there and reported "(version unknown)" for a perfectly
|
|
# good install.
|
|
function cmd_version() -> int {
|
|
let cc = ludicc()
|
|
if is_exec(cc) {
|
|
shell(`{cc} --version`)
|
|
return 0
|
|
}
|
|
let v = read_file(`{ludic_home()}VERSION`)
|
|
if v != null { print(`ludic {s_trim(v)}`); return 0 }
|
|
print("ludic (version unknown)")
|
|
return 0
|
|
}
|
|
|
|
# ---- 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_count() >= 3 { ver = arg(2) }
|
|
if not shq("command -v curl >/dev/null 2>&1") {
|
|
err("ludic upgrade: needs curl\n")
|
|
return 1
|
|
}
|
|
# stderr, not stdout: this line is progress, and stdout is block-buffered when
|
|
# piped, which would print it after the installer it introduces
|
|
err(`upgrading from {install_url()}\n`)
|
|
if ver == "" { return sh(`curl -fsSL {install_url()} | sh`) }
|
|
return sh(`curl -fsSL {install_url()} | sh -s -- --version {ver}`)
|
|
}
|