@thomasfosterau/effect-parse 0.2.0 → 0.3.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.
@@ -1,10 +1,15 @@
1
1
  /**
2
- * Building Effect `Schema`s from parsers.
2
+ * Effect `Schema`s and parsers, in both directions.
3
3
  *
4
- * A parser over strings becomes a schema that decodes a `string` into the
5
- * parser's output. Parse errors become schema issues carrying the rendered
6
- * `ParseError` message. Encoding needs a printer, supplied as `encode`;
7
- * without one, encoding is forbidden.
4
+ * {@link make} turns a parser over strings into a schema that decodes a
5
+ * `string` into the parser's output. Parse errors become schema issues
6
+ * carrying the rendered `ParseError` message. Encoding needs a printer,
7
+ * supplied as `encode`; without one, encoding is forbidden.
8
+ *
9
+ * {@link toParser} goes the other way: it derives a parser from a schema,
10
+ * guided by annotations ({@link annotate}) that say how each part is
11
+ * written. {@link toPrinter} derives the printer that writes that text, and
12
+ * {@link fromSchema} wraps both as a schema.
8
13
  *
9
14
  * @example
10
15
  * ```ts
@@ -25,13 +30,48 @@
25
30
  *
26
31
  * @since 0.1.0
27
32
  */
33
+ import * as Arr from "effect/Array"
28
34
  import * as Effect from "effect/Effect"
35
+ import { dual } from "effect/Function"
36
+ import * as Match from "effect/Match"
37
+ import * as MutableHashMap from "effect/MutableHashMap"
38
+ import * as Option from "effect/Option"
39
+ import * as Order from "effect/Order"
40
+ import * as Predicate from "effect/Predicate"
41
+ import * as Record from "effect/Record"
29
42
  import * as Result from "effect/Result"
30
43
  import * as Schema from "effect/Schema"
44
+ import * as SchemaAST from "effect/SchemaAST"
31
45
  import * as SchemaIssue from "effect/SchemaIssue"
32
46
  import * as SchemaTransformation from "effect/SchemaTransformation"
47
+ import * as Ascii from "./Ascii.js"
33
48
  import type { Parser } from "./Parser.js"
34
49
  import * as P from "./Parser.js"
50
+ import * as Token from "./Token.js"
51
+ import { describeLiteral } from "./internal/tokens.js"
52
+
53
+ /**
54
+ * The key of the annotation that says how a schema is written as text
55
+ * ({@link Annotation}), namespaced to this package so it cannot collide with
56
+ * another library's. {@link annotate} sets it.
57
+ *
58
+ * @since 0.3.0
59
+ * @category annotations
60
+ */
61
+ export const annotationKey = "@thomasfosterau/effect-parse/ParserSchema" as const
62
+
63
+ declare module "effect/Schema" {
64
+ namespace Annotations {
65
+ interface Annotations {
66
+ /**
67
+ * How the schema is written as text, read by `ParserSchema.toParser`
68
+ * and `ParserSchema.toPrinter`: a parser for the schema's type, or a
69
+ * `ParserSchema.Grammar`.
70
+ */
71
+ readonly [annotationKey]?: Annotation<any> | undefined
72
+ }
73
+ }
74
+ }
35
75
 
36
76
  /**
37
77
  * Options for {@link make}.
@@ -54,6 +94,39 @@ export interface Options<A> {
54
94
  */
55
95
  export interface ParserSchema<A> extends Schema.decodeTo<Schema.declare<A>, Schema.String> {}
56
96
 
97
+ type Encode<A> = (value: A) => Result.Result<string, SchemaIssue.Issue>
98
+
99
+ const encodeWith =
100
+ <A>(encode: (value: A) => string): Encode<A> =>
101
+ (value) =>
102
+ Result.succeed(encode(value))
103
+
104
+ const decodeWith = <T extends Schema.Top>(
105
+ target: T,
106
+ parser: Parser<T["Encoded"], unknown, never, string>,
107
+ encode: Encode<T["Encoded"]> | undefined
108
+ ) =>
109
+ Schema.String.pipe(
110
+ Schema.decodeTo(
111
+ target,
112
+ SchemaTransformation.transformEffect<T["Encoded"], string>({
113
+ decode: (input, parseOptions) => {
114
+ const result = P.parse(parser, input)
115
+ return Result.isSuccess(result)
116
+ ? Effect.succeed(result.success)
117
+ : Effect.fail(new SchemaIssue.InvalidValue({ message: result.failure.message }, input, parseOptions))
118
+ },
119
+ encode: (value, parseOptions) => {
120
+ if (encode === undefined) {
121
+ return Effect.fail(new SchemaIssue.Forbidden({ message: "Encoding is not supported" }, value, parseOptions))
122
+ }
123
+ const result = encode(value)
124
+ return Result.isSuccess(result) ? Effect.succeed(result.success) : Effect.fail(result.failure)
125
+ }
126
+ })
127
+ )
128
+ )
129
+
57
130
  /**
58
131
  * Creates a schema that decodes a string with `parser`, which must consume
59
132
  * the whole string.
@@ -61,27 +134,913 @@ export interface ParserSchema<A> extends Schema.decodeTo<Schema.declare<A>, Sche
61
134
  * @since 0.1.0
62
135
  * @category constructors
63
136
  */
64
- export const make = <A, E>(parser: Parser<A, E, never, string>, options?: Options<A>): ParserSchema<A> => {
65
- const target = Schema.declare<A>(
66
- (_u): _u is A => true,
67
- options?.description !== undefined ? { description: options.description } : undefined
137
+ export const make = <A, E>(parser: Parser<A, E, never, string>, options?: Options<A>): ParserSchema<A> =>
138
+ decodeWith(
139
+ Schema.declare<A>(
140
+ (_u): _u is A => true,
141
+ options?.description !== undefined ? { description: options.description } : undefined
142
+ ),
143
+ parser,
144
+ options?.encode === undefined ? undefined : encodeWith(options.encode)
68
145
  )
146
+
147
+ // -----------------------------------------------------------------------------
148
+ // Deriving parsers and printers from schemas
149
+ // -----------------------------------------------------------------------------
150
+
151
+ /**
152
+ * How a schema is written as text, given in its annotation (see
153
+ * {@link annotate}) and read by {@link toParser} and {@link toPrinter}.
154
+ * Every field is optional; a schema without one is written as described on
155
+ * {@link toParser}.
156
+ *
157
+ * @since 0.3.0
158
+ * @category models
159
+ */
160
+ export interface Grammar<A = unknown> {
161
+ /**
162
+ * Parses the schema's type outright, replacing what {@link toParser} would
163
+ * derive. Its output is still validated against the schema's type and
164
+ * checks, and its failures' causes become `SchemaError`s with their message.
165
+ * Give a {@link Grammar.print} with it, or the schema cannot be printed.
166
+ */
167
+ readonly parser?: Parser<A, unknown, never, string> | undefined
168
+ /** Writes a value as the text {@link Grammar.parser} reads, replacing what {@link toPrinter} would derive. */
169
+ readonly print?: ((value: A) => string) | undefined
170
+ /** Text written before the value. */
171
+ readonly prefix?: string | undefined
172
+ /** Text written after the value. */
173
+ readonly suffix?: string | undefined
174
+ /** Text written between the fields of a struct, the elements of a tuple or array, or the entries of a record. */
175
+ readonly separator?: string | undefined
176
+ /**
177
+ * Text written between a key and its value. On a struct, it writes each
178
+ * field as `key`, separator, value, in any order; records always need one.
179
+ */
180
+ readonly keySeparator?: string | undefined
181
+ /**
182
+ * Whether whitespace may surround the text this schema writes (its prefix,
183
+ * suffix and separators), and whitespace around unannotated strings is
184
+ * trimmed. Inherited by the schemas inside it. Printers write no
185
+ * whitespace.
186
+ */
187
+ readonly whitespace?: boolean | undefined
188
+ }
189
+
190
+ /**
191
+ * The value of the annotation under {@link annotationKey}: a
192
+ * {@link Grammar}, or a parser for the schema's type (short for
193
+ * `{ parser }`, which cannot be printed).
194
+ *
195
+ * @since 0.3.0
196
+ * @category models
197
+ */
198
+ export type Annotation<A = unknown> = Grammar<A> | Parser<A, unknown, never, string>
199
+
200
+ /**
201
+ * Annotates a schema with how it is written as text, for {@link toParser}
202
+ * and {@link toPrinter}. The same as
203
+ * `schema.annotate({ [ParserSchema.annotationKey]: annotation })`, except
204
+ * that the parser's output must have the schema's type.
205
+ *
206
+ * @example
207
+ * ```ts
208
+ * import { Schema } from "effect"
209
+ * import { ParserSchema } from "@thomasfosterau/effect-parse"
210
+ *
211
+ * const Point = Schema.Struct({ x: Schema.Int, y: Schema.Int }).pipe(
212
+ * ParserSchema.annotate({ prefix: "(", separator: ",", suffix: ")" })
213
+ * )
214
+ * ```
215
+ *
216
+ * @since 0.3.0
217
+ * @category annotations
218
+ */
219
+ export const annotate: {
220
+ // A grammar without a parser constrains nothing
221
+ <A = never>(
222
+ annotation: Annotation<A>
223
+ ): <S extends Schema.Top>(self: S & ([A] extends [S["Type"]] ? unknown : never)) => S["Rebuild"]
224
+ <S extends Schema.Top>(self: S, annotation: Annotation<S["Type"]>): S["Rebuild"]
225
+ } = /*#__PURE__*/ dual(2, <S extends Schema.Top>(self: S, annotation: Annotation<S["Type"]>): S["Rebuild"] =>
226
+ self.annotate({ [annotationKey]: annotation })
227
+ )
228
+
229
+ /**
230
+ * Derives a parser from a schema. The parser reads the text the schema's
231
+ * annotations ({@link Grammar}) describe and returns a value of the schema's
232
+ * type, which the schema's checks and transformations have already
233
+ * validated; their failures become parse errors whose `cause` is the
234
+ * `SchemaError`.
235
+ *
236
+ * Without an annotation, a schema is parsed as follows:
237
+ *
238
+ * - `String`: one or more characters up to the next separator, key
239
+ * separator, suffix or literal around it (to the end of the input at the
240
+ * top level).
241
+ * - `Number`: a decimal number (`Ascii.float`), or a decimal integer when
242
+ * the schema checks `isInt` (as `Schema.Int` does). `BigInt`: a decimal
243
+ * integer. `Boolean`: `true` or `false`. `Null`: `null`. `Undefined` and
244
+ * `Void`: nothing.
245
+ * - `Literal`, `Enum` and `TemplateLiteral`: their text.
246
+ * - `Struct` and `Tuple`: their fields or elements in order, between
247
+ * separators; an optional one may be left out along with its separator.
248
+ * `Array`: elements between separators.
249
+ * - `Record`, and `Struct` with a `keySeparator`: `key`, key separator,
250
+ * value entries in any order, between separators.
251
+ * - `Union`: the first member that parses, in order (literals longest
252
+ * first). `suspend`: recursion.
253
+ * - A transformation (a `Class`, `NumberFromString`, ...): its encoded side,
254
+ * decoded by the schema. A grammar on it with a separator applies to the
255
+ * encoded side.
256
+ *
257
+ * A parser that fails where the schema starts reports the schema's
258
+ * `identifier` annotation (or its `expected` annotation, unless that belongs
259
+ * to a check) as what it expected. Other declarations, `Unknown`, `Any`,
260
+ * symbols and `object` need a parser in their annotation; without one,
261
+ * `toParser` throws. Transformations must decode synchronously.
262
+ *
263
+ * @example
264
+ * ```ts
265
+ * import { Schema } from "effect"
266
+ * import { Parser, ParserSchema } from "@thomasfosterau/effect-parse"
267
+ *
268
+ * const Version = Schema.Struct({ major: Schema.Int, minor: Schema.Int }).pipe(
269
+ * ParserSchema.annotate({ separator: "." })
270
+ * )
271
+ *
272
+ * Parser.parse(ParserSchema.toParser(Version), "1.2") // Result.succeed({ major: 1, minor: 2 })
273
+ * ```
274
+ *
275
+ * @since 0.3.0
276
+ * @category derivation
277
+ */
278
+ export const toParser = <S extends Schema.ConstraintDecoder<unknown>>(
279
+ schema: S
280
+ ): Parser<S["Type"], Schema.SchemaError, never, string> => {
281
+ const ast = schema.ast
282
+ const parser = derive(ast, initial<AnyParser>())
283
+ return grammarOf(ast).whitespace === true ? P.delimited(Ascii.multispace0, parser, Ascii.multispace0) : parser
284
+ }
285
+
286
+ /**
287
+ * Derives a printer from a schema: it writes a value of the schema's type as
288
+ * the text {@link toParser} reads, following the same annotations, with no
289
+ * optional whitespace. Transformations encode the value first, and a
290
+ * {@link Grammar.print} replaces the derived printer.
291
+ *
292
+ * The printer fails with a `SchemaError` when the value is not of the
293
+ * schema's type, or cannot be written so that it reads back: a string that
294
+ * is empty or contains a character that would end it (a separator, a
295
+ * suffix), or a part annotated with a parser but no `print`. Where the
296
+ * grammar is ambiguous (an optional field followed by a field of the same
297
+ * type), the text can still read back as a different value;
298
+ * {@link fromSchema} checks that it doesn't.
299
+ *
300
+ * @example
301
+ * ```ts
302
+ * import { Schema } from "effect"
303
+ * import { ParserSchema } from "@thomasfosterau/effect-parse"
304
+ *
305
+ * const Version = Schema.Struct({ major: Schema.Int, minor: Schema.Int }).pipe(
306
+ * ParserSchema.annotate({ separator: "." })
307
+ * )
308
+ *
309
+ * ParserSchema.toPrinter(Version)({ major: 1, minor: 2 }) // Result.succeed("1.2")
310
+ * ```
311
+ *
312
+ * @since 0.3.0
313
+ * @category derivation
314
+ */
315
+ export const toPrinter = <S extends Schema.ConstraintCodec<unknown, unknown, unknown, never>>(
316
+ schema: S
317
+ ): ((value: S["Type"]) => Result.Result<string, Schema.SchemaError>) => {
318
+ const validate = typeDecoder(schema.ast)
319
+ const print = printerOf(schema.ast, initial<Printer>())
320
+ return (value) => {
321
+ const valid = validate(value)
322
+ if (Result.isFailure(valid)) return Result.fail(valid.failure)
323
+ const out: Array<string> = []
324
+ const issue = print(valid.success, out)
325
+ return issue === undefined ? Result.succeed(out.join("")) : Result.fail(schemaError(issue))
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Creates a schema that decodes a string with the parser {@link toParser}
331
+ * derives from `schema`, into `schema`'s type, and encodes with the printer
332
+ * {@link toPrinter} derives, or with `encode` when given. A derived printer's
333
+ * text is parsed back, and encoding fails unless it gives a value equivalent
334
+ * to the one encoded, so decoding what was encoded always gives it back.
335
+ *
336
+ * @example
337
+ * ```ts
338
+ * import { Schema } from "effect"
339
+ * import { ParserSchema } from "@thomasfosterau/effect-parse"
340
+ *
341
+ * const Pair = ParserSchema.fromSchema(
342
+ * Schema.Tuple([Schema.String, Schema.Number]).pipe(ParserSchema.annotate({ separator: "=" }))
343
+ * )
344
+ *
345
+ * Schema.decodeUnknownSync(Pair)("width=10") // ["width", 10]
346
+ * Schema.encodeSync(Pair)(["height", 2]) // "height=2"
347
+ * ```
348
+ *
349
+ * @since 0.3.0
350
+ * @category constructors
351
+ */
352
+ export const fromSchema = <S extends Schema.ConstraintCodec<unknown, unknown, never, never> & Schema.Top>(
353
+ schema: S,
354
+ options?: { readonly encode?: ((value: S["Type"]) => string) | undefined }
355
+ ): Schema.decodeTo<Schema.toType<S>, Schema.String> => {
356
+ const parser = toParser(schema)
69
357
  const encode = options?.encode
70
- return Schema.String.pipe(
71
- Schema.decodeTo(
72
- target,
73
- SchemaTransformation.transformEffect<A, string>({
74
- decode: (input, parseOptions) => {
75
- const result = P.parse(parser, input)
76
- return Result.isSuccess(result)
77
- ? Effect.succeed(result.success)
78
- : Effect.fail(new SchemaIssue.InvalidValue({ message: result.failure.message }, input, parseOptions))
79
- },
80
- encode: (value, parseOptions) =>
81
- encode !== undefined
82
- ? Effect.succeed(encode(value))
83
- : Effect.fail(new SchemaIssue.Forbidden({ message: "Encoding is not supported" }, value, parseOptions))
358
+ return decodeWith<Schema.toType<S>>(
359
+ Schema.toType(schema),
360
+ parser,
361
+ encode === undefined ? roundTrip(schema, parser) : encodeWith(encode)
362
+ )
363
+ }
364
+
365
+ /** Prints with the derived printer, then checks that the text reads back as the value. */
366
+ const roundTrip = <S extends Schema.ConstraintCodec<unknown, unknown, never, never> & Schema.Top>(
367
+ schema: S,
368
+ parser: Parser<S["Type"], Schema.SchemaError, never, string>
369
+ ): Encode<S["Type"]> => {
370
+ const print = toPrinter(schema)
371
+ const equivalent = Schema.toEquivalence(Schema.toType(schema))
372
+ return (value) =>
373
+ Result.flatMap(
374
+ Result.mapError(print(value), (error) => error.issue),
375
+ (text) => {
376
+ const back = P.parse(parser, text)
377
+ return Result.isSuccess(back) && equivalent(back.success, value)
378
+ ? Result.succeed(text)
379
+ : Result.fail(
380
+ new SchemaIssue.InvalidValue({
381
+ message: `The value is written ${describeLiteral(text)}, which does not read back as the value`
382
+ })
383
+ )
384
+ }
385
+ )
386
+ }
387
+
388
+ // -----------------------------------------------------------------------------
389
+ // The walk shared by parsers and printers
390
+ // -----------------------------------------------------------------------------
391
+
392
+ interface Context<T> {
393
+ /** Characters ending an unannotated string, sorted. */
394
+ readonly stop: string
395
+ readonly whitespace: boolean
396
+ /** Grammar passed from a transformation to its encoded side. */
397
+ readonly inherit: Grammar | undefined
398
+ readonly path: ReadonlyArray<PropertyKey>
399
+ /** What was derived for the recursive schemas met so far, by context. */
400
+ readonly recursions: Array<Recursion<T>>
401
+ }
402
+
403
+ interface Recursion<T> {
404
+ readonly ast: SchemaAST.Suspend
405
+ readonly key: string
406
+ readonly derived: T
407
+ }
408
+
409
+ const initial = <T>(): Context<T> => ({ stop: "", whitespace: false, inherit: undefined, path: [], recursions: [] })
410
+
411
+ /** The annotation `key` on `ast`, from its last check back to the node, as `Schema.resolveAnnotations` resolves it. */
412
+ const lookup = (ast: SchemaAST.AST, key: string): unknown => {
413
+ const checks = ast.checks
414
+ if (checks !== undefined) {
415
+ for (let i = checks.length - 1; i >= 0; i--) {
416
+ const value = checks[i].annotations?.[key]
417
+ if (value !== undefined) return value
418
+ }
419
+ }
420
+ return ast.annotations?.[key]
421
+ }
422
+
423
+ const grammarOf = (ast: SchemaAST.AST): Grammar => {
424
+ const value = lookup(ast, annotationKey)
425
+ if (value === undefined) return {}
426
+ // SAFETY: the annotation is typed `Annotation`, a parser or a grammar
427
+ return P.isParser(value) ? { parser: value as ForeignParser } : (value as Grammar)
428
+ }
429
+
430
+ const isAnnotated = (ast: SchemaAST.AST): boolean => lookup(ast, annotationKey) !== undefined
431
+
432
+ const withStop = <T>(ctx: Context<T>, text: string | undefined): Context<T> => {
433
+ if (text === undefined || text === "" || ctx.stop.includes(text[0])) return ctx
434
+ return { ...ctx, stop: Arr.sort([...ctx.stop, text[0]], Order.String).join("") }
435
+ }
436
+
437
+ const at = <T>(ctx: Context<T>, key: PropertyKey): Context<T> => ({
438
+ ...ctx,
439
+ inherit: undefined,
440
+ path: [...ctx.path, key]
441
+ })
442
+
443
+ const where = <T>(ctx: Context<T>): string => `[${ctx.path.map(String).join(", ")}]`
444
+
445
+ const unsupported = <T>(ast: SchemaAST.AST, ctx: Context<T>, what: string): never => {
446
+ throw new TypeError(`ParserSchema: cannot derive a ${what} for ${ast._tag} at ${where(ctx)}; annotate it`)
447
+ }
448
+
449
+ interface Resolved<T> {
450
+ readonly grammar: Grammar
451
+ readonly whitespace: boolean
452
+ /** The context of what the node contains. */
453
+ readonly inner: Context<T>
454
+ }
455
+
456
+ /** The node's grammar, with what its transformation passed down, and the context inside it. */
457
+ const resolve = <T>(ast: SchemaAST.AST, ctx: Context<T>): Resolved<T> => {
458
+ const own = grammarOf(ast)
459
+ const inherit = ctx.inherit
460
+ const grammar: Grammar =
461
+ inherit === undefined
462
+ ? own
463
+ : {
464
+ ...own,
465
+ separator: own.separator ?? inherit.separator,
466
+ keySeparator: own.keySeparator ?? inherit.keySeparator
467
+ }
468
+ const whitespace = grammar.whitespace ?? ctx.whitespace
469
+ return { grammar, whitespace, inner: { ...withStop(ctx, grammar.suffix), whitespace, inherit: undefined } }
470
+ }
471
+
472
+ /** The context of a transformation's encoded side, which the transformation's separators apply to. */
473
+ const encodedContext = <T>(grammar: Grammar, ctx: Context<T>): Context<T> => ({
474
+ ...ctx,
475
+ inherit: { separator: grammar.separator, keySeparator: grammar.keySeparator }
476
+ })
477
+
478
+ /**
479
+ * The node with its chain of links ending at the type of its encoded side,
480
+ * which is what the encoded side is parsed into and printed from.
481
+ */
482
+ const linkTarget = (ast: SchemaAST.AST, links: SchemaAST.Encoding): SchemaAST.AST => {
483
+ const last = links[links.length - 1]
484
+ let target = SchemaAST.toType(ast)
485
+ for (const link of links) {
486
+ const source = link === last ? SchemaAST.toType(last.to) : link.to
487
+ // SAFETY: `decodeTo` appends the link as it is, middleware included, as the schema's own links are
488
+ target = SchemaAST.decodeTo(source, target, link.transformation as SchemaTransformation.Transformation<any, any>)
489
+ }
490
+ return target
491
+ }
492
+
493
+ /**
494
+ * What is derived for a recursive schema: deriving it again in the same
495
+ * context gives the same result, so the derivation ends.
496
+ */
497
+ const recursive = <T>(ast: SchemaAST.Suspend, ctx: Context<T>, make: () => T): T => {
498
+ const key = `${ctx.whitespace ? 1 : 0}${ctx.stop}`
499
+ const known = ctx.recursions.find((r) => r.ast === ast && r.key === key)
500
+ if (known !== undefined) return known.derived
501
+ const derived = make()
502
+ ctx.recursions.push({ ast, key, derived })
503
+ return derived
504
+ }
505
+
506
+ /** The context for the items of a sequence: unannotated strings also stop at the separator. */
507
+ const itemContext = <T>(grammar: Grammar, ctx: Context<T>): Context<T> => withStop(ctx, grammar.separator)
508
+
509
+ /** The rest element of an array schema, if any; elements after it are not supported. */
510
+ const restOf = <T>(ast: SchemaAST.Arrays, ctx: Context<T>, what: string): SchemaAST.AST | undefined => {
511
+ const [head, ...after] = ast.rest
512
+ // Elements after the rest would need the rest to stop short
513
+ return Arr.isReadonlyArrayNonEmpty(after) ? unsupported(ast, ctx, what) : head
514
+ }
515
+
516
+ /** The contexts of an array schema's elements and its rest. */
517
+ const elementContexts = <T>(
518
+ ast: SchemaAST.Arrays,
519
+ rest: SchemaAST.AST | undefined,
520
+ grammar: Grammar,
521
+ ctx: Context<T>
522
+ ): ReadonlyArray<Context<T>> => {
523
+ const n = ast.elements.length
524
+ return ast.elements.map((_, i) => at(i < n - 1 || rest !== undefined ? itemContext(grammar, ctx) : ctx, i))
525
+ }
526
+
527
+ /** The contexts of a struct's fields, written in order. */
528
+ const fieldContexts = <T>(ast: SchemaAST.Objects, grammar: Grammar, ctx: Context<T>): ReadonlyArray<Context<T>> => {
529
+ const fields = ast.propertySignatures
530
+ return fields.map((field, i) => at(i < fields.length - 1 ? itemContext(grammar, ctx) : ctx, field.name))
531
+ }
532
+
533
+ const isKeyed = (ast: SchemaAST.Objects, grammar: Grammar): boolean =>
534
+ grammar.keySeparator !== undefined || Arr.isReadonlyArrayNonEmpty(ast.indexSignatures)
535
+
536
+ const keySeparatorOf = <T>(grammar: Grammar, ctx: Context<T>): string => {
537
+ if (grammar.keySeparator === undefined) {
538
+ throw new TypeError(`ParserSchema: a record at ${where(ctx)} needs a keySeparator in its annotation`)
539
+ }
540
+ return grammar.keySeparator
541
+ }
542
+
543
+ /** The field name of a struct in keyed form: a string. */
544
+ const keyName = <T>(ast: SchemaAST.Objects, field: SchemaAST.PropertySignature, ctx: Context<T>, what: string) =>
545
+ Predicate.isString(field.name) ? field.name : unsupported(ast, ctx, what)
546
+
547
+ const propertyName = (key: PropertyKey): string | symbol => (Predicate.isNumber(key) ? String(key) : key)
548
+
549
+ /** Whether a check (as `Schema.Int` has) restricts numbers to integers. */
550
+ const isIntegral = (checks: SchemaAST.Checks | undefined): boolean =>
551
+ checks !== undefined &&
552
+ checks.some((check) =>
553
+ check instanceof SchemaAST.FilterGroup
554
+ ? isIntegral(check.checks)
555
+ : check.annotations?.arbitraryConstraint?.["number"] === "integer"
556
+ )
557
+
558
+ type LiteralValue = SchemaAST.Literal["literal"]
559
+
560
+ /** Literals with the longest text first, so a literal is never cut short by one it starts with. */
561
+ const byLength = <A extends LiteralValue>(values: ReadonlyArray<A>): Array<A> =>
562
+ Arr.sort(
563
+ values,
564
+ Order.mapInput(Order.Number, (value: A) => -String(value).length)
565
+ )
566
+
567
+ const isPlainLiteral = (ast: SchemaAST.AST): ast is SchemaAST.Literal =>
568
+ SchemaAST.isLiteral(ast) && ast.encoding === undefined && !isAnnotated(ast)
569
+
570
+ /** The literals of a union of plain literals, longest first, or `undefined`. */
571
+ const plainLiterals = (ast: SchemaAST.Union): ReadonlyArray<LiteralValue> | undefined => {
572
+ const literals = Arr.filter(ast.types, isPlainLiteral)
573
+ return literals.length === ast.types.length ? byLength(literals.map((member) => member.literal)) : undefined
574
+ }
575
+
576
+ const schemaError = (issue: SchemaIssue.Issue) => new Schema.SchemaError(issue)
577
+
578
+ const decoder = (schema: Schema.ConstraintDecoder<unknown>) => Schema.decodeUnknownResult(schema)
579
+
580
+ /** Decodes into the type of `ast`, checks included. */
581
+ const typeDecoder = (ast: SchemaAST.AST) => decoder(Schema.make<Schema.Codec<unknown>>(SchemaAST.toType(ast)))
582
+
583
+ // -----------------------------------------------------------------------------
584
+ // Parsers
585
+ // -----------------------------------------------------------------------------
586
+
587
+ type AnyParser = Parser<any, Schema.SchemaError, never, string>
588
+
589
+ type ForeignParser = Parser<unknown, unknown, never, string>
590
+
591
+ const messageOf = (cause: unknown): string =>
592
+ Predicate.isString(cause)
593
+ ? cause
594
+ : Predicate.hasProperty(cause, "message") && Predicate.isString(cause.message)
595
+ ? cause.message
596
+ : String(cause)
597
+
598
+ /** A parser whose failures' causes are `SchemaError`s, as {@link toParser}'s are. */
599
+ const asSchemaErrors = (parser: ForeignParser): AnyParser =>
600
+ P.mapError(parser, (cause) =>
601
+ cause instanceof Schema.SchemaError
602
+ ? cause
603
+ : schemaError(new SchemaIssue.InvalidValue({ message: messageOf(cause) }))
604
+ )
605
+
606
+ /** Literal text, with optional whitespace around it. */
607
+ const text = (s: string, ws: boolean): AnyParser =>
608
+ ws ? P.delimited(Ascii.multispace0, Token.literal(s), Ascii.multispace0) : Token.literal(s)
609
+
610
+ const derive = (ast: SchemaAST.AST, ctx: Context<AnyParser>): AnyParser => {
611
+ const { grammar, inner, whitespace } = resolve(ast, ctx)
612
+ let parser: AnyParser
613
+ if (grammar.parser !== undefined) parser = P.tryMap(asSchemaErrors(grammar.parser), typeDecoder(ast))
614
+ else if (ast.encoding !== undefined) parser = decoded(ast, ast.encoding, grammar, inner)
615
+ else parser = checked(ast, structural(ast, grammar, inner))
616
+ if (grammar.prefix !== undefined) parser = P.preceded(text(grammar.prefix, whitespace), parser)
617
+ if (grammar.suffix !== undefined) parser = P.terminated(parser, text(grammar.suffix, whitespace))
618
+ // A check's own `expected` describes the check, not the text
619
+ const label = lookup(ast, "identifier") ?? ast.annotations?.expected
620
+ return Predicate.isString(label) ? P.expected(parser, label) : parser
621
+ }
622
+
623
+ /** Runs the checks on `ast` (its filters) on the parser's output. */
624
+ const checked = (ast: SchemaAST.AST, parser: AnyParser): AnyParser =>
625
+ ast.checks === undefined ? parser : P.tryMap(parser, decoder(Schema.Unknown.check(...ast.checks)))
626
+
627
+ /**
628
+ * Parses the encoded side of a transformation and decodes it. The encoded
629
+ * side is parsed into its own type, so the last link decodes from that type
630
+ * rather than from the fully encoded form.
631
+ */
632
+ const decoded = (
633
+ ast: SchemaAST.AST,
634
+ links: SchemaAST.Encoding,
635
+ grammar: Grammar,
636
+ ctx: Context<AnyParser>
637
+ ): AnyParser => {
638
+ const from = derive(links[links.length - 1].to, encodedContext(grammar, ctx))
639
+ return P.tryMap(from, decoder(Schema.make<Schema.Codec<unknown>>(linkTarget(ast, links))))
640
+ }
641
+
642
+ const structural = (ast: SchemaAST.AST, grammar: Grammar, ctx: Context<AnyParser>): AnyParser =>
643
+ Match.valueTags(ast, {
644
+ String: () => unannotatedString(ctx, 1),
645
+ Number: (number) => (isIntegral(number.checks) ? integer : Ascii.float),
646
+ BigInt: () => Ascii.decBigInt,
647
+ Boolean: () => P.alt(P.as(Token.literal("true"), true), P.as(Token.literal("false"), false)),
648
+ Null: () => P.as(Token.literal("null"), null),
649
+ Undefined: () => P.succeed(undefined),
650
+ Void: () => P.succeed(undefined),
651
+ Never: () => P.reject,
652
+ Literal: (lit) => literal(lit.literal),
653
+ Enum: (e) => P.alt(...byLength(e.enums.map(([, value]) => value)).map(literal)),
654
+ TemplateLiteral: (template) => templateLiteral(template, ctx),
655
+ Arrays: (arrays) => sequenceOf(arrays, grammar, ctx),
656
+ Objects: (objects) =>
657
+ isKeyed(objects, grammar) ? keyed(objects, grammar, ctx) : positional(objects, grammar, ctx),
658
+ Union: (union) => alternatives(union, ctx),
659
+ Suspend: (suspend) => recursive(suspend, ctx, () => P.suspend(() => derive(suspend.thunk(), ctx))),
660
+ Declaration: (other) => unsupported(other, ctx, "parser"),
661
+ Unknown: (other) => unsupported(other, ctx, "parser"),
662
+ Any: (other) => unsupported(other, ctx, "parser"),
663
+ Symbol: (other) => unsupported(other, ctx, "parser"),
664
+ UniqueSymbol: (other) => unsupported(other, ctx, "parser"),
665
+ ObjectKeyword: (other) => unsupported(other, ctx, "parser")
666
+ })
667
+
668
+ const unannotatedString = (ctx: Context<AnyParser>, min: number): AnyParser => {
669
+ const s = Token.takeTill(ctx.stop, { min })
670
+ // Leading whitespace is skipped first, so it doesn't count towards `min`
671
+ return ctx.whitespace
672
+ ? P.preceded(
673
+ Ascii.multispace0,
674
+ P.map(s, (value) => value.trimEnd())
675
+ )
676
+ : s
677
+ }
678
+
679
+ const integer: AnyParser = /*#__PURE__*/ asSchemaErrors(Ascii.decInt)
680
+
681
+ const literal = (value: LiteralValue): AnyParser => P.as(Token.literal(String(value)), value)
682
+
683
+ const templateLiteral = (ast: SchemaAST.TemplateLiteral, ctx: Context<AnyParser>): AnyParser =>
684
+ P.taken(
685
+ P.seq(
686
+ ...ast.parts.map((part, i) => {
687
+ const next = ast.parts[i + 1]
688
+ const partCtx = next !== undefined && SchemaAST.isLiteral(next) ? withStop(ctx, String(next.literal)) : ctx
689
+ // An interpolated string may be empty
690
+ return SchemaAST.isString(part) && !isAnnotated(part)
691
+ ? unannotatedString(partCtx, 0)
692
+ : derive(part, at(partCtx, i))
84
693
  })
85
694
  )
86
695
  )
696
+
697
+ const alternatives = (ast: SchemaAST.Union, ctx: Context<AnyParser>): AnyParser => {
698
+ const literals = plainLiterals(ast)
699
+ return literals !== undefined
700
+ ? P.alt(...literals.map(literal))
701
+ : P.alt(...ast.types.map((member) => derive(member, ctx)))
702
+ }
703
+
704
+ interface Item {
705
+ readonly parser: AnyParser
706
+ readonly optional: boolean
707
+ }
708
+
709
+ type Values = Parser<Array<unknown>, Schema.SchemaError, never, string>
710
+
711
+ /**
712
+ * Parses `items` in order, then any number of `rest`, with `separator`
713
+ * between the ones present. The output has the value of item `i` at index
714
+ * `i` (a hole when an optional item is missing), then the rest.
715
+ */
716
+ const sequence = (
717
+ items: ReadonlyArray<Item>,
718
+ rest: AnyParser | undefined,
719
+ separator: AnyParser | undefined
720
+ ): Values => {
721
+ const n = items.length
722
+ const tail = (needSeparator: boolean): Values => {
723
+ if (rest === undefined) return P.sync(() => new Array(n))
724
+ const values =
725
+ separator === undefined
726
+ ? P.repeat(rest)
727
+ : needSeparator
728
+ ? P.repeat(P.preceded(separator, rest))
729
+ : P.separated(rest, separator)
730
+ return P.map(values, (xs) => {
731
+ const out = new Array(n)
732
+ for (let j = 0; j < xs.length; j++) out[n + j] = xs[j]
733
+ return out
734
+ })
735
+ }
736
+ // One parser per position and whether a separator comes first, at index `2 * i + needSeparator`
737
+ const memo: Array<Values | undefined> = []
738
+ const from = (i: number, needSeparator: boolean): Values => {
739
+ const key = i * 2 + (needSeparator ? 1 : 0)
740
+ const known = memo[key]
741
+ if (known !== undefined) return known
742
+ let parser: Values
743
+ if (i === n) parser = tail(needSeparator)
744
+ else {
745
+ const item = items[i]
746
+ const head = needSeparator && separator !== undefined ? P.preceded(separator, item.parser) : item.parser
747
+ const present = P.map(P.seq(head, from(i + 1, true)), ([value, out]) => {
748
+ out[i] = value
749
+ return out
750
+ })
751
+ parser = item.optional ? P.alt(present, from(i + 1, needSeparator)) : present
752
+ }
753
+ memo[key] = parser
754
+ return parser
755
+ }
756
+ return from(0, false)
757
+ }
758
+
759
+ const separatorOf = (grammar: Grammar, ctx: Context<AnyParser>): AnyParser | undefined =>
760
+ grammar.separator === undefined ? undefined : text(grammar.separator, ctx.whitespace)
761
+
762
+ const sequenceOf = (ast: SchemaAST.Arrays, grammar: Grammar, ctx: Context<AnyParser>): AnyParser => {
763
+ const head = restOf(ast, ctx, "parser")
764
+ const contexts = elementContexts(ast, head, grammar, ctx)
765
+ const items = ast.elements.map((element, i) => ({
766
+ parser: derive(element, contexts[i]),
767
+ optional: SchemaAST.isOptional(element)
768
+ }))
769
+ const rest = head === undefined ? undefined : derive(head, at(itemContext(grammar, ctx), items.length))
770
+ // Optional elements come last, so the present values are the first ones
771
+ return P.map(sequence(items, rest, separatorOf(grammar, ctx)), (out) => out.filter((_, i) => i in out))
772
+ }
773
+
774
+ const positional = (ast: SchemaAST.Objects, grammar: Grammar, ctx: Context<AnyParser>): AnyParser => {
775
+ const fields = ast.propertySignatures
776
+ const contexts = fieldContexts(ast, grammar, ctx)
777
+ const items = fields.map((field, i) => ({
778
+ parser: derive(field.type, contexts[i]),
779
+ optional: SchemaAST.isOptional(field.type)
780
+ }))
781
+ return P.map(sequence(items, undefined, separatorOf(grammar, ctx)), (out) =>
782
+ Record.fromEntries(fields.flatMap((field, i) => (i in out ? [[propertyName(field.name), out[i]] as const] : [])))
783
+ )
784
+ }
785
+
786
+ type Entry = readonly [key: string | symbol, value: unknown, known: boolean]
787
+
788
+ const keyed = (ast: SchemaAST.Objects, grammar: Grammar, ctx: Context<AnyParser>): AnyParser => {
789
+ const keySeparator = keySeparatorOf(grammar, ctx)
790
+ const between = text(keySeparator, ctx.whitespace)
791
+ const valueCtx = itemContext(grammar, ctx)
792
+ const known = ast.propertySignatures.map((field): AnyParser => {
793
+ const name = keyName(ast, field, ctx, "parser")
794
+ const value = derive(field.type, at(valueCtx, name))
795
+ return P.map(P.preceded(P.seq(Token.literal(name), between), value), (v): Entry => [name, v, true])
796
+ })
797
+ const indexed = ast.indexSignatures.map((signature): AnyParser => {
798
+ const key = derive(signature.parameter, at(withStop(ctx, keySeparator), "<key>"))
799
+ const value = derive(signature.type, at(valueCtx, "<value>"))
800
+ return P.map(P.separatedPair(key, between, value), ([k, v]): Entry => [propertyName(k), v, false])
801
+ })
802
+ const entry = P.alt(...known, ...indexed)
803
+ const separator = separatorOf(grammar, ctx)
804
+ const entries = separator === undefined ? P.repeat(entry) : P.separated(entry, separator)
805
+ // A missing field fails where its entry could have been, as a syntax error, so a
806
+ // failure further in (the entry that ended the repetition early) is reported instead
807
+ const missing = ast.propertySignatures.map((field) =>
808
+ P.expected(P.reject, describeLiteral(`${String(field.name)}${keySeparator}`))
809
+ )
810
+ return P.flatMap(
811
+ P.tryMap(entries, (es: ReadonlyArray<Entry>) => assemble(ast, es)),
812
+ ([value, absent]) => (absent === undefined ? P.succeed(value) : missing[absent])
813
+ )
814
+ }
815
+
816
+ /**
817
+ * Builds the object from its entries, declared fields first, rejecting
818
+ * duplicate keys. Also returns the index of the first required field
819
+ * missing, if any.
820
+ */
821
+ const assemble = (
822
+ ast: SchemaAST.Objects,
823
+ entries: ReadonlyArray<Entry>
824
+ ): Result.Result<readonly [object, number | undefined], Schema.SchemaError> => {
825
+ const values = MutableHashMap.empty<string | symbol, unknown>()
826
+ for (const [key, value] of entries) {
827
+ if (MutableHashMap.has(values, key)) {
828
+ return Result.fail(
829
+ schemaError(new SchemaIssue.Pointer([key], new SchemaIssue.InvalidValue({ message: "Duplicate key" })))
830
+ )
831
+ }
832
+ MutableHashMap.set(values, key, value)
833
+ }
834
+ const out: Array<readonly [string | symbol, unknown]> = []
835
+ let absent: number | undefined
836
+ ast.propertySignatures.forEach((field, i) => {
837
+ const name = propertyName(field.name)
838
+ const value = MutableHashMap.get(values, name)
839
+ if (Option.isSome(value)) out.push([name, value.value])
840
+ else if (absent === undefined && !SchemaAST.isOptional(field.type)) absent = i
841
+ })
842
+ for (const [key, value, isKnown] of entries) if (!isKnown) out.push([key, value])
843
+ return Result.succeed([Record.fromEntries(out), absent])
844
+ }
845
+
846
+ // -----------------------------------------------------------------------------
847
+ // Printers
848
+ // -----------------------------------------------------------------------------
849
+
850
+ /**
851
+ * Writes the text of a value, already validated against the schema's type,
852
+ * to `out`, or returns why it cannot be written.
853
+ */
854
+ type Printer = (value: any, out: Array<string>) => SchemaIssue.Issue | undefined
855
+
856
+ /** The issue for a value that cannot be written at the node `ctx` is at. */
857
+ const unwritable = <T>(ctx: Context<T>, message: string, value: unknown): SchemaIssue.Issue => {
858
+ const issue = new SchemaIssue.InvalidValue({ message }, value, { reportInput: true })
859
+ return Arr.isReadonlyArrayNonEmpty(ctx.path) ? new SchemaIssue.Pointer(ctx.path, issue) : issue
860
+ }
861
+
862
+ const writing =
863
+ (show: (value: any) => string): Printer =>
864
+ (value, out) => {
865
+ out.push(show(value))
866
+ return undefined
867
+ }
868
+
869
+ const nothing: Printer = (_, __) => undefined
870
+
871
+ const printerOf = (ast: SchemaAST.AST, ctx: Context<Printer>): Printer => {
872
+ const { grammar, inner } = resolve(ast, ctx)
873
+ let print: Printer
874
+ if (grammar.print !== undefined) print = writing(grammar.print)
875
+ else if (grammar.parser !== undefined) print = unprintable(ctx)
876
+ else if (ast.encoding !== undefined) print = encoded(ast, ast.encoding, grammar, inner)
877
+ else print = printerFor(ast, grammar, inner)
878
+ const prefix = grammar.prefix
879
+ const suffix = grammar.suffix
880
+ if (prefix === undefined && suffix === undefined) return print
881
+ const inside = print
882
+ return (value, out) => {
883
+ if (prefix !== undefined) out.push(prefix)
884
+ const issue = inside(value, out)
885
+ if (suffix !== undefined) out.push(suffix)
886
+ return issue
887
+ }
888
+ }
889
+
890
+ const unprintable =
891
+ (ctx: Context<Printer>): Printer =>
892
+ (value) =>
893
+ unwritable(ctx, "This value has a parser in its annotation but no print, so it cannot be written", value)
894
+
895
+ /** Encodes a transformation's value and prints its encoded side. */
896
+ const encoded = (ast: SchemaAST.AST, links: SchemaAST.Encoding, grammar: Grammar, ctx: Context<Printer>): Printer => {
897
+ const to = printerOf(links[links.length - 1].to, encodedContext(grammar, ctx))
898
+ const encode = Schema.encodeUnknownResult(Schema.make<Schema.Codec<unknown, unknown>>(linkTarget(ast, links)))
899
+ return (value, out) => {
900
+ const result = encode(value)
901
+ return Result.isSuccess(result) ? to(result.success, out) : result.failure.issue
902
+ }
903
+ }
904
+
905
+ const printerFor = (ast: SchemaAST.AST, grammar: Grammar, ctx: Context<Printer>): Printer =>
906
+ Match.valueTags(ast, {
907
+ String: () => stringPrinter(ctx),
908
+ Number: () => writing(String),
909
+ BigInt: () => writing(String),
910
+ Boolean: () => writing(String),
911
+ Null: () => writing(String),
912
+ Undefined: () => nothing,
913
+ Void: () => nothing,
914
+ Never: () => nothing,
915
+ Literal: () => writing(String),
916
+ Enum: () => writing(String),
917
+ TemplateLiteral: () => writing(String),
918
+ Arrays: (arrays) => arraysPrinter(arrays, grammar, ctx),
919
+ Objects: (objects) =>
920
+ isKeyed(objects, grammar) ? keyedPrinter(objects, grammar, ctx) : positionalPrinter(objects, grammar, ctx),
921
+ Union: (union) => unionPrinter(union, ctx),
922
+ Suspend: (suspend) =>
923
+ recursive(suspend, ctx, () => {
924
+ let print: Printer | undefined
925
+ return (value, out) => (print ??= printerOf(suspend.thunk(), ctx))(value, out)
926
+ }),
927
+ Declaration: (other) => unsupported(other, ctx, "printer"),
928
+ Unknown: (other) => unsupported(other, ctx, "printer"),
929
+ Any: (other) => unsupported(other, ctx, "printer"),
930
+ Symbol: (other) => unsupported(other, ctx, "printer"),
931
+ UniqueSymbol: (other) => unsupported(other, ctx, "printer"),
932
+ ObjectKeyword: (other) => unsupported(other, ctx, "printer")
933
+ })
934
+
935
+ /**
936
+ * Writes a string as an unannotated string is parsed: refusing one that
937
+ * would not read back, being empty or holding a character that ends it.
938
+ */
939
+ const stringPrinter =
940
+ (ctx: Context<Printer>): Printer =>
941
+ (value: string, out) => {
942
+ if (value === "") return unwritable(ctx, "An empty string cannot be written here", value)
943
+ const stop = Arr.findFirst(Array.from(ctx.stop), (c) => value.includes(c))
944
+ if (Option.isSome(stop)) {
945
+ return unwritable(ctx, `A string containing ${describeLiteral(stop.value)} cannot be written here`, value)
946
+ }
947
+ if (ctx.whitespace && value.trim() !== value) {
948
+ return unwritable(ctx, "A string with whitespace around it cannot be written here", value)
949
+ }
950
+ out.push(value)
951
+ return undefined
952
+ }
953
+
954
+ /** Writes values with a separator between them, stopping at the first that cannot be written. */
955
+ const writeAll = (
956
+ writes: ReadonlyArray<(out: Array<string>) => SchemaIssue.Issue | undefined>,
957
+ separator: string,
958
+ out: Array<string>
959
+ ): SchemaIssue.Issue | undefined => {
960
+ for (let i = 0; i < writes.length; i++) {
961
+ if (i > 0) out.push(separator)
962
+ const issue = writes[i](out)
963
+ if (issue !== undefined) return issue
964
+ }
965
+ return undefined
966
+ }
967
+
968
+ const arraysPrinter = (ast: SchemaAST.Arrays, grammar: Grammar, ctx: Context<Printer>): Printer => {
969
+ const head = restOf(ast, ctx, "printer")
970
+ const contexts = elementContexts(ast, head, grammar, ctx)
971
+ const elements = ast.elements.map((element, i) => printerOf(element, contexts[i]))
972
+ const rest = head === undefined ? nothing : printerOf(head, at(itemContext(grammar, ctx), elements.length))
973
+ const separator = grammar.separator ?? ""
974
+ return (value: ReadonlyArray<unknown>, out) =>
975
+ writeAll(
976
+ value.map((element, i) => (o: Array<string>) => (elements[i] ?? rest)(element, o)),
977
+ separator,
978
+ out
979
+ )
980
+ }
981
+
982
+ const positionalPrinter = (ast: SchemaAST.Objects, grammar: Grammar, ctx: Context<Printer>): Printer => {
983
+ const contexts = fieldContexts(ast, grammar, ctx)
984
+ const fields = ast.propertySignatures.map((field, i) => ({
985
+ name: propertyName(field.name),
986
+ print: printerOf(field.type, contexts[i])
987
+ }))
988
+ const separator = grammar.separator ?? ""
989
+ return (value: Record.ReadonlyRecord<string | symbol, unknown>, out) =>
990
+ writeAll(
991
+ fields
992
+ .filter((field) => Record.has(value, field.name))
993
+ .map((field) => (o: Array<string>) => field.print(value[field.name], o)),
994
+ separator,
995
+ out
996
+ )
997
+ }
998
+
999
+ const keyedPrinter = (ast: SchemaAST.Objects, grammar: Grammar, ctx: Context<Printer>): Printer => {
1000
+ const keySeparator = keySeparatorOf(grammar, ctx)
1001
+ const valueCtx = itemContext(grammar, ctx)
1002
+ const fields = ast.propertySignatures.map((field) => {
1003
+ const name = keyName(ast, field, ctx, "printer")
1004
+ return { name, print: printerOf(field.type, at(valueCtx, name)) }
1005
+ })
1006
+ const names = fields.map((field) => field.name)
1007
+ const indexed = ast.indexSignatures.map((signature) => ({
1008
+ is: Schema.is(Schema.make<Schema.Top>(SchemaAST.toType(signature.parameter))),
1009
+ key: printerOf(signature.parameter, at(withStop(ctx, keySeparator), "<key>")),
1010
+ value: printerOf(signature.type, at(valueCtx, "<value>"))
1011
+ }))
1012
+ const entry =
1013
+ (key: Printer, print: Printer, k: unknown, v: unknown) =>
1014
+ (out: Array<string>): SchemaIssue.Issue | undefined =>
1015
+ key(k, out) ?? (out.push(keySeparator), print(v, out))
1016
+ return (value: Record.ReadonlyRecord<string, unknown>, out) => {
1017
+ const writes = fields
1018
+ .filter((field) => Record.has(value, field.name))
1019
+ .map((field) => entry(writing(String), field.print, field.name, value[field.name]))
1020
+ for (const key of Record.keys(value)) {
1021
+ const signature = names.includes(key) ? undefined : indexed.find((s) => s.is(key))
1022
+ if (signature !== undefined) writes.push(entry(signature.key, signature.value, key, value[key]))
1023
+ }
1024
+ return writeAll(writes, grammar.separator ?? "", out)
1025
+ }
1026
+ }
1027
+
1028
+ /** Writes a union's value with the first member it belongs to that can write it. */
1029
+ const unionPrinter = (ast: SchemaAST.Union, ctx: Context<Printer>): Printer => {
1030
+ if (plainLiterals(ast) !== undefined) return writing(String)
1031
+ const members = ast.types.map((member) => ({
1032
+ is: Schema.is(Schema.make<Schema.Top>(SchemaAST.toType(member))),
1033
+ print: printerOf(member, ctx)
1034
+ }))
1035
+ return (value, out) => {
1036
+ let issue: SchemaIssue.Issue | undefined
1037
+ for (const member of members) {
1038
+ if (!member.is(value)) continue
1039
+ const mark = out.length
1040
+ issue = member.print(value, out)
1041
+ if (issue === undefined) return undefined
1042
+ out.length = mark
1043
+ }
1044
+ return issue ?? unwritable(ctx, "No member of the union can write this value", value)
1045
+ }
87
1046
  }