@defold-typescript/tstl-plugin 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,168 @@
1
+ import type * as ts from "typescript";
2
+ import {
3
+ displayPathOf,
4
+ isExcludedProjectPath,
5
+ listProjectResourcePaths,
6
+ readSceneDocuments,
7
+ SCENE_EXTENSIONS,
8
+ type SceneReadHost,
9
+ } from "./scene-documents";
10
+
11
+ // `SceneReadHost` plus the two watch facilities `ts.System` declares as
12
+ // optional, typed off the editor's own callback types so the real
13
+ // `ts.server.ServerHost` satisfies this structurally the way `SceneReadHost`
14
+ // already does.
15
+ export interface SceneWatchHost extends SceneReadHost {
16
+ watchFile?(
17
+ path: string,
18
+ callback: ts.FileWatcherCallback,
19
+ pollingInterval?: number,
20
+ ): ts.FileWatcher;
21
+ watchDirectory?(
22
+ path: string,
23
+ callback: ts.DirectoryWatcherCallback,
24
+ recursive?: boolean,
25
+ ): ts.FileWatcher;
26
+ }
27
+
28
+ export interface SceneIndexCache {
29
+ readonly host: SceneWatchHost;
30
+ readonly projectRoot: string;
31
+ documents(extensions?: readonly string[]): ReturnType<typeof readSceneDocuments>;
32
+ resourcePaths(extensions: readonly string[]): Set<string>;
33
+ derived<T>(key: string, compute: () => T): T;
34
+ dispose(): void;
35
+ }
36
+
37
+ // Sorted, so two call sites naming the same extensions in a different order
38
+ // share a cache entry rather than walking twice for one universe.
39
+ function cacheKey(extensions: readonly string[]): string {
40
+ return [...extensions].sort().join("|");
41
+ }
42
+
43
+ // Holds the project walks the completion builders share, invalidated by the
44
+ // editor host's own watchers.
45
+ //
46
+ // Both facilities are required, and missing either disables caching entirely:
47
+ // `watchDirectory` reports structure changes, and whether a content edit reaches
48
+ // it is platform-dependent, so a directory watcher alone would leave the cache
49
+ // offering an id the author just renamed away. A stale id universe is worse than
50
+ // a slow one, so such a host falls back to the per-request walk it performs
51
+ // today.
52
+ export function createSceneIndexCache(host: SceneWatchHost, projectRoot: string): SceneIndexCache {
53
+ const watchDirectory = host.watchDirectory;
54
+ const watchFile = host.watchFile;
55
+ const enabled = typeof watchDirectory === "function" && typeof watchFile === "function";
56
+
57
+ const documentCache = new Map<string, ReturnType<typeof readSceneDocuments>>();
58
+ const resourceCache = new Map<string, Set<string>>();
59
+ const derivedCache = new Map<string, unknown>();
60
+ const fileWatchers = new Map<string, ts.FileWatcher>();
61
+ // The union of every extension set the cache has been asked for. Invalidating
62
+ // on any event at all would let an ordinary `.ts` save drop the scene index.
63
+ const servedExtensions = new Set<string>();
64
+
65
+ let directoryWatcher: ts.FileWatcher | undefined;
66
+ let disposed = false;
67
+
68
+ function closeFileWatchers(): void {
69
+ for (const watcher of fileWatchers.values()) watcher.close();
70
+ fileWatchers.clear();
71
+ }
72
+
73
+ // One pass builds every derived index from the same documents, so per-index
74
+ // dependency tracking would buy nothing: clear the lot.
75
+ function invalidate(): void {
76
+ documentCache.clear();
77
+ resourceCache.clear();
78
+ derivedCache.clear();
79
+ closeFileWatchers();
80
+ }
81
+
82
+ function affectsCache(hostPath: string): boolean {
83
+ const displayPath = displayPathOf(projectRoot, hostPath);
84
+ if (isExcludedProjectPath(displayPath)) return false;
85
+ for (const extension of servedExtensions) {
86
+ if (displayPath.endsWith(extension)) return true;
87
+ }
88
+ return false;
89
+ }
90
+
91
+ function onEvent(hostPath: string): void {
92
+ if (disposed) return;
93
+ if (affectsCache(hostPath)) invalidate();
94
+ }
95
+
96
+ function active(): boolean {
97
+ if (!enabled || disposed) return false;
98
+ if (!directoryWatcher && watchDirectory) {
99
+ directoryWatcher = watchDirectory(projectRoot, onEvent, true);
100
+ }
101
+ return true;
102
+ }
103
+
104
+ function serve(extensions: readonly string[]): void {
105
+ for (const extension of extensions) servedExtensions.add(extension);
106
+ }
107
+
108
+ function watchReadFiles(paths: readonly string[]): void {
109
+ if (!watchFile) return;
110
+ for (const path of paths) {
111
+ if (fileWatchers.has(path)) continue;
112
+ fileWatchers.set(
113
+ path,
114
+ watchFile(path, (fileName) => onEvent(fileName)),
115
+ );
116
+ }
117
+ }
118
+
119
+ return {
120
+ host,
121
+ projectRoot,
122
+
123
+ documents(extensions = SCENE_EXTENSIONS) {
124
+ if (!active()) return readSceneDocuments(host, projectRoot, extensions);
125
+ const key = cacheKey(extensions);
126
+ const cached = documentCache.get(key);
127
+ if (cached) return cached;
128
+ const result = readSceneDocuments(host, projectRoot, extensions);
129
+ documentCache.set(key, result);
130
+ serve(extensions);
131
+ watchReadFiles(result.paths);
132
+ return result;
133
+ },
134
+
135
+ // Nothing is read here, so no per-file watcher is registered: a resource
136
+ // path is the whole suggestion, and adding or removing one is a structure
137
+ // change the directory watcher already reports.
138
+ resourcePaths(extensions) {
139
+ if (!active()) return listProjectResourcePaths(host, projectRoot, extensions);
140
+ const key = cacheKey(extensions);
141
+ const cached = resourceCache.get(key);
142
+ if (cached) return cached;
143
+ const result = listProjectResourcePaths(host, projectRoot, extensions);
144
+ resourceCache.set(key, result);
145
+ serve(extensions);
146
+ return result;
147
+ },
148
+
149
+ derived<T>(key: string, compute: () => T): T {
150
+ if (!active()) return compute();
151
+ if (derivedCache.has(key)) return derivedCache.get(key) as T;
152
+ const value = compute();
153
+ derivedCache.set(key, value);
154
+ return value;
155
+ },
156
+
157
+ dispose() {
158
+ if (disposed) return;
159
+ disposed = true;
160
+ directoryWatcher?.close();
161
+ directoryWatcher = undefined;
162
+ closeFileWatchers();
163
+ documentCache.clear();
164
+ resourceCache.clear();
165
+ derivedCache.clear();
166
+ },
167
+ };
168
+ }
@@ -0,0 +1,215 @@
1
+ import {
2
+ buildConfigKeyIndex,
3
+ buildGuiNodeIndex,
4
+ buildInputActionIndex,
5
+ buildSceneComponentIndex,
6
+ buildSceneObjectPathIndex,
7
+ buildSpriteAnimationIndex,
8
+ type ClassifiedSlot,
9
+ componentIdOfSameObjectAddress,
10
+ computeOutputRel,
11
+ isAddressClass,
12
+ isFragmentCaret,
13
+ } from "@defold-typescript/transpiler";
14
+ import { readBuildConfigFromHost } from "./build-config";
15
+ import {
16
+ ANIMATION_ASSET_EXTENSIONS,
17
+ displayPathOf,
18
+ GAME_PROJECT_DOCUMENT,
19
+ GUI_EXTENSIONS,
20
+ INPUT_BINDING_EXTENSIONS,
21
+ PROJECT_EXTENSIONS,
22
+ } from "./scene-documents";
23
+ import type { SceneIndexCache } from "./scene-index-cache";
24
+
25
+ // Keys of their own under the same cache the completion indexes use, so a
26
+ // provenance map is thrown away with every index it was derived beside — and
27
+ // never collides with the id universe a completion reads under the bare name.
28
+ const COMPONENT_PROVENANCE = "provenance:component-ids";
29
+ const ACTION_PROVENANCE = "provenance:input-actions";
30
+ const GUI_PROVENANCE = "provenance:gui-nodes";
31
+ const PATH_PROVENANCE = "provenance:object-paths";
32
+ const ANIMATION_PROVENANCE = "provenance:sprite-animations";
33
+
34
+ type Provenance = ReadonlyMap<string, readonly string[]>;
35
+
36
+ // Re-running a shipped builder over a one-document narrowing of the same map is
37
+ // what answers "did this document declare that id" — exactly, and with no
38
+ // second reader of the scene text format. It is exact only where the documents
39
+ // are independent, which is what keeps composed universes out of this module.
40
+ function byDeclaringDocument(
41
+ documents: ReadonlyMap<string, string>,
42
+ idsOf: (narrowed: ReadonlyMap<string, string>) => Iterable<string>,
43
+ ): Provenance {
44
+ const declarers = new Map<string, string[]>();
45
+ for (const [displayPath, text] of documents) {
46
+ for (const id of idsOf(new Map([[displayPath, text]]))) {
47
+ const paths = declarers.get(id);
48
+ if (paths) {
49
+ paths.push(displayPath);
50
+ } else {
51
+ declarers.set(id, [displayPath]);
52
+ }
53
+ }
54
+ }
55
+ for (const paths of declarers.values()) paths.sort();
56
+ return declarers;
57
+ }
58
+
59
+ // A `.gui` claims one generated script, so a node id is only provenance for the
60
+ // file that script belongs to. Narrowing keeps two `.gui` files claiming the
61
+ // same script from cancelling each other out the way the whole-project index
62
+ // makes them: both really do declare the id.
63
+ function byClaimedScript(documents: ReadonlyMap<string, string>): ReadonlyMap<string, Provenance> {
64
+ const byResource = new Map<string, Map<string, string[]>>();
65
+ for (const [displayPath, text] of documents) {
66
+ const index = buildGuiNodeIndex(new Map([[displayPath, text]]));
67
+ for (const [resource, ids] of index.byScriptResource) {
68
+ let declarers = byResource.get(resource);
69
+ if (!declarers) {
70
+ declarers = new Map();
71
+ byResource.set(resource, declarers);
72
+ }
73
+ for (const id of ids) {
74
+ const paths = declarers.get(id);
75
+ if (paths) {
76
+ paths.push(displayPath);
77
+ } else {
78
+ declarers.set(id, [displayPath]);
79
+ }
80
+ }
81
+ }
82
+ }
83
+ for (const declarers of byResource.values()) {
84
+ for (const paths of declarers.values()) paths.sort();
85
+ }
86
+ return byResource;
87
+ }
88
+
89
+ function componentProvenance(cache: SceneIndexCache): Provenance {
90
+ return cache.derived(COMPONENT_PROVENANCE, () =>
91
+ byDeclaringDocument(
92
+ cache.documents().documents,
93
+ (narrowed) => buildSceneComponentIndex(narrowed).ids,
94
+ ),
95
+ );
96
+ }
97
+
98
+ function actionProvenance(cache: SceneIndexCache): Provenance {
99
+ return cache.derived(ACTION_PROVENANCE, () =>
100
+ byDeclaringDocument(cache.documents(INPUT_BINDING_EXTENSIONS).documents, (narrowed) =>
101
+ buildInputActionIndex(narrowed),
102
+ ),
103
+ );
104
+ }
105
+
106
+ function nodeProvenance(cache: SceneIndexCache, fileName: string): Provenance {
107
+ const byResource = cache.derived(GUI_PROVENANCE, () =>
108
+ byClaimedScript(cache.documents(GUI_EXTENSIONS).documents),
109
+ );
110
+ // An output path cannot say which include base produced it, so the file being
111
+ // edited is mapped forward through the build's own math — the same direction
112
+ // the completion path takes.
113
+ const config = readBuildConfigFromHost(cache.host, cache.projectRoot);
114
+ const resource = computeOutputRel(
115
+ displayPathOf(cache.projectRoot, fileName),
116
+ config,
117
+ "gui-script",
118
+ );
119
+ return byResource.get(resource) ?? new Map();
120
+ }
121
+
122
+ // A composed path is not a narrowing of anything: the builder records the
123
+ // document declaring each leaf as it walks, so the map is read straight off the
124
+ // index rather than recovered by re-running it per document.
125
+ function pathProvenance(cache: SceneIndexCache): Provenance {
126
+ return cache.derived(
127
+ PATH_PROVENANCE,
128
+ () => buildSceneObjectPathIndex(cache.documents().documents).declaredIn,
129
+ );
130
+ }
131
+
132
+ // Scoped exactly the way the animation completion is: to the sprite component
133
+ // the slot's sibling literal addresses on the one game object owning this
134
+ // script, resolved through the same helper so the two cannot disagree about
135
+ // which sprite is addressed. The tile set is reported only for an id it really
136
+ // declares — a panel naming a file that does not carry the name is the
137
+ // fabricated answer this surface exists to avoid.
138
+ function animationProvenance(input: {
139
+ slot: ClassifiedSlot;
140
+ cache: SceneIndexCache;
141
+ fileName: string;
142
+ entryName: string;
143
+ }): readonly string[] {
144
+ const { slot, cache, fileName, entryName } = input;
145
+ const component = componentIdOfSameObjectAddress(slot.addressText ?? "");
146
+ if (component === undefined) return [];
147
+ const index = cache.derived(ANIMATION_PROVENANCE, () =>
148
+ buildSpriteAnimationIndex({
149
+ scenes: cache.documents().documents,
150
+ assets: cache.documents(ANIMATION_ASSET_EXTENSIONS).documents,
151
+ }),
152
+ );
153
+ const config = readBuildConfigFromHost(cache.host, cache.projectRoot);
154
+ const resource = computeOutputRel(displayPathOf(cache.projectRoot, fileName), config, "script");
155
+ const tileSet = index.tileSetByScriptResource.get(resource)?.get(component);
156
+ if (tileSet === undefined) return [];
157
+ return index.byScriptResource.get(resource)?.get(component)?.has(entryName) ? [tileSet] : [];
158
+ }
159
+
160
+ // A config key is declared by the one file that answers a reader at runtime, and
161
+ // a resource path is declared by the file it names. Both are still checked
162
+ // against the project rather than asserted: a panel naming a document that does
163
+ // not carry the id is the fabricated answer this whole surface exists to avoid.
164
+ function constantProvenance(
165
+ slot: ClassifiedSlot,
166
+ cache: SceneIndexCache,
167
+ entryName: string,
168
+ ): readonly string[] {
169
+ if (slot.class === "config-key") {
170
+ const text = cache.documents(PROJECT_EXTENSIONS).documents.get(GAME_PROJECT_DOCUMENT);
171
+ if (text === undefined) return [];
172
+ const keys = cache.derived("config-keys", () => buildConfigKeyIndex(text));
173
+ return keys.has(entryName) ? [GAME_PROJECT_DOCUMENT] : [];
174
+ }
175
+ const extensions = slot.resourceExtensions;
176
+ if (extensions === undefined || extensions.length === 0) return [];
177
+ return cache.resourcePaths(extensions).has(entryName) ? [entryName.replace(/^\//, "")] : [];
178
+ }
179
+
180
+ // The display paths of the project files that declare `entryName` in the
181
+ // universe this slot draws from, sorted, empty when the project does not name
182
+ // one exactly. Every kind the completion path offers is answered here; silence
183
+ // means the file could not be named, not that the kind is uncovered, and it
184
+ // forwards the request to the editor's own panel.
185
+ export function resolveEntryProvenance(input: {
186
+ slot: ClassifiedSlot;
187
+ position: number;
188
+ cache: SceneIndexCache;
189
+ fileName: string;
190
+ entryName: string;
191
+ }): readonly string[] {
192
+ const { slot, position, cache, fileName, entryName } = input;
193
+ if (isAddressClass(slot.class)) {
194
+ // The caret decides which universe owns the request, on the same predicate
195
+ // the completion path offers from — so a name carried by both maps is still
196
+ // answered from the one the author is standing in.
197
+ if (!isFragmentCaret(slot, position)) {
198
+ return pathProvenance(cache).get(entryName) ?? [];
199
+ }
200
+ return componentProvenance(cache).get(entryName) ?? [];
201
+ }
202
+ if (slot.class === "gui-node") {
203
+ return nodeProvenance(cache, fileName).get(entryName) ?? [];
204
+ }
205
+ if (slot.class === "action-id") {
206
+ return actionProvenance(cache).get(entryName) ?? [];
207
+ }
208
+ if (slot.class === "animation") {
209
+ return animationProvenance({ slot, cache, fileName, entryName });
210
+ }
211
+ if (slot.class === "config-key" || slot.class === "resource-path") {
212
+ return constantProvenance(slot, cache, entryName);
213
+ }
214
+ return [];
215
+ }