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:
parent
c804c4b0ee
commit
047bdf4189
13 changed files with 405 additions and 17 deletions
|
|
@ -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 = "<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))
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
91
tools/ludic-tools/fmt_lint_json.ludic
Normal file
91
tools/ludic-tools/fmt_lint_json.ludic
Normal 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))
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
110
tools/ludic-tools/fmt_stdin.ludic
Normal file
110
tools/ludic-tools/fmt_stdin.ludic
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue