# 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 \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 ` 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 — scaffold a project that builds and runs as it stands. function cmd_new() -> int { if arg_count() < 3 { err("usage: ludic new \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 == "-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]\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/ (build/_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 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 } 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 ` ... `PASS|FAIL # `: 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}`) }