schema: ludicc --emit-schema / ludic schema, --check --diagnostics=json, and editor attributes

--emit-schema FILE writes the compiler's resolved view once the program type-checks: every
record (fields, types, defaults as written, docs, places, attributes), every registry with its
entries in their final order after the open-registry merge (key, constant, index, file:line:col
of the entry and of each field value, and which file contributed which keys), every const, and
the zero-argument functions a fn value can name. Deterministic, schema_version 1; the runtime is
left out. `ludic schema [file] [-o FILE]` wraps it.

--check --diagnostics=json prints every error as one JSON array on stdout: the checker's and the
module rules' all, a parse or lowering error as the last. Tokens and nodes now carry a column.

Fields take several @attributes; @Ref(Registry), @OneOf(PREFIX_), @Range(lo, hi), @Unit("..."),
@Asset("..."), @Color on a field and @AppendOnly / @ByKey on a registry change nothing but go into
the schema, and @Ref naming no registry is an error (every one reported). Fixtures:
examples/lang/attributes.ludic, examples/rejected/ref_unknown.ludic, cases in ludic-dev test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 18:06:54 +03:00
parent f8d811aa97
commit 42deb76c28
27 changed files with 1220 additions and 23 deletions

View file

@ -31,6 +31,7 @@ program Ludic {
import "bundle.ludic"
import "scripts.ludic"
import "deps.ludic"
import "schema.ludic"
import "testpar.ludic"
import "migrate.ludic"
@ -45,10 +46,12 @@ program Ludic {
print(" build [file] [--headless] [-o out]")
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(" 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(" 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("")
@ -102,6 +105,7 @@ program Ludic {
if (cmd == "run") { return cmd_run() }
if (cmd == "test") { return cmd_test() }
if (cmd == "deps") { return cmd_deps() }
if (cmd == "schema") { return cmd_schema() }
if (cmd == "migrate") { return cmd_migrate() }
if (cmd == "clean") { return cmd_clean() }
if (cmd == "fmt") { return cmd_fmt() }

View file

@ -277,6 +277,7 @@ function parse_build_args(start: int) -> pointer {
else if a == "--save-temps" { g_save = true }
else if a == "--unsafe" { g_unsafe_build = true }
else if a == "--check" { g_check_build = true }
else if a == "--diagnostics=json" { g_check_build = true; g_diag_json_build = true } # every error, as JSON on stdout
else if a == "-o" {
ai += 1
if ai < arg_count() { g_out = arg(ai) }
@ -284,7 +285,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]\n")
err(" build/run take: [file] [--headless|--windowed] [-o out] [--save-temps] [--check [--diagnostics=json]]\n")
g_argerr = true
}
else { src = a }
@ -307,8 +308,13 @@ function output_path(entry: pointer) -> pointer {
# --check: parse, types, modules, uses, ports - everything the compiler checks before it emits -
# 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
function check_app(src: pointer) -> int {
ensure_ludicc()
if g_diag_json_build {
if not shq(`{ludicc()} --check --diagnostics=json{title_flag()}{unsafe_flag()} {src}`) { return 1 }
return 0
}
if not shq(`{ludicc()} --check{title_flag()}{unsafe_flag()} {src}`) { return 1 }
print(`checked {src}`)
return 0

View file

@ -0,0 +1,48 @@
# ---- ludic schema -------------------------------------------------------------
# What an editor of a program's data needs, from the compiler's own view: every record with its
# fields, types, defaults, docs and attributes (@Ref, @Range, ...), every registry with its entries
# in their final order and where each is written, every const, and the functions a `fn` value can
# name. The compiler writes it (`ludicc --emit-schema`, selfhost/backend/emit_schema.ludic) once the
# program has type-checked.
#
# ludic schema [file] the JSON on stdout
# ludic schema [file] -o FILE into FILE
# ludic schema --unsafe ... a program whose own files write `unsafe`
function cmd_schema() -> int {
var src = ""
var dest = ""
var ai = 2
while ai < arg_count() {
let a = arg(ai)
if a == "-o" or a == "--out" {
if ai + 1 >= arg_count() { err(`ludic schema: {a} needs a file\n`); return 2 }
ai += 1
dest = arg(ai)
}
else if a == "--unsafe" { g_unsafe_build = true }
else if a[0] == '-' {
err(`ludic schema: unknown option {a}\n`)
err(" usage: ludic schema [file] [-o FILE] [--unsafe]\n")
return 2
}
else { src = a }
ai += 1
}
let entry = find_entry(src)
if entry == "" { return no_entry() }
ensure_ludicc()
var target = dest
if target == "" { target = tmp_path("schema.json") }
let errs = tmp_path("schema.err")
if not shq(`{ludicc()} --headless{unsafe_flag()} {sh_single(entry)} --emit-schema {sh_single(target)} 2> {errs}`) {
err(`ludic schema: {entry} did not compile:\n`)
err(capture(`grep -i error {errs} | head -20`))
return 1
}
if dest == "" {
let s = read_file(target)
if s == null { err("ludic schema: the compiler wrote no schema (is it older than ludic schema?)\n"); return 1 }
out(s)
}
return 0
}

View file

@ -12,7 +12,7 @@
# the built-in commands a hook can wrap (every user command that does work)
function is_hookable(cmd: pointer) -> bool {
return (cmd == "build") or (cmd == "run") or (cmd == "test") or (cmd == "deps") or (cmd == "migrate") or (cmd == "bundle") or (cmd == "pack") or (cmd == "clean") or (cmd == "fmt") or (cmd == "get") or (cmd == "add") or (cmd == "update") or (cmd == "verify") or (cmd == "vendor") or (cmd == "assets") or (cmd == "build-lib")
return (cmd == "build") or (cmd == "run") or (cmd == "test") or (cmd == "deps") or (cmd == "schema") or (cmd == "migrate") or (cmd == "bundle") or (cmd == "pack") or (cmd == "clean") or (cmd == "fmt") or (cmd == "get") or (cmd == "add") or (cmd == "update") or (cmd == "verify") or (cmd == "vendor") or (cmd == "assets") or (cmd == "build-lib")
}
# the directory this `ludic` binary lives in, with no trailing slash

View file

@ -16,6 +16,7 @@ function selfhost_frags() -> []pointer {
push(f, "selfhost/support/io.ludic")
push(f, "selfhost/frontend/ast.ludic")
push(f, "selfhost/frontend/lex.ludic")
push(f, "selfhost/frontend/diag.ludic")
push(f, "selfhost/frontend/parse.ludic")
push(f, "selfhost/frontend/parse_game.ludic")
push(f, "selfhost/frontend/generics.ludic")
@ -31,6 +32,7 @@ function selfhost_frags() -> []pointer {
push(f, "selfhost/frontend/registry.ludic")
push(f, "selfhost/frontend/registry_finish.ludic")
push(f, "selfhost/frontend/registry_open.ludic")
push(f, "selfhost/frontend/attrs.ludic")
push(f, "selfhost/frontend/resource.ludic")
push(f, "selfhost/frontend/defaults.ludic")
push(f, "selfhost/frontend/view.ludic")
@ -86,6 +88,7 @@ function selfhost_frags() -> []pointer {
push(f, "selfhost/backend/emit_call.ludic")
push(f, "selfhost/backend/emit_fnval.ludic")
push(f, "selfhost/backend/emit_vis.ludic")
push(f, "selfhost/backend/emit_schema.ludic")
push(f, "selfhost/check/check_state.ludic")
push(f, "selfhost/check/check_index.ludic")
push(f, "selfhost/check/check_types.ludic")

View file

@ -53,6 +53,27 @@ function reject_count(path: pointer, n: int, label: pointer) -> void {
if (got == string(n)) { ok(label) } else { bad2(label, `reported {got} error(s), not {n}`) }
}
# --emit-schema: the example's schema holds `want` (a line of its JSON, exactly as written)
function schema_case(path: pointer, want: pointer, label: pointer) -> void {
let js = `{tmp_dir()}/sc_{flat(path)}.json`
if not shq(`bin/ludicc examples/{path}.ludic --emit-schema {js} 2>{tmp_dir()}/sc.err`) {
bad2(label, capture_line(`grep -i error {tmp_dir()}/sc.err | head -1`))
return
}
if s_contains(capture(`cat {js}`), want) { ok(label) } else { bad2(label, `the schema has no [{want}]`) }
}
# --check --diagnostics=json: refused, with exactly `n` diagnostics on stdout, one of them holding `want`
function diag_json_case(path: pointer, n: int, want: pointer, label: pointer) -> void {
let js = `{tmp_dir()}/dj_{flat(path)}.json`
if shq(`bin/ludicc examples/{path}.ludic --check --diagnostics=json > {js} 2>/dev/null`) {
bad2(label, "checked clean, and should have been refused")
return
}
let got = capture_line(`grep -c '"severity": "error"' {js}`)
if not (got == string(n)) { bad2(label, `reported {got} error(s), not {n}`); return }
if s_contains(capture(`cat {js}`), want) { ok(label) } else { bad2(label, `no diagnostic holds [{want}]`) }
}
# 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
@ -1177,6 +1198,8 @@ function cmd_dev_test() -> int {
reject_case("rejected/text_plus_number", "text joins text only", "text plus a number is refused")
reject_case("rejected/math_into_text", "field a1 of Note wants a string and this is an int", "a Math.* result has its type: an int from Math.max is not text")
reject_count("rejected/every_error", 3, "every type error in a program is reported at once, not only the first")
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", 2, "@Ref(Shops) names no registry", "--diagnostics=json: every @Ref that names no registry")
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")
@ -1207,6 +1230,10 @@ function cmd_dev_test() -> int {
reject_case("rejected/alias_twice", "Trail.length is declared twice", "a namespace method is declared once")
feat_case("lang/registry", "", "3 Crate true 2 fire_ring -1", "registry.ludic (L8: a registry filled by def declarations - index constants, a count, the key, a lookup)")
reject_case("rejected/registry_unknown", "there is no registry Tables", "a def names a registry that exists")
feat_case("lang/attributes", "", "2 1 Lantern 2", "attributes.ludic (editor attributes: @Ref, @OneOf, @Range, @Unit, @Asset, @Color, @AppendOnly, @ByKey - no effect on the program)")
reject_case("rejected/ref_unknown", "field Tool.seller: @Ref(Vendorz) names no registry", "@Ref names a registry that exists")
schema_case("lang/attributes", "{\"name\": \"weight\", \"type\": \"float\", \"default\": \"1.0\", \"file\": \"examples/lang/attributes.ludic\", \"line\": 24, \"col\": 33, \"attributes\": [{\"name\": \"Range\", \"args\": [0, 20.5]}, {\"name\": \"Unit\", \"args\": [\"kg\"]}]", "--emit-schema: a field's type, default, place and attributes")
schema_case("lang/attributes", "{\"key\": \"lantern\", \"constant\": \"TL_LANTERN\", \"index\": 1, \"file\": \"examples/lang/data/attr_tools.lres\", \"line\": 3, \"col\": 1", "--emit-schema: a resource entry's constant, index and place")
reject_case("rejected/registry_twice", "def Furnishings crate is declared twice", "a registry's key is declared once")
reject_case("rejected/registry_field", "Furnishing has no field colour", "a def's fields are the record's")
feat_case("modules/open_registry", "", "clock animals weather / 2 2 3", "open_registry.ludic (L8+L3: an open registry takes other modules' defs; its own entries first, then by module name)")