plan-verify 0.0.0-stage → 0.1.0

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.
Files changed (222) hide show
  1. package/.github/workflows/ci.yml +48 -0
  2. package/README.md +1431 -2
  3. package/assets/icon.png +0 -0
  4. package/assets/logo-mark.png +0 -0
  5. package/assets/logo.png +0 -0
  6. package/examples/checkout.ears.md +74 -0
  7. package/examples/order-always.ears.md +3 -0
  8. package/examples/order-awkward.ears.md +8 -0
  9. package/examples/order-bad.json +1 -0
  10. package/examples/order-chain.ears.md +9 -0
  11. package/examples/order-chosen.ears.md +7 -0
  12. package/examples/order-clear.ears.md +8 -0
  13. package/examples/order-combined.ears.md +10 -0
  14. package/examples/order-conflict.ears.md +8 -0
  15. package/examples/order-covered.ears.md +9 -0
  16. package/examples/order-duplicate.ears.md +8 -0
  17. package/examples/order-fail.json +5 -0
  18. package/examples/order-misuse.ears.md +7 -0
  19. package/examples/order-negation.ears.md +7 -0
  20. package/examples/order-pass.json +5 -0
  21. package/examples/order-quantity.ears.md +8 -0
  22. package/examples/order-redundant.ears.md +9 -0
  23. package/examples/order-repair.ears.md +9 -0
  24. package/examples/order-repeat.ears.md +13 -0
  25. package/examples/order-same.ears.md +3 -0
  26. package/examples/order-status.ears.md +7 -0
  27. package/examples/order-unanswered.ears.md +8 -0
  28. package/examples/order-unhandled.ears.md +8 -0
  29. package/examples/order-unmeasured.ears.md +10 -0
  30. package/examples/order-unused.ears.md +13 -0
  31. package/examples/order-vacuous.ears.md +9 -0
  32. package/examples/order-vague.ears.md +7 -0
  33. package/examples/order-wide.ears.md +5 -0
  34. package/examples/order-wording.ears.md +8 -0
  35. package/examples/order.ears.md +7 -0
  36. package/examples/payment-if.ears.md +7 -0
  37. package/examples/plan-to-problems/README.md +54 -0
  38. package/examples/plan-to-problems/expected-problems.md +25 -0
  39. package/examples/plan-to-problems/generate.mjs +186 -0
  40. package/examples/plan-to-problems/generated.ears.md +27 -0
  41. package/examples/plan-to-problems/lint.json +576 -0
  42. package/examples/plan-to-problems/plan.md +5 -0
  43. package/examples/plan-to-problems/planted.txt +5 -0
  44. package/examples/plan-to-problems/problems.md +112 -0
  45. package/examples/status-placed.ears.md +3 -0
  46. package/package.json +25 -4
  47. package/scripts/install-scriptc.sh +16 -0
  48. package/skills/plan-verify/SKILL.md +1515 -0
  49. package/src/check.ts +250 -0
  50. package/src/decide.ts +1324 -0
  51. package/src/ears.ts +1520 -0
  52. package/src/jsonvalue.ts +228 -0
  53. package/src/lint.ts +1245 -0
  54. package/src/main.ts +273 -0
  55. package/src/policy.ts +148 -0
  56. package/src/repair.ts +567 -0
  57. package/src/report.ts +329 -0
  58. package/src/scenarios.ts +149 -0
  59. package/src/smt.ts +97 -0
  60. package/src/solver.ts +868 -0
  61. package/src/spec.ts +307 -0
  62. package/src/term.ts +331 -0
  63. package/src/text.ts +95 -0
  64. package/test/fixtures/malformed.ears.md +3 -0
  65. package/test/fixtures/unknown-phrase.ears.md +5 -0
  66. package/test/golden/check-fail-json.exit +1 -0
  67. package/test/golden/check-fail-json.stderr +0 -0
  68. package/test/golden/check-fail-json.stdout +13 -0
  69. package/test/golden/check-fail.exit +1 -0
  70. package/test/golden/check-fail.stderr +0 -0
  71. package/test/golden/check-fail.stdout +7 -0
  72. package/test/golden/check-pass-json.exit +1 -0
  73. package/test/golden/check-pass-json.stderr +0 -0
  74. package/test/golden/check-pass-json.stdout +6 -0
  75. package/test/golden/check-pass.exit +1 -0
  76. package/test/golden/check-pass.stderr +0 -0
  77. package/test/golden/check-pass.stdout +3 -0
  78. package/test/golden/emit-smt-json-usage.exit +1 -0
  79. package/test/golden/emit-smt-json-usage.stderr +0 -0
  80. package/test/golden/emit-smt-json-usage.stdout +4 -0
  81. package/test/golden/emit-smt-json.exit +1 -0
  82. package/test/golden/emit-smt-json.stderr +0 -0
  83. package/test/golden/emit-smt-json.stdout +5 -0
  84. package/test/golden/emit-smt.exit +1 -0
  85. package/test/golden/emit-smt.stderr +0 -0
  86. package/test/golden/emit-smt.stdout +10 -0
  87. package/test/golden/help.exit +1 -0
  88. package/test/golden/help.stderr +0 -0
  89. package/test/golden/help.stdout +8 -0
  90. package/test/golden/lint-awkward.exit +1 -0
  91. package/test/golden/lint-awkward.stderr +0 -0
  92. package/test/golden/lint-awkward.stdout +29 -0
  93. package/test/golden/lint-chain.exit +1 -0
  94. package/test/golden/lint-chain.stderr +0 -0
  95. package/test/golden/lint-chain.stdout +56 -0
  96. package/test/golden/lint-checkout.exit +1 -0
  97. package/test/golden/lint-checkout.stderr +0 -0
  98. package/test/golden/lint-checkout.stdout +301 -0
  99. package/test/golden/lint-clean.exit +1 -0
  100. package/test/golden/lint-clean.stderr +0 -0
  101. package/test/golden/lint-clean.stdout +9 -0
  102. package/test/golden/lint-clear.exit +1 -0
  103. package/test/golden/lint-clear.stderr +0 -0
  104. package/test/golden/lint-clear.stdout +9 -0
  105. package/test/golden/lint-conflict-json.exit +1 -0
  106. package/test/golden/lint-conflict-json.stderr +0 -0
  107. package/test/golden/lint-conflict-json.stdout +80 -0
  108. package/test/golden/lint-conflict.exit +1 -0
  109. package/test/golden/lint-conflict.stderr +0 -0
  110. package/test/golden/lint-conflict.stdout +41 -0
  111. package/test/golden/lint-duplicate.exit +1 -0
  112. package/test/golden/lint-duplicate.stderr +0 -0
  113. package/test/golden/lint-duplicate.stdout +14 -0
  114. package/test/golden/lint-gap.exit +1 -0
  115. package/test/golden/lint-gap.stderr +0 -0
  116. package/test/golden/lint-gap.stdout +12 -0
  117. package/test/golden/lint-misuse.exit +1 -0
  118. package/test/golden/lint-misuse.stderr +0 -0
  119. package/test/golden/lint-misuse.stdout +18 -0
  120. package/test/golden/lint-negation.exit +1 -0
  121. package/test/golden/lint-negation.stderr +0 -0
  122. package/test/golden/lint-negation.stdout +13 -0
  123. package/test/golden/lint-quantity.exit +1 -0
  124. package/test/golden/lint-quantity.stderr +0 -0
  125. package/test/golden/lint-quantity.stdout +41 -0
  126. package/test/golden/lint-redundant.exit +1 -0
  127. package/test/golden/lint-redundant.stderr +0 -0
  128. package/test/golden/lint-redundant.stdout +14 -0
  129. package/test/golden/lint-repair-json.exit +1 -0
  130. package/test/golden/lint-repair-json.stderr +0 -0
  131. package/test/golden/lint-repair-json.stdout +127 -0
  132. package/test/golden/lint-repair.exit +1 -0
  133. package/test/golden/lint-repair.stderr +0 -0
  134. package/test/golden/lint-repair.stdout +54 -0
  135. package/test/golden/lint-repeat.exit +1 -0
  136. package/test/golden/lint-repeat.stderr +0 -0
  137. package/test/golden/lint-repeat.stdout +48 -0
  138. package/test/golden/lint-status.exit +1 -0
  139. package/test/golden/lint-status.stderr +0 -0
  140. package/test/golden/lint-status.stdout +44 -0
  141. package/test/golden/lint-unanswered.exit +1 -0
  142. package/test/golden/lint-unanswered.stderr +0 -0
  143. package/test/golden/lint-unanswered.stdout +12 -0
  144. package/test/golden/lint-unhandled.exit +1 -0
  145. package/test/golden/lint-unhandled.stderr +0 -0
  146. package/test/golden/lint-unhandled.stdout +12 -0
  147. package/test/golden/lint-unmeasured.exit +1 -0
  148. package/test/golden/lint-unmeasured.stderr +0 -0
  149. package/test/golden/lint-unmeasured.stdout +13 -0
  150. package/test/golden/lint-unused-json.exit +1 -0
  151. package/test/golden/lint-unused-json.stderr +0 -0
  152. package/test/golden/lint-unused-json.stdout +37 -0
  153. package/test/golden/lint-unused.exit +1 -0
  154. package/test/golden/lint-unused.stderr +0 -0
  155. package/test/golden/lint-unused.stdout +25 -0
  156. package/test/golden/lint-vacuous-json.exit +1 -0
  157. package/test/golden/lint-vacuous-json.stderr +0 -0
  158. package/test/golden/lint-vacuous-json.stdout +27 -0
  159. package/test/golden/lint-vacuous.exit +1 -0
  160. package/test/golden/lint-vacuous.stderr +0 -0
  161. package/test/golden/lint-vacuous.stdout +19 -0
  162. package/test/golden/lint-vague.exit +1 -0
  163. package/test/golden/lint-vague.stderr +0 -0
  164. package/test/golden/lint-vague.stdout +21 -0
  165. package/test/golden/lint-wide.exit +1 -0
  166. package/test/golden/lint-wide.stderr +0 -0
  167. package/test/golden/lint-wide.stdout +9 -0
  168. package/test/golden/lint-wording.exit +1 -0
  169. package/test/golden/lint-wording.stderr +0 -0
  170. package/test/golden/lint-wording.stdout +21 -0
  171. package/test/golden/malformed-scenario.exit +1 -0
  172. package/test/golden/malformed-scenario.stderr +1 -0
  173. package/test/golden/malformed-scenario.stdout +0 -0
  174. package/test/golden/malformed-spec.exit +1 -0
  175. package/test/golden/malformed-spec.stderr +1 -0
  176. package/test/golden/malformed-spec.stdout +0 -0
  177. package/test/golden/policy-counterexample.exit +1 -0
  178. package/test/golden/policy-counterexample.stderr +0 -0
  179. package/test/golden/policy-counterexample.stdout +13 -0
  180. package/test/golden/policy-covered.exit +1 -0
  181. package/test/golden/policy-covered.stderr +0 -0
  182. package/test/golden/policy-covered.stdout +19 -0
  183. package/test/golden/policy-holds.exit +1 -0
  184. package/test/golden/policy-holds.stderr +0 -0
  185. package/test/golden/policy-holds.stdout +3 -0
  186. package/test/golden/policy-json.exit +1 -0
  187. package/test/golden/policy-json.stderr +0 -0
  188. package/test/golden/policy-json.stdout +34 -0
  189. package/test/golden/policy-status.exit +1 -0
  190. package/test/golden/policy-status.stderr +0 -0
  191. package/test/golden/policy-status.stdout +16 -0
  192. package/test/golden/policy-vacuous.exit +1 -0
  193. package/test/golden/policy-vacuous.stderr +0 -0
  194. package/test/golden/policy-vacuous.stdout +3 -0
  195. package/test/golden/policy-wide.exit +1 -0
  196. package/test/golden/policy-wide.stderr +0 -0
  197. package/test/golden/policy-wide.stdout +15 -0
  198. package/test/golden/policy.exit +1 -0
  199. package/test/golden/policy.stderr +0 -0
  200. package/test/golden/policy.stdout +19 -0
  201. package/test/golden/scenarios-json.exit +1 -0
  202. package/test/golden/scenarios-json.stderr +0 -0
  203. package/test/golden/scenarios-json.stdout +75 -0
  204. package/test/golden/scenarios-status.exit +1 -0
  205. package/test/golden/scenarios-status.stderr +0 -0
  206. package/test/golden/scenarios-status.stdout +24 -0
  207. package/test/golden/scenarios.exit +1 -0
  208. package/test/golden/scenarios.stderr +0 -0
  209. package/test/golden/scenarios.stdout +26 -0
  210. package/test/golden/unit.exit +1 -0
  211. package/test/golden/unit.stderr +0 -0
  212. package/test/golden/unit.stdout +2 -0
  213. package/test/golden/unknown-phrase.exit +1 -0
  214. package/test/golden/unknown-phrase.stderr +1 -0
  215. package/test/golden/unknown-phrase.stdout +0 -0
  216. package/test/golden/usage.exit +1 -0
  217. package/test/golden/usage.stderr +9 -0
  218. package/test/golden/usage.stdout +0 -0
  219. package/test/run-all.ts +499 -0
  220. package/test/soundness.ts +375 -0
  221. package/test/unit.ts +1352 -0
  222. package/tsconfig.json +15 -0
package/src/ears.ts ADDED
@@ -0,0 +1,1520 @@
1
+ /**
2
+ * Parser for an EARS spec with a declared vocabulary.
3
+ *
4
+ * Declarations come first in practice. A requirement can only mention
5
+ * symbols declared above it. Clauses, when present, go in this order:
6
+ * while, when, if, where. `then` is required exactly when an if clause
7
+ * is present. The response is `the <system> shall <boolean>`.
8
+ *
9
+ * A declaration may bind a symbol to a phrase. Matching is the exact
10
+ * word sequence, case-insensitive, and the longest match wins.
11
+ * "is not" inside a phrase that contains "is" is the same symbol, negated.
12
+ * An Int or enum may also bind `as` (how a requirement states it) and
13
+ * `says` (how a report states it). A Bool may bind `action` as a second phrase.
14
+ */
15
+
16
+ import {
17
+ findEnum,
18
+ findSymbol,
19
+ findVariant,
20
+ negateReport,
21
+ type EnumDecl,
22
+ type Requirement,
23
+ type Role,
24
+ type Sort,
25
+ type Spec,
26
+ type SymbolDecl,
27
+ type Term,
28
+ } from "./spec.ts";
29
+ import { displayQuote } from "./text.ts";
30
+
31
+ export type SpecResult = { ok: true; spec: Spec } | { ok: false; line: number; message: string };
32
+
33
+ const RANGE_BOUND = 1000000;
34
+
35
+ type SortTag = { kind: "bool" } | { kind: "int" } | { kind: "enum"; enumName: string };
36
+
37
+ type Parsed = { ok: true; term: Term; sort: SortTag } | { ok: false; message: string };
38
+
39
+ class Cur {
40
+ text: string;
41
+ i: number;
42
+
43
+ constructor(text: string) {
44
+ this.text = text;
45
+ this.i = 0;
46
+ }
47
+
48
+ skip(): void {
49
+ while (this.i < this.text.length) {
50
+ const code = this.text.charCodeAt(this.i);
51
+ if (code !== 32 && code !== 9 && code !== 10 && code !== 13) break;
52
+ this.i++;
53
+ }
54
+ }
55
+
56
+ eof(): boolean {
57
+ this.skip();
58
+ return this.i >= this.text.length;
59
+ }
60
+ }
61
+
62
+ export function parseSpec(text: string): SpecResult {
63
+ const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
64
+ const lines = splitLines(source);
65
+ const spec: Spec = { enums: [], symbols: [], requirements: [] };
66
+ let index = 0;
67
+ while (index < lines.length) {
68
+ const trimmed = lines[index].trim();
69
+ if (isIgnorable(trimmed)) {
70
+ index++;
71
+ continue;
72
+ }
73
+ if (isDeclaration(trimmed)) {
74
+ const message = parseDeclaration(trimmed, index + 1, spec);
75
+ if (message !== null) return { ok: false, line: index + 1, message: message };
76
+ index++;
77
+ continue;
78
+ }
79
+ const id = matchId(trimmed);
80
+ if (id === null) {
81
+ return {
82
+ ok: false,
83
+ line: index + 1,
84
+ message: "expected a requirement id such as R1, a declaration, a heading, or a comment",
85
+ };
86
+ }
87
+ const startLine = index + 1;
88
+ let sentence = id.rest.trim();
89
+ index++;
90
+ while (index < lines.length) {
91
+ const next = lines[index].trim();
92
+ if (next.length === 0 || isIgnorable(next) || matchId(next) !== null || isDeclaration(next)) break;
93
+ if (sentence.length === 0) sentence = next;
94
+ else sentence += " " + next;
95
+ index++;
96
+ }
97
+ if (sentence.length === 0) {
98
+ return { ok: false, line: startLine, message: "expected a sentence after " + id.id };
99
+ }
100
+ for (let r = 0; r < spec.requirements.length; r++) {
101
+ if (spec.requirements[r].id === id.id) {
102
+ return { ok: false, line: startLine, message: "duplicate requirement " + id.id };
103
+ }
104
+ }
105
+ const parsed = parseSentence(sentence, spec);
106
+ if (!parsed.ok) return { ok: false, line: startLine, message: parsed.message };
107
+ const requirement: Requirement = {
108
+ id: id.id,
109
+ line: startLine,
110
+ sentence: sentence,
111
+ system: parsed.system,
112
+ antecedent: parsed.antecedent,
113
+ consequent: parsed.consequent,
114
+ hasWhen: parsed.hasWhen,
115
+ hasIf: parsed.hasIf,
116
+ whenTerm: parsed.whenTerm,
117
+ };
118
+ spec.requirements.push(requirement);
119
+ }
120
+ return { ok: true, spec: spec };
121
+ }
122
+
123
+ export type PropertyResult = { ok: true; requirements: Requirement[] } | { ok: false; line: number; message: string };
124
+
125
+ /** Requirement lines only. Symbols come from the spec this property is checked against. */
126
+ export function parseProperty(text: string, spec: Spec): PropertyResult {
127
+ const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
128
+ const lines = splitLines(source);
129
+ const requirements: Requirement[] = [];
130
+ let index = 0;
131
+ while (index < lines.length) {
132
+ const trimmed = lines[index].trim();
133
+ if (isIgnorable(trimmed)) {
134
+ index++;
135
+ continue;
136
+ }
137
+ if (isDeclaration(trimmed)) {
138
+ return { ok: false, line: index + 1, message: "a property cannot declare symbols" };
139
+ }
140
+ const id = matchId(trimmed);
141
+ if (id === null) {
142
+ return { ok: false, line: index + 1, message: "expected a requirement id such as P1" };
143
+ }
144
+ const startLine = index + 1;
145
+ let sentence = id.rest.trim();
146
+ index++;
147
+ while (index < lines.length) {
148
+ const next = lines[index].trim();
149
+ if (next.length === 0 || isIgnorable(next) || matchId(next) !== null || isDeclaration(next)) break;
150
+ if (sentence.length === 0) sentence = next;
151
+ else sentence += " " + next;
152
+ index++;
153
+ }
154
+ if (sentence.length === 0) {
155
+ return { ok: false, line: startLine, message: "expected a sentence after " + id.id };
156
+ }
157
+ for (let r = 0; r < requirements.length; r++) {
158
+ if (requirements[r].id === id.id) {
159
+ return { ok: false, line: startLine, message: "duplicate requirement " + id.id };
160
+ }
161
+ }
162
+ const parsed = parseSentence(sentence, spec);
163
+ if (!parsed.ok) return { ok: false, line: startLine, message: parsed.message };
164
+ requirements.push({
165
+ id: id.id,
166
+ line: startLine,
167
+ sentence: sentence,
168
+ system: parsed.system,
169
+ antecedent: parsed.antecedent,
170
+ consequent: parsed.consequent,
171
+ hasWhen: parsed.hasWhen,
172
+ hasIf: parsed.hasIf,
173
+ whenTerm: parsed.whenTerm,
174
+ });
175
+ }
176
+ if (requirements.length === 0) return { ok: false, line: 1, message: "expected a requirement" };
177
+ return { ok: true, requirements: requirements };
178
+ }
179
+
180
+ interface IdMatch {
181
+ id: string;
182
+ rest: string;
183
+ }
184
+
185
+ function matchId(line: string): IdMatch | null {
186
+ if (line.length < 2) return null;
187
+ const head = line.charCodeAt(0);
188
+ if (head !== 82 && head !== 80) return null;
189
+ let i = 1;
190
+ if (!isDigit(line.charCodeAt(i))) return null;
191
+ while (i < line.length && isDigit(line.charCodeAt(i))) i++;
192
+ while (i < line.length && line.charCodeAt(i) === 46) {
193
+ i++;
194
+ if (i >= line.length || !isDigit(line.charCodeAt(i))) return null;
195
+ while (i < line.length && isDigit(line.charCodeAt(i))) i++;
196
+ }
197
+ if (i < line.length && !isSpace(line.charCodeAt(i))) return null;
198
+ return { id: line.slice(0, i), rest: line.slice(i) };
199
+ }
200
+
201
+ function isDeclaration(line: string): boolean {
202
+ const word = leadingWord(line).toLowerCase();
203
+ return word === "enum" || word === "event" || word === "state" || word === "input" || word === "output" || word === "unwanted";
204
+ }
205
+
206
+ function isIgnorable(line: string): boolean {
207
+ if (line.length === 0) return true;
208
+ if (line.startsWith("#")) return true;
209
+ if (line.startsWith("//")) return true;
210
+ if (line.startsWith("<!--")) return true;
211
+ if (line === "-->") return true;
212
+ return false;
213
+ }
214
+
215
+ function parseDeclaration(line: string, lineNo: number, spec: Spec): string | null {
216
+ const cur = new Cur(line);
217
+ const first = readIdent(cur);
218
+ if (first === null) return "expected a declaration";
219
+ const keyword = first.toLowerCase();
220
+ if (keyword === "enum") return parseEnum(cur, lineNo, spec);
221
+ let unwanted = false;
222
+ let roleWord = keyword;
223
+ if (keyword === "unwanted") {
224
+ unwanted = true;
225
+ const second = readIdent(cur);
226
+ if (second === null || second.toLowerCase() !== "event") return "unwanted only applies to an event";
227
+ roleWord = "event";
228
+ }
229
+ if (roleWord !== "event" && roleWord !== "state" && roleWord !== "input" && roleWord !== "output") {
230
+ return "expected a declaration";
231
+ }
232
+ const role: Role = roleWord;
233
+ const name = readIdent(cur);
234
+ if (name === null) return "expected a symbol name";
235
+ const clash = nameClash(spec, name);
236
+ if (clash.length > 0) return clash;
237
+ cur.skip();
238
+ if (cur.i >= cur.text.length || cur.text.charCodeAt(cur.i) !== 58) return "expected ':'";
239
+ cur.i++;
240
+ const sort = parseSort(cur, spec, role);
241
+ if (typeof sort === "string") return sort;
242
+ if ((role === "event" || role === "state") && sort.kind !== "bool") {
243
+ return role === "event" ? "events must be Bool" : "states must be Bool";
244
+ }
245
+ const described = parseQuoted(cur, "description");
246
+ if (!described.ok) return described.message;
247
+ const phrased = parseOptionalPhrase(cur, spec);
248
+ if (!phrased.ok) return phrased.message;
249
+ const tail = parseWordingTail(cur, spec, sort, name, phrased.phrase);
250
+ if (!tail.ok) return tail.message;
251
+ const decl: SymbolDecl = {
252
+ role: role,
253
+ name: name,
254
+ sort: sort,
255
+ description: described.text,
256
+ phrase: phrased.phrase,
257
+ outcomeYes: tail.yes,
258
+ outcomeNo: tail.no,
259
+ action: tail.action,
260
+ says: tail.says,
261
+ asText: tail.asText,
262
+ unwanted: unwanted,
263
+ line: lineNo,
264
+ source: line,
265
+ };
266
+ spec.symbols.push(decl);
267
+ return null;
268
+ }
269
+
270
+ function parseEnum(cur: Cur, lineNo: number, spec: Spec): string | null {
271
+ const name = readIdent(cur);
272
+ if (name === null) return "expected an enum name";
273
+ const clash = nameClash(spec, name);
274
+ if (clash.length > 0) return clash;
275
+ cur.skip();
276
+ if (cur.i >= cur.text.length || cur.text.charCodeAt(cur.i) !== 123) return "expected '{'";
277
+ cur.i++;
278
+ const variants: string[] = [];
279
+ cur.skip();
280
+ if (cur.i < cur.text.length && cur.text.charCodeAt(cur.i) === 125) return "expected a variant";
281
+ while (!cur.eof()) {
282
+ const variant = readIdent(cur);
283
+ if (variant === null) return "expected a variant";
284
+ const variantClash = nameClash(spec, variant);
285
+ if (variantClash.length > 0) return variantClash;
286
+ for (let i = 0; i < variants.length; i++) {
287
+ if (variants[i] === variant) return displayQuote(variant) + " is already an enum variant";
288
+ }
289
+ variants.push(variant);
290
+ cur.skip();
291
+ if (cur.i < cur.text.length && cur.text.charCodeAt(cur.i) === 44) {
292
+ cur.i++;
293
+ cur.skip();
294
+ if (cur.i < cur.text.length && cur.text.charCodeAt(cur.i) === 125) return "trailing comma";
295
+ continue;
296
+ }
297
+ break;
298
+ }
299
+ cur.skip();
300
+ if (cur.i >= cur.text.length || cur.text.charCodeAt(cur.i) !== 125) return "expected '}'";
301
+ cur.i++;
302
+ if (!cur.eof()) return "unexpected trailing text";
303
+ const decl: EnumDecl = { name: name, variants: variants, line: lineNo };
304
+ spec.enums.push(decl);
305
+ return null;
306
+ }
307
+
308
+ function parseSort(cur: Cur, spec: Spec, role: Role): Sort | string {
309
+ const word = readIdent(cur);
310
+ if (word === null) return "expected a type";
311
+ const lower = word.toLowerCase();
312
+ if (lower === "bool") return { kind: "bool" };
313
+ if (lower === "int") {
314
+ if (role === "event" || role === "state") return role === "event" ? "events must be Bool" : "states must be Bool";
315
+ const min = readSigned(cur);
316
+ if (min === null) return "expected an integer range such as 1..10";
317
+ cur.skip();
318
+ if (cur.i + 1 >= cur.text.length || cur.text.charCodeAt(cur.i) !== 46 || cur.text.charCodeAt(cur.i + 1) !== 46) {
319
+ return "expected '..' in the integer range";
320
+ }
321
+ cur.i += 2;
322
+ const max = readSigned(cur);
323
+ if (max === null) return "expected the end of the integer range";
324
+ if (min < -RANGE_BOUND || max > RANGE_BOUND) return "integer range must stay inside -1000000..1000000";
325
+ if (min > max) return "integer range min is greater than max";
326
+ return { kind: "int", min: min, max: max };
327
+ }
328
+ const enumDecl = findEnum(spec, word);
329
+ if (enumDecl === null) return "unknown enum " + displayQuote(word);
330
+ if (role === "event" || role === "state") return role === "event" ? "events must be Bool" : "states must be Bool";
331
+ return { kind: "enum", enumName: enumDecl.name };
332
+ }
333
+
334
+ function parseQuoted(cur: Cur, label: string): { ok: true; text: string } | { ok: false; message: string } {
335
+ cur.skip();
336
+ if (cur.i >= cur.text.length || cur.text.charCodeAt(cur.i) !== 34) {
337
+ return { ok: false, message: "expected a " + label + " in quotes" };
338
+ }
339
+ cur.i++;
340
+ let out = "";
341
+ while (cur.i < cur.text.length) {
342
+ const code = cur.text.charCodeAt(cur.i);
343
+ if (code === 34) {
344
+ cur.i++;
345
+ if (out.length === 0) return { ok: false, message: "expected a " + label };
346
+ return { ok: true, text: out };
347
+ }
348
+ if (code === 10 || code === 13) return { ok: false, message: label + " cannot contain a newline" };
349
+ if (code === 92) {
350
+ cur.i++;
351
+ if (cur.i >= cur.text.length) return { ok: false, message: "unterminated string" };
352
+ const esc = cur.text.charCodeAt(cur.i);
353
+ if (esc === 110 || esc === 114) return { ok: false, message: label + " cannot contain a newline" };
354
+ if (esc === 34 || esc === 92 || esc === 47) out += cur.text.slice(cur.i, cur.i + 1);
355
+ else if (esc === 116) out += "\t";
356
+ else return { ok: false, message: "bad escape in " + label };
357
+ cur.i++;
358
+ continue;
359
+ }
360
+ out += cur.text.slice(cur.i, cur.i + 1);
361
+ cur.i++;
362
+ }
363
+ return { ok: false, message: "unterminated string" };
364
+ }
365
+
366
+ function parseOptionalPhrase(cur: Cur, spec: Spec): { ok: true; phrase: string } | { ok: false; message: string } {
367
+ cur.skip();
368
+ if (cur.eof()) return { ok: true, phrase: "" };
369
+ const save = cur.i;
370
+ const word = readIdent(cur);
371
+ if (word === null || lowerAscii(word) !== "phrase") {
372
+ cur.i = save;
373
+ return { ok: true, phrase: "" };
374
+ }
375
+ const quoted = parseQuoted(cur, "phrase");
376
+ if (!quoted.ok) return quoted;
377
+ const phrase = normalizeSpaces(quoted.text);
378
+ if (phrase.length === 0) return { ok: false, message: "expected a phrase" };
379
+ const clash = phraseClash(spec, phrase);
380
+ if (clash.length > 0) return { ok: false, message: clash };
381
+ return { ok: true, phrase: phrase };
382
+ }
383
+
384
+ interface WordingTail {
385
+ ok: true;
386
+ yes: string;
387
+ no: string;
388
+ action: string;
389
+ says: string;
390
+ asText: string;
391
+ }
392
+
393
+ function parseWordingTail(cur: Cur, spec: Spec, sort: Sort, name: string, phrase: string): WordingTail | { ok: false; message: string } {
394
+ let yes = "";
395
+ let no = "";
396
+ let action = "";
397
+ let says = "";
398
+ let asText = "";
399
+ let sawOutcome = false;
400
+ while (!cur.eof()) {
401
+ const save = cur.i;
402
+ const word = readIdent(cur);
403
+ if (word === null) return { ok: false, message: "unexpected trailing text" };
404
+ const key = lowerAscii(word);
405
+ if (key === "outcome") {
406
+ if (sawOutcome) return { ok: false, message: "repeated outcome" };
407
+ cur.i = save;
408
+ const stated = parseOptionalOutcome(cur, sort);
409
+ if (!stated.ok) return stated;
410
+ yes = stated.yes;
411
+ no = stated.no;
412
+ sawOutcome = true;
413
+ continue;
414
+ }
415
+ if (key === "action") {
416
+ if (action.length > 0) return { ok: false, message: "repeated action" };
417
+ if (sort.kind !== "bool") return { ok: false, message: "action is only for Bool" };
418
+ const quoted = parseQuoted(cur, "action");
419
+ if (!quoted.ok) return { ok: false, message: articleQuote(quoted.message, "action") };
420
+ const text = normalizeSpaces(quoted.text);
421
+ if (phrase.length > 0 && lowerAscii(phrase) === lowerAscii(text)) {
422
+ return { ok: false, message: displayQuote(text) + " is already a phrase" };
423
+ }
424
+ const clash = phraseClash(spec, text);
425
+ if (clash.length > 0) return { ok: false, message: clash };
426
+ action = text;
427
+ continue;
428
+ }
429
+ if (key === "says") {
430
+ if (says.length > 0) return { ok: false, message: "repeated says" };
431
+ if (sort.kind === "bool") return { ok: false, message: "says is only for Int or enum" };
432
+ const quoted = parseQuoted(cur, "says");
433
+ if (!quoted.ok) return { ok: false, message: articleQuote(quoted.message, "says") };
434
+ const text = normalizeSpaces(quoted.text);
435
+ if (!holePresent(text, name)) return { ok: false, message: "says needs {value}" };
436
+ says = text;
437
+ continue;
438
+ }
439
+ if (key === "as") {
440
+ if (asText.length > 0) return { ok: false, message: "repeated as" };
441
+ if (sort.kind === "bool") return { ok: false, message: "as is only for Int or enum" };
442
+ const quoted = parseQuoted(cur, "as");
443
+ if (!quoted.ok) return { ok: false, message: articleQuote(quoted.message, "as") };
444
+ const text = normalizeSpaces(quoted.text);
445
+ const patternError = validateAs(text, name);
446
+ if (patternError !== null) return { ok: false, message: patternError };
447
+ asText = text;
448
+ continue;
449
+ }
450
+ return { ok: false, message: "unexpected trailing text" };
451
+ }
452
+ return { ok: true, yes: yes, no: no, action: action, says: says, asText: asText };
453
+ }
454
+
455
+ function articleQuote(message: string, label: string): string {
456
+ if (message === "expected a " + label) return "expected an " + label;
457
+ if (message === "expected a " + label + " in quotes") return "expected an " + label + " in quotes";
458
+ return message;
459
+ }
460
+
461
+ function holePresent(text: string, symbolName: string): boolean {
462
+ if (containsToken(text, "{value}")) return true;
463
+ if (containsToken(text, "{name}")) return true;
464
+ if (containsToken(text, "{" + symbolName + "}")) return true;
465
+ return false;
466
+ }
467
+
468
+ function containsToken(text: string, token: string): boolean {
469
+ if (token.length === 0 || token.length > text.length) return false;
470
+ for (let i = 0; i + token.length <= text.length; i++) {
471
+ if (text.slice(i, i + token.length) === token) return true;
472
+ }
473
+ return false;
474
+ }
475
+
476
+ function validateAs(text: string, symbolName: string): string | null {
477
+ const tokens = tokenizeAs(text, symbolName);
478
+ if (tokens === null) return "as can only be words, {op}, and {value}";
479
+ let values = 0;
480
+ let ops = 0;
481
+ for (let i = 0; i < tokens.length; i++) {
482
+ if (tokens[i].kind === "value") values++;
483
+ if (tokens[i].kind === "op") ops++;
484
+ }
485
+ if (values === 0) return "as needs {value}";
486
+ if (values > 1) return "as has more than one {value}";
487
+ if (ops > 1) return "repeated {op}";
488
+ return null;
489
+ }
490
+
491
+ function parseOptionalOutcome(
492
+ cur: Cur,
493
+ sort: Sort,
494
+ ): { ok: true; yes: string; no: string } | { ok: false; message: string } {
495
+ cur.skip();
496
+ if (cur.eof()) return { ok: true, yes: "", no: "" };
497
+ const save = cur.i;
498
+ const word = readIdent(cur);
499
+ if (word === null || lowerAscii(word) !== "outcome") {
500
+ cur.i = save;
501
+ return { ok: true, yes: "", no: "" };
502
+ }
503
+ if (sort.kind !== "bool") return { ok: false, message: "outcome is only for Bool" };
504
+ const yesQuoted = parseQuoted(cur, "outcome");
505
+ if (!yesQuoted.ok) return { ok: false, message: outcomeQuoteMessage(yesQuoted.message) };
506
+ const yes = normalizeSpaces(yesQuoted.text);
507
+ const yesError = outcomeWords(yes);
508
+ if (yesError !== null) return { ok: false, message: yesError };
509
+ cur.skip();
510
+ if (cur.i < cur.text.length && cur.text.charCodeAt(cur.i) === 47) {
511
+ cur.i++;
512
+ const noQuoted = parseQuoted(cur, "outcome");
513
+ if (!noQuoted.ok) return { ok: false, message: outcomeQuoteMessage(noQuoted.message) };
514
+ const no = normalizeSpaces(noQuoted.text);
515
+ const noError = outcomeWords(no);
516
+ if (noError !== null) return { ok: false, message: noError };
517
+ return { ok: true, yes: yes, no: no };
518
+ }
519
+ return { ok: true, yes: yes, no: negateReport(yes) };
520
+ }
521
+
522
+ function outcomeQuoteMessage(message: string): string {
523
+ if (message === "expected a outcome") return "expected an outcome";
524
+ if (message === "expected a outcome in quotes") return "expected an outcome in quotes";
525
+ return message;
526
+ }
527
+
528
+ function outcomeWords(text: string): string | null {
529
+ if (text.length === 0) return "expected an outcome";
530
+ for (let i = 0; i < text.length; i++) {
531
+ const code = text.charCodeAt(i);
532
+ if (isPhraseStop(code) || code === 43) return "an outcome can only be words";
533
+ }
534
+ return null;
535
+ }
536
+
537
+ interface SentenceOk {
538
+ ok: true;
539
+ system: string;
540
+ antecedent: Term | null;
541
+ consequent: Term;
542
+ hasWhen: boolean;
543
+ hasIf: boolean;
544
+ whenTerm: Term | null;
545
+ }
546
+
547
+ type SentenceResult = SentenceOk | { ok: false; message: string };
548
+
549
+ function parseSentence(sentence: string, spec: Spec): SentenceResult {
550
+ if (sentence.charCodeAt(sentence.length - 1) !== 46) return { ok: false, message: "expected a period" };
551
+ const body = sentence.slice(0, sentence.length - 1).trim();
552
+ if (body.length === 0) return { ok: false, message: "expected a sentence" };
553
+ const parts = splitCommas(body);
554
+ if (parts === null) return { ok: false, message: "unbalanced parentheses" };
555
+ for (let i = 0; i < parts.length; i++) {
556
+ if (parts[i].length === 0) return { ok: false, message: "empty clause" };
557
+ }
558
+ const guards: Term[] = [];
559
+ let hasWhen = false;
560
+ let hasIf = false;
561
+ let whenTerm: Term | null = null;
562
+ let lastKeyword = -1;
563
+ const keywords = ["while", "when", "if", "where"];
564
+ for (let i = 0; i < parts.length - 1; i++) {
565
+ const cur = new Cur(parts[i]);
566
+ const word = readIdent(cur);
567
+ if (word === null) return { ok: false, message: "expected while, when, if, or where" };
568
+ const keyword = word.toLowerCase();
569
+ let keyIndex = -1;
570
+ for (let k = 0; k < keywords.length; k++) {
571
+ if (keywords[k] === keyword) keyIndex = k;
572
+ }
573
+ if (keyIndex < 0) return { ok: false, message: "expected while, when, if, or where" };
574
+ if (keyIndex === lastKeyword) return { ok: false, message: "repeated " + keyword + " clause" };
575
+ if (keyIndex < lastKeyword) return { ok: false, message: "expected while, when, if, where in that order" };
576
+ lastKeyword = keyIndex;
577
+ const condition = parsePredicate(cur.text.slice(cur.i), spec, "condition");
578
+ if (!condition.ok) return condition;
579
+ if (condition.sort.kind !== "bool") return { ok: false, message: "expected a boolean" };
580
+ guards.push(condition.term);
581
+ if (keyword === "when") {
582
+ hasWhen = true;
583
+ whenTerm = condition.term;
584
+ }
585
+ if (keyword === "if") hasIf = true;
586
+ }
587
+ const response = parseResponse(parts[parts.length - 1], spec, hasIf);
588
+ if (!response.ok) return response;
589
+ return {
590
+ ok: true,
591
+ system: response.system,
592
+ antecedent: andAll(guards),
593
+ consequent: response.consequent,
594
+ hasWhen: hasWhen,
595
+ hasIf: hasIf,
596
+ whenTerm: whenTerm,
597
+ };
598
+ }
599
+
600
+ interface ResponseOk {
601
+ ok: true;
602
+ system: string;
603
+ consequent: Term;
604
+ }
605
+
606
+ function parseResponse(text: string, spec: Spec, hasIf: boolean): ResponseOk | { ok: false; message: string } {
607
+ const cur = new Cur(text);
608
+ const first = readIdent(cur);
609
+ if (first === null) return { ok: false, message: "expected the system name" };
610
+ let rest = text;
611
+ let hasThen = false;
612
+ if (first.toLowerCase() === "then") {
613
+ hasThen = true;
614
+ rest = cur.text.slice(cur.i);
615
+ } else {
616
+ cur.i = 0;
617
+ }
618
+ if (hasIf && !hasThen) return { ok: false, message: "expected \"then\"" };
619
+ if (!hasIf && hasThen) return { ok: false, message: "unexpected \"then\" without an if clause" };
620
+ const body = new Cur(rest);
621
+ const theWord = readIdent(body);
622
+ if (theWord === null || theWord.toLowerCase() !== "the") return { ok: false, message: "expected \"the\"" };
623
+ let system = "";
624
+ while (true) {
625
+ const save = body.i;
626
+ const word = readSystemWord(body);
627
+ if (word === null) {
628
+ body.i = save;
629
+ return { ok: false, message: "expected \"shall\"" };
630
+ }
631
+ const lower = word.toLowerCase();
632
+ if (lower === "shall") break;
633
+ if (lower === "should" || lower === "must" || lower === "will") {
634
+ return { ok: false, message: "expected \"shall\"" };
635
+ }
636
+ if (system.length === 0) system = word;
637
+ else system += " " + word;
638
+ }
639
+ if (system.length === 0) return { ok: false, message: "expected a system name before shall" };
640
+ const predText = body.text.slice(body.i);
641
+ if (predText.trim().length === 0) return { ok: false, message: "expected a response" };
642
+ const pred = parsePredicate(predText, spec, "response");
643
+ if (!pred.ok) return pred;
644
+ if (pred.sort.kind !== "bool") return { ok: false, message: "expected a boolean" };
645
+ return { ok: true, system: system, consequent: pred.term };
646
+ }
647
+
648
+ function parsePredicate(text: string, spec: Spec, slot: string): Parsed {
649
+ const cur = new Cur(text);
650
+ if (cur.eof()) return { ok: false, message: slot === "response" ? "expected a response" : "expected a condition" };
651
+ const parsed = parseOr(cur, spec);
652
+ if (!parsed.ok) return parsed;
653
+ if (!cur.eof()) {
654
+ const word = peekIdent(cur);
655
+ if (word.length > 0) return { ok: false, message: "unexpected " + displayQuote(word) };
656
+ return { ok: false, message: "unexpected trailing text" };
657
+ }
658
+ return parsed;
659
+ }
660
+
661
+ function parseOr(cur: Cur, spec: Spec): Parsed {
662
+ let left = parseAnd(cur, spec);
663
+ if (!left.ok) return left;
664
+ while (consumeWord(cur, "or")) {
665
+ const right = parseAnd(cur, spec);
666
+ if (!right.ok) return right;
667
+ if (left.sort.kind !== "bool" || right.sort.kind !== "bool") return { ok: false, message: "or expects booleans" };
668
+ left = { ok: true, term: { kind: "or", left: left.term, right: right.term }, sort: { kind: "bool" } };
669
+ }
670
+ return left;
671
+ }
672
+
673
+ function parseAnd(cur: Cur, spec: Spec): Parsed {
674
+ let left = parseNot(cur, spec);
675
+ if (!left.ok) return left;
676
+ while (consumeWord(cur, "and")) {
677
+ const right = parseNot(cur, spec);
678
+ if (!right.ok) return right;
679
+ if (left.sort.kind !== "bool" || right.sort.kind !== "bool") return { ok: false, message: "and expects booleans" };
680
+ left = { ok: true, term: { kind: "and", left: left.term, right: right.term }, sort: { kind: "bool" } };
681
+ }
682
+ return left;
683
+ }
684
+
685
+ function parseNot(cur: Cur, spec: Spec): Parsed {
686
+ if (consumeWord(cur, "not")) {
687
+ const inner = parseNot(cur, spec);
688
+ if (!inner.ok) return inner;
689
+ if (inner.sort.kind !== "bool") return { ok: false, message: "not expects a boolean" };
690
+ return { ok: true, term: { kind: "not", term: inner.term }, sort: { kind: "bool" } };
691
+ }
692
+ return parseCmp(cur, spec);
693
+ }
694
+
695
+ function parseCmp(cur: Cur, spec: Spec): Parsed {
696
+ const left = parseSum(cur, spec, true);
697
+ if (!left.ok) return left;
698
+ const english = consumeEnglishOp(cur);
699
+ let op = english;
700
+ if (op.length === 0) op = peekOp(cur);
701
+ if (op.length === 0) return left;
702
+ if (english.length === 0) {
703
+ cur.skip();
704
+ cur.i += op.length;
705
+ }
706
+ const right = parseSum(cur, spec, true);
707
+ if (!right.ok) return right;
708
+ if (op === "=" || op === "!=") {
709
+ if (!sameSort(left.sort, right.sort)) {
710
+ return { ok: false, message: "cannot compare " + sortName(left.sort) + " with " + sortName(right.sort) };
711
+ }
712
+ const kind = op === "=" ? "eq" : "neq";
713
+ return { ok: true, term: { kind: kind, left: left.term, right: right.term }, sort: { kind: "bool" } };
714
+ }
715
+ if (left.sort.kind !== "int" || right.sort.kind !== "int") return { ok: false, message: "comparison expects integers" };
716
+ let kind: "lt" | "le" | "gt" | "ge" = "lt";
717
+ if (op === "<") kind = "lt";
718
+ else if (op === "<=") kind = "le";
719
+ else if (op === ">") kind = "gt";
720
+ else kind = "ge";
721
+ return { ok: true, term: { kind: kind, left: left.term, right: right.term }, sort: { kind: "bool" } };
722
+ }
723
+
724
+ function parseSum(cur: Cur, spec: Spec, allowPattern: boolean): Parsed {
725
+ let left = parseUnary(cur, spec, allowPattern);
726
+ if (!left.ok) return left;
727
+ while (true) {
728
+ const op = peekAdd(cur);
729
+ if (op.length === 0) return left;
730
+ cur.skip();
731
+ cur.i += 1;
732
+ const right = parseUnary(cur, spec, allowPattern);
733
+ if (!right.ok) return right;
734
+ if (left.sort.kind !== "int" || right.sort.kind !== "int") return { ok: false, message: op + " expects integers" };
735
+ if (op === "+") left = { ok: true, term: { kind: "add", left: left.term, right: right.term }, sort: { kind: "int" } };
736
+ else left = { ok: true, term: { kind: "sub", left: left.term, right: right.term }, sort: { kind: "int" } };
737
+ }
738
+ }
739
+
740
+ function parseUnary(cur: Cur, spec: Spec, allowPattern: boolean): Parsed {
741
+ const save = cur.i;
742
+ cur.skip();
743
+ if (cur.i < cur.text.length && cur.text.charCodeAt(cur.i) === 45) {
744
+ const after = cur.i + 1;
745
+ const code = after < cur.text.length ? cur.text.charCodeAt(after) : 0;
746
+ if (code !== 61) {
747
+ cur.i++;
748
+ const inner = parseUnary(cur, spec, allowPattern);
749
+ if (!inner.ok) return inner;
750
+ if (inner.sort.kind !== "int") return { ok: false, message: "unary minus expects an integer" };
751
+ if (inner.term.kind === "int") {
752
+ return { ok: true, term: { kind: "int", value: -inner.term.value }, sort: { kind: "int" } };
753
+ }
754
+ return {
755
+ ok: true,
756
+ term: { kind: "sub", left: { kind: "int", value: 0 }, right: inner.term },
757
+ sort: { kind: "int" },
758
+ };
759
+ }
760
+ }
761
+ cur.i = save;
762
+ return parseAtom(cur, spec, allowPattern);
763
+ }
764
+
765
+ function parseAtom(cur: Cur, spec: Spec, allowPattern: boolean): Parsed {
766
+ cur.skip();
767
+ if (cur.i >= cur.text.length) return { ok: false, message: "expected an expression" };
768
+ const code = cur.text.charCodeAt(cur.i);
769
+ if (code === 40) {
770
+ cur.i++;
771
+ const inner = parseOr(cur, spec);
772
+ if (!inner.ok) return inner;
773
+ cur.skip();
774
+ if (cur.i >= cur.text.length || cur.text.charCodeAt(cur.i) !== 41) return { ok: false, message: "expected ')'" };
775
+ cur.i++;
776
+ return inner;
777
+ }
778
+ if (isDigit(code)) {
779
+ const value = readPositive(cur);
780
+ if (value === null) return { ok: false, message: "integer is too long" };
781
+ return { ok: true, term: { kind: "int", value: value }, sort: { kind: "int" } };
782
+ }
783
+ const save = cur.i;
784
+ const phraseHit = matchPhrase(cur, spec);
785
+ if (!allowPattern) {
786
+ if (phraseHit !== null) {
787
+ cur.i = phraseHit.end;
788
+ return finishPhrase(spec, phraseHit);
789
+ }
790
+ } else {
791
+ const patternHit = matchAsPattern(cur, spec);
792
+ if (patternHit !== null && !patternHit.ok) return { ok: false, message: patternHit.message };
793
+ const patternEnd = patternHit !== null && patternHit.ok ? patternHit.end : -1;
794
+ if (phraseHit !== null && phraseHit.end >= patternEnd) {
795
+ cur.i = phraseHit.end;
796
+ return finishPhrase(spec, phraseHit);
797
+ }
798
+ if (patternHit !== null && patternHit.ok) {
799
+ cur.i = patternHit.end;
800
+ return finishPattern(spec, patternHit);
801
+ }
802
+ }
803
+ cur.i = save;
804
+ const word = readRawIdent(cur);
805
+ if (word === null) {
806
+ if (hasPhrase(spec) && readPhraseWord(cur.text, save) !== null) {
807
+ return unknownPhrase(cur, spec, save);
808
+ }
809
+ return { ok: false, message: "expected an expression" };
810
+ }
811
+ const lower = word.toLowerCase();
812
+ if (lower === "true") return { ok: true, term: { kind: "bool", value: true }, sort: { kind: "bool" } };
813
+ if (lower === "false") return { ok: true, term: { kind: "bool", value: false }, sort: { kind: "bool" } };
814
+ if (isReserved(word)) return { ok: false, message: "unexpected " + displayQuote(word) };
815
+ const symbol = findSymbol(spec, word);
816
+ if (symbol >= 0) return symbolTerm(spec, symbol);
817
+ const variant = findVariant(spec, word);
818
+ if (variant !== null) {
819
+ return {
820
+ ok: true,
821
+ term: { kind: "enum", enumName: variant.enumName, variant: word, index: variant.index },
822
+ sort: { kind: "enum", enumName: variant.enumName },
823
+ };
824
+ }
825
+ if (hasPhrase(spec)) return unknownPhrase(cur, spec, save);
826
+ return { ok: false, message: "unknown symbol " + displayQuote(word) };
827
+ }
828
+
829
+ function symbolTerm(spec: Spec, index: number): Parsed {
830
+ const decl = spec.symbols[index];
831
+ let sort: SortTag = { kind: "bool" };
832
+ if (decl.sort.kind === "int") sort = { kind: "int" };
833
+ else if (decl.sort.kind === "enum") sort = { kind: "enum", enumName: decl.sort.enumName };
834
+ return { ok: true, term: { kind: "sym", name: decl.name, index: index, phrase: decl.phrase }, sort: sort };
835
+ }
836
+
837
+ interface PhraseHit {
838
+ index: number;
839
+ end: number;
840
+ negated: boolean;
841
+ }
842
+
843
+ function finishPhrase(spec: Spec, hit: PhraseHit): Parsed {
844
+ const base = symbolTerm(spec, hit.index);
845
+ if (!base.ok) return base;
846
+ if (!hit.negated) return base;
847
+ if (base.sort.kind !== "bool") return { ok: false, message: "not expects a boolean" };
848
+ return { ok: true, term: { kind: "not", term: base.term }, sort: { kind: "bool" } };
849
+ }
850
+
851
+ function matchPhrase(cur: Cur, spec: Spec): PhraseHit | null {
852
+ const words: string[] = [];
853
+ const ends: number[] = [];
854
+ let pos = cur.i;
855
+ while (pos < cur.text.length) {
856
+ while (pos < cur.text.length && isSpace(cur.text.charCodeAt(pos))) pos++;
857
+ const read = readPhraseWord(cur.text, pos);
858
+ if (read === null) break;
859
+ words.push(lowerAscii(read.word));
860
+ ends.push(read.end);
861
+ pos = read.end;
862
+ }
863
+ if (words.length === 0) return null;
864
+ let bestEnd = -1;
865
+ let bestIndex = -1;
866
+ let bestNegated = false;
867
+ for (let s = 0; s < spec.symbols.length; s++) {
868
+ const sym = spec.symbols[s];
869
+ const candidates: string[] = [];
870
+ if (sym.phrase.length > 0) candidates.push(sym.phrase);
871
+ if (sym.action.length > 0) candidates.push(sym.action);
872
+ for (let c = 0; c < candidates.length; c++) {
873
+ const hit = matchParts(splitWords(candidates[c]), words, ends);
874
+ if (hit === null) continue;
875
+ if (hit.end > bestEnd) {
876
+ bestEnd = hit.end;
877
+ bestIndex = s;
878
+ bestNegated = hit.negated;
879
+ }
880
+ }
881
+ }
882
+ if (bestIndex < 0) return null;
883
+ return { index: bestIndex, end: bestEnd, negated: bestNegated };
884
+ }
885
+
886
+ interface PartHit {
887
+ end: number;
888
+ negated: boolean;
889
+ }
890
+
891
+ function matchParts(parts: string[], words: string[], ends: number[]): PartHit | null {
892
+ let pos = 0;
893
+ let negated = false;
894
+ for (let w = 0; w < parts.length; w++) {
895
+ if (pos >= words.length) return null;
896
+ if (lowerAscii(parts[w]) !== words[pos]) return null;
897
+ const nextPart = w + 1 < parts.length ? lowerAscii(parts[w + 1]) : "";
898
+ if (lowerAscii(parts[w]) === "is" && nextPart !== "not" && w + 1 < parts.length) {
899
+ if (pos + 1 < words.length && words[pos + 1] === "not") {
900
+ negated = true;
901
+ pos += 2;
902
+ continue;
903
+ }
904
+ }
905
+ pos++;
906
+ }
907
+ if (pos <= 0) return null;
908
+ return { end: ends[pos - 1], negated: negated };
909
+ }
910
+
911
+ interface AsToken {
912
+ kind: string;
913
+ text: string;
914
+ }
915
+
916
+ function tokenizeAs(text: string, symbolName: string): AsToken[] | null {
917
+ const parts = splitWords(text);
918
+ if (parts.length === 0) return null;
919
+ const tokens: AsToken[] = [];
920
+ for (let i = 0; i < parts.length; i++) {
921
+ const part = parts[i];
922
+ if (part.charCodeAt(0) === 123) {
923
+ if (part.charCodeAt(part.length - 1) !== 125) return null;
924
+ const inner = part.slice(1, part.length - 1);
925
+ if (inner === "op") tokens.push({ kind: "op", text: "" });
926
+ else if (inner === "value" || inner === "name" || inner === symbolName) tokens.push({ kind: "value", text: "" });
927
+ else return null;
928
+ continue;
929
+ }
930
+ for (let c = 0; c < part.length; c++) {
931
+ const code = part.charCodeAt(c);
932
+ if (isPhraseStop(code) || code === 43 || code === 123 || code === 125) return null;
933
+ }
934
+ tokens.push({ kind: "word", text: part });
935
+ }
936
+ return tokens;
937
+ }
938
+
939
+ interface AsOk {
940
+ ok: true;
941
+ index: number;
942
+ end: number;
943
+ op: string;
944
+ right: Term;
945
+ rightSort: SortTag;
946
+ }
947
+
948
+ type AsHit = AsOk | { ok: false; message: string };
949
+
950
+ function matchAsPattern(cur: Cur, spec: Spec): AsHit | null {
951
+ let best: AsOk | null = null;
952
+ let startedMessage = "";
953
+ for (let s = 0; s < spec.symbols.length; s++) {
954
+ if (spec.symbols[s].asText.length === 0) continue;
955
+ const save = cur.i;
956
+ const hit = matchOneAs(cur, spec, s);
957
+ cur.i = save;
958
+ if (hit === null) continue;
959
+ if (!hit.ok) {
960
+ if (startedMessage.length === 0) startedMessage = hit.message;
961
+ continue;
962
+ }
963
+ if (best === null || hit.end > best.end) best = hit;
964
+ }
965
+ if (best !== null) return best;
966
+ if (startedMessage.length > 0) return { ok: false, message: startedMessage };
967
+ return null;
968
+ }
969
+
970
+ function matchOneAs(cur: Cur, spec: Spec, index: number): AsOk | { ok: false; message: string } | null {
971
+ const sym = spec.symbols[index];
972
+ const tokens = tokenizeAs(sym.asText, sym.name);
973
+ if (tokens === null) return null;
974
+ let op = "=";
975
+ let right: Term = { kind: "int", value: 0 };
976
+ let rightSort: SortTag = { kind: "int" };
977
+ let sawValue = false;
978
+ let started = false;
979
+ for (let t = 0; t < tokens.length; t++) {
980
+ const token = tokens[t];
981
+ if (token.kind === "word") {
982
+ cur.skip();
983
+ const word = readRawIdent(cur);
984
+ if (word === null || !sameLoose(token.text, word)) {
985
+ if (!started) return null;
986
+ return { ok: false, message: "expected " + displayQuote(token.text) };
987
+ }
988
+ started = true;
989
+ continue;
990
+ }
991
+ if (token.kind === "op") {
992
+ op = matchOpHole(cur);
993
+ started = true;
994
+ continue;
995
+ }
996
+ const value = parseValueHole(cur, spec, sym);
997
+ if (!value.ok) {
998
+ if (!started) return null;
999
+ return value;
1000
+ }
1001
+ right = value.term;
1002
+ rightSort = value.sort;
1003
+ sawValue = true;
1004
+ started = true;
1005
+ }
1006
+ if (!sawValue) return null;
1007
+ return { ok: true, index: index, end: cur.i, op: op, right: right, rightSort: rightSort };
1008
+ }
1009
+
1010
+ function parseValueHole(cur: Cur, spec: Spec, sym: SymbolDecl): Parsed {
1011
+ if (sym.sort.kind === "enum") {
1012
+ const word = readIdent(cur);
1013
+ if (word === null) return { ok: false, message: "expected a " + sym.sort.enumName + " variant" };
1014
+ const variant = findVariant(spec, word);
1015
+ if (variant === null || variant.enumName !== sym.sort.enumName) {
1016
+ return { ok: false, message: "expected a " + sym.sort.enumName + " variant" };
1017
+ }
1018
+ return {
1019
+ ok: true,
1020
+ term: { kind: "enum", enumName: variant.enumName, variant: word, index: variant.index },
1021
+ sort: { kind: "enum", enumName: variant.enumName },
1022
+ };
1023
+ }
1024
+ return parseSum(cur, spec, false);
1025
+ }
1026
+
1027
+ function matchOpHole(cur: Cur): string {
1028
+ const save = cur.i;
1029
+ if (consumeWord(cur, "at")) {
1030
+ if (consumeWord(cur, "most")) return "<=";
1031
+ if (consumeWord(cur, "least")) return ">=";
1032
+ cur.i = save;
1033
+ }
1034
+ if (consumeWord(cur, "less")) {
1035
+ if (consumeWord(cur, "than")) return "<";
1036
+ cur.i = save;
1037
+ }
1038
+ if (consumeWord(cur, "greater")) {
1039
+ if (consumeWord(cur, "than")) return ">";
1040
+ cur.i = save;
1041
+ }
1042
+ if (consumeWord(cur, "not")) return "!=";
1043
+ cur.i = save;
1044
+ return "=";
1045
+ }
1046
+
1047
+ function sameLoose(pattern: string, text: string): boolean {
1048
+ const a = lowerAscii(pattern);
1049
+ const b = lowerAscii(text);
1050
+ if (a === b) return true;
1051
+ if (a.length > 1 && a.slice(a.length - 1) === "s" && a.slice(0, a.length - 1) === b) return true;
1052
+ if (b.length > 1 && b.slice(b.length - 1) === "s" && b.slice(0, b.length - 1) === a) return true;
1053
+ return false;
1054
+ }
1055
+
1056
+ function finishPattern(spec: Spec, hit: AsOk): Parsed {
1057
+ const left = symbolTerm(spec, hit.index);
1058
+ if (!left.ok) return left;
1059
+ if (hit.op !== "=" && hit.op !== "!=") {
1060
+ if (left.sort.kind !== "int" || hit.rightSort.kind !== "int") return { ok: false, message: "comparison expects integers" };
1061
+ } else if (!sameSort(left.sort, hit.rightSort)) {
1062
+ return { ok: false, message: "cannot compare " + sortName(left.sort) + " with " + sortName(hit.rightSort) };
1063
+ }
1064
+ if (hit.op === "=") return { ok: true, term: { kind: "eq", left: left.term, right: hit.right }, sort: { kind: "bool" } };
1065
+ if (hit.op === "!=") return { ok: true, term: { kind: "neq", left: left.term, right: hit.right }, sort: { kind: "bool" } };
1066
+ if (hit.op === "<") return { ok: true, term: { kind: "lt", left: left.term, right: hit.right }, sort: { kind: "bool" } };
1067
+ if (hit.op === "<=") return { ok: true, term: { kind: "le", left: left.term, right: hit.right }, sort: { kind: "bool" } };
1068
+ if (hit.op === ">") return { ok: true, term: { kind: "gt", left: left.term, right: hit.right }, sort: { kind: "bool" } };
1069
+ return { ok: true, term: { kind: "ge", left: left.term, right: hit.right }, sort: { kind: "bool" } };
1070
+ }
1071
+
1072
+ function unknownPhrase(cur: Cur, spec: Spec, start: number): Parsed {
1073
+ const span = phraseSpan(cur.text, start);
1074
+ cur.i = span.end;
1075
+ return { ok: false, message: unknownPhraseMessage(span.text, spec) };
1076
+ }
1077
+
1078
+ interface Span {
1079
+ text: string;
1080
+ end: number;
1081
+ }
1082
+
1083
+ function phraseSpan(text: string, start: number): Span {
1084
+ let pos = start;
1085
+ while (pos < text.length && isSpace(text.charCodeAt(pos))) pos++;
1086
+ const wordStart = pos;
1087
+ let end = pos;
1088
+ while (pos < text.length) {
1089
+ while (pos < text.length && isSpace(text.charCodeAt(pos))) pos++;
1090
+ const read = readPhraseWord(text, pos);
1091
+ if (read === null) break;
1092
+ const lower = lowerAscii(read.word);
1093
+ if (lower === "and" || lower === "or" || lower === "not") break;
1094
+ end = read.end;
1095
+ pos = read.end;
1096
+ }
1097
+ return { text: normalizeSpaces(text.slice(wordStart, end)), end: end };
1098
+ }
1099
+
1100
+ function unknownPhraseMessage(span: string, spec: Spec): string {
1101
+ const picked = closestPhrases(span, spec);
1102
+ let message = "unknown phrase " + displayQuote(span);
1103
+ if (picked.length === 0) return message;
1104
+ message += ". Closest:";
1105
+ for (let i = 0; i < picked.length; i++) {
1106
+ message += " " + displayQuote(picked[i]);
1107
+ if (i + 1 < picked.length) message += ",";
1108
+ }
1109
+ return message + ".";
1110
+ }
1111
+
1112
+ function closestPhrases(span: string, spec: Spec): string[] {
1113
+ const target = lowerAscii(span);
1114
+ const bestIdx: number[] = [];
1115
+ const bestDist: number[] = [];
1116
+ for (let s = 0; s < spec.symbols.length; s++) {
1117
+ const phrase = spec.symbols[s].phrase;
1118
+ if (phrase.length === 0) continue;
1119
+ const dist = editDistance(target, lowerAscii(phrase));
1120
+ let place = 0;
1121
+ while (place < bestIdx.length) {
1122
+ const better = dist < bestDist[place] || (dist === bestDist[place] && s < bestIdx[place]);
1123
+ if (better) break;
1124
+ place++;
1125
+ }
1126
+ if (place >= 3) continue;
1127
+ if (bestIdx.length < 3) {
1128
+ bestIdx.push(0);
1129
+ bestDist.push(0);
1130
+ }
1131
+ for (let k = bestIdx.length - 1; k > place; k--) {
1132
+ bestIdx[k] = bestIdx[k - 1];
1133
+ bestDist[k] = bestDist[k - 1];
1134
+ }
1135
+ bestIdx[place] = s;
1136
+ bestDist[place] = dist;
1137
+ }
1138
+ const out: string[] = [];
1139
+ for (let i = 0; i < bestIdx.length; i++) out.push(spec.symbols[bestIdx[i]].phrase);
1140
+ return out;
1141
+ }
1142
+
1143
+ function editDistance(a: string, b: string): number {
1144
+ const n = a.length;
1145
+ const m = b.length;
1146
+ const prev: number[] = [];
1147
+ const curr: number[] = [];
1148
+ for (let j = 0; j <= m; j++) {
1149
+ prev.push(j);
1150
+ curr.push(0);
1151
+ }
1152
+ for (let i = 1; i <= n; i++) {
1153
+ curr[0] = i;
1154
+ const ca = a.charCodeAt(i - 1);
1155
+ for (let j = 1; j <= m; j++) {
1156
+ const cost = ca === b.charCodeAt(j - 1) ? 0 : 1;
1157
+ let best = prev[j] + 1;
1158
+ const ins = curr[j - 1] + 1;
1159
+ if (ins < best) best = ins;
1160
+ const sub = prev[j - 1] + cost;
1161
+ if (sub < best) best = sub;
1162
+ curr[j] = best;
1163
+ }
1164
+ for (let j = 0; j <= m; j++) prev[j] = curr[j];
1165
+ }
1166
+ return prev[m];
1167
+ }
1168
+
1169
+ interface PhraseWord {
1170
+ word: string;
1171
+ end: number;
1172
+ }
1173
+
1174
+ function readPhraseWord(text: string, pos: number): PhraseWord | null {
1175
+ if (pos >= text.length || isSpace(text.charCodeAt(pos)) || isPhraseStop(text.charCodeAt(pos))) return null;
1176
+ const start = pos;
1177
+ pos++;
1178
+ while (pos < text.length && !isSpace(text.charCodeAt(pos)) && !isPhraseStop(text.charCodeAt(pos))) pos++;
1179
+ return { word: text.slice(start, pos), end: pos };
1180
+ }
1181
+
1182
+ function isPhraseStop(code: number): boolean {
1183
+ return code === 40 || code === 41 || code === 44 || code === 61 || code === 60 || code === 62 || code === 33 || code === 46;
1184
+ }
1185
+
1186
+ function hasPhrase(spec: Spec): boolean {
1187
+ for (let i = 0; i < spec.symbols.length; i++) {
1188
+ if (spec.symbols[i].phrase.length > 0) return true;
1189
+ }
1190
+ return false;
1191
+ }
1192
+
1193
+ function phraseClash(spec: Spec, phrase: string): string {
1194
+ const parts = splitWords(phrase);
1195
+ if (parts.length === 0) return "expected a phrase";
1196
+ for (let i = 0; i < phrase.length; i++) {
1197
+ const code = phrase.charCodeAt(i);
1198
+ if (isPhraseStop(code) || code === 43) return "a phrase can only be words";
1199
+ }
1200
+ const first = lowerAscii(parts[0]);
1201
+ if (first === "not" || first === "and" || first === "or") {
1202
+ return "a phrase cannot start with " + displayQuote(parts[0]);
1203
+ }
1204
+ if (parts.length === 1 && isReserved(parts[0])) return displayQuote(phrase) + " is a reserved word";
1205
+ const lower = lowerAscii(phrase);
1206
+ for (let i = 0; i < spec.symbols.length; i++) {
1207
+ const sym = spec.symbols[i];
1208
+ if (sym.phrase.length > 0 && lowerAscii(sym.phrase) === lower) return displayQuote(phrase) + " is already a phrase";
1209
+ if (sym.action.length > 0 && lowerAscii(sym.action) === lower) return displayQuote(phrase) + " is already a phrase";
1210
+ if (lowerAscii(sym.name) === lower) return displayQuote(phrase) + " is already a symbol";
1211
+ }
1212
+ for (let i = 0; i < spec.enums.length; i++) {
1213
+ const decl = spec.enums[i];
1214
+ if (lowerAscii(decl.name) === lower) return displayQuote(phrase) + " is already an enum";
1215
+ for (let v = 0; v < decl.variants.length; v++) {
1216
+ if (lowerAscii(decl.variants[v]) === lower) return displayQuote(phrase) + " is already an enum variant";
1217
+ }
1218
+ }
1219
+ return "";
1220
+ }
1221
+
1222
+ function splitWords(text: string): string[] {
1223
+ const parts: string[] = [];
1224
+ let start = 0;
1225
+ for (let i = 0; i <= text.length; i++) {
1226
+ if (i === text.length || text.charCodeAt(i) === 32) {
1227
+ if (i > start) parts.push(text.slice(start, i));
1228
+ start = i + 1;
1229
+ }
1230
+ }
1231
+ return parts;
1232
+ }
1233
+
1234
+ function normalizeSpaces(text: string): string {
1235
+ let out = "";
1236
+ let pending = false;
1237
+ for (let i = 0; i < text.length; i++) {
1238
+ const code = text.charCodeAt(i);
1239
+ if (isSpace(code)) {
1240
+ if (out.length > 0) pending = true;
1241
+ continue;
1242
+ }
1243
+ if (pending) out += " ";
1244
+ pending = false;
1245
+ out += text.slice(i, i + 1);
1246
+ }
1247
+ return out;
1248
+ }
1249
+
1250
+ function lowerAscii(text: string): string {
1251
+ let out = "";
1252
+ for (let i = 0; i < text.length; i++) {
1253
+ const code = text.charCodeAt(i);
1254
+ if (code >= 65 && code <= 90) out += String.fromCharCode(code + 32);
1255
+ else out += text.slice(i, i + 1);
1256
+ }
1257
+ return out;
1258
+ }
1259
+
1260
+ function consumeEnglishOp(cur: Cur): string {
1261
+ const save = cur.i;
1262
+ if (!consumeWord(cur, "is")) return "";
1263
+ const afterIs = cur.i;
1264
+ if (consumeWord(cur, "not")) return "!=";
1265
+ cur.i = afterIs;
1266
+ if (consumeWord(cur, "at")) {
1267
+ if (consumeWord(cur, "most")) return "<=";
1268
+ if (consumeWord(cur, "least")) return ">=";
1269
+ cur.i = save;
1270
+ return "";
1271
+ }
1272
+ if (consumeWord(cur, "less")) {
1273
+ if (consumeWord(cur, "than")) return "<";
1274
+ cur.i = save;
1275
+ return "";
1276
+ }
1277
+ if (consumeWord(cur, "greater")) {
1278
+ if (consumeWord(cur, "than")) return ">";
1279
+ cur.i = save;
1280
+ return "";
1281
+ }
1282
+ return "=";
1283
+ }
1284
+
1285
+ function andAll(terms: Term[]): Term | null {
1286
+ if (terms.length === 0) return null;
1287
+ let acc = terms[0];
1288
+ for (let i = 1; i < terms.length; i++) acc = { kind: "and", left: acc, right: terms[i] };
1289
+ return acc;
1290
+ }
1291
+
1292
+ function splitCommas(text: string): string[] | null {
1293
+ const parts: string[] = [];
1294
+ let start = 0;
1295
+ let depth = 0;
1296
+ for (let i = 0; i < text.length; i++) {
1297
+ const code = text.charCodeAt(i);
1298
+ if (code === 40) depth++;
1299
+ else if (code === 41) {
1300
+ depth--;
1301
+ if (depth < 0) return null;
1302
+ } else if (code === 44 && depth === 0) {
1303
+ parts.push(text.slice(start, i).trim());
1304
+ start = i + 1;
1305
+ }
1306
+ }
1307
+ if (depth !== 0) return null;
1308
+ parts.push(text.slice(start).trim());
1309
+ return parts;
1310
+ }
1311
+
1312
+ function sameSort(left: SortTag, right: SortTag): boolean {
1313
+ if (left.kind !== right.kind) return false;
1314
+ if (left.kind === "enum" && right.kind === "enum") return left.enumName === right.enumName;
1315
+ return true;
1316
+ }
1317
+
1318
+ function sortName(sort: SortTag): string {
1319
+ if (sort.kind === "bool") return "Bool";
1320
+ if (sort.kind === "int") return "Int";
1321
+ return sort.enumName;
1322
+ }
1323
+
1324
+ function nameClash(spec: Spec, name: string): string {
1325
+ if (isReserved(name)) return displayQuote(name) + " is a reserved word";
1326
+ if (findSymbol(spec, name) >= 0) return displayQuote(name) + " is already a symbol";
1327
+ if (findEnum(spec, name) !== null) return displayQuote(name) + " is already an enum";
1328
+ if (findVariant(spec, name) !== null) return displayQuote(name) + " is already an enum variant";
1329
+ const lower = lowerAscii(name);
1330
+ for (let i = 0; i < spec.symbols.length; i++) {
1331
+ const sym = spec.symbols[i];
1332
+ if (sym.phrase.length > 0 && lowerAscii(sym.phrase) === lower) {
1333
+ return displayQuote(name) + " is already a phrase";
1334
+ }
1335
+ if (sym.action.length > 0 && lowerAscii(sym.action) === lower) {
1336
+ return displayQuote(name) + " is already a phrase";
1337
+ }
1338
+ }
1339
+ return "";
1340
+ }
1341
+
1342
+ function isReserved(name: string): boolean {
1343
+ const word = name.toLowerCase();
1344
+ return (
1345
+ word === "not" ||
1346
+ word === "and" ||
1347
+ word === "or" ||
1348
+ word === "true" ||
1349
+ word === "false" ||
1350
+ word === "while" ||
1351
+ word === "when" ||
1352
+ word === "if" ||
1353
+ word === "where" ||
1354
+ word === "then" ||
1355
+ word === "the" ||
1356
+ word === "shall" ||
1357
+ word === "should" ||
1358
+ word === "must" ||
1359
+ word === "will" ||
1360
+ word === "event" ||
1361
+ word === "state" ||
1362
+ word === "input" ||
1363
+ word === "output" ||
1364
+ word === "unwanted" ||
1365
+ word === "enum" ||
1366
+ word === "bool" ||
1367
+ word === "int" ||
1368
+ word === "phrase" ||
1369
+ word === "outcome" ||
1370
+ word === "action" ||
1371
+ word === "says" ||
1372
+ word === "as"
1373
+ );
1374
+ }
1375
+
1376
+ function consumeWord(cur: Cur, expected: string): boolean {
1377
+ const save = cur.i;
1378
+ const word = readIdent(cur);
1379
+ if (word !== null && word.toLowerCase() === expected) return true;
1380
+ cur.i = save;
1381
+ return false;
1382
+ }
1383
+
1384
+ function peekIdent(cur: Cur): string {
1385
+ const save = cur.i;
1386
+ const word = readIdent(cur);
1387
+ cur.i = save;
1388
+ if (word === null) return "";
1389
+ return word;
1390
+ }
1391
+
1392
+ function peekOp(cur: Cur): string {
1393
+ const save = cur.i;
1394
+ cur.skip();
1395
+ const two = cur.text.slice(cur.i, cur.i + 2);
1396
+ cur.i = save;
1397
+ if (two === "!=" || two === "<=" || two === ">=") return two;
1398
+ if (two.length === 0) return "";
1399
+ const one = two.slice(0, 1);
1400
+ if (one === "=" || one === "<" || one === ">") return one;
1401
+ return "";
1402
+ }
1403
+
1404
+ function peekAdd(cur: Cur): string {
1405
+ const save = cur.i;
1406
+ cur.skip();
1407
+ if (cur.i >= cur.text.length) {
1408
+ cur.i = save;
1409
+ return "";
1410
+ }
1411
+ const ch = cur.text.slice(cur.i, cur.i + 1);
1412
+ cur.i = save;
1413
+ if (ch === "+" || ch === "-") return ch;
1414
+ return "";
1415
+ }
1416
+
1417
+ function readIdent(cur: Cur): string | null {
1418
+ cur.skip();
1419
+ return readRawIdent(cur);
1420
+ }
1421
+
1422
+ function readRawIdent(cur: Cur): string | null {
1423
+ if (cur.i >= cur.text.length || !isIdentStart(cur.text.charCodeAt(cur.i))) return null;
1424
+ const start = cur.i;
1425
+ cur.i++;
1426
+ while (cur.i < cur.text.length && isIdentPart(cur.text.charCodeAt(cur.i))) cur.i++;
1427
+ return cur.text.slice(start, cur.i);
1428
+ }
1429
+
1430
+ function readSystemWord(cur: Cur): string | null {
1431
+ cur.skip();
1432
+ if (cur.i >= cur.text.length || !isIdentStart(cur.text.charCodeAt(cur.i))) return null;
1433
+ const start = cur.i;
1434
+ cur.i++;
1435
+ while (cur.i < cur.text.length) {
1436
+ const code = cur.text.charCodeAt(cur.i);
1437
+ if (isIdentPart(code) || code === 45) {
1438
+ cur.i++;
1439
+ continue;
1440
+ }
1441
+ break;
1442
+ }
1443
+ return cur.text.slice(start, cur.i);
1444
+ }
1445
+
1446
+ function readSigned(cur: Cur): number | null {
1447
+ const save = cur.i;
1448
+ cur.skip();
1449
+ let negative = false;
1450
+ if (cur.i < cur.text.length && cur.text.charCodeAt(cur.i) === 45) {
1451
+ negative = true;
1452
+ cur.i++;
1453
+ }
1454
+ if (cur.i >= cur.text.length || !isDigit(cur.text.charCodeAt(cur.i))) {
1455
+ cur.i = save;
1456
+ return null;
1457
+ }
1458
+ const value = readPositive(cur);
1459
+ if (value === null) return null;
1460
+ return negative ? -value : value;
1461
+ }
1462
+
1463
+ function readPositive(cur: Cur): number | null {
1464
+ if (cur.i >= cur.text.length || !isDigit(cur.text.charCodeAt(cur.i))) return null;
1465
+ if (cur.text.charCodeAt(cur.i) === 48) {
1466
+ const next = cur.i + 1;
1467
+ if (next < cur.text.length && isDigit(cur.text.charCodeAt(next))) return null;
1468
+ cur.i++;
1469
+ return 0;
1470
+ }
1471
+ let value = 0;
1472
+ let digits = 0;
1473
+ while (cur.i < cur.text.length && isDigit(cur.text.charCodeAt(cur.i))) {
1474
+ value = value * 10 + (cur.text.charCodeAt(cur.i) - 48);
1475
+ cur.i++;
1476
+ digits++;
1477
+ if (digits > 9) return null;
1478
+ }
1479
+ return value;
1480
+ }
1481
+
1482
+ function leadingWord(line: string): string {
1483
+ let i = 0;
1484
+ while (i < line.length && isSpace(line.charCodeAt(i))) i++;
1485
+ const start = i;
1486
+ if (i >= line.length || !isIdentStart(line.charCodeAt(i))) return "";
1487
+ i++;
1488
+ while (i < line.length && isIdentPart(line.charCodeAt(i))) i++;
1489
+ return line.slice(start, i);
1490
+ }
1491
+
1492
+ function splitLines(text: string): string[] {
1493
+ const lines: string[] = [];
1494
+ let start = 0;
1495
+ for (let i = 0; i <= text.length; i++) {
1496
+ if (i === text.length || text.charCodeAt(i) === 10) {
1497
+ let line = text.slice(start, i);
1498
+ if (line.length > 0 && line.charCodeAt(line.length - 1) === 13) line = line.slice(0, line.length - 1);
1499
+ lines.push(line);
1500
+ start = i + 1;
1501
+ }
1502
+ }
1503
+ return lines;
1504
+ }
1505
+
1506
+ function isDigit(code: number): boolean {
1507
+ return code >= 48 && code <= 57;
1508
+ }
1509
+
1510
+ function isSpace(code: number): boolean {
1511
+ return code === 32 || code === 9 || code === 10 || code === 13;
1512
+ }
1513
+
1514
+ function isIdentStart(code: number): boolean {
1515
+ return (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || code === 95;
1516
+ }
1517
+
1518
+ function isIdentPart(code: number): boolean {
1519
+ return isIdentStart(code) || isDigit(code);
1520
+ }