feat(lang): #76 namespace block form with export/internal visibility
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 22s
ci / build-and-test (push) Successful in 2m25s
commit-lint / conventional-commits (push) Successful in 5s
docs / build-and-deploy (push) Successful in 29s

`namespace Name { export function foo(...) ... internal function bar(...) ... }`
declares a Name.* namespace once and controls its public surface declaratively,
instead of annotating every function with @Namespace(Name). Inside the block
each `function short(...)` is emitted as `namelower_short`; export (the default)
makes it callable as Name.short(...), internal keeps it a private helper
(emitted, callable by short name from siblings — calls are rewritten — but
Name.internalOne() is a compile error). Block sugar for the per-function
@Namespace annotation; a package's public API reads at a glance. Namespaces
declared the old way are unchanged (gameplay_foundation still passes).

namespace added to LUDIC_KW_DECL + JetBrains/TextMate + docs page + inventory
(vocab/impl/docs checks green). Example namespace_block (export + internal +
sibling calls + internal-visibility compile error verified). Full suite 116/0,
fixpoint holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-02 08:14:54 +03:00
parent 3eb5447f74
commit 23e232e380
12 changed files with 19192 additions and 18330 deletions

View file

@ -0,0 +1,3 @@
bump: minor
type: feat
Namespace block form (#76) — `namespace Name { export function foo(…) … internal function bar(…) … }` declares a `Name.*` namespace once and controls its public surface declaratively, instead of annotating every function with `@Namespace(Name)` one at a time. Inside the block each `function short(…)` is emitted as `namelower_short`; an `export` function (the default) is callable as `Name.short(…)`, while an `internal` function is a private helper — emitted and callable by its short name from siblings in the block (calls are rewritten to the emitted name), but not part of the `Name.*` surface (`Name.internalOne()` is a compile error). It is the block sugar for the per-function `@Namespace` annotation, so a package's public API reads at a glance. A namespace declared the old per-function way is unchanged. Example: `examples/library/namespace_block.ludic`.

View file

@ -0,0 +1,28 @@
---
id: kw-namespace
name: namespace
category: structure
kind: keyword
tokens: namespace
sig: namespace Name { export function foo(…) … internal function bar(…) … }
tip: Group functions into a Name.* namespace and control its public surface.
order: 16
---
A <code>namespace</code> block declares a <code>Name.*</code> namespace once and controls its public surface declaratively, instead of annotating every function with <code>@Namespace(Name)</code> one at a time. Inside the block each <code>function short(…)</code> is emitted as <code>namelower_short</code>; an <code>export</code> function (the default) is callable from anywhere as <code>Name.short(…)</code>, while an <code>internal</code> function is a private helper — emitted, and callable by its short name from other functions in the same block, but not part of the <code>Name.*</code> surface (calling <code>Name.internalOne()</code> is a compile error). It is the block sugar for the per-function <code>@Namespace</code> annotation, so a package's public API reads at a glance.
Sibling calls inside the block use the short name (<code>base()</code>), and the compiler rewrites them to the emitted name — so a namespace reads like a self-contained module.
```ludic
program Demo {
namespace Combat {
export function amount() -> int { return base() + 5 }
internal function base() -> int { return 10 }
export function double(n: int) -> int { return n + n }
}
entry {
print(Combat.amount()) # 15
print(Combat.double(21)) # 42
}
}
```

View file

@ -0,0 +1,20 @@
# namespace_block.ludic — the `namespace Name { ... }` block form (#76). Declares
# the namespace once and controls its public surface declaratively: `export`
# functions are callable as Name.method, `internal` ones are private helpers.
# Sibling calls inside the block use the short name.
#
# Deterministic; a full run prints: 15 42 100
program NamespaceBlock {
namespace Combat {
export function amount() -> int { return base() + 5 } # calls the internal sibling by short name
internal function base() -> int { return 10 } # a private helper — not Combat.base
export function double(n: int) -> int { return n + n }
export function via_internal() -> int { return base() * 10 }
}
entry {
print(Combat.amount()) # 15 — 10 (internal base) + 5
print(Combat.double(21)) # 42
print(Combat.via_internal()) # 100 — reaches the internal helper from inside the namespace
}
}

View file

@ -689,7 +689,12 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
# #62: a package-provided namespace (declared with @Namespace(Foo)) that none
# of the hardcoded core blocks matched — alias Foo.method to the bare function
# foo_method (positional args), the same generic path the core aliases use.
if (bare == null) and is_registered_namespace(ns) { bare = ns_lower(ns) + ("_") + meth }
if (bare == null) and is_registered_namespace(ns) {
bare = ns_lower(ns) + ("_") + meth
# #76: a namespace declared with a block controls its public surface — an
# `internal` method is emitted but not callable as Name.method.
if is_ns_block(ns) and (not ns_export_has(bare)) { perr(`{ns}.{meth} is internal to namespace {ns}`) }
}
if (bare == null) { perr(`unknown builtin {ns}.{meth}`) }
reorder_named(e, labels)
let id = node(E_ID); id.s = bare; e.a = id

View file

@ -419,6 +419,66 @@ function parse_fn() -> Node {
}
function parse_main() -> Node { pi = pi + 1; let n = node(N_MAIN); n.a = block(); return n }
# issue #76 — rewrite calls to a namespace-block sibling (a bare short-name call)
# to the prefixed function name, so a body can call `pending()` where the function
# is emitted as `combat_pending`. Only E_CALL callees that are bare identifiers are
# rewritten (Ludic has no function values, and Name.method calls are E_MEMBER).
function ns_short_in(names: []pointer, s: pointer) -> bool {
var i = 0
while i < len(names) { if (names[i] == s) { return true }; i = i + 1 }
return false
}
function ns_rewrite_calls(n: Node, shorts: []pointer, prefix: pointer) -> void {
if (n == null) { return }
if (n.kind == E_CALL) and (n.a != null) and (n.a.kind == E_ID) {
if ns_short_in(shorts, n.a.s) { n.a.s = prefix + n.a.s }
}
ns_rewrite_calls(n.a, shorts, prefix)
ns_rewrite_calls(n.b, shorts, prefix)
ns_rewrite_calls(n.c, shorts, prefix)
var i = 0
while i < len(n.kids) { ns_rewrite_calls(n.kids[i], shorts, prefix); i = i + 1 }
}
# issue #76 — `namespace Name { [export|internal] function short(…) … }`. Declares
# the namespace once; each function is emitted as `<namelower>_short` and, when
# exported (the default; `internal` opts out), registered so `Name.short(…)`
# dispatches to it — the same generic path @Namespace(Name) uses, so it is the
# block sugar for it. Sibling calls inside the block are rewritten to the prefixed
# name. `internal` keeps a function as a private helper: it is emitted but not part
# of the Name.* surface.
function parse_namespace() -> void {
pi = pi + 1 # eat "namespace"
let nsname = eat_id()
eat_op("{"); skipnl()
let fns = new []Node
let shorts = new []pointer
let exps = new []int
while not is_op("}") {
var is_exp = 1 # default: exported (public)
if is_id("export") { pi = pi + 1 }
else { if is_id("internal") { pi = pi + 1; is_exp = 0 } }
if not is_id("function") { perr("a namespace body holds functions: expected `function`") }
let f = parse_fn()
push(fns, f); push(shorts, f.s); push(exps, is_exp)
skipnl()
}
eat_op("}")
let prefix = ns_lower(nsname) + ("_")
var i = 0 # prefix every function name
while i < len(fns) { fns[i].s = prefix + shorts[i]; i = i + 1 }
i = 0 # rewrite sibling calls to the prefixed name
while i < len(fns) { ns_rewrite_calls(fns[i].a, shorts, prefix); i = i + 1 }
register_namespace(nsname) # Name.method dispatch (generic path)
push(g_ns_blocks, nsname)
i = 0
while i < len(fns) {
if (exps[i] == 1) { push(g_ns_exports, fns[i].s) } # record the public surface
push(prog, fns[i])
i = i + 1
}
}
# directory part of a path, including the trailing '/', or "" if none
function dir_of(path: pointer) -> pointer {
var last = 0 - 1
@ -481,6 +541,26 @@ var g_esys_fn: []pointer
var g_esys_phase: []pointer
var g_namespaces: []pointer
# issue #76: `namespace Name { export/internal function … }` block form. A block
# declares the namespace once and controls its public surface declaratively.
# g_ns_blocks names the namespaces declared this way; g_ns_exports holds the
# prefixed function names that are *exported* (e.g. "combat_amount"), so emit_ns_call
# can reject Name.method for an `internal` helper. (A namespace declared the old
# per-function @Namespace(Name) way has no block entry, so all its methods stay
# dispatchable — back-compatible.)
var g_ns_blocks: []pointer
var g_ns_exports: []pointer
function is_ns_block(name: pointer) -> bool {
var i = 0
while i < len(g_ns_blocks) { if (g_ns_blocks[i] == name) { return true }; i = i + 1 }
return false
}
function ns_export_has(prefixed: pointer) -> bool {
var i = 0
while i < len(g_ns_exports) { if (g_ns_exports[i] == prefixed) { return true }; i = i + 1 }
return false
}
# lever 5 of the controller extensibility contract (#57): `disable system <fn>`
# switches off exactly one engine-owned system (an esys_* registered via
# @EngineSystem or the core seed) at compile time, so a game can carry a
@ -626,6 +706,7 @@ function parse_one_decl() -> void {
push(prog, h); return
}
if is_id("ui") { push(prog, parse_ui()); return }
if is_id("namespace") { parse_namespace(); return } # #76 namespace block
if is_id("var") { push(prog, parse_var()); return }
if is_id("const") { push(prog, parse_const()); return }
if is_id("function") {
@ -973,6 +1054,8 @@ function parse_program() -> void {
push(g_esys_comp, "Sprite"); push(g_esys_fn, "esys_sprite"); push(g_esys_phase, "Render")
push(g_esys_comp, "Light2D"); push(g_esys_fn, "esys_light2d"); push(g_esys_phase, "Render")
g_namespaces = new []pointer
g_ns_blocks = new []pointer
g_ns_exports = new []pointer
loaded_paths = new []pointer
skipnl()
g_game_name = "Ludic"

File diff suppressed because it is too large Load diff

View file

@ -274,7 +274,8 @@
"kw-import",
"kw-extern",
"kw-ui",
"kw-enum"
"kw-enum",
"kw-namespace"
],
"system": [
"system-run",

View file

@ -41,7 +41,7 @@ object LudicTokens {
*/
object LudicVocabulary {
val DECL = setOf(
"program", "import", "property", "model", "enum", "ui",
"program", "import", "property", "model", "enum", "ui", "namespace",
"const", "var", "function", "extern", "handler", "entry", "event", "scene", "test"
)
val CLAUSE = setOf(

View file

@ -176,7 +176,7 @@
{ "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" },
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|on)\\b" },
{ "name": "keyword.other.ludic", "match": "\\b(import|extern)\\b" },
{ "name": "storage.type.ludic", "match": "\\b(program|property|model|enum|ui|const|var|let|function|handler|entry|state|test)\\b" },
{ "name": "storage.type.ludic", "match": "\\b(program|property|model|namespace|enum|ui|const|var|let|function|handler|entry|state|test)\\b" },
{ "name": "support.type.primitive.ludic", "match": "\\b(int|long|fixed|bool|entity|string|pointer|byte|words|fixeds|pointers|Vector|IVec2|Rect|void)\\b" },
{ "name": "constant.language.boolean.ludic", "match": "\\b(true|false|null)\\b" },
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" }

View file

@ -176,7 +176,7 @@
{ "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" },
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|on)\\b" },
{ "name": "keyword.other.ludic", "match": "\\b(import|extern)\\b" },
{ "name": "storage.type.ludic", "match": "\\b(program|property|model|enum|ui|const|var|let|function|handler|entry|state|test)\\b" },
{ "name": "storage.type.ludic", "match": "\\b(program|property|model|namespace|enum|ui|const|var|let|function|handler|entry|state|test)\\b" },
{ "name": "support.type.primitive.ludic", "match": "\\b(int|long|fixed|bool|entity|string|pointer|byte|words|fixeds|pointers|Vector|IVec2|Rect|void)\\b" },
{ "name": "constant.language.boolean.ludic", "match": "\\b(true|false|null)\\b" },
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" }

View file

@ -52,7 +52,7 @@ typedef struct {
* mirror the compiler's parser: anything parse_decl() dispatches on is a
* declaration keyword, anything stmt() dispatches on is a statement keyword. */
static const char* LUDIC_KW_DECL[] = {
"program","import","property","model","enum","ui",
"program","import","property","model","enum","ui","namespace",
"const","var","function","extern","handler","entry","event","scene","test", 0
};
static const char* LUDIC_KW_CLAUSE[] = {

View file

@ -241,6 +241,7 @@ function cmd_test() -> int {
feat_case("library/grid", "", "1 2 3 4 5 6 7 8 9 10 11 12 13", "grid.ludic (Grid line/flood/line_of_sight + A* pathfinding over the tilemap)")
feat_case("library/anim", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34", "anim.ludic (Anim frame/once/pingpong/cell + Tween progress/loop/yoyo/ease/number/round/point/tint)")
feat_case("library/anim_sugar", "", "4 8 2 1 0 100 100 0 0 1 20 20 30 0 1", "anim_sugar.ludic (Anim.clip/play/on_frame/fired + Motion.to + fluent Tween.to/chain/delay/parallel handles; issue #48)")
feat_case("library/namespace_block", "", "15 42 100", "namespace_block.ludic (#76 namespace Name { export/internal function } block — declares the namespace once, controls the public surface)")
feat_case("library/atlas", "", "16 16 32 48 1 1 1 16 1", "atlas.ludic (#81 Sprite.sheet/cell/cell_span/define/named + Assets.image/get — namespaced spritesheet/atlas with multi-cell sprites)")
feat_case("library/camera_zoom", "", "1 0 0 1 1", "camera_zoom.ludic (Camera.zoom deterministic Q16.16 render-time zoom about the screen centre, verified by pixel readback; issue #78)")
feat_case("library/clear_color", "q", "1 1", "clear_color.ludic (@ClearColor: the Render phase auto-clears to the declared colour + auto-presents, no Screen.clear/show in the handler; issue #86)")