feat(lang): open registry - other modules' defs, in an order imports cannot change

A def from another module into a registry that is not open is refused,
and defs go through visibility (the registry exported, its module in the
definer's uses). Index order: the declaring module's entries, then the
other modules' by module name, each in reading order. A registry entry
is emitted as its own file's code, so what it names is seen from its
own module. Reseed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 04:28:34 +03:00
parent 05b09b4c98
commit 6cfaaf0bf9
19 changed files with 49801 additions and 47689 deletions

View file

@ -314,6 +314,40 @@ 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.
A registry belongs to its module, and a `def` written in another module is refused - unless the
registry says `open`:
```ludic
# doc-check: skip — a module spans files
# core/index.ludic
module core
export property System { key: string = "", run: fn() -> int = null }
export open registry Systems of System as SY
def Systems clock { run: fn clock_run }
# weather/index.ludic
module weather uses core
def Systems weather { run: fn weather_run } # weather_run may stay private to weather
```
A def into an open registry goes through visibility like any other reference: the registry must be
exported, and a module that says `uses` names the registry's module. What the entry itself names
(`fn weather_run`) is seen from the def's own module. The errors:
```
game.ludic:4: error: def Tools saw: registry Tools is not open to other modules; declare it 'open registry Tools' in module kit, or write the def there
game.ludic:4: error: Tools is private to module kit; mark it 'export' where it is declared (kit/index.ludic)
```
**The index order of an open registry** is the declaring module's own entries first, in the order
they are read, then every other module's: the modules in the order of their names, each one's
entries in the order they are read. `SY_CLOCK` is 0 however the program imports things, and an
entry from `alpha` comes before one from `zeta` even when `zeta` is imported first - so a table
saved by position keeps its meaning when a barrel's imports are reordered. The rule for a saved
table is still to append: a new entry goes at the end of its own module's list, and a new module
whose name sorts before an existing one moves that one's entries along. A registry whose defs are
all in its own module (or in no module) keeps exactly the order it always had.
### Resource files (`registry ... from`)
A registry's entries can live in a data file instead of the source:

7
changes/open-registry.md Normal file
View file

@ -0,0 +1,7 @@
bump: minor
type: feature
**`export open registry Items of Item` - a registry other modules may add to, in a stable order.**
A `def` from another module into a registry that is not open is refused, and a def now goes
through visibility like any reference (the registry exported, its module in the definer's `uses`).
An open registry's index order is the declaring module's entries first, then every other module's
by module name, each in the order it is read - independent of the order a barrel imports them in.

View file

@ -0,0 +1,17 @@
# open_registry.ludic — L8 + L3: `export open registry Systems of System` takes defs from other
# modules. The index order is the declaring module's entries, then every other module's by module
# name - alpha before zeta, although zeta is imported first - so it never depends on import order.
#
# Running it prints: clock animals weather / 2 2 3
import "reg/core"
import "reg/zeta"
import "reg/alpha"
program OpenRegistry {
entry {
var out = ""
for i in 0 .. SY_COUNT { out = out + Systems[i].key + " " }
let s = Systems[SY_ANIMALS]
let w = Systems[SY_WEATHER]
print(`{out}/ {s.run()} {SY_WEATHER} {w.run()}`)
}
}

View file

@ -0,0 +1,6 @@
# alpha/index.ludic - another module's entry
module alpha uses core
def Systems animals { run: fn animals_run }
function animals_run() -> int {
return 2
}

View file

@ -0,0 +1,11 @@
# core/index.ludic - declares the registry other modules add to, and its own first entry
module core
export property System {
key: string = ""
run: fn() -> int = null
}
export open registry Systems of System as SY
def Systems clock { run: fn clock_run }
function clock_run() -> int {
return 1
}

View file

@ -0,0 +1,6 @@
# zeta/index.ludic - adds an entry to core's open registry; its function stays private to zeta
module zeta uses core
def Systems weather { run: fn weather_run }
function weather_run() -> int {
return 3
}

View file

@ -0,0 +1,7 @@
# closed_kit/index.ludic - a registry that is not open
module kit
export property Tool {
name: string = ""
}
export registry Tools of Tool
def Tools axe { name: "Axe" }

View file

@ -0,0 +1,10 @@
# hidden_kit/index.ludic - open, but not exported: nothing outside kit can name it
module kit
export property Tool {
name: string = ""
}
open registry Tools of Tool
def Tools axe { name: "Axe" }
export function tool_count() -> int {
return TOOLS_COUNT
}

View file

@ -0,0 +1,8 @@
# L8 + L3: only an `open registry` takes defs from another module
import "closed_kit"
program RegistryClosed {
def Tools saw { name: "Saw" }
entry {
print(TOOLS_COUNT)
}
}

View file

@ -0,0 +1,8 @@
# L8 + L3: a def goes through visibility like any reference - the registry must be exported
import "hidden_kit"
program RegistryHidden {
def Tools saw { name: "Saw" }
entry {
print(tool_count())
}
}

View file

@ -139,14 +139,14 @@ function emit_push(e: Node) -> Val {
# is the first element's; an empty literal has none, so it is spelled `new []T`.
function emit_list(e: Node) -> Val {
if len(e.kids) == 0 { perr("an empty list literal has no element type: write `new []T` instead") }
let first = emit_expr(e.kids[0])
let first = emit_list_elem(e.kids[0])
let ty = "[]" + first.ty
let elt = llty(first.ty)
let s = emit_new_slice(ty)
emit_push_into(s.code, elt, first)
var i = 1
while i < len(e.kids) {
let v = emit_expr(e.kids[i])
let v = emit_list_elem(e.kids[i])
if not (v.ty == first.ty) { perr(`list literal mixes element types {first.ty} and {v.ty}`) }
emit_push_into(s.code, elt, v)
i += 1
@ -154,6 +154,15 @@ function emit_list(e: Node) -> Val {
return s
}
# an element is its own file's code: a registry's entries come from the files their defs are in
# (L8), and what an entry may see (L3) is its own module's view, not the registry's
function emit_list_elem(k: Node) -> Val {
let saved = g_err_file
if k.file != null { g_err_file = k.file }
let v = emit_expr(k)
g_err_file = saved
return v
}
# append the already-emitted value `v` (of LLVM element type `elt`) to the slice
# whose header is `h`, growing the storage when it is full
function emit_push_into(h: pointer, elt: pointer, v: Val) -> void {

View file

@ -1004,6 +1004,12 @@ function parse_one_decl() -> void {
if is_id("namespace") { parse_namespace(); return } # #76 namespace block
if is_id("port") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "{") { parse_port(); return } # L3 ports
if is_id("bind") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "{") { parse_bind(); return }
if is_id("open") and (toks[pi + 1].text == "registry") { # L8: open to other modules' defs
pi += 1
parse_registry()
g_rg_open[len(g_rg_open) - 1] = 1
return
}
if is_id("registry") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "of") { parse_registry(); return } # L8
if is_id("def") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].kind == TK_ID) { parse_def(); return } # L8
if is_id("view") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "{") { parse_view(); return } # L11

View file

@ -15,6 +15,7 @@ var g_rg_name: []pointer = new []pointer
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_df_reg: []pointer = new []pointer
var g_df_key: []pointer = new []pointer
var g_df_rec: []Node = new []Node
@ -70,6 +71,7 @@ function parse_registry() -> void {
push(g_rg_type, t)
push(g_rg_prefix, prefix)
push(g_rg_var, v)
push(g_rg_open, 0)
if from != null { res_read(v, from) }
}
# def REGISTRY key { field: value, ... }

View file

@ -8,6 +8,7 @@ function registries_finish() -> void {
g_parsing = false
perr(`def {g_df_reg[d]} {g_df_key[d]}: there is no registry {g_df_reg[d]}`)
}
reg_def_check(d) # registry_open.ludic: open, and visible
d += 1
}
var r = 0
@ -26,41 +27,41 @@ function reg_fill(r: int) -> void {
lst.line = v.line
let seen = new []pointer
var n = 0
var d = 0
while d < len(g_df_reg) {
if (g_df_reg[d] == g_rg_name[r]) {
let key = g_df_key[d]
let rec = g_df_rec[d]
var j = 0
while j < len(seen) {
if (seen[j] == key) {
g_err_file = rec.file
g_err_line = rec.line
g_parsing = false
perr(`def {g_rg_name[r]} {key} is declared twice`)
}
j += 1
let order = reg_order(r)
var o = 0
while o < len(order) {
let d = order[o]
let key = g_df_key[d]
let rec = g_df_rec[d]
var j = 0
while j < len(seen) {
if (seen[j] == key) {
g_err_file = rec.file
g_err_line = rec.line
g_parsing = false
perr(`def {g_rg_name[r]} {key} is declared twice`)
}
push(seen, key)
if keyed and not reg_rec_has(rec, "key") {
let fi = node(E_FINIT)
fi.s = "key"
let ks = node(E_STR)
ks.s = key
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
nw.file = rec.file
nw.line = rec.line
push(lst.kids, nw)
reg_const(v, `{g_rg_prefix[r]}_{reg_upper(key)}`, n)
n += 1
j += 1
}
d += 1
push(seen, key)
if keyed and not reg_rec_has(rec, "key") {
let fi = node(E_FINIT)
fi.s = "key"
let ks = node(E_STR)
ks.s = key
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
nw.file = rec.file
nw.line = rec.line
push(lst.kids, nw)
reg_const(v, `{g_rg_prefix[r]}_{reg_upper(key)}`, n)
n += 1
o += 1
}
reg_const(v, `{g_rg_prefix[r]}_COUNT`, n)
if n == 0 {

View file

@ -0,0 +1,80 @@
# registry_open.ludic — L8 + L3: which defs a registry takes, and in what order.
#
# A def written in another module than its registry's is refused unless the registry says `open
# registry`, and then it goes through the same visibility as any reference: the registry must be
# exported, and a module that says `uses` names the registry's module. The index order is the
# declaring module's own entries first, in the order they were read, then each other module's, the
# modules in the order of their names and each one's entries in the order they were read - so an
# entry's index does not depend on the order a barrel happens to import things in. A registry and
# its defs in one module, or in no module, keep exactly the order they always had.
function reg_def_check(d: int) -> void {
let r = reg_find(g_df_reg[d])
let v = g_rg_var[r]
let rec = g_df_rec[d]
let to = module_of(v.file)
let from = module_of(rec.file)
if (to == "") or (from == to) { return }
g_err_file = rec.file
g_err_line = rec.line
let saved = g_parsing
g_parsing = false
if g_rg_open[r] == 0 {
perr(`def {v.s} {g_df_key[d]}: registry {v.s} is not open to other modules; declare it 'open registry {v.s}' in module {to}, or write the def there`)
}
vis_check(v, v.s)
g_parsing = saved
}
# a < b, byte by byte
function reg_str_less(a: pointer, b: pointer) -> bool {
var i = 0
while i < len(a) and i < len(b) {
if a[i] != b[i] { return a[i] < b[i] }
i += 1
}
return len(a) < len(b)
}
# the defs of registry r, as indices into g_df_*, in index order
function reg_order(r: int) -> []int {
let out = new []int
let own = module_of(g_rg_var[r].file)
let others = new []pointer
var d = 0
while d < len(g_df_reg) {
if (g_df_reg[d] == g_rg_name[r]) {
let m = module_of(g_df_rec[d].file)
if (m == own) { push(out, d) }
else {
var seen = false
var j = 0
while j < len(others) {
if (others[j] == m) { seen = true }
j += 1
}
if not seen { push(others, m) }
}
}
d += 1
}
# a handful of modules: an insertion sort by name
var i = 1
while i < len(others) {
let x = others[i]
var j = i - 1
while j >= 0 and reg_str_less(x, others[j]) {
others[j + 1] = others[j]
j -= 1
}
others[j + 1] = x
i += 1
}
i = 0
while i < len(others) {
d = 0
while d < len(g_df_reg) {
if (g_df_reg[d] == g_rg_name[r]) and (module_of(g_df_rec[d].file) == others[i]) { push(out, d) }
d += 1
}
i += 1
}
return out
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,7 @@ function selfhost_frags() -> []pointer {
push(f, "selfhost/frontend/ports.ludic")
push(f, "selfhost/frontend/registry.ludic")
push(f, "selfhost/frontend/registry_finish.ludic")
push(f, "selfhost/frontend/registry_open.ludic")
push(f, "selfhost/frontend/resource.ludic")
push(f, "selfhost/frontend/defaults.ludic")
push(f, "selfhost/frontend/view.ludic")

View file

@ -765,6 +765,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("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)")
reject_case("rejected/registry_closed", "registry Tools is not open to other modules", "a def from another module into a registry that is not open is refused")
reject_case("rejected/registry_hidden", "Tools is private to module kit", "a def goes through visibility: the registry must be exported")
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")