@mark1russell7/coeval 0.25.0 → 0.25.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package",
3
3
  "name": "@mark1russell7/coeval",
4
- "version": "0.25.0",
4
+ "version": "0.25.1",
5
5
  "description": "coeval: the kernel of declarative languages. Documents in envelopes, a ladder that finds the evaluator, kind contracts, compiled plans, a static kit, a formula kit and a run side with capabilities.",
6
6
  "type": "module",
7
7
  "main": "./build/index.js",
package/src/index.ts CHANGED
@@ -91,6 +91,7 @@ export {
91
91
  restrictMachine,
92
92
  restrictWorld,
93
93
  } from "./merge.ts";
94
+ export { type Language, type LanguageCode, type LanguageDefinition, compose, defineLanguage } from "./language.ts";
94
95
  export {
95
96
  type Answer,
96
97
  type AskOptions,
@@ -0,0 +1,147 @@
1
+ import { type Key, isRecord } from "./doc.ts";
2
+ import type { Machine } from "./evaluate.ts";
3
+ import { type MachinePart, type MergeConflict, type WorldPart, MergeError, mergeMachines } from "./merge.ts";
4
+
5
+ /** The code half of a language: each field of a machine part, without the world. */
6
+ export type LanguageCode = Omit<MachinePart, "world">;
7
+
8
+ /** The input of `defineLanguage`. */
9
+ export interface LanguageDefinition {
10
+ /** The name of the language. A composition holds one language of a name. */
11
+ readonly name: string;
12
+ /** The version, as `MAJOR.MINOR.PATCH`. */
13
+ readonly version: string;
14
+ /** The world of the language, with the verbs that it owns. It is JSON. */
15
+ readonly world?: WorldPart;
16
+ /** The lowest compatible version of each language that this language needs, by name (LANG.REQUIRES). */
17
+ readonly requires?: Readonly<Record<string, string>>;
18
+ /** The evaluators, the hooks and the other code. A function reads the merge of the languages before it. */
19
+ readonly code?: LanguageCode | ((prev: Machine) => LanguageCode);
20
+ }
21
+
22
+ /** One language as one frozen value, for `compose`. */
23
+ export interface Language {
24
+ readonly name: string;
25
+ readonly version: string;
26
+ readonly world: WorldPart;
27
+ readonly requires: Readonly<Record<string, string>>;
28
+ readonly code?: LanguageCode | ((prev: Machine) => LanguageCode);
29
+ /**
30
+ * The canonical JSON of the data half: `requires` and `world`, with the keys in order. Two equal data halves give
31
+ * one text, in each order of their keys (LANG.IDENTITY). The name, the version and this text identify a language.
32
+ */
33
+ readonly data: string;
34
+ }
35
+
36
+ const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
37
+
38
+ function numbers(version: string): readonly [number, number, number] {
39
+ const found = VERSION.exec(version);
40
+ if (found === null) throw new TypeError(`A language version has the form MAJOR.MINOR.PATCH. The text ${JSON.stringify(version)} does not.`);
41
+ return [Number(found[1]), Number(found[2]), Number(found[3])];
42
+ }
43
+
44
+ /**
45
+ * This function tells if a version satisfies a lowest version, by the rules of a caret range of SemVer. The first
46
+ * number that is not zero stays the same, and the version is not lower.
47
+ */
48
+ function compatible(version: string, lowest: string): boolean {
49
+ const have = numbers(version);
50
+ const want = numbers(lowest);
51
+ const stable = want.findIndex((part) => part !== 0);
52
+ const fixed = stable < 0 ? 3 : stable + 1;
53
+ for (let index = 0; index < 3; index += 1) {
54
+ const [a, b] = [have[index] as number, want[index] as number];
55
+ if (a !== b) return index >= fixed && a > b;
56
+ }
57
+ return true;
58
+ }
59
+
60
+ /** This function gives the JSON text of a value with its keys in order. A value that is not JSON is an error. */
61
+ function canonical(value: unknown, path: readonly Key[]): string {
62
+ if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
63
+ if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
64
+ if (Array.isArray(value)) return `[${value.map((item, index) => canonical(item, [...path, index])).join(",")}]`;
65
+ if (!isRecord(value)) throw new TypeError(`The data half of a language is JSON. The value at ${JSON.stringify(path)} is not.`);
66
+ const keys = Object.keys(value)
67
+ .filter((key) => value[key] !== undefined)
68
+ .sort();
69
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonical(value[key], [...path, key])}`).join(",")}}`;
70
+ }
71
+
72
+ function frozen<T>(value: T): T {
73
+ if (typeof value !== "object" || value === null) return value;
74
+ for (const item of Object.values(value)) frozen(item);
75
+ return Object.freeze(value);
76
+ }
77
+
78
+ /**
79
+ * This function makes one value of a language. The data half is JSON, thus a host can store it and compare it. The
80
+ * code half binds the evaluator names of the world.
81
+ *
82
+ * The language keeps a frozen copy of its data half, thus a later change to the input changes nothing.
83
+ *
84
+ * @param definition - The name, the version, the world, the needed languages and the code.
85
+ * @returns The language.
86
+ * @throws {TypeError} A version in the definition is not `MAJOR.MINOR.PATCH`, the name is empty, or the
87
+ * data half is not JSON.
88
+ */
89
+ export function defineLanguage(definition: LanguageDefinition): Language {
90
+ if (definition.name === "") throw new TypeError("A language has a name.");
91
+ numbers(definition.version);
92
+ const requires = definition.requires ?? {};
93
+ for (const name of Object.keys(requires)) numbers(requires[name] as string);
94
+ const data = canonical({ requires, world: definition.world ?? {} }, []);
95
+ const copy = frozen(JSON.parse(data) as { readonly requires: Readonly<Record<string, string>>; readonly world: WorldPart });
96
+ const language = { name: definition.name, version: definition.version, world: copy.world, requires: copy.requires, data };
97
+ return Object.freeze(definition.code === undefined ? language : { ...language, code: definition.code });
98
+ }
99
+
100
+ function partOf(language: Language): MachinePart | ((prev: Machine) => MachinePart) {
101
+ const { code, world } = language;
102
+ return typeof code === "function" ? (prev) => ({ ...code(prev), world }) : { ...code, world };
103
+ }
104
+
105
+ /**
106
+ * This function merges languages into one machine, in the order of the arguments, with `mergeMachines`.
107
+ *
108
+ * - A language comes after each language that it names in `requires`, at a compatible version (LANG.REQUIRES).
109
+ * - One language that comes twice counts once. Thus two languages can share a base.
110
+ * - Two versions of one name, or two data halves under one name and one version, are conflicts (LANG.COMPOSE).
111
+ *
112
+ * @param languages - The languages.
113
+ * @returns The machine.
114
+ * @throws {MergeError} The languages do not fit, or their merge has a conflict. The error lists each conflict.
115
+ */
116
+ export function compose(...languages: readonly Language[]): Machine {
117
+ const conflicts: MergeConflict[] = [];
118
+ const parts: (MachinePart | ((prev: Machine) => MachinePart))[] = [];
119
+ const seen = new Map<string, { readonly language: Language; readonly index: number }>();
120
+ languages.forEach((language, index) => {
121
+ const earlier = seen.get(language.name);
122
+ const conflict = (name: string, other: number, reason: string): void => {
123
+ conflicts.push({ path: ["languages", name], parts: [Math.min(other, index), Math.max(other, index)], reason });
124
+ };
125
+ if (earlier !== undefined) {
126
+ if (earlier.language.version !== language.version) conflict(language.name, earlier.index, "Two versions of one language.");
127
+ else if (earlier.language.data !== language.data) conflict(language.name, earlier.index, "Two languages with one name and one version differ.");
128
+ else if (earlier.language.code !== language.code) parts.push(partOf(language));
129
+ return;
130
+ }
131
+ for (const name of Object.keys(language.requires)) {
132
+ const needed = language.requires[name] as string;
133
+ const found = seen.get(name);
134
+ if (found === undefined) {
135
+ const later = languages.findIndex((other) => other.name === name);
136
+ if (later < 0) conflict(name, index, `A language requires ${name}, and the composition does not have it.`);
137
+ else conflict(name, later, `A language requires ${name}, and ${name} comes after it.`);
138
+ } else if (!compatible(found.language.version, needed)) {
139
+ conflict(name, found.index, `A language requires ${name} ${needed}, and the composition has ${found.language.version}.`);
140
+ }
141
+ }
142
+ seen.set(language.name, { language, index });
143
+ parts.push(partOf(language));
144
+ });
145
+ if (conflicts.length > 0) throw new MergeError(conflicts);
146
+ return mergeMachines(...parts);
147
+ }