@defold-typescript/tstl-plugin 0.24.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.
package/src/index.ts CHANGED
@@ -1,5 +1,237 @@
1
- import { getProgramDiagnostics } from "@defold-typescript/transpiler";
1
+ import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import {
4
+ buildConfigKeyIndex,
5
+ buildGuiNodeIndex,
6
+ buildInputActionIndex,
7
+ buildSceneComponentIndex,
8
+ buildSceneObjectPathIndex,
9
+ buildSpriteAnimationIndex,
10
+ type ClassifiedSlot,
11
+ componentIdOfSameObjectAddress,
12
+ computeOutputRel,
13
+ getProgramDiagnostics,
14
+ isAddressClass,
15
+ isFragmentCaret,
16
+ resolveClassifiedSlotAtPosition,
17
+ } from "@defold-typescript/transpiler";
18
+ import type { UrlParameterTable } from "@defold-typescript/types";
2
19
  import type * as ts from "typescript";
20
+ import { readBuildConfigFromHost } from "./build-config";
21
+ import {
22
+ buildAddressPathCompletionEntries,
23
+ buildSceneCompletionEntries,
24
+ buildWholeLiteralCompletionEntries,
25
+ CONTRIBUTED_ENTRY_KIND,
26
+ DEFOLD_COMPLETION_SOURCE,
27
+ } from "./scene-completions";
28
+ import {
29
+ ANIMATION_ASSET_EXTENSIONS,
30
+ displayPathOf,
31
+ GAME_PROJECT_DOCUMENT,
32
+ GUI_EXTENSIONS,
33
+ INPUT_BINDING_EXTENSIONS,
34
+ PROJECT_EXTENSIONS,
35
+ } from "./scene-documents";
36
+ import {
37
+ createSceneIndexCache,
38
+ type SceneIndexCache,
39
+ type SceneWatchHost,
40
+ } from "./scene-index-cache";
41
+ import { resolveEntryProvenance } from "./scene-provenance";
42
+
43
+ const requireFromHere = createRequire(import.meta.url);
44
+
45
+ // The JSON subpath, never the package's `.` entry: `@defold-typescript/types`
46
+ // resolves that entry to TypeScript source, which would survive
47
+ // `--packages=external` into the packed plugin and fail under plain node the
48
+ // way bug-88 did. A declared subpath of plain JSON carries none of that hazard.
49
+ // Resolved and read rather than `require`d, so the value never depends on
50
+ // whether something else in the host process already imported it as a module.
51
+ let urlParameterTable: UrlParameterTable | undefined;
52
+ function loadUrlParameterTable(): UrlParameterTable | undefined {
53
+ if (!urlParameterTable) {
54
+ try {
55
+ const path = requireFromHere.resolve("@defold-typescript/types/url-parameters.json");
56
+ urlParameterTable = JSON.parse(readFileSync(path, "utf8")) as UrlParameterTable;
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ return urlParameterTable;
62
+ }
63
+
64
+ // A slot resolves for a caret anywhere inside the quotes, but a component
65
+ // entry's `replacementSpan` only ever covers the fragment — so offering one to
66
+ // a caret in the path would edit text the author is not standing on. The guard
67
+ // is above the walk, so a caret in the path costs no `.go`/`.collection` parse.
68
+ function componentEntries(
69
+ slot: ClassifiedSlot,
70
+ position: number,
71
+ cache: SceneIndexCache,
72
+ baseEntries: readonly ts.CompletionEntry[],
73
+ ): ts.CompletionEntry[] {
74
+ if (!isFragmentCaret(slot, position)) {
75
+ return [];
76
+ }
77
+ // A partial universe still suggests — unlike the reachability report, a
78
+ // suggestion claims nothing about what is absent.
79
+ return buildSceneCompletionEntries({
80
+ slot,
81
+ ids: cache.derived(
82
+ "component-ids",
83
+ () => buildSceneComponentIndex(cache.documents().documents).ids,
84
+ ),
85
+ baseEntries,
86
+ });
87
+ }
88
+
89
+ // The exact complement of `componentEntries`' guard, so precisely one of the two
90
+ // universes answers any caret in an address. Project-wide like the component
91
+ // universe, and for the same reason: what a path resolves to at runtime depends
92
+ // on the collection that was loaded, which the file being edited does not say.
93
+ function objectPathEntries(
94
+ slot: ClassifiedSlot,
95
+ position: number,
96
+ cache: SceneIndexCache,
97
+ baseEntries: readonly ts.CompletionEntry[],
98
+ ): ts.CompletionEntry[] {
99
+ if (isFragmentCaret(slot, position)) {
100
+ return [];
101
+ }
102
+ return buildAddressPathCompletionEntries({
103
+ slot,
104
+ paths: cache.derived(
105
+ "object-paths",
106
+ () => buildSceneObjectPathIndex(cache.documents().documents).paths,
107
+ ),
108
+ baseEntries,
109
+ });
110
+ }
111
+
112
+ // No caret guard: the span is the whole literal, so an entry is well-formed
113
+ // wherever inside the quotes the caret sits. Node ids are scoped to the single
114
+ // `.gui` that names this file's generated script — a project-wide union would
115
+ // offer ids `gui.get_node` could never resolve at runtime. A scene names an
116
+ // output resource, and an output path cannot say which include base produced it,
117
+ // so the file being edited is mapped forward through the build's own math rather
118
+ // than the resource being mapped back.
119
+ function nodeEntries(
120
+ slot: ClassifiedSlot,
121
+ cache: SceneIndexCache,
122
+ fileName: string,
123
+ baseEntries: readonly ts.CompletionEntry[],
124
+ ): ts.CompletionEntry[] {
125
+ const { projectRoot } = cache;
126
+ const index = cache.derived("gui-nodes", () =>
127
+ buildGuiNodeIndex(cache.documents(GUI_EXTENSIONS).documents),
128
+ );
129
+ const config = readBuildConfigFromHost(cache.host, projectRoot);
130
+ const resource = computeOutputRel(displayPathOf(projectRoot, fileName), config, "gui-script");
131
+ const ids = index.byScriptResource.get(resource);
132
+ return ids === undefined ? [] : buildWholeLiteralCompletionEntries({ slot, ids, baseEntries });
133
+ }
134
+
135
+ // Also no caret guard, and the same forward output-path mapping — but scoped
136
+ // one step further than a node id: to the sprite component the slot's *sibling*
137
+ // literal addresses on the one game object that owns this script. Every
138
+ // unresolved link returns nothing rather than a project-wide guess, because a
139
+ // `sprite.play_flipbook` id the addressed atlas does not declare is a runtime
140
+ // crash.
141
+ function animationEntries(
142
+ slot: ClassifiedSlot,
143
+ cache: SceneIndexCache,
144
+ fileName: string,
145
+ baseEntries: readonly ts.CompletionEntry[],
146
+ ): ts.CompletionEntry[] {
147
+ const component = componentIdOfSameObjectAddress(slot.addressText ?? "");
148
+ if (component === undefined) {
149
+ return [];
150
+ }
151
+ const { projectRoot } = cache;
152
+ const index = cache.derived("sprite-animations", () =>
153
+ buildSpriteAnimationIndex({
154
+ scenes: cache.documents().documents,
155
+ assets: cache.documents(ANIMATION_ASSET_EXTENSIONS).documents,
156
+ }),
157
+ );
158
+ const config = readBuildConfigFromHost(cache.host, projectRoot);
159
+ const resource = computeOutputRel(displayPathOf(projectRoot, fileName), config, "script");
160
+ const ids = index.byScriptResource.get(resource)?.get(component);
161
+ return ids === undefined ? [] : buildWholeLiteralCompletionEntries({ slot, ids, baseEntries });
162
+ }
163
+
164
+ // The one kind needing no scene at all: the candidates are the project's own
165
+ // files of the extensions the slot's entry declares, so there is nothing to
166
+ // resolve ownership through and no document to parse. Same whole-literal span as
167
+ // a node id — a resource path is the entire text between the quotes.
168
+ function resourceEntries(
169
+ slot: ClassifiedSlot,
170
+ cache: SceneIndexCache,
171
+ baseEntries: readonly ts.CompletionEntry[],
172
+ ): ts.CompletionEntry[] {
173
+ const extensions = slot.resourceExtensions;
174
+ if (extensions === undefined || extensions.length === 0) {
175
+ return [];
176
+ }
177
+ const ids = cache.resourcePaths(extensions);
178
+ return buildWholeLiteralCompletionEntries({ slot, ids, baseEntries });
179
+ }
180
+
181
+ // The kind whose candidates come from a single project file rather than a walk
182
+ // of many: `game.project` is the whole universe, since a key it never writes
183
+ // answers the reader's default at runtime. Same whole-literal span as a resource
184
+ // path — a config key is the entire text between the quotes, including any `#`
185
+ // a `dependencies#0`-shaped key carries.
186
+ function configKeyEntries(
187
+ slot: ClassifiedSlot,
188
+ cache: SceneIndexCache,
189
+ baseEntries: readonly ts.CompletionEntry[],
190
+ ): ts.CompletionEntry[] {
191
+ const text = cache.documents(PROJECT_EXTENSIONS).documents.get(GAME_PROJECT_DOCUMENT);
192
+ if (text === undefined) {
193
+ return [];
194
+ }
195
+ const ids = cache.derived("config-keys", () => buildConfigKeyIndex(text));
196
+ return buildWholeLiteralCompletionEntries({ slot, ids, baseEntries });
197
+ }
198
+
199
+ // The kind with no scene, no owner and no sibling to resolve through: the slot
200
+ // resolver has already settled that this `hash("…")` is compared against an
201
+ // `on_input` action id, so every action the project declares is a candidate.
202
+ // Same whole-literal span as a config key.
203
+ function actionIdEntries(
204
+ slot: ClassifiedSlot,
205
+ cache: SceneIndexCache,
206
+ baseEntries: readonly ts.CompletionEntry[],
207
+ ): ts.CompletionEntry[] {
208
+ const ids = cache.derived("input-actions", () =>
209
+ buildInputActionIndex(cache.documents(INPUT_BINDING_EXTENSIONS).documents),
210
+ );
211
+ return buildWholeLiteralCompletionEntries({ slot, ids, baseEntries });
212
+ }
213
+
214
+ // The panel a claimed request is answered with. `documentation` rather than
215
+ // `displayParts` carries the paths because a host renders the former as the
216
+ // body of the panel, which is where a list of files reads as one.
217
+ function provenancePanel(
218
+ entryName: string,
219
+ declaredIn: readonly string[],
220
+ ): ts.CompletionEntryDetails {
221
+ return {
222
+ name: entryName,
223
+ kind: CONTRIBUTED_ENTRY_KIND,
224
+ kindModifiers: "",
225
+ displayParts: [{ kind: "stringLiteral", text: JSON.stringify(entryName) }],
226
+ documentation: [
227
+ {
228
+ kind: "text",
229
+ text: `Declared in ${declaredIn.join(", ")}`,
230
+ },
231
+ ],
232
+ source: [{ kind: "text", text: DEFOLD_COMPLETION_SOURCE }],
233
+ };
234
+ }
3
235
 
4
236
  // A TS language-service plugin is loaded by package name and its main is called
5
237
  // as this `init` factory; the editor passes its own `typescript` instance so the
@@ -21,6 +253,63 @@ export default function init(modules: { typescript: typeof import("typescript")
21
253
  }
22
254
  }
23
255
 
256
+ // One index for the life of this language service, shared by every
257
+ // completion surface. A host that cannot enumerate files gets none at all —
258
+ // the same early return the completion path already takes.
259
+ const serverHost = info.serverHost as SceneWatchHost | undefined;
260
+ const cache = serverHost?.readDirectory
261
+ ? createSceneIndexCache(serverHost, info.project.getCurrentDirectory())
262
+ : undefined;
263
+
264
+ // Set after the member-copy loop, which would otherwise leave `dispose`
265
+ // forwarding straight to the base — closing the project without ever closing
266
+ // a watcher, and silently, because the forwarder does exist.
267
+ proxy.dispose = () => {
268
+ cache?.dispose();
269
+ const disposeBase = (base as Partial<ts.LanguageService>).dispose;
270
+ disposeBase?.call(base);
271
+ };
272
+
273
+ // Set after the member-copy loop for the same reason as `dispose`. Claimed
274
+ // only when the host round-tripped our own discriminator, the slot still
275
+ // resolves, and the entry's universe really names a declaring file — every
276
+ // other request is handed to the base member exactly as received, because a
277
+ // fabricated panel is worse than the editor's own.
278
+ proxy.getCompletionEntryDetails = (
279
+ fileName: string,
280
+ position: number,
281
+ entryName: string,
282
+ formatOptions: ts.FormatCodeOptions | ts.FormatCodeSettings | undefined,
283
+ source: string | undefined,
284
+ preferences: ts.UserPreferences | undefined,
285
+ data: ts.CompletionEntryData | undefined,
286
+ ): ts.CompletionEntryDetails | undefined => {
287
+ const forward = () =>
288
+ base.getCompletionEntryDetails?.(
289
+ fileName,
290
+ position,
291
+ entryName,
292
+ formatOptions,
293
+ source,
294
+ preferences,
295
+ data,
296
+ );
297
+ if (source !== DEFOLD_COMPLETION_SOURCE || !cache) {
298
+ return forward();
299
+ }
300
+ const program = base.getProgram();
301
+ const table = loadUrlParameterTable();
302
+ if (!program || !table) {
303
+ return forward();
304
+ }
305
+ const slot = resolveClassifiedSlotAtPosition({ program, table, fileName, position });
306
+ if (!slot) {
307
+ return forward();
308
+ }
309
+ const declaredIn = resolveEntryProvenance({ slot, position, cache, fileName, entryName });
310
+ return declaredIn.length === 0 ? forward() : provenancePanel(entryName, declaredIn);
311
+ };
312
+
24
313
  proxy.getSemanticDiagnostics = (fileName: string): ts.Diagnostic[] => {
25
314
  const prior = base.getSemanticDiagnostics(fileName);
26
315
  const program = base.getProgram();
@@ -35,6 +324,65 @@ export default function init(modules: { typescript: typeof import("typescript")
35
324
  return [...prior, ...transpiler];
36
325
  };
37
326
 
327
+ // Strictly additive: every path that cannot produce a suggestion returns the
328
+ // base result untouched, because a plugin that swallows the editor's own
329
+ // completions is worse than one that offers nothing.
330
+ proxy.getCompletionsAtPosition = (
331
+ fileName: string,
332
+ position: number,
333
+ options: ts.GetCompletionsAtPositionOptions | undefined,
334
+ formattingSettings?: ts.FormatCodeSettings,
335
+ ): ts.WithMetadata<ts.CompletionInfo> | undefined => {
336
+ const prior = base.getCompletionsAtPosition(fileName, position, options, formattingSettings);
337
+ const program = base.getProgram();
338
+ const table = loadUrlParameterTable();
339
+ if (!program || !table) {
340
+ return prior;
341
+ }
342
+ const slot = resolveClassifiedSlotAtPosition({ program, table, fileName, position });
343
+ if (!slot) {
344
+ return prior;
345
+ }
346
+ if (!cache) {
347
+ return prior;
348
+ }
349
+
350
+ // Read through the index the host's own watchers invalidate, so a slot the
351
+ // author keeps typing in costs no walk after the first — and still reflects
352
+ // a scene edited, added or removed. On a host missing either watch
353
+ // facility the cache delegates straight through, walking per request the
354
+ // way this path always did.
355
+ const baseEntries = prior?.entries ?? [];
356
+ const entries = isAddressClass(slot.class)
357
+ ? [
358
+ ...componentEntries(slot, position, cache, baseEntries),
359
+ ...objectPathEntries(slot, position, cache, baseEntries),
360
+ ]
361
+ : slot.class === "gui-node"
362
+ ? nodeEntries(slot, cache, fileName, baseEntries)
363
+ : slot.class === "animation"
364
+ ? animationEntries(slot, cache, fileName, baseEntries)
365
+ : slot.class === "resource-path"
366
+ ? resourceEntries(slot, cache, baseEntries)
367
+ : slot.class === "config-key"
368
+ ? configKeyEntries(slot, cache, baseEntries)
369
+ : slot.class === "action-id"
370
+ ? actionIdEntries(slot, cache, baseEntries)
371
+ : [];
372
+ if (entries.length === 0) {
373
+ return prior;
374
+ }
375
+ if (!prior) {
376
+ return {
377
+ isGlobalCompletion: false,
378
+ isMemberCompletion: false,
379
+ isNewIdentifierLocation: false,
380
+ entries,
381
+ };
382
+ }
383
+ return { ...prior, entries: [...prior.entries, ...entries] };
384
+ };
385
+
38
386
  return proxy;
39
387
  }
40
388
 
@@ -0,0 +1,114 @@
1
+ import type { ClassifiedSlot } from "@defold-typescript/transpiler";
2
+ import type ts from "typescript";
3
+
4
+ // The editor's own `ts` is not in scope here and is not needed: this is a
5
+ // string-enum member whose value is its name, and the plugin builds plain data.
6
+ // Exported because the details panel restates the kind of the entry it answers
7
+ // for, and a second literal there could drift from what was offered.
8
+ export const CONTRIBUTED_ENTRY_KIND = "string" as ts.ScriptElementKind;
9
+
10
+ // `sortText` is compared as a string, and TypeScript's own priorities are
11
+ // "10"–"18" with `"z"`-prefixed deprecated variants, so "z18" is the greatest
12
+ // key it produces. A string that strictly extends the greatest key present
13
+ // sorts after it, and therefore after every key at or below it — which is how
14
+ // one derived key lands the contributed entries last however the base list is
15
+ // keyed, including keys no host of ours chose. The "zz" seed is the floor for
16
+ // an empty base, where there is no maximum to extend: above everything
17
+ // TypeScript emits, rather than below it the way a bare "z" would be. One
18
+ // shared key for every entry, so their alphabetical order survives a tie
19
+ // broken on the label.
20
+ function keyAboveAll(baseEntries: readonly ts.CompletionEntry[]): string {
21
+ return `${baseEntries.reduce(
22
+ (greatest, base) => (base.sortText > greatest ? base.sortText : greatest),
23
+ "zz",
24
+ )}0`;
25
+ }
26
+
27
+ // The one `ts.CompletionEntry` field the host round-trips verbatim as the
28
+ // details request's `source` argument, which is what lets a later request
29
+ // recognize an entry as ours rather than guess from its name. Namespaced rather
30
+ // than a bare word because a host is free to surface it beside the label.
31
+ export const DEFOLD_COMPLETION_SOURCE = "defold-typescript/scene";
32
+
33
+ function entriesFor(
34
+ ids: ReadonlySet<string>,
35
+ offeredName: (id: string) => string,
36
+ baseEntries: readonly ts.CompletionEntry[],
37
+ replacementSpan: { start: number; length: number },
38
+ ): ts.CompletionEntry[] {
39
+ // Where a project has generated the scene-derived union, the base service
40
+ // already offers the whole literal. Drop our duplicate rather than the base's:
41
+ // the plugin only ever adds.
42
+ const offered = new Set(baseEntries.map((base) => base.name));
43
+ const sortText = keyAboveAll(baseEntries);
44
+ return [...ids]
45
+ .sort()
46
+ .filter((id) => !offered.has(offeredName(id)))
47
+ .map((id) => ({
48
+ name: id,
49
+ kind: CONTRIBUTED_ENTRY_KIND,
50
+ kindModifiers: "",
51
+ sortText,
52
+ replacementSpan,
53
+ source: DEFOLD_COMPLETION_SOURCE,
54
+ }));
55
+ }
56
+
57
+ // One entry per component id, each replacing only the `#fragment` span inside
58
+ // the quotes. Nothing here touches the filesystem or the host, so the whole
59
+ // judgment is a function of the slot, the id universe, and what the base
60
+ // service already offers.
61
+ export function buildSceneCompletionEntries(input: {
62
+ slot: ClassifiedSlot;
63
+ ids: ReadonlySet<string>;
64
+ baseEntries: readonly ts.CompletionEntry[];
65
+ }): ts.CompletionEntry[] {
66
+ const { slot, ids, baseEntries } = input;
67
+ if (slot.fragmentStart === -1) return [];
68
+
69
+ const fragmentOffset = slot.fragmentStart - slot.textStart;
70
+ const prefix = slot.text.slice(0, fragmentOffset);
71
+ return entriesFor(ids, (id) => `${prefix}${id}`, baseEntries, {
72
+ start: slot.fragmentStart,
73
+ length: slot.text.length - fragmentOffset,
74
+ });
75
+ }
76
+
77
+ // One entry per game-object path, each replacing only the path half inside the
78
+ // quotes — the mirror image of `buildSceneCompletionEntries`, which keeps the
79
+ // path and edits the fragment. A literal carrying no `#` is all path, so its
80
+ // span is the whole text; `offeredName` re-attaches the surviving fragment so
81
+ // the dedup compares the literal an accepted entry would produce, not a bare id.
82
+ export function buildAddressPathCompletionEntries(input: {
83
+ slot: ClassifiedSlot;
84
+ paths: ReadonlySet<string>;
85
+ baseEntries: readonly ts.CompletionEntry[];
86
+ }): ts.CompletionEntry[] {
87
+ const { slot, paths, baseEntries } = input;
88
+ // `fragmentStart` is the first character *after* the `#`, so the separator
89
+ // itself belongs to the surviving suffix rather than to the replaced span.
90
+ const length =
91
+ slot.fragmentStart === -1 ? slot.text.length : slot.fragmentStart - slot.textStart - 1;
92
+ const suffix = slot.text.slice(length);
93
+ return entriesFor(paths, (path) => `${path}${suffix}`, baseEntries, {
94
+ start: slot.textStart,
95
+ length,
96
+ });
97
+ }
98
+
99
+ // One entry per id, for the kinds whose literal is the name outright — a `.gui`
100
+ // node id and an atlas animation id. Unlike an address, neither has a path half
101
+ // to keep: the whole inside-quotes text is the name, so accepting an entry
102
+ // replaces all of it and no caret guard is needed. One builder rather than two,
103
+ // because a second copy would be free to drift on span or dedup.
104
+ export function buildWholeLiteralCompletionEntries(input: {
105
+ slot: ClassifiedSlot;
106
+ ids: ReadonlySet<string>;
107
+ baseEntries: readonly ts.CompletionEntry[];
108
+ }): ts.CompletionEntry[] {
109
+ const { slot, ids, baseEntries } = input;
110
+ return entriesFor(ids, (id) => id, baseEntries, {
111
+ start: slot.textStart,
112
+ length: slot.text.length,
113
+ });
114
+ }
@@ -0,0 +1,126 @@
1
+ import { isDefignoredPath } from "@defold-typescript/transpiler";
2
+
3
+ // The narrow slice of `ts.server.ServerHost` this needs: the real host
4
+ // satisfies it structurally, and a test fake is two methods rather than a whole
5
+ // server. `readDirectory` is optional because a host may not provide it, and a
6
+ // plugin that throws takes the editor's completions down with it.
7
+ export interface SceneReadHost {
8
+ readDirectory?(path: string, extensions?: readonly string[]): string[];
9
+ readFile(path: string): string | undefined;
10
+ }
11
+
12
+ export const SCENE_EXTENSIONS = [".go", ".collection"];
13
+
14
+ // The extension sets more than one reader asks the host for. They live here
15
+ // rather than beside either caller because the index cache serves exactly the
16
+ // sets it is handed: a second definition that drifted would walk — and watch —
17
+ // a universe the other reader never invalidates.
18
+ export const GUI_EXTENSIONS = [".gui"];
19
+ export const INPUT_BINDING_EXTENSIONS = [".input_binding"];
20
+
21
+ // The documents an animation id is declared by: an atlas or tile source names
22
+ // the ids, and a `.sprite` is the hop a referenced component takes to reach one.
23
+ export const ANIMATION_ASSET_EXTENSIONS = [".atlas", ".tilesource", ".sprite"];
24
+
25
+ // The walk returns every `.project` the project holds, so the lookup is by
26
+ // display path — a vendored `*.project` declares keys this project's readers
27
+ // cannot resolve.
28
+ export const PROJECT_EXTENSIONS = [".project"];
29
+ export const GAME_PROJECT_DOCUMENT = "game.project";
30
+
31
+ // The project-relative name a file is keyed by — the same name the editor
32
+ // reports for a program file, so a scene's claim on a script can be looked up
33
+ // against the file being edited.
34
+ export function displayPathOf(projectRoot: string, filePath: string): string {
35
+ const path = filePath.replace(/\\/g, "/");
36
+ const root = projectRoot.replace(/\\/g, "/").replace(/\/+$/, "");
37
+ return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
38
+ }
39
+
40
+ // What both walks exclude is what Defold does not load as a project resource:
41
+ // `build/` output, plus the directories the scaffolded `.defignore` names
42
+ // (`isDefignoredPath`, root-anchored because a `.defignore` line is a
43
+ // root-relative path).
44
+ //
45
+ // Bob writes `_generated_*.go` copies of the project's scenes under `build/`;
46
+ // reading them would report every id twice, against files no author edits. The
47
+ // segment test runs on the project-relative path, so a project that itself
48
+ // lives under a directory named `build` is not excluded wholesale — a different
49
+ // rule from `.defignore`'s, because bob writes those copies below whatever
50
+ // depth it is pointed at.
51
+ function isBuildOutput(displayPath: string): boolean {
52
+ return displayPath.split("/").includes("build");
53
+ }
54
+
55
+ // The single rule, exported because the index cache filters watch events with
56
+ // it: a copy in the watcher could drift from what the walks exclude, and an
57
+ // event the walk would have ignored is exactly the event that must not throw the
58
+ // cache away — bob writes `_generated_*.go` throughout a build.
59
+ export function isExcludedProjectPath(displayPath: string): boolean {
60
+ return isBuildOutput(displayPath) || isDefignoredPath(displayPath);
61
+ }
62
+
63
+ // Read the project's own scene sources through the editor host — by default the
64
+ // `.go`/`.collection` set the component-id universe is built from, or whatever
65
+ // `extensions` names instead. The two universes stay disjoint: a `.gui` declares
66
+ // node ids, not component ids, so folding it into the default would feed gui
67
+ // text to `buildSceneComponentIndex`. Keys are project-relative display paths,
68
+ // matching what `buildSceneComponentIndex` reports in `incomplete`, and every
69
+ // file that could not be read is named in `unreadable` — a universe with a
70
+ // silent hole cannot be reasoned about. `paths` names the host paths actually
71
+ // read — what a watcher must be registered on, which cannot be reconstructed
72
+ // from a display path for a file outside the project root.
73
+ export function readSceneDocuments(
74
+ host: SceneReadHost,
75
+ projectRoot: string,
76
+ extensions: readonly string[] = SCENE_EXTENSIONS,
77
+ ): { documents: Map<string, string>; unreadable: string[]; paths: string[] } {
78
+ const documents = new Map<string, string>();
79
+ const unreadable: string[] = [];
80
+ const paths: string[] = [];
81
+
82
+ if (!host.readDirectory) {
83
+ unreadable.push("the editor host cannot enumerate project files, so no scene source was read");
84
+ return { documents, unreadable, paths };
85
+ }
86
+
87
+ for (const filePath of host.readDirectory(projectRoot, extensions)) {
88
+ const displayPath = displayPathOf(projectRoot, filePath);
89
+ if (isExcludedProjectPath(displayPath)) continue;
90
+ const text = host.readFile(filePath);
91
+ if (text === undefined) {
92
+ unreadable.push(`${displayPath}: could not be read`);
93
+ continue;
94
+ }
95
+ documents.set(displayPath, text);
96
+ paths.push(filePath);
97
+ }
98
+
99
+ return { documents, unreadable, paths };
100
+ }
101
+
102
+ // The project's own files of the given kinds, as the `/`-prefixed
103
+ // project-relative paths a Defold resource property names. Nothing is read: a
104
+ // resource path is the whole suggestion, so the walk is the entire universe.
105
+ //
106
+ // The extension filter is re-applied rather than trusted. `readSceneDocuments`
107
+ // can take the host at its word because a wrong document merely contributes no
108
+ // ids, but here a wrong file *is* the suggestion — a host that ignored its
109
+ // `extensions` argument would turn a `.font` slot into a project-wide file dump.
110
+ export function listProjectResourcePaths(
111
+ host: SceneReadHost,
112
+ projectRoot: string,
113
+ extensions: readonly string[],
114
+ ): Set<string> {
115
+ if (!host.readDirectory) return new Set();
116
+
117
+ const paths: string[] = [];
118
+ for (const filePath of host.readDirectory(projectRoot, extensions)) {
119
+ const displayPath = displayPathOf(projectRoot, filePath);
120
+ if (isExcludedProjectPath(displayPath)) continue;
121
+ if (!extensions.some((extension) => displayPath.endsWith(extension))) continue;
122
+ paths.push(`/${displayPath}`);
123
+ }
124
+
125
+ return new Set(paths.sort());
126
+ }