lecodes-cli 1.0.0 → 2.0.1

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.
Files changed (104) hide show
  1. package/dist/index.js +6211 -3649
  2. package/package.json +12 -8
  3. package/runtime/materials/lightmap.mat +115 -139
  4. package/runtime/materials/particles-quad.mat +6 -2
  5. package/runtime/materials/terrain-lightmap.mat +123 -0
  6. package/runtime/materials/terrain.mat +36 -55
  7. package/runtime/sdk-types.json +1 -1
  8. package/runtime/web/VERSION.json +5 -0
  9. package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
  10. package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
  11. package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
  12. package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
  13. package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
  14. package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
  15. package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
  16. package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
  17. package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
  18. package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
  19. package/runtime/web/assets/map-CyBxOSV0.js +1 -0
  20. package/runtime/web/assets/map-XzoOFt0E.js +2 -0
  21. package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
  22. package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
  23. package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
  24. package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
  25. package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
  26. package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
  27. package/runtime/web/index.html +36 -0
  28. package/runtime/web/player.js +21 -0
  29. package/runtime/web-page/index.html +41 -0
  30. package/runtime/web-page/page.css +1 -0
  31. package/runtime/web-page/page.js +31 -0
  32. package/src/cli/help.ts +25 -0
  33. package/src/cli/index.ts +7 -7
  34. package/src/commands/app/android.ts +3 -1
  35. package/src/commands/app/desktop.ts +1 -1
  36. package/src/commands/app/desktopMac.ts +10 -9
  37. package/src/commands/app/icon.ts +9 -6
  38. package/src/commands/app/index.ts +3 -1
  39. package/src/commands/app/shared.ts +13 -5
  40. package/src/commands/app/templates/android.ts +10 -11
  41. package/src/commands/app/templates/ios.ts +1 -1
  42. package/src/commands/assets.ts +1 -1
  43. package/src/commands/compile.ts +15 -2
  44. package/src/commands/design/index.ts +4 -4
  45. package/src/commands/design/snapshot.ts +3 -3
  46. package/src/commands/desktop.ts +5 -1
  47. package/src/commands/dev.ts +82 -29
  48. package/src/commands/index.ts +64 -60
  49. package/src/commands/init/index.ts +11 -15
  50. package/src/commands/init/templates.ts +64 -21
  51. package/src/commands/lightmap.ts +459 -200
  52. package/src/commands/navmesh.ts +2 -2
  53. package/src/commands/plugin.ts +46 -0
  54. package/src/commands/render.ts +22 -16
  55. package/src/commands/scene.ts +6 -1
  56. package/src/commands/serve.ts +58 -0
  57. package/src/commands/shaders/new.ts +1 -1
  58. package/src/commands/test.ts +402 -398
  59. package/src/commands/update/index.ts +3 -3
  60. package/src/compile/designCompile.ts +1 -1
  61. package/src/compile/headlessBundle.ts +133 -133
  62. package/src/compile/nativeStack.ts +1 -1
  63. package/src/compile/projectCompile.ts +10 -2
  64. package/src/compile/sceneCompile.ts +8 -22
  65. package/src/declarations/lecodes-3d-editor.d.ts +4 -0
  66. package/src/declarations/lecodes-assets.d.ts +1 -1
  67. package/src/declarations/{lecodes-renderer.d.ts → lecodes-headless.d.ts} +3 -3
  68. package/src/dev/clientTemplates.ts +18 -14
  69. package/src/dev/devServer.ts +75 -38
  70. package/src/dev/iosDev.ts +180 -0
  71. package/src/dev/webRunner.ts +82 -14
  72. package/src/hosts/cmgenTool.ts +1 -1
  73. package/src/hosts/desktopRenderer.ts +98 -27
  74. package/src/hosts/desktopScript.ts +150 -35
  75. package/src/hosts/distRoot.ts +1 -1
  76. package/src/hosts/matcTool.ts +1 -1
  77. package/src/hosts/peerInstall.ts +5 -5
  78. package/src/hosts/peers.ts +2 -2
  79. package/src/index.ts +4 -1
  80. package/src/plugin/contract.ts +438 -0
  81. package/src/plugin/emitKotlin.ts +357 -0
  82. package/src/plugin/emitSdk.ts +259 -0
  83. package/src/plugin/emitSwift.ts +336 -0
  84. package/src/plugin/emitWeb.ts +383 -0
  85. package/src/plugin/generate.ts +157 -0
  86. package/src/plugin/lines.ts +28 -0
  87. package/src/plugin/lower.ts +231 -0
  88. package/src/plugin/manifest.ts +192 -0
  89. package/src/project/ignore.ts +164 -163
  90. package/runtime/materials/lightmap-baked-lite.mat +0 -180
  91. package/runtime/materials/lightmap-baked.mat +0 -181
  92. package/runtime/scene-harness.json +0 -1
  93. package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  94. package/runtime/web/assets/basis-C64VHDVD.js +0 -1
  95. package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  96. package/runtime/web/assets/createViewerLite-DHqTFhWB.js +0 -866
  97. package/runtime/web/assets/draco-BiISTFcR.js +0 -118
  98. package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  99. package/runtime/web/assets/index-Q06c6oHx.js +0 -2
  100. package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
  101. package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
  102. package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
  103. package/runtime/web/embed.html +0 -32
  104. package/runtime/web/embed.js +0 -171
@@ -10,7 +10,7 @@ import { dirname, join, resolve } from "node:path"
10
10
  * peers (a project-local `-D` install is invisible to it).
11
11
  * prefix ~/.lecodes/peers (LECODES_PEERS_DIR) — the CLI's own install dir, filled ON FIRST USE by
12
12
  * peerInstall.ts when a command needs a peer that isn't there: no admin rights, and the
13
- * runtime-set peer (lecodes-renderer) is pinned to THIS CLI's version, so the compiler and
13
+ * runtime-set peer (lecodes-headless) is pinned to THIS CLI's version, so the compiler and
14
14
  * the headless host can't drift apart. `@napi-rs/canvas` (PNG output) rides along with it.
15
15
  *
16
16
  * Global wins when both exist (it is what the user chose by hand). `--version` reports each peer's
@@ -18,7 +18,7 @@ import { dirname, join, resolve } from "node:path"
18
18
  * errors or a design screen rendering empty — is visible at a glance. The probe is an export subpath
19
19
  * that resolves to a real file in BOTH the published package and the workspace source. */
20
20
  export const OPTIONAL_PEERS: [name: string, probe: string][] = [
21
- ["lecodes-renderer", "lecodes-renderer/headless"],
21
+ ["lecodes-headless", "lecodes-headless/headless"],
22
22
  ["lecodes-design", "lecodes-design/server"],
23
23
  ["lecodes-3d-editor", "lecodes-3d-editor/server"],
24
24
  ["lecodes-assets", "lecodes-assets/cli"],
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, realpathSync } from "node:fs"
2
2
  import { delimiter, dirname, join } from "node:path"
3
3
  import pkg from "../package.json"
4
+ import { SDK_VERSION } from "lecodes-sdk/version"
4
5
  import { c, log, reportError, runCli } from "./cli"
5
6
  import { commandGroups } from "./commands"
6
7
  import { installedDesktopVersions, selectedDesktopVersion } from "./hosts/desktopRenderer"
@@ -39,7 +40,9 @@ const INSTALLED = STANDALONE && (() => {
39
40
  /** `--version`: the CLI, every separately-installed optional peer (or "not installed"), and the
40
41
  * desktop host / matc `render --desktop` and the shader compile would use (cache state, no network). */
41
42
  const printVersion = (): void => {
42
- const rows: [string, string][] = [["lecodes-cli", pkg.version ?? "unknown"]]
43
+ // The SDK the CLI compiles with: its major is stamped into every bundle (`// sdk:`) and is what a
44
+ // host must match to run it — the number to compare with `lecodes-desktop --version`'s SDK.
45
+ const rows: [string, string][] = [["lecodes-cli", pkg.version ?? "unknown"], ["lecodes-sdk", SDK_VERSION]]
43
46
  for (const [name, probe] of OPTIONAL_PEERS) {
44
47
  const version = peerVersion(name, probe)
45
48
  const where = peerSource(name, probe)
@@ -0,0 +1,438 @@
1
+ // A plugin's `contract.d.ts` → the MODEL every emitter of the plugin generator works from
2
+ // (docs/plans/plugins-plan.md in core). The contract is the wire of one or more channels: per
3
+ // channel an interface that extends a marker (`View<name, Params, Events>` /
4
+ // `Service<name, Params, Events>`), plus the structs, enums and aliases its members name.
5
+ //
6
+ // Read from the SYNTAX tree, no type checker (the gen-bridges rule): the markers, the params and
7
+ // the events are found by what the file says, never by what a name resolves to. That is also why
8
+ // the reader is strict — a contract is written by third parties, and what it does not understand
9
+ // must be an error with a line, never a silent `any`.
10
+ //
11
+ // `ts` is passed in: the CLI has no TypeScript of its own and loads the plugin repo's.
12
+
13
+ import type tsModule from "typescript"
14
+
15
+ export type Scalar = "i32" | "f64" | "boolean" | "string" | "bytes" | "file" | "json" | "void"
16
+
17
+ export type Type =
18
+ | { kind: Scalar }
19
+ /** A struct / an alias this contract declares. */
20
+ | { kind: "ref", name: string }
21
+ /** An inline union of string literals (a named one is an alias of it). */
22
+ | { kind: "literals", values: string[] }
23
+ /** An inline object type. */
24
+ | { kind: "object", fields: Field[] }
25
+ /** `{ [key: string]: T }` */
26
+ | { kind: "map", of: Type }
27
+ | { kind: "list", of: Type }
28
+ | { kind: "tuple", of: Type[] }
29
+ /** Two or more members, never null (see `nullable`). */
30
+ | { kind: "union", of: Type[] }
31
+ /** `T | null` */
32
+ | { kind: "nullable", of: Type }
33
+
34
+ export type Field = { name: string, type: Type, optional: boolean, doc: string }
35
+
36
+ export type Decl =
37
+ | { kind: "struct", name: string, fields: Field[], doc: string, line: number }
38
+ | { kind: "alias", name: string, type: Type, doc: string, line: number }
39
+
40
+ export type Arg = { name: string, type: Type, optional: boolean }
41
+
42
+ export type Method = {
43
+ name: string
44
+ args: Arg[]
45
+ /** What the promise resolves to; `void` for none. */
46
+ result: Type
47
+ /** The contract version that added it (`@since`), 1 when untagged. */
48
+ since: number
49
+ /** The codes the call can fail with (`@rejects`). */
50
+ rejects: string[]
51
+ doc: string
52
+ line: number
53
+ }
54
+
55
+ export type Event = { name: string, payload: Type, since: number, doc: string, line: number }
56
+
57
+ export type Channel = {
58
+ kind: "view" | "service"
59
+ /** What the halves register under: `registerView(name)`. */
60
+ name: string
61
+ /** The interface's name — the prefix of every generated name. */
62
+ iface: string
63
+ /** The params struct's fields (empty when the marker names none). */
64
+ params: Field[]
65
+ /** The interfaces the marker names, as the contract exports them. */
66
+ paramsType?: string
67
+ eventsType?: string
68
+ events: Event[]
69
+ methods: Method[]
70
+ /** The highest `@since` of its members: what a half reports when it registers. */
71
+ version: number
72
+ doc: string
73
+ line: number
74
+ }
75
+
76
+ export type Contract = { file: string, channels: Channel[], decls: Decl[] }
77
+
78
+ export type Problem = { line: number, message: string }
79
+
80
+ export class ContractError extends Error {
81
+ readonly file: string
82
+ readonly problems: Problem[]
83
+ constructor(file: string, problems: Problem[]) {
84
+ super(problems.map(p => `${file}:${p.line}: ${p.message}`).join("\n"))
85
+ this.name = "ContractError"
86
+ this.file = file
87
+ this.problems = problems
88
+ }
89
+ }
90
+
91
+ /** The module a contract imports its vocabulary from, and the names it may take. */
92
+ export const CONTRACT_MODULE = "lecodes-sdk/plugin"
93
+ const MARKERS: Record<string, Channel["kind"]> = { View: "view", Service: "service" }
94
+ const VOCABULARY: Record<string, Scalar> = { i32: "i32", f64: "f64", File: "file", Json: "json" }
95
+
96
+ /** Names a contract may not declare: the generated code of every language would shadow a global. */
97
+ const RESERVED = new Set([
98
+ "Array", "Boolean", "Date", "Error", "File", "Function", "Json", "Map", "Number", "Object", "Promise", "Record",
99
+ "Service", "Set", "String", "Symbol", "Uint8Array", "View",
100
+ // What a native half's generated file names: the types of Swift and Kotlin, the host's support types.
101
+ "Any", "Bool", "Data", "Double", "Int", "Int32", "List", "Pair", "Triple", "Unit", "Void",
102
+ "NoCode", "PluginFile", "Reply", "Wire",
103
+ ])
104
+
105
+ /** A method or an argument becomes an identifier in TypeScript, Swift and Kotlin. */
106
+ const KEYWORDS = new Set([
107
+ "as", "break", "case", "catch", "class", "const", "continue", "default", "delete", "do", "else", "enum", "export",
108
+ "extends", "false", "finally", "for", "fun", "func", "function", "if", "import", "in", "init", "interface", "internal",
109
+ "is", "let", "new", "null", "object", "package", "private", "protocol", "public", "return", "self", "static", "struct",
110
+ "super", "switch", "this", "throw", "true", "try", "typeof", "val", "var", "void", "when", "while", "with",
111
+ ])
112
+ /** Members of the wrapper's bases a method would replace. */
113
+ const TAKEN = new Set(["call", "on", "off", "open", "close", "destroy", "style", "constructor"])
114
+
115
+ const TAGS = new Set(["since", "rejects"])
116
+
117
+ export const readContract = (ts: typeof tsModule, file: string, text: string): Contract => {
118
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
119
+ const problems: Problem[] = []
120
+ const lineOf = (n: tsModule.Node): number => sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1
121
+ const fail = (n: tsModule.Node, message: string): void => { problems.push({ line: lineOf(n), message }) }
122
+
123
+ // ---- pass 1: what the file declares and imports -------------------------------------------------
124
+ const imported = new Set<string>()
125
+ const interfaces = new Map<string, tsModule.InterfaceDeclaration>()
126
+ const aliases = new Map<string, tsModule.TypeAliasDeclaration>()
127
+
128
+ for (const s of sf.statements) {
129
+ if (ts.isImportDeclaration(s)) {
130
+ const from = ts.isStringLiteral(s.moduleSpecifier) ? s.moduleSpecifier.text : ""
131
+ if (from !== CONTRACT_MODULE) { fail(s, `a contract imports from "${CONTRACT_MODULE}" only (got "${from}")`); continue }
132
+ const clause = s.importClause
133
+ const named = clause?.namedBindings && ts.isNamedImports(clause.namedBindings) ? clause.namedBindings : undefined
134
+ if (!clause || !named || clause.name) { fail(s, `write the import as \`import type { … } from "${CONTRACT_MODULE}"\``); continue }
135
+ for (const e of named.elements) {
136
+ if (e.propertyName) { fail(e, `import "${e.propertyName.text}" under its own name — the generator reads names, not what they resolve to`); continue }
137
+ const name = e.name.text
138
+ if (!(name in MARKERS) && !(name in VOCABULARY)) fail(e, `"${CONTRACT_MODULE}" has no "${name}" for a contract (it has ${[...Object.keys(MARKERS), ...Object.keys(VOCABULARY)].join(", ")})`)
139
+ imported.add(name)
140
+ }
141
+ continue
142
+ }
143
+ if (ts.isInterfaceDeclaration(s) || ts.isTypeAliasDeclaration(s)) {
144
+ const name = s.name.text
145
+ if (RESERVED.has(name)) fail(s.name, `"${name}" is taken by a global or by the contract's vocabulary — pick another name`)
146
+ if (interfaces.has(name) || aliases.has(name)) fail(s.name, `"${name}" is declared twice`)
147
+ if (s.typeParameters?.length) fail(s.name, `"${name}": a contract declares no generic types`)
148
+ if (ts.isInterfaceDeclaration(s)) interfaces.set(name, s)
149
+ else aliases.set(name, s)
150
+ continue
151
+ }
152
+ fail(s, "a contract holds type imports, interfaces and type aliases only")
153
+ }
154
+
155
+ // ---- docs and tags ------------------------------------------------------------------------------
156
+ const docOf = (n: tsModule.Node): string => {
157
+ const docs = ts.getJSDocCommentsAndTags(n).filter(ts.isJSDoc)
158
+ return docs.map(d => ts.getTextOfJSDocComment(d.comment) ?? "").filter(Boolean).join("\n").trim()
159
+ }
160
+ const tagsOf = (n: tsModule.Node): { since: number, rejects: string[] } => {
161
+ let since = 1
162
+ const rejects: string[] = []
163
+ for (const tag of ts.getJSDocTags(n)) {
164
+ const name = tag.tagName.text
165
+ const value = (ts.getTextOfJSDocComment(tag.comment) ?? "").trim()
166
+ if (!TAGS.has(name)) { fail(tag, `unknown tag @${name} (a contract knows ${[...TAGS].map(t => "@" + t).join(", ")})`); continue }
167
+ if (name === "since") {
168
+ if (!/^[1-9]\d*$/.test(value)) fail(tag, `@since takes a contract version: a whole number from 1 (got "${value}")`)
169
+ else since = Number(value)
170
+ } else {
171
+ const codes = value.split(/[\s,]+/).filter(Boolean)
172
+ if (codes.length === 0) fail(tag, "@rejects lists the codes a call can fail with")
173
+ for (const code of codes) {
174
+ if (!/^[a-z][A-Za-z0-9]*$/.test(code)) fail(tag, `@rejects: "${code}" is not a code (camelCase, a letter first)`)
175
+ else if (!rejects.includes(code)) rejects.push(code)
176
+ }
177
+ }
178
+ }
179
+ return { since, rejects }
180
+ }
181
+
182
+ // ---- types ---------------------------------------------------------------------------------------
183
+ const VOID: Type = { kind: "void" }
184
+ const JSON_TYPE: Type = { kind: "json" }
185
+
186
+ const readFields = (members: tsModule.NodeArray<tsModule.TypeElement>, owner: string): Field[] => {
187
+ const fields: Field[] = []
188
+ for (const m of members) {
189
+ if (ts.isMethodSignature(m)) { fail(m, `${owner}: a method belongs to an interface that extends View<…> or Service<…>`); continue }
190
+ if (ts.isIndexSignatureDeclaration(m)) { fail(m, `${owner}: an index signature stands alone — \`{ [key: string]: T }\` as the member's whole type`); continue }
191
+ if (!ts.isPropertySignature(m) || !m.type || !(ts.isIdentifier(m.name) || ts.isStringLiteral(m.name))) { fail(m, `${owner}: a member is \`name: Type\` or \`name?: Type\``); continue }
192
+ const name = m.name.text
193
+ if (fields.some(f => f.name === name)) fail(m, `${owner}: "${name}" is declared twice`)
194
+ fields.push({ name, type: readType(m.type, `${owner}.${name}`), optional: !!m.questionToken, doc: docOf(m) })
195
+ }
196
+ return fields
197
+ }
198
+
199
+ const readType = (n: tsModule.TypeNode, where: string): Type => {
200
+ if (ts.isParenthesizedTypeNode(n)) return readType(n.type, where)
201
+ switch (n.kind) {
202
+ case ts.SyntaxKind.StringKeyword: return { kind: "string" }
203
+ case ts.SyntaxKind.BooleanKeyword: return { kind: "boolean" }
204
+ case ts.SyntaxKind.VoidKeyword: fail(n, `${where}: void is the payload of an event or the result of a call, nothing else`); return VOID
205
+ case ts.SyntaxKind.NumberKeyword: fail(n, `${where}: say i32 or f64 — the native half must know what it reads`); return { kind: "f64" }
206
+ case ts.SyntaxKind.AnyKeyword:
207
+ case ts.SyntaxKind.UnknownKeyword:
208
+ case ts.SyntaxKind.ObjectKeyword: fail(n, `${where}: say Json for a value the generator passes through untyped`); return JSON_TYPE
209
+ case ts.SyntaxKind.UndefinedKeyword: fail(n, `${where}: an absent value is an optional member or argument (\`name?:\`), not undefined`); return JSON_TYPE
210
+ }
211
+ if (ts.isArrayTypeNode(n)) return { kind: "list", of: readType(n.elementType, where) }
212
+ if (ts.isTupleTypeNode(n)) {
213
+ // A pair or a triple is a type of every language a half is written in; a longer one is not
214
+ // (Kotlin has Pair and Triple), and its members deserve names by then.
215
+ if (n.elements.length < 2 || n.elements.length > 3) fail(n, `${where}: a tuple has two or three elements — say an interface for more`)
216
+ return { kind: "tuple", of: n.elements.map((e, i) => {
217
+ if (ts.isNamedTupleMember(e) || ts.isOptionalTypeNode(e) || ts.isRestTypeNode(e)) { fail(e, `${where}: a tuple's elements are plain types`); return JSON_TYPE }
218
+ return readType(e, `${where}[${i}]`)
219
+ }) }
220
+ }
221
+ if (ts.isTypeLiteralNode(n)) {
222
+ const index = n.members.length === 1 && ts.isIndexSignatureDeclaration(n.members[0]!) ? n.members[0] as tsModule.IndexSignatureDeclaration : undefined
223
+ if (index) {
224
+ const key = index.parameters[0]?.type
225
+ if (!key || key.kind !== ts.SyntaxKind.StringKeyword) fail(index, `${where}: a map's key is a string`)
226
+ return { kind: "map", of: readType(index.type, where) }
227
+ }
228
+ return { kind: "object", fields: readFields(n.members, where) }
229
+ }
230
+ if (ts.isLiteralTypeNode(n)) {
231
+ if (n.literal.kind === ts.SyntaxKind.NullKeyword) { fail(n, `${where}: null stands in a union — \`T | null\``); return JSON_TYPE }
232
+ if (ts.isStringLiteral(n.literal)) return { kind: "literals", values: [n.literal.text] }
233
+ fail(n, `${where}: a literal type is a string ("front"), not a number or a boolean`)
234
+ return JSON_TYPE
235
+ }
236
+ if (ts.isUnionTypeNode(n)) return readUnion(n, where)
237
+ if (ts.isTypeReferenceNode(n) && ts.isIdentifier(n.typeName)) return readReference(n, n.typeName.text, where)
238
+ fail(n, `${where}: \`${n.getText(sf)}\` is not a type a contract can carry`)
239
+ return JSON_TYPE
240
+ }
241
+
242
+ const readUnion = (n: tsModule.UnionTypeNode, where: string): Type => {
243
+ let nullable = false
244
+ const literals: string[] = []
245
+ const others: Type[] = []
246
+ // A nested union (parentheses) flattens into this one: its literals merge, its null is ours.
247
+ const absorb = (t: Type): void => {
248
+ if (t.kind === "literals") { for (const v of t.values) if (!literals.includes(v)) literals.push(v) }
249
+ else if (t.kind === "nullable") { nullable = true; absorb(t.of) }
250
+ else if (t.kind === "union") t.of.forEach(absorb)
251
+ else others.push(t)
252
+ }
253
+ for (const member of n.types) {
254
+ const m = ts.isParenthesizedTypeNode(member) ? member.type : member
255
+ if (ts.isLiteralTypeNode(m) && m.literal.kind === ts.SyntaxKind.NullKeyword) { nullable = true; continue }
256
+ absorb(readType(m, where))
257
+ }
258
+ if (literals.length > 0 && others.some(t => t.kind === "string")) fail(n, `${where}: string already holds every literal of this union`)
259
+ const members: Type[] = literals.length > 0 ? [{ kind: "literals", values: literals }, ...others] : others
260
+ if (members.length === 0) { fail(n, `${where}: null alone is not a type`); return JSON_TYPE }
261
+ const inner: Type = members.length === 1 ? members[0]! : { kind: "union", of: members }
262
+ return nullable ? { kind: "nullable", of: inner } : inner
263
+ }
264
+
265
+ const readReference = (n: tsModule.TypeReferenceNode, name: string, where: string): Type => {
266
+ const args = n.typeArguments ?? []
267
+ if (name === "Uint8Array") return { kind: "bytes" }
268
+ if (name === "Array" && args.length === 1) return { kind: "list", of: readType(args[0]!, where) }
269
+ if (name === "Record" && args.length === 2) {
270
+ if (args[0]!.kind !== ts.SyntaxKind.StringKeyword) fail(args[0]!, `${where}: a map's key is a string`)
271
+ return { kind: "map", of: readType(args[1]!, where) }
272
+ }
273
+ if (name === "Promise") { fail(n, `${where}: a promise is the result of a call, nothing else`); return JSON_TYPE }
274
+ if (args.length > 0) { fail(n, `${where}: \`${n.getText(sf)}\` — a contract names no generic type but Array and Record`); return JSON_TYPE }
275
+ if (name in VOCABULARY) {
276
+ if (!imported.has(name)) fail(n, `${where}: import ${name} from "${CONTRACT_MODULE}"`)
277
+ return { kind: VOCABULARY[name]! }
278
+ }
279
+ const iface = interfaces.get(name)
280
+ if (iface && markerOf(iface)) { fail(n, `${where}: ${name} is a channel, not a value`); return JSON_TYPE }
281
+ if (iface && isEventsInterface(name)) { fail(n, `${where}: ${name} is the events of a channel, not a value — name the payload's own type`); return JSON_TYPE }
282
+ if (iface || aliases.has(name)) return { kind: "ref", name }
283
+ fail(n, `${where}: ${name} is not declared in this contract`)
284
+ return JSON_TYPE
285
+ }
286
+
287
+ // ---- channels ------------------------------------------------------------------------------------
288
+ type Marker = { kind: Channel["kind"], node: tsModule.ExpressionWithTypeArguments }
289
+ const markerOf = (d: tsModule.InterfaceDeclaration): Marker | undefined => {
290
+ for (const clause of d.heritageClauses ?? []) {
291
+ for (const t of clause.types) {
292
+ const name = ts.isIdentifier(t.expression) ? t.expression.text : ""
293
+ if (name in MARKERS) return { kind: MARKERS[name]!, node: t }
294
+ }
295
+ }
296
+ return undefined
297
+ }
298
+
299
+ const isEventsInterface = (name: string): boolean => {
300
+ for (const d of interfaces.values()) {
301
+ const arg = markerOf(d)?.node.typeArguments?.[2]
302
+ if (arg && ts.isTypeReferenceNode(arg) && ts.isIdentifier(arg.typeName) && arg.typeName.text === name) return true
303
+ }
304
+ return false
305
+ }
306
+
307
+ /** The interface a marker argument names, as `what` ("params" / "events"). */
308
+ const markerInterface = (arg: tsModule.TypeNode | undefined, what: string, owner: string): tsModule.InterfaceDeclaration | undefined => {
309
+ if (!arg) return undefined
310
+ const name = ts.isTypeReferenceNode(arg) && ts.isIdentifier(arg.typeName) && !arg.typeArguments?.length ? arg.typeName.text : ""
311
+ const d = name ? interfaces.get(name) : undefined
312
+ if (!d) { fail(arg, `${owner}: the ${what} of a channel are an interface of this contract (got \`${arg.getText(sf)}\`)`); return undefined }
313
+ if (markerOf(d)) { fail(arg, `${owner}: ${name} is a channel, it cannot be the ${what} of one`); return undefined }
314
+ if (d.heritageClauses?.length) fail(d.name, `${name}: the ${what} of a channel extend nothing`)
315
+ return d
316
+ }
317
+
318
+ const readEvents = (d: tsModule.InterfaceDeclaration): Event[] => {
319
+ const events: Event[] = []
320
+ for (const m of d.members) {
321
+ const name = ts.isPropertySignature(m) && (ts.isIdentifier(m.name) || ts.isStringLiteral(m.name)) ? m.name.text : ""
322
+ if (!ts.isPropertySignature(m) || !m.type || !name) { fail(m, `${d.name.text}: an event is \`name: Payload\` — \`void\` for none. It is not written as a method`); continue }
323
+ if (m.questionToken) fail(m, `${d.name.text}.${name}: an event is not optional`)
324
+ if (events.some(e => e.name === name)) fail(m, `${d.name.text}: "${name}" is declared twice`)
325
+ const payload = m.type.kind === ts.SyntaxKind.VoidKeyword ? VOID : readType(m.type, `${d.name.text}.${name}`)
326
+ const { since, rejects } = tagsOf(m)
327
+ if (rejects.length) fail(m, `${d.name.text}.${name}: an event rejects nothing`)
328
+ events.push({ name, payload, since, doc: docOf(m), line: lineOf(m) })
329
+ }
330
+ return events
331
+ }
332
+
333
+ const readMethods = (d: tsModule.InterfaceDeclaration): Method[] => {
334
+ const methods: Method[] = []
335
+ for (const m of d.members) {
336
+ const owner = d.name.text
337
+ if (!ts.isMethodSignature(m) || !ts.isIdentifier(m.name)) { fail(m, `${owner}: a channel holds methods only — a value it sends is an event, a value it starts with is a param`); continue }
338
+ const name = m.name.text
339
+ const where = `${owner}.${name}`
340
+ if (methods.some(x => x.name === name)) fail(m, `${owner}: "${name}" is declared twice (a contract has no overloads)`)
341
+ if (KEYWORDS.has(name) || name.startsWith("_")) fail(m.name, `${where}: "${name}" is a keyword of a language the contract is generated to — pick another name`)
342
+ else if (TAKEN.has(name)) fail(m.name, `${where}: "${name}" is a member of every wrapper already — pick another name`)
343
+ if (m.questionToken) fail(m, `${where}: a method is not optional — a method added later is tagged @since`)
344
+ if (m.typeParameters?.length) fail(m, `${where}: a method is not generic`)
345
+
346
+ const args: Arg[] = []
347
+ for (const p of m.parameters) {
348
+ if (!ts.isIdentifier(p.name) || !p.type || p.dotDotDotToken) { fail(p, `${where}: an argument is \`name: Type\` or \`name?: Type\``); continue }
349
+ const optional = !!p.questionToken
350
+ if (KEYWORDS.has(p.name.text)) fail(p.name, `${where}: "${p.name.text}" is a keyword of a language the contract is generated to — pick another name`)
351
+ if (!optional && args.some(a => a.optional)) fail(p, `${where}: "${p.name.text}" follows an optional argument — optional ones come last`)
352
+ args.push({ name: p.name.text, type: readType(p.type, `${where}(${p.name.text})`), optional })
353
+ }
354
+
355
+ let result: Type = VOID
356
+ const r = m.type
357
+ const promised = r && ts.isTypeReferenceNode(r) && ts.isIdentifier(r.typeName) && r.typeName.text === "Promise" && r.typeArguments?.length === 1 ? r.typeArguments[0]! : undefined
358
+ if (!promised) fail(m, `${where}: a call crosses to the host and back — it returns Promise<…>`)
359
+ else if (promised.kind !== ts.SyntaxKind.VoidKeyword) result = readType(promised, `${where}()`)
360
+
361
+ methods.push({ name, args, result, ...tagsOf(m), doc: docOf(m), line: lineOf(m) })
362
+ }
363
+ return methods
364
+ }
365
+
366
+ // ---- pass 2: the declarations ---------------------------------------------------------------------
367
+ const channels: Channel[] = []
368
+ const decls: Decl[] = []
369
+ const paramsParts = new Set<string>()
370
+ /** An events interface is not a struct (a payload may be void): it lives in its channel only. */
371
+ const eventsParts = new Set<string>()
372
+
373
+ for (const d of interfaces.values()) {
374
+ const marker = markerOf(d)
375
+ if (!marker) continue
376
+ const iface = d.name.text
377
+ const heritage = (d.heritageClauses ?? []).flatMap(c => [...c.types])
378
+ if (heritage.length > 1) fail(d.name, `${iface}: a channel extends its marker and nothing else`)
379
+ const [nameArg, paramsArg, eventsArg, ...rest] = marker.node.typeArguments ?? []
380
+ if (rest.length) fail(marker.node, `${iface}: the marker takes a name, the params, the events`)
381
+ const markerName = ts.isIdentifier(marker.node.expression) ? marker.node.expression.text : ""
382
+ if (!imported.has(markerName)) fail(marker.node, `${iface}: import ${markerName} from "${CONTRACT_MODULE}"`)
383
+
384
+ let name = ""
385
+ if (nameArg && ts.isLiteralTypeNode(nameArg) && ts.isStringLiteral(nameArg.literal)) name = nameArg.literal.text
386
+ else fail(marker.node, `${iface}: the marker's first argument is the channel's name, a string literal — what the halves register under`)
387
+ if (name && !/^[a-z][A-Za-z0-9]*(\.[a-z][A-Za-z0-9]*)*$/.test(name)) fail(nameArg!, `${iface}: "${name}" is not a channel name (camelCase, a vendor prefix before a dot: "acme.bluetooth")`)
388
+ if (name && channels.some(c => c.name === name && c.kind === marker.kind)) fail(nameArg!, `${iface}: the ${marker.kind} "${name}" is declared twice`)
389
+
390
+ const paramsDecl = markerInterface(paramsArg, "params", iface)
391
+ const eventsDecl = markerInterface(eventsArg, "events", iface)
392
+ if (paramsDecl) paramsParts.add(paramsDecl.name.text)
393
+ if (eventsDecl) eventsParts.add(eventsDecl.name.text)
394
+
395
+ // A view is mounted by the host whatever the app passed: params it could not read have no one to
396
+ // reject to, so every one of them can be left out.
397
+ for (const m of paramsDecl?.members ?? []) {
398
+ if (ts.isPropertySignature(m) && !m.questionToken) fail(m, `${paramsDecl!.name.text}: a param is optional (\`${m.name.getText(sf)}?:\`) — the half falls back on its default`)
399
+ }
400
+ const events = eventsDecl ? readEvents(eventsDecl) : []
401
+ const methods = readMethods(d)
402
+ const { since, rejects } = tagsOf(d)
403
+ if (since !== 1 || rejects.length) fail(d.name, `${iface}: @since and @rejects tag a method or an event, not the channel`)
404
+ channels.push({
405
+ kind: marker.kind, name, iface,
406
+ params: paramsDecl ? readFields(paramsDecl.members, paramsDecl.name.text) : [],
407
+ ...(paramsDecl ? { paramsType: paramsDecl.name.text } : {}),
408
+ ...(eventsDecl ? { eventsType: eventsDecl.name.text } : {}),
409
+ events, methods,
410
+ version: Math.max(1, ...methods.map(m => m.since), ...events.map(e => e.since)),
411
+ doc: docOf(d), line: lineOf(d),
412
+ })
413
+ }
414
+
415
+ for (const d of interfaces.values()) {
416
+ if (markerOf(d)) continue
417
+ const name = d.name.text
418
+ if (eventsParts.has(name)) continue
419
+ if (d.heritageClauses?.length && !paramsParts.has(name)) fail(d.name, `${name}: a struct extends nothing — repeat the members or name the other struct as a member`)
420
+ // A params interface is read with its channel AND stays a struct a member may name.
421
+ decls.push({ kind: "struct", name, fields: readFields(d.members, name), doc: docOf(d), line: lineOf(d) })
422
+ }
423
+ for (const a of aliases.values()) {
424
+ decls.push({ kind: "alias", name: a.name.text, type: readType(a.type, a.name.text), doc: docOf(a), line: lineOf(a) })
425
+ }
426
+
427
+ if (channels.length === 0 && problems.length === 0) {
428
+ problems.push({ line: 1, message: "a contract declares a channel: an interface that extends View<…> or Service<…>" })
429
+ }
430
+
431
+ if (problems.length) {
432
+ // A params interface is read twice (its channel, its struct): one report per finding.
433
+ const seen = new Set<string>()
434
+ const unique = problems.filter(p => { const k = `${p.line}:${p.message}`; if (seen.has(k)) return false; seen.add(k); return true })
435
+ throw new ContractError(file, unique.sort((a, b) => a.line - b.line))
436
+ }
437
+ return { file, channels, decls: decls.sort((a, b) => a.line - b.line) }
438
+ }