@mark1russell7/coeval 0.23.0 → 0.24.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/src/syntax.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Json, type JsonRecord, type Key, isEnvelope, isRecord, typePath } from "./doc.ts";
1
+ import { type Json, type JsonRecord, type Key, docEquals, isEnvelope, isRecord, typePath } from "./doc.ts";
2
2
  import { type Result, describe, fail, isStackOverflow, ok } from "./errors.ts";
3
3
  import type { EvalFn, Part } from "./evaluate.ts";
4
4
  import type { MachinePart } from "./merge.ts";
@@ -109,6 +109,18 @@ export interface SyntaxForm {
109
109
  * form `literal` takes no `wrap`, and a form `list` takes only `true`.
110
110
  */
111
111
  readonly wrap?: boolean | readonly string[];
112
+ /**
113
+ * The fields that take data with documents in it (L1c, D177), for example the clauses of `handle`. Such an
114
+ * argument is a list between brackets, a record between braces, or a document. A literal in it stays its data. A
115
+ * record of data has no key `type`. A name of `data` is a field of the form, and it is not in `wrap`.
116
+ */
117
+ readonly data?: readonly string[];
118
+ /**
119
+ * The name of a domain with a codec (K143, D141). A form `literal` of the kind `ext.domain` needs it. The text
120
+ * `date("2026-01-02")` then gives the value `{ domain: "date", data: "2026-01-02" }`. The syntax does not read the
121
+ * data: the codec of the machine reads it at evaluation. Each other form takes no `domain`.
122
+ */
123
+ readonly domain?: string;
112
124
  }
113
125
 
114
126
  /** The way that the arguments of a call form make the value of its document (D63). */
@@ -248,6 +260,10 @@ interface Form {
248
260
  readonly min: number;
249
261
  /** The arguments that take a document: `true` for each, or the names of the fields. */
250
262
  readonly wrap: boolean | ReadonlySet<string>;
263
+ /** The domain of a form literal of `ext.domain` (D141). */
264
+ readonly domain: string | undefined;
265
+ /** The fields that take data with documents in it (D177). */
266
+ readonly data: ReadonlySet<string>;
251
267
  }
252
268
 
253
269
  /** The type of a data envelope, and its key for a comparison with the path of a type. */
@@ -291,6 +307,8 @@ interface Compiled {
291
307
  readonly reserved: ReadonlySet<string>;
292
308
  readonly opWords: ReadonlySet<string>;
293
309
  readonly maxDepth: number;
310
+ /** `true` when a form has a field of data (D177). Such a field reads brackets and braces. */
311
+ readonly brackets: boolean;
294
312
  }
295
313
 
296
314
  const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -376,6 +394,14 @@ function compileConstructs(given: SyntaxConstructs | undefined, ops: Pick<Compil
376
394
  return { constructs, constructKinds };
377
395
  }
378
396
 
397
+ /** This function examines the field `domain` of a call form (D141). A form `literal` of `ext.domain` needs a name, and each other form refuses it. */
398
+ function compileDomain(given: SyntaxForm, args: SyntaxFormArgs): string | undefined {
399
+ const writes = args === "literal" && given.kind === "ext.domain";
400
+ if (writes && (typeof given.domain !== "string" || given.domain === "")) throw new TypeError(`The call form "${given.name}" of ext.domain needs the name of its domain.`);
401
+ if (!writes && given.domain !== undefined) throw new TypeError(`The call form "${given.name}" takes no domain: only a form literal of ext.domain has one.`);
402
+ return given.domain;
403
+ }
404
+
379
405
  /** This function examines the field `wrap` of a call form (K159). A name that is not a field of the form throws. */
380
406
  function compileWrap(given: SyntaxForm, args: SyntaxFormArgs): boolean | ReadonlySet<string> {
381
407
  const wrap = given.wrap;
@@ -390,6 +416,17 @@ function compileWrap(given: SyntaxForm, args: SyntaxFormArgs): boolean | Readonl
390
416
  }
391
417
 
392
418
  /** This function gives the types of the data envelopes of a profile (K159), or `undefined` without them. */
419
+ /** This function examines the field `data` of a call form (D177). Each name is a field of the form, and no name is in `wrap`. */
420
+ function compileData(given: SyntaxForm, wrap: boolean | ReadonlySet<string>): ReadonlySet<string> {
421
+ const data = given.data ?? [];
422
+ if (data.length > 0 && given.fields === undefined) throw new TypeError(`The call form "${given.name}" takes data only with fields.`);
423
+ for (const name of data) {
424
+ if (given.fields?.includes(name) !== true) throw new TypeError(`The call form "${given.name}" has no field ${name} for its data.`);
425
+ if (wrap === true || (wrap !== false && wrap.has(name))) throw new TypeError(`The call form "${given.name}" has the field ${name} in wrap and in data.`);
426
+ }
427
+ return new Set(data);
428
+ }
429
+
393
430
  function compileEnvelopes(given: SyntaxEnvelopes | undefined): Compiled["envelopes"] {
394
431
  if (given === undefined) return undefined;
395
432
  const out: { number?: EnvelopeKind; text?: EnvelopeKind; bool?: EnvelopeKind } = {};
@@ -423,10 +460,16 @@ function compileProfile(profile: SyntaxProfile): Compiled {
423
460
  if (!FORM_ARGS.includes(args)) throw new TypeError(`The call form "${given.name}" has the unknown arguments ${String(args)}.`);
424
461
  if ((args === "fields") !== (given.fields !== undefined) && args !== "named") throw new TypeError(`The call form "${given.name}" with the arguments ${args} ${args === "fields" ? "needs" : "takes no"} fields.`);
425
462
  if (given.min !== undefined && (args === "named" || args === "literal")) throw new TypeError(`The call form "${given.name}" with the arguments ${args} takes no min.`);
426
- const form: Form = { name: given.name, type, args, fields: given.fields, min: given.min ?? given.fields?.length ?? 0, wrap: compileWrap(given, args) };
463
+ const wrap = compileWrap(given, args);
464
+ const form: Form = { name: given.name, type, args, fields: given.fields, min: given.min ?? given.fields?.length ?? 0, wrap, domain: compileDomain(given, args), data: compileData(given, wrap) };
427
465
  forms.set(form.name, form);
428
466
  addTo(formsByKind, key, form);
429
467
  }
468
+ // A field of data reads brackets and braces, thus no operator starts with one of them (D177).
469
+ const brackets = [...forms.values()].some((form) => form.data.size > 0);
470
+ for (const symbol of brackets ? [...ops.prefixSymbols, ...ops.binarySymbols] : []) {
471
+ if (/^[[\]{}]/.test(symbol)) throw new TypeError(`The operator "${symbol}" reads the text of a field of data.`);
472
+ }
430
473
  const compiled: Compiled = {
431
474
  ...ops,
432
475
  numbers: literals.numbers ?? true,
@@ -450,6 +493,7 @@ function compileProfile(profile: SyntaxProfile): Compiled {
450
493
  ...compileConstructs(profile.constructs, ops),
451
494
  reserved: new Set([...words.keys(), ...ops.opWords, ...roots]),
452
495
  maxDepth: profile.maxDepth ?? 256,
496
+ brackets,
453
497
  };
454
498
  compiledProfiles.set(profile, compiled);
455
499
  return compiled;
@@ -888,7 +932,7 @@ class Parser {
888
932
 
889
933
  /** This method stops at a token where a group or a list must end. A list can also continue with its separator. */
890
934
  protected noEnd(close: string, list: boolean): never {
891
- const closer = close === ")" ? "parenthesis" : "brace";
935
+ const closer = closerName(close);
892
936
  const before = list ? `"${this.syn.separator}" or a ` : "";
893
937
  return this.stop(`A ${before}closing ${closer} is missing.`, this.pos, this.tokenLength());
894
938
  }
@@ -914,17 +958,33 @@ class Parser {
914
958
  this.pos += 1;
915
959
  return args;
916
960
  }
917
- if (this.pos >= this.text.length) this.stop(`A closing ${close === ")" ? "parenthesis" : "brace"} is missing.`, open, 1);
961
+ if (this.pos >= this.text.length) this.stop(`A closing ${closerName(close)} is missing.`, open, 1);
918
962
  this.noEnd(close, true);
919
963
  }
920
964
  }
921
965
 
922
966
  /**
923
- * This method reads the entries `key: expr` into a record: the arguments of a form `named` (SYNTAX.RECORD), or the
924
- * entries between braces without a form (SYNTAX.RECORD-TEXT).
967
+ * This method reads the argument of a field of data (D177): a list between brackets, a record between braces, or a
968
+ * document. A literal in it stays its data.
969
+ */
970
+ private data(): Json {
971
+ this.skip();
972
+ const start = this.pos;
973
+ const c = this.text[start];
974
+ if (c !== "[" && c !== "{") return this.bound();
975
+ this.depth += 1;
976
+ if (this.depth > this.syn.maxDepth) this.stop(`The text is deeper than ${String(this.syn.maxDepth)} levels.`, start, 0);
977
+ const value = c === "[" ? this.argumentsOf(() => this.data(), "]") : this.entries(undefined, true);
978
+ this.depth -= 1;
979
+ return value;
980
+ }
981
+
982
+ /**
983
+ * This method reads the entries `key: expr` into a record. The record is the arguments of a form `named`
984
+ * (SYNTAX.RECORD), or the entries between braces (SYNTAX.RECORD-TEXT). It is also a record of data (D177).
925
985
  */
926
- private entries(form: Form | undefined): Record<string, Json> {
927
- const owner = form === undefined ? "record" : `form ${form.name}`;
986
+ private entries(form: Form | undefined, data = false): Record<string, Json> {
987
+ const owner = data ? "record of data" : form === undefined ? "record" : `form ${form.name}`;
928
988
  const value: Record<string, Json> = {};
929
989
  const entry = (): void => {
930
990
  this.skip();
@@ -936,9 +996,10 @@ class Parser {
936
996
  this.stop(`Each ${form === undefined ? "entry" : "argument"} of the ${owner} is a key, a ":" and a document.`, at, Math.max(end - at, this.tokenLength()));
937
997
  }
938
998
  if (form?.fields !== undefined && !form.fields.includes(key)) this.stop(`The form ${form.name} has no field ${key}.`, at, end - at);
999
+ if (data && key === "type") this.stop("A record of data has no key type, because the key type makes a document.", at, end - at);
939
1000
  if (Object.hasOwn(value, key)) this.stop(`The ${owner} has the field ${key} two times.`, at, end - at);
940
1001
  this.pos += 1;
941
- const arg = this.bound();
1002
+ const arg = data || form?.data.has(key) === true ? this.data() : this.bound();
942
1003
  setOwn(value, key, form !== undefined && wraps(form, key) ? envelop(this.syn, arg) : arg);
943
1004
  };
944
1005
  this.argumentsOf(entry, form === undefined ? "}" : ")");
@@ -948,11 +1009,16 @@ class Parser {
948
1009
  private call(name: string, start: number, nameLength: number): Json {
949
1010
  const form = this.syn.forms.get(name);
950
1011
  if (form?.args === "named") return opDoc(form, this.entries(form));
951
- const args = this.argumentsOf(() => this.bound());
1012
+ let index = 0;
1013
+ const args = this.argumentsOf(() => {
1014
+ const key = form?.fields?.[index];
1015
+ index += 1;
1016
+ return key !== undefined && form?.data.has(key) === true ? this.data() : this.bound();
1017
+ });
952
1018
  if (form?.args === "literal") {
953
1019
  const [only] = args;
954
1020
  if (args.length !== 1 || !isScalar(only)) this.stop(`The form ${name} takes one literal that JSON writes.`, start, this.pos - start);
955
- return opDoc(form, only);
1021
+ return opDoc(form, form.domain === undefined ? only : { domain: form.domain, data: only });
956
1022
  }
957
1023
  if (form !== undefined) {
958
1024
  const most = form.fields?.length ?? Number.POSITIVE_INFINITY;
@@ -979,17 +1045,23 @@ class Parser {
979
1045
  class End {}
980
1046
 
981
1047
  function hasBraces(syn: Compiled): boolean {
982
- return syn.constructs.record !== undefined || syn.constructs.let !== undefined;
1048
+ return syn.constructs.record !== undefined || syn.constructs.let !== undefined || syn.brackets;
1049
+ }
1050
+
1051
+ /** This function gives the name of a closer in a message. */
1052
+ function closerName(close: string): string {
1053
+ return close === ")" ? "parenthesis" : close === "]" ? "bracket" : "brace";
983
1054
  }
984
1055
 
985
1056
  /** This function gives the closer of an opening bracket of a profile, or `undefined` for each other character. */
986
1057
  function closerOf(syn: Compiled, c: string | undefined): string | undefined {
987
1058
  if (c === "(") return ")";
1059
+ if (c === "[" && syn.brackets) return "]";
988
1060
  return c === "{" && hasBraces(syn) ? "}" : undefined;
989
1061
  }
990
1062
 
991
1063
  function isCloser(syn: Compiled, c: string | undefined): boolean {
992
- return c === ")" || (c === "}" && hasBraces(syn));
1064
+ return c === ")" || (c === "]" && syn.brackets) || (c === "}" && hasBraces(syn));
993
1065
  }
994
1066
 
995
1067
  /** This function gives the position after a quoted text at `at`, or after one other character. An open quote gives `Infinity`. */
@@ -1173,7 +1245,7 @@ type SubText = (child: unknown, path: readonly Key[], wrapped?: boolean) => Resu
1173
1245
  type Shape =
1174
1246
  | { readonly tag: "text"; readonly text: string }
1175
1247
  | { readonly tag: "op"; readonly op: Op; readonly terms: readonly unknown[] }
1176
- | { readonly tag: "call"; readonly name: string; readonly args: readonly (readonly [Key, unknown, boolean?])[]; readonly named?: boolean }
1248
+ | { readonly tag: "call"; readonly name: string; readonly args: readonly (readonly [Key, unknown, boolean?, boolean?])[]; readonly named?: boolean }
1177
1249
  | { readonly tag: "path"; readonly segments: readonly (string | readonly [Key, unknown])[] }
1178
1250
  | { readonly tag: "bind"; readonly name: string; readonly doc: unknown }
1179
1251
  | ConstructShape
@@ -1256,6 +1328,8 @@ function refShape(syn: Compiled, value: unknown): Shape {
1256
1328
 
1257
1329
  function callName(syn: Compiled, keys: readonly Key[]): string | undefined {
1258
1330
  if (keys.length === 0 || !keys.every((key) => typeof key === "string" && NAME.test(key))) return undefined;
1331
+ // `parse` reads the dots of a call name as field steps, thus a profile without them has no such text (D142).
1332
+ if (keys.length > 1 && !syn.fields) return undefined;
1259
1333
  const name = keys.join(".");
1260
1334
  if (syn.forms.has(name) || syn.opWords.has(keys[0] as string)) return undefined;
1261
1335
  return syn.ops === true || syn.ops.has(name) ? name : undefined;
@@ -1281,17 +1355,24 @@ function wrappedShape(syn: Compiled, doc: unknown): Shape | undefined {
1281
1355
  return literal.tag === "text" ? literal : undefined;
1282
1356
  }
1283
1357
 
1358
+ /** This function tells if a value of `ext.domain` is a record with this domain and its data, and no other key. */
1359
+ function holdsDomain(value: unknown, domain: string): boolean {
1360
+ return isRecord(value) && value["domain"] === domain && Object.hasOwn(value, "data") && Object.keys(value).length === 2;
1361
+ }
1362
+
1284
1363
  function formShape(syn: Compiled, form: Form, value: unknown): Shape | undefined {
1285
1364
  if (form.args === "literal") {
1286
- if (!isScalar(value)) return undefined;
1287
- const literal = literalShape(syn, value);
1365
+ if (form.domain !== undefined && !holdsDomain(value, form.domain)) return undefined;
1366
+ const data = form.domain === undefined ? value : (value as Readonly<Record<string, unknown>>)["data"];
1367
+ if (!isScalar(data)) return undefined;
1368
+ const literal = literalShape(syn, data);
1288
1369
  return literal.tag === "text" ? { tag: "text", text: `${form.name}(${literal.text})` } : undefined;
1289
1370
  }
1290
1371
  if (form.args === "named") {
1291
1372
  if (!isRecord(value)) return undefined;
1292
1373
  const keys = Object.keys(value);
1293
1374
  if (form.fields !== undefined && !keys.every((key) => form.fields?.includes(key) === true)) return undefined;
1294
- return { tag: "call", name: form.name, args: keys.map((key) => [key, value[key], wraps(form, key)] as const), named: true };
1375
+ return { tag: "call", name: form.name, args: keys.map((key) => [key, value[key], wraps(form, key), form.data.has(key)] as const), named: true };
1295
1376
  }
1296
1377
  if (form.fields === undefined) {
1297
1378
  if (!Array.isArray(value) || value.length < form.min) return undefined;
@@ -1302,7 +1383,7 @@ function formShape(syn: Compiled, form: Form, value: unknown): Shape | undefined
1302
1383
  const count = Object.keys(value).length;
1303
1384
  const present = form.fields.slice(0, count);
1304
1385
  if (count < form.min || present.length < count || !present.every((key) => Object.hasOwn(value, key))) return undefined;
1305
- return { tag: "call", name: form.name, args: present.map((key) => [key, value[key], wraps(form, key)] as const) };
1386
+ return { tag: "call", name: form.name, args: present.map((key) => [key, value[key], wraps(form, key), form.data.has(key)] as const) };
1306
1387
  }
1307
1388
 
1308
1389
  /** This function tells if a record has exactly these two keys. */
@@ -1422,6 +1503,20 @@ function bracesText(syn: Compiled, record: JsonRecord, path: readonly Key[], sub
1422
1503
  return ok(`{${texts.value.map((text, index) => `${keyText(keys[index] as string)}: ${text}`).join(`${syn.separator} `)}}`);
1423
1504
  }
1424
1505
 
1506
+ /**
1507
+ * This function writes the argument of a field of data (D177): a list between brackets, or a record between braces.
1508
+ * The function `sub` writes a literal and a document. A record that is an envelope is a document.
1509
+ */
1510
+ function dataText(syn: Compiled, value: unknown, path: readonly Key[], sub: SubText): Result<string> {
1511
+ const each: SubText = (child, at) => dataText(syn, child, at, sub);
1512
+ if (Array.isArray(value)) {
1513
+ const texts = partTexts(value.map((item, index) => [index, item] as const), path, each);
1514
+ return texts.ok ? ok(`[${texts.value.join(`${syn.separator} `)}]`) : texts;
1515
+ }
1516
+ // A record with the key type that is not an envelope goes to sub, which gives no text for it. The parser refuses its text.
1517
+ return isRecord(value) && !isEnvelope(value) && !Object.hasOwn(value, "type") ? bracesText(syn, value, path, each) : sub(value, path);
1518
+ }
1519
+
1425
1520
  /** This function writes a construct form (K145). */
1426
1521
  function constructText(syn: Compiled, shape: ConstructShape, sub: SubText): Result<string> {
1427
1522
  if (shape.tag === "record") return bracesText(syn, shape.entries, [], sub);
@@ -1468,8 +1563,8 @@ function printNode(syn: Compiled, doc: unknown, sub: SubText): Result<string> {
1468
1563
  return ok(texts.join("."));
1469
1564
  }
1470
1565
  const texts: string[] = [];
1471
- for (const [key, arg, wrapped] of shape.args) {
1472
- const result = sub(arg, [key], wrapped);
1566
+ for (const [key, arg, wrapped, data] of shape.args) {
1567
+ const result = data === true ? dataText(syn, arg, [key], sub) : sub(arg, [key], wrapped);
1473
1568
  if (!result.ok) return result;
1474
1569
  texts.push(shape.named === true ? `${keyText(String(key))}: ${result.value}` : result.value);
1475
1570
  }
@@ -1531,3 +1626,210 @@ export function syntaxFormat(profile: SyntaxProfile): MachinePart {
1531
1626
  };
1532
1627
  return { world: { defaults: { format: "syntax.format" } }, evaluators: { "syntax.format": code } };
1533
1628
  }
1629
+
1630
+ // === The check of a profile ===
1631
+
1632
+ /**
1633
+ * The name of a finding of `checkSyntax` (D140). Each name holds a rule of the parser or the printer.
1634
+ *
1635
+ * - `invalid`: `parse` throws a `TypeError` for the profile. The reason is its message.
1636
+ * - `operator`: the document of one operator does not come back. A negative precedence stops an infix operator,
1637
+ * because an expression starts at the precedence `0`. The kinds `ref`, `lit` and `ext.number` have their own text.
1638
+ * - `grouping`: an operator with a second operator in a term does not come back. The parser reads a term from the
1639
+ * precedence plus `1`, and the printer compares precedences, thus a gap under `1` loses the tree. A postfix operator
1640
+ * at the precedence of a `right` infix operator loses it too.
1641
+ * - `longest-match`: the printer writes two postfix operators without a space, and a longer symbol reads their text.
1642
+ * - `word`: a word of `literals.words` does not read as its value. A word operator reads first, and a word that is
1643
+ * not a plain name has no token.
1644
+ * - `root`: a root does not come back. A word operator reads first, and a literal word reads a root without steps.
1645
+ * - `pattern`: a pattern of references reads a text that has another meaning, because the parser tries patterns first.
1646
+ * - `form`: the document of a call form does not come back. A word operator reads first, the last form of a name
1647
+ * hides the others, and a `min` above the fields permits no text.
1648
+ * - `call`: a name of `calls.ops` does not come back. A form of the name wins, a word operator reads first, and a name
1649
+ * with dots needs the field steps.
1650
+ * - `separator`: the separator does not end an argument, and a neutral separator keeps the same document. An operator
1651
+ * reads first, the parser skips a space before it, or a name or a bracket at its start changes the term.
1652
+ * - `construct`: the document of a construct form does not come back, for example two forms have one kind.
1653
+ */
1654
+ export type SyntaxFindingCode = "invalid" | "operator" | "grouping" | "longest-match" | "word" | "root" | "pattern" | "form" | "call" | "separator" | "construct";
1655
+
1656
+ /** One fault of a syntax profile: its name, its place in the profile and the reason (D140). */
1657
+ export interface SyntaxFinding {
1658
+ /** The name of the fault. */
1659
+ readonly code: SyntaxFindingCode;
1660
+ /** The keys from the root of the profile to the entry, for example `["operators", 3]`. A default list has keys too. */
1661
+ readonly place: readonly Key[];
1662
+ /** The rule that the entry breaks, and the text that shows it. */
1663
+ readonly reason: string;
1664
+ }
1665
+
1666
+ /** The state of one check: the profile, its compiled form, a leaf document and the two functions that record a fault. */
1667
+ interface ProfileCheck {
1668
+ readonly profile: SyntaxProfile;
1669
+ readonly syn: Compiled;
1670
+ readonly leaf: Json;
1671
+ /** This function adds a finding. A pattern that reads the start of `text` takes the code and the place. */
1672
+ readonly report: (code: SyntaxFindingCode, place: readonly Key[], text: string, reason: string) => void;
1673
+ /** This function reports a document that breaks the law, and it tells if the law holds. `start` stands for a refused text. */
1674
+ readonly keeps: (code: SyntaxFindingCode, place: readonly Key[], rule: string, doc: Json, start?: string) => boolean;
1675
+ }
1676
+
1677
+ /** This function tells how a document breaks `parse(print(doc))`. It gives `undefined` when the document comes back. */
1678
+ function lawBreak(profile: SyntaxProfile, doc: Json): { readonly text: string | undefined; readonly why: string } | undefined {
1679
+ const printed = print(profile, doc);
1680
+ if (!printed.ok) return { text: undefined, why: printed.error.reason };
1681
+ const text = printed.value;
1682
+ const back = parse(profile, text);
1683
+ if (back.ok) return docEquals(back.doc, doc) ? undefined : { text, why: `The text ${text} reads as ${JSON.stringify(back.doc)}.` };
1684
+ return { text, why: `The text ${text} gives the issue: ${back.issues[0]?.message ?? ""}` };
1685
+ }
1686
+
1687
+ /** This function gives the index of the first pattern that `parse` uses at the start of a text, or `-1`. */
1688
+ function patternAt(syn: Compiled, text: string): number {
1689
+ return syn.patterns.findIndex(({ sticky }) => {
1690
+ sticky.lastIndex = 0;
1691
+ const match = sticky.exec(text)?.[0];
1692
+ return match !== undefined && match !== "" && !/[A-Za-z0-9_$(]/.test(text[match.length] ?? "");
1693
+ });
1694
+ }
1695
+
1696
+ /** This function gives each small document of an operator with `term` in one term. A `flat` operator also has three terms. */
1697
+ function operatorDocs(op: Op, term: Json, leaf: Json): readonly Json[] {
1698
+ if (op.fixity !== "infix") return [opDoc(op, [term])];
1699
+ const pairs = [[term, leaf], [leaf, term]];
1700
+ return (op.assoc === "flat" ? [...pairs, [term, leaf, leaf], [leaf, term, leaf]] : pairs).map((terms) => opDoc(op, terms));
1701
+ }
1702
+
1703
+ /** This function gives the longer symbol that reads two postfix operators without a space, or `undefined`. */
1704
+ function gluedSymbol(syn: Compiled, outer: Op, inner: Op): string | undefined {
1705
+ if (outer.word || inner.word || outer.fixity !== "postfix" || inner.fixity !== "postfix") return undefined;
1706
+ const glued = inner.symbol + outer.symbol;
1707
+ const read = syn.binarySymbols.find((symbol) => glued.startsWith(symbol));
1708
+ return read === inner.symbol ? undefined : read;
1709
+ }
1710
+
1711
+ function checkOperators({ profile, syn, leaf, keeps }: ProfileCheck): void {
1712
+ const ops = (profile.operators ?? []).map((given) => (given.fixity === "prefix" ? syn.prefix : syn.binary).get(given.symbol) as Op);
1713
+ const sound = ops.filter((op, index) => operatorDocs(op, leaf, leaf).every((doc) => keeps("operator", ["operators", index], `The operator ${op.symbol} does not come back.`, doc)));
1714
+ for (const outer of sound) {
1715
+ const place = ["operators", ops.indexOf(outer)];
1716
+ for (const inner of sound) {
1717
+ const glued = gluedSymbol(syn, outer, inner);
1718
+ const code = glued === undefined ? "grouping" : "longest-match";
1719
+ const rule = glued === undefined ? `The operator ${outer.symbol} with ${inner.symbol} in a term loses the tree.` : `The symbol ${glued} reads the postfix operators ${inner.symbol} and ${outer.symbol}.`;
1720
+ for (const term of operatorDocs(inner, leaf, leaf)) for (const doc of operatorDocs(outer, term, leaf)) keeps(code, place, rule, doc);
1721
+ }
1722
+ }
1723
+ }
1724
+
1725
+ function checkLiterals({ profile, syn, report }: ProfileCheck): void {
1726
+ for (const [word, value] of syn.words) {
1727
+ const back = parse(profile, word);
1728
+ if (!back.ok || !docEquals(back.doc, envelop(syn, literalDoc(value)))) report("word", ["literals", "words", word], word, `The word ${word} does not read as its value.`);
1729
+ }
1730
+ for (const value of [0, -1, "a"]) {
1731
+ const shape = literalShape(syn, value);
1732
+ if (shape.tag === "text" && patternAt(syn, shape.text) >= 0) report("pattern", [], shape.text, `The literal ${shape.text} reads as a reference.`);
1733
+ }
1734
+ }
1735
+
1736
+ function checkRoots({ profile, syn, keeps }: ProfileCheck): void {
1737
+ (profile.refs?.roots ?? DEFAULT_ROOTS).forEach((root, index) => {
1738
+ for (const path of syn.fields ? [[root], [root, "x"]] : [[root]]) {
1739
+ keeps("root", ["refs", "roots", index], `The root ${root} does not come back.`, { type: "ref", value: path }, path.join("."));
1740
+ }
1741
+ });
1742
+ }
1743
+
1744
+ /** This function gives a value that a call form writes: the fewest arguments, and at least one. */
1745
+ function formValue(given: SyntaxForm, leaf: Json): Json {
1746
+ const args = given.args ?? (given.fields === undefined ? "list" : "fields");
1747
+ const least = Math.max(given.min ?? given.fields?.length ?? 0, 1);
1748
+ if (args === "literal") return 1;
1749
+ if (args === "list") return Array.from({ length: least }, () => leaf);
1750
+ const keys = (given.fields ?? ["x"]).slice(0, args === "named" ? 1 : least);
1751
+ return Object.fromEntries(keys.map((key) => [key, leaf]));
1752
+ }
1753
+
1754
+ function checkCalls({ profile, syn, leaf, keeps }: ProfileCheck): void {
1755
+ const calls = profile.calls ?? {};
1756
+ (calls.forms ?? []).forEach((form, index) => {
1757
+ keeps("form", ["calls", "forms", index], `The form ${form.name} does not come back.`, { type: typeOfKind(form.kind).type, value: formValue(form, leaf) });
1758
+ });
1759
+ // The dotted text of a call name needs the field steps. A profile with `ops: true` and no field steps has no dotted call.
1760
+ if (syn.ops === true && syn.fields) keeps("call", ["calls", "ops"], "A call name with a dot does not come back.", { type: ["ops", "m", "p"], value: [leaf] });
1761
+ if (typeof calls.ops === "boolean" || calls.ops === undefined) return;
1762
+ calls.ops.forEach((name, index) => {
1763
+ keeps("call", ["calls", "ops", index], `The call ${name} does not come back.`, { type: ["ops", ...name.split(".")], value: [leaf] });
1764
+ });
1765
+ }
1766
+
1767
+ /** This function writes two terms with the separator. It blames the separator only when a neutral separator keeps the document. */
1768
+ function checkSeparator({ profile, syn, leaf, report }: ProfileCheck): void {
1769
+ if (syn.ops !== true && syn.ops.size === 0 && syn.forms.size === 0 && syn.constructKinds.size === 0) return;
1770
+ const open: SyntaxProfile = { ...profile, calls: { ...profile.calls, ops: true } };
1771
+ const neutral: SyntaxProfile = { ...profile, calls: { ...open.calls, separator: "\u0001" } };
1772
+ const postfix = [...syn.binary.values()].filter((op) => op.fixity === "postfix").map((op) => opDoc(op, [leaf]));
1773
+ const terms: Json[] = [leaf, { type: "ref", value: [syn.bare, "b c"] }, "a", 1, ...postfix];
1774
+ const docs = terms.map((term): Json => ({ type: ["ops", "probe__"], value: [term, leaf] }));
1775
+ const entries = { x: leaf, y: leaf };
1776
+ const { record, let: bind } = syn.constructs;
1777
+ if (record !== undefined) docs.push({ type: record, value: entries });
1778
+ if (bind !== undefined) docs.push({ type: bind, value: { bind: entries, body: leaf } });
1779
+ for (const doc of docs) {
1780
+ const broken = lawBreak(neutral, doc) === undefined ? lawBreak(open, doc) : undefined;
1781
+ if (broken !== undefined) report("separator", ["calls", "separator"], "", `The separator "${syn.separator}" does not end an argument. ${broken.why}`);
1782
+ }
1783
+ }
1784
+
1785
+ function checkConstructs({ syn, leaf, keeps }: ProfileCheck): void {
1786
+ const entries = { x: leaf };
1787
+ const values: Readonly<Record<keyof SyntaxConstructs, Json>> = { record: entries, let: { bind: entries, body: leaf }, fn: { params: ["x"], body: leaf }, call: { fn: leaf, args: [leaf] } };
1788
+ for (const name of CONSTRUCTS) {
1789
+ const type = syn.constructs[name];
1790
+ if (type !== undefined) keeps("construct", ["constructs", name], `The form ${name} does not come back.`, { type, value: values[name] });
1791
+ }
1792
+ }
1793
+
1794
+ /**
1795
+ * This function finds the faults of a syntax profile before a host uses it (SYNTAX.PROFILE-CHECK, D140). A fault is a
1796
+ * document of an entry that breaks `parse(print(doc))`, or a text of an entry that reads another way. The function
1797
+ * writes and reads one small document for each entry, for each pair of operators and for each root. Thus an empty list
1798
+ * tells that these small documents come back. It does not prove the law for each document. `parse` and `print` do not
1799
+ * call this function, thus it adds no cost to them.
1800
+ *
1801
+ * @param profile - The syntax profile.
1802
+ * @returns At most one finding for each code and place, in the order of the operators, the literals, the references, the calls and the constructs.
1803
+ * @throws An error of a `read` or a `write` function of a pattern.
1804
+ */
1805
+ export function checkSyntax(profile: SyntaxProfile): readonly SyntaxFinding[] {
1806
+ // A small `maxDepth` is a limit that the host chose, not a fault, thus each probe has the default limit.
1807
+ const open: SyntaxProfile = { ...profile, maxDepth: 256 };
1808
+ let syn: Compiled;
1809
+ try {
1810
+ syn = compileProfile(open);
1811
+ } catch (error) {
1812
+ if (error instanceof TypeError) return [{ code: "invalid", place: [], reason: error.message }];
1813
+ throw error;
1814
+ }
1815
+ const found = new Map<string, SyntaxFinding>();
1816
+ const report: ProfileCheck["report"] = (code, place, text, reason) => {
1817
+ const index = patternAt(syn, text);
1818
+ const finding: SyntaxFinding = index < 0 ? { code, place, reason } : { code: "pattern", place: ["refs", "patterns", index], reason: `The pattern reads the start of the text ${text}. ${reason}` };
1819
+ const key = JSON.stringify([finding.code, finding.place]);
1820
+ if (!found.has(key)) found.set(key, finding);
1821
+ };
1822
+ const keeps: ProfileCheck["keeps"] = (code, place, rule, doc, start = "") => {
1823
+ const broken = lawBreak(open, doc);
1824
+ if (broken !== undefined) report(code, place, broken.text ?? start, `${rule} ${broken.why}`);
1825
+ return broken === undefined;
1826
+ };
1827
+ const check: ProfileCheck = { profile: open, syn, leaf: { type: "ref", value: [syn.bare, "a"] }, report, keeps };
1828
+ checkOperators(check);
1829
+ checkLiterals(check);
1830
+ checkRoots(check);
1831
+ checkCalls(check);
1832
+ checkSeparator(check);
1833
+ checkConstructs(check);
1834
+ return [...found.values()];
1835
+ }
package/src/typing.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type Envelope, type Key, isRecord, typeName } from "./doc.ts";
2
2
  import type { Machine } from "./evaluate.ts";
3
3
  import type { DocVisit, Issue, WorldIssue } from "./static.ts";
4
- import { type ValueType, domainOf, fits, isUnknown, typeIssues, typeText } from "./types.ts";
4
+ import { type Domain, type ValueType, domainOf, fits, isPrecise, isUnknown, typeIssues, typeText } from "./types.ts";
5
5
  import { type Kind, type Shape, eachKind, kindAt } from "./world.ts";
6
6
 
7
7
  /*
@@ -116,6 +116,8 @@ export interface TypeProblem {
116
116
  * (TYPE.RULE).
117
117
  */
118
118
  export interface TypeRule {
119
+ /** `true` when the document reads a value from outside the document. The check then has no proof of its type (PLAN.GUARD-BOUNDARY). */
120
+ readonly read?: true;
119
121
  /**
120
122
  * This function tells the type that a part must have, and the types of the names for the part. The check asks it
121
123
  * before it types the part. The parts that give the names of a rule `keysAt` come first.
@@ -174,6 +176,33 @@ export interface Typed {
174
176
  readonly type: ValueType;
175
177
  /** The type that the position of the document needs, when it needs one. */
176
178
  readonly expected?: ValueType;
179
+ /** `true` when the rule of the document tells a read of a value that comes from outside the document. */
180
+ readonly read?: true;
181
+ }
182
+
183
+ /** A place where a promise of the types holds only with a test at run time (PLAN.GUARD). */
184
+ export interface Boundary {
185
+ /** The keys from the root document to the document. */
186
+ readonly place: readonly Key[];
187
+ /** The type that the value of the place must have. */
188
+ readonly type: ValueType;
189
+ }
190
+
191
+ /**
192
+ * This function gives the boundaries of a check. A part with an imprecise type at a position that needs a type is a
193
+ * boundary. A read of a known type is a boundary too, because the check trusts the root types (PLAN.GUARD-BOUNDARY).
194
+ *
195
+ * @param typing - The places of a check of types.
196
+ * @param domains - The domains of the machine.
197
+ * @returns The boundaries, in the order of the document.
198
+ */
199
+ export function boundariesOf(typing: { readonly places: readonly Typed[] }, domains?: Readonly<Record<string, Domain>>): readonly Boundary[] {
200
+ const found: Boundary[] = [];
201
+ for (const item of typing.places) {
202
+ if (item.expected !== undefined && item.expected !== "unknown" && !isPrecise(item.type, domains)) found.push({ place: item.place, type: item.expected });
203
+ else if (item.read === true && item.type !== "unknown") found.push({ place: item.place, type: item.type });
204
+ }
205
+ return found;
177
206
  }
178
207
 
179
208
  /** One document that the walk of a check visited, with what the pass of types needs. The package does not export it. */
@@ -238,7 +267,7 @@ function pathKey(path: readonly Key[]): string {
238
267
  export function ruleOf(machine: Machine, name: string | undefined): TypeRule | undefined {
239
268
  if (name === undefined || (machine.runners !== undefined && Object.hasOwn(machine.runners, name))) return undefined;
240
269
  const evaluator = Object.hasOwn(machine.evaluators, name) ? machine.evaluators[name] : undefined;
241
- const rule = typeof evaluator === "object" && evaluator !== null ? (evaluator as { readonly type?: unknown }).type : undefined;
270
+ const rule = (typeof evaluator === "object" && evaluator !== null) || typeof evaluator === "function" ? (evaluator as { readonly type?: unknown }).type : undefined;
242
271
  return typeof rule === "object" && rule !== null && typeof (rule as TypeRule).synth === "function" ? (rule as TypeRule) : undefined;
243
272
  }
244
273
 
@@ -408,7 +437,8 @@ export function typePass(machine: Machine, nodes: readonly TypeNode[], verb: str
408
437
 
409
438
  const places = nodes.map((node, index) => {
410
439
  const want = needed[index];
411
- const entry = { type: types[index] ?? "unknown", ...(want === undefined ? {} : { expected: want }) } as Typed;
440
+ const read = !node.marked && ruleOf(machine, node.evaluator)?.read === true;
441
+ const entry = { type: types[index] ?? "unknown", ...(want === undefined ? {} : { expected: want }), ...(read ? { read } : {}) } as Typed;
412
442
  // The keys of a place are made only when a reader asks for them, as a visit does.
413
443
  Object.defineProperty(entry, "place", { enumerable: true, get: () => node.visit.place });
414
444
  Object.defineProperty(entry, "at", { enumerable: true, get: () => node.visit.at });
package/src/world.ts CHANGED
@@ -89,7 +89,13 @@ export interface ScopeRule {
89
89
  export type CapsRule =
90
90
  | { readonly rule: "same"; readonly in?: readonly (readonly Key[])[] }
91
91
  | { readonly rule: "mask"; readonly in?: readonly (readonly Key[])[] }
92
- | { readonly rule: "only"; readonly caps: readonly Cap[]; readonly in?: readonly (readonly Key[])[] };
92
+ | { readonly rule: "only"; readonly caps: readonly Cap[]; readonly in?: readonly (readonly Key[])[] }
93
+ /**
94
+ * The parts that the rule covers get each capability that the value of the document holds at the path `from`. The
95
+ * code of the kind answers these effects with its own clauses (`RunPart.handled`), thus they do not reach the host.
96
+ * The key `"*"` in `from` steps into each item (CAP.GRANT, D146).
97
+ */
98
+ | { readonly rule: "grant"; readonly from: readonly Key[]; readonly in?: readonly (readonly Key[])[] };
93
99
 
94
100
  /**
95
101
  * A lazy rule of a kind names the parts that an evaluation can skip and still give a value. An example is the