@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
|
@@ -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
|
+
}
|