@defold-typescript/types 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/emit-dts.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from "./doc-comment";
17
17
  import type { TranslationStore } from "./example-store";
18
18
  import { hashExampleSource, lookupTranslation } from "./example-store";
19
+ import { classifyUrlParameter, type UrlParameterTable } from "./url-parameters";
19
20
 
20
21
  export interface EmitOptions {
21
22
  mapType?: (defoldType: string) => string;
@@ -30,6 +31,12 @@ export interface EmitOptions {
30
31
  // `examples/translations.json`. Loading lives in `scripts/example-store-io.ts`
31
32
  // so this module stays node-free for downstream consumers.
32
33
  translations?: TranslationStore;
34
+ // Which parameters address the scene graph, so their `string` member emits a
35
+ // scene-derived address alias instead. Defaults to an empty table — every
36
+ // slot classifies `none` and the output is byte-identical to an un-retyped
37
+ // emit. `regen` supplies the committed `url-parameters.json`; the table
38
+ // arrives as data so this module stays free of `node:fs` (bug-88).
39
+ urlParameters?: UrlParameterTable;
33
40
  }
34
41
 
35
42
  export const TS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
@@ -81,6 +88,7 @@ export const TS_RESERVED_NAMES = new Set([
81
88
  "with",
82
89
  "debugger",
83
90
  "extends",
91
+ "enum",
84
92
  ]);
85
93
 
86
94
  // A parameter name that clears `TS_IDENTIFIER` but is a TS reserved word (e.g.
@@ -218,7 +226,13 @@ export const MAPPING_TABLE_SLOTS: ReadonlyMap<string, { key: string; value: stri
218
226
  // honest, ts-defold-matching shape. Per-FQN, not a blanket "empty returnvalues
219
227
  // -> unknown" rule: `gui.set` also has empty returnvalues and `void` is correct
220
228
  // there.
221
- export const RETURN_TYPE_OVERRIDES: ReadonlyMap<string, string> = new Map([["gui.get", "unknown"]]);
229
+ export const RETURN_TYPE_OVERRIDES: ReadonlyMap<string, string> = new Map([
230
+ ["gui.get", "unknown"],
231
+ // Upstream documents the returned transaction step in prose only — its
232
+ // `returnvalues` is empty while every sibling `editor.tx.*` builder declares
233
+ // one, and `editor.transact()` takes exactly that.
234
+ ["editor.tx.add", 'Opaque<"transaction_step">'],
235
+ ]);
222
236
 
223
237
  // FQN-keyed allowlist of the `types.is_*` checks that genuinely narrow their
224
238
  // argument, mapped to the `DEFOLD_TYPE_MAP` token whose interface they prove.
@@ -1426,6 +1440,7 @@ export function emitDeclarations(module: ApiModule, options?: EmitOptions): stri
1426
1440
  const constantFqns = new Set(module.constants.map((c) => c.name));
1427
1441
  const knownConstantFqns = options?.knownConstantFqns;
1428
1442
  const translations = options?.translations ?? {};
1443
+ const urlParameters = options?.urlParameters ?? [];
1429
1444
  const baseMapType = options?.mapType ?? defaultMapType;
1430
1445
  const mapType = (token: string): string =>
1431
1446
  constantFqns.has(token) || knownConstantFqns?.has(token)
@@ -1451,32 +1466,14 @@ export function emitDeclarations(module: ApiModule, options?: EmitOptions): stri
1451
1466
  : a.name.localeCompare(b.name),
1452
1467
  );
1453
1468
 
1454
- // One-level nested functions (`socket.dns.toip`) fail the flat-identifier test
1455
- // in `prepareFunction` above (the stripped `dns.toip` has a dot), so they are
1456
- // absent from `functions`. Re-collect them grouped by their single leading
1457
- // segment, re-stripping against `<namespace>.<segment>.` so the emitted
1458
- // identifier is the final segment. Only exactly-one-dot, both-sides-identifier
1459
- // locals qualify; deeper nesting and non-identifier segments stay dropped.
1460
- const nestedFunctionLocal = /^[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*$/;
1461
- const nestedGroups = new Map<string, PreparedFunction[]>();
1462
- for (const fn of module.functions) {
1463
- const local = stripPrefix(fn.name, prefix);
1464
- if (!nestedFunctionLocal.test(local)) continue;
1465
- const segment = local.slice(0, local.indexOf("."));
1466
- const prepared = prepareFunction(fn, `${module.namespace}.${segment}.`);
1467
- if (prepared === null) continue;
1468
- const group = nestedGroups.get(segment) ?? [];
1469
- group.push(prepared);
1470
- nestedGroups.set(segment, group);
1471
- }
1472
- for (const group of nestedGroups.values()) {
1473
- group.sort((a, b) =>
1474
- a.name === b.name
1475
- ? a.original.parameters.length - b.original.parameters.length
1476
- : a.name.localeCompare(b.name),
1477
- );
1478
- }
1479
- const nestedSegments = [...nestedGroups.keys()].sort((a, b) => a.localeCompare(b));
1469
+ // Nested members (`socket.dns.toip`, `editor.ui.COLOR.TEXT`) fail the
1470
+ // flat-identifier test in `prepareVariable`/`prepareFunction` above (the
1471
+ // stripped local has a dot), so they are absent from `variables`/`functions`.
1472
+ // Re-collect them into a tree keyed by their full leading path, re-stripping
1473
+ // against `<namespace>.<path>.` so the emitted identifier is the final
1474
+ // segment. One or two leading identifier segments qualify; anything deeper,
1475
+ // and any non-identifier segment, stays dropped.
1476
+ const nestedRoot = collectNestedGroups(module, prefix);
1480
1477
 
1481
1478
  // Colon methods (`client:send`) are FUNCTION elements named `<receiver>:<method>`
1482
1479
  // and are NOT namespace-prefixed, so they fail the flat-identifier test in
@@ -1525,7 +1522,7 @@ export function emitDeclarations(module: ApiModule, options?: EmitOptions): stri
1525
1522
  for (const docLine of functionDocLines(fn.original, translations, handleIndent)) {
1526
1523
  lines.push(docLine);
1527
1524
  }
1528
- lines.push(`${handleIndent}${emitMethod(fn, mapType, resolver)}`);
1525
+ lines.push(`${handleIndent}${emitMethod(fn, mapType, resolver, urlParameters)}`);
1529
1526
  }
1530
1527
  lines.push(`${INDENT}}`);
1531
1528
  }
@@ -1554,7 +1551,7 @@ export function emitDeclarations(module: ApiModule, options?: EmitOptions): stri
1554
1551
  const reserved = TS_RESERVED_NAMES.has(fn.name);
1555
1552
  const emitName = aliasName(fn.name, aliases);
1556
1553
  for (const docLine of functionDocLines(fn.original, translations)) lines.push(docLine);
1557
- const line = emitFunction(fn, emitName, mapType, resolver);
1554
+ const line = emitFunction(fn, emitName, mapType, resolver, urlParameters);
1558
1555
  lines.push(`${INDENT}${reserved ? "" : decl}${line}`);
1559
1556
  }
1560
1557
 
@@ -1562,31 +1559,57 @@ export function emitDeclarations(module: ApiModule, options?: EmitOptions): stri
1562
1559
  lines.push(`${INDENT}export { ${alias.internal} as ${alias.public} };`);
1563
1560
  }
1564
1561
 
1565
- const nestedIndent = `${INDENT}${INDENT}`;
1566
- for (const segment of nestedSegments) {
1567
- const group = nestedGroups.get(segment) ?? [];
1568
- // A reserved-name function inside a nested namespace gets the same recovery as
1569
- // a top-level one: emitted un-exported as `_<name>` and re-exported under the
1570
- // reserved name. The alias switches this namespace out of implicit-export mode,
1571
- // so its siblings then need an explicit `export` to stay reachable.
1572
- const segmentAliases: { internal: string; public: string }[] = [];
1573
- const segmentDecl = group.some((fn) => TS_RESERVED_NAMES.has(fn.name)) ? "export " : "";
1574
- lines.push(`${INDENT}${decl}namespace ${segment} {`);
1575
- for (const fn of group) {
1576
- const reserved = TS_RESERVED_NAMES.has(fn.name);
1577
- const emitName = aliasName(fn.name, segmentAliases);
1578
- for (const docLine of functionDocLines(fn.original, translations, nestedIndent)) {
1579
- lines.push(docLine);
1562
+ const emitNestedLevel = (
1563
+ level: ReadonlyMap<string, NestedGroup>,
1564
+ indent: string,
1565
+ outerDecl: string,
1566
+ ): void => {
1567
+ for (const segment of [...level.keys()].sort((a, b) => a.localeCompare(b))) {
1568
+ const group = level.get(segment) as NestedGroup;
1569
+ const bodyIndent = `${indent}${INDENT}`;
1570
+ // A reserved-name member inside a nested namespace gets the same recovery as
1571
+ // a top-level one: emitted un-exported as `_<name>` and re-exported under the
1572
+ // reserved name. The alias switches this namespace out of implicit-export mode,
1573
+ // so its siblings then need an explicit `export` to stay reachable.
1574
+ const segmentAliases: { internal: string; public: string }[] = [];
1575
+ const segmentDecl =
1576
+ group.variables.some((v) => TS_RESERVED_NAMES.has(v.name)) ||
1577
+ group.functions.some((fn) => TS_RESERVED_NAMES.has(fn.name))
1578
+ ? "export "
1579
+ : "";
1580
+ lines.push(`${indent}${outerDecl}namespace ${segment} {`);
1581
+ for (const v of group.variables) {
1582
+ const reserved = TS_RESERVED_NAMES.has(v.name);
1583
+ const emitName = aliasName(v.name, segmentAliases);
1584
+ for (const docLine of summaryDocLines(
1585
+ v.original.brief,
1586
+ v.original.description,
1587
+ bodyIndent,
1588
+ )) {
1589
+ lines.push(docLine);
1590
+ }
1591
+ lines.push(
1592
+ `${bodyIndent}${reserved ? "" : segmentDecl}${emitVariable(v, emitName, mapType)}`,
1593
+ );
1580
1594
  }
1581
- lines.push(
1582
- `${nestedIndent}${reserved ? "" : segmentDecl}${emitFunction(fn, emitName, mapType, resolver)}`,
1583
- );
1584
- }
1585
- for (const alias of [...segmentAliases].sort((a, b) => a.public.localeCompare(b.public))) {
1586
- lines.push(`${nestedIndent}export { ${alias.internal} as ${alias.public} };`);
1595
+ for (const fn of group.functions) {
1596
+ const reserved = TS_RESERVED_NAMES.has(fn.name);
1597
+ const emitName = aliasName(fn.name, segmentAliases);
1598
+ for (const docLine of functionDocLines(fn.original, translations, bodyIndent)) {
1599
+ lines.push(docLine);
1600
+ }
1601
+ lines.push(
1602
+ `${bodyIndent}${reserved ? "" : segmentDecl}${emitFunction(fn, emitName, mapType, resolver, urlParameters)}`,
1603
+ );
1604
+ }
1605
+ for (const alias of [...segmentAliases].sort((a, b) => a.public.localeCompare(b.public))) {
1606
+ lines.push(`${bodyIndent}export { ${alias.internal} as ${alias.public} };`);
1607
+ }
1608
+ emitNestedLevel(group.children, bodyIndent, segmentDecl);
1609
+ lines.push(`${indent}}`);
1587
1610
  }
1588
- lines.push(`${INDENT}}`);
1589
- }
1611
+ };
1612
+ emitNestedLevel(nestedRoot, INDENT, decl);
1590
1613
 
1591
1614
  if (module.properties.length > 0) {
1592
1615
  const members = [...module.properties].sort((a, b) => a.name.localeCompare(b.name));
@@ -1616,13 +1639,17 @@ export interface SymbolSignature {
1616
1639
  * availability matrix joins on. This is the single declaration-backed source for
1617
1640
  * the shared `api-signatures.json` artifact: the same `mapType` (constant
1618
1641
  * branding) and table-doc resolver produce text that appears verbatim in the
1619
- * committed `.d.ts`. The caller applies the same `skipFunctions` filter the
1620
- * `.d.ts` generation does, so a dropped member never yields a signature.
1642
+ * committed `.d.ts`, and nested members come from the same
1643
+ * {@link collectNestedGroups} tree the declaration emitter walks, so neither
1644
+ * surface can decide on its own which members are nested or how deep they go.
1645
+ * The caller applies the same `skipFunctions` filter the `.d.ts` generation
1646
+ * does, so a dropped member never yields a signature.
1621
1647
  */
1622
1648
  export function emitSymbolSignatures(module: ApiModule, options?: EmitOptions): SymbolSignature[] {
1623
1649
  const prefix = `${module.namespace}.`;
1624
1650
  const constantFqns = new Set(module.constants.map((c) => c.name));
1625
1651
  const knownConstantFqns = options?.knownConstantFqns;
1652
+ const urlParameters = options?.urlParameters ?? [];
1626
1653
  const baseMapType = options?.mapType ?? defaultMapType;
1627
1654
  const mapType = (token: string): string =>
1628
1655
  constantFqns.has(token) || knownConstantFqns?.has(token)
@@ -1649,20 +1676,32 @@ export function emitSymbolSignatures(module: ApiModule, options?: EmitOptions):
1649
1676
  if (prepared === null) continue;
1650
1677
  out.push({
1651
1678
  identity: fnIdentity(fn),
1652
- tsSignature: emitFunction(prepared, emitName(prepared.name), mapType, resolver),
1679
+ tsSignature: emitFunction(
1680
+ prepared,
1681
+ emitName(prepared.name),
1682
+ mapType,
1683
+ resolver,
1684
+ urlParameters,
1685
+ ),
1653
1686
  });
1654
1687
  }
1655
1688
 
1656
- const nestedFunctionLocal = /^[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*$/;
1657
- for (const fn of module.functions) {
1658
- const local = stripPrefix(fn.name, prefix);
1659
- if (!nestedFunctionLocal.test(local)) continue;
1660
- const segment = local.slice(0, local.indexOf("."));
1661
- const prepared = prepareFunction(fn, `${module.namespace}.${segment}.`);
1662
- if (prepared === null) continue;
1689
+ const nested = flattenNestedGroups(collectNestedGroups(module, prefix));
1690
+ for (const v of nested.variables) {
1663
1691
  out.push({
1664
- identity: fnIdentity(fn),
1665
- tsSignature: emitFunction(prepared, prepared.name, mapType, resolver),
1692
+ identity: {
1693
+ namespace: module.namespace,
1694
+ kind: "VARIABLE",
1695
+ name: v.original.name,
1696
+ signature: "",
1697
+ },
1698
+ tsSignature: emitVariable(v, emitName(v.name), mapType),
1699
+ });
1700
+ }
1701
+ for (const fn of nested.functions) {
1702
+ out.push({
1703
+ identity: fnIdentity(fn.original),
1704
+ tsSignature: emitFunction(fn, emitName(fn.name), mapType, resolver, urlParameters),
1666
1705
  });
1667
1706
  }
1668
1707
 
@@ -1671,7 +1710,7 @@ export function emitSymbolSignatures(module: ApiModule, options?: EmitOptions):
1671
1710
  for (const prepared of group) {
1672
1711
  out.push({
1673
1712
  identity: fnIdentity(prepared.original),
1674
- tsSignature: emitMethod(prepared, mapType, resolver),
1713
+ tsSignature: emitMethod(prepared, mapType, resolver, urlParameters),
1675
1714
  });
1676
1715
  }
1677
1716
  }
@@ -1728,6 +1767,92 @@ interface PreparedVariable {
1728
1767
  original: ApiVariable;
1729
1768
  }
1730
1769
 
1770
+ // One level of the nested-member tree: the members declared directly under a
1771
+ // segment, plus the segments declared beneath it.
1772
+ interface NestedGroup {
1773
+ variables: PreparedVariable[];
1774
+ functions: PreparedFunction[];
1775
+ children: Map<string, NestedGroup>;
1776
+ }
1777
+
1778
+ // A stripped local carrying one or two leading identifier segments before the
1779
+ // member name (`dns.toip`, `COLOR.TEXT`, `schema.integer`). Three levels deep is
1780
+ // beyond anything the vendored documents describe, so it stays dropped rather
1781
+ // than half-emitted.
1782
+ const NESTED_MEMBER_LOCAL = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*){1,2}$/;
1783
+
1784
+ function collectNestedGroups(module: ApiModule, prefix: string): Map<string, NestedGroup> {
1785
+ const root = new Map<string, NestedGroup>();
1786
+ const groupAt = (segments: readonly string[]): NestedGroup => {
1787
+ let level = root;
1788
+ let group: NestedGroup | undefined;
1789
+ for (const segment of segments) {
1790
+ let next = level.get(segment);
1791
+ if (next === undefined) {
1792
+ next = { variables: [], functions: [], children: new Map() };
1793
+ level.set(segment, next);
1794
+ }
1795
+ group = next;
1796
+ level = next.children;
1797
+ }
1798
+ return group as NestedGroup;
1799
+ };
1800
+ const pathOf = (local: string): string[] => local.split(".").slice(0, -1);
1801
+
1802
+ for (const v of module.variables) {
1803
+ const local = stripPrefix(v.name, prefix);
1804
+ if (!NESTED_MEMBER_LOCAL.test(local)) continue;
1805
+ const segments = pathOf(local);
1806
+ const prepared = prepareVariable(v, `${module.namespace}.${segments.join(".")}.`);
1807
+ if (prepared === null) continue;
1808
+ groupAt(segments).variables.push(prepared);
1809
+ }
1810
+ for (const fn of module.functions) {
1811
+ const local = stripPrefix(fn.name, prefix);
1812
+ if (!NESTED_MEMBER_LOCAL.test(local)) continue;
1813
+ const segments = pathOf(local);
1814
+ const prepared = prepareFunction(fn, `${module.namespace}.${segments.join(".")}.`);
1815
+ if (prepared === null) continue;
1816
+ groupAt(segments).functions.push(prepared);
1817
+ }
1818
+
1819
+ const sortLevel = (level: Map<string, NestedGroup>): void => {
1820
+ for (const group of level.values()) {
1821
+ group.variables.sort((a, b) => a.name.localeCompare(b.name));
1822
+ group.functions.sort((a, b) =>
1823
+ a.name === b.name
1824
+ ? a.original.parameters.length - b.original.parameters.length
1825
+ : a.name.localeCompare(b.name),
1826
+ );
1827
+ sortLevel(group.children);
1828
+ }
1829
+ };
1830
+ sortLevel(root);
1831
+ return root;
1832
+ }
1833
+
1834
+ // Every member a nested tree holds, at any depth, in the order the declaration
1835
+ // emitter walks it (segments sorted, each group's own members before its
1836
+ // children). Callers that need no per-segment container read the tree through
1837
+ // this rather than re-deriving which members are nested and how deep.
1838
+ function flattenNestedGroups(level: ReadonlyMap<string, NestedGroup>): {
1839
+ variables: PreparedVariable[];
1840
+ functions: PreparedFunction[];
1841
+ } {
1842
+ const variables: PreparedVariable[] = [];
1843
+ const functions: PreparedFunction[] = [];
1844
+ const walk = (current: ReadonlyMap<string, NestedGroup>): void => {
1845
+ for (const segment of [...current.keys()].sort((a, b) => a.localeCompare(b))) {
1846
+ const group = current.get(segment) as NestedGroup;
1847
+ variables.push(...group.variables);
1848
+ functions.push(...group.functions);
1849
+ walk(group.children);
1850
+ }
1851
+ };
1852
+ walk(level);
1853
+ return { variables, functions };
1854
+ }
1855
+
1731
1856
  function prepareFunction(fn: ApiFunction, prefix: string): PreparedFunction | null {
1732
1857
  const stripped = stripPrefix(fn.name, prefix);
1733
1858
  if (!TS_IDENTIFIER.test(stripped)) return null;
@@ -1813,13 +1938,23 @@ function memberSignature(
1813
1938
  name: string,
1814
1939
  mapType: (t: string) => string,
1815
1940
  resolver: TableDocResolver,
1941
+ urlParameters: UrlParameterTable,
1816
1942
  ): string {
1817
1943
  const original = prepared.original.parameters;
1818
1944
  const elementName = prepared.original.name;
1819
1945
  const cutoff = trailingOptionalCutoff(original);
1820
- const params = original
1821
- .map((p, i) => emitParameter(p, i, i >= cutoff, mapType, resolver, elementName))
1822
- .join(", ");
1946
+ const varargIndex = original.findIndex(isVarargParameter);
1947
+ const positional = (varargIndex === -1 ? original : original.slice(0, varargIndex)).map((p, i) =>
1948
+ emitParameter(p, i, i >= cutoff, mapType, resolver, elementName, urlParameters),
1949
+ );
1950
+ const params = (
1951
+ varargIndex === -1
1952
+ ? positional
1953
+ : [
1954
+ ...positional,
1955
+ emitRestParameter(original, varargIndex, mapType, resolver, elementName, urlParameters),
1956
+ ]
1957
+ ).join(", ");
1823
1958
  const ret = emitReturn(prepared.original.returnValues, mapType, resolver, elementName);
1824
1959
  const predicateToken = TYPE_PREDICATES.get(elementName);
1825
1960
  const soleParam = original[0];
@@ -1839,8 +1974,9 @@ function emitFunction(
1839
1974
  name: string,
1840
1975
  mapType: (t: string) => string,
1841
1976
  resolver: TableDocResolver,
1977
+ urlParameters: UrlParameterTable,
1842
1978
  ): string {
1843
- return `function ${memberSignature(prepared, name, mapType, resolver)}`;
1979
+ return `function ${memberSignature(prepared, name, mapType, resolver, urlParameters)}`;
1844
1980
  }
1845
1981
 
1846
1982
  // A colon-method member of a handle interface: identical signature machinery to a
@@ -1850,15 +1986,18 @@ function emitMethod(
1850
1986
  prepared: PreparedFunction,
1851
1987
  mapType: (t: string) => string,
1852
1988
  resolver: TableDocResolver,
1989
+ urlParameters: UrlParameterTable,
1853
1990
  ): string {
1854
- return memberSignature(prepared, prepared.name, mapType, resolver);
1991
+ return memberSignature(prepared, prepared.name, mapType, resolver, urlParameters);
1855
1992
  }
1856
1993
 
1857
1994
  // Build the indented JSDoc lines for a function from its ref-doc prose. The
1858
1995
  // summary prefers the full `description`, falling back to the one-line `brief`;
1859
1996
  // each `@param` name is the parameter's *emitted* name (the `arg<index>`
1860
- // fallback applies to non-identifier names, matching `emitParameter`) so the tag
1861
- // resolves on hover; a single documented return becomes `@returns`. Returns `[]`
1997
+ // fallback applies to non-identifier names and a vararg drops its dots, matching
1998
+ // `emitParameter` and `emitRestParameter`) so the tag resolves on hover; a
1999
+ // parameter folded into a rest element union keeps its own tag, so the prose
2000
+ // documenting it survives. A single documented return becomes `@returns`. Returns `[]`
1862
2001
  // for a fully-undocumented function, leaving its emission byte-identical.
1863
2002
  function functionDocLines(
1864
2003
  fn: ApiFunction,
@@ -1866,7 +2005,7 @@ function functionDocLines(
1866
2005
  indent: string = INDENT,
1867
2006
  ): string[] {
1868
2007
  const params = fn.parameters.map((p, index) => ({
1869
- name: safeParamName(p.name, index),
2008
+ name: emittedParamName(p, index),
1870
2009
  doc: htmlToDocText(p.doc),
1871
2010
  }));
1872
2011
  const onlyReturn = fn.returnValues.length === 1 ? fn.returnValues[0] : undefined;
@@ -1928,6 +2067,83 @@ function trailingOptionalCutoff(params: readonly ApiParameter[]): number {
1928
2067
  return cutoff;
1929
2068
  }
1930
2069
 
2070
+ const SCENE_ADDRESS_ALIASES: Readonly<Record<string, string>> = {
2071
+ "game-object": "SceneGameObjectAddress",
2072
+ component: "SceneComponentAddress",
2073
+ either: "SceneAddress",
2074
+ };
2075
+
2076
+ // A classified address slot keeps every mapped member except `string`, which
2077
+ // becomes the matching scene-derived alias. Wrapping `mapType` rather than
2078
+ // rewriting the finished union leaves `mapSlotUnion`'s member ordering and
2079
+ // de-duplication in charge, so only the one token moves.
2080
+ function addressMapType(mapType: (t: string) => string, alias: string): (t: string) => string {
2081
+ return (token) => (token === "string" ? alias : mapType(token));
2082
+ }
2083
+
2084
+ // The mapped slot type of a parameter, without the name, `?` or `| undefined`
2085
+ // decoration — so a parameter folded into a rest element union contributes its
2086
+ // type alone.
2087
+ function parameterType(
2088
+ p: ApiParameter,
2089
+ mapType: (t: string) => string,
2090
+ resolver: TableDocResolver,
2091
+ elementName: string,
2092
+ urlParameters: UrlParameterTable,
2093
+ ): string {
2094
+ const concrete = p.types.filter((t) => t !== "nil");
2095
+ // The table is keyed by the *raw* ref-doc parameter name, not the emitted
2096
+ // `safeParamName` fallback.
2097
+ const alias = SCENE_ADDRESS_ALIASES[classifyUrlParameter(urlParameters, elementName, p.name)];
2098
+ const slotMapType = alias === undefined ? mapType : addressMapType(mapType, alias);
2099
+ return concrete.length > 0
2100
+ ? mapSlotUnion(concrete, p.doc, slotMapType, true, resolver, elementName, "param", p.name)
2101
+ : "unknown";
2102
+ }
2103
+
2104
+ const VARARG_PREFIX = "...";
2105
+
2106
+ function isVarargParameter(p: ApiParameter): boolean {
2107
+ return p.name.startsWith(VARARG_PREFIX);
2108
+ }
2109
+
2110
+ // A vararg's emitted name is its documented name minus the dots (`...commands`
2111
+ // -> `commands`); a bare `...` carries none, so it becomes `args`.
2112
+ function varargParamName(rawName: string, index: number): string {
2113
+ const named = rawName.slice(VARARG_PREFIX.length);
2114
+ return named === "" ? "args" : safeParamName(named, index);
2115
+ }
2116
+
2117
+ function emittedParamName(p: ApiParameter, index: number): string {
2118
+ return isVarargParameter(p) ? varargParamName(p.name, index) : safeParamName(p.name, index);
2119
+ }
2120
+
2121
+ // TS1266 forbids a positional parameter after a rest one, so every parameter
2122
+ // documented *after* the vararg folds into the rest's element union — which is
2123
+ // the only shape that types `editor.execute("git", "log", { out: "capture" })`,
2124
+ // where upstream documents a trailing options table behind the vararg.
2125
+ function emitRestParameter(
2126
+ params: readonly ApiParameter[],
2127
+ varargIndex: number,
2128
+ mapType: (t: string) => string,
2129
+ resolver: TableDocResolver,
2130
+ elementName: string,
2131
+ urlParameters: UrlParameterTable,
2132
+ ): string {
2133
+ const vararg = params[varargIndex];
2134
+ if (vararg === undefined) return "";
2135
+ const members = [
2136
+ ...new Set(
2137
+ params
2138
+ .slice(varargIndex)
2139
+ .map((p) => parameterType(p, mapType, resolver, elementName, urlParameters)),
2140
+ ),
2141
+ ];
2142
+ const first = members[0] ?? "unknown";
2143
+ const element = members.length > 1 ? `(${members.join(" | ")})` : first;
2144
+ return `...${varargParamName(vararg.name, varargIndex)}: ${element}[]`;
2145
+ }
2146
+
1931
2147
  function emitParameter(
1932
2148
  p: ApiParameter,
1933
2149
  index: number,
@@ -1935,13 +2151,10 @@ function emitParameter(
1935
2151
  mapType: (t: string) => string,
1936
2152
  resolver: TableDocResolver,
1937
2153
  elementName: string,
2154
+ urlParameters: UrlParameterTable,
1938
2155
  ): string {
1939
2156
  const name = safeParamName(p.name, index);
1940
- const concrete = p.types.filter((t) => t !== "nil");
1941
- const ts =
1942
- concrete.length > 0
1943
- ? mapSlotUnion(concrete, p.doc, mapType, true, resolver, elementName, "param", p.name)
1944
- : "unknown";
2157
+ const ts = parameterType(p, mapType, resolver, elementName, urlParameters);
1945
2158
  // An interior doc-optional param (a required param follows, so the trailing-`?`
1946
2159
  // projection cannot mark it) keeps its optionality as `| undefined` — TSTL
1947
2160
  // lowers `undefined` to `nil`, the faithful call. Trailing optionals keep the
@@ -2193,7 +2406,7 @@ export function isKnownDefoldTypeToken(token: string): boolean {
2193
2406
  );
2194
2407
  }
2195
2408
 
2196
- function defaultMapType(token: string): string {
2409
+ export function defaultMapType(token: string): string {
2197
2410
  if (Object.hasOwn(DEFOLD_TYPE_MAP, token)) {
2198
2411
  const mapped = DEFOLD_TYPE_MAP[token];
2199
2412
  if (typeof mapped === "string") return mapped;
@@ -7,9 +7,9 @@ import type * as Core from "./core-types";
7
7
  // engine-globals test fails if the two fall out of sync.
8
8
  declare global {
9
9
  /** An opaque, branded handle to a hashed name; see {@link Core.Hash}. */
10
- type Hash = Core.Hash;
10
+ type Hash<S extends string = string> = Core.Hash<S>;
11
11
  /** Hash a string into the engine's `Hash` handle. */
12
- function hash(s: string): Core.Hash;
12
+ function hash<S extends string>(s: S): Core.Hash<S>;
13
13
  /** Render a `Hash` handle as its hexadecimal string. */
14
14
  function hash_to_hex(h: Core.Hash): string;
15
15
  /** Pretty-print any value to the console for debugging. */
@@ -33,17 +33,17 @@ declare global {
33
33
  * ```
34
34
  */
35
35
  function get<P>(): <K extends keyof P>(
36
- url: string | Hash | Url,
36
+ url: SceneAddress | Hash | Url,
37
37
  property: K,
38
38
  options?: GoPropertyOptions,
39
39
  ) => P[K];
40
40
  function get<K extends keyof go.properties>(
41
- url: string | Hash | Url,
41
+ url: SceneAddress | Hash | Url,
42
42
  property: K,
43
43
  options?: GoPropertyOptions,
44
44
  ): go.properties[K];
45
45
  function get(
46
- url: string | Hash | Url,
46
+ url: SceneAddress | Hash | Url,
47
47
  property: string | Hash,
48
48
  options?: GoPropertyOptions,
49
49
  ): number | boolean | Hash | Url | Vector3 | Vector4 | Quaternion | Opaque<"resource">;
@@ -66,19 +66,19 @@ declare global {
66
66
  * ```
67
67
  */
68
68
  function set<P>(): <K extends keyof P>(
69
- url: string | Hash | Url,
69
+ url: SceneAddress | Hash | Url,
70
70
  property: K,
71
71
  value: P[K],
72
72
  options?: GoPropertyOptions,
73
73
  ) => void;
74
74
  function set<K extends keyof go.properties>(
75
- url: string | Hash | Url,
75
+ url: SceneAddress | Hash | Url,
76
76
  property: K,
77
77
  value: go.properties[K],
78
78
  options?: GoPropertyOptions,
79
79
  ): void;
80
80
  function set(
81
- url: string | Hash | Url,
81
+ url: SceneAddress | Hash | Url,
82
82
  property: string | Hash,
83
83
  value: number | boolean | Hash | Url | Vector3 | Vector4 | Quaternion | Opaque<"resource">,
84
84
  options?: GoPropertyOptions,
package/src/index.ts CHANGED
@@ -91,3 +91,15 @@ export {
91
91
  type SignatureOverride,
92
92
  type SignatureStore,
93
93
  } from "./signature-store";
94
+ export {
95
+ classifyUrlParameter,
96
+ collectParameterSlots,
97
+ collectUrlParameterSlots,
98
+ parameterTypesSatisfyClass,
99
+ REQUIRED_TYPES,
100
+ type UrlParameterClass,
101
+ type UrlParameterEntry,
102
+ type UrlParameterSlot,
103
+ type UrlParameterSource,
104
+ type UrlParameterTable,
105
+ } from "./url-parameters";
@@ -31,12 +31,12 @@ declare global {
31
31
  * ```
32
32
  */
33
33
  function post<K extends string>(
34
- receiver: string | Url | Hash,
34
+ receiver: SceneAddress | Url | Hash,
35
35
  message_id: K,
36
36
  message?: MsgPostPayload<K>,
37
37
  ): void;
38
38
  function post(
39
- receiver: string | Url | Hash,
39
+ receiver: SceneAddress | Url | Hash,
40
40
  message_id: Hash,
41
41
  message?: Record<string | number, unknown>,
42
42
  ): void;
@@ -76,7 +76,7 @@ declare global {
76
76
  * ```
77
77
  */
78
78
  function url(): Url;
79
- function url(urlstring: string): Url;
79
+ function url(urlstring: SceneAddress): Url;
80
80
  function url(socket: string | Hash, path: string | Hash, fragment: string | Hash): Url;
81
81
  }
82
82
  }