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:
parent
f8d811aa97
commit
42deb76c28
27 changed files with 1220 additions and 23 deletions
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
48
tools/ludic-cli/schema.ludic
Normal file
48
tools/ludic-cli/schema.ludic
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue