@ic-reactor/codegen 0.11.1 → 0.12.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.
@@ -0,0 +1,515 @@
1
+ import type {
2
+ CandidMetadata,
3
+ CandidSchema,
4
+ CandidType,
5
+ CandidTypeDeclaration,
6
+ } from "@ic-reactor/parser"
7
+ import {
8
+ BUILT_IN_FORMAT_HELPERS,
9
+ type CustomJSDocFormatTypes,
10
+ normalizeValidationMetadata,
11
+ } from "./metadata.js"
12
+
13
+ export { BUILT_IN_JSDOC_FORMAT_TYPES } from "./metadata.js"
14
+ export { BUILT_IN_FORMAT_HELPERS } from "./metadata.js"
15
+
16
+ export type {
17
+ CustomJSDocFormatTypes,
18
+ JSDocFormatDefinition,
19
+ } from "./metadata.js"
20
+
21
+ export interface GenerateCodecDeclarationsOptions {
22
+ /**
23
+ * Canister name used to derive the service export name.
24
+ * Converted to SCREAMING_SNAKE_CASE (e.g. `"my-backend"` → `MY_BACKEND`).
25
+ * Ignored when `serviceExportName` is set explicitly.
26
+ */
27
+ canisterName?: string
28
+ /** Explicit service export name. Takes priority over `canisterName`. */
29
+ serviceExportName?: string
30
+ customJSDocFormatTypes?: CustomJSDocFormatTypes
31
+ includeCompatibilityExports?: boolean
32
+ }
33
+
34
+ const reservedWords = new Set([
35
+ "break",
36
+ "case",
37
+ "catch",
38
+ "class",
39
+ "const",
40
+ "continue",
41
+ "debugger",
42
+ "default",
43
+ "delete",
44
+ "do",
45
+ "else",
46
+ "export",
47
+ "extends",
48
+ "false",
49
+ "finally",
50
+ "for",
51
+ "function",
52
+ "if",
53
+ "import",
54
+ "in",
55
+ "instanceof",
56
+ "new",
57
+ "null",
58
+ "return",
59
+ "super",
60
+ "switch",
61
+ "this",
62
+ "throw",
63
+ "true",
64
+ "try",
65
+ "typeof",
66
+ "var",
67
+ "void",
68
+ "while",
69
+ "with",
70
+ "yield",
71
+ "let",
72
+ "package",
73
+ "private",
74
+ "protected",
75
+ "public",
76
+ "static",
77
+ ])
78
+
79
+ function isValidIdentifier(name: string): boolean {
80
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && !reservedWords.has(name)
81
+ }
82
+
83
+ function assertIdentifier(name: string, label: string): void {
84
+ if (!isValidIdentifier(name)) {
85
+ throw new Error(`${label} must be a valid TypeScript identifier: ${name}`)
86
+ }
87
+ }
88
+
89
+ function propertyName(name: string): string {
90
+ return isValidIdentifier(name) ? name : JSON.stringify(name)
91
+ }
92
+
93
+ function normalizeOptions(
94
+ options: string | GenerateCodecDeclarationsOptions = {}
95
+ ): GenerateCodecDeclarationsOptions {
96
+ return typeof options === "string" ? { serviceExportName: options } : options
97
+ }
98
+
99
+ /**
100
+ * Convert a canister name to a SCREAMING_SNAKE_CASE identifier.
101
+ * Hyphens and spaces are replaced with underscores.
102
+ */
103
+ function toServiceExportName(canisterName: string): string {
104
+ return canisterName.replace(/[-\s]+/g, "_").toUpperCase()
105
+ }
106
+
107
+ function resolveServiceExportName(
108
+ options: GenerateCodecDeclarationsOptions,
109
+ declaredTypeNames: Set<string>
110
+ ): string {
111
+ if (options.serviceExportName) return options.serviceExportName
112
+
113
+ if (options.canisterName) {
114
+ let name = toServiceExportName(options.canisterName)
115
+ if (declaredTypeNames.has(name)) {
116
+ name = `${name}_SERVICE`
117
+ }
118
+ return name
119
+ }
120
+
121
+ return "_SERVICE"
122
+ }
123
+
124
+ function hasMetadata(
125
+ metadata: CandidMetadata | undefined
126
+ ): metadata is CandidMetadata {
127
+ return metadata != null && Object.keys(metadata).length > 0
128
+ }
129
+
130
+ function stripUndefined<T extends Record<string, unknown>>(value: T): T {
131
+ return Object.fromEntries(
132
+ Object.entries(value).filter(([, entry]) => entry !== undefined)
133
+ ) as T
134
+ }
135
+
136
+ function isEmptyObject(value: unknown): boolean {
137
+ return (
138
+ value != null &&
139
+ typeof value === "object" &&
140
+ !Array.isArray(value) &&
141
+ Object.keys(value).length === 0
142
+ )
143
+ }
144
+
145
+ function textFormatHelperFor(
146
+ expression: string,
147
+ metadata: CandidMetadata,
148
+ options: GenerateCodecDeclarationsOptions
149
+ ): string | undefined {
150
+ const format = metadata.validation?.format
151
+ if (!format || expression !== "c.text()") return undefined
152
+ if (options.customJSDocFormatTypes?.[format.type]) {
153
+ return undefined
154
+ }
155
+ return BUILT_IN_FORMAT_HELPERS[format.type]
156
+ }
157
+
158
+ function hasOnlyDescriptionDocs(
159
+ docs: string[] | undefined,
160
+ description: string | undefined
161
+ ): boolean {
162
+ return (
163
+ docs != null &&
164
+ description != null &&
165
+ docs.length === 1 &&
166
+ docs[0] === description
167
+ )
168
+ }
169
+
170
+ function metadataForRender(
171
+ metadata: CandidMetadata,
172
+ options: GenerateCodecDeclarationsOptions
173
+ ): CandidMetadata {
174
+ if (!metadata.validation) {
175
+ return metadata
176
+ }
177
+
178
+ return {
179
+ ...metadata,
180
+ validation: normalizeValidationMetadata(metadata.validation, {
181
+ customJSDocFormatTypes: options.customJSDocFormatTypes,
182
+ }),
183
+ }
184
+ }
185
+
186
+ function applyMetadata(
187
+ expression: string,
188
+ metadata: CandidMetadata | undefined,
189
+ options: GenerateCodecDeclarationsOptions
190
+ ): string {
191
+ if (!hasMetadata(metadata)) return expression
192
+
193
+ const textFormatHelper = textFormatHelperFor(expression, metadata, options)
194
+ const textFormatMessage = metadata.validation?.format?.message
195
+ const renderedMetadata = metadataForRender(metadata, options)
196
+ const { description, ...metadataRest } = renderedMetadata
197
+ const rest: Partial<CandidMetadata> = { ...metadataRest }
198
+ let result = textFormatHelper
199
+ ? `c.${textFormatHelper}(${textFormatMessage ? JSON.stringify(textFormatMessage) : ""})`
200
+ : expression
201
+
202
+ if (textFormatHelper) {
203
+ delete rest.docs
204
+ if (rest.validation) {
205
+ const { format: _format, ...validationRest } = rest.validation
206
+ rest.validation = stripUndefined(validationRest)
207
+
208
+ if (isEmptyObject(rest.validation)) {
209
+ delete rest.validation
210
+ }
211
+ }
212
+ } else if (hasOnlyDescriptionDocs(rest.docs, description)) {
213
+ delete rest.docs
214
+ }
215
+
216
+ if (description) {
217
+ result += `.describe(${JSON.stringify(description)})`
218
+ }
219
+
220
+ if (Object.keys(rest).length > 0) {
221
+ result += `.meta(${JSON.stringify(rest)})`
222
+ }
223
+
224
+ return result
225
+ }
226
+
227
+ function getReferencedNames(type: CandidType): string[] {
228
+ const refs: string[] = []
229
+
230
+ function visit(node: CandidType): void {
231
+ switch (node.kind) {
232
+ case "reference":
233
+ refs.push(node.name)
234
+ break
235
+ case "opt":
236
+ case "vec":
237
+ visit(node.type)
238
+ break
239
+ case "record":
240
+ case "variant":
241
+ for (const field of node.fields) {
242
+ visit(field.type)
243
+ }
244
+ break
245
+ case "tuple":
246
+ for (const item of node.types) {
247
+ visit(item)
248
+ }
249
+ break
250
+ }
251
+ }
252
+
253
+ visit(type)
254
+ return refs
255
+ }
256
+
257
+ export function sortDeclarations(
258
+ declarations: CandidTypeDeclaration[]
259
+ ): CandidTypeDeclaration[] {
260
+ const sorted: CandidTypeDeclaration[] = []
261
+ const visited = new Set<string>()
262
+ const visiting: string[] = []
263
+ const declarationByName = new Map(
264
+ declarations.map((decl) => [decl.name, decl])
265
+ )
266
+
267
+ function visit(name: string): void {
268
+ if (visited.has(name)) return
269
+
270
+ const cycleStart = visiting.indexOf(name)
271
+ if (cycleStart !== -1) {
272
+ const cycle = [...visiting.slice(cycleStart), name].join(" -> ")
273
+ throw new Error(`Recursive Candid types are not supported yet: ${cycle}`)
274
+ }
275
+
276
+ const declaration = declarationByName.get(name)
277
+ if (!declaration) return
278
+
279
+ visiting.push(name)
280
+ for (const dependency of getReferencedNames(declaration.type)) {
281
+ visit(dependency)
282
+ }
283
+ visiting.pop()
284
+
285
+ visited.add(name)
286
+ sorted.push(declaration)
287
+ }
288
+
289
+ for (const declaration of declarations) {
290
+ visit(declaration.name)
291
+ }
292
+
293
+ return sorted
294
+ }
295
+
296
+ export function renderType(
297
+ type: CandidType,
298
+ indent = "",
299
+ options: GenerateCodecDeclarationsOptions = {}
300
+ ): string {
301
+ const nextIndent = `${indent} `
302
+ let expression: string
303
+
304
+ switch (type.kind) {
305
+ case "null":
306
+ expression = "c.null()"
307
+ break
308
+ case "bool":
309
+ expression = "c.bool()"
310
+ break
311
+ case "nat":
312
+ expression = "c.nat()"
313
+ break
314
+ case "int":
315
+ expression = "c.int()"
316
+ break
317
+ case "nat8":
318
+ expression = "c.nat8()"
319
+ break
320
+ case "nat16":
321
+ expression = "c.nat16()"
322
+ break
323
+ case "nat32":
324
+ expression = "c.nat32()"
325
+ break
326
+ case "nat64":
327
+ expression = "c.nat64()"
328
+ break
329
+ case "int8":
330
+ expression = "c.int8()"
331
+ break
332
+ case "int16":
333
+ expression = "c.int16()"
334
+ break
335
+ case "int32":
336
+ expression = "c.int32()"
337
+ break
338
+ case "int64":
339
+ expression = "c.int64()"
340
+ break
341
+ case "float32":
342
+ expression = "c.float32()"
343
+ break
344
+ case "float64":
345
+ expression = "c.float64()"
346
+ break
347
+ case "text":
348
+ expression = "c.text()"
349
+ break
350
+ case "reserved":
351
+ expression = "c.reserved()"
352
+ break
353
+ case "empty":
354
+ expression = "c.empty()"
355
+ break
356
+ case "principal":
357
+ expression = "c.principal()"
358
+ break
359
+ case "blob":
360
+ expression = "c.blob()"
361
+ break
362
+ case "reference":
363
+ assertIdentifier(type.name, "Type reference")
364
+ expression = type.name
365
+ break
366
+ case "opt":
367
+ expression = `c.opt(${renderType(type.type, indent, options)})`
368
+ break
369
+ case "vec":
370
+ expression = `c.vec(${renderType(type.type, indent, options)})`
371
+ break
372
+ case "record": {
373
+ if (type.fields.length === 0) {
374
+ expression = "c.record({})"
375
+ } else {
376
+ const fields = type.fields
377
+ .map((field) => {
378
+ const fieldExpression = applyMetadata(
379
+ renderType(field.type, nextIndent, options),
380
+ field.metadata,
381
+ options
382
+ )
383
+ return `${nextIndent}${propertyName(field.name)}: ${fieldExpression},`
384
+ })
385
+ .join("\n")
386
+ expression = `c.record({\n${fields}\n${indent}})`
387
+ }
388
+ break
389
+ }
390
+ case "variant": {
391
+ if (type.fields.length === 0) {
392
+ expression = "c.variant({})"
393
+ } else {
394
+ const fields = type.fields
395
+ .map((field) => {
396
+ const fieldExpression = applyMetadata(
397
+ renderType(field.type, nextIndent, options),
398
+ field.metadata,
399
+ options
400
+ )
401
+ return `${nextIndent}${propertyName(field.name)}: ${fieldExpression},`
402
+ })
403
+ .join("\n")
404
+ expression = `c.variant({\n${fields}\n${indent}})`
405
+ }
406
+ break
407
+ }
408
+ case "tuple":
409
+ expression = `c.tuple([${type.types
410
+ .map((item) => renderType(item, indent, options))
411
+ .join(", ")}])`
412
+ break
413
+ case "func":
414
+ case "service":
415
+ case "class":
416
+ case "unknown":
417
+ case "knot":
418
+ case "future":
419
+ expression = `/* c.${type.kind} is not supported */ c.reserved()`
420
+ break
421
+ }
422
+
423
+ return applyMetadata(expression, type.metadata, options)
424
+ }
425
+
426
+ function renderMethodReturn(
427
+ method: NonNullable<CandidSchema["service"]>["methods"][number],
428
+ options: GenerateCodecDeclarationsOptions
429
+ ): string {
430
+ if (method.mode === "oneway") return ""
431
+ if (method.returns.length === 0) return ""
432
+ if (method.returns.length === 1) {
433
+ return `, ${renderType(method.returns[0], " ", options)}`
434
+ }
435
+ return `, [${method.returns
436
+ .map((returnType) => renderType(returnType, " ", options))
437
+ .join(", ")}]`
438
+ }
439
+
440
+ /**
441
+ * Converts a structured CandidSchema AST into readable `@ic-reactor/cod`
442
+ * codec declarations.
443
+ */
444
+ export function generateCodecDeclarations(
445
+ schema: CandidSchema,
446
+ optionsOrServiceExportName: string | GenerateCodecDeclarationsOptions = {}
447
+ ): string {
448
+ const options = normalizeOptions(optionsOrServiceExportName)
449
+ const lines: string[] = ['import { c } from "@ic-reactor/cod"', ""]
450
+
451
+ for (const declaration of sortDeclarations(schema.types)) {
452
+ assertIdentifier(declaration.name, "Type declaration name")
453
+
454
+ lines.push(
455
+ `export const ${declaration.name} = ${applyMetadata(
456
+ renderType(declaration.type, "", options),
457
+ declaration.metadata,
458
+ options
459
+ )}`
460
+ )
461
+ lines.push(
462
+ `export type ${declaration.name} = c.infer<typeof ${declaration.name}>`
463
+ )
464
+ lines.push("")
465
+ }
466
+
467
+ if (schema.service) {
468
+ const includeCompatibilityExports =
469
+ options.includeCompatibilityExports ?? false
470
+ const declaredTypeNames = new Set(schema.types.map((t) => t.name))
471
+ const serviceExportName = resolveServiceExportName(
472
+ options,
473
+ declaredTypeNames
474
+ )
475
+ assertIdentifier(serviceExportName, "Service export name")
476
+ const methods = schema.service.methods
477
+ .map((method) => {
478
+ const args = method.args
479
+ .map((arg) => renderType(arg, " ", options))
480
+ .join(", ")
481
+ const methodExpression = `c.${method.mode}([${args}]${renderMethodReturn(
482
+ method,
483
+ options
484
+ )})`
485
+ return ` ${propertyName(method.name)}: ${applyMetadata(
486
+ methodExpression,
487
+ method.metadata,
488
+ options
489
+ )},`
490
+ })
491
+ .join("\n")
492
+
493
+ const serviceExpression =
494
+ methods.length > 0 ? `c.service({\n${methods}\n})` : "c.service({})"
495
+ lines.push(
496
+ `export const ${serviceExportName} = ${applyMetadata(
497
+ serviceExpression,
498
+ schema.service.metadata,
499
+ options
500
+ )}`
501
+ )
502
+ lines.push("")
503
+
504
+ if (includeCompatibilityExports) {
505
+ lines.push(`export const idlFactory = ${serviceExportName}.idlFactory`)
506
+ lines.push(
507
+ `export type _SERVICE = c.ServiceOf<typeof ${serviceExportName}>`
508
+ )
509
+ lines.push("")
510
+ lines.push(`export const manifest = ${serviceExportName}.manifest()`)
511
+ }
512
+ }
513
+
514
+ return `${lines.join("\n").trimEnd()}\n`
515
+ }