@josd/eyecode 0.0.0-stage → 0.1.4

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/reader.js ADDED
@@ -0,0 +1,396 @@
1
+ // Reading terms back from JavaScript source, without executing it.
2
+ //
3
+ // Conclusions, proofs and check reports are written as JavaScript
4
+ // expressions, one statement each (writer.js). This module parses them with
5
+ // its own small grammar and builds terms, so a document is data and never code
6
+ // that runs: only string and number literals, names, calls of a plain name,
7
+ // arrays, objects of bindings and a minus sign on a number are accepted.
8
+ import { EMPTY, EyecodeError, Struct, Var, nonfinite } from './terms.js';
9
+ import { CHAINED, RESERVED_WORDS, decodeName } from './writer.js';
10
+
11
+ // Names that a program reads as JavaScript values, so a document may not use them.
12
+ const JS_VALUES = new Set(['undefined', 'NaN', 'Infinity']);
13
+
14
+ // The name of the variable an identifier writes: names starting VAR_ are
15
+ // encoded (Section 8.1).
16
+ function variableName(text, line) {
17
+ if (!text.startsWith('VAR_')) return text;
18
+ const name = decodeName(text);
19
+ if (name === null) throw new EyecodeError(`line ${line}: ${text} is not the encoding of a variable name`);
20
+ return name;
21
+ }
22
+
23
+ // Numbers and names of any length, matched where the scan stands.
24
+ const NUMBER = /(?:([0-9]+)n|((?:[0-9]+\.[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?|[0-9]+[eE][+-]?[0-9]+)|([0-9]+))/y;
25
+ const NAME = /[A-Za-z0-9_$]+/y;
26
+
27
+ const SIMPLE_ESCAPES = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', 0: '\0', "'": "'", '"': '"', '\\': '\\' };
28
+
29
+ class Tokens {
30
+ constructor(text) {
31
+ this.text = text;
32
+ this.pos = 0;
33
+ this.line = 1;
34
+ this.list = [];
35
+ this.scan();
36
+ this.index = 0;
37
+ }
38
+
39
+ error(message, line = this.line) {
40
+ return new EyecodeError(`line ${line}: ${message}`);
41
+ }
42
+
43
+ scan() {
44
+ const { text } = this;
45
+ const n = text.length;
46
+ let pos = 0;
47
+ let line = 1;
48
+ while (pos < n) {
49
+ const ch = text[pos];
50
+ if (ch === '\n') {
51
+ this.list.push({ kind: 'newline', text: '\n', line });
52
+ line++;
53
+ pos++;
54
+ } else if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '') {
55
+ pos++;
56
+ } else if (ch === '/' && text[pos + 1] === '/') {
57
+ while (pos < n && text[pos] !== '\n') pos++;
58
+ } else if (ch === '/' && text[pos + 1] === '*') {
59
+ const end = text.indexOf('*/', pos + 2);
60
+ if (end < 0) throw this.error('unterminated comment', line);
61
+ for (let i = pos; i < end; i++) if (text[i] === '\n') line++;
62
+ pos = end + 2;
63
+ } else if (ch === "'" || ch === '"') {
64
+ const [value, next] = this.string(pos, line);
65
+ this.list.push({ kind: 'string', text: value, line });
66
+ pos = next;
67
+ } else if (/[0-9.]/.test(ch) && (ch !== '.' || /[0-9]/.test(text[pos + 1] || ''))) {
68
+ NUMBER.lastIndex = pos;
69
+ const match = NUMBER.exec(text);
70
+ const after = text[pos + match[0].length] || '';
71
+ if (/[A-Za-z0-9_$.]/.test(after)) throw this.error(`malformed number ${text.slice(pos, pos + match[0].length + 1)}`, line);
72
+ if (match[1] !== undefined || match[3] !== undefined) {
73
+ const digits = match[1] !== undefined ? match[1] : match[3];
74
+ if (digits.length > 1 && digits[0] === '0') throw this.error(`an integer has no leading zero: ${digits}`, line);
75
+ this.list.push({ kind: 'int', text: digits, line });
76
+ } else {
77
+ this.list.push({ kind: 'float', text: match[2], line });
78
+ }
79
+ pos += match[0].length;
80
+ } else if (/[A-Za-z_$]/.test(ch)) {
81
+ NAME.lastIndex = pos;
82
+ const match = NAME.exec(text);
83
+ this.list.push({ kind: 'name', text: match[0], line });
84
+ pos += match[0].length;
85
+ } else if (text.startsWith('...', pos)) {
86
+ this.list.push({ kind: 'punct', text: '...', line });
87
+ pos += 3;
88
+ } else if ('()[]{},:;-'.includes(ch)) {
89
+ this.list.push({ kind: 'punct', text: ch, line });
90
+ pos++;
91
+ } else {
92
+ throw this.error(`unexpected character ${JSON.stringify(ch)}`, line);
93
+ }
94
+ }
95
+ this.list.push({ kind: 'end', text: '', line });
96
+ }
97
+
98
+ string(start, line) {
99
+ const { text } = this;
100
+ const quote = text[start];
101
+ let pos = start + 1;
102
+ let out = '';
103
+ while (true) {
104
+ if (pos >= text.length || text[pos] === '\n' || text[pos] === '\r') {
105
+ throw this.error('unterminated string', line);
106
+ }
107
+ const ch = text[pos];
108
+ if (ch === quote) return [out, pos + 1];
109
+ // A document spells control characters as escapes, as the writer does.
110
+ if (ch < ' ' && ch !== '\t') throw this.error('a control character in a string must be escaped', line);
111
+ if (ch !== '\\') {
112
+ out += ch;
113
+ pos++;
114
+ continue;
115
+ }
116
+ const next = text[pos + 1];
117
+ if (next === 'x') {
118
+ const hex = text.slice(pos + 2, pos + 4);
119
+ if (!/^[0-9a-fA-F]{2}$/.test(hex)) throw this.error('malformed \\x escape', line);
120
+ out += String.fromCharCode(parseInt(hex, 16));
121
+ pos += 4;
122
+ } else if (next === 'u' && text[pos + 2] === '{') {
123
+ const end = text.indexOf('}', pos + 3);
124
+ const hex = end < 0 ? '' : text.slice(pos + 3, end);
125
+ if (!/^[0-9a-fA-F]{1,6}$/.test(hex) || parseInt(hex, 16) > 0x10ffff) throw this.error('malformed \\u escape', line);
126
+ out += String.fromCodePoint(parseInt(hex, 16));
127
+ pos = end + 1;
128
+ } else if (next === 'u') {
129
+ const hex = text.slice(pos + 2, pos + 6);
130
+ if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw this.error('malformed \\u escape', line);
131
+ out += String.fromCharCode(parseInt(hex, 16));
132
+ pos += 6;
133
+ } else if (next !== undefined && SIMPLE_ESCAPES[next] !== undefined && !(next === '0' && /[0-9]/.test(text[pos + 2] || ''))) {
134
+ out += SIMPLE_ESCAPES[next];
135
+ pos += 2;
136
+ } else {
137
+ throw this.error(`unsupported escape \\${next ?? ''}`, line);
138
+ }
139
+ }
140
+ }
141
+
142
+ peek() {
143
+ return this.list[this.index];
144
+ }
145
+
146
+ next() {
147
+ return this.list[this.index++];
148
+ }
149
+
150
+ // Newlines inside brackets are layout.
151
+ skipNewlines() {
152
+ while (this.list[this.index].kind === 'newline') this.index++;
153
+ }
154
+
155
+ at(text) {
156
+ const token = this.list[this.index];
157
+ return token.kind === 'punct' && token.text === text;
158
+ }
159
+
160
+ expect(text) {
161
+ this.skipNewlines();
162
+ const token = this.next();
163
+ if (token.kind !== 'punct' || token.text !== text) {
164
+ throw this.error(`expected ${text}, found ${describe(token)}`, token.line);
165
+ }
166
+ }
167
+ }
168
+
169
+ function describe(token) {
170
+ if (token.kind === 'end') return 'the end of the text';
171
+ if (token.kind === 'newline') return 'the end of the line';
172
+ if (token.kind === 'string') return 'a string';
173
+ return JSON.stringify(token.text);
174
+ }
175
+
176
+ export class Reader {
177
+ constructor() {
178
+ this.anonymous = 0;
179
+ }
180
+
181
+ // One expression, with the tokens positioned at its start. Long lists and
182
+ // deep expressions are read with an explicit stack rather than recursion.
183
+ expression(tokens) {
184
+ const values = [];
185
+ // Each frame collects the items of one open bracket.
186
+ const frames = [];
187
+ let state = 'value';
188
+ while (true) {
189
+ if (state === 'value') {
190
+ tokens.skipNewlines();
191
+ const token = tokens.next();
192
+ if (token.kind === 'string') {
193
+ values.push(token.text);
194
+ state = 'after';
195
+ } else if (token.kind === 'int') {
196
+ values.push(BigInt(token.text));
197
+ state = 'after';
198
+ } else if (token.kind === 'float') {
199
+ values.push(this.float(token.text, token.line));
200
+ state = 'after';
201
+ } else if (token.kind === 'punct' && token.text === '-') {
202
+ const number = tokens.next();
203
+ if (number.kind === 'int') values.push(-BigInt(number.text));
204
+ else if (number.kind === 'float') values.push(-this.float(number.text, number.line));
205
+ else throw tokens.error('a minus sign applies only to a number', token.line);
206
+ state = 'after';
207
+ } else if (token.kind === 'name') {
208
+ if (RESERVED_WORDS.has(token.text) || JS_VALUES.has(token.text) || token.text.includes('$')) {
209
+ throw tokens.error(`${token.text} is not a term`, token.line);
210
+ }
211
+ if (tokens.at('(')) {
212
+ tokens.next();
213
+ frames.push({ kind: 'call', name: token.text, line: token.line, start: values.length });
214
+ tokens.skipNewlines();
215
+ if (tokens.at(')')) {
216
+ tokens.next();
217
+ frames.pop();
218
+ values.push(this.call(token.text, [], token.line));
219
+ state = 'after';
220
+ }
221
+ } else {
222
+ values.push(this.variable(token.text, token.line));
223
+ state = 'after';
224
+ }
225
+ } else if (token.kind === 'punct' && token.text === '[') {
226
+ frames.push({ kind: 'list', line: token.line, start: values.length, tail: null });
227
+ tokens.skipNewlines();
228
+ if (tokens.at(']')) {
229
+ tokens.next();
230
+ frames.pop();
231
+ values.push(EMPTY);
232
+ state = 'after';
233
+ } else if (tokens.at('...')) {
234
+ // [...T] is T itself.
235
+ tokens.next();
236
+ frames[frames.length - 1].tail = values.length;
237
+ }
238
+ } else if (token.kind === 'punct' && token.text === '{') {
239
+ frames.push({ kind: 'object', line: token.line, start: values.length });
240
+ tokens.skipNewlines();
241
+ if (tokens.at('}')) {
242
+ tokens.next();
243
+ frames.pop();
244
+ values.push(EMPTY);
245
+ state = 'after';
246
+ } else {
247
+ this.key(tokens, values);
248
+ }
249
+ } else if (token.kind === 'punct' && token.text === '(') {
250
+ frames.push({ kind: 'group', line: token.line, start: values.length });
251
+ } else {
252
+ throw tokens.error(`expected a term, found ${describe(token)}`, token.line);
253
+ }
254
+ continue;
255
+ }
256
+ // state 'after': one value is complete; close or continue its bracket.
257
+ if (!frames.length) return values.pop();
258
+ const frame = frames[frames.length - 1];
259
+ tokens.skipNewlines();
260
+ const token = tokens.next();
261
+ if (frame.kind === 'group') {
262
+ if (token.kind !== 'punct' || token.text !== ')') throw tokens.error(`expected ), found ${describe(token)}`, token.line);
263
+ frames.pop();
264
+ continue;
265
+ }
266
+ const close = frame.kind === 'call' ? ')' : frame.kind === 'list' ? ']' : '}';
267
+ if (token.kind === 'punct' && token.text === ',') {
268
+ tokens.skipNewlines();
269
+ if (tokens.at(close)) {
270
+ tokens.next();
271
+ this.finish(frames.pop(), values);
272
+ continue;
273
+ }
274
+ if (frame.kind === 'list' && frame.tail !== null) {
275
+ throw tokens.error('only the last list item can be spread', token.line);
276
+ }
277
+ if (frame.kind === 'list' && tokens.at('...')) {
278
+ tokens.next();
279
+ frame.tail = values.length;
280
+ } else if (frame.kind === 'object') {
281
+ this.key(tokens, values);
282
+ }
283
+ state = 'value';
284
+ continue;
285
+ }
286
+ if (token.kind === 'punct' && token.text === close) {
287
+ this.finish(frames.pop(), values);
288
+ continue;
289
+ }
290
+ throw tokens.error(`expected , or ${close}, found ${describe(token)}`, token.line);
291
+ }
292
+ }
293
+
294
+ key(tokens, values) {
295
+ tokens.skipNewlines();
296
+ const token = tokens.next();
297
+ if (token.kind === 'string' || (token.kind === 'name' && !token.text.includes('$'))) {
298
+ values.push(token.text);
299
+ } else {
300
+ throw tokens.error(`a binding name must be a name or a string, found ${describe(token)}`, token.line);
301
+ }
302
+ tokens.expect(':');
303
+ }
304
+
305
+ finish(frame, values) {
306
+ const items = values.splice(frame.start);
307
+ if (frame.kind === 'call') {
308
+ values.push(this.call(frame.name, items, frame.line));
309
+ } else if (frame.kind === 'list') {
310
+ let result = EMPTY;
311
+ let count = items.length;
312
+ if (frame.tail !== null) {
313
+ result = items[count - 1];
314
+ count--;
315
+ }
316
+ for (let i = count - 1; i >= 0; i--) result = new Struct('.', [items[i], result]);
317
+ values.push(result);
318
+ } else {
319
+ // Bindings: {X: Value, ...} reads as a list of '='(Name, Value).
320
+ let result = EMPTY;
321
+ for (let i = items.length - 2; i >= 0; i -= 2) {
322
+ result = new Struct('.', [new Struct('=', [items[i], items[i + 1]]), result]);
323
+ }
324
+ values.push(result);
325
+ }
326
+ }
327
+
328
+ float(text, line) {
329
+ const value = Number(text);
330
+ if (!Number.isFinite(value)) throw nonfinite(value, `line ${line}: `);
331
+ return value;
332
+ }
333
+
334
+ variable(text, line) {
335
+ if (text === '_') {
336
+ this.anonymous++;
337
+ return new Var(`_#${this.anonymous - 1}`);
338
+ }
339
+ return new Var(variableName(text, line));
340
+ }
341
+
342
+ call(name, args, line) {
343
+ if (name === 'struct') {
344
+ if (!args.length || typeof args[0] !== 'string') {
345
+ throw new EyecodeError(`line ${line}: struct() needs a name string first`);
346
+ }
347
+ return args.length > 1 ? new Struct(args[0], args.slice(1)) : args[0];
348
+ }
349
+ if (!args.length) return name;
350
+ if (CHAINED.has(name) && args.length > 2) {
351
+ // and(A, B, C) is the chain and(and(A, B), C).
352
+ let result = args[0];
353
+ for (let i = 1; i < args.length; i++) result = new Struct(name, [result, args[i]]);
354
+ return result;
355
+ }
356
+ return new Struct(name, args);
357
+ }
358
+ }
359
+
360
+ // One term, such as a goal given on the command line.
361
+ export function readTerm(text) {
362
+ const tokens = new Tokens(text);
363
+ const reader = new Reader();
364
+ tokens.skipNewlines();
365
+ if (tokens.peek().kind === 'end') throw new EyecodeError('line 1: expected a term, found the end of the text');
366
+ const term = reader.expression(tokens);
367
+ tokens.skipNewlines();
368
+ if (tokens.at(';')) {
369
+ tokens.next();
370
+ tokens.skipNewlines();
371
+ }
372
+ const rest = tokens.peek();
373
+ if (rest.kind !== 'end') throw tokens.error(`expected the end of the term, found ${describe(rest)}`, rest.line);
374
+ return term;
375
+ }
376
+
377
+ // The terms of a document, one expression statement each, with their lines.
378
+ // Statements end at a line end outside brackets, or at a semicolon.
379
+ export function readTerms(text) {
380
+ const tokens = new Tokens(text);
381
+ const reader = new Reader();
382
+ const out = [];
383
+ while (true) {
384
+ while (tokens.peek().kind === 'newline' || tokens.at(';')) tokens.next();
385
+ const token = tokens.peek();
386
+ if (token.kind === 'end') return out;
387
+ const term = reader.expression(tokens);
388
+ out.push([term, token.line]);
389
+ const after = tokens.peek();
390
+ if (after.kind === 'punct' && after.text === ';') {
391
+ tokens.next();
392
+ } else if (after.kind !== 'newline' && after.kind !== 'end') {
393
+ throw tokens.error(`expected the end of the statement, found ${describe(after)}`, after.line);
394
+ }
395
+ }
396
+ }