feat(lang): ports - port Clock { ... } in a module, bind Clock { ... } in the app

A port is a record of function values a module calls through
(Clock.now()) without naming the module that answers. A port used and
never bound, a bind missing a required member or naming one the port
lacks, and a second bind are refused; the binder must see the port, and
what it binds is checked from its own file. Reseed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 04:20:12 +03:00
parent d0ad364ad4
commit b1ccaf08bd
19 changed files with 50439 additions and 47750 deletions

View file

@ -141,6 +141,43 @@ fishing/land.ludic:4: error: fishing uses items.inv_add (items/index.ludic:3): a
- `LUDIC_VIS_REPORT=1` lists these too, as `uses: <file>:<line>: <module>.<name> used from <file>
(module <m>)`, and builds.
### Ports (`port`, `bind`)
A module that needs something from outside itself - the time, a save, a sound - declares a
**port** instead of naming (and `uses`-ing) the module that answers. A port is a record of
function values; a member with no default is required:
```ludic
# doc-check: skip — a module spans files
# clock/index.ludic
module clock uses units
export port Clock {
now: fn() -> int
day: fn() -> int = fn first_day
}
export function hour_of_day() -> int { return Clock.now() - (Clock.day() - 1) * HOURS }
# app/index.ludic - where the program is put together
module app uses clock
bind Clock { now: fn game_hours }
function game_hours() -> int { return g_hours }
```
Calls go through the port by name, `Clock.now()`. `clock` never names `app`, so it needs no
`uses app`; the binder must be able to see the port - it is `export`ed, and a binder that says
`uses` names the port's module. What the bind names (`fn game_hours`) is checked from the bind's
own file. The compiler refuses:
```
app.ludic:5: error: port Clock is used here but never bound: the program has to say 'bind Clock { ... }' once, where it is put together
app.ludic:9: error: bind Clock leaves out now, which has no default
app.ludic:11: error: port Clock is bound twice (first at app.ludic:10)
app.ludic:9: error: port Clock has no member later
```
and a member of the wrong type is a type error like any other (`field now of Clock_port wants a
fn()->int and this is a fn(int)->float`). A port nobody uses may stay unbound.
### Types are checked before anything is emitted
Between the parse and the emitter a checker walks every function, the entry, the tests, the

7
changes/ports.md Normal file
View file

@ -0,0 +1,7 @@
bump: minor
type: feature
**Ports: `port Clock { now: fn() -> int, day: fn() -> int = fn first_day }` in a module and
`bind Clock { now: fn game_hours }` where the program is put together.** A module asks for what it
needs through `Clock.now()` without naming - or `uses`-ing - the module that answers. A port used
and never bound, a bind that leaves out a member with no default, a member the port does not have
and a second bind are refused; the binder must see the (exported) port.

View file

@ -0,0 +1,15 @@
# ports.ludic — L3 ports: module clock needs the time and declares `port Clock`; module app binds
# it with its own function; clock calls through `Clock.now()` and never names app. A member with a
# default (`day`) may be left out of the bind.
#
# Running it prints: 30 5
import "station/units"
import "station/clock"
import "station/app"
program Ports {
entry {
let a = Clock.now()
advance(-25)
print(`{a} {hour_of_day()}`)
}
}

View file

@ -0,0 +1,10 @@
# app/index.ludic - the app puts the program together: it sees the port and fills it
module app uses clock
bind Clock { now: fn game_hours }
var g_hours: int = 30
function game_hours() -> int {
return g_hours
}
export function advance(h: int) -> void {
g_hours += h
}

View file

@ -0,0 +1,13 @@
# clock/index.ludic - the clock needs the time of day and asks for it through a port: it names
# no module that answers, so it `uses` only units
module clock uses units
export port Clock {
now: fn() -> int
day: fn() -> int = fn first_day
}
function first_day() -> int {
return 1
}
export function hour_of_day() -> int {
return Clock.now() - (Clock.day() - 1) * HOURS
}

View file

@ -0,0 +1,3 @@
# units/index.ludic - a module with nothing to ask of anyone
module units
export const HOURS: int = 24

View file

@ -0,0 +1,6 @@
# port_clock/index.ludic - a port that is not exported
module clock
port Clock { now: fn() -> int }
export function tell() -> int {
return Clock.now()
}

View file

@ -0,0 +1,14 @@
# L3: a bind gives every member that has no default
program PortMissing {
port Clock {
now: fn() -> int
day: fn() -> int
}
function hours() -> int {
return 3
}
bind Clock { now: fn hours }
entry {
print(Clock.now())
}
}

View file

@ -0,0 +1,11 @@
# L3: whoever binds a port has to see it - clock's Clock is not exported
import "port_clock"
program PortPrivate {
function hours() -> int {
return 3
}
bind Clock { now: fn hours }
entry {
print(tell())
}
}

View file

@ -0,0 +1,15 @@
# L3: a port is bound once
program PortTwice {
port Clock { now: fn() -> int }
function hours() -> int {
return 3
}
function minutes() -> int {
return 4
}
bind Clock { now: fn hours }
bind Clock { now: fn minutes }
entry {
print(Clock.now())
}
}

View file

@ -0,0 +1,7 @@
# L3: a port that is used and never bound is a program not yet put together
program PortUnbound {
port Clock { now: fn() -> int }
entry {
print(Clock.now())
}
}

View file

@ -72,7 +72,14 @@ function emit_global_init_fn() -> void {
# may see (L3), are that file's - not whichever statement was lowered last
g_err_file = d.file
g_err_line = d.line
# a port's bind is written in the app and checked there (frontend/ports.ludic)
if is_port_var(d) {
g_err_file = d.a.file
g_err_line = d.a.line
g_vis_off = true
}
let v = emit_expr(d.a)
g_vis_off = false
let cv = coerce_code(v, d.ty) # any conversion is its own line, before the store
emit(" store "); emit(llty(d.ty)); emit(" "); emit(cv); emit(", ptr @g_"); emit(d.s); emit("\n")
}

View file

@ -30,6 +30,7 @@ function vis_say(m: pointer) -> void {
}
function vis_check(d: Node, what0: pointer) -> void {
if d == null { return }
port_check_bound(d)
if g_vis_off { return }
if d.file == null { return }
let what = vis_plain(what0)

View file

@ -1002,6 +1002,8 @@ function parse_one_decl() -> void {
}
if is_id("ui") { push(prog, parse_ui()); return }
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("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
@ -1504,6 +1506,7 @@ function parse_program() -> void {
}
modules_finish() # L3: the declared uses have no cycle
registries_finish() # L8: each registry gets its defs
ports_finish() # L3: each port gets its one bind
views_finish() # L11: each view gets its model and call
components_finish() # L11: each component gets its class
}

View file

@ -0,0 +1,120 @@
# ports.ludic — L3 ports: what a module needs from outside itself, filled in by whoever puts the
# program together, so the module never names (or `uses`) the code that answers it.
#
# port Clock { now: fn() -> float, day: fn() -> int = fn default_day } # in module clock
# bind Clock { now: fn game_now } # in the app
# Clock.now() # anywhere that sees it
#
# A port is a record of function values (the record `Clock_port`, members without a default are
# required) and a global `Clock` of that record, in the port's module and exported with it. The
# one `bind` is that global's initializer. When the program is read (ports_finish) every bind
# must name a port, give each required member, give no member twice-over, and see the port;
# a port used anywhere must be bound (port_check_bound, at the use). The binding's values are
# checked for visibility from the bind's file, here, so the port's module needs no `uses` of it.
var g_pt_name: []pointer = new []pointer
var g_pt_comp: []Node = new []Node
var g_pt_var: []Node = new []Node
var g_pt_bind: []Node = new []Node # the bind that filled it; null until one does
var g_bd: []Node = new []Node # every bind, in source order: s=port a=record
# port NAME { member: fn(...) -> T [= fn default], ... }
function parse_port() -> void {
let c = parse_component()
let name = c.s
c.s = `{name}_port`
push(prog, c)
let v = node(N_VAR)
v.s = name
v.ty = c.s
v.line = c.line
push(prog, v)
push(g_pt_name, name)
push(g_pt_comp, c)
push(g_pt_var, v)
push(g_pt_bind, null)
}
# bind NAME { member: fn f, ... }
function parse_bind() -> void {
pi += 1
let b = node(N_VAR)
b.s = eat_id()
skipnl()
b.a = record()
push(g_bd, b)
}
function port_find(name: pointer) -> int {
var i = 0
while i < len(g_pt_name) {
if (g_pt_name[i] == name) { return i }
i += 1
}
return -1
}
function is_port_var(d: Node) -> bool {
if d.kind != N_VAR { return false }
let k = port_find(d.s)
if k < 0 { return false }
return (d.ty == g_pt_comp[k].s)
}
# a port used where none is bound: the program is not put together yet
function port_check_bound(d: Node) -> void {
if not is_port_var(d) { return }
if g_pt_bind[port_find(d.s)] != null { return }
perr(`port {d.s} is used here but never bound: the program has to say 'bind {d.s} {{ ... }}' once, where it is put together`)
}
function port_err(b: Node, msg: pointer) -> void {
g_err_file = b.file
g_err_line = b.line
perr(msg)
}
function port_bind_one(b: Node) -> void {
let k = port_find(b.s)
if k < 0 { port_err(b, `bind {b.s}: there is no port {b.s}`) }
let first = g_pt_bind[k]
if first != null { port_err(b, `port {b.s} is bound twice (first at {first.file}:{itoa(first.line)})`) }
g_pt_bind[k] = b
let c = g_pt_comp[k]
let rec = b.a
var i = 0
while i < len(rec.kids) {
if field_index(c, rec.kids[i].s) < 0 { port_err(b, `port {b.s} has no member {rec.kids[i].s}`) }
i += 1
}
i = 0
while i < len(c.kids) {
if c.kids[i].a == null and not reg_rec_has(rec, c.kids[i].s) {
port_err(b, `bind {b.s} leaves out {c.kids[i].s}, which has no default`)
}
i += 1
}
# what the bind names is seen from the bind's file: the port itself, and every `fn name`
g_err_file = b.file
g_err_line = b.line
let v = g_pt_var[k]
vis_check(v, b.s)
i = 0
while i < len(rec.kids) {
let e = rec.kids[i].a
if e != null and e.kind == E_FNREF {
let f = find_fn(e.s)
if f != null { vis_check(f, e.s) }
}
i += 1
}
let nw = node(E_NEW)
nw.s = c.s
nw.a = rec
nw.file = b.file
nw.line = b.line
v.a = nw
}
function ports_finish() -> void {
let saved = g_parsing
g_parsing = false
var i = 0
while i < len(g_bd) {
port_bind_one(g_bd[i])
i += 1
}
g_parsing = saved
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -21,6 +21,7 @@ function selfhost_frags() -> []pointer {
push(f, "selfhost/frontend/generics.ludic")
push(f, "selfhost/frontend/aliases.ludic")
push(f, "selfhost/frontend/modules.ludic")
push(f, "selfhost/frontend/ports.ludic")
push(f, "selfhost/frontend/registry.ludic")
push(f, "selfhost/frontend/registry_finish.ludic")
push(f, "selfhost/frontend/resource.ludic")

View file

@ -740,6 +740,11 @@ function cmd_dev_test() -> int {
reject_case("rejected/uses_cycle", "go round in a circle: a -> b -> a", "a cycle in the declared uses is refused")
feat_case("modules/friend_of", "", "7 7", "friend_of.ludic (L3: `friend module lab of bank` sees bank's private names)")
reject_case("rejected/friend_scope", "add is private to module bank", "a friend of one module is not a friend of another")
feat_case("modules/ports", "", "30 5", "ports.ludic (L3: `port Clock { ... }` in a module, `bind Clock { ... }` in the app, calls through Clock.now())")
reject_case("rejected/port_unbound", "port Clock is used here but never bound", "a port used and never bound is refused at the use")
reject_case("rejected/port_missing", "bind Clock leaves out day, which has no default", "a bind gives every required member")
reject_case("rejected/port_twice", "port Clock is bound twice (first at", "a port is bound once")
reject_case("rejected/port_private", "Clock is private to module clock", "whoever binds a port has to see it")
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)")
reject_case("rejected/wrong_arity", "this call to area leaves out h, which has no default", "a call with the wrong number of arguments is refused")