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

@ -2431,6 +2431,28 @@ had when a rule came in, which it may keep but not add to, lowered automatically
fixed - so a rule can arrive in a codebase that breaks it today (`ludic-fmt --init-baseline` fixed - so a rule can arrive in a codebase that breaks it today (`ludic-fmt --init-baseline`
writes it). A `;` or a `#` inside a string does not count. writes it). A `;` or a `#` inside a string does not count.
For editors and tools (`ludic fmt` finds the project the way these describe - the nearest
`package.ludic` upwards from the working directory, or from the directory of `--stdin-name` - and runs
the formatter from there):
```bash
ludic fmt --lint --json # the violations --lint prints, as one JSON array
ludic fmt - --stdin-name src/a.ludic < buf # a buffer formatted to stdout, as if it were src/a.ludic
ludic fmt - --lint --json --stdin-name src/a.ludic < buf # a buffer linted as src/a.ludic
```
`--lint --json` writes `[{"file", "line", "col", "rule", "message"}]` on stdout - every violation
`--lint` would print, ordered by file, line and column (`col`, 1-based, is left out for a rule with no
column: a file's length) - and everything else on stderr; the exit status is `--lint`'s (1 when a file
has more than its baseline allows). Unlike `--lint`, it never rewrites the baseline. `-` reads the
whole of stdin: formatting writes the result to stdout, and a buffer that does not read as Ludic - a
string, template or key literal left open, a bracket never closed or closed by the wrong one - is
refused with exit 2 and `<name>:<line>:<col>: error: ...` on stderr, nothing on stdout. With `--lint`,
the buffer is judged as the file `--stdin-name` names, relative to the project: its baseline
allowance applies, a name outside the `lint paths` breaks no rule (`[]`), and the report names it as
given (or `-`). A project with no `lint` lines lints a buffer clean. `--stdin-name` ending `.md`
formats the buffer as Markdown.
### Editors ### Editors
```bash ```bash

View file

@ -0,0 +1,12 @@
bump: minor
type: feat
**`ludic fmt` for editors.** `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 (the
summary on stderr, `--lint`'s exit status, and the baseline never rewritten). `ludic fmt -` formats
stdin to stdout under the project found from the working directory (the nearest `package.ludic`
upwards), and refuses a buffer that does not read as Ludic - an open string, a bracket never closed or
closed by the wrong one - with exit 2 and `<name>:<line>:<col>: error: ...` on stderr.
`--stdin-name <path>` makes the buffer that file: the project is found from its directory, and
`ludic fmt - --lint --json --stdin-name <path>` judges it against that file's baseline and `lint
paths`, reporting it under the name given. Hooks around `fmt` and `get` read nothing from stdin and
write to stderr when the command's stdout is a program's (`--json`, `-`).

View file

@ -79,6 +79,9 @@ program Ludic {
print(" upgrade [version] reinstall from the docs site (the same script that installed it)") 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(" doctor check that the install is complete and usable")
print(" fmt [--check] [paths...] format Ludic source in place (--check: report, write nothing)") 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(" 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(" ui-preview [--font DIR] preview a game's ludic.ui components for a studio, on stdio")
print(" help this message") print(" help this message")

View file

@ -571,7 +571,8 @@ function strip_ext(p: pointer) -> pointer {
# ---- ludic fmt / lsp -------------------------------------------------------- # ---- ludic fmt / lsp --------------------------------------------------------
# ludic fmt [--check] [paths...] — format the project's source in place (src/ and # 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 # non-zero if anything is unformatted, which is what a pre-commit hook or CI
# wants. # wants.
# #
@ -582,18 +583,35 @@ function strip_ext(p: pointer) -> pointer {
function cmd_fmt() -> int { function cmd_fmt() -> int {
var mode = "-w" var mode = "-w"
var args = "" var args = ""
var lint = false
var json = false
var stdin = false
var name = ""
var ai = 2 var ai = 2
while ai < arg_count() { while ai < arg_count() {
let a = arg(ai) let a = arg(ai)
if a == "--check" { mode = "--check" } 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] == '-' { else if a[0] == '-' {
err(`ludic fmt: unknown option {a}\n`) 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 return 1
} }
else { args = `{args} {a}` } else { args = `{args} {a}` }
ai += 1 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 == "" { if args == "" {
let found = capture_line("find src tests -name '*.ludic' 2>/dev/null | sort") let found = capture_line("find src tests -name '*.ludic' 2>/dev/null | sort")
if found == "" { if found == "" {
@ -605,6 +623,21 @@ function cmd_fmt() -> int {
return sh(`{tool("ludic-fmt")} {mode}{args}`) 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, # 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. # so the server's location is the CLI's problem rather than the user's.
function cmd_lsp() -> int { function cmd_lsp() -> int {

View file

@ -32,6 +32,17 @@ function script_env(event: pointer, phase: pointer) -> pointer {
return env 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 # run every `hook <phase> <event>` in order; the first failure's exit code, or 0
function run_hooks(phase: pointer, event: pointer) -> int { function run_hooks(phase: pointer, event: pointer) -> int {
if not file_exists("package.ludic") { return 0 } 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) { while i + 2 < len(m.hooks) {
if m.hooks[i] == phase and m.hooks[i + 1] == event { if m.hooks[i] == phase and m.hooks[i + 1] == event {
let cmd = m.hooks[i + 2] let cmd = m.hooks[i + 2]
say(`> {phase} {event}: {cmd}`) var rc = 0
let rc = sh(`{script_env(event, phase)}{cmd}`) 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 { if rc != 0 {
err(`ludic: the {phase} {event} hook failed (exit {string(rc)}): {cmd}\n`) err(`ludic: the {phase} {event} hook failed (exit {string(rc)}): {cmd}\n`)
return rc return rc

View file

@ -123,6 +123,28 @@ function stdin_file_cases() -> void {
ok(lbl) 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 # 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 # 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 # `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/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") 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() 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)") 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_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") 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") 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") } 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 ---------------------- # --- --check exit status, for CI and pre-commit hooks ----------------------
let chk1 = sh(`{fmt} --check -q {tmp_dir()}/m.ludic`) let chk1 = sh(`{fmt} --check -q {tmp_dir()}/m.ludic`)
check("--check exits 1 on unformatted input", string(chk1), "1") check("--check exits 1 on unformatted input", string(chk1), "1")

View file

@ -8,11 +8,16 @@
# ludic-fmt -w a.ludic rewrite in place # ludic-fmt -w a.ludic rewrite in place
# ludic-fmt --check a.ludic exit 1 if unformatted # ludic-fmt --check a.ludic exit 1 if unformatted
# ludic-fmt a.md format the ```ludic fences in a document # ludic-fmt a.md format the ```ludic fences in a document
# cat a.ludic | ludic-fmt - filter mode (stdin -> stdout) # cat a.ludic | ludic-fmt - filter mode (stdin -> stdout); a buffer that does not read as
# Ludic (an open string, an unclosed bracket) exits 2, why on stderr
# ludic-fmt --lint --json the project's lint, as one JSON array on stdout
# ludic-fmt - --lint --json --stdin-name src/a.ludic lint a buffer as that file
program LudicFmt { program LudicFmt {
import "fmt_lint.ludic" import "fmt_lint.ludic"
import "fmt_lint_rules.ludic" import "fmt_lint_rules.ludic"
import "fmt_lint_run.ludic" import "fmt_lint_run.ludic"
import "fmt_lint_json.ludic"
import "fmt_stdin.ludic"
# ---- token kinds (mirror ludic_syntax.h) ---- # ---- token kinds (mirror ludic_syntax.h) ----
const LT_EOF: int = 0 const LT_EOF: int = 0
const LT_NL: int = 1 const LT_NL: int = 1
@ -632,24 +637,43 @@ program LudicFmt {
var failed = false var failed = false
var lint_only = false var lint_only = false
var lint_init = false var lint_init = false
var json = false
var stdin_name = ""
var stdin_rel = ""
let files = new []pointer let files = new []pointer
var ai = 1 var ai = 1
while ai < arg_count() { while ai < arg_count() {
let a = arg(ai) let a = arg(ai)
if (a == "--json") { json = true; ai += 1; continue }
if (a == "--stdin-name") and ai + 1 < arg_count() { stdin_name = string(arg(ai + 1)); ai += 2; continue }
if (a == "--stdin-rel") and ai + 1 < arg_count() { stdin_rel = string(arg(ai + 1)); ai += 2; continue }
if (a == "-w") or (a == "--write") { write = true } if (a == "-w") or (a == "--write") { write = true }
else { if (a == "--check") or (a == "-l") { check = true } else { if (a == "--check") or (a == "-l") { check = true }
else { if (a == "-q") or (a == "--quiet") { quiet = true } else { if (a == "-q") or (a == "--quiet") { quiet = true }
else { if (a == "--indent") { ai += 1; if ai < arg_count() { indent = 0; let d = arg(ai); var di = 0; while d[di] != 0 { indent = indent * 10 + (d[di] - 48); di += 1 } } } else { if (a == "--indent") { ai += 1; if ai < arg_count() { indent = 0; let d = arg(ai); var di = 0; while d[di] != 0 { indent = indent * 10 + (d[di] - 48); di += 1 } } }
else { if (a == "--lint") { lint_only = true } else { if (a == "--lint") { lint_only = true }
else { if (a == "--init-baseline") { lint_only = true; lint_init = true } else { if (a == "--init-baseline") { lint_only = true; lint_init = true }
else { if (a == "-h") or (a == "--help") { print("ludic-fmt — format Ludic source; --check also checks package.ludic's lint rules, --lint checks the project"); return } else { if (a == "-h") or (a == "--help") { print("ludic-fmt — format Ludic source; --check also checks package.ludic's lint rules, --lint checks the project (--json: as a JSON array); - formats stdin (--stdin-name PATH: as that file)"); return }
else { push(files, a) } } } } } } } else { push(files, a) } } } } } } }
ai += 1 ai += 1
} }
if indent < 1 or indent > 8 { indent = 2 } if indent < 1 or indent > 8 { indent = 2 }
let stdin_dash = len(files) == 1 and (files[0] == "-")
if json and not lint_only { lt_err("ludic-fmt: --json goes with --lint\n"); exit(2) }
lt_json = json
lt_partial = json and not lint_init # a report for an editor never rewrites the baseline
# - --lint: a buffer on stdin, judged as the file --stdin-name names
if lint_only and stdin_dash {
if lint_init { lt_err("ludic-fmt: --init-baseline reads the project, not stdin\n"); exit(2) }
if not fs_lint_stdin(stdin_name, stdin_rel) { exit(1) }
return
}
# --lint: the project's rules over its paths, against the baseline (L10) # --lint: the project's rules over its paths, against the baseline (L10)
if lint_only { if lint_only {
if not lint_config() { print("ludic-fmt --lint: package.ludic states no `lint` rules"); exit(2) } if not lint_config() {
if json { lt_err("ludic-fmt --lint: package.ludic states no `lint` rules\n") } else { print("ludic-fmt --lint: package.ludic states no `lint` rules") }
exit(2)
}
let all = new []string let all = new []string
var li = 0 var li = 0
while li < len(lt_paths) { while li < len(lt_paths) {
@ -668,9 +692,19 @@ program LudicFmt {
let linting = check and lint_config() let linting = check and lint_config()
# stdin filter mode # stdin filter mode
if len(files) == 0 or (len(files) == 1 and (files[0] == "-")) { if len(files) == 0 or stdin_dash {
let text = slurp_stdin() let text = slurp_stdin()
let out = format_source(text, false, indent) let md = fs_ends(stdin_name, ".md") or fs_ends(stdin_name, ".markdown")
if not md {
lex(text)
if not fs_parses() {
var shown = stdin_name
if len(shown) == 0 { shown = "<stdin>" }
lt_err(`{shown}:{fs_err_line}:{fs_err_col}: error: {fs_err_msg}\n`)
exit(2)
}
}
let out = format_source(text, md, indent)
file_write(file_stdout(), out, cstr_len(out)) file_write(file_stdout(), out, cstr_len(out))
return return
} }

View file

@ -22,6 +22,7 @@ var lr_file: []string = new []string
var lr_rule: []string = new []string var lr_rule: []string = new []string
var lr_line: []int = new []int var lr_line: []int = new []int
var lr_msg: []string = new []string var lr_msg: []string = new []string
var lr_col: []int = new []int # 1-based byte column, 0 when the rule has none (a file's length)
function lt_atoi(s: string) -> int { function lt_atoi(s: string) -> int {
var n = 0 var n = 0
@ -103,9 +104,11 @@ function lint_config() -> bool {
} }
return any return any
} }
function lt_report(path: string, rule: string, line: int, msg: string) -> void { function lt_report(path: string, rule: string, line: int, msg: string) -> void { lt_report_at(path, rule, line, 0, msg) }
function lt_report_at(path: string, rule: string, line: int, col: int, msg: string) -> void {
push(lr_file, path) push(lr_file, path)
push(lr_rule, rule) push(lr_rule, rule)
push(lr_line, line) push(lr_line, line)
push(lr_col, col)
push(lr_msg, msg) push(lr_msg, msg)
} }

View file

@ -0,0 +1,91 @@
# fmt_lint_json.ludic — `ludic-fmt --lint --json`: the violations --lint would print, as ONE JSON
# array on stdout for an editor, `[{"file", "line", "col", "rule", "message"}]` ordered by file, then
# line, then column; "col" only where the rule has one. Everything else --lint says goes to stderr.
var lt_json: bool = false
var lj_idx: []int = new []int # the reports --lint would have printed, by index
var lt_show_from: string = "" # a buffer on stdin is judged as this path (its baseline, its scope)
var lt_show_as: string = "" # and reported under this name (--stdin-name, or "-")
function lt_err(s: string) -> void { file_write(file_stderr(), s, len(s)) }
function lt_shown(path: string) -> string {
if len(lt_show_from) > 0 and path == lt_show_from { return lt_show_as }
return path
}
function lt_strcmp(a: string, b: string) -> int {
var i = 0
while i < len(a) and i < len(b) {
if a[i] != b[i] { return a[i] - b[i] }
i += 1
}
return len(a) - len(b)
}
# report x before report y?
function lj_before(x: int, y: int) -> bool {
let c = lt_strcmp(lr_file[x], lr_file[y])
if c != 0 { return c < 0 }
if lr_line[x] != lr_line[y] { return lr_line[x] < lr_line[y] }
if lr_col[x] != lr_col[y] { return lr_col[x] < lr_col[y] }
return lt_strcmp(lr_rule[x], lr_rule[y]) < 0
}
function lj_sort() -> void {
var i = 1
while i < len(lj_idx) {
let x = lj_idx[i]
var j = i - 1
while j >= 0 and lj_before(x, lj_idx[j]) {
lj_idx[j + 1] = lj_idx[j]
j -= 1
}
lj_idx[j + 1] = x
i += 1
}
}
function lj_hex(d: int) -> int {
if d < 10 { return '0' + d }
return 'a' + d - 10
}
# `s` as a JSON string, quotes included
function lt_jq(b: Buf, s: string) -> void {
buf_putc(b, '"')
var i = 0
while i < len(s) {
let c = s[i] & 255
if c == '"' { buf_puts(b, "\\\"") }
else if c == '\\' { buf_puts(b, "\\\\") }
else if c == '\n' { buf_puts(b, "\\n") }
else if c == '\t' { buf_puts(b, "\\t") }
else if c == '\r' { buf_puts(b, "\\r") }
else if c < 32 {
buf_puts(b, "\\u00")
buf_putc(b, lj_hex(c >> 4))
buf_putc(b, lj_hex(c & 15))
}
else { buf_putc(b, c) }
i += 1
}
buf_putc(b, '"')
}
function lt_json_out() -> void {
lj_sort()
let b = buf_new()
buf_putc(b, '[')
var k = 0
while k < len(lj_idx) {
let i = lj_idx[k]
if k > 0 { buf_putc(b, ',') }
buf_puts(b, "\n {\"file\": ")
lt_jq(b, lt_shown(lr_file[i]))
buf_puts(b, `, "line": {lr_line[i]}`)
if lr_col[i] > 0 { buf_puts(b, `, "col": {lr_col[i]}`) }
buf_puts(b, ", \"rule\": ")
lt_jq(b, lr_rule[i])
buf_puts(b, ", \"message\": ")
lt_jq(b, lr_msg[i])
buf_putc(b, '}')
k += 1
}
if k > 0 { buf_putc(b, '\n') }
buf_puts(b, "]\n")
let s = buf_str(b)
file_write(file_stdout(), s, cstr_len(s))
}

View file

@ -26,8 +26,9 @@ function lint_comments(path: string, ls: []string) -> void {
} else { } else {
if run > 0 { if run > 0 {
let header = not code_seen and start == 0 let header = not code_seen and start == 0
if header and lt_max_header > 0 and run > lt_max_header { lt_report(path, "max_header_lines", start + 1, `the opening comment is {run} lines (the limit is {lt_max_header})`) } let col = lt_indent(ls[start]) + 1
if not header and lt_max_comment > 0 and run > lt_max_comment { lt_report(path, "max_comment_lines", start + 1, `a comment of {run} lines (the limit is {lt_max_comment}): say why in a line or two; the story goes in the commit or docs/`) } if header and lt_max_header > 0 and run > lt_max_header { lt_report_at(path, "max_header_lines", start + 1, col, `the opening comment is {run} lines (the limit is {lt_max_header})`) }
if not header and lt_max_comment > 0 and run > lt_max_comment { lt_report_at(path, "max_comment_lines", start + 1, col, `a comment of {run} lines (the limit is {lt_max_comment}): say why in a line or two; the story goes in the commit or docs/`) }
run = 0 run = 0
} }
if not blank { code_seen = true } if not blank { code_seen = true }
@ -35,6 +36,14 @@ function lint_comments(path: string, ls: []string) -> void {
i += 1 i += 1
} }
} }
# the spaces and tabs a line opens with
function lt_indent(line: string) -> int {
var i = 0
while i < len(line) and (line[i] == ' ' or line[i] == 9) { i += 1 }
return i
}
# a token's 1-based byte column on its line
function lt_tok_col(i: int) -> int { return tk_start[i] - linestart[tk_line[i]] + 1 }
# 0 a blank line, 1 a comment line, 2 code # 0 a blank line, 1 a comment line, 2 code
function lt_words_raw(line: string) -> int { function lt_words_raw(line: string) -> int {
var i = 0 var i = 0
@ -48,7 +57,7 @@ function lint_statements(path: string) -> void {
while i < ntok() { while i < ntok() {
if tk_kind[i] == LT_OP and tok_len(i) == 1 and src[tk_start[i]] == ';' { if tk_kind[i] == LT_OP and tok_len(i) == 1 and src[tk_start[i]] == ';' {
let k = tk_kind[i + 1] let k = tk_kind[i + 1]
if k != LT_NL and k != LT_COMMENT and k != LT_EOF { lt_report(path, "one_statement", tk_line[i] + 1, "two statements on one line") } if k != LT_NL and k != LT_COMMENT and k != LT_EOF { lt_report_at(path, "one_statement", tk_line[i] + 1, lt_tok_col(i + 1), "two statements on one line") }
} }
i += 1 i += 1
} }
@ -60,7 +69,7 @@ function lint_functions(path: string) -> void {
let end = lt_fn_end(i) let end = lt_fn_end(i)
if end >= 0 { if end >= 0 {
let n = tk_line[end] - tk_line[i] + 1 let n = tk_line[end] - tk_line[i] + 1
if n > lt_max_fn { lt_report(path, "max_function_lines", tk_line[i] + 1, `{lt_fn_name(i)} is {n} lines (the limit is {lt_max_fn})`) } if n > lt_max_fn { lt_report_at(path, "max_function_lines", tk_line[i] + 1, lt_tok_col(i), `{lt_fn_name(i)} is {n} lines (the limit is {lt_max_fn})`) }
} }
} }
i += 1 i += 1

View file

@ -104,14 +104,23 @@ function lint_judge(init: bool) -> bool {
file_close(f) file_close(f)
} }
} }
print(` lint: {total} violation(s) in the baseline, {len(pf)} file/rule pair(s)`) let summary = ` lint: {total} violation(s) in the baseline, {len(pf)} file/rule pair(s)`
if lt_json {
lt_err(`{summary}\n`)
lt_json_out()
} else { print(summary) }
return ok return ok
} }
function lt_print_pair(path: string, rule: string, now: int, allow: int) -> void { function lt_print_pair(path: string, rule: string, now: int, allow: int) -> void {
var i = 0 var i = 0
while i < len(lr_file) { while i < len(lr_file) {
if lr_file[i] == path and lr_rule[i] == rule { print(`{path}:{lr_line[i]}: {rule}: {lr_msg[i]}`) } if lr_file[i] == path and lr_rule[i] == rule {
if lt_json { push(lj_idx, i) } else { print(`{lt_shown(path)}:{lr_line[i]}: {rule}: {lr_msg[i]}`) }
}
i += 1 i += 1
} }
if allow > 0 { print(` ({path} may have {allow} of {rule}, and has {now})`) } if allow > 0 {
let note = ` ({lt_shown(path)} may have {allow} of {rule}, and has {now})`
if lt_json { lt_err(`{note}\n`) } else { print(note) }
}
} }

View file

@ -0,0 +1,110 @@
# fmt_stdin.ludic — `ludic-fmt -`: a buffer on stdin, formatted to stdout, for an editor. The
# formatter works on tokens and never fails, so a buffer it cannot read as Ludic - a string, a
# template or a key literal left open, a bracket never closed or closed by the wrong one - would come
# back re-indented around the fault; such a buffer is refused instead, with where on stderr.
var fs_err_line: int = 0
var fs_err_col: int = 0
var fs_err_msg: string = ""
function fs_fail(i: int, msg: string) -> bool {
fs_err_line = tk_line[i] + 1
fs_err_col = tk_start[i] - linestart[tk_line[i]] + 1
fs_err_msg = msg
return false
}
# a string-ish token that reached the end of its line (or the text) without its closing quote
function fs_open_quote(i: int) -> bool {
let t = tok_text(i)
let n = tok_len(i)
var q = t[0]
var body = 1
if q == 'k' {
q = '"'
body = 2
if n > 1 and t[1] == 'n' { body = 3 }
}
if n <= body { return true }
if t[n - 1] != q { return true }
return t[n - 2] == '\\' and not fs_escaped_end(t, n)
}
# is the backslash before the last byte itself escaped (`"a\\"` is closed, `"a\"` is not)?
function fs_escaped_end(t: pointer, n: int) -> bool {
var k = n - 2
var run = 0
while k >= 1 and t[k] == '\\' {
run += 1
k -= 1
}
return run % 2 == 0
}
function fs_closer(c: int) -> int {
if c == '(' { return ')' }
if c == '[' { return ']' }
return '}'
}
# lex(text) first; true when the tokens read as Ludic, else fs_err_* say where they stop
function fs_parses() -> bool {
let open = new []int
var i = 0
while i < ntok() {
let k = tk_kind[i]
if k == LT_STR and fs_open_quote(i) { return fs_fail(i, "this string is never closed") }
if k == LT_OP and tok_len(i) == 1 {
let c = src[tk_start[i]]
if c == '(' or c == '[' or c == '{' { push(open, i) }
if c == ')' or c == ']' or c == '}' {
if len(open) == 0 { return fs_fail(i, `a {string(tok_text(i))} with nothing open to close`) }
let o = open[len(open) - 1]
let want = fs_closer(src[tk_start[o]])
if c != want {
let at = `{tk_line[o] + 1}:{tk_start[o] - linestart[tk_line[o]] + 1}`
return fs_fail(i, `a {string(tok_text(i))} where the {string(tok_text(o))} opened at {at} wants its close`)
}
let closed = List.pop(open)
}
}
i += 1
}
if len(open) > 0 {
let o = open[len(open) - 1]
return fs_fail(o, `this {string(tok_text(o))} is never closed`)
}
return true
}
function fs_ends(s: string, suf: string) -> bool {
if len(suf) > len(s) { return false }
return s[len(s) - len(suf) .. len(s)] == suf
}
function fs_strip_dot(p: string) -> string {
var s = p
while len(s) >= 2 and s[0] == '.' and s[1] == '/' { s = s[2 .. len(s)] }
while len(s) > 1 and s[len(s) - 1] == '/' { s = s[0 .. len(s) - 1] }
return s
}
# is a buffer standing for `rel` (relative to the project) one --lint would walk?
function fs_in_scope(rel: string) -> bool {
if not fs_ends(rel, ".ludic") { return false }
var i = 0
while i < len(lt_paths) {
let p = fs_strip_dot(lt_paths[i])
if p == "." or rel == p { return true }
if len(rel) > len(p) and rel[0 .. len(p)] == p and rel[len(p)] == '/' { return true }
i += 1
}
return false
}
# `ludic-fmt - --lint [--json]`: the buffer judged as the file it stands for, against the baseline,
# which is never rewritten from a buffer. A buffer outside the project's lint paths breaks no rule.
function fs_lint_stdin(name: string, rel_in: string) -> bool {
var rel = fs_strip_dot(rel_in)
if len(rel) == 0 { rel = fs_strip_dot(name) }
var shown = name
if len(shown) == 0 { shown = "-" }
if len(rel) == 0 { rel = "-" }
lt_show_from = rel
lt_show_as = shown
let text = string(slurp_stdin())
if lint_config() and (rel == "-" or fs_in_scope(rel)) { lint_file(rel, text) }
lt_partial = true
return lint_judge(false)
}