@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/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
- export type IR =
41
- | { k: "unknown"; desc?: string }
42
- | { k: "null"; desc?: string }
43
- | { k: "undefined"; desc?: string }
44
- | { k: "boolean"; desc?: string }
45
- | { k: "bigint"; desc?: string }
46
- /** Any non-null object (the bare `object` keyword). */
47
- | { k: "anyobject"; desc?: string }
48
- | { k: "string"; min?: number; max?: number; url?: boolean; desc?: string }
49
- | { k: "number"; min?: number; max?: number; xmin?: boolean; xmax?: boolean; int?: boolean; desc?: string }
50
- | { k: "lit"; v: unknown; desc?: string }
51
- | { k: "union"; members: IR[]; desc?: string }
52
- | { k: "array"; el: IR; min?: number; max?: number; desc?: string }
53
- | { k: "object"; props: PropIR[]; index?: IR; extras: Extras; desc?: string }
54
- /** Embedded schema with runtime steps; validated by calling `run`. */
55
- | { k: "sub"; schema: EmbeddableSchema; desc?: string };
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 Def[] | { readonly [k: string]: Def };
136
+ export type Def = string | RegExp | Date | EmbeddableSchema | readonly unknown[] | { readonly [k: string]: unknown };
69
137
 
70
138
  // ── tokenizer ────────────────────────────────────────────────────────────────
71
139
 
72
- type Tok = { t: "id"; v: string } | { t: "num"; v: number } | { t: "str"; v: string } | { t: "op"; v: string };
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") throw new OmpTypeError(`expected number after comparator in "${this.#src}"`);
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 num = this.#next();
244
- if (num.t !== "num") throw new OmpTypeError(`expected number after comparator in "${this.#src}"`);
245
- node = applyBound(node, t2.v, num.v, this.#src);
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
- if (!make) throw new OmpTypeError(`unknown keyword "${t.v}" in "${this.#src}"`);
286
- return make();
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 range, string length, or array length. */
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 (def as object);
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
- /** Parse a definition (string DSL, object literal, tuple, or embedded schema) into IR. */
364
- export function parseDef(def: Def): IR {
365
- if (typeof def === "string") {
366
- const parsed = new StrParser(def).parseTop();
367
- if (parsed.optional) throw new OmpTypeError(`optional "?" marker is only valid on object property values`);
368
- return parsed.ir;
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 (isEmbedded(def)) return embed(def);
371
- if (Array.isArray(def)) {
372
- if (def.length === 2 && def[1] === "[]") return { k: "array", el: parseDef(def[0] as Def) };
373
- throw new OmpTypeError(`unsupported tuple definition (only [def, "[]"])`);
374
- }
375
- if (typeof def === "object" && def !== null) {
376
- const props: PropIR[] = [];
377
- let index: IR | undefined;
378
- let extras: Extras = "keep";
379
- const rec = def as { readonly [k: string]: Def };
380
- for (const rawKey in rec) {
381
- const val = rec[rawKey];
382
- if (rawKey === "+") {
383
- if (val === "reject" || val === "delete") extras = val;
384
- else if (val === "ignore") extras = "keep";
385
- else throw new OmpTypeError(`bad "+" value ${String(val)}`);
386
- continue;
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
- if (rawKey === "[string]") {
389
- index = parseDef(val);
390
- continue;
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
- const opt = rawKey.endsWith("?");
393
- const key = opt ? rawKey.slice(0, -1) : rawKey;
394
- if (typeof val === "string") {
395
- const parsed = new StrParser(val).parseTop();
396
- const prop: PropIR = { key, opt: opt || parsed.optional, val: parsed.ir };
397
- if (parsed.hasDefault) {
398
- prop.def = parsed.def;
399
- prop.hasDefault = true;
400
- }
401
- props.push(prop);
402
- } else if (isEmbedded(val) && val.hasDefault) {
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: parseDef(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 { k: "object", props, index, extras };
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
- return true;
918
+ result = true;
919
+ break;
920
+ case "morph":
921
+ case "alias":
922
+ result = true;
923
+ break;
425
924
  case "object":
426
- if (ir.extras === "delete") return true;
427
- for (const p of ir.props) {
428
- if (p.hasDefault || hasMorph(p.val)) return true;
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
- return ir.index !== undefined && hasMorph(ir.index);
930
+ if (!result && ir.index !== undefined) result = hasMorph(ir.index);
931
+ break;
431
932
  case "array":
432
- return hasMorph(ir.el);
933
+ result = hasMorph(ir.el);
934
+ break;
433
935
  case "union":
434
- return ir.members.some(hasMorph);
435
- default:
436
- return false;
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 typeof ir.v === "string" ? JSON.stringify(ir.v) : String(ir.v);
471
- case "union":
472
- return [...new Set(ir.members.map(expectedOf))].join(" or ");
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
  }