1import * as Arr from "../../Array.ts"
2import { format, formatPropertyKey } from "../../Formatter.ts"
3import type * as Schema from "../../Schema.ts"
4import type * as SchemaRepresentation from "../../SchemaRepresentation.ts"
5import { errorWithPath } from "../errors.ts"
6import * as InternalRecord from "../record.ts"
7import * as InternalAnnotations from "./annotations.ts"
8
9type Path = ReadonlyArray<string | number>
10type CheckRepresentationAnnotation = SchemaRepresentation.CheckRepresentationAnnotation<
11 SchemaRepresentation.Representation
12>
13
14/** @internal */
15export function makeCode(runtime: string, Type: string): SchemaRepresentation.Code {
16 return { runtime, Type }
17}
18
19function renderEmittableAnnotation(input: unknown): string | undefined {
20 if (
21 input === null ||
22 typeof input === "string" ||
23 typeof input === "boolean" ||
24 typeof input === "number" ||
25 typeof input === "bigint"
26 ) return format(input)
27 if (typeof input === "symbol") {
28 const key = globalThis.Symbol.keyFor(input)
29 return key === undefined ? undefined : `Symbol.for(${format(key)})`
30 }
31 if (typeof input !== "object") return undefined
32 if (Array.isArray(input)) {
33 const values: Array<string> = []
34 for (const value of input) {
35 const rendered = renderEmittableAnnotation(value)
36 if (rendered === undefined) return undefined
37 values.push(rendered)
38 }
39 return `[${values.join(", ")}]`
40 }
41
42 const entries: Array<string> = []
43 for (const [key, value] of Object.entries(input)) {
44 const rendered = renderEmittableAnnotation(value)
45 if (rendered === undefined) return undefined
46 entries.push(`${formatPropertyKey(key)}: ${rendered}`)
47 }
48 return entries.length === 0 ? "{}" : `{ ${entries.join(", ")} }`
49}
50
51function renderAnnotations(
52 annotations: Schema.Annotations.Annotations | undefined
53): string | undefined {
54 if (annotations === undefined) return undefined
55 const entries: Array<string> = []
56 for (const [key, value] of Object.entries(annotations)) {
57 if (InternalAnnotations.annotationExcludedKeys.has(key)) continue
58 const rendered = renderEmittableAnnotation(value)
59 if (rendered !== undefined) entries.push(`${formatPropertyKey(key)}: ${rendered}`)
60 }
61 return entries.length === 0 ? undefined : `{ ${entries.join(", ")} }`
62}
63
64/** @internal */
65export function sanitizeJavaScriptIdentifier(input: string): string {
66 if (input.length === 0) return "_"
67 const out = input.replace(/[^A-Za-z0-9_$]/gu, "_")
68 const first = out[0]
69 return first >= "a" && first <= "z"
70 ? first.toUpperCase() + out.slice(1)
71 : first >= "0" && first <= "9"
72 ? `_${out}`
73 : out
74}
75
76function isSimpleLiveLiteral(
77 representation: SchemaRepresentation.Representation
78): representation is SchemaRepresentation.Literal {
79 return representation._tag === "Literal" && representation.checks.length === 0 &&
80 representation.annotations === undefined
81}
82
83/** @internal */
84export interface TopologicalSort {
85 readonly nonRecursives: ReadonlyArray<{
86 readonly $ref: string
87 readonly representation: SchemaRepresentation.Representation
88 }>
89 readonly recursives: Readonly<Record<string, SchemaRepresentation.Representation>>
90}
91
92/** @internal */
93export function topologicalSort(
94 references: SchemaRepresentation.References
95): TopologicalSort {
96 const identifiers = Object.keys(references)
97 const identifierSet = new Set(identifiers)
98
99 function collectRefs(root: SchemaRepresentation.Representation): ReadonlySet<string> {
100 const refs = new Set<string>()
101 const visited = new WeakSet<object>()
102 const stack: Array<SchemaRepresentation.Representation> = [root]
103
104 function pushRepresentationSchemas(representation: CheckRepresentationAnnotation | undefined): void {
105 if (representation?.schemas !== undefined) stack.push(...representation.schemas)
106 }
107
108 function pushChecks(
109 checks: ReadonlyArray<SchemaRepresentation.Check>
110 ): void {
111 for (const check of checks) {
112 pushRepresentationSchemas(check.representation)
113 if (check._tag === "FilterGroup") pushChecks(check.checks)
114 }
115 }
116
117 while (stack.length > 0) {
118 const representation = stack.pop()!
119 if (visited.has(representation)) continue
120 visited.add(representation)
121 if (representation._tag === "Reference") {
122 if (identifierSet.has(representation.$ref)) refs.add(representation.$ref)
123 continue
124 }
125
126 pushChecks(representation.checks)
127 switch (representation._tag) {
128 case "Declaration":
129 pushRepresentationSchemas(representation.representation)
130 stack.push(...representation.typeParameters)
131 break
132 case "Suspend":
133 stack.push(representation.thunk)
134 break
135 case "TemplateLiteral":
136 stack.push(...representation.parts)
137 break
138 case "Arrays":
139 for (const element of representation.elements) stack.push(element.type)
140 stack.push(...representation.rest)
141 break
142 case "Objects":
143 for (const property of representation.propertySignatures) stack.push(property.type)
144 for (const signature of representation.indexSignatures) {
145 stack.push(signature.parameter, signature.type)
146 }
147 break
148 case "Union":
149 stack.push(...representation.types)
150 break
151 }
152 }
153 return refs
154 }
155
156 const dependencies = new Map<string, ReadonlySet<string>>(
157 identifiers.map((identifier) => [identifier, collectRefs(references[identifier])])
158 )
159 const recursive = new Set<string>()
160 const state = new Map<string, 0 | 1 | 2>()
161 const stack: Array<string> = []
162
163 function visit(identifier: string): void {
164 const current = state.get(identifier) ?? 0
165 if (current === 1) {
166 const start = stack.indexOf(identifier)
167 for (let index = start; index < stack.length; index++) recursive.add(stack[index])
168 return
169 }
170 if (current === 2) return
171 state.set(identifier, 1)
172 stack.push(identifier)
173 for (const dependency of dependencies.get(identifier)!) visit(dependency)
174 stack.pop()
175 state.set(identifier, 2)
176 }
177
178 for (const identifier of identifiers) visit(identifier)
179
180 const inDegree = new Map<string, number>()
181 const dependents = new Map<string, Set<string>>()
182 for (const identifier of identifiers) {
183 if (!recursive.has(identifier)) {
184 inDegree.set(identifier, 0)
185 dependents.set(identifier, new Set())
186 }
187 }
188 for (const [identifier, internalDependencies] of dependencies) {
189 if (recursive.has(identifier)) continue
190 for (const dependency of internalDependencies) {
191 if (recursive.has(dependency)) continue
192 inDegree.set(identifier, inDegree.get(identifier)! + 1)
193 dependents.get(dependency)!.add(identifier)
194 }
195 }
196
197 const queue: Array<string> = []
198 for (const [identifier, degree] of inDegree) {
199 if (degree === 0) queue.push(identifier)
200 }
201 const nonRecursives: Array<{
202 readonly $ref: string
203 readonly representation: SchemaRepresentation.Representation
204 }> = []
205 for (let index = 0; index < queue.length; index++) {
206 const $ref = queue[index]
207 nonRecursives.push({ $ref, representation: references[$ref] })
208 for (const dependent of dependents.get($ref)!) {
209 const degree = inDegree.get(dependent)! - 1
210 inDegree.set(dependent, degree)
211 if (degree === 0) queue.push(dependent)
212 }
213 }
214 const recursives: Record<string, SchemaRepresentation.Representation> = {}
215 for (const identifier of recursive) InternalRecord.assignProperty(recursives, identifier, references[identifier])
216 return { nonRecursives, recursives }
217}
218
219/** @internal */
220export function toCodeDocument(
221 document: SchemaRepresentation.MultiDocument
222): SchemaRepresentation.CodeDocument {
223 const artifacts: Array<SchemaRepresentation.Artifact> = []
224 const sorted = topologicalSort(document.references)
225 const sanitizedReferences = new Map<string, string>()
226 const uniqueIdentifiers = new Set<string>()
227 let compilingRecursiveDefinition = false
228 let explicitSuspendDepth = 0
229
230 for (const { $ref } of sorted.nonRecursives) ensureUniqueIdentifier($ref)
231 for (const $ref of Object.keys(sorted.recursives)) ensureUniqueIdentifier($ref)
232
233 const nonRecursives = sorted.nonRecursives.map(({ $ref, representation }) => ({
234 $ref: ensureUniqueIdentifier($ref),
235 code: recur(representation, ["references", $ref])
236 }))
237 const recursives: Record<string, SchemaRepresentation.Code> = {}
238 for (const [$ref, representation] of Object.entries(sorted.recursives)) {
239 compilingRecursiveDefinition = true
240 InternalRecord.assignProperty(recursives, ensureUniqueIdentifier($ref), recur(representation, ["references", $ref]))
241 compilingRecursiveDefinition = false
242 }
243 const codes = document.representations.map((representation, index) =>
244 recur(representation, ["representations", index])
245 )
246
247 return {
248 codes,
249 references: { nonRecursives, recursives },
250 artifacts
251 }
252
253 function ensureUniqueIdentifier(original: string): string {
254 const existing = sanitizedReferences.get(original)
255 if (existing !== undefined) return existing
256 const candidate = freshIdentifier(original)
257 sanitizedReferences.set(original, candidate)
258 return candidate
259 }
260
261 function freshIdentifier(seed: string): string {
262 const sanitized = sanitizeJavaScriptIdentifier(seed)
263 let candidate = sanitized
264 let suffix = 0
265 while (uniqueIdentifiers.has(candidate)) candidate = `${sanitized}${++suffix}`
266 uniqueIdentifiers.add(candidate)
267 return candidate
268 }
269
270 function addImport(importDeclaration: string): void {
271 if (!artifacts.some((artifact) => artifact._tag === "Import" && artifact.importDeclaration === importDeclaration)) {
272 artifacts.push({ _tag: "Import", importDeclaration })
273 }
274 }
275
276 function addSymbol(symbol: symbol): string {
277 const identifier = freshIdentifier("_symbol")
278 const key = globalThis.Symbol.keyFor(symbol)
279 const description = symbol.description
280 artifacts.push({
281 _tag: "Symbol",
282 identifier,
283 code: makeCode(
284 key === undefined
285 ? `Symbol(${description === undefined ? "" : format(description)})`
286 : `Symbol.for(${format(key)})`,
287 `typeof ${identifier}`
288 )
289 })
290 return identifier
291 }
292
293 function annotationSchemas(
294 representation: CheckRepresentationAnnotation | undefined,
295 path: Path
296 ): ReadonlyArray<SchemaRepresentation.Code> {
297 return representation?.schemas?.map((schema, index) => recur(schema, [...path, "schemas", index])) ?? []
298 }
299
300 function runtimeAnnotate(
301 annotations: Schema.Annotations.Annotations | undefined,
302 method: "annotate" | "annotateKey" = "annotate"
303 ): string {
304 const rendered = renderAnnotations(annotations)
305 return rendered === undefined ? "" : `.${method}(${rendered})`
306 }
307
308 function unionOptionsRuntime(options: NonNullable<SchemaRepresentation.Union["options"]>): string {
309 if (!Object.hasOwn(options, "mode")) return "{}"
310 return `{ mode: ${options.mode === undefined ? "undefined" : format(options.mode)} }`
311 }
312
313 function compileCheck(
314 check: SchemaRepresentation.Check,
315 path: Path
316 ): string {
317 const callback = check.annotations?.toCode
318 let runtime: string
319 if (callback !== undefined) {
320 const schemas = annotationSchemas(check.representation, [...path, "representation"])
321 const output = (callback as SchemaRepresentation.Generation.Check)({ schemas })
322 for (const importDeclaration of output.importDeclarations ?? []) addImport(importDeclaration)
323 runtime = output.runtime
324 } else if (check._tag === "Filter") {
325 throw errorWithPath("Missing toCode callback", [...path, "annotations", "toCode"])
326 } else {
327 runtime = `Schema.makeFilterGroup([${
328 check.checks.map((child, index) => compileCheck(child, [...path, "checks", index])).join(", ")
329 }])`
330 }
331 runtime += runtimeAnnotate(check.annotations)
332 if (check._tag === "Filter" && check.aborted) runtime += ".abort()"
333 return runtime
334 }
335
336 function applyNode(
337 base: SchemaRepresentation.Code,
338 representation: Exclude<SchemaRepresentation.Representation, SchemaRepresentation.Reference>,
339 path: Path
340 ): SchemaRepresentation.Code {
341 let runtime = base.runtime + runtimeAnnotate(representation.annotations)
342 for (let index = 0; index < representation.checks.length; index++) {
343 const check = representation.checks[index]
344 runtime += `.check(${compileCheck(check, [...path, "checks", index])})`
345 }
346 return makeCode(runtime, base.Type)
347 }
348
349 function recur(
350 representation: SchemaRepresentation.Representation,
351 path: Path
352 ): SchemaRepresentation.Code {
353 if (representation._tag === "Reference") {
354 if (!Object.hasOwn(document.references, representation.$ref)) {
355 throw errorWithPath(`Invalid reference ${representation.$ref}`, [...path, "$ref"])
356 }
357 const identifier = ensureUniqueIdentifier(representation.$ref)
358 if (
359 compilingRecursiveDefinition && explicitSuspendDepth === 0 &&
360 Object.hasOwn(sorted.recursives, representation.$ref)
361 ) {
362 return makeCode(`Schema.suspend((): Schema.Codec<${identifier}> => ${identifier})`, identifier)
363 }
364 return makeCode(identifier, identifier)
365 }
366 return applyNode(on(representation, path), representation, path)
367 }
368
369 function on(
370 representation: Exclude<SchemaRepresentation.Representation, SchemaRepresentation.Reference>,
371 path: Path
372 ): SchemaRepresentation.Code {
373 switch (representation._tag) {
374 case "Declaration": {
375 const callback = representation.annotations?.toCode
376 if (callback === undefined) {
377 throw errorWithPath("Missing toCode callback", [...path, "annotations", "toCode"])
378 }
379 const typeParameters = representation.typeParameters.map((typeParameter, index) =>
380 recur(typeParameter, [...path, "typeParameters", index])
381 )
382 const output = (callback as SchemaRepresentation.Generation.Declaration)({ typeParameters })
383 for (const importDeclaration of output.importDeclarations ?? []) addImport(importDeclaration)
384 return makeCode(output.runtime, output.Type)
385 }
386 case "Suspend": {
387 explicitSuspendDepth++
388 const thunk = recur(representation.thunk, [...path, "thunk"])
389 explicitSuspendDepth--
390 return makeCode(`Schema.suspend((): Schema.Codec<${thunk.Type}> => ${thunk.runtime})`, thunk.Type)
391 }
392 case "Null":
393 return makeCode("Schema.Null", "null")
394 case "Undefined":
395 return makeCode("Schema.Undefined", "undefined")
396 case "Void":
397 return makeCode("Schema.Void", "void")
398 case "Never":
399 return makeCode("Schema.Never", "never")
400 case "Unknown":
401 return makeCode("Schema.Unknown", "unknown")
402 case "Any":
403 return makeCode("Schema.Any", "any")
404 case "String":
405 return makeCode("Schema.String", "string")
406 case "Number":
407 return makeCode("Schema.Number", "number")
408 case "Boolean":
409 return makeCode("Schema.Boolean", "boolean")
410 case "BigInt":
411 return makeCode("Schema.BigInt", "bigint")
412 case "Symbol":
413 return makeCode("Schema.Symbol", "symbol")
414 case "Literal": {
415 const literal = format(representation.literal)
416 return makeCode(`Schema.Literal(${literal})`, literal)
417 }
418 case "UniqueSymbol": {
419 const identifier = addSymbol(representation.symbol)
420 return makeCode(`Schema.UniqueSymbol(${identifier})`, `typeof ${identifier}`)
421 }
422 case "ObjectKeyword":
423 return makeCode("Schema.ObjectKeyword", "object")
424 case "Enum": {
425 const identifier = freshIdentifier("_Enum")
426 artifacts.push({
427 _tag: "Enum",
428 identifier,
429 code: makeCode(
430 `enum ${identifier} { ${
431 representation.enums.map(([name, value]) => `${format(name)} = ${format(value)}`).join(
432 ", "
433 )
434 } }`,
435 `typeof ${identifier}`
436 )
437 })
438 return makeCode(`Schema.Enum(${identifier})`, identifier)
439 }
440 case "TemplateLiteral": {
441 const parts = representation.parts.map((part, index) => recur(part, [...path, "parts", index]))
442 const Type = `\`${parts.map((part) => `\${${part.Type}}`).join("")}\``
443 return makeCode(`Schema.TemplateLiteral([${parts.map((part) => part.runtime).join(", ")}])`, Type)
444 }
445 case "Arrays": {
446 const elements = representation.elements.map((element, index) => {
447 const type = recur(element.type, [...path, "elements", index, "type"])
448 return makeCode(
449 `${element.isOptional ? "Schema.optionalKey(" : ""}${type.runtime}${element.isOptional ? ")" : ""}${
450 runtimeAnnotate(element.annotations, "annotateKey")
451 }`,
452 element.isOptional ? `(${type.Type})?` : type.Type
453 )
454 })
455 const rest = representation.rest.map((item, index) => recur(item, [...path, "rest", index]))
456 let code: SchemaRepresentation.Code
457 if (Arr.isArrayNonEmpty(rest)) {
458 const item = rest[0]
459 if (elements.length === 0 && rest.length === 1) {
460 code = makeCode(`Schema.Array(${item.runtime})`, `ReadonlyArray<${item.Type}>`)
461 } else {
462 const post = rest.slice(1)
463 code = makeCode(
464 `Schema.TupleWithRest(Schema.Tuple([${elements.map((element) => element.runtime).join(", ")}]), [${
465 rest.map((item) => item.runtime).join(", ")
466 }])`,
467 `readonly [${elements.map((element) => element.Type).join(", ")}, ...Array<${item.Type}>${
468 post.length > 0 ? `, ${post.map((item) => item.Type).join(", ")}` : ""
469 }]`
470 )
471 }
472 } else {
473 code = makeCode(
474 `Schema.Tuple([${elements.map((element) => element.runtime).join(", ")}])`,
475 `readonly [${elements.map((element) => element.Type).join(", ")}]`
476 )
477 }
478 return code
479 }
480 case "Objects": {
481 const properties = representation.propertySignatures.map((property, index) => {
482 const isSymbol = typeof property.name === "symbol"
483 const name = isSymbol
484 ? `[${addSymbol(property.name)}]`
485 : formatPropertyKey(property.name)
486 const type = recur(property.type, [...path, "propertySignatures", index, "type"])
487 let runtime = type.runtime
488 if (property.isMutable) runtime = `Schema.mutableKey(${runtime})`
489 if (property.isOptional) runtime = `Schema.optionalKey(${runtime})`
490 runtime += runtimeAnnotate(property.annotations, "annotateKey")
491 const runtimeName = property.name === "__proto__" ? `[${name}]` : name
492 const typeName = `${property.isMutable ? "" : "readonly "}${name}${property.isOptional ? "?" : ""}`
493 return {
494 code: makeCode(`${runtimeName}: ${runtime}`, `${typeName}: ${type.Type}`)
495 }
496 })
497 const indexSignatures = representation.indexSignatures.map((signature, index) => ({
498 parameter: recur(signature.parameter, [...path, "indexSignatures", index, "parameter"]),
499 type: recur(signature.type, [...path, "indexSignatures", index, "type"])
500 }))
501 const propertyRuntimes = properties.map((property) => property.code.runtime).join(", ")
502 const propertyTypes = properties.map((property) => property.code.Type).join(", ")
503 let code: SchemaRepresentation.Code
504 if (indexSignatures.length === 0) {
505 code = makeCode(
506 `Schema.Struct({ ${propertyRuntimes} })`,
507 `{ ${propertyTypes} }`
508 )
509 } else if (properties.length === 0 && indexSignatures.length === 1) {
510 const signature = indexSignatures[0]
511 code = makeCode(
512 `Schema.Record(${signature.parameter.runtime}, ${signature.type.runtime})`,
513 `{ readonly [x: ${signature.parameter.Type}]: ${signature.type.Type} }`
514 )
515 } else {
516 const indexRuntimes = indexSignatures.map((signature) =>
517 `Schema.Record(${signature.parameter.runtime}, ${signature.type.runtime})`
518 ).join(", ")
519 const indexTypes = indexSignatures.map((signature) =>
520 `readonly [x: ${signature.parameter.Type}]: ${signature.type.Type}`
521 )
522 code = properties.length === 0
523 ? makeCode(
524 `Schema.StructWithRest(Schema.Struct({ ${propertyRuntimes} }), [${indexRuntimes}])`,
525 `{ ${indexTypes.join(", ")} }`
526 )
527 : makeCode(
528 `Schema.StructWithRest(Schema.Struct({ ${propertyRuntimes} }), [${indexRuntimes}])`,
529 [`{ ${propertyTypes} }`, ...indexTypes.map((indexType) => `{ ${indexType} }`)].join(" & ")
530 )
531 }
532 return code
533 }
534 case "Union": {
535 if (representation.options === undefined && representation.types.length === 0) {
536 return makeCode("Schema.Never", "never")
537 }
538 if (representation.options === undefined && representation.types.every(isSimpleLiveLiteral)) {
539 const literals = representation.types.map((literal) => format(literal.literal))
540 return literals.length === 1
541 ? makeCode(`Schema.Literal(${literals[0]})`, literals[0])
542 : makeCode(`Schema.Literals([${literals.join(", ")}])`, literals.join(" | "))
543 }
544 const types = representation.types.map((type, index) => recur(type, [...path, "types", index]))
545 const options = representation.options === undefined
546 ? ""
547 : `, ${unionOptionsRuntime(representation.options)}`
548 return makeCode(
549 `Schema.Union([${types.map((type) => type.runtime).join(", ")}]${options})`,
550 types.length === 0 ? "never" : types.map((type) => type.Type).join(" | ")
551 )
552 }
553 }
554 }
555}