feat(stdlib): namespaced standard library (issue #2)

Implement the bulk of the namespaced-stdlib proposal (workshopsoft/ludic#2):
156 namespace methods across Math, Text, List, Ease, Collide, World, Net,
Sys, Save, Mem, extended Screen, Color functions, extended Random, and Time.
All deterministic fixed-point; self-hosting (C-free bootstrap fixpoint holds).

Compiler (selfhost/):
- Math.*: sqrt/sin/cos/tan/atan2/asin/acos (fixed-point runtime prelude —
  bit-by-bit isqrt, 256-entry interpolated sine table, Ross atan2), plus
  hypot/dist/dist2/deg_to_rad/rad_to_deg/posmod/wrap/ping_pong/snapped/
  move_toward/smoothstep/lerp/remap/sign/floor/ceil/round.
- Text.* (complete): upper/lower/trim/repeat/pad, split/join/replace,
  and the libc-backed queries.
- List.* (complete): insert/remove_at/remove/sort plus the earlier ops.
- Ease.* (in/out/in_out/back/bounce) and Collide.* (rects/point_rect/
  circles/rect_circle).
- Phase 3: World/Net/Sys/Save namespaced over the bare builtins (byte-
  identical IR) and Mem.* (bytes/words/copy/fill/peek/poke).
- Screen.* extended (line/circle/fill_circle/triangle/fill_triangle via new
  runtime primitives; sprite/sprite_scaled aliases), Color.* functions,
  Random.* (value/int/sign), Time.* (frame/delta/elapsed/now — new
  game-loop frame counter).
- Fix a lexer bug: fixed-point literals with >4 fractional digits overflowed.

Docs & tooling:
- 129 new per-symbol doc pages; gen.py made data-driven (namespaces
  discovered from the docs, no hardcoded list); new check-impl.py enforces
  that every implemented namespace method / keyword / type / phase has a
  doc page, wired into `x test-tools`. Document the previously-undocumented
  keywords (break/continue/where/entry/new/public + and/or/not tokens).
- LSP: namespaced signature help (ns_method_sig) covering every namespace.

Tests: 12 new self-host/regression tests + a golden render for the drawing
primitives. All suites green (selfhost 21, regression 45, tools 29).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-30 00:26:19 +03:00
parent ff15c4e01d
commit a38195128f
235 changed files with 24676 additions and 7762 deletions

160
tools/docgen/check-impl.py Normal file
View file

@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Docs coverage, anchored to the implementation — not a hand-kept list.
The failure mode this prevents: you add a feature to the language and forget to
document it. Nobody notices until someone goes looking for the docs that were
never written. So the set of things that MUST have a doc page is read straight
from the implementation, and every one is required to have a page (and every
namespace-method page is required to correspond to something real):
* namespace methods selfhost/emit_expr.ludic `emit_ns_call` + the
`is_<ns>_ns` predicates it delegates to (Math/Text/List)
* keywords tools/ludic-tools/ludic_syntax.h LUDIC_KW_* (minus
LUDIC_KW_RESERVED, which is not-yet-implemented)
* primitive types ludic_syntax.h LUDIC_TYPES
* phases ludic_syntax.h LUDIC_PHASES
Coverage means: the symbol appears in the `tokens:` of some docs/language page.
For namespace methods that page is docs/language/<ns>/<ns>-<method>.md with
token `<Ns>.<method>`. Run: python3 tools/docgen/check-impl.py
"""
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
LANG = os.path.join(ROOT, "docs", "language")
problems = []
def read(*parts):
with open(os.path.join(ROOT, *parts), encoding="utf-8") as fh:
return fh.read()
def fn_body(text, name):
"""The source of `fn <name>` up to the next top-level `fn ` (or EOF)."""
m = re.search(r"^fn %s\b" % re.escape(name), text, re.M)
if not m:
return ""
rest = text[m.end():]
nxt = re.search(r"^fn ", rest, re.M)
return text[m.start(): m.end() + (nxt.start() if nxt else len(rest))]
def all_selfhost_source():
src = ""
d = os.path.join(ROOT, "selfhost")
for fn in sorted(os.listdir(d)):
if fn.endswith(".ludic"):
src += read("selfhost", fn) + "\n"
return src
def compiler_ns_methods():
"""{Namespace: set(method)} the compiler actually dispatches on."""
expr = read("selfhost", "emit_expr.ludic")
body = fn_body(expr, "emit_ns_call")
if not body:
problems.append("emit_ns_call not found in selfhost/emit_expr.ludic")
return {}
allsrc = all_selfhost_source()
result = {}
# each `if (ns == "X")` opens a block that runs to the next such marker
chunks = re.split(r'if \(ns == "', body)
for chunk in chunks[1:]:
mn = re.match(r'(\w+)"', chunk)
if not mn:
continue
ns = mn.group(1)
methods = set(re.findall(r'meth == "([A-Za-z_][A-Za-z0-9_]*)"', chunk))
if not methods:
# delegated form: `if is_<ns>_ns(meth) { return emit_<ns>_ns(...) }`
pm = re.search(r"is_(\w+)_ns\(meth\)", chunk)
if pm:
pred = fn_body(allsrc, "is_%s_ns" % pm.group(1))
methods = set(re.findall(r'meth == "([A-Za-z_][A-Za-z0-9_]*)"', pred))
if methods:
result.setdefault(ns, set()).update(methods)
return result
def documented():
"""(tokens set, {(ns, member): id} for namespace-method pages)."""
tokens = set()
nsmethods = {}
for cat in sorted(os.listdir(LANG)):
cdir = os.path.join(LANG, cat)
if not os.path.isdir(cdir):
continue
for fn in os.listdir(cdir):
if not fn.endswith(".md") or fn == "_section.md":
continue
meta = {}
for line in read("docs", "language", cat, fn).split("\n"):
m = re.match(r"^(\w+):\s*(.*)$", line)
if m:
meta[m.group(1)] = m.group(2).strip()
if line.strip() == "---" and meta:
break
tokens.update(meta.get("tokens", "").split())
if meta.get("kind") == "namespace-method" and meta.get("ns") and meta.get("member"):
nsmethods[(meta["ns"], meta["member"])] = meta.get("id", fn[:-3])
return tokens, nsmethods
def vocab_sets():
h = read("tools", "ludic-tools", "ludic_syntax.h")
def clist(name):
m = re.search(r"\b" + name + r"\s*\[\s*\]\s*=\s*\{(.*?)\}\s*;", h, re.S)
if not m:
problems.append("ludic_syntax.h: %s not found" % name)
return set()
return set(re.findall(r'"([^"]+)"', m.group(1)))
reserved = clist("LUDIC_KW_RESERVED")
keywords = (clist("LUDIC_KW_DECL") | clist("LUDIC_KW_CLAUSE")
| clist("LUDIC_KW_STMT")) - reserved
return keywords, clist("LUDIC_TYPES"), clist("LUDIC_PHASES")
def main():
ns_impl = compiler_ns_methods()
tokens, ns_doc = documented()
# 1) every implemented namespace method has a page with the right token
for ns, methods in sorted(ns_impl.items()):
for meth in sorted(methods):
page = os.path.join(LANG, ns.lower(), "%s-%s.md" % (ns.lower(), meth))
if (ns, meth) not in ns_doc:
problems.append("undocumented %s.%s — add %s (tokens: %s.%s)"
% (ns, meth, os.path.relpath(page, ROOT), ns, meth))
elif "%s.%s" % (ns, meth) not in tokens:
problems.append("%s.%s documented but its page lacks that `tokens:` entry" % (ns, meth))
# 2) every documented namespace method corresponds to real dispatch
for (ns, meth), sid in sorted(ns_doc.items()):
if meth not in ns_impl.get(ns, set()):
problems.append("stale doc %s: %s.%s is not dispatched by the compiler" % (sid, ns, meth))
# 3) every implemented keyword / type / phase is documented
keywords, types, phases = vocab_sets()
for label, names in (("keyword", keywords), ("type", types), ("phase", phases)):
for n in sorted(names):
if n not in tokens:
problems.append("undocumented %s: %s (no docs/language page lists it in `tokens:`)" % (label, n))
n = sum(len(v) for v in ns_impl.values())
if problems:
print("docs-vs-implementation drift:", file=sys.stderr)
for p in problems:
print(" -", p, file=sys.stderr)
return 1
print("docs cover the implementation: %d namespace methods, %d keywords, %d types, %d phases"
% (n, len(keywords), len(types), len(phases)))
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -106,9 +106,14 @@ def load_sections():
meta["related_list"] = meta.get("related", "").split()
entries.append(meta)
entries.sort(key=lambda e: (int(e.get("order", 999)), e.get("name", "")))
# A namespace section is discovered, not hardcoded: it is any section
# whose symbols are `namespace-method`s, named by their `ns:` field. So
# adding a namespace is just adding its docs dir — gen.py needs no edit.
ns_name = next((e["ns"] for e in entries
if e.get("kind") == "namespace-method" and e.get("ns")), None)
sections.append({"id": smeta.get("id", cat), "title": smeta.get("title", cat.title()),
"order": int(smeta.get("order", 999)), "blurb": sblurb,
"cat": cat, "entries": entries})
"cat": cat, "entries": entries, "ns_name": ns_name})
sections.sort(key=lambda s: (s["order"], s["title"]))
return sections
@ -142,7 +147,10 @@ def build_symbols(sections, palette):
elif kind == "annotation":
sym["annotations"][tok] = eid
if tip: sym["tips"][tok] = tip
# namespaces: Screen/Input/Random/Map from ns methods, plus Color
# namespaces are discovered from the ns methods present, plus Color; each
# namespace's tip falls back to its section blurb so no hardcoded list of
# namespaces is needed here when a new one is added.
ns_blurb = {s["ns_name"]: plain_text(s["blurb"]) for s in sections if s.get("ns_name")}
nsnames = set()
for tok in sym["nsmethods"]:
if "." in tok: nsnames.add(tok.split(".", 1)[0])
@ -150,13 +158,26 @@ def build_symbols(sections, palette):
for nn in sorted(nsnames):
sym["namespaces"][nn] = ns_page(nn)
sym["cards"][ns_page(nn)] = {"name": nn, "sig": nn + ".*", "kind": "namespace",
"tip": NS_TIP.get(nn, ""), "page": ns_page(nn) + ".html",
"tip": ns_tip(nn, ns_blurb), "page": ns_page(nn) + ".html",
"section": "Namespaces"}
return sym, items
NS_TIP = {"Screen": "The 2D drawing surface.", "Color": "The named color palette.",
"Input": "Reading the keyboard.", "Random": "The seeded, deterministic RNG.",
"Map": "The character-grid tilemap."}
def plain_text(s, limit=160):
"""The first sentence of a blurb, stripped of tags — for use as a one-line tip."""
t = re.sub(r"<[^>]+>", "", s or "").strip()
m = re.match(r"(.+?[.!?])(\s|$)", t)
if m: t = m.group(1)
return t if len(t) <= limit else t[:limit].rsplit(" ", 1)[0] + "…"
def ns_tip(nsname, ns_blurb):
"""A namespace's tip: an optional curated override, else its section blurb."""
return NS_TIP.get(nsname) or ns_blurb.get(nsname, "")
# Optional short overrides for a namespace's one-line tip. A namespace NOT
# listed here falls back to the first sentence of its section blurb (see
# ns_tip), so a newly added namespace needs no edit here. Color has no docs
# section of its own, so its override is required.
NS_TIP = {"Color": "The named color palette."}
# ---------------------------------------------------------------------------
# shared chrome
@ -238,7 +259,7 @@ def item_page(e, section, cfg, items):
<script>Ludic.highlightAll(); Ludic.installCards(); Ludic.flashTarget();</script>
</body></html>""", {
"TOPBAR": topbar(cfg),
"SECHREF": "ns-" + section["cat"] + ".html" if section["cat"] in ("screen","input","random","map") else "api.html#" + section["id"],
"SECHREF": ns_page(section["ns_name"]) + ".html" if section.get("ns_name") else "api.html#" + section["id"],
"SECTION": esc(section["title"]),
"NAME": esc(e.get("name","")),
"KIND": e.get("kind","").replace("namespace-method","method"),
@ -270,7 +291,7 @@ def ns_overview_page(nsname, section, cfg):
<script src="ludic-highlight.js"></script>
<script>Ludic.installCards(); Ludic.flashTarget();</script>
</body></html>""", {"TOPBAR": topbar(cfg), "NAME": esc(nsname), "BLURB": section["blurb"], "ROWS": rows})
return head_html(nsname + " — Ludic", NS_TIP.get(nsname,""), "item.css") + "\n<body>\n" + body
return head_html(nsname + " — Ludic", NS_TIP.get(nsname) or plain_text(section["blurb"]), "item.css") + "\n<body>\n" + body
def color_page(palette, cfg):
groups = ""
@ -416,11 +437,10 @@ def main():
continue
for e in s["entries"]:
W(e["id"] + ".html", item_page(e, s, cfg, items)); npages += 1
# namespace overview pages
by_cat = {s["cat"]: s for s in sections}
for nsname, cat in (("Screen","screen"),("Input","input"),("Random","random"),("Map","map")):
if cat in by_cat:
W(ns_page(nsname) + ".html", ns_overview_page(nsname, by_cat[cat], cfg)); npages += 1
# namespace overview pages — one per discovered namespace section
for s in sections:
if s["ns_name"]:
W(ns_page(s["ns_name"]) + ".html", ns_overview_page(s["ns_name"], s, cfg)); npages += 1
W("ns-color.html", color_page(palette, cfg)); npages += 1
# index + landing + assets
W("api.html", api_index(sections, cfg))

View file

@ -50,6 +50,20 @@
"fn-file_stdout",
"fn-file_stderr"
],
"collide": [
"collide-rects",
"collide-point_rect",
"collide-circles",
"collide-rect_circle"
],
"color": [
"color-rgb",
"color-rgba",
"color-lerp",
"color-darken",
"color-lighten",
"color-with_alpha"
],
"control": [
"kw-if",
"kw-while",
@ -60,6 +74,13 @@
"kw-state",
"kw-become"
],
"ease": [
"ease-in",
"ease-out",
"ease-in_out",
"ease-back",
"ease-bounce"
],
"ecs": [
"kw-spawn",
"kw-despawn",
@ -95,11 +116,77 @@
"input": [
"input-key"
],
"list": [
"list-len",
"list-push",
"list-clear",
"list-first",
"list-last",
"list-pop",
"list-swap",
"list-contains",
"list-index_of",
"list-reverse",
"list-insert",
"list-remove_at",
"list-remove",
"list-sort"
],
"map": [
"map-size",
"map-row",
"map-tile"
],
"math": [
"math-min",
"math-max",
"math-abs",
"math-clamp",
"math-sign",
"math-floor",
"math-ceil",
"math-round",
"math-lerp",
"math-inverse_lerp",
"math-remap",
"math-sqrt",
"math-sin",
"math-cos",
"math-tan",
"math-hypot",
"math-dist",
"math-dist2",
"math-deg_to_rad",
"math-rad_to_deg",
"math-posmod",
"math-wrap",
"math-ping_pong",
"math-snapped",
"math-move_toward",
"math-smoothstep",
"math-atan2",
"math-asin",
"math-acos"
],
"mem": [
"mem-bytes",
"mem-words",
"mem-copy",
"mem-fill",
"mem-peek",
"mem-poke"
],
"net": [
"net-send",
"net-poll",
"net-serialize",
"net-apply",
"net-owner",
"net-set_owner",
"net-is_server",
"net-is_owner",
"net-local_id"
],
"networking": [
"fn-net_send",
"fn-net_poll",
@ -134,7 +221,14 @@
"random": [
"random-range",
"random-chance",
"random-seed"
"random-seed",
"random-value",
"random-int",
"random-sign"
],
"save": [
"save-write",
"save-read"
],
"scenes": [
"kw-scene",
@ -154,7 +248,14 @@
"screen-show",
"screen-width",
"screen-height",
"screen-status"
"screen-status",
"screen-line",
"screen-circle",
"screen-fill_circle",
"screen-triangle",
"screen-fill_triangle",
"screen-sprite",
"screen-sprite_scaled"
],
"structure": [
"kw-program",
@ -172,6 +273,50 @@
"kw-ui",
"kw-enum"
],
"sys": [
"sys-arg",
"sys-arg_count",
"sys-exit",
"sys-run",
"sys-env",
"sys-read_char",
"sys-file_open",
"sys-file_read",
"sys-file_write",
"sys-file_seek",
"sys-file_tell",
"sys-file_close",
"sys-stdout",
"sys-stderr"
],
"text": [
"text-length",
"text-char_at",
"text-slice",
"text-starts_with",
"text-ends_with",
"text-contains",
"text-index_of",
"text-equals",
"text-concat",
"text-to_int",
"text-from_int",
"text-upper",
"text-lower",
"text-trim",
"text-repeat",
"text-pad_left",
"text-pad_right",
"text-split",
"text-join",
"text-replace"
],
"time": [
"time-frame",
"time-delta",
"time-elapsed",
"time-now"
],
"types": [
"type-bool",
"type-byte",
@ -185,5 +330,23 @@
"type-str",
"type-void",
"type-words"
],
"world": [
"world-get",
"world-set",
"world-has",
"world-count",
"world-size",
"world-spawn",
"world-save",
"world-load",
"world-prop_id",
"world-field_id",
"world-model_id",
"world-kind",
"world-register_prop",
"world-attach",
"world-detach",
"world-query_next"
]
}