@defold-typescript/tstl-plugin 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,8 +5,14 @@ export type PluginFactory = (modules: {
5
5
  create(info: ts.server.PluginCreateInfo): ts.LanguageService;
6
6
  };
7
7
  export declare const SCENE_DOCUMENTS: Record<string, string>;
8
+ export declare const BOOTSTRAP_MAIN = "[bootstrap]\nmain_collection = /main.collectionc\n";
9
+ export declare function scriptObject(id: string, resource: string): string;
10
+ export declare function proxyObject(collection: string): string;
11
+ export declare const TWO_WORLD_DOCUMENTS: Record<string, string>;
8
12
  export interface ProxyHost {
9
13
  documents: Record<string, string>;
14
+ /** Listed by `readDirectory`, but answered `undefined` by `readFile`. */
15
+ unreadable: Set<string>;
10
16
  directoryReads: string[][];
11
17
  openWatchers: number;
12
18
  fireDirectory(hostPath: string): void;
@@ -20,7 +26,12 @@ export interface CompletionSetup {
20
26
  export declare function completionSetup(options: {
21
27
  source: string;
22
28
  base: ts.WithMetadata<ts.CompletionInfo> | undefined;
29
+ /** Further program files beside the edited one, so a check can be scoped to it. */
30
+ sources?: Record<string, string>;
23
31
  documents?: Record<string, string>;
32
+ /** Scene sources the host lists but cannot read, so a walk composes a real hole. */
33
+ unreadable?: readonly string[];
34
+ baseDiagnostics?: ts.Diagnostic[];
24
35
  serverHost?: boolean;
25
36
  watch?: boolean;
26
37
  baseDispose?: boolean;
package/dist/index.js CHANGED
@@ -3,22 +3,22 @@ import { readFileSync } from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import {
5
5
  ANIMATION_ASSET_EXTENSIONS as ANIMATION_ASSET_EXTENSIONS2,
6
+ buildComponentAnimationIndex as buildComponentAnimationIndex2,
6
7
  buildConfigKeyIndex as buildConfigKeyIndex2,
8
+ buildGuiFlipbookIndex as buildGuiFlipbookIndex2,
7
9
  buildGuiNodeIndex as buildGuiNodeIndex2,
8
10
  buildInputActionIndex as buildInputActionIndex2,
9
- buildSceneComponentIndex as buildSceneComponentIndex2,
10
- buildSceneObjectPathIndex as buildSceneObjectPathIndex2,
11
- buildSpriteAnimationIndex as buildSpriteAnimationIndex2,
11
+ buildSceneObjectPathIndex as buildSceneObjectPathIndex3,
12
12
  componentIdOfSameObjectAddress as componentIdOfSameObjectAddress2,
13
- computeOutputRel as computeOutputRel2,
14
- displayPathOf as displayPathOf3,
15
- GAME_PROJECT_DOCUMENT as GAME_PROJECT_DOCUMENT2,
16
- GUI_EXTENSIONS as GUI_EXTENSIONS2,
13
+ computeOutputRel as computeOutputRel3,
14
+ displayPathOf as displayPathOf4,
15
+ GAME_PROJECT_DOCUMENT as GAME_PROJECT_DOCUMENT3,
16
+ GUI_EXTENSIONS as GUI_EXTENSIONS3,
17
17
  getProgramDiagnostics,
18
18
  INPUT_BINDING_EXTENSIONS as INPUT_BINDING_EXTENSIONS2,
19
19
  isAddressClass as isAddressClass2,
20
20
  isFragmentCaret as isFragmentCaret2,
21
- PROJECT_EXTENSIONS as PROJECT_EXTENSIONS2,
21
+ PROJECT_EXTENSIONS as PROJECT_EXTENSIONS3,
22
22
  resolveClassifiedSlotAtPosition
23
23
  } from "@defold-typescript/transpiler";
24
24
 
@@ -87,11 +87,23 @@ function buildWholeLiteralCompletionEntries(input) {
87
87
  });
88
88
  }
89
89
 
90
+ // src/scene-diagnostics.ts
91
+ import {
92
+ checkCrossWorldAddresses,
93
+ checkUrlFragmentReachability
94
+ } from "@defold-typescript/transpiler";
95
+
90
96
  // src/scene-index-cache.ts
91
97
  import {
98
+ buildSceneCollectionRoles,
99
+ buildSceneComponentIndex,
100
+ COLLECTION_REFERENCE_EXTENSIONS,
92
101
  displayPathOf,
102
+ GAME_PROJECT_DOCUMENT,
93
103
  isExcludedProjectPath,
104
+ LIBRARY_DEPENDENCY_ROOT,
94
105
  listProjectResourcePaths,
106
+ PROJECT_EXTENSIONS,
95
107
  readSceneDocuments,
96
108
  SCENE_EXTENSIONS
97
109
  } from "@defold-typescript/transpiler";
@@ -122,6 +134,9 @@ function createSceneIndexCache(host, projectRoot) {
122
134
  }
123
135
  function affectsCache(hostPath) {
124
136
  const displayPath = displayPathOf(projectRoot, hostPath);
137
+ if (displayPath === LIBRARY_DEPENDENCY_ROOT || displayPath.startsWith(`${LIBRARY_DEPENDENCY_ROOT}/`)) {
138
+ return true;
139
+ }
125
140
  if (isExcludedProjectPath(displayPath))
126
141
  return false;
127
142
  for (const extension of servedExtensions) {
@@ -207,31 +222,145 @@ function createSceneIndexCache(host, projectRoot) {
207
222
  }
208
223
  };
209
224
  }
225
+ function sceneComponentIndexOf(cache) {
226
+ return cache.derived("component-index", () => buildSceneComponentIndex(cache.documents().documents));
227
+ }
228
+ function sceneCollectionRolesOf(cache) {
229
+ return cache.derived("collection-roles", () => buildSceneCollectionRoles({
230
+ documents: cache.documents().documents,
231
+ references: cache.documents(COLLECTION_REFERENCE_EXTENSIONS).documents,
232
+ gameProject: cache.documents(PROJECT_EXTENSIONS).documents.get(GAME_PROJECT_DOCUMENT)
233
+ }));
234
+ }
235
+
236
+ // src/scene-relative-addresses.ts
237
+ import {
238
+ buildSceneObjectPathIndex,
239
+ buildScriptNamingContexts,
240
+ computeOutputRel,
241
+ displayPathOf as displayPathOf2,
242
+ GUI_EXTENSIONS,
243
+ guiScriptResourcesOf,
244
+ relativeAddressesFrom
245
+ } from "@defold-typescript/transpiler";
246
+ var CONTEXTS = "relative:naming-contexts";
247
+ function projectContexts(cache) {
248
+ return cache.derived(CONTEXTS, () => {
249
+ const index = buildSceneObjectPathIndex(cache.documents().documents, sceneCollectionRolesOf(cache));
250
+ const guiScripts = guiScriptResourcesOf(cache.documents(GUI_EXTENSIONS).documents);
251
+ return { index, byScriptResource: buildScriptNamingContexts(index, guiScripts) };
252
+ });
253
+ }
254
+ function projectObjectIndex(cache) {
255
+ return projectContexts(cache).index;
256
+ }
257
+ function relativeUniverseFor(cache, fileName) {
258
+ return cache.derived(`relative-universe:${fileName}`, () => {
259
+ const { index, byScriptResource } = projectContexts(cache);
260
+ const config = readBuildConfigFromHost(cache.host, cache.projectRoot);
261
+ const rel = displayPathOf2(cache.projectRoot, fileName);
262
+ const byObject = new Map;
263
+ for (const kind of ["script", "gui-script"]) {
264
+ for (const context of byScriptResource.get(computeOutputRel(rel, config, kind)) ?? []) {
265
+ byObject.set(context.object, context);
266
+ }
267
+ }
268
+ const contexts = [...byObject.values()].sort((a, b) => a.object.localeCompare(b.object));
269
+ const paths = new Set;
270
+ const addresses = new Set;
271
+ const contextsByEntry = new Map;
272
+ for (const context of contexts) {
273
+ const relative = relativeAddressesFrom(index, context);
274
+ for (const entry of relative.paths) {
275
+ paths.add(entry);
276
+ contextsByEntry.set(entry, [...contextsByEntry.get(entry) ?? [], context.object]);
277
+ }
278
+ for (const entry of relative.addresses) {
279
+ addresses.add(entry);
280
+ contextsByEntry.set(entry, [...contextsByEntry.get(entry) ?? [], context.object]);
281
+ }
282
+ }
283
+ for (const objects of contextsByEntry.values())
284
+ objects.sort();
285
+ return { contexts, paths, addresses, contextsByEntry };
286
+ });
287
+ }
288
+ function worldsFor(cache, fileName) {
289
+ const sockets = new Set;
290
+ for (const context of relativeUniverseFor(cache, fileName).contexts) {
291
+ sockets.add(context.socket);
292
+ }
293
+ return [...sockets];
294
+ }
295
+ function offersBareWorld(universe) {
296
+ return universe.contexts.length === 0 || universe.contexts.some((context) => context.socket === undefined);
297
+ }
298
+
299
+ // src/scene-diagnostics.ts
300
+ var DEFOLD_DIAGNOSTIC_SOURCE = "defold-typescript";
301
+ var UNREACHABLE_FRAGMENT_CODE = 9000101;
302
+ var FOREIGN_SOCKET_CODE = 9000102;
303
+ function suggestion(typescript, file, finding, code) {
304
+ return {
305
+ file,
306
+ start: finding.start,
307
+ length: finding.length,
308
+ messageText: finding.message,
309
+ category: typescript.DiagnosticCategory.Suggestion,
310
+ source: DEFOLD_DIAGNOSTIC_SOURCE,
311
+ code
312
+ };
313
+ }
314
+ function sceneReachabilityDiagnostics(input) {
315
+ const { ts: typescript, program, table, cache, fileName } = input;
316
+ const sourceFile = program.getSourceFile(fileName);
317
+ if (!sourceFile) {
318
+ return [];
319
+ }
320
+ const index = sceneComponentIndexOf(cache);
321
+ const report = checkUrlFragmentReachability({
322
+ program,
323
+ table,
324
+ index: { ids: index.ids, incomplete: [...index.incomplete, ...cache.documents().unreadable] },
325
+ objects: projectObjectIndex(cache),
326
+ sourceFiles: [sourceFile]
327
+ });
328
+ const fragments = report.kind === "suppressed" ? [] : report.findings.map((finding) => suggestion(typescript, sourceFile, finding, UNREACHABLE_FRAGMENT_CODE));
329
+ const crossWorld = checkCrossWorldAddresses({
330
+ program,
331
+ table,
332
+ worldsOf: (name) => worldsFor(cache, name),
333
+ sourceFiles: [sourceFile]
334
+ }).map((finding) => suggestion(typescript, sourceFile, finding, FOREIGN_SOCKET_CODE));
335
+ return [...fragments, ...crossWorld];
336
+ }
210
337
 
211
338
  // src/scene-provenance.ts
212
339
  import {
213
340
  ANIMATION_ASSET_EXTENSIONS,
341
+ buildComponentAnimationIndex,
214
342
  buildConfigKeyIndex,
343
+ buildGuiFlipbookIndex,
215
344
  buildGuiNodeIndex,
216
345
  buildInputActionIndex,
217
- buildSceneComponentIndex,
218
- buildSceneObjectPathIndex,
219
- buildSpriteAnimationIndex,
346
+ buildSceneComponentIndex as buildSceneComponentIndex2,
347
+ buildSceneObjectPathIndex as buildSceneObjectPathIndex2,
220
348
  componentIdOfSameObjectAddress,
221
- computeOutputRel,
222
- displayPathOf as displayPathOf2,
223
- GAME_PROJECT_DOCUMENT,
224
- GUI_EXTENSIONS,
349
+ computeOutputRel as computeOutputRel2,
350
+ displayPathOf as displayPathOf3,
351
+ GAME_PROJECT_DOCUMENT as GAME_PROJECT_DOCUMENT2,
352
+ GUI_EXTENSIONS as GUI_EXTENSIONS2,
225
353
  INPUT_BINDING_EXTENSIONS,
226
354
  isAddressClass,
227
355
  isFragmentCaret,
228
- PROJECT_EXTENSIONS
356
+ PROJECT_EXTENSIONS as PROJECT_EXTENSIONS2
229
357
  } from "@defold-typescript/transpiler";
230
358
  var COMPONENT_PROVENANCE = "provenance:component-ids";
231
359
  var ACTION_PROVENANCE = "provenance:input-actions";
232
360
  var GUI_PROVENANCE = "provenance:gui-nodes";
233
361
  var PATH_PROVENANCE = "provenance:object-paths";
234
- var ANIMATION_PROVENANCE = "provenance:sprite-animations";
362
+ var ANIMATION_PROVENANCE = "provenance:component-animations";
363
+ var GUI_ANIMATION_PROVENANCE = "provenance:gui-flipbook";
235
364
  function byDeclaringDocument(documents, idsOf) {
236
365
  const declarers = new Map;
237
366
  for (const [displayPath, text] of documents) {
@@ -275,49 +404,73 @@ function byClaimedScript(documents) {
275
404
  return byResource;
276
405
  }
277
406
  function componentProvenance(cache) {
278
- return cache.derived(COMPONENT_PROVENANCE, () => byDeclaringDocument(cache.documents().documents, (narrowed) => buildSceneComponentIndex(narrowed).ids));
407
+ return cache.derived(COMPONENT_PROVENANCE, () => byDeclaringDocument(cache.documents().documents, (narrowed) => buildSceneComponentIndex2(narrowed).ids));
279
408
  }
280
409
  function actionProvenance(cache) {
281
410
  return cache.derived(ACTION_PROVENANCE, () => byDeclaringDocument(cache.documents(INPUT_BINDING_EXTENSIONS).documents, (narrowed) => buildInputActionIndex(narrowed)));
282
411
  }
283
412
  function nodeProvenance(cache, fileName) {
284
- const byResource = cache.derived(GUI_PROVENANCE, () => byClaimedScript(cache.documents(GUI_EXTENSIONS).documents));
413
+ const byResource = cache.derived(GUI_PROVENANCE, () => byClaimedScript(cache.documents(GUI_EXTENSIONS2).documents));
285
414
  const config = readBuildConfigFromHost(cache.host, cache.projectRoot);
286
- const resource = computeOutputRel(displayPathOf2(cache.projectRoot, fileName), config, "gui-script");
415
+ const resource = computeOutputRel2(displayPathOf3(cache.projectRoot, fileName), config, "gui-script");
287
416
  return byResource.get(resource) ?? new Map;
288
417
  }
289
418
  function pathProvenance(cache) {
290
- return cache.derived(PATH_PROVENANCE, () => buildSceneObjectPathIndex(cache.documents().documents).declaredIn);
419
+ return cache.derived(PATH_PROVENANCE, () => buildSceneObjectPathIndex2(cache.documents().documents, sceneCollectionRolesOf(cache)).declaredIn);
420
+ }
421
+ function guiAnimationProvenance(input) {
422
+ const { cache, fileName, entryName } = input;
423
+ const index = cache.derived(GUI_ANIMATION_PROVENANCE, () => buildGuiFlipbookIndex({
424
+ scenes: cache.documents(GUI_EXTENSIONS2).documents,
425
+ assets: cache.documents(ANIMATION_ASSET_EXTENSIONS).documents
426
+ }));
427
+ const config = readBuildConfigFromHost(cache.host, cache.projectRoot);
428
+ const resource = computeOutputRel2(displayPathOf3(cache.projectRoot, fileName), config, "gui-script");
429
+ const declarers = index.byScriptResource.get(resource)?.get(entryName);
430
+ return declarers === undefined ? [] : [...declarers].sort();
291
431
  }
292
432
  function animationProvenance(input) {
293
433
  const { slot, cache, fileName, entryName } = input;
434
+ if (slot.nodeParameter !== undefined) {
435
+ return guiAnimationProvenance({ cache, fileName, entryName });
436
+ }
294
437
  const component = componentIdOfSameObjectAddress(slot.addressText ?? "");
295
438
  if (component === undefined)
296
439
  return [];
297
- const index = cache.derived(ANIMATION_PROVENANCE, () => buildSpriteAnimationIndex({
440
+ const index = cache.derived(ANIMATION_PROVENANCE, () => buildComponentAnimationIndex({
298
441
  scenes: cache.documents().documents,
299
442
  assets: cache.documents(ANIMATION_ASSET_EXTENSIONS).documents
300
443
  }));
301
444
  const config = readBuildConfigFromHost(cache.host, cache.projectRoot);
302
- const resource = computeOutputRel(displayPathOf2(cache.projectRoot, fileName), config, "script");
303
- const tileSet = index.tileSetByScriptResource.get(resource)?.get(component);
304
- if (tileSet === undefined)
305
- return [];
306
- return index.byScriptResource.get(resource)?.get(component)?.has(entryName) ? [tileSet] : [];
445
+ const resource = computeOutputRel2(displayPathOf3(cache.projectRoot, fileName), config, "script");
446
+ const source = index.sourceByScriptResource.get(resource)?.get(component)?.get(entryName);
447
+ return source === undefined ? [] : [source];
307
448
  }
308
449
  function constantProvenance(slot, cache, entryName) {
309
450
  if (slot.class === "config-key") {
310
- const text = cache.documents(PROJECT_EXTENSIONS).documents.get(GAME_PROJECT_DOCUMENT);
451
+ const text = cache.documents(PROJECT_EXTENSIONS2).documents.get(GAME_PROJECT_DOCUMENT2);
311
452
  if (text === undefined)
312
453
  return [];
313
454
  const keys = cache.derived("config-keys", () => buildConfigKeyIndex(text));
314
- return keys.has(entryName) ? [GAME_PROJECT_DOCUMENT] : [];
455
+ return keys.has(entryName) ? [GAME_PROJECT_DOCUMENT2] : [];
315
456
  }
316
457
  const extensions = slot.resourceExtensions;
317
458
  if (extensions === undefined || extensions.length === 0)
318
459
  return [];
319
460
  return cache.resourcePaths(extensions).has(entryName) ? [entryName.replace(/^\//, "")] : [];
320
461
  }
462
+ function resolveRelativeEntryProvenance(input) {
463
+ const { slot, position, cache, fileName, entryName } = input;
464
+ if (!isAddressClass(slot.class) || isFragmentCaret(slot, position))
465
+ return;
466
+ const universe = relativeUniverseFor(cache, fileName);
467
+ const resolvesFrom = universe.contextsByEntry.get(entryName);
468
+ if (resolvesFrom === undefined)
469
+ return;
470
+ const others = universe.contexts.map((context) => context.object).filter((object) => !resolvesFrom.includes(object));
471
+ const relative = `Relative to ${resolvesFrom.join(", ")}`;
472
+ return others.length === 0 ? relative : `${relative}; not from ${others.join(", ")}`;
473
+ }
321
474
  function resolveEntryProvenance(input) {
322
475
  const { slot, position, cache, fileName, entryName } = input;
323
476
  if (isAddressClass(slot.class)) {
@@ -361,40 +514,60 @@ function componentEntries(slot, position, cache, baseEntries) {
361
514
  }
362
515
  return buildSceneCompletionEntries({
363
516
  slot,
364
- ids: cache.derived("component-ids", () => buildSceneComponentIndex2(cache.documents().documents).ids),
517
+ ids: sceneComponentIndexOf(cache).ids,
365
518
  baseEntries
366
519
  });
367
520
  }
368
- function objectPathEntries(slot, position, cache, baseEntries) {
521
+ function objectPathEntries(slot, position, cache, fileName, baseEntries) {
369
522
  if (isFragmentCaret2(slot, position)) {
370
523
  return [];
371
524
  }
372
- return buildAddressPathCompletionEntries({
373
- slot,
374
- paths: cache.derived("object-paths", () => buildSceneObjectPathIndex2(cache.documents().documents).paths),
375
- baseEntries
376
- });
525
+ const universe = relativeUniverseFor(cache, fileName);
526
+ const bare = offersBareWorld(universe);
527
+ const absolute = cache.derived("object-paths", () => buildSceneObjectPathIndex3(cache.documents().documents, sceneCollectionRolesOf(cache)).paths);
528
+ const paths = new Set;
529
+ for (const key of absolute) {
530
+ if (bare || !key.startsWith("/"))
531
+ paths.add(key);
532
+ }
533
+ for (const key of universe.paths)
534
+ paths.add(key);
535
+ const entries = buildAddressPathCompletionEntries({ slot, paths, baseEntries });
536
+ if (slot.fragmentStart === -1) {
537
+ entries.push(...buildWholeLiteralCompletionEntries({ slot, ids: universe.addresses, baseEntries }));
538
+ }
539
+ return entries;
377
540
  }
378
541
  function nodeEntries(slot, cache, fileName, baseEntries) {
379
542
  const { projectRoot } = cache;
380
- const index = cache.derived("gui-nodes", () => buildGuiNodeIndex2(cache.documents(GUI_EXTENSIONS2).documents));
543
+ const index = cache.derived("gui-nodes", () => buildGuiNodeIndex2(cache.documents(GUI_EXTENSIONS3).documents));
381
544
  const config = readBuildConfigFromHost(cache.host, projectRoot);
382
- const resource = computeOutputRel2(displayPathOf3(projectRoot, fileName), config, "gui-script");
545
+ const resource = computeOutputRel3(displayPathOf4(projectRoot, fileName), config, "gui-script");
383
546
  const ids = index.byScriptResource.get(resource);
384
547
  return ids === undefined ? [] : buildWholeLiteralCompletionEntries({ slot, ids, baseEntries });
385
548
  }
386
549
  function animationEntries(slot, cache, fileName, baseEntries) {
550
+ const { projectRoot } = cache;
551
+ if (slot.nodeParameter !== undefined) {
552
+ const index2 = cache.derived("gui-flipbook", () => buildGuiFlipbookIndex2({
553
+ scenes: cache.documents(GUI_EXTENSIONS3).documents,
554
+ assets: cache.documents(ANIMATION_ASSET_EXTENSIONS2).documents
555
+ }));
556
+ const config2 = readBuildConfigFromHost(cache.host, projectRoot);
557
+ const resource2 = computeOutputRel3(displayPathOf4(projectRoot, fileName), config2, "gui-script");
558
+ const declared = index2.byScriptResource.get(resource2);
559
+ return declared === undefined ? [] : buildWholeLiteralCompletionEntries({ slot, ids: new Set(declared.keys()), baseEntries });
560
+ }
387
561
  const component = componentIdOfSameObjectAddress2(slot.addressText ?? "");
388
562
  if (component === undefined) {
389
563
  return [];
390
564
  }
391
- const { projectRoot } = cache;
392
- const index = cache.derived("sprite-animations", () => buildSpriteAnimationIndex2({
565
+ const index = cache.derived("component-animations", () => buildComponentAnimationIndex2({
393
566
  scenes: cache.documents().documents,
394
567
  assets: cache.documents(ANIMATION_ASSET_EXTENSIONS2).documents
395
568
  }));
396
569
  const config = readBuildConfigFromHost(cache.host, projectRoot);
397
- const resource = computeOutputRel2(displayPathOf3(projectRoot, fileName), config, "script");
570
+ const resource = computeOutputRel3(displayPathOf4(projectRoot, fileName), config, "script");
398
571
  const ids = index.byScriptResource.get(resource)?.get(component);
399
572
  return ids === undefined ? [] : buildWholeLiteralCompletionEntries({ slot, ids, baseEntries });
400
573
  }
@@ -407,7 +580,7 @@ function resourceEntries(slot, cache, baseEntries) {
407
580
  return buildWholeLiteralCompletionEntries({ slot, ids, baseEntries });
408
581
  }
409
582
  function configKeyEntries(slot, cache, baseEntries) {
410
- const text = cache.documents(PROJECT_EXTENSIONS2).documents.get(GAME_PROJECT_DOCUMENT2);
583
+ const text = cache.documents(PROJECT_EXTENSIONS3).documents.get(GAME_PROJECT_DOCUMENT3);
411
584
  if (text === undefined) {
412
585
  return [];
413
586
  }
@@ -418,18 +591,13 @@ function actionIdEntries(slot, cache, baseEntries) {
418
591
  const ids = cache.derived("input-actions", () => buildInputActionIndex2(cache.documents(INPUT_BINDING_EXTENSIONS2).documents));
419
592
  return buildWholeLiteralCompletionEntries({ slot, ids, baseEntries });
420
593
  }
421
- function provenancePanel(entryName, declaredIn) {
594
+ function provenancePanel(entryName, documentation) {
422
595
  return {
423
596
  name: entryName,
424
597
  kind: CONTRIBUTED_ENTRY_KIND,
425
598
  kindModifiers: "",
426
599
  displayParts: [{ kind: "stringLiteral", text: JSON.stringify(entryName) }],
427
- documentation: [
428
- {
429
- kind: "text",
430
- text: `Declared in ${declaredIn.join(", ")}`
431
- }
432
- ],
600
+ documentation: [{ kind: "text", text: documentation }],
433
601
  source: [{ kind: "text", text: DEFOLD_COMPLETION_SOURCE }]
434
602
  };
435
603
  }
@@ -467,8 +635,18 @@ function init(modules) {
467
635
  if (!slot) {
468
636
  return forward();
469
637
  }
638
+ const relative = resolveRelativeEntryProvenance({
639
+ slot,
640
+ position,
641
+ cache,
642
+ fileName,
643
+ entryName
644
+ });
645
+ if (relative !== undefined) {
646
+ return provenancePanel(entryName, relative);
647
+ }
470
648
  const declaredIn = resolveEntryProvenance({ slot, position, cache, fileName, entryName });
471
- return declaredIn.length === 0 ? forward() : provenancePanel(entryName, declaredIn);
649
+ return declaredIn.length === 0 ? forward() : provenancePanel(entryName, `Declared in ${declaredIn.join(", ")}`);
472
650
  };
473
651
  proxy.getSemanticDiagnostics = (fileName) => {
474
652
  const prior = base.getSemanticDiagnostics(fileName);
@@ -477,7 +655,9 @@ function init(modules) {
477
655
  return prior;
478
656
  }
479
657
  const transpiler = getProgramDiagnostics(program, program.getSourceFile(fileName)).map((diagnostic) => ({ ...diagnostic, category: ts.DiagnosticCategory.Suggestion }));
480
- return [...prior, ...transpiler];
658
+ const table = loadUrlParameterTable();
659
+ const scene = cache && table ? sceneReachabilityDiagnostics({ ts, program, table, cache, fileName }) : [];
660
+ return [...prior, ...transpiler, ...scene];
481
661
  };
482
662
  proxy.getCompletionsAtPosition = (fileName, position, options, formattingSettings) => {
483
663
  const prior = base.getCompletionsAtPosition(fileName, position, options, formattingSettings);
@@ -496,7 +676,7 @@ function init(modules) {
496
676
  const baseEntries = prior?.entries ?? [];
497
677
  const entries = isAddressClass2(slot.class) ? [
498
678
  ...componentEntries(slot, position, cache, baseEntries),
499
- ...objectPathEntries(slot, position, cache, baseEntries)
679
+ ...objectPathEntries(slot, position, cache, fileName, baseEntries)
500
680
  ] : slot.class === "gui-node" ? nodeEntries(slot, cache, fileName, baseEntries) : slot.class === "animation" ? animationEntries(slot, cache, fileName, baseEntries) : slot.class === "resource-path" ? resourceEntries(slot, cache, baseEntries) : slot.class === "config-key" ? configKeyEntries(slot, cache, baseEntries) : slot.class === "action-id" ? actionIdEntries(slot, cache, baseEntries) : [];
501
681
  if (entries.length === 0) {
502
682
  return prior;
@@ -0,0 +1,29 @@
1
+ import type { UrlParameterTable } from "@defold-typescript/types";
2
+ import type * as ts from "typescript";
3
+ import { type SceneIndexCache } from "./scene-index-cache";
4
+ /** The `source` an editor renders beside the message, and `--json` never sees. */
5
+ export declare const DEFOLD_DIAGNOSTIC_SOURCE = "defold-typescript";
6
+ /**
7
+ * The address findings the editor surfaces on the file being edited, as
8
+ * advisory suggestions.
9
+ *
10
+ * Every path that cannot produce one answers with an empty array. A suppressed
11
+ * reachability report is the important case: there is no file span on which a
12
+ * "the check did not run" notice could honestly sit, and a speculative squiggle
13
+ * is worse than none — the build says so in prose instead, where a whole-project
14
+ * line has somewhere to go.
15
+ *
16
+ * The *whole* hole is one index, composed the way `sceneIndexForBuild` composes
17
+ * it: what the parse could not read plus what the walk could not reach. The
18
+ * completion path deliberately reads a partial universe — a suggestion claims
19
+ * nothing about what is absent — but a finding does, so a component declared
20
+ * only by an unresolved dependency suppresses the check rather than reporting
21
+ * the address as unreachable.
22
+ */
23
+ export declare function sceneReachabilityDiagnostics(input: {
24
+ ts: typeof import("typescript");
25
+ program: ts.Program;
26
+ table: UrlParameterTable;
27
+ cache: SceneIndexCache;
28
+ fileName: string;
29
+ }): ts.Diagnostic[];
@@ -1,4 +1,4 @@
1
- import { readSceneDocuments, type SceneReadHost } from "@defold-typescript/transpiler";
1
+ import { readSceneDocuments, type SceneCollectionRoles, type SceneComponentIndex, type SceneReadHost } from "@defold-typescript/transpiler";
2
2
  import type * as ts from "typescript";
3
3
  export interface SceneWatchHost extends SceneReadHost {
4
4
  watchFile?(path: string, callback: ts.FileWatcherCallback, pollingInterval?: number): ts.FileWatcher;
@@ -13,3 +13,5 @@ export interface SceneIndexCache {
13
13
  dispose(): void;
14
14
  }
15
15
  export declare function createSceneIndexCache(host: SceneWatchHost, projectRoot: string): SceneIndexCache;
16
+ export declare function sceneComponentIndexOf(cache: SceneIndexCache): SceneComponentIndex;
17
+ export declare function sceneCollectionRolesOf(cache: SceneIndexCache): SceneCollectionRoles;
@@ -1,5 +1,24 @@
1
1
  import { type ClassifiedSlot } from "@defold-typescript/transpiler";
2
- import type { SceneIndexCache } from "./scene-index-cache";
2
+ import { type SceneIndexCache } from "./scene-index-cache";
3
+ /**
4
+ * The sentence a relative entry's panel carries, or `undefined` where the entry
5
+ * is not one — a fragment caret, a slot that names no address, or a name this
6
+ * file's relative universe does not offer.
7
+ *
8
+ * A relative address is valid from the object hosting the script and nowhere
9
+ * else, so the objects it resolves from *are* its provenance: naming a declaring
10
+ * file instead would answer a question the author is not asking. Where the entry
11
+ * resolves from only some of the contexts this script runs in, the others are
12
+ * named too — a sometimes-valid entry that read as an always-valid one is the
13
+ * wrong-suggestion class this panel exists to close.
14
+ */
15
+ export declare function resolveRelativeEntryProvenance(input: {
16
+ slot: ClassifiedSlot;
17
+ position: number;
18
+ cache: SceneIndexCache;
19
+ fileName: string;
20
+ entryName: string;
21
+ }): string | undefined;
3
22
  export declare function resolveEntryProvenance(input: {
4
23
  slot: ClassifiedSlot;
5
24
  position: number;
@@ -0,0 +1,55 @@
1
+ import { type NamingContext, type SceneObjectPathIndex } from "@defold-typescript/transpiler";
2
+ import { type SceneIndexCache } from "./scene-index-cache";
3
+ export interface RelativeUniverse {
4
+ /** Every object hosting this file's generated script, sorted by object path. */
5
+ readonly contexts: readonly NamingContext[];
6
+ /** Relative game-object paths, valid from at least one context. */
7
+ readonly paths: ReadonlySet<string>;
8
+ /** Those paths joined to the components their object owns. */
9
+ readonly addresses: ReadonlySet<string>;
10
+ /** Entry name -> the sorted object paths it resolves from. */
11
+ readonly contextsByEntry: ReadonlyMap<string, readonly string[]>;
12
+ }
13
+ /**
14
+ * Every game-object path the project declares and the components each owns —
15
+ * the index a check scopes an absolute `#fragment` address against.
16
+ *
17
+ * Read off the walk `projectContexts` already caches rather than built again:
18
+ * the naming contexts are derived *from* this index, so the reachability path
19
+ * costs no second `.go`/`.collection` parse.
20
+ */
21
+ export declare function projectObjectIndex(cache: SceneIndexCache): SceneObjectPathIndex;
22
+ /**
23
+ * The relative universe the file at `fileName` may write from, keyed per file
24
+ * under the same cache the absolute indexes live in.
25
+ *
26
+ * The file is mapped *forward* to the script resource a scene would name — the
27
+ * same direction `nodeEntries` takes, because an output path cannot say which
28
+ * include base produced it. Both script kinds are resolved and their contexts
29
+ * unioned: a `.ts` is named as a `.script` by a game object and as a
30
+ * `.gui_script` by a `.gui`, and nothing in the file itself says which.
31
+ */
32
+ export declare function relativeUniverseFor(cache: SceneIndexCache, fileName: string): RelativeUniverse;
33
+ /**
34
+ * The worlds the file's script runs in, as `checkCrossWorldAddresses` asks for
35
+ * them: the de-duplicated sockets of every naming context hosting it, with the
36
+ * bootstrap world carried as `undefined`.
37
+ *
38
+ * The same answer `scriptWorldsForBuild` gives the CLI, reached through the
39
+ * chain that resolver was modelled on — so an address the build calls foreign is
40
+ * foreign in the editor too. An empty answer is the honest unknown: a script no
41
+ * scene hosts runs in no world this project can prove, and the check reports
42
+ * nothing for it.
43
+ */
44
+ export declare function worldsFor(cache: SceneIndexCache, fileName: string): readonly (string | undefined)[];
45
+ /**
46
+ * Whether a bare `/path` can resolve from where this file runs. A bare address
47
+ * continues the caller's own world, so one offered to a script running behind a
48
+ * proxy names an object that world cannot reach.
49
+ *
50
+ * A file with no naming context offers them: nothing is known about where it
51
+ * runs, and withholding every bare path would delete the completions the
52
+ * absolute universe has always provided. Socket-qualified keys are never
53
+ * touched — whether a slot accepts a foreign socket is a different question.
54
+ */
55
+ export declare function offersBareWorld(universe: RelativeUniverse): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defold-typescript/tstl-plugin",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "TypeScript language-service plugin surfacing Defold transpiler diagnostics live in the editor.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,8 +37,8 @@
37
37
  "test": "bun test"
38
38
  },
39
39
  "dependencies": {
40
- "@defold-typescript/transpiler": "0.30.0",
41
- "@defold-typescript/types": "0.30.0"
40
+ "@defold-typescript/transpiler": "0.31.0",
41
+ "@defold-typescript/types": "0.31.0"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "typescript": ">=5.0.0 <7"
package/src/index.ts CHANGED
@@ -2,12 +2,12 @@ import { readFileSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import {
4
4
  ANIMATION_ASSET_EXTENSIONS,
5
+ buildComponentAnimationIndex,
5
6
  buildConfigKeyIndex,
7
+ buildGuiFlipbookIndex,
6
8
  buildGuiNodeIndex,
7
9
  buildInputActionIndex,
8
- buildSceneComponentIndex,
9
10
  buildSceneObjectPathIndex,
10
- buildSpriteAnimationIndex,
11
11
  type ClassifiedSlot,
12
12
  componentIdOfSameObjectAddress,
13
13
  computeOutputRel,
@@ -31,12 +31,16 @@ import {
31
31
  CONTRIBUTED_ENTRY_KIND,
32
32
  DEFOLD_COMPLETION_SOURCE,
33
33
  } from "./scene-completions";
34
+ import { sceneReachabilityDiagnostics } from "./scene-diagnostics";
34
35
  import {
35
36
  createSceneIndexCache,
36
37
  type SceneIndexCache,
37
38
  type SceneWatchHost,
39
+ sceneCollectionRolesOf,
40
+ sceneComponentIndexOf,
38
41
  } from "./scene-index-cache";
39
- import { resolveEntryProvenance } from "./scene-provenance";
42
+ import { resolveEntryProvenance, resolveRelativeEntryProvenance } from "./scene-provenance";
43
+ import { offersBareWorld, relativeUniverseFor } from "./scene-relative-addresses";
40
44
 
41
45
  const requireFromHere = createRequire(import.meta.url);
42
46
 
@@ -76,35 +80,50 @@ function componentEntries(
76
80
  // suggestion claims nothing about what is absent.
77
81
  return buildSceneCompletionEntries({
78
82
  slot,
79
- ids: cache.derived(
80
- "component-ids",
81
- () => buildSceneComponentIndex(cache.documents().documents).ids,
82
- ),
83
+ ids: sceneComponentIndexOf(cache).ids,
83
84
  baseEntries,
84
85
  });
85
86
  }
86
87
 
87
88
  // The exact complement of `componentEntries`' guard, so precisely one of the two
88
- // universes answers any caret in an address. Project-wide like the component
89
- // universe, and for the same reason: what a path resolves to at runtime depends
90
- // on the collection that was loaded, which the file being edited does not say.
89
+ // universes answers any caret in an address. The absolute half is project-wide
90
+ // like the component universe, and for the same reason: what a path resolves to
91
+ // at runtime depends on the collection that was loaded, which the file being
92
+ // edited does not say. The relative half cannot be — a relative address
93
+ // continues the collection path of the object hosting *this* script — so it is
94
+ // read per file and merged in, and the caller's own world decides whether a bare
95
+ // absolute path can resolve at all.
91
96
  function objectPathEntries(
92
97
  slot: ClassifiedSlot,
93
98
  position: number,
94
99
  cache: SceneIndexCache,
100
+ fileName: string,
95
101
  baseEntries: readonly ts.CompletionEntry[],
96
102
  ): ts.CompletionEntry[] {
97
103
  if (isFragmentCaret(slot, position)) {
98
104
  return [];
99
105
  }
100
- return buildAddressPathCompletionEntries({
101
- slot,
102
- paths: cache.derived(
103
- "object-paths",
104
- () => buildSceneObjectPathIndex(cache.documents().documents).paths,
105
- ),
106
- baseEntries,
107
- });
106
+ const universe = relativeUniverseFor(cache, fileName);
107
+ const bare = offersBareWorld(universe);
108
+ const absolute = cache.derived(
109
+ "object-paths",
110
+ () =>
111
+ buildSceneObjectPathIndex(cache.documents().documents, sceneCollectionRolesOf(cache)).paths,
112
+ );
113
+ const paths = new Set<string>();
114
+ for (const key of absolute) {
115
+ if (bare || !key.startsWith("/")) paths.add(key);
116
+ }
117
+ for (const key of universe.paths) paths.add(key);
118
+ const entries = buildAddressPathCompletionEntries({ slot, paths, baseEntries });
119
+ // A join is the whole literal, so accepting one where the author already typed
120
+ // a `#` would silently rewrite the fragment they are holding.
121
+ if (slot.fragmentStart === -1) {
122
+ entries.push(
123
+ ...buildWholeLiteralCompletionEntries({ slot, ids: universe.addresses, baseEntries }),
124
+ );
125
+ }
126
+ return entries;
108
127
  }
109
128
 
110
129
  // No caret guard: the span is the whole literal, so an entry is well-formed
@@ -131,24 +150,46 @@ function nodeEntries(
131
150
  }
132
151
 
133
152
  // Also no caret guard, and the same forward output-path mapping — but scoped
134
- // one step further than a node id: to the sprite component the slot's *sibling*
135
- // literal addresses on the one game object that owns this script. Every
136
- // unresolved link returns nothing rather than a project-wide guess, because a
137
- // `sprite.play_flipbook` id the addressed atlas does not declare is a runtime
138
- // crash.
153
+ // one step further than a node id, in whichever of the two ways the slot's own
154
+ // entry declares. Every unresolved link returns nothing rather than a
155
+ // project-wide guess, because an animation id the resolved atlas does not
156
+ // declare is a runtime crash.
157
+ //
158
+ // The branch is the table's own discriminator rather than a second arm in the
159
+ // class chain: an `animation` entry names either an address companion or a node
160
+ // companion, and the drift guard is what keeps it to exactly one.
139
161
  function animationEntries(
140
162
  slot: ClassifiedSlot,
141
163
  cache: SceneIndexCache,
142
164
  fileName: string,
143
165
  baseEntries: readonly ts.CompletionEntry[],
144
166
  ): ts.CompletionEntry[] {
167
+ const { projectRoot } = cache;
168
+ // Scoped to the `.gui` scene naming this file's generated gui script, never
169
+ // to the node the call addresses: `gui.set_texture` retargets a node at
170
+ // runtime, so the scene's textures are the honest universe.
171
+ if (slot.nodeParameter !== undefined) {
172
+ const index = cache.derived("gui-flipbook", () =>
173
+ buildGuiFlipbookIndex({
174
+ scenes: cache.documents(GUI_EXTENSIONS).documents,
175
+ assets: cache.documents(ANIMATION_ASSET_EXTENSIONS).documents,
176
+ }),
177
+ );
178
+ const config = readBuildConfigFromHost(cache.host, projectRoot);
179
+ const resource = computeOutputRel(displayPathOf(projectRoot, fileName), config, "gui-script");
180
+ const declared = index.byScriptResource.get(resource);
181
+ return declared === undefined
182
+ ? []
183
+ : buildWholeLiteralCompletionEntries({ slot, ids: new Set(declared.keys()), baseEntries });
184
+ }
185
+ // Scoped to the sprite or model component the slot's *sibling* literal
186
+ // addresses on the one game object that owns this script.
145
187
  const component = componentIdOfSameObjectAddress(slot.addressText ?? "");
146
188
  if (component === undefined) {
147
189
  return [];
148
190
  }
149
- const { projectRoot } = cache;
150
- const index = cache.derived("sprite-animations", () =>
151
- buildSpriteAnimationIndex({
191
+ const index = cache.derived("component-animations", () =>
192
+ buildComponentAnimationIndex({
152
193
  scenes: cache.documents().documents,
153
194
  assets: cache.documents(ANIMATION_ASSET_EXTENSIONS).documents,
154
195
  }),
@@ -210,23 +251,17 @@ function actionIdEntries(
210
251
  }
211
252
 
212
253
  // The panel a claimed request is answered with. `documentation` rather than
213
- // `displayParts` carries the paths because a host renders the former as the
214
- // body of the panel, which is where a list of files reads as one.
215
- function provenancePanel(
216
- entryName: string,
217
- declaredIn: readonly string[],
218
- ): ts.CompletionEntryDetails {
254
+ // `displayParts` carries the sentence because a host renders the former as the
255
+ // body of the panel, which is where a list of files — or of objects — reads as
256
+ // one. The sentence is the caller's, since an absolute entry is answered with
257
+ // the files declaring it and a relative one with the objects it resolves from.
258
+ function provenancePanel(entryName: string, documentation: string): ts.CompletionEntryDetails {
219
259
  return {
220
260
  name: entryName,
221
261
  kind: CONTRIBUTED_ENTRY_KIND,
222
262
  kindModifiers: "",
223
263
  displayParts: [{ kind: "stringLiteral", text: JSON.stringify(entryName) }],
224
- documentation: [
225
- {
226
- kind: "text",
227
- text: `Declared in ${declaredIn.join(", ")}`,
228
- },
229
- ],
264
+ documentation: [{ kind: "text", text: documentation }],
230
265
  source: [{ kind: "text", text: DEFOLD_COMPLETION_SOURCE }],
231
266
  };
232
267
  }
@@ -304,8 +339,23 @@ export default function init(modules: { typescript: typeof import("typescript")
304
339
  if (!slot) {
305
340
  return forward();
306
341
  }
342
+ // Tried first because the two universes are disjoint by construction and
343
+ // only this one knows which object an entry is relative to; a name it does
344
+ // not carry falls straight through to the declaring-file answer.
345
+ const relative = resolveRelativeEntryProvenance({
346
+ slot,
347
+ position,
348
+ cache,
349
+ fileName,
350
+ entryName,
351
+ });
352
+ if (relative !== undefined) {
353
+ return provenancePanel(entryName, relative);
354
+ }
307
355
  const declaredIn = resolveEntryProvenance({ slot, position, cache, fileName, entryName });
308
- return declaredIn.length === 0 ? forward() : provenancePanel(entryName, declaredIn);
356
+ return declaredIn.length === 0
357
+ ? forward()
358
+ : provenancePanel(entryName, `Declared in ${declaredIn.join(", ")}`);
309
359
  };
310
360
 
311
361
  proxy.getSemanticDiagnostics = (fileName: string): ts.Diagnostic[] => {
@@ -319,7 +369,13 @@ export default function init(modules: { typescript: typeof import("typescript")
319
369
  const transpiler = getProgramDiagnostics(program, program.getSourceFile(fileName)).map(
320
370
  (diagnostic) => ({ ...diagnostic, category: ts.DiagnosticCategory.Suggestion }),
321
371
  );
322
- return [...prior, ...transpiler];
372
+ // The same advisory posture, and the same absent-cache early return the
373
+ // completion path takes: a host that cannot enumerate files has no scene
374
+ // universe, and an empty one would report every address as unreachable.
375
+ const table = loadUrlParameterTable();
376
+ const scene =
377
+ cache && table ? sceneReachabilityDiagnostics({ ts, program, table, cache, fileName }) : [];
378
+ return [...prior, ...transpiler, ...scene];
323
379
  };
324
380
 
325
381
  // Strictly additive: every path that cannot produce a suggestion returns the
@@ -354,7 +410,7 @@ export default function init(modules: { typescript: typeof import("typescript")
354
410
  const entries = isAddressClass(slot.class)
355
411
  ? [
356
412
  ...componentEntries(slot, position, cache, baseEntries),
357
- ...objectPathEntries(slot, position, cache, baseEntries),
413
+ ...objectPathEntries(slot, position, cache, fileName, baseEntries),
358
414
  ]
359
415
  : slot.class === "gui-node"
360
416
  ? nodeEntries(slot, cache, fileName, baseEntries)
@@ -0,0 +1,92 @@
1
+ import {
2
+ checkCrossWorldAddresses,
3
+ checkUrlFragmentReachability,
4
+ } from "@defold-typescript/transpiler";
5
+ import type { UrlParameterTable } from "@defold-typescript/types";
6
+ import type * as ts from "typescript";
7
+ import { type SceneIndexCache, sceneComponentIndexOf } from "./scene-index-cache";
8
+ import { projectObjectIndex, worldsFor } from "./scene-relative-addresses";
9
+
10
+ /** The `source` an editor renders beside the message, and `--json` never sees. */
11
+ export const DEFOLD_DIAGNOSTIC_SOURCE = "defold-typescript";
12
+
13
+ // A range neither TypeScript (< 100000) nor tstl occupies, so a host filtering
14
+ // by code can tell the two address findings apart from each other and from
15
+ // everything else the language service reports.
16
+ const UNREACHABLE_FRAGMENT_CODE = 9_000_101;
17
+ const FOREIGN_SOCKET_CODE = 9_000_102;
18
+
19
+ function suggestion(
20
+ typescript: typeof import("typescript"),
21
+ file: ts.SourceFile,
22
+ finding: { readonly start: number; readonly length: number; readonly message: string },
23
+ code: number,
24
+ ): ts.Diagnostic {
25
+ return {
26
+ file,
27
+ start: finding.start,
28
+ length: finding.length,
29
+ messageText: finding.message,
30
+ category: typescript.DiagnosticCategory.Suggestion,
31
+ source: DEFOLD_DIAGNOSTIC_SOURCE,
32
+ code,
33
+ };
34
+ }
35
+
36
+ /**
37
+ * The address findings the editor surfaces on the file being edited, as
38
+ * advisory suggestions.
39
+ *
40
+ * Every path that cannot produce one answers with an empty array. A suppressed
41
+ * reachability report is the important case: there is no file span on which a
42
+ * "the check did not run" notice could honestly sit, and a speculative squiggle
43
+ * is worse than none — the build says so in prose instead, where a whole-project
44
+ * line has somewhere to go.
45
+ *
46
+ * The *whole* hole is one index, composed the way `sceneIndexForBuild` composes
47
+ * it: what the parse could not read plus what the walk could not reach. The
48
+ * completion path deliberately reads a partial universe — a suggestion claims
49
+ * nothing about what is absent — but a finding does, so a component declared
50
+ * only by an unresolved dependency suppresses the check rather than reporting
51
+ * the address as unreachable.
52
+ */
53
+ export function sceneReachabilityDiagnostics(input: {
54
+ ts: typeof import("typescript");
55
+ program: ts.Program;
56
+ table: UrlParameterTable;
57
+ cache: SceneIndexCache;
58
+ fileName: string;
59
+ }): ts.Diagnostic[] {
60
+ const { ts: typescript, program, table, cache, fileName } = input;
61
+ const sourceFile = program.getSourceFile(fileName);
62
+ if (!sourceFile) {
63
+ return [];
64
+ }
65
+ const index = sceneComponentIndexOf(cache);
66
+ const report = checkUrlFragmentReachability({
67
+ program,
68
+ table,
69
+ index: { ids: index.ids, incomplete: [...index.incomplete, ...cache.documents().unreadable] },
70
+ objects: projectObjectIndex(cache),
71
+ sourceFiles: [sourceFile],
72
+ });
73
+ const fragments =
74
+ report.kind === "suppressed"
75
+ ? []
76
+ : report.findings.map((finding) =>
77
+ suggestion(typescript, sourceFile, finding, UNREACHABLE_FRAGMENT_CODE),
78
+ );
79
+
80
+ // Outside the branch above, never inside it: the two honesty rules are
81
+ // independent — one suppresses index-wide on an incomplete component universe,
82
+ // the other withholds per file on an unresolvable world — which is the reason
83
+ // the transpiler kept them as two exports rather than one.
84
+ const crossWorld = checkCrossWorldAddresses({
85
+ program,
86
+ table,
87
+ worldsOf: (name) => worldsFor(cache, name),
88
+ sourceFiles: [sourceFile],
89
+ }).map((finding) => suggestion(typescript, sourceFile, finding, FOREIGN_SOCKET_CODE));
90
+
91
+ return [...fragments, ...crossWorld];
92
+ }
@@ -1,9 +1,17 @@
1
1
  import {
2
+ buildSceneCollectionRoles,
3
+ buildSceneComponentIndex,
4
+ COLLECTION_REFERENCE_EXTENSIONS,
2
5
  displayPathOf,
6
+ GAME_PROJECT_DOCUMENT,
3
7
  isExcludedProjectPath,
8
+ LIBRARY_DEPENDENCY_ROOT,
4
9
  listProjectResourcePaths,
10
+ PROJECT_EXTENSIONS,
5
11
  readSceneDocuments,
6
12
  SCENE_EXTENSIONS,
13
+ type SceneCollectionRoles,
14
+ type SceneComponentIndex,
7
15
  type SceneReadHost,
8
16
  } from "@defold-typescript/transpiler";
9
17
  import type * as ts from "typescript";
@@ -81,6 +89,19 @@ export function createSceneIndexCache(host: SceneWatchHost, projectRoot: string)
81
89
 
82
90
  function affectsCache(hostPath: string): boolean {
83
91
  const displayPath = displayPathOf(projectRoot, hostPath);
92
+ // `resolve` rewrites this surface wholesale, and `.defold-types/` is
93
+ // `.defignore`d — so the exclusion test below would throw the event away and
94
+ // leave the editor offering ids from a dependency the author just removed.
95
+ // The manifest counts too: it alone changes when a dependency is dropped.
96
+ // The root itself, not only its descendants: removing the last dependency
97
+ // deletes the whole directory and writes nothing inside it, so that event is
98
+ // the only one a watcher reports.
99
+ if (
100
+ displayPath === LIBRARY_DEPENDENCY_ROOT ||
101
+ displayPath.startsWith(`${LIBRARY_DEPENDENCY_ROOT}/`)
102
+ ) {
103
+ return true;
104
+ }
84
105
  if (isExcludedProjectPath(displayPath)) return false;
85
106
  for (const extension of servedExtensions) {
86
107
  if (displayPath.endsWith(extension)) return true;
@@ -166,3 +187,30 @@ export function createSceneIndexCache(host: SceneWatchHost, projectRoot: string)
166
187
  },
167
188
  };
168
189
  }
190
+
191
+ // The component-id universe, whole — ids *and* the walk's own record of what it
192
+ // could not prove — read through the cache so one walk serves both readers. The
193
+ // two disagree about what to do with the hole rather than about how to build it:
194
+ // a completion suggests from a partial universe, a finding must not, so the
195
+ // index is shared here and each caller applies its own rule.
196
+ export function sceneComponentIndexOf(cache: SceneIndexCache): SceneComponentIndex {
197
+ return cache.derived("component-index", () =>
198
+ buildSceneComponentIndex(cache.documents().documents),
199
+ );
200
+ }
201
+
202
+ // The world each collection is, read through the cache so the completion path
203
+ // and the provenance path share one classification — and so the walk over the
204
+ // proxy/factory documents and `game.project` is invalidated by the same
205
+ // watchers the scene walk is. Both call sites go through here rather than
206
+ // building roles of their own, which is what would let one of them drift back
207
+ // to offering every collection's paths bare.
208
+ export function sceneCollectionRolesOf(cache: SceneIndexCache): SceneCollectionRoles {
209
+ return cache.derived("collection-roles", () =>
210
+ buildSceneCollectionRoles({
211
+ documents: cache.documents().documents,
212
+ references: cache.documents(COLLECTION_REFERENCE_EXTENSIONS).documents,
213
+ gameProject: cache.documents(PROJECT_EXTENSIONS).documents.get(GAME_PROJECT_DOCUMENT),
214
+ }),
215
+ );
216
+ }
@@ -1,11 +1,12 @@
1
1
  import {
2
2
  ANIMATION_ASSET_EXTENSIONS,
3
+ buildComponentAnimationIndex,
3
4
  buildConfigKeyIndex,
5
+ buildGuiFlipbookIndex,
4
6
  buildGuiNodeIndex,
5
7
  buildInputActionIndex,
6
8
  buildSceneComponentIndex,
7
9
  buildSceneObjectPathIndex,
8
- buildSpriteAnimationIndex,
9
10
  type ClassifiedSlot,
10
11
  componentIdOfSameObjectAddress,
11
12
  computeOutputRel,
@@ -18,7 +19,8 @@ import {
18
19
  PROJECT_EXTENSIONS,
19
20
  } from "@defold-typescript/transpiler";
20
21
  import { readBuildConfigFromHost } from "./build-config";
21
- import type { SceneIndexCache } from "./scene-index-cache";
22
+ import { type SceneIndexCache, sceneCollectionRolesOf } from "./scene-index-cache";
23
+ import { relativeUniverseFor } from "./scene-relative-addresses";
22
24
 
23
25
  // Keys of their own under the same cache the completion indexes use, so a
24
26
  // provenance map is thrown away with every index it was derived beside — and
@@ -27,7 +29,8 @@ const COMPONENT_PROVENANCE = "provenance:component-ids";
27
29
  const ACTION_PROVENANCE = "provenance:input-actions";
28
30
  const GUI_PROVENANCE = "provenance:gui-nodes";
29
31
  const PATH_PROVENANCE = "provenance:object-paths";
30
- const ANIMATION_PROVENANCE = "provenance:sprite-animations";
32
+ const ANIMATION_PROVENANCE = "provenance:component-animations";
33
+ const GUI_ANIMATION_PROVENANCE = "provenance:gui-flipbook";
31
34
 
32
35
  type Provenance = ReadonlyMap<string, readonly string[]>;
33
36
 
@@ -123,16 +126,48 @@ function nodeProvenance(cache: SceneIndexCache, fileName: string): Provenance {
123
126
  function pathProvenance(cache: SceneIndexCache): Provenance {
124
127
  return cache.derived(
125
128
  PATH_PROVENANCE,
126
- () => buildSceneObjectPathIndex(cache.documents().documents).declaredIn,
129
+ () =>
130
+ buildSceneObjectPathIndex(cache.documents().documents, sceneCollectionRolesOf(cache))
131
+ .declaredIn,
127
132
  );
128
133
  }
129
134
 
130
- // Scoped exactly the way the animation completion is: to the sprite component
131
- // the slot's sibling literal addresses on the one game object owning this
132
- // script, resolved through the same helper so the two cannot disagree about
133
- // which sprite is addressed. The tile set is reported only for an id it really
134
- // declares — a panel naming a file that does not carry the name is the
135
- // fabricated answer this surface exists to avoid.
135
+ // Scoped exactly the way the gui flipbook completion is: to the `.gui` scene
136
+ // naming this file's generated gui script. The index already records which
137
+ // named texture declared each id, so the answer is read straight off it — and
138
+ // an id no named texture carries is answered with silence rather than the
139
+ // scene that failed to declare it.
140
+ function guiAnimationProvenance(input: {
141
+ cache: SceneIndexCache;
142
+ fileName: string;
143
+ entryName: string;
144
+ }): readonly string[] {
145
+ const { cache, fileName, entryName } = input;
146
+ const index = cache.derived(GUI_ANIMATION_PROVENANCE, () =>
147
+ buildGuiFlipbookIndex({
148
+ scenes: cache.documents(GUI_EXTENSIONS).documents,
149
+ assets: cache.documents(ANIMATION_ASSET_EXTENSIONS).documents,
150
+ }),
151
+ );
152
+ const config = readBuildConfigFromHost(cache.host, cache.projectRoot);
153
+ const resource = computeOutputRel(
154
+ displayPathOf(cache.projectRoot, fileName),
155
+ config,
156
+ "gui-script",
157
+ );
158
+ const declarers = index.byScriptResource.get(resource)?.get(entryName);
159
+ return declarers === undefined ? [] : [...declarers].sort();
160
+ }
161
+
162
+ // Scoped exactly the way the animation completion is: to the sprite or model
163
+ // component the slot's sibling literal addresses on the one game object owning
164
+ // this script, resolved through the same helper so the two cannot disagree
165
+ // about which component is addressed. The declaring document — an atlas for a
166
+ // sprite, and for a model the animation set that lists the entry producing the
167
+ // id — is reported only for an id it really declares: the per-id map's key set
168
+ // *is* the component's id set, so a miss there already means this component
169
+ // does not carry the name, and a panel naming a file that does not carry it is
170
+ // the fabricated answer this surface exists to avoid.
136
171
  function animationProvenance(input: {
137
172
  slot: ClassifiedSlot;
138
173
  cache: SceneIndexCache;
@@ -140,19 +175,21 @@ function animationProvenance(input: {
140
175
  entryName: string;
141
176
  }): readonly string[] {
142
177
  const { slot, cache, fileName, entryName } = input;
178
+ if (slot.nodeParameter !== undefined) {
179
+ return guiAnimationProvenance({ cache, fileName, entryName });
180
+ }
143
181
  const component = componentIdOfSameObjectAddress(slot.addressText ?? "");
144
182
  if (component === undefined) return [];
145
183
  const index = cache.derived(ANIMATION_PROVENANCE, () =>
146
- buildSpriteAnimationIndex({
184
+ buildComponentAnimationIndex({
147
185
  scenes: cache.documents().documents,
148
186
  assets: cache.documents(ANIMATION_ASSET_EXTENSIONS).documents,
149
187
  }),
150
188
  );
151
189
  const config = readBuildConfigFromHost(cache.host, cache.projectRoot);
152
190
  const resource = computeOutputRel(displayPathOf(cache.projectRoot, fileName), config, "script");
153
- const tileSet = index.tileSetByScriptResource.get(resource)?.get(component);
154
- if (tileSet === undefined) return [];
155
- return index.byScriptResource.get(resource)?.get(component)?.has(entryName) ? [tileSet] : [];
191
+ const source = index.sourceByScriptResource.get(resource)?.get(component)?.get(entryName);
192
+ return source === undefined ? [] : [source];
156
193
  }
157
194
 
158
195
  // A config key is declared by the one file that answers a reader at runtime, and
@@ -175,6 +212,37 @@ function constantProvenance(
175
212
  return cache.resourcePaths(extensions).has(entryName) ? [entryName.replace(/^\//, "")] : [];
176
213
  }
177
214
 
215
+ /**
216
+ * The sentence a relative entry's panel carries, or `undefined` where the entry
217
+ * is not one — a fragment caret, a slot that names no address, or a name this
218
+ * file's relative universe does not offer.
219
+ *
220
+ * A relative address is valid from the object hosting the script and nowhere
221
+ * else, so the objects it resolves from *are* its provenance: naming a declaring
222
+ * file instead would answer a question the author is not asking. Where the entry
223
+ * resolves from only some of the contexts this script runs in, the others are
224
+ * named too — a sometimes-valid entry that read as an always-valid one is the
225
+ * wrong-suggestion class this panel exists to close.
226
+ */
227
+ export function resolveRelativeEntryProvenance(input: {
228
+ slot: ClassifiedSlot;
229
+ position: number;
230
+ cache: SceneIndexCache;
231
+ fileName: string;
232
+ entryName: string;
233
+ }): string | undefined {
234
+ const { slot, position, cache, fileName, entryName } = input;
235
+ if (!isAddressClass(slot.class) || isFragmentCaret(slot, position)) return undefined;
236
+ const universe = relativeUniverseFor(cache, fileName);
237
+ const resolvesFrom = universe.contextsByEntry.get(entryName);
238
+ if (resolvesFrom === undefined) return undefined;
239
+ const others = universe.contexts
240
+ .map((context) => context.object)
241
+ .filter((object) => !resolvesFrom.includes(object));
242
+ const relative = `Relative to ${resolvesFrom.join(", ")}`;
243
+ return others.length === 0 ? relative : `${relative}; not from ${others.join(", ")}`;
244
+ }
245
+
178
246
  // The display paths of the project files that declare `entryName` in the
179
247
  // universe this slot draws from, sorted, empty when the project does not name
180
248
  // one exactly. Every kind the completion path offers is answered here; silence
@@ -0,0 +1,145 @@
1
+ import {
2
+ buildSceneObjectPathIndex,
3
+ buildScriptNamingContexts,
4
+ computeOutputRel,
5
+ displayPathOf,
6
+ GUI_EXTENSIONS,
7
+ guiScriptResourcesOf,
8
+ type NamingContext,
9
+ relativeAddressesFrom,
10
+ type SceneObjectPathIndex,
11
+ } from "@defold-typescript/transpiler";
12
+ import { readBuildConfigFromHost } from "./build-config";
13
+ import { type SceneIndexCache, sceneCollectionRolesOf } from "./scene-index-cache";
14
+
15
+ // The relative forms the file being edited may write, and the objects each one
16
+ // resolves from. Unlike every other completion universe this is per-file by
17
+ // construction: a relative address continues the collection path of the object
18
+ // hosting the script, so it means one thing from there and nothing anywhere
19
+ // else.
20
+ export interface RelativeUniverse {
21
+ /** Every object hosting this file's generated script, sorted by object path. */
22
+ readonly contexts: readonly NamingContext[];
23
+ /** Relative game-object paths, valid from at least one context. */
24
+ readonly paths: ReadonlySet<string>;
25
+ /** Those paths joined to the components their object owns. */
26
+ readonly addresses: ReadonlySet<string>;
27
+ /** Entry name -> the sorted object paths it resolves from. */
28
+ readonly contextsByEntry: ReadonlyMap<string, readonly string[]>;
29
+ }
30
+
31
+ const CONTEXTS = "relative:naming-contexts";
32
+
33
+ interface ProjectContexts {
34
+ readonly index: SceneObjectPathIndex;
35
+ readonly byScriptResource: ReadonlyMap<string, readonly NamingContext[]>;
36
+ }
37
+
38
+ function projectContexts(cache: SceneIndexCache): ProjectContexts {
39
+ return cache.derived(CONTEXTS, () => {
40
+ const index = buildSceneObjectPathIndex(
41
+ cache.documents().documents,
42
+ sceneCollectionRolesOf(cache),
43
+ );
44
+ // A `.gui` names the gui script it drives, and that indirection is the only
45
+ // way a gui script reaches an object: the edge is read through the same
46
+ // shared builder the CLI's walk uses rather than a second reader.
47
+ const guiScripts = guiScriptResourcesOf(cache.documents(GUI_EXTENSIONS).documents);
48
+ return { index, byScriptResource: buildScriptNamingContexts(index, guiScripts) };
49
+ });
50
+ }
51
+
52
+ /**
53
+ * Every game-object path the project declares and the components each owns —
54
+ * the index a check scopes an absolute `#fragment` address against.
55
+ *
56
+ * Read off the walk `projectContexts` already caches rather than built again:
57
+ * the naming contexts are derived *from* this index, so the reachability path
58
+ * costs no second `.go`/`.collection` parse.
59
+ */
60
+ export function projectObjectIndex(cache: SceneIndexCache): SceneObjectPathIndex {
61
+ return projectContexts(cache).index;
62
+ }
63
+
64
+ /**
65
+ * The relative universe the file at `fileName` may write from, keyed per file
66
+ * under the same cache the absolute indexes live in.
67
+ *
68
+ * The file is mapped *forward* to the script resource a scene would name — the
69
+ * same direction `nodeEntries` takes, because an output path cannot say which
70
+ * include base produced it. Both script kinds are resolved and their contexts
71
+ * unioned: a `.ts` is named as a `.script` by a game object and as a
72
+ * `.gui_script` by a `.gui`, and nothing in the file itself says which.
73
+ */
74
+ export function relativeUniverseFor(cache: SceneIndexCache, fileName: string): RelativeUniverse {
75
+ return cache.derived(`relative-universe:${fileName}`, () => {
76
+ const { index, byScriptResource } = projectContexts(cache);
77
+ const config = readBuildConfigFromHost(cache.host, cache.projectRoot);
78
+ const rel = displayPathOf(cache.projectRoot, fileName);
79
+
80
+ const byObject = new Map<string, NamingContext>();
81
+ for (const kind of ["script", "gui-script"] as const) {
82
+ for (const context of byScriptResource.get(computeOutputRel(rel, config, kind)) ?? []) {
83
+ byObject.set(context.object, context);
84
+ }
85
+ }
86
+ const contexts = [...byObject.values()].sort((a, b) => a.object.localeCompare(b.object));
87
+
88
+ const paths = new Set<string>();
89
+ const addresses = new Set<string>();
90
+ const contextsByEntry = new Map<string, string[]>();
91
+ for (const context of contexts) {
92
+ const relative = relativeAddressesFrom(index, context);
93
+ for (const entry of relative.paths) {
94
+ paths.add(entry);
95
+ contextsByEntry.set(entry, [...(contextsByEntry.get(entry) ?? []), context.object]);
96
+ }
97
+ for (const entry of relative.addresses) {
98
+ addresses.add(entry);
99
+ contextsByEntry.set(entry, [...(contextsByEntry.get(entry) ?? []), context.object]);
100
+ }
101
+ }
102
+ for (const objects of contextsByEntry.values()) objects.sort();
103
+
104
+ return { contexts, paths, addresses, contextsByEntry };
105
+ });
106
+ }
107
+
108
+ /**
109
+ * The worlds the file's script runs in, as `checkCrossWorldAddresses` asks for
110
+ * them: the de-duplicated sockets of every naming context hosting it, with the
111
+ * bootstrap world carried as `undefined`.
112
+ *
113
+ * The same answer `scriptWorldsForBuild` gives the CLI, reached through the
114
+ * chain that resolver was modelled on — so an address the build calls foreign is
115
+ * foreign in the editor too. An empty answer is the honest unknown: a script no
116
+ * scene hosts runs in no world this project can prove, and the check reports
117
+ * nothing for it.
118
+ */
119
+ export function worldsFor(
120
+ cache: SceneIndexCache,
121
+ fileName: string,
122
+ ): readonly (string | undefined)[] {
123
+ const sockets = new Set<string | undefined>();
124
+ for (const context of relativeUniverseFor(cache, fileName).contexts) {
125
+ sockets.add(context.socket);
126
+ }
127
+ return [...sockets];
128
+ }
129
+
130
+ /**
131
+ * Whether a bare `/path` can resolve from where this file runs. A bare address
132
+ * continues the caller's own world, so one offered to a script running behind a
133
+ * proxy names an object that world cannot reach.
134
+ *
135
+ * A file with no naming context offers them: nothing is known about where it
136
+ * runs, and withholding every bare path would delete the completions the
137
+ * absolute universe has always provided. Socket-qualified keys are never
138
+ * touched — whether a slot accepts a foreign socket is a different question.
139
+ */
140
+ export function offersBareWorld(universe: RelativeUniverse): boolean {
141
+ return (
142
+ universe.contexts.length === 0 ||
143
+ universe.contexts.some((context) => context.socket === undefined)
144
+ );
145
+ }