@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/syntax.ts
ADDED
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
import { type Json, type Key, isEnvelope, isRecord, typePath } from "./doc.ts";
|
|
2
|
+
import { type Result, describe, fail, isStackOverflow, ok } from "./errors.ts";
|
|
3
|
+
import type { EvalFn, Part } from "./evaluate.ts";
|
|
4
|
+
import type { MachinePart } from "./merge.ts";
|
|
5
|
+
import { encodeValue } from "./codec.ts";
|
|
6
|
+
|
|
7
|
+
/** The place of an operator: before its term, between two terms, or after its term. */
|
|
8
|
+
export type SyntaxFixity = "prefix" | "infix" | "postfix";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The grouping of a chain of one infix operator. `left` reads `a - b - c` as `(a - b) - c`, and `right` reads
|
|
12
|
+
* `a ^ b ^ c` as `a ^ (b ^ c)`. `none` refuses a chain. `flat` reads a chain as one document with each term.
|
|
13
|
+
*/
|
|
14
|
+
export type SyntaxAssoc = "left" | "right" | "none" | "flat";
|
|
15
|
+
|
|
16
|
+
/** One operator of a syntax profile. */
|
|
17
|
+
export interface SyntaxOperator {
|
|
18
|
+
/** The text of the operator, for example `+`, `<>` or `and`. A word operator is a plain name. */
|
|
19
|
+
readonly symbol: string;
|
|
20
|
+
/** The place of the operator. */
|
|
21
|
+
readonly fixity: SyntaxFixity;
|
|
22
|
+
/** The precedence. A larger number binds more tightly. */
|
|
23
|
+
readonly prec: number;
|
|
24
|
+
/** The grouping of a chain of an infix operator. The default is `left`. */
|
|
25
|
+
readonly assoc?: SyntaxAssoc;
|
|
26
|
+
/** The type of the document that the operator makes, with dots between the keys, for example `ops.add` or `and`. */
|
|
27
|
+
readonly kind: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The literal rules of a syntax profile. */
|
|
31
|
+
export interface SyntaxLiterals {
|
|
32
|
+
/** `false` removes the decimal numbers. The default is `true`. */
|
|
33
|
+
readonly numbers?: boolean;
|
|
34
|
+
/** `true` puts a minus sign right before a digit into the number, where a term starts. The default is `true`. */
|
|
35
|
+
readonly signed?: boolean;
|
|
36
|
+
/** The texts: the quote and the escape of a quote. `false` removes the texts. The default is `"` with backslashes. */
|
|
37
|
+
readonly strings?: false | { readonly quote?: '"' | "'"; readonly escape?: "backslash" | "double" };
|
|
38
|
+
/** The words that are literals, with their values. The default is `true`, `false` and `null`. */
|
|
39
|
+
readonly words?: Readonly<Record<string, unknown>>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A reference syntax that a pattern gives, for example the address `A1` of a cell. */
|
|
43
|
+
export interface SyntaxRefPattern {
|
|
44
|
+
/** The pattern of the text of a reference. The parser tries it before a name. */
|
|
45
|
+
readonly pattern: RegExp;
|
|
46
|
+
/** This function gives the path of the reference from its text. */
|
|
47
|
+
readonly read: (text: string) => readonly Key[];
|
|
48
|
+
/** This function gives the text of a path, or `undefined` when the pattern does not write the path. */
|
|
49
|
+
readonly write: (path: readonly unknown[]) => string | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The reference syntax of a syntax profile. */
|
|
53
|
+
export interface SyntaxRefs {
|
|
54
|
+
/** The root of a bare name. The default is `names`. Thus `x` is the path `["names", "x"]`. */
|
|
55
|
+
readonly bare?: string;
|
|
56
|
+
/** The roots that the text names first, for example `self.price`. The default is `self`, `world`, `event` and `caps`. */
|
|
57
|
+
readonly roots?: readonly string[];
|
|
58
|
+
/** `false` removes the field steps `a.b`. The default is `true`. */
|
|
59
|
+
readonly fields?: boolean;
|
|
60
|
+
/** The patterns of other references. */
|
|
61
|
+
readonly patterns?: readonly SyntaxRefPattern[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A call form: a name with arguments in parentheses that makes a construct, for example `if(test, then, else)`. */
|
|
65
|
+
export interface SyntaxForm {
|
|
66
|
+
/** The name in the text. */
|
|
67
|
+
readonly name: string;
|
|
68
|
+
/** The type of the document, with dots between the keys. */
|
|
69
|
+
readonly kind: string;
|
|
70
|
+
/** The fields of the value, in the order of the arguments. Without them, the value is the list of the arguments. */
|
|
71
|
+
readonly fields?: readonly string[];
|
|
72
|
+
/** The smallest number of arguments. The default is the number of the fields, or `0`. */
|
|
73
|
+
readonly min?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The call syntax of a syntax profile. */
|
|
77
|
+
export interface SyntaxCalls {
|
|
78
|
+
/** The operations that a call `name(args)` can name. `true` permits each name. The default is `true`. */
|
|
79
|
+
readonly ops?: boolean | readonly string[];
|
|
80
|
+
/** The call forms of constructs. A form wins over an operation with the same name. */
|
|
81
|
+
readonly forms?: readonly SyntaxForm[];
|
|
82
|
+
/** The text between two arguments. The default is `,`. */
|
|
83
|
+
readonly separator?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The text syntax of a language (D41): an operator table, literal rules, reference syntax and call syntax. `parse`
|
|
88
|
+
* reads a text into a document, and `print` writes a document as text. The profile is data, thus a site, an editor
|
|
89
|
+
* and a language read the same rules.
|
|
90
|
+
*/
|
|
91
|
+
export interface SyntaxProfile {
|
|
92
|
+
/** The operators. */
|
|
93
|
+
readonly operators?: readonly SyntaxOperator[];
|
|
94
|
+
/** The literal rules. */
|
|
95
|
+
readonly literals?: SyntaxLiterals;
|
|
96
|
+
/** The reference syntax. */
|
|
97
|
+
readonly refs?: SyntaxRefs;
|
|
98
|
+
/** The call syntax. */
|
|
99
|
+
readonly calls?: SyntaxCalls;
|
|
100
|
+
/** The deepest nesting that `parse` accepts. The default is `256`. */
|
|
101
|
+
readonly maxDepth?: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A problem of a text: its character offset, its length and a message. */
|
|
105
|
+
export interface SyntaxIssue {
|
|
106
|
+
/** The 0-based character offset of the problem. */
|
|
107
|
+
readonly offset: number;
|
|
108
|
+
/** The number of characters of the problem. `0` marks a place between two characters. */
|
|
109
|
+
readonly length: number;
|
|
110
|
+
/** The message. */
|
|
111
|
+
readonly message: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The result of `parse`: a document, or the problems of the text. */
|
|
115
|
+
export type ParseResult = { readonly ok: true; readonly doc: Json } | { readonly ok: false; readonly issues: readonly SyntaxIssue[] };
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The standard syntax of the formula kit. It gives the site and the playground a text form. The operators are
|
|
119
|
+
* `or`, `and`, `not`, the comparisons, `+ - * / %`, a minus sign and `^`, from the loosest to the tightest. A call
|
|
120
|
+
* `name(args)` applies the operation `ops.name`. `if(test, then, else)` and `list(items)` make the constructs.
|
|
121
|
+
*/
|
|
122
|
+
export const STD_SYNTAX: SyntaxProfile = /* @__PURE__ */ Object.freeze({
|
|
123
|
+
operators: [
|
|
124
|
+
{ symbol: "or", fixity: "infix", prec: 1, assoc: "flat", kind: "or" },
|
|
125
|
+
{ symbol: "and", fixity: "infix", prec: 2, assoc: "flat", kind: "and" },
|
|
126
|
+
{ symbol: "not", fixity: "prefix", prec: 3, kind: "ops.not" },
|
|
127
|
+
{ symbol: "==", fixity: "infix", prec: 4, assoc: "none", kind: "ops.eq" },
|
|
128
|
+
{ symbol: "!=", fixity: "infix", prec: 4, assoc: "none", kind: "ops.ne" },
|
|
129
|
+
{ symbol: "<", fixity: "infix", prec: 4, assoc: "none", kind: "ops.lt" },
|
|
130
|
+
{ symbol: "<=", fixity: "infix", prec: 4, assoc: "none", kind: "ops.le" },
|
|
131
|
+
{ symbol: ">", fixity: "infix", prec: 4, assoc: "none", kind: "ops.gt" },
|
|
132
|
+
{ symbol: ">=", fixity: "infix", prec: 4, assoc: "none", kind: "ops.ge" },
|
|
133
|
+
{ symbol: "+", fixity: "infix", prec: 5, kind: "ops.add" },
|
|
134
|
+
{ symbol: "-", fixity: "infix", prec: 5, kind: "ops.sub" },
|
|
135
|
+
{ symbol: "*", fixity: "infix", prec: 6, kind: "ops.mul" },
|
|
136
|
+
{ symbol: "/", fixity: "infix", prec: 6, kind: "ops.div" },
|
|
137
|
+
{ symbol: "%", fixity: "infix", prec: 6, kind: "ops.mod" },
|
|
138
|
+
{ symbol: "-", fixity: "prefix", prec: 7, kind: "ops.neg" },
|
|
139
|
+
{ symbol: "^", fixity: "infix", prec: 8, assoc: "right", kind: "ops.pow" },
|
|
140
|
+
],
|
|
141
|
+
literals: { words: { true: true, false: false, null: null, undefined: undefined, NaN: NaN, Infinity: Infinity } },
|
|
142
|
+
calls: {
|
|
143
|
+
forms: [
|
|
144
|
+
{ name: "if", kind: "if", fields: ["test", "then", "else"], min: 2 },
|
|
145
|
+
{ name: "list", kind: "list" },
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
} satisfies SyntaxProfile);
|
|
149
|
+
|
|
150
|
+
// === The compiled profile ===
|
|
151
|
+
|
|
152
|
+
interface Op {
|
|
153
|
+
readonly symbol: string;
|
|
154
|
+
readonly fixity: SyntaxFixity;
|
|
155
|
+
readonly prec: number;
|
|
156
|
+
readonly assoc: SyntaxAssoc;
|
|
157
|
+
readonly type: Json;
|
|
158
|
+
readonly kindKey: string;
|
|
159
|
+
readonly word: boolean;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface Form {
|
|
163
|
+
readonly name: string;
|
|
164
|
+
readonly type: Json;
|
|
165
|
+
readonly fields: readonly string[] | undefined;
|
|
166
|
+
readonly min: number;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface Pattern {
|
|
170
|
+
readonly sticky: RegExp;
|
|
171
|
+
readonly whole: RegExp;
|
|
172
|
+
readonly read: (text: string) => readonly Key[];
|
|
173
|
+
readonly write: (path: readonly unknown[]) => string | undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface Compiled {
|
|
177
|
+
readonly prefix: ReadonlyMap<string, Op>;
|
|
178
|
+
readonly binary: ReadonlyMap<string, Op>;
|
|
179
|
+
readonly prefixSymbols: readonly string[];
|
|
180
|
+
readonly binarySymbols: readonly string[];
|
|
181
|
+
readonly byKind: ReadonlyMap<string, readonly Op[]>;
|
|
182
|
+
readonly numbers: boolean;
|
|
183
|
+
readonly signed: boolean;
|
|
184
|
+
readonly quote: string | undefined;
|
|
185
|
+
readonly doubled: boolean;
|
|
186
|
+
readonly words: ReadonlyMap<string, unknown>;
|
|
187
|
+
readonly bare: string;
|
|
188
|
+
readonly roots: ReadonlySet<string>;
|
|
189
|
+
readonly fields: boolean;
|
|
190
|
+
readonly patterns: readonly Pattern[];
|
|
191
|
+
readonly ops: true | ReadonlySet<string>;
|
|
192
|
+
readonly forms: ReadonlyMap<string, Form>;
|
|
193
|
+
readonly formsByKind: ReadonlyMap<string, readonly Form[]>;
|
|
194
|
+
readonly separator: string;
|
|
195
|
+
readonly reserved: ReadonlySet<string>;
|
|
196
|
+
readonly opWords: ReadonlySet<string>;
|
|
197
|
+
readonly maxDepth: number;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
201
|
+
const NAME_AT = /[A-Za-z_][A-Za-z0-9_]*/y;
|
|
202
|
+
const DIGITS_AT = /[0-9]+/y;
|
|
203
|
+
const NUMBER_AT = /(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/y;
|
|
204
|
+
const SYMBOL = /^[^\sA-Za-z0-9_"'`()$.]+$/;
|
|
205
|
+
const DEFAULT_WORDS: Readonly<Record<string, unknown>> = { true: true, false: false, null: null };
|
|
206
|
+
const DEFAULT_ROOTS: readonly string[] = ["self", "world", "event", "caps"];
|
|
207
|
+
|
|
208
|
+
const compiledProfiles = new WeakMap<SyntaxProfile, Compiled>();
|
|
209
|
+
|
|
210
|
+
function typeOfKind(kind: string): { readonly type: Json; readonly key: string } {
|
|
211
|
+
const keys = kind.split(".");
|
|
212
|
+
if (kind === "" || keys.some((key) => key === "")) throw new TypeError(`The kind "${kind}" of a syntax profile has an empty key.`);
|
|
213
|
+
return { type: keys.length === 1 ? (keys[0] as string) : keys, key: JSON.stringify(keys) };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function addTo<V>(map: Map<string, V[]>, key: string, value: V): void {
|
|
217
|
+
const list = map.get(key);
|
|
218
|
+
if (list === undefined) map.set(key, [value]);
|
|
219
|
+
else list.push(value);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function compileOps(profile: SyntaxProfile, separator: string): Pick<Compiled, "prefix" | "binary" | "prefixSymbols" | "binarySymbols" | "byKind" | "opWords"> {
|
|
223
|
+
const prefix = new Map<string, Op>();
|
|
224
|
+
const binary = new Map<string, Op>();
|
|
225
|
+
const byKind = new Map<string, Op[]>();
|
|
226
|
+
const assocAt = new Map<number, SyntaxAssoc>();
|
|
227
|
+
const opWords = new Set<string>();
|
|
228
|
+
for (const given of profile.operators ?? []) {
|
|
229
|
+
const word = NAME.test(given.symbol);
|
|
230
|
+
if (!word && (!SYMBOL.test(given.symbol) || given.symbol.includes(separator))) {
|
|
231
|
+
throw new TypeError(`The operator "${given.symbol}" is not a plain name or a group of symbols.`);
|
|
232
|
+
}
|
|
233
|
+
if (!Number.isFinite(given.prec)) throw new TypeError(`The operator "${given.symbol}" has no finite precedence.`);
|
|
234
|
+
const { type, key } = typeOfKind(given.kind);
|
|
235
|
+
const op: Op = { symbol: given.symbol, fixity: given.fixity, prec: given.prec, assoc: given.assoc ?? "left", type, kindKey: key, word };
|
|
236
|
+
const table = op.fixity === "prefix" ? prefix : binary;
|
|
237
|
+
if (table.has(op.symbol)) throw new TypeError(`The operator "${op.symbol}" has two entries of the same place.`);
|
|
238
|
+
table.set(op.symbol, op);
|
|
239
|
+
if (op.fixity === "infix") {
|
|
240
|
+
const assoc = assocAt.get(op.prec);
|
|
241
|
+
if (assoc !== undefined && assoc !== op.assoc) throw new TypeError(`The infix operators of the precedence ${String(op.prec)} have different groupings.`);
|
|
242
|
+
assocAt.set(op.prec, op.assoc);
|
|
243
|
+
}
|
|
244
|
+
if (word) opWords.add(op.symbol);
|
|
245
|
+
addTo(byKind, key, op);
|
|
246
|
+
}
|
|
247
|
+
const longestFirst = (table: Map<string, Op>): string[] =>
|
|
248
|
+
[...table.keys()].filter((symbol) => !NAME.test(symbol)).sort((a, b) => b.length - a.length);
|
|
249
|
+
return { prefix, binary, prefixSymbols: longestFirst(prefix), binarySymbols: longestFirst(binary), byKind, opWords };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function compileProfile(profile: SyntaxProfile): Compiled {
|
|
253
|
+
const cached = compiledProfiles.get(profile);
|
|
254
|
+
if (cached !== undefined) return cached;
|
|
255
|
+
const calls = profile.calls ?? {};
|
|
256
|
+
const separator = calls.separator ?? ",";
|
|
257
|
+
const ops = compileOps(profile, separator);
|
|
258
|
+
const literals = profile.literals ?? {};
|
|
259
|
+
const strings = literals.strings === false ? undefined : (literals.strings ?? {});
|
|
260
|
+
const words = new Map(Object.entries(literals.words ?? DEFAULT_WORDS));
|
|
261
|
+
const refs = profile.refs ?? {};
|
|
262
|
+
const roots = new Set(refs.roots ?? DEFAULT_ROOTS);
|
|
263
|
+
const forms = new Map<string, Form>();
|
|
264
|
+
const formsByKind = new Map<string, Form[]>();
|
|
265
|
+
for (const given of calls.forms ?? []) {
|
|
266
|
+
if (!NAME.test(given.name)) throw new TypeError(`The call form "${given.name}" is not a plain name.`);
|
|
267
|
+
const { type, key } = typeOfKind(given.kind);
|
|
268
|
+
const form: Form = { name: given.name, type, fields: given.fields, min: given.min ?? given.fields?.length ?? 0 };
|
|
269
|
+
forms.set(form.name, form);
|
|
270
|
+
addTo(formsByKind, key, form);
|
|
271
|
+
}
|
|
272
|
+
const compiled: Compiled = {
|
|
273
|
+
...ops,
|
|
274
|
+
numbers: literals.numbers ?? true,
|
|
275
|
+
signed: literals.signed ?? true,
|
|
276
|
+
quote: strings === undefined ? undefined : (strings.quote ?? '"'),
|
|
277
|
+
doubled: strings !== undefined && strings.escape === "double",
|
|
278
|
+
words,
|
|
279
|
+
bare: refs.bare ?? "names",
|
|
280
|
+
roots,
|
|
281
|
+
fields: refs.fields ?? true,
|
|
282
|
+
patterns: (refs.patterns ?? []).map((p) => {
|
|
283
|
+
const flags = p.pattern.flags.replace(/[gy]/g, "");
|
|
284
|
+
return { sticky: new RegExp(p.pattern.source, `${flags}y`), whole: new RegExp(`^(?:${p.pattern.source})$`, flags), read: p.read, write: p.write };
|
|
285
|
+
}),
|
|
286
|
+
ops: calls.ops === undefined || calls.ops === true ? true : new Set(calls.ops === false ? [] : calls.ops),
|
|
287
|
+
forms,
|
|
288
|
+
formsByKind,
|
|
289
|
+
separator,
|
|
290
|
+
reserved: new Set([...words.keys(), ...ops.opWords, ...roots]),
|
|
291
|
+
maxDepth: profile.maxDepth ?? 256,
|
|
292
|
+
};
|
|
293
|
+
compiledProfiles.set(profile, compiled);
|
|
294
|
+
return compiled;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// === Parse ===
|
|
298
|
+
|
|
299
|
+
/** The stop of a parse. The parser throws it, and `parse` gives its issue. */
|
|
300
|
+
class Stop {
|
|
301
|
+
constructor(readonly issue: SyntaxIssue) {}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function literalDoc(value: unknown): Json {
|
|
305
|
+
return encodeValue({}, value);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function opDoc(op: Op | Form, value: Json): Json {
|
|
309
|
+
return { type: op.type, value };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const ESCAPES: Readonly<Record<string, string>> = { '"': '"', "'": "'", "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" };
|
|
313
|
+
|
|
314
|
+
class Parser {
|
|
315
|
+
private pos = 0;
|
|
316
|
+
private depth = 0;
|
|
317
|
+
|
|
318
|
+
constructor(
|
|
319
|
+
private readonly syn: Compiled,
|
|
320
|
+
private readonly text: string,
|
|
321
|
+
) {}
|
|
322
|
+
|
|
323
|
+
parse(): Json {
|
|
324
|
+
this.skip();
|
|
325
|
+
if (this.pos >= this.text.length) this.stop("The text is empty.", 0, 0);
|
|
326
|
+
const doc = this.expr(0);
|
|
327
|
+
this.skip();
|
|
328
|
+
if (this.pos < this.text.length) this.stop("The text continues after the expression.", this.pos, this.tokenLength());
|
|
329
|
+
return doc;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
position(): number {
|
|
333
|
+
return this.pos;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private stop(message: string, offset: number, length: number): never {
|
|
337
|
+
throw new Stop({ offset, length, message });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private skip(): void {
|
|
341
|
+
while (this.pos < this.text.length && /\s/.test(this.text[this.pos] as string)) this.pos += 1;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private at(pattern: RegExp, pos: number = this.pos): string | undefined {
|
|
345
|
+
pattern.lastIndex = pos;
|
|
346
|
+
return pattern.exec(this.text)?.[0];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private tokenLength(): number {
|
|
350
|
+
return (this.at(NAME_AT) ?? this.at(DIGITS_AT) ?? "x").length;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** This method reads an expression by precedence climbing. It stops at an operator below `min`. */
|
|
354
|
+
private expr(min: number): Json {
|
|
355
|
+
const start = this.pos;
|
|
356
|
+
this.depth += 1;
|
|
357
|
+
if (this.depth > this.syn.maxDepth) this.stop(`The text is deeper than ${String(this.syn.maxDepth)} levels.`, start, 0);
|
|
358
|
+
let left = this.operand();
|
|
359
|
+
for (;;) {
|
|
360
|
+
this.skip();
|
|
361
|
+
const found = this.operatorAt();
|
|
362
|
+
if (found === undefined || found.op.prec < min) break;
|
|
363
|
+
const op = found.op;
|
|
364
|
+
this.pos += found.length;
|
|
365
|
+
if (op.fixity === "postfix") {
|
|
366
|
+
left = opDoc(op, [left]);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (op.assoc === "flat") {
|
|
370
|
+
const terms = [left, this.expr(op.prec + 1)];
|
|
371
|
+
for (;;) {
|
|
372
|
+
this.skip();
|
|
373
|
+
const next = this.operatorAt();
|
|
374
|
+
if (next === undefined || next.op.fixity !== "infix" || next.op.kindKey !== op.kindKey) break;
|
|
375
|
+
this.pos += next.length;
|
|
376
|
+
terms.push(this.expr(op.prec + 1));
|
|
377
|
+
}
|
|
378
|
+
left = opDoc(op, terms);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
left = opDoc(op, [left, this.expr(op.assoc === "right" ? op.prec : op.prec + 1)]);
|
|
382
|
+
if (op.assoc === "none") {
|
|
383
|
+
this.skip();
|
|
384
|
+
const next = this.operatorAt();
|
|
385
|
+
if (next !== undefined && next.op.fixity === "infix" && next.op.prec === op.prec) {
|
|
386
|
+
this.stop(`The operator ${next.op.symbol} does not chain after ${op.symbol}. Add parentheses.`, this.pos, next.length);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
this.depth -= 1;
|
|
391
|
+
return left;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** This method finds an infix or a postfix operator at the position. */
|
|
395
|
+
private operatorAt(): { readonly op: Op; readonly length: number } | undefined {
|
|
396
|
+
const name = this.at(NAME_AT);
|
|
397
|
+
if (name !== undefined) {
|
|
398
|
+
const op = this.syn.binary.get(name);
|
|
399
|
+
return op !== undefined && op.word ? { op, length: name.length } : undefined;
|
|
400
|
+
}
|
|
401
|
+
for (const symbol of this.syn.binarySymbols) {
|
|
402
|
+
if (this.text.startsWith(symbol, this.pos)) return { op: this.syn.binary.get(symbol) as Op, length: symbol.length };
|
|
403
|
+
}
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private operand(): Json {
|
|
408
|
+
this.skip();
|
|
409
|
+
const start = this.pos;
|
|
410
|
+
const c = this.text[start];
|
|
411
|
+
if (c === undefined) return this.stop("A term is missing at the end of the text.", start, 0);
|
|
412
|
+
for (const pattern of this.syn.patterns) {
|
|
413
|
+
const match = this.at(pattern.sticky);
|
|
414
|
+
if (match !== undefined && match !== "" && !/[A-Za-z0-9_$(]/.test(this.text[start + match.length] ?? "")) {
|
|
415
|
+
this.pos += match.length;
|
|
416
|
+
return { type: "ref", value: pattern.read(match) as Json };
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (/[0-9]/.test(c)) return this.number(false);
|
|
420
|
+
if (c === "-" && this.syn.signed && /[0-9]/.test(this.text[start + 1] ?? "")) return this.number(true);
|
|
421
|
+
if (c === this.syn.quote) return this.string();
|
|
422
|
+
if (c === "(") return this.group();
|
|
423
|
+
if (c === "`" || /[A-Za-z_]/.test(c)) return this.named();
|
|
424
|
+
for (const symbol of this.syn.prefixSymbols) {
|
|
425
|
+
if (this.text.startsWith(symbol, start)) {
|
|
426
|
+
this.pos += symbol.length;
|
|
427
|
+
const op = this.syn.prefix.get(symbol) as Op;
|
|
428
|
+
return opDoc(op, [this.expr(op.prec + 1)]);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (c === ")" || this.text.startsWith(this.syn.separator, start)) return this.stop("A term is missing.", start, 0);
|
|
432
|
+
return this.stop(`The text "${c}" cannot start a term.`, start, 1);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private number(negative: boolean): Json {
|
|
436
|
+
const start = this.pos;
|
|
437
|
+
if (!this.syn.numbers) this.stop("This syntax has no numbers.", start, this.tokenLength());
|
|
438
|
+
if (negative) this.pos += 1;
|
|
439
|
+
const digits = this.at(NUMBER_AT) as string;
|
|
440
|
+
this.pos += digits.length;
|
|
441
|
+
const value = Number(digits);
|
|
442
|
+
return literalDoc(negative ? -value : value);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private string(): Json {
|
|
446
|
+
const start = this.pos;
|
|
447
|
+
const quote = this.syn.quote as string;
|
|
448
|
+
let out = "";
|
|
449
|
+
this.pos += 1;
|
|
450
|
+
for (;;) {
|
|
451
|
+
const c = this.text[this.pos];
|
|
452
|
+
if (c === undefined) return this.stop("A closing quote is missing.", start, this.text.length - start);
|
|
453
|
+
if (c === quote) {
|
|
454
|
+
if (this.syn.doubled && this.text[this.pos + 1] === quote) {
|
|
455
|
+
out += quote;
|
|
456
|
+
this.pos += 2;
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
this.pos += 1;
|
|
460
|
+
return literalDoc(out);
|
|
461
|
+
}
|
|
462
|
+
if (c === "\\" && !this.syn.doubled) {
|
|
463
|
+
out += this.escape();
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
out += c;
|
|
467
|
+
this.pos += 1;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private escape(): string {
|
|
472
|
+
const start = this.pos;
|
|
473
|
+
const c = this.text[start + 1] ?? "";
|
|
474
|
+
if (c === "u") {
|
|
475
|
+
const hex = this.text.slice(start + 2, start + 6);
|
|
476
|
+
if (!/^[0-9A-Fa-f]{4}$/.test(hex)) return this.stop("The escape \\u needs four hex digits.", start, 2);
|
|
477
|
+
this.pos += 6;
|
|
478
|
+
return String.fromCharCode(Number.parseInt(hex, 16));
|
|
479
|
+
}
|
|
480
|
+
const out = Object.hasOwn(ESCAPES, c) ? ESCAPES[c] : undefined;
|
|
481
|
+
if (out === undefined) return this.stop(`The escape \\${c} is not valid.`, start, 2);
|
|
482
|
+
this.pos += 2;
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private group(): Json {
|
|
487
|
+
const open = this.pos;
|
|
488
|
+
this.pos += 1;
|
|
489
|
+
const inner = this.expr(0);
|
|
490
|
+
this.skip();
|
|
491
|
+
if (this.text[this.pos] !== ")") {
|
|
492
|
+
if (this.pos >= this.text.length) this.stop("A closing parenthesis is missing.", open, 1);
|
|
493
|
+
this.stop("A closing parenthesis is missing.", this.pos, this.tokenLength());
|
|
494
|
+
}
|
|
495
|
+
this.pos += 1;
|
|
496
|
+
return inner;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** This method reads a name in backticks. Two backticks in the name are one backtick. */
|
|
500
|
+
private quoted(): string {
|
|
501
|
+
const start = this.pos;
|
|
502
|
+
let out = "";
|
|
503
|
+
this.pos += 1;
|
|
504
|
+
for (;;) {
|
|
505
|
+
const c = this.text[this.pos];
|
|
506
|
+
if (c === undefined) return this.stop("A closing backtick is missing.", start, this.text.length - start);
|
|
507
|
+
if (c === "`") {
|
|
508
|
+
if (this.text[this.pos + 1] !== "`") {
|
|
509
|
+
this.pos += 1;
|
|
510
|
+
return out;
|
|
511
|
+
}
|
|
512
|
+
this.pos += 1;
|
|
513
|
+
}
|
|
514
|
+
out += c;
|
|
515
|
+
this.pos += 1;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** This method reads a name: a word operator, a call, a literal word or a reference. */
|
|
520
|
+
private named(): Json {
|
|
521
|
+
const start = this.pos;
|
|
522
|
+
const plain = this.text[start] !== "`";
|
|
523
|
+
const first = plain ? (this.at(NAME_AT) as string) : this.quoted();
|
|
524
|
+
if (plain) {
|
|
525
|
+
this.pos += first.length;
|
|
526
|
+
const prefix = this.syn.prefix.get(first);
|
|
527
|
+
if (prefix !== undefined && prefix.word) return opDoc(prefix, [this.expr(prefix.prec + 1)]);
|
|
528
|
+
if (this.syn.binary.get(first)?.word === true) this.stop(`A term is missing before ${first}.`, start, first.length);
|
|
529
|
+
}
|
|
530
|
+
const path: Key[] = [first];
|
|
531
|
+
let allPlain = plain;
|
|
532
|
+
for (;;) {
|
|
533
|
+
const back = this.pos;
|
|
534
|
+
this.skip();
|
|
535
|
+
if (!this.syn.fields || this.text[this.pos] !== ".") {
|
|
536
|
+
this.pos = back;
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
this.pos += 1;
|
|
540
|
+
this.skip();
|
|
541
|
+
if (this.text[this.pos] === "`") {
|
|
542
|
+
path.push(this.quoted());
|
|
543
|
+
allPlain = false;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
const name = this.at(NAME_AT);
|
|
547
|
+
const digits = name === undefined ? this.at(DIGITS_AT) : undefined;
|
|
548
|
+
if (name === undefined && digits === undefined) this.stop("A name is missing after the dot.", this.pos, 0);
|
|
549
|
+
this.pos += (name ?? (digits as string)).length;
|
|
550
|
+
path.push(name ?? Number(digits));
|
|
551
|
+
if (name === undefined) allPlain = false;
|
|
552
|
+
}
|
|
553
|
+
const back = this.pos;
|
|
554
|
+
this.skip();
|
|
555
|
+
if (this.text[this.pos] === "(") {
|
|
556
|
+
if (!allPlain) this.stop("The name of a call is a plain name.", start, back - start);
|
|
557
|
+
return this.call(path.join("."), start, back - start);
|
|
558
|
+
}
|
|
559
|
+
this.pos = back;
|
|
560
|
+
if (plain && path.length === 1 && this.syn.words.has(first)) return literalDoc(this.syn.words.get(first));
|
|
561
|
+
const full = plain && this.syn.roots.has(first) ? path : [this.syn.bare, ...path];
|
|
562
|
+
return { type: "ref", value: full };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private call(name: string, start: number, nameLength: number): Json {
|
|
566
|
+
const open = this.pos;
|
|
567
|
+
const args: Json[] = [];
|
|
568
|
+
this.pos += 1;
|
|
569
|
+
this.skip();
|
|
570
|
+
if (this.text[this.pos] === ")") this.pos += 1;
|
|
571
|
+
else {
|
|
572
|
+
for (;;) {
|
|
573
|
+
args.push(this.expr(0));
|
|
574
|
+
this.skip();
|
|
575
|
+
if (this.text.startsWith(this.syn.separator, this.pos)) {
|
|
576
|
+
this.pos += this.syn.separator.length;
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
if (this.text[this.pos] === ")") {
|
|
580
|
+
this.pos += 1;
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
if (this.pos >= this.text.length) this.stop("A closing parenthesis is missing.", open, 1);
|
|
584
|
+
this.stop(`A "${this.syn.separator}" or a closing parenthesis is missing.`, this.pos, this.tokenLength());
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
const form = this.syn.forms.get(name);
|
|
588
|
+
if (form !== undefined) {
|
|
589
|
+
const most = form.fields?.length ?? Number.POSITIVE_INFINITY;
|
|
590
|
+
if (args.length < form.min || args.length > most) {
|
|
591
|
+
const range = most === form.min ? String(most) : most === Number.POSITIVE_INFINITY ? `${String(form.min)} or more` : `${String(form.min)} to ${String(most)}`;
|
|
592
|
+
this.stop(`The form ${name} takes ${range} arguments.`, start, this.pos - start);
|
|
593
|
+
}
|
|
594
|
+
if (form.fields === undefined) return opDoc(form, args);
|
|
595
|
+
const value: Record<string, Json> = {};
|
|
596
|
+
args.forEach((arg, index) => {
|
|
597
|
+
value[form.fields?.[index] as string] = arg;
|
|
598
|
+
});
|
|
599
|
+
return opDoc(form, value);
|
|
600
|
+
}
|
|
601
|
+
if (this.syn.ops !== true && !this.syn.ops.has(name)) this.stop(`This syntax has no function ${name}.`, start, nameLength);
|
|
602
|
+
return { type: ["ops", ...name.split(".")], value: args };
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* This function reads a text into a document with the rules of a profile (SYNTAX.PARSE). It does not throw for a
|
|
608
|
+
* bad text. The issue gives the offset, the length and a message (SYNTAX.SPAN).
|
|
609
|
+
*
|
|
610
|
+
* @param profile - The syntax profile.
|
|
611
|
+
* @param text - The text.
|
|
612
|
+
* @returns The document, or the issues of the text.
|
|
613
|
+
* @throws {TypeError} The profile is not valid, for example an operator has two entries of the same place.
|
|
614
|
+
*/
|
|
615
|
+
export function parse(profile: SyntaxProfile, text: string): ParseResult {
|
|
616
|
+
const parser = new Parser(compileProfile(profile), text);
|
|
617
|
+
try {
|
|
618
|
+
return { ok: true, doc: parser.parse() };
|
|
619
|
+
} catch (error) {
|
|
620
|
+
if (error instanceof Stop) return { ok: false, issues: [error.issue] };
|
|
621
|
+
if (isStackOverflow(error)) return { ok: false, issues: [{ offset: parser.position(), length: 0, message: "The text is too deep for the stack of the host." }] };
|
|
622
|
+
throw error;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// === Print ===
|
|
627
|
+
|
|
628
|
+
/** The text of a part, from its document and its path in the value of the parent. */
|
|
629
|
+
type SubText = (child: unknown, path: readonly Key[]) => Result<string>;
|
|
630
|
+
|
|
631
|
+
type Shape =
|
|
632
|
+
| { readonly tag: "text"; readonly text: string }
|
|
633
|
+
| { readonly tag: "op"; readonly op: Op; readonly terms: readonly unknown[] }
|
|
634
|
+
| { readonly tag: "call"; readonly name: string; readonly args: readonly (readonly [Key, unknown])[] }
|
|
635
|
+
| { readonly tag: "none"; readonly reason: string };
|
|
636
|
+
|
|
637
|
+
function none(reason: string): Shape {
|
|
638
|
+
return { tag: "none", reason };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function quoteName(name: string): string {
|
|
642
|
+
return `\`${name.replaceAll("`", "``")}\``;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function wordFor(syn: Compiled, value: unknown): string | undefined {
|
|
646
|
+
for (const [word, held] of syn.words) if (Object.is(held, value)) return word;
|
|
647
|
+
return undefined;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function textOfString(syn: Compiled, value: string): string | undefined {
|
|
651
|
+
const quote = syn.quote;
|
|
652
|
+
if (quote === undefined) return undefined;
|
|
653
|
+
if (syn.doubled) return `${quote}${value.replaceAll(quote, quote + quote)}${quote}`;
|
|
654
|
+
const json = JSON.stringify(value);
|
|
655
|
+
return quote === '"' ? json : `'${json.slice(1, -1).replaceAll('\\"', '"').replaceAll("'", "\\'")}'`;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function textOfNumber(syn: Compiled, value: number): string | undefined {
|
|
659
|
+
if (!syn.numbers) return wordFor(syn, value);
|
|
660
|
+
const negative = value < 0 || Object.is(value, -0);
|
|
661
|
+
if (negative && !syn.signed) return wordFor(syn, value);
|
|
662
|
+
if (Number.isFinite(value)) return negative ? `-${String(-value)}` : String(value);
|
|
663
|
+
return wordFor(syn, value) ?? (Number.isNaN(value) ? undefined : negative ? "-1e999" : "1e999");
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function literalShape(syn: Compiled, value: unknown): Shape {
|
|
667
|
+
const text = typeof value === "string" ? textOfString(syn, value) : typeof value === "number" ? textOfNumber(syn, value) : wordFor(syn, value);
|
|
668
|
+
return text === undefined ? none(`This syntax has no text for the literal ${describe(value)}.`) : { tag: "text", text };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function plainSegment(syn: Compiled, name: string): boolean {
|
|
672
|
+
return NAME.test(name) && !syn.reserved.has(name) && !syn.patterns.some((p) => p.whole.test(name));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function laterSegment(step: unknown): string | undefined {
|
|
676
|
+
if (typeof step === "string") return NAME.test(step) ? step : quoteName(step);
|
|
677
|
+
return typeof step === "number" && Number.isSafeInteger(step) && step >= 0 ? String(step) : undefined;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function refShape(syn: Compiled, value: unknown): Shape {
|
|
681
|
+
if (!Array.isArray(value) || value.length === 0) return none("The path of the reference is empty.");
|
|
682
|
+
for (const pattern of syn.patterns) {
|
|
683
|
+
const text = pattern.write(value);
|
|
684
|
+
if (text !== undefined) return { tag: "text", text };
|
|
685
|
+
}
|
|
686
|
+
const [root, ...rest] = value as readonly unknown[];
|
|
687
|
+
const many = rest.length > 1 || (root !== syn.bare && rest.length > 0);
|
|
688
|
+
if (many && !syn.fields) return none("This syntax has no field steps.");
|
|
689
|
+
const later = rest.slice(1).map(laterSegment);
|
|
690
|
+
let first: string | undefined;
|
|
691
|
+
let tail = later;
|
|
692
|
+
if (root === syn.bare && typeof rest[0] === "string") first = plainSegment(syn, rest[0]) ? rest[0] : quoteName(rest[0]);
|
|
693
|
+
else if (typeof root === "string" && syn.roots.has(root) && !syn.patterns.some((p) => p.whole.test(root))) {
|
|
694
|
+
first = root;
|
|
695
|
+
tail = rest.map(laterSegment);
|
|
696
|
+
}
|
|
697
|
+
if (first === undefined || tail.includes(undefined)) return none("This syntax has no text for the path of the reference.");
|
|
698
|
+
return { tag: "text", text: [first, ...tail].join(".") };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function callName(syn: Compiled, keys: readonly Key[]): string | undefined {
|
|
702
|
+
if (keys.length === 0 || !keys.every((key) => typeof key === "string" && NAME.test(key))) return undefined;
|
|
703
|
+
const name = keys.join(".");
|
|
704
|
+
if (syn.forms.has(name) || syn.opWords.has(keys[0] as string)) return undefined;
|
|
705
|
+
return syn.ops === true || syn.ops.has(name) ? name : undefined;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function formShape(form: Form, value: unknown): Shape | undefined {
|
|
709
|
+
if (form.fields === undefined) {
|
|
710
|
+
if (!Array.isArray(value) || value.length < form.min) return undefined;
|
|
711
|
+
return { tag: "call", name: form.name, args: value.map((item, index) => [index, item] as const) };
|
|
712
|
+
}
|
|
713
|
+
if (!isRecord(value)) return undefined;
|
|
714
|
+
// The fields that the value holds must be the first fields of the form, because the arguments go in order.
|
|
715
|
+
const count = Object.keys(value).length;
|
|
716
|
+
const present = form.fields.slice(0, count);
|
|
717
|
+
if (count < form.min || present.length < count || !present.every((key) => Object.hasOwn(value, key))) return undefined;
|
|
718
|
+
return { tag: "call", name: form.name, args: present.map((key) => [key, value[key]] as const) };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function opShape(syn: Compiled, key: string, terms: readonly unknown[]): Shape | undefined {
|
|
722
|
+
for (const op of syn.byKind.get(key) ?? []) {
|
|
723
|
+
const fits = op.fixity === "infix" ? terms.length === 2 || (op.assoc === "flat" && terms.length > 2) : terms.length === 1;
|
|
724
|
+
if (fits) return { tag: "op", op, terms };
|
|
725
|
+
}
|
|
726
|
+
return undefined;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** This function gives the shape of a document: a literal or a reference, an operator, a call, or no text. */
|
|
730
|
+
function shapeOf(syn: Compiled, doc: unknown): Shape {
|
|
731
|
+
if (!isEnvelope(doc)) return Array.isArray(doc) || isRecord(doc) ? none("This syntax has no text for a list or a record literal.") : literalShape(syn, doc);
|
|
732
|
+
if (doc.name !== undefined || doc.own !== undefined) return none("This syntax has no text for the header of a document.");
|
|
733
|
+
const path = typePath(doc.type);
|
|
734
|
+
if (path === undefined) return none("This syntax has no text for a computed type.");
|
|
735
|
+
const value: unknown = doc.value;
|
|
736
|
+
const name = path.join(".");
|
|
737
|
+
if (name === "lit") return isEnvelope(value) || Array.isArray(value) || isRecord(value) ? none("This syntax has no text for a list or a record literal.") : literalShape(syn, value);
|
|
738
|
+
if (name === "ext.undefined") return literalShape(syn, undefined);
|
|
739
|
+
if (name === "ext.number") {
|
|
740
|
+
const numbers: Readonly<Record<string, number>> = { NaN: Number.NaN, Infinity: Number.POSITIVE_INFINITY, "-Infinity": Number.NEGATIVE_INFINITY, "-0": -0 };
|
|
741
|
+
return typeof value === "string" && Object.hasOwn(numbers, value) ? literalShape(syn, numbers[value]) : none("The value of ext.number is not valid.");
|
|
742
|
+
}
|
|
743
|
+
if (name === "ref") return refShape(syn, value);
|
|
744
|
+
const key = JSON.stringify(path);
|
|
745
|
+
const op = Array.isArray(value) ? opShape(syn, key, value) : undefined;
|
|
746
|
+
if (op !== undefined) return op;
|
|
747
|
+
for (const form of syn.formsByKind.get(key) ?? []) {
|
|
748
|
+
const shape = formShape(form, value);
|
|
749
|
+
if (shape !== undefined) return shape;
|
|
750
|
+
}
|
|
751
|
+
const called = path[0] === "ops" && Array.isArray(value) ? callName(syn, path.slice(1)) : undefined;
|
|
752
|
+
if (called !== undefined) return { tag: "call", name: called, args: (value as readonly unknown[]).map((item, index) => [index, item] as const) };
|
|
753
|
+
return none(`This syntax has no text for the kind ${name}.`);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function levelOf(syn: Compiled, doc: unknown): number {
|
|
757
|
+
const shape = shapeOf(syn, doc);
|
|
758
|
+
return shape.tag === "op" ? shape.op.prec : Number.POSITIVE_INFINITY;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** This function tells if a term needs parentheses in its position, so that `parse` reads the same tree. */
|
|
762
|
+
function needsGroup(op: Op, index: number, level: number): boolean {
|
|
763
|
+
if (op.fixity === "postfix") return level < op.prec;
|
|
764
|
+
if (op.fixity === "prefix" || op.assoc === "none" || op.assoc === "flat") return level <= op.prec;
|
|
765
|
+
const looser = op.assoc === "left" ? index === 0 : index !== 0;
|
|
766
|
+
return looser ? level < op.prec : level <= op.prec;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function opText(syn: Compiled, op: Op, terms: readonly unknown[], sub: SubText): Result<string> {
|
|
770
|
+
const texts: string[] = [];
|
|
771
|
+
for (let index = 0; index < terms.length; index += 1) {
|
|
772
|
+
const term = terms[index];
|
|
773
|
+
const result = sub(term, [index]);
|
|
774
|
+
if (!result.ok) return result;
|
|
775
|
+
let text = result.value;
|
|
776
|
+
const signedDigit = op.fixity === "prefix" && op.symbol === "-" && syn.signed && /^[0-9]/.test(text);
|
|
777
|
+
if (needsGroup(op, index, levelOf(syn, term)) || signedDigit) text = `(${text})`;
|
|
778
|
+
texts.push(text);
|
|
779
|
+
}
|
|
780
|
+
const [only = ""] = texts;
|
|
781
|
+
if (op.fixity === "prefix") return ok(op.word || /^[^A-Za-z0-9_("'`$]/.test(only) ? `${op.symbol} ${only}` : `${op.symbol}${only}`);
|
|
782
|
+
if (op.fixity === "postfix") return ok(op.word ? `${only} ${op.symbol}` : `${only}${op.symbol}`);
|
|
783
|
+
return ok(texts.join(` ${op.symbol} `));
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** This function writes one document. `sub` gives the text of each part. */
|
|
787
|
+
function printNode(syn: Compiled, doc: unknown, sub: SubText): Result<string> {
|
|
788
|
+
const shape = shapeOf(syn, doc);
|
|
789
|
+
if (shape.tag === "text") return ok(shape.text);
|
|
790
|
+
if (shape.tag === "none") return fail("#VALUE!", shape.reason);
|
|
791
|
+
if (shape.tag === "op") return opText(syn, shape.op, shape.terms, sub);
|
|
792
|
+
const texts: string[] = [];
|
|
793
|
+
for (const [key, arg] of shape.args) {
|
|
794
|
+
const result = sub(arg, [key]);
|
|
795
|
+
if (!result.ok) return result;
|
|
796
|
+
texts.push(result.value);
|
|
797
|
+
}
|
|
798
|
+
return ok(`${shape.name}(${texts.join(`${syn.separator} `)})`);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function printTree(syn: Compiled, doc: unknown, place: readonly Key[]): Result<string> {
|
|
802
|
+
const result = printNode(syn, doc, (child, path) => printTree(syn, child, [...place, ...path]));
|
|
803
|
+
return result.ok || result.error.place !== undefined ? result : fail(result.error.code, result.error.reason, { place });
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* This function writes a document as text with the rules of a profile (SYNTAX.PRINT). It writes the fewest
|
|
808
|
+
* parentheses that keep the tree. `parse` reads the text back to the same document (SYNTAX.ROUNDTRIP).
|
|
809
|
+
*
|
|
810
|
+
* @param profile - The syntax profile.
|
|
811
|
+
* @param doc - The document.
|
|
812
|
+
* @returns The text, or `#VALUE!` at the place of a document that the profile cannot write.
|
|
813
|
+
* @throws {TypeError} The profile is not valid.
|
|
814
|
+
*/
|
|
815
|
+
export function print(profile: SyntaxProfile, doc: unknown): Result<string> {
|
|
816
|
+
const syn = compileProfile(profile);
|
|
817
|
+
try {
|
|
818
|
+
return printTree(syn, doc, []);
|
|
819
|
+
} catch (error) {
|
|
820
|
+
if (isStackOverflow(error)) return fail("#DEPTH!", "The document is too deep for the stack of the host.", { cause: error });
|
|
821
|
+
throw error;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* This function makes the part of a merge that adds the verb `format` to a machine (D23, D41). Its default writes
|
|
827
|
+
* each document with the printer of the profile. A part gives its own text through the ladder, thus an own entry or
|
|
828
|
+
* a kind entry of `format` still wins.
|
|
829
|
+
*
|
|
830
|
+
* @param profile - The syntax profile.
|
|
831
|
+
* @returns The part for `mergeMachines`.
|
|
832
|
+
*/
|
|
833
|
+
export function syntaxFormat(profile: SyntaxProfile): MachinePart {
|
|
834
|
+
const syn = compileProfile(profile);
|
|
835
|
+
const code: EvalFn = ({ doc, parts, place }) => {
|
|
836
|
+
const byPath = new Map<string, Part>(parts.map((part) => [JSON.stringify(part.path), part]));
|
|
837
|
+
return printNode(syn, doc, (child, path) => {
|
|
838
|
+
const part = byPath.get(JSON.stringify(path));
|
|
839
|
+
if (part === undefined) return printTree(syn, child, [...place, ...path]);
|
|
840
|
+
const result = part.evaluate();
|
|
841
|
+
if (!result.ok) return result;
|
|
842
|
+
return typeof result.value === "string" ? ok(result.value) : fail("#VALUE!", `The verb format of a part gave ${describe(result.value)}, not a text.`, { place: [...place, ...path] });
|
|
843
|
+
});
|
|
844
|
+
};
|
|
845
|
+
return { world: { defaults: { format: "syntax.format" } }, evaluators: { "syntax.format": code } };
|
|
846
|
+
}
|