feat(tools): L10 ludic-fmt enforces a project's style

lint lines in package.ludic - one_statement, max_file_lines, max_function_lines,
max_comment_lines, max_header_lines, paths, baseline - checked by ludic-fmt --check
<files> and ludic-fmt --lint (the project), at the line; a baseline ratchet lets a
rule arrive in a codebase that breaks it (--init-baseline), lowered as it is fixed.
check-impl reads the alias declarations too (it had been blind to every namespace
L6 moved out of the compiler), and eight methods get their pages; a test holds the
formatter to keeping type arguments together (L5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-24 14:38:35 +03:00
parent e543525eb7
commit 334469ef61
16 changed files with 635 additions and 2 deletions

View file

@ -10,6 +10,9 @@
# ludic-fmt a.md format the ```ludic fences in a document
# cat a.ludic | ludic-fmt - filter mode (stdin -> stdout)
program LudicFmt {
import "fmt_lint.ludic"
import "fmt_lint_rules.ludic"
import "fmt_lint_run.ludic"
# ---- token kinds (mirror ludic_syntax.h) ----
const LT_EOF: int = 0
const LT_NL: int = 1
@ -589,6 +592,8 @@ program LudicFmt {
var quiet = false
var changed = false
var failed = false
var lint_only = false
var lint_init = false
let files = new []pointer
var ai = 1
while ai < arg_count() {
@ -597,11 +602,32 @@ program LudicFmt {
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 == "-h") or (a == "--help") { print("ludic-fmt — format Ludic source"); return }
else { push(files, a) } } } } }
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 { push(files, a) } } } } } } }
ai += 1
}
if indent < 1 or indent > 8 { indent = 2 }
# --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) }
let all = new []string
var li = 0
while li < len(lt_paths) {
lint_walk(lt_paths[li], all)
li += 1
}
var lj = 0
while lj < len(all) {
let t = read_file(all[lj])
if t != null { lint_file(all[lj], string(t)) }
lj += 1
}
if not lint_judge(lint_init) { exit(1) }
return
}
let linting = check and lint_config()
# stdin filter mode
if len(files) == 0 or (len(files) == 1 and (files[0] == "-")) {
@ -620,6 +646,7 @@ program LudicFmt {
file_write(file_stderr(), m, cstr_len(m)); failed = true
} else {
let is_md = ends_with(path, ".md") or ends_with(path, ".markdown")
if linting and not is_md { lint_file(path, string(text)) }
let out = format_source(text, is_md, indent)
let same = (out == text)
if check {
@ -638,6 +665,11 @@ program LudicFmt {
fi += 1
}
if failed { exit(2) }
# the files named are part of a project: judge them, but never rewrite its baseline
if linting {
lt_partial = true
if not lint_judge(false) { exit(1) }
}
if check and changed { exit(1) }
}
}

View file

@ -0,0 +1,111 @@
# fmt_lint.ludic — L10: the style rules, checked by the formatter. A project states them in its
# package.ludic, and `ludic-fmt --check` (the files given) or `ludic-fmt --lint` (the project's
# paths) fails a file that breaks one:
# lint one_statement two statements on one line (`a(); b()`)
# lint max_file_lines 100 a file's length
# lint max_function_lines 50 a function's, from `function` to its closing brace
# lint max_comment_lines 2 a block of comment lines - a comment says why, in a line or two
# lint max_header_lines 6 the block that opens a file, which says what the file is
# lint paths "src" "lab" what --lint walks
# lint baseline "tests/lint-baseline.txt"
# The baseline is a ratchet: `<count> <rule> <file>` lines, the violations a file had when a rule
# came in. A file may have that many and no more; when it has fewer the baseline is lowered, and
# `--init-baseline` writes it from what is there now.
var lt_one: bool = false
var lt_max_file: int = 0
var lt_max_fn: int = 0
var lt_max_comment: int = 0
var lt_max_header: int = 0
var lt_baseline: string = ""
var lt_paths: []string = new []string
var lr_file: []string = new []string
var lr_rule: []string = new []string
var lr_line: []int = new []int
var lr_msg: []string = new []string
function lt_atoi(s: string) -> int {
var n = 0
var i = 0
while i < len(s) {
let c = s[i]
if c >= '0' and c <= '9' { n = n * 10 + (c - '0') }
i += 1
}
return n
}
function lt_unquote(s: string) -> string {
let n = len(s)
if n >= 2 and s[0] == '"' and s[n - 1] == '"' { return s[1 .. n - 1] }
return s
}
# the words of a line, a "quoted" one kept whole, a # ending it
function lt_words(line: string) -> []string {
let out = new []string
var i = 0
let n = len(line)
while i < n {
while i < n and (line[i] == ' ' or line[i] == 9) { i += 1 }
if i >= n or line[i] == '#' { break }
var j = i
if line[i] == '"' {
j = i + 1
while j < n and line[j] != '"' { j += 1 }
if j < n { j += 1 }
} else {
while j < n and line[j] != ' ' and line[j] != 9 { j += 1 }
}
push(out, lt_unquote(line[i .. j]))
i = j
}
return out
}
function lt_lines(text: string) -> []string {
let out = new []string
var a = 0
var i = 0
let n = len(text)
while i < n {
if text[i] == '\n' {
push(out, text[a .. i])
a = i + 1
}
i += 1
}
if a < n { push(out, text[a .. n]) }
return out
}
# the project's rules, from package.ludic in the directory ludic-fmt runs in
function lint_config() -> bool {
let man = read_file("package.ludic")
if man == null { return false }
var any = false
let ls = lt_lines(string(man))
var i = 0
while i < len(ls) {
let w = lt_words(ls[i])
if len(w) >= 2 and w[0] == "lint" {
any = true
if w[1] == "one_statement" { lt_one = true }
if w[1] == "max_file_lines" and len(w) > 2 { lt_max_file = lt_atoi(w[2]) }
if w[1] == "max_function_lines" and len(w) > 2 { lt_max_fn = lt_atoi(w[2]) }
if w[1] == "max_comment_lines" and len(w) > 2 { lt_max_comment = lt_atoi(w[2]) }
if w[1] == "max_header_lines" and len(w) > 2 { lt_max_header = lt_atoi(w[2]) }
if w[1] == "baseline" and len(w) > 2 { lt_baseline = w[2] }
if w[1] == "paths" {
var k = 2
while k < len(w) {
push(lt_paths, w[k])
k += 1
}
}
}
i += 1
}
return any
}
function lt_report(path: string, rule: string, line: int, msg: string) -> void {
push(lr_file, path)
push(lr_rule, rule)
push(lr_line, line)
push(lr_msg, msg)
}

View file

@ -0,0 +1,109 @@
# fmt_lint_rules.ludic — L10: the rules themselves, over one file's text and its tokens
function lint_file(path: string, text: string) -> void {
let ls = lt_lines(text)
if lt_max_file > 0 and len(ls) > lt_max_file { lt_report(path, "max_file_lines", len(ls), `{len(ls)} lines (the limit is {lt_max_file})`) }
lint_comments(path, ls)
lex(text)
if lt_one { lint_statements(path) }
if lt_max_fn > 0 { lint_functions(path) }
}
function lint_comments(path: string, ls: []string) -> void {
var run = 0
var start = 0
var code_seen = false
var i = 0
while i <= len(ls) {
var is_c = false
var blank = true
if i < len(ls) {
let w = lt_words_raw(ls[i])
is_c = w == 1
blank = w == 0
}
if is_c {
if run == 0 { start = i }
run += 1
} 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/`) }
run = 0
}
if not blank { code_seen = true }
}
i += 1
}
}
# 0 a blank line, 1 a comment line, 2 code
function lt_words_raw(line: string) -> int {
var i = 0
while i < len(line) and (line[i] == ' ' or line[i] == 9) { i += 1 }
if i >= len(line) { return 0 }
if line[i] == '#' { return 1 }
return 2
}
function lint_statements(path: string) -> void {
var i = 0
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") }
}
i += 1
}
}
function lint_functions(path: string) -> void {
var i = 0
while i < ntok() {
if tk_kind[i] == LT_KW and (tok_text(i) == "function") {
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})`) }
}
}
i += 1
}
}
function lt_fn_name(i: int) -> string {
if i + 1 < ntok() { return string(tok_text(i + 1)) }
return "a function"
}
# the token closing a function's body, or -1 for one with none (an extern)
function lt_fn_end(i: int) -> int {
var j = i + 1
var paren = 0
var closed = false
while j < ntok() {
let k = tk_kind[j]
if k == LT_OP and tok_len(j) == 1 {
let c = src[tk_start[j]]
if c == '(' { paren += 1 }
if c == ')' {
paren -= 1
if paren == 0 { closed = true }
}
if c == '{' and paren == 0 and closed { return lt_close(j) }
}
if (k == LT_NL or k == LT_EOF) and paren == 0 and closed { return -1 }
j += 1
}
return -1
}
function lt_close(open: int) -> int {
var depth = 0
var j = open
while j < ntok() {
if tk_kind[j] == LT_OP and tok_len(j) == 1 {
let c = src[tk_start[j]]
if c == '{' { depth += 1 }
if c == '}' {
depth -= 1
if depth == 0 { return j }
}
}
j += 1
}
return ntok() - 1
}

View file

@ -0,0 +1,117 @@
# fmt_lint_run.ludic — L10: walking a project, and the baseline that lets a rule arrive in a
# codebase that breaks it today without letting it get worse
var bl_file: []string = new []string
var bl_rule: []string = new []string
var bl_count: []int = new []int
var lt_partial: bool = false # --check of some files: the baseline is read, never written
function lint_walk(dir: string, out: []string) -> void {
let names = Fs.list(dir)
if names == null { return }
var i = 0
while i < len(names) {
let p = `{dir}/{names[i]}`
if Fs.is_dir(p) { lint_walk(p, out) }
else { if ends_with(p, ".ludic") { push(out, p) } }
i += 1
}
}
function lt_baseline_read() -> void {
if len(lt_baseline) == 0 { return }
let t = read_file(lt_baseline)
if t == null { return }
let ls = lt_lines(string(t))
var i = 0
while i < len(ls) {
let w = lt_words(ls[i])
if len(w) == 3 {
push(bl_count, lt_atoi(w[0]))
push(bl_rule, w[1])
push(bl_file, w[2])
}
i += 1
}
}
function lt_allowed(path: string, rule: string) -> int {
var i = 0
while i < len(bl_file) {
if bl_file[i] == path and bl_rule[i] == rule { return bl_count[i] }
i += 1
}
return 0
}
# how many violations of `rule` the file has now
function lt_count(path: string, rule: string) -> int {
var n = 0
var i = 0
while i < len(lr_file) {
if lr_file[i] == path and lr_rule[i] == rule { n += 1 }
i += 1
}
return n
}
function lt_seen(fs: []string, rs: []string, path: string, rule: string) -> bool {
var i = 0
while i < len(fs) {
if fs[i] == path and rs[i] == rule { return true }
i += 1
}
return false
}
# true when every file is within its allowance; prints what is not
function lint_judge(init: bool) -> bool {
lt_baseline_read()
let pf = new []string
let pr = new []string
var i = 0
while i < len(lr_file) {
if not lt_seen(pf, pr, lr_file[i], lr_rule[i]) {
push(pf, lr_file[i])
push(pr, lr_rule[i])
}
i += 1
}
var ok = true
var lowered = false
var total = 0
let out = buf_new()
buf_puts(out, "# lint baseline: the violations each file had when a rule came in (ludic-fmt --lint); only ever lowered\n")
var p = 0
while p < len(pf) {
let now = lt_count(pf[p], pr[p])
let allow = lt_allowed(pf[p], pr[p])
total += now
if not init and now > allow {
ok = false
lt_print_pair(pf[p], pr[p], now, allow)
}
var keep = now
if not init and now > allow { keep = allow }
if not init and now < allow { lowered = true }
if keep > 0 { buf_puts(out, `{keep} {pr[p]} {pf[p]}\n`) }
p += 1
}
var b = 0
while b < len(bl_file) {
if lt_count(bl_file[b], bl_rule[b]) == 0 and bl_count[b] > 0 and not init { lowered = true }
b += 1
}
if len(lt_baseline) > 0 and (init or lowered) and not lt_partial {
let f = file_open(lt_baseline, "wb")
if f != null {
let s = buf_str(out)
file_write(f, s, cstr_len(s))
file_close(f)
}
}
print(` lint: {total} violation(s) in the baseline, {len(pf)} file/rule pair(s)`)
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]}`) }
i += 1
}
if allow > 0 { print(` ({path} may have {allow} of {rule}, and has {now})`) }
}