@tilua-types/luau 8.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,37 +1,67 @@
1
- # @tilua-types/luau
2
-
3
- Luau as a tilua type library.
4
-
5
- tilua itself knows Lua: its standard library is Lua's, and what the compiler
6
- writes runs on Lua 5.1 to 5.4, LuaJIT and Luau alike. This library is what
7
- makes a project Luau:
8
-
9
- - **Luau's additions, typed.** `buffer`, `bit32`, `utf8`, `string.split`,
10
- `table.find`, `table.freeze`, `warn`, `debug.info` and the rest — added to
11
- the standard library's own tables rather than replacing them.
12
- - **Luau's syntax in the output.** The compiler writes `a += b`, `continue`,
13
- `if c then a else b`, `a // b` and `for _, v in t` as they are, where plain
14
- Lua needs longer forms: shorter output, and the code Luau compiles fastest.
15
- A bundle of several files loads the others with `require` by path.
16
-
17
- ```bash
18
- npm i -D @tilua-types/luau
19
- ```
20
-
21
- ```json
22
- { "types": ["luau"] }
23
- ```
24
-
25
- [`@tilua-types/roblox`](../roblox) loads it: Roblox's Lua is Luau, so a
26
- Roblox project has both without naming this one.
27
-
28
- <!-- compatibility:start -->
29
- ## Compatibility
30
-
31
- `@tilua-types/luau@8.0.0` works with these tilua packages. *Released with* is the
32
- version it was built and tested against.
33
-
34
- | Package | Range | Released with | |
35
- | --- | --- | --- | --- |
36
- | `@tilua/parser` | `^8.0.0` | 8.0.0 | peer |
37
- <!-- compatibility:end -->
1
+ # @tilua-types/luau
2
+
3
+ Luau as a tilua type library.
4
+
5
+ tilua itself knows Lua: its standard library is Lua's, and what the compiler
6
+ writes runs on Lua 5.1 to 5.4, LuaJIT and Luau alike. This library is what
7
+ makes a project Luau:
8
+
9
+ - **Luau's additions, typed.** `buffer`, `bit32`, `utf8`, `string.split`,
10
+ `table.find`, `table.freeze`, `warn`, `debug.info` and the rest — added to
11
+ the standard library's own tables rather than replacing them.
12
+ - **Luau's syntax in the output.** The compiler writes `a += b`, `continue`,
13
+ `if c then a else b`, `a // b` and `for _, v in t` as they are, where plain
14
+ Lua needs longer forms: shorter output, and the code Luau compiles fastest.
15
+ A bundle of several files loads the others with `require` by path.
16
+ - **Typed exports.** A bundle whose entry exports is cast to the entry's
17
+ exports, written as Luau types, so a script that `require`s it knows what
18
+ it got (see below).
19
+
20
+ ```bash
21
+ npm i -D @tilua-types/luau
22
+ ```
23
+
24
+ ```json
25
+ { "types": ["luau"] }
26
+ ```
27
+
28
+ [`@tilua-types/roblox`](../roblox) loads it: Roblox's Lua is Luau, so a
29
+ Roblox project has both without naming this one.
30
+
31
+ ## Typed exports
32
+
33
+ ```lua
34
+ export type Point = { x: number, y: number }
35
+ export type Dog = { name: string, friend: (self: Dog) -> Dog, ClassObject: DogClass }
36
+ type DogClass = { new: (name: string) -> Dog, name: "Dog", ParentClass: nil }
37
+ return (G.require("src/main") :: any) :: { origin: () -> Point, Dog: DogClass, id: <T>(v: T) -> T }
38
+ ```
39
+
40
+ - Every `export type` of the entry is written as one, so the requirer can
41
+ name it: `local p: Main.Point = Main.origin()`.
42
+ - Any other named type the exports reach — a class, an interface — gets a
43
+ local alias, which is also how a type that reaches itself is written.
44
+ - A class the host made (a Roblox `Part`, `Vector3`) is named as it is: its
45
+ Luau has that type already.
46
+ - What Luau's types cannot say is written as near as they come: a tuple is a
47
+ table of its elements' union, a template literal type a `string`, and a
48
+ conditional or mapped type that was never resolved `any`. A `private`
49
+ member is left out.
50
+
51
+ The cast goes through `any`, so it is never an error in the bundle itself.
52
+ It is this library's option, on unless the config turns it off:
53
+
54
+ ```json
55
+ { "types": ["luau"], "typedExports": false }
56
+ ```
57
+
58
+ <!-- compatibility:start -->
59
+ ## Compatibility
60
+
61
+ `@tilua-types/luau@9.0.0` works with these tilua packages. *Released with* is the
62
+ version it was built and tested against.
63
+
64
+ | Package | Range | Released with | |
65
+ | --- | --- | --- | --- |
66
+ | `@tilua/parser` | `^9.0.0` | 9.0.0 | peer |
67
+ <!-- compatibility:end -->
package/exports.mjs ADDED
@@ -0,0 +1,311 @@
1
+ // @ts-check
2
+ /**
3
+ * What the entry exports, written as a Luau type.
4
+ *
5
+ * A bundle that exports is a ModuleScript, and the script that `require`s it
6
+ * reads its type from what it returns. Lowered, that is `G.require(...)` — a
7
+ * table nothing types — so the bundle casts it on the way out:
8
+ *
9
+ * export type Point = { x: number, y: number }
10
+ * type DogClass = { new: (name: string) -> Dog, ... }
11
+ * export type Dog = { name: string, friend: (self: Dog) -> Dog, ... }
12
+ * return (G.require("src/main") :: any) :: { origin: () -> Point, Dog: DogClass }
13
+ *
14
+ * The cast goes through `any`, so it can never be an error in the bundle
15
+ * itself. Every `export type` of the entry is written as one, so a requirer
16
+ * can name it (`Main.Point`). Any other named type the exports reach — a
17
+ * class, an interface — gets a local alias, which is also what lets a type
18
+ * that reaches itself (`friend(): Dog`) be written at all. A class the host
19
+ * made (a Roblox `Part`) is named as it is: its Luau has that type already.
20
+ *
21
+ * It is the reader's view, not a proof: what Luau's types cannot say — a
22
+ * tuple, a conditional, a mapped type — is written as near as Luau comes,
23
+ * and as `any` where nothing comes near.
24
+ */
25
+ import { isIdentifier, isLuauName, luauString } from "@tilua/parser"
26
+
27
+ /** @typedef {import("@tilua/parser").Type} Type */
28
+ /** @typedef {import("@tilua/parser").ModuleExports} ModuleExports */
29
+ /**
30
+ * @typedef {object} Alias
31
+ * @property {string} name
32
+ * @property {Type} type
33
+ * @property {readonly string[]} params
34
+ * @property {string} shape its property names, to tell two types of one name apart
35
+ * @property {string} [body]
36
+ * @property {boolean} [exported]
37
+ */
38
+
39
+ /** How deep an unnamed type is written before the rest is `any`. */
40
+ const MAX_DEPTH = 12
41
+
42
+ /**
43
+ * The type of `exports`' table, and the aliases it names — or `undefined`
44
+ * when the module's exports are not known.
45
+ * @param {ModuleExports} exports
46
+ * @returns {{ declarations: string[], type: string } | undefined}
47
+ */
48
+ export function typedExports(exports) {
49
+ if (exports.partial) return undefined
50
+ const writer = new TypeWriter()
51
+ // The module's own `export type`s first: they keep the names they have.
52
+ // `default` is the type of an `export default class`, which the class's
53
+ // own name already says.
54
+ const named = [...exports.types].filter(([name]) => isLuauName(name) && name !== "default")
55
+ for (const [name, exported] of named) writer.reserve(name, exported.type, exported.params)
56
+ /** @type {[string, Type][]} */
57
+ const fields = [...exports.values]
58
+ if (exports.default) fields.push(["default", exports.default])
59
+ const type = writer.table(fields.map(([name, type]) => ({ name, type, optional: false })), undefined, new Set(), new Set(), 0)
60
+ for (const [name, exported] of named) writer.define(name, exported.type, exported.params)
61
+ return { declarations: writer.declarations(), type }
62
+ }
63
+
64
+ class TypeWriter {
65
+ constructor() {
66
+ /** @type {Map<Type, Alias>} */
67
+ this.byType = new Map()
68
+ /** @type {Map<string, Alias>} */
69
+ this.byName = new Map()
70
+ /** Aliases by what the program called them, every one of that name.
71
+ * @type {Map<string, Alias[]>} */
72
+ this.written = new Map()
73
+ /** @type {Alias[]} */
74
+ this.order = []
75
+ }
76
+
77
+ /** Claim `name` for `type` before anything is written, so that the
78
+ * program's own name is the one it keeps.
79
+ * @param {string} name @param {Type} type @param {readonly string[]} params */
80
+ reserve(name, type, params) {
81
+ this.add(name, name, type, params).exported = true
82
+ }
83
+
84
+ /** Write the body of an alias `reserve` claimed.
85
+ * @param {string} name @param {Type} type @param {readonly string[]} params */
86
+ define(name, type, params) {
87
+ const alias = this.byName.get(name)
88
+ if (alias && alias.body === undefined) alias.body = this.write(type, new Set(params), new Set(), 0, true)
89
+ }
90
+
91
+ /** @returns {string[]} */
92
+ declarations() {
93
+ return this.order.filter(a => a.body !== undefined).map(a => {
94
+ const params = a.params.length ? `<${a.params.join(", ")}>` : ""
95
+ return `${a.exported ? "export " : ""}type ${a.name}${params} = ${a.body}`
96
+ })
97
+ }
98
+
99
+ /** @param {string} given @param {string} name @param {Type} type @param {readonly string[]} params */
100
+ add(given, name, type, params) {
101
+ /** @type {Alias} */
102
+ const alias = { name, type, params, shape: shapeOf(type) }
103
+ // Two names for one type: the first keeps it.
104
+ if (!this.byType.has(type)) this.byType.set(type, alias)
105
+ this.byName.set(name, alias)
106
+ const same = this.written.get(given) ?? []
107
+ same.push(alias)
108
+ this.written.set(given, same)
109
+ this.order.push(alias)
110
+ return alias
111
+ }
112
+
113
+ /** The alias that writes the named type `type` — the program's name for
114
+ * it, or a fresh one where that is taken by a different type.
115
+ * @param {string} given @param {Type} type @param {ReadonlySet<string>} scope */
116
+ named(given, type, scope) {
117
+ const known = [this.byType.get(type), ...(this.written.get(given) ?? [])]
118
+ .find(a => a !== undefined && a.params.length === 0 && (a.type === type || a.shape === shapeOf(type)))
119
+ if (known) return known.name
120
+ const base = luauTypeName(given)
121
+ let name = base
122
+ for (let n = 2; this.byName.has(name) || scope.has(name); n++) name = `${base}${n}`
123
+ const alias = this.add(given, name, type, [])
124
+ // Registered before it is written: a type that reaches itself names
125
+ // the alias on the way back instead of writing itself forever.
126
+ alias.body = this.write(type, new Set(), new Set(), 0, true)
127
+ return name
128
+ }
129
+
130
+ /**
131
+ * `type` in Luau. `scope` holds the type parameters that may be named
132
+ * here; `seen`, the unnamed types being written further up. `body` says
133
+ * this is the alias's own body, which is written out, not named.
134
+ * @param {Type} type @param {ReadonlySet<string>} scope @param {ReadonlySet<Type>} seen
135
+ * @param {number} depth @param {boolean} [body]
136
+ * @returns {string}
137
+ */
138
+ write(type, scope, seen, depth, body = false) {
139
+ // A type an alias already writes is named, not written again — even
140
+ // inside its own alias, which is how a type reaches itself.
141
+ const alias = body ? undefined : this.byType.get(type)
142
+ if (alias && alias.params.length === 0) return alias.name
143
+ if (depth > MAX_DEPTH || seen.has(type)) return "any"
144
+ const inner = new Set(seen).add(type)
145
+ /** @param {Type} t @param {ReadonlySet<string>} [s] */
146
+ const next = (t, s = scope) => this.write(t, s, inner, depth + 1)
147
+ switch (type.kind) {
148
+ case "any": return "any"
149
+ case "unknown": return "unknown"
150
+ case "never": return "never"
151
+ case "primitive":
152
+ if (type.name === "void") return "nil"
153
+ return type.name === "object" ? "any" : type.name
154
+ case "literal":
155
+ if (type.base === "string") return luauString(String(type.value))
156
+ return type.base === "boolean" ? String(type.value) : "number"
157
+ case "array": return `{ ${next(type.element)} }`
158
+ case "tuple": {
159
+ // A table of the elements: Luau has no type for each place.
160
+ const members = [...type.elements, ...(type.rest ? [type.rest] : [])]
161
+ return members.length ? `{ ${union(members.map(m => next(m)))} }` : "{}"
162
+ }
163
+ case "object": {
164
+ if (!body) {
165
+ // `Box<number>`: an instance of a generic alias this file writes.
166
+ const ref = type.nameRef
167
+ const generic = ref && this.byName.get(ref.name)
168
+ if (ref && generic && generic.params.length === ref.typeArguments.length) {
169
+ return `${generic.name}<${ref.typeArguments.map(a => next(a)).join(", ")}>`
170
+ }
171
+ // A class the host made — a Roblox `Part` — is a type
172
+ // its Luau already has, by the same name.
173
+ if (type.class && !type.class.runtime && isLuauName(type.class.name)) {
174
+ const args = type.class.typeArguments?.get(type.class.name)
175
+ return args?.length ? `${type.class.name}<${args.map(a => next(a)).join(", ")}>` : type.class.name
176
+ }
177
+ if (type.name && !ref) return this.named(type.name, type, scope)
178
+ }
179
+ const properties = [...type.properties]
180
+ // What only the class itself may read is not the requirer's.
181
+ .filter(([, p]) => !p.private)
182
+ .map(([name, p]) => ({ name, type: p.type, optional: p.optional }))
183
+ return this.table(properties, type.indexer, scope, inner, depth)
184
+ }
185
+ case "function": {
186
+ const generic = new Set([...scope, ...(type.typeParams ?? [])])
187
+ const params = type.params.map((p, i) => {
188
+ const written = this.write(p.type, generic, inner, depth + 1)
189
+ const shown = p.optional ? optional(written) : written
190
+ // A method's receiver is Luau's `self`.
191
+ const name = i === 0 && (p.name === "this" || p.name === "self") ? "self" : p.name
192
+ return name && isLuauName(name) ? `${name}: ${shown}` : shown
193
+ })
194
+ if (type.varargs) {
195
+ params.push(type.restIsWhole ? "...any" : `...${this.write(type.varargs, generic, inner, depth + 1)}`)
196
+ }
197
+ const generics = type.typeParams?.length ? `<${type.typeParams.join(", ")}>` : ""
198
+ // Nothing, where Luau says so: `-> ()`, not one `nil`.
199
+ const returns = type.returns.kind === "primitive" && (type.returns.name === "void" || type.returns.name === "nil")
200
+ ? "()" : atom(this.write(type.returns, generic, inner, depth + 1))
201
+ return `${generics}(${params.join(", ")}) -> ${returns}`
202
+ }
203
+ case "typeParam":
204
+ if (scope.has(type.name)) return type.name
205
+ return type.constraint ? next(type.constraint) : "any"
206
+ case "union": {
207
+ const members = type.types.map(t => next(t))
208
+ const hasNil = type.types.some(t => t.kind === "primitive" && (t.name === "nil" || t.name === "void"))
209
+ const rest = members.filter(m => m !== "nil")
210
+ if (!hasNil) return union(rest)
211
+ if (!rest.length) return "nil"
212
+ return rest.length === 1 ? optional(rest[0]) : `${union(rest)} | nil`
213
+ }
214
+ case "intersection": {
215
+ const members = [...new Set(type.types.map(t => atom(next(t))))]
216
+ return members.includes("any") ? "any" : members.join(" & ")
217
+ }
218
+ case "genericRef": {
219
+ const generic = this.byName.get(type.name)
220
+ if (generic && generic.params.length === type.typeArguments.length && generic.params.length > 0) {
221
+ return `${generic.name}<${type.typeArguments.map(a => next(a)).join(", ")}>`
222
+ }
223
+ if (scope.has(type.name) && !type.typeArguments.length) return type.name
224
+ const expanded = type.origin?.expand(type)
225
+ return expanded && expanded !== type ? next(expanded) : "any"
226
+ }
227
+ case "templateLiteral": return "string"
228
+ case "difference": return next(type.base)
229
+ default: return "any"
230
+ }
231
+ }
232
+
233
+ /**
234
+ * A table type: `{ name: T, ["not a name"]: U, [K]: V }`.
235
+ * @param {readonly { name: string, type: Type, optional: boolean }[]} properties
236
+ * @param {{ key: Type, value: Type } | undefined} indexer
237
+ * @param {ReadonlySet<string>} scope @param {ReadonlySet<Type>} seen @param {number} depth
238
+ */
239
+ table(properties, indexer, scope, seen, depth) {
240
+ const fields = properties.map(p => {
241
+ const written = this.write(p.type, scope, seen, depth + 1)
242
+ const key = isLuauName(p.name) ? p.name : `[${luauString(p.name)}]`
243
+ return `${key}: ${p.optional ? optional(written) : written}`
244
+ })
245
+ if (indexer) fields.push(`[${this.write(indexer.key, scope, seen, depth + 1)}]: ${this.write(indexer.value, scope, seen, depth + 1)}`)
246
+ return fields.length ? `{ ${fields.join(", ")} }` : "{}"
247
+ }
248
+ }
249
+
250
+ /** @param {readonly string[]} members */
251
+ function union(members) {
252
+ const unique = [...new Set(members)]
253
+ if (unique.includes("any")) return "any"
254
+ // `true | false` is how the analyzer holds a `boolean`.
255
+ if (unique.includes("true") && unique.includes("false")) {
256
+ unique.splice(unique.indexOf("true"), 1, "boolean")
257
+ unique.splice(unique.indexOf("false"), 1)
258
+ }
259
+ return unique.length ? unique.map(atom).join(" | ") : "never"
260
+ }
261
+
262
+ /** `T?`, with `T` in parentheses where `?` would bind to less of it.
263
+ * @param {string} written */
264
+ function optional(written) {
265
+ if (written === "any" || written === "unknown" || written === "nil" || written.endsWith("?")) return written
266
+ if (written.endsWith(" | nil")) return written
267
+ return `${atom(written)}?`
268
+ }
269
+
270
+ /** `written` in parentheses where it is a function, a union or an
271
+ * intersection: an operand of `|`, `&` or `?`, or a function's result.
272
+ * @param {string} written */
273
+ function atom(written) {
274
+ return isCompound(written) ? `(${written})` : written
275
+ }
276
+
277
+ /** Does `written` have `->`, `|` or `&` outside every bracket?
278
+ * @param {string} written */
279
+ function isCompound(written) {
280
+ let depth = 0
281
+ let quoted = false
282
+ for (let i = 0; i < written.length; i++) {
283
+ const c = written[i]
284
+ if (quoted) {
285
+ if (c === "\\") i++
286
+ else if (c === "\"") quoted = false
287
+ continue
288
+ }
289
+ if (c === "\"") quoted = true
290
+ else if (c === "(" || c === "{" || c === "<") depth++
291
+ else if (c === ")" || c === "}" || (c === ">" && written[i - 1] !== "-")) depth--
292
+ else if (depth === 0 && (c === "|" || c === "&" || (c === "-" && written[i + 1] === ">"))) return true
293
+ }
294
+ return false
295
+ }
296
+
297
+ /** A type's property names: two types the program gave one name are one
298
+ * type only if they have the same members.
299
+ * @param {Type} type */
300
+ function shapeOf(type) {
301
+ return type.kind === "object" ? [...type.properties.keys()].sort().join(",") : type.kind
302
+ }
303
+
304
+ /** A name the program gave a type, as a Luau type name: `typeof Dog` — the
305
+ * class table — is `DogClass`.
306
+ * @param {string} given */
307
+ function luauTypeName(given) {
308
+ const name = given.startsWith("typeof ") ? `${given.slice("typeof ".length)}Class` : given
309
+ const cleaned = name.replace(/[^A-Za-z0-9_]/g, "_")
310
+ return isIdentifier(cleaned) && isLuauName(cleaned) ? cleaned : `T_${cleaned}`
311
+ }
package/lowering.mjs CHANGED
@@ -10,7 +10,12 @@
10
10
  * if c then a else b rather than a function called on the spot
11
11
  * for _, v in t rather than ipairs(t), or a function doing what Luau does
12
12
  * require("./chunk") rather than loadfile, which Luau has none of
13
+ *
14
+ * Luau has types, too: with `typedExports` (on unless the config turns it
15
+ * off), what the bundle's entry exports is cast to its type, so a script that
16
+ * `require`s the bundle knows what it got (see exports.mjs).
13
17
  */
18
+ import { typedExports } from "./exports.mjs"
14
19
 
15
20
  /** @typedef {import("@tilua/parser").LoweringPlugin} LoweringPlugin */
16
21
 
@@ -26,6 +31,13 @@ const plugin = {
26
31
  "numberLiterals",
27
32
  "requireByPath",
28
33
  ],
34
+ entryExports({ value, exports, options }) {
35
+ if (options.typedExports === false) return undefined
36
+ const typed = typedExports(exports)
37
+ if (!typed) return undefined
38
+ // Through `any`, so the cast is never an error in the bundle itself.
39
+ return { statements: typed.declarations, value: `(${value} :: any) :: ${typed.type}` }
40
+ },
29
41
  }
30
42
 
31
43
  export default plugin
package/package.json CHANGED
@@ -1,19 +1,27 @@
1
1
  {
2
2
  "name": "@tilua-types/luau",
3
- "version": "8.0.0",
3
+ "version": "9.0.0",
4
4
  "description": "Luau as a tilua type library: what Luau adds to Lua 5.1 (buffer, bit32, string.split, ...), and Luau's syntax in the compiled output",
5
5
  "license": "ISC",
6
6
  "files": [
7
7
  "index.d.tilua",
8
8
  "lowering.mjs",
9
+ "exports.mjs",
9
10
  "README.md"
10
11
  ],
11
12
  "tilua": {
12
13
  "types": "index.d.tilua",
13
- "lowering": "lowering.mjs"
14
+ "lowering": "lowering.mjs",
15
+ "options": {
16
+ "typedExports": {
17
+ "type": "boolean",
18
+ "default": true,
19
+ "description": "Cast what the bundle's entry exports to its Luau type, so a script that requires the bundle knows what it got."
20
+ }
21
+ }
14
22
  },
15
23
  "peerDependencies": {
16
- "@tilua/parser": "^8.0.0"
24
+ "@tilua/parser": "^9.0.0"
17
25
  },
18
26
  "keywords": [
19
27
  "tilua",