@mark1russell7/coeval 0.6.3 → 0.7.1
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/README.md +63 -5
- package/build/index.d.ts +1771 -254
- package/build/index.d.ts.map +1 -1
- package/build/index.js +5809 -511
- package/build/index.js.map +1 -1
- package/package.json +1 -1
- package/src/agree.ts +15 -3
- package/src/codec.ts +185 -0
- package/src/conform.ts +1068 -0
- package/src/deref.ts +284 -47
- package/src/durable.ts +238 -0
- package/src/effects.ts +110 -0
- package/src/errors.ts +2 -1
- package/src/evaluate.ts +480 -113
- package/src/facts.ts +252 -0
- package/src/formula.ts +450 -42
- package/src/graph.ts +399 -0
- package/src/index.ts +46 -0
- package/src/ladder.ts +40 -1
- package/src/merge.ts +91 -4
- package/src/parts.ts +26 -11
- package/src/providers.ts +286 -0
- package/src/reads.ts +105 -0
- package/src/reference.ts +23 -9
- package/src/schedule.ts +436 -0
- package/src/static.ts +266 -29
- package/src/stdlib.ts +24 -7
- package/src/syntax.ts +846 -0
- package/src/types.ts +768 -0
- package/src/typing.ts +390 -0
- package/src/world.ts +63 -9
package/src/merge.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Key, docEquals, isRecord } from "./doc.ts";
|
|
2
2
|
import type { DerefCode, Walker } from "./deref.ts";
|
|
3
3
|
import type { Evaluator, Machine, Runner } from "./evaluate.ts";
|
|
4
|
+
import { type Domain, type ValueType, STD_DOMAINS } from "./types.ts";
|
|
4
5
|
import { type Kind, type World, type WorldEntry, isAlias, isKind, isNamespace } from "./world.ts";
|
|
5
6
|
|
|
6
7
|
/** The keys from a part of a merge to one of its entries, for example `["kinds", "ref"]`. */
|
|
@@ -54,6 +55,7 @@ export class MergeError extends Error {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
type Known = NonNullable<Machine["known"]>;
|
|
58
|
+
type Facts = NonNullable<Machine["facts"]>;
|
|
57
59
|
|
|
58
60
|
const NONE: ReadonlySet<string> = /* @__PURE__ */ new Set();
|
|
59
61
|
|
|
@@ -85,6 +87,56 @@ function same(a: unknown, b: unknown): boolean {
|
|
|
85
87
|
return Object.is(a, b) || docEquals(defined(a), defined(b));
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Two domains are the same when they are one object. They are also the same when they have the same test `is`,
|
|
92
|
+
* parents, summary and steps. Thus two languages that give one test under one name give one domain.
|
|
93
|
+
*/
|
|
94
|
+
function sameDomain(a: Domain, b: Domain): boolean {
|
|
95
|
+
return a === b || (a.is === b.is && same(a.under ?? [], b.under ?? []) && a.summary === b.summary && same(a.steps ?? {}, b.steps ?? {}));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** What `extendDomain` adds to a domain: more parents, and the types of more steps. */
|
|
99
|
+
export interface DomainExtension {
|
|
100
|
+
/** The names of the parents to add. A member of the domain then also passes the test of each new parent. */
|
|
101
|
+
readonly under?: readonly string[];
|
|
102
|
+
/** The types of the steps to add. A step with the key of an earlier step replaces its type. */
|
|
103
|
+
readonly steps?: Readonly<Record<string, ValueType>>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* This function makes a part of a merge that adds parents or steps to a domain of an earlier part (TYPE.PARENTS). An
|
|
108
|
+
* example is a domain of another language. The part reads the domain from the merge before it (`prev`). It gives a
|
|
109
|
+
* copy with the same test and summary, and it names `["domains", name]` in `replaces` (D17). A domain of the kernel
|
|
110
|
+
* that the earlier parts do not name comes from `STD_DOMAINS`.
|
|
111
|
+
*
|
|
112
|
+
* A new parent adds its test to the domain (TYPE.MEMBER). Thus `fits` stays sound, but a value that fails the test of
|
|
113
|
+
* the parent is no longer a member. The language that adds the parent keeps the signatures of the other language
|
|
114
|
+
* true. A name without a domain gives an empty part, and `typeIssues` reports each use of that name.
|
|
115
|
+
*
|
|
116
|
+
* @param name - The name of the domain.
|
|
117
|
+
* @param more - The parents and the steps to add.
|
|
118
|
+
* @returns The part, as a function of the merge before it.
|
|
119
|
+
*/
|
|
120
|
+
export function extendDomain(name: string, more: DomainExtension): (prev: Machine) => MachinePart {
|
|
121
|
+
return (prev) => {
|
|
122
|
+
const found = own(prev.domains, name) ?? own(STD_DOMAINS, name);
|
|
123
|
+
if (typeof found !== "object" || found === null || typeof found.is !== "function") return {};
|
|
124
|
+
// A domain can be an instance of a class, thus the copy reads each field by its name.
|
|
125
|
+
const under = [...(found.under ?? [])];
|
|
126
|
+
for (const parent of more.under ?? []) if (parent !== name && !under.includes(parent)) under.push(parent);
|
|
127
|
+
const steps = { ...found.steps, ...more.steps };
|
|
128
|
+
const domain: Domain = {
|
|
129
|
+
is: found.is,
|
|
130
|
+
...(under.length === 0 ? {} : { under }),
|
|
131
|
+
...(found.summary === undefined ? {} : { summary: found.summary }),
|
|
132
|
+
...(Object.keys(steps).length === 0 ? {} : { steps }),
|
|
133
|
+
};
|
|
134
|
+
const domains: Record<string, Domain> = {};
|
|
135
|
+
setOwn(domains, name, domain);
|
|
136
|
+
return { domains, replaces: [["domains", name]] };
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
88
140
|
/** Two lists of roots are the same when they hold the same roots, in any order. */
|
|
89
141
|
function sameRoots(a: unknown, b: unknown): boolean {
|
|
90
142
|
if (!Array.isArray(a) || !Array.isArray(b)) return same(a, b);
|
|
@@ -246,7 +298,7 @@ export function mergeWorlds(...parts: readonly (WorldPart | ((prev: World) => Wo
|
|
|
246
298
|
}
|
|
247
299
|
|
|
248
300
|
/** The fields of a machine that merge by their own rules. Each other field is one value, or a conflict. */
|
|
249
|
-
const TABLES = /* @__PURE__ */ new Set(["world", "evaluators", "runners", "derefs", "walkers", "known", "replaces"]);
|
|
301
|
+
const TABLES = /* @__PURE__ */ new Set(["world", "evaluators", "runners", "derefs", "walkers", "domains", "known", "facts", "replaces"]);
|
|
250
302
|
|
|
251
303
|
interface MachineDraft {
|
|
252
304
|
readonly world: WorldDraft;
|
|
@@ -254,7 +306,9 @@ interface MachineDraft {
|
|
|
254
306
|
runners?: Record<string, Runner>;
|
|
255
307
|
derefs?: Record<string, DerefCode>;
|
|
256
308
|
walkers?: Record<string, Walker>;
|
|
309
|
+
domains?: Record<string, Domain>;
|
|
257
310
|
readonly hooks: { readonly part: number; readonly known: Known }[];
|
|
311
|
+
readonly factHooks: { readonly part: number; readonly facts: Facts }[];
|
|
258
312
|
/** The last part that replaced the code of each evaluator name. */
|
|
259
313
|
readonly replaced: Map<string, number>;
|
|
260
314
|
readonly rest: Record<string, unknown>;
|
|
@@ -282,8 +336,30 @@ function composeKnown(hooks: readonly { readonly part: number; readonly known: K
|
|
|
282
336
|
};
|
|
283
337
|
}
|
|
284
338
|
|
|
339
|
+
/**
|
|
340
|
+
* This function composes the hooks `facts` in the order of their parts. The first answer that is not `undefined`
|
|
341
|
+
* wins. A hook does not answer for a name whose code a later part replaces (D24).
|
|
342
|
+
*/
|
|
343
|
+
function composeFacts(hooks: readonly { readonly part: number; readonly facts: Facts }[], replaced: ReadonlyMap<string, number>): Facts | undefined {
|
|
344
|
+
const first = hooks[0];
|
|
345
|
+
if (first === undefined) return undefined;
|
|
346
|
+
if (hooks.length === 1 && replaced.size === 0) return first.facts;
|
|
347
|
+
const list = [...hooks];
|
|
348
|
+
const last = new Map(replaced);
|
|
349
|
+
return (path, code) => {
|
|
350
|
+
const after = last.get(code);
|
|
351
|
+
for (const hook of list) {
|
|
352
|
+
if (after !== undefined && hook.part < after) continue;
|
|
353
|
+
const answer = hook.facts(path, code);
|
|
354
|
+
if (answer !== undefined) return answer;
|
|
355
|
+
}
|
|
356
|
+
return undefined;
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
285
360
|
function machineOf(draft: MachineDraft): Machine {
|
|
286
361
|
const known = composeKnown(draft.hooks, draft.replaced);
|
|
362
|
+
const facts = composeFacts(draft.factHooks, draft.replaced);
|
|
287
363
|
return {
|
|
288
364
|
...draft.rest,
|
|
289
365
|
world: worldOf(draft.world),
|
|
@@ -291,7 +367,9 @@ function machineOf(draft: MachineDraft): Machine {
|
|
|
291
367
|
...(draft.runners === undefined ? {} : { runners: { ...draft.runners } }),
|
|
292
368
|
...(draft.derefs === undefined ? {} : { derefs: { ...draft.derefs } }),
|
|
293
369
|
...(draft.walkers === undefined ? {} : { walkers: { ...draft.walkers } }),
|
|
370
|
+
...(draft.domains === undefined ? {} : { domains: { ...draft.domains } }),
|
|
294
371
|
...(known === undefined ? {} : { known }),
|
|
372
|
+
...(facts === undefined ? {} : { facts }),
|
|
295
373
|
};
|
|
296
374
|
}
|
|
297
375
|
|
|
@@ -344,9 +422,11 @@ function checkHidden(m: Merger, draft: MachineDraft): void {
|
|
|
344
422
|
*
|
|
345
423
|
* - The worlds merge with the rules of `mergeWorlds`. The paths of their conflicts start with `"world"`.
|
|
346
424
|
* - The tables `evaluators`, `runners`, `derefs` and `walkers` merge name by name. A function is equal only to itself.
|
|
425
|
+
* - The table `domains` merges name by name. Two domains with the same test, parents and summary give one domain (D22).
|
|
347
426
|
* - The evaluator and the runner of a name are its code. A replacement of the name replaces both.
|
|
348
427
|
* - A runner hides the evaluator of its name. Thus one part must give both, or the merge gives a conflict.
|
|
349
428
|
* - The hooks `known` compose in the order of the parts. A hook does not answer for a name that a later part replaces.
|
|
429
|
+
* The hooks `facts` compose the same way, and the first answer that is not `undefined` wins.
|
|
350
430
|
* - Each other field, for example `permits`, `adopt`, `roots`, `plain` or `maxDepth`, is one value. Two different
|
|
351
431
|
* values are a conflict. An absent field has no value.
|
|
352
432
|
*
|
|
@@ -359,7 +439,7 @@ function checkHidden(m: Merger, draft: MachineDraft): void {
|
|
|
359
439
|
*/
|
|
360
440
|
export function mergeMachines(...parts: readonly (MachinePart | ((prev: Machine) => MachinePart))[]): Machine {
|
|
361
441
|
const m = new Merger();
|
|
362
|
-
const draft: MachineDraft = { world: { kinds: {}, defaults: {} }, evaluators: {}, hooks: [], replaced: new Map(), rest: {}, parts: [] };
|
|
442
|
+
const draft: MachineDraft = { world: { kinds: {}, defaults: {} }, evaluators: {}, hooks: [], factHooks: [], replaced: new Map(), rest: {}, parts: [] };
|
|
363
443
|
parts.forEach((input, index) => {
|
|
364
444
|
const part = typeof input === "function" ? input(machineOf(draft)) : input;
|
|
365
445
|
draft.parts.push(part);
|
|
@@ -374,7 +454,12 @@ export function mergeMachines(...parts: readonly (MachinePart | ((prev: Machine)
|
|
|
374
454
|
const walkers = (draft.walkers ??= {});
|
|
375
455
|
for (const name of Object.keys(part.walkers)) mergeLeaf(m, walkers, name, part.walkers[name], ["walkers", name], index, replaces, Object.is, "Two parts give two different walkers for this name.");
|
|
376
456
|
}
|
|
457
|
+
if (part.domains !== undefined) {
|
|
458
|
+
const domains = (draft.domains ??= {});
|
|
459
|
+
for (const name of Object.keys(part.domains)) mergeLeaf(m, domains, name, part.domains[name], ["domains", name], index, replaces, sameDomain, "Two parts give two different domains for this base type.");
|
|
460
|
+
}
|
|
377
461
|
if (part.known !== undefined && !draft.hooks.some((hook) => hook.known === part.known)) draft.hooks.push({ part: index, known: part.known });
|
|
462
|
+
if (part.facts !== undefined && !draft.factHooks.some((hook) => hook.facts === part.facts)) draft.factHooks.push({ part: index, facts: part.facts });
|
|
378
463
|
const fields = part as Readonly<Record<string, unknown>>;
|
|
379
464
|
for (const key of Object.keys(fields)) {
|
|
380
465
|
if (TABLES.has(key)) continue;
|
|
@@ -440,7 +525,7 @@ export function restrictWorld(world: World, paths: readonly MergePath[]): World
|
|
|
440
525
|
}
|
|
441
526
|
|
|
442
527
|
/** The tables of the code of a machine, as the paths of a restriction name them. */
|
|
443
|
-
const CODE_TABLES = ["evaluators", "runners", "derefs", "walkers"] as const;
|
|
528
|
+
const CODE_TABLES = ["evaluators", "runners", "derefs", "walkers", "domains"] as const;
|
|
444
529
|
|
|
445
530
|
/**
|
|
446
531
|
* This function removes entries from a machine, for a language that restricts another language. A path names an
|
|
@@ -448,7 +533,7 @@ const CODE_TABLES = ["evaluators", "runners", "derefs", "walkers"] as const;
|
|
|
448
533
|
*
|
|
449
534
|
* - A path that starts with `"world"` removes an entry of the world, with the rules of `restrictWorld`.
|
|
450
535
|
* - `["evaluators", name]` or `["runners", name]` removes the code of the name: its evaluator and its runner.
|
|
451
|
-
* - `["derefs", name]` and `["
|
|
536
|
+
* - `["derefs", name]`, `["walkers", name]` and `["domains", name]` remove one entry of their tables.
|
|
452
537
|
*
|
|
453
538
|
* A path that names no entry changes nothing. The function does not change the machine. An own entry names an
|
|
454
539
|
* evaluator directly, thus only a machine without the code of a name refuses each document that names it.
|
|
@@ -464,6 +549,7 @@ export function restrictMachine(machine: Machine, paths: readonly MergePath[]):
|
|
|
464
549
|
runners: machine.runners === undefined ? undefined : { ...machine.runners },
|
|
465
550
|
derefs: machine.derefs === undefined ? undefined : { ...machine.derefs },
|
|
466
551
|
walkers: machine.walkers === undefined ? undefined : { ...machine.walkers },
|
|
552
|
+
domains: machine.domains === undefined ? undefined : { ...machine.domains },
|
|
467
553
|
};
|
|
468
554
|
for (const path of paths) {
|
|
469
555
|
const [table, name, ...rest] = path.map(String);
|
|
@@ -482,5 +568,6 @@ export function restrictMachine(machine: Machine, paths: readonly MergePath[]):
|
|
|
482
568
|
...(tables.runners === undefined ? {} : { runners: tables.runners as NonNullable<Machine["runners"]> }),
|
|
483
569
|
...(tables.derefs === undefined ? {} : { derefs: tables.derefs as NonNullable<Machine["derefs"]> }),
|
|
484
570
|
...(tables.walkers === undefined ? {} : { walkers: tables.walkers as NonNullable<Machine["walkers"]> }),
|
|
571
|
+
...(tables.domains === undefined ? {} : { domains: tables.domains as NonNullable<Machine["domains"]> }),
|
|
485
572
|
};
|
|
486
573
|
}
|
package/src/parts.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Envelope, type Json, type Key, docEquals, isEnvelope, isRecord, typeName, typePath } from "./doc.ts";
|
|
2
2
|
import { type Kind, type Shape, type World, lookupKind } from "./world.ts";
|
|
3
|
+
import type { ValueType } from "./types.ts";
|
|
3
4
|
|
|
4
5
|
/** One part of a value: its place in the value, the JSON of the part, and the type that its position declares. */
|
|
5
6
|
export interface PartSlot {
|
|
@@ -11,6 +12,8 @@ export interface PartSlot {
|
|
|
11
12
|
readonly of?: string;
|
|
12
13
|
/** `true` when the position of the part declares an address: a reference there gives what its path reaches. */
|
|
13
14
|
readonly address?: true;
|
|
15
|
+
/** The value type that the shape declares for the part (`expect`). Only a check of types reads it. */
|
|
16
|
+
readonly expect?: ValueType;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
type Template =
|
|
@@ -56,6 +59,11 @@ function hasSlots(template: Template): boolean {
|
|
|
56
59
|
return template.t !== "data";
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
/** This function gives the shape of a part with the default type `of` and the value type `expect` of its container. */
|
|
63
|
+
function termOf(of: string | undefined, expect: ValueType | undefined): Shape {
|
|
64
|
+
return { shape: "term", ...(of === undefined ? {} : { of }), ...(expect === undefined ? {} : { expect }) };
|
|
65
|
+
}
|
|
66
|
+
|
|
59
67
|
/** This function tells if a shape holds only data: no value of the shape can hold a part. */
|
|
60
68
|
function isData(shape: Shape): boolean {
|
|
61
69
|
return shape.shape === "none" || shape.shape === "text" || shape.shape === "number" || shape.shape === "one";
|
|
@@ -94,19 +102,26 @@ export function splitParts(shape: Shape | undefined, value: Json): Split {
|
|
|
94
102
|
// A value without parts is data: the split needs no template.
|
|
95
103
|
if (shape === undefined || shape.shape === "none") return { ok: true, parts: NO_SLOTS, data: value, fill: () => value };
|
|
96
104
|
const parts: PartSlot[] = [];
|
|
97
|
-
const slot = (item: Json, path: readonly Key[], of: string | undefined, address = false): Template => {
|
|
98
|
-
parts.push({
|
|
105
|
+
const slot = (item: Json, path: readonly Key[], of: string | undefined, address = false, expect?: ValueType): Template => {
|
|
106
|
+
parts.push({
|
|
107
|
+
path,
|
|
108
|
+
key: path.join("."),
|
|
109
|
+
item,
|
|
110
|
+
...(of === undefined ? {} : { of }),
|
|
111
|
+
...(address ? { address: true as const } : {}),
|
|
112
|
+
...(expect === undefined ? {} : { expect }),
|
|
113
|
+
});
|
|
99
114
|
return { t: "slot", index: parts.length - 1 };
|
|
100
115
|
};
|
|
101
116
|
|
|
102
|
-
const deep = (item: Json, path: readonly Key[], of: string | undefined): Template => {
|
|
103
|
-
if (isEnvelope(item)) return slot(item, path, of);
|
|
117
|
+
const deep = (item: Json, path: readonly Key[], of: string | undefined, expect: ValueType | undefined): Template => {
|
|
118
|
+
if (isEnvelope(item)) return slot(item, path, of, false, expect);
|
|
104
119
|
if (Array.isArray(item)) {
|
|
105
|
-
const items = (item as readonly Json[]).map((child, index) => deep(child, [...path, index], of));
|
|
120
|
+
const items = (item as readonly Json[]).map((child, index) => deep(child, [...path, index], of, expect));
|
|
106
121
|
return items.some(hasSlots) ? { t: "list", items } : { t: "data", value: item };
|
|
107
122
|
}
|
|
108
123
|
if (isRecord(item)) {
|
|
109
|
-
const fields = Object.keys(item).map((key) => [key, deep(item[key] as Json, [...path, key], of)] as const);
|
|
124
|
+
const fields = Object.keys(item).map((key) => [key, deep(item[key] as Json, [...path, key], of, expect)] as const);
|
|
110
125
|
return fields.some(([, child]) => hasSlots(child)) ? { t: "record", fields } : { t: "data", value: item };
|
|
111
126
|
}
|
|
112
127
|
return { t: "data", value: item };
|
|
@@ -121,9 +136,9 @@ export function splitParts(shape: Shape | undefined, value: Json): Split {
|
|
|
121
136
|
}
|
|
122
137
|
if (spec.shape === "term") {
|
|
123
138
|
if (spec.kinds !== undefined) kindFault(spec.kinds, item, spec.of, path);
|
|
124
|
-
return slot(item, path, spec.of, spec.address === true);
|
|
139
|
+
return slot(item, path, spec.of, spec.address === true, spec.expect);
|
|
125
140
|
}
|
|
126
|
-
if (spec.shape === "deep") return deep(item, path, spec.of);
|
|
141
|
+
if (spec.shape === "deep") return deep(item, path, spec.of, spec.expect);
|
|
127
142
|
if (spec.shape === "list") {
|
|
128
143
|
if (!Array.isArray(item)) throw new Bad("The value must be a list.", path);
|
|
129
144
|
const list = item as readonly Json[];
|
|
@@ -131,21 +146,21 @@ export function splitParts(shape: Shape | undefined, value: Json): Split {
|
|
|
131
146
|
const twice = list.findIndex((child, index) => list.slice(0, index).some((before) => docEquals(before, child)));
|
|
132
147
|
if (twice !== -1) throw new Bad("The items of the list must be different.", [...path, twice]);
|
|
133
148
|
}
|
|
134
|
-
const each: Shape = spec.item ??
|
|
149
|
+
const each: Shape = spec.item ?? termOf(spec.of, spec.expect);
|
|
135
150
|
const items = list.map((child, index) => walk(each, child, [...path, index]));
|
|
136
151
|
// A list of data is data, also when it is empty. Only a list that can hold parts gives null in the data.
|
|
137
152
|
return isData(each) ? { t: "data", value: item } : { t: "list", items };
|
|
138
153
|
}
|
|
139
154
|
if (!isRecord(item)) throw new Bad("The value must be a record.", path);
|
|
140
155
|
if (spec.shape === "map") {
|
|
141
|
-
const each: Shape = spec.item ??
|
|
156
|
+
const each: Shape = spec.item ?? termOf(spec.of, spec.expect);
|
|
142
157
|
return { t: "record", fields: Object.keys(item).map((key) => [key, walk(each, item[key] as Json, [...path, key])] as const) };
|
|
143
158
|
}
|
|
144
159
|
for (const name of spec.required ?? []) {
|
|
145
160
|
if (!Object.hasOwn(item, name)) throw new Bad(`The field ${name} is missing.`, path);
|
|
146
161
|
}
|
|
147
162
|
const named: Readonly<Record<string, Shape>> = Array.isArray(spec.fields)
|
|
148
|
-
? Object.fromEntries((spec.fields as readonly string[]).map((name) => [name,
|
|
163
|
+
? Object.fromEntries((spec.fields as readonly string[]).map((name) => [name, termOf(spec.of, spec.expect)]))
|
|
149
164
|
: (spec.fields as Readonly<Record<string, Shape>>);
|
|
150
165
|
const fields: (readonly [string, Template])[] = [];
|
|
151
166
|
for (const key of Object.keys(item)) {
|
package/src/providers.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { type Json, type Key, docEquals, isRecord } from "./doc.ts";
|
|
2
|
+
import { type TypeIssue, type ValueType, isUnknown, join, member } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Type providers (R5 P4, D28). A provider makes a value type from data that a host already has: a JSON Schema or
|
|
6
|
+
* sample values. A host gives such a type to a root of a check, for example the schema of a store for `world`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The type that a provider reads, and the problems of the reading. Each problem tells what the type does not test. */
|
|
10
|
+
export interface TypeReading {
|
|
11
|
+
readonly type: ValueType;
|
|
12
|
+
readonly issues: readonly TypeIssue[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The deepest schema that `typeFromSchema` reads. A deeper schema reads as `unknown`. */
|
|
16
|
+
const MAX_SCHEMA_DEPTH = 256;
|
|
17
|
+
|
|
18
|
+
/** The keywords that describe a schema and do not test a value. The reading ignores them without an issue. */
|
|
19
|
+
const NOTES: ReadonlySet<string> = /* @__PURE__ */ new Set([
|
|
20
|
+
"$schema",
|
|
21
|
+
"$id",
|
|
22
|
+
"$anchor",
|
|
23
|
+
"$comment",
|
|
24
|
+
"$defs",
|
|
25
|
+
"definitions",
|
|
26
|
+
"title",
|
|
27
|
+
"description",
|
|
28
|
+
"default",
|
|
29
|
+
"examples",
|
|
30
|
+
"deprecated",
|
|
31
|
+
"readOnly",
|
|
32
|
+
"writeOnly",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/** The keywords that the reading reads. Each other keyword gives an issue and tests nothing. */
|
|
36
|
+
const READ: ReadonlySet<string> = /* @__PURE__ */ new Set(["type", "properties", "required", "additionalProperties", "items", "enum", "const", "anyOf", "oneOf"]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The keywords that can permit more values than the other keywords permit. Older drafts ignore each keyword beside
|
|
40
|
+
* `$ref`, and `nullable` of OpenAPI 3.0 permits `null`. Thus a schema with one of them reads as `unknown`.
|
|
41
|
+
*/
|
|
42
|
+
const WIDENING: ReadonlySet<string> = /* @__PURE__ */ new Set(["$ref", "$dynamicRef", "$recursiveRef", "nullable"]);
|
|
43
|
+
|
|
44
|
+
/** The base type of each name of the keyword `type`. */
|
|
45
|
+
const BASES: Readonly<Record<string, ValueType>> = { null: "null", boolean: "bool", number: "number", integer: "number", string: "text" };
|
|
46
|
+
|
|
47
|
+
/** This function sets an own field of a new record. Only the key `__proto__` needs `defineProperty`. */
|
|
48
|
+
function setOwn(target: Record<string, unknown>, key: string, value: unknown): void {
|
|
49
|
+
if (key === "__proto__") Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
|
|
50
|
+
else target[key] = value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The union of types: the join of each type, or `never` for no type. */
|
|
54
|
+
function unionOf(types: readonly ValueType[]): ValueType {
|
|
55
|
+
return types.reduce<ValueType>((all, type) => join(all, type), "never");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The state of one reading of a schema: the issues that it found. */
|
|
59
|
+
class SchemaReader {
|
|
60
|
+
readonly issues: TypeIssue[] = [];
|
|
61
|
+
|
|
62
|
+
issue(at: readonly Key[], reason: string): void {
|
|
63
|
+
this.issues.push({ at, reason });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** This method reads one schema. */
|
|
67
|
+
read(schema: unknown, at: readonly Key[], depth: number): ValueType {
|
|
68
|
+
if (depth > MAX_SCHEMA_DEPTH) {
|
|
69
|
+
this.issue(at, `The schema is deeper than ${MAX_SCHEMA_DEPTH} levels. The type reads it as unknown.`);
|
|
70
|
+
return "unknown";
|
|
71
|
+
}
|
|
72
|
+
if (schema === true) return "unknown";
|
|
73
|
+
if (schema === false) return "never";
|
|
74
|
+
if (!isRecord(schema)) {
|
|
75
|
+
this.issue(at, "A schema is a record or a truth value. The type reads this value as unknown.");
|
|
76
|
+
return "unknown";
|
|
77
|
+
}
|
|
78
|
+
let widens = false;
|
|
79
|
+
for (const key of Object.keys(schema)) {
|
|
80
|
+
if (READ.has(key) || NOTES.has(key)) continue;
|
|
81
|
+
if (WIDENING.has(key)) {
|
|
82
|
+
this.issue([...at, key], `The type does not read the keyword ${key}. Thus it reads the schema as unknown.`);
|
|
83
|
+
widens = true;
|
|
84
|
+
} else {
|
|
85
|
+
this.issue([...at, key], `The type does not read the keyword ${key}. It does not test what the keyword tests.`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (widens) return "unknown";
|
|
89
|
+
// A schema is the conjunction of its keywords. The reading keeps one of them, and each one alone holds each value.
|
|
90
|
+
const sides: { readonly key: string; readonly type: ValueType }[] = [];
|
|
91
|
+
if (Object.hasOwn(schema, "type")) sides.push({ key: "type", type: this.byType(schema, at, depth) });
|
|
92
|
+
else if (["properties", "required", "additionalProperties", "items"].some((key) => Object.hasOwn(schema, key)) && !["enum", "const", "anyOf", "oneOf"].some((key) => Object.hasOwn(schema, key))) {
|
|
93
|
+
this.issue(at, "The schema has no keyword type. Thus a value of each JSON type fits it, and the type reads it as unknown.");
|
|
94
|
+
}
|
|
95
|
+
for (const key of ["anyOf", "oneOf"]) {
|
|
96
|
+
if (!Object.hasOwn(schema, key)) continue;
|
|
97
|
+
const items = schema[key];
|
|
98
|
+
if (!Array.isArray(items)) {
|
|
99
|
+
this.issue([...at, key], `The value of ${key} is a list of schemas.`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// A union holds each value of oneOf too. It does not test that only one schema fits.
|
|
103
|
+
sides.push({ key, type: unionOf(items.map((item, index) => this.read(item, [...at, key, index], depth + 1))) });
|
|
104
|
+
}
|
|
105
|
+
// A finite set of values meets each other keyword exactly: the type keeps each value that each side holds.
|
|
106
|
+
const values = this.values(schema, at);
|
|
107
|
+
if (values !== undefined) return { one: values.filter((value) => sides.every((side) => member(value, side.type))) };
|
|
108
|
+
const known = sides.filter((side) => !isUnknown(side.type, undefined));
|
|
109
|
+
const kept = known[0];
|
|
110
|
+
if (kept === undefined) return "unknown";
|
|
111
|
+
for (const side of known.slice(1)) this.issue([...at, side.key], `The type reads the keyword ${kept.key}, and not the keyword ${side.key}.`);
|
|
112
|
+
return kept.type;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** This method reads `enum` and `const`: the values that the schema permits, or `undefined` without them. */
|
|
116
|
+
values(schema: Readonly<Record<string, unknown>>, at: readonly Key[]): Json[] | undefined {
|
|
117
|
+
let values: Json[] | undefined;
|
|
118
|
+
if (Object.hasOwn(schema, "enum")) {
|
|
119
|
+
if (Array.isArray(schema["enum"])) values = [...(schema["enum"] as Json[])];
|
|
120
|
+
else this.issue([...at, "enum"], "The value of enum is a list of JSON values.");
|
|
121
|
+
}
|
|
122
|
+
if (Object.hasOwn(schema, "const")) {
|
|
123
|
+
const one = schema["const"] as Json;
|
|
124
|
+
values = values === undefined ? [one] : values.filter((value) => docEquals(value, one));
|
|
125
|
+
}
|
|
126
|
+
return values;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** This method reads the keyword `type` with the keywords of an object and of an array. */
|
|
130
|
+
byType(schema: Readonly<Record<string, unknown>>, at: readonly Key[], depth: number): ValueType {
|
|
131
|
+
const raw = schema["type"];
|
|
132
|
+
const names = typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : undefined;
|
|
133
|
+
if (names === undefined) {
|
|
134
|
+
this.issue([...at, "type"], "The value of type is a text or a list of texts.");
|
|
135
|
+
return "unknown";
|
|
136
|
+
}
|
|
137
|
+
const types: ValueType[] = [];
|
|
138
|
+
for (const [index, name] of names.entries()) {
|
|
139
|
+
const where = typeof raw === "string" ? [...at, "type"] : [...at, "type", index];
|
|
140
|
+
if (name === "object") types.push(this.object(schema, at, depth));
|
|
141
|
+
else if (name === "array") types.push(this.array(schema, at, depth));
|
|
142
|
+
else if (typeof name === "string" && Object.hasOwn(BASES, name)) {
|
|
143
|
+
if (name === "integer") this.issue(where, "The type reads integer as number. It does not test that the number is whole.");
|
|
144
|
+
types.push(BASES[name] as ValueType);
|
|
145
|
+
} else {
|
|
146
|
+
this.issue(where, `The type ${String(name)} is not a type of JSON Schema. The type reads it as unknown.`);
|
|
147
|
+
return "unknown";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return unionOf(types);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** This method reads an object: its properties, the properties that it needs and the other properties. */
|
|
154
|
+
object(schema: Readonly<Record<string, unknown>>, at: readonly Key[], depth: number): ValueType {
|
|
155
|
+
const fields: Record<string, ValueType> = {};
|
|
156
|
+
const properties = schema["properties"] ?? {};
|
|
157
|
+
if (isRecord(properties)) {
|
|
158
|
+
for (const key of Object.keys(properties)) setOwn(fields, key, this.read(properties[key], [...at, "properties", key], depth + 1));
|
|
159
|
+
} else {
|
|
160
|
+
this.issue([...at, "properties"], "The value of properties is a record of schemas.");
|
|
161
|
+
}
|
|
162
|
+
const required = schema["required"] ?? [];
|
|
163
|
+
const needed = Array.isArray(required) ? required.filter((key): key is string => typeof key === "string") : [];
|
|
164
|
+
if (!Array.isArray(required) || needed.length !== required.length) this.issue([...at, "required"], "The value of required is a list of texts.");
|
|
165
|
+
// A property that `required` names without a schema can hold each value.
|
|
166
|
+
for (const key of needed) if (!Object.hasOwn(fields, key)) setOwn(fields, key, "unknown");
|
|
167
|
+
const optional = Object.keys(fields).filter((key) => !needed.includes(key));
|
|
168
|
+
const other = schema["additionalProperties"];
|
|
169
|
+
// Each property that a pattern names is permitted too, thus the record is closed only without patterns.
|
|
170
|
+
let closed = other === false && !Object.hasOwn(schema, "patternProperties");
|
|
171
|
+
if (other !== undefined && other !== true && other !== false) {
|
|
172
|
+
this.issue([...at, "additionalProperties"], "The type reads additionalProperties with a schema as an open record. The other fields can hold each value.");
|
|
173
|
+
closed = false;
|
|
174
|
+
}
|
|
175
|
+
return { record: fields, ...(optional.length === 0 ? {} : { optional }), ...(closed ? { closed: true } : {}) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** This method reads an array: the schema of its items. */
|
|
179
|
+
array(schema: Readonly<Record<string, unknown>>, at: readonly Key[], depth: number): ValueType {
|
|
180
|
+
const items = schema["items"];
|
|
181
|
+
if (items === undefined) return { list: "unknown" };
|
|
182
|
+
// With prefixItems, the keyword items covers only the items after the prefix.
|
|
183
|
+
if (Object.hasOwn(schema, "prefixItems") || Array.isArray(items)) {
|
|
184
|
+
this.issue([...at, "items"], "The type does not read the items of a tuple. It reads a list of unknown items.");
|
|
185
|
+
return { list: "unknown" };
|
|
186
|
+
}
|
|
187
|
+
return { list: this.read(items, [...at, "items"], depth + 1) };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* This function makes a value type from a JSON Schema (TYPE.SCHEMA). A host uses it for the type of a root, for
|
|
193
|
+
* example the schema of a store for `world`. It reads this subset:
|
|
194
|
+
*
|
|
195
|
+
* - `type`: `null`, `boolean`, `number`, `integer` (as `number`), `string`, `object` and `array`, or a list of them.
|
|
196
|
+
* - `properties`, `required` and `additionalProperties` give a record. `false` closes it, and a property that is not
|
|
197
|
+
* in `required` is optional.
|
|
198
|
+
* - `items` gives a list. `enum` and `const` give `one`. `anyOf` and `oneOf` give a union.
|
|
199
|
+
*
|
|
200
|
+
* The type holds each value that the schema accepts. Each keyword that the function does not read tests nothing, as
|
|
201
|
+
* `unknown`, and the issues name it. Examples are `minimum`, `pattern` and `allOf`. A schema with `$ref` or `nullable`
|
|
202
|
+
* reads as `unknown`, because these keywords can permit more values. A schema with more than one of `type`, `anyOf`
|
|
203
|
+
* and `oneOf` gives the type of the first, and an issue for each other. The function does not throw.
|
|
204
|
+
*
|
|
205
|
+
* @param schema - The schema, as JSON.
|
|
206
|
+
* @returns The type, and the problems of the reading with their keys in the schema.
|
|
207
|
+
*/
|
|
208
|
+
export function typeFromSchema(schema: unknown): TypeReading {
|
|
209
|
+
const reader = new SchemaReader();
|
|
210
|
+
try {
|
|
211
|
+
return { type: reader.read(schema, [], 0), issues: reader.issues };
|
|
212
|
+
} catch (error) {
|
|
213
|
+
// A getter of a host value can throw. The reading then gives the top.
|
|
214
|
+
reader.issue([], `The schema cannot be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
215
|
+
return { type: "unknown", issues: reader.issues };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The options of `typeFromSamples`. */
|
|
220
|
+
export interface SampleOptions {
|
|
221
|
+
/** `true`: the samples show each field, thus each record type is closed. The default is `false`. */
|
|
222
|
+
readonly closed?: boolean;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The deepest sample that `typeFromSamples` reads. A deeper value gives `unknown`. */
|
|
226
|
+
const MAX_SAMPLE_DEPTH = 256;
|
|
227
|
+
|
|
228
|
+
/** This function gives the type of a group of sample values at one depth. */
|
|
229
|
+
function samplesAt(values: readonly unknown[], closed: boolean, depth: number): ValueType {
|
|
230
|
+
if (depth > MAX_SAMPLE_DEPTH) return values.length === 0 ? "never" : "unknown";
|
|
231
|
+
const types: ValueType[] = [];
|
|
232
|
+
const bases = new Set<string>();
|
|
233
|
+
const items: unknown[] = [];
|
|
234
|
+
const records: Readonly<Record<string, unknown>>[] = [];
|
|
235
|
+
let lists = false;
|
|
236
|
+
for (const value of values) {
|
|
237
|
+
if (value === null) bases.add("null");
|
|
238
|
+
else if (typeof value === "boolean") bases.add("bool");
|
|
239
|
+
else if (typeof value === "number") bases.add("number");
|
|
240
|
+
else if (typeof value === "string") bases.add("text");
|
|
241
|
+
else if (Array.isArray(value)) {
|
|
242
|
+
lists = true;
|
|
243
|
+
// A hole of a list holds undefined, thus a loop by index reads it too.
|
|
244
|
+
for (let index = 0; index < value.length; index += 1) items.push(value[index]);
|
|
245
|
+
} else if (isRecord(value)) records.push(value);
|
|
246
|
+
// A value without a type of the kernel, for example a function or undefined, has each type.
|
|
247
|
+
else return "unknown";
|
|
248
|
+
}
|
|
249
|
+
for (const base of ["null", "bool", "number", "text"]) if (bases.has(base)) types.push(base);
|
|
250
|
+
if (lists) types.push({ list: samplesAt(items, closed, depth + 1) });
|
|
251
|
+
if (records.length > 0) {
|
|
252
|
+
// All records join into one record. A field that a record does not have is optional.
|
|
253
|
+
const keys: string[] = [];
|
|
254
|
+
for (const record of records) for (const key of Object.keys(record)) if (!keys.includes(key)) keys.push(key);
|
|
255
|
+
const fields: Record<string, ValueType> = {};
|
|
256
|
+
const optional: string[] = [];
|
|
257
|
+
for (const key of keys) {
|
|
258
|
+
const found = records.filter((record) => Object.hasOwn(record, key));
|
|
259
|
+
if (found.length < records.length) optional.push(key);
|
|
260
|
+
setOwn(fields, key, samplesAt(found.map((record) => record[key]), closed, depth + 1));
|
|
261
|
+
}
|
|
262
|
+
types.push({ record: fields, ...(optional.length === 0 ? {} : { optional }), ...(closed ? { closed: true } : {}) });
|
|
263
|
+
}
|
|
264
|
+
return types.length === 1 ? (types[0] as ValueType) : unionOf(types);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* This function infers a value type from sample values (TYPE.SAMPLES), as a type provider of F# does. A host uses it
|
|
269
|
+
* for the type of a root, for example from some events.
|
|
270
|
+
*
|
|
271
|
+
* - `null`, a truth value, a number and a text give their base types, not their values.
|
|
272
|
+
* - The items of each list join into one type of items.
|
|
273
|
+
* - All records join into one record with each key of the samples. A key that a record does not have is optional.
|
|
274
|
+
* - Values of different forms give a union, as `join` gives. No samples give `never`.
|
|
275
|
+
* - A value without a type of the kernel, for example a function, gives `unknown`.
|
|
276
|
+
*
|
|
277
|
+
* Each sample is a member of the type. A record is open, because samples do not show that a field cannot exist. With
|
|
278
|
+
* `closed: true`, each record is closed, and a reference to a field that no sample has gives `#TYPE!`.
|
|
279
|
+
*
|
|
280
|
+
* @param values - The sample values.
|
|
281
|
+
* @param options - `closed: true` closes each record type.
|
|
282
|
+
* @returns The type.
|
|
283
|
+
*/
|
|
284
|
+
export function typeFromSamples(values: readonly unknown[], options: SampleOptions = {}): ValueType {
|
|
285
|
+
return samplesAt(values, options.closed === true, 0);
|
|
286
|
+
}
|