fix(cli): scaffold a project that compiles, and validate flags

`ludic new my-game` wrote `program My-Game`, so the first thing anyone did with
a new project — run it — failed with `expected '{', got '-'`. The project name
is a directory name and the identifier is Ludic source, and they do not accept
the same characters: names are now folded into a valid identifier (my-game ->
MyGame, 2048 -> Game2048, a.b.c -> ABC) and a name that cannot be a directory or
a package is refused with the rule instead of being mangled into one.

An audit of every command's flags turned up more of the same shape, all fixed:

- build/run ignored unknown options, so `--headles` silently produced a windowed
  binary, and `-o` with no path was silently dropped.
- `ludic fmt` printed the formatted text to stdout while its help said "in
  place", so it appeared to do nothing. It writes now, with --check for the
  report-only case a hook wants.
- `ludic test nosuch.ludic` deferred the error to the compiler.
- build-lib's failure messages ran `{tmp_dir()}` through the shell literally —
  an interpolation written inside a non-interpolating string — and its argument
  guess matched package.lock.ludic, then tried to compile the lockfile.
- Several messages still identified the tool as `x`.

`ludic-dev test` now scaffolds under four awkward names, builds and tests each,
and asserts a space-bearing name is refused — the case that shipped broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-05 23:27:16 +03:00
parent b24f0dd572
commit b839304f91
6 changed files with 208 additions and 31 deletions

View file

@ -55,6 +55,66 @@ function no_entry() -> int {
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,
@ -136,6 +196,11 @@ function cmd_new() -> int {
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
@ -145,8 +210,8 @@ function cmd_new() -> int {
err(`ludic new: cannot write {name}/package.ludic\n`)
return 1
}
write_file(`{name}/src/main.ludic`, template_main(title_case(name)))
write_file(`{name}/tests/smoke.ludic`, template_test(title_case(name)))
write_file(`{name}/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}/`)
@ -167,21 +232,38 @@ 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 == "-o" { ai += 1; if ai < arg_count() { g_out = arg(ai) } }
else if a[0] != '-' { src = a }
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)
}
@ -197,6 +279,7 @@ function output_path(entry: pointer) -> pointer {
# 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 }
@ -212,6 +295,7 @@ function cmd_build() -> int {
# 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 }
@ -221,6 +305,7 @@ function cmd_run() -> int {
# `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 }
@ -251,6 +336,11 @@ function cmd_test() -> int {
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
}
run("mkdir -p build")
var failed = 0
var i = 0
@ -293,12 +383,30 @@ function strip_ext(p: pointer) -> pointer {
# ---- ludic fmt / lsp --------------------------------------------------------
# ludic fmt [paths...] — the formatter over the project (src/ and tests/ by
# default), or over the paths named.
# 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() { args = `{args} {arg(ai)}`; ai += 1 }
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 == "" {
@ -307,7 +415,7 @@ function cmd_fmt() -> int {
}
args = ` {found}`
}
return sh(`{tool("ludic-fmt")}{args}`)
return sh(`{tool("ludic-fmt")} {mode}{args}`)
}
# ludic lsp — the language server on stdio. Editors are configured to run this,
@ -410,7 +518,9 @@ function cmd_upgrade() -> int {
err("ludic upgrade: needs curl\n")
return 1
}
print(`upgrading from {install_url()}`)
# 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}`)
}