diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 930437f..1b04c4f 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -14,7 +14,7 @@ jobs: uses: robinraju/release-downloader@v1.6 with: repository: Roblox/luau - tag: "0.723" + tag: "0.735" fileName: luau-ubuntu.zip out-file-path: bin @@ -23,3 +23,6 @@ jobs: - name: Run unit tests run: bin/luau test/tests.luau + + - name: Run typechecking tests + run: bash test/typecheck/run.sh "$GITHUB_WORKSPACE/bin/luau-analyze" diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d8c8cd..4e22fa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed +- Component type inference with Luau's new type solver. Components now use + `component` and `read_component`; mutating APIs such as `Registry:set()` + require `component`, preventing incompatible values from being accepted. + See [PR #30](https://github.com/centau/ecr/pull/30) for details. - Method `Registry:create(id)` causing an out of bounds buffer access if key was already in use with a different version. --- diff --git a/README.md b/README.md index 0ecaabe..ebe9354 100644 --- a/README.md +++ b/README.md @@ -25,14 +25,17 @@ ECR is a Luau ECS library. Read the [crash course](https://centau.github.io/ecr/tut/crash-course) for a brief introduction to the library. +When using Luau's new type solver, declare components without constructors as +`ecr.component<>()`. + ## Code sample ```lua local ecr = require(ecr) -- define components -local Position = ecr.component() :: Vector3 -local Velocity = ecr.component() :: Vector3 +local Position = ecr.component<>() +local Velocity = ecr.component<>() -- define a system local function update_physics(world: ecr.Registry, dt: number) diff --git a/docs/api/Observer.md b/docs/api/Observer.md index a980687..ea94b88 100644 --- a/docs/api/Observer.md +++ b/docs/api/Observer.md @@ -15,7 +15,7 @@ Excludes entities with the given components from the observer. - **Type** ```lua - function Observer:exclude(components: ...unknown): Observer + function Observer:exclude(components: ...read_component): Observer ``` - **Details** @@ -32,7 +32,7 @@ Disconnects the observer, stopping any new changes from being tracked - **Type** ```lua - function Observer:disconnect(): Observer + function Observer:disconnect(): Observer ``` The observer must be empty before it is disconnected. @@ -50,7 +50,7 @@ Reconnects the Observer and allows it to track changes again. - **Type** ```lua - function Observer:reconnect(): Observer + function Observer:reconnect(): Observer ``` -------------------------------------------------------------------------------- @@ -62,7 +62,7 @@ Clears all stored entities. - **Type** ```lua - function Observer:clear(): Observer + function Observer:clear(): Observer ``` ## Iteration diff --git a/docs/api/Registry.md b/docs/api/Registry.md index c4b5fa4..71af3ac 100644 --- a/docs/api/Registry.md +++ b/docs/api/Registry.md @@ -71,18 +71,18 @@ Checks if the given entity exists in the registry. ### add() -Adds all given components to an entity. +Adds a component to an entity using its constructor. - **Type** ```lua - function Registry:add(id: entity, components: T...) + function Registry:add(id: entity, component: read_component): T ``` - **Details** - Adds the given components to the entity by using each component - constructor or no value at all if the component is a tag type. + Adds the component to the entity by using its constructor, or no value if it + is a tag type. Returns the current value. Adding a component to an entity that already has the component will do nothing. @@ -96,7 +96,7 @@ Adds or changes an entity's component. - **Type** ```lua - function Registry:set(id: entity, component: T, value: T) + function Registry:set(id: entity, component: component, value: T) ``` - **Details** @@ -116,7 +116,7 @@ Updates an entity's component. - **Type** ```lua - function Registry:patch(id: entity, component: T, patcher: (T) -> T): T + function Registry:patch(id: entity, component: component, patcher: ((T) -> T)?): T ``` - **Details** @@ -145,7 +145,7 @@ Checks if an entity has all of the given components. - **Type** ```lua - function Registry:has(id: entity, components: T...): boolean + function Registry:has(id: entity, components: ...read_component): boolean ``` - **Details** @@ -161,12 +161,13 @@ Gets an entity's component values. - **Type** ```lua - function Registry:get(id: entity, components: T...): T... + function Registry:get(id: entity, component: read_component): T ``` - **Details** - Will error if the entity does not have a component. + Will error if the entity does not have a component. There are overloads for + two through four component types, each returning the matching value tuple. -------------------------------------------------------------------------------- @@ -177,7 +178,7 @@ Gets an entity's component value. - **Type** ```lua - function Registry:try_get(id: entity, components: T): T? + function Registry:try_get(id: entity, component: read_component): T? ``` - **Details** @@ -193,7 +194,7 @@ Removes the given components from an entity. - **Type** ```lua - function Registry:remove(id: entity, components: T...) + function Registry:remove(id: entity, components: ...read_component) ``` - **Details** @@ -209,7 +210,7 @@ Removes all entities and components from the registry. - **Type** ```lua - function Registry:clear(components: T...) + function Registry:clear(components: ...read_component) ``` - **Details** @@ -229,7 +230,7 @@ Returns the first entity found that has a component matching the given value. - **Type** ```lua - function Registry:find(component: T, value: T): entity? + function Registry:find(component: component, value: T): entity? ``` - **Details** @@ -245,7 +246,7 @@ Copies the values of a component and pastes it into another component - **Type** ```lua - function Registry:copy(copy: T, paste: T) + function Registry:copy(copy: component, paste: component) ``` - **Details** @@ -262,7 +263,7 @@ Creates a [`view`](View) with the given component types. - **Type** ```lua - function Registry:view(components: T...): View + function Registry:view(component: component): View ``` -------------------------------------------------------------------------------- @@ -274,7 +275,7 @@ Creates an [`observer`](Observer) with the given component types. - **Type** ```lua - function Registry:track(...: T...): Observer + function Registry:track(component: read_component): Observer ``` -------------------------------------------------------------------------------- @@ -286,7 +287,7 @@ Creates a [`group`](Group.md) with the given component types. - **Type** ```lua - function Registry:group(...: T...): Group + function Registry:group(a: read_component, b: read_component): Group ``` - **Details** @@ -312,7 +313,7 @@ added to an entity. - **Type** ```lua - function Registry:on_add(component: T): Signal + function Registry:on_add(component: read_component): Signal ``` The signal is fired *after* the component is changed. @@ -333,7 +334,7 @@ changed for an entity. - **Type** ```lua - function Registry:on_change(component: T): Signal + function Registry:on_change(component: read_component): Signal ``` The signal is fired *before* the component is changed. @@ -354,7 +355,7 @@ removed from an entity. - **Type** ```lua - function Registry:on_remove(component: T): Signal + function Registry:on_remove(component: read_component): Signal ``` - **Details** @@ -369,6 +370,30 @@ removed from an entity. -------------------------------------------------------------------------------- +### on_clear() + +Returns a [signal](Signal) fired when the given component type is cleared. + +- **Type** + + ```lua + function Registry:on_clear(component: read_component): Signal<> + ``` + +-------------------------------------------------------------------------------- + +### after_clear() + +Returns a [signal](Signal) fired after the given component type is cleared. + +- **Type** + + ```lua + function Registry:after_clear(component: read_component): Signal<> + ``` + +-------------------------------------------------------------------------------- + ### handle() Returns a [handle](Handle) to an entity. @@ -411,8 +436,8 @@ Returns the [pool](Pool) for a given component type. - **Type** ```lua - function Registry:storage(component: T): Pool - function Registry:storage(): () -> (unknown, Pool) + function Registry:storage(component: component): Pool + function Registry:storage(): () -> (read_component, Pool) ``` - **Details** diff --git a/docs/api/View.md b/docs/api/View.md index e87ca68..ad2c018 100644 --- a/docs/api/View.md +++ b/docs/api/View.md @@ -15,7 +15,7 @@ Excludes entities with the given components from the view. - **Type** ```lua - function View:exclude(components: ...unknown): View + function View:exclude(components: ...read_component): View ``` - **Details** diff --git a/docs/api/ecr.md b/docs/api/ecr.md index 787a8fb..a302fde 100644 --- a/docs/api/ecr.md +++ b/docs/api/ecr.md @@ -28,8 +28,7 @@ Creates a new component type. - **Type** ```lua - function ecr.component(): unknown - function ecr.component(constructor: () -> T): T + function ecr.component(constructor: (() -> T)?): component ``` - **Details** @@ -45,8 +44,8 @@ Creates a new component type. No constructor. ```lua - local Position = ecr.component() :: Vector3 - local Health = ecr.component() :: number + local Position = ecr.component<>() + local Health = ecr.component<>() ``` With constructor. @@ -71,7 +70,7 @@ Creates a new tag component type. - **Type** ```lua - function ecr.tag(): nil + function ecr.tag(): component ``` - **Details** @@ -89,7 +88,7 @@ Checks if a component type is a tag. - **Type** ```lua - function ecr.is_tag(ctype: T): boolean + function ecr.is_tag(ctype: read_component): boolean ``` -------------------------------------------------------------------------------- @@ -125,7 +124,8 @@ Associates names with components for debugging. - **Type** ```lua - function ecr.name(names: T & Map) -> T + function ecr.name(names: T & { read [string]: read_component }): T + function ecr.name(component: component): string? ``` - **Details** @@ -196,7 +196,7 @@ Special component type that represents entities in a registry. - **Type** ```lua - ecr.entity: entity + ecr.entity: component ``` -------------------------------------------------------------------------------- diff --git a/docs/index.md b/docs/index.md index 26253c1..b334520 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,4 +21,3 @@ features: - title: In Development details: Not recommended for production use. --- - diff --git a/docs/tut/context.md b/docs/tut/context.md index 7fda7d4..f89af35 100644 --- a/docs/tut/context.md +++ b/docs/tut/context.md @@ -13,7 +13,7 @@ time [`Registry:context()`](../api/Registry#context) is called, subsequent calls return the same entity. ```lua -local Round = ecr.component() :: number +local Round = ecr.component<>() registry:context():set(Round, 1) diff --git a/docs/tut/crash-course.md b/docs/tut/crash-course.md index 921ea75..05c0ede 100644 --- a/docs/tut/crash-course.md +++ b/docs/tut/crash-course.md @@ -36,13 +36,12 @@ A component is data that can be added to entities. There is the *component type*, which represents a type of data, and there is the *component value*, which is a value for a type added to an entity. -Component types are created by `ecr.component()`, which returns an id -representing that type. This can be typecasted to the type of value it -represents. +Component types are created by `ecr.component()`, which returns an id +representing a value of type `T`. ```lua -local Name = ecr.component() :: string -local Health = ecr.component() :: number +local Name = ecr.component<>() +local Health = ecr.component<>() ``` Entities can have any amount of components added to or removed from them, @@ -152,8 +151,8 @@ component types must also be defined before the registry using them is created. local ecr = require(ecr) local cts = { - Health = ecr.component() :: number, - Poisoned = ecr.component() :: number + Health = ecr.component<>(), + Poisoned = ecr.component<>() } ecr.name(cts) diff --git a/docs/tut/tags.md b/docs/tut/tags.md index 73f7071..c8ab2f3 100644 --- a/docs/tut/tags.md +++ b/docs/tut/tags.md @@ -10,7 +10,7 @@ local Tag = ecr.tag() Tags types are used in the same way as any other component type. They are useful for marking entities in some state, and are more efficient than something like -`ecr.component() :: true`. +`ecr.component<>()`. Example usage: diff --git a/src/ecr.luau b/src/ecr.luau index f23a850..85633f9 100644 --- a/src/ecr.luau +++ b/src/ecr.luau @@ -11,6 +11,8 @@ local MAX_ENTITIES = 0x0000_FFFF -------------------------------------------------------------------------------- export type entity = number +export type component = { __ctype: T, read __component: true } +export type read_component = { read __ctype: T, read __component: true } type Array = { [number]: T } type Map = { [T]: U } @@ -70,18 +72,21 @@ type HandleMT = { __index: HandleMT, destroy: (self: Handle) -> (), has_none: (self: Handle) -> boolean, - add: (self: Handle, T) -> T, - set: (self: Handle, ctype: T, value: T) -> Handle, - insert: (self: Handle, ctype: Array, value: T) -> Handle, - patch: (self: Handle, ctype: T, fn: ((T) -> T)?) -> T, - has: (self: Handle, T...) -> boolean, - get: (self: Handle, T...) -> T..., - try_get: (self: Handle, T) -> T?, - remove: (self: Handle, T...) -> (), + add: (self: Handle, ctype: read_component) -> T, + set: (self: Handle, ctype: component, value: T) -> Handle, + insert: (self: Handle, ctype: component>, value: T) -> Handle, + patch: (self: Handle, ctype: component, fn: ((T) -> T)?) -> T, + has: (self: Handle, ...read_component) -> boolean, + get: & ((self: Handle, ctype: read_component) -> A) + & ((self: Handle, read_component, read_component) -> (A, B)) + & ((self: Handle, read_component, read_component, read_component) -> (A, B, C)) + & ((self: Handle, read_component, read_component, read_component, read_component) -> (A, B, C, D)), + try_get: (self: Handle, ctype: read_component) -> T?, + remove: (self: Handle, ...read_component) -> (), } export type View = typeof(setmetatable({} :: { - exclude: (self: View, U...) -> View, + exclude: (self: View, ...read_component) -> View, of: (self: View, list: Array) -> View, patch: (self: View, fn: (T...) -> T...) -> (), iter: (self: View) -> () -> (entity, T...) @@ -91,7 +96,7 @@ export type View = typeof(setmetatable({} :: { })) export type Observer = typeof(setmetatable({} :: { - exclude: (self: Observer, U...) -> Observer, + exclude: (self: Observer, ...read_component) -> Observer, disconnect: (self: Observer) -> Observer, reconnect: (self: Observer) -> Observer, clear: (self: Observer) -> Observer, @@ -115,29 +120,51 @@ export type Registry = { contains: (self: Registry, id: entity) -> boolean, has_none: (self: Registry, id: entity) -> boolean, - add: (self: Registry, id: entity, ctype: T) -> T, - set: (self: Registry, id: entity, ctype: T, value: T) -> (), - insert: (self: Registry, id: entity, ctype: Array, value: T) -> (), - patch: (self: Registry, id: entity, ctype: T, fn: ((T) -> T)?) -> T, - has: (self: Registry, id: entity, T...) -> boolean, - get: (self: Registry, id: entity, T...) -> T..., - try_get: (self: Registry, id: entity, T) -> T?, - remove: (self: Registry, id: entity, T...) -> (), - find: (self: Registry, ctype: T, value: T) -> entity?, - copy: (self: Registry, a: T, b: T) -> (), - - view: (self: Registry, T...) -> View, - track: (self: Registry, T...) -> Observer, - group: (self: Registry, T...) -> Group, - - clear: (self: Registry, T...) -> (), - storage: ((self: Registry, ctype: T) -> Pool) & ((self: Registry) -> () -> (unknown, Pool)), - - on_add: (self: Registry, ctype: T) -> Signal, - on_change: (self: Registry, ctype: T) -> Signal, - on_remove: (self: Registry, ctype: T) -> Signal, - on_clear: (self: Registry, ctype: T) -> Signal<>, - after_clear: (self: Registry, ctype: T) -> Signal<>, + add: (self: Registry, id: entity, ctype: read_component) -> T, + set: (self: Registry, id: entity, ctype: component, value: T) -> (), + insert: (self: Registry, id: entity, ctype: component>, value: T) -> (), + patch: (self: Registry, id: entity, ctype: component, fn: ((T) -> T)?) -> T, + has: (self: Registry, id: entity, ...read_component) -> boolean, + get: & ((self: Registry, id: entity, ctype: read_component) -> A) + & ((self: Registry, id: entity, read_component, read_component) -> (A, B)) + & ((self: Registry, id: entity, read_component, read_component, read_component) -> (A, B, C)) + & ((self: Registry, id: entity, read_component, read_component, read_component, read_component) -> (A, B, C, D)), + try_get: (self: Registry, id: entity, ctype: read_component) -> T?, + remove: (self: Registry, id: entity, ...read_component) -> (), + find: (self: Registry, ctype: component, value: T) -> entity?, + copy: (self: Registry, a: component, b: component) -> (), + + view: & ((self: Registry, component) -> View) + & ((self: Registry, component, component) -> View) + & ((self: Registry, component, component, component) -> View) + & ((self: Registry, component, component, component, component) -> View) + & ((self: Registry, component, component, component, component, component) -> View) + & ((self: Registry, component, component, component, component, component, component) -> View) + & ((self: Registry, component, component, component, component, component, component, component) -> View) + & ((self: Registry, component, component, component, component, component, component, component, component, ...read_component) -> View), + + track: & ((self: Registry, read_component) -> Observer) + & ((self: Registry, read_component, read_component) -> Observer) + & ((self: Registry, read_component, read_component, read_component) -> Observer) + & ((self: Registry, read_component, read_component, read_component, read_component) -> Observer) + & ((self: Registry, read_component, read_component, read_component, read_component, read_component) -> Observer) + & ((self: Registry, read_component, read_component, read_component, read_component, read_component, read_component) -> Observer) + & ((self: Registry, read_component, read_component, read_component, read_component, read_component, read_component, read_component) -> Observer) + & ((self: Registry, read_component, read_component, read_component, read_component, read_component, read_component, read_component, read_component, ...read_component) -> Observer), + + group: & ((self: Registry, read_component, read_component) -> Group) + & ((self: Registry, read_component, read_component, read_component) -> Group) + & ((self: Registry, read_component, read_component, read_component, read_component) -> Group) + & ((self: Registry, read_component, read_component, read_component, read_component, read_component, ...read_component) -> Group), + + clear: (self: Registry, ...read_component) -> (), + storage: ((self: Registry, ctype: component) -> Pool) & ((self: Registry) -> () -> (read_component, Pool)), + + on_add: (self: Registry, ctype: read_component) -> Signal, + on_change: (self: Registry, ctype: read_component) -> Signal, + on_remove: (self: Registry, ctype: read_component) -> Signal, + on_clear: (self: Registry, ctype: read_component) -> Signal<>, + after_clear: (self: Registry, ctype: read_component) -> Signal<>, handle: ((self: Registry, id: entity) -> Handle) & ((self: Registry) -> Handle), context: (self: Registry) -> Handle @@ -155,7 +182,7 @@ export type Queue = typeof(setmetatable({} :: { local NIL = nil :: any -- error but stack trace always starts at first callsite outside of this file -local function throw(msg: string) +local function throw(msg: string): T local s = 1 repeat s += 1 until debug.info(s, "s") ~= debug.info(1, "s") error(msg, s) @@ -689,12 +716,12 @@ end local view_create, observer_create, group_create do type IRegistry = { - storage: (self: IRegistry, ctype: T) -> Pool, - on_add: (self: IRegistry, ctype: T) -> Signal, - on_change: (self: IRegistry, ctype: T) -> Signal, - on_remove: (self: IRegistry, ctype: T) -> Signal, - on_clear: (self: IRegistry, ctype: T) -> Signal<>, - after_clear: (self: IRegistry, ctype: T) -> Signal<>, + storage: (self: IRegistry, ctype: component) -> Pool, + on_add: (self: IRegistry, ctype: component) -> Signal, + on_change: (self: IRegistry, ctype: component) -> Signal, + on_remove: (self: IRegistry, ctype: component) -> Signal, + on_clear: (self: IRegistry, ctype: component) -> Signal<>, + after_clear: (self: IRegistry, ctype: component) -> Signal<>, } local function HAS_ANY(pools: Array>, key: number): boolean @@ -1709,7 +1736,7 @@ end -------------------------------------------------------------------------------- local Handle = (function(): HandleMT - local Handle = {} + local Handle = {} :: HandleMT Handle.__index = Handle function Handle.destroy(self: Handle) @@ -1720,25 +1747,25 @@ local Handle = (function(): HandleMT return self.registry:has_none(self.entity) end - function Handle.add(self: Handle, ctype: T): T + function Handle.add(self: Handle, ctype: read_component): T return self.registry:add(self.entity, ctype) end - function Handle.set(self: Handle, ctype: T, value: T): Handle + function Handle.set(self: Handle, ctype: component, value: T): Handle self.registry:set(self.entity, ctype, value) return self end - function Handle.insert(self: Handle, ctype: Array, value: T): Handle + function Handle.insert(self: Handle, ctype: component>, value: T): Handle self.registry:insert(self.entity, ctype, value) return self end - function Handle.patch(self: Handle, ctype: T, fn: ((T) -> T)?): T + function Handle.patch(self: Handle, ctype: component, fn: ((T) -> T)?): T return self.registry:patch(self.entity, ctype, fn) end - function Handle.has(self: Handle, ...: T...): boolean + function Handle.has(self: Handle, ...: read_component): boolean return self.registry:has(self.entity, ...) end @@ -1746,11 +1773,11 @@ local Handle = (function(): HandleMT return self.registry:get(self.entity, ...) end - function Handle.try_get(self: Handle, ctype: T): T? + function Handle.try_get(self: Handle, ctype: read_component): T? return self.registry:try_get(self.entity, ctype) end - function Handle.remove(self: Handle, ...: T...) + function Handle.remove(self: Handle, ...: read_component) self.registry:remove(self.entity, ...) end @@ -1815,22 +1842,22 @@ local function registry_create(range_start: number?, range_stop: number?): Regis group[i] = pool pool.group = group - table.insert(group.connections, registry:on_add(ctype):connect(function(id) + table.insert(group.connections, (registry :: Registry):on_add(ctype):connect(function(id) POOL_TRY_GROUP(pool, id) - end)) + end)); - table.insert(group.connections, registry:on_remove(ctype):connect(function(id) + table.insert(group.connections, (registry :: Registry):on_remove(ctype):connect(function(id) POOL_TRY_UNGROUP(pool, id) - end)) + end)); - registry:on_clear(ctype):connect(function() + (registry :: Registry):on_clear(ctype):connect(function() for _, cn in group.connections do cn:disconnect() end - end) + end); - registry:after_clear(ctype):connect(function() + (registry :: Registry):after_clear(ctype):connect(function() group.size = 0 for _, cn in group.connections do cn:reconnect() end - end) + end); end local pool = STORAGE((...)) @@ -2063,7 +2090,7 @@ local function registry_create(range_start: number?, range_stop: number?): Regis (b == nil or POOL_FIND(STORAGE(b), key) ~= ID_NULL) and (c == nil or POOL_FIND(STORAGE(c), key) ~= ID_NULL) and (d == nil or POOL_FIND(STORAGE(d), key) ~= ID_NULL) and - (e == nil or throw("args exceeded") :: never) + (e == nil or throw<>("args exceeded")) end :: any) :: (self: Registry, id: entity, T...) -> boolean local function UNSAFE_GET(ctype: CType, key: number): unknown @@ -2161,7 +2188,7 @@ local function registry_create(range_start: number?, range_stop: number?): Regis ASSERT(STORAGE(ctype).group == group, `cannot create group; {ctype_debug(ctype, i)} is not owned by the same group as previous args`) end - return group_create(registry, group or group_init(...), ...) + return group_create(registry :: Registry, group or group_init(...), ...) end function registry.storage(self: Registry, ctype: T?): any @@ -2229,7 +2256,7 @@ local function registry_create(range_start: number?, range_stop: number?): Regis end function registry.handle(self: Registry, id: entity?): Handle - id = id or registry:create() + id = id or (registry :: Registry):create() local handle = handle_cache[id] @@ -2239,18 +2266,18 @@ local function registry_create(range_start: number?, range_stop: number?): Regis entity = id :: entity }, Handle)) - handle_cache[id] = handle + handle_cache[id] = handle :: Handle end - return assert(handle) + return assert(handle) :: Handle end function registry.context(self: Registry): Handle - if not ENTITY_LIST_ID_IN_USE(entity_list, ID_CTX) then registry:create(ID_CTX) end - return registry:handle(ID_CTX) + if not ENTITY_LIST_ID_IN_USE(entity_list, ID_CTX) then (registry :: any):create(ID_CTX) end + return (registry :: Registry):handle(ID_CTX) end - return table.freeze(registry) + return table.freeze(registry) :: Registry end -------------------------------------------------------------------------------- @@ -2407,11 +2434,11 @@ local ecr = { component = function(constructor: () -> ()?) ASSERT(constructor == nil or type(constructor) == "function", "constructor must be a function") return ctype_create(constructor or false) - end :: (() -> unknown) & ((constructor: () -> T) -> T), + end :: ((constructor: (() -> T)?) -> component), - tag = function() return ctype_create(true) end :: () -> nil, + tag = function() return ctype_create(true) end :: () -> component, - is_tag = function(ctype: T): boolean + is_tag = function(ctype: read_component): boolean ASSERT_CTYPE_VALID(ctype) return ctype_is_tag(ctype) end, @@ -2427,7 +2454,7 @@ local ecr = { ASSERT_CTYPE_VALID(v) return ctype_names[v] end - end :: ((names: T & {}) -> T) & ((ctype: T) -> string?), + end :: ((names: T & {read [string]: read_component}) -> T) & ((ctype: component) -> string?), queue = queue_create, @@ -2435,7 +2462,7 @@ local ecr = { buffer_to_array = buffer_to_array, buffer_to_buffer = buffer_to_buffer, - entity = ctype_entity, + entity = ctype_entity :: component, null = ID_NULL, context = ID_CTX, id_size = ID_SIZE, diff --git a/test/typecheck/errors.luau b/test/typecheck/errors.luau new file mode 100644 index 0000000..770ad2e --- /dev/null +++ b/test/typecheck/errors.luau @@ -0,0 +1,97 @@ +--!strict +-- Every line marked `-- @error` must produce at least one type error. +-- No unmarked line may produce one. +local ecr = require("../../src/ecr") +type component = ecr.component +type read_component = ecr.read_component +type any_component = read_component + +local Number = ecr.component<>() +local String = ecr.component<>() +local Strings = ecr.component<<{ string }>>() + +local reg = ecr.registry() +local e = reg:create() + +-------------------------------------------------------------------------------- +-- the original bug: value must match the component's type +-------------------------------------------------------------------------------- + +reg:set(e, Number, "not a number") -- @error +reg:set(e, String, 9999) -- @error +reg:insert(e, Strings, 1) -- @error +reg:find(Number, "nope") -- @error +reg:copy(Number, String) -- @error +reg:patch(e, Number, function(n: number) return "no" end) -- @error + +-------------------------------------------------------------------------------- +-- an erased component cannot be written through +-------------------------------------------------------------------------------- + +local function launder_set(r: ecr.Registry, id: ecr.entity, c: any_component) + r:set(id, c, 1) -- @error +end + +local function launder_typed(r: ecr.Registry, id: ecr.entity, c: read_component) + r:set(id, c, 1) -- @error +end + +local function launder_patch(r: ecr.Registry, id: ecr.entity, c: any_component) + r:patch(id, c) -- @error +end + +local function launder_find(r: ecr.Registry, c: any_component) + r:find(c, 1) -- @error +end + +-- Pool.values is writable, so storage must not accept an erased component +local function launder_storage(r: ecr.Registry, c: any_component) + return r:storage(c) -- @error +end + +-- View:patch() writes values back, so views must not accept an erased component +local function launder_view(r: ecr.Registry, c: any_component) + return r:view(c) -- @error +end + +-------------------------------------------------------------------------------- +-- reads keep their precise type; they are not silently widened +-------------------------------------------------------------------------------- + +local bad_get: string = reg:get(e, Number) -- @error +local m1: number, m2: number = reg:get(e, Number, String) -- @error +local bad_try: string? = reg:try_get(e, Number) -- @error +local bad_add: string = reg:add(e, Number) -- @error +local bad_handle: string = reg:handle(e):get(Number) -- @error + +-------------------------------------------------------------------------------- +-- container variance +-------------------------------------------------------------------------------- + +local list: { component } = { Number } + +-- a writable array of erased components would let the callee insert the +-- wrong component type into the caller's array +local function mutate(cs: { any_component }) end +mutate(list) -- @error + +-------------------------------------------------------------------------------- +-- non-components are rejected +-------------------------------------------------------------------------------- + +reg:clear("NotAComponent") -- @error +reg:has(e, 42) -- @error +reg:set(e, "NotAComponent", 1) -- @error +ecr.is_tag({}) -- @error + +-- limitation: a table literal's named fields are not checked against a +-- `[string]` indexer, so `ecr.name` cannot validate its values at type level +-- The indexer is still required - it is what disambiguates the two overloads +ecr.name { Number = 5 } +ecr.name { 1, 2, 3, 4, 5} -- @error + +-------------------------------------------------------------------------------- +-- signals keep their value type +-------------------------------------------------------------------------------- + +reg:on_add(Number):connect(function(id, v: string) end) -- @error diff --git a/test/typecheck/run.sh b/test/typecheck/run.sh new file mode 100644 index 0000000..b0043b9 --- /dev/null +++ b/test/typecheck/run.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Type-level test runner for ecr's public type surface. +# usage: ./run.sh [path-to-luau-analyze] +set -uo pipefail +cd "$(dirname "$0")" + +ANALYZE="${1:-luau-analyze}" +command -v "$ANALYZE" >/dev/null 2>&1 || { echo "cannot find $ANALYZE"; exit 127; } + +FAILURES=0 +TOTAL=0 +PASSED=0 + +COLOR_WHITE='\033[37;1m' +COLOR_GREEN='\033[32;1m' +COLOR_RED='\033[31;1m' +COLOR_GRAY='\033[30;1m' +COLOR_RESET='\033[0m' + +print_case() { + local status=$1 name=$2 + local color=$COLOR_GREEN + [ "$status" = PASS ] || color=$COLOR_RED + printf '%b%s%b%b│%b %b%s%b\n' "$color" "$status" "$COLOR_RESET" "$COLOR_GRAY" "$COLOR_RESET" "$COLOR_GRAY" "$name" "$COLOR_RESET" +} + +pass_case() { + TOTAL=$((TOTAL + 1)) + PASSED=$((PASSED + 1)) + print_case PASS "$1" +} + +fail_case() { + TOTAL=$((TOTAL + 1)) + FAILURES=$((FAILURES + 1)) + print_case FAIL "$1" +} + +# ecr.luau is imported directly. Its implementation diagnostics are reported +# at src/ecr.luau locations; this suite checks only consumer fixture locations. +printf '%btypechecking%b\n' "$COLOR_WHITE" "$COLOR_RESET" +OUT=$("$ANALYZE" --solver=new tests.luau 2>&1 | grep -E '^\./tests\.luau\([0-9]+,.*(TypeError|SyntaxError)' || true) +if [ -n "$OUT" ]; then + fail_case "typechecks" + printf '%s\n' "$OUT" | sed 's/^/ /' +else + pass_case "typechecks" +fi + +OUT=$("$ANALYZE" --solver=new errors.luau 2>&1) + +mapfile -t GOT < <(echo "$OUT" | grep -E 'TypeError|SyntaxError' | grep -oE '^\./errors\.luau\([0-9]+,' | grep -oE '[0-9]+' | sort -un) +mapfile -t WANT < <(grep -nE '^[^-]*--[[:space:]]*@error' errors.luau | cut -d: -f1 | sort -un) + +in_list() { local n=$1; shift; for x in "$@"; do [ "$x" = "$n" ] && return 0; done; return 1; } + +for w in "${WANT[@]}"; do + if in_list "$w" "${GOT[@]}"; then + pass_case "fails typechecking ($w)" + else + fail_case "fails typechecking ($w)" + sed -n "${w}p" errors.luau | sed 's/^/ /' + fi +done + +for g in "${GOT[@]}"; do + if ! in_list "$g" "${WANT[@]}"; then + fail_case "unexpected type error ($g)" + echo "$OUT" | grep "errors.luau($g," | sed 's/^/ /' + fi +done + +printf '\n%b%d/%d test cases passed.%b\n' "$COLOR_GRAY" "$PASSED" "$TOTAL" "$COLOR_RESET" +if [ "$FAILURES" -eq 0 ]; then + printf '%b0 fails%b\n' "$COLOR_GREEN" "$COLOR_RESET" +else + printf '%b%d fail%s%b\n' "$COLOR_RED" "$FAILURES" "$([ "$FAILURES" -eq 1 ] && echo '' || echo 's')" "$COLOR_RESET" + exit 1 +fi diff --git a/test/typecheck/tests.luau b/test/typecheck/tests.luau new file mode 100644 index 0000000..f8725dd --- /dev/null +++ b/test/typecheck/tests.luau @@ -0,0 +1,172 @@ +--!strict +-- Every line in this file must typecheck cleanly. +local ecr = require("../../src/ecr") +type component = ecr.component +type read_component = ecr.read_component +type any_component = read_component + +local Number = ecr.component<>() +local String = ecr.component<>() +local Tags = ecr.component<<{ string }>>() +local Anything = ecr.component<>() +local NumOrStr = ecr.component<>() +local Tag = ecr.tag() + +local reg = ecr.registry() +local e = reg:create() + +-------------------------------------------------------------------------------- +-- constructors +-------------------------------------------------------------------------------- + +local Inferred = ecr.component(function() return "hi" end) +local _: component = Inferred + +local _: component = Tag +local _: component = ecr.entity + +-------------------------------------------------------------------------------- +-- writing: exact value type required +-------------------------------------------------------------------------------- + +reg:set(e, Number, 1) +reg:set(e, String, "hi") +reg:set(e, Anything, 1) +reg:set(e, Anything, "also fine") +reg:set(e, NumOrStr, 1) +reg:set(e, NumOrStr, "x") +reg:set(e, Tag, nil) +reg:insert(e, Tags, "a") +reg:patch(e, Number, function(n: number) return n + 1 end) +local _: ecr.entity? = reg:find(Number, 1) +reg:copy(Number, Number) + +-------------------------------------------------------------------------------- +-- reading: inference is preserved through read_component +-------------------------------------------------------------------------------- + +local _: number = reg:get(e, Number) +local _: number, _: string = reg:get(e, Number, String) +local h, n, t = reg:get(e, Number, String, Tags) +local _: number, _: string, _: { string } = h, n, t +local _: number? = reg:try_get(e, Number) +local _: number = reg:add(e, Number) + +-------------------------------------------------------------------------------- +-- erasure: multiple components in one call +-------------------------------------------------------------------------------- + +reg:clear(Number, String, Tags, Tag) +local _: boolean = reg:has(e, Number, String) +reg:remove(e, Number, String) +local _: boolean = ecr.is_tag(Tag) + +-------------------------------------------------------------------------------- +-- erasure: user-written wrappers +-------------------------------------------------------------------------------- + +-- the case from the PR discussion: an erased component can still be read +local function read_erased(r: ecr.Registry, id: ecr.entity, c: any_component): unknown + return r:get(id, c) +end +local _: unknown = read_erased(reg, e, Number) +local _: unknown = read_erased(reg, e, Anything) + +local function count(r: ecr.Registry, id: ecr.entity, ...: any_component): number + local n = 0 + for i = 1, select("#", ...) do + if r:has(id, (select(i, ...))) then n += 1 end + end + return n +end +local _: number = count(reg, e, Number, String) + +-- a wrapper that keeps the value type +local function get_or(r: ecr.Registry, id: ecr.entity, c: read_component, fallback: T): T + local v = r:try_get(id, c) + return if v == nil then fallback else v +end +local _: number = get_or(reg, e, Number, 0) +local _: string = get_or(reg, e, String, "") + +-- arrays of components, via a read-only indexer +local list: { component } = { Number } +local function clear_all(r: ecr.Registry, cs: { read any_component }) + for _, c in cs do r:clear(c) end +end +clear_all(reg, list) + +-------------------------------------------------------------------------------- +-- views, observers, groups +-------------------------------------------------------------------------------- + +for id, hp, nm in reg:view(Number, String) do + local _: ecr.entity, _: number, _: string = id, hp, nm +end +reg:view(Number):exclude(String, Tag) +reg:view(Number, String):patch(function(hp, nm) return hp + 1, nm end) +reg:view(Number):of({ e }) + +for id, hp in reg:track(Number) do + local _: ecr.entity, _: number = id, hp +end +reg:track(Number):exclude(Tag):clear() + +for id, hp, nm in reg:group(Number, String) do + local _: ecr.entity, _: number, _: string = id, hp, nm +end + +-------------------------------------------------------------------------------- +-- signals and storage +-------------------------------------------------------------------------------- + +reg:on_add(Number):connect(function(id, v) local _: number = v end) +reg:on_change(String):connect(function(id, v) local _: string = v end) +reg:on_remove(Number):connect(function(id) end) +reg:on_clear(Number):connect(function() end) +reg:after_clear(Number):connect(function() end) + +local pool = reg:storage(Number) +local _: number = pool.values[1] +pool.values[1] = 2 + +for c, p in reg:storage() do + local _: any_component, _ = c, p +end + +-------------------------------------------------------------------------------- +-- handles +-------------------------------------------------------------------------------- + +local hnd = reg:handle(e) +hnd:set(Number, 1):set(String, "x") +local _: number = hnd:get(Number) +local _: number, _: string = hnd:get(Number, String) +local _: number? = hnd:try_get(Number) +local _: boolean = hnd:has(Number, String) +hnd:remove(Number, String) + +-------------------------------------------------------------------------------- +-- naming +-------------------------------------------------------------------------------- + +local named = ecr.name { + Number = Number, + String = String, +} +local _: component = named.Number +local _: component = named.String +local _: string? = ecr.name(Number) + +-------------------------------------------------------------------------------- +-- unions +-------------------------------------------------------------------------------- + +local function serdes(c: component | component) end +serdes(Number) +serdes(String) + +local function serdes2(c: read_component) end +serdes2(Number) +serdes2(String) +serdes2(NumOrStr)