@zmdb/compiler 1.0.0-beta.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 (172) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +40 -0
  3. package/dist/codegen/index.d.ts +50 -0
  4. package/dist/codegen/index.d.ts.map +1 -0
  5. package/dist/codegen/index.js +364 -0
  6. package/dist/codegen/index.js.map +1 -0
  7. package/dist/codegen/scan.d.ts +105 -0
  8. package/dist/codegen/scan.d.ts.map +1 -0
  9. package/dist/codegen/scan.js +450 -0
  10. package/dist/codegen/scan.js.map +1 -0
  11. package/dist/codegen/witness.d.ts +76 -0
  12. package/dist/codegen/witness.d.ts.map +1 -0
  13. package/dist/codegen/witness.js +690 -0
  14. package/dist/codegen/witness.js.map +1 -0
  15. package/dist/config/contract.d.ts +41 -0
  16. package/dist/config/contract.d.ts.map +1 -0
  17. package/dist/config/contract.js +5 -0
  18. package/dist/config/contract.js.map +1 -0
  19. package/dist/config/index.d.ts +53 -0
  20. package/dist/config/index.d.ts.map +1 -0
  21. package/dist/config/index.js +277 -0
  22. package/dist/config/index.js.map +1 -0
  23. package/dist/config/index.zmdb.generated.d.ts +12 -0
  24. package/dist/config/index.zmdb.generated.js +28 -0
  25. package/dist/config/index.zmdb.witness.d.ts +3 -0
  26. package/dist/config/index.zmdb.witness.d.ts.map +1 -0
  27. package/dist/config/index.zmdb.witness.js +20 -0
  28. package/dist/config/index.zmdb.witness.js.map +1 -0
  29. package/dist/configured-plugin.d.ts +11 -0
  30. package/dist/configured-plugin.d.ts.map +1 -0
  31. package/dist/configured-plugin.js +27 -0
  32. package/dist/configured-plugin.js.map +1 -0
  33. package/dist/emit/index.d.ts +103 -0
  34. package/dist/emit/index.d.ts.map +1 -0
  35. package/dist/emit/index.js +1356 -0
  36. package/dist/emit/index.js.map +1 -0
  37. package/dist/errors.d.ts +7 -0
  38. package/dist/errors.d.ts.map +1 -0
  39. package/dist/errors.js +2 -0
  40. package/dist/errors.js.map +1 -0
  41. package/dist/index.d.ts +46 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +254 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/lint/ast.d.ts +17 -0
  46. package/dist/lint/ast.d.ts.map +1 -0
  47. package/dist/lint/ast.js +77 -0
  48. package/dist/lint/ast.js.map +1 -0
  49. package/dist/lint/host-types.d.ts +9 -0
  50. package/dist/lint/host-types.d.ts.map +1 -0
  51. package/dist/lint/host-types.js +2 -0
  52. package/dist/lint/host-types.js.map +1 -0
  53. package/dist/lint/index.d.ts +23 -0
  54. package/dist/lint/index.d.ts.map +1 -0
  55. package/dist/lint/index.js +53 -0
  56. package/dist/lint/index.js.map +1 -0
  57. package/dist/lint/rules/no-distributed-nullable-tags.d.ts +3 -0
  58. package/dist/lint/rules/no-distributed-nullable-tags.d.ts.map +1 -0
  59. package/dist/lint/rules/no-distributed-nullable-tags.js +71 -0
  60. package/dist/lint/rules/no-distributed-nullable-tags.js.map +1 -0
  61. package/dist/lint/rules/no-empty-patch.d.ts +3 -0
  62. package/dist/lint/rules/no-empty-patch.d.ts.map +1 -0
  63. package/dist/lint/rules/no-empty-patch.js +26 -0
  64. package/dist/lint/rules/no-empty-patch.js.map +1 -0
  65. package/dist/lint/rules/no-interpolated-sql.d.ts +3 -0
  66. package/dist/lint/rules/no-interpolated-sql.d.ts.map +1 -0
  67. package/dist/lint/rules/no-interpolated-sql.js +35 -0
  68. package/dist/lint/rules/no-interpolated-sql.js.map +1 -0
  69. package/dist/lint/rules/no-unbounded-find.d.ts +3 -0
  70. package/dist/lint/rules/no-unbounded-find.d.ts.map +1 -0
  71. package/dist/lint/rules/no-unbounded-find.js +26 -0
  72. package/dist/lint/rules/no-unbounded-find.js.map +1 -0
  73. package/dist/lint/rules/no-unknown-json-column.d.ts +3 -0
  74. package/dist/lint/rules/no-unknown-json-column.d.ts.map +1 -0
  75. package/dist/lint/rules/no-unknown-json-column.js +35 -0
  76. package/dist/lint/rules/no-unknown-json-column.js.map +1 -0
  77. package/dist/lint/rules/require-sql-on-number.d.ts +3 -0
  78. package/dist/lint/rules/require-sql-on-number.d.ts.map +1 -0
  79. package/dist/lint/rules/require-sql-on-number.js +34 -0
  80. package/dist/lint/rules/require-sql-on-number.js.map +1 -0
  81. package/dist/lint/types.d.ts +12 -0
  82. package/dist/lint/types.d.ts.map +1 -0
  83. package/dist/lint/types.js +2 -0
  84. package/dist/lint/types.js.map +1 -0
  85. package/dist/metro/metro.d.ts +22 -0
  86. package/dist/metro/metro.d.ts.map +1 -0
  87. package/dist/metro/metro.js +242 -0
  88. package/dist/metro/metro.js.map +1 -0
  89. package/dist/protobuf/decode.d.ts +20 -0
  90. package/dist/protobuf/decode.d.ts.map +1 -0
  91. package/dist/protobuf/decode.js +289 -0
  92. package/dist/protobuf/decode.js.map +1 -0
  93. package/dist/protobuf/descriptor.d.ts +16 -0
  94. package/dist/protobuf/descriptor.d.ts.map +1 -0
  95. package/dist/protobuf/descriptor.js +303 -0
  96. package/dist/protobuf/descriptor.js.map +1 -0
  97. package/dist/protobuf/encode.d.ts +20 -0
  98. package/dist/protobuf/encode.d.ts.map +1 -0
  99. package/dist/protobuf/encode.js +203 -0
  100. package/dist/protobuf/encode.js.map +1 -0
  101. package/dist/protobuf/grpc-ir.d.ts +16 -0
  102. package/dist/protobuf/grpc-ir.d.ts.map +1 -0
  103. package/dist/protobuf/grpc-ir.js +2 -0
  104. package/dist/protobuf/grpc-ir.js.map +1 -0
  105. package/dist/protobuf/plan.d.ts +42 -0
  106. package/dist/protobuf/plan.d.ts.map +1 -0
  107. package/dist/protobuf/plan.js +158 -0
  108. package/dist/protobuf/plan.js.map +1 -0
  109. package/dist/reflect/callsites.d.ts +40 -0
  110. package/dist/reflect/callsites.d.ts.map +1 -0
  111. package/dist/reflect/callsites.js +153 -0
  112. package/dist/reflect/callsites.js.map +1 -0
  113. package/dist/reflect/index.d.ts +106 -0
  114. package/dist/reflect/index.d.ts.map +1 -0
  115. package/dist/reflect/index.js +1421 -0
  116. package/dist/reflect/index.js.map +1 -0
  117. package/dist/reflect/session.d.ts +118 -0
  118. package/dist/reflect/session.d.ts.map +1 -0
  119. package/dist/reflect/session.js +228 -0
  120. package/dist/reflect/session.js.map +1 -0
  121. package/dist/testing/index.d.ts +69 -0
  122. package/dist/testing/index.d.ts.map +1 -0
  123. package/dist/testing/index.js +210 -0
  124. package/dist/testing/index.js.map +1 -0
  125. package/dist/transform/index.d.ts +74 -0
  126. package/dist/transform/index.d.ts.map +1 -0
  127. package/dist/transform/index.js +572 -0
  128. package/dist/transform/index.js.map +1 -0
  129. package/dist/unplugin/index.d.ts +53 -0
  130. package/dist/unplugin/index.d.ts.map +1 -0
  131. package/dist/unplugin/index.js +130 -0
  132. package/dist/unplugin/index.js.map +1 -0
  133. package/dist/unplugin/inline-bench.d.ts +29 -0
  134. package/dist/unplugin/inline-bench.d.ts.map +1 -0
  135. package/dist/unplugin/inline-bench.js +82 -0
  136. package/dist/unplugin/inline-bench.js.map +1 -0
  137. package/package.json +110 -0
  138. package/src/codegen/index.ts +451 -0
  139. package/src/codegen/scan.ts +574 -0
  140. package/src/codegen/witness.ts +819 -0
  141. package/src/config/contract.ts +48 -0
  142. package/src/config/index.ts +384 -0
  143. package/src/config/index.zmdb.generated.d.ts +12 -0
  144. package/src/config/index.zmdb.generated.js +28 -0
  145. package/src/config/index.zmdb.witness.ts +22 -0
  146. package/src/configured-plugin.ts +35 -0
  147. package/src/emit/index.ts +1496 -0
  148. package/src/errors.ts +6 -0
  149. package/src/index.ts +336 -0
  150. package/src/lint/ast.ts +96 -0
  151. package/src/lint/host-types.ts +9 -0
  152. package/src/lint/index.ts +80 -0
  153. package/src/lint/rules/no-distributed-nullable-tags.ts +76 -0
  154. package/src/lint/rules/no-empty-patch.ts +26 -0
  155. package/src/lint/rules/no-interpolated-sql.ts +45 -0
  156. package/src/lint/rules/no-unbounded-find.ts +26 -0
  157. package/src/lint/rules/no-unknown-json-column.ts +36 -0
  158. package/src/lint/rules/require-sql-on-number.ts +34 -0
  159. package/src/lint/types.ts +11 -0
  160. package/src/metro/metro.ts +313 -0
  161. package/src/protobuf/decode.ts +375 -0
  162. package/src/protobuf/descriptor.ts +353 -0
  163. package/src/protobuf/encode.ts +277 -0
  164. package/src/protobuf/grpc-ir.ts +17 -0
  165. package/src/protobuf/plan.ts +244 -0
  166. package/src/reflect/callsites.ts +198 -0
  167. package/src/reflect/index.ts +1723 -0
  168. package/src/reflect/session.ts +276 -0
  169. package/src/testing/index.ts +298 -0
  170. package/src/transform/index.ts +690 -0
  171. package/src/unplugin/index.ts +182 -0
  172. package/src/unplugin/inline-bench.ts +92 -0
@@ -0,0 +1,819 @@
1
+ // The three files project compilation writes beside a source file, as text.
2
+ // Generated banners name the compiler package so checked-in artifacts identify their
3
+ // current owner without implying that the retired executable still exists.
4
+ //
5
+ // Nothing here runs the compiler or the emitter. It is the text half of project compilation:
6
+ // given the entries `scan.ts` found, produce the witness module, and — given the transformer's
7
+ // output for that witness — produce the JavaScript and declarations that go with it. Keeping
8
+ // it pure is what makes it testable without a `tsgo` process.
9
+ //
10
+ // ## Why three files
11
+ //
12
+ // The rewrite is destructive. After a run the source says `zmdbIsUser(data)` and the
13
+ // `is<User>(data)` it came from is gone, so the type argument — the only input the whole
14
+ // pipeline has — would be gone with it. The **witness** keeps it, in a form the consumer's
15
+ // own `tsc` checks: a wrapper per entry, written against the runtime API. That makes a
16
+ // renamed or deleted `User` a build error in a generated file, rather than a compiled
17
+ // validator that quietly keeps describing a type nobody declares any more.
18
+ //
19
+ // The witness is also the transform input, and its output cannot be the artifact: the
20
+ // emitted helpers are untyped JavaScript (`function _zmdbFreeze(_v)`), which under
21
+ // `noImplicitAny` is an error per parameter. So the artifact is a **`.js`** — the emitter's
22
+ // output needs no annotations because nothing typechecks it — plus a **`.d.ts`** carrying
23
+ // the signatures. That split pays for itself twice over: there is not one cast anywhere in
24
+ // the generated code, and `schemaOf<T>()`'s phantom (`TaggedSchema<T>`'s `unique symbol`
25
+ // slot, which no object literal can satisfy) is *declared* in the `.d.ts` rather than
26
+ // asserted into existence.
27
+ //
28
+ // ## How the JavaScript is extracted
29
+ //
30
+ // By sentinel comments, and only because this module wrote every line around them. The
31
+ // transformer replaces call spans and prepends its prelude; it never moves a statement and
32
+ // never touches a comment. So the text between `/*zmdb:begin:NAME*/` and
33
+ // `/*zmdb:end:NAME*/` is the wrapper with its call inlined, and it still starts with the
34
+ // exact signature line this module generated — which is what makes swapping that line for
35
+ // its JavaScript form a string operation rather than a parse.
36
+
37
+ import type { NamedImportBindings, Node, SourceFile } from 'typescript/unstable/ast';
38
+ import { SyntaxKind } from 'typescript/unstable/ast';
39
+ import {
40
+ isIdentifier,
41
+ isImportDeclaration,
42
+ isNamedImports,
43
+ isNamespaceImport,
44
+ isPropertyAccessExpression,
45
+ isStringLiteral,
46
+ } from 'typescript/unstable/ast/is';
47
+
48
+ import { Rewriter } from '../transform/index.js';
49
+ import type { Entry, SiteEntry, TypeImport } from './scan.js';
50
+
51
+ /** Wraps the header and the import block: everything the `.js` does not want. */
52
+ const IMPORTS_OPEN = '/*zmdb:imports*/';
53
+ const IMPORTS_CLOSE = '/*zmdb:/imports*/';
54
+
55
+ function begin(name: string): string {
56
+ return `/*zmdb:begin:${name}*/`;
57
+ }
58
+
59
+ function end(name: string): string {
60
+ return `/*zmdb:end:${name}*/`;
61
+ }
62
+
63
+ /** The type each callee's signature needs beyond the type argument itself. */
64
+ const SUPPORT_TYPES: Readonly<Record<string, readonly string[]>> = {
65
+ validate: ['ValidateResult'],
66
+ validateShallow: ['ValidateResult'],
67
+ toJsonSchema: ['JsonSchemaObject'],
68
+ schemaOf: ['TaggedSchema'],
69
+ toolFor: ['ToolOptions', 'ToolProvider', 'ToolSpecFor'],
70
+ loadGrpcService: ['GrpcLoadedService'],
71
+ };
72
+
73
+ // -----------------------------------------------------------------------------
74
+ // Paths
75
+ // -----------------------------------------------------------------------------
76
+
77
+ export interface ArtifactPaths {
78
+ readonly witness: string;
79
+ readonly js: string;
80
+ readonly dts: string;
81
+ /** What the rewritten source imports. Always the `.js`; TypeScript finds the `.d.ts`. */
82
+ readonly specifier: string;
83
+ }
84
+
85
+ const TS_EXTENSIONS: readonly [string, string, string][] = [
86
+ // source extension, runtime extension, declaration extension
87
+ ['.mts', '.mjs', '.d.mts'],
88
+ ['.cts', '.cjs', '.d.cts'],
89
+ ['.tsx', '.js', '.d.ts'],
90
+ ['.ts', '.js', '.d.ts'],
91
+ ];
92
+
93
+ /**
94
+ * Where the three files go for one source file.
95
+ *
96
+ * Beside the source, one set per source file, which is a deliberate departure from a single
97
+ * per-project module. Two things fall out of it and both are worth more than the tidiness:
98
+ * the witness's relative imports are the source's own relative imports unchanged (no path
99
+ * arithmetic, and none of the ways that gets it wrong), and two files that both write
100
+ * `is<Row>` cannot collide over the export name `zmdbIsRow`.
101
+ *
102
+ * The runtime extension follows the source's, so a `.mts` file gets a `.mjs` — the module
103
+ * kind of the generated file has to match the module kind of the file importing it.
104
+ */
105
+ export function artifactPaths(sourcePath: string): ArtifactPaths {
106
+ for (const [source, runtime, declaration] of TS_EXTENSIONS) {
107
+ if (!sourcePath.endsWith(source)) continue;
108
+ const stem = sourcePath.slice(0, -source.length);
109
+ return {
110
+ witness: `${stem}.zmdb.witness${source === '.tsx' ? '.ts' : source}`,
111
+ js: `${stem}.zmdb.generated${runtime}`,
112
+ dts: `${stem}.zmdb.generated${declaration}`,
113
+ specifier: `./${base(stem)}.zmdb.generated${runtime}`,
114
+ };
115
+ }
116
+ return {
117
+ witness: `${sourcePath}.zmdb.witness.ts`,
118
+ js: `${sourcePath}.zmdb.generated.js`,
119
+ dts: `${sourcePath}.zmdb.generated.d.ts`,
120
+ specifier: `./${base(sourcePath)}.zmdb.generated.js`,
121
+ };
122
+ }
123
+
124
+ function base(path: string): string {
125
+ const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
126
+ return cut === -1 ? path : path.slice(cut + 1);
127
+ }
128
+
129
+ /** Whether a path is something a previous run wrote. The scan must not recurse into these. */
130
+ export function isGeneratedPath(path: string): boolean {
131
+ return /\.zmdb\.(?:witness|generated)\.[cm]?[jt]sx?$|\.zmdb\.generated\.d\.[cm]?ts$/.test(path);
132
+ }
133
+
134
+ // -----------------------------------------------------------------------------
135
+ // Signatures
136
+ // -----------------------------------------------------------------------------
137
+
138
+ interface Signature {
139
+ /** Generic parameters the generated wrapper itself carries. */
140
+ readonly typeParameters?: string;
141
+ /** The TypeScript parameter list, annotations included. */
142
+ readonly parameters: string;
143
+ /** The TypeScript return type. */
144
+ readonly returns: string;
145
+ /** The parameter list with the annotations gone, for the JavaScript. */
146
+ readonly plain: string;
147
+ /** The runtime call the witness makes, which is what the transformer replaces. */
148
+ readonly call: string;
149
+ }
150
+
151
+ /**
152
+ * The wrapper's shape for one entry.
153
+ *
154
+ * Every callee becomes a *function*, including the two — `toJsonSchema<T>()` and
155
+ * `schemaOf<T>()` — that take no argument and could have been a `const`. Not for efficiency:
156
+ * the emitter hoists both to one deeply frozen module-level literal and the wrapper returns a
157
+ * reference to it, so a `const` would compile to the same bytes. It is so that the generated
158
+ * module has the same shape as the call it replaced. `document()` was a call in the source and
159
+ * stays one, which is what keeps the rewrite a rename rather than a refactor.
160
+ */
161
+ function signature(entry: Entry): Signature {
162
+ const type = entry.typeText;
163
+ const typeArguments = entry.depthText === undefined ? type : `${type}, ${entry.depthText}`;
164
+ const captured = entry.argumentsText?.join(', ');
165
+ switch (entry.callee) {
166
+ case 'is':
167
+ case 'equals':
168
+ return {
169
+ parameters: 'value: unknown',
170
+ returns: `value is ${type}`,
171
+ plain: 'value',
172
+ call: `${entry.callee}<${type}>(value)`,
173
+ };
174
+ case 'isShallow':
175
+ return {
176
+ parameters: 'value: unknown',
177
+ returns: `value is ${type}`,
178
+ plain: 'value',
179
+ call: `isShallow<${typeArguments}>(value)`,
180
+ };
181
+ case 'assert':
182
+ case 'assertEquals':
183
+ return { parameters: 'value: unknown', returns: type, plain: 'value', call: `${entry.callee}<${type}>(value)` };
184
+ case 'assertShallow':
185
+ return {
186
+ parameters: 'value: unknown',
187
+ returns: type,
188
+ plain: 'value',
189
+ call: `assertShallow<${typeArguments}>(value)`,
190
+ };
191
+ case 'validate':
192
+ return {
193
+ parameters: 'value: unknown',
194
+ returns: `ValidateResult<${type}>`,
195
+ plain: 'value',
196
+ call: `validate<${type}>(value)`,
197
+ };
198
+ case 'validateShallow':
199
+ return {
200
+ parameters: 'value: unknown',
201
+ returns: `ValidateResult<${type}>`,
202
+ plain: 'value',
203
+ call: `validateShallow<${typeArguments}>(value)`,
204
+ };
205
+ case 'random':
206
+ return { parameters: '', returns: type, plain: '', call: `random<${type}>()` };
207
+ case 'toJsonSchema':
208
+ return { parameters: '', returns: 'JsonSchemaObject', plain: '', call: `toJsonSchema<${type}>()` };
209
+ case 'schemaOf':
210
+ return { parameters: '', returns: `TaggedSchema<${type}>`, plain: '', call: `schemaOf<${type}>()` };
211
+ case 'toolFor':
212
+ return {
213
+ typeParameters: '<P extends ToolProvider>',
214
+ parameters: 'provider: P, name: string, opts?: ToolOptions',
215
+ returns: 'ToolSpecFor[P]',
216
+ plain: 'provider, name, opts',
217
+ call: `toolFor<${type}, P>(provider, name, opts)`,
218
+ };
219
+ case 'grpcDescriptor':
220
+ return {
221
+ parameters: '',
222
+ returns: 'string',
223
+ plain: '',
224
+ call: `grpcDescriptor<${type}>(${captured ?? ''})`,
225
+ };
226
+ case 'loadGrpcService':
227
+ return {
228
+ parameters: '',
229
+ returns: `GrpcLoadedService<${type}>`,
230
+ plain: '',
231
+ call: `loadGrpcService<${type}>(${captured ?? ''})`,
232
+ };
233
+ case 'protoDescriptor':
234
+ return { parameters: '', returns: 'string', plain: '', call: `protoDescriptor<${type}>()` };
235
+ case 'protoDecode':
236
+ return {
237
+ parameters: 'bytes: Uint8Array',
238
+ returns: type,
239
+ plain: 'bytes',
240
+ call: `protoDecode<${type}>(bytes)`,
241
+ };
242
+ case 'protoEncode':
243
+ return {
244
+ parameters: `value: ${type}`,
245
+ returns: 'Uint8Array',
246
+ plain: 'value',
247
+ call: `protoEncode<${type}>(value)`,
248
+ };
249
+ default:
250
+ // `CALLEES` is the only source of `callee`, and every member of it is above. A new one
251
+ // added there and not here would otherwise generate a wrapper that calls nothing.
252
+ throw new Error(`@zmdb/compiler has no wrapper for \`${entry.callee}<T>()\``);
253
+ }
254
+ }
255
+
256
+ /** `export function NAME(value: unknown): value is User {` — the line the `.js` swaps out. */
257
+ function tsOpening(entry: Entry): string {
258
+ const { parameters, returns, typeParameters = '' } = signature(entry);
259
+ return `export function ${entry.name}${typeParameters}(${parameters}): ${returns} {`;
260
+ }
261
+
262
+ // -----------------------------------------------------------------------------
263
+ // Imports
264
+ // -----------------------------------------------------------------------------
265
+
266
+ function typeImportLines(imports: readonly TypeImport[], style: string): string[] {
267
+ const named = new Map<string, string[]>();
268
+ const lines: string[] = [];
269
+ for (const entry of imports) {
270
+ if (entry.kind === 'default') {
271
+ lines.push(`import type ${entry.local} from ${quote(entry.specifier, style)};`);
272
+ } else if (entry.kind === 'namespace') {
273
+ lines.push(`import type * as ${entry.local} from ${quote(entry.specifier, style)};`);
274
+ } else {
275
+ const clause = entry.original === entry.local ? entry.local : `${entry.original} as ${entry.local}`;
276
+ const group = named.get(entry.specifier);
277
+ if (group) group.push(clause);
278
+ else named.set(entry.specifier, [clause]);
279
+ }
280
+ }
281
+ for (const [specifier, clauses] of named) {
282
+ lines.push(`import type { ${clauses.toSorted().join(', ')} } from ${quote(specifier, style)};`);
283
+ }
284
+ return lines.toSorted();
285
+ }
286
+
287
+ /**
288
+ * The runtime API the witness calls, and the types its signatures mention.
289
+ *
290
+ * Values come from the specifier the *source* used, never from an implementation package: a
291
+ * project that installed only `zmdb` has no direct `@zmdb/validator` dependency. Support
292
+ * types normally share that entry. The one deliberate split is root `schemaOf`: the curated
293
+ * root keeps the callable, while `TaggedSchema` belongs to the complete `zmdb/schema` concern.
294
+ */
295
+ function calleeImportLines(entries: readonly Entry[], sources: ReadonlyMap<string, string>, style: string): string[] {
296
+ const values = new Map<string, Set<string>>();
297
+ const types = new Map<string, Set<string>>();
298
+ const into = (map: Map<string, Set<string>>, specifier: string, name: string): void => {
299
+ const group = map.get(specifier);
300
+ if (group) group.add(name);
301
+ else map.set(specifier, new Set([name]));
302
+ };
303
+
304
+ for (const entry of entries) {
305
+ const specifier = sources.get(entry.callee) ?? '@zmdb/validator';
306
+ into(values, specifier, entry.callee);
307
+ const support = SUPPORT_TYPES[entry.callee];
308
+ if (support) {
309
+ const supportSpecifier = entry.callee === 'schemaOf' && specifier === 'zmdb' ? 'zmdb/schema' : specifier;
310
+ for (const name of support) into(types, supportSpecifier, name);
311
+ }
312
+ }
313
+
314
+ const lines: string[] = [];
315
+ for (const [specifier, names] of values) {
316
+ lines.push(`import { ${[...names].toSorted().join(', ')} } from ${quote(specifier, style)};`);
317
+ }
318
+ for (const [specifier, names] of types) {
319
+ lines.push(`import type { ${[...names].toSorted().join(', ')} } from ${quote(specifier, style)};`);
320
+ }
321
+ return lines.toSorted();
322
+ }
323
+
324
+ /** The support-type imports on their own, for the `.d.ts`, which calls nothing. */
325
+ function supportTypeLines(entries: readonly Entry[], sources: ReadonlyMap<string, string>, style: string): string[] {
326
+ return calleeImportLines(entries, sources, style).filter(line => line.startsWith('import type '));
327
+ }
328
+
329
+ /**
330
+ * A module specifier, quoted the way the file it is going into quotes them.
331
+ *
332
+ * Cosmetic in isolation, load-bearing in aggregate: a consumer runs a formatter over its
333
+ * tree, and a generated file that disagrees with it is a diff on every `--check`.
334
+ */
335
+ function quote(specifier: string, style: string): string {
336
+ if (style !== "'") return JSON.stringify(specifier);
337
+ return `'${specifier.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`;
338
+ }
339
+
340
+ /**
341
+ * Which quote the source file uses for its own imports. Single unless it says otherwise —
342
+ * the majority style, and the one a file with no imports at all is most likely to want.
343
+ */
344
+ export function quoteStyle(sourceFile: SourceFile): string {
345
+ for (const node of sourceFile.statements) {
346
+ if (!isImportDeclaration(node)) continue;
347
+ const specifier = node.moduleSpecifier;
348
+ if (!isStringLiteral(specifier)) continue;
349
+ const char = sourceFile.text[specifier.getStart()];
350
+ if (char === '"' || char === "'") return char;
351
+ }
352
+ return "'";
353
+ }
354
+
355
+ // -----------------------------------------------------------------------------
356
+ // The witness
357
+ // -----------------------------------------------------------------------------
358
+
359
+ export interface WitnessInput {
360
+ /** Base name of the source this witness belongs to, for the header. */
361
+ readonly sourceName: string;
362
+ readonly entries: readonly Entry[];
363
+ readonly typeImports: readonly TypeImport[];
364
+ readonly calleeSources: ReadonlyMap<string, string>;
365
+ /** The quote character the source uses. See `quoteStyle`. */
366
+ readonly style: string;
367
+ }
368
+
369
+ export function witnessSource(input: WitnessInput): string {
370
+ const { sourceName, entries, typeImports, calleeSources, style } = input;
371
+ const parts: string[] = [
372
+ IMPORTS_OPEN,
373
+ `// Generated by @zmdb/compiler from ${sourceName}. Checked, not edited.`,
374
+ '//',
375
+ `// One wrapper per validation call ${sourceName} used to make, written against the runtime`,
376
+ '// API so that the compiler still sees each type argument. That is the point of the file: it',
377
+ '// is what the project compiler reads on the next run to regenerate the compiled module, and it is',
378
+ '// what turns a renamed or deleted type into a build error here instead of a validator that',
379
+ '// silently checks a shape nobody declares.',
380
+ '//',
381
+ '// Editing it by hand does nothing. An entry disappears when the source stops referring to',
382
+ "// its export name, so deleting a call in the source is how you delete a validator; there's",
383
+ '// no bookkeeping to keep in step.',
384
+ ];
385
+ const imports = [...typeImportLines(typeImports, style), ...calleeImportLines(entries, calleeSources, style)];
386
+ if (imports.length > 0) parts.push('', ...imports);
387
+ parts.push(IMPORTS_CLOSE);
388
+
389
+ for (const entry of entries) {
390
+ const { call } = signature(entry);
391
+ parts.push('', begin(entry.name), tsOpening(entry), ` return ${call};`, '}', end(entry.name));
392
+ }
393
+
394
+ return `${parts.join('\n')}\n`;
395
+ }
396
+
397
+ // -----------------------------------------------------------------------------
398
+ // The compiled pair
399
+ // -----------------------------------------------------------------------------
400
+
401
+ export interface GeneratedModules {
402
+ readonly js: string;
403
+ readonly dts: string;
404
+ }
405
+
406
+ export interface GenerateInput extends WitnessInput {
407
+ /** `transformFile`'s output for the witness. */
408
+ readonly transformed: string;
409
+ }
410
+
411
+ /**
412
+ * The `.js` and the `.d.ts`, assembled from the transformed witness.
413
+ *
414
+ * Only the wrapper bodies come from the transform; every other line is regenerated here from
415
+ * the same `Entry` list the witness was built from. So the two files cannot disagree about a
416
+ * signature, and neither can drift from the witness.
417
+ */
418
+ export function generatedModules(input: GenerateInput): GeneratedModules {
419
+ const { sourceName, entries, transformed } = input;
420
+
421
+ const header = (tail: readonly string[]): string =>
422
+ [
423
+ `// Generated by @zmdb/compiler from ${sourceName}. Do not edit.`,
424
+ '//',
425
+ `// The validators, compiled. Every check below was emitted from a type in ${sourceName}'s`,
426
+ `// witness module by \`@zmdb/compiler\`, so there is no schema to walk at runtime and`,
427
+ '// nothing to look up: the shape is the control flow.',
428
+ '//',
429
+ ...tail,
430
+ ].join('\n');
431
+
432
+ const jsHeader = header([
433
+ '// JavaScript rather than TypeScript on purpose — the emitted helpers are untyped, and a',
434
+ '// generated file that needed `any` to typecheck would be a worse trade than one that is',
435
+ '// simply not typechecked. The declarations live in the sidecar `.d.ts`.',
436
+ '//',
437
+ '// Formatters and linters should skip it. The emitter writes a check as one long expression',
438
+ '// because that is what it is; reformatting would leave the next compiler check',
439
+ '// permanently out of date against a file nothing was wrong with.',
440
+ ]);
441
+ const dtsHeader = header([
442
+ '// This half is the types: the signatures the compiler reads when the source imports the',
443
+ '// module next door. Nothing here is checked against the implementation, and nothing needs',
444
+ '// to be — the witness makes the same claims against the runtime API, and *that* is checked.',
445
+ ]);
446
+
447
+ const preludeEnd = firstMarker(transformed, entries);
448
+ let prelude = transformed.slice(0, preludeEnd);
449
+ const open = prelude.indexOf(IMPORTS_OPEN);
450
+ const close = prelude.indexOf(IMPORTS_CLOSE);
451
+ if (open !== -1 && close !== -1) {
452
+ // The header and the witness's own imports. Both are TypeScript-only by now: every call
453
+ // that needed the runtime API has been inlined, so the import would load a validator
454
+ // nobody calls, and the type imports are not syntax a `.js` file may contain.
455
+ prelude = prelude.slice(0, open) + prelude.slice(close + IMPORTS_CLOSE.length);
456
+ }
457
+
458
+ const bodies = entries.map(entry => javascriptFor(entry, transformed));
459
+
460
+ const declarations = entries.map(entry => {
461
+ const { parameters, returns, typeParameters = '' } = signature(entry);
462
+ return `export declare function ${entry.name}${typeParameters}(${parameters}): ${returns};`;
463
+ });
464
+
465
+ const dtsImports = [
466
+ ...typeImportLines(input.typeImports, input.style),
467
+ ...supportTypeLines(entries, input.calleeSources, input.style),
468
+ ];
469
+
470
+ return {
471
+ js: `${jsHeader}\n${join([prelude.trim(), ...bodies])}\n`,
472
+ dts: `${dtsHeader}\n${join([dtsImports.join('\n'), ...declarations])}\n`,
473
+ };
474
+ }
475
+
476
+ function join(blocks: readonly string[]): string {
477
+ return blocks.filter(block => block.length > 0).join('\n\n');
478
+ }
479
+
480
+ /** Where the wrappers start, which is where the prelude ends. */
481
+ function firstMarker(transformed: string, entries: readonly Entry[]): number {
482
+ let earliest = transformed.length;
483
+ for (const entry of entries) {
484
+ const at = transformed.indexOf(begin(entry.name));
485
+ if (at !== -1 && at < earliest) earliest = at;
486
+ }
487
+ return earliest;
488
+ }
489
+
490
+ /**
491
+ * One wrapper, as JavaScript.
492
+ *
493
+ * The sentinel comments bound it and the signature line is known verbatim, so this is two
494
+ * string operations. A missing marker means the transformer moved or dropped a statement,
495
+ * which it has no path to do — so it throws rather than writing a module with a hole in it.
496
+ */
497
+ function javascriptFor(entry: Entry, transformed: string): string {
498
+ const from = transformed.indexOf(begin(entry.name));
499
+ const to = transformed.indexOf(end(entry.name));
500
+ if (from === -1 || to === -1 || to < from) {
501
+ throw new Error(`the transformed witness lost the wrapper for ${entry.name}`);
502
+ }
503
+ const chunk = transformed.slice(from + begin(entry.name).length, to).trim();
504
+ const opening = tsOpening(entry);
505
+ if (!chunk.startsWith(opening)) {
506
+ throw new Error(`the transformed witness rewrote the signature of ${entry.name}`);
507
+ }
508
+ const { plain } = signature(entry);
509
+ return `export function ${entry.name}(${plain}) {${chunk.slice(opening.length)}`;
510
+ }
511
+
512
+ // -----------------------------------------------------------------------------
513
+ // The source rewrite
514
+ // -----------------------------------------------------------------------------
515
+
516
+ export interface RewriteInput {
517
+ readonly sourceFile: SourceFile;
518
+ readonly code: string;
519
+ readonly sites: readonly SiteEntry[];
520
+ /** What to import the generated module as. See `artifactPaths`. */
521
+ readonly specifier: string;
522
+ readonly entries: readonly Entry[];
523
+ readonly calleeSources: ReadonlyMap<string, string>;
524
+ /** The quote character the source uses. See `quoteStyle`. */
525
+ readonly style: string;
526
+ }
527
+
528
+ /**
529
+ * `is<User>(data)` → `zmdbIsUser(data)`, plus the import that makes it resolve.
530
+ *
531
+ * The whole call is replaced rather than just its callee, because the type argument has to
532
+ * go with it and `is<User>` is one span in the middle of another. Arguments are read through
533
+ * the `Rewriter`, so `assert<A>(is<B>(x))` picks up the inner rewrite instead of carrying a
534
+ * stale copy of it.
535
+ */
536
+ export function rewriteSource(input: RewriteInput): string {
537
+ const { sourceFile, code, sites, specifier, entries, calleeSources, style } = input;
538
+ const rewriter = new Rewriter(code);
539
+
540
+ // Back to front, for the same reason the transformer does it: an earlier offset is only
541
+ // valid while everything after it is untouched.
542
+ for (const { site, entry } of sites.toReversed()) {
543
+ const args = site.node.arguments;
544
+ const first = args[0];
545
+ const last = args[args.length - 1];
546
+ const inner = entry.argumentsText === undefined && first && last ? rewriter.slice(first.getStart(), last.end) : '';
547
+ rewriter.replace(site.node.getStart(), site.node.end, `${entry.name}(${inner})`);
548
+ }
549
+
550
+ const names = entries.map(entry => entry.name);
551
+ const statement = names.length === 0 ? '' : `import { ${names.join(', ')} } from ${quote(specifier, style)};`;
552
+
553
+ // The import edits come after the call edits and are all at lower offsets, so no applied
554
+ // edit lies inside one of their spans and the `Rewriter`'s arithmetic is unaffected.
555
+ const owned = new Set(calleeSources.values());
556
+ const compiled = new Set(sites.map(({ site }) => site.binding));
557
+ const context: EditContext = {
558
+ specifier,
559
+ statement,
560
+ names,
561
+ owned,
562
+ compiled,
563
+ style,
564
+ live: liveIdentifiers(sourceFile, sites),
565
+ };
566
+ for (const edit of importEdits(sourceFile, context).toSorted((a, b) => b.start - a.start)) {
567
+ rewriter.replace(edit.start, edit.end, edit.text);
568
+ }
569
+
570
+ return rewriter.text;
571
+ }
572
+
573
+ /**
574
+ * Every name the file still uses after the rewrite, as names rather than as text.
575
+ *
576
+ * This decides whether a callee's import is now dead, and it has to be an AST walk. Searching
577
+ * the text for `is` finds it in `import { is } from 'zmdb'`, which is the statement being
578
+ * judged, and finds it in a comment — this file's own fixture said "the type argument *is* the
579
+ * input" and kept a compiled-away import alive on the strength of it. A regex has no way to
580
+ * know which of those is a reference and the parser already does.
581
+ *
582
+ * Three things are therefore skipped: import declarations, because an import is not a use; the
583
+ * head of each compiled call, because `is<User>` is what is going away, while its *arguments*
584
+ * are walked, so an `assert` passed as a value inside one keeps its import; and the property
585
+ * half of `a.b`, because `payload.is` names a property, not the imported function.
586
+ */
587
+ function liveIdentifiers(sourceFile: SourceFile, sites: readonly SiteEntry[]): ReadonlySet<string> {
588
+ const compiled = new Map<Node, readonly Node[]>();
589
+ for (const { site } of sites) compiled.set(site.node, site.node.arguments);
590
+
591
+ const names = new Set<string>();
592
+ const walk = (node: Node): void => {
593
+ if (isImportDeclaration(node)) return;
594
+ const args = compiled.get(node);
595
+ if (args) {
596
+ for (const argument of args) walk(argument);
597
+ return;
598
+ }
599
+ if (isIdentifier(node)) {
600
+ names.add(node.text);
601
+ return;
602
+ }
603
+ if (isPropertyAccessExpression(node)) {
604
+ walk(node.expression);
605
+ return;
606
+ }
607
+ node.forEachChild(walk);
608
+ };
609
+ sourceFile.forEachChild(walk);
610
+ return names;
611
+ }
612
+
613
+ interface TextEdit {
614
+ readonly start: number;
615
+ readonly end: number;
616
+ readonly text: string;
617
+ }
618
+
619
+ /**
620
+ * How far a deleted statement's deletion should reach: through the end of its line, and
621
+ * through the blank lines after it when they are no longer separating anything.
622
+ *
623
+ * The line itself is not optional — leaving the newline behind puts a blank line where the
624
+ * statement was, and no later run would remove it, so the file would be permanently one line
625
+ * emptier than the formatter wants it. The blank lines after are the same problem one level
626
+ * up: deleting the only import above a blank line leaves the file starting with that blank
627
+ * line, so a paragraph break that now divides nothing goes with the paragraph.
628
+ */
629
+ function throughLine(text: string, start: number, stop: number): number {
630
+ let at = skipSpaces(text, stop);
631
+ if (at >= text.length || text[at] !== '\n') return stop;
632
+ at += 1;
633
+ if (!blankAbove(text, start)) return at;
634
+ while (at < text.length && text[skipSpaces(text, at)] === '\n') at = skipSpaces(text, at) + 1;
635
+ return at;
636
+ }
637
+
638
+ function skipSpaces(text: string, from: number): number {
639
+ let at = from;
640
+ while (at < text.length && (text[at] === ' ' || text[at] === '\t' || text[at] === '\r')) at += 1;
641
+ return at;
642
+ }
643
+
644
+ /** Whether `start`'s line is the first in the file or has a blank line above it. */
645
+ function blankAbove(text: string, start: number): boolean {
646
+ let at = start - 1;
647
+ while (at >= 0 && (text[at] === ' ' || text[at] === '\t' || text[at] === '\r')) at -= 1;
648
+ if (at < 0) return true;
649
+ if (text[at] !== '\n') return false;
650
+ at -= 1;
651
+ while (at >= 0 && (text[at] === ' ' || text[at] === '\t' || text[at] === '\r')) at -= 1;
652
+ return at < 0 || text[at] === '\n';
653
+ }
654
+
655
+ /** The local names a `{ … }` import clause binds. Aliases are not something codegen writes. */
656
+ function importedNames(bindings: NamedImportBindings | undefined): string[] {
657
+ if (!bindings || !isNamedImports(bindings)) return [];
658
+ return bindings.elements.filter(element => isIdentifier(element.name)).map(element => element.name.text);
659
+ }
660
+
661
+ /** Whether an import already binds exactly `wanted`, in any order. */
662
+ function satisfies(found: readonly string[], wanted: readonly string[]): boolean {
663
+ return found.length === wanted.length && wanted.every(name => found.includes(name));
664
+ }
665
+
666
+ interface EditContext {
667
+ /** What the generated import names. */
668
+ readonly specifier: string;
669
+ /** The generated import statement, or `''` when the file needs none. */
670
+ readonly statement: string;
671
+ /** What that statement names, for comparing against one already in the file. */
672
+ readonly names: readonly string[];
673
+ /** Specifiers a callee was imported from, whose namespace binding may now be dead. */
674
+ readonly owned: ReadonlySet<string>;
675
+ /** Local named-import or namespace bindings whose calls were compiled away. */
676
+ readonly compiled: ReadonlySet<string>;
677
+ readonly style: string;
678
+ /** See `liveIdentifiers`: the names that still count as a use. */
679
+ readonly live: ReadonlySet<string>;
680
+ }
681
+
682
+ /**
683
+ * Add the generated import, and take away the ones the rewrite just orphaned.
684
+ *
685
+ * The second half is not tidiness: `import { is } from 'zmdb'` with no `is` left in the file
686
+ * is an error under `noUnusedLocals`, so a codegen that left it behind would break the build
687
+ * it was supposed to speed up. Only bindings this rewrite could have orphaned are considered
688
+ * — the callee names, and a namespace or default binding of a module a callee came
689
+ * from — and only when the name is referenced nowhere in what is left.
690
+ */
691
+ function importEdits(sourceFile: SourceFile, context: EditContext): TextEdit[] {
692
+ const { specifier, statement, names, owned, compiled, style, live } = context;
693
+ const edits: TextEdit[] = [];
694
+ let existing: { start: number; end: number; withLine: number; names: string[] } | undefined;
695
+ // Where the generated import can go, and where it cannot. The new statement goes after the
696
+ // last import that is *still there* — anchoring it to one this pass is deleting would put an
697
+ // insertion inside a deleted span, and the deletion would eat it.
698
+ const surviving: number[] = [];
699
+ const removals: { readonly at: number; readonly start: number; readonly nodeEnd: number; readonly end: number }[] =
700
+ [];
701
+
702
+ for (const node of sourceFile.statements) {
703
+ if (!isImportDeclaration(node)) continue;
704
+ surviving.push(node.end);
705
+ /** Delete the statement whole: it exists only to import something now compiled away. */
706
+ const remove = (): void => {
707
+ surviving.pop();
708
+ const through = throughLine(sourceFile.text, node.getStart(), node.end);
709
+ removals.push({ at: edits.length, start: node.getStart(), nodeEnd: node.end, end: through });
710
+ edits.push({ start: node.getStart(), end: through, text: '' });
711
+ };
712
+ const moduleSpecifier = node.moduleSpecifier;
713
+ if (!isStringLiteral(moduleSpecifier)) continue;
714
+ if (moduleSpecifier.text === specifier) {
715
+ // A previous run's import. Its *names* are what get compared, never its text — see
716
+ // `satisfies` below.
717
+ existing = {
718
+ start: node.getStart(),
719
+ end: node.end,
720
+ withLine: throughLine(sourceFile.text, node.getStart(), node.end),
721
+ names: importedNames(node.importClause?.namedBindings),
722
+ };
723
+ continue;
724
+ }
725
+
726
+ const clause = node.importClause;
727
+ if (!clause) continue;
728
+ const dead = (name: string): boolean => !live.has(name);
729
+
730
+ const bindings = clause.namedBindings;
731
+ const namespaceName =
732
+ bindings && isNamespaceImport(bindings) && isIdentifier(bindings.name) ? bindings.name.text : undefined;
733
+ const defaultName = clause.name && isIdentifier(clause.name) ? clause.name.text : undefined;
734
+
735
+ if (owned.has(moduleSpecifier.text)) {
736
+ const single = namespaceName ?? defaultName;
737
+ if (single !== undefined && (!bindings || !isNamedImports(bindings)) && dead(single)) {
738
+ remove();
739
+ continue;
740
+ }
741
+ }
742
+
743
+ if (!bindings || !isNamedImports(bindings)) continue;
744
+ const keep: string[] = [];
745
+ let dropped = false;
746
+ for (const element of bindings.elements) {
747
+ if (!isIdentifier(element.name)) continue;
748
+ const local = element.name.text;
749
+ if (compiled.has(local) && dead(local)) {
750
+ dropped = true;
751
+ continue;
752
+ }
753
+ const original = element.propertyName && isIdentifier(element.propertyName) ? element.propertyName.text : local;
754
+ // `import { type A, B }` — the per-specifier modifier has to survive the rebuild, or
755
+ // a type-only binding becomes a value import of something that does not exist at runtime.
756
+ const modifier = element.isTypeOnly ? 'type ' : '';
757
+ keep.push(`${modifier}${original === local ? local : `${original} as ${local}`}`);
758
+ }
759
+ if (!dropped) continue;
760
+ if (keep.length === 0 && defaultName === undefined) {
761
+ remove();
762
+ } else {
763
+ const prefix = defaultName === undefined ? '' : `${defaultName}, `;
764
+ const kind = clause.phaseModifier === SyntaxKind.TypeKeyword ? 'import type' : 'import';
765
+ edits.push({
766
+ start: node.getStart(),
767
+ end: node.end,
768
+ text:
769
+ keep.length === 0
770
+ ? `${kind} ${defaultName ?? ''} from ${quote(moduleSpecifier.text, style)};`
771
+ : `${kind} ${prefix}{ ${keep.join(', ')} } from ${quote(moduleSpecifier.text, style)};`,
772
+ });
773
+ }
774
+ }
775
+
776
+ if (statement.length === 0) {
777
+ // Nothing left to import. An existing import from a previous run is now dead, and
778
+ // leaving it would keep a generated module alive that nothing references.
779
+ if (existing) edits.push({ start: existing.start, end: existing.withLine, text: '' });
780
+ return edits;
781
+ }
782
+
783
+ if (existing) {
784
+ // Only when it says something different. The one thing this statement has to get right is
785
+ // which names come from the generated module, and a consumer's formatter owns everything
786
+ // else about it: `printWidth` wraps a seven-name import across eight lines, `sortImports`
787
+ // reorders the names, and neither is a reason to rewrite the file. Comparing text here
788
+ // would put compiler check mode and `fmt --check` in a loop that neither can win.
789
+ if (!satisfies(existing.names, names)) edits.push({ start: existing.start, end: existing.end, text: statement });
790
+ return edits;
791
+ }
792
+
793
+ const anchor = surviving.at(-1);
794
+ if (anchor !== undefined) {
795
+ edits.push({ start: anchor, end: anchor, text: `\n${statement}` });
796
+ return edits;
797
+ }
798
+
799
+ const first = removals.toSorted((a, b) => a.start - b.start)[0];
800
+ if (first) {
801
+ // Every import the file had was orphaned by the rewrite — the common case for a module whose
802
+ // only import was the validator. So the generated one takes the first one's place *as* that
803
+ // deletion: an insertion at the same offset would be two edits claiming one byte, with no
804
+ // rule saying which of them won. The trailing whitespace the deletion swallowed comes back
805
+ // with it, so the file keeps the paragraph break it had.
806
+ edits[first.at] = {
807
+ start: first.start,
808
+ end: first.end,
809
+ text: `${statement}${sourceFile.text.slice(first.nodeEnd, first.end)}`,
810
+ };
811
+ return edits;
812
+ }
813
+
814
+ // No imports at all. After the leading comment of the first statement rather than at
815
+ // offset zero, so a file's licence header stays at the top of it.
816
+ const at = sourceFile.statements[0]?.getStart() ?? 0;
817
+ edits.push({ start: at, end: at, text: `${statement}\n\n` });
818
+ return edits;
819
+ }