feat(lang): a module's private records and events are its own

A property or event a module does not export no longer collides with
another module's of the same spelling: each private one is renamed for
its module, with the types, new, emit and @On written in that module.
Exported ones stay one namespace; two events of one spelling are now
refused. Generic records, entity components and component or view
records stay global. Reseed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 11:02:48 +03:00
parent 029a1ebba2
commit 69352babfd
17 changed files with 50199 additions and 44822 deletions

View file

@ -112,9 +112,16 @@ may share its spelling with a name in another module, in a file in no module, or
runtime: `shop` and `weather` may each have a private `seed`, and each module's code reaches its
own (a local of the same name still shadows it). There is no need to prefix a package's privates.
Exported names are one namespace across the program, so two modules that both `export function
seed` are still refused (`function 'seed' is defined twice`); and records and events - private or
not - still share one namespace. Where two names meet, the private one is compiled under its
module's name (`seed$shop`), which is the spelling a message about it may show.
seed` are still refused (`function 'seed' is defined twice`). Where two names meet, the private
one is compiled under its module's name (`seed$shop`), which is the spelling a message about it may
show.
The same holds for a `property` and an `event`: `fishing` and `hunting` may each have a private
`Catch` record and a private `Landed` event with different fields, and each module's types, `new`,
`emit` and `@On` reach its own (compiled as `Catch__fishing`). Two exported ones of one spelling are
refused (`'Catch' is defined twice`, `event 'Landed' is defined twice`). Three kinds of record stay
one namespace, because other code names them by spelling: a generic record, a property that is an
entity's component (a `model` names it), and the record a component or a view generates.
To move an existing codebase onto modules, build it once with `LUDIC_VIS_REPORT=1`: every
reference that would be refused is printed as `vis: <file>:<line>: <module>.<name> used from

7
changes/private-kinds.md Normal file
View file

@ -0,0 +1,7 @@
bump: minor
type: feature
**A module's private records and events are its own.** A `property` or `event` a module does not
export no longer collides with the same spelling in another module: each private one is compiled
under its module's name and its module's types, `new`, `emit` and `@On` follow it. Exported ones are
one namespace, and two events of one spelling are now refused (the first used to take the other's
emits silently). Generic records, entity components and component or view records stay global.

View file

@ -0,0 +1,20 @@
# fishing/index.ludic - a private record and a private event, spelt as hunting spells its own
module fishing
property Catch {
fish: int = 0
}
event Landed { fish: int }
var landed: int = 0
var last: Catch = null
@On(Landed) handler Count { landed += fish }
function catch_new(n: int) -> Catch {
let c = new Catch
c.fish = n
return c
}
export function fishing_day() -> int {
last = catch_new(3)
emit Landed(fish: last.fish)
emit Landed(fish: 4)
return landed
}

View file

@ -0,0 +1,15 @@
# hunting/index.ludic - a Catch and a Landed of its own, with other fields
module hunting
property Catch {
name: string = ""
weight: float = 0.0
}
event Landed { name: string }
var heard: string = ""
@On(Landed) handler Hear { heard = heard + name }
export function hunting_day() -> string {
let c = new Catch
c.name = "hare"
emit Landed(name: c.name)
return heard
}

View file

@ -0,0 +1,12 @@
# private_kinds.ludic — L3: a module's private records and events are its own too. fishing and
# hunting each have a record Catch and an event Landed, with different fields; each module's `new`,
# types, `emit` and `@On` reach its own.
#
# Running it prints: 7 hare
import "kinds/fishing"
import "kinds/hunting"
program PrivateKinds {
entry {
print(`{fishing_day()} {hunting_day()}`)
}
}

View file

@ -0,0 +1,8 @@
# L3: two modules that both export an event of one name clash
import "exported_events/a"
import "exported_events/b"
program ExportedEvents {
entry {
print(0)
}
}

View file

@ -0,0 +1,3 @@
# a/index.ludic - exports Landed
module a
export event Landed { fish: int }

View file

@ -0,0 +1,3 @@
# b/index.ludic - exports Landed too
module b
export event Landed { name: string }

View file

@ -0,0 +1,9 @@
# L3: two modules that both export a record of one name clash
import "exported_kinds/a"
import "exported_kinds/b"
program ExportedKinds {
entry {
let c = new Catch
print(0)
}
}

View file

@ -0,0 +1,5 @@
# a/index.ludic - exports Catch
module a
export property Catch {
fish: int = 0
}

View file

@ -0,0 +1,5 @@
# b/index.ludic - exports Catch too: exported records are one namespace
module b
export property Catch {
name: string = ""
}

View file

@ -4,7 +4,7 @@
# (`sp_seed` in module shop becomes `sp_seed$shop`), and so is every reference to it written in that
# module that no local shadows. Exported and public names stay one namespace, so a clash between two
# of those is still the error it was. Nothing is renamed when nothing clashes, so a program with no
# clash compiles exactly as before. Records and events still share one namespace.
# clash compiles exactly as before. Records and events: privates_types.ludic.
var g_rn_mod: []pointer = new []pointer
var g_rn_old: []pointer = new []pointer
var g_rn_new: []pointer = new []pointer
@ -107,8 +107,8 @@ function privates_rename() -> void {
}
a = b
}
if len(g_rn_old) == 0 { return }
priv_apply()
if len(g_rn_old) > 0 { priv_apply() }
privates_types() # records and events (privates_types.ludic)
}
# the rename for a reference to `s` written in file `f`, or null
function priv_find(s: pointer, f: pointer, want_fn: int) -> pointer {

View file

@ -0,0 +1,231 @@
# privates_types.ludic — L3: a module's private records and events are its own too. A `property`
# or an `event` a module does not export may share its spelling with another module's (or the
# runtime's): where two meet, each private one is renamed for its module (`Catch` in module fishing
# becomes `Catch__fishing`), and so is every use written in that module - a type in a signature, a
# field, a `let`, a `new`, an `emit` and an `@On`. Exported ones stay one namespace. Three kinds are
# left global, because other code names them by spelling: a generic record, a property that is an
# entity's component (named by a model), and a component's or a view's generated record.
var g_rt_mod: []pointer = new []pointer # records
var g_rt_old: []pointer = new []pointer
var g_rt_new: []pointer = new []pointer
var g_re_mod: []pointer = new []pointer # events
var g_re_old: []pointer = new []pointer
var g_re_new: []pointer = new []pointer
function pt_in(xs: []pointer, s: pointer) -> bool {
if xs == null { return false }
var i = 0
while i < len(xs) {
if (xs[i] == s) { return true }
i += 1
}
return false
}
# a record other code names by spelling: an entity's component, a generic's argument, a UI class
function pt_pinned(d: Node) -> bool {
if d.tps != null { return true }
if pt_in(g_cm_name, d.s) or pt_in(g_vw_name, d.s) { return true }
var i = 0
while i < len(g_gi_args) {
if has_sub(`|{g_gi_args[i]}|`, `|{d.s}|`) or has_sub(g_gi_args[i], `<{d.s}`) { return true }
i += 1
}
i = 0
while i < len(prog) {
let a = prog[i]
if a.kind == N_ARCH {
var k = 0
while k < len(a.kids) {
if (a.kids[k].s == d.s) { return true }
k += 1
}
}
i += 1
}
return false
}
function pt_is_record(k: int) -> bool { return k == N_COMP or k == N_STRUCT }
# records: every private one whose spelling another module (or the runtime) also declares
function pt_records() -> void {
var i = 0
while i < g_prog_user_end and i < len(prog) {
let d = prog[i]
let m = module_of(d.file)
if pt_is_record(d.kind) and d.vis != 1 and not (m == "") and d.s != null {
var other = false
var j = 0
while j < len(prog) and not other {
let o = prog[j]
if j != i and pt_is_record(o.kind) and (o.s == d.s) and not (priv_mod(j) == m) { other = true }
j += 1
}
if other and not pt_pinned(d) {
push(g_rt_mod, m)
push(g_rt_old, d.s)
push(g_rt_new, `{d.s}__{m}`)
}
}
i += 1
}
}
function pt_events() -> void {
var i = 0
while i < len(g_events) {
let d = g_events[i]
var m: pointer = ""
if d.file != null { m = module_of(d.file) }
if d.vis != 1 and not (m == "") {
var other = false
var j = 0
while j < len(g_events) {
let o = g_events[j]
var om: pointer = ""
if o.file != null { om = module_of(o.file) }
if j != i and (o.s == d.s) and not (om == m) { other = true }
j += 1
}
if other {
push(g_re_mod, m)
push(g_re_old, d.s)
push(g_re_new, `{d.s}__{m}`)
}
}
i += 1
}
}
function pt_id_ch(c: int) -> bool {
return (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9') or c == '_'
}
# `t` with every whole name `old` in it made `new` ("[]Catch" -> "[]Catch__fishing")
function pt_rewrite(t: pointer, old: pointer, nw: pointer) -> pointer {
if not has_sub(t, old) { return t }
var out: pointer = ""
var a = 0
var i = 0
let n = len(t)
while i <= n {
if i == n or not pt_id_ch(t[i]) {
if i > a and (t[a .. i] == old) { out = out + nw } else { out = out + t[a .. i] }
if i < n { out = out + t[i .. i + 1] }
a = i + 1
}
i += 1
}
return out
}
# a type text written in file `f`, with its module's private records renamed
function pt_ty(t: pointer, f: pointer) -> pointer {
if t == null or f == null { return t }
var out = t
var m: pointer = null
var k = 0
while k < len(g_rt_old) {
if has_sub(out, g_rt_old[k]) {
if m == null { m = module_of(f) }
if (m == g_rt_mod[k]) { out = pt_rewrite(out, g_rt_old[k], g_rt_new[k]) }
}
k += 1
}
return out
}
function pt_event(s: pointer, f: pointer) -> pointer {
if s == null or f == null { return s }
var k = 0
while k < len(g_re_old) {
if (g_re_old[k] == s) and (module_of(f) == g_re_mod[k]) { return g_re_new[k] }
k += 1
}
return s
}
function pt_walk(n: Node) -> void {
if n == null { return }
if n.ty != null and len(g_rt_old) > 0 { n.ty = pt_ty(n.ty, n.file) }
if n.kind == E_NEW and n.s != null and len(g_rt_old) > 0 { n.s = pt_ty(n.s, n.file) }
if n.kind == S_EMIT and len(g_re_old) > 0 { n.s = pt_event(n.s, n.file) }
pt_walk(n.a)
pt_walk(n.b)
pt_walk(n.c)
if n.kids != null {
var i = 0
while i < len(n.kids) {
pt_walk(n.kids[i])
i += 1
}
}
}
function pt_walk_all(xs: []Node) -> void {
if xs == null { return }
var i = 0
while i < len(xs) {
pt_walk(xs[i])
i += 1
}
}
function pt_apply() -> void {
var i = 0
while i < g_prog_user_end and i < len(prog) {
let d = prog[i]
if pt_is_record(d.kind) and d.vis != 1 and d.s != null {
var k = 0
while k < len(g_rt_old) {
if (g_rt_old[k] == d.s) and (module_of(d.file) == g_rt_mod[k]) {
d.s = g_rt_new[k]
k = len(g_rt_old)
}
k += 1
}
}
i += 1
}
i = 0
while i < len(g_events) {
let e = g_events[i]
if e.vis != 1 { e.s = pt_event(e.s, e.file) }
i += 1
}
i = 0
while i < g_prog_user_end and i < len(prog) {
pt_walk(prog[i])
i += 1
}
pt_walk_all(g_tests)
pt_walk_all(g_onlisten)
pt_walk_all(g_onspawn)
pt_walk_all(g_ondespawn)
pt_walk_all(g_onattach)
pt_walk_all(g_ondetach)
pt_walk_all(g_onenable)
pt_walk_all(g_ondisable)
pt_walk_all(g_computed)
pt_walk_all(g_scenes)
pt_walk_all(g_events)
# an @On listener names its event by spelling, in the listener's own file
i = 0
while i < len(g_onlisten) {
g_onlisten[i].s = pt_event(g_onlisten[i].s, g_onlisten[i].file)
i += 1
}
}
# two events of one spelling left after the renaming are two exports (or two in no module): an error,
# where it used to be the first one silently taking the other's emits
function pt_events_twice() -> void {
var i = 0
while i < len(g_events) {
var j = i + 1
while j < len(g_events) {
if (g_events[j].s == g_events[i].s) and g_events[j].file != null {
g_err_file = g_events[j].file
g_err_line = g_events[j].line
perr(`event '{g_events[i].s}' is defined twice (first in {g_events[i].file}:{itoa(g_events[i].line)})`)
}
j += 1
}
i += 1
}
}
function privates_types() -> void {
pt_records()
pt_events()
if len(g_rt_old) > 0 or len(g_re_old) > 0 { pt_apply() }
pt_events_twice()
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -23,6 +23,7 @@ function selfhost_frags() -> []pointer {
push(f, "selfhost/frontend/modules.ludic")
push(f, "selfhost/frontend/ports.ludic")
push(f, "selfhost/frontend/privates.ludic")
push(f, "selfhost/frontend/privates_types.ludic")
push(f, "selfhost/frontend/registry.ludic")
push(f, "selfhost/frontend/registry_finish.ludic")
push(f, "selfhost/frontend/registry_open.ludic")

View file

@ -821,6 +821,9 @@ function cmd_dev_test() -> int {
reject_case("rejected/uses_package", "mech uses ludic_inventory.inv_count", "a package's module counts under `uses` like any other")
reject_case("rejected/uses_render3d", "mech uses ludic_render3d.PI", "a package with no module line is still named under `uses`")
feat_case("modules/private_twins", "", "105 41 7", "private_twins.ludic (L3: two modules' private seed, dice and LIMIT, and the program's seed, do not collide)")
feat_case("modules/private_kinds", "", "7 hare", "private_kinds.ludic (L3: two modules' private record Catch and event Landed do not collide)")
reject_case("rejected/exported_kinds", "'Catch' is defined twice", "two modules that export one record still clash")
reject_case("rejected/exported_events", "event 'Landed' is defined twice", "two modules that export one event clash")
reject_case("rejected/exported_twins", "function 'seed' is defined twice", "two modules that export one name still clash")
vis_report_case("rejected/uses_missing", "uses: examples/rejected/uses_valley/items/index.ludic:3: items.inv_add used from", "LUDIC_VIS_REPORT=1 lists a uses violation and builds")
feat_case("lang/checked", "", "7.5 3 12 hi 2 ok", "checked.ludic (L4: literals take their slot's kind, string(p), []string as []pointer, null, named args)")