feat(lang): L3 module scope - module, export, friend module

A barrel's 'module NAME' makes its directory a module; a declaration other modules
use says 'export'. Private use from another module is an error naming the module
and where to mark it; 'friend module' sees everything (a test harness); a file in
no module is public and a package keeps its own module. LUDIC_VIS_REPORT=1 lists
every violation instead of stopping, so a codebase can be given its exports first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-24 00:52:06 +03:00
parent 0c73287e35
commit 0677aee93f
20 changed files with 55913 additions and 54132 deletions

View file

@ -70,6 +70,46 @@ The same holds inside a function: a `let` or `var` declares its name once per bl
block, a loop variable or a parameter may reuse it), and a function with a result type must block, a loop variable or a parameter may reuse it), and a function with a result type must
`return` one on every path - running off the end of its body is an error, not a zero. `return` one on every path - running off the end of its body is an error, not a zero.
### Modules (`module`, `export`, `friend module`)
One namespace is not the same as one room. A barrel that says `module bank` makes its
directory a **module**: the barrel, every file it imports, and every file those import - until
one says `module` of its own - belong to `bank`. Inside a module every name is visible as
before. From anywhere else, a module's `function`, `var`, `const`, `property` or `event` is
reachable only if its declaration says `export`:
```ludic
# doc-check: skip — a module spans files
# bank/index.ludic
module bank
import "ledger.ludic"
# bank/ledger.ludic
var balance: int = 0 # private: only module bank sees it
export event Deposited { amount: int }
function add(n: int) -> void { balance += n }
export function deposit(n: int) -> void {
add(n)
emit Deposited(amount: n)
}
```
A program that imports `bank` may call `deposit` and listen to `Deposited`; calling `add` is
```
visible.ludic:5: error: add is private to module bank; mark it 'export' where it is declared (bank/ledger.ludic)
```
A file in no module - the program's own file, the runtime - is public, and a package found
through `ludic_modules` or the toolchain keeps its own module rather than its importer's. A
program that says `friend module lab` sees every module's private names: that is for a test
harness, which has to reach inside what it tests. `export` is a keyword before a declaration
and is not the `@export` annotation, which names a C symbol.
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
<file>` and the build goes on, so a script can add the `export`s the program already relies on.
## Models (entity kinds) ## Models (entity kinds)
An `model` names a *kind* of entity and the fixed set of properties it An `model` names a *kind* of entity and the fixed set of properties it

8
changes/module-scope.md Normal file
View file

@ -0,0 +1,8 @@
bump: minor
type: feature
**Modules.** `module NAME` in a barrel makes its directory a module, and a module's
declarations are private to it unless they say `export`: a function, global, record or event of
another module used without one is an error naming the module and where to mark it. `friend
module NAME` sees everything (a test harness), a file in no module is public, and a package keeps
its own module. `LUDIC_VIS_REPORT=1` lists every violation instead of stopping, so an existing
program can be given its exports by a script before the rule applies to it.

View file

@ -0,0 +1,3 @@
# bank/index.ludic - a module: only what it exports is reachable from outside
module bank
import "ledger.ludic"

View file

@ -0,0 +1,13 @@
# bank/ledger.ludic - inherits `module bank` from the barrel that imports it
var balance: int = 0
export event Deposited { amount: int }
function add(n: int) -> void {
balance += n
}
export function deposit(n: int) -> void {
add(n)
emit Deposited(amount: n)
}
export function total() -> int {
return balance
}

View file

@ -0,0 +1,14 @@
# visible.ludic — L3: a program in no module uses what module bank exports: a function to call
# and an event to listen to. Its private `add` and `balance` stay out of reach.
#
# Running it prints: 42 42
import "bank"
program Visible {
var heard: int = 0
@On(Deposited) handler Heard { heard += amount }
entry {
deposit(30)
deposit(12)
print(`{total()} {heard}`)
}
}

View file

@ -0,0 +1,3 @@
# bank/index.ludic - a module: only what it exports is reachable from outside
module bank
import "ledger.ludic"

View file

@ -0,0 +1,13 @@
# bank/ledger.ludic - inherits `module bank` from the barrel that imports it
var balance: int = 0
export event Deposited { amount: int }
function add(n: int) -> void {
balance += n
}
export function deposit(n: int) -> void {
add(n)
emit Deposited(amount: n)
}
export function total() -> int {
return balance
}

View file

@ -0,0 +1,8 @@
# L3: `add` is private to module bank; calling it from outside the module is refused
import "private_bank"
program Private {
entry {
add(5)
print(total())
}
}

View file

@ -1409,6 +1409,7 @@ function emit_call(e: Node) -> Val {
if (fn2 == null) { perr(`unknown function {name}`) } if (fn2 == null) { perr(`unknown function {name}`) }
cname = rtname cname = rtname
} }
vis_check(fn2, name)
reorder_named(e, param_labels(fn2)) reorder_named(e, param_labels(fn2))
# evaluate args first (their IR is emitted before the call instruction), coercing # evaluate args first (their IR is emitted before the call instruction), coercing
# each to the parameter's declared type so an int passed for a `long` widens. # each to the parameter's declared type so an int passed for a `long` widens.
@ -1471,6 +1472,7 @@ function emit_expr(e: Node) -> Val {
if li >= 0 { return emit_load_at(loc_reg[li], loc_ty[li]) } if li >= 0 { return emit_load_at(loc_reg[li], loc_ty[li]) }
let g = find_global(e.s) let g = find_global(e.s)
if (g != null) { if (g != null) {
vis_check(g, e.s)
# a const reference IS its initializer expression, carrying that # a const reference IS its initializer expression, carrying that
# expression's real type — so `const X: fixed = 10.0` yields a `fixed`, not # expression's real type — so `const X: fixed = 10.0` yields a `fixed`, not
# the raw Q16.16 bits mislabelled `int`. Every existing const is an int # the raw Q16.16 bits mislabelled `int`. Every existing const is an int

View file

@ -62,6 +62,7 @@ function fn_ty_ret(t: pointer) -> pointer {
function emit_fnref(e: Node) -> Val { function emit_fnref(e: Node) -> Val {
let d = find_fn(e.s) let d = find_fn(e.s)
if d == null { perr(`fn {e.s}: no function called {e.s}`) } if d == null { perr(`fn {e.s}: no function called {e.s}`) }
vis_check(d, e.s)
return val(`@fn_{e.s}`, fn_sig_of(d)) return val(`@fn_{e.s}`, fn_sig_of(d))
} }
# a call through a value of a function type # a call through a value of a function type

View file

@ -26,6 +26,7 @@ function rec_field(rec: Node, fname: pointer) -> Node {
function emit_new_struct(name: pointer, rec: Node) -> Val { function emit_new_struct(name: pointer, rec: Node) -> Val {
let s = layout_node(name) # a struct or a property — same shape let s = layout_node(name) # a struct or a property — same shape
if (s == null) { perr("unknown record type in new") } if (s == null) { perr("unknown record type in new") }
vis_check(s, name)
let lty = layout_ty(name) let lty = layout_ty(name)
let sz = emit_sizeof(lty) let sz = emit_sizeof(lty)
let obj = emit_bind(`call ptr @malloc(i64 {sz})`) let obj = emit_bind(`call ptr @malloc(i64 {sz})`)

View file

@ -41,6 +41,7 @@ function emit_assign(st: Node) -> void {
else { else {
let g = find_global(t.s) let g = find_global(t.s)
if (g == null) { perr(`assign to unknown {t.s}`) } if (g == null) { perr(`assign to unknown {t.s}`) }
vis_check(g, t.s)
addr = `@g_{t.s}`; ty = g.ty addr = `@g_{t.s}`; ty = g.ty
} }
} else { } else {
@ -291,6 +292,7 @@ function emit_match(st: Node) -> void {
function emit_emit(st: Node) -> Val { function emit_emit(st: Node) -> Val {
let ev = find_event(st.s) let ev = find_event(st.s)
if (ev == null) { perr(`emit: unknown event {st.s}`) } if (ev == null) { perr(`emit: unknown event {st.s}`) }
vis_check(ev, st.s)
# evaluate each payload field in declared order (default for a missing arg) # evaluate each payload field in declared order (default for a missing arg)
let fcodes = new []pointer let fcodes = new []pointer
let ftys = new []pointer let ftys = new []pointer

View file

@ -0,0 +1,37 @@
# emit_vis.ludic — L3: module scope. A file belongs to the module its own `module NAME` line or
# its importer's names (frontend/parse.ludic, module_of); a file in no module is public. A
# reference from one module to another module's function, global, record or event needs that
# declaration `export`ed, unless the referring module is a `friend module` (a test harness).
# LUDIC_VIS_REPORT=1 prints every violation as `vis: <file>: <module>.<name>` and goes on, so a
# tool can add the exports a codebase needs before the rule is switched on for it.
var g_vis_report: int = -1
function vis_reporting() -> bool {
if g_vis_report < 0 {
g_vis_report = 0
if getenv("LUDIC_VIS_REPORT") != null { g_vis_report = 1 }
}
return g_vis_report == 1
}
function vis_allowed(d: Node, from: pointer) -> bool {
if d.vis == 1 { return true }
if d.file == null { return true }
let to = module_of(d.file)
if to == "" { return true }
if from == to { return true }
return module_is_friend(from)
}
# `what` is the name as written at the reference
function vis_check(d: Node, what: pointer) -> void {
if d == null { return }
var here = g_err_file
if here == null { return }
let from = module_of(here)
if vis_allowed(d, from) { return }
let to = module_of(d.file)
if vis_reporting() {
let m = `vis: {d.file}:{itoa(d.line)}: {to}.{what} used from {here}\n`
file_write(file_stderr(), m, len(m))
return
}
perr(`{what} is private to module {to}; mark it 'export' where it is declared ({d.file})`)
}

View file

@ -658,6 +658,8 @@ function emit_event_fns() -> void {
g_term = false g_term = false
let next = lbl("evnext") let next = lbl("evnext")
g_ret_label = next g_ret_label = next
g_err_file = g_onlisten[i].file
vis_check(ev, en)
emit_block(g_onlisten[i].a) emit_block(g_onlisten[i].a)
g_ret_label = "ret" g_ret_label = "ret"
if not g_term { emit(` br label %{next}\n`) } if not g_term { emit(` br label %{next}\n`) }

View file

@ -85,6 +85,7 @@ property Node {
kids: []Node # variadic children kids: []Node # variadic children
line: int = 0 line: int = 0
file: pointer = null # the source file the node was parsed from (for diagnostics) file: pointer = null # the source file the node was parsed from (for diagnostics)
vis: int = 0 # L3: 1 when the declaration is `export`ed from its module
} }
# every node remembers where it was parsed (file + the line of the token the # every node remembers where it was parsed (file + the line of the token the

View file

@ -14,6 +14,32 @@ var g_parse_file: pointer = "" # the file whose tokens are being parsed
# `numbers float`: files whose bare decimal literals are float, not fixed. A file that says # `numbers float`: files whose bare decimal literals are float, not fixed. A file that says
# so is listed, and so is every non-runtime file it imports (a barrel passes it on). # so is listed, and so is every non-runtime file it imports (a barrel passes it on).
var g_float_files: []pointer = new []pointer var g_float_files: []pointer = new []pointer
# L3 modules: `module NAME` at the top of a file names the module it and everything it imports
# belong to, until an import names its own; `friend module NAME` may see every module's private
# names (a test harness). A file in no module - the runtime, a program's root - is public.
var g_mod_file: []pointer = new []pointer
var g_mod_name: []pointer = new []pointer
var g_mod_friends: []pointer = new []pointer
function module_of(f: pointer) -> pointer {
var i = len(g_mod_file) - 1
while i >= 0 {
if (g_mod_file[i] == f) { return g_mod_name[i] }
i -= 1
}
return ""
}
function module_set(f: pointer, name: pointer) -> void {
push(g_mod_file, f)
push(g_mod_name, name)
}
function module_is_friend(name: pointer) -> bool {
var i = 0
while i < len(g_mod_friends) {
if (g_mod_friends[i] == name) { return true }
i += 1
}
return false
}
function is_float_file(f: pointer) -> bool { function is_float_file(f: pointer) -> bool {
if (f == null) { return false } if (f == null) { return false }
var i = 0 var i = 0
@ -847,6 +873,36 @@ function parse_one_decl() -> void {
} }
skipnl() skipnl()
} }
if is_id("friend") and (toks[pi + 1].text == "module") {
pi += 2
let fm = eat_id()
module_set(g_parse_file, fm)
push(g_mod_friends, fm)
return
}
if is_id("module") and toks[pi + 1].kind == TK_ID {
pi += 1
module_set(g_parse_file, eat_id())
return
}
# `export function f`, `export var v`, ...: visible from other modules (L3)
if is_id("export") and (toks[pi + 1].kind == TK_ID) {
pi += 1
let p0 = len(prog)
let e0 = len(g_events)
parse_one_decl()
var k = p0
while k < len(prog) {
prog[k].vis = 1
k += 1
}
k = e0
while k < len(g_events) {
g_events[k].vis = 1
k += 1
}
return
}
if is_id("numbers") and (toks[pi + 1].text == "float") { if is_id("numbers") and (toks[pi + 1].text == "float") {
pi += 2 pi += 2
if not is_float_file(g_parse_file) { push(g_float_files, g_parse_file) } if not is_float_file(g_parse_file) { push(g_float_files, g_parse_file) }
@ -1041,6 +1097,10 @@ function do_import(rel: pointer) -> void {
if already_loaded(full) { return } if already_loaded(full) { return }
push(loaded_paths, full) push(loaded_paths, full)
if is_float_file(g_parse_file) and not is_runtime_path(rel) and not is_float_file(full) { push(g_float_files, full) } if is_float_file(g_parse_file) and not is_runtime_path(rel) and not is_float_file(full) { push(g_float_files, 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)
let beside = full == join_path(cur_dir, rel)
if beside and not is_runtime_path(rel) and not (module_of(g_parse_file) == "") { module_set(full, module_of(g_parse_file)) }
if (src == null) { perr(`cannot open import {full}`) } if (src == null) { perr(`cannot open import {full}`) }
# the audio runtime can arrive through atlas.ludic's own import or the Assets # the audio runtime can arrive through atlas.ludic's own import or the Assets
# splice, not only through an Audio.* call in the game; a windowed build must # splice, not only through an Audio.* call in the game; a windowed build must

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -54,6 +54,7 @@ function selfhost_frags() -> []pointer {
push(f, "selfhost/backend/emit_expr.ludic") push(f, "selfhost/backend/emit_expr.ludic")
push(f, "selfhost/backend/emit_call.ludic") push(f, "selfhost/backend/emit_call.ludic")
push(f, "selfhost/backend/emit_fnval.ludic") push(f, "selfhost/backend/emit_fnval.ludic")
push(f, "selfhost/backend/emit_vis.ludic")
push(f, "selfhost/backend/emit_stmt.ludic") push(f, "selfhost/backend/emit_stmt.ludic")
push(f, "selfhost/backend/game/emit_ecs.ludic") push(f, "selfhost/backend/game/emit_ecs.ludic")
push(f, "selfhost/backend/game/emit_query.ludic") push(f, "selfhost/backend/game/emit_query.ludic")

View file

@ -706,6 +706,8 @@ function cmd_dev_test() -> int {
reject_case("rejected/missing_return", "can reach its end without returning", "a function that can run off its end without its result is refused") reject_case("rejected/missing_return", "can reach its end without returning", "a function that can run off its end without its result is refused")
feat_case("functions/values", "", "42 10 25 49 81 1.5 1", "values.ludic (L2: fn types, fn name, calls through a local, global, field, element, parameter, result)") feat_case("functions/values", "", "42 10 25 49 81 1.5 1", "values.ludic (L2: fn types, fn name, calls through a local, global, field, element, parameter, result)")
reject_case("rejected/fn_type_mismatch", "is not a fn(int)->int", "a function of the wrong type is refused") reject_case("rejected/fn_type_mismatch", "is not a fn(int)->int", "a function of the wrong type is refused")
feat_case("modules/visible", "", "42 42", "visible.ludic (L3: a module's exported function and event used from outside it)")
reject_case("rejected/private_module", "add is private to module bank", "a private function of another module is refused")
# EV2 the world table: the mod reflection ABI, callable from Ludic by name. # EV2 the world table: the mod reflection ABI, callable from Ludic by name.
net_case("ecs/world_get", "50 1 7") net_case("ecs/world_get", "50 1 7")