@defold-typescript/transpiler 0.51.0 → 0.52.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/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@ export { HSM_MODULE_SPECIFIER, HSM_REQUIRE_ROOT, hsmModules, hsmRequireName, hsm
13
13
  export { buildInputActionIndex } from "./input-action-index";
14
14
  export type { ExtensionDependency, LibraryIncludedEntries, LibrarySceneDocument, LibrarySceneDocuments, LibrarySharedEntry, } from "./library-dependencies";
15
15
  export { archiveWrapperOf, isContainedResourcePath, LIBRARY_DEPENDENCY_ROOT, libraryIncludedEntries, readGameProjectDependencies, readLibrarySceneDocuments, } from "./library-dependencies";
16
+ export { scanEmittedRequires } from "./lua-require-scan";
16
17
  export { findMainEntryFactoryImports } from "./main-entry-factory-imports";
17
18
  export type { BuildConfig, ScriptKind, SourceOutputKind } from "./output-paths";
18
19
  export { computeOutputRel, DEFAULT_INCLUDE, parseBuildConfig, projectRelativeBase, requirePathForRel, SCRIPT_SUFFIX_BY_KIND, stripIncludeBase, } from "./output-paths";
package/dist/index.js CHANGED
@@ -172,22 +172,26 @@ var FACTORY_SPECIFIERS = new Set([
172
172
  `${FACTORY_MODULE}/gui-script`,
173
173
  `${FACTORY_MODULE}/render-script`
174
174
  ]);
175
- function resolvesToFactoryExport(callee, checker) {
175
+ function factoryExportName(callee, checker) {
176
176
  let symbol = checker.getSymbolAtLocation(callee);
177
177
  if (symbol === undefined) {
178
- return false;
178
+ return;
179
179
  }
180
180
  if (symbol.flags & ts2.SymbolFlags.Alias) {
181
181
  symbol = checker.getAliasedSymbol(symbol);
182
182
  }
183
- if (!FACTORY_NAMES.has(symbol.getName())) {
184
- return false;
183
+ const name = symbol.getName();
184
+ if (!FACTORY_NAMES.has(name)) {
185
+ return;
185
186
  }
186
187
  const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0];
187
188
  if (declaration === undefined) {
188
- return false;
189
+ return;
189
190
  }
190
- return declaration.getSourceFile().fileName.includes(FACTORY_MODULE);
191
+ return declaration.getSourceFile().fileName.includes(FACTORY_MODULE) ? name : undefined;
192
+ }
193
+ function resolvesToFactoryExport(callee, checker) {
194
+ return factoryExportName(callee, checker) !== undefined;
191
195
  }
192
196
  function isThisParameter2(param) {
193
197
  return ts2.isIdentifier(param.name) && ts2.identifierToKeywordKind(param.name) === ts2.SyntaxKind.ThisKeyword;
@@ -270,6 +274,15 @@ function emitInitMerge(fn, context, property) {
270
274
  const initFn = createFunctionExpression2(createBlock2([stateDecl, guard]), [createIdentifier2("self")], undefined, NodeFlags2.Declaration, fn);
271
275
  return [builderDecl, createAssignmentStatement(createIdentifier2("init"), initFn, property)];
272
276
  }
277
+ var RESOURCE_PROPERTY_MARKERS = new Set([
278
+ "resource.atlas",
279
+ "resource.buffer",
280
+ "resource.font",
281
+ "resource.material",
282
+ "resource.render_target",
283
+ "resource.texture",
284
+ "resource.tile_source"
285
+ ]);
273
286
  function propertyMembers(property) {
274
287
  if (!ts2.isPropertyAssignment(property) || !ts2.isObjectLiteralExpression(property.initializer)) {
275
288
  return [];
@@ -306,11 +319,108 @@ function isPropertiesHook(property) {
306
319
  function emitPropertyRegistrations(property, context) {
307
320
  return propertyMembers(property).map(({ name, initializer }) => createExpressionStatement(createCallExpression2(createTableIndexExpression(createIdentifier2("go"), createStringLiteral2("property")), [createStringLiteral2(name), context.transformExpression(initializer)])));
308
321
  }
322
+ function scriptOnlyProperties(property, factory) {
323
+ const file = property.getSourceFile();
324
+ const start = property.getStart(file);
325
+ return {
326
+ file,
327
+ start,
328
+ length: property.getEnd() - start,
329
+ category: ts2.DiagnosticCategory.Error,
330
+ code: 0,
331
+ messageText: `\`${factory}\` cannot declare \`properties\`: Defold builds \`go.property\` only in \`.script\` files. Declare the property in a \`defineScript\` and send its value with \`msg.post\`.`
332
+ };
333
+ }
334
+ function outermostWrapper(node) {
335
+ let current = node;
336
+ while (ts2.isParenthesizedExpression(current.parent) || ts2.isAsExpression(current.parent) || ts2.isSatisfiesExpression(current.parent) || ts2.isNonNullExpression(current.parent) || ts2.isTypeAssertionExpression(current.parent)) {
337
+ current = current.parent;
338
+ }
339
+ return current;
340
+ }
341
+ function innermostExpression(node) {
342
+ let current = node;
343
+ while (ts2.isParenthesizedExpression(current) || ts2.isAsExpression(current) || ts2.isSatisfiesExpression(current) || ts2.isNonNullExpression(current) || ts2.isTypeAssertionExpression(current)) {
344
+ current = current.expression;
345
+ }
346
+ return current;
347
+ }
348
+ function globalName(symbol, checker) {
349
+ if (symbol === undefined) {
350
+ return;
351
+ }
352
+ const fqn = checker.getFullyQualifiedName(symbol);
353
+ return fqn.startsWith("global.") ? fqn.slice("global.".length) : undefined;
354
+ }
355
+ function calledGlobal(call, checker) {
356
+ const declaration = checker.getResolvedSignature(call)?.declaration;
357
+ if (declaration === undefined || !ts2.isFunctionDeclaration(declaration) || declaration.name === undefined) {
358
+ return;
359
+ }
360
+ return globalName(checker.getSymbolAtLocation(declaration.name), checker);
361
+ }
362
+ function isPropertyDefault(call, checker) {
363
+ const value = outermostWrapper(call);
364
+ const parent = value.parent;
365
+ if (ts2.isCallExpression(parent)) {
366
+ return parent.arguments[1] === value && calledGlobal(parent, checker) === "go.property";
367
+ }
368
+ const properties = parent?.parent?.parent;
369
+ if (properties === undefined || !ts2.isPropertyAssignment(properties) || !isPropertiesHook(properties)) {
370
+ return false;
371
+ }
372
+ const hooks = properties.parent;
373
+ return ts2.isObjectLiteralExpression(hooks) && factoryHooks(hooks.parent, checker) === hooks && propertyMembers(properties).some((member) => member.initializer === value);
374
+ }
375
+ function misplacedMarker(node, marker) {
376
+ const file = node.getSourceFile();
377
+ const start = node.getStart(file);
378
+ return {
379
+ file,
380
+ start,
381
+ length: node.getEnd() - start,
382
+ category: ts2.DiagnosticCategory.Error,
383
+ code: 0,
384
+ messageText: `\`${marker}\` is a build-time marker: Defold's build reads it only in a \`go.property\` default, and the engine binds no such function. Use it as a \`defineScript\` \`properties\` default.`
385
+ };
386
+ }
387
+ function readsAsMarker(call) {
388
+ const callee = innermostExpression(call.expression);
389
+ if (ts2.isOptionalChain(call) || ts2.isOptionalChain(callee)) {
390
+ return false;
391
+ }
392
+ if (!ts2.isPropertyAccessExpression(callee) && !(ts2.isElementAccessExpression(callee) && ts2.isStringLiteralLike(innermostExpression(callee.argumentExpression)))) {
393
+ return false;
394
+ }
395
+ const receiver = innermostExpression(callee.expression);
396
+ if (!ts2.isIdentifier(receiver) || receiver.text !== "resource") {
397
+ return false;
398
+ }
399
+ const [path] = call.arguments;
400
+ return path === undefined || ts2.isStringLiteralLike(innermostExpression(path));
401
+ }
402
+ function unreadableMarker(node, marker) {
403
+ const file = node.getSourceFile();
404
+ const start = node.getStart(file);
405
+ return {
406
+ file,
407
+ start,
408
+ length: node.getEnd() - start,
409
+ category: ts2.DiagnosticCategory.Error,
410
+ code: 0,
411
+ messageText: `\`${marker}\` in a property default must be written \`${marker}("<path>")\`, with a string literal path: Defold's build reads no other form.`
412
+ };
413
+ }
414
+ function isCallee(node) {
415
+ const callee = outermostWrapper(node);
416
+ return ts2.isCallExpression(callee.parent) && callee.parent.expression === callee;
417
+ }
309
418
  function eraseFactoryCall(expression, context) {
310
419
  const hooks = factoryHooks(expression, context.checker);
311
- if (hooks === undefined) {
420
+ if (hooks === undefined || !ts2.isCallExpression(expression)) {
312
421
  return;
313
422
  }
423
+ const factory = factoryExportName(expression.expression, context.checker);
314
424
  const registrations = [];
315
425
  const statements = [];
316
426
  for (const property of hooks.properties) {
@@ -319,7 +429,11 @@ function eraseFactoryCall(expression, context) {
319
429
  continue;
320
430
  }
321
431
  if (name === "properties") {
322
- registrations.push(...emitPropertyRegistrations(property, context));
432
+ if (factory === "defineScript") {
433
+ registrations.push(...emitPropertyRegistrations(property, context));
434
+ } else if (factory !== undefined && propertyMembers(property).length > 0) {
435
+ context.diagnostics.push(scriptOnlyProperties(property, factory));
436
+ }
323
437
  continue;
324
438
  }
325
439
  const fn = hookFunction(property);
@@ -373,7 +487,37 @@ var lifecycleErasurePlugin = {
373
487
  }
374
488
  return context.superTransformStatements(node);
375
489
  },
376
- [ts2.SyntaxKind.ImportDeclaration]: (node, context) => isFactoryOnlyImport(node) ? [] : context.superTransformStatements(node)
490
+ [ts2.SyntaxKind.ImportDeclaration]: (node, context) => isFactoryOnlyImport(node) ? [] : context.superTransformStatements(node),
491
+ [ts2.SyntaxKind.CallExpression]: (node, context) => {
492
+ const marker = node.parent === undefined ? undefined : calledGlobal(node, context.checker);
493
+ if (marker !== undefined && RESOURCE_PROPERTY_MARKERS.has(marker)) {
494
+ if (!isPropertyDefault(node, context.checker)) {
495
+ context.diagnostics.push(misplacedMarker(node, marker));
496
+ } else if (!readsAsMarker(node)) {
497
+ context.diagnostics.push(unreadableMarker(node, marker));
498
+ }
499
+ }
500
+ return context.superTransformExpression(node);
501
+ },
502
+ [ts2.SyntaxKind.PropertyAccessExpression]: (node, context) => {
503
+ if (node.parent !== undefined && !isCallee(node)) {
504
+ const marker = globalName(context.checker.getSymbolAtLocation(node.name), context.checker);
505
+ if (marker !== undefined && RESOURCE_PROPERTY_MARKERS.has(marker)) {
506
+ context.diagnostics.push(misplacedMarker(node, marker));
507
+ }
508
+ }
509
+ return context.superTransformExpression(node);
510
+ },
511
+ [ts2.SyntaxKind.ElementAccessExpression]: (node, context) => {
512
+ if (node.parent !== undefined && !isCallee(node)) {
513
+ const key = innermostExpression(node.argumentExpression);
514
+ const marker = ts2.isStringLiteralLike(key) ? globalName(context.checker.getPropertyOfType(context.checker.getTypeAtLocation(node.expression), key.text), context.checker) : undefined;
515
+ if (marker !== undefined && RESOURCE_PROPERTY_MARKERS.has(marker)) {
516
+ context.diagnostics.push(misplacedMarker(node, marker));
517
+ }
518
+ }
519
+ return context.superTransformExpression(node);
520
+ }
377
521
  }
378
522
  };
379
523
 
@@ -3694,6 +3838,7 @@ function collectOutputs(transpiledFiles, diagnostics, userKeys, emittedCompanion
3694
3838
  }
3695
3839
  }
3696
3840
  const hsm = hsmClosure([...Object.values(lua), ...Object.values(companions)], compileHsmModules);
3841
+ const hsmSourceMaps = hsm === undefined ? undefined : selectHsmSourceMaps(Object.keys(hsm));
3697
3842
  return {
3698
3843
  lua,
3699
3844
  sourceMaps,
@@ -3701,9 +3846,21 @@ function collectOutputs(transpiledFiles, diagnostics, userKeys, emittedCompanion
3701
3846
  ...Object.keys(companions).length > 0 ? { companions } : {},
3702
3847
  ...lualib !== undefined ? { lualib } : {},
3703
3848
  ...timersImported ? { timersRuntime: TIMERS_RUNTIME } : {},
3704
- ...hsm !== undefined ? { hsmModules: hsm } : {}
3849
+ ...hsm !== undefined ? { hsmModules: hsm } : {},
3850
+ ...hsmSourceMaps !== undefined ? { hsmSourceMaps } : {}
3705
3851
  };
3706
3852
  }
3853
+ function selectHsmSourceMaps(names) {
3854
+ const maps = compileHsmSourceMaps();
3855
+ const selected = {};
3856
+ for (const name of names) {
3857
+ const map = maps[name];
3858
+ if (map !== undefined) {
3859
+ selected[name] = map;
3860
+ }
3861
+ }
3862
+ return selected;
3863
+ }
3707
3864
  function transpileVirtual(files, companionEmit, overrides = {}) {
3708
3865
  return tstl2.transpileVirtualProject({ ...AMBIENT_FILES, ...files }, {
3709
3866
  luaTarget: tstl2.LuaTarget.Lua51,
@@ -3738,6 +3895,12 @@ function requiresLualib(lua) {
3738
3895
  return scanEmittedRequires(lua).some(({ path: requirePath }) => requirePath === "lualib_bundle");
3739
3896
  }
3740
3897
  function compileHsmModules() {
3898
+ return compileHsm().lua;
3899
+ }
3900
+ function compileHsmSourceMaps() {
3901
+ return compileHsm().sourceMaps;
3902
+ }
3903
+ function compileHsm() {
3741
3904
  if (compiledHsm !== undefined) {
3742
3905
  return compiledHsm;
3743
3906
  }
@@ -3752,6 +3915,7 @@ function compileHsmModules() {
3752
3915
  const result = transpileVirtual(files, createCompanionEmitPlugin(), { removeComments: true });
3753
3916
  const problems = result.diagnostics.map((d) => `${d.file?.fileName ?? "(project)"}: ${flattenDiagnosticMessage(d.messageText)}`);
3754
3917
  const lua = {};
3918
+ const sourceMaps = {};
3755
3919
  for (const file of result.transpiledFiles) {
3756
3920
  if (isLualibBundle(file)) {
3757
3921
  continue;
@@ -3765,14 +3929,17 @@ function compileHsmModules() {
3765
3929
  problems.push(`the compiled ${name} module requires lualib_bundle`);
3766
3930
  }
3767
3931
  lua[name] = file.lua;
3932
+ if (typeof file.luaSourceMap === "string") {
3933
+ sourceMaps[name] = file.luaSourceMap;
3934
+ }
3768
3935
  }
3769
3936
  if (problems.length > 0) {
3770
3937
  throw new Error(`@defold-typescript/transpiler: the hsm library does not compile:
3771
3938
  ${problems.join(`
3772
3939
  `)}`);
3773
3940
  }
3774
- compiledHsm = lua;
3775
- return lua;
3941
+ compiledHsm = { lua, sourceMaps };
3942
+ return compiledHsm;
3776
3943
  }
3777
3944
  function transpile(source) {
3778
3945
  const project = transpileProject({ files: { "main.ts": source } });
@@ -4379,6 +4546,7 @@ export {
4379
4546
  resolveClassifiedSlotAtPosition,
4380
4547
  resolveHsmSourceDir,
4381
4548
  rewriteEmittedRequires,
4549
+ scanEmittedRequires,
4382
4550
  slotRejectsForeignSocket,
4383
4551
  socketOfAddress,
4384
4552
  stripIncludeBase,
@@ -3,6 +3,7 @@ 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 declare const RESOURCE_PROPERTY_MARKERS: ReadonlySet<string>;
6
7
  export interface PropertyMember {
7
8
  readonly name: string;
8
9
  readonly initializer: ts.Expression;
@@ -22,6 +22,7 @@ export interface TranspileProjectResult {
22
22
  readonly lualib?: string;
23
23
  readonly timersRuntime?: string;
24
24
  readonly hsmModules?: Readonly<Record<string, string>>;
25
+ readonly hsmSourceMaps?: Readonly<Record<string, string>>;
25
26
  }
26
27
  export declare const AMBIENT_FILES: Readonly<Record<string, string>>;
27
28
  interface CollectableFile {
@@ -42,5 +43,7 @@ export declare function transpileProject(input: TranspileProjectInput): Transpil
42
43
  * lualib bundle.
43
44
  */
44
45
  export declare function compileHsmModules(): Readonly<Record<string, string>>;
46
+ /** The source map of every module `compileHsmModules` returns, under the same keys. */
47
+ export declare function compileHsmSourceMaps(): Readonly<Record<string, string>>;
45
48
  export declare function transpile(source: string): TranspileResult;
46
49
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defold-typescript/transpiler",
3
- "version": "0.51.0",
3
+ "version": "0.52.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.51.0",
38
+ "@defold-typescript/types": "0.52.0",
39
39
  "@typescript-to-lua/language-extensions": "1.19.0",
40
40
  "typescript": "6.0.2",
41
41
  "typescript-to-lua": "^1.36.0"
@@ -7,8 +7,8 @@ import { scanEmittedRequires } from "./lua-require-scan";
7
7
 
8
8
  // `@defold-typescript/types/hsm` is the `index` module; every other module is a
9
9
  // subpath of it. Each lowers to a require of `defold_typescript_hsm.<module>`,
10
- // which the CLI writes under the output root the way it writes the timers
11
- // runtime.
10
+ // which the CLI respells to the module's place in its runtime folder, the way it
11
+ // handles the timers runtime.
12
12
  export const HSM_MODULE_SPECIFIER = "@defold-typescript/types/hsm";
13
13
  export const HSM_REQUIRE_ROOT = "defold_typescript_hsm";
14
14
 
package/src/index.ts CHANGED
@@ -46,6 +46,7 @@ export {
46
46
  readGameProjectDependencies,
47
47
  readLibrarySceneDocuments,
48
48
  } from "./library-dependencies";
49
+ export { scanEmittedRequires } from "./lua-require-scan";
49
50
  export { findMainEntryFactoryImports } from "./main-entry-factory-imports";
50
51
  export type { BuildConfig, ScriptKind, SourceOutputKind } from "./output-paths";
51
52
  export {
@@ -35,22 +35,29 @@ const FACTORY_SPECIFIERS = new Set([
35
35
  `${FACTORY_MODULE}/render-script`,
36
36
  ]);
37
37
 
38
- export function resolvesToFactoryExport(callee: ts.Expression, checker: ts.TypeChecker): boolean {
38
+ // The lifecycle factory a callee resolves to, aliases followed, or `undefined`
39
+ // when it is not one of this package's factory exports.
40
+ function factoryExportName(callee: ts.Expression, checker: ts.TypeChecker): string | undefined {
39
41
  let symbol = checker.getSymbolAtLocation(callee);
40
42
  if (symbol === undefined) {
41
- return false;
43
+ return undefined;
42
44
  }
43
45
  if (symbol.flags & ts.SymbolFlags.Alias) {
44
46
  symbol = checker.getAliasedSymbol(symbol);
45
47
  }
46
- if (!FACTORY_NAMES.has(symbol.getName())) {
47
- return false;
48
+ const name = symbol.getName();
49
+ if (!FACTORY_NAMES.has(name)) {
50
+ return undefined;
48
51
  }
49
52
  const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0];
50
53
  if (declaration === undefined) {
51
- return false;
54
+ return undefined;
52
55
  }
53
- return declaration.getSourceFile().fileName.includes(FACTORY_MODULE);
56
+ return declaration.getSourceFile().fileName.includes(FACTORY_MODULE) ? name : undefined;
57
+ }
58
+
59
+ export function resolvesToFactoryExport(callee: ts.Expression, checker: ts.TypeChecker): boolean {
60
+ return factoryExportName(callee, checker) !== undefined;
54
61
  }
55
62
 
56
63
  function isThisParameter(param: ts.ParameterDeclaration): boolean {
@@ -208,6 +215,20 @@ function emitInitMerge(
208
215
  return [builderDecl, createAssignmentStatement(createIdentifier("init"), initFn, property)];
209
216
  }
210
217
 
218
+ // Build-time markers, never engine functions: bob reads `resource.<kind>("path")`
219
+ // only as a `go.property` default (`LuaScanner.java:923-933` at defold
220
+ // `a81dbb04`), and the engine's `resource` table registers none of them
221
+ // (`script_resource.cpp:3447-3463` in 1.14.0). Called anywhere else, each is nil.
222
+ export const RESOURCE_PROPERTY_MARKERS: ReadonlySet<string> = new Set([
223
+ "resource.atlas",
224
+ "resource.buffer",
225
+ "resource.font",
226
+ "resource.material",
227
+ "resource.render_target",
228
+ "resource.texture",
229
+ "resource.tile_source",
230
+ ]);
231
+
211
232
  export interface PropertyMember {
212
233
  readonly name: string;
213
234
  readonly initializer: ts.Expression;
@@ -275,14 +296,171 @@ function emitPropertyRegistrations(
275
296
  );
276
297
  }
277
298
 
299
+ // Defold builds `go.property` only in `.script` files, so a GUI or render
300
+ // script's `properties` block has nothing it could lower to.
301
+ function scriptOnlyProperties(
302
+ property: ts.ObjectLiteralElementLike,
303
+ factory: string,
304
+ ): ts.Diagnostic {
305
+ const file = property.getSourceFile();
306
+ const start = property.getStart(file);
307
+ return {
308
+ file,
309
+ start,
310
+ length: property.getEnd() - start,
311
+ category: ts.DiagnosticCategory.Error,
312
+ code: 0,
313
+ messageText: `\`${factory}\` cannot declare \`properties\`: Defold builds \`go.property\` only in \`.script\` files. Declare the property in a \`defineScript\` and send its value with \`msg.post\`.`,
314
+ };
315
+ }
316
+
317
+ // The outermost expression that only wraps `node` in parentheses or a type-only
318
+ // form, so `(resource.atlas("/a.atlas") as Hash)` sits where the bare call would.
319
+ function outermostWrapper(node: ts.Expression): ts.Expression {
320
+ let current = node;
321
+ while (
322
+ ts.isParenthesizedExpression(current.parent) ||
323
+ ts.isAsExpression(current.parent) ||
324
+ ts.isSatisfiesExpression(current.parent) ||
325
+ ts.isNonNullExpression(current.parent) ||
326
+ ts.isTypeAssertionExpression(current.parent)
327
+ ) {
328
+ current = current.parent;
329
+ }
330
+ return current;
331
+ }
332
+
333
+ // The inverse of `outermostWrapper`: the expression a run of parentheses and
334
+ // type-only forms wraps, which TSTL emits in place of the whole run.
335
+ function innermostExpression(node: ts.Expression): ts.Expression {
336
+ let current = node;
337
+ while (
338
+ ts.isParenthesizedExpression(current) ||
339
+ ts.isAsExpression(current) ||
340
+ ts.isSatisfiesExpression(current) ||
341
+ ts.isNonNullExpression(current) ||
342
+ ts.isTypeAssertionExpression(current)
343
+ ) {
344
+ current = current.expression;
345
+ }
346
+ return current;
347
+ }
348
+
349
+ // Keyed by the declaration's fully qualified name: only a symbol declared in an
350
+ // ambient `declare global` block carries the `global.` prefix, so a project's
351
+ // own `resource` never matches.
352
+ function globalName(symbol: ts.Symbol | undefined, checker: ts.TypeChecker): string | undefined {
353
+ if (symbol === undefined) {
354
+ return undefined;
355
+ }
356
+ const fqn = checker.getFullyQualifiedName(symbol);
357
+ return fqn.startsWith("global.") ? fqn.slice("global.".length) : undefined;
358
+ }
359
+
360
+ function calledGlobal(call: ts.CallExpression, checker: ts.TypeChecker): string | undefined {
361
+ const declaration = checker.getResolvedSignature(call)?.declaration;
362
+ if (
363
+ declaration === undefined ||
364
+ !ts.isFunctionDeclaration(declaration) ||
365
+ declaration.name === undefined
366
+ ) {
367
+ return undefined;
368
+ }
369
+ return globalName(checker.getSymbolAtLocation(declaration.name), checker);
370
+ }
371
+
372
+ // A `properties` default of a factory call, or the default argument of a
373
+ // direct `go.property` call: the two places bob reads a marker.
374
+ function isPropertyDefault(call: ts.CallExpression, checker: ts.TypeChecker): boolean {
375
+ const value = outermostWrapper(call);
376
+ const parent = value.parent;
377
+ if (ts.isCallExpression(parent)) {
378
+ return parent.arguments[1] === value && calledGlobal(parent, checker) === "go.property";
379
+ }
380
+ const properties = parent?.parent?.parent;
381
+ if (
382
+ properties === undefined ||
383
+ !ts.isPropertyAssignment(properties) ||
384
+ !isPropertiesHook(properties)
385
+ ) {
386
+ return false;
387
+ }
388
+ const hooks = properties.parent;
389
+ return (
390
+ ts.isObjectLiteralExpression(hooks) &&
391
+ factoryHooks(hooks.parent as ts.Expression, checker) === hooks &&
392
+ propertyMembers(properties).some((member) => member.initializer === value)
393
+ );
394
+ }
395
+
396
+ function misplacedMarker(node: ts.Expression, marker: string): ts.Diagnostic {
397
+ const file = node.getSourceFile();
398
+ const start = node.getStart(file);
399
+ return {
400
+ file,
401
+ start,
402
+ length: node.getEnd() - start,
403
+ category: ts.DiagnosticCategory.Error,
404
+ code: 0,
405
+ messageText: `\`${marker}\` is a build-time marker: Defold's build reads it only in a \`go.property\` default, and the engine binds no such function. Use it as a \`defineScript\` \`properties\` default.`,
406
+ };
407
+ }
408
+
409
+ // Bob reads a default as a marker only when the emitted text is
410
+ // `resource.<kind>("<path>")` or `resource.<kind>()`: the object token must be
411
+ // `resource` with a dot `NAME` key, and the first argument a string literal or
412
+ // absent (`LuaScanner.java:801-822`, `:923-933` and `:493-510` at defold
413
+ // `a81dbb04`). The receiver test is textual like bob's; the marker resolution
414
+ // has already excluded a project's own `resource`.
415
+ function readsAsMarker(call: ts.CallExpression): boolean {
416
+ const callee = innermostExpression(call.expression);
417
+ if (ts.isOptionalChain(call) || ts.isOptionalChain(callee)) {
418
+ return false;
419
+ }
420
+ if (
421
+ !ts.isPropertyAccessExpression(callee) &&
422
+ !(
423
+ ts.isElementAccessExpression(callee) &&
424
+ ts.isStringLiteralLike(innermostExpression(callee.argumentExpression))
425
+ )
426
+ ) {
427
+ return false;
428
+ }
429
+ const receiver = innermostExpression(callee.expression);
430
+ if (!ts.isIdentifier(receiver) || receiver.text !== "resource") {
431
+ return false;
432
+ }
433
+ const [path] = call.arguments;
434
+ return path === undefined || ts.isStringLiteralLike(innermostExpression(path));
435
+ }
436
+
437
+ function unreadableMarker(node: ts.CallExpression, marker: string): ts.Diagnostic {
438
+ const file = node.getSourceFile();
439
+ const start = node.getStart(file);
440
+ return {
441
+ file,
442
+ start,
443
+ length: node.getEnd() - start,
444
+ category: ts.DiagnosticCategory.Error,
445
+ code: 0,
446
+ messageText: `\`${marker}\` in a property default must be written \`${marker}("<path>")\`, with a string literal path: Defold's build reads no other form.`,
447
+ };
448
+ }
449
+
450
+ function isCallee(node: ts.Expression): boolean {
451
+ const callee = outermostWrapper(node);
452
+ return ts.isCallExpression(callee.parent) && callee.parent.expression === callee;
453
+ }
454
+
278
455
  function eraseFactoryCall(
279
456
  expression: ts.Expression,
280
457
  context: TransformationContext,
281
458
  ): Statement[] | undefined {
282
459
  const hooks = factoryHooks(expression, context.checker);
283
- if (hooks === undefined) {
460
+ if (hooks === undefined || !ts.isCallExpression(expression)) {
284
461
  return undefined;
285
462
  }
463
+ const factory = factoryExportName(expression.expression, context.checker);
286
464
  const registrations: Statement[] = [];
287
465
  const statements: Statement[] = [];
288
466
  for (const property of hooks.properties) {
@@ -291,7 +469,11 @@ function eraseFactoryCall(
291
469
  continue;
292
470
  }
293
471
  if (name === "properties") {
294
- registrations.push(...emitPropertyRegistrations(property, context));
472
+ if (factory === "defineScript") {
473
+ registrations.push(...emitPropertyRegistrations(property, context));
474
+ } else if (factory !== undefined && propertyMembers(property).length > 0) {
475
+ context.diagnostics.push(scriptOnlyProperties(property, factory));
476
+ }
295
477
  continue;
296
478
  }
297
479
  const fn = hookFunction(property);
@@ -374,5 +556,48 @@ export const lifecycleErasurePlugin: Plugin = {
374
556
  },
375
557
  [ts.SyntaxKind.ImportDeclaration]: (node, context) =>
376
558
  isFactoryOnlyImport(node) ? [] : context.superTransformStatements(node),
559
+ // TSTL's optional-chain lowering also visits nodes it synthesizes, which have
560
+ // no parent and no source span; only parsed nodes are checked.
561
+ [ts.SyntaxKind.CallExpression]: (node, context) => {
562
+ const marker = node.parent === undefined ? undefined : calledGlobal(node, context.checker);
563
+ if (marker !== undefined && RESOURCE_PROPERTY_MARKERS.has(marker)) {
564
+ if (!isPropertyDefault(node, context.checker)) {
565
+ context.diagnostics.push(misplacedMarker(node, marker));
566
+ } else if (!readsAsMarker(node)) {
567
+ context.diagnostics.push(unreadableMarker(node, marker));
568
+ }
569
+ }
570
+ return context.superTransformExpression(node);
571
+ },
572
+ [ts.SyntaxKind.PropertyAccessExpression]: (node, context) => {
573
+ if (node.parent !== undefined && !isCallee(node)) {
574
+ const marker = globalName(context.checker.getSymbolAtLocation(node.name), context.checker);
575
+ if (marker !== undefined && RESOURCE_PROPERTY_MARKERS.has(marker)) {
576
+ context.diagnostics.push(misplacedMarker(node, marker));
577
+ }
578
+ }
579
+ return context.superTransformExpression(node);
580
+ },
581
+ [ts.SyntaxKind.ElementAccessExpression]: (node, context) => {
582
+ if (node.parent !== undefined && !isCallee(node)) {
583
+ // The checker resolves a string key only when it is the direct argument,
584
+ // so a wrapped key is looked up by name on the receiver's type, as the
585
+ // checker does for a direct one.
586
+ const key = innermostExpression(node.argumentExpression);
587
+ const marker = ts.isStringLiteralLike(key)
588
+ ? globalName(
589
+ context.checker.getPropertyOfType(
590
+ context.checker.getTypeAtLocation(node.expression),
591
+ key.text,
592
+ ),
593
+ context.checker,
594
+ )
595
+ : undefined;
596
+ if (marker !== undefined && RESOURCE_PROPERTY_MARKERS.has(marker)) {
597
+ context.diagnostics.push(misplacedMarker(node, marker));
598
+ }
599
+ }
600
+ return context.superTransformExpression(node);
601
+ },
377
602
  },
378
603
  };
@@ -1,7 +1,7 @@
1
1
  // The importable polyfill specifier (matched by the type checker and the
2
2
  // lowering pass) and the flat Lua require name the import is rewritten to. The
3
- // CLI writes the runtime to `<require name>.lua` at the output root, mirroring
4
- // `lualib_bundle`, so `require("defold_typescript_timers")` resolves in Defold.
3
+ // CLI writes the runtime into its runtime folder beside `lualib_bundle` and
4
+ // respells every `require("defold_typescript_timers")` to that path.
5
5
  export const TIMERS_MODULE_SPECIFIER = "@defold-typescript/types/timers";
6
6
  export const TIMERS_REQUIRE_NAME = "defold_typescript_timers";
7
7
 
package/src/transpile.ts CHANGED
@@ -63,23 +63,27 @@ export interface TranspileProjectResult {
63
63
  // TSTL synthesizes this bundle (no user source) whenever a lualib feature is
64
64
  // registered — either by a user source using one (`Object.keys`, spread,
65
65
  // `__TS__TypeOf`, ...) or by the timers import lowering, whose generated
66
- // runtime requires the bundle. The emitted `require("lualib_bundle")` only
67
- // resolves in Defold if the CLI writes it to the output root. Absent when
66
+ // runtime requires the bundle. The emitted `require("lualib_bundle")` is an
67
+ // internal name the CLI respells to where it writes the bundle. Absent when
68
68
  // neither trigger fires.
69
69
  readonly lualib?: string;
70
70
  // Hand-authored runtime Lua for the `@defold-typescript/types/timers`
71
71
  // polyfills (`setTimeout`/`wait`/...). Unlike `defineScript`, the import is
72
- // not erased — it lowers to `require("defold_typescript_timers")`, so the CLI
73
- // must write this to the output root. Present only when a user file imports
74
- // the module (pay-for-use).
72
+ // not erased — it lowers to `require("defold_typescript_timers")`, an
73
+ // internal name the CLI respells to where it writes this. Present only when
74
+ // a user file imports the module (pay-for-use).
75
75
  readonly timersRuntime?: string;
76
76
  // The compiled `hsm` modules the program requires, keyed by module name
77
77
  // (`index`, `defold`, ...). An hsm import lowers to
78
- // `require("defold_typescript_hsm.<module>")`, so the CLI must write each one
79
- // under the output root. Present only when some chunk requires one
80
- // (pay-for-use); never carries a source map, since the `.ts` it would point
81
- // at is not in the user's project.
78
+ // `require("defold_typescript_hsm.<module>")`, an internal name the CLI
79
+ // respells to where it writes each one. Present only when some chunk requires one
80
+ // (pay-for-use).
82
81
  readonly hsmModules?: Readonly<Record<string, string>>;
82
+ // The source map of each module in `hsmModules`, under the same keys. Its
83
+ // `sources` names `<module>.ts` with an empty `sourceRoot`: only the CLI knows
84
+ // where the module is written and where the hsm source sits, so it sets the
85
+ // root that joins them.
86
+ readonly hsmSourceMaps?: Readonly<Record<string, string>>;
83
87
  }
84
88
 
85
89
  function flattenDiagnosticMessage(
@@ -313,6 +317,7 @@ export function collectOutputs(
313
317
  }
314
318
 
315
319
  const hsm = hsmClosure([...Object.values(lua), ...Object.values(companions)], compileHsmModules);
320
+ const hsmSourceMaps = hsm === undefined ? undefined : selectHsmSourceMaps(Object.keys(hsm));
316
321
 
317
322
  return {
318
323
  lua,
@@ -322,9 +327,22 @@ export function collectOutputs(
322
327
  ...(lualib !== undefined ? { lualib } : {}),
323
328
  ...(timersImported ? { timersRuntime: TIMERS_RUNTIME } : {}),
324
329
  ...(hsm !== undefined ? { hsmModules: hsm } : {}),
330
+ ...(hsmSourceMaps !== undefined ? { hsmSourceMaps } : {}),
325
331
  };
326
332
  }
327
333
 
334
+ function selectHsmSourceMaps(names: readonly string[]): Record<string, string> {
335
+ const maps = compileHsmSourceMaps();
336
+ const selected: Record<string, string> = {};
337
+ for (const name of names) {
338
+ const map = maps[name];
339
+ if (map !== undefined) {
340
+ selected[name] = map;
341
+ }
342
+ }
343
+ return selected;
344
+ }
345
+
328
346
  function transpileVirtual(
329
347
  files: Readonly<Record<string, string>>,
330
348
  companionEmit: ReturnType<typeof createCompanionEmitPlugin>,
@@ -382,7 +400,12 @@ export function transpileProject(input: TranspileProjectInput): TranspileProject
382
400
  );
383
401
  }
384
402
 
385
- let compiledHsm: Readonly<Record<string, string>> | undefined;
403
+ interface CompiledHsm {
404
+ readonly lua: Readonly<Record<string, string>>;
405
+ readonly sourceMaps: Readonly<Record<string, string>>;
406
+ }
407
+
408
+ let compiledHsm: CompiledHsm | undefined;
386
409
 
387
410
  function requiresLualib(lua: string): boolean {
388
411
  return scanEmittedRequires(lua).some(({ path: requirePath }) => requirePath === "lualib_bundle");
@@ -398,6 +421,15 @@ function requiresLualib(lua: string): boolean {
398
421
  * lualib bundle.
399
422
  */
400
423
  export function compileHsmModules(): Readonly<Record<string, string>> {
424
+ return compileHsm().lua;
425
+ }
426
+
427
+ /** The source map of every module `compileHsmModules` returns, under the same keys. */
428
+ export function compileHsmSourceMaps(): Readonly<Record<string, string>> {
429
+ return compileHsm().sourceMaps;
430
+ }
431
+
432
+ function compileHsm(): CompiledHsm {
401
433
  if (compiledHsm !== undefined) {
402
434
  return compiledHsm;
403
435
  }
@@ -417,6 +449,7 @@ export function compileHsmModules(): Readonly<Record<string, string>> {
417
449
  (d) => `${d.file?.fileName ?? "(project)"}: ${flattenDiagnosticMessage(d.messageText)}`,
418
450
  );
419
451
  const lua: Record<string, string> = {};
452
+ const sourceMaps: Record<string, string> = {};
420
453
  for (const file of result.transpiledFiles) {
421
454
  if (isLualibBundle(file)) {
422
455
  continue;
@@ -430,14 +463,17 @@ export function compileHsmModules(): Readonly<Record<string, string>> {
430
463
  problems.push(`the compiled ${name} module requires lualib_bundle`);
431
464
  }
432
465
  lua[name] = file.lua;
466
+ if (typeof file.luaSourceMap === "string") {
467
+ sourceMaps[name] = file.luaSourceMap;
468
+ }
433
469
  }
434
470
  if (problems.length > 0) {
435
471
  throw new Error(
436
472
  `@defold-typescript/transpiler: the hsm library does not compile:\n ${problems.join("\n ")}`,
437
473
  );
438
474
  }
439
- compiledHsm = lua;
440
- return lua;
475
+ compiledHsm = { lua, sourceMaps };
476
+ return compiledHsm;
441
477
  }
442
478
 
443
479
  export function transpile(source: string): TranspileResult {