lecodes-cli 0.20.2 → 2.0.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/README.md +56 -57
- package/dist/index.js +11052 -7592
- package/package.json +14 -8
- package/runtime/materials/decal-relief.mat +12 -1
- package/runtime/materials/decal.mat +17 -3
- package/runtime/materials/lightmap.mat +117 -138
- 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/api.ts +1 -302
- package/src/cli/args.ts +37 -0
- package/src/cli/command.ts +113 -0
- package/src/cli/errors.ts +13 -0
- package/src/cli/help.ts +158 -0
- package/src/cli/index.ts +7 -0
- package/src/cli/output.ts +102 -0
- package/src/cli/run.ts +106 -0
- package/src/commands/{appAndroid.ts → app/android.ts} +454 -43
- package/src/commands/{appDesktop.ts → app/desktop.ts} +64 -33
- package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +34 -27
- package/src/commands/{appIcon.ts → app/icon.ts} +11 -8
- package/src/commands/{app.ts → app/index.ts} +400 -290
- package/src/commands/{appShared.ts → app/shared.ts} +43 -44
- package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +72 -39
- package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
- package/src/commands/{appTemplates.ts → app/templates/ios.ts} +2 -2
- package/src/commands/assets.ts +23 -21
- package/src/commands/clone.ts +48 -51
- package/src/commands/compile.ts +76 -89
- package/src/commands/create.ts +46 -47
- package/src/commands/design/comments.ts +361 -0
- package/src/commands/design/context.ts +101 -0
- package/src/commands/design/index.ts +391 -0
- package/src/commands/design/snapshot.ts +134 -0
- package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
- package/src/commands/desktop.ts +94 -82
- package/src/commands/dev.ts +260 -182
- package/src/commands/diff.ts +39 -51
- package/src/commands/index.ts +64 -0
- package/src/commands/{init.ts → init/index.ts} +195 -191
- package/src/commands/{projectTemplates.ts → init/templates.ts} +64 -21
- package/src/commands/install.ts +80 -67
- package/src/commands/lightmap.ts +543 -252
- package/src/commands/link.ts +87 -94
- package/src/commands/login.ts +25 -23
- package/src/commands/navmesh.ts +169 -139
- package/src/commands/plugin.ts +46 -0
- package/src/commands/pn.ts +201 -244
- package/src/commands/pull.ts +49 -56
- package/src/commands/push.ts +48 -54
- package/src/commands/render.ts +176 -111
- package/src/commands/scene.ts +63 -55
- package/src/commands/serve.ts +58 -0
- package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
- package/src/commands/{shadersNew.ts → shaders/new.ts} +3 -3
- package/src/commands/shared.ts +79 -0
- package/src/commands/status.ts +22 -26
- package/src/commands/test.ts +94 -63
- package/src/commands/thumbs.ts +185 -175
- package/src/commands/update/index.ts +226 -0
- package/src/commands/{types.ts → update/types.ts} +31 -22
- package/src/compile/collect.ts +5 -5
- package/src/compile/collectLocal.ts +3 -3
- package/src/compile/designCompile.ts +4 -4
- package/src/compile/headlessBundle.ts +16 -12
- package/src/compile/nativeStack.ts +1 -1
- package/src/compile/projectCompile.ts +12 -4
- package/src/compile/sceneCompile.ts +14 -26
- package/src/compile/screenEntry.ts +123 -127
- package/src/compile/shaders.ts +3 -3
- package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +4 -0
- package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +1 -1
- package/src/{lecodes-renderer.d.ts → declarations/lecodes-headless.d.ts} +3 -3
- package/src/dev/androidDev.ts +1 -1
- package/src/dev/clientTemplates.ts +18 -14
- package/src/dev/devServer.ts +78 -41
- package/src/dev/iosDev.ts +180 -0
- package/src/dev/webRunner.ts +83 -15
- package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +2 -2
- package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +104 -32
- package/src/{desktopScript.ts → hosts/desktopScript.ts} +151 -36
- package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
- package/src/{matcTool.ts → hosts/matcTool.ts} +2 -2
- package/src/{peerInstall.ts → hosts/peerInstall.ts} +7 -7
- package/src/{peers.ts → hosts/peers.ts} +2 -2
- package/src/{releases.ts → hosts/releases.ts} +1 -1
- package/src/index.ts +35 -480
- package/src/platform/api.ts +302 -0
- package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
- package/src/{config.ts → platform/config.ts} +1 -1
- package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
- 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/{projectEnv.ts → project/env.ts} +33 -2
- package/src/{ignore.ts → project/ignore.ts} +164 -163
- package/src/{localFiles.ts → project/localFiles.ts} +1 -1
- package/src/{manifest.ts → project/manifest.ts} +1 -1
- package/src/{project.ts → project/materialize.ts} +2 -2
- package/src/project/paths.ts +20 -0
- package/src/{textDiff.ts → project/textDiff.ts} +1 -1
- package/src/{types.ts → project/types.ts} +0 -0
- package/runtime/materials/lightmap-baked-lite.mat +0 -175
- package/runtime/materials/lightmap-baked.mat +0 -176
- 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-Ct_PZdof.js +0 -852
- package/runtime/web/assets/draco-BiISTFcR.js +0 -118
- package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
- package/runtime/web/assets/index-BMt7AnC5.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/commands/design.ts +0 -846
- package/src/commands/update.ts +0 -211
- package/src/util.ts +0 -146
- /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
- /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
- /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// The generator's emitter for `web/`: a contract → `<id>.gen.ts`, everything of the web half that is
|
|
2
|
+
// not the plugin's own work (docs/plans/plugins-plan.md in core) — the twin of emitSwift.ts and
|
|
3
|
+
// emitKotlin.ts, over the same lowered model:
|
|
4
|
+
// - the contract's types, as the half reads and writes them;
|
|
5
|
+
// - `<Name>Code` + `<Name>Error`: the codes the channel rejects with, thrown;
|
|
6
|
+
// - `<Name>Events`: one function per event;
|
|
7
|
+
// - `<Name>Plugin`: the INTERFACE the half implements — one method per call, a value or a promise;
|
|
8
|
+
// - `register<Name>(host, make)` + the glue that reads a call's arguments, refuses a malformed one
|
|
9
|
+
// and hands the call over.
|
|
10
|
+
//
|
|
11
|
+
// The wire carries VALUES, and on the web the app's own: a Uint8Array crosses as it is, a File as the
|
|
12
|
+
// handle of a host buffer.
|
|
13
|
+
//
|
|
14
|
+
// The file imports NOTHING. A web half depends on its generated file, on the document it is handed
|
|
15
|
+
// and on the browser — which is what lets another host (web-lite) and a prebuilt third-party module
|
|
16
|
+
// take the same half. What the host is asked for is a SHAPE (`PluginHost`), declared in the file.
|
|
17
|
+
|
|
18
|
+
import { EmitError } from "./emitSdk"
|
|
19
|
+
import { waysOf, type L, type LChannel, type LDecl, type LField, type Lowered, type Ways } from "./lower"
|
|
20
|
+
|
|
21
|
+
const str = (value: string): string => JSON.stringify(value)
|
|
22
|
+
const key = (name: string): string => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : str(name)
|
|
23
|
+
const at = (o: string, name: string): string => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? `${o}.${name}` : `${o}[${str(name)}]`
|
|
24
|
+
|
|
25
|
+
const doc = (text: string, indent = ""): string => {
|
|
26
|
+
if (!text) return ""
|
|
27
|
+
const lines = text.split("\n").map(l => l.trim())
|
|
28
|
+
if (lines.length === 1) return `${indent}/** ${lines[0]} */\n`
|
|
29
|
+
return `${indent}/** ${lines[0]}\n${lines.slice(1).map(l => `${indent} * ${l}`.trimEnd()).join("\n")} */\n`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A type as the half names it. An optional that is not a member's or an argument's is a null. */
|
|
33
|
+
export const webType = (t: L): string => {
|
|
34
|
+
switch (t.kind) {
|
|
35
|
+
case "i32": case "f64": return "number"
|
|
36
|
+
case "boolean": return "boolean"
|
|
37
|
+
case "string": return "string"
|
|
38
|
+
case "json": return "Json"
|
|
39
|
+
case "file": return "PluginFile"
|
|
40
|
+
case "bytes": return "Uint8Array"
|
|
41
|
+
case "void": return "void"
|
|
42
|
+
case "enum": case "struct": case "union": return t.name
|
|
43
|
+
case "list": return `${t.of.kind === "optional" ? `(${webType(t.of)})` : webType(t.of)}[]`
|
|
44
|
+
case "map": return `{ [key: string]: ${webType(t.of)} }`
|
|
45
|
+
case "optional": return `${webType(t.of)} | null`
|
|
46
|
+
case "tuple": return `[${t.of.map(webType).join(", ")}]`
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A member or an argument: left out (`name?: T`) or null (`name: T | null`). */
|
|
51
|
+
const member = (name: string, type: L, omit: boolean): string =>
|
|
52
|
+
type.kind === "optional" && omit ? `${key(name)}?: ${webType(type.of)}` : `${key(name)}: ${webType(type)}`
|
|
53
|
+
|
|
54
|
+
/** The expression of type `T | undefined` that reads the wire's value `e`: undefined = it is not a T. */
|
|
55
|
+
const decode = (t: L, e: string, depth = 0): string => {
|
|
56
|
+
const v = `v${depth}`
|
|
57
|
+
switch (t.kind) {
|
|
58
|
+
case "i32": return `wire.i32(${e})`
|
|
59
|
+
case "f64": return `wire.f64(${e})`
|
|
60
|
+
case "boolean": return `wire.bool(${e})`
|
|
61
|
+
case "string": return `wire.string(${e})`
|
|
62
|
+
case "json": return `wire.json(${e})`
|
|
63
|
+
case "bytes": return `wire.bytes(${e})`
|
|
64
|
+
case "file": return `readFile(${e})`
|
|
65
|
+
case "enum": case "struct": case "union": return `read${t.name}(${e})`
|
|
66
|
+
case "list": return `wire.list(${e}, (${v}) => ${decode(t.of, v, depth + 1)})`
|
|
67
|
+
case "map": return `wire.map(${e}, (${v}) => ${decode(t.of, v, depth + 1)})`
|
|
68
|
+
// Absent and null are one thing on the wire: a null here.
|
|
69
|
+
case "optional": return `(wire.present(${e}) ? ${decode(t.of, e, depth)} : null)`
|
|
70
|
+
case "tuple": {
|
|
71
|
+
const parts = t.of.map((m, i) => `const ${v}_${i} = ${decode(m, `${v}[${i}]`, depth + 1)}; if (${v}_${i} === undefined) return undefined`)
|
|
72
|
+
return `((${v}): ${webType(t)} | undefined => { if (!${v}) return undefined; ${parts.join("; ")}; return [${t.of.map((_, i) => `${v}_${i}`).join(", ")}] })(wire.array(${e}, ${t.of.length}))`
|
|
73
|
+
}
|
|
74
|
+
case "void": throw new EmitError("void has no value to read")
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type WebEmitOptions = { id: string }
|
|
79
|
+
|
|
80
|
+
export const emitWeb = (lowered: Lowered, opts: WebEmitOptions): string => {
|
|
81
|
+
const byName = new Map(lowered.decls.map(d => [d.name, d]))
|
|
82
|
+
const ways: Ways = waysOf(lowered)
|
|
83
|
+
|
|
84
|
+
/** Does a value of this type hold a File anywhere? Only such a value is rewritten on its way out:
|
|
85
|
+
* everything else — a Uint8Array too — leaves as the half made it, and the app gets that value. */
|
|
86
|
+
const carriesFile = (t: L, seen = new Set<string>()): boolean => {
|
|
87
|
+
switch (t.kind) {
|
|
88
|
+
case "file": return true
|
|
89
|
+
case "list": case "map": case "optional": return carriesFile(t.of, seen)
|
|
90
|
+
case "tuple": return t.of.some(m => carriesFile(m, seen))
|
|
91
|
+
case "struct": case "union": {
|
|
92
|
+
if (seen.has(t.name)) return false
|
|
93
|
+
seen.add(t.name)
|
|
94
|
+
const d = byName.get(t.name)!
|
|
95
|
+
if (d.kind === "struct") return d.fields.some(f => carriesFile(f.type, seen))
|
|
96
|
+
if (d.kind === "union") return d.members.some(m => carriesFile(m, seen))
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
default: return false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The expression that writes the value `e` to the wire. */
|
|
104
|
+
const encode = (t: L, e: string, depth = 0): string => {
|
|
105
|
+
if (!carriesFile(t)) return e
|
|
106
|
+
const v = `v${depth}`
|
|
107
|
+
switch (t.kind) {
|
|
108
|
+
case "file": return `writeFile(host, ${e})`
|
|
109
|
+
case "struct": return `write${t.name}(host, ${e})`
|
|
110
|
+
case "list": return `${e}.map((${v}) => ${encode(t.of, v, depth + 1)})`
|
|
111
|
+
case "map": return `Object.fromEntries(Object.entries(${e}).map(([k, ${v}]) => [k, ${encode(t.of, v, depth + 1)}]))`
|
|
112
|
+
case "tuple": return `[${t.of.map((m, i) => encode(m, `${e}[${i}]`, depth + 1)).join(", ")}]`
|
|
113
|
+
case "optional": return `(${e} == null ? null : ${encode(t.of, e, depth)})`
|
|
114
|
+
// A union is told apart by what its members are on the wire; a File among them is not a thing
|
|
115
|
+
// the lowering lets through.
|
|
116
|
+
default: throw new EmitError(`a File inside a ${t.kind} is not written by this generator`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const emitDecl = (d: LDecl): string => {
|
|
121
|
+
const read = ways.read.has(d.name)
|
|
122
|
+
const write = ways.write.has(d.name) && carriesFile({ kind: d.kind, name: d.name } as L)
|
|
123
|
+
if (d.kind === "enum") {
|
|
124
|
+
const values = d.values.map(str)
|
|
125
|
+
return [
|
|
126
|
+
doc(d.doc),
|
|
127
|
+
`export type ${d.name} = ${values.join(" | ")}\n`,
|
|
128
|
+
read ? `const read${d.name} = (v: unknown): ${d.name} | undefined =>\n ${values.map(x => `v === ${x}`).join(" || ")} ? v : undefined\n` : "",
|
|
129
|
+
].join("")
|
|
130
|
+
}
|
|
131
|
+
if (d.kind === "union") {
|
|
132
|
+
return [
|
|
133
|
+
doc(d.doc),
|
|
134
|
+
`export type ${d.name} = ${d.members.map(webType).join(" | ")}\n`,
|
|
135
|
+
read ? `const read${d.name} = (v: unknown): ${d.name} | undefined =>\n ${d.members.map(m => decode(m, "v", 1)).join(" ?? ")}\n` : "",
|
|
136
|
+
].join("")
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const members = d.fields.map(f => `${doc(f.doc, " ")} ${member(f.name, f.type, f.omit)}`)
|
|
140
|
+
const reads = !read ? [] : d.fields.map((f, i) => {
|
|
141
|
+
const from = at("o", f.name)
|
|
142
|
+
if (f.type.kind !== "optional") return ` const f${i} = ${decode(f.type, from)}\n if (f${i} === undefined) return undefined`
|
|
143
|
+
// An optional member that IS there must read: a wrong type is a malformed value, not an absent one.
|
|
144
|
+
const inner = decode(f.type.of, from)
|
|
145
|
+
return ` let f${i}: ${webType(f.type.of)} | ${f.omit ? "undefined" : "null"} = ${f.omit ? "undefined" : "null"}\n if (wire.present(${from})) {\n const r = ${inner}\n if (r === undefined) return undefined\n f${i} = r\n }`
|
|
146
|
+
})
|
|
147
|
+
const required = d.fields.map((f, i) => ({ f, i })).filter(({ f }) => !(f.type.kind === "optional" && f.omit))
|
|
148
|
+
const left = d.fields.map((f, i) => ({ f, i })).filter(({ f }) => f.type.kind === "optional" && f.omit)
|
|
149
|
+
const reader = !read ? "" : d.fields.length === 0
|
|
150
|
+
? `const read${d.name} = (v: unknown): ${d.name} | undefined => !wire.present(v) || wire.object(v) ? {} : undefined\n`
|
|
151
|
+
: [
|
|
152
|
+
`const read${d.name} = (v: unknown): ${d.name} | undefined => {\n const o = wire.object(v)\n if (!o) return undefined\n`,
|
|
153
|
+
reads.join("\n") + "\n",
|
|
154
|
+
` const out: ${d.name} = ${required.length ? `{ ${required.map(({ f, i }) => `${key(f.name)}: f${i}`).join(", ")} }` : "{}"}\n`,
|
|
155
|
+
left.map(({ f, i }) => ` if (f${i} !== undefined) ${at("out", f.name)} = f${i}\n`).join(""),
|
|
156
|
+
` return out\n}\n`,
|
|
157
|
+
].join("")
|
|
158
|
+
const writer = !write ? "" : [
|
|
159
|
+
`const write${d.name} = (host: PluginHost, v: ${d.name}): unknown => ({\n ...v,\n`,
|
|
160
|
+
d.fields.filter(f => carriesFile(f.type)).map(f => {
|
|
161
|
+
const from = at("v", f.name)
|
|
162
|
+
return f.type.kind === "optional" && f.omit
|
|
163
|
+
? ` ...(${from} === undefined ? {} : { ${key(f.name)}: ${encode(f.type.of, from)} }),\n`
|
|
164
|
+
: ` ${key(f.name)}: ${encode(f.type, from)},\n`
|
|
165
|
+
}).join(""),
|
|
166
|
+
`})\n`,
|
|
167
|
+
].join("")
|
|
168
|
+
return [
|
|
169
|
+
doc(d.doc),
|
|
170
|
+
`export interface ${d.name} {${members.length ? "\n" + members.join("\n") + "\n" : ""}}\n`,
|
|
171
|
+
reader, writer,
|
|
172
|
+
].join("")
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const emitChannel = (ch: LChannel): string => {
|
|
176
|
+
const isView = ch.kind === "view"
|
|
177
|
+
const iface = ch.iface
|
|
178
|
+
const out: string[] = []
|
|
179
|
+
|
|
180
|
+
if (ch.codes.length) {
|
|
181
|
+
out.push([
|
|
182
|
+
`/** The codes the calls of "${ch.name}" reject with. */\n`,
|
|
183
|
+
`export type ${iface}Code = ${ch.codes.map(str).join(" | ")}\n\n`,
|
|
184
|
+
`/** What a call throws to reject with a code of the contract — the app's promise rejects with it\n * as the message. Any other error is a message the contract has no code for. */\n`,
|
|
185
|
+
`export class ${iface}Error extends Error {\n constructor(readonly code: ${iface}Code) { super(code) }\n}\n`,
|
|
186
|
+
].join(""))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---- events ----
|
|
190
|
+
const payloadOf = (e: LChannel["events"][number]): string | null =>
|
|
191
|
+
e.inline ? `{ ${e.inline.map(f => member(f.name, f.type, f.omit)).join(", ")} }` : e.payload.kind === "void" ? null : webType(e.payload)
|
|
192
|
+
const events = ch.events.map(e => {
|
|
193
|
+
const payload = payloadOf(e)
|
|
194
|
+
return `${doc(e.doc, " ")} ${key(e.name)}(${payload === null ? "" : `payload: ${payload}`}): void`
|
|
195
|
+
})
|
|
196
|
+
out.push(`/** The events of "${ch.name}": what the half sends the app. */\nexport interface ${iface}Events {${events.length ? "\n" + events.join("\n") + "\n" : ""}}\n`)
|
|
197
|
+
|
|
198
|
+
// ---- the interface ----
|
|
199
|
+
const signatures = ch.methods.map(m => {
|
|
200
|
+
const args = m.args.map(a => member(a.name, a.type, a.omit))
|
|
201
|
+
const result = webType(m.result)
|
|
202
|
+
const tags = [m.doc, m.rejects.length ? `Rejects with ${m.rejects.map(str).join(" / ")}.` : ""].filter(Boolean).join("\n")
|
|
203
|
+
return `${doc(tags, " ")} ${m.name}(${args.join(", ")}): ${result} | Promise<${result}>`
|
|
204
|
+
})
|
|
205
|
+
out.push([
|
|
206
|
+
doc(ch.doc || `The ${ch.kind} "${ch.name}".`).replace(/ \*\/\n$/, `\n *\n * What the half implements. A call returns its result, or a promise of it; it rejects by throwing. */\n`),
|
|
207
|
+
`export interface ${iface}Plugin {\n`,
|
|
208
|
+
isView ? ` /** The element the host mounts. The half fills it and follows its size itself. */\n readonly el: HTMLElement\n` : "",
|
|
209
|
+
signatures.length ? signatures.join("\n") + "\n" : "",
|
|
210
|
+
isView
|
|
211
|
+
? ` /** The instance goes (the world swapped): release the hardware. */\n destroy?(): void\n`
|
|
212
|
+
: ` /** The session closed (the app's close, the world's teardown): stop what it started. */\n close?(): void\n`,
|
|
213
|
+
`}\n`,
|
|
214
|
+
].join(""))
|
|
215
|
+
|
|
216
|
+
// ---- registration + the glue ----
|
|
217
|
+
const cases = ch.methods.map(m => {
|
|
218
|
+
const lines: string[] = [` case ${str(m.name)}: {`]
|
|
219
|
+
m.args.forEach((a, i) => {
|
|
220
|
+
const bad = `throw new Error(${str(`bad arguments — ${iface}.${m.name}: ${a.name}`)})`
|
|
221
|
+
if (a.type.kind !== "optional") {
|
|
222
|
+
lines.push(` const a${i} = ${decode(a.type, `args[${i}]`)}`)
|
|
223
|
+
lines.push(` if (a${i} === undefined) ${bad}`)
|
|
224
|
+
} else {
|
|
225
|
+
const none = a.omit ? "undefined" : "null"
|
|
226
|
+
lines.push(` let a${i}: ${webType(a.type.of)} | ${none} = ${none}`)
|
|
227
|
+
lines.push(` if (wire.present(args[${i}])) {\n const r = ${decode(a.type.of, `args[${i}]`)}\n if (r === undefined) ${bad}\n a${i} = r\n }`)
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
const call = `plugin.${m.name}(${m.args.map((_, i) => `a${i}`).join(", ")})`
|
|
231
|
+
if (m.result.kind === "void") lines.push(` await ${call}\n return undefined`)
|
|
232
|
+
else if (!carriesFile(m.result)) lines.push(` return await ${call}`)
|
|
233
|
+
else lines.push(` const r = await ${call}\n return ${encode(m.result, "r")}`)
|
|
234
|
+
lines.push(` }`)
|
|
235
|
+
return lines.join("\n")
|
|
236
|
+
})
|
|
237
|
+
// A payload that holds a File is rewritten on its way out, like a result.
|
|
238
|
+
const written = (e: LChannel["events"][number]): string => {
|
|
239
|
+
if (e.inline) {
|
|
240
|
+
const files = e.inline.filter(f => carriesFile(f.type))
|
|
241
|
+
if (!files.length) return "payload"
|
|
242
|
+
return `{ ...payload, ${files.map(f => {
|
|
243
|
+
const from = at("payload", f.name)
|
|
244
|
+
return f.type.kind === "optional" && f.omit
|
|
245
|
+
? `...(${from} === undefined ? {} : { ${key(f.name)}: ${encode(f.type.of, from)} })`
|
|
246
|
+
: `${key(f.name)}: ${encode(f.type, from)}`
|
|
247
|
+
}).join(", ")} }`
|
|
248
|
+
}
|
|
249
|
+
return encode(e.payload, "payload")
|
|
250
|
+
}
|
|
251
|
+
const emitters = ch.events.map(e =>
|
|
252
|
+
` ${key(e.name)}: (${payloadOf(e) === null ? "" : "payload"}) => channel.emit(${str(e.name)}${payloadOf(e) === null ? "" : `, ${written(e)}`}),`)
|
|
253
|
+
const params = `read${ch.params}(params) ?? {}`
|
|
254
|
+
out.push([
|
|
255
|
+
`export const ${iface}Channel = {\n /** What the halves register under. */\n name: ${str(ch.name)},\n /** The contract's version: the highest \`@since\` of its members. */\n version: ${ch.version},\n} as const\n\n`,
|
|
256
|
+
isView
|
|
257
|
+
? `/** Register the view: \`make\` builds one instance from its params, in the document it is handed. */\n`
|
|
258
|
+
: `/** Register the service: \`make\` builds one session from its params. It must be cheap — no prompt,\n * no I/O: that belongs to the first call that needs it. */\n`,
|
|
259
|
+
`export const register${iface} = (host: PluginHost, make: (params: ${ch.params}, events: ${iface}Events${isView ? ", doc: Document" : ""}) => ${iface}Plugin): void => {\n`,
|
|
260
|
+
`__BUFFERS__`,
|
|
261
|
+
` host.register${isView ? "View" : "Service"}(${iface}Channel.name, (params, channel${isView ? ", doc" : ""}) => {\n`,
|
|
262
|
+
` const events: ${iface}Events = {${emitters.length ? "\n" + emitters.join("\n") + "\n " : ""}}\n`,
|
|
263
|
+
` const plugin = make(${params}, events${isView ? ", doc" : ""})\n`,
|
|
264
|
+
// Reads a call off the wire and hands it to the half; a malformed call never reaches it.
|
|
265
|
+
` const call = async (method: string, args: unknown[]): Promise<unknown> => {\n switch (method) {\n`,
|
|
266
|
+
cases.length ? cases.join("\n") + "\n" : "",
|
|
267
|
+
` default: throw new Error(\`Unknown method: \${method}\`)\n }\n }\n`,
|
|
268
|
+
isView
|
|
269
|
+
? ` return { el: plugin.el, call, destroy: () => plugin.destroy?.() }\n`
|
|
270
|
+
: ` return { call, close: () => plugin.close?.() }\n`,
|
|
271
|
+
` }, ${iface}Channel.version)\n}\n`,
|
|
272
|
+
].join(""))
|
|
273
|
+
return out.join("\n")
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const guarded = (what: string, body: () => string): string => {
|
|
277
|
+
try { return body() } catch (e) {
|
|
278
|
+
if (e instanceof EmitError) throw new EmitError(`${what}: ${e.message}`)
|
|
279
|
+
throw e
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const header = lowered.channels.map(c => `the ${c.kind} "${c.name}", contract version ${c.version}`).join("; ")
|
|
284
|
+
const body = [
|
|
285
|
+
`// ---- the contract's types ----\n\n`,
|
|
286
|
+
lowered.decls.map(d => guarded(d.name, () => emitDecl(d))).join("\n"),
|
|
287
|
+
...lowered.channels.map(ch => `\n// ---- ${ch.iface} ----\n\n${guarded(ch.iface, () => emitChannel(ch))}`),
|
|
288
|
+
].join("")
|
|
289
|
+
const uses = (name: string): boolean => new RegExp(`\\b${name}\\b`).test(body)
|
|
290
|
+
const views = lowered.channels.some(c => c.kind === "view")
|
|
291
|
+
const services = lowered.channels.some(c => c.kind === "service")
|
|
292
|
+
|
|
293
|
+
return [
|
|
294
|
+
`// GENERATED by \`lecodes plugin gen\` from contract.d.ts — do not edit.\n`,
|
|
295
|
+
`// ${opts.id}: ${header}.\n`,
|
|
296
|
+
`//\n// The web half's side of the channel. This file imports nothing: a half depends on it, on the\n// document it is handed and on the browser.\n\n`,
|
|
297
|
+
`// ---- what the host hands a half ----\n\n`,
|
|
298
|
+
`/** Where a half's events go. */\nexport interface PluginChannel { emit(event: string, data?: unknown): void }\n\n`,
|
|
299
|
+
`/** The host, as a half needs it: a SHAPE — the host's own type is never imported. */\nexport interface PluginHost {\n`,
|
|
300
|
+
` // \`version\` = the contract version the half was generated from: what the app's \`supports()\` asks.\n`,
|
|
301
|
+
views ? ` registerView(name: string, factory: (params: unknown, channel: PluginChannel, doc: Document) => {\n el: HTMLElement, call?(method: string, args: unknown[]): unknown, destroy?(): void,\n }, version: number): void\n` : "",
|
|
302
|
+
services ? ` registerService(name: string, factory: (params: unknown, channel: PluginChannel) => {\n call?(method: string, args: unknown[]): unknown, close?(): void,\n }, version: number): void\n` : "",
|
|
303
|
+
uses("writeFile") ? ` /** Store bytes in the host's buffer table: the handle a File crosses as. */\n putBuffer(bytes: ArrayBuffer): number\n` : "",
|
|
304
|
+
uses("readFile") ? ` /** The bytes of a buffer of the host's table — what the handle of a File the app sent names. */\n getBuffer(systemId: number): ArrayBuffer | Uint8Array | null | undefined\n` : "",
|
|
305
|
+
`}\n\n`,
|
|
306
|
+
uses("PluginFile") ? [
|
|
307
|
+
`/** A \`File\` of the contract. One a half hands to the app: its bytes go into the host's buffer\n * table, the app gets the handle (a texture source, an upload, a share). One the app hands to a\n * half: the bytes of the buffer its handle names. */\n`,
|
|
308
|
+
`export interface PluginFile { data: ArrayBuffer | Uint8Array, name: string }\n\n`,
|
|
309
|
+
].join("") : "",
|
|
310
|
+
uses("writeFile") ? [
|
|
311
|
+
`const writeFile = (host: PluginHost, file: PluginFile): unknown => {\n`,
|
|
312
|
+
` const bytes = file.data instanceof Uint8Array ? file.data.slice().buffer : file.data\n`,
|
|
313
|
+
` return { systemId: host.putBuffer(bytes), name: file.name, size: bytes.byteLength }\n}\n\n`,
|
|
314
|
+
].join("") : "",
|
|
315
|
+
uses("Json") ? `/** A value the generator passes through untyped. */\nexport type Json = null | boolean | number | string | Json[] | { [key: string]: Json }\n\n` : "",
|
|
316
|
+
uses("wire") || uses("readFile") ? WIRE : "",
|
|
317
|
+
uses("readFile") ? READ_FILE : "",
|
|
318
|
+
body.replaceAll("__BUFFERS__", uses("readFile") ? ` buffers = (systemId) => host.getBuffer(systemId)\n` : ""),
|
|
319
|
+
].join("")
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The reader of a File the app sent: the host's buffer table is reached through the host a half
|
|
323
|
+
* registered with. */
|
|
324
|
+
const READ_FILE = `/** Where the bytes of a File the app sent are: the buffer table of the host a half registered with. */
|
|
325
|
+
let buffers: ((systemId: number) => ArrayBuffer | Uint8Array | null | undefined) | undefined
|
|
326
|
+
|
|
327
|
+
/** A File the app handed over; undefined when it is not a handle, or the buffer is gone. */
|
|
328
|
+
const readFile = (v: unknown): PluginFile | undefined => {
|
|
329
|
+
const o = wire.object(v)
|
|
330
|
+
const id = o ? wire.i32(o.systemId) : undefined
|
|
331
|
+
const data = id === undefined ? undefined : buffers?.(id)
|
|
332
|
+
return o && data ? { data, name: wire.string(o.name) ?? "" } : undefined
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
`
|
|
336
|
+
|
|
337
|
+
/** The readers of the wire's values: undefined = absent, null, or not that type. */
|
|
338
|
+
const WIRE = `// The readers of the wire's values: undefined = absent, null, or not that type. On the web the wire
|
|
339
|
+
// carries the app's OWN values (the host is JavaScript like the app). What is read of the contract is
|
|
340
|
+
// built anew — a struct, a list, bytes — so what a half keeps is its own; a Json value alone is the
|
|
341
|
+
// app's object, as it is.
|
|
342
|
+
const wire = {
|
|
343
|
+
present: (v: unknown): boolean => v !== undefined && v !== null,
|
|
344
|
+
/** A number that is not finite is a null on the wire, as JSON has it. */
|
|
345
|
+
f64: (v: unknown): number | undefined => typeof v === "number" && Number.isFinite(v) ? v : undefined,
|
|
346
|
+
i32: (v: unknown): number | undefined =>
|
|
347
|
+
typeof v === "number" && Number.isInteger(v) && v >= -2147483648 && v <= 2147483647 ? v : undefined,
|
|
348
|
+
bool: (v: unknown): boolean | undefined => typeof v === "boolean" ? v : undefined,
|
|
349
|
+
string: (v: unknown): string | undefined => typeof v === "string" ? v : undefined,
|
|
350
|
+
/** Any JSON value, as it is; a null is a value here. */
|
|
351
|
+
json: (v: unknown): any => v,
|
|
352
|
+
/** A copy of the bytes. Told by its class name, not \`instanceof\`: the app's realm may not be the half's. */
|
|
353
|
+
bytes: (v: unknown): Uint8Array | undefined =>
|
|
354
|
+
Object.prototype.toString.call(v) === "[object Uint8Array]" ? (v as Uint8Array).slice() : undefined,
|
|
355
|
+
object: (v: unknown): Record<string, unknown> | undefined =>
|
|
356
|
+
typeof v === "object" && v !== null && !Array.isArray(v) ? v as Record<string, unknown> : undefined,
|
|
357
|
+
/** An array of exactly \`count\` values (a tuple), or of any length. */
|
|
358
|
+
array: (v: unknown, count?: number): unknown[] | undefined =>
|
|
359
|
+
Array.isArray(v) && (count === undefined || v.length === count) ? v : undefined,
|
|
360
|
+
/** Every element or nothing: one that does not read fails the list. */
|
|
361
|
+
list: <T>(v: unknown, each: (v: unknown) => T | undefined): T[] | undefined => {
|
|
362
|
+
if (!Array.isArray(v)) return undefined
|
|
363
|
+
const out: T[] = []
|
|
364
|
+
for (const item of v) {
|
|
365
|
+
const r = each(item)
|
|
366
|
+
if (r === undefined) return undefined
|
|
367
|
+
out.push(r)
|
|
368
|
+
}
|
|
369
|
+
return out
|
|
370
|
+
},
|
|
371
|
+
map: <T>(v: unknown, each: (v: unknown) => T | undefined): { [key: string]: T } | undefined => {
|
|
372
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) return undefined
|
|
373
|
+
const out: { [key: string]: T } = {}
|
|
374
|
+
for (const [k, item] of Object.entries(v)) {
|
|
375
|
+
const r = each(item)
|
|
376
|
+
if (r === undefined) return undefined
|
|
377
|
+
out[k] = r
|
|
378
|
+
}
|
|
379
|
+
return out
|
|
380
|
+
},
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
`
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// `lecodes plugin gen`: the contracts under a path → their generated files. A plugin is a directory
|
|
2
|
+
// with a `contract.d.ts`; the path is one plugin, or a repo that keeps them in `plugins/<id>/`.
|
|
3
|
+
// Generated files are written beside the hand-written ones of the place they travel to
|
|
4
|
+
// (`sdk/<id>.gen.ts`, `ios/<Name>.gen.swift`, `android/…/<Name>.gen.kt`, `web/<id>.gen.ts`) and are
|
|
5
|
+
// committed — `check` is what CI runs.
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"
|
|
8
|
+
import { createRequire } from "node:module"
|
|
9
|
+
import { basename, join, relative, resolve } from "node:path"
|
|
10
|
+
import type tsModule from "typescript"
|
|
11
|
+
import { CliError } from "../cli"
|
|
12
|
+
import { ContractError, readContract, type Contract } from "./contract"
|
|
13
|
+
import { emitKotlin } from "./emitKotlin"
|
|
14
|
+
import { EmitError, emitSdk } from "./emitSdk"
|
|
15
|
+
import { emitSwift } from "./emitSwift"
|
|
16
|
+
import { emitWeb } from "./emitWeb"
|
|
17
|
+
import { lower } from "./lower"
|
|
18
|
+
import { ManifestError, readManifestIfAny } from "./manifest"
|
|
19
|
+
|
|
20
|
+
export const CONTRACT_FILE = "contract.d.ts"
|
|
21
|
+
|
|
22
|
+
export type PluginDir = { id: string, dir: string }
|
|
23
|
+
|
|
24
|
+
/** The plugins under `path`: itself when it holds a contract, else every `plugins/<id>/` that does. */
|
|
25
|
+
export const findPlugins = (path: string): PluginDir[] => {
|
|
26
|
+
const root = resolve(path)
|
|
27
|
+
if (existsSync(join(root, CONTRACT_FILE))) return [{ id: basename(root), dir: root }]
|
|
28
|
+
const holder = join(root, "plugins")
|
|
29
|
+
if (!existsSync(holder)) return []
|
|
30
|
+
return readdirSync(holder).sort()
|
|
31
|
+
.map(id => ({ id, dir: join(holder, id) }))
|
|
32
|
+
.filter(p => statSync(p.dir).isDirectory() && existsSync(join(p.dir, CONTRACT_FILE)))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The CLI carries no TypeScript: a contract is read with the one its author has — the plugin's own
|
|
36
|
+
* (looked up from its directory upwards), else whatever the CLI's install can see. */
|
|
37
|
+
export const loadTypescript = (dir: string): typeof tsModule => {
|
|
38
|
+
for (const from of [join(dir, "package.json"), import.meta.url]) {
|
|
39
|
+
try {
|
|
40
|
+
const mod = createRequire(from)("typescript")
|
|
41
|
+
const ts = mod?.createSourceFile ? mod : mod?.default // a runtime may hand the namespace over
|
|
42
|
+
if (ts?.createSourceFile) return ts
|
|
43
|
+
} catch { /* the next place */ }
|
|
44
|
+
}
|
|
45
|
+
throw new CliError(`Reading a contract needs TypeScript — add it to the plugin's repo: npm install --save-dev typescript`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type Generated = { file: string, text: string }
|
|
49
|
+
|
|
50
|
+
/** Everything one plugin's contract generates, as `path → text`. Throws ContractError / EmitError. */
|
|
51
|
+
export const generatePlugin = (ts: typeof tsModule, plugin: PluginDir): { contract: Contract, files: Generated[] } => {
|
|
52
|
+
const file = join(plugin.dir, CONTRACT_FILE)
|
|
53
|
+
const contract = readContract(ts, file, readFileSync(file, "utf8"))
|
|
54
|
+
// The manifest is held to the folders the plugin has: a half the manifest does not name is built
|
|
55
|
+
// into no host.
|
|
56
|
+
readManifestIfAny(plugin.dir, plugin.id)
|
|
57
|
+
const handWritten = existsSync(join(plugin.dir, "sdk", `${plugin.id}.ts`))
|
|
58
|
+
const single = contract.channels.length === 1
|
|
59
|
+
const files: Generated[] = contract.channels.map(channel => ({
|
|
60
|
+
// One channel is the rule; a contract with several names each file after its channel.
|
|
61
|
+
file: join(plugin.dir, "sdk", single ? `${plugin.id}.gen.ts` : `${plugin.id}.${channel.iface}.gen.ts`),
|
|
62
|
+
text: emitSdk(contract, channel, { id: plugin.id, handWritten }),
|
|
63
|
+
}))
|
|
64
|
+
// A platform's file is generated where its FOLDER exists: the plugin says which platforms it has
|
|
65
|
+
// by having them.
|
|
66
|
+
if (existsSync(join(plugin.dir, "ios"))) {
|
|
67
|
+
const name = single ? contract.channels[0]!.iface : contract.channels[0]!.iface + "Plugin"
|
|
68
|
+
files.push({ file: join(plugin.dir, "ios", `${name}.gen.swift`), text: emitSwift(lower(contract), { id: plugin.id }) })
|
|
69
|
+
}
|
|
70
|
+
if (existsSync(join(plugin.dir, "android"))) {
|
|
71
|
+
const name = single ? contract.channels[0]!.iface : contract.channels[0]!.iface + "Plugin"
|
|
72
|
+
const pkg = kotlinPackage(join(plugin.dir, "android"))
|
|
73
|
+
files.push({ file: join(pkg.dir, `${name}.gen.kt`), text: emitKotlin(lower(contract), { id: plugin.id, package: pkg.name }) })
|
|
74
|
+
}
|
|
75
|
+
if (existsSync(join(plugin.dir, "web"))) {
|
|
76
|
+
files.push({ file: join(plugin.dir, "web", `${plugin.id}.gen.ts`), text: emitWeb(lower(contract), { id: plugin.id }) })
|
|
77
|
+
const foreign = hostImports(join(plugin.dir, "web"))
|
|
78
|
+
if (foreign.length) throw new EmitError(`web/ imports the host — a web half depends on its generated file, the document it is handed and the browser:\n${foreign.map(f => ` ${f}`).join("\n")}`)
|
|
79
|
+
}
|
|
80
|
+
return { contract, files }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The imports of a web half that name a LeCodes package (`lecodes-sdk`, `lecodes-web-shared`, …), as
|
|
84
|
+
* `file:line: the import`. A half that imports its host runs on that host only. */
|
|
85
|
+
export const hostImports = (web: string): string[] => {
|
|
86
|
+
const found: string[] = []
|
|
87
|
+
const walk = (dir: string): void => {
|
|
88
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
89
|
+
const path = join(dir, entry)
|
|
90
|
+
if (statSync(path).isDirectory()) { if (entry !== "node_modules" && entry !== "dist") walk(path); continue }
|
|
91
|
+
if (!/\.(ts|tsx|js|mjs)$/.test(entry)) continue
|
|
92
|
+
readFileSync(path, "utf8").split("\n").forEach((line, i) => {
|
|
93
|
+
const m = /(?:from\s+|import\s*\(\s*|import\s+)["'](lecodes-[^"']*)["']/.exec(line)
|
|
94
|
+
if (m && !/^\s*\/\//.test(line)) found.push(`${relative(join(web, ".."), path)}:${i + 1}: ${m[1]}`)
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
walk(web)
|
|
99
|
+
return found
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Where a plugin's Kotlin lives: `android/` is laid out as a Gradle module (its manifest and
|
|
103
|
+
* resources travel with the sources), and the generated file is a file of the plugin's PACKAGE —
|
|
104
|
+
* the one folder under `src/main/java` that the path narrows down to. */
|
|
105
|
+
export const kotlinPackage = (android: string): { dir: string, name: string } => {
|
|
106
|
+
const root = join(android, "src", "main", "java")
|
|
107
|
+
let dir = root
|
|
108
|
+
while (existsSync(dir)) {
|
|
109
|
+
const entries = readdirSync(dir).filter(e => !e.startsWith("."))
|
|
110
|
+
const dirs = entries.filter(e => statSync(join(dir, e)).isDirectory())
|
|
111
|
+
if (dirs.length !== 1 || entries.length !== 1) break
|
|
112
|
+
dir = join(dir, dirs[0]!)
|
|
113
|
+
}
|
|
114
|
+
if (dir === root) {
|
|
115
|
+
throw new EmitError(`android/ has no package yet — make the folder of the plugin's Kotlin package (android/src/main/java/com/example/plugin), the generated file is written into it`)
|
|
116
|
+
}
|
|
117
|
+
return { dir, name: relative(root, dir).split(/[\\/]/).join(".") }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type GenReport = {
|
|
121
|
+
plugins: { id: string, contract: Contract, written: string[], stale: string[], fresh: string[] }[]
|
|
122
|
+
/** Contracts that did not read or emit, as printable text. */
|
|
123
|
+
failures: string[]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Generate (or, with `check`, compare) every plugin under `path`. Paths in the report are relative
|
|
127
|
+
* to `path`. A failed contract does not stop the others. */
|
|
128
|
+
export const generate = (path: string, opts: { check?: boolean } = {}): GenReport => {
|
|
129
|
+
const plugins = findPlugins(path)
|
|
130
|
+
if (plugins.length === 0) throw new CliError(`No plugin under ${resolve(path)}: a plugin is a directory with a ${CONTRACT_FILE} (one, or plugins/<id>/ of a repo).`)
|
|
131
|
+
const ts = loadTypescript(plugins[0]!.dir)
|
|
132
|
+
const root = resolve(path)
|
|
133
|
+
const report: GenReport = { plugins: [], failures: [] }
|
|
134
|
+
|
|
135
|
+
for (const plugin of plugins) {
|
|
136
|
+
try {
|
|
137
|
+
const { contract, files } = generatePlugin(ts, plugin)
|
|
138
|
+
const entry = { id: plugin.id, contract, written: [] as string[], stale: [] as string[], fresh: [] as string[] }
|
|
139
|
+
for (const f of files) {
|
|
140
|
+
const name = relative(root, f.file) || basename(f.file)
|
|
141
|
+
const current = existsSync(f.file) ? readFileSync(f.file, "utf8") : null
|
|
142
|
+
if (current === f.text) { entry.fresh.push(name); continue }
|
|
143
|
+
if (opts.check) { entry.stale.push(name); continue }
|
|
144
|
+
mkdirSync(join(f.file, ".."), { recursive: true })
|
|
145
|
+
writeFileSync(f.file, f.text)
|
|
146
|
+
entry.written.push(name)
|
|
147
|
+
}
|
|
148
|
+
report.plugins.push(entry)
|
|
149
|
+
} catch (e) {
|
|
150
|
+
if (e instanceof ContractError) report.failures.push(e.problems.map(p => `${relative(root, e.file) || e.file}:${p.line}: ${p.message}`).join("\n"))
|
|
151
|
+
else if (e instanceof EmitError) report.failures.push(`${relative(root, join(plugin.dir, CONTRACT_FILE))}: ${e.message}`)
|
|
152
|
+
else if (e instanceof ManifestError) report.failures.push(`${relative(root, e.file) || e.file}:\n${e.problems.map(p => ` ${p}`).join("\n")}`)
|
|
153
|
+
else throw e
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return report
|
|
157
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// How the native emitters (emitSwift.ts, emitKotlin.ts) lay generated code out: a codec is a LIST
|
|
2
|
+
// OF LINES, indented relative to where it is put. A statement has a line of its own; what nests is
|
|
3
|
+
// indented — generated code is read when a plugin is debugged, and then it is read like any other.
|
|
4
|
+
|
|
5
|
+
export const INDENT = " "
|
|
6
|
+
|
|
7
|
+
/** `lines` one level (or `by`) deeper. */
|
|
8
|
+
export const indent = (lines: string[], by = INDENT): string[] => lines.map(l => l === "" ? l : by + l)
|
|
9
|
+
|
|
10
|
+
/** `head` … `tail` around `inner`: on ONE line when `inner` is one short statement, else as a block. */
|
|
11
|
+
export const block = (head: string, inner: string[], tail: string): string[] => {
|
|
12
|
+
const one = `${head} ${inner[0]} ${tail}`
|
|
13
|
+
return inner.length === 1 && one.length <= 100 ? [one] : [head, ...indent(inner), tail]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** An expression of several lines in its place: `before` its first line, `after` its last. */
|
|
17
|
+
export const wrap = (before: string, lines: string[], after: string): string[] =>
|
|
18
|
+
lines.map((l, i) => `${i === 0 ? before : ""}${l}${i === lines.length - 1 ? after : ""}`)
|
|
19
|
+
|
|
20
|
+
/** `lines` as text, each `by` deep. */
|
|
21
|
+
export const text = (lines: string[], by: string): string => indent(lines, by).join("\n")
|
|
22
|
+
|
|
23
|
+
/** A name for the wire's writer that none of `names` (the identifiers in its scope) has. */
|
|
24
|
+
export const writerName = (names: string[]): string => {
|
|
25
|
+
let w = "w"
|
|
26
|
+
while (names.includes(w)) w += "_"
|
|
27
|
+
return w
|
|
28
|
+
}
|