From 42deb76c284daa1d7432ca4d9386886ddfe0391e Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 29 Sep 2026 18:06:54 +0300 Subject: [PATCH] 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 --- COMPILING.md | 2 + LANGUAGE.md | 53 +++ changes/editor-schema.md | 14 + examples/lang/attributes.ludic | 38 ++ examples/lang/data/attr_tools.lres | 8 + examples/rejected/ref_unknown.ludic | 10 + selfhost/backend/emit_float.ludic | 4 + selfhost/backend/emit_frame.ludic | 10 +- selfhost/backend/emit_schema.ludic | 589 ++++++++++++++++++++++++++++ selfhost/backend/emit_vis.ludic | 10 +- selfhost/check/check_env.ludic | 7 + selfhost/check/check_stmt.ludic | 1 + selfhost/frontend/ast.ludic | 5 + selfhost/frontend/attrs.ludic | 164 ++++++++ selfhost/frontend/diag.ludic | 95 +++++ selfhost/frontend/lex.ludic | 16 +- selfhost/frontend/parse.ludic | 39 +- selfhost/frontend/parse_game.ludic | 36 +- selfhost/frontend/registry.ludic | 23 +- selfhost/frontend/resource.ludic | 9 +- selfhost/main.ludic | 18 +- tools/ludic-cli/main.ludic | 4 + tools/ludic-cli/project.ludic | 8 +- tools/ludic-cli/schema.ludic | 48 +++ tools/ludic-cli/scripts.ludic | 2 +- tools/ludic-cli/selfhost.ludic | 3 + tools/ludic-cli/test.ludic | 27 ++ 27 files changed, 1220 insertions(+), 23 deletions(-) create mode 100644 changes/editor-schema.md create mode 100644 examples/lang/attributes.ludic create mode 100644 examples/lang/data/attr_tools.lres create mode 100644 examples/rejected/ref_unknown.ludic create mode 100644 selfhost/backend/emit_schema.ludic create mode 100644 selfhost/frontend/attrs.ludic create mode 100644 selfhost/frontend/diag.ludic create mode 100644 tools/ludic-cli/schema.ludic diff --git a/COMPILING.md b/COMPILING.md index 5f828023..66aafd04 100644 --- a/COMPILING.md +++ b/COMPILING.md @@ -73,6 +73,8 @@ point at a different LLVM toolchain if you have one. | a windowed native executable | `ludicc game.ludic -o build/game` | | a headless executable | `ludicc game.ludic --headless -o build/game` | | the IR, to read | `ludicc src.ludic --emit-llvm -o src.ll` | +| the schema an editor reads (records, registries and their entries, consts) | `ludicc src.ludic --emit-schema schema.json` | +| every error, as a JSON array on stdout | `ludicc src.ludic --check --diagnostics=json` | | a shared library † | `ludicc lib.ludic --shared -o build/liblib.dylib` | | a game that runs in a browser † | `ludicc game.ludic --target wasm32-unknown-unknown -o build/web/game.wasm` | | an object file † | `ludicc src.ludic -c -o src.o` | diff --git a/LANGUAGE.md b/LANGUAGE.md index 68954093..0f8de4c8 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -625,6 +625,49 @@ int and this is a string`). Its entries are defs of the module that wrote the li must be open (and exported) to it, and they take that module's place in the order: the declaring module's entries, then each other module's by module name, and within a file, file order. +### Editor attributes and the schema (`@Ref`, `@Range`, ..., `ludic schema`) + +A field can say what an editor of the data should offer for it, and a registry how its entries may +change, on the same `@` a field's `@max(64)` is written with - one or several, on the field's line or +the lines above it: + +```ludic +# doc-check: skip — the registries it names are declared elsewhere +property Tool { + @Ref(Vendors) seller: int = 0 # an index into that registry: its entries are offered + @OneOf(GR_) grade: int = 0 # one of the constants whose names start GR_ + @Range(0, 20.5) @Unit("kg") weight: float = 1.0 + @Asset("gltf") model: string = "" # a file of that kind (any string) + @Color tint: int = 0 +} +@AppendOnly @ByKey +registry Tools of Tool as TL from "data/tools.lres" +``` + +They change nothing the program does. One is checked: `@Ref` must name a registry the program +declares, and a field whose `@Ref` names none is an error naming the field and the registry (every +such field is reported, not only the first). + +`ludicc app.ludic --emit-schema out.json` (or `ludic schema [file] [-o out.json]`) writes what the +compiler resolved, once the types are checked and every open registry has its entries, as one JSON +object with `"schema_version": 1`: + +- `records` - every `property`, `state` and `event`: its module, file, line and column, its doc + comment (the comment lines above it, else the one ending its line), and its fields, each with its + type as text, its default as written (or null), its doc, its place and its attributes + (`[{"name": "Range", "args": [0, 20.5]}]`); +- `registries` - every registry: its record, prefix, resource file, whether it is open, its own + attributes, and its entries in their final index order (`{"key": "axe", "constant": "TL_AXE", + "index": 0, "file": ..., "line": ..., "col": ..., "fields": [{"name", "value", "file", "line", + "col"}]}`), with `contributors`: which resource file or file of `def`s brought which keys in; +- `consts` - every const: its type, its value as written, its module and doc; +- `functions` - every function a `fn` value can name with no arguments (the states it takes are the + runtime's to supply): its return type, `fn_type` and signature. + +Each list is sorted by name (then file and line), a registry's entries are in index order, and the +paths are the ones the compiler read, so two runs over the same source write the same file. The +engine's runtime is left out. + ### Default parameters, and calls that name what they change A parameter can have a default, and a call leaves out what it does not change - the last ones when @@ -2055,6 +2098,8 @@ ludic test tests/math.ludic --test adds # just the test named "adds" (-v: every ludic test -j 4 # four tests at once (default: one per CPU) ludic test packages/ludic.base # the test programs under a directory (a package's) ludic build --check # every check a build makes, nothing written +ludic build --check --diagnostics=json # the same, every error as a JSON array on stdout +ludic schema -o build/schema.json # records, registries, entries and consts, for editors ludic deps # how tangled the modules are, as the compiler resolved them ludic deps --check tests/deps-baseline.txt # fail when a number rose (--baseline FILE writes them) @@ -2068,6 +2113,14 @@ parse, the types, `export`, `uses` and layers, ports and binds, registries, and unknown name) - and writes nothing: no IR, no link. On Maroon Lake it takes about four seconds, for iterating on `uses` lines. +With `--diagnostics=json` (`ludicc --check --diagnostics=json`) nothing goes to stderr: stdout is one +JSON array of `{"file", "line", "col", "severity", "message"}` (`[]` when the program is clean; a +`col` of 0 is a place known only by its line), and the exit status is 1 when any is an error. Every +type error is in it, every `@Ref` naming no registry, and every module rule broken (`export`, +`uses`); an error the parser or the code writer cannot go on from - a token it did not expect, an +unknown name while lowering, a def into a registry that does not exist - ends the array, and type +errors end it before the module rules are looked at. + `ludic deps` compiles the program (the package's entry, or a file) with the compiler recording every reference its visibility pass resolves - from the module it is written in to the module of what it names - and every assignment to another module's global. It prints five numbers: `modules` (the diff --git a/changes/editor-schema.md b/changes/editor-schema.md new file mode 100644 index 00000000..0a39d301 --- /dev/null +++ b/changes/editor-schema.md @@ -0,0 +1,14 @@ +bump: minor +type: feat +**A schema for editors, and every error as JSON.** `ludicc --emit-schema out.json` (and `ludic +schema [file] [-o FILE]`) writes what the compiler resolved once the program type-checks: every +record with its fields' types, defaults, doc comments and places; every registry with its record, +prefix, resource file, openness and 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 brought +which entries in); every const; and the functions a `fn` value can name with no arguments. +Deterministic, `"schema_version": 1`. Fields and registries carry editor attributes on the existing +`@` syntax - `@Ref(Registry)`, `@OneOf(PREFIX_)`, `@Range(lo, hi)`, `@Unit("m/s")`, `@Asset("gltf")`, +`@Color`, and `@AppendOnly` / `@ByKey` on a registry - which change nothing but go into the schema; +`@Ref` naming no registry is an error. A field may now carry several attributes. `ludicc --check +--diagnostics=json` (`ludic build --check --diagnostics=json`) prints every error as one JSON array +of `{file, line, col, severity, message}` on stdout; tokens and nodes now know their column. diff --git a/examples/lang/attributes.ludic b/examples/lang/attributes.ludic new file mode 100644 index 00000000..37a64fbd --- /dev/null +++ b/examples/lang/attributes.ludic @@ -0,0 +1,38 @@ +# attributes.ludic — editor metadata on the @-syntax: which registry a field indexes, which constants +# it takes, its range, its unit, the kind of file it names, that it is a colour, and a registry's own +# rules (append only, saved by key). None of them changes what the program does: `ludicc +# --emit-schema` carries them to an editor with every record, registry entry and const. One is +# checked - @Ref must name a registry that exists. +# +# Running it prints: 2 1 Lantern 2 +program Attributes { + # a shop in the valley + property Vendor { + key: string = "" + name: string = "" # what the sign over the counter says + } + @AppendOnly @ByKey + registry Vendors of Vendor as VD + def Vendors store { name: "General store" } + def Vendors marina { name: "Marina" } + + # a tool a camp keeps + property Tool { + key: string = "" + name: string = "" + @Ref(Vendors) seller: int = 0 # who sells it + @Range(0, 20.5) @Unit("kg") weight: float = 1.0 + @Asset("gltf") + model: string = "" # its model, under assets/kit + @Color tint: int = 16777215 + @OneOf(VD_) stocked_at: int = VD_STORE + } + @AppendOnly + registry Tools of Tool as TL from "data/attr_tools.lres" + const CAMP_TOOLS: int = 2 * VD_COUNT # how many a camp can carry + # the tool a new camp starts with + function first_tool() -> string { return Tools[0].name } + entry { + print(`{TL_COUNT} {Tools[TL_LANTERN].seller} {Tools[TL_LANTERN].name} {CAMP_TOOLS / 2}`) + } +} diff --git a/examples/lang/data/attr_tools.lres b/examples/lang/data/attr_tools.lres new file mode 100644 index 00000000..4d79ef3c --- /dev/null +++ b/examples/lang/data/attr_tools.lres @@ -0,0 +1,8 @@ +# attr_tools.lres - the tools attributes.ludic's registry reads when it is compiled +axe { name: "Axe", seller: VD_STORE, weight: 1.5, model: "kit/axe.gltf" } +lantern { + name: "Lantern" + seller: VD_MARINA + weight: 0.75 + stocked_at: VD_MARINA +} diff --git a/examples/rejected/ref_unknown.ludic b/examples/rejected/ref_unknown.ludic new file mode 100644 index 00000000..7fd948d6 --- /dev/null +++ b/examples/rejected/ref_unknown.ludic @@ -0,0 +1,10 @@ +# attributes: @Ref names a registry - there is no registry Vendorz, nor Shops (both are reported) +program R { + property Vendor { key: string = "" } + registry Vendors of Vendor + property Tool { + @Ref(Vendorz) seller: int = 0 + @Ref(Shops) shop: int = 0 + } + entry { print(VENDORS_COUNT) } +} diff --git a/selfhost/backend/emit_float.ludic b/selfhost/backend/emit_float.ludic index b4409888..7ba82994 100644 --- a/selfhost/backend/emit_float.ludic +++ b/selfhost/backend/emit_float.ludic @@ -135,6 +135,10 @@ function fp_promote_note(from: pointer, t: pointer) -> void { if g_warn_promote == 0 { return } var file = g_err_file if (file == null) { file = "" } + if g_diag_json { + diag_add(file, g_err_line, 0, "warning", `a computed {from} is promoted to {t}`) + return + } let m = `{file}:{itoa(g_err_line)}: warning: a computed {from} is promoted to {t}\n` file_write(file_stderr(), m, len(m)) } diff --git a/selfhost/backend/emit_frame.ludic b/selfhost/backend/emit_frame.ludic index ffffcf6e..3b232b0e 100644 --- a/selfhost/backend/emit_frame.ludic +++ b/selfhost/backend/emit_frame.ludic @@ -261,8 +261,12 @@ function deps_keeps(f: pointer, seen: []bool) -> void { if kind == null { kind = "text" } deps_line(f, `fkeep {kind} {d.file}:{itoa(site.line)} {at} {fr_chain(k)}`) if g_arena_strict { - let m = `{d.file}:{itoa(site.line)}: error: frame code keeps what it makes: this {kind} is kept at {at} ({fr_chain(k)}) - keep(...) or intern(...) to copy it on purpose, or @alloc_ok("why")\n` - file_write(file_stderr(), m, len(m)) + let fm = `frame code keeps what it makes: this {kind} is kept at {at} ({fr_chain(k)}) - keep(...) or intern(...) to copy it on purpose, or @alloc_ok("why")` + if g_diag_json { diag_add(d.file, site.line, site.col, "error", fm) } + else { + let m = `{d.file}:{itoa(site.line)}: error: {fm}\n` + file_write(file_stderr(), m, len(m)) + } } n += 1 } @@ -270,7 +274,7 @@ function deps_keeps(f: pointer, seen: []bool) -> void { s += 1 } deps_line(f, `frame_keeps {itoa(n)}`) - if g_arena_strict and n > 0 { exit(1) } + if g_arena_strict and n > 0 { diag_exit(1) } deps_births(f, seen) deps_resources(f) # 25.5e deps_owned(f) diff --git a/selfhost/backend/emit_schema.ludic b/selfhost/backend/emit_schema.ludic new file mode 100644 index 00000000..12f15e33 --- /dev/null +++ b/selfhost/backend/emit_schema.ludic @@ -0,0 +1,589 @@ +# emit_schema.ludic — `ludicc --emit-schema FILE`: what an editor of the program's data needs, from +# the compiler's own view once the types are checked and every open registry has its entries. One +# JSON object, schema_version 1: +# +# records every property, state and event: its fields with their types, defaults, docs and +# attributes (attrs.ludic) +# registries every registry: its record, prefix, resource file, open or not, attributes, and its +# entries in their final order - key, constant, index, where each entry and each of its +# field values is written - with which file contributed which entries +# consts every const: its type and its value as written +# functions every function a `fn` value can name with no arguments (the states it takes are the +# runtime's to supply) +# +# Deterministic: each list sorted by name (then file and line), an entry list in index order, the +# paths as the compiler read them, and nothing about the run. The engine's runtime is left out. +var g_schema_out: pointer = null + +function schema_write(path: pointer) -> bool { + let b = buf_new() + buf_puts(b, "{\n \"schema_version\": 1,\n \"entry\": ") + jq_put(b, g_mg_entry) + buf_puts(b, ",\n \"records\": [") + sc_records(b) + buf_puts(b, "\n ],\n \"registries\": [") + sc_registries(b) + buf_puts(b, "\n ],\n \"consts\": [") + sc_consts(b) + buf_puts(b, "\n ],\n \"functions\": [") + sc_functions(b) + buf_puts(b, "\n ]\n}\n") + let f = file_open(path, "wb") + if f == null { return false } + file_write(f, buf_str(b), b.len) + file_close(f) + return true +} + +# ---- names and text --------------------------------------------------------------------------- +# a declaration's name as written: without the module a private clash renamed it for +function sc_name(s: pointer) -> pointer { + if s == null { return "" } + var i = 0 + while i < len(g_rt_new) { + if (g_rt_new[i] == s) { return g_rt_old[i] } + i += 1 + } + i = 0 + while i < len(g_re_new) { + if (g_re_new[i] == s) { return g_re_old[i] } + i += 1 + } + return vis_plain(s) +} +# a type as written: Pool for the instance Pool$Thing, a private record by its own name +function sc_ty(t: pointer) -> pointer { + if t == null { return "void" } + var out: pointer = "" + var a = 0 + var i = 0 + let n = len(t) + while i <= n { + if i == n or not gen_name_ch(t[i]) { + if i > a { out = out + sc_ty_word(t[a .. i]) } + if i < n { out = out + t[i .. i + 1] } + a = i + 1 + } + i += 1 + } + return out +} +function sc_ty_word(w: pointer) -> pointer { + let g = gen_find(w) + if g < 0 { return sc_name(w) } + let args = gen_split(g_gi_args[g]) + var out = sc_name(g_gi_gen[g]) + "<" + var i = 0 + while i < len(args) { + if i > 0 { out = out + ", " } + out = out + sc_ty(args[i]) + i += 1 + } + return out + ">" +} +function sc_src_of(file: pointer) -> pointer { + if file == null { return null } + let s = fm_get(g_fm_src, file) + if s == null or len(s) == 0 { return null } + return s +} +# the text between two offsets of a file, or null +function sc_text(file: pointer, a: int, b: int) -> pointer { + let s = sc_src_of(file) + if s == null or a < 0 or b <= a or b > len(s) { return null } + return s[a .. b] +} +# the comment block on the lines above a declaration (past its @attribute lines), else the comment +# that ends its own line; null when it has neither +function sc_doc(file: pointer, pos: int) -> pointer { + let s = sc_src_of(file) + if s == null or pos < 0 or pos >= len(s) { return null } + var ls = pos + while ls > 0 and s[ls - 1] != '\n' { ls -= 1 } + let lines = new []pointer + var e = ls + while e > 0 { + let le = e - 1 + var bgn = le + while bgn > 0 and s[bgn - 1] != '\n' { bgn -= 1 } + var k = bgn + while k < le and (s[k] == ' ' or s[k] == '\t') { k += 1 } + if k < le and s[k] == '#' { + push(lines, sc_comment(s, k, le)) + e = bgn + } else if k < le and s[k] == '@' { + e = bgn + } else { + break + } + } + if len(lines) > 0 { + var out: pointer = "" + var j = len(lines) - 1 + while j >= 0 { + out = out + lines[j] + if j > 0 { out = out + "\n" } + j -= 1 + } + return out + } + # the comment at the end of its own line, outside any string + var i = pos + var q = 0 + while i < len(s) and s[i] != '\n' { + let c = s[i] + if q != 0 { + if c == CH_BACKSLASH { i += 1 } + else if c == q { q = 0 } + } + else if c == '"' or c == '`' { q = c } + else if c == '#' { + var le2 = i + while le2 < len(s) and s[le2] != '\n' { le2 += 1 } + return sc_comment(s, i, le2) + } + i += 1 + } + return null +} +# a comment's text: past its '#' and one space, without the end of its line +function sc_comment(s: pointer, hash: int, le: int) -> pointer { + var a = hash + 1 + if a < le and s[a] == ' ' { a += 1 } + var z = le + while z > a and (s[z - 1] == ' ' or s[z - 1] == '\t' or s[z - 1] == '\r') { z -= 1 } + return s[a .. z] +} +function sc_str(b: Buf, s: pointer) -> void { + if s == null { buf_puts(b, "null") } else { jq_put(b, s) } +} +function sc_mod(file: pointer) -> pointer { + let m = module_of(file) + if m == null or len(m) == 0 { return null } + return m +} +function sc_bool(b: Buf, v: bool) -> void { + if v { buf_puts(b, "true") } else { buf_puts(b, "false") } +} +# a group of attributes as a JSON array +function sc_attrs(b: Buf, g: int) -> void { + buf_puts(b, "[") + if g >= 0 { + var j = g_at_start[g] + while j < g_at_start[g] + g_at_count[g] { + if j > g_at_start[g] { buf_puts(b, ", ") } + buf_puts(b, "{\"name\": ") + jq_put(b, g_at_name[j]) + buf_puts(b, ", \"args\": ") + buf_puts(b, g_at_args[j]) + buf_puts(b, "}") + j += 1 + } + } + buf_puts(b, "]") +} +# "file", "line", "col" of a place +function sc_at(b: Buf, file: pointer, line: int, col: int) -> void { + buf_puts(b, "\"file\": ") + sc_str(b, file) + buf_puts(b, ", \"line\": ") + buf_puti(b, line) + buf_puts(b, ", \"col\": ") + buf_puti(b, col) +} +function sc_sep(b: Buf, first: bool, indent: pointer) -> void { + if not first { buf_puts(b, ",") } + buf_puts(b, "\n") + buf_puts(b, indent) +} + +# ---- order ------------------------------------------------------------------------------------ +# indices sorted by their keys, ties kept in the order given (a merge sort) +function sc_sort(xs: []int, keys: []pointer) -> void { + let tmp = new []int + var i = 0 + while i < len(xs) { push(tmp, 0); i += 1 } + sc_msort(xs, keys, 0, len(xs), tmp) +} +function sc_msort(xs: []int, keys: []pointer, lo: int, hi: int, tmp: []int) -> void { + if hi - lo < 2 { return } + let mid = (lo + hi) / 2 + sc_msort(xs, keys, lo, mid, tmp) + sc_msort(xs, keys, mid, hi, tmp) + var i = lo + var j = mid + var k = lo + while k < hi { + if j >= hi or (i < mid and not reg_str_less(keys[xs[j]], keys[xs[i]])) { + tmp[k] = xs[i] + i += 1 + } else { + tmp[k] = xs[j] + j += 1 + } + k += 1 + } + k = lo + while k < hi { + xs[k] = tmp[k] + k += 1 + } +} +# a key that sorts by name, then file, then line +function sc_key(name: pointer, file: pointer, line: int) -> pointer { + var f = file + if f == null { f = "" } + let ln = itoa(1000000 + line) + return name + "\t" + f + "\t" + ln +} +# a declaration the program wrote (not the runtime's, not one the compiler made) +function sc_written(d: Node) -> bool { + return d.file != null and d.pos >= 0 and not is_runtime_file(d.file) +} + +# ---- records ---------------------------------------------------------------------------------- +function sc_records(b: Buf) -> void { + let ds = new []Node + let xs = new []int + let keys = new []pointer + var i = 0 + while i < len(prog) { + let d = prog[i] + if d.kind == N_COMP and d.s != null and sc_written(d) and gen_find(d.s) < 0 { + push(xs, len(ds)) + push(keys, sc_key(sc_name(d.s), d.file, d.line)) + push(ds, d) + } + i += 1 + } + i = 0 + while i < len(g_events) { + let e = g_events[i] + if e.s != null and sc_written(e) { + push(xs, len(ds)) + push(keys, sc_key(sc_name(e.s), e.file, e.line)) + push(ds, e) + } + i += 1 + } + sc_sort(xs, keys) + i = 0 + while i < len(xs) { + sc_record(b, ds[xs[i]], i == 0) + i += 1 + } +} +function sc_record(b: Buf, d: Node, first: bool) -> void { + sc_sep(b, first, " ") + var kind = "property" + if d.kind == N_EVENT { kind = "event" } else if d.uns == 2 { kind = "state" } + buf_puts(b, "{\"name\": ") + jq_put(b, sc_name(d.s)) + buf_puts(b, ", \"kind\": ") + jq_put(b, kind) + buf_puts(b, ", \"module\": ") + sc_str(b, sc_mod(d.file)) + buf_puts(b, ", \"exported\": ") + sc_bool(b, d.vis == 1) + buf_puts(b, ", ") + sc_at(b, d.file, d.line, d.col) + buf_puts(b, ", \"type_params\": ") + if d.tps == null { buf_puts(b, "null") } else { jq_put(b, d.tps) } + buf_puts(b, ",\n \"doc\": ") + sc_str(b, sc_doc(d.file, d.pos)) + buf_puts(b, ",\n \"fields\": [") + var n = 0 + var k = 0 + while k < len(d.kids) { + let f = d.kids[k] + if f.kind == N_FIELD and f.pos >= 0 { + sc_field(b, f, n == 0) + n += 1 + } + k += 1 + } + if n > 0 { buf_puts(b, "\n ") } + buf_puts(b, "]}") +} +function sc_field(b: Buf, f: Node, first: bool) -> void { + sc_sep(b, first, " ") + buf_puts(b, "{\"name\": ") + jq_put(b, f.s) + buf_puts(b, ", \"type\": ") + jq_put(b, sc_ty(f.ty)) + buf_puts(b, ", \"default\": ") + sc_str(b, sc_text(f.file, f.sp0, f.sp1)) + buf_puts(b, ", ") + sc_at(b, f.file, f.line, f.col) + buf_puts(b, ", \"attributes\": ") + sc_attrs(b, f.at) + buf_puts(b, ", \"doc\": ") + sc_str(b, sc_doc(f.file, f.pos)) + buf_puts(b, "}") +} + +# ---- registries ------------------------------------------------------------------------------- +function sc_registries(b: Buf) -> void { + let xs = new []int + let keys = new []pointer + var r = 0 + while r < len(g_rg_name) { + let v = g_rg_var[r] + if v.file != null and not is_runtime_file(v.file) { + push(xs, r) + push(keys, sc_key(g_rg_name[r], v.file, v.line)) + } else { + push(keys, "") + } + r += 1 + } + sc_sort(xs, keys) + var i = 0 + while i < len(xs) { + sc_registry(b, xs[i], i == 0) + i += 1 + } +} +function sc_registry(b: Buf, r: int, first: bool) -> void { + let v = g_rg_var[r] + sc_sep(b, first, " ") + buf_puts(b, "{\"name\": ") + jq_put(b, g_rg_name[r]) + buf_puts(b, ", \"record\": ") + jq_put(b, sc_ty(g_rg_type[r])) + buf_puts(b, ", \"prefix\": ") + jq_put(b, g_rg_prefix[r]) + buf_puts(b, ", \"source\": ") + sc_str(b, g_rg_from[r]) + buf_puts(b, ", \"open\": ") + sc_bool(b, g_rg_open[r] == 1) + buf_puts(b, ", \"exported\": ") + sc_bool(b, v.vis == 1) + buf_puts(b, ", \"module\": ") + sc_str(b, sc_mod(v.file)) + buf_puts(b, ",\n ") + sc_at(b, v.file, v.line, v.col) + buf_puts(b, ", \"attributes\": ") + sc_attrs(b, g_rg_at[r]) + buf_puts(b, ", \"doc\": ") + sc_str(b, sc_doc(v.file, v.pos)) + let order = reg_order(r) + buf_puts(b, ",\n \"count\": ") + buf_puti(b, len(order)) + buf_puts(b, ",\n \"contributors\": [") + sc_contributors(b, order) + buf_puts(b, "],\n \"entries\": [") + var o = 0 + while o < len(order) { + sc_entry(b, r, order[o], o) + o += 1 + } + if len(order) > 0 { buf_puts(b, "\n ") } + buf_puts(b, "]}") +} +# who brought an entry in: a resource file by the declaration that read it, `def`s by their file +function sc_contrib_id(d: int) -> pointer { + if len(g_df_src[d]) == 0 { return "def\n" + g_df_rec[d].file } + return g_df_src[d] + "\n" + g_df_via[d] +} +# which file brought which entries in, in the order they first appear in the registry +function sc_contributors(b: Buf, order: []int) -> void { + let seen = new []pointer + var o = 0 + while o < len(order) { + let d = order[o] + let id = sc_contrib_id(d) + var known = false + var j = 0 + while j < len(seen) { + if (seen[j] == id) { known = true } + j += 1 + } + if not known { + push(seen, id) + if len(seen) > 1 { buf_puts(b, ",") } + buf_puts(b, "\n {\"kind\": ") + var file = g_df_src[d] + if len(file) == 0 { + jq_put(b, "def") + file = g_df_rec[d].file + } else { + jq_put(b, "resource") + } + buf_puts(b, ", \"file\": ") + sc_str(b, file) + buf_puts(b, ", \"via\": ") + jq_put(b, g_df_via[d]) + buf_puts(b, ", \"module\": ") + sc_str(b, sc_mod(g_df_rec[d].file)) + buf_puts(b, ", \"keys\": [") + var n = 0 + var p = o + while p < len(order) { + let e = order[p] + if (sc_contrib_id(e) == id) { + if n > 0 { buf_puts(b, ", ") } + jq_put(b, g_df_key[e]) + n += 1 + } + p += 1 + } + buf_puts(b, "]}") + } + o += 1 + } + if len(seen) > 0 { buf_puts(b, "\n ") } +} +function sc_entry(b: Buf, r: int, d: int, index: int) -> void { + let rec = g_df_rec[d] + sc_sep(b, index == 0, " ") + buf_puts(b, "{\"key\": ") + jq_put(b, g_df_key[d]) + buf_puts(b, ", \"constant\": ") + jq_put(b, `{g_rg_prefix[r]}_{reg_upper(g_df_key[d])}`) + buf_puts(b, ", \"index\": ") + buf_puti(b, index) + buf_puts(b, ", ") + sc_at(b, rec.file, g_df_kline[d], g_df_kcol[d]) + buf_puts(b, ", \"fields\": [") + var n = 0 + var k = 0 + while k < len(rec.kids) { + let fi = rec.kids[k] + if fi.kind == E_FINIT and fi.sp0 >= 0 { + if n > 0 { buf_puts(b, ",") } + buf_puts(b, "\n {\"name\": ") + jq_put(b, fi.s) + buf_puts(b, ", \"value\": ") + sc_str(b, sc_text(fi.file, fi.sp0, fi.sp1)) + buf_puts(b, ", ") + sc_at(b, fi.file, fi.line, fi.col) + buf_puts(b, "}") + n += 1 + } + k += 1 + } + if n > 0 { buf_puts(b, "\n ") } + buf_puts(b, "]}") +} + +# ---- consts ----------------------------------------------------------------------------------- +function sc_consts(b: Buf) -> void { + let xs = new []int + let keys = new []pointer + var i = 0 + while i < len(prog) { + let d = prog[i] + if d.kind == N_CONST and d.s != null and sc_written(d) { push(xs, i); push(keys, sc_key(sc_name(d.s), d.file, d.line)) } + else { push(keys, "") } + i += 1 + } + sc_sort(xs, keys) + i = 0 + while i < len(xs) { + let d = prog[xs[i]] + sc_sep(b, i == 0, " ") + buf_puts(b, "{\"name\": ") + jq_put(b, sc_name(d.s)) + buf_puts(b, ", \"type\": ") + jq_put(b, sc_ty(d.ty)) + buf_puts(b, ", \"value\": ") + sc_str(b, sc_text(d.file, d.sp0, d.sp1)) + buf_puts(b, ", \"module\": ") + sc_str(b, sc_mod(d.file)) + buf_puts(b, ", \"exported\": ") + sc_bool(b, d.vis == 1) + buf_puts(b, ", ") + sc_at(b, d.file, d.line, d.col) + buf_puts(b, ", \"doc\": ") + sc_str(b, sc_doc(d.file, d.pos)) + buf_puts(b, "}") + i += 1 + } +} + +# ---- functions a fn value can name ------------------------------------------------------------ +function sc_is_state_param(p: Node) -> bool { + return is_state_ty(p.ty) or is_state_ty(sc_name(p.ty)) +} +function sc_generic_fn(names: []pointer, s: pointer) -> bool { + let base = vis_plain(s) + var i = 0 + while i < len(names) { + if (names[i] == base) { return true } + i += 1 + } + return false +} +function sc_functions(b: Buf) -> void { + let gens = new []pointer + var i = 0 + while i < len(prog) { + if prog[i].kind == N_FN and prog[i].tps != null and prog[i].s != null { push(gens, vis_plain(prog[i].s)) } + i += 1 + } + let xs = new []int + let keys = new []pointer + i = 0 + while i < len(prog) { + let d = prog[i] + var ok = d.kind == N_FN and d.s != null and d.tps == null and sc_written(d) + if ok { ok = not sc_generic_fn(gens, d.s) } + if ok { + var k = 0 + while k < len(d.kids) { + if d.kids[k].kind == N_PARAM and not sc_is_state_param(d.kids[k]) { ok = false } + k += 1 + } + } + if ok { push(xs, i); push(keys, sc_key(sc_name(d.s), d.file, d.line)) } + else { push(keys, "") } + i += 1 + } + sc_sort(xs, keys) + i = 0 + while i < len(xs) { + sc_function(b, prog[xs[i]], i == 0) + i += 1 + } +} +function sc_function(b: Buf, d: Node, first: bool) -> void { + let ret = sc_ty(d.ty) + var ps: pointer = "" + var states: pointer = "" + var k = 0 + while k < len(d.kids) { + let p = d.kids[k] + if p.kind == N_PARAM { + var mt: pointer = "" + if p.uns == 1 { mt = "mut " } + if len(ps) > 0 { ps = ps + ", " } + ps = ps + p.s + ": " + mt + sc_ty(p.ty) + let bq = buf_new() + jq_put(bq, sc_ty(p.ty)) + if len(states) > 0 { states = states + ", " } + states = states + buf_str(bq) + } + k += 1 + } + sc_sep(b, first, " ") + buf_puts(b, "{\"name\": ") + jq_put(b, sc_name(d.s)) + buf_puts(b, ", \"module\": ") + sc_str(b, sc_mod(d.file)) + buf_puts(b, ", \"exported\": ") + sc_bool(b, d.vis == 1) + buf_puts(b, ", ") + sc_at(b, d.file, d.line, d.col) + buf_puts(b, ", \"returns\": ") + jq_put(b, ret) + buf_puts(b, ", \"fn_type\": ") + jq_put(b, `fn() -> {ret}`) + buf_puts(b, ", \"signature\": ") + jq_put(b, `function {sc_name(d.s)}({ps}) -> {ret}`) + buf_puts(b, ", \"states\": [") + buf_puts(b, states) + buf_puts(b, "], \"doc\": ") + sc_str(b, sc_doc(d.file, d.pos)) + buf_puts(b, "}") +} diff --git a/selfhost/backend/emit_vis.ludic b/selfhost/backend/emit_vis.ludic index 16242fdd..9dc1f070 100644 --- a/selfhost/backend/emit_vis.ludic +++ b/selfhost/backend/emit_vis.ludic @@ -61,7 +61,8 @@ function vis_check(d: Node, what0: pointer) -> void { if vis_reporting() { vis_say(`uses: {d.file}:{itoa(d.line)}: {uto}.{what} used from {here} (module {from})\n`) } else { - perr(`{from} uses {uto}.{what} ({d.file}:{itoa(d.line)}): add 'uses {uto}' to {from}'s module line, or take it through a port`) + let um = `{from} uses {uto}.{what} ({d.file}:{itoa(d.line)}): add 'uses {uto}' to {from}'s module line, or take it through a port` + if g_diag_json { diag_add(here, g_err_line, 0, "error", um) } else { perr(um) } } } let to = module_of(d.file) @@ -72,5 +73,10 @@ function vis_check(d: Node, what0: pointer) -> void { vis_say(`vis: {d.file}:{itoa(d.line)}: {to}.{what} used from {here}\n`) return } - perr(`{what} is private to module {to}; mark it 'export' where it is declared ({d.file})`) + let pm = `{what} is private to module {to}; mark it 'export' where it is declared ({d.file})` + if g_diag_json { + diag_add(here, g_err_line, 0, "error", pm) + return + } + perr(pm) } diff --git a/selfhost/check/check_env.ludic b/selfhost/check/check_env.ludic index e9b5506a..2eb0e3d4 100644 --- a/selfhost/check/check_env.ludic +++ b/selfhost/check/check_env.ludic @@ -41,9 +41,16 @@ function ck_reporting() -> bool { function ck_err(cat: pointer, n: Node, msg: pointer) -> void { var file: pointer = "" var line = 0 + var col = 0 if n != null { if n.file != null { file = n.file } line = n.line + col = n.col + } + if g_diag_json and not ck_reporting() { + diag_add(file, line, col, "error", msg) + ck_errors += 1 + return } var m = `{file}:{itoa(line)}: error: {msg}\n` if ck_reporting() { m = `check[{cat}]: {file}:{itoa(line)}: {msg}\n` } diff --git a/selfhost/check/check_stmt.ludic b/selfhost/check/check_stmt.ludic index 9c5a58ce..dbd889ea 100644 --- a/selfhost/check/check_stmt.ludic +++ b/selfhost/check/check_stmt.ludic @@ -337,6 +337,7 @@ function check_program() -> void { } gen_finish() if ck_errors > 0 and not ck_reporting() and not g_migrate { + if g_diag_json { diag_exit(1) } let m = `{itoa(ck_errors)} type error(s)\n` file_write(file_stderr(), m, len(m)) exit(1) diff --git a/selfhost/frontend/ast.ludic b/selfhost/frontend/ast.ludic index 3e0bb155..97080738 100644 --- a/selfhost/frontend/ast.ludic +++ b/selfhost/frontend/ast.ludic @@ -94,6 +94,10 @@ property Node { mg: int = -1 # 0.S2: its index in the migration's tables (migrate.ludic) vw: int = -1 # 0.S: the view a function was generated for or written in cm: int = -1 # 0.S: the component a function was generated for or written in + col: int = 0 # the column of its first token (1-based; 0 when generated) + at: int = -1 # its attributes (@Ref(Items), @Range(0, 1), ...): a group in attrs.ludic + sp0: int = -1 # a field's default, a const's value, an entry field's value: where its + sp1: int = -1 # source text starts and ends in its file (the schema's text of it) } # every node remembers where it was parsed (file + the line of the token the @@ -105,6 +109,7 @@ function node(kind: int) -> Node { n.file = g_parse_file if g_parsing and pi < len(toks) { n.line = toks[pi].line + n.col = toks[pi].col n.pos = toks[pi].pos if g_gen_nodes { n.pos = -1 } # made by the compiler, not read from a file } diff --git a/selfhost/frontend/attrs.ludic b/selfhost/frontend/attrs.ludic new file mode 100644 index 00000000..90c13665 --- /dev/null +++ b/selfhost/frontend/attrs.ludic @@ -0,0 +1,164 @@ +# attrs.ludic — what an editor needs to know about a field or a registry, said on the @-syntax the +# compiler already reads (@max(64), @owns(Kind)) and carried into `--emit-schema`: +# +# property Item { +# @Ref(Vendors) seller: int = 0 # an index into that registry (its entries are offered) +# @OneOf(GR_) grade: int = 0 # one of the constants that start GR_ +# @Range(0, 1.5) weight: float = 0.4 +# @Unit("m/s") speed: float = 0.0 +# @Asset("gltf") model: string = "" # a file of that kind +# @Color tint: int = 0 +# } +# @AppendOnly @ByKey registry Items of Item as IT from "items.lres" +# +# They change nothing the program does. One is checked: @Ref must name a registry that exists. A +# field's or a registry's attributes are a group (Node.at, g_rg_at): a run of names, each with its +# arguments as JSON values - an identifier or a string as a string, a number as written. +var g_at_start: []int = new []int # a group's first attribute ... +var g_at_count: []int = new []int # ... and how many it holds +var g_at_name: []pointer = new []pointer +var g_at_args: []pointer = new []pointer # the arguments, a JSON array: ["Items"], [0, 1.5] +var g_at_first: []pointer = new []pointer # the first argument as written (@Ref's registry), or null +var g_at_nargs: []int = new []int +var g_at_open: int = -1 # the group the attributes being read go into +var g_at_decl: []pointer = new []pointer # @AppendOnly / @ByKey read before a declaration +var g_at_keep: bool = false # `export` parses its declaration one call down + +# a new group, empty; its index +function at_group() -> int { + push(g_at_start, len(g_at_name)) + push(g_at_count, 0) + return len(g_at_start) - 1 +} +function at_push(g: int, name: pointer, args: pointer, first: pointer, nargs: int) -> void { + push(g_at_name, name) + push(g_at_args, args) + push(g_at_first, first) + push(g_at_nargs, nargs) + g_at_count[g] = g_at_count[g] + 1 +} +# `(a, "b", -1.5)` after an attribute's name, if it is there, as a JSON array; the first argument +# as written goes to g_at_arg0, the count to g_at_argn +var g_at_arg0: pointer = null +var g_at_argn: int = 0 +var g_at_allnum: bool = true # every argument a number +function at_args() -> pointer { + g_at_arg0 = null + g_at_argn = 0 + g_at_allnum = true + if not is_op("(") { return "[]" } + pi += 1 + var out: pointer = "[" + while not is_op(")") { + skipnl() + var neg = false + if is_op("-") { neg = true; pi += 1 } + let t = toks[pi] + var v: pointer = null + var raw: pointer = null + if t.kind == TK_INT { + raw = itoa(t.ival) + if t.text != null { raw = t.text } + if neg { raw = "-" + raw } + v = raw + } else if t.kind == TK_FLOAT { + raw = t.text + if neg { raw = "-" + raw } + v = raw + if raw[0] == '.' or (neg and raw[1] == '.') { v = at_lead0(raw) } + } else if (t.kind == TK_STR or t.kind == TK_ID) and not neg { + g_at_allnum = false + raw = t.text + let b = buf_new() + jq_put(b, raw) + v = buf_str(b) + } else { + perr(`an attribute's argument is a name, a string or a number, not {tok_desc()}`) + } + pi += 1 + if g_at_argn > 0 { out = out + ", " } + out = out + v + if g_at_argn == 0 { g_at_arg0 = raw } + g_at_argn += 1 + skipnl() + if is_op(",") { pi += 1 } + } + eat_op(")") + return out + "]" +} +function at_lead0(raw: pointer) -> pointer { + if raw[0] == '-' { return "-0" + raw[1 .. len(raw)] } + return "0" + raw +} +# the editor attributes a field may carry, and what each takes +function at_field_known(a: pointer) -> bool { + return (a == "Ref") or (a == "OneOf") or (a == "Range") or (a == "Unit") or (a == "Asset") or (a == "Color") +} +# one field attribute, its '@' and name already read: its arguments, checked for shape, into group g +function at_field(g: int, a: pointer) -> void { + let args = at_args() + let n = g_at_argn + if (a == "Ref") or (a == "OneOf") { + if n != 1 or toks[pi - 2].kind != TK_ID { perr(`@{a} takes one name: @{a}(Items)`) } + } + if (a == "Range") and n != 2 { perr("@Range takes the least and the most: @Range(0, 1.5)") } + if (a == "Range") and not g_at_allnum { perr("@Range takes two numbers: @Range(0, 1.5)") } + if ((a == "Unit") or (a == "Asset")) and (n != 1 or toks[pi - 2].kind != TK_STR) { perr(`@{a} takes one string: @{a}("m/s")`) } + if (a == "Color") and n != 0 { perr("@Color takes nothing") } + at_push(g, a, args, g_at_arg0, n) +} +# a field's group, made the first time it needs one +function at_field_group(f: Node) -> int { + if f.at < 0 { f.at = at_group() } + return f.at +} +# @AppendOnly / @ByKey before a declaration: kept for the registry it is on +function at_decl(a: pointer) -> void { + push(g_at_decl, a) +} +function at_decl_reset() -> void { + if g_at_keep { + g_at_keep = false + return + } + if len(g_at_decl) > 0 { g_at_decl = new []pointer } +} +# the registry just declared takes the attributes read before it +function at_registry_take() -> int { + if len(g_at_decl) == 0 { return -1 } + let g = at_group() + var i = 0 + while i < len(g_at_decl) { + at_push(g, g_at_decl[i], "[]", null, 0) + i += 1 + } + g_at_decl = new []pointer + return g +} +# once every registry is declared: each @Ref names one, or the build stops with every one that does not +function at_check_refs() -> void { + var i = 0 + while i < len(prog) { + let d = prog[i] + if d.kind == N_COMP { + var k = 0 + while k < len(d.kids) { + let f = d.kids[k] + if f.at >= 0 { at_check_field(d, f) } + k += 1 + } + } + i += 1 + } + diag_stop_if_errors() +} +function at_check_field(d: Node, f: Node) -> void { + let g = f.at + var j = g_at_start[g] + while j < g_at_start[g] + g_at_count[g] { + if (g_at_name[j] == "Ref") and reg_find(g_at_first[j]) < 0 { + perr_at(f.file, f.line, f.col, `field {d.s}.{f.s}: @Ref({g_at_first[j]}) names no registry - there is no registry {g_at_first[j]}`) + } + j += 1 + } +} diff --git a/selfhost/frontend/diag.ludic b/selfhost/frontend/diag.ludic new file mode 100644 index 00000000..bbd530ce --- /dev/null +++ b/selfhost/frontend/diag.ludic @@ -0,0 +1,95 @@ +# diag.ludic — `--diagnostics=json`: every error and warning the compiler reports, collected and +# written to stdout as one JSON array of {file, line, col, severity, message} when it exits, instead +# of the `file:line: error: message` lines on stderr. The type checker and the module rules (vis, +# uses) report everything they find; an error the parser or the emitter cannot go on from (a token +# it did not expect, an unknown name while lowering) is the last one in the array. A col of 0 is a +# place known only to its line. +var g_diag_json: bool = false +var g_dg_file: []pointer = new []pointer +var g_dg_line: []int = new []int +var g_dg_col: []int = new []int +var g_dg_sev: []pointer = new []pointer +var g_dg_msg: []pointer = new []pointer +var g_dg_errors: int = 0 + +# one diagnostic, kept for the array - once, though a pass may find it twice (a reference the +# checker and the emitter both resolve) +function diag_add(file: pointer, line: int, col: int, sev: pointer, msg: pointer) -> void { + var f = file + if f == null { f = "" } + let m = diag_trim(msg) + var i = 0 + while i < len(g_dg_file) { + if g_dg_line[i] == line and g_dg_col[i] == col and (g_dg_msg[i] == m) and (g_dg_file[i] == f) and (g_dg_sev[i] == sev) { return } + i += 1 + } + push(g_dg_file, f) + push(g_dg_line, line) + push(g_dg_col, col) + push(g_dg_sev, sev) + push(g_dg_msg, m) + if (sev == "error") { g_dg_errors += 1 } +} +# a message without its closing newline +function diag_trim(m: pointer) -> pointer { + var n = len(m) + while n > 0 and (m[n - 1] == '\n' or m[n - 1] == '\r') { n -= 1 } + return m[0 .. n] +} +# `s` as a JSON string, quotes included, into b +function jq_put(b: Buf, s: pointer) -> void { + buf_putc(b, '"') + if s != null { + var i = 0 + while s[i] != 0 { + let c = s[i] + if c == '"' { buf_puts(b, "\\\"") } + else if c == CH_BACKSLASH { buf_puts(b, "\\\\") } + else if c == '\n' { buf_puts(b, "\\n") } + else if c == '\t' { buf_puts(b, "\\t") } + else if c == '\r' { buf_puts(b, "\\r") } + else if c >= 0 and c < 32 { + buf_puts(b, "\\u00") + buf_putc(b, jq_hex(c >> 4)) + buf_putc(b, jq_hex(c & 15)) + } + else { buf_putc(b, c) } + i += 1 + } + } + buf_putc(b, '"') +} +function jq_hex(d: int) -> int { + if d < 10 { return 48 + d } + return 87 + d +} +# the array, on stdout +function diag_flush() -> void { + let b = buf_new() + buf_puts(b, "[") + var i = 0 + while i < len(g_dg_file) { + if i > 0 { buf_puts(b, ",") } + buf_puts(b, "\n {\"file\": ") + jq_put(b, g_dg_file[i]) + buf_puts(b, ", \"line\": ") + buf_puti(b, g_dg_line[i]) + buf_puts(b, ", \"col\": ") + buf_puti(b, g_dg_col[i]) + buf_puts(b, ", \"severity\": ") + jq_put(b, g_dg_sev[i]) + buf_puts(b, ", \"message\": ") + jq_put(b, g_dg_msg[i]) + buf_puts(b, "}") + i += 1 + } + if len(g_dg_file) > 0 { buf_puts(b, "\n") } + buf_puts(b, "]\n") + let out = file_stdout() + file_write(out, buf_str(b), b.len) +} +# stop: with --diagnostics=json the array is written first +function diag_exit(code: int) -> void { + if g_diag_json { diag_flush() } + exit(code) +} diff --git a/selfhost/frontend/lex.ludic b/selfhost/frontend/lex.ludic index 9137cb63..7ed3ce65 100644 --- a/selfhost/frontend/lex.ludic +++ b/selfhost/frontend/lex.ludic @@ -11,12 +11,15 @@ const TK_EOF: int = 5 const TK_FLOAT: int = 6 const TK_INTERP: int = 7 # `text {expr} text` — raw content, split by the parser -property Tok { kind: int = 0, text: pointer = null, ival: int = 0, line: int = 0, pos: int = -1, end: int = -1 } +property Tok { kind: int = 0, text: pointer = null, ival: int = 0, line: int = 0, pos: int = -1, end: int = -1, col: int = 0, off: int = -1, oend: int = -1 } +# col: the token's column (1-based) on its line; off / oend: where it starts and ends in the text +# lexed, whatever the base (a resource file's own offsets, for the schema's spans - never a rewrite) # where the tokens are in their file (0.S's migration rewrites source by position): a file's own # text starts at 0, an interpolation hole at its place in the file, and generated code has none (-1) var g_lex_base: int = -1 var lx_start: int = 0 +var lx_line0: int = 0 # where the line being lexed starts in the text var toks: []Tok @@ -24,6 +27,8 @@ function tok_push(kind: int, text: pointer, ival: int, line: int) -> void { let t = new Tok t.kind = kind; t.text = text; t.ival = ival; t.line = line if g_lex_base >= 0 { t.pos = g_lex_base + lx_start } + t.off = lx_start + t.col = lx_start - lx_line0 + 1 push(toks, t) } @@ -103,6 +108,10 @@ function hole_end(s: pointer, i0: int, n: int) -> int { # report a lexical error in the standard `file:line: error: msg` shape and stop function lex_fail(line: int, msg: pointer) -> void { + if g_diag_json { + diag_add(g_parse_file, line, lx_start - lx_line0 + 1, "error", msg) + diag_exit(1) + } let m = `{g_parse_file}:{line}: error: {msg}\n` file_write(file_stderr(), m, len(m)) exit(1) @@ -119,6 +128,7 @@ function lex(src: pointer) -> void { # re-lexed on its own, and keeps the line of the string it sits in) function lex_at(src: pointer, first_line: int) -> void { toks = new []Tok + lx_line0 = 0 var i = 0 var line = first_line let n = len(src) @@ -127,11 +137,12 @@ function lex_at(src: pointer, first_line: int) -> void { # the token the last turn pushed ends where this one starts looking if len(toks) > lx_n { if g_lex_base >= 0 { toks[len(toks) - 1].end = g_lex_base + i } + toks[len(toks) - 1].oend = i lx_n = len(toks) } lx_start = i let c = src[i] - if c == '\n' { tok_push(TK_NL, null, 0, line); line += 1; i += 1; continue } + if c == '\n' { tok_push(TK_NL, null, 0, line); line += 1; i += 1; lx_line0 = i; continue } if c == ' ' or c == '\t' or c == '\r' { i += 1; continue } if c == '#' { # '#' comment to end of line while i < n and src[i] != '\n' { i += 1 } @@ -255,6 +266,7 @@ function lex_at(src: pointer, first_line: int) -> void { lex_error(line, c) } if len(toks) > lx_n and g_lex_base >= 0 { toks[len(toks) - 1].end = g_lex_base + i } + if len(toks) > lx_n { toks[len(toks) - 1].oend = i } lx_start = i tok_push(TK_EOF, null, 0, line) } diff --git a/selfhost/frontend/parse.ludic b/selfhost/frontend/parse.ludic index b429aff4..2a40b14d 100644 --- a/selfhost/frontend/parse.ludic +++ b/selfhost/frontend/parse.ludic @@ -75,6 +75,7 @@ function fm_get(m: FileMap, f: pointer) -> pointer { var g_fm_mod: FileMap = fm_new() var g_fm_pkg: FileMap = fm_new() var g_fm_trusted: FileMap = fm_new() +var g_fm_src: FileMap = fm_new() # every file read, by its path: the schema's docs and spans function trust_file(f: pointer) -> void { push(g_trusted_files, f) fm_put(g_fm_trusted, f, "1") @@ -110,16 +111,36 @@ function is_kw(v: pointer) -> bool { return is_id(v) } function perr(msg: pointer) -> void { var file = g_err_file var line = g_err_line + var col = 0 if g_parsing { file = g_parse_file line = 0 - if pi < len(toks) { line = toks[pi].line } + if pi < len(toks) { line = toks[pi].line; col = toks[pi].col } } if file == null { file = "" } + if g_diag_json { + diag_add(file, line, col, "error", msg) + diag_exit(1) + } let m = `{file}:{line}: error: {msg}\n` file_write(file_stderr(), m, len(m)) exit(1) } +# an error the pass reporting it can go on from (every @Ref, every module rule broken): reported, +# counted, and the build stops once the pass is done (diag_stop_if_errors) +function perr_at(file: pointer, line: int, col: int, msg: pointer) -> void { + var f = file + if f == null { f = "" } + if g_diag_json { diag_add(f, line, col, "error", msg) } + else { + let m = `{f}:{itoa(line)}: error: {msg}\n` + file_write(file_stderr(), m, len(m)) + g_dg_errors += 1 + } +} +function diag_stop_if_errors() -> void { + if g_dg_errors > 0 { diag_exit(1) } +} # the current token, described for a diagnostic function tok_desc() -> pointer { @@ -444,7 +465,11 @@ function record() -> Node { eat_op("{") let r = node(E_REC) while true { skipnl(); if is_op("}") { break } - let fi = node(E_FINIT); fi.s = eat_id(); eat_op(":"); fi.a = expr(); push(r.kids, fi) + let fi = node(E_FINIT); fi.s = eat_id(); eat_op(":") + let v0 = pi + fi.a = expr() + fi.sp0 = toks[v0].off; fi.sp1 = toks[pi - 1].oend # the schema's text of the value + push(r.kids, fi) if is_op(",") { pi += 1 } } eat_op("}"); return r } @@ -598,7 +623,10 @@ function parse_var() -> Node { return n } function parse_const() -> Node { - pi += 1; let n = node(N_CONST); n.s = eat_id(); eat_op(":"); n.ty = ptype(); eat_op("="); n.a = expr() + pi += 1; let n = node(N_CONST); n.s = eat_id(); eat_op(":"); n.ty = ptype(); eat_op("=") + let v0 = pi + n.a = expr() + n.sp0 = toks[v0].off; n.sp1 = toks[pi - 1].oend # the schema's text of the value return n } # the language's statement / declaration words cannot name a function (`new`, @@ -915,6 +943,7 @@ function already_loaded(full: pointer) -> bool { # Modifiers are `@annotations` in front of the declaration: `@export`, `@edge`, # `@pure`, `@deterministic`, … — one channel, not a zoo of prefix keywords. function parse_one_decl() -> void { + at_decl_reset() # attrs.ludic: @AppendOnly / @ByKey belong to one declaration var is_export = false var qspec: Node = null var onspawn_model: pointer = null @@ -964,6 +993,7 @@ function parse_one_decl() -> void { else if a == "EngineSystem" { eat_op("("); esys_comp = eat_id(); eat_op(","); esys_phase = eat_id(); eat_op(")") } # @EngineSystem(Comp, Phase) package engine system (#62) else if a == "Namespace" { eat_op("("); ns_name = eat_id(); eat_op(")") } # @Namespace(Name) package Foo.* namespace (#62) else if a == "deterministic" { is_det = true } + else if (a == "AppendOnly") or (a == "ByKey") { at_decl(a) } # a registry's, for editors (attrs.ludic) else if a == "alloc_ok" { # 25.2: @alloc_ok("why") - this allocates in a frame on purpose eat_op("(") let why = expr() @@ -1000,6 +1030,7 @@ function parse_one_decl() -> void { # `export function f`, `export var v`, ...: visible from other modules (L3) if is_id("export") and (toks[pi + 1].kind == TK_ID) { pi += 1 + g_at_keep = true let p0 = len(prog) let e0 = len(g_events) parse_one_decl() @@ -1249,6 +1280,7 @@ function do_import(rel: pointer) -> void { } if already_loaded(full) { return } push(loaded_paths, full) + fm_put(g_fm_src, full, src) if is_float_file(g_parse_file) and not is_runtime_path(rel) and not is_float_file(full) { float_file_add(full) } # a module reaches as far as its own files: a package found through $LUDIC_HOME or # ludic_modules is not beside its importer and keeps its own module (or none) @@ -1645,6 +1677,7 @@ function parse_program() -> void { g_gen_nodes = true # 0.S: what the finishing passes make is generated modules_finish() # L3: the declared uses have no cycle registries_finish() # L8: each registry gets its defs + at_check_refs() # every @Ref names a registry (attrs.ludic) ports_finish() # L3: each port gets its one bind views_finish() # L11: each view gets its model and call components_finish() # L11: each component gets its class diff --git a/selfhost/frontend/parse_game.ludic b/selfhost/frontend/parse_game.ludic index 2848b2f9..a1004c0d 100644 --- a/selfhost/frontend/parse_game.ludic +++ b/selfhost/frontend/parse_game.ludic @@ -11,14 +11,21 @@ function parse_component() -> Node { var is_sync = false # @Sync — this field replicates (NETWORKING N2) var is_frame = false # @frame — a fn stored here runs every frame (25.2) var cap = 0 # @max(n) — this list never holds more than n (25.5a) - if is_op("@") { + var fat = -1 # its attributes, for the schema (attrs.ludic) + while is_op("@") { # one or more, on its line or the lines above pi += 1 let ann = eat_id() - if ann == "Computed" { is_computed = true } else if ann == "Sync" { is_sync = true } else if ann == "frame" { is_frame = true } + if (ann == "Computed") or (ann == "Sync") or (ann == "frame") { + if ann == "Computed" { is_computed = true } else if ann == "Sync" { is_sync = true } else { is_frame = true } + if fat < 0 { fat = at_group() } + at_push(fat, ann, "[]", null, 0) + } else if ann == "owns" { # @owns(PhysShape) — this field holds a handle its record owns (25.5e) eat_op("(") g_own_pending = eat_id() eat_op(")") + if fat < 0 { fat = at_group() } + at_push(fat, ann, `["{g_own_pending}"]`, g_own_pending, 1) } else if ann == "max" { eat_op("(") @@ -26,14 +33,30 @@ function parse_component() -> Node { if ce.kind != E_INT or ce.ival <= 0 { perr("@max takes the most the list may hold: @max(64)") } cap = ce.ival eat_op(")") + if fat < 0 { fat = at_group() } + at_push(fat, ann, `[{itoa(cap)}]`, itoa(cap), 1) + } + else if at_field_known(ann) { # @Ref(Items), @Range(0, 1), @Unit("m/s"), ... + if fat < 0 { fat = at_group() } + at_field(fat, ann) + } + else if is_op("(") { # anything else: read past, as a declaration's is + var d = 0 + while true { if is_op("(") { d += 1 }; if is_op(")") { d -= 1 }; pi += 1; if d == 0 { break } } } skipnl() } let f = node(N_FIELD); f.s = eat_id(); eat_op(":"); f.ty = ptype() + f.at = fat if is_frame { push(g_frame_fields, `{n.s}.{f.s}`) } if cap > 0 { push(g_cap_keys, `{n.s}.{f.s}`); push(g_cap_vals, cap) } if g_own_pending != null { push(g_own_type, n.s); push(g_own_field, f.s); push(g_own_kind, g_own_pending); g_own_pending = null } - if is_op("=") { pi += 1; f.a = expr() } + if is_op("=") { + pi += 1 + let v0 = pi + f.a = expr() + f.sp0 = toks[v0].off; f.sp1 = toks[pi - 1].oend # the schema's text of the default + } if is_sync { f.ival = 1 } # mark the field replicable (read by emit_net) if is_computed { register_computed(n.s, f.s, f.ty, f.a) } # derived: no storage else { push(n.kids, f) } @@ -52,7 +75,12 @@ function parse_event() -> Node { n.s = eat_id(); skipnl(); eat_op("{") while true { skipnl(); if is_op("}") { break } let f = node(N_FIELD); f.s = eat_id(); eat_op(":"); f.ty = ptype() - if is_op("=") { pi += 1; f.a = expr() } + if is_op("=") { + pi += 1 + let v0 = pi + f.a = expr() + f.sp0 = toks[v0].off; f.sp1 = toks[pi - 1].oend + } push(n.kids, f) if is_op(",") { pi += 1 } } eat_op("}"); return n diff --git a/selfhost/frontend/registry.ludic b/selfhost/frontend/registry.ludic index 35482e09..f5bc5e20 100644 --- a/selfhost/frontend/registry.ludic +++ b/selfhost/frontend/registry.ludic @@ -16,9 +16,25 @@ var g_rg_type: []pointer = new []pointer var g_rg_prefix: []pointer = new []pointer var g_rg_var: []Node = new []Node var g_rg_open: []int = new []int # 1: `open registry` - other modules may def into it +var g_rg_from: []pointer = new []pointer # its resource file as written, or null +var g_rg_at: []int = new []int # its attributes (@AppendOnly, @ByKey), a group or -1 var g_df_reg: []pointer = new []pointer var g_df_key: []pointer = new []pointer var g_df_rec: []Node = new []Node +var g_df_kline: []int = new []int # where its key is written (the schema's) +var g_df_kcol: []int = new []int +var g_df_src: []pointer = new []pointer # the resource file it came from, or "" for a `def` +var g_df_via: []pointer = new []pointer # the declaration that brought it in: file:line +# one def, and where it came from +function df_push(reg: pointer, key: pointer, rec: Node, kline: int, kcol: int, src: pointer, via: pointer) -> void { + push(g_df_reg, reg) + push(g_df_key, key) + push(g_df_rec, rec) + push(g_df_kline, kline) + push(g_df_kcol, kcol) + push(g_df_src, src) + push(g_df_via, via) +} function reg_upper(s: pointer) -> pointer { let n = len(s) @@ -72,6 +88,8 @@ function parse_registry() -> void { push(g_rg_prefix, prefix) push(g_rg_var, v) push(g_rg_open, 0) + push(g_rg_from, from) + push(g_rg_at, at_registry_take()) # attrs.ludic if from != null { res_read(v, from) } } # def REGISTRY key { field: value, ... } @@ -85,12 +103,11 @@ function parse_def() -> void { res_read_into(reg, from, `def {reg} from`) return } + let kt = toks[pi] let key = eat_id() skipnl() let rec = record() - push(g_df_reg, reg) - push(g_df_key, key) - push(g_df_rec, rec) + df_push(reg, key, rec, kt.line, kt.col, "", `{g_parse_file}:{itoa(kt.line)}`) } function reg_find(name: pointer) -> int { var i = 0 diff --git a/selfhost/frontend/resource.ludic b/selfhost/frontend/resource.ludic index ef3b2493..e78e445d 100644 --- a/selfhost/frontend/resource.ludic +++ b/selfhost/frontend/resource.ludic @@ -28,6 +28,10 @@ function res_read_into(reg: pointer, from: pointer, who: pointer) -> void { let saved_pi = pi let saved_file = g_parse_file let saved_parsing = g_parsing + var via_line = 0 + if saved_pi > 0 { via_line = saved_toks[saved_pi - 1].line } + let via = `{saved_file}:{itoa(via_line)}` + fm_put(g_fm_src, path, src) # the schema's spans and docs read it again if not (module_of(saved_file) == "") { module_set(path, module_of(saved_file)) } if is_float_file(saved_file) and not is_float_file(path) { float_file_add(path) } g_parse_file = path @@ -38,12 +42,11 @@ function res_read_into(reg: pointer, from: pointer, who: pointer) -> void { while true { skipnl() if toks[pi].kind == TK_EOF { break } + let kt = toks[pi] let key = eat_id() skipnl() let rec = record() - push(g_df_reg, reg) - push(g_df_key, key) - push(g_df_rec, rec) + df_push(reg, key, rec, kt.line, kt.col, path, via) } g_res_mode = false toks = saved_toks diff --git a/selfhost/main.ludic b/selfhost/main.ludic index 582028f8..e759d396 100644 --- a/selfhost/main.ludic +++ b/selfhost/main.ludic @@ -55,6 +55,10 @@ function ensure_slash(d: pointer) -> pointer { } function die(msg: pointer) -> void { + if g_diag_json { + diag_add("", 0, 0, "error", msg) + diag_exit(1) + } file_write(file_stderr(), msg, len(msg)) exit(1) } @@ -154,6 +158,9 @@ entry { else if a == "--save-temps" { save = true } else if a == "--run" { run = true } else if a == "--check" { g_check_only = true } + else if a == "--diagnostics=json" { g_diag_json = true } # every error, as JSON on stdout (diag.ludic) + else if a == "--diagnostics=text" { g_diag_json = false } + else if a == "--emit-schema" { ai += 1; if ai < arg_count() { g_schema_out = arg(ai) } } # emit_schema.ludic else if a == "--globals" { g_allow_globals = true } else if a == "--migrate-state" { # 0.S2: write the plan ludic migrate state applies ai += 1 @@ -194,6 +201,7 @@ entry { let src = read_file(path) if (src == null) { die("ludicc: cannot open input\n") } + fm_put(g_fm_src, path, src) # the schema reads docs and spans from it cur_dir = dir_of(path) g_src_name = base_name(path) # for panic/expect file:line messages g_parse_file = path # for the compiler's own file:line diagnostics @@ -238,13 +246,21 @@ entry { file_close(sink) } if g_migrate { mg_finish() } + # --emit-schema FILE: the declarations, the registries and their entries as the checker resolved + # them (emit_schema.ludic); on its own it stops there, with -o or --check the build goes on + if g_schema_out != null { + if not schema_write(g_schema_out) { die(`ludicc: cannot write {g_schema_out}\n`) } + if not g_check_only and not has_target and not emit_ir { diag_exit(0) } + } # --check: every check a build makes - the emitter refuses things too (a bind to a function that is # gone, an unknown name) - and nothing written if g_check_only { emit_program() - exit(0) + if g_dg_errors > 0 { diag_exit(1) } + diag_exit(0) } emit_program() + if g_dg_errors > 0 { diag_exit(1) } # errors a pass went on from (diag.ludic) deps_flush() # LUDIC_DEPS=: the module graph (ludic deps) # --emit-llvm, or no binary target: emit IR and stop (stdout when out is null). diff --git a/tools/ludic-cli/main.ludic b/tools/ludic-cli/main.ludic index 0b6c02c4..77c24747 100644 --- a/tools/ludic-cli/main.ludic +++ b/tools/ludic-cli/main.ludic @@ -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/ (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() } diff --git a/tools/ludic-cli/project.ludic b/tools/ludic-cli/project.ludic index 7d2485bb..57788e67 100644 --- a/tools/ludic-cli/project.ludic +++ b/tools/ludic-cli/project.ludic @@ -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 diff --git a/tools/ludic-cli/schema.ludic b/tools/ludic-cli/schema.ludic new file mode 100644 index 00000000..094585fb --- /dev/null +++ b/tools/ludic-cli/schema.ludic @@ -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 +} diff --git a/tools/ludic-cli/scripts.ludic b/tools/ludic-cli/scripts.ludic index 0a29e7c2..8e44efe1 100644 --- a/tools/ludic-cli/scripts.ludic +++ b/tools/ludic-cli/scripts.ludic @@ -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 diff --git a/tools/ludic-cli/selfhost.ludic b/tools/ludic-cli/selfhost.ludic index d2231467..37b4da9d 100644 --- a/tools/ludic-cli/selfhost.ludic +++ b/tools/ludic-cli/selfhost.ludic @@ -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") diff --git a/tools/ludic-cli/test.ludic b/tools/ludic-cli/test.ludic index 576f0a05..546a47d3 100644 --- a/tools/ludic-cli/test.ludic +++ b/tools/ludic-cli/test.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 and this is a Pool", "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)")