ludic/tools/ludic-cli/docgen.ludic
Orkuncakilkaya e175619543 refactor(cli)!: split the contributor tool out of the ludic CLI
`ludic help` ended with a section titled "contributing to the toolchain itself",
listing bootstrap, reseed, docs-gen and release tasks. None of that is available
to someone who installed the language — those tasks need the repository — so the
shipped tool was advertising work its user cannot do, in a namespace they have to
read past to find `new` and `run`.

The tasks move to a second program, dev.ludic -> bin/ludic-dev, built from a
checkout and excluded from every release artifact. `ludic` keeps the project and
package commands and nothing else; `ludic dev …` now explains where the tasks
went instead of failing as an unknown command.

What this shook out: the two programs share prelude/build/project/pkg, so the
helpers each had accreted in whichever file first needed them — cc(),
ensure_ludicc, the string functions, title_case, cmd_version — moved to where
both can see them. The argument-shift indirection added for the `dev` namespace
is gone with the namespace, so commands read argv directly again.

`ludic-dev test` asserts the split rather than trusting it: the staged install
must build a project, and `ludic dev build` there must fail while naming
ludic-dev. install.sh keeps building older tags, whose bootstrap goes through
main.ludic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:15:12 +03:00

614 lines
23 KiB
Text

# docgen.ludic — the Ludic documentation-site generator and its guards, ported
# off Python (gen.py / check.py / palette.py). Three x subcommands:
#
# ludic-dev docs-palette emit selfhost/backend/stdlib/emit_color.ludic +
# docs/language/colors/palette.json from the palette
# table below (the named-colour source of truth).
# ludic-dev docs-gen [--out DIR] parse docs/language/** + docs/site/*, read the asset
# templates, emit every page + symbols.json + the
# highlighter + .nojekyll into DIR (default build/pages).
# ludic-dev docs-check [DIR] the coverage/integrity guard over a generated site.
#
# String work is over NUL-terminated byte buffers; helpers here mirror gen.py's
# html.escape / json.dumps / front-matter parsing closely enough to reproduce the
# Python generator's output byte-for-byte (verified against the oracle in ludic-dev test).
# ---- a growable byte buffer (avoids O(n^2) string building) ------------------
property Sb { data: pointer = null, len: int = 0, cap: int = 0 }
function sb_new() -> Sb {
let s = new Sb
s.cap = 256
s.data = bytes(256)
s.len = 0
return s
}
function sb_grow(s: Sb, need: int) -> void {
if s.len + need + 1 <= s.cap { return }
var nc = s.cap * 2
while nc < s.len + need + 1 { nc *= 2 }
let nd = bytes(nc)
var i = 0
while i < s.len { nd[i] = s.data[i]; i += 1 }
s.data = nd
s.cap = nc
}
function sb_puts(s: Sb, t: pointer) -> void {
let n = slen(t)
sb_grow(s, n)
var i = 0
while i < n { s.data[s.len] = t[i]; s.len += 1; i += 1 }
}
function sb_putc(s: Sb, c: int) -> void {
sb_grow(s, 1)
s.data[s.len] = c
s.len += 1
}
function sb_str(s: Sb) -> pointer {
s.data[s.len] = 0
return s.data
}
# ---- HTML escaping (html.escape) --------------------------------------------
# quote=false escapes & < > ; quote=true also escapes " and ' (Python entities:
# &amp; &lt; &gt; &quot; &#x27;). Two-pass so a big code block stays O(n).
function esc_q(s: pointer, quote: bool) -> pointer {
if s == null { return "" }
let n = slen(s)
let b = sb_new()
var i = 0
while i < n {
let c = s[i]
if c == '&' { sb_puts(b, "&amp;") }
else if c == '<' { sb_puts(b, "&lt;") }
else if c == '>' { sb_puts(b, "&gt;") }
else if quote and c == '"' { sb_puts(b, "&quot;") }
else if quote and c == '\'' { sb_puts(b, "&#x27;") }
else { sb_putc(b, c) }
i += 1
}
return sb_str(b)
}
function hesc(s: pointer) -> pointer { return esc_q(s, false) }
function hescattr(s: pointer) -> pointer { return esc_q(s, true) }
# ---- generic byte/character predicates --------------------------------------
# Python \s = [ \t\n\r\f\v]
function is_space_all(c: int) -> bool {
return c == ' ' or c == '\t' or c == '\n' or c == '\r' or c == 12 or c == 11
}
# Python \w = [A-Za-z0-9_]
function is_word(c: int) -> bool {
return (c >= '0' and c <= '9') or (c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z') or c == '_'
}
# strip only leading '\n' (Python str.lstrip("\n"))
function lstrip_nl(s: pointer) -> pointer {
var i = 0
while s[i] == '\n' { i += 1 }
return sslice(s, i, slen(s))
}
# lexicographic byte compare: <0, 0, >0
function str_cmp(a: pointer, b: pointer) -> int {
var i = 0
while a[i] != 0 and b[i] != 0 {
let ca = a[i] & 255
let cb = b[i] & 255
if ca != cb { return ca - cb }
i += 1
}
return (a[i] & 255) - (b[i] & 255)
}
function str_gt(a: pointer, b: pointer) -> bool { return str_cmp(a, b) > 0 }
# does s end with suffix suf?
function s_ends(s: pointer, suf: pointer) -> bool {
let ls = slen(s)
let lf = slen(suf)
if lf > ls { return false }
return s_starts_at(s, ls - lf, suf)
}
# split on runs of ASCII whitespace (Python str.split() with no arg)
function split_ws(s: pointer) -> []pointer {
let out = new []pointer
let n = slen(s)
var i = 0
while i < n {
while i < n and is_space_all(s[i]) { i += 1 }
var e = i
while e < n and not is_space_all(s[e]) { e += 1 }
if e > i { push(out, sslice(s, i, e)) }
i = e
}
return out
}
# stable insertion sort of a []pointer, ascending byte order (Python sorted())
function strs_sort(a: []pointer) -> void {
var i = 1
while i < len(a) {
let cur = a[i]
var j = i - 1
while j >= 0 and str_gt(a[j], cur) { a[j + 1] = a[j]; j -= 1 }
a[j + 1] = cur
i += 1
}
}
# `<code>` backtick spans: `x` -> <code>x</code> (inner text NOT escaped).
# Mirrors codeify's re.sub(r"`([^`]+)`", ...): needs >=1 non-backtick inside.
function codeify(s: pointer) -> pointer {
let n = slen(s)
let b = sb_new()
var i = 0
while i < n {
if s[i] == '`' {
var j = i + 1
while j < n and s[j] != '`' { j += 1 }
if j < n and j > i + 1 {
sb_puts(b, "<code>")
var k = i + 1
while k < j { sb_putc(b, s[k]); k += 1 }
sb_puts(b, "</code>")
i = j + 1
} else {
sb_putc(b, '`')
i += 1
}
} else {
sb_putc(b, s[i])
i += 1
}
}
return sb_str(b)
}
# ---- a Python-json.dumps-compatible JSON writer over the JVal tree ----------
function jvs(s: pointer) -> JVal { let v = jval_new(JV_STR); v.s = s; return v }
function jvi(n: int) -> JVal { let v = jval_new(JV_NUM); v.num = n; return v }
function jvo() -> JVal { return jval_new(JV_OBJ) }
function jva() -> JVal { return jval_new(JV_ARR) }
# like Python dict assignment: update in place at an existing key (keeping its
# first-insertion position, taking the new value), else append. Matters where a
# token collides across kinds (e.g. `exit`/`string`/`fixed`) in sym["tips"].
function jvput(o: JVal, k: pointer, v: JVal) -> void {
var i = 0
while i < len(o.keys) { if o.keys[i] == k { o.kids[i] = v; return }; i += 1 }
push(o.keys, k)
push(o.kids, v)
}
function jvpush(a: JVal, v: JVal) -> void { push(a.kids, v) }
function hex_lower(n: int) -> int {
if n < 10 { return 48 + n }
return 87 + n
}
function jemit_u(b: Sb, cp: int) -> void {
sb_putc(b, '\\'); sb_putc(b, 'u') # \u
sb_putc(b, hex_lower((cp >> 12) & 15))
sb_putc(b, hex_lower((cp >> 8) & 15))
sb_putc(b, hex_lower((cp >> 4) & 15))
sb_putc(b, hex_lower(cp & 15))
}
# emit the JSON-escaped content of s (no surrounding quotes). ensure_ascii: emit
# non-ASCII as \uXXXX (UTF-8 decoded, surrogate pairs for astral); else raw bytes.
function json_escape_into(b: Sb, s: pointer, ensure_ascii: bool) -> void {
let n = slen(s)
var i = 0
while i < n {
let c = s[i] & 255
if c == '"' { sb_putc(b, '\\'); sb_putc(b, '"'); i += 1 }
else if c == '\\' { sb_putc(b, '\\'); sb_putc(b, '\\'); i += 1 }
else if c == 8 { sb_putc(b, '\\'); sb_putc(b, 'b'); i += 1 }
else if c == '\t' { sb_putc(b, '\\'); sb_putc(b, 't'); i += 1 }
else if c == '\n' { sb_putc(b, '\\'); sb_putc(b, 'n'); i += 1 }
else if c == 12 { sb_putc(b, '\\'); sb_putc(b, 'f'); i += 1 }
else if c == '\r' { sb_putc(b, '\\'); sb_putc(b, 'r'); i += 1 }
else if c < ' ' {
sb_putc(b, '\\'); sb_putc(b, 'u'); sb_putc(b, '0'); sb_putc(b, '0')
sb_putc(b, hex_lower((c >> 4) & 15)); sb_putc(b, hex_lower(c & 15))
i += 1
}
else if c < 128 { sb_putc(b, c); i += 1 }
else if not ensure_ascii { sb_putc(b, c); i += 1 }
else {
var cp = 0
var nb = 1
if c < 224 { cp = c - 192; nb = 2 }
else if c < 240 { cp = c - 224; nb = 3 }
else { cp = c - 240; nb = 4 }
var k = 1
while k < nb and i + k < n { cp = cp * 64 + ((s[i + k] & 255) - 128); k += 1 }
i += nb
if cp <= 65535 { jemit_u(b, cp) }
else {
let c2 = cp - 65536
jemit_u(b, 55296 + (c2 >> 10))
jemit_u(b, 56320 + (c2 & 1023))
}
}
}
}
function json_indent(b: Sb, depth: int) -> void {
var i = 0
while i < depth { sb_putc(b, ' '); sb_putc(b, ' '); i += 1 }
}
# pretty=true reproduces json.dumps(indent=2); pretty=false the compact default
# (item sep ", ", key sep ": "). ensure_ascii toggles \uXXXX for non-ASCII.
function jwrite(b: Sb, v: JVal, pretty: bool, depth: int, ea: bool) -> void {
if v.t == JV_NULL { sb_puts(b, "null"); return }
if v.t == JV_BOOL {
if v.b == 1 { sb_puts(b, "true") } else { sb_puts(b, "false") }
return
}
if v.t == JV_NUM { sb_puts(b, string(v.num)); return }
if v.t == JV_STR { sb_putc(b, '"'); json_escape_into(b, v.s, ea); sb_putc(b, '"'); return }
if v.t == JV_ARR {
let m = len(v.kids)
if m == 0 { sb_puts(b, "[]"); return }
sb_putc(b, '[')
var i = 0
while i < m {
if pretty { sb_putc(b, '\n'); json_indent(b, depth + 1) }
jwrite(b, v.kids[i], pretty, depth + 1, ea)
if i < m - 1 { sb_putc(b, ','); if not pretty { sb_putc(b, ' ') } }
i += 1
}
if pretty { sb_putc(b, '\n'); json_indent(b, depth) }
sb_putc(b, ']')
return
}
let m = len(v.keys)
if m == 0 { sb_puts(b, "{}"); return }
sb_putc(b, '{')
var i = 0
while i < m {
if pretty { sb_putc(b, '\n'); json_indent(b, depth + 1) }
sb_putc(b, '"'); json_escape_into(b, v.keys[i], ea); sb_putc(b, '"')
sb_putc(b, ':'); sb_putc(b, ' ')
jwrite(b, v.kids[i], pretty, depth + 1, ea)
if i < m - 1 { sb_putc(b, ','); if not pretty { sb_putc(b, ' ') } }
i += 1
}
if pretty { sb_putc(b, '\n'); json_indent(b, depth) }
sb_putc(b, '}')
}
function json_dumps(v: JVal, pretty: bool, ea: bool) -> pointer {
let b = sb_new()
jwrite(b, v, pretty, 0, ea)
return sb_str(b)
}
# ============================================================================
# palette — the named-colour source of truth (ported from palette.py)
# ============================================================================
var pal_names: []pointer = new []pointer
var pal_hexes: []pointer = new []pointer # 6-digit uppercase, no prefix
var pal_groups: []pointer = new []pointer
var pal_ready: int = 0
function pal_add(nm: pointer, hx: pointer, grp: pointer) -> void {
push(pal_names, nm)
push(pal_hexes, hx)
push(pal_groups, grp)
}
function palette_init() -> void {
if pal_ready == 1 { return }
pal_ready = 1
pal_names = new []pointer
pal_hexes = new []pointer
pal_groups = new []pointer
pal_add("White", "FFFFFF", "Whites")
pal_add("Snow", "FFFAFA", "Whites")
pal_add("Ivory", "FFFFF0", "Whites")
pal_add("EggShellWhite", "F0EAD6", "Whites")
pal_add("FloralWhite", "FFFAF0", "Whites")
pal_add("SeaShell", "FFF5EE", "Whites")
pal_add("Linen", "FAF0E6", "Whites")
pal_add("AntiqueWhite", "FAEBD7", "Whites")
pal_add("OldLace", "FDF5E6", "Whites")
pal_add("Beige", "F5F5DC", "Whites")
pal_add("Cream", "FFFDD0", "Whites")
pal_add("Honeydew", "F0FFF0", "Whites")
pal_add("MintCream", "F5FFFA", "Whites")
pal_add("Azure", "F0FFFF", "Whites")
pal_add("AliceBlue", "F0F8FF", "Whites")
pal_add("GhostWhite", "F8F8FF", "Whites")
pal_add("WhiteSmoke", "F5F5F5", "Whites")
pal_add("Lavender", "E6E6FA", "Whites")
pal_add("Bone", "E3DAC9", "Whites")
pal_add("Parchment", "F1E9D2", "Whites")
pal_add("Gainsboro", "DCDCDC", "Grays")
pal_add("LightGray", "D3D3D3", "Grays")
pal_add("Silver", "C0C0C0", "Grays")
pal_add("Ash", "B2BEB5", "Grays")
pal_add("DarkGray", "A9A9A9", "Grays")
pal_add("Gray", "808080", "Grays")
pal_add("DimGray", "696969", "Grays")
pal_add("Nickel", "727472", "Grays")
pal_add("Slate", "708090", "Grays")
pal_add("SlateGray", "708090", "Grays")
pal_add("LightSlateGray", "778899", "Grays")
pal_add("Gunmetal", "2A3439", "Grays")
pal_add("Charcoal", "36454F", "Grays")
pal_add("Graphite", "1C1C1C", "Grays")
pal_add("Onyx", "353839", "Grays")
pal_add("Jet", "343434", "Grays")
pal_add("Black", "000000", "Grays")
pal_add("EerieBlack", "1B1B1B", "Grays")
pal_add("RaisinBlack", "242124", "Grays")
pal_add("Ebony", "555D50", "Grays")
pal_add("Red", "FF0000", "Reds")
pal_add("Crimson", "DC143C", "Reds")
pal_add("Scarlet", "FF2400", "Reds")
pal_add("Vermilion", "E34234", "Reds")
pal_add("FireBrick", "B22222", "Reds")
pal_add("Cinnabar", "E44D2E", "Reds")
pal_add("DarkRed", "8B0000", "Reds")
pal_add("Maroon", "800000", "Reds")
pal_add("Ruby", "E0115F", "Reds")
pal_add("Cardinal", "C41E3A", "Reds")
pal_add("IndianRed", "CD5C5C", "Reds")
pal_add("Rust", "B7410E", "Reds")
pal_add("Sangria", "92000A", "Reds")
pal_add("Redwood", "A45A52", "Reds")
pal_add("Cerise", "DE3163", "Reds")
pal_add("Amaranth", "E52B50", "Reds")
pal_add("Carmine", "960018", "Reds")
pal_add("Chestnut", "954535", "Reds")
pal_add("Brick", "CB4154", "Reds")
pal_add("TerraCotta", "E2725B", "Reds")
pal_add("Pink", "FFC0CB", "Pinks")
pal_add("LightPink", "FFB6C1", "Pinks")
pal_add("HotPink", "FF69B4", "Pinks")
pal_add("DeepPink", "FF1493", "Pinks")
pal_add("PaleVioletRed", "DB7093", "Pinks")
pal_add("Rose", "FF007F", "Pinks")
pal_add("Blush", "DE5D83", "Pinks")
pal_add("Salmon", "FA8072", "Pinks")
pal_add("LightSalmon", "FFA07A", "Pinks")
pal_add("DarkSalmon", "E9967A", "Pinks")
pal_add("Coral", "FF7F50", "Pinks")
pal_add("Watermelon", "FC6C85", "Pinks")
pal_add("Flamingo", "FC8EAC", "Pinks")
pal_add("Bubblegum", "FFC1CC", "Pinks")
pal_add("Fuchsia", "FF00FF", "Pinks")
pal_add("Magenta", "FF00FF", "Pinks")
pal_add("Mauve", "E0B0FF", "Pinks")
pal_add("Puce", "CC8899", "Pinks")
pal_add("Thistle", "D8BFD8", "Pinks")
pal_add("Orchid", "DA70D6", "Pinks")
pal_add("Orange", "FFA500", "Oranges")
pal_add("DarkOrange", "FF8C00", "Oranges")
pal_add("Tangerine", "F28500", "Oranges")
pal_add("Pumpkin", "FF7518", "Oranges")
pal_add("Apricot", "FBCEB1", "Oranges")
pal_add("Peach", "FFE5B4", "Oranges")
pal_add("Cantaloupe", "FFA62B", "Oranges")
pal_add("Amber", "FFBF00", "Oranges")
pal_add("Bronze", "CD7F32", "Oranges")
pal_add("Copper", "B87333", "Oranges")
pal_add("Marigold", "EAA221", "Oranges")
pal_add("Carrot", "ED9121", "Oranges")
pal_add("Persimmon", "EC5800", "Oranges")
pal_add("Papaya", "FF9E2C", "Oranges")
pal_add("Sunset", "FAD6A5", "Oranges")
pal_add("Yellow", "FFFF00", "Yellows")
pal_add("LightYellow", "FFFFE0", "Yellows")
pal_add("Gold", "FFD700", "Yellows")
pal_add("Goldenrod", "DAA520", "Yellows")
pal_add("Lemon", "FFF700", "Yellows")
pal_add("Canary", "FFEF00", "Yellows")
pal_add("Mustard", "FFDB58", "Yellows")
pal_add("Flax", "EEDC82", "Yellows")
pal_add("Wheat", "F5DEB3", "Yellows")
pal_add("Corn", "FBEC5D", "Yellows")
pal_add("Dandelion", "F0E130", "Yellows")
pal_add("Saffron", "F4C430", "Yellows")
pal_add("Khaki", "F0E68C", "Yellows")
pal_add("DarkKhaki", "BDB76B", "Yellows")
pal_add("Straw", "E4D96F", "Yellows")
pal_add("Brown", "8B4513", "Browns")
pal_add("SaddleBrown", "8B4513", "Browns")
pal_add("Sienna", "A0522D", "Browns")
pal_add("Chocolate", "D2691E", "Browns")
pal_add("Peru", "CD853F", "Browns")
pal_add("Tan", "D2B48C", "Browns")
pal_add("BurlyWood", "DEB887", "Browns")
pal_add("Sand", "C2B280", "Browns")
pal_add("Coffee", "6F4E37", "Browns")
pal_add("Espresso", "4B3621", "Browns")
pal_add("Mahogany", "C04000", "Browns")
pal_add("Walnut", "773F1A", "Browns")
pal_add("Umber", "635147", "Browns")
pal_add("Sepia", "704214", "Browns")
pal_add("Taupe", "483C32", "Browns")
pal_add("Fawn", "E5AA70", "Browns")
pal_add("Caramel", "C68E17", "Browns")
pal_add("Cocoa", "D2691E", "Browns")
pal_add("Hazel", "8E7618", "Browns")
pal_add("Wenge", "645452", "Browns")
pal_add("Green", "008000", "Greens")
pal_add("Lime", "00FF00", "Greens")
pal_add("LimeGreen", "32CD32", "Greens")
pal_add("LawnGreen", "7CFC00", "Greens")
pal_add("Chartreuse", "7FFF00", "Greens")
pal_add("GreenYellow", "ADFF2F", "Greens")
pal_add("SpringGreen", "00FF7F", "Greens")
pal_add("MintGreen", "98FF98", "Greens")
pal_add("SeaGreen", "2E8B57", "Greens")
pal_add("MediumSeaGreen", "3CB371", "Greens")
pal_add("ForestGreen", "228B22", "Greens")
pal_add("DarkGreen", "006400", "Greens")
pal_add("OliveDrab", "6B8E23", "Greens")
pal_add("Olive", "808000", "Greens")
pal_add("Moss", "8A9A5B", "Greens")
pal_add("Fern", "4F7942", "Greens")
pal_add("Emerald", "50C878", "Greens")
pal_add("Jade", "00A86B", "Greens")
pal_add("Malachite", "0BDA51", "Greens")
pal_add("Shamrock", "009E60", "Greens")
pal_add("Pistachio", "93C572", "Greens")
pal_add("Avocado", "568203", "Greens")
pal_add("Pine", "01796F", "Greens")
pal_add("Sage", "9CAF88", "Greens")
pal_add("Kelly", "4CBB17", "Greens")
pal_add("Hunter", "355E3B", "Greens")
pal_add("Basil", "579229", "Greens")
pal_add("Clover", "2E8B57", "Greens")
pal_add("Juniper", "6D9A79", "Greens")
pal_add("Neon", "39FF14", "Greens")
pal_add("Cyan", "00FFFF", "Cyans")
pal_add("Aqua", "00FFFF", "Cyans")
pal_add("LightCyan", "E0FFFF", "Cyans")
pal_add("PaleTurquoise", "AFEEEE", "Cyans")
pal_add("Aquamarine", "7FFFD4", "Cyans")
pal_add("Turquoise", "40E0D0", "Cyans")
pal_add("MediumTurquoise", "48D1CC", "Cyans")
pal_add("DarkTurquoise", "00CED1", "Cyans")
pal_add("Teal", "008080", "Cyans")
pal_add("DarkCyan", "008B8B", "Cyans")
pal_add("CadetBlue", "5F9EA0", "Cyans")
pal_add("Lagoon", "018E8E", "Cyans")
pal_add("Seafoam", "93E9BE", "Cyans")
pal_add("Cerulean", "007BA7", "Cyans")
pal_add("SkyBlueLight", "80DAEB", "Cyans")
pal_add("Robin", "00CCCC", "Cyans")
pal_add("Verdigris", "43B3AE", "Cyans")
pal_add("Celadon", "ACE1AF", "Cyans")
pal_add("Blue", "0000FF", "Blues")
pal_add("LightBlue", "ADD8E6", "Blues")
pal_add("PowderBlue", "B0E0E6", "Blues")
pal_add("SkyBlue", "87CEEB", "Blues")
pal_add("LightSkyBlue", "87CEFA", "Blues")
pal_add("DeepSkyBlue", "00BFFF", "Blues")
pal_add("DodgerBlue", "1E90FF", "Blues")
pal_add("CornflowerBlue", "6495ED", "Blues")
pal_add("SteelBlue", "4682B4", "Blues")
pal_add("RoyalBlue", "4169E1", "Blues")
pal_add("MediumBlue", "0000CD", "Blues")
pal_add("DarkBlue", "00008B", "Blues")
pal_add("Navy", "000080", "Blues")
pal_add("MidnightBlue", "191970", "Blues")
pal_add("Cobalt", "0047AB", "Blues")
pal_add("Sapphire", "0F52BA", "Blues")
pal_add("Denim", "1560BD", "Blues")
pal_add("Indigo", "4B0082", "Blues")
pal_add("Prussian", "003153", "Blues")
pal_add("Ultramarine", "3F00FF", "Blues")
pal_add("Periwinkle", "CCCCFF", "Blues")
pal_add("Iris", "5A4FCF", "Blues")
pal_add("Glaucous", "6082B6", "Blues")
pal_add("Zaffre", "0014A8", "Blues")
pal_add("Berry", "2E2D88", "Blues")
pal_add("Purple", "800080", "Purples")
pal_add("Violet", "EE82EE", "Purples")
pal_add("DarkViolet", "9400D3", "Purples")
pal_add("BlueViolet", "8A2BE2", "Purples")
pal_add("MediumPurple", "9370DB", "Purples")
pal_add("Amethyst", "9966CC", "Purples")
pal_add("Plum", "8E4585", "Purples")
pal_add("Eggplant", "614051", "Purples")
pal_add("Grape", "6F2DA8", "Purples")
pal_add("Wine", "722F37", "Purples")
pal_add("Mulberry", "C54B8C", "Purples")
pal_add("Lilac", "C8A2C8", "Purples")
pal_add("Wisteria", "C9A0DC", "Purples")
pal_add("Heliotrope", "DF73FF", "Purples")
pal_add("Byzantium", "702963", "Purples")
pal_add("Tyrian", "66023C", "Purples")
pal_add("RebeccaPurple", "663399", "Purples")
pal_add("Orchid2", "AF69EF", "Purples")
}
# emit selfhost/backend/stdlib/emit_color.ludic
function palette_emit_ludic(path: pointer) -> bool {
let b = sb_new()
sb_puts(b, "# ============================================================================\n")
sb_puts(b, "# emit_color.ludic — the named-color palette, resolved at compile time.\n")
sb_puts(b, "#\n")
sb_puts(b, "# `Color.Name` in a game lowers to a plain 0xRRGGBB int here: no runtime cost,\n")
sb_puts(b, "# no allocation, identical codegen to writing the hex by hand. Unknown names are\n")
sb_puts(b, "# a compile error (color_lookup returns -1, which emit_expr reports).\n")
sb_puts(b, "#\n")
sb_puts(b, "# GENERATED by `ludic-dev docs-palette` from the single source-of-truth palette table\n")
sb_puts(b, "# in tools/ludic-cli/docgen.ludic. Edit the palette there and regenerate; do not hand-edit.\n")
sb_puts(b, "# ============================================================================\n")
sb_puts(b, "\n")
sb_puts(b, "function color_lookup(name: pointer) -> int {\n")
var i = 0
while i < len(pal_names) {
sb_puts(b, ` if (name == "{pal_names[i]}") {{ return 0x{pal_hexes[i]} }}\n`)
i += 1
}
sb_puts(b, " return -1\n")
sb_puts(b, "}\n")
return write_file(path, sb_str(b))
}
# emit docs/language/colors/palette.json (json.dump indent=2, no trailing nl)
function palette_emit_json(path: pointer) -> bool {
let b = sb_new()
sb_puts(b, "{\n")
sb_puts(b, ` "count": {string(len(pal_names))},\n`)
sb_puts(b, ` "groups": [` + "\n")
# groups in first-appearance order
let seen = new []pointer
var gi = 0
var firstg = true
while gi < len(pal_groups) {
let g = pal_groups[gi]
if not set_has(seen, g) {
push(seen, g)
if not firstg { sb_puts(b, ",\n") }
firstg = false
sb_puts(b, " {\n")
sb_puts(b, ` "name": "{g}",\n`)
sb_puts(b, ` "colors": [` + "\n")
var ci = 0
var firstc = true
while ci < len(pal_names) {
if pal_groups[ci] == g {
if not firstc { sb_puts(b, ",\n") }
firstc = false
sb_puts(b, " {\n")
sb_puts(b, ` "name": "{pal_names[ci]}",\n`)
sb_puts(b, ` "hex": "{pal_hexes[ci]}"\n`)
sb_puts(b, " }")
}
ci += 1
}
sb_puts(b, "\n ]\n")
sb_puts(b, " }")
}
gi += 1
}
sb_puts(b, "\n ]\n")
sb_puts(b, "}")
return write_file(path, sb_str(b))
}
# `ludic-dev docs-palette` rewrites the two tracked outputs; `ludic-dev docs-palette --check`
# regenerates into scratch files and compares them to the tracked ones instead,
# so the drift guard judges the working tree, not git HEAD.
function cmd_docs_palette() -> int {
let check = arg_count() > 2 and arg(2) == "--check"
palette_init()
# guard: duplicate names
let seen = new []pointer
var i = 0
while i < len(pal_names) {
if set_has(seen, pal_names[i]) { err(`DUPLICATE NAME: {pal_names[i]}\n`); return 1 }
push(seen, pal_names[i])
i += 1
}
var ludic_out = "selfhost/backend/stdlib/emit_color.ludic"
var json_out = "docs/language/colors/palette.json"
if check { ludic_out = tmp_path("pal_emit_color.ludic"); json_out = tmp_path("pal_palette.json") }
if not palette_emit_ludic(ludic_out) { err("cannot write emit_color.ludic\n"); return 1 }
if not palette_emit_json(json_out) { err("cannot write palette.json\n"); return 1 }
if check {
if not shq(`cmp -s {ludic_out} selfhost/backend/stdlib/emit_color.ludic`) { err("emit_color.ludic drifted from the palette table (run: ludic-dev docs-palette)\n"); return 1 }
if not shq(`cmp -s {json_out} docs/language/colors/palette.json`) { err("palette.json drifted from the palette table (run: ludic-dev docs-palette)\n"); return 1 }
}
print(`OK {string(len(pal_names))} colors`)
return 0
}