From 047bdf4189f550a2109c78bbd7504a17d50e2b5f Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Wed, 30 Sep 2026 00:24:43 +0300 Subject: [PATCH] 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 --- LANGUAGE.md | 22 +++++ changes/fmt-editor-output.md | 12 +++ tools/ludic-cli/main.ludic | 3 + tools/ludic-cli/project.ludic | 37 ++++++++- tools/ludic-cli/scripts.ludic | 21 ++++- tools/ludic-cli/test.ludic | 23 ++++++ tools/ludic-cli/tools.ludic | 22 +++++ tools/ludic-tools/fmt.ludic | 44 ++++++++-- tools/ludic-tools/fmt_lint.ludic | 5 +- tools/ludic-tools/fmt_lint_json.ludic | 91 ++++++++++++++++++++ tools/ludic-tools/fmt_lint_rules.ludic | 17 +++- tools/ludic-tools/fmt_lint_run.ludic | 15 +++- tools/ludic-tools/fmt_stdin.ludic | 110 +++++++++++++++++++++++++ 13 files changed, 405 insertions(+), 17 deletions(-) create mode 100644 changes/fmt-editor-output.md create mode 100644 tools/ludic-tools/fmt_lint_json.ludic create mode 100644 tools/ludic-tools/fmt_stdin.ludic diff --git a/LANGUAGE.md b/LANGUAGE.md index f000d961..75555f32 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -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` 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 `::: 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 ```bash diff --git a/changes/fmt-editor-output.md b/changes/fmt-editor-output.md new file mode 100644 index 00000000..9af67c50 --- /dev/null +++ b/changes/fmt-editor-output.md @@ -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 `::: error: ...` on stderr. +`--stdin-name ` makes the buffer that file: the project is found from its directory, and +`ludic fmt - --lint --json --stdin-name ` 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`, `-`). diff --git a/tools/ludic-cli/main.ludic b/tools/ludic-cli/main.ludic index 7e3921f6..db413115 100644 --- a/tools/ludic-cli/main.ludic +++ b/tools/ludic-cli/main.ludic @@ -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") diff --git a/tools/ludic-cli/project.ludic b/tools/ludic-cli/project.ludic index 4601b096..d5562c71 100644 --- a/tools/ludic-cli/project.ludic +++ b/tools/ludic-cli/project.ludic @@ -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 { diff --git a/tools/ludic-cli/scripts.ludic b/tools/ludic-cli/scripts.ludic index 8e44efe1..9d184864 100644 --- a/tools/ludic-cli/scripts.ludic +++ b/tools/ludic-cli/scripts.ludic @@ -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 ` 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 diff --git a/tools/ludic-cli/test.ludic b/tools/ludic-cli/test.ludic index 5f655b0f..41ca7ecb 100644 --- a/tools/ludic-cli/test.ludic +++ b/tools/ludic-cli/test.ludic @@ -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 and this is a Pool", "two instances of one generic are two types") diff --git a/tools/ludic-cli/tools.ludic b/tools/ludic-cli/tools.ludic index 1a041cb0..e8e0d976 100644 --- a/tools/ludic-cli/tools.ludic +++ b/tools/ludic-cli/tools.ludic @@ -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") diff --git a/tools/ludic-tools/fmt.ludic b/tools/ludic-tools/fmt.ludic index e29924a2..8ea072a7 100644 --- a/tools/ludic-tools/fmt.ludic +++ b/tools/ludic-tools/fmt.ludic @@ -8,11 +8,16 @@ # ludic-fmt -w a.ludic rewrite in place # ludic-fmt --check a.ludic exit 1 if unformatted # 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 { import "fmt_lint.ludic" import "fmt_lint_rules.ludic" import "fmt_lint_run.ludic" + import "fmt_lint_json.ludic" + import "fmt_stdin.ludic" # ---- token kinds (mirror ludic_syntax.h) ---- const LT_EOF: int = 0 const LT_NL: int = 1 @@ -632,24 +637,43 @@ program LudicFmt { var failed = false var lint_only = false var lint_init = false + var json = false + var stdin_name = "" + var stdin_rel = "" let files = new []pointer var ai = 1 while ai < arg_count() { 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 } else { if (a == "--check") or (a == "-l") { check = 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 == "--lint") { lint_only = 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) } } } } } } } ai += 1 } 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) 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 var li = 0 while li < len(lt_paths) { @@ -668,9 +692,19 @@ program LudicFmt { let linting = check and lint_config() # 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 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 = "" } + 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)) return } diff --git a/tools/ludic-tools/fmt_lint.ludic b/tools/ludic-tools/fmt_lint.ludic index 7d7ffa5a..760d9144 100644 --- a/tools/ludic-tools/fmt_lint.ludic +++ b/tools/ludic-tools/fmt_lint.ludic @@ -22,6 +22,7 @@ var lr_file: []string = new []string var lr_rule: []string = new []string var lr_line: []int = new []int 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 { var n = 0 @@ -103,9 +104,11 @@ function lint_config() -> bool { } 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_rule, rule) push(lr_line, line) + push(lr_col, col) push(lr_msg, msg) } diff --git a/tools/ludic-tools/fmt_lint_json.ludic b/tools/ludic-tools/fmt_lint_json.ludic new file mode 100644 index 00000000..5668f153 --- /dev/null +++ b/tools/ludic-tools/fmt_lint_json.ludic @@ -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)) +} diff --git a/tools/ludic-tools/fmt_lint_rules.ludic b/tools/ludic-tools/fmt_lint_rules.ludic index 13c2898e..3257ccba 100644 --- a/tools/ludic-tools/fmt_lint_rules.ludic +++ b/tools/ludic-tools/fmt_lint_rules.ludic @@ -26,8 +26,9 @@ function lint_comments(path: string, ls: []string) -> void { } else { if run > 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})`) } - 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/`) } + let col = lt_indent(ls[start]) + 1 + 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 } if not blank { code_seen = true } @@ -35,6 +36,14 @@ function lint_comments(path: string, ls: []string) -> void { 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 function lt_words_raw(line: string) -> int { var i = 0 @@ -48,7 +57,7 @@ function lint_statements(path: string) -> void { while i < ntok() { if tk_kind[i] == LT_OP and tok_len(i) == 1 and src[tk_start[i]] == ';' { 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 } @@ -60,7 +69,7 @@ function lint_functions(path: string) -> void { let end = lt_fn_end(i) if end >= 0 { 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 diff --git a/tools/ludic-tools/fmt_lint_run.ludic b/tools/ludic-tools/fmt_lint_run.ludic index dced52b1..55cafcb7 100644 --- a/tools/ludic-tools/fmt_lint_run.ludic +++ b/tools/ludic-tools/fmt_lint_run.ludic @@ -104,14 +104,23 @@ function lint_judge(init: bool) -> bool { 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 } function lt_print_pair(path: string, rule: string, now: int, allow: int) -> void { var i = 0 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 } - 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) } + } } diff --git a/tools/ludic-tools/fmt_stdin.ludic b/tools/ludic-tools/fmt_stdin.ludic new file mode 100644 index 00000000..b952df80 --- /dev/null +++ b/tools/ludic-tools/fmt_stdin.ludic @@ -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) +}