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:
parent
0c73287e35
commit
0677aee93f
20 changed files with 55913 additions and 54132 deletions
40
LANGUAGE.md
40
LANGUAGE.md
|
|
@ -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
|
||||
`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)
|
||||
|
||||
An `model` names a *kind* of entity and the fixed set of properties it
|
||||
|
|
|
|||
8
changes/module-scope.md
Normal file
8
changes/module-scope.md
Normal 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.
|
||||
3
examples/modules/bank/index.ludic
Normal file
3
examples/modules/bank/index.ludic
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# bank/index.ludic - a module: only what it exports is reachable from outside
|
||||
module bank
|
||||
import "ledger.ludic"
|
||||
13
examples/modules/bank/ledger.ludic
Normal file
13
examples/modules/bank/ledger.ludic
Normal 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
|
||||
}
|
||||
14
examples/modules/visible.ludic
Normal file
14
examples/modules/visible.ludic
Normal 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}`)
|
||||
}
|
||||
}
|
||||
3
examples/rejected/private_bank/index.ludic
Normal file
3
examples/rejected/private_bank/index.ludic
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# bank/index.ludic - a module: only what it exports is reachable from outside
|
||||
module bank
|
||||
import "ledger.ludic"
|
||||
13
examples/rejected/private_bank/ledger.ludic
Normal file
13
examples/rejected/private_bank/ledger.ludic
Normal 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
|
||||
}
|
||||
8
examples/rejected/private_module.ludic
Normal file
8
examples/rejected/private_module.ludic
Normal 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -1409,6 +1409,7 @@ function emit_call(e: Node) -> Val {
|
|||
if (fn2 == null) { perr(`unknown function {name}`) }
|
||||
cname = rtname
|
||||
}
|
||||
vis_check(fn2, name)
|
||||
reorder_named(e, param_labels(fn2))
|
||||
# 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.
|
||||
|
|
@ -1471,6 +1472,7 @@ function emit_expr(e: Node) -> Val {
|
|||
if li >= 0 { return emit_load_at(loc_reg[li], loc_ty[li]) }
|
||||
let g = find_global(e.s)
|
||||
if (g != null) {
|
||||
vis_check(g, e.s)
|
||||
# a const reference IS its initializer expression, carrying that
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ function fn_ty_ret(t: pointer) -> pointer {
|
|||
function emit_fnref(e: Node) -> Val {
|
||||
let d = find_fn(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))
|
||||
}
|
||||
# a call through a value of a function type
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ function rec_field(rec: Node, fname: pointer) -> Node {
|
|||
function emit_new_struct(name: pointer, rec: Node) -> Val {
|
||||
let s = layout_node(name) # a struct or a property — same shape
|
||||
if (s == null) { perr("unknown record type in new") }
|
||||
vis_check(s, name)
|
||||
let lty = layout_ty(name)
|
||||
let sz = emit_sizeof(lty)
|
||||
let obj = emit_bind(`call ptr @malloc(i64 {sz})`)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ function emit_assign(st: Node) -> void {
|
|||
else {
|
||||
let g = find_global(t.s)
|
||||
if (g == null) { perr(`assign to unknown {t.s}`) }
|
||||
vis_check(g, t.s)
|
||||
addr = `@g_{t.s}`; ty = g.ty
|
||||
}
|
||||
} else {
|
||||
|
|
@ -291,6 +292,7 @@ function emit_match(st: Node) -> void {
|
|||
function emit_emit(st: Node) -> Val {
|
||||
let ev = find_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)
|
||||
let fcodes = new []pointer
|
||||
let ftys = new []pointer
|
||||
|
|
|
|||
37
selfhost/backend/emit_vis.ludic
Normal file
37
selfhost/backend/emit_vis.ludic
Normal 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})`)
|
||||
}
|
||||
|
|
@ -658,6 +658,8 @@ function emit_event_fns() -> void {
|
|||
g_term = false
|
||||
let next = lbl("evnext")
|
||||
g_ret_label = next
|
||||
g_err_file = g_onlisten[i].file
|
||||
vis_check(ev, en)
|
||||
emit_block(g_onlisten[i].a)
|
||||
g_ret_label = "ret"
|
||||
if not g_term { emit(` br label %{next}\n`) }
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ property Node {
|
|||
kids: []Node # variadic children
|
||||
line: int = 0
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# so is listed, and so is every non-runtime file it imports (a barrel passes it on).
|
||||
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 {
|
||||
if (f == null) { return false }
|
||||
var i = 0
|
||||
|
|
@ -847,6 +873,36 @@ function parse_one_decl() -> void {
|
|||
}
|
||||
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") {
|
||||
pi += 2
|
||||
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 }
|
||||
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) }
|
||||
# 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}`) }
|
||||
# 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
|
||||
|
|
|
|||
54917
selfhost/ludicc.seed.ll
54917
selfhost/ludicc.seed.ll
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -54,6 +54,7 @@ function selfhost_frags() -> []pointer {
|
|||
push(f, "selfhost/backend/emit_expr.ludic")
|
||||
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_stmt.ludic")
|
||||
push(f, "selfhost/backend/game/emit_ecs.ludic")
|
||||
push(f, "selfhost/backend/game/emit_query.ludic")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
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")
|
||||
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.
|
||||
net_case("ecs/world_get", "50 1 7")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue