ludic/tools/ludic-cli/docgen_check.ludic
Orkuncakilkaya aca263642d feat(cli): install in one command, and call the CLI ludic
Getting started meant cloning the repository, bootstrapping a compiler and
learning a task runner called `x`. That is a contributor's workflow handed to
everyone who wants to try the language.

Installing is now one command:

    curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh

install.sh puts a complete toolchain — compiler, CLI, engine runtime, bundled
ludic.* packages, formatter, language server — in ~/.ludic and adds it to PATH.
Prebuilt artifacts are checksum-verified; where a platform has none, or the
release predates this layout, it bootstraps from the compiler's own IR seed with
clang. The docs site publishes the script beside the pages that quote it, so the
page and the script can never come from different releases.

`x` becomes `ludic`, and the surface splits by audience. A user of the language
sees `new`, `run`, `build`, `test`, `add`, `fmt`, `lsp`, `doctor`, `upgrade`;
`ludic new` scaffolds a project that builds and plays as it stands. Everything
the toolchain repo needs moved under `ludic dev` — build, test, reseed,
bootstrap-cfree, docs-gen, release — unchanged apart from the namespace. Those
tasks read arguments one position further along, so dispatch_dev sets a shift
and commands use arg_n()/arg_total() rather than each knowing its own depth.

Release artifacts become complete install roots (bin/ beside runtime/, packages/
and VERSION) rather than bare binaries, which is what the installer unpacks.
`ludic dev test` asserts the whole shape: it stages an install, puts it on PATH
with no LUDIC_HOME, and runs new -> build -> test through it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 22:01:52 +03:00

291 lines
9.3 KiB
Text

# docgen_check.ludic — `ludic dev docs-check [DIR]`: the coverage / integrity guard over
# a generated docs site, ported from tools/docgen/check.py. Fails (exit 1) if the
# pages contract is broken, an inventory symbol lacks a source file or page, a
# token is documented on two pages, a namespace/section is split across dirs, or
# a highlighter link target was not generated. Thin (un-expanded) symbols warn.
# a key -> set-of-values bucket (for token/dir conflict detection)
property Bucket { k1: pointer = "", k2: pointer = "", vals: []pointer }
function bucket_get(bs: []Bucket, k1: pointer, k2: pointer) -> Bucket {
var i = 0
while i < len(bs) {
if bs[i].k1 == k1 and bs[i].k2 == k2 { return bs[i] }
i += 1
}
let b = new Bucket
b.k1 = k1
b.k2 = k2
b.vals = new []pointer
push(bs, b)
return b
}
function bucket_add(bs: []Bucket, k1: pointer, k2: pointer, v: pointer) -> void {
let b = bucket_get(bs, k1, k2)
var i = 0
while i < len(b.vals) { if b.vals[i] == v { return }; i += 1 }
push(b.vals, v)
}
# ", ".join(sorted(vals))
function join_sorted(vals: []pointer) -> pointer {
strs_sort(vals)
let b = sb_new()
var i = 0
while i < len(vals) {
if i > 0 { sb_puts(b, ", ") }
sb_puts(b, vals[i])
i += 1
}
return sb_str(b)
}
# parse_front: like parse_doc but body is stripped on both ends (check.py)
function parse_front(path: pointer) -> Doc {
let d = new Doc
d.m = meta_new()
let text = read_file(path)
if text == null { d.body = ""; return d }
d.body = text
if not s_starts(text, "---") { return d }
let end = s_index(text, "\n---", 3)
if end < 0 { return d }
let fm = sslice(text, 3, end)
let fn2 = slen(fm)
var j = 0
while j < fn2 {
let ln = line_at(fm, j)
j = j + slen(ln) + 1
let t = s_trim(ln)
if slen(t) == 0 { continue }
let c = s_index(ln, ":", 0)
if c < 0 { continue }
meta_put(d.m, s_trim(sslice(ln, 0, c)), s_trim(sslice(ln, c + 1, slen(ln))))
}
d.body = s_trim(sslice(text, end + 4, slen(text)))
return d
}
# count Unicode codepoints (a byte is a lead unless (b & 0xC0) == 0x80)
function cp_len(s: pointer) -> int {
let n = slen(s)
var c = 0
var i = 0
while i < n { if (s[i] & 192) != 128 { c += 1 }; i += 1 }
return c
}
function replace_all(hay: pointer, needle: pointer, repl: pointer) -> pointer {
if slen(needle) == 0 { return hay }
let b = sb_new()
let nl = slen(needle)
let n = slen(hay)
var i = 0
while i < n {
if s_starts_at(hay, i, needle) { sb_puts(b, repl); i += nl }
else { sb_putc(b, hay[i]); i += 1 }
}
return sb_str(b)
}
# strip a filename's trailing ".md" -> the fallback id (fn[:-3])
function drop_md(fn: pointer) -> pointer {
let n = slen(fn)
if n >= 3 { return sslice(fn, 0, n - 3) }
return fn
}
function cmd_docs_check() -> int {
var site = "build/pages"
if arg_total() >= 3 { site = arg_n(2) }
let problems = new []pointer
let warnings = new []pointer
# 1) pages contract
if not file_exists(`{site}/index.html`) { push(problems, "missing required file: index.html") }
if not file_exists(`{site}/api.html`) { push(problems, "missing required file: api.html") }
if not file_exists(`{site}/.nojekyll`) { push(problems, "missing required file: .nojekyll") }
# the landing page tells people to curl this; a site without it publishes a
# broken install command
if not file_exists(`{site}/install.sh`) { push(problems, "missing required file: install.sh") }
# 2) coverage + thin detection over docs/language/**
let id2src = meta_new()
let thin = new []pointer
let cats = list_sorted("docs/language")
var ci = 0
while ci < len(cats) {
let cat = cats[ci]
ci += 1
let cdir = "docs/language/" + cat
if not is_dir(cdir) { continue }
let files = list_sorted(cdir)
var fi = 0
while fi < len(files) {
let fn = files[fi]
fi += 1
if not s_ends(fn, ".md") { continue }
if fn == "_section.md" { continue }
let d = parse_front(cdir + "/" + fn)
let sid = meta_get(d.m, "id", drop_md(fn))
meta_put(id2src, sid, cdir + "/" + fn)
# thin: drop a Parameters: block (to end, case-insensitive) + the tip line
let tipline = meta_get(d.m, "tip", "")
let low = lower_ascii(d.body)
var btext = d.body
let pp = s_index(low, "parameters:", 0)
if pp >= 0 { btext = sslice(d.body, 0, pp) }
btext = s_trim(btext)
if slen(tipline) > 0 { btext = s_trim(replace_all(btext, tipline, "")) }
if cp_len(btext) < 40 { push(thin, sid) }
}
}
# coverage against the authoritative inventory
let invtext = read_file("tools/docgen/inventory.json")
if invtext != null {
let inv = json_parse(invtext)
if inv.t == JV_OBJ {
var k = 0
while k < len(inv.keys) {
let cat = inv.keys[k]
let ids = inv.kids[k]
k += 1
if ids.t != JV_ARR { continue }
var m = 0
while m < len(ids.kids) {
let sid = ids.kids[m].s
m += 1
if meta_get(id2src, sid, "") == "" { push(problems, `no source file for inventory symbol: {sid} ({cat})`) }
else if not file_exists(`{site}/{sid}.html`) { push(problems, `no generated page for symbol: {sid}.html`) }
}
}
}
}
# 2b/2c) duplicate tokens + one-dir-per-namespace/section
let tok2ids = new []Bucket # k1=kind, k2=tok -> ids
let ns2dirs = new []Bucket # k1="", k2=ns -> dirs
let id2dirs = new []Bucket # k1="", k2=section id -> dirs
let title2dirs = new []Bucket # k1="", k2=lower(title) -> dirs
ci = 0
while ci < len(cats) {
let cat = cats[ci]
ci += 1
let cdir = "docs/language/" + cat
if not is_dir(cdir) { continue }
let secp = cdir + "/_section.md"
if file_exists(secp) {
let sd = parse_front(secp)
bucket_add(id2dirs, "", meta_get(sd.m, "id", cat), cat)
bucket_add(title2dirs, "", lower_ascii(s_trim(meta_get(sd.m, "title", ""))), cat)
}
let files = list_sorted(cdir)
var fi = 0
while fi < len(files) {
let fn = files[fi]
fi += 1
if not s_ends(fn, ".md") { continue }
if fn == "_section.md" { continue }
let d = parse_front(cdir + "/" + fn)
let kind = meta_get(d.m, "kind", "")
let id = meta_get(d.m, "id", "")
let toks = split_ws(meta_get(d.m, "tokens", ""))
var ti = 0
while ti < len(toks) { bucket_add(tok2ids, kind, toks[ti], id); ti += 1 }
let ns = meta_get(d.m, "ns", "")
if slen(ns) > 0 { bucket_add(ns2dirs, "", ns, cat) }
}
}
var bi = 0
while bi < len(tok2ids) {
let b = tok2ids[bi]
bi += 1
if len(b.vals) > 1 { push(problems, `token '{b.k2}' ({b.k1}) documented on multiple pages: {join_sorted(b.vals)}`) }
}
bi = 0
while bi < len(ns2dirs) {
let b = ns2dirs[bi]
bi += 1
if len(b.vals) > 1 { push(problems, `namespace '{b.k2}' documented from multiple dirs: {join_sorted(b.vals)}`) }
}
bi = 0
while bi < len(id2dirs) {
let b = id2dirs[bi]
bi += 1
if len(b.vals) > 1 { push(problems, `section id '{b.k2}' declared by multiple dirs: {join_sorted(b.vals)}`) }
}
bi = 0
while bi < len(title2dirs) {
let b = title2dirs[bi]
bi += 1
if slen(b.k2) > 0 and len(b.vals) > 1 { push(problems, `section title '{b.k2}' shared by multiple dirs: {join_sorted(b.vals)}`) }
}
# 3) thin warning
if len(thin) > 0 {
strs_sort(thin)
let b = sb_new()
var i = 0
while i < len(thin) and i < 12 {
if i > 0 { sb_puts(b, ", ") }
sb_puts(b, thin[i])
i += 1
}
var tail = ""
if len(thin) > 12 { tail = " …" }
push(warnings, `{string(len(thin))} symbols still have only seed text: {sb_str(b)}{tail}`)
}
# 4) highlighter targets exist
let sjtext = read_file(`{site}/symbols.json`)
if sjtext != null {
let sj = json_parse(sjtext)
let hl = j_get(sj, "highlight")
let targets = new []pointer
collect_targets_str(j_get(hl, "keywords"), targets)
collect_targets_str(j_get(hl, "types"), targets)
collect_targets_str(j_get(hl, "phases"), targets)
collect_targets_str(j_get(hl, "annotations"), targets)
collect_targets_id(j_get(hl, "builtins"), targets)
collect_targets_id(j_get(hl, "nsmethods"), targets)
collect_targets_str(j_get(hl, "namespaces"), targets)
strs_sort(targets)
var i = 0
while i < len(targets) {
let tgt = targets[i]
i += 1
if not file_exists(`{site}/{tgt}.html`) { push(problems, `highlighter links to {tgt}.html but it was not generated`) }
}
}
# report
var wi = 0
while wi < len(warnings) { print(` warning: {warnings[wi]}`); wi += 1 }
if len(problems) > 0 {
print("docs check FAILED:")
var pi = 0
while pi < len(problems) { print(` - {problems[pi]}`); pi += 1 }
return 1
}
print(`docs check OK: {site} ({string(len(id2src.keys))} symbols documented)`)
return 0
}
# add every string value of an object's members (set semantics via the caller)
function collect_targets_str(o: JVal, out: []pointer) -> void {
if o.t != JV_OBJ { return }
var i = 0
while i < len(o.kids) {
let v = o.kids[i]
i += 1
if v.t == JV_STR { set_add(out, v.s) }
}
}
# add each member value's ["id"] (builtins / nsmethods)
function collect_targets_id(o: JVal, out: []pointer) -> void {
if o.t != JV_OBJ { return }
var i = 0
while i < len(o.kids) {
let v = o.kids[i]
i += 1
let id = j_get(v, "id")
if id.t == JV_STR { set_add(out, id.s) }
}
}