@defold-typescript/transpiler 0.29.0 → 0.31.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.
Files changed (37) hide show
  1. package/dist/animation-sources.d.ts +6 -0
  2. package/dist/{sprite-animation-index.d.ts → component-animation-index.d.ts} +4 -4
  3. package/dist/config-key-index.d.ts +1 -0
  4. package/dist/gui-flipbook-index.d.ts +8 -0
  5. package/dist/gui-node-index.d.ts +21 -0
  6. package/dist/index.d.ts +21 -8
  7. package/dist/index.js +1317 -289
  8. package/dist/library-dependencies.d.ts +47 -0
  9. package/dist/scene-address-declaration.d.ts +14 -0
  10. package/dist/scene-collection-roles.d.ts +30 -0
  11. package/dist/scene-component-index.d.ts +20 -0
  12. package/dist/scene-documents.d.ts +21 -0
  13. package/dist/scene-naming-context.d.ts +66 -0
  14. package/dist/scene-object-path-index.d.ts +4 -1
  15. package/dist/url-address-literals.d.ts +10 -0
  16. package/dist/url-address-slots.d.ts +3 -1
  17. package/dist/url-cross-world-address.d.ts +41 -0
  18. package/dist/url-fragment-reachability.d.ts +3 -0
  19. package/package.json +2 -2
  20. package/src/animation-sources.ts +90 -0
  21. package/src/component-animation-index.ts +399 -0
  22. package/src/config-key-index.ts +28 -2
  23. package/src/gui-flipbook-index.ts +91 -0
  24. package/src/gui-node-index.ts +42 -4
  25. package/src/index.ts +66 -10
  26. package/src/library-dependencies.ts +337 -0
  27. package/src/scene-address-declaration.ts +74 -0
  28. package/src/scene-collection-roles.ts +267 -0
  29. package/src/scene-component-index.ts +74 -6
  30. package/src/scene-documents.ts +186 -0
  31. package/src/scene-naming-context.ts +130 -0
  32. package/src/scene-object-path-index.ts +172 -33
  33. package/src/url-address-literals.ts +115 -0
  34. package/src/url-address-slots.ts +23 -2
  35. package/src/url-cross-world-address.ts +109 -0
  36. package/src/url-fragment-reachability.ts +72 -93
  37. package/src/sprite-animation-index.ts +0 -241
@@ -0,0 +1,267 @@
1
+ import { readGameProjectSetting } from "./config-key-index";
2
+ import { parseSceneTextFormat, type SceneMessage, SceneTextFormatError } from "./scene-text-format";
3
+
4
+ // Which world each collection is, so an address can say where it resolves. A
5
+ // game object's address has two axes — which world, then which path inside it —
6
+ // and only these roles can tell them apart. Shaped like the indexes it feeds: a
7
+ // non-empty `incomplete` means the classification is not provably whole, so no
8
+ // consumer may conclude a collection has no role.
9
+ export interface SceneCollectionRoles {
10
+ /** The one collection `[bootstrap] main_collection` names, whose paths are bare. */
11
+ readonly bootstrap?: string | undefined;
12
+ /** Proxied collections, mapped to the socket `CollectionDesc.name` gives them. */
13
+ readonly sockets: ReadonlyMap<string, string>;
14
+ /** Collections another collection instances, whose paths exist only under that id. */
15
+ readonly instanced: ReadonlySet<string>;
16
+ /** Collection-factory prototypes, whose objects have no static path at all. */
17
+ readonly factoryPrototypes: ReadonlySet<string>;
18
+ readonly incomplete: readonly string[];
19
+ }
20
+
21
+ export interface SceneCollectionRolesInput {
22
+ /** The `.go`/`.collection` universe, keyed by display path. */
23
+ readonly documents: ReadonlyMap<string, string>;
24
+ /** The `.collectionproxy`/`.collectionfactory` documents, keyed by display path. */
25
+ readonly references: ReadonlyMap<string, string>;
26
+ /** `game.project`'s text, or `undefined` when the walk could not read it. */
27
+ readonly gameProject: string | undefined;
28
+ }
29
+
30
+ // The two places Defold embeds a whole escaped document in `data:`, the same
31
+ // rule `buildSceneComponentIndex` states — every other `data:` is a plain scalar.
32
+ const EMBEDDING_BLOCKS = new Set(["embedded_instances", "embedded_components"]);
33
+
34
+ // A component reference and the field its document holds the target under: a
35
+ // proxy names the collection it opens as a world, a factory names the prototype
36
+ // it spawns copies of.
37
+ const REFERENCE_KINDS = [
38
+ { extension: ".collectionproxy", type: "collectionproxy", field: "collection" },
39
+ { extension: ".collectionfactory", type: "collectionfactory", field: "prototype" },
40
+ ] as const;
41
+
42
+ type ReferenceKind = (typeof REFERENCE_KINDS)[number];
43
+
44
+ // Matches `scene-object-path-index.ts`: resource paths inside a scene are
45
+ // project-absolute, documents are keyed by project-relative display path.
46
+ function resourceKey(resource: string): string {
47
+ return resource.startsWith("/") ? resource.slice(1) : resource;
48
+ }
49
+
50
+ // `game.project` and a `.collectionproxy` name the *compiled* resource the
51
+ // engine loads (`/main/main.collectionc`), not the source the walk read — the
52
+ // same normalisation `resolveBootPathScripts` applies to its own boot walk.
53
+ // Without it every real project fails to find its bootstrap world.
54
+ function collectionKey(resource: string): string {
55
+ return resourceKey(resource).replace(/\.collectionc$/, ".collection");
56
+ }
57
+
58
+ function firstField(message: SceneMessage, name: string): string | undefined {
59
+ return (message.fields.get(name) ?? [])[0];
60
+ }
61
+
62
+ // Only a `.collection` is a world. A `.go` is a prototype, so running the role
63
+ // rules over it would make every prototype in the project a role-less
64
+ // "reachable no way" entry and drown the honest ones.
65
+ function isCollection(displayPath: string): boolean {
66
+ return displayPath.endsWith(".collection");
67
+ }
68
+
69
+ /**
70
+ * Classify every collection the project declares into the world it is: the one
71
+ * bootstrap root, a proxy world addressed through its own socket, a collection
72
+ * instanced inside another, or a factory prototype with no static address.
73
+ *
74
+ * Pure, like the indexes it feeds — the filesystem walk belongs to the caller.
75
+ * There is deliberately no everything-else-is-a-root fallback: that fallback is
76
+ * what offers a bare `/enemy` for an object living in another world, and losing
77
+ * a completion is safe where offering a false one is not.
78
+ */
79
+ export function buildSceneCollectionRoles(input: SceneCollectionRolesInput): SceneCollectionRoles {
80
+ const { documents, references, gameProject } = input;
81
+ const incomplete: string[] = [];
82
+
83
+ const parsed = new Map<string, SceneMessage>();
84
+ for (const [displayPath, text] of documents) {
85
+ try {
86
+ parsed.set(displayPath, parseSceneTextFormat(text));
87
+ } catch (error) {
88
+ if (!(error instanceof SceneTextFormatError)) throw error;
89
+ incomplete.push(`${displayPath}: could not be parsed (${error.message})`);
90
+ }
91
+ }
92
+
93
+ const instanced = new Set<string>();
94
+ for (const document of parsed.values()) {
95
+ for (const block of document.messages.get("collection_instances") ?? []) {
96
+ const collection = firstField(block, "collection");
97
+ if (collection !== undefined) instanced.add(resourceKey(collection));
98
+ }
99
+ }
100
+
101
+ const proxied = new Set<string>();
102
+ const factoryPrototypes = new Set<string>();
103
+
104
+ function record(kind: ReferenceKind, resource: string): void {
105
+ (kind.type === "collectionproxy" ? proxied : factoryPrototypes).add(collectionKey(resource));
106
+ }
107
+
108
+ // A standalone reference is a component file of its own; its target lives in
109
+ // the reference document rather than in the scene that points at it, so a
110
+ // reference the walk never read is a named gap and not a dropped edge.
111
+ function followReferenceDocument(displayPath: string, resource: string): void {
112
+ const kind = REFERENCE_KINDS.find((candidate) => resource.endsWith(candidate.extension));
113
+ if (kind === undefined) return;
114
+ const text = references.get(resourceKey(resource));
115
+ if (text === undefined) {
116
+ incomplete.push(
117
+ `${displayPath}: the component ${resource} is not among the project's readable ${kind.extension} documents, so the collection it names is unclassified`,
118
+ );
119
+ return;
120
+ }
121
+ let document: SceneMessage;
122
+ try {
123
+ document = parseSceneTextFormat(text);
124
+ } catch (error) {
125
+ if (!(error instanceof SceneTextFormatError)) throw error;
126
+ incomplete.push(`${resourceKey(resource)}: could not be parsed (${error.message})`);
127
+ return;
128
+ }
129
+ const target = firstField(document, kind.field);
130
+ if (target === undefined) {
131
+ incomplete.push(
132
+ `${resourceKey(resource)}: declares no ${kind.field}:, so the collection it names is unclassified`,
133
+ );
134
+ return;
135
+ }
136
+ record(kind, target);
137
+ }
138
+
139
+ function walk(
140
+ message: SceneMessage,
141
+ blockName: string,
142
+ displayPath: string,
143
+ depth: number,
144
+ ): void {
145
+ if (blockName === "components") {
146
+ const component = firstField(message, "component");
147
+ if (component !== undefined) followReferenceDocument(displayPath, component);
148
+ }
149
+
150
+ const payloads: SceneMessage[] = [];
151
+ if (EMBEDDING_BLOCKS.has(blockName)) {
152
+ for (const payload of message.fields.get("data") ?? []) {
153
+ try {
154
+ payloads.push(parseSceneTextFormat(payload));
155
+ } catch (error) {
156
+ if (!(error instanceof SceneTextFormatError)) throw error;
157
+ incomplete.push(
158
+ `${displayPath}: embedded payload at depth ${depth + 1} could not be parsed (${error.message})`,
159
+ );
160
+ }
161
+ }
162
+ }
163
+
164
+ if (blockName === "embedded_components") {
165
+ const type = firstField(message, "type");
166
+ const kind = REFERENCE_KINDS.find((candidate) => candidate.type === type);
167
+ if (kind !== undefined) {
168
+ for (const payload of payloads) {
169
+ const target = firstField(payload, kind.field);
170
+ if (target === undefined) {
171
+ incomplete.push(
172
+ `${displayPath}: the embedded ${kind.type} declares no ${kind.field}:, so the collection it names is unclassified`,
173
+ );
174
+ continue;
175
+ }
176
+ record(kind, target);
177
+ }
178
+ }
179
+ }
180
+
181
+ for (const payload of payloads) walk(payload, "", displayPath, depth + 1);
182
+ for (const [name, nested] of message.messages) {
183
+ for (const child of nested) walk(child, name, displayPath, depth);
184
+ }
185
+ }
186
+
187
+ for (const [displayPath, document] of parsed) walk(document, "", displayPath, 0);
188
+
189
+ // The socket is the proxied collection's own `name:` — `CollectionDesc.name`,
190
+ // which the editor writes on the collection itself and which is deliberately
191
+ // neither the proxy component's id nor the file's basename.
192
+ const sockets = new Map<string, string>();
193
+ const byName = new Map<string, string[]>();
194
+ for (const displayPath of [...proxied].sort()) {
195
+ const document = parsed.get(displayPath);
196
+ if (document === undefined) {
197
+ incomplete.push(
198
+ `${displayPath}: is opened as a collection proxy but is not among the project's readable scene documents, so its world has no name`,
199
+ );
200
+ continue;
201
+ }
202
+ const name = firstField(document, "name");
203
+ if (name === undefined || name === "") {
204
+ incomplete.push(
205
+ `${displayPath}: is opened as a collection proxy but declares no name:, so its world cannot be addressed`,
206
+ );
207
+ continue;
208
+ }
209
+ sockets.set(displayPath, name);
210
+ byName.set(name, [...(byName.get(name) ?? []), displayPath]);
211
+ }
212
+ for (const [name, holders] of byName) {
213
+ if (holders.length > 1) {
214
+ incomplete.push(
215
+ `${holders.join(", ")}: all declare name: "${name}", so an address under that socket does not say which world it means`,
216
+ );
217
+ }
218
+ }
219
+
220
+ // A project the walk found no collection in has no world to name, so an
221
+ // absent `[bootstrap]` says nothing a reader could act on — the same posture
222
+ // `sceneIndexForBuild` takes towards a scene-less project. A project that does
223
+ // have collections is a different case: there the absence is exactly why no
224
+ // address is offered bare, so it is always named.
225
+ const hasCollections = [...parsed.keys()].some(isCollection);
226
+
227
+ let bootstrap: string | undefined;
228
+ if (gameProject === undefined) {
229
+ if (hasCollections) {
230
+ incomplete.push(
231
+ "game.project could not be read, so no collection is known to be the bootstrap world and no address is offered unqualified",
232
+ );
233
+ }
234
+ } else {
235
+ const main = readGameProjectSetting(gameProject, "bootstrap", "main_collection");
236
+ if (main === undefined) {
237
+ if (hasCollections) {
238
+ incomplete.push(
239
+ "game.project declares no [bootstrap] main_collection, so no collection is the bootstrap world",
240
+ );
241
+ }
242
+ } else if (!parsed.has(collectionKey(main))) {
243
+ incomplete.push(
244
+ `game.project names ${main} as [bootstrap] main_collection, which is not among the project's readable scene documents`,
245
+ );
246
+ } else {
247
+ bootstrap = collectionKey(main);
248
+ }
249
+ }
250
+
251
+ for (const displayPath of parsed.keys()) {
252
+ if (!isCollection(displayPath)) continue;
253
+ if (
254
+ displayPath === bootstrap ||
255
+ sockets.has(displayPath) ||
256
+ instanced.has(displayPath) ||
257
+ factoryPrototypes.has(displayPath)
258
+ ) {
259
+ continue;
260
+ }
261
+ incomplete.push(
262
+ `${displayPath}: is reached by no bootstrap, proxy, instance or factory reference, so which world its objects live in is unknown`,
263
+ );
264
+ }
265
+
266
+ return { bootstrap, sockets, instanced, factoryPrototypes, incomplete };
267
+ }
@@ -20,23 +20,56 @@ const COMPONENT_BLOCKS = new Set(["components", "embedded_components"]);
20
20
  // projects as unreadable and suppress the check for good.
21
21
  const EMBEDDING_BLOCKS = new Set(["embedded_instances", "embedded_components"]);
22
22
 
23
+ // A component id and the resource it references, where one is referenced at all.
24
+ // The two axes are gathered by one pass because they are read off the same
25
+ // block: a second walk over the same documents is exactly how the id universe
26
+ // and the resource universe drift apart.
27
+ export interface ComponentDeclarations {
28
+ readonly ids: Set<string>;
29
+ readonly resources: Map<string, string>;
30
+ }
31
+
32
+ // Resource paths inside a scene are project-absolute; documents are keyed by
33
+ // project-relative display path, the same convention `resourceKey` follows in
34
+ // `scene-object-path-index.ts`.
35
+ function resourceKey(resource: string): string {
36
+ return resource.startsWith("/") ? resource.slice(1) : resource;
37
+ }
38
+
23
39
  function collect(
24
40
  message: SceneMessage,
25
41
  blockName: string,
26
42
  depth: number,
27
43
  displayPath: string,
28
- ids: Set<string>,
44
+ declarations: ComponentDeclarations,
29
45
  incomplete: string[],
30
46
  ): void {
31
47
  if (COMPONENT_BLOCKS.has(blockName)) {
32
- for (const id of message.fields.get("id") ?? []) {
33
- ids.add(id);
48
+ const declared = message.fields.get("id") ?? [];
49
+ for (const id of declared) {
50
+ declarations.ids.add(id);
51
+ }
52
+ // `embedded_components` is skipped on purpose: its payload *is* the
53
+ // component, so it names no resource and must never be credited with one.
54
+ if (blockName === "components") {
55
+ const [id] = declared;
56
+ const [component] = message.fields.get("component") ?? [];
57
+ if (id !== undefined && id !== "" && component !== undefined) {
58
+ declarations.resources.set(id, resourceKey(component));
59
+ }
34
60
  }
35
61
  }
36
62
  if (EMBEDDING_BLOCKS.has(blockName)) {
37
63
  for (const payload of message.fields.get("data") ?? []) {
38
64
  try {
39
- collect(parseSceneTextFormat(payload), "", depth + 1, displayPath, ids, incomplete);
65
+ collect(
66
+ parseSceneTextFormat(payload),
67
+ "",
68
+ depth + 1,
69
+ displayPath,
70
+ declarations,
71
+ incomplete,
72
+ );
40
73
  } catch (error) {
41
74
  if (!(error instanceof SceneTextFormatError)) throw error;
42
75
  incomplete.push(
@@ -47,11 +80,44 @@ function collect(
47
80
  }
48
81
  for (const [name, nested] of message.messages) {
49
82
  for (const child of nested) {
50
- collect(child, name, depth, displayPath, ids, incomplete);
83
+ collect(child, name, depth, displayPath, declarations, incomplete);
51
84
  }
52
85
  }
53
86
  }
54
87
 
88
+ /**
89
+ * Every component id one already-parsed scene message declares, plus the
90
+ * resource each *referenced* component names, following the two `data:`
91
+ * payloads Defold embeds a whole document in. Exported because the game-object
92
+ * path walk attributes components to the object that owns them and must read
93
+ * the same rule this index reads — a second collector is exactly how the two
94
+ * universes drift apart.
95
+ *
96
+ * `blockName` names the block `message` was taken from, because the `data:`
97
+ * re-parse is keyed off it: a whole document is `""`, while a single
98
+ * `embedded_instances` block has to say so or its payload is never opened.
99
+ */
100
+ export function collectComponentDeclarations(
101
+ message: SceneMessage,
102
+ displayPath: string,
103
+ incomplete: string[],
104
+ blockName = "",
105
+ ): ComponentDeclarations {
106
+ const declarations: ComponentDeclarations = { ids: new Set(), resources: new Map() };
107
+ collect(message, blockName, 0, displayPath, declarations, incomplete);
108
+ return declarations;
109
+ }
110
+
111
+ /** The id half of {@link collectComponentDeclarations}, for callers that join no resources. */
112
+ export function collectComponentIds(
113
+ message: SceneMessage,
114
+ displayPath: string,
115
+ incomplete: string[],
116
+ blockName = "",
117
+ ): Set<string> {
118
+ return collectComponentDeclarations(message, displayPath, incomplete, blockName).ids;
119
+ }
120
+
55
121
  // Build the component-id universe from already-read scene sources: keys are
56
122
  // display paths, values are file text. Pure — the filesystem walk belongs to the
57
123
  // caller, so a test can drive this from inline strings and a build can drive it
@@ -69,7 +135,9 @@ export function buildSceneComponentIndex(
69
135
 
70
136
  for (const [displayPath, text] of documents) {
71
137
  try {
72
- collect(parseSceneTextFormat(text), "", 0, displayPath, ids, incomplete);
138
+ for (const id of collectComponentIds(parseSceneTextFormat(text), displayPath, incomplete)) {
139
+ ids.add(id);
140
+ }
73
141
  } catch (error) {
74
142
  if (!(error instanceof SceneTextFormatError)) throw error;
75
143
  incomplete.push(`${displayPath}: could not be parsed (${error.message})`);
@@ -0,0 +1,186 @@
1
+ import { readLibrarySceneDocuments } from "./library-dependencies";
2
+ import { isDefignoredPath } from "./project-resources";
3
+
4
+ // The narrow slice of `ts.server.ServerHost` this needs: the real host
5
+ // satisfies it structurally, and a test fake is two methods rather than a whole
6
+ // server. `readDirectory` is optional because a host may not provide it, and a
7
+ // plugin that throws takes the editor's completions down with it.
8
+ export interface SceneReadHost {
9
+ readDirectory?(path: string, extensions?: readonly string[]): string[];
10
+ readFile(path: string): string | undefined;
11
+ }
12
+
13
+ export const SCENE_EXTENSIONS = [".go", ".collection"];
14
+
15
+ // The extension sets more than one reader asks the host for. They live here
16
+ // rather than beside either caller because the index cache serves exactly the
17
+ // sets it is handed: a second definition that drifted would walk — and watch —
18
+ // a universe the other reader never invalidates.
19
+ export const GUI_EXTENSIONS = [".gui"];
20
+ export const INPUT_BINDING_EXTENSIONS = [".input_binding"];
21
+
22
+ // The component files that name a collection without instancing it: a
23
+ // `.collectionproxy` opens it as its own world, a `.collectionfactory` spawns
24
+ // copies of it at runtime. Deliberately not folded into `SCENE_EXTENSIONS`,
25
+ // which would feed non-scene text to `buildSceneComponentIndex`.
26
+ export const COLLECTION_REFERENCE_EXTENSIONS = [".collectionproxy", ".collectionfactory"];
27
+
28
+ // The documents an animation id is declared by, plus every hop taken to reach
29
+ // one: an atlas or tile source names a sprite's ids outright, a `.animationset`
30
+ // names the files whose basenames are a model's ids, and a `.sprite` or
31
+ // `.model` is the hop a referenced component takes to reach either.
32
+ export const ANIMATION_ASSET_EXTENSIONS = [
33
+ ".atlas",
34
+ ".tilesource",
35
+ ".sprite",
36
+ ".model",
37
+ ".animationset",
38
+ ];
39
+
40
+ // The file kinds a library shares into the address universe: the documents that
41
+ // declare ids, plus the ones that *classify* a collection into the world it is —
42
+ // a library's own `.collectionproxy` is what says its collection is a proxy
43
+ // world rather than reached by nothing. `.input_binding` and `.project` are
44
+ // deliberately absent: `game.project` names exactly one active binding file, and
45
+ // a vendored `*.project` declares keys this project's readers cannot resolve —
46
+ // the rule `PROJECT_EXTENSIONS` states below.
47
+ //
48
+ // It lives here rather than beside its reader for the reason the sets above do,
49
+ // plus one more: `library-dependencies.ts` and this file import each other, and
50
+ // a constant built from these arrays at that module's top level would read them
51
+ // before this one had run.
52
+ export const LIBRARY_SCENE_EXTENSIONS = [
53
+ ...SCENE_EXTENSIONS,
54
+ ...GUI_EXTENSIONS,
55
+ ...ANIMATION_ASSET_EXTENSIONS,
56
+ ...COLLECTION_REFERENCE_EXTENSIONS,
57
+ ];
58
+
59
+ // The walk returns every `.project` the project holds, so the lookup is by
60
+ // display path — a vendored `*.project` declares keys this project's readers
61
+ // cannot resolve.
62
+ export const PROJECT_EXTENSIONS = [".project"];
63
+ export const GAME_PROJECT_DOCUMENT = "game.project";
64
+
65
+ // The project-relative name a file is keyed by — the same name the editor
66
+ // reports for a program file, so a scene's claim on a script can be looked up
67
+ // against the file being edited.
68
+ export function displayPathOf(projectRoot: string, filePath: string): string {
69
+ const path = filePath.replace(/\\/g, "/");
70
+ const root = projectRoot.replace(/\\/g, "/").replace(/\/+$/, "");
71
+ return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
72
+ }
73
+
74
+ // What both walks exclude is what Defold does not load as a project resource:
75
+ // `build/` output, plus the directories the scaffolded `.defignore` names
76
+ // (`isDefignoredPath`, root-anchored because a `.defignore` line is a
77
+ // root-relative path).
78
+ //
79
+ // Bob writes `_generated_*.go` copies of the project's scenes under `build/`;
80
+ // reading them would report every id twice, against files no author edits. The
81
+ // segment test runs on the project-relative path, so a project that itself
82
+ // lives under a directory named `build` is not excluded wholesale — a different
83
+ // rule from `.defignore`'s, because bob writes those copies below whatever
84
+ // depth it is pointed at.
85
+ function isBuildOutput(displayPath: string): boolean {
86
+ return displayPath.split("/").includes("build");
87
+ }
88
+
89
+ // The single rule, exported because the index cache filters watch events with
90
+ // it: a copy in the watcher could drift from what the walks exclude, and an
91
+ // event the walk would have ignored is exactly the event that must not throw the
92
+ // cache away — bob writes `_generated_*.go` throughout a build.
93
+ export function isExcludedProjectPath(displayPath: string): boolean {
94
+ return isBuildOutput(displayPath) || isDefignoredPath(displayPath);
95
+ }
96
+
97
+ // Read the project's own scene sources through the editor host — by default the
98
+ // `.go`/`.collection` set the component-id universe is built from, or whatever
99
+ // `extensions` names instead. The two universes stay disjoint: a `.gui` declares
100
+ // node ids, not component ids, so folding it into the default would feed gui
101
+ // text to `buildSceneComponentIndex`. Keys are project-relative display paths,
102
+ // matching what `buildSceneComponentIndex` reports in `incomplete`, and every
103
+ // file that could not be read is named in `unreadable` — a universe with a
104
+ // silent hole cannot be reasoned about. `paths` names the host paths actually
105
+ // read — what a watcher must be registered on, which cannot be reconstructed
106
+ // from a display path for a file outside the project root.
107
+ //
108
+ // The universe is the project plus whatever `resolve` last materialized from its
109
+ // library dependencies: a library document joins it under the merged Defold
110
+ // resource path it is addressed by, with its origin recorded in `origins` rather
111
+ // than folded into the key, and the project's own file wins a collision. Which
112
+ // makes the universe only as current as the last `resolve` — so a dependency
113
+ // that is declared but unresolved, unreadable or shadowed is a named
114
+ // `unreadable` line, never a silent omission.
115
+ export function readSceneDocuments(
116
+ host: SceneReadHost,
117
+ projectRoot: string,
118
+ extensions: readonly string[] = SCENE_EXTENSIONS,
119
+ ): {
120
+ documents: Map<string, string>;
121
+ origins: Map<string, string>;
122
+ unreadable: string[];
123
+ paths: string[];
124
+ } {
125
+ const documents = new Map<string, string>();
126
+ const origins = new Map<string, string>();
127
+ const unreadable: string[] = [];
128
+ const paths: string[] = [];
129
+
130
+ if (!host.readDirectory) {
131
+ unreadable.push("the editor host cannot enumerate project files, so no scene source was read");
132
+ return { documents, origins, unreadable, paths };
133
+ }
134
+
135
+ for (const filePath of host.readDirectory(projectRoot, extensions)) {
136
+ const displayPath = displayPathOf(projectRoot, filePath);
137
+ if (isExcludedProjectPath(displayPath)) continue;
138
+ const text = host.readFile(filePath);
139
+ if (text === undefined) {
140
+ unreadable.push(`${displayPath}: could not be read`);
141
+ continue;
142
+ }
143
+ documents.set(displayPath, text);
144
+ paths.push(filePath);
145
+ }
146
+
147
+ const library = readLibrarySceneDocuments(host, projectRoot, extensions);
148
+ unreadable.push(...library.unreadable);
149
+ for (const { displayPath, hostPath, url, text } of library.documents) {
150
+ if (documents.has(displayPath)) {
151
+ unreadable.push(`${displayPath}: also declared by ${url}; the project's own file is used`);
152
+ continue;
153
+ }
154
+ documents.set(displayPath, text);
155
+ origins.set(displayPath, url);
156
+ paths.push(hostPath);
157
+ }
158
+
159
+ return { documents, origins, unreadable, paths };
160
+ }
161
+
162
+ // The project's own files of the given kinds, as the `/`-prefixed
163
+ // project-relative paths a Defold resource property names. Nothing is read: a
164
+ // resource path is the whole suggestion, so the walk is the entire universe.
165
+ //
166
+ // The extension filter is re-applied rather than trusted. `readSceneDocuments`
167
+ // can take the host at its word because a wrong document merely contributes no
168
+ // ids, but here a wrong file *is* the suggestion — a host that ignored its
169
+ // `extensions` argument would turn a `.font` slot into a project-wide file dump.
170
+ export function listProjectResourcePaths(
171
+ host: SceneReadHost,
172
+ projectRoot: string,
173
+ extensions: readonly string[],
174
+ ): Set<string> {
175
+ if (!host.readDirectory) return new Set();
176
+
177
+ const paths: string[] = [];
178
+ for (const filePath of host.readDirectory(projectRoot, extensions)) {
179
+ const displayPath = displayPathOf(projectRoot, filePath);
180
+ if (isExcludedProjectPath(displayPath)) continue;
181
+ if (!extensions.some((extension) => displayPath.endsWith(extension))) continue;
182
+ paths.push(`/${displayPath}`);
183
+ }
184
+
185
+ return new Set(paths.sort());
186
+ }