@oh-my-pi/omptype 17.2.6 → 17.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -2
- package/README.md +83 -14
- package/dist/js/ark.js +16 -0
- package/dist/js/compile.js +747 -0
- package/dist/js/errors.js +199 -0
- package/dist/js/index.js +12 -0
- package/dist/js/infer.js +2 -0
- package/dist/js/interp.js +476 -0
- package/dist/js/ir.js +930 -0
- package/dist/js/json-schema.js +273 -0
- package/dist/js/keywords.js +233 -0
- package/dist/js/type.js +1078 -0
- package/dist/js/typebox.js +346 -0
- package/dist/js/zod.js +269 -0
- package/dist/types/ark.d.ts +3 -11
- package/dist/types/compile.d.ts +2 -0
- package/dist/types/errors.d.ts +51 -15
- package/dist/types/index.d.ts +3 -4
- package/dist/types/infer.d.ts +107 -30
- package/dist/types/interp.d.ts +5 -3
- package/dist/types/ir.d.ts +76 -7
- package/dist/types/json-schema.d.ts +6 -1
- package/dist/types/keywords.d.ts +7 -0
- package/dist/types/type.d.ts +238 -49
- package/dist/types/zod.d.ts +1 -1
- package/package.json +20 -8
- package/src/ark.ts +4 -14
- package/src/compile.ts +432 -54
- package/src/errors.ts +146 -34
- package/src/index.ts +3 -4
- package/src/infer.ts +284 -77
- package/src/interp.ts +153 -9
- package/src/ir.ts +636 -89
- package/src/json-schema.ts +109 -14
- package/src/keywords.ts +270 -0
- package/src/type.ts +1302 -147
- package/src/typebox.ts +1 -1
- package/src/zod.ts +40 -27
package/src/ir.ts
CHANGED
|
@@ -10,11 +10,18 @@
|
|
|
10
10
|
* and the JSON Schema emitter (`json-schema.ts`).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { OmpTypeError } from "./errors";
|
|
13
|
+
import { OmpErrors, OmpTypeError } from "./errors";
|
|
14
|
+
import { keywordIR, patternIR, templateIR } from "./keywords";
|
|
14
15
|
|
|
15
16
|
/** Brand carried by `Type` instances so the parser can embed them in defs. */
|
|
16
17
|
export const IR_BRAND: unique symbol = Symbol("omptype.schema");
|
|
17
18
|
|
|
19
|
+
const kMorph: unique symbol = Symbol("omptype.hasMorph");
|
|
20
|
+
|
|
21
|
+
interface IRAnalysis {
|
|
22
|
+
[kMorph]?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
/**
|
|
19
26
|
* The parser-facing surface of an embedded `Type` instance.
|
|
20
27
|
* `type.ts` implements this on every schema it creates.
|
|
@@ -37,22 +44,83 @@ export interface EmbeddableSchema {
|
|
|
37
44
|
/** Policy for undeclared object keys. */
|
|
38
45
|
export type Extras = "keep" | "reject" | "delete";
|
|
39
46
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
47
|
+
/** Constructor accepted by `type.instanceOf` and tuple `instanceof` expressions. */
|
|
48
|
+
export type Constructor = abstract new (...args: never[]) => object;
|
|
49
|
+
|
|
50
|
+
/** Context available to in-definition morph callbacks. */
|
|
51
|
+
export interface MorphContext {
|
|
52
|
+
/** Return a validation error at the current path. */
|
|
53
|
+
error(expectation: string): OmpErrors;
|
|
54
|
+
/** Alias of `error` matching ArkType's rejection vocabulary. */
|
|
55
|
+
reject(expectation: string): OmpErrors;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One fixed tuple position, optionally absent or defaulted. */
|
|
59
|
+
export interface TupleItemIR {
|
|
60
|
+
val: IR;
|
|
61
|
+
opt: boolean;
|
|
62
|
+
def?: unknown;
|
|
63
|
+
defFactory?: boolean;
|
|
64
|
+
hasDefault?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Fixed, optional, variadic, and postfix tuple sequence. */
|
|
68
|
+
export interface TupleIR {
|
|
69
|
+
k: "tuple";
|
|
70
|
+
prefix: TupleItemIR[];
|
|
71
|
+
variadic?: IR;
|
|
72
|
+
postfix: IR[];
|
|
73
|
+
desc?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type IR = IRAnalysis &
|
|
77
|
+
(
|
|
78
|
+
| { k: "unknown"; desc?: string }
|
|
79
|
+
| { k: "null"; desc?: string }
|
|
80
|
+
| { k: "undefined"; desc?: string }
|
|
81
|
+
| { k: "boolean"; desc?: string }
|
|
82
|
+
| { k: "bigint"; desc?: string }
|
|
83
|
+
| { k: "symbol"; desc?: string }
|
|
84
|
+
| { k: "never"; desc?: string }
|
|
85
|
+
/** Any non-null object (the bare `object` keyword). */
|
|
86
|
+
| { k: "anyobject"; desc?: string }
|
|
87
|
+
| { k: "string"; min?: number; max?: number; url?: boolean; desc?: string }
|
|
88
|
+
| {
|
|
89
|
+
k: "number";
|
|
90
|
+
min?: number;
|
|
91
|
+
max?: number;
|
|
92
|
+
xmin?: boolean;
|
|
93
|
+
xmax?: boolean;
|
|
94
|
+
int?: boolean;
|
|
95
|
+
divisor?: number;
|
|
96
|
+
desc?: string;
|
|
97
|
+
}
|
|
98
|
+
| { k: "lit"; v: unknown; desc?: string }
|
|
99
|
+
| { k: "union"; members: IR[]; desc?: string }
|
|
100
|
+
| { k: "intersection"; members: IR[]; desc?: string }
|
|
101
|
+
| { k: "array"; el: IR; min?: number; max?: number; desc?: string }
|
|
102
|
+
| TupleIR
|
|
103
|
+
| { k: "object"; props: PropIR[]; index?: IR; extras: Extras; desc?: string }
|
|
104
|
+
| {
|
|
105
|
+
k: "refine";
|
|
106
|
+
base: IR;
|
|
107
|
+
pred: (value: unknown) => boolean;
|
|
108
|
+
expected: string;
|
|
109
|
+
json?: Record<string, unknown>;
|
|
110
|
+
desc?: string;
|
|
111
|
+
}
|
|
112
|
+
| {
|
|
113
|
+
k: "morph";
|
|
114
|
+
input: IR;
|
|
115
|
+
fn: (value: unknown, context: MorphContext) => unknown;
|
|
116
|
+
out?: IR;
|
|
117
|
+
desc?: string;
|
|
118
|
+
}
|
|
119
|
+
| { k: "instance"; ctor: Constructor; expected: string; desc?: string }
|
|
120
|
+
| { k: "alias"; name: string; resolve: () => IR; desc?: string }
|
|
121
|
+
/** Embedded schema with runtime steps; validated by calling `run`. */
|
|
122
|
+
| { k: "sub"; schema: EmbeddableSchema; desc?: string }
|
|
123
|
+
);
|
|
56
124
|
|
|
57
125
|
export interface PropIR {
|
|
58
126
|
key: string;
|
|
@@ -65,13 +133,18 @@ export interface PropIR {
|
|
|
65
133
|
}
|
|
66
134
|
|
|
67
135
|
/** Definition input accepted by `type()` and object property values. */
|
|
68
|
-
export type Def = string | EmbeddableSchema | readonly
|
|
136
|
+
export type Def = string | RegExp | Date | EmbeddableSchema | readonly unknown[] | { readonly [k: string]: unknown };
|
|
69
137
|
|
|
70
138
|
// ── tokenizer ────────────────────────────────────────────────────────────────
|
|
71
139
|
|
|
72
|
-
type Tok =
|
|
140
|
+
type Tok =
|
|
141
|
+
| { t: "id"; v: string }
|
|
142
|
+
| { t: "num"; v: number }
|
|
143
|
+
| { t: "date"; v: Date }
|
|
144
|
+
| { t: "str"; v: string }
|
|
145
|
+
| { t: "op"; v: string };
|
|
73
146
|
|
|
74
|
-
const SIMPLE_OPS = "|()[]
|
|
147
|
+
const SIMPLE_OPS = "|()[]=?%";
|
|
75
148
|
|
|
76
149
|
function tokenize(src: string): Tok[] {
|
|
77
150
|
const toks: Tok[] = [];
|
|
@@ -91,6 +164,16 @@ function tokenize(src: string): Tok[] {
|
|
|
91
164
|
i = j + 1;
|
|
92
165
|
continue;
|
|
93
166
|
}
|
|
167
|
+
if (c === "d" && (src[i + 1] === "'" || src[i + 1] === '"')) {
|
|
168
|
+
const quote = src[i + 1];
|
|
169
|
+
const end = src.indexOf(quote, i + 2);
|
|
170
|
+
if (end < 0) throw new OmpTypeError(`unterminated date literal in "${src}"`);
|
|
171
|
+
const value = new Date(src.slice(i + 2, end));
|
|
172
|
+
if (Number.isNaN(value.valueOf())) throw new OmpTypeError(`invalid date literal in "${src}"`);
|
|
173
|
+
toks.push({ t: "date", v: value });
|
|
174
|
+
i = end + 1;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
94
177
|
if ((c >= "0" && c <= "9") || (c === "-" && i + 1 < n && src[i + 1] >= "0" && src[i + 1] <= "9")) {
|
|
95
178
|
let j = i + 1;
|
|
96
179
|
while (j < n && ((src[j] >= "0" && src[j] <= "9") || src[j] === "." || src[j] === "e" || src[j] === "+")) j++;
|
|
@@ -130,20 +213,25 @@ function tokenize(src: string): Tok[] {
|
|
|
130
213
|
const CMP: Record<string, true> = { "<": true, "<=": true, ">": true, ">=": true };
|
|
131
214
|
|
|
132
215
|
const KEYWORDS: Record<string, () => IR> = {
|
|
133
|
-
string: () => ({ k: "string" }),
|
|
134
|
-
"string.url": () => ({ k: "string", url: true }),
|
|
135
216
|
number: () => ({ k: "number" }),
|
|
136
217
|
"number.integer": () => ({ k: "number", int: true }),
|
|
137
218
|
boolean: () => ({ k: "boolean" }),
|
|
138
219
|
bigint: () => ({ k: "bigint" }),
|
|
220
|
+
symbol: () => ({ k: "symbol" }),
|
|
221
|
+
never: () => ({ k: "never" }),
|
|
139
222
|
null: () => ({ k: "null" }),
|
|
140
223
|
undefined: () => ({ k: "undefined" }),
|
|
141
224
|
unknown: () => ({ k: "unknown" }),
|
|
225
|
+
any: () => ({ k: "unknown" }),
|
|
142
226
|
object: () => ({ k: "anyobject" }),
|
|
227
|
+
Date: () => ({ k: "instance", ctor: Date, expected: "a Date" }),
|
|
143
228
|
true: () => ({ k: "lit", v: true }),
|
|
144
229
|
false: () => ({ k: "lit", v: false }),
|
|
145
230
|
};
|
|
146
231
|
|
|
232
|
+
/** Resolve a named scope alias to its lazy IR reference. */
|
|
233
|
+
export type AliasResolver = (name: string) => IR | undefined;
|
|
234
|
+
|
|
147
235
|
interface ParsedTop {
|
|
148
236
|
ir: IR;
|
|
149
237
|
def?: unknown;
|
|
@@ -156,9 +244,11 @@ class StrParser {
|
|
|
156
244
|
#toks: Tok[];
|
|
157
245
|
#pos = 0;
|
|
158
246
|
#src: string;
|
|
247
|
+
#resolve: AliasResolver | undefined;
|
|
159
248
|
|
|
160
|
-
constructor(src: string) {
|
|
249
|
+
constructor(src: string, resolve?: AliasResolver) {
|
|
161
250
|
this.#src = src;
|
|
251
|
+
this.#resolve = resolve;
|
|
162
252
|
this.#toks = tokenize(src);
|
|
163
253
|
}
|
|
164
254
|
|
|
@@ -221,33 +311,47 @@ class StrParser {
|
|
|
221
311
|
parseBounded(): IR {
|
|
222
312
|
const t = this.#peek();
|
|
223
313
|
const t1 = this.#peek(1);
|
|
224
|
-
if (t?.t === "num" && t1?.t === "op" && CMP[t1.v]) {
|
|
225
|
-
// leading bound: `1 <= string <= 10`, `0 < number`
|
|
314
|
+
if ((t?.t === "num" || t?.t === "date") && t1?.t === "op" && CMP[t1.v]) {
|
|
226
315
|
const lo = t.v;
|
|
227
316
|
this.#pos += 2;
|
|
228
|
-
let node = this.parsePostfix();
|
|
317
|
+
let node = this.#eatDivisor(this.parsePostfix());
|
|
229
318
|
node = applyBound(node, flip(t1.v), lo, this.#src);
|
|
230
319
|
const t2 = this.#peek();
|
|
231
320
|
if (t2?.t === "op" && CMP[t2.v]) {
|
|
232
321
|
this.#pos++;
|
|
233
322
|
const hi = this.#next();
|
|
234
|
-
if (hi.t !== "num"
|
|
323
|
+
if (hi.t !== "num" && hi.t !== "date") {
|
|
324
|
+
throw new OmpTypeError(`expected bound after comparator in "${this.#src}"`);
|
|
325
|
+
}
|
|
235
326
|
node = applyBound(node, t2.v, hi.v, this.#src);
|
|
236
327
|
}
|
|
237
328
|
return this.#eatArraySuffixes(node);
|
|
238
329
|
}
|
|
239
|
-
let node = this.parsePostfix();
|
|
330
|
+
let node = this.#eatDivisor(this.parsePostfix());
|
|
240
331
|
const t2 = this.#peek();
|
|
241
332
|
if (t2?.t === "op" && CMP[t2.v]) {
|
|
242
333
|
this.#pos++;
|
|
243
|
-
const
|
|
244
|
-
if (
|
|
245
|
-
|
|
334
|
+
const limit = this.#next();
|
|
335
|
+
if (limit.t !== "num" && limit.t !== "date") {
|
|
336
|
+
throw new OmpTypeError(`expected bound after comparator in "${this.#src}"`);
|
|
337
|
+
}
|
|
338
|
+
node = applyBound(node, t2.v, limit.v, this.#src);
|
|
246
339
|
node = this.#eatArraySuffixes(node);
|
|
247
340
|
}
|
|
248
341
|
return node;
|
|
249
342
|
}
|
|
250
343
|
|
|
344
|
+
#eatDivisor(node: IR): IR {
|
|
345
|
+
if (!this.#eatOp("%")) return node;
|
|
346
|
+
const divisor = this.#next();
|
|
347
|
+
if (divisor.t !== "num") throw new OmpTypeError(`expected number after % in "${this.#src}"`);
|
|
348
|
+
if (node.k !== "number") throw new OmpTypeError(`% requires number in "${this.#src}"`);
|
|
349
|
+
if (!Number.isFinite(divisor.v) || divisor.v === 0)
|
|
350
|
+
throw new OmpTypeError(`divisor must be non-zero in "${this.#src}"`);
|
|
351
|
+
node.divisor = divisor.v;
|
|
352
|
+
return node;
|
|
353
|
+
}
|
|
354
|
+
|
|
251
355
|
/** Wrap `node` in array IR for each `[]` pair at the cursor. */
|
|
252
356
|
#eatArraySuffixes(node: IR): IR {
|
|
253
357
|
for (;;) {
|
|
@@ -278,17 +382,217 @@ class StrParser {
|
|
|
278
382
|
if (!this.#eatOp(")")) throw new OmpTypeError(`expected ')' in "${this.#src}"`);
|
|
279
383
|
return inner;
|
|
280
384
|
}
|
|
281
|
-
if (t.t === "str") return { k: "lit", v: t.v };
|
|
282
|
-
if (t.t === "num") return { k: "lit", v: t.v };
|
|
385
|
+
if (t.t === "str" || t.t === "num" || t.t === "date") return { k: "lit", v: t.v };
|
|
283
386
|
if (t.t === "id") {
|
|
284
387
|
const make = KEYWORDS[t.v];
|
|
285
|
-
|
|
286
|
-
|
|
388
|
+
const keyword = make?.() ?? keywordIR(t.v) ?? this.#resolve?.(t.v);
|
|
389
|
+
if (!keyword) throw new OmpTypeError(`unknown keyword "${t.v}" in "${this.#src}"`);
|
|
390
|
+
return keyword;
|
|
287
391
|
}
|
|
288
392
|
throw new OmpTypeError(`unexpected token in "${this.#src}"`);
|
|
289
393
|
}
|
|
290
394
|
}
|
|
291
395
|
|
|
396
|
+
const STRING_DEF_CACHE_MAX = 1_024;
|
|
397
|
+
const stringDefCache = new Map<string, ParsedTop>();
|
|
398
|
+
|
|
399
|
+
function isWhitespaceAt(src: string, index: number): boolean {
|
|
400
|
+
const code = src.charCodeAt(index);
|
|
401
|
+
return code === 32 || (code >= 9 && code <= 13) || (code > 127 && /\s/.test(src[index]));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Fast path for the literal unions pervasive in command schemas. */
|
|
405
|
+
function parseLiteralUnion(src: string): IR | undefined {
|
|
406
|
+
const members: IR[] = [];
|
|
407
|
+
let index = 0;
|
|
408
|
+
while (index < src.length && isWhitespaceAt(src, index)) index++;
|
|
409
|
+
for (;;) {
|
|
410
|
+
const quote = src[index];
|
|
411
|
+
if (quote !== "'" && quote !== '"') return undefined;
|
|
412
|
+
const end = src.indexOf(quote, index + 1);
|
|
413
|
+
if (end < 0) return undefined;
|
|
414
|
+
members.push({ k: "lit", v: src.slice(index + 1, end) });
|
|
415
|
+
index = end + 1;
|
|
416
|
+
while (index < src.length && isWhitespaceAt(src, index)) index++;
|
|
417
|
+
if (index === src.length) {
|
|
418
|
+
return members.length === 1 ? members[0] : { k: "union", members };
|
|
419
|
+
}
|
|
420
|
+
if (src[index] !== "|") return undefined;
|
|
421
|
+
index++;
|
|
422
|
+
while (index < src.length && isWhitespaceAt(src, index)) index++;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function genericArguments(src: string): { name: string; args: string[] } | undefined {
|
|
427
|
+
const open = src.indexOf("<");
|
|
428
|
+
if (open < 1 || !src.endsWith(">")) return undefined;
|
|
429
|
+
const name = src.slice(0, open).trim();
|
|
430
|
+
const body = src.slice(open + 1, -1);
|
|
431
|
+
const args: string[] = [];
|
|
432
|
+
let depth = 0;
|
|
433
|
+
let quote = "";
|
|
434
|
+
let start = 0;
|
|
435
|
+
for (let index = 0; index < body.length; index++) {
|
|
436
|
+
const char = body[index];
|
|
437
|
+
if (quote !== "") {
|
|
438
|
+
if (char === quote && body[index - 1] !== "\\") quote = "";
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (char === "'" || char === '"' || char === "`") {
|
|
442
|
+
quote = char;
|
|
443
|
+
} else if (char === "<" || char === "(" || char === "[") {
|
|
444
|
+
depth++;
|
|
445
|
+
} else if (char === ">" || char === ")" || char === "]") {
|
|
446
|
+
depth--;
|
|
447
|
+
} else if (char === "," && depth === 0) {
|
|
448
|
+
args.push(body.slice(start, index).trim());
|
|
449
|
+
start = index + 1;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
args.push(body.slice(start).trim());
|
|
453
|
+
return { name, args };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function genericKeys(ir: IR): Set<string> {
|
|
457
|
+
const keys = new Set<string>();
|
|
458
|
+
const visit = (node: IR): void => {
|
|
459
|
+
if (node.k === "lit" && typeof node.v === "string") keys.add(node.v);
|
|
460
|
+
else if (node.k === "union") for (const member of node.members) visit(member);
|
|
461
|
+
};
|
|
462
|
+
visit(ir);
|
|
463
|
+
return keys;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function resolveStructuralIR(ir: IR): IR {
|
|
467
|
+
const seen = new Set<IR>();
|
|
468
|
+
while (ir.k === "alias") {
|
|
469
|
+
if (seen.has(ir)) throw new OmpTypeError(`cannot structurally transform recursive alias "${ir.name}"`);
|
|
470
|
+
seen.add(ir);
|
|
471
|
+
ir = ir.resolve();
|
|
472
|
+
}
|
|
473
|
+
return ir;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function mergeObjectIR(left: IR, right: IR): IR {
|
|
477
|
+
left = resolveStructuralIR(left);
|
|
478
|
+
right = resolveStructuralIR(right);
|
|
479
|
+
if (left.k !== "object" || right.k !== "object") {
|
|
480
|
+
throw new OmpTypeError("Merge requires object arguments");
|
|
481
|
+
}
|
|
482
|
+
const props = [...left.props];
|
|
483
|
+
for (const prop of right.props) {
|
|
484
|
+
const index = props.findIndex(candidate => candidate.key === prop.key);
|
|
485
|
+
if (index < 0) props.push(prop);
|
|
486
|
+
else props[index] = prop;
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
k: "object",
|
|
490
|
+
props,
|
|
491
|
+
index: right.index ?? left.index,
|
|
492
|
+
extras: right.extras === "keep" ? left.extras : right.extras,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function parseGeneric(src: string, resolve?: AliasResolver): IR | undefined {
|
|
497
|
+
const generic = genericArguments(src);
|
|
498
|
+
if (generic === undefined) return undefined;
|
|
499
|
+
if (generic.name === "Record" && generic.args.length === 2) {
|
|
500
|
+
return { k: "object", props: [], index: parseDef(generic.args[1], resolve), extras: "keep" };
|
|
501
|
+
}
|
|
502
|
+
if ((generic.name === "Partial" || generic.name === "Required") && generic.args.length === 1) {
|
|
503
|
+
const object = resolveStructuralIR(parseDef(generic.args[0], resolve));
|
|
504
|
+
if (object.k !== "object") throw new OmpTypeError(`${generic.name} requires an object`);
|
|
505
|
+
const optional = generic.name === "Partial";
|
|
506
|
+
return { ...object, props: object.props.map(prop => ({ ...prop, opt: optional })) };
|
|
507
|
+
}
|
|
508
|
+
if ((generic.name === "Pick" || generic.name === "Omit") && generic.args.length === 2) {
|
|
509
|
+
const object = resolveStructuralIR(parseDef(generic.args[0], resolve));
|
|
510
|
+
if (object.k !== "object") throw new OmpTypeError(`${generic.name} requires an object`);
|
|
511
|
+
const keys = genericKeys(parseDef(generic.args[1], resolve));
|
|
512
|
+
const pick = generic.name === "Pick";
|
|
513
|
+
return { ...object, props: object.props.filter(prop => keys.has(prop.key) === pick) };
|
|
514
|
+
}
|
|
515
|
+
if (generic.name === "Merge" && generic.args.length === 2) {
|
|
516
|
+
return mergeObjectIR(parseDef(generic.args[0], resolve), parseDef(generic.args[1], resolve));
|
|
517
|
+
}
|
|
518
|
+
return undefined;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function dateLiteral(source: string): Date {
|
|
522
|
+
const value = new Date(source);
|
|
523
|
+
if (!Number.isFinite(value.valueOf())) throw new OmpTypeError(`invalid Date literal "${source}"`);
|
|
524
|
+
return value;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function parseDateExpression(src: string): IR | undefined {
|
|
528
|
+
const first = src.charCodeAt(0);
|
|
529
|
+
if (first !== 68 && first !== 100) return undefined;
|
|
530
|
+
const literal = src.match(/^d(['"])(.*)\1$/);
|
|
531
|
+
if (literal) return { k: "lit", v: dateLiteral(literal[2]) };
|
|
532
|
+
const forward = src.match(/^Date\s*(<=|<|>=|>)\s*d(['"])(.*)\2$/);
|
|
533
|
+
const reverse = src.match(/^d(['"])(.*)\1\s*(<=|<|>=|>)\s*Date$/);
|
|
534
|
+
if (!forward && !reverse) return undefined;
|
|
535
|
+
const bound = dateLiteral(forward ? forward[3] : (reverse?.[2] ?? ""));
|
|
536
|
+
const operator = forward
|
|
537
|
+
? forward[1]
|
|
538
|
+
: reverse?.[3] === "<="
|
|
539
|
+
? ">="
|
|
540
|
+
: reverse?.[3] === "<"
|
|
541
|
+
? ">"
|
|
542
|
+
: reverse?.[3] === ">="
|
|
543
|
+
? "<="
|
|
544
|
+
: "<";
|
|
545
|
+
const timestamp = bound.valueOf();
|
|
546
|
+
const relation =
|
|
547
|
+
operator === ">="
|
|
548
|
+
? "at or after"
|
|
549
|
+
: operator === ">"
|
|
550
|
+
? "later than"
|
|
551
|
+
: operator === "<="
|
|
552
|
+
? "at or before"
|
|
553
|
+
: "earlier than";
|
|
554
|
+
return {
|
|
555
|
+
k: "refine",
|
|
556
|
+
base: { k: "instance", ctor: Date, expected: "a Date" },
|
|
557
|
+
pred: value => {
|
|
558
|
+
if (!(value instanceof Date)) return false;
|
|
559
|
+
const actual = value.valueOf();
|
|
560
|
+
return operator === ">="
|
|
561
|
+
? actual >= timestamp
|
|
562
|
+
: operator === ">"
|
|
563
|
+
? actual > timestamp
|
|
564
|
+
: operator === "<="
|
|
565
|
+
? actual <= timestamp
|
|
566
|
+
: actual < timestamp;
|
|
567
|
+
},
|
|
568
|
+
expected: `a Date ${relation} ${bound.toISOString()}`,
|
|
569
|
+
json: operator === ">=" || operator === ">" ? { minimum: bound.toISOString() } : { maximum: bound.toISOString() },
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Parse recurring global DSL fragments once; scoped aliases bypass the cache. */
|
|
574
|
+
function parseStringDef(src: string, resolve?: AliasResolver): ParsedTop {
|
|
575
|
+
if (resolve === undefined) {
|
|
576
|
+
const cached = stringDefCache.get(src);
|
|
577
|
+
if (cached) return cached;
|
|
578
|
+
}
|
|
579
|
+
let ir = parseDateExpression(src) ?? parseLiteralUnion(src) ?? parseGeneric(src, resolve);
|
|
580
|
+
if (ir === undefined && src.startsWith("`") && src.endsWith("`")) {
|
|
581
|
+
ir = templateIR(src.slice(1, -1));
|
|
582
|
+
} else if (ir === undefined && src.startsWith("/") && src.lastIndexOf("/") > 0) {
|
|
583
|
+
const end = src.lastIndexOf("/");
|
|
584
|
+
try {
|
|
585
|
+
ir = patternIR(new RegExp(src.slice(1, end), src.slice(end + 1)));
|
|
586
|
+
} catch {
|
|
587
|
+
throw new OmpTypeError(`invalid regular expression "${src}"`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
const parsed: ParsedTop =
|
|
591
|
+
ir === undefined ? new StrParser(src, resolve).parseTop() : { ir, hasDefault: false, optional: false };
|
|
592
|
+
if (resolve === undefined && stringDefCache.size < STRING_DEF_CACHE_MAX) stringDefCache.set(src, parsed);
|
|
593
|
+
return parsed;
|
|
594
|
+
}
|
|
595
|
+
|
|
292
596
|
function flip(op: string): string {
|
|
293
597
|
switch (op) {
|
|
294
598
|
case "<":
|
|
@@ -302,8 +606,24 @@ function flip(op: string): string {
|
|
|
302
606
|
}
|
|
303
607
|
}
|
|
304
608
|
|
|
305
|
-
/** Apply `node CMP value` — numeric
|
|
306
|
-
function applyBound(node: IR, op: string, value: number, src: string): IR {
|
|
609
|
+
/** Apply `node CMP value` — numeric/string/array ranges or Date bounds. */
|
|
610
|
+
function applyBound(node: IR, op: string, value: number | Date, src: string): IR {
|
|
611
|
+
if (value instanceof Date) {
|
|
612
|
+
if (!acceptsDate(node)) throw new OmpTypeError(`date bound requires Date in "${src}"`);
|
|
613
|
+
const limit = value.valueOf();
|
|
614
|
+
const relation =
|
|
615
|
+
op === ">=" ? "on or after" : op === ">" ? "later than" : op === "<=" ? "on or before" : "earlier than";
|
|
616
|
+
return {
|
|
617
|
+
k: "refine",
|
|
618
|
+
base: node,
|
|
619
|
+
pred: input => {
|
|
620
|
+
if (!(input instanceof Date)) return false;
|
|
621
|
+
const time = input.valueOf();
|
|
622
|
+
return op === ">=" ? time >= limit : op === ">" ? time > limit : op === "<=" ? time <= limit : time < limit;
|
|
623
|
+
},
|
|
624
|
+
expected: `a Date ${relation} ${value.toISOString()}`,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
307
627
|
if (node.k === "number") {
|
|
308
628
|
switch (op) {
|
|
309
629
|
case ">=":
|
|
@@ -345,10 +665,14 @@ function applyBound(node: IR, op: string, value: number, src: string): IR {
|
|
|
345
665
|
throw new OmpTypeError(`cannot bound ${node.k} in "${src}"`);
|
|
346
666
|
}
|
|
347
667
|
|
|
668
|
+
function acceptsDate(node: IR): boolean {
|
|
669
|
+
return (node.k === "instance" && node.ctor === Date) || (node.k === "refine" && acceptsDate(node.base));
|
|
670
|
+
}
|
|
671
|
+
|
|
348
672
|
// ── definition parser ────────────────────────────────────────────────────────
|
|
349
673
|
|
|
350
674
|
function isEmbedded(def: unknown): def is EmbeddableSchema {
|
|
351
|
-
return (typeof def === "function" || (typeof def === "object" && def !== null)) && IR_BRAND in
|
|
675
|
+
return (typeof def === "function" || (typeof def === "object" && def !== null)) && IR_BRAND in def;
|
|
352
676
|
}
|
|
353
677
|
|
|
354
678
|
/** Embed a schema value: inline pure structure, keep `sub` nodes for stepped schemas. */
|
|
@@ -360,46 +684,192 @@ export function embed(schema: EmbeddableSchema): IR {
|
|
|
360
684
|
return schema.ir;
|
|
361
685
|
}
|
|
362
686
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
687
|
+
function isCallback(value: unknown): value is (input: unknown, context: MorphContext) => unknown {
|
|
688
|
+
return typeof value === "function";
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function isConstructor(value: unknown): value is Constructor {
|
|
692
|
+
return typeof value === "function" && value.prototype !== undefined;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function parseTupleItem(def: unknown, resolve?: AliasResolver): TupleItemIR {
|
|
696
|
+
if (Array.isArray(def) && def.length === 2 && def[1] === "?") {
|
|
697
|
+
return { val: parseDef(def[0], resolve), opt: true };
|
|
369
698
|
}
|
|
370
|
-
if (
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
699
|
+
if (Array.isArray(def) && def.length === 3 && def[1] === "=") {
|
|
700
|
+
return {
|
|
701
|
+
val: parseDef(def[0], resolve),
|
|
702
|
+
opt: true,
|
|
703
|
+
def: def[2],
|
|
704
|
+
defFactory: typeof def[2] === "function",
|
|
705
|
+
hasDefault: true,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
return { val: parseDef(def, resolve), opt: false };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function parseTuple(def: readonly unknown[], resolve?: AliasResolver): TupleIR {
|
|
712
|
+
const prefix: TupleItemIR[] = [];
|
|
713
|
+
const postfix: IR[] = [];
|
|
714
|
+
let variadic: IR | undefined;
|
|
715
|
+
let optionalSeen = false;
|
|
716
|
+
for (let index = 0; index < def.length; index++) {
|
|
717
|
+
if (def[index] === "...") {
|
|
718
|
+
if (variadic !== undefined || index + 1 >= def.length) {
|
|
719
|
+
throw new OmpTypeError("a tuple may have one spread followed by an array definition");
|
|
387
720
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
721
|
+
const spread = parseDef(def[++index], resolve);
|
|
722
|
+
if (spread.k !== "array") throw new OmpTypeError("tuple spread element must be an array");
|
|
723
|
+
variadic = spread.el;
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if (variadic !== undefined) {
|
|
727
|
+
const item = parseTupleItem(def[index], resolve);
|
|
728
|
+
if (item.opt || item.hasDefault) {
|
|
729
|
+
throw new OmpTypeError("optional tuple elements cannot follow a variadic element");
|
|
391
730
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
731
|
+
postfix.push(item.val);
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
const item = parseTupleItem(def[index], resolve);
|
|
735
|
+
if (optionalSeen && !item.opt && !item.hasDefault) {
|
|
736
|
+
throw new OmpTypeError("required tuple elements cannot follow optional elements");
|
|
737
|
+
}
|
|
738
|
+
optionalSeen ||= item.opt || item.hasDefault === true;
|
|
739
|
+
prefix.push(item);
|
|
740
|
+
}
|
|
741
|
+
return { k: "tuple", prefix, variadic, postfix };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Build the runtime schema for an object's or tuple's keys. */
|
|
745
|
+
export function keyOf(node: IR): IR {
|
|
746
|
+
if (node.k === "object") {
|
|
747
|
+
const members: IR[] = node.props.map(prop => ({ k: "lit", v: prop.key }));
|
|
748
|
+
if (node.index !== undefined) members.push({ k: "string" });
|
|
749
|
+
if (members.length === 0) return { k: "never" };
|
|
750
|
+
return members.length === 1 ? members[0] : { k: "union", members };
|
|
751
|
+
}
|
|
752
|
+
if (node.k === "tuple") return { k: "number", int: true, min: 0 };
|
|
753
|
+
throw new OmpTypeError(`keyof requires an object or tuple (was ${node.k})`);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function parseArrayExpression(def: readonly unknown[], resolve?: AliasResolver): IR {
|
|
757
|
+
if (def.length === 2 && def[1] === "[]") return { k: "array", el: parseDef(def[0], resolve) };
|
|
758
|
+
if (def.length === 2 && def[0] === "keyof") return keyOf(parseDef(def[1], resolve));
|
|
759
|
+
if (def[0] === "instanceof") {
|
|
760
|
+
const members: IR[] = [];
|
|
761
|
+
for (let index = 1; index < def.length; index++) {
|
|
762
|
+
const ctor = def[index];
|
|
763
|
+
if (!isConstructor(ctor)) throw new OmpTypeError("instanceof operands must be constructors");
|
|
764
|
+
members.push({ k: "instance", ctor, expected: `an instance of ${ctor.name || "the constructor"}` });
|
|
765
|
+
}
|
|
766
|
+
return members.length === 1 ? members[0] : { k: "union", members };
|
|
767
|
+
}
|
|
768
|
+
if (def[0] === "===") {
|
|
769
|
+
const members = def.slice(1).map(value => ({ k: "lit", v: value }) satisfies IR);
|
|
770
|
+
if (members.length === 0) return { k: "never" };
|
|
771
|
+
return members.length === 1 ? members[0] : { k: "union", members };
|
|
772
|
+
}
|
|
773
|
+
if (def.length >= 3 && def[1] === "|") {
|
|
774
|
+
return { k: "union", members: [parseDef(def[0], resolve), parseDef(def[2], resolve)] };
|
|
775
|
+
}
|
|
776
|
+
if (def.length >= 3 && def[1] === "&") {
|
|
777
|
+
return { k: "intersection", members: [parseDef(def[0], resolve), parseDef(def[2], resolve)] };
|
|
778
|
+
}
|
|
779
|
+
if (def.length === 3 && def[1] === "=>") {
|
|
780
|
+
if (!isCallback(def[2])) throw new OmpTypeError("morph operator requires a function");
|
|
781
|
+
return { k: "morph", input: parseDef(def[0], resolve), fn: def[2] };
|
|
782
|
+
}
|
|
783
|
+
if (def.length === 3 && def[1] === "|>") {
|
|
784
|
+
return { k: "morph", input: parseDef(def[0], resolve), fn: value => value, out: parseDef(def[2], resolve) };
|
|
785
|
+
}
|
|
786
|
+
if (def.length === 3 && def[1] === ":") {
|
|
787
|
+
if (!isCallback(def[2])) throw new OmpTypeError("narrow operator requires a predicate");
|
|
788
|
+
const predicate = def[2];
|
|
789
|
+
return {
|
|
790
|
+
k: "refine",
|
|
791
|
+
base: parseDef(def[0], resolve),
|
|
792
|
+
pred: value =>
|
|
793
|
+
predicate(value, {
|
|
794
|
+
error: () => OmpErrors.single([], "the predicate", value),
|
|
795
|
+
reject: () => OmpErrors.single([], "the predicate", value),
|
|
796
|
+
}) === true,
|
|
797
|
+
expected: "a value satisfying the predicate",
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
if (def.length >= 3 && def[1] === "@") {
|
|
801
|
+
const base = parseDef(def[0], resolve);
|
|
802
|
+
const meta = def[2];
|
|
803
|
+
if (typeof meta === "string") return { ...base, desc: meta };
|
|
804
|
+
if (typeof meta === "object" && meta !== null && "description" in meta && typeof meta.description === "string") {
|
|
805
|
+
return { ...base, desc: meta.description };
|
|
806
|
+
}
|
|
807
|
+
return base;
|
|
808
|
+
}
|
|
809
|
+
return parseTuple(def, resolve);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function isObjectDefinition(def: unknown): def is Record<string, unknown> {
|
|
813
|
+
return (
|
|
814
|
+
typeof def === "object" &&
|
|
815
|
+
def !== null &&
|
|
816
|
+
!Array.isArray(def) &&
|
|
817
|
+
!(def instanceof RegExp) &&
|
|
818
|
+
!(def instanceof Date)
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function parseObjectDefinition(def: Record<string, unknown>, resolve?: AliasResolver): IR {
|
|
823
|
+
const props: PropIR[] = [];
|
|
824
|
+
let index: IR | undefined;
|
|
825
|
+
let extras: Extras = "keep";
|
|
826
|
+
for (const rawKey in def) {
|
|
827
|
+
const val = def[rawKey];
|
|
828
|
+
if (rawKey === "+") {
|
|
829
|
+
if (val === "reject" || val === "delete") extras = val;
|
|
830
|
+
else if (val === "ignore") extras = "keep";
|
|
831
|
+
else throw new OmpTypeError(`bad "+" value ${String(val)}`);
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
if (rawKey === "...") {
|
|
835
|
+
const spread = parseDef(val, resolve);
|
|
836
|
+
if (spread.k !== "object") throw new OmpTypeError("object spread must resolve to an object");
|
|
837
|
+
for (const prop of spread.props) {
|
|
838
|
+
const previous = props.findIndex(candidate => candidate.key === prop.key);
|
|
839
|
+
if (previous < 0) props.push(prop);
|
|
840
|
+
else props[previous] = prop;
|
|
841
|
+
}
|
|
842
|
+
index ??= spread.index;
|
|
843
|
+
if (spread.extras !== "keep") extras = spread.extras;
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
if (rawKey === "[string]") {
|
|
847
|
+
index = parseDef(val, resolve);
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
const opt = rawKey.charCodeAt(rawKey.length - 1) === 63;
|
|
851
|
+
const key = opt ? rawKey.slice(0, -1) : rawKey;
|
|
852
|
+
if (typeof val === "string") {
|
|
853
|
+
const parsed = parseStringDef(val, resolve);
|
|
854
|
+
const prop: PropIR = { key, opt: opt || parsed.optional, val: parsed.ir };
|
|
855
|
+
if (parsed.hasDefault) {
|
|
856
|
+
prop.def = parsed.def;
|
|
857
|
+
prop.hasDefault = true;
|
|
858
|
+
}
|
|
859
|
+
props.push(prop);
|
|
860
|
+
} else if (Array.isArray(val) && val.length === 2 && val[1] === "?") {
|
|
861
|
+
props.push({ key, opt: true, val: parseDef(val[0], resolve) });
|
|
862
|
+
} else if (Array.isArray(val) && val.length === 3 && val[1] === "=") {
|
|
863
|
+
props.push({
|
|
864
|
+
key,
|
|
865
|
+
opt,
|
|
866
|
+
val: parseDef(val[0], resolve),
|
|
867
|
+
def: val[2],
|
|
868
|
+
defFactory: typeof val[2] === "function",
|
|
869
|
+
hasDefault: true,
|
|
870
|
+
});
|
|
871
|
+
} else if (isEmbedded(val)) {
|
|
872
|
+
if (val.hasDefault) {
|
|
403
873
|
props.push({
|
|
404
874
|
key,
|
|
405
875
|
opt,
|
|
@@ -409,32 +879,77 @@ export function parseDef(def: Def): IR {
|
|
|
409
879
|
hasDefault: true,
|
|
410
880
|
});
|
|
411
881
|
} else {
|
|
412
|
-
props.push({ key, opt, val:
|
|
882
|
+
props.push({ key, opt, val: embed(val) });
|
|
413
883
|
}
|
|
884
|
+
} else if (isObjectDefinition(val)) {
|
|
885
|
+
props.push({ key, opt, val: parseObjectDefinition(val, resolve) });
|
|
886
|
+
} else {
|
|
887
|
+
props.push({ key, opt, val: parseDef(val, resolve) });
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return { k: "object", props, index, extras };
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** Parse a definition, optionally resolving names from an enclosing scope. */
|
|
894
|
+
export function parseDef(def: unknown, resolve?: AliasResolver): IR {
|
|
895
|
+
if (typeof def === "string") {
|
|
896
|
+
const parsed = parseStringDef(def, resolve);
|
|
897
|
+
if (parsed.optional) {
|
|
898
|
+
throw new OmpTypeError(`optional "?" marker is only valid on object property values`);
|
|
414
899
|
}
|
|
415
|
-
return
|
|
900
|
+
return parsed.ir;
|
|
416
901
|
}
|
|
902
|
+
if (Array.isArray(def)) return parseArrayExpression(def, resolve);
|
|
903
|
+
if (def instanceof RegExp) return patternIR(def);
|
|
904
|
+
if (def instanceof Date) return { k: "lit", v: def };
|
|
905
|
+
if (isEmbedded(def)) return embed(def);
|
|
906
|
+
if (isObjectDefinition(def)) return parseObjectDefinition(def, resolve);
|
|
417
907
|
throw new OmpTypeError(`unsupported definition ${String(def)}`);
|
|
418
908
|
}
|
|
419
909
|
|
|
420
910
|
/** True when validating `ir` can produce an output different from its input. */
|
|
421
911
|
export function hasMorph(ir: IR): boolean {
|
|
912
|
+
const cached = ir[kMorph];
|
|
913
|
+
if (cached !== undefined) return cached;
|
|
914
|
+
|
|
915
|
+
let result = false;
|
|
422
916
|
switch (ir.k) {
|
|
423
917
|
case "sub":
|
|
424
|
-
|
|
918
|
+
result = true;
|
|
919
|
+
break;
|
|
920
|
+
case "morph":
|
|
921
|
+
case "alias":
|
|
922
|
+
result = true;
|
|
923
|
+
break;
|
|
425
924
|
case "object":
|
|
426
|
-
|
|
427
|
-
for (
|
|
428
|
-
|
|
925
|
+
result = ir.extras === "delete";
|
|
926
|
+
for (let i = 0; !result && i < ir.props.length; i++) {
|
|
927
|
+
const prop = ir.props[i];
|
|
928
|
+
result = prop.hasDefault === true || hasMorph(prop.val);
|
|
429
929
|
}
|
|
430
|
-
|
|
930
|
+
if (!result && ir.index !== undefined) result = hasMorph(ir.index);
|
|
931
|
+
break;
|
|
431
932
|
case "array":
|
|
432
|
-
|
|
933
|
+
result = hasMorph(ir.el);
|
|
934
|
+
break;
|
|
433
935
|
case "union":
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
936
|
+
result = ir.members.some(hasMorph);
|
|
937
|
+
break;
|
|
938
|
+
case "intersection":
|
|
939
|
+
result = ir.members.some(hasMorph);
|
|
940
|
+
break;
|
|
941
|
+
case "refine":
|
|
942
|
+
result = hasMorph(ir.base);
|
|
943
|
+
break;
|
|
944
|
+
case "tuple":
|
|
945
|
+
result =
|
|
946
|
+
ir.prefix.some(item => item.hasDefault === true || hasMorph(item.val)) ||
|
|
947
|
+
(ir.variadic !== undefined && hasMorph(ir.variadic)) ||
|
|
948
|
+
ir.postfix.some(hasMorph);
|
|
949
|
+
break;
|
|
437
950
|
}
|
|
951
|
+
ir[kMorph] = result;
|
|
952
|
+
return result;
|
|
438
953
|
}
|
|
439
954
|
|
|
440
955
|
/** Human-readable expectation for error messages, e.g. `"a string"`. */
|
|
@@ -450,6 +965,10 @@ export function expectedOf(ir: IR): string {
|
|
|
450
965
|
return "boolean";
|
|
451
966
|
case "bigint":
|
|
452
967
|
return "a bigint";
|
|
968
|
+
case "symbol":
|
|
969
|
+
return "a symbol";
|
|
970
|
+
case "never":
|
|
971
|
+
return "never";
|
|
453
972
|
case "anyobject":
|
|
454
973
|
return "an object";
|
|
455
974
|
case "string": {
|
|
@@ -464,16 +983,44 @@ export function expectedOf(ir: IR): string {
|
|
|
464
983
|
if (ir.min !== undefined) out += ` ${ir.xmin ? "more than" : "at least"} ${ir.min}`;
|
|
465
984
|
if (ir.max !== undefined)
|
|
466
985
|
out += `${ir.min !== undefined ? " and" : ""} ${ir.xmax ? "less than" : "at most"} ${ir.max}`;
|
|
986
|
+
if (ir.divisor !== undefined) out += ` divisible by ${ir.divisor}`;
|
|
467
987
|
return out;
|
|
468
988
|
}
|
|
469
989
|
case "lit":
|
|
470
|
-
return
|
|
471
|
-
|
|
472
|
-
|
|
990
|
+
return ir.v instanceof Date
|
|
991
|
+
? `the date ${ir.v.toISOString()}`
|
|
992
|
+
: typeof ir.v === "string"
|
|
993
|
+
? JSON.stringify(ir.v)
|
|
994
|
+
: String(ir.v);
|
|
995
|
+
case "union": {
|
|
996
|
+
if (ir.members.length === 0) return "";
|
|
997
|
+
const first = expectedOf(ir.members[0]);
|
|
998
|
+
if (ir.members.length === 1) return first;
|
|
999
|
+
const second = expectedOf(ir.members[1]);
|
|
1000
|
+
if (ir.members.length === 2) return first === second ? first : `${first} or ${second}`;
|
|
1001
|
+
const expectations = first === second ? [first] : [first, second];
|
|
1002
|
+
for (let i = 2; i < ir.members.length; i++) {
|
|
1003
|
+
const expected = expectedOf(ir.members[i]);
|
|
1004
|
+
if (!expectations.includes(expected)) expectations.push(expected);
|
|
1005
|
+
}
|
|
1006
|
+
return expectations.join(" or ");
|
|
1007
|
+
}
|
|
1008
|
+
case "intersection":
|
|
1009
|
+
return ir.members.map(expectedOf).join(" and ");
|
|
473
1010
|
case "array":
|
|
474
1011
|
return "an array";
|
|
1012
|
+
case "tuple":
|
|
1013
|
+
return "a tuple";
|
|
475
1014
|
case "object":
|
|
476
1015
|
return "an object";
|
|
1016
|
+
case "instance":
|
|
1017
|
+
return ir.expected;
|
|
1018
|
+
case "refine":
|
|
1019
|
+
return ir.expected;
|
|
1020
|
+
case "morph":
|
|
1021
|
+
return expectedOf(ir.input);
|
|
1022
|
+
case "alias":
|
|
1023
|
+
return ir.name;
|
|
477
1024
|
case "sub":
|
|
478
1025
|
return expectedOf(ir.schema.ir);
|
|
479
1026
|
}
|