@defold-typescript/transpiler 0.40.0 → 0.42.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.
@@ -3,5 +3,12 @@ import { type Plugin } from "typescript-to-lua";
3
3
  export declare const FACTORY_MODULE = "@defold-typescript/types";
4
4
  export declare const FACTORY_NAMES: Set<string>;
5
5
  export declare function resolvesToFactoryExport(callee: ts.Expression, checker: ts.TypeChecker): boolean;
6
+ export interface PropertyMember {
7
+ readonly name: string;
8
+ readonly initializer: ts.Expression;
9
+ }
10
+ export declare function propertyMembers(property: ts.ObjectLiteralElementLike): PropertyMember[];
11
+ export declare function factoryHooks(expression: ts.Expression, checker: ts.TypeChecker): ts.ObjectLiteralExpression | undefined;
12
+ export declare function isPropertiesHook(property: ts.ObjectLiteralElementLike): boolean;
6
13
  export declare function isFactoryOnlyImport(node: ts.ImportDeclaration): boolean;
7
14
  export declare const lifecycleErasurePlugin: Plugin;
@@ -1,2 +1,3 @@
1
1
  export declare const SCAFFOLDED_DEFIGNORE_LINES: string[];
2
- export declare function isDefignoredPath(displayPath: string): boolean;
2
+ export declare function parseDefignore(text: string): string[];
3
+ export declare function isDefignoredPath(displayPath: string, lines?: readonly string[]): boolean;
@@ -0,0 +1,16 @@
1
+ import * as ts from "typescript";
2
+ export interface TextScriptPropertyFinding {
3
+ readonly name: string;
4
+ readonly file: string;
5
+ /** 1-based line of the default's initializer or the direct call's value. */
6
+ readonly line: number;
7
+ /** 1-based column of the default's initializer or the direct call's value. */
8
+ readonly column: number;
9
+ }
10
+ export declare function findTextScriptProperties(program: ts.Program, fileNames: readonly string[]): TextScriptPropertyFinding[];
11
+ /**
12
+ * Whether a value of `type` may be a string at runtime: a string-like type, a
13
+ * union or intersection with such a member, or a type parameter or other
14
+ * instantiable type whose constraint may hold one.
15
+ */
16
+ export declare function mayHoldString(type: ts.Type, checker: ts.TypeChecker, visited?: Set<ts.Type>): boolean;
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from "typescript-to-lua";
2
+ export declare const TYPE_APPLICATION_APIS: ReadonlySet<string>;
3
+ export declare const typeApplicationErasurePlugin: Plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defold-typescript/transpiler",
3
- "version": "0.40.0",
3
+ "version": "0.42.0",
4
4
  "description": "TypeScript-to-Lua build pipeline tuned for Defold's runtime.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,7 +35,7 @@
35
35
  "test": "bun test"
36
36
  },
37
37
  "dependencies": {
38
- "@defold-typescript/types": "0.40.0",
38
+ "@defold-typescript/types": "0.42.0",
39
39
  "@typescript-to-lua/language-extensions": "1.19.0",
40
40
  "typescript-to-lua": "^1.36.0"
41
41
  }
package/src/index.ts CHANGED
@@ -48,7 +48,11 @@ export {
48
48
  stripIncludeBase,
49
49
  } from "./output-paths";
50
50
  export { getProgramDiagnostics } from "./program-diagnostics";
51
- export { isDefignoredPath, SCAFFOLDED_DEFIGNORE_LINES } from "./project-resources";
51
+ export {
52
+ isDefignoredPath,
53
+ parseDefignore,
54
+ SCAFFOLDED_DEFIGNORE_LINES,
55
+ } from "./project-resources";
52
56
  export { rewriteEmittedRequires } from "./require-rewrite";
53
57
  export { buildSceneAddressDeclaration } from "./scene-address-declaration";
54
58
  export type { SceneCollectionRoles, SceneCollectionRolesInput } from "./scene-collection-roles";
@@ -86,6 +90,8 @@ export type { SceneMessage } from "./scene-text-format";
86
90
  export { parseSceneTextFormat, SceneTextFormatError } from "./scene-text-format";
87
91
  export type { TranspileSession } from "./session";
88
92
  export { createTranspileSession } from "./session";
93
+ export type { TextScriptPropertyFinding } from "./text-script-property";
94
+ export { findTextScriptProperties } from "./text-script-property";
89
95
  export { TIMERS_REQUIRE_NAME } from "./timers-runtime";
90
96
  export type {
91
97
  TranspileDiagnostic,
@@ -208,18 +208,19 @@ function emitInitMerge(
208
208
  return [builderDecl, createAssignmentStatement(createIdentifier("init"), initFn, property)];
209
209
  }
210
210
 
211
- // The value-keyed `properties` field maps each `key: default` to a chunk-scope
212
- // `go.property("key", default)` the editor reads. Emitted before the hook
213
- // assignments and after the source's `local` consts, so a default referencing a
214
- // module const resolves.
215
- function emitPropertyRegistrations(
216
- property: ts.ObjectLiteralElementLike,
217
- context: TransformationContext,
218
- ): Statement[] {
211
+ export interface PropertyMember {
212
+ readonly name: string;
213
+ readonly initializer: ts.Expression;
214
+ }
215
+
216
+ // The `key: default` members of a factory's `properties` field that register as
217
+ // script properties: a property assignment keyed by an identifier or a string
218
+ // literal. Shared by the emitter and every analysis of what it registers.
219
+ export function propertyMembers(property: ts.ObjectLiteralElementLike): PropertyMember[] {
219
220
  if (!ts.isPropertyAssignment(property) || !ts.isObjectLiteralExpression(property.initializer)) {
220
221
  return [];
221
222
  }
222
- const registrations: Statement[] = [];
223
+ const members: PropertyMember[] = [];
223
224
  for (const member of property.initializer.properties) {
224
225
  if (!ts.isPropertyAssignment(member)) {
225
226
  continue;
@@ -228,32 +229,60 @@ function emitPropertyRegistrations(
228
229
  if (!ts.isIdentifier(key) && !ts.isStringLiteral(key)) {
229
230
  continue;
230
231
  }
231
- registrations.push(
232
- createExpressionStatement(
233
- createCallExpression(
234
- createTableIndexExpression(createIdentifier("go"), createStringLiteral("property")),
235
- [createStringLiteral(key.text), context.transformExpression(member.initializer)],
236
- ),
237
- ),
238
- );
232
+ members.push({ name: key.text, initializer: member.initializer });
239
233
  }
240
- return registrations;
234
+ return members;
241
235
  }
242
236
 
243
- function eraseFactoryCall(
237
+ // The hooks object literal of a call to a lifecycle factory, or `undefined` when
238
+ // the expression is not one the erasure rewrites.
239
+ export function factoryHooks(
244
240
  expression: ts.Expression,
245
- context: TransformationContext,
246
- ): Statement[] | undefined {
241
+ checker: ts.TypeChecker,
242
+ ): ts.ObjectLiteralExpression | undefined {
247
243
  if (!ts.isCallExpression(expression)) {
248
244
  return undefined;
249
245
  }
250
- if (!resolvesToFactoryExport(expression.expression, context.checker)) {
246
+ if (!resolvesToFactoryExport(expression.expression, checker)) {
251
247
  return undefined;
252
248
  }
253
249
  const hooks = expression.arguments[0];
254
250
  if (hooks === undefined || !ts.isObjectLiteralExpression(hooks)) {
255
251
  return undefined;
256
252
  }
253
+ return hooks;
254
+ }
255
+
256
+ export function isPropertiesHook(property: ts.ObjectLiteralElementLike): boolean {
257
+ return hookName(property) === "properties";
258
+ }
259
+
260
+ // The value-keyed `properties` field maps each `key: default` to a chunk-scope
261
+ // `go.property("key", default)` the editor reads. Emitted before the hook
262
+ // assignments and after the source's `local` consts, so a default referencing a
263
+ // module const resolves.
264
+ function emitPropertyRegistrations(
265
+ property: ts.ObjectLiteralElementLike,
266
+ context: TransformationContext,
267
+ ): Statement[] {
268
+ return propertyMembers(property).map(({ name, initializer }) =>
269
+ createExpressionStatement(
270
+ createCallExpression(
271
+ createTableIndexExpression(createIdentifier("go"), createStringLiteral("property")),
272
+ [createStringLiteral(name), context.transformExpression(initializer)],
273
+ ),
274
+ ),
275
+ );
276
+ }
277
+
278
+ function eraseFactoryCall(
279
+ expression: ts.Expression,
280
+ context: TransformationContext,
281
+ ): Statement[] | undefined {
282
+ const hooks = factoryHooks(expression, context.checker);
283
+ if (hooks === undefined) {
284
+ return undefined;
285
+ }
257
286
  const registrations: Statement[] = [];
258
287
  const statements: Statement[] = [];
259
288
  for (const property of hooks.properties) {
@@ -1,10 +1,10 @@
1
1
  import * as ts from "typescript";
2
2
  import * as tstl from "typescript-to-lua";
3
- import { arrayIndexOptionLoweringPlugin } from "./array-index-option-lowering";
4
3
  import { lifecycleErasurePlugin } from "./lifecycle-erasure";
5
4
  import { messageDispatchLoweringPlugin } from "./message-dispatch-lowering";
6
5
  import { messageGuardLoweringPlugin } from "./message-guard-lowering";
7
6
  import { timersLoweringPlugin } from "./timers-lowering";
7
+ import { typeApplicationErasurePlugin } from "./type-application-erasure";
8
8
  import { windowEventGuardLoweringPlugin } from "./window-event-guard-lowering";
9
9
 
10
10
  // The same lua plugins the build path lists (transpile.ts / session.ts), so the
@@ -15,7 +15,7 @@ const LUA_PLUGINS: tstl.Plugin[] = [
15
15
  windowEventGuardLoweringPlugin,
16
16
  messageDispatchLoweringPlugin,
17
17
  timersLoweringPlugin,
18
- arrayIndexOptionLoweringPlugin,
18
+ typeApplicationErasurePlugin,
19
19
  ];
20
20
 
21
21
  const noopWriteFile: ts.WriteFileCallback = () => {};
@@ -9,12 +9,25 @@
9
9
  // `.vscode`).
10
10
  export const SCAFFOLDED_DEFIGNORE_LINES = ["/node_modules", "/.defold-types", "/.vscode"];
11
11
 
12
- // Whether a project-relative display path lies under one of the lines `init`
13
- // scaffolds. Root-anchored, because a `.defignore` line is a root-relative
14
- // path: `assets/node_modules/tiles.atlas` is a resource Defold loads.
15
- export function isDefignoredPath(displayPath: string): boolean {
12
+ // A project's own `.defignore` text as root-anchored lines, the shape
13
+ // `isDefignoredPath` matches against. A line without its leading `/` names the
14
+ // same root-relative path.
15
+ export function parseDefignore(text: string): string[] {
16
+ return text
17
+ .split("\n")
18
+ .map((line) => line.trim())
19
+ .filter((line) => line !== "")
20
+ .map((line) => (line.startsWith("/") ? line : `/${line}`));
21
+ }
22
+
23
+ // Whether a project-relative display path lies under one of `lines`, by
24
+ // default the ones `init` scaffolds. Root-anchored, because a `.defignore` line
25
+ // is a root-relative path: `assets/node_modules/tiles.atlas` is a resource
26
+ // Defold loads.
27
+ export function isDefignoredPath(
28
+ displayPath: string,
29
+ lines: readonly string[] = SCAFFOLDED_DEFIGNORE_LINES,
30
+ ): boolean {
16
31
  const rooted = `/${displayPath.replace(/^\/+/, "")}`;
17
- return SCAFFOLDED_DEFIGNORE_LINES.some(
18
- (line) => rooted === line || rooted.startsWith(`${line}/`),
19
- );
32
+ return lines.some((line) => rooted === line || rooted.startsWith(`${line}/`));
20
33
  }
package/src/session.ts CHANGED
@@ -3,7 +3,6 @@ import { createRequire } from "node:module";
3
3
  import * as path from "node:path";
4
4
  import * as ts from "typescript";
5
5
  import * as tstl from "typescript-to-lua";
6
- import { arrayIndexOptionLoweringPlugin } from "./array-index-option-lowering";
7
6
  import { type CompanionEmitter, createCompanionEmitPlugin } from "./companion-emit";
8
7
  import { editorScriptErasurePlugin } from "./editor-script-erasure";
9
8
  import { lifecycleErasurePlugin } from "./lifecycle-erasure";
@@ -11,6 +10,7 @@ import { messageDispatchLoweringPlugin } from "./message-dispatch-lowering";
11
10
  import { messageGuardLoweringPlugin } from "./message-guard-lowering";
12
11
  import { timersLoweringPlugin } from "./timers-lowering";
13
12
  import { AMBIENT_FILES, collectOutputs, type TranspileProjectResult } from "./transpile";
13
+ import { typeApplicationErasurePlugin } from "./type-application-erasure";
14
14
  import { windowEventGuardLoweringPlugin } from "./window-event-guard-lowering";
15
15
 
16
16
  export interface TranspileSession {
@@ -44,7 +44,7 @@ function compilerOptions(companionEmit: CompanionEmitter): tstl.CompilerOptions
44
44
  { plugin: windowEventGuardLoweringPlugin },
45
45
  { plugin: messageDispatchLoweringPlugin },
46
46
  { plugin: timersLoweringPlugin },
47
- { plugin: arrayIndexOptionLoweringPlugin },
47
+ { plugin: typeApplicationErasurePlugin },
48
48
  { plugin: companionEmit.plugin },
49
49
  ],
50
50
  };
@@ -0,0 +1,100 @@
1
+ import * as ts from "typescript";
2
+ import { findDirectGoPropertyCalls } from "./go-property-direct-call";
3
+ import { factoryHooks, isPropertiesHook, propertyMembers } from "./lifecycle-erasure";
4
+
5
+ export interface TextScriptPropertyFinding {
6
+ readonly name: string;
7
+ readonly file: string;
8
+ /** 1-based line of the default's initializer or the direct call's value. */
9
+ readonly line: number;
10
+ /** 1-based column of the default's initializer or the direct call's value. */
11
+ readonly column: number;
12
+ }
13
+
14
+ // The script properties whose default may hold a string, declared either in a
15
+ // `properties` field or by a direct `go.property` call. Defold registers a
16
+ // string `go.property` only from 1.13.2, so a build for an older target must
17
+ // refuse them; typed rather than literal-driven, so a const holding a string
18
+ // counts.
19
+ export function findTextScriptProperties(
20
+ program: ts.Program,
21
+ fileNames: readonly string[],
22
+ ): TextScriptPropertyFinding[] {
23
+ const checker = program.getTypeChecker();
24
+ const findings: TextScriptPropertyFinding[] = [];
25
+
26
+ for (const file of fileNames) {
27
+ const sourceFile = program.getSourceFile(file);
28
+ if (sourceFile === undefined) {
29
+ continue;
30
+ }
31
+ const located: { name: string; value: ts.Node }[] = [];
32
+ const visit = (node: ts.Node): void => {
33
+ if (ts.isCallExpression(node)) {
34
+ const hooks = factoryHooks(node, checker);
35
+ for (const property of hooks?.properties ?? []) {
36
+ if (!isPropertiesHook(property)) {
37
+ continue;
38
+ }
39
+ for (const { name, initializer } of propertyMembers(property)) {
40
+ if (mayHoldString(checker.getTypeAtLocation(initializer), checker)) {
41
+ located.push({ name, value: initializer });
42
+ }
43
+ }
44
+ }
45
+ }
46
+ ts.forEachChild(node, visit);
47
+ };
48
+ visit(sourceFile);
49
+ for (const call of findDirectGoPropertyCalls(sourceFile)) {
50
+ const [nameArgument, value] = call.arguments;
51
+ if (nameArgument === undefined || value === undefined) {
52
+ continue;
53
+ }
54
+ if (mayHoldString(checker.getTypeAtLocation(value), checker)) {
55
+ const name = ts.isStringLiteralLike(nameArgument)
56
+ ? nameArgument.text
57
+ : nameArgument.getText(sourceFile);
58
+ located.push({ name, value });
59
+ }
60
+ }
61
+ located.sort(
62
+ (left, right) => left.value.getStart(sourceFile) - right.value.getStart(sourceFile),
63
+ );
64
+ for (const { name, value } of located) {
65
+ const position = sourceFile.getLineAndCharacterOfPosition(value.getStart(sourceFile));
66
+ findings.push({ name, file, line: position.line + 1, column: position.character + 1 });
67
+ }
68
+ }
69
+
70
+ return findings;
71
+ }
72
+
73
+ /**
74
+ * Whether a value of `type` may be a string at runtime: a string-like type, a
75
+ * union or intersection with such a member, or a type parameter or other
76
+ * instantiable type whose constraint may hold one.
77
+ */
78
+ export function mayHoldString(
79
+ type: ts.Type,
80
+ checker: ts.TypeChecker,
81
+ visited: Set<ts.Type> = new Set(),
82
+ ): boolean {
83
+ if (visited.has(type)) {
84
+ return false;
85
+ }
86
+ visited.add(type);
87
+ if (type.flags & ts.TypeFlags.StringLike) {
88
+ return true;
89
+ }
90
+ if (type.isUnionOrIntersection()) {
91
+ return type.types.some((member) => mayHoldString(member, checker, visited));
92
+ }
93
+ if (type.flags & ts.TypeFlags.Instantiable) {
94
+ const constraint = checker.getBaseConstraintOfType(type);
95
+ return constraint !== undefined && constraint !== type
96
+ ? mayHoldString(constraint, checker, visited)
97
+ : false;
98
+ }
99
+ return false;
100
+ }
package/src/transpile.ts CHANGED
@@ -3,7 +3,6 @@ import { createRequire } from "node:module";
3
3
  import * as path from "node:path";
4
4
  import type * as ts from "typescript";
5
5
  import * as tstl from "typescript-to-lua";
6
- import { arrayIndexOptionLoweringPlugin } from "./array-index-option-lowering";
7
6
  import { createCompanionEmitPlugin } from "./companion-emit";
8
7
  import { editorScriptErasurePlugin } from "./editor-script-erasure";
9
8
  import {
@@ -15,6 +14,7 @@ import { messageDispatchLoweringPlugin } from "./message-dispatch-lowering";
15
14
  import { messageGuardLoweringPlugin } from "./message-guard-lowering";
16
15
  import { importsTimersModule, timersLoweringPlugin } from "./timers-lowering";
17
16
  import { TIMERS_RUNTIME } from "./timers-runtime";
17
+ import { typeApplicationErasurePlugin } from "./type-application-erasure";
18
18
  import { windowEventGuardLoweringPlugin } from "./window-event-guard-lowering";
19
19
 
20
20
  export interface TranspileResult {
@@ -322,7 +322,7 @@ export function transpileProject(input: TranspileProjectInput): TranspileProject
322
322
  { plugin: windowEventGuardLoweringPlugin },
323
323
  { plugin: messageDispatchLoweringPlugin },
324
324
  { plugin: timersLoweringPlugin },
325
- { plugin: arrayIndexOptionLoweringPlugin },
325
+ { plugin: typeApplicationErasurePlugin },
326
326
  { plugin: companionEmit.plugin },
327
327
  ],
328
328
  });
@@ -0,0 +1,34 @@
1
+ import * as ts from "typescript";
2
+ import type { Expression, Plugin } from "typescript-to-lua";
3
+
4
+ // The Defold functions declared with a curried type-application overload,
5
+ // `go.get<P>()(url, property)`: the empty call only fixes `P` for the checker.
6
+ export const TYPE_APPLICATION_APIS: ReadonlySet<string> = new Set(["go.get", "go.set"]);
7
+
8
+ // Keyed by the declaration's fully qualified name: only a symbol declared in
9
+ // an ambient `declare global` block carries the `global.` prefix, so a
10
+ // project's own `go` never matches.
11
+ function isTypeApplication(call: ts.CallExpression, checker: ts.TypeChecker): boolean {
12
+ if (call.arguments.length !== 0) return false;
13
+ const declaration = checker.getResolvedSignature(call)?.declaration;
14
+ if (declaration === undefined || !ts.isFunctionDeclaration(declaration)) return false;
15
+ if (declaration.parameters.length !== 0 || declaration.typeParameters === undefined) return false;
16
+ if (declaration.name === undefined) return false;
17
+ const symbol = checker.getSymbolAtLocation(declaration.name);
18
+ if (symbol === undefined) return false;
19
+ const fqn = checker.getFullyQualifiedName(symbol);
20
+ return fqn.startsWith("global.") && TYPE_APPLICATION_APIS.has(fqn.slice("global.".length));
21
+ }
22
+
23
+ // `go.get<P>()` becomes `go.get`, so the curried call reaches the engine as
24
+ // `go.get(url, property)` and a stored `go.get<P>()` is the function itself.
25
+ export const typeApplicationErasurePlugin: Plugin = {
26
+ visitors: {
27
+ [ts.SyntaxKind.CallExpression]: (node, context): Expression => {
28
+ if (isTypeApplication(node, context.checker)) {
29
+ return context.transformExpression(node.expression);
30
+ }
31
+ return context.superTransformExpression(node);
32
+ },
33
+ },
34
+ };
@@ -1,6 +0,0 @@
1
- import { type Plugin } from "typescript-to-lua";
2
- export declare const ONE_BASED_INDEX_OPTION_APIS: ReadonlySet<string>;
3
- export declare const INDEX_OPTION_VARIABLE_MESSAGE = "`options.index` is zero-based and is converted to Defold's 1-based index only in an object literal written at the call; pass the options inline.";
4
- export declare const INDEX_OPTION_SPREAD_MESSAGE = "A spread carrying `index` cannot be converted to Defold's 1-based index; write `index` explicitly after the spread.";
5
- export declare const INDEX_OPTION_UNDEFINED_MESSAGE = "`options.index` may be undefined here, so it cannot be converted to Defold's 1-based index; pass a number, or leave `index` out.";
6
- export declare const arrayIndexOptionLoweringPlugin: Plugin;
@@ -1,212 +0,0 @@
1
- import * as ts from "typescript";
2
- import {
3
- createBinaryExpression,
4
- createNumericLiteral,
5
- type Expression,
6
- getOriginalPos,
7
- isCallExpression,
8
- isNumericLiteral,
9
- isStringLiteral,
10
- isTableExpression,
11
- type Plugin,
12
- SyntaxKind,
13
- setNodePosition,
14
- type TableExpression,
15
- } from "typescript-to-lua";
16
-
17
- // The Defold functions whose `options.index` upstream documents as "index into
18
- // array property (1 based)". TypeScript authors it zero-based; the lowering
19
- // adds the one Lua expects.
20
- export const ONE_BASED_INDEX_OPTION_APIS: ReadonlySet<string> = new Set([
21
- "go.get",
22
- "go.set",
23
- "gui.get",
24
- "gui.set",
25
- ]);
26
-
27
- export const INDEX_OPTION_VARIABLE_MESSAGE =
28
- "`options.index` is zero-based and is converted to Defold's 1-based index only in an object literal written at the call; pass the options inline.";
29
- export const INDEX_OPTION_SPREAD_MESSAGE =
30
- "A spread carrying `index` cannot be converted to Defold's 1-based index; write `index` explicitly after the spread.";
31
- export const INDEX_OPTION_UNDEFINED_MESSAGE =
32
- "`options.index` may be undefined here, so it cannot be converted to Defold's 1-based index; pass a number, or leave `index` out.";
33
-
34
- const OPTIONS_PARAMETER = "options";
35
- const INDEX_KEY = "index";
36
-
37
- const loweredLiterals = new WeakSet<ts.Node>();
38
-
39
- // Keyed like `tableKey` in url-address-slots.ts: only a symbol declared in an
40
- // ambient `declare global` block carries the `global.` prefix, so a project's
41
- // own `go` or `gui` never matches. Walking up from the signature declaration
42
- // reaches `go.get<P>()` from the function type its curried inner call resolves to.
43
- function oneBasedApi(declaration: ts.Declaration, checker: ts.TypeChecker): boolean {
44
- let node: ts.Node | undefined = declaration;
45
- while (node !== undefined && !ts.isFunctionDeclaration(node)) node = node.parent;
46
- if (node?.name === undefined) return false;
47
- const symbol = checker.getSymbolAtLocation(node.name);
48
- if (symbol === undefined) return false;
49
- const fqn = checker.getFullyQualifiedName(symbol);
50
- return fqn.startsWith("global.") && ONE_BASED_INDEX_OPTION_APIS.has(fqn.slice("global.".length));
51
- }
52
-
53
- function optionsArgument(
54
- call: ts.CallExpression,
55
- checker: ts.TypeChecker,
56
- ): ts.Expression | undefined {
57
- const declaration = checker.getResolvedSignature(call)?.declaration;
58
- if (declaration === undefined || !ts.isFunctionLike(declaration)) return undefined;
59
- if (!oneBasedApi(declaration, checker)) return undefined;
60
- const index = declaration.parameters.findIndex(
61
- (parameter) =>
62
- parameter.dotDotDotToken === undefined &&
63
- ts.isIdentifier(parameter.name) &&
64
- parameter.name.text === OPTIONS_PARAMETER,
65
- );
66
- return index === -1 ? undefined : call.arguments[index];
67
- }
68
-
69
- function unwrap(expression: ts.Expression): ts.Expression {
70
- let current = expression;
71
- while (
72
- ts.isParenthesizedExpression(current) ||
73
- ts.isAsExpression(current) ||
74
- ts.isSatisfiesExpression(current) ||
75
- ts.isNonNullExpression(current) ||
76
- ts.isTypeAssertionExpression(current)
77
- ) {
78
- current = current.expression;
79
- }
80
- return current;
81
- }
82
-
83
- function someConstituent(type: ts.Type, predicate: (type: ts.Type) => boolean): boolean {
84
- return type.isUnion() ? type.types.some(predicate) : predicate(type);
85
- }
86
-
87
- function carriesIndex(type: ts.Type): boolean {
88
- return someConstituent(type, (t) => t.getProperty(INDEX_KEY) !== undefined);
89
- }
90
-
91
- function admitsUndefined(type: ts.Type): boolean {
92
- return someConstituent(
93
- type,
94
- (t) => (t.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0,
95
- );
96
- }
97
-
98
- function propertyName(
99
- property: ts.ObjectLiteralElementLike,
100
- checker: ts.TypeChecker,
101
- ): string | undefined {
102
- const name = property.name;
103
- if (name === undefined) return undefined;
104
- if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
105
- return name.text;
106
- }
107
- if (ts.isComputedPropertyName(name)) {
108
- const type = checker.getTypeAtLocation(name.expression);
109
- if (type.isStringLiteral()) return type.value;
110
- }
111
- return undefined;
112
- }
113
-
114
- function indexValueType(
115
- property: ts.ObjectLiteralElementLike,
116
- checker: ts.TypeChecker,
117
- ): ts.Type | undefined {
118
- if (ts.isPropertyAssignment(property)) return checker.getTypeAtLocation(property.initializer);
119
- if (ts.isShorthandPropertyAssignment(property)) return checker.getTypeAtLocation(property.name);
120
- return undefined;
121
- }
122
-
123
- function rejection(node: ts.Node, messageText: string): ts.Diagnostic {
124
- const file = node.getSourceFile();
125
- const start = node.getStart(file);
126
- return {
127
- file,
128
- start,
129
- length: node.getEnd() - start,
130
- category: ts.DiagnosticCategory.Error,
131
- code: 0,
132
- messageText,
133
- };
134
- }
135
-
136
- // The first reason `options` cannot be lowered, or `undefined` when it can.
137
- function rejectOptions(options: ts.Expression, checker: ts.TypeChecker): ts.Diagnostic | undefined {
138
- const literal = unwrap(options);
139
- if (!ts.isObjectLiteralExpression(literal)) {
140
- return carriesIndex(checker.getTypeAtLocation(literal))
141
- ? rejection(options, INDEX_OPTION_VARIABLE_MESSAGE)
142
- : undefined;
143
- }
144
- let uncoveredSpread: ts.SpreadAssignment | undefined;
145
- let explicitIndex: ts.ObjectLiteralElementLike | undefined;
146
- for (const property of literal.properties) {
147
- if (ts.isSpreadAssignment(property)) {
148
- if (carriesIndex(checker.getTypeAtLocation(property.expression))) uncoveredSpread = property;
149
- } else if (propertyName(property, checker) === INDEX_KEY) {
150
- uncoveredSpread = undefined;
151
- explicitIndex = property;
152
- }
153
- }
154
- if (uncoveredSpread !== undefined) return rejection(uncoveredSpread, INDEX_OPTION_SPREAD_MESSAGE);
155
- const valueType = explicitIndex && indexValueType(explicitIndex, checker);
156
- if (explicitIndex !== undefined && valueType !== undefined && admitsUndefined(valueType)) {
157
- return rejection(explicitIndex, INDEX_OPTION_UNDEFINED_MESSAGE);
158
- }
159
- return undefined;
160
- }
161
-
162
- function plusOne(value: Expression): Expression {
163
- if (isNumericLiteral(value)) {
164
- return setNodePosition(createNumericLiteral(value.value + 1), getOriginalPos(value));
165
- }
166
- return setNodePosition(
167
- createBinaryExpression(value, createNumericLiteral(1), SyntaxKind.AdditionOperator),
168
- getOriginalPos(value),
169
- );
170
- }
171
-
172
- // `rejectOptions` guarantees the last `index` field is the effective one: a
173
- // spread carrying `index` after it is rejected. Every earlier one is shadowed
174
- // and keeps its source value.
175
- function offsetEffectiveIndexField(tables: readonly TableExpression[]): void {
176
- for (const field of tables.flatMap((table) => table.fields).reverse()) {
177
- if (field.key !== undefined && isStringLiteral(field.key) && field.key.value === INDEX_KEY) {
178
- field.value = plusOne(field.value);
179
- return;
180
- }
181
- }
182
- }
183
-
184
- export const arrayIndexOptionLoweringPlugin: Plugin = {
185
- visitors: {
186
- [ts.SyntaxKind.CallExpression]: (node, context): Expression => {
187
- const options = optionsArgument(node, context.checker);
188
- if (options !== undefined) {
189
- const diagnostic = rejectOptions(options, context.checker);
190
- if (diagnostic !== undefined) {
191
- context.diagnostics.push(diagnostic);
192
- } else {
193
- const literal = unwrap(options);
194
- if (ts.isObjectLiteralExpression(literal)) loweredLiterals.add(literal);
195
- }
196
- }
197
- return context.superTransformExpression(node);
198
- },
199
- // A spread lowers the literal to `__TS__ObjectAssign({}, base, {index = …})`,
200
- // so the table carrying `index` can sit one call deep.
201
- [ts.SyntaxKind.ObjectLiteralExpression]: (node, context): Expression => {
202
- const result = context.superTransformExpression(node);
203
- if (!loweredLiterals.has(node)) return result;
204
- if (isTableExpression(result)) {
205
- offsetEffectiveIndexField([result]);
206
- } else if (isCallExpression(result)) {
207
- offsetEffectiveIndexField(result.params.filter(isTableExpression));
208
- }
209
- return result;
210
- },
211
- },
212
- };