@defold-typescript/tstl-plugin 0.24.0 → 0.26.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/build-config.d.ts +3 -0
- package/dist/index.js +524 -1
- package/dist/scene-completions.d.ts +19 -0
- package/dist/scene-documents.d.ts +18 -0
- package/dist/scene-index-cache.d.ts +15 -0
- package/dist/scene-provenance.d.ts +9 -0
- package/package.json +3 -2
- package/src/build-config.ts +24 -0
- package/src/index.ts +349 -1
- package/src/scene-completions.ts +114 -0
- package/src/scene-documents.ts +126 -0
- package/src/scene-index-cache.ts +168 -0
- package/src/scene-provenance.ts +215 -0
package/src/index.ts
CHANGED
|
@@ -1,5 +1,237 @@
|
|
|
1
|
-
import {
|
|
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
|
+
}
|