Skip to content

Commit 7956e4c

Browse files
committed
Harden struct semantics: reserved init, static sharing, cast and self-inherit guards
Four audit findings: - data fields named 'init' are now rejected like 'new' (the name is reserved for the lifecycle hook); a data-init previously vanished silently when inherited by a child struct - inherited statics are copied by reference at the child's declaration (Child.f = Parent.f, same lifecycle as flattened methods): struct instances stored in statics are shared by the whole hierarchy, and uninitialized parent statics assigned before the child are inherited correctly; previously they were merged by type only, so the type said string while the runtime read nil. Static-static overrides via the child's static block work; child-static vs parent-instance is still a conflict - casting a table literal to a struct type is rejected (the result would lack the metatable wiring and crash on first method call); casting variables remains the interop escape hatch - 'struct A:A' is rejected with "cannot extend itself" (it previously typechecked and generated a struct table set as its own ancestor) Adds specs for all four (75 struct specs total, 2001 in the suite).
1 parent 956ba92 commit 7956e4c

10 files changed

Lines changed: 494 additions & 137 deletions

File tree

docs/src/structs.md

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,9 @@ code, refer to the parent by name: `A.init(self)`.
188188
### Methods are flattened, not dispatched
189189

190190
Method calls on struct instances are a **single table lookup**. There is
191-
no metatable chain between structs and no method resolution at call
192-
time. Instead, at the point a child struct is declared, every method
193-
known on its parent is copied into the child:
191+
no method resolution at call time and no per-call overhead: at the point
192+
a child struct is declared, every method known on its parent is copied
193+
into the child:
194194

195195
```lua
196196
local struct Shape
@@ -222,7 +222,9 @@ Circle.new = function(opts) ... end -- init chain: none here
222222
```
223223

224224
So `c:describe()` resolves as: instance table → (miss) → `__index`
225-
`Circle.describe` → hit. One hop, always.
225+
`Circle.describe` → hit. One hop, always. Struct tables carry no
226+
metatable of their own — everything inherited (methods, statics) is
227+
copied onto the child at declaration time.
226228

227229
**Overrides are just later assignments.** A method defined on the child
228230
*after* its declaration overwrites the flattened copy:
@@ -248,6 +250,7 @@ parent methods before child structs; the checker enforces this order.
248250
|---|---|---|
249251
| `__index = X` | once, at declaration | one metatable hop on instance field miss |
250252
| method flattening (`X.m = P.m`) | once, at declaration | none — direct table entry |
253+
| static copying (`X.s = P.s`) | once, at declaration | none — direct table entry |
251254
| init chain | fixed call list inside `.new` | none — unconditional direct calls |
252255
| defaults | one `if` per defaulted field in `.new` | `== nil` check only |
253256

@@ -475,9 +478,22 @@ end
475478
print(Derived.klass) -- "Base" (inherited)
476479
```
477480

478-
Initialized statics are re-emitted per child (an independent copy of the
479-
initializer); a static declared without an initializer on a parent is not
480-
readable through a child — assign it on the child explicitly if needed.
481+
Statics are copied **by reference** at the child's declaration — the
482+
generated code emits `Derived.klass = Base.klass` (and so on for every
483+
parent static), the same lifecycle and zero-dispatch approach as
484+
flattened methods. Consequences:
485+
486+
- **struct instances stored in statics are shared by reference** across
487+
the whole hierarchy: there is a single `config` instance, and
488+
mutating `App.config.debug` is seen by every child — the common
489+
pattern (shared configuration, singletons, pools) works exactly as
490+
expected;
491+
- **scalar statics become per-child values**: rebinding `Base.count = 5`
492+
after a child was declared is not seen by that child's slot (mutate a
493+
shared holder instance instead if you need live updates);
494+
- **shadowing**: assigning `Derived.count = 100` creates the child's
495+
own slot and leaves `Base.count` untouched; a child `static` block
496+
entry with its own initializer does the same declaratively.
481497

482498
## Cross-module inheritance
483499

@@ -520,9 +536,11 @@ error:
520536
presence at the child's declaration site;
521537
- the parent must not inherit `init` from its own ancestors (those
522538
ancestor tables are not visible outside the parent's module);
523-
- the parent's default values must be literals — computed defaults may
524-
reference the parent module's locals, which don't exist in the
525-
inheriting module;
539+
- the parent's *instance* default values must be literals — computed
540+
defaults may reference the parent module's locals, which don't exist
541+
in the inheriting module. Static initializers are exempt: inherited
542+
statics are copied by value, so their expressions are never re-emitted
543+
in the inheriting module;
526544
- structs described by declaration files (`.d.tl`) cannot be extended:
527545
their runtime shape is by contract and may not follow struct
528546
semantics.

spec/lang/declaration/struct_spec.lua

Lines changed: 176 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,123 @@ describe("struct", function()
244244
print(Derived.count)
245245
]]))
246246

247+
it("snapshots parent static scalars at the child's declaration (runtime)", function()
248+
local code = [[
249+
local struct Base
250+
static
251+
count: number = 0
252+
end
253+
end
254+
255+
local struct Early:Base
256+
end
257+
258+
Base.count = 5
259+
260+
local struct Late:Base
261+
end
262+
263+
print(Early.count, Late.count) -- 0 5: reference copies at declaration
264+
]]
265+
local check_result = tl.check_string(code)
266+
assert.same({}, check_result.type_errors)
267+
local generated = tl.gen(code)
268+
local chunk = load(generated, "@main.lua")
269+
assert.truthy(chunk, "generated code must compile")
270+
local out = {}
271+
local orig_print = _G.print
272+
_G.print = function(...) table.insert(out, table.concat({ ... }, "\t")) end
273+
chunk()
274+
_G.print = orig_print
275+
assert.same({ "0\t5" }, out)
276+
end)
277+
278+
it("shares static struct instances by reference across the hierarchy (runtime)", function()
279+
local code = [[
280+
local struct Config
281+
debug: boolean
282+
end
283+
284+
local struct App
285+
static
286+
config: Config = Config.new { debug = false }
287+
end
288+
end
289+
290+
local struct DevApp:App
291+
end
292+
293+
print(App.config == DevApp.config) -- true: one shared instance
294+
App.config.debug = true
295+
print(DevApp.config.debug) -- true: mutation is shared
296+
]]
297+
local check_result = tl.check_string(code)
298+
assert.same({}, check_result.type_errors)
299+
local generated = tl.gen(code)
300+
local chunk = load(generated, "@main.lua")
301+
assert.truthy(chunk)
302+
local out = {}
303+
local orig_print = _G.print
304+
_G.print = function(...)
305+
local parts = {}
306+
for i = 1, select("#", ...) do
307+
table.insert(parts, tostring(select(i, ...)))
308+
end
309+
table.insert(out, table.concat(parts, "\t"))
310+
end
311+
chunk()
312+
_G.print = orig_print
313+
assert.same({ "true", "true" }, out)
314+
end)
315+
316+
it("shadowing a parent static via the child creates the child's own slot", util.check([[
317+
local struct Base
318+
static
319+
count: number = 0
320+
end
321+
end
322+
323+
local struct Child:Base
324+
end
325+
326+
Base.count = 5
327+
Child.count = 100 -- shadow: child's own slot
328+
print(Base.count, Child.count) -- 5 100
329+
]]))
330+
331+
it("inherits uninitialized statics assigned before the child", util.check([[
332+
local struct Parent
333+
x: number
334+
static
335+
version: string
336+
end
337+
end
338+
339+
Parent.version = "1.0" -- BEFORE the child
340+
341+
local struct Child:Parent
342+
y: number
343+
end
344+
345+
print(Parent.version, Child.version) -- 1.0 1.0
346+
]]))
347+
348+
it("allows overriding an inherited static with the child's own initializer", util.check([[
349+
local struct Parent
350+
static
351+
tag: string = "parent"
352+
end
353+
end
354+
355+
local struct Child:Parent
356+
static
357+
tag: string = "child"
358+
end
359+
end
360+
361+
print(Parent.tag, Child.tag) -- parent child
362+
]]))
363+
247364
it("allows only one static block per struct", function()
248365
local result, err = tl.check_string([[
249366
local struct Foo
@@ -658,7 +775,54 @@ describe("struct", function()
658775
]])
659776
assert.truthy(result.syntax_errors and #result.syntax_errors > 0)
660777
end)
661-
end)
778+
779+
it("rejects a struct extending itself", function()
780+
local result = tl.check_string([[
781+
local struct A:A
782+
x: number
783+
end
784+
]])
785+
assert.truthy(result.type_errors and #result.type_errors > 0)
786+
assert.match("cannot extend itself", result.type_errors[1].msg)
787+
end)
788+
789+
it("rejects casting a table literal to a struct type", function()
790+
local result = tl.check_string([[
791+
local struct Point
792+
x: number
793+
y: number
794+
end
795+
796+
function Point:len(): number
797+
return self.x + self.y
798+
end
799+
800+
local p = { x = 1, y = 2 } as Point
801+
print(p:len())
802+
]])
803+
assert.truthy(result.type_errors and #result.type_errors > 0)
804+
assert.match("instead of casting a table literal", result.type_errors[1].msg)
805+
end)
806+
807+
it("allows casting variables to struct types (interop escape hatch)", util.check([[
808+
local struct Point
809+
x: number
810+
y: number
811+
end
812+
813+
function Point:len(): number
814+
return self.x + self.y
815+
end
816+
817+
local p = Point.new { x = 1, y = 2 }
818+
local q = p as Point
819+
print(q:len())
820+
821+
local external: any = Point.new { x = 3, y = 4 }
822+
local r = external as Point
823+
print(r:len())
824+
]]))
825+
end)
662826

663827
describe(":Parent syntax ergonomics", function()
664828
it("allows a field named 'from' in first position of the body", util.check([[
@@ -890,14 +1054,17 @@ describe("struct", function()
8901054
assert.match("new' is reserved", result.type_errors[1].msg)
8911055
end)
8921056

893-
it("allows a data field named 'init'", util.check([[
894-
local struct A
895-
x: number
896-
init: number = 7
897-
end
898-
local a = A.new { x = 1 }
899-
print(a.init)
900-
]]))
1057+
it("rejects a data field named 'init' with a clear error", function()
1058+
local result, err = tl.check_string([[
1059+
local struct A
1060+
x: number
1061+
init: number = 7
1062+
end
1063+
local a = A.new { x = 1 }
1064+
]])
1065+
assert.truthy(result.type_errors and #result.type_errors > 0)
1066+
assert.match("init' is reserved", result.type_errors[1].msg)
1067+
end)
9011068

9021069
it("rejects a static field shadowing an instance field", function()
9031070
local result, err = tl.check_string([[

0 commit comments

Comments
 (0)