@oh-my-pi/omptype 17.2.7 → 17.2.8

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/errors.ts CHANGED
@@ -12,18 +12,23 @@
12
12
  export interface ErrorContext {
13
13
  readonly code: string;
14
14
  readonly path: readonly PropertyKey[];
15
+ readonly propString: string;
15
16
  readonly data: unknown;
16
17
  readonly expected: string;
17
18
  readonly actual: string;
18
19
  readonly problem: string;
20
+ readonly description: string;
21
+ readonly rule?: unknown;
19
22
  }
20
23
 
21
24
  /** Per-schema overrides for validation error text. */
22
25
  export interface ErrorConfig {
23
26
  readonly expected?: string | ((context: ErrorContext) => string);
24
- readonly actual?: string | ((context: ErrorContext) => string);
27
+ readonly actual?: string | ((data: unknown) => string);
25
28
  readonly problem?: string | ((context: ErrorContext) => string);
26
29
  readonly message?: string | ((context: ErrorContext) => string);
30
+ /** Internal: custom predicate expectations display the offending value rather than its domain. */
31
+ readonly preserveActual?: boolean;
27
32
  }
28
33
 
29
34
  function format(
@@ -50,6 +55,17 @@ export class OmpError {
50
55
  this.#rawExpected = expected;
51
56
  this.#config = config;
52
57
  }
58
+ /** Prefix this failure when a nested schema delegates validation. */
59
+ prefix(key: PropertyKey): this {
60
+ this.path.unshift(key);
61
+ return this;
62
+ }
63
+
64
+ /** Apply schema-local formatting to this failure. */
65
+ configure(config: ErrorConfig): this {
66
+ this.#config = { ...this.#config, ...config };
67
+ return this;
68
+ }
53
69
 
54
70
  /** Stable category for programmatic error handling. */
55
71
  get code(): string {
@@ -57,19 +73,34 @@ export class OmpError {
57
73
  }
58
74
 
59
75
  #context(expected: string, actual: string, problem = ""): ErrorContext {
60
- return { code: this.code, path: this.path, data: this.data, expected, actual, problem };
76
+ const { description, rule } = describeExpectation(this.#rawExpected);
77
+ return {
78
+ code: this.code,
79
+ path: this.path,
80
+ propString: formatPath(this.path),
81
+ data: this.data,
82
+ expected,
83
+ actual,
84
+ problem,
85
+ description,
86
+ ...(rule === undefined ? {} : { rule }),
87
+ };
61
88
  }
62
89
 
63
90
  /** Human-readable expectation, including a configured override. */
64
91
  get expected(): string {
65
- const actual = describeValue(this.data);
66
- return format(this.#config?.expected, this.#context(this.#rawExpected, actual), this.#rawExpected);
92
+ const actual = describeValue(this.data, this.#config?.preserveActual ? "predicate" : this.code);
93
+ const parts = this.#rawExpected.split(" or ");
94
+ const fallback =
95
+ parts.length < 3 ? this.#rawExpected : `${parts.slice(0, -1).join(", ")} or ${parts[parts.length - 1]}`;
96
+ return format(this.#config?.expected, this.#context(fallback, actual), fallback);
67
97
  }
68
98
 
69
99
  /** Short description of the received value, e.g. `"a number"` or `"missing"`. */
70
100
  get actual(): string {
71
- const actual = describeValue(this.data);
72
- return format(this.#config?.actual, this.#context(this.expected, actual), actual);
101
+ const actual = describeValue(this.data, this.#config?.preserveActual ? "predicate" : this.code);
102
+ const override = this.#config?.actual;
103
+ return typeof override === "function" ? override(this.data) : (override ?? actual);
73
104
  }
74
105
 
75
106
  /** Path-less problem statement: `must be <expected> (was <actual>)`. */
@@ -77,17 +108,20 @@ export class OmpError {
77
108
  const expected = this.expected;
78
109
  const actual = this.actual;
79
110
  const fallback =
80
- this.data === MISSING ? `must be ${expected} (was missing)` : `must be ${expected} (was ${actual})`;
111
+ this.data === MISSING
112
+ ? `must be ${expected} (was missing)`
113
+ : actual === ""
114
+ ? `must be ${expected}`
115
+ : `must be ${expected} (was ${actual})`;
81
116
  return format(this.#config?.problem, this.#context(expected, actual, fallback), fallback);
82
117
  }
83
-
84
- /** Full message including the path prefix. */
85
118
  get message(): string {
86
119
  const expected = this.expected;
87
120
  const actual = this.actual;
88
121
  const problem = this.problem;
89
- const at = this.path.length === 0 ? "" : `${this.path.map(String).join(".")} `;
90
- return format(this.#config?.message, this.#context(expected, actual, problem), `${at}${problem}`);
122
+ const at = this.path.length === 0 ? "" : `${formatPath(this.path)} `;
123
+ const messageActual = this.#config?.actual === undefined ? describeKind(this.data) : actual;
124
+ return format(this.#config?.message, this.#context(expected, messageActual, problem), `${at}${problem}`);
91
125
  }
92
126
 
93
127
  toString(): string {
@@ -97,8 +131,23 @@ export class OmpError {
97
131
 
98
132
  /** Sentinel for a required key that was absent (distinguishes from `undefined`). */
99
133
  export const MISSING: unique symbol = Symbol("omptype.missing");
134
+ function stringifyValue(data: object): string {
135
+ const seen = new WeakSet<object>();
136
+ return (
137
+ JSON.stringify(data, (_key, value: unknown) => {
138
+ if (typeof value !== "object" || value === null) return value;
139
+ if (seen.has(value)) return "(cycle)";
140
+ seen.add(value);
141
+ return value;
142
+ }) ?? "an object"
143
+ );
144
+ }
100
145
 
101
- function describeValue(data: unknown): string {
146
+ function describeValue(data: unknown, code: string): string {
147
+ if (data === MISSING) return "missing";
148
+ if (code === "undeclared") return "";
149
+ if (code === "referenceSame") return "";
150
+ if (code === "domain") return describeKind(data);
102
151
  if (data === null) return "null";
103
152
  if (Array.isArray(data)) return "an array";
104
153
  switch (typeof data) {
@@ -113,7 +162,34 @@ function describeValue(data: unknown): string {
113
162
  case "undefined":
114
163
  return "undefined";
115
164
  case "object":
116
- return "an object";
165
+ return stringifyValue(data);
166
+ case "function":
167
+ return "a function";
168
+ default:
169
+ return "a symbol";
170
+ }
171
+ }
172
+
173
+ function describeKind(data: unknown): string {
174
+ if (data === MISSING) return "missing";
175
+ if (typeof data === "number" && Number.isNaN(data)) return "NaN";
176
+ if (data === null) return "null";
177
+ if (Array.isArray(data)) return "an object";
178
+ switch (typeof data) {
179
+ case "string":
180
+ return "a string";
181
+ case "number":
182
+ return "a number";
183
+ case "bigint":
184
+ return "a bigint";
185
+ case "boolean":
186
+ return "boolean";
187
+ case "undefined":
188
+ return "undefined";
189
+ case "object": {
190
+ const ctor = Object.getPrototypeOf(data)?.constructor;
191
+ return typeof ctor?.name === "string" && ctor.name !== "Object" ? ctor.name : "an object";
192
+ }
117
193
  case "function":
118
194
  return "a function";
119
195
  default:
@@ -121,23 +197,101 @@ function describeValue(data: unknown): string {
121
197
  }
122
198
  }
123
199
 
200
+ function describeExpectation(expected: string): { description: string; rule?: unknown } {
201
+ const divisor = /divisible by (\d+(?:\.\d+)?)/.exec(expected);
202
+ if (divisor) {
203
+ const rule = Number(divisor[1]);
204
+ return { description: rule === 2 ? "even" : `divisible by ${rule}`, rule };
205
+ }
206
+ return { description: expected.replace(/^(?:an?|the) /, "") };
207
+ }
208
+
209
+ function formatPath(path: readonly PropertyKey[]): string {
210
+ let out = "";
211
+ for (const key of path) {
212
+ if (typeof key === "number") out += `[${key}]`;
213
+ else if (typeof key === "symbol") out += `[${String(key)}]`;
214
+ else out += out.length === 0 ? key : `.${key}`;
215
+ }
216
+ return out;
217
+ }
218
+
124
219
  function errorCode(expected: string, data: unknown): string {
125
220
  if (data === MISSING) return "required";
126
- if (expected.includes("divisible by")) return "divisor";
127
- if (expected.includes("at least") || expected.includes("more than")) return "min";
128
- if (expected.includes("at most") || expected.includes("less than")) return "max";
129
- if (expected.includes("matching") || expected.includes("format") || expected.includes("email")) return "pattern";
130
- if (expected.includes("predicate") || expected.includes("satisfying")) return "predicate";
221
+ if (expected === "removed") return "undeclared";
222
+ if (expected.includes("serialized to the same value")) return "referenceSame";
223
+ if (expected.includes("divisible by") || expected.includes("integer")) return "divisor";
224
+ if (
225
+ expected === "true" ||
226
+ expected === "false" ||
227
+ expected === "null" ||
228
+ expected === "undefined" ||
229
+ expected === "NaN" ||
230
+ expected === "Infinity" ||
231
+ expected === "-Infinity" ||
232
+ /^-?\d+(?:\.\d+)?n?$/.test(expected) ||
233
+ (expected.includes(" or ") &&
234
+ expected.split(" or ").every(part => /^".*"$|^-?\d+(?:\.\d+)?n?$|^(?:true|false|null|undefined)$/.test(part)))
235
+ ) {
236
+ return "unit";
237
+ }
238
+ if (
239
+ expected.includes("at least") ||
240
+ expected.includes("more than") ||
241
+ expected.includes("timestamp after") ||
242
+ expected === "non-negative" ||
243
+ expected === "positive"
244
+ ) {
245
+ return "min";
246
+ }
247
+ if (
248
+ expected.includes("at most") ||
249
+ expected.includes("less than") ||
250
+ expected.includes("timestamp before") ||
251
+ expected === "non-positive" ||
252
+ expected === "negative"
253
+ ) {
254
+ return "max";
255
+ }
256
+ if (
257
+ expected.includes("matching") ||
258
+ expected.includes("format") ||
259
+ expected.includes("email") ||
260
+ expected.includes("parsable") ||
261
+ expected.includes("only digits")
262
+ ) {
263
+ return "pattern";
264
+ }
265
+ if (expected.includes("predicate") || expected.includes("satisfying") || expected.includes("according to"))
266
+ return "predicate";
131
267
  if (expected.startsWith('"') || expected.startsWith("the date ")) return "unit";
132
- return "domain";
268
+ if (expected === "an Error" || expected === "a Date" || expected.startsWith("an instance of ")) return "domain";
269
+ if (
270
+ (expected.includes(" or ") && !expected.includes("IPv")) ||
271
+ expected.endsWith(" instance") ||
272
+ expected.startsWith("a number representing") ||
273
+ [
274
+ "a string",
275
+ "a number",
276
+ "a bigint",
277
+ "a symbol",
278
+ "boolean",
279
+ "an object",
280
+ "an array",
281
+ "a tuple",
282
+ "undefined",
283
+ "null",
284
+ "unknown",
285
+ "never",
286
+ ].includes(expected)
287
+ )
288
+ return "domain";
289
+ return "predicate";
133
290
  }
134
291
 
135
292
  /**
136
- * Single-failure validation result with a lazy array-like entry.
137
- *
138
- * Validators fast-fail, so allocating an `Array` subclass and a separate entry
139
- * on every rejection only penalizes callers that inspect errors by identity.
140
- * Indexing, iteration, and `map` materialize the entry on demand.
293
+ * Validation failure result. The common single-error case remains lazy;
294
+ * traversal only materializes an entry array when a second error is appended.
141
295
  */
142
296
  type StoredPath = PropertyKey[] | PropertyKey | undefined;
143
297
 
@@ -146,10 +300,9 @@ export class OmpErrors implements Iterable<OmpError> {
146
300
  #expected: string;
147
301
  #data: unknown;
148
302
  #entry: OmpError | undefined;
303
+ #entries: OmpError[] | undefined;
149
304
  #config: ErrorConfig | undefined;
150
-
151
- /** Number of failures; omptype validators fast-fail on the first error. */
152
- readonly length = 1;
305
+ #separator = "\n";
153
306
 
154
307
  constructor(path: StoredPath, expected: string, data: unknown, config?: ErrorConfig) {
155
308
  this.#path = path;
@@ -158,9 +311,12 @@ export class OmpErrors implements Iterable<OmpError> {
158
311
  this.#config = config;
159
312
  }
160
313
 
161
- /** First and only validation failure, materialized on demand. */
314
+ get length(): number {
315
+ return this.#entries?.length ?? 1;
316
+ }
317
+
162
318
  get 0(): OmpError {
163
- return this.#getEntry();
319
+ return this.#entries?.[0] ?? this.#getEntry();
164
320
  }
165
321
 
166
322
  static single(path: PropertyKey[], expected: string, data: unknown, config?: ErrorConfig): OmpErrors {
@@ -175,53 +331,95 @@ export class OmpErrors implements Iterable<OmpError> {
175
331
  return entry;
176
332
  }
177
333
 
178
- /** Prefix the failure path with `key` when nesting sub-schemas. */
334
+ /** Append all failures from `other`, preserving traversal order. */
335
+ append(other: OmpErrors): this {
336
+ this.#entries ??= [this.#getEntry()];
337
+ const entries = this.#entries;
338
+ for (const entry of other) entries.push(entry);
339
+ return this;
340
+ }
341
+
342
+ /** Prefix every failure path with `key` when nesting sub-schemas. */
179
343
  prefix(key: PropertyKey): this {
180
- const path = this.#path;
181
- this.#path = path === undefined ? [key] : Array.isArray(path) ? [key, ...path] : [key, path];
182
- this.#entry = undefined;
344
+ if (this.#entries) {
345
+ for (const entry of this.#entries) entry.prefix(key);
346
+ } else {
347
+ const path = this.#path;
348
+ this.#path = path === undefined ? [key] : Array.isArray(path) ? [key, ...path] : [key, path];
349
+ this.#entry = undefined;
350
+ }
183
351
  return this;
184
352
  }
185
353
 
186
- /** Apply schema-local message formatting without rebuilding the failure. */
354
+ /** Apply schema-local message formatting without rebuilding failures. */
187
355
  configure(config: ErrorConfig): this {
188
- this.#config = { ...this.#config, ...config };
189
- this.#entry = undefined;
356
+ if (this.#entries) {
357
+ for (const entry of this.#entries) entry.configure(config);
358
+ } else {
359
+ this.#config = { ...this.#config, ...config };
360
+ this.#entry = undefined;
361
+ }
190
362
  return this;
191
363
  }
192
364
 
193
- /** Index the failure by its dotted property path (`""` for the root). */
194
365
  get byPath(): Readonly<Record<string, OmpError>> {
195
- const entry = this.#getEntry();
196
- return { [entry.path.map(String).join(".")]: entry };
366
+ const result: Record<string, OmpError> = {};
367
+ for (const entry of this) result[entry.path.map(String).join(".")] = entry;
368
+ return result;
197
369
  }
198
370
 
199
- /** Transform the failure entry into a plain array. */
200
371
  map<result>(fn: (error: OmpError, index: number, errors: OmpErrors) => result): result[] {
201
- return [fn(this.#getEntry(), 0, this)];
372
+ const result: result[] = [];
373
+ let index = 0;
374
+ for (const entry of this) result.push(fn(entry, index++, this));
375
+ return result;
202
376
  }
203
377
 
204
- /** Select the failure entry into a plain array. */
205
378
  filter(fn: (error: OmpError, index: number, errors: OmpErrors) => unknown): OmpError[] {
206
- const entry = this.#getEntry();
207
- return fn(entry, 0, this) ? [entry] : [];
379
+ const result: OmpError[] = [];
380
+ let index = 0;
381
+ for (const entry of this) {
382
+ if (fn(entry, index++, this)) result.push(entry);
383
+ }
384
+ return result;
208
385
  }
209
386
 
210
- /** Iterate over the single failure entry. */
211
387
  *[Symbol.iterator](): IterableIterator<OmpError> {
212
- yield this.#getEntry();
388
+ if (this.#entries) {
389
+ yield* this.#entries;
390
+ } else {
391
+ yield this.#getEntry();
392
+ }
213
393
  }
214
394
 
215
- /** Human-readable failure text, materialized only when requested. */
395
+ /** @internal Render multiple branch failures as alternatives rather than independent failures. */
396
+ asAlternatives(): this {
397
+ this.#separator = " or ";
398
+ return this;
399
+ }
216
400
  get summary(): string {
217
- return this.#getEntry().message;
401
+ const entries = [...this];
402
+ if (
403
+ this.#separator === "\n" &&
404
+ entries.length > 1 &&
405
+ entries.every(
406
+ entry =>
407
+ Object.is(entry.data, entries[0].data) &&
408
+ entry.path.length === entries[0].path.length &&
409
+ entry.path.every((key, index) => key === entries[0].path[index]),
410
+ )
411
+ ) {
412
+ const at = formatPath(entries[0].path);
413
+ const actual = typeof entries[0].data === "string" ? JSON.stringify(entries[0].data) : String(entries[0].data);
414
+ return `${at}${at === "" ? "" : " "}(${actual}) must be...\n${entries.map(entry => ` ◦ ${entry.expected}`).join("\n")}`;
415
+ }
416
+ return entries.map(error => error.message).join(this.#separator);
218
417
  }
219
418
 
220
419
  toString(): string {
221
420
  return this.summary;
222
421
  }
223
422
 
224
- /** Throw a `TraversalError` carrying this result. */
225
423
  throw(): never {
226
424
  throw new TraversalError(this);
227
425
  }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * JSON Schema → omptype importer.
3
+ *
4
+ * Rebuilds a callable schema from JSON Schema documents — the inverse of
5
+ * `Type.toJsonSchema()` for the dialect omptype (and its Zod/TypeBox
6
+ * adapters) emits: draft-07 and draft-2020-12 structural keywords, string
7
+ * formats, `$defs`/`definitions` references (including recursion), enums,
8
+ * and composition via `anyOf`/`oneOf`/`allOf`.
9
+ *
10
+ * Unknown or non-structural keywords are ignored, matching lenient importer
11
+ * behavior: the result validates every constraint the schema can express in
12
+ * omptype IR and passes the rest through.
13
+ */
14
+ import { OmpTypeError } from "./errors";
15
+ import { type EmbeddableSchema, type IR, IR_BRAND, type PropIR, type TupleItemIR } from "./ir";
16
+ import { keywordIR, patternIR } from "./keywords";
17
+ import { type BaseType, type } from "./type";
18
+
19
+ type JsonSchema = Record<string, unknown>;
20
+
21
+ /** `format` values lowered to built-in keyword validators. */
22
+ const FORMAT_KEYWORDS: Record<string, string> = {
23
+ email: "string.email",
24
+ uuid: "string.uuid",
25
+ "date-time": "string.date.iso",
26
+ date: "string.date.iso",
27
+ ipv4: "string.ip.v4",
28
+ ipv6: "string.ip.v6",
29
+ regex: "string.regex",
30
+ };
31
+
32
+ class Importer {
33
+ readonly #root: JsonSchema;
34
+ readonly #aliases = new Map<string, IR>();
35
+
36
+ constructor(root: JsonSchema) {
37
+ this.#root = root;
38
+ }
39
+
40
+ resolveRef(ref: string): IR {
41
+ const cached = this.#aliases.get(ref);
42
+ if (cached !== undefined) return cached;
43
+
44
+ let target: unknown;
45
+ if (ref === "#") {
46
+ target = this.#root;
47
+ } else {
48
+ const defsMatch = /^#\/(\$defs|definitions)\/(.+)$/.exec(ref);
49
+ if (defsMatch === null) throw new OmpTypeError(`unsupported $ref: ${ref}`);
50
+ const defs = this.#root[defsMatch[1]];
51
+ target = typeof defs === "object" && defs !== null ? (defs as JsonSchema)[defsMatch[2]] : undefined;
52
+ if (target === undefined) throw new OmpTypeError(`unresolved $ref: ${ref}`);
53
+ }
54
+
55
+ // Register the alias before lowering so recursive references resolve
56
+ // to the same node instead of recursing forever.
57
+ let lowered: IR | undefined;
58
+ const alias: IR = {
59
+ k: "alias",
60
+ name: ref,
61
+ resolve: () => {
62
+ lowered ??= this.lower(target);
63
+ return lowered;
64
+ },
65
+ };
66
+ this.#aliases.set(ref, alias);
67
+ return alias;
68
+ }
69
+
70
+ lower(schema: unknown): IR {
71
+ if (schema === true) return { k: "unknown" };
72
+ if (schema === false) return { k: "never" };
73
+ if (typeof schema !== "object" || schema === null) {
74
+ throw new OmpTypeError("JSON Schema nodes must be booleans or objects");
75
+ }
76
+ const node = schema as JsonSchema;
77
+ const desc = typeof node.description === "string" ? node.description : undefined;
78
+ const ir = this.#lowerNode(node);
79
+ return desc === undefined ? ir : { ...ir, desc };
80
+ }
81
+
82
+ #lowerNode(node: JsonSchema): IR {
83
+ if (typeof node.$ref === "string") return this.resolveRef(node.$ref);
84
+
85
+ if (Array.isArray(node.enum)) {
86
+ const members = node.enum.map((v): IR => ({ k: "lit", v }));
87
+ return members.length === 1 ? members[0] : { k: "union", members };
88
+ }
89
+ if ("const" in node) return { k: "lit", v: node.const };
90
+
91
+ const branches = node.anyOf ?? node.oneOf;
92
+ if (Array.isArray(branches)) {
93
+ return { k: "union", members: branches.map(branch => this.lower(branch)) };
94
+ }
95
+ if (Array.isArray(node.allOf)) {
96
+ const members = node.allOf.map(branch => this.lower(branch));
97
+ return members.length === 1 ? members[0] : { k: "intersection", members };
98
+ }
99
+ if (typeof node.not === "object" && node.not !== null && Object.keys(node.not).length === 0) {
100
+ return { k: "never" };
101
+ }
102
+
103
+ if (Array.isArray(node.type)) {
104
+ const members = node.type.map(t => this.#lowerTyped(node, String(t)));
105
+ return members.length === 1 ? members[0] : { k: "union", members };
106
+ }
107
+ if (typeof node.type === "string") return this.#lowerTyped(node, node.type);
108
+
109
+ // No explicit type: infer from structural keywords, else accept anything.
110
+ if (node.properties !== undefined || node.required !== undefined) return this.#lowerObject(node);
111
+ if (node.items !== undefined || node.prefixItems !== undefined) return this.#lowerArray(node);
112
+ return { k: "unknown" };
113
+ }
114
+
115
+ #lowerTyped(node: JsonSchema, kind: string): IR {
116
+ switch (kind) {
117
+ case "null":
118
+ return { k: "null" };
119
+ case "boolean":
120
+ return { k: "boolean" };
121
+ case "string":
122
+ return this.#lowerString(node);
123
+ case "number":
124
+ case "integer": {
125
+ const ir: IR = { k: "number" };
126
+ if (kind === "integer") ir.int = true;
127
+ if (typeof node.minimum === "number") ir.min = node.minimum;
128
+ if (typeof node.maximum === "number") ir.max = node.maximum;
129
+ if (typeof node.exclusiveMinimum === "number") {
130
+ ir.min = node.exclusiveMinimum;
131
+ ir.xmin = true;
132
+ }
133
+ if (typeof node.exclusiveMaximum === "number") {
134
+ ir.max = node.exclusiveMaximum;
135
+ ir.xmax = true;
136
+ }
137
+ if (typeof node.multipleOf === "number") ir.divisor = node.multipleOf;
138
+ return ir;
139
+ }
140
+ case "object":
141
+ return this.#lowerObject(node);
142
+ case "array":
143
+ return this.#lowerArray(node);
144
+ default:
145
+ throw new OmpTypeError(`unsupported JSON Schema type: ${kind}`);
146
+ }
147
+ }
148
+
149
+ #lowerString(node: JsonSchema): IR {
150
+ const base: IR = { k: "string" };
151
+ if (typeof node.minLength === "number") base.min = node.minLength;
152
+ if (typeof node.maxLength === "number") base.max = node.maxLength;
153
+ if (node.format === "uri" || node.format === "url") base.url = true;
154
+
155
+ const members: IR[] = [];
156
+ if (typeof node.format === "string") {
157
+ const keyword = FORMAT_KEYWORDS[node.format];
158
+ if (keyword !== undefined) {
159
+ const formatIR = keywordIR(keyword);
160
+ if (formatIR !== undefined) members.push(formatIR);
161
+ }
162
+ }
163
+ if (typeof node.pattern === "string") members.push(patternIR(new RegExp(node.pattern)));
164
+
165
+ if (members.length === 0) return base;
166
+ if (base.min !== undefined || base.max !== undefined || base.url) members.unshift(base);
167
+ return members.length === 1 ? members[0] : { k: "intersection", members };
168
+ }
169
+
170
+ #lowerObject(node: JsonSchema): IR {
171
+ const required = new Set(Array.isArray(node.required) ? node.required.map(String) : []);
172
+ const props: PropIR[] = [];
173
+ if (typeof node.properties === "object" && node.properties !== null) {
174
+ for (const [key, value] of Object.entries(node.properties)) {
175
+ const prop: PropIR = { key, opt: !required.has(key), val: this.lower(value) };
176
+ if (typeof value === "object" && value !== null && "default" in value) {
177
+ prop.hasDefault = true;
178
+ prop.def = (value as JsonSchema).default;
179
+ prop.opt = true;
180
+ }
181
+ props.push(prop);
182
+ }
183
+ }
184
+ const extra = node.additionalProperties;
185
+ return {
186
+ k: "object",
187
+ props,
188
+ extras: extra === false ? "reject" : "keep",
189
+ ...(typeof extra === "object" && extra !== null ? { index: this.lower(extra) } : {}),
190
+ };
191
+ }
192
+
193
+ #lowerArray(node: JsonSchema): IR {
194
+ if (Array.isArray(node.prefixItems)) {
195
+ const minItems = typeof node.minItems === "number" ? node.minItems : node.prefixItems.length;
196
+ const prefix: TupleItemIR[] = node.prefixItems.map((item, index) => ({
197
+ val: this.lower(item),
198
+ opt: index >= minItems,
199
+ }));
200
+ return {
201
+ k: "tuple",
202
+ prefix,
203
+ postfix: [],
204
+ ...(node.items !== undefined && node.items !== false ? { variadic: this.lower(node.items) } : {}),
205
+ };
206
+ }
207
+ const ir: IR = { k: "array", el: node.items === undefined ? { k: "unknown" } : this.lower(node.items) };
208
+ if (typeof node.minItems === "number") ir.min = node.minItems;
209
+ if (typeof node.maxItems === "number") ir.max = node.maxItems;
210
+ return ir;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Build a callable omptype schema from a JSON Schema document.
216
+ *
217
+ * # Errors
218
+ * Throws {@link OmpTypeError} on malformed nodes, unresolvable `$ref`s, or
219
+ * types omptype cannot represent.
220
+ */
221
+ export function fromJsonSchema(schema: unknown): BaseType {
222
+ const importer = new Importer(typeof schema === "object" && schema !== null ? (schema as JsonSchema) : {});
223
+ const embedded: EmbeddableSchema = {
224
+ [IR_BRAND]: true,
225
+ ir: importer.lower(schema),
226
+ hasSteps: false,
227
+ hasDefault: false,
228
+ run: value => value,
229
+ };
230
+ return type.raw(embedded);
231
+ }
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * JSON Schema emission.
7
7
  */
8
8
  export * from "./errors";
9
+ export * from "./from-json-schema";
9
10
  export * from "./infer";
10
11
  export * from "./ir";
11
12
  export * from "./json-schema";