ludic fmt for editors: --lint --json, and a buffer on stdin (R9)

ludic-fmt --lint --json prints the violations --lint reports as one JSON array on stdout,
[{file, line, col, rule, message}] ordered by file, line and column (col where the rule knows it),
the summary on stderr, --lint's exit status, and never rewrites the baseline. ludic-fmt - refuses a
buffer that does not read as Ludic (a string/template/key literal left open, a bracket never closed
or closed by the wrong one) with exit 2 and name:line:col on stderr; - --lint judges a buffer as the
file --stdin-name names (--stdin-rel: that path relative to the project), against its baseline and
lint paths. ludic fmt --lint and ludic fmt - run it from the nearest package.ludic upwards (from
--stdin-name's directory when given). Hooks read nothing and write to stderr under --json or -.
Regression cases added to test-tools and ludic-dev test (fmt_editor_cases), not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-30 00:24:43 +03:00
parent c804c4b0ee
commit 047bdf4189
13 changed files with 405 additions and 17 deletions

View file

@ -79,6 +79,9 @@ program Ludic {
print(" upgrade [version] reinstall from the docs site (the same script that installed it)")
print(" doctor check that the install is complete and usable")
print(" fmt [--check] [paths...] format Ludic source in place (--check: report, write nothing)")
print(" fmt --lint [--json] check the project's `lint` rules (--json: one JSON array on stdout)")
print(" fmt - [--stdin-name PATH] format stdin to stdout under the project's rules, as if it were PATH")
print(" fmt - --lint [--json] [--stdin-name PATH] lint stdin as PATH")
print(" lsp run the language server on stdio (what editors spawn)")
print(" ui-preview [--font DIR] preview a game's ludic.ui components for a studio, on stdio")
print(" help this message")

View file

@ -571,7 +571,8 @@ function strip_ext(p: pointer) -> pointer {
# ---- 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
# tests/ by default), or the paths named. `--lint [--json]` checks the project's style rules and
# `-` formats stdin to stdout, both from the project's root (fmt_in_project). --check writes nothing and exits
# non-zero if anything is unformatted, which is what a pre-commit hook or CI
# wants.
#
@ -582,18 +583,35 @@ function strip_ext(p: pointer) -> pointer {
function cmd_fmt() -> int {
var mode = "-w"
var args = ""
var lint = false
var json = false
var stdin = false
var name = ""
var ai = 2
while ai < arg_count() {
let a = arg(ai)
if a == "--check" { mode = "--check" }
else if a == "--lint" { lint = true }
else if a == "--json" { json = true }
else if a == "-" { stdin = true }
else if a == "--stdin-name" and ai + 1 < arg_count() {
name = arg(ai + 1)
ai += 1
}
else if a[0] == '-' {
err(`ludic fmt: unknown option {a}\n`)
err(" usage: ludic fmt [--check] [paths...]\n")
err(" usage: ludic fmt [--check] [paths...] | ludic fmt --lint [--json] | ludic fmt - [--stdin-name PATH] [--lint [--json]]\n")
return 1
}
else { args = `{args} {a}` }
ai += 1
}
if json and not lint { err("ludic fmt: --json goes with --lint\n"); return 1 }
if name != "" and not stdin { err("ludic fmt: --stdin-name goes with - (the buffer on stdin)\n"); return 1 }
if lint or stdin {
if args != "" { err("ludic fmt: --lint walks the project's `lint paths`, and - reads stdin; neither takes paths\n"); return 1 }
return fmt_in_project(lint, json, stdin, name)
}
if args == "" {
let found = capture_line("find src tests -name '*.ludic' 2>/dev/null | sort")
if found == "" {
@ -605,6 +623,21 @@ function cmd_fmt() -> int {
return sh(`{tool("ludic-fmt")} {mode}{args}`)
}
# `ludic fmt --lint` and `ludic fmt -`: ludic-fmt run from the project's root - the nearest directory
# upwards holding a package.ludic, from the --stdin-name's directory or else this one - so its `lint`
# lines, paths and baseline apply, with the buffer standing for its path relative to that root.
function fmt_in_project(lint: bool, json: bool, stdin: bool, name: pointer) -> int {
var flags = ""
if stdin { flags = ` - --stdin-name {sh_single(name)} --stdin-rel "$rel"` }
if lint { flags = flags + " --lint" }
if json { flags = flags + " --json" }
var s = `F={sh_single(tool("ludic-fmt"))}; case "$F" in /*) ;; */*) F="$PWD/$F" ;; esac; n={sh_single(name)}; `
s = s + `if [ -n "$n" ] && a=$(cd -- "$(dirname -- "$n")" 2>/dev/null && pwd -P); then b=$(basename -- "$n"); else a=$(pwd -P); b=; fi; `
s = s + `r=$a; while [ "$r" != / ] && [ ! -f "$r/package.ludic" ]; do r=$(dirname -- "$r"); done; rel=; `
s = s + `if [ -f "$r/package.ludic" ]; then if [ -n "$b" ]; then rel=${{a#"$r"}}; rel=${{rel#/}}; rel=${{rel:+$rel/}}$b; fi; cd -- "$r" || exit 2; fi; `
return sh(s + `exec "$F"{flags}`)
}
# 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 {

View file

@ -32,6 +32,17 @@ function script_env(event: pointer, phase: pointer) -> pointer {
return env
}
# a command whose stdout is for a program (`--json`) or whose stdin is a buffer (`ludic fmt -`): its
# hooks read nothing and say everything on stderr, so neither is spoilt
function machine_output() -> bool {
var ai = 2
while ai < arg_count() {
if arg(ai) == "--json" or arg(ai) == "-" { return true }
ai += 1
}
return false
}
# 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 }
@ -40,8 +51,14 @@ function run_hooks(phase: pointer, event: pointer) -> int {
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}`)
var rc = 0
if machine_output() {
err(`> {phase} {event}: {cmd}\n`)
rc = sh(`{script_env(event, phase)}( {cmd} ) < /dev/null 1>&2`)
} else {
say(`> {phase} {event}: {cmd}`)
rc = sh(`{script_env(event, phase)}{cmd}`)
}
if rc != 0 {
err(`ludic: the {phase} {event} hook failed (exit {string(rc)}): {cmd}\n`)
return rc

View file

@ -123,6 +123,28 @@ function stdin_file_cases() -> void {
ok(lbl)
}
# `ludic fmt` for editors (R9): run from a directory under the project, `--lint --json` and `-` find
# the nearest package.ludic upwards and judge a buffer as the file --stdin-name names, relative to it
function fmt_editor_cases() -> void {
let lbl = "ludic fmt --lint --json and ludic fmt - from inside a project"
let p = `{tmp_dir()}/fmt_ed`
let here = capture_line("pwd")
shell(`rm -rf {p} && mkdir -p {p}/src/deep`)
write_file(`{p}/package.ludic`, "package \"t\"\nlint one_statement\nlint paths \"src\"\n")
write_file(`{p}/src/deep/a.ludic`, "function f() -> int {\n let a = 1; return a\n}\n")
let js = `{p}/out.json`
if shq(`cd {p}/src/deep && {here}/bin/ludic fmt --lint --json > {js} 2>/dev/null`) { bad2(lbl, "--lint --json passed a file breaking one_statement"); return }
let said = capture(`cat {js}`)
if not s_contains(said, `"file": "src/deep/a.ludic", "line": 2, "col": 15`) { bad2(lbl, `--lint --json said {s_trim(said)}`); return }
let one = capture(`cd {p}/src/deep && {here}/bin/ludic fmt - --lint --json --stdin-name a.ludic < a.ludic 2>/dev/null`)
if not s_contains(one, `"file": "a.ludic", "line": 2`) { bad2(lbl, `a buffer linted as src/deep/a.ludic said {s_trim(one)}`); return }
let out = capture(`cd {p}/src/deep && printf 'function g() -> int{{return 2}}\n' | {here}/bin/ludic fmt - --stdin-name {p}/src/deep/b.ludic`)
if not (s_trim(out) == "function g() -> int { return 2 }") { bad2(lbl, `ludic fmt - wrote {s_trim(out)}`); return }
if shq(`printf '{{\n' | {here}/bin/ludic fmt - > /dev/null 2>&1`) { bad2(lbl, "ludic fmt - accepted an unclosed brace"); return }
if shq(`{here}/bin/ludic fmt --json > /dev/null 2>&1`) { bad2(lbl, "--json without --lint was accepted"); return }
ok(lbl)
}
# a feature example that consumes an in-repo controller package (packages/): same
# as feat_case, but the compiler is pointed at packages/ via LUDIC_MODULES so a
# `import "ludic.foo/bar.ludic"` resolves, and LUDIC_HOME anchors the engine
@ -1252,6 +1274,7 @@ function cmd_dev_test() -> int {
diag_json_case("rejected/ref_unknown", 1, "@Ref(Shops): this program has no Shops - left unresolved", "--diagnostics=json: an @Ref to a registry the program lacks is a warning beside the error")
diag_json_case("rejected/node_bad", 8, "crate.shape is \\\"cube\\\", and field Tool.shape is @OneOf(", "--diagnostics=json: every @Node / @Clip / @Material with no model field, @OneOf of the wrong kind or a row outside its words, and the targets the program lacks as warnings")
stdin_file_cases()
fmt_editor_cases()
feat_case("lang/generics", "", "7 Crater Lake a 2 west 7 0.75 2.5 1", "generics.ludic (L5: generic records and functions, nested instances, fn-typed parameters and fields, inference from the result's slot)")
reject_case("rejected/generic_unbound", "cannot tell what T is in this call to pool_new", "a call whose type arguments nothing decides is refused")
reject_case("rejected/generic_mismatch", "words wants a Pool<string> and this is a Pool<int>", "two instances of one generic are two types")

View file

@ -129,6 +129,28 @@ function cmd_test_tools() -> int {
write_file(`{lp}/src/c.ludic`, "function h() -> string { return \"a; b\" }\n")
if shq(`cd {lp} && {lfmt} --lint > /dev/null`) { ok("a ; inside a string is not a statement") } else { bad("a ; inside a string is not a statement") }
# --- for editors (R9): --lint --json, and a buffer on stdin -----------------
write_file(`{lp}/src/d.ludic`, "function d() -> int {\n let a = 1; let b = \"q\\\"\"; return a\n}\n")
let lj = `{lp}/lint.json`
if not shq(`cd {lp} && {lfmt} --lint --json > {lj} 2> {lp}/lint.err`) and shq(`python3 -m json.tool {lj} > /dev/null`) and shq(`grep -q '"file": "src/d.ludic", "line": 2, "col": 15, "rule": "one_statement"' {lj}`) and not shq(`grep -q 'lint:' {lj}`) {
ok("--lint --json: one JSON array on stdout, with the column, the summary on stderr")
} else { bad2("--lint --json", capture_line(`cat {lj}`)) }
shell(`cd {lp} && {lfmt} --init-baseline > /dev/null`)
write_file(`{lp}/src/d.ludic`, "function d() -> int { return 1 }\n")
let pinned = capture_line(`cat {lp}/lint-baseline.txt`)
shell(`cd {lp} && {lfmt} --lint --json > /dev/null 2>&1`)
check("--lint --json never lowers the baseline (plain --lint does)", capture_line(`cat {lp}/lint-baseline.txt`), pinned)
write_file(`{lp}/buf.txt`, "function g() -> int {\n let b = 2; return b\n}\n")
let sj = capture(`cd {lp} && {lfmt} - --lint --json --stdin-name src/new.ludic < buf.txt`)
if s_contains(sj, `"file": "src/new.ludic", "line": 2`) { ok("- --lint --json: a buffer judged as the file it stands for") } else { bad2("- --lint --json", sj) }
check("a buffer outside the lint paths breaks no rule", s_trim(capture(`cd {lp} && {lfmt} - --lint --json --stdin-name notes/new.ludic < buf.txt 2>/dev/null`)), "[]")
check("- formats stdin to stdout", capture_line(`printf 'function f() -> int{{return 1}}\n' | {fmt} -`), "function f() -> int { return 1 }")
let bad_rc = sh(`printf 'function f() -> int {{\n let s = (1]\n}}\n' | {fmt} - --stdin-name x.ludic > {tmp_dir()}/bad.out 2> {tmp_dir()}/bad.err`)
if bad_rc == 2 and shq(`grep -q '^x.ludic:2:13: error:' {tmp_dir()}/bad.err`) and shq(`test ! -s {tmp_dir()}/bad.out`) {
ok("- refuses a buffer with a bracket closed by the wrong one: exit 2, where on stderr, nothing on stdout")
} else { bad2("- refuses an unreadable buffer", capture_line(`cat {tmp_dir()}/bad.err`)) }
check("- refuses an unclosed string", string(sh(`printf 'let s = "abc\n' | {fmt} - > /dev/null 2>&1`)), "2")
# --- --check exit status, for CI and pre-commit hooks ----------------------
let chk1 = sh(`{fmt} --check -q {tmp_dir()}/m.ludic`)
check("--check exits 1 on unformatted input", string(chk1), "1")