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.
- package/dist/index.js +6211 -3649
- package/package.json +12 -8
- package/runtime/materials/lightmap.mat +115 -139
- package/runtime/materials/particles-quad.mat +6 -2
- package/runtime/materials/terrain-lightmap.mat +123 -0
- package/runtime/materials/terrain.mat +36 -55
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +5 -0
- package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
- package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
- package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
- package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
- package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
- package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
- package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
- package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
- package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
- package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
- package/runtime/web/assets/map-CyBxOSV0.js +1 -0
- package/runtime/web/assets/map-XzoOFt0E.js +2 -0
- package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
- package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
- package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
- package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
- package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
- package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
- package/runtime/web/index.html +36 -0
- package/runtime/web/player.js +21 -0
- package/runtime/web-page/index.html +41 -0
- package/runtime/web-page/page.css +1 -0
- package/runtime/web-page/page.js +31 -0
- package/src/cli/help.ts +25 -0
- package/src/cli/index.ts +7 -7
- package/src/commands/app/android.ts +3 -1
- package/src/commands/app/desktop.ts +1 -1
- package/src/commands/app/desktopMac.ts +10 -9
- package/src/commands/app/icon.ts +9 -6
- package/src/commands/app/index.ts +3 -1
- package/src/commands/app/shared.ts +13 -5
- package/src/commands/app/templates/android.ts +10 -11
- package/src/commands/app/templates/ios.ts +1 -1
- package/src/commands/assets.ts +1 -1
- package/src/commands/compile.ts +15 -2
- package/src/commands/design/index.ts +4 -4
- package/src/commands/design/snapshot.ts +3 -3
- package/src/commands/desktop.ts +5 -1
- package/src/commands/dev.ts +82 -29
- package/src/commands/index.ts +64 -60
- package/src/commands/init/index.ts +11 -15
- package/src/commands/init/templates.ts +64 -21
- package/src/commands/lightmap.ts +459 -200
- package/src/commands/navmesh.ts +2 -2
- package/src/commands/plugin.ts +46 -0
- package/src/commands/render.ts +22 -16
- package/src/commands/scene.ts +6 -1
- package/src/commands/serve.ts +58 -0
- package/src/commands/shaders/new.ts +1 -1
- package/src/commands/test.ts +402 -398
- package/src/commands/update/index.ts +3 -3
- package/src/compile/designCompile.ts +1 -1
- package/src/compile/headlessBundle.ts +133 -133
- package/src/compile/nativeStack.ts +1 -1
- package/src/compile/projectCompile.ts +10 -2
- package/src/compile/sceneCompile.ts +8 -22
- package/src/declarations/lecodes-3d-editor.d.ts +4 -0
- package/src/declarations/lecodes-assets.d.ts +1 -1
- package/src/declarations/{lecodes-renderer.d.ts → lecodes-headless.d.ts} +3 -3
- package/src/dev/clientTemplates.ts +18 -14
- package/src/dev/devServer.ts +75 -38
- package/src/dev/iosDev.ts +180 -0
- package/src/dev/webRunner.ts +82 -14
- package/src/hosts/cmgenTool.ts +1 -1
- package/src/hosts/desktopRenderer.ts +98 -27
- package/src/hosts/desktopScript.ts +150 -35
- package/src/hosts/distRoot.ts +1 -1
- package/src/hosts/matcTool.ts +1 -1
- package/src/hosts/peerInstall.ts +5 -5
- package/src/hosts/peers.ts +2 -2
- package/src/index.ts +4 -1
- package/src/plugin/contract.ts +438 -0
- package/src/plugin/emitKotlin.ts +357 -0
- package/src/plugin/emitSdk.ts +259 -0
- package/src/plugin/emitSwift.ts +336 -0
- package/src/plugin/emitWeb.ts +383 -0
- package/src/plugin/generate.ts +157 -0
- package/src/plugin/lines.ts +28 -0
- package/src/plugin/lower.ts +231 -0
- package/src/plugin/manifest.ts +192 -0
- package/src/project/ignore.ts +164 -163
- package/runtime/materials/lightmap-baked-lite.mat +0 -180
- package/runtime/materials/lightmap-baked.mat +0 -181
- package/runtime/scene-harness.json +0 -1
- package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
- package/runtime/web/assets/basis-C64VHDVD.js +0 -1
- package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/runtime/web/assets/createViewerLite-DHqTFhWB.js +0 -866
- package/runtime/web/assets/draco-BiISTFcR.js +0 -118
- package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
- package/runtime/web/assets/index-Q06c6oHx.js +0 -2
- package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
- package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
- package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
- package/runtime/web/embed.html +0 -32
- package/runtime/web/embed.js +0 -171
package/src/hosts/peers.ts
CHANGED
|
@@ -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-
|
|
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-
|
|
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
|
-
|
|
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
|
+
}
|