feat(lang): L9 resource files - registry ... from

registry NAME of RECORD [as PREFIX] from "file.lres" fills a registry from a data
file of key { field: value } entries, read at compile time with the record as its
schema: every entry is checked like a def, errors name the resource file's line,
nested records and lists of them are bare { } / [{ }] typed by their fields, and
values are expressions in the registry's module. The entries are compiled in, so
nothing parses at start-up and a build that succeeds has validated its resources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-24 14:00:20 +03:00
parent 7e7f3ab297
commit e543525eb7
15 changed files with 46229 additions and 44690 deletions

View file

@ -246,6 +246,35 @@ the rule it always had: add an entry at the end, never between two. The order is
compiler reads them in, and an `import` is read where it stands: a file's imported defs come before
the defs written after the import line.
### Resource files (`registry ... from`)
A registry's entries can live in a data file instead of the source:
```ludic
# doc-check: skip — the file it names is beside the example
registry Tools of Tool as TL from "data/tools.lres"
```
```
# tools.lres
axe {
name: "Axe"
weight: 1.5
uses: [{ verb: "Chop", minutes: 20 }, { verb: "Split", minutes: 10 }]
}
lantern { name: "Lantern", weight: 0.75, uses: [] }
```
The file is a list of entries, each a key and a record, with `#` comments. It is read when the
program is compiled: the registry's record is its schema, so every entry is checked as a `def` is
- a field the record does not have, a string where it wants a number, a key twice - and the error
names the line in the resource file. A field whose type is a record takes a bare `{ ... }`, and one
typed as a list of records takes `[{ ... }, ...]`; the schema supplies the type. Values are Ludic
expressions in the registry's module, so an entry refers to another table's entry by its constant.
The entries are compiled in: nothing is parsed at start-up, and a build that succeeds has checked
every resource it uses. The path is the project's (where the build runs), else beside the file that
declares the registry.
### Memory is safe unless it says `unsafe`
The typed buffers are slices: `words(n)`, `floats(n)`, `fixeds(n)`, `doubles(n)` and

8
changes/resources.md Normal file
View file

@ -0,0 +1,8 @@
bump: minor
type: feature
**Resource files.** `registry Tools of Tool as TL from "data/tools.lres"` fills a registry from a
data file of `key { field: value }` entries. It is read at compile time with the registry's record as
its schema - a misspelt field, a wrong type or a repeated key is a compile error at the resource
file's line - and compiled in, so nothing parses at start-up. Nested records and lists of them are
bare `{ ... }` / `[{ ... }]`, typed by the schema; values are expressions, so an entry names another
table's entry by its constant.

View file

@ -0,0 +1,11 @@
# tools.lres - the tools a camp keeps, read by resources.ludic's registry when it is compiled
axe {
name: "Axe"
weight: 1.5
uses: [{ verb: "Chop", minutes: 20 }, { verb: "Split", minutes: 10 }]
}
lantern {
name: "Lantern"
weight: 0.75
uses: []
}

View file

@ -0,0 +1,24 @@
# resources.ludic — L9: a registry whose entries live in a resource file. `from` names the file;
# it is read when the program is compiled, every entry checked against the record (the schema),
# and compiled in - so a mistake is a compile error at the resource file's line, and nothing parses
# at start-up. A field whose type is a record, or a list of them, takes a bare `{ ... }`.
#
# Running it prints: 2 Axe 2 Split 10 0 1
program Resources {
numbers float
property Use {
verb: string = ""
minutes: int = 0
}
property Tool {
key: string = ""
name: string = ""
weight: float = 0.0
uses: []Use = null
}
registry Tools of Tool as TL from "data/tools.lres"
entry {
let axe = Tools[TL_AXE]
print(`{TL_COUNT} {axe.name} {len(axe.uses)} {axe.uses[1].verb} {axe.uses[1].minutes} {len(Tools[TL_LANTERN].uses)} {tools_find("lantern")}`)
}
}

View file

@ -0,0 +1,4 @@
axe {
name: "Axe"
weight: "heavy"
}

View file

@ -0,0 +1,11 @@
# L9: a resource file's entries are checked against the record, at the file's own line
program ResourceField {
numbers float
property Tool {
key: string = ""
name: string = ""
weight: float = 0.0
}
registry Tools of Tool from "data/bad_tools.lres"
entry { print(TOOLS_COUNT) }
}

View file

@ -0,0 +1,6 @@
# L9: a registry's resource file must exist
program ResourceMissing {
property Tool { key: string = "" }
registry Tools of Tool from "data/no_such.lres"
entry { print(TOOLS_COUNT) }
}

View file

@ -296,6 +296,7 @@ function p_primary() -> Node {
let n = node(E_ID); n.s = t.text; pi += 1; return n
}
if is_op("(") { pi += 1; skipnl(); let e = expr(); skipnl(); eat_op(")"); return e }
if g_res_mode and is_op("{") { return record() } # L9: a resource's nested record, typed by its field
perr("expected expression")
return node(E_INT)
}

View file

@ -58,12 +58,19 @@ function parse_registry() -> void {
pi += 1
prefix = eat_id()
}
var from: pointer = null
if is_id("from") {
pi += 1
from = toks[pi].text
pi += 1
}
v.ty = "[]" + t
push(prog, v)
push(g_rg_name, v.s)
push(g_rg_type, t)
push(g_rg_prefix, prefix)
push(g_rg_var, v)
if from != null { res_read(v, from) }
}
# def REGISTRY key { field: value, ... }
function parse_def() -> void {

View file

@ -50,6 +50,7 @@ function reg_fill(r: int) -> void {
fi.a = ks
push(rec.kids, fi)
}
if comp != null { res_type_fields(rec, comp) } # L9: nested records take their field's type
let nw = node(E_NEW)
nw.s = t
nw.a = rec

View file

@ -0,0 +1,87 @@
# resource.ludic — L9: a registry's entries can live in a resource file.
#
# registry Items of Item as IT from "assets/data/items.lres"
#
# The file is a list of entries, each a key and a record, `rope { name: "Rope", weight: 0.4 }`, with
# `#` comments; a field whose type is a record, or a list of records, takes a bare `{ ... }` or a
# `[{ ... }, ...]`, because the schema - the registry's record - says what it is. The file is read
# when the program is: every entry is checked like a `def` (a misspelt field, a string for an int,
# a key twice) and an error names the line in the resource file. Its values are Ludic expressions
# in the registry's module, so an entry names another table's entry by its constant (VD_STORE).
# The path is the project's (the directory the build runs in), else beside the declaring file.
var g_res_mode: bool = false
function res_path(from: pointer, declaring: pointer) -> pointer {
if read_file(from) != null { return from }
return join_path(dir_of(declaring), from)
}
function res_read(v: Node, from: pointer) -> void {
let path = res_path(from, g_parse_file)
let src = read_file(path)
if src == null { perr(`registry {v.s}: cannot read the resource file {from}`) }
let saved_toks = toks
let saved_pi = pi
let saved_file = g_parse_file
let saved_parsing = g_parsing
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) { push(g_float_files, path) }
g_parse_file = path
g_parsing = true
g_res_mode = true
lex_at(src, 1)
pi = 0
while true {
skipnl()
if toks[pi].kind == TK_EOF { break }
let key = eat_id()
skipnl()
let rec = record()
push(g_df_reg, v.s)
push(g_df_key, key)
push(g_df_rec, rec)
}
g_res_mode = false
toks = saved_toks
pi = saved_pi
g_parse_file = saved_file
g_parsing = saved_parsing
}
# a bare record, or a list of them, becomes a `new` of the type its field declares
function res_type(e: Node, ty: pointer) -> Node {
if e == null or ty == null { return e }
if e.kind == E_REC {
let comp = find_comp(ty)
if comp == null { return e }
res_type_fields(e, comp)
let nw = node(E_NEW)
nw.s = ty
nw.a = e
nw.file = e.file
nw.line = e.line
return nw
}
if e.kind == E_LIST and is_slice_ty(ty) {
if len(e.kids) == 0 {
let empty = node(E_NEW)
empty.s = ty
empty.file = e.file
empty.line = e.line
return empty
}
var i = 0
while i < len(e.kids) {
e.kids[i] = res_type(e.kids[i], slice_elem(ty))
i += 1
}
}
return e
}
function res_type_fields(rec: Node, comp: Node) -> void {
var i = 0
while i < len(rec.kids) {
let fi = rec.kids[i]
let fx = field_index(comp, fi.s)
if fx >= 0 { fi.a = res_type(fi.a, comp.kids[fx].ty) }
i += 1
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,7 @@ function selfhost_frags() -> []pointer {
push(f, "selfhost/frontend/aliases.ludic")
push(f, "selfhost/frontend/registry.ludic")
push(f, "selfhost/frontend/registry_finish.ludic")
push(f, "selfhost/frontend/resource.ludic")
push(f, "selfhost/backend/emit_core.ludic")
push(f, "selfhost/backend/emit_head.ludic")
push(f, "selfhost/backend/emit_addr.ludic")

View file

@ -742,6 +742,9 @@ function cmd_dev_test() -> int {
reject_case("rejected/registry_unknown", "there is no registry Tables", "a def names a registry that exists")
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("lang/resources", "", "2 Axe 2 Split 10 0 1", "resources.ludic (L9: a registry read from a resource file at compile time, nested records typed by the schema)")
reject_case("rejected/resource_field", "bad_tools.lres:3: error: field weight of Tool wants a float", "a resource file's entry is checked at its own line")
reject_case("rejected/resource_missing", "cannot read the resource file data/no_such.lres", "a registry's resource file must exist")
# EV2 the world table: the mod reflection ABI, callable from Ludic by name.
net_case("ecs/world_get", "50 1 7")