Merge lang/foundations (9391695) into lang/schemacode: LANGUAGE.md's command list keeps --stdin-file (R10), the code map in schema (R7) and ludic syntax (R8); selfhost/main.ludic carries all three flags; reseeded from the merged source, bootstrap-cfree fixpoint holds (298653 lines), syntax --check clean, no conflict markers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-30 00:21:47 +03:00
commit d39a27a904
81 changed files with 114027 additions and 104730 deletions

View file

@ -484,19 +484,7 @@ function grammar_alt(root: JVal, node: pointer, marker: pointer) -> []pointer {
return out
}
# keywords the self-host parser dispatches on: is_id("x") + streq(t.text, "x")
function parser_keywords() -> []pointer {
let out = new []pointer
add_parser_kw(out, "selfhost/frontend/parse.ludic")
add_parser_kw(out, "selfhost/frontend/parse_game.ludic")
return out
}
function add_parser_kw(out: []pointer, path: pointer) -> void {
let t = read_file(path)
if t == null { return }
add_lower(out, collect_after(t, "is_id(" + dq()))
add_lower(out, collect_after(t, "streq(t.text, " + dq()))
}
# the lower-case names among `src` into `out` (a set)
function add_lower(out: []pointer, src: []pointer) -> void {
var i = 0
while i < len(src) { if all_lower(src[i]) { set_add(out, src[i]) }; i += 1 }
@ -537,7 +525,6 @@ function cmd_check_vocab() -> int {
let h_widgets = table_set(h, "LUDIC_WIDGETS")
let h_builtins = c_table_names(h, "LUDIC_BUILTINS")
let h_intrinsics = c_table_names(h, "LUDIC_INTRINSICS")
let h_reserved = table_set(h, "LUDIC_KW_RESERVED")
# --- against the JetBrains lexer ---
let kt = read_file("tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/LudicTokens.kt")
@ -561,16 +548,26 @@ function cmd_check_vocab() -> int {
cmp_sets("primitive types", h_types, grammar_alt(g, "keyword", "fixed"), "ludic.tmLanguage.json")
}
# --- against the self-host parser ---
let pkw = parser_keywords()
if len(pkw) == 0 {
cv_problem("could not extract any keywords from selfhost/frontend/parse*.ludic")
} else {
cmp_unparsed("declaration keywords", h_decl, pkw, h_reserved)
cmp_unparsed("clause keywords", h_clause, pkw, h_reserved)
# reserved words the parser now accepts should be promoted
var i = 0
while i < len(h_reserved) { if set_has(pkw, h_reserved[i]) { cv_problem("LUDIC_KW_RESERVED lists a keyword the parser now accepts — promote it: " + h_reserved[i]) }; i += 1 }
# --- the keyword, type and phase tables, the docs and the parser, against the compiler's own
# vocabulary (ludicc --emit-syntax; syntax_gen.ludic) ---
if sx_vocab() == null { cv_problem("bin/ludicc has no --emit-syntax: rebuild it (ludic-dev build)") }
else {
g_sx_prob = new []pointer
let ts = sx_targets()
var ti = 0
while ti < len(ts) {
let text = read_file(ts[ti])
if text != null {
let want = sx_regenerate(ts[ti], text)
if want != null and not (want == text) { cv_problem(ts[ti] + " is behind the vocabulary - run ludic-dev syntax") }
}
sx_carries(ts[ti])
ti += 1
}
sx_docs()
sx_parser()
var pi2 = 0
while pi2 < len(g_sx_prob) { cv_problem(g_sx_prob[pi2]); pi2 += 1 }
}
if CV_N > 0 {
@ -581,16 +578,6 @@ function cmd_check_vocab() -> int {
return 0
}
# keywords in `kws` the parser never dispatches on (minus reserved) are a problem
function cmp_unparsed(label: pointer, kws: []pointer, pkw: []pointer, reserved: []pointer) -> void {
var i = 0
while i < len(kws) {
if not set_has(pkw, kws[i]) {
if not set_has(reserved, kws[i]) { cv_problem("ludic_syntax.h lists a " + label + " the selfhost parser never dispatches on: " + kws[i]) }
}
i += 1
}
}
# ============================================================================
# json/xml asset validation — replaces the python3 json.load / xml.dom checks

View file

@ -30,6 +30,7 @@ program LudicDev {
import "lsp_test.ludic"
import "forgejo.ludic"
import "checks.ludic"
import "syntax_gen.ludic"
import "docgen.ludic"
import "docgen_gen.ludic"
import "docgen_check.ludic"
@ -67,6 +68,7 @@ program LudicDev {
print(" check-docs every ```ludic doc fence parses (or is marked skip/expect-error)")
print(" check-impl every implemented feature has a docs/language page")
print(" check-vocabulary the vocabulary is in sync across grammar / lexer / header / parser")
print(" syntax [--check] write every grammar's keyword, type and phase lists from ludicc --emit-syntax")
print(" lint-asset <file> validate one editor .json / .xml asset")
print(" docs-gen [--out DIR] generate the documentation site (default build/pages)")
print(" docs-check [DIR] coverage/integrity guard over a generated docs site")
@ -108,6 +110,7 @@ program LudicDev {
if (cmd == "check-docs") { return cmd_check_docs() }
if (cmd == "check-impl") { return cmd_check_impl() }
if (cmd == "check-vocabulary") { return cmd_check_vocab() }
if (cmd == "syntax") { return cmd_syntax_gen() }
if (cmd == "lint-asset") { return cmd_lint_asset() }
if (cmd == "docs-palette") { return cmd_docs_palette() }
if (cmd == "glgen") { return cmd_glgen() }

View file

@ -286,6 +286,20 @@ function cmd_test_lsp() -> int {
lsp_notify("textDocument/didSave", `{{"textDocument":{lsp_doc(ccf)}}}`)
lsp_request("textDocument/documentSymbol", `{{"textDocument":{lsp_doc(ccf)}}}`)
# ---- the declarations a module file holds (R8): exported records, a registry with its
# attributes, states, events, actions, ports, enums, reducers and components, every member
let dsrc = "module kits uses base\n\n# a kit\nexport property Kit {\n key: string = \"\"\n @Asset(\"gltf\") model: string = \"\"\n @Node(model) grip: string = \"\"\n @Ref(Kits) spare: int = 0\n pick: fn(int, float) -> bool\n}\n\n# every kit\n@AppendOnly @ByKey\nexport registry Kits of Kit as KIT from \"kits.lres\"\nexport state KitState {\n held: int = -1\n seen: []int\n}\nexport event KitDropped { kit: int, x: float }\nexport action PickKit { kit: int }\nexport port KitWorld {\n ground: fn(float, float) -> float\n}\nenum Grade { Low, High }\nreducer KitState on PickKit(st: mut KitState, a: PickKit) {\n st.held = a.kit\n}\ncomponent Tray(k: KitState) {\n prop step: int = 1\n state open: bool = false\n on press() { }\n}\nexport function kit_weight(k: Kit) -> int { return k.spare }\n"
let dfile = tmp_path("lsp_decls.ludic")
write_file(dfile, dsrc)
lsp_open(dfile, "ludic")
let duri = lsp_doc(dfile)
let dsyms = lsp_request("textDocument/documentSymbol", `{{"textDocument":{duri}}}`)
let dref = lsp_request("textDocument/definition", `{{"textDocument":{duri},"position":{position_of(dsrc, "@Ref(Kits)", 6)}}}`)
let dhov = lsp_request("textDocument/hover", `{{"textDocument":{duri},"position":{position_of(dsrc, "@Ref(Kits)", 6)}}}`)
let dnode = lsp_request("textDocument/definition", `{{"textDocument":{duri},"position":{position_of(dsrc, "@Node(model)", 7)}}}`)
let dty = lsp_request("textDocument/definition", `{{"textDocument":{duri},"position":{position_of(dsrc, "kit_weight(k: Kit)", 14)}}}`)
let dat = lsp_request("textDocument/hover", `{{"textDocument":{duri},"position":{position_of(dsrc, "@Ref(Kits)", 1)}}}`)
# ---- workspace-wide symbol search ---------------------------------------
let ws = lsp_request("workspace/symbol", `{{"query":"Stats"}}`)
@ -456,5 +470,43 @@ function cmd_test_lsp() -> int {
check_true("a name inside a template hole resolves", Text.contains(j_get(j_get(lsp_result(thov), "contents"), "value").s, "param variant: int"))
check("find-usages reaches into template holes", loc_lines_key(lsp_result(trefs)), "1,3,4")
check_true("positions after a multi-line template stay on the right line", Text.contains(j_get(j_get(lsp_result(tafter), "contents"), "value").s, "let multi"))
# a module file's declarations, each by its own name and kind, with every member as a child
let dk = lsp_result(dsyms)
check_true("an exported property is a struct named for itself, all its fields children", lsp_sym_is(dk, "Kit", 23, 5))
check_true("a registry is a symbol of its own, with what it holds", lsp_sym_is(dk, "Kits", 18, 0) and Text.contains(lsp_sym_detail(dk, "Kits"), "of Kit as KIT"))
check_true("a state is a struct with its fields", lsp_sym_is(dk, "KitState", 23, 2))
check_true("an event and an action are events", lsp_sym_is(dk, "KitDropped", 24, 2) and lsp_sym_is(dk, "PickKit", 24, 1))
check_true("a port is an interface, a fn-typed member written out", lsp_sym_is(dk, "KitWorld", 11, 1) and Text.contains(lsp_sym_detail(dk, "KitWorld"), "fn(float, float) -> float"))
check_true("an enum and its members", lsp_sym_is(dk, "Grade", 10, 2))
check_true("a reducer is named for its state and its action", lsp_sym_is(dk, "KitState on PickKit", 12, -1))
check_true("a component holds its props, state and events", lsp_sym_is(dk, "Tray", 5, 3))
check_true("an exported function is still a function", lsp_sym_is(dk, "kit_weight", 12, -1))
check_true("go-to-definition on @Ref(Kits) reaches the registry", j_get(j_get(j_get(lsp_result(dref), "range"), "start"), "line").num == 13)
check_true("hover on @Ref(Kits) shows the registry", Text.contains(j_get(j_get(lsp_result(dhov), "contents"), "value").s, "registry Kits of Kit"))
check_true("go-to-definition on @Node(model) reaches the field it names", j_get(j_get(j_get(lsp_result(dnode), "range"), "start"), "line").num == 5)
check_true("go-to-definition on an exported record's type name", j_get(j_get(j_get(lsp_result(dty), "range"), "start"), "line").num == 3)
check_true("hover on an attribute shows its reference page", Text.contains(j_get(j_get(lsp_result(dat), "contents"), "value").s, "@Ref(Registry)"))
return report()
}
# the symbol named `name` among `syms` has LSP kind `kind` and, unless n is -1, n children
function lsp_sym_is(syms: JVal, name: pointer, kind: int, n: int) -> bool {
var i = 0
while i < len(syms.kids) {
let y = syms.kids[i]
if j_get(y, "name").s == name {
if j_get(y, "kind").num != kind { return false }
return n < 0 or len(j_get(y, "children").kids) == n
}
i += 1
}
return false
}
function lsp_sym_detail(syms: JVal, name: pointer) -> pointer {
var i = 0
while i < len(syms.kids) {
if j_get(syms.kids[i], "name").s == name { return j_get(syms.kids[i], "detail").s }
i += 1
}
return ""
}

View file

@ -32,6 +32,7 @@ program Ludic {
import "scripts.ludic"
import "deps.ludic"
import "schema.ludic"
import "syntax_cli.ludic"
import "ui_preview.ludic"
import "testpar.ludic"
import "migrate.ludic"
@ -48,11 +49,13 @@ program Ludic {
print(" compile to build/<name> (a native binary, nothing to ship beside it)")
print(" build [file] --check only check it: types, modules, uses and ports; nothing emitted")
print(" build [file] --check --diagnostics=json every error as a JSON array on stdout")
print(" build [file] --check --stdin-file PATH stdin is PATH's unsaved text (an editor's buffer)")
print(" test [file|dir...] [-j N] [-v] [--test NAME]")
print(" compile and run the project's tests (-v: every test's line)")
print(" deps [file] [--graph|--dot|--writes|--uses MOD|--check F|--baseline F]")
print(" the module graph as the compiler sees it, and how tangled it is")
print(" schema [file] [-o FILE] records, registries and their entries, consts, as JSON (for editors)")
print(" syntax [--json] [-o FILE] the language's vocabulary: keywords, declarations, types, phases, attributes, operators")
print(" migrate state [file] [--prune] [--runtime] module-level vars into states, passed as parameters (0.S); --prune drops states nothing uses, --tighten also mut nothing writes")
print(" clean remove build/")
print("")
@ -108,6 +111,7 @@ program Ludic {
if (cmd == "test") { return cmd_test() }
if (cmd == "deps") { return cmd_deps() }
if (cmd == "schema") { return cmd_schema() }
if (cmd == "syntax") { return cmd_syntax() }
if (cmd == "migrate") { return cmd_migrate() }
if (cmd == "clean") { return cmd_clean() }
if (cmd == "fmt") { return cmd_fmt() }

View file

@ -280,6 +280,11 @@ function parse_build_args(start: int) -> pointer {
else if a == "--no-maps" { g_no_maps_build = true } # --check leaves the map directories alone
else if a == "--maps" { g_maps_build = true } # ... or reads them, whatever program it checks
else if a == "--diagnostics=json" { g_check_build = true; g_diag_json_build = true } # every error, as JSON on stdout
else if a == "--stdin-file" { # stdin is that file's unsaved text: checked, never built
ai += 1
if ai < arg_count() { g_stdin_file_build = arg(ai); g_check_build = true }
else { err("ludic: --stdin-file needs a path\n"); g_argerr = true }
}
else if a == "-o" {
ai += 1
if ai < arg_count() { g_out = arg(ai) }
@ -287,7 +292,7 @@ function parse_build_args(start: int) -> pointer {
}
else if a[0] == '-' {
err(`ludic: unknown option {a}\n`)
err(" build/run take: [file] [--headless|--windowed] [-o out] [--save-temps] [--check [--diagnostics=json] [--maps|--no-maps]]\n")
err(" build/run take: [file] [--headless|--windowed] [-o out] [--save-temps] [--check [--diagnostics=json] [--stdin-file path] [--maps|--no-maps]]\n")
g_argerr = true
}
else { src = a }
@ -311,6 +316,7 @@ function output_path(entry: pointer) -> pointer {
# and nothing emitted or linked; a fraction of a build's time, for iterating on uses lines
var g_check_build: bool = false
var g_diag_json_build: bool = false # --diagnostics=json: the compiler's JSON array is the whole stdout
var g_stdin_file_build: pointer = "" # --stdin-file <path>: the compiler reads stdin where it would read <path>
var g_no_maps_build: bool = false
var g_maps_build: bool = false
# the maps are checked with the package's entry - the game - and left alone for any other program: a
@ -330,13 +336,17 @@ function bld_same_path(a: pointer, b: pointer) -> bool {
if len(y) > 2 and y[0] == '.' and y[1] == '/' { y = y[2..len(y)] }
return x == y
}
function stdin_file_flag() -> pointer {
if g_stdin_file_build == "" { return "" }
return ` --stdin-file {sh_single(g_stdin_file_build)}`
}
function check_app(src: pointer) -> int {
ensure_ludicc()
if g_diag_json_build {
if not shq(`{ludicc()} --check --diagnostics=json{no_maps_flag(src)}{title_flag()}{unsafe_flag()} {src}`) { return 1 }
if not shq(`{ludicc()} --check --diagnostics=json{no_maps_flag(src)}{title_flag()}{unsafe_flag()}{stdin_file_flag()} {src}`) { return 1 }
return 0
}
if not shq(`{ludicc()} --check{no_maps_flag(src)}{title_flag()}{unsafe_flag()} {src}`) { return 1 }
if not shq(`{ludicc()} --check{no_maps_flag(src)}{title_flag()}{unsafe_flag()}{stdin_file_flag()} {src}`) { return 1 }
print(`checked {src}`)
return 0
}

View file

@ -17,6 +17,7 @@ function selfhost_frags() -> []pointer {
push(f, "selfhost/frontend/ast.ludic")
push(f, "selfhost/frontend/lex.ludic")
push(f, "selfhost/frontend/diag.ludic")
push(f, "selfhost/frontend/stdin_file.ludic")
push(f, "selfhost/frontend/parse.ludic")
push(f, "selfhost/frontend/parse_game.ludic")
push(f, "selfhost/frontend/generics.ludic")
@ -34,6 +35,7 @@ function selfhost_frags() -> []pointer {
push(f, "selfhost/frontend/registry_open.ludic")
push(f, "selfhost/frontend/attrs.ludic")
push(f, "selfhost/frontend/attrs_node.ludic")
push(f, "selfhost/frontend/vocab.ludic")
push(f, "selfhost/frontend/resource.ludic")
push(f, "selfhost/frontend/permap.ludic")
push(f, "selfhost/frontend/permap_consts.ludic")

View file

@ -0,0 +1,86 @@
# ---- ludic syntax -------------------------------------------------------------
# The language's vocabulary as the compiler holds it (`ludicc --emit-syntax`,
# selfhost/frontend/vocab.ludic): every keyword with its role and whether it is
# reserved, every declaration's form, the built-in types, the phases, every
# attribute with where it goes, what it takes and what it means, the operators and
# the literal forms. An editor's grammar is written from it (`ludic-dev syntax`).
#
# ludic syntax one line per entry: kind, name, role
# ludic syntax --json the JSON, as the compiler writes it
# ludic syntax ... -o FILE into FILE
# the vocabulary's JSON from the compiler, or null when it cannot say
function syntax_json_text() -> pointer {
ensure_ludicc()
let js = tmp_path("syntax.json")
if not shq(`{ludicc()} --emit-syntax > {sh_single(js)} 2> {tmp_path("syntax.err")}`) { return null }
let s = read_file(js)
if s == null or slen(s) == 0 or s[0] != '{' { return null }
return s
}
function cmd_syntax() -> int {
var json = false
var dest = ""
var ai = 2
while ai < arg_count() {
let a = arg(ai)
if a == "--json" { json = true }
else if a == "-o" or a == "--out" {
if ai + 1 >= arg_count() { err(`ludic syntax: {a} needs a file\n`); return 2 }
ai += 1
dest = arg(ai)
}
else {
err(`ludic syntax: unknown argument {a}\n`)
err(" usage: ludic syntax [--json] [-o FILE]\n")
return 2
}
ai += 1
}
let s = syntax_json_text()
if s == null { err("ludic syntax: the compiler wrote no vocabulary (is it older than ludic syntax?)\n"); return 1 }
var text = s
if not json { text = syntax_lines(s) }
if dest == "" { out(text); return 0 }
if not write_file(dest, text) { err(`ludic syntax: cannot write {dest}\n`); return 1 }
return 0
}
# the plain listing, `keyword module declaration`, one entry a line - read off the JSON's own
# layout (a list opens on ` "name": [`, one entry per line after it) rather than parsed
function syntax_lines(s: pointer) -> pointer {
var o = ""
var kind = ""
let n = slen(s)
var i = 0
while i < n {
let ln = line_at(s, i)
i = i + slen(ln) + 1
if s_starts(ln, " \"") and s_contains(ln, "[") {
kind = sslice(ln, 3, s_index(ln, "\"", 3) - 1) # "keywords" -> keyword
continue
}
if not s_starts(ln, " {\"") { continue }
var name = syntax_field(ln, s_index(ln, "\": ", 0) + 3)
if kind == "attribute" { name = "@" + name }
var role = ""
let r = s_index(ln, "\"role\": ", 0)
if r >= 0 { role = syntax_field(ln, r + 8) }
let f = s_index(ln, "\"form\": ", 0)
if f >= 0 { role = syntax_field(ln, f + 8) }
o = o + kind + "\t" + name + "\t" + role + "\n"
}
return o
}
# the JSON string starting at ln[q] (its opening quote), unescaped
function syntax_field(ln: pointer, q: int) -> pointer {
var o = ""
var i = q + 1
while ln[i] != 0 and ln[i] != '"' {
if ln[i] == '\\' { i += 1 }
o = o + sslice(ln, i, i + 1)
i += 1
}
return o
}

View file

@ -0,0 +1,508 @@
# syntax_gen.ludic — one vocabulary, written into every editor's grammar and checked everywhere
# else (`ludic-dev syntax [--check]`).
#
# The vocabulary is what `ludicc --emit-syntax` prints (selfhost/frontend/vocab.ludic, held to the
# parser's own recognisers). From it, between marked lines, this writes:
#
# tools/ludic-tools/ludic_syntax.h LUDIC_KW_DECL / _CLAUSE / _STMT, LUDIC_TYPES,
# LUDIC_PHASES, LUDIC_ATTRIBUTES
# tools/ludic-tools/lsp.ludic, lsp/types.ludic the server's word tests (keyword, type, phase,
# the words that may also be names)
# tools/editors/shared/ludic.tmLanguage.json every pattern marked "ludic-dev syntax: <group>"
# (and its copy in tools/editors/vscode/syntaxes/)
# tools/editors/jetbrains/.../LudicTokens.kt LudicVocabulary's keyword, type and phase sets
# tools/editors/emacs/ludic-mode.el the keyword, type and phase lists
#
# and it checks what cannot be written: that docs/language has a page for every keyword, type,
# phase and attribute, and that the parser tests no word and reads no attribute the vocabulary
# lacks (a scan of selfhost/frontend) - nor the vocabulary lists one the parser never tests.
#
# ludic-dev syntax rewrite the marked regions from the vocabulary
# ludic-dev syntax --check fail, naming each, on a region behind the vocabulary or a gap
var g_sx: JVal = null
var g_sx_prob: []pointer = new []pointer
# the vocabulary, from bin/ludicc (null when that compiler has no --emit-syntax)
function sx_vocab() -> JVal {
if g_sx != null { return g_sx }
ensure_ludicc()
let js = tmp_path("syntax.json")
if not shq(`bin/ludicc --emit-syntax > {js} 2> {tmp_path("syntax.err")}`) { return null }
let s = read_file(js)
if s == null or slen(s) == 0 or s[0] != '{' { return null }
g_sx = json_parse(s)
return g_sx
}
# the `key` of every entry of `list` whose role is `role` ("" for all)
function sx_words(list: pointer, key: pointer, role: pointer) -> []pointer {
let out = new []pointer
let xs = j_get(g_sx, list)
var i = 0
while i < len(xs.kids) {
let e = xs.kids[i]
if slen(role) == 0 or j_get(e, "role").s == role { push(out, j_get(e, key).s) }
i += 1
}
return out
}
function sx_kw(role: pointer) -> []pointer { return sx_words("keywords", "word", role) }
# a statement is written like one, and and/or/not highlight with them
function sx_stmt() -> []pointer { return set_union(sx_kw("statement"), sx_kw("operator")) }
# every keyword that is not a literal (true, false and null lex as booleans)
function sx_all_kw() -> []pointer { return set_union(set_union(sx_kw("declaration"), sx_kw("modifier")), sx_stmt()) }
# the words the language server lets stand as a name where one is written (`var view = ...`,
# `a.model`): every word the parser does not reserve, and every declaring or modifying word
function sx_contextual() -> []pointer {
let out = new []pointer
let xs = j_get(g_sx, "keywords")
var i = 0
while i < len(xs.kids) {
let e = xs.kids[i]
let r = j_get(e, "role").s
if (j_get(e, "reserved").b == 0 and r != "constant" and r != "operator") or r == "declaration" or r == "modifier" { push(out, j_get(e, "word").s) }
i += 1
}
return out
}
function sx_attr_names() -> []pointer {
let out = new []pointer
let xs = sx_words("attributes", "name", "")
var i = 0
while i < len(xs) { push(out, "@" + xs[i]); i += 1 }
return out
}
# ---- writing a list in each file's own spelling ----
# words quoted with `q`, joined by `sep`, after `lead` on the first line and under `indent` on the
# rest, wrapped before column `width`; `last` follows the final word (a C table's terminating 0)
function sx_wrap(words: []pointer, q: pointer, sep: pointer, lead: pointer, indent: pointer, width: int, last: pointer) -> pointer {
var out = lead
var col = slen(lead)
var fresh = true
var i = 0
while i < len(words) {
var item = q + words[i] + q
if i + 1 < len(words) { item = item + s_trim(sep) } else { item = item + last }
var gap = ""
if not fresh { gap = sep_gap(sep) }
if not fresh and col + slen(gap) + slen(item) > width {
out = out + "\n" + indent
col = slen(indent)
gap = ""
}
out = out + gap + item
col = col + slen(gap) + slen(item)
fresh = false
i += 1
}
return out
}
# the spaces a separator puts between two items (", " -> " ", "," -> "")
function sep_gap(sep: pointer) -> pointer {
if s_ends(sep, " ") { return " " }
return ""
}
function sx_c_table(name: pointer, words: []pointer) -> pointer {
return "static const char* " + name + "[] = {\n" + sx_wrap(words, "\"", ",", " ", " ", 100, ", 0") + "\n};\n"
}
function sx_kt_set(name: pointer, words: []pointer) -> pointer {
return " val " + name + " = setOf(\n" + sx_wrap(words, "\"", ", ", " ", " ", 100, "") + "\n )\n"
}
function sx_el_list(name: pointer, words: []pointer) -> pointer {
return "(defconst " + name + "\n" + sx_wrap(words, "\"", " ", " '(", " ", 96, "))") + "\n"
}
# a Ludic word test: `if (w == "a") or ... { return true }`, six to a line
function sx_ludic_test(fname: pointer, words: []pointer, ind: pointer) -> pointer {
var out = ind + "function " + fname + "(w: pointer) -> bool {\n"
var i = 0
while i < len(words) {
var line = ind + " if "
var k = 0
while k < 6 and i < len(words) {
if k > 0 { line = line + " or " }
line = line + "(w == \"" + words[i] + "\")"
k += 1
i += 1
}
out = out + line + " { return true }\n"
}
return out + ind + " return false\n" + ind + "}\n"
}
# a TextMate alternation, as it is written inside a JSON string: \\b(a|b|c)\\b
function sx_tm_alt(words: []pointer) -> pointer {
var out = ""
var i = 0
while i < len(words) {
if i > 0 { out = out + "|" }
out = out + words[i]
i += 1
}
return "\\\\b(" + out + ")\\\\b"
}
# ---- the regions ----
function sx_begin_note() -> pointer { return "ludic-dev syntax: begin - generated from `ludicc --emit-syntax` (selfhost/frontend/vocab.ludic); run `ludic-dev syntax`, do not edit" }
function sx_header_region() -> pointer {
var o = "/* " + sx_begin_note() + " */\n"
o = o + sx_c_table("LUDIC_KW_DECL", sx_kw("declaration"))
o = o + sx_c_table("LUDIC_KW_CLAUSE", sx_kw("modifier"))
o = o + sx_c_table("LUDIC_KW_STMT", sx_stmt())
o = o + sx_c_table("LUDIC_TYPES", sx_words("types", "name", ""))
o = o + sx_c_table("LUDIC_PHASES", sx_words("phases", "name", ""))
o = o + sx_c_table("LUDIC_ATTRIBUTES", sx_words("attributes", "name", ""))
return o + "/* ludic-dev syntax: end */\n"
}
function sx_kotlin_region() -> pointer {
var o = " // " + sx_begin_note() + "\n"
o = o + sx_kt_set("DECL", sx_kw("declaration"))
o = o + sx_kt_set("CLAUSE", sx_kw("modifier"))
o = o + sx_kt_set("STMT", sx_stmt())
o = o + sx_kt_set("PRIMITIVES", sx_words("types", "name", ""))
o = o + sx_kt_set("PHASES", sx_words("phases", "name", ""))
return o + " // ludic-dev syntax: end\n"
}
function sx_emacs_region() -> pointer {
var o = ";; " + sx_begin_note() + "\n"
o = o + sx_el_list("ludic--declaration-keywords", sx_kw("declaration"))
o = o + sx_el_list("ludic--clause-keywords", sx_kw("modifier"))
o = o + sx_el_list("ludic--statement-keywords", sx_stmt())
o = o + sx_el_list("ludic--types", sx_words("types", "name", ""))
o = o + sx_el_list("ludic--phases", sx_words("phases", "name", ""))
return o + ";; ludic-dev syntax: end\n"
}
function sx_lsp_region() -> pointer {
var o = " # " + sx_begin_note() + "\n"
o = o + sx_ludic_test("is_type_word", sx_words("types", "name", ""), " ")
o = o + sx_ludic_test("is_phase_word", sx_words("phases", "name", ""), " ")
o = o + sx_ludic_test("is_keyword_word", sx_all_kw(), " ")
return o + " # ludic-dev syntax: end\n"
}
function sx_lsp_types_region() -> pointer {
return "# " + sx_begin_note() + "\n" + sx_ludic_test("is_contextual_word", sx_contextual(), "") + "# ludic-dev syntax: end\n"
}
# `text` with the lines from the one holding "ludic-dev syntax: begin" to the one holding
# "ludic-dev syntax: end" replaced by `region`; null when the markers are not there
function sx_splice(text: pointer, region: pointer) -> pointer {
let b = s_index(text, "ludic-dev syntax: begin", 0)
if b < 0 { return null }
let e = s_index(text, "ludic-dev syntax: end", b)
if e < 0 { return null }
var ls = b
while ls > 0 and text[ls - 1] != '\n' { ls -= 1 }
var le = e
while text[le] != 0 and text[le] != '\n' { le += 1 }
if text[le] == '\n' { le += 1 }
return sslice(text, 0, ls) + region + sslice(text, le, slen(text))
}
# the TextMate grammar: each pattern whose line says "comment": "ludic-dev syntax: <group>" has its
# "match" on that line rewritten
function sx_tm_group(group: pointer) -> pointer {
if group == "declaration" { return sx_tm_alt(sx_kw("declaration")) }
if group == "modifier" { return sx_tm_alt(sx_kw("modifier")) }
if group == "statement" { return sx_tm_alt(sx_kw("statement")) }
if group == "operator" { return sx_tm_alt(sx_kw("operator")) }
if group == "constant" { return sx_tm_alt(sx_kw("constant")) }
if group == "types" { return sx_tm_alt(sx_words("types", "name", "")) }
if group == "phases" { return sx_tm_alt(sx_words("phases", "name", "")) }
if group == "phase-clause" {
let alt = sx_tm_alt(sx_words("phases", "name", ""))
return "\\\\b(phase)\\\\s+" + sslice(alt, 3, slen(alt))
}
return null
}
function sx_tm_rewrite(text: pointer) -> pointer {
let marker = "\"comment\": \"ludic-dev syntax: "
var out = ""
var i = 0
let n = slen(text)
while i < n {
let ln = line_at(text, i)
i = i + slen(ln) + 1
var line = ln
let m = s_index(ln, marker, 0)
if m >= 0 {
let g0 = m + slen(marker)
let group = sslice(ln, g0, s_index(ln, "\"", g0))
let alt = sx_tm_group(group)
let mq = s_index(ln, "\"match\": \"", 0)
if alt == null or mq < 0 { push(g_sx_prob, `ludic.tmLanguage.json: an unknown group "{group}" or no "match" beside it`) }
else {
let a = mq + 10
var e = a
while ln[e] != 0 and ln[e] != '"' { if ln[e] == '\\' { e += 1 }; e += 1 }
line = sslice(ln, 0, a) + alt + sslice(ln, e, slen(ln))
}
}
out = out + line
if i <= n { out = out + "\n" }
}
return out
}
# the files and what each should hold; kind: "region" (between the markers) or "tm"
function sx_targets() -> []pointer {
let t = new []pointer
push(t, "tools/ludic-tools/ludic_syntax.h")
push(t, "tools/ludic-tools/lsp.ludic")
push(t, "tools/ludic-tools/lsp/types.ludic")
push(t, "tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/LudicTokens.kt")
push(t, "tools/editors/emacs/ludic-mode.el")
push(t, "tools/editors/shared/ludic.tmLanguage.json")
push(t, "tools/editors/vscode/syntaxes/ludic.tmLanguage.json")
return t
}
# what `path` should read, from what it reads now; null (and a problem) when it cannot be written
function sx_regenerate(path: pointer, text: pointer) -> pointer {
if s_ends(path, ".tmLanguage.json") { return sx_tm_rewrite(text) }
var region: pointer = null
if s_ends(path, "ludic_syntax.h") { region = sx_header_region() }
if s_ends(path, "ludic-tools/lsp.ludic") { region = sx_lsp_region() }
if s_ends(path, "lsp/types.ludic") { region = sx_lsp_types_region() }
if s_ends(path, "LudicTokens.kt") { region = sx_kotlin_region() }
if s_ends(path, "ludic-mode.el") { region = sx_emacs_region() }
let out = sx_splice(text, region)
if out == null { push(g_sx_prob, `{path}: no "ludic-dev syntax: begin" ... "end" lines to write between`) }
return out
}
# ---- what cannot be generated, checked ----
# the words a file's text quotes (every "..." in it), for "does it carry every keyword"
function sx_quoted(text: pointer) -> []pointer {
let out = new []pointer
var i = 0
let n = slen(text)
while i < n {
if text[i] == '"' {
var j = i + 1
while j < n and text[j] != '"' and text[j] != '\n' { j += 1 }
set_add(out, sslice(text, i + 1, j))
i = j + 1
} else { i += 1 }
}
return out
}
# the alternations of a TextMate grammar: every a|b|c word inside \b( )\b
function sx_tm_words(text: pointer) -> []pointer {
let out = new []pointer
let open = bx3('\\', 'b', '(')
var i = 0
while true {
let p = s_index(text, open, i)
if p < 0 { break }
let e = s_index(text, ")", p) # an alternation holds no ')'; the \\b after it is escaped twice here
if e < 0 { break }
let alts = split_pipe(sslice(text, p + 3, e))
var k = 0
while k < len(alts) { set_add(out, alts[k]); k += 1 }
i = e + 1
}
return out
}
# every word of `want` missing from `have` is a problem naming the file
function sx_need(file: pointer, what: pointer, want: []pointer, have: []pointer) -> int {
var missing = 0
var i = 0
while i < len(want) {
if not set_has(have, want[i]) {
push(g_sx_prob, `{file} is missing the {what} {want[i]}`)
missing += 1
}
i += 1
}
return missing
}
# does `file` carry every keyword, type and phase of the vocabulary? (the number missing)
function sx_carries(file: pointer) -> int {
let t = read_file(file)
if t == null { push(g_sx_prob, `{file}: cannot read it`); return 1 }
var have: []pointer = null
if s_ends(file, ".tmLanguage.json") { have = sx_tm_words(t) } else { have = sx_quoted(t) }
# the server's other file holds only the words that may stand as names
if s_ends(file, "lsp/types.ludic") { return sx_need(file, "keyword that may be a name", sx_contextual(), have) }
var n = sx_need(file, "keyword", sx_all_kw(), have)
n += sx_need(file, "type", sx_words("types", "name", ""), have)
n += sx_need(file, "phase", sx_words("phases", "name", ""), have)
return n
}
# docs/language: every keyword, type, phase and attribute is some page's token
function sx_docs() -> int {
let tokens = new []pointer
let pairs = new []pointer
collect_docs(tokens, pairs)
var n = sx_need("docs/language", "page for the keyword", set_union(sx_all_kw(), sx_kw("constant")), tokens)
n += sx_need("docs/language", "page for the type", sx_words("types", "name", ""), tokens)
n += sx_need("docs/language", "page for the phase", sx_words("phases", "name", ""), tokens)
n += sx_need("docs/language", "page for the attribute", sx_attr_names(), tokens)
return n
}
# the parser, scanned: the lower-case words it tests (is_id("w"), text == "w") and the attributes
# it reads (a == "Name", ann == "Name") across selfhost/frontend
function sx_parser_scan(words: []pointer, attrs: []pointer) -> void {
let list = capture("ls selfhost/frontend/*.ludic")
var i = 0
let n = slen(list)
while i < n {
let path = s_trim(line_at(list, i))
i = i + slen(line_at(list, i)) + 1
if slen(path) == 0 or s_ends(path, "/vocab.ludic") { continue }
let t = read_file(path)
if t == null { continue }
add_lower(words, collect_after(t, "is_id(" + dq()))
add_lower(words, collect_after(t, "is_kw(" + dq()))
add_lower(words, collect_after(t, "text == " + dq()))
sx_add_attrs(attrs, collect_after(t, " a == " + dq()))
sx_add_attrs(attrs, collect_after(t, "(a == " + dq()))
sx_add_attrs(attrs, collect_after(t, " ann == " + dq()))
sx_add_attrs(attrs, collect_after(t, "(ann == " + dq()))
}
}
function sx_add_attrs(out: []pointer, src: []pointer) -> void {
var i = 0
while i < len(src) {
let w = src[i]
if slen(w) > 0 and sx_ident(w) and not (w == "gltf") { set_add(out, w) } # "gltf": @Asset's argument
i += 1
}
}
function sx_ident(w: pointer) -> bool {
var i = 0
while w[i] != 0 {
let c = w[i]
if not ((c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9') or c == '_') { return false }
i += 1
}
return true
}
# both directions: nothing the parser tests is missing from the vocabulary, and nothing the
# vocabulary lists as read is something the parser never tests
function sx_parser() -> int {
let words = new []pointer
let attrs = new []pointer
sx_parser_scan(words, attrs)
if len(words) == 0 { push(g_sx_prob, "could not read any word the parser tests from selfhost/frontend"); return 1 }
# words the parser tests that are not the language's words: a builtin it notes, a type after `numbers`
let known = set_union(set_union(sx_all_kw(), sx_kw("constant")), sx_words("types", "name", ""))
let vattrs = sx_words("attributes", "name", "")
var n = 0
var i = 0
while i < len(words) {
let w = words[i]
if not set_has(known, w) and not set_has(vattrs, w) and not (w == "text_of") {
push(g_sx_prob, `the parser tests the word {w}, which vocab.ludic lacks - add its row and run ludic-dev syntax`)
n += 1
}
i += 1
}
i = 0
while i < len(attrs) {
if not set_has(vattrs, attrs[i]) {
push(g_sx_prob, `the parser reads @{attrs[i]}, which vocab.ludic lacks - add its row and run ludic-dev syntax`)
n += 1
}
i += 1
}
let kws = set_union(sx_all_kw(), sx_kw("constant"))
i = 0
while i < len(kws) {
if not set_has(words, kws[i]) { push(g_sx_prob, `vocab.ludic lists the keyword {kws[i]}, which the parser never tests`); n += 1 }
i += 1
}
let checked = sx_words("attributes", "name", "checked")
i = 0
while i < len(checked) {
if not set_has(attrs, checked[i]) { push(g_sx_prob, `vocab.ludic lists @{checked[i]} as read, and the parser never reads it`); n += 1 }
i += 1
}
return n
}
# ---- the command ----
function sx_report(what: pointer) -> int {
if len(g_sx_prob) == 0 { return 0 }
err(`{what}:\n`)
var i = 0
while i < len(g_sx_prob) { err(` - {g_sx_prob[i]}\n`); i += 1 }
return 1
}
# usage: ludic-dev syntax [--check]
function cmd_syntax_gen() -> int {
let check = argn(2, "") == "--check"
if sx_vocab() == null { err("ludic-dev syntax: bin/ludicc has no --emit-syntax (run ludic-dev build)\n"); return 2 }
g_sx_prob = new []pointer
let ts = sx_targets()
var changed = 0
var i = 0
while i < len(ts) {
let path = ts[i]
i += 1
let text = read_file(path)
if text == null { push(g_sx_prob, `{path}: cannot read it`); continue }
let want = sx_regenerate(path, text)
if want == null or want == text { continue }
if check { push(g_sx_prob, `{path} is behind the vocabulary - run ludic-dev syntax`) }
else {
write_file(path, want)
print(` wrote {path}`)
changed += 1
}
}
if check {
i = 0
while i < len(ts) { sx_carries(ts[i]); i += 1 }
sx_docs()
sx_parser()
return sx_report("the vocabulary and what is written from it disagree")
}
if changed == 0 { print(" every grammar already says what ludicc --emit-syntax does") }
return sx_report("ludic-dev syntax")
}
# ---- the regression suite's cases (test.ludic): one line each ----
function syntax_cases() -> void {
print("== one vocabulary: ludic syntax --json, and every grammar, the server and the docs against it ==")
if not shq("bin/ludic syntax --json > /dev/null 2>&1") or sx_vocab() == null { bad("ludic syntax --json printed no vocabulary"); return }
# the words editor tooling had lost track of (the scripting proposal's R8)
let r8 = ["module", "uses", "port", "bind", "action", "reducer", "dispatch", "registry", "def", "open", "component", "prop", "view", "alias", "friend", "unsafe", "numbers", "of", "as", "from", "attach", "detach"]
let kws = sx_all_kw()
var gone = ""
var i = 0
while i < len(r8) { if not set_has(kws, r8[i]) { gone = gone + " " + r8[i] }; i += 1 }
if slen(gone) == 0 { ok("ludic syntax --json: every keyword, among them module, uses, port, bind, action, reducer, dispatch, registry, def, component, view") }
else { bad2("ludic syntax --json lacks keywords", gone) }
let ats = sx_words("attributes", "name", "")
let r8a = ["Ref", "OneOf", "Range", "Unit", "Asset", "Color", "Node", "Clip", "Material", "Tint", "Derived", "Text", "Multiline", "Key", "AppendOnly", "ByKey", "PerMap", "Chunked", "max", "owns", "frame", "Sync", "Computed", "alloc_ok"]
gone = ""
i = 0
while i < len(r8a) { if not set_has(ats, r8a[i]) { gone = gone + " @" + r8a[i] }; i += 1 }
if slen(gone) == 0 { ok("ludic syntax --json: every attribute, with where it goes, its arguments and its doc") }
else { bad2("ludic syntax --json lacks attributes", gone) }
let ts = sx_targets()
i = 0
while i < len(ts) {
g_sx_prob = new []pointer
let path = ts[i]
i += 1
let text = read_file(path)
var behind = false
if text != null {
let want = sx_regenerate(path, text)
behind = want == null or not (want == text)
}
let missing = sx_carries(path)
if missing == 0 and not behind { ok(`{path} carries the vocabulary's words (as ludic-dev syntax writes them)`) }
else if missing > 0 { bad2(`{path} is missing words`, g_sx_prob[0]) }
else { bad2(`{path} is behind the vocabulary`, "run ludic-dev syntax") }
}
g_sx_prob = new []pointer
if sx_docs() == 0 { ok("docs/language has a page for every keyword, type, phase and attribute") }
else { bad2(`docs/language lacks {string(len(g_sx_prob))} page(s)`, g_sx_prob[0]) }
g_sx_prob = new []pointer
if sx_parser() == 0 { ok("the parser tests no word and reads no attribute the vocabulary lacks, and the reverse") }
else { bad2("the parser and vocab.ludic disagree", g_sx_prob[0]) }
}

View file

@ -99,6 +99,30 @@ function diag_json_case(path: pointer, n: int, want: pointer, label: pointer) ->
if s_contains(capture(`cat {js}`), want) { ok(label) } else { bad2(label, `no diagnostic holds [{want}]`) }
}
# --stdin-file <path>: stdin stands for <path> wherever the program opens it. A buffer that breaks an
# import reached through its barrel (spelt with `..`) is refused at the buffer's line under the file's
# usual name; a buffer that fixes a refused entry checks clean; a path nothing opens is a warning.
function stdin_file_cases() -> void {
let lbl = "--stdin-file: an unsaved buffer checked in place of its file"
let fish = "examples/modules/kinds/fishing/index.ludic"
let js = `{tmp_dir()}/sf_break.json`
let spelt = "./examples/modules/kinds/fishing/../fishing/index.ludic"
if shq(`sed 's/return fishing_st.landed/return "seven"/' {fish} | bin/ludicc examples/modules/private_kinds.ludic --check --diagnostics=json --stdin-file {spelt} > {js} 2>/dev/null`) {
bad2(lbl, "a buffer with an error checked clean"); return
}
let broke = capture(`cat {js}`)
if not s_contains(broke, `"file": "{fish}", "line": 21`) { bad2(lbl, `no error at the buffer's line 21 of {fish}: {s_trim(broke)}`); return }
let js2 = `{tmp_dir()}/sf_fix.json`
let fix = "sed -e 's/print(on + 1)/print(1)/' -e 's/half(1, 2)/half(1)/' -e 's/-> int { return \"seven\" }/-> int { return 7 }/'"
let clean = shq(`{fix} examples/rejected/every_error.ludic | bin/ludicc examples/rejected/every_error.ludic --check --diagnostics=json --stdin-file examples/rejected/every_error.ludic > {js2} 2>/dev/null`)
let said = s_trim(capture(`cat {js2}`))
if not clean or not (said == "[]") { bad2(lbl, `a buffer that fixes the saved file's errors said {said}`); return }
let js3 = `{tmp_dir()}/sf_none.json`
shq(`printf '' | bin/ludicc examples/modules/private_kinds.ludic --check --diagnostics=json --stdin-file examples/nowhere.ludic > {js3} 2>/dev/null`)
if not s_contains(capture(`cat {js3}`), "the program does not reach this file") { bad2(lbl, "a path the program never opens was not reported"); return }
ok(lbl)
}
# a feature example that consumes an in-repo controller package (packages/): same
# as feat_case, but the compiler is pointed at packages/ via LUDIC_MODULES so a
# `import "ludic.foo/bar.ludic"` resolves, and LUDIC_HOME anchors the engine
@ -1227,6 +1251,7 @@ function cmd_dev_test() -> int {
diag_json_case("rejected/every_error", 3, "\"line\": 8, \"col\": 15, \"severity\": \"error\", \"message\": \"half takes 1 argument(s) and this call gives 2\"", "--diagnostics=json: every type error as {file, line, col, severity, message}")
diag_json_case("rejected/ref_unknown", 1, "@Ref(Shops): this program has no Shops - left unresolved", "--diagnostics=json: an @Ref to a registry the program lacks is a warning beside the error")
diag_json_case("rejected/node_bad", 8, "crate.shape is \\\"cube\\\", and field Tool.shape is @OneOf(", "--diagnostics=json: every @Node / @Clip / @Material with no model field, @OneOf of the wrong kind or a row outside its words, and the targets the program lacks as warnings")
stdin_file_cases()
feat_case("lang/generics", "", "7 Crater Lake a 2 west 7 0.75 2.5 1", "generics.ludic (L5: generic records and functions, nested instances, fn-typed parameters and fields, inference from the result's slot)")
reject_case("rejected/generic_unbound", "cannot tell what T is in this call to pool_new", "a call whose type arguments nothing decides is refused")
reject_case("rejected/generic_mismatch", "words wants a Pool<string> and this is a Pool<int>", "two instances of one generic are two types")
@ -1485,6 +1510,8 @@ function cmd_dev_test() -> int {
smoke("library/cursor_capture") # #89 Input.cursor_mode compiles (no-op headless; windowed links cocoa.ll)
smoke("rendering/gl_triangle") # Gl.* (OpenGL 4.1 core) compiles headless; the run needs a GPU context
syntax_cases()
print("== the compiler and the CLI (ludicc / ludic) ==")
# ludicc comes out of the IR seed with clang alone; the CLI is then compiled
# by it, from Ludic.