@defold-typescript/transpiler 0.23.0 → 0.25.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.
@@ -0,0 +1 @@
1
+ export declare function buildInputActionIndex(documents: ReadonlyMap<string, string>): ReadonlySet<string>;
@@ -0,0 +1,11 @@
1
+ export type ScriptKind = "script" | "gui-script" | "render-script" | "editor-script";
2
+ export type SourceOutputKind = ScriptKind | "module";
3
+ export interface BuildConfig {
4
+ readonly outDir: string | undefined;
5
+ readonly include: string[];
6
+ }
7
+ export declare const DEFAULT_INCLUDE: string[];
8
+ export declare const SCRIPT_SUFFIX_BY_KIND: Record<ScriptKind, string>;
9
+ export declare function parseBuildConfig(raw: string): BuildConfig;
10
+ export declare function stripIncludeBase(pattern: string): string;
11
+ export declare function computeOutputRel(rel: string, config: BuildConfig, kind: SourceOutputKind): string;
@@ -0,0 +1,2 @@
1
+ export declare const SCAFFOLDED_DEFIGNORE_LINES: string[];
2
+ export declare function isDefignoredPath(displayPath: string): boolean;
@@ -0,0 +1,5 @@
1
+ export interface SceneComponentIndex {
2
+ readonly ids: ReadonlySet<string>;
3
+ readonly incomplete: readonly string[];
4
+ }
5
+ export declare function buildSceneComponentIndex(documents: ReadonlyMap<string, string>): SceneComponentIndex;
@@ -0,0 +1,6 @@
1
+ export interface SceneObjectPathIndex {
2
+ readonly paths: ReadonlySet<string>;
3
+ readonly declaredIn: ReadonlyMap<string, readonly string[]>;
4
+ readonly incomplete: readonly string[];
5
+ }
6
+ export declare function buildSceneObjectPathIndex(documents: ReadonlyMap<string, string>): SceneObjectPathIndex;
@@ -0,0 +1,9 @@
1
+ export interface SceneMessage {
2
+ readonly fields: ReadonlyMap<string, readonly string[]>;
3
+ readonly messages: ReadonlyMap<string, readonly SceneMessage[]>;
4
+ }
5
+ export declare class SceneTextFormatError extends Error {
6
+ readonly line: number;
7
+ constructor(message: string, line: number);
8
+ }
9
+ export declare function parseSceneTextFormat(text: string): SceneMessage;
@@ -0,0 +1,10 @@
1
+ export interface SpriteAnimationIndex {
2
+ readonly byScriptResource: ReadonlyMap<string, ReadonlyMap<string, ReadonlySet<string>>>;
3
+ readonly tileSetByScriptResource: ReadonlyMap<string, ReadonlyMap<string, string>>;
4
+ readonly unresolved: readonly string[];
5
+ }
6
+ export declare function buildSpriteAnimationIndex(input: {
7
+ scenes: ReadonlyMap<string, string>;
8
+ assets: ReadonlyMap<string, string>;
9
+ }): SpriteAnimationIndex;
10
+ export declare function componentIdOfSameObjectAddress(address: string): string | undefined;
@@ -0,0 +1,19 @@
1
+ import type { UrlParameterClass, UrlParameterTable } from "@defold-typescript/types";
2
+ import * as ts from "typescript";
3
+ export interface ClassifiedSlot {
4
+ readonly class: UrlParameterClass;
5
+ readonly text: string;
6
+ readonly textStart: number;
7
+ readonly fragmentStart: number;
8
+ readonly addressText?: string;
9
+ readonly resourceExtensions?: readonly string[];
10
+ }
11
+ export declare function isAddressClass(parameterClass: UrlParameterClass): boolean;
12
+ export declare function isFragmentCaret(slot: ClassifiedSlot, position: number): boolean;
13
+ export declare function addressClassOfArgument(checker: ts.TypeChecker, table: UrlParameterTable, argument: ts.Expression): UrlParameterClass;
14
+ export declare function resolveClassifiedSlotAtPosition(input: {
15
+ program: ts.Program;
16
+ table: UrlParameterTable;
17
+ fileName: string;
18
+ position: number;
19
+ }): ClassifiedSlot | undefined;
@@ -0,0 +1,23 @@
1
+ import type { UrlParameterTable } from "@defold-typescript/types";
2
+ import * as ts from "typescript";
3
+ import type { SceneComponentIndex } from "./scene-component-index";
4
+ export interface UrlFragmentFinding {
5
+ readonly fileName: string;
6
+ readonly start: number;
7
+ readonly length: number;
8
+ readonly fragment: string;
9
+ readonly message: string;
10
+ }
11
+ export type UrlFragmentReport = {
12
+ readonly kind: "suppressed";
13
+ readonly reasons: readonly string[];
14
+ } | {
15
+ readonly kind: "checked";
16
+ readonly findings: readonly UrlFragmentFinding[];
17
+ };
18
+ export declare function checkUrlFragmentReachability(input: {
19
+ program: ts.Program;
20
+ table: UrlParameterTable;
21
+ index: SceneComponentIndex;
22
+ sourceFiles?: readonly ts.SourceFile[];
23
+ }): UrlFragmentReport;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defold-typescript/transpiler",
3
- "version": "0.23.0",
3
+ "version": "0.25.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.23.0",
38
+ "@defold-typescript/types": "0.25.0",
39
39
  "@typescript-to-lua/language-extensions": "1.19.0",
40
40
  "typescript-to-lua": "^1.36.0"
41
41
  }
@@ -0,0 +1,34 @@
1
+ // The `<section>.<key>` ids the project's `game.project` declares — the whole
2
+ // universe `sys.get_config_string` can resolve, since a key the file never
3
+ // writes answers the reader's default instead.
4
+ //
5
+ // Deliberately a line reader rather than a parse of the scene text format:
6
+ // `game.project` is a flat INI the editor writes, the same shape
7
+ // `readExtensionDependencies` already walks. A key may carry a `#`
8
+ // (`dependencies#0`), which is part of its name here and never a fragment.
9
+
10
+ function sectionNameOf(line: string): string | undefined {
11
+ const match = line.trim().match(/^\[(.+)\]$/);
12
+ return match?.[1];
13
+ }
14
+
15
+ export function buildConfigKeyIndex(gameProjectText: string): ReadonlySet<string> {
16
+ const keys = new Set<string>();
17
+ let section: string | undefined;
18
+ for (const line of gameProjectText.split("\n")) {
19
+ const header = sectionNameOf(line);
20
+ if (header !== undefined) {
21
+ section = header;
22
+ continue;
23
+ }
24
+ // A line before the first header belongs to no section, so it names nothing
25
+ // a reader could ask for.
26
+ if (section === undefined) continue;
27
+ const separator = line.indexOf("=");
28
+ if (separator === -1) continue;
29
+ const key = line.slice(0, separator).trim();
30
+ if (key === "") continue;
31
+ keys.add(`${section}.${key}`);
32
+ }
33
+ return keys;
34
+ }
@@ -1,33 +1,46 @@
1
1
  import * as ts from "typescript";
2
2
  import {
3
3
  createReturnStatement,
4
+ type Expression,
4
5
  type Plugin,
5
6
  type Statement,
6
7
  type TransformationContext,
7
8
  } from "typescript-to-lua";
8
9
 
9
10
  export const EDITOR_FACTORY_MODULE = "@defold-typescript/types";
10
- export const EDITOR_FACTORY_NAME = "defineEditorScript";
11
- // Only the bare main entry for now; the walled `@defold-typescript/types/editor-script`
12
- // subpath import arrives with the per-kind editor wall (a deferred slice).
13
- const EDITOR_FACTORY_SPECIFIERS = new Set([EDITOR_FACTORY_MODULE]);
11
+ export const EDITOR_SCRIPT_FACTORY_NAME = "defineEditorScript";
12
+ export const EDITOR_COMMAND_FACTORY_NAME = "defineEditorCommand";
13
+ const EDITOR_FACTORY_NAMES: ReadonlySet<string> = new Set([
14
+ EDITOR_SCRIPT_FACTORY_NAME,
15
+ EDITOR_COMMAND_FACTORY_NAME,
16
+ ]);
17
+ // The bare main entry and the walled per-kind subpath the guide teaches; the
18
+ // other kinds' subpaths export their own factories, never these.
19
+ const EDITOR_FACTORY_SPECIFIERS = new Set([
20
+ EDITOR_FACTORY_MODULE,
21
+ `${EDITOR_FACTORY_MODULE}/editor-script`,
22
+ ]);
14
23
 
15
- function resolvesToEditorFactoryExport(callee: ts.Expression, checker: ts.TypeChecker): boolean {
24
+ function resolveEditorFactoryExport(
25
+ callee: ts.Expression,
26
+ checker: ts.TypeChecker,
27
+ ): string | undefined {
16
28
  let symbol = checker.getSymbolAtLocation(callee);
17
29
  if (symbol === undefined) {
18
- return false;
30
+ return undefined;
19
31
  }
20
32
  if (symbol.flags & ts.SymbolFlags.Alias) {
21
33
  symbol = checker.getAliasedSymbol(symbol);
22
34
  }
23
- if (symbol.getName() !== EDITOR_FACTORY_NAME) {
24
- return false;
35
+ const name = symbol.getName();
36
+ if (!EDITOR_FACTORY_NAMES.has(name)) {
37
+ return undefined;
25
38
  }
26
39
  const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0];
27
40
  if (declaration === undefined) {
28
- return false;
41
+ return undefined;
29
42
  }
30
- return declaration.getSourceFile().fileName.includes(EDITOR_FACTORY_MODULE);
43
+ return declaration.getSourceFile().fileName.includes(EDITOR_FACTORY_MODULE) ? name : undefined;
31
44
  }
32
45
 
33
46
  // Unlike the runtime kinds (lowered to flat top-level lifecycle globals), the
@@ -41,7 +54,12 @@ function lowerEditorFactoryCall(
41
54
  if (!ts.isCallExpression(expression)) {
42
55
  return undefined;
43
56
  }
44
- if (!resolvesToEditorFactoryExport(expression.expression, context.checker)) {
57
+ // Only the module factory owns the chunk-level return; a command factory call
58
+ // is unwrapped in place by the `CallExpression` visitor below.
59
+ if (
60
+ resolveEditorFactoryExport(expression.expression, context.checker) !==
61
+ EDITOR_SCRIPT_FACTORY_NAME
62
+ ) {
45
63
  return undefined;
46
64
  }
47
65
  const module = expression.arguments[0];
@@ -77,8 +95,8 @@ export function isEditorFactoryOnlyImport(node: ts.ImportDeclaration): boolean {
77
95
  if (runtime.length === 0) {
78
96
  return false;
79
97
  }
80
- return runtime.every(
81
- (element) => (element.propertyName ?? element.name).text === EDITOR_FACTORY_NAME,
98
+ return runtime.every((element) =>
99
+ EDITOR_FACTORY_NAMES.has((element.propertyName ?? element.name).text),
82
100
  );
83
101
  }
84
102
 
@@ -96,6 +114,19 @@ export const editorScriptErasurePlugin: Plugin = {
96
114
  }
97
115
  return context.superTransformStatements(node);
98
116
  },
117
+ // `defineEditorCommand` is a compile-time identity with no runtime
118
+ // counterpart once its import is erased, so the call must collapse to the
119
+ // command table it wraps rather than survive as a nil-global call.
120
+ [ts.SyntaxKind.CallExpression]: (node, context): Expression => {
121
+ const command = node.arguments[0];
122
+ if (
123
+ command !== undefined &&
124
+ resolveEditorFactoryExport(node.expression, context.checker) === EDITOR_COMMAND_FACTORY_NAME
125
+ ) {
126
+ return context.transformExpression(command);
127
+ }
128
+ return context.superTransformExpression(node);
129
+ },
99
130
  [ts.SyntaxKind.ImportDeclaration]: (node, context) =>
100
131
  isEditorFactoryOnlyImport(node) ? [] : context.superTransformStatements(node),
101
132
  },
@@ -0,0 +1,71 @@
1
+ import { parseSceneTextFormat, SceneTextFormatError } from "./scene-text-format";
2
+
3
+ // The node ids each script may address, keyed by the project-relative resource
4
+ // path of the `.gui_script` that owns them, plus an honest record of every scene
5
+ // whose ownership could not be settled. A key's absence is not an empty scene:
6
+ // it means no single `.gui` owns that resource, so nothing may be offered for
7
+ // it.
8
+ export interface GuiNodeIndex {
9
+ readonly byScriptResource: ReadonlyMap<string, ReadonlySet<string>>;
10
+ readonly unresolved: readonly string[];
11
+ }
12
+
13
+ // A `.gui` names its script as a project-absolute resource path, and the key is
14
+ // that path with the leading `/` removed — never a guess at the source it came
15
+ // from. An output path cannot say which include base was stripped to produce it,
16
+ // so the only well-defined direction is forward: a caller maps the file it holds
17
+ // through the build's own output-path math and looks the result up here. A
18
+ // hand-written Lua `.gui_script` keys itself, which no computed name (always
19
+ // `.ts.gui_script`) can equal, so it needs no separate check.
20
+ const SCRIPT_SUFFIX = ".gui_script";
21
+
22
+ function scriptKeyOf(script: string): string | undefined {
23
+ if (!script.endsWith(SCRIPT_SUFFIX)) return undefined;
24
+ const key = script.startsWith("/") ? script.slice(1) : script;
25
+ return key === SCRIPT_SUFFIX ? undefined : key;
26
+ }
27
+
28
+ export function buildGuiNodeIndex(documents: ReadonlyMap<string, string>): GuiNodeIndex {
29
+ const byScriptResource = new Map<string, ReadonlySet<string>>();
30
+ const claimedBy = new Map<string, string>();
31
+ const unresolved: string[] = [];
32
+
33
+ for (const [displayPath, text] of documents) {
34
+ let document: ReturnType<typeof parseSceneTextFormat>;
35
+ try {
36
+ document = parseSceneTextFormat(text);
37
+ } catch (error) {
38
+ if (!(error instanceof SceneTextFormatError)) throw error;
39
+ unresolved.push(`${displayPath}: could not be parsed (${error.message})`);
40
+ continue;
41
+ }
42
+
43
+ const [script] = document.fields.get("script") ?? [];
44
+ if (script === undefined) continue;
45
+ const key = scriptKeyOf(script);
46
+ if (key === undefined) continue;
47
+
48
+ const owner = claimedBy.get(key);
49
+ if (owner !== undefined) {
50
+ byScriptResource.delete(key);
51
+ unresolved.push(
52
+ `${key}: claimed by both ${owner} and ${displayPath}, so its node ids are ambiguous`,
53
+ );
54
+ continue;
55
+ }
56
+ claimedBy.set(key, displayPath);
57
+
58
+ // Top-level `nodes` only. A `layouts` or `templates` block repeats a node
59
+ // under an override, and walking into them would offer the same id twice
60
+ // while inventing ids from scenes this script does not drive.
61
+ const ids = new Set<string>();
62
+ for (const message of document.messages.get("nodes") ?? []) {
63
+ for (const id of message.fields.get("id") ?? []) {
64
+ if (id !== "") ids.add(id);
65
+ }
66
+ }
67
+ byScriptResource.set(key, ids);
68
+ }
69
+
70
+ return { byScriptResource, unresolved };
71
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,31 @@
1
+ export { buildConfigKeyIndex } from "./config-key-index";
2
+ export type { GuiNodeIndex } from "./gui-node-index";
3
+ export { buildGuiNodeIndex } from "./gui-node-index";
4
+ export { buildInputActionIndex } from "./input-action-index";
1
5
  export { findMainEntryFactoryImports } from "./main-entry-factory-imports";
6
+ export type { BuildConfig, ScriptKind, SourceOutputKind } from "./output-paths";
7
+ export {
8
+ computeOutputRel,
9
+ DEFAULT_INCLUDE,
10
+ parseBuildConfig,
11
+ SCRIPT_SUFFIX_BY_KIND,
12
+ stripIncludeBase,
13
+ } from "./output-paths";
2
14
  export { getProgramDiagnostics } from "./program-diagnostics";
15
+ export { isDefignoredPath, SCAFFOLDED_DEFIGNORE_LINES } from "./project-resources";
16
+ export type { SceneComponentIndex } from "./scene-component-index";
17
+ export { buildSceneComponentIndex } from "./scene-component-index";
18
+ export type { SceneObjectPathIndex } from "./scene-object-path-index";
19
+ export { buildSceneObjectPathIndex } from "./scene-object-path-index";
20
+ export type { SceneMessage } from "./scene-text-format";
21
+ export { parseSceneTextFormat, SceneTextFormatError } from "./scene-text-format";
3
22
  export type { TranspileSession } from "./session";
4
23
  export { createTranspileSession } from "./session";
24
+ export type { SpriteAnimationIndex } from "./sprite-animation-index";
25
+ export {
26
+ buildSpriteAnimationIndex,
27
+ componentIdOfSameObjectAddress,
28
+ } from "./sprite-animation-index";
5
29
  export type {
6
30
  TranspileDiagnostic,
7
31
  TranspileProjectInput,
@@ -9,3 +33,12 @@ export type {
9
33
  TranspileResult,
10
34
  } from "./transpile";
11
35
  export { transpile, transpileProject } from "./transpile";
36
+ export type { ClassifiedSlot } from "./url-address-slots";
37
+ export {
38
+ addressClassOfArgument,
39
+ isAddressClass,
40
+ isFragmentCaret,
41
+ resolveClassifiedSlotAtPosition,
42
+ } from "./url-address-slots";
43
+ export type { UrlFragmentFinding, UrlFragmentReport } from "./url-fragment-reachability";
44
+ export { checkUrlFragmentReachability } from "./url-fragment-reachability";
@@ -0,0 +1,41 @@
1
+ import { parseSceneTextFormat, SceneTextFormatError } from "./scene-text-format";
2
+
3
+ // The action names the project's `.input_binding` files declare — the whole
4
+ // universe an `on_input` handler can compare against, since an action no binding
5
+ // produces never fires. A project-wide union, deliberately: which binding is
6
+ // live is a `game.project` `[input] game_binding` setting, and a suggestion
7
+ // claims nothing about what is absent.
8
+ //
9
+ // Every top-level sub-message contributes, not a list of the trigger kinds
10
+ // Defold ships today: `key_trigger`, `mouse_trigger`, `gamepad_trigger` and
11
+ // `text_trigger` all carry the same `action` field, and a kind added upstream
12
+ // would otherwise drop out of the universe silently. The `input` half names an
13
+ // engine constant rather than anything the project declares, so it is never
14
+ // offered.
15
+ //
16
+ // A document that will not parse is skipped and its siblings still contribute —
17
+ // the same honest-hole convention `buildGuiNodeIndex` uses, with no `unresolved`
18
+ // channel because nothing here reports.
19
+ export function buildInputActionIndex(documents: ReadonlyMap<string, string>): ReadonlySet<string> {
20
+ const actions = new Set<string>();
21
+
22
+ for (const text of documents.values()) {
23
+ let document: ReturnType<typeof parseSceneTextFormat>;
24
+ try {
25
+ document = parseSceneTextFormat(text);
26
+ } catch (error) {
27
+ if (!(error instanceof SceneTextFormatError)) throw error;
28
+ continue;
29
+ }
30
+
31
+ for (const triggers of document.messages.values()) {
32
+ for (const trigger of triggers) {
33
+ for (const action of trigger.fields.get("action") ?? []) {
34
+ if (action !== "") actions.add(action);
35
+ }
36
+ }
37
+ }
38
+ }
39
+
40
+ return actions;
41
+ }
@@ -312,13 +312,22 @@ export function isFactoryOnlyImport(node: ts.ImportDeclaration): boolean {
312
312
  if (clause === undefined || clause.name !== undefined) {
313
313
  return false;
314
314
  }
315
+ // A whole-clause `import type { ... }` binds nothing at runtime; leave it for
316
+ // the normal transform, which elides it.
317
+ if (clause.isTypeOnly) {
318
+ return false;
319
+ }
315
320
  const bindings = clause.namedBindings;
316
321
  if (bindings === undefined || !ts.isNamedImports(bindings)) {
317
322
  return false;
318
323
  }
319
- return bindings.elements.every((element) =>
320
- FACTORY_NAMES.has((element.propertyName ?? element.name).text),
321
- );
324
+ // Type-only specifiers ride along and drop with the erased statement; erase
325
+ // only when every runtime specifier is a lifecycle factory.
326
+ const runtime = bindings.elements.filter((element) => !element.isTypeOnly);
327
+ if (runtime.length === 0) {
328
+ return false;
329
+ }
330
+ return runtime.every((element) => FACTORY_NAMES.has((element.propertyName ?? element.name).text));
322
331
  }
323
332
 
324
333
  export const lifecycleErasurePlugin: Plugin = {
@@ -0,0 +1,69 @@
1
+ import * as path from "node:path";
2
+
3
+ export type ScriptKind = "script" | "gui-script" | "render-script" | "editor-script";
4
+
5
+ export type SourceOutputKind = ScriptKind | "module";
6
+
7
+ export interface BuildConfig {
8
+ readonly outDir: string | undefined;
9
+ readonly include: string[];
10
+ }
11
+
12
+ interface TsConfig {
13
+ compilerOptions?: {
14
+ outDir?: string;
15
+ };
16
+ include?: string[];
17
+ }
18
+
19
+ export const DEFAULT_INCLUDE = ["src/**/*.ts"];
20
+
21
+ export const SCRIPT_SUFFIX_BY_KIND: Record<ScriptKind, string> = {
22
+ script: ".ts.script",
23
+ "gui-script": ".ts.gui_script",
24
+ "render-script": ".ts.render_script",
25
+ "editor-script": ".ts.editor_script",
26
+ };
27
+
28
+ // The `tsconfig.json` reading the output-path math depends on, separated from
29
+ // how the text was obtained: the CLI reads it off disk, the editor plugin gets
30
+ // it from the language-service host, and both must derive the same names from
31
+ // the same text or the plugin predicts a resource the build never writes.
32
+ export function parseBuildConfig(raw: string): BuildConfig {
33
+ const tsconfig = JSON.parse(raw) as TsConfig;
34
+ const outDir = tsconfig.compilerOptions?.outDir;
35
+ const include = tsconfig.include?.length ? tsconfig.include : DEFAULT_INCLUDE;
36
+ return { outDir, include };
37
+ }
38
+
39
+ export function stripIncludeBase(pattern: string): string {
40
+ const firstWildcard = pattern.search(/[*?[]/);
41
+ if (firstWildcard === -1) {
42
+ return pattern.endsWith("/") ? pattern : `${path.posix.dirname(pattern)}/`;
43
+ }
44
+ const upToWildcard = pattern.slice(0, firstWildcard);
45
+ const lastSlash = upToWildcard.lastIndexOf("/");
46
+ return lastSlash === -1 ? "" : upToWildcard.slice(0, lastSlash + 1);
47
+ }
48
+
49
+ function relUnderOutDir(rel: string, config: BuildConfig): string {
50
+ const { outDir, include } = config;
51
+ if (outDir === undefined || outDir === "" || outDir === ".") {
52
+ return rel;
53
+ }
54
+ const includeBase =
55
+ include
56
+ .map(stripIncludeBase)
57
+ .filter((base) => rel.startsWith(base))
58
+ .sort((a, b) => b.length - a.length)[0] ?? "";
59
+ const relUnderBase = rel.slice(includeBase.length);
60
+ return path.posix.join(outDir, relUnderBase);
61
+ }
62
+
63
+ export function computeOutputRel(rel: string, config: BuildConfig, kind: SourceOutputKind): string {
64
+ const baseRel = relUnderOutDir(rel, config);
65
+ if (kind === "module") {
66
+ return baseRel.replace(/\.ts$/, ".lua");
67
+ }
68
+ return baseRel.replace(/\.ts$/, SCRIPT_SUFFIX_BY_KIND[kind]);
69
+ }
@@ -0,0 +1,20 @@
1
+ // The Defold editor and bob read neither `.gitignore` nor `.vscode`; they scan
2
+ // the whole project tree for resources. `.defignore` (one root-relative path per
3
+ // line) is how a project tells them to skip a folder. These three carry files
4
+ // the editor would otherwise misread as project resources: `node_modules`
5
+ // dependency trees, the resolver-generated `.defold-types` LuaLS surface, and
6
+ // `.vscode` config plus the multi-MB debug engine binary. `src/` is never
7
+ // listed — its emitted `.ts.script`/`.gui_script`/`.lua` components are exactly
8
+ // what Defold must load. Committed config (not in `GITIGNORE_LINES`, like
9
+ // `.vscode`).
10
+ export const SCAFFOLDED_DEFIGNORE_LINES = ["/node_modules", "/.defold-types", "/.vscode"];
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 {
16
+ const rooted = `/${displayPath.replace(/^\/+/, "")}`;
17
+ return SCAFFOLDED_DEFIGNORE_LINES.some(
18
+ (line) => rooted === line || rooted.startsWith(`${line}/`),
19
+ );
20
+ }
@@ -0,0 +1,80 @@
1
+ import { parseSceneTextFormat, type SceneMessage, SceneTextFormatError } from "./scene-text-format";
2
+
3
+ // Every component id the project declares, plus an honest record of everything
4
+ // the walk could not read. A non-empty `incomplete` means the universe is not
5
+ // provably whole, so no consumer may conclude that a missing id is absent.
6
+ export interface SceneComponentIndex {
7
+ readonly ids: ReadonlySet<string>;
8
+ readonly incomplete: readonly string[];
9
+ }
10
+
11
+ // `id` under these names is a component id. `instances`,
12
+ // `collection_instances` and `embedded_instances` carry game-object ids, which
13
+ // name a *path* — and a path can never be proven wrong, because `factory.create`
14
+ // invents them at runtime.
15
+ const COMPONENT_BLOCKS = new Set(["components", "embedded_components"]);
16
+
17
+ // The only two places Defold embeds a whole escaped document in `data:`. Every
18
+ // other `data:` is a plain scalar — `embedded_collision_shape` stores sphere
19
+ // radii there — so re-parsing all of them would report the repo's own example
20
+ // projects as unreadable and suppress the check for good.
21
+ const EMBEDDING_BLOCKS = new Set(["embedded_instances", "embedded_components"]);
22
+
23
+ function collect(
24
+ message: SceneMessage,
25
+ blockName: string,
26
+ depth: number,
27
+ displayPath: string,
28
+ ids: Set<string>,
29
+ incomplete: string[],
30
+ ): void {
31
+ if (COMPONENT_BLOCKS.has(blockName)) {
32
+ for (const id of message.fields.get("id") ?? []) {
33
+ ids.add(id);
34
+ }
35
+ }
36
+ if (EMBEDDING_BLOCKS.has(blockName)) {
37
+ for (const payload of message.fields.get("data") ?? []) {
38
+ try {
39
+ collect(parseSceneTextFormat(payload), "", depth + 1, displayPath, ids, incomplete);
40
+ } catch (error) {
41
+ if (!(error instanceof SceneTextFormatError)) throw error;
42
+ incomplete.push(
43
+ `${displayPath}: embedded payload at depth ${depth + 1} could not be parsed (${error.message})`,
44
+ );
45
+ }
46
+ }
47
+ }
48
+ for (const [name, nested] of message.messages) {
49
+ for (const child of nested) {
50
+ collect(child, name, depth, displayPath, ids, incomplete);
51
+ }
52
+ }
53
+ }
54
+
55
+ // Build the component-id universe from already-read scene sources: keys are
56
+ // display paths, values are file text. Pure — the filesystem walk belongs to the
57
+ // caller, so a test can drive this from inline strings and a build can drive it
58
+ // from a real scan.
59
+ export function buildSceneComponentIndex(
60
+ documents: ReadonlyMap<string, string>,
61
+ ): SceneComponentIndex {
62
+ const ids = new Set<string>();
63
+ const incomplete: string[] = [];
64
+
65
+ if (documents.size === 0) {
66
+ incomplete.push("no scene sources were read, so no component id can be proven absent");
67
+ return { ids, incomplete };
68
+ }
69
+
70
+ for (const [displayPath, text] of documents) {
71
+ try {
72
+ collect(parseSceneTextFormat(text), "", 0, displayPath, ids, incomplete);
73
+ } catch (error) {
74
+ if (!(error instanceof SceneTextFormatError)) throw error;
75
+ incomplete.push(`${displayPath}: could not be parsed (${error.message})`);
76
+ }
77
+ }
78
+
79
+ return { ids, incomplete };
80
+ }