fix(lang): the action drain is the program's; --check runs every check a build makes; a registry key named count is refused; name lookups are tables

- the function that calls every reducer (and the action queue) is written in the program's own
  file: in the first action's file it belonged to that module, depended on every module with a
  reducer, and joined a game's modules into one 69-module cycle (examples/actions/modules and a
  ludic deps case hold it); the state instances, the queue and the reducers make no deps edges
- ludicc --check / ludic build --check lower the program too and write nothing, so the code
  writer's refusals are in it: a bind to a function that is gone, an unknown name (and the checker
  now refuses fn <missing> itself); rejects bind_missing_fn, unknown_name, registry_count_key
- def R count is refused: its constant would be PREFIX_COUNT, the registry's size
- a file's module, package, trust and numbers-float are tables, and from the check on the lookups
  of functions, enums, records, globals and externs are too (tagged enums kept as a list): Maroon
  Lake's check-only build went from about 20 s to 7 s including lowering, its IR from about 2
  minutes to under 10 s; duplicate declarations are found by table, not a pair of loops
- threads.ludic's pool check gives each call a little work, so a busy machine cannot run them all
  on the caller before a worker wakes (it failed one run in three under load)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 23:34:34 +03:00
parent 2fdefa6040
commit 8cf4b3fed6
24 changed files with 67169 additions and 64398 deletions

View file

@ -350,7 +350,7 @@ The self-hosted `ludicc`/`ludic` (built with `bin/ludic-dev build-cli`) accept:
--windowed force a windowed (Cocoa) build
--headless force a headless build (stdin input, out.ppm output)
--emit-llvm stop at LLVM IR — write it and exit, no clang
--check parse and check only (types, modules, uses, layers, ports); write nothing
--check every check a build makes (types, modules, uses, layers, ports, binds); write nothing
--fmt lex + parse only; exit 0 if it parses, 1 on a parse error
(the check-docs gate; canonical formatting not yet restored)
--save-temps keep the intermediate .ll

View file

@ -2025,7 +2025,7 @@ ludic test # compile and run the project's `test`
ludic test tests/math.ludic --test adds # just the test named "adds" (-v: every result line)
ludic test -j 4 # four tests at once (default: one per CPU)
ludic test packages/ludic.base # the test programs under a directory (a package's)
ludic build --check # only check: types, modules, uses and ports - nothing emitted
ludic build --check # every check a build makes, nothing written
ludic deps # how tangled the modules are, as the compiler resolved them
ludic deps --check tests/deps-baseline.txt # fail when a number rose (--baseline FILE writes them)
@ -2033,11 +2033,11 @@ ludicc app.ludic -o build/app # the compiler directly: a native binar
ludicc app.ludic --emit-llvm -o app.ll # stop at LLVM IR
```
`ludic build [file] --check` (the compiler's `ludicc --check`) runs everything the compiler checks
before it writes code - the parse, the types, `export`, `uses` and layers, ports and binds, registries
- and stops: no IR, no link. On Maroon Lake it takes about three seconds where a build takes about a
minute, for iterating on `uses` lines. Two checks belong to the code writer and only a build makes
them: a function that can reach its end without its result, and a local declared twice in a block.
`ludic build [file] --check` (the compiler's `ludicc --check`) runs every check a build makes - the
parse, the types, `export`, `uses` and layers, ports and binds, registries, and the code writer's own
(a function that can reach its end without its result, a bind to a function that is not there, an
unknown name) - and writes nothing: no IR, no link. On Maroon Lake it takes about four seconds, for
iterating on `uses` lines.
`ludic deps` compiles the program (the package's entry, or a file) with the compiler recording every
reference its visibility pass resolves - from the module it is written in to the module of what it

View file

@ -0,0 +1,10 @@
bump: patch
type: fix
**The action drain is the program's; `--check` is every check; `count` is not a registry key.**
The function that calls every reducer is written in the program's own file, so the module that
declares the actions no longer depends on every module that reduces them (a game's `base` joined a
69-module cycle). `ludic build --check` runs the code writer's checks too - a bind to a function that
is gone, an unknown name - and still writes nothing. A registry entry keyed `count` is refused
(`def Tools count: its constant would be TL_COUNT, which is the registry's size`). And a big
program builds much faster: the emitter's and the checker's lookups by name are tables, not walks of
the whole program (on Maroon Lake a check-only build, lowering included, went from about 20 s to under 10 s, its IR from about 2 minutes to under 10 s).

View file

@ -0,0 +1,4 @@
# acts/index.ludic - the actions, in a module that uses nothing: dispatching or declaring them makes
# no module depend on the modules whose reducers handle them
module acts uses
export action PickUp { item: int }

View file

@ -0,0 +1,5 @@
# bag/index.ludic - the bag's state and its reducer; it uses acts for the action's type
module bag uses acts
export state Bag { items: []int = new []int }
reducer Bag on PickUp(b: mut Bag, a: PickUp) { push(b.items, a.item) }
export function bag_count(b: Bag) -> int { return len(b.items) }

View file

@ -0,0 +1,12 @@
# modules.ludic - 0.R: an action declared in one module and reduced in another; the drain that calls
# every reducer is the program's own, so acts does not come to depend on bag (ludic deps: no cycle)
import "mods/acts"
import "mods/bag"
program ActionModules {
entry (b: Bag) {
dispatch PickUp { item: 4 }
dispatch PickUp { item: 5 }
drain_actions()
print(bag_count(b))
}
}

View file

@ -23,8 +23,13 @@ program Threads {
Sync.unlock(threads_st.lock)
}
# remember which calls ran on a pool thread
function placed(i: int, out: words) -> void { if Job.is_worker() { out[i] = 1 } else { out[i] = 0 } }
# remember which calls ran on a pool thread - each with a little work in it, so on a busy machine
# the calling thread cannot claim every index before a worker wakes
function placed(i: int, out: words) -> void {
var spin = 0
for k in 0 .. 2000 { spin = spin + k % 7 }
if Job.is_worker() { out[i] = 1 + spin * 0 } else { out[i] = spin * 0 }
}
entry (threads_st: mut ThreadsState) {
if Sync.cpu_count() >= 1 { print(1) }

View file

@ -0,0 +1,6 @@
# a port bound to a function that is not there: refused by a check-only build too
program BindMissingFn {
port Clock { now: fn() -> int }
bind Clock { now: fn no_such_clock }
entry { print(Clock.now()) }
}

View file

@ -0,0 +1,8 @@
# a def keyed `count` would be TL_COUNT, the registry's own size
program RegistryCountKey {
property Tool { key: string = "", n: int = 0 }
registry Tools of Tool as TL
def Tools count { n: 1 }
def Tools axe { n: 2 }
entry { print(TL_COUNT) }
}

View file

@ -0,0 +1,4 @@
# a name that is nothing: refused by a check-only build as by a build
program UnknownName {
entry { print(no_such_name + 1) }
}

View file

@ -151,7 +151,94 @@ function find_arch(name: pointer) -> Node {
while i < len(prog) { let d = prog[i]; if d.kind == N_ARCH and (d.s == name) { return d }; i += 1 }
return null
}
# ---- the emitter's lookups by name: tables, once the program is whole ---------------------
# find_fn / find_enum / find_comp / find_global / find_extern are asked for every name the emitter
# lowers; walking prog for each was most of a big program's build. From emit_program on, each kind
# is a table, kept up with what is appended to prog; a hit whose node has since been renamed falls
# back to the walk.
const IX_SLOTS: int = 131072
var g_ix_on: bool = false
var g_ix_tagged: []Node = new []Node
var g_ix_upto: int = 0
var g_ix_keys: [][]pointer = new [][]pointer # per kind: 0 fn, 1 enum, 2 comp, 3 global, 4 extern
var g_ix_vals: [][]pointer = new [][]pointer
function ix_start() -> void {
g_ix_keys = new [][]pointer
g_ix_vals = new [][]pointer
var t = 0
while t < 5 {
let k = new []pointer
let v = new []pointer
var i = 0
while i < IX_SLOTS {
push(k, null)
push(v, null)
i += 1
}
push(g_ix_keys, k)
push(g_ix_vals, v)
t += 1
}
g_ix_upto = 0
g_ix_tagged = new []Node
g_ix_on = true
}
function ix_kind(d: Node) -> int {
if d.kind == N_FN { return 0 }
if d.kind == N_ENUM { return 1 }
if d.kind == N_COMP { return 2 }
if d.kind == N_VAR or d.kind == N_CONST { return 3 }
if d.kind == N_EXTERN { return 4 }
return -1
}
function ix_slot(t: int, name: pointer) -> int {
var h = 5381
var i = 0
let n = len(name)
while i < n {
h = (h * 33 + name[i]) % 1000003
i += 1
}
h = h % IX_SLOTS
let ks = g_ix_keys[t]
while ks[h] != null and not (ks[h] == name) { h = (h + 1) % IX_SLOTS }
return h
}
function ix_sync() -> void {
while g_ix_upto < len(prog) {
let d = prog[g_ix_upto]
let t = ix_kind(d)
if d.kind == N_ENUM and enum_is_tagged(d) { push(g_ix_tagged, d) }
if t >= 0 and d.s != null {
let h = ix_slot(t, d.s)
if g_ix_keys[t][h] == null { # the first of a name wins, as the walk had it
g_ix_keys[t][h] = d.s
g_ix_vals[t][h] = d
}
}
g_ix_upto += 1
}
}
# the node of kind t called name, or null; -1 when the table cannot say (off, or a stale hit)
var g_ix_miss: bool = false
function ix_find(t: int, name: pointer) -> Node {
g_ix_miss = false
if not g_ix_on or name == null {
g_ix_miss = true
return null
}
ix_sync()
let h = ix_slot(t, name)
let p = g_ix_vals[t][h]
if p == null { return null }
let d: Node = p
if (d.s == name) and ix_kind(d) == t { return d }
g_ix_miss = true
return null
}
function find_comp(name: pointer) -> Node {
let x = ix_find(2, name)
if not g_ix_miss { return x }
var i = 0
while i < len(prog) { let d = prog[i]; if d.kind == N_COMP and (d.s == name) { return d }; i += 1 }
return null
@ -174,6 +261,8 @@ function field_type(s: Node, fname: pointer) -> pointer {
# find a global var/const by name
function find_global(name: pointer) -> Node {
let x = ix_find(3, name)
if not g_ix_miss { return x }
var i = 0
while i < len(prog) {
let d = prog[i]
@ -188,6 +277,14 @@ function find_global(name: pointer) -> Node {
# enum with that variant. Enum names live in `prog` like any other declaration.
function enum_ordinal(ename: pointer, vname: pointer) -> int {
var i = 0
if g_ix_on { # the table knows the one enum of that name
let d = find_enum(ename)
if d != null {
var j = 0
while j < len(d.kids) { if (d.kids[j].s == vname) { return j }; j += 1 }
}
i = len(prog)
}
while i < len(prog) {
let d = prog[i]
if d.kind == N_ENUM and (d.s == ename) {
@ -234,6 +331,8 @@ function has_countdowns() -> bool {
return false
}
function find_fn(name: pointer) -> Node {
let x = ix_find(0, name)
if not g_ix_miss { return x }
var i = 0
while i < len(prog) { let d = prog[i]; if d.kind == N_FN and (d.s == name) { return d }; i += 1 }
return null
@ -248,6 +347,8 @@ function find_fn(name: pointer) -> Node {
var g_var_ord: int = 0
function find_enum(name: pointer) -> Node {
let x = ix_find(1, name)
if not g_ix_miss { return x }
var i = 0
while i < len(prog) { let d = prog[i]; if d.kind == N_ENUM and (d.s == name) { return d }; i += 1 }
return null
@ -276,6 +377,16 @@ function variant_ordinal(en: Node, vname: pointer) -> int {
# Only tagged enums participate, so a plain enum's `Enum.Variant` member form and
# any like-named function are left alone.
function variant_enum(vname: pointer) -> Node {
if g_ix_on { # the tagged enums, kept as prog grows
ix_sync()
var t = 0
while t < len(g_ix_tagged) {
let ord = variant_ordinal(g_ix_tagged[t], vname)
if ord >= 0 { g_var_ord = ord; return g_ix_tagged[t] }
t += 1
}
return null
}
var i = 0
while i < len(prog) {
let d = prog[i]
@ -306,6 +417,8 @@ function enum_box_size(en: Node) -> int { return 8 * (1 + enum_max_arity(en)) }
# is the transport seam (net_send/net_poll), the windowing/socket FFI, and any
# C/Rust/Zig library binding — the same seam NETWORKING-DESIGN §5 names.
function find_extern(name: pointer) -> Node {
let x = ix_find(4, name)
if not g_ix_miss { return x }
var i = 0
while i < len(prog) { let d = prog[i]; if d.kind == N_EXTERN and (d.s == name) { return d }; i += 1 }
return null

View file

@ -213,19 +213,19 @@ function emit_test_runner() -> void {
# two `function`s with one name would collide in the object file; say so in
# source terms (and name both files) instead of leaving it to the IR assembler.
function check_duplicate_fns() -> void {
let k = new []pointer # by name, in a table: a pair of loops was quadratic
let v = new []Node
ck_tab_init(k, v)
var i = 0
while i < len(prog) {
let d = prog[i]
if d.kind == N_FN {
var j = i + 1
while j < len(prog) {
let o = prog[j]
if o.kind == N_FN and (o.s == d.s) {
g_err_file = o.file; g_err_line = o.line
perr(`function '{d.s}' is defined twice (first in {d.file}:{itoa(d.line)})`)
}
j += 1
let first = ck_tab_get(k, v, d.s)
if first != null {
g_err_file = d.file; g_err_line = d.line
perr(`function '{d.s}' is defined twice (first in {first.file}:{itoa(first.line)})`)
}
ck_tab_put(k, v, d.s, d)
}
i += 1
}
@ -240,22 +240,23 @@ function decl_group(k: int) -> int {
return 0
}
function check_duplicate_decls() -> void {
let k = new []pointer
let v = new []Node
ck_tab_init(k, v)
let n = g_prog_user_end
var i = 0
while i < n and i < len(prog) {
let d = prog[i]
let g = decl_group(d.kind)
if g > 0 {
var j = i + 1
while j < n and j < len(prog) {
let o = prog[j]
if decl_group(o.kind) == g and (o.s == d.s) {
g_err_file = o.file
g_err_line = o.line
perr(`'{d.s}' is defined twice (first in {d.file}:{itoa(d.line)})`)
}
j += 1
let key = `{itoa(g)}:{d.s}`
let first = ck_tab_get(k, v, key)
if first != null {
g_err_file = d.file
g_err_line = d.line
perr(`'{d.s}' is defined twice (first in {first.file}:{itoa(first.line)})`)
}
ck_tab_put(k, v, key, d)
}
i += 1
}
@ -264,6 +265,7 @@ function check_duplicate_decls() -> void {
function emit_program() -> void {
check_duplicate_fns()
check_duplicate_decls()
ix_start() # the lookups by name, as tables from here on
head = buf_new()
code = buf_new()
g_uses_str = false

View file

@ -29,17 +29,29 @@ function vis_plain(what: pointer) -> pointer {
function vis_say(m: pointer) -> void {
file_write(file_stderr(), m, len(m))
}
# does s start with p - without making the slice a comparison of s[0 .. n] would (asked for every
# reference)
function str_prefix(s: pointer, p: pointer) -> bool {
if s == null { return false }
let n = len(p)
if len(s) < n { return false }
var i = 0
while i < n {
if s[i] != p[i] { return false }
i += 1
}
return true
}
function vis_check(d: Node, what0: pointer) -> void {
if d == null { return }
port_check_bound(d)
# 0.S/0.R: a state's instance, the action queue and a reducer are the runtime's to supply and to
# call - the code that names them depends on no module for it
if d.kind == N_VAR and d.uns == 1 and str_prefix(d.s, "state$") and is_state_ty(d.ty) { return }
if d.kind == N_FN and (str_prefix(d.s, "ludic_reduce__") or is_action_builtin(d.s)) { return }
deps_edge(d, what0) # LUDIC_DEPS: the graph ludic deps reads (emit_deps.ludic)
if g_vis_off { return }
if d.file == null { return }
# 0.S: a state's instance, supplied by the runtime (a component's or a view's glue, an entry's
# parameters): the code that names it only passes it on
if d.kind == N_VAR and d.uns == 1 and is_state_ty(d.ty) and len(d.s) > 6 and (d.s[0 .. 6] == "state$") { return }
# 0.R: the action queue and the reducers are the runtime's to call, from any module
if d.kind == N_FN and (is_action_builtin(d.s) or (len(d.s) > 14 and (d.s[0 .. 14] == "ludic_reduce__"))) { return }
let what = vis_plain(what0)
var here = g_err_file
if here == null { return }

View file

@ -49,6 +49,9 @@ function ck_expr(e: Node) -> pointer {
let f = ck_fn(e.s)
ck_vis(f, e.s, e)
if f != null { return fn_sig_of(f) }
# the emitter's own refusal, here too, so a check-only build finds it (a bind to a function
# that is gone)
if find_fn(e.s) == null { ck_err("fnref", e, `fn {e.s}: no function called {e.s}`) }
return "?"
}
if k == E_NEW { return ck_new(e) }

View file

@ -19,6 +19,7 @@ var g_red_action: []pointer = new []pointer
var g_dsp_ids: []Node = new []Node # each dispatch's action number, filled at the end
var g_dsp_names: []pointer = new []pointer
var g_dsp_at: []Node = new []Node
var g_act_main: pointer = null # the program's own file, where the drain is written
const ACTION_PASSES: int = 64
# action NAME { fields } - a record
@ -101,12 +102,11 @@ function actions_finish() -> void {
g_dsp_ids[i].ival = k
i += 1
}
# the queue and the drain are the program's, written in its own file: in the file of the first
# action they would belong to its module, and it would call every module's reducers
var file = g_parse_file
var line = 1
if len(g_act_nodes) > 0 {
file = g_act_nodes[0].file
line = g_act_nodes[0].line
}
if g_act_main != null { file = g_act_main }
let line = 1
# no actions: drain_actions() is still there (a ludic.base runner calls it), and does nothing
if len(g_act_names) == 0 {
vw_parse("export function drain_actions() -> void {\n}\n", file, line, 1)

View file

@ -128,15 +128,9 @@ function pkg_name_of(rel: pointer) -> pointer {
function pkg_file_set(f: pointer, name: pointer) -> void {
push(g_pk_file, f)
push(g_pk_name, name)
fm_put(g_fm_pkg, f, name)
}
function pkg_of_file(f: pointer) -> pointer {
var i = len(g_pk_file) - 1
while i >= 0 {
if (g_pk_file[i] == f) { return g_pk_name[i] }
i -= 1
}
return ""
}
function pkg_of_file(f: pointer) -> pointer { return fm_get(g_fm_pkg, f) }
# the module a declaration is in, for the uses rule: its own, else its package's
function module_for_uses(f: pointer) -> pointer {
let m = module_of(f)

View file

@ -25,36 +25,77 @@ var g_trusted_files: []pointer = new []pointer
var g_unsafe_all: bool = false
function unsafe_trusted(f: pointer) -> bool {
if g_unsafe_all { return true }
return not (fm_get(g_fm_trusted, f) == "")
}
# a file's facts - its module, its package, whether it is trusted - asked of every reference the
# checker and the emitter resolve: a table by file name, not a list walked each time (with a game's
# nine hundred files that walk was most of a check-only build)
const FM_SLOTS: int = 16384
property FileMap {
keys: []pointer = null
vals: []pointer = null
}
function fm_new() -> FileMap {
let m = new FileMap
m.keys = new []pointer
m.vals = new []pointer
var i = 0
while i < len(g_trusted_files) {
if (g_trusted_files[i] == f) { return true }
while i < FM_SLOTS {
push(m.keys, null)
push(m.vals, null)
i += 1
}
return false
return m
}
function fm_slot(m: FileMap, f: pointer) -> int {
var h = 5381
var i = 0
let n = len(f)
while i < n {
h = (h * 33 + f[i]) % 1000003
i += 1
}
h = h % FM_SLOTS
while m.keys[h] != null and not (m.keys[h] == f) { h = (h + 1) % FM_SLOTS }
return h
}
# the latest value set for f wins, as the lists' walk from the end had it
function fm_put(m: FileMap, f: pointer, v: pointer) -> void {
if f == null { return }
let h = fm_slot(m, f)
m.keys[h] = f
m.vals[h] = v
}
function fm_get(m: FileMap, f: pointer) -> pointer {
if f == null { return "" }
let h = fm_slot(m, f)
if m.keys[h] == null { return "" }
return m.vals[h]
}
var g_fm_mod: FileMap = fm_new()
var g_fm_pkg: FileMap = fm_new()
var g_fm_trusted: FileMap = fm_new()
function trust_file(f: pointer) -> void {
push(g_trusted_files, f)
fm_put(g_fm_trusted, f, "1")
}
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_of(f: pointer) -> pointer { return fm_get(g_fm_mod, f) }
function module_set(f: pointer, name: pointer) -> void {
push(g_mod_file, f)
push(g_mod_name, name)
fm_put(g_fm_mod, f, name)
}
function is_float_file(f: pointer) -> bool {
if (f == null) { return false }
var i = 0
while i < len(g_float_files) {
if (g_float_files[i] == f) { return true }
i += 1
return not (fm_get(g_fm_float, f) == "")
}
return false
var g_fm_float: FileMap = fm_new()
function float_file_add(f: pointer) -> void {
push(g_float_files, f)
fm_put(g_fm_float, f, "1")
}
var g_parsing: bool = true # false once lowering starts
var g_err_file: pointer = "" # the statement being lowered
@ -952,7 +993,7 @@ function parse_one_decl() -> void {
}
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) }
if not is_float_file(g_parse_file) { float_file_add(g_parse_file) }
return
}
if is_id("import") { pi += 1
@ -1180,11 +1221,11 @@ 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) }
if is_float_file(g_parse_file) and not is_runtime_path(rel) and not is_float_file(full) { float_file_add(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 is_runtime_path(rel) or not beside or (beside and unsafe_trusted(g_parse_file)) { push(g_trusted_files, full) }
if is_runtime_path(rel) or not beside or (beside and unsafe_trusted(g_parse_file)) { trust_file(full) }
if beside and not is_runtime_path(rel) and not (module_of(g_parse_file) == "") { module_set(full, module_of(g_parse_file)) }
if not beside and not is_runtime_path(rel) { pkg_file_set(full, pkg_name_of(rel)) } # modules.ludic
if beside and not is_runtime_path(rel) and not (pkg_of_file(g_parse_file) == "") { pkg_file_set(full, pkg_of_file(g_parse_file)) }

View file

@ -44,6 +44,12 @@ function reg_fill(r: int) -> void {
j += 1
}
push(seen, key)
if (reg_upper(key) == "COUNT") {
g_err_file = rec.file
g_err_line = rec.line
g_parsing = false
perr(`def {g_rg_name[r]} {key}: its constant would be {g_rg_prefix[r]}_COUNT, which is the registry's size; give the entry another key`)
}
if keyed and not reg_rec_has(rec, "key") {
let fi = node(E_FINIT)
fi.s = "key"

View file

@ -29,7 +29,7 @@ function res_read_into(reg: pointer, from: pointer, who: pointer) -> void {
let saved_file = g_parse_file
let saved_parsing = g_parsing
if not (module_of(saved_file) == "") { module_set(path, module_of(saved_file)) }
if is_float_file(saved_file) and not is_float_file(path) { push(g_float_files, path) }
if is_float_file(saved_file) and not is_float_file(path) { float_file_add(path) }
g_parse_file = path
g_parsing = true
g_res_mode = true

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -188,6 +188,7 @@ entry {
cur_dir = dir_of(path)
g_src_name = base_name(path) # for panic/expect file:line messages
g_parse_file = path # for the compiler's own file:line diagnostics
g_act_main = path # 0.R: where the action drain is written
lex(src)
parse_program()
g_prog_user_end = len(prog)
@ -217,9 +218,15 @@ entry {
check_duplicate_decls()
g_mg_entry = path
if g_migrate { mg_collect() } # 0.S2: the vars that move (migrate.ludic)
ix_start() # the lookups by name, as tables (emit_core.ludic)
check_program() # L4: the types agree before anything is emitted
if g_migrate { mg_finish() }
if g_check_only { exit(0) } # --check: the checks are all there is
# --check: every check a build makes - the emitter refuses things too (a bind to a function that is
# gone, an unknown name) - and nothing written
if g_check_only {
emit_program()
exit(0)
}
emit_program()
deps_flush() # LUDIC_DEPS=<file>: the module graph (ludic deps)

View file

@ -91,7 +91,7 @@ function dp_load(path: pointer) -> bool {
if len(w) >= 6 and w[0] == "alias" { push(dp_aliases, lines[i]) }
if len(w) >= 5 and w[0] == "width" and w[4] == "0" and s_to_int(w[1]) > dp_wide_n {
dp_wide_n = s_to_int(w[1])
dp_wide_at = `{w[2]} ({w[3]})`
dp_wide_at = `{dp_fn_name(w[2])} ({w[3]})`
}
if len(w) >= 5 and w[0] == "write" {
push(dp_wowner, w[1])
@ -102,6 +102,15 @@ function dp_load(path: pointer) -> bool {
}
return true
}
# a function as its source names it: a reducer is `reducer Bag on PickUp`, not its symbol
function dp_fn_name(n: pointer) -> pointer {
if not s_starts(n, "ludic_reduce__") { return n }
let rest = n[14 .. slen(n)]
for i in 0 .. slen(rest) - 1 {
if rest[i] == '_' and rest[i + 1] == '_' { return `reducer {rest[i + 2 .. slen(rest)]} on {rest[0 .. i]}` }
}
return n
}
function dp_own(k: int) -> bool { return dp_pkg[k] == 0 }
# an edge the numbers count: between two of the program's own modules
function dp_counted(e: int) -> bool { return dp_own(dp_ef[e]) and dp_own(dp_et[e]) }

View file

@ -500,9 +500,9 @@ function deps_case() -> void {
# --check: the module rules, the ports and the types, with nothing emitted - a broken uses line is
# refused as a full build refuses it, and a sound program writes no file
function check_build_case() -> void {
let lbl = "ludicc --check / ludic build --check: modules, uses, ports and types without emitting"
let bad = ["rejected/uses_missing", "rejected/layer_reach", "rejected/private_module", "rejected/port_unbound", "rejected/registry_hidden", "rejected/port_new", "rejected/wrong_arity"]
let want = ["fishing uses items.inv_add", "hud uses items.item_count", "add is private to module bank", "port Clock is used here but never bound", "Tools is private to module kit", "Clock is a port", "leaves out h"]
let lbl = "ludicc --check / ludic build --check: every check a build makes (binds, names, registries too), nothing written"
let bad = ["rejected/uses_missing", "rejected/layer_reach", "rejected/private_module", "rejected/port_unbound", "rejected/registry_hidden", "rejected/port_new", "rejected/wrong_arity", "rejected/bind_missing_fn", "rejected/unknown_name", "rejected/registry_count_key"]
let want = ["fishing uses items.inv_add", "hud uses items.item_count", "add is private to module bank", "port Clock is used here but never bound", "Tools is private to module kit", "Clock is a port", "leaves out h", "fn no_such_clock: no function called no_such_clock", "unknown identifier no_such_name", "its constant would be TL_COUNT, which is the registry's size"]
for i in 0 .. len(bad) {
if shq(`bin/ludicc --check examples/{bad[i]}.ludic > {tmp_dir()}/chk.err 2>&1`) { bad2(lbl, `{bad[i]} passed the check`); return }
let said = capture(`cat {tmp_dir()}/chk.err`)
@ -547,6 +547,13 @@ function expect_str_case() -> void {
let want = "ok - equal text built two ways\nexamples/library/testing_strings.ludic:11: expect_eq failed (got \"camp\", want \"lake\")\nFAIL - different text fails, and says both\nexamples/library/testing_strings.ludic:15: expect_eq failed (got \"<null>\", want \"camp\")\nFAIL - a null is not a string\n== 1 passed, 2 failed ==\n"
if s_trim(got) == s_trim(want) { ok(lbl) } else { bad2(lbl, `said [{s_trim(got)}]`) }
}
# 0.R: the drain that calls every reducer is the program's own, so the module that declares the
# actions gains no dependency on the modules that reduce them
function actions_deps_case() -> void {
let lbl = "the action drain is the program's: acts depends on no reducer's module (ludic deps)"
let got = capture(`bin/ludic deps examples/actions/modules.ludic 2>&1`)
if s_contains(got, "largest_cycle: 1") and s_contains(got, "dependencies: 0") and s_contains(got, "the widest function: reducer Bag on PickUp (") { ok(lbl) } else { bad2(lbl, s_trim(got)) }
}
# a program built headless with the toolchain and run: its first line
function headless_case(path: pointer, exp: pointer, label: pointer) -> void {
let out = `{tmp_dir()}/h_{flat(path)}`
@ -963,8 +970,11 @@ function cmd_dev_test() -> int {
reject_case("rejected/layer_reach", "hud uses items.item_count", "a layered module is still held to its uses outside the layer")
reject_case("rejected/layer_cycle", "go round in a circle: items -> layer app -> items", "a cycle through a layer and out of it is refused")
feat_case("actions/pack", "", "0 1 9 13 picked 7 picked 9 too heavy", "pack.ludic (0.R: actions, reducers - one per state, in the order of the states' names - dispatch, drain_actions, an action a reducer dispatches goes behind)")
feat_case("actions/modules", "", "2", "modules.ludic (0.R: an action in one module, its reducer in another)")
actions_deps_case()
feat_case("actions/phases", "dd a q", "1 0 2 0 2 1 1 1 1 2 1 2", "phases.ludic (0.R: the frame loop drains the queue after every phase - Input's actions are reduced before Update)")
feat_case("actions/runaway", "", "actions: Ping is still being dispatched after 64 rounds of reducers - a reducer dispatches what dispatches it", "runaway.ludic (0.R: a reducer that dispatches what dispatches it is stopped by name)")
reject_case("rejected/registry_count_key", "def Tools count: its constant would be TL_COUNT", "a registry key named count is refused")
reject_case("rejected/reducer_two_states", "a reducer takes one state, and w is a Wallet", "a reducer takes exactly one state")
reject_case("rejected/reducer_not_action", "Buy is not an action", "a reducer is on an action")
reject_case("rejected/dispatch_unknown", "dispatch Sell: Sell is not an action", "only an action is dispatched")