@dallaylaen/ski-interpreter 1.0.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.
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/bin/ski.js +122 -0
- package/index.js +8 -0
- package/lib/expr.js +1009 -0
- package/lib/parser.js +326 -0
- package/lib/quest.js +311 -0
- package/lib/util.js +74 -0
- package/package.json +46 -0
- package/types/index.d.ts +7 -0
- package/types/lib/expr.d.ts +344 -0
- package/types/lib/parser.d.ts +138 -0
- package/types/lib/quest.d.ts +139 -0
- package/types/lib/util.d.ts +13 -0
package/lib/parser.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Combinatory logic simulator
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { Tokenizer, restrict } = require('./util');
|
|
6
|
+
const { globalOptions, Expr, App, FreeVar, Lambda, Native, Alias, Church, native } = require('./expr');
|
|
7
|
+
|
|
8
|
+
class Empty extends Expr {
|
|
9
|
+
apply (...args) {
|
|
10
|
+
return args.length ? args.shift().apply(...args) : this;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
postParse () {
|
|
14
|
+
throw new Error('Attempt to use empty expression () as a term');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class PartialLambda extends Empty {
|
|
19
|
+
// TODO mutable! rewrite ro when have time
|
|
20
|
+
constructor (term, known = {}) {
|
|
21
|
+
super();
|
|
22
|
+
this.impl = new Empty();
|
|
23
|
+
if (term instanceof FreeVar)
|
|
24
|
+
this.terms = [term];
|
|
25
|
+
else if (term instanceof PartialLambda) {
|
|
26
|
+
if (!(term.impl instanceof FreeVar))
|
|
27
|
+
throw new Error('Expected FreeVar->...->FreeVar->Expr');
|
|
28
|
+
this.terms = [...term.terms, term.impl];
|
|
29
|
+
} else
|
|
30
|
+
throw new Error('Expected FreeVar or PartialLambda');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
apply (term, ...tail) {
|
|
34
|
+
if (term === null || tail.length !== 0 )
|
|
35
|
+
throw new Error('bad syntax in partial lambda expr');
|
|
36
|
+
this.impl = this.impl.apply(term);
|
|
37
|
+
return this;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
postParse () {
|
|
41
|
+
return new Lambda(this.terms, this.impl);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// uncomment if debugging with prints
|
|
45
|
+
/* toString () {
|
|
46
|
+
return this.terms.join('->') + '->' + (this.impl ?? '???');
|
|
47
|
+
} */
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const combChars = new Tokenizer(
|
|
51
|
+
'[()]', '[A-Z]', '[a-z_][a-z_0-9]*', '\\b[0-9]+\\b', '->', '\\+'
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
class SKI {
|
|
55
|
+
/**
|
|
56
|
+
*
|
|
57
|
+
* @param {{
|
|
58
|
+
* allow: string?,
|
|
59
|
+
* numbers: boolean?,
|
|
60
|
+
* lambdas: boolean?,
|
|
61
|
+
* terms: { [key: string]: Expr|string}?,
|
|
62
|
+
* annotate: boolean?,
|
|
63
|
+
* }} [options]
|
|
64
|
+
*/
|
|
65
|
+
constructor (options = {}) {
|
|
66
|
+
this.annotate = options.annotate ?? false;
|
|
67
|
+
this.known = { ...native };
|
|
68
|
+
this.hasNumbers = true;
|
|
69
|
+
this.hasLambdas = true;
|
|
70
|
+
this.allow = new Set(Object.keys(this.known));
|
|
71
|
+
|
|
72
|
+
// Import terms, if any. Omit native ones
|
|
73
|
+
for (const name in options.terms ?? {}) {
|
|
74
|
+
// Native terms already handled by allow
|
|
75
|
+
if (!options.terms[name].match(/^Native:/))
|
|
76
|
+
this.add(name, options.terms[name]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Finally, impose restrictions
|
|
80
|
+
// We must do it after recreating terms, or else terms reliant on forbidden terms will fail
|
|
81
|
+
this.hasNumbers = options.numbers ?? true;
|
|
82
|
+
this.hasLambdas = options.lambdas ?? true;
|
|
83
|
+
if (options.allow)
|
|
84
|
+
this.restrict(options.allow);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
*
|
|
89
|
+
* @param {Alias|String} term
|
|
90
|
+
* @param {Expr|String|[number, function(...Expr): Expr, {note: string?, fast: boolean?}]} [impl]
|
|
91
|
+
* @param {String} [note]
|
|
92
|
+
* @return {SKI} chainable
|
|
93
|
+
*/
|
|
94
|
+
add (term, impl, note ) {
|
|
95
|
+
if (typeof term === 'string') {
|
|
96
|
+
if (typeof impl === 'string')
|
|
97
|
+
term = new Alias(term, this.parse(impl), { canonize: true });
|
|
98
|
+
else if (impl instanceof Expr)
|
|
99
|
+
term = new Alias(term, impl, { canonize: true });
|
|
100
|
+
else
|
|
101
|
+
throw new Error('add: term must be an Alias or a string and impl must be an Expr or a string');
|
|
102
|
+
} else if (term instanceof Alias)
|
|
103
|
+
term = new Alias(term.name, term.impl, { canonize: true });
|
|
104
|
+
|
|
105
|
+
// This should normally be unreachable but let's keep just in case
|
|
106
|
+
if (!(term instanceof Alias))
|
|
107
|
+
throw new Error('add: term must be an Alias or a string (accompanied with an implementation)');
|
|
108
|
+
|
|
109
|
+
if (this.annotate && note === undefined && term.canonical)
|
|
110
|
+
note = term.canonical.toString({ terse: true, html: true });
|
|
111
|
+
if (note !== undefined)
|
|
112
|
+
term.note = note;
|
|
113
|
+
|
|
114
|
+
this.known['' + term] = term;
|
|
115
|
+
this.allow.add('' + term);
|
|
116
|
+
|
|
117
|
+
return this;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
maybeAdd (name, impl) {
|
|
121
|
+
if (this.known[name])
|
|
122
|
+
this.allow.add(name);
|
|
123
|
+
else
|
|
124
|
+
this.add(name, impl);
|
|
125
|
+
return this;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Restrict the interpreter to given terms. Terms prepended with '+' will be added
|
|
130
|
+
* and terms preceeded with '-' will be removed.
|
|
131
|
+
* @example ski.restrict('SK') // use the basis
|
|
132
|
+
* @example ski.restrict('+I') // allow I now
|
|
133
|
+
* @example ski.restrict('-SKI +BCKW' ); // switch basis
|
|
134
|
+
* @example ski.restrict('-foo -bar'); // forbid some user functions
|
|
135
|
+
* @param {string} spec
|
|
136
|
+
* @return {SKI} chainable
|
|
137
|
+
*/
|
|
138
|
+
restrict (spec) {
|
|
139
|
+
this.allow = restrict(this.allow, spec);
|
|
140
|
+
return this;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
*
|
|
145
|
+
* @param {string} spec
|
|
146
|
+
* @return {string}
|
|
147
|
+
*/
|
|
148
|
+
showRestrict (spec = '+') {
|
|
149
|
+
const out = [];
|
|
150
|
+
let prevShort = true;
|
|
151
|
+
for (const term of [...restrict(this.allow, spec)].sort()) {
|
|
152
|
+
const nextShort = term.match(/^[A-Z]$/);
|
|
153
|
+
if (out.length && !(prevShort && nextShort))
|
|
154
|
+
out.push(' ');
|
|
155
|
+
out.push(term);
|
|
156
|
+
prevShort = nextShort;
|
|
157
|
+
}
|
|
158
|
+
return out.join('');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
*
|
|
163
|
+
* @param {String} name
|
|
164
|
+
* @return {SKI}
|
|
165
|
+
*/
|
|
166
|
+
remove (name) {
|
|
167
|
+
this.known[name].outdated = true;
|
|
168
|
+
delete this.known[name];
|
|
169
|
+
this.allow.delete(name);
|
|
170
|
+
return this;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
*
|
|
175
|
+
* @return {{[key:string]: Native|Alias}}
|
|
176
|
+
*/
|
|
177
|
+
getTerms () {
|
|
178
|
+
const out = {};
|
|
179
|
+
for (const name of Object.keys(this.known)) {
|
|
180
|
+
if (this.allow.has(name))
|
|
181
|
+
out[name] = this.known[name];
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
*
|
|
188
|
+
* @param {string} source
|
|
189
|
+
* @param {{[keys: string]: Expr}} vars
|
|
190
|
+
* @param {{numbers: boolean?, lambdas: boolean?, allow: string?}} options
|
|
191
|
+
* @return {Expr}
|
|
192
|
+
*/
|
|
193
|
+
parse (source, vars = {}, options = {}) {
|
|
194
|
+
const lines = source.replace(/\/\/[^\n]*$/gm, '')
|
|
195
|
+
.split(/\s*;[\s;]*/).filter( s => s.match(/\S/));
|
|
196
|
+
|
|
197
|
+
const jar = { ...vars };
|
|
198
|
+
|
|
199
|
+
let expr = new Empty();
|
|
200
|
+
for (const item of lines) {
|
|
201
|
+
const [_, save, str] = item.match(/^(?:\s*([A-Z]|[a-z][a-z_0-9]*)\s*=\s*)?(.*)$/s);
|
|
202
|
+
if (expr instanceof Alias)
|
|
203
|
+
expr.outdated = true;
|
|
204
|
+
expr = this.parseLine(str, jar, options);
|
|
205
|
+
|
|
206
|
+
if (save !== undefined) {
|
|
207
|
+
if (jar[save] !== undefined)
|
|
208
|
+
throw new Error('Attempt to redefine a known term: ' + save);
|
|
209
|
+
expr = new Alias(save, expr);
|
|
210
|
+
jar[save] = expr;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// console.log('parsed line:', item, '; got:', expr,'; jar now: ', jar);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// reimport free variables, so that co-parsing x(y(z)) and z(x(y)) with the same jar
|
|
217
|
+
// results in _equal_ free vars and not just ones with the same name
|
|
218
|
+
for (const name in jar) {
|
|
219
|
+
if (!vars[name] && jar[name] instanceof SKI.classes.FreeVar)
|
|
220
|
+
vars[name] = jar[name];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return expr;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
*
|
|
228
|
+
* @param {String} source S(KI)I
|
|
229
|
+
* @param {{[keys: string]: Expr}} vars
|
|
230
|
+
* @param {{numbers: boolean?, lambdas: boolean?, allow: string?}} options
|
|
231
|
+
* @return {Expr} parsed expression
|
|
232
|
+
*/
|
|
233
|
+
parseLine (source, vars = {}, options = {}) {
|
|
234
|
+
const opt = {
|
|
235
|
+
numbers: options.numbers ?? this.hasNumbers,
|
|
236
|
+
lambdas: options.lambdas ?? this.hasLambdas,
|
|
237
|
+
allow: restrict(this.allow, options.allow),
|
|
238
|
+
};
|
|
239
|
+
// make sure '+' usage is in sync with numerals
|
|
240
|
+
opt.numbers ? opt.allow.add('+') : opt.allow.delete('+');
|
|
241
|
+
|
|
242
|
+
const tokens = combChars.split(source);
|
|
243
|
+
|
|
244
|
+
const empty = new Empty();
|
|
245
|
+
/** @type {Expr[]} */
|
|
246
|
+
const stack = [empty];
|
|
247
|
+
|
|
248
|
+
// TODO each token should carry along its position in source
|
|
249
|
+
for (const c of tokens) {
|
|
250
|
+
// console.log("parseLine: found "+c+"; stack =", stack.join(", "));
|
|
251
|
+
if (c === '(')
|
|
252
|
+
stack.push(empty);
|
|
253
|
+
else if (c === ')') {
|
|
254
|
+
if (stack.length < 2)
|
|
255
|
+
throw new Error('unbalanced input: extra closing parenthesis' + source);
|
|
256
|
+
const x = stack.pop().postParse();
|
|
257
|
+
const f = stack.pop();
|
|
258
|
+
stack.push(f.apply(x));
|
|
259
|
+
} else if (c === '->') {
|
|
260
|
+
if (!opt.lambdas)
|
|
261
|
+
throw new Error('Lambdas not supported, allow them explicitly');
|
|
262
|
+
stack.push(new PartialLambda(stack.pop(), vars));
|
|
263
|
+
} else if (c.match(/^[0-9]+$/)) {
|
|
264
|
+
if (!opt.numbers)
|
|
265
|
+
throw new Error('Church numbers not supported, allow them explicitly');
|
|
266
|
+
const f = stack.pop();
|
|
267
|
+
stack.push(f.apply(new Church(c)));
|
|
268
|
+
} else {
|
|
269
|
+
const f = stack.pop();
|
|
270
|
+
if (!vars[c] && this.known[c] && !opt.allow.has(c)) {
|
|
271
|
+
throw new Error('Term \'' + c + '\' is not in the restricted set '
|
|
272
|
+
+ [...opt.allow].sort().join(' '));
|
|
273
|
+
}
|
|
274
|
+
// look in temp vars first, then in known terms, then fallback to creating free var
|
|
275
|
+
const x = vars[c] ?? this.known[c] ?? (vars[c] = new FreeVar(c));
|
|
276
|
+
stack.push(f.apply(x));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (stack.length !== 1) {
|
|
281
|
+
throw new Error('unbalanced input: missing '
|
|
282
|
+
+ (stack.length - 1) + ' closing parenthesis:' + source);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return stack.pop().postParse();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
toJSON () {
|
|
289
|
+
return {
|
|
290
|
+
allow: this.showRestrict('+'),
|
|
291
|
+
numbers: this.hasNumbers,
|
|
292
|
+
lambdas: this.hasLambdas,
|
|
293
|
+
terms: this.getTerms(),
|
|
294
|
+
annotate: this.annotate,
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Create shortcuts for common terms
|
|
300
|
+
/**
|
|
301
|
+
* Create free var(s) for subsequent use
|
|
302
|
+
* @param {String} names
|
|
303
|
+
* @return {FreeVar[]}
|
|
304
|
+
*/
|
|
305
|
+
SKI.free = (...names) => names.map(s => new FreeVar(s));
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Convert a number to Church encoding
|
|
309
|
+
* @param {number} n
|
|
310
|
+
* @return {Church}
|
|
311
|
+
*/
|
|
312
|
+
SKI.church = n => new Church(n);
|
|
313
|
+
SKI.classes = { Expr, Native, Alias, FreeVar, Lambda, Church };
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
*
|
|
317
|
+
* @type {{[key: string]: Native}}
|
|
318
|
+
*/
|
|
319
|
+
|
|
320
|
+
for (const name in native)
|
|
321
|
+
SKI[name] = native[name];
|
|
322
|
+
SKI.native = native;
|
|
323
|
+
SKI.options = globalOptions;
|
|
324
|
+
SKI.lambdaPlaceholder = Expr.lambdaPlaceholder;
|
|
325
|
+
|
|
326
|
+
module.exports = { SKI };
|
package/lib/quest.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
const { SKI } = require('./parser');
|
|
2
|
+
const { Expr, FreeVar, Alias, Lambda } = SKI.classes;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {{
|
|
6
|
+
* pass: boolean,
|
|
7
|
+
* reason: string?,
|
|
8
|
+
* steps: number,
|
|
9
|
+
* start: Expr,
|
|
10
|
+
* found: Expr,
|
|
11
|
+
* expected: Expr,
|
|
12
|
+
* note: string?,
|
|
13
|
+
* args: Expr[],
|
|
14
|
+
* case: Case
|
|
15
|
+
* }} CaseResult
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
class Quest {
|
|
19
|
+
/**
|
|
20
|
+
* @description A combinator problem with a set of test cases for the proposed solution.
|
|
21
|
+
* @param {{
|
|
22
|
+
* title: string?,
|
|
23
|
+
* descr: string?,
|
|
24
|
+
* subst: string?,
|
|
25
|
+
* allow: string?,
|
|
26
|
+
* numbers: boolean?,
|
|
27
|
+
* vars: string[]?,
|
|
28
|
+
* engine: SKI?,
|
|
29
|
+
* engineFull: SKI?,
|
|
30
|
+
* cases: [{max: number?, note: string?, feedInput: boolean, lambdas: boolean?}|string[], ...string[][]]?
|
|
31
|
+
* }} options
|
|
32
|
+
*/
|
|
33
|
+
constructor (options = {}) {
|
|
34
|
+
const { input, vars, cases, allow, numbers, lambdas, subst, engine, engineFull, ...meta } = options;
|
|
35
|
+
|
|
36
|
+
//
|
|
37
|
+
this.engine = engine ?? new SKI();
|
|
38
|
+
this.engineFull = engineFull ?? new SKI();
|
|
39
|
+
this.restrict = { allow, numbers: numbers ?? false, lambdas: lambdas ?? false };
|
|
40
|
+
this.vars = {};
|
|
41
|
+
this.subst = Array.isArray(subst) ? subst : [subst ?? 'phi'];
|
|
42
|
+
|
|
43
|
+
const jar = {};
|
|
44
|
+
|
|
45
|
+
// options.vars is a list of expressions.
|
|
46
|
+
// we suck all free variables + all term declarations from there into this.vars
|
|
47
|
+
// to feed it later to every case's parser.
|
|
48
|
+
for (const term of vars ?? []) {
|
|
49
|
+
const expr = this.engineFull.parse(term, jar);
|
|
50
|
+
if (expr instanceof SKI.classes.Alias)
|
|
51
|
+
this.vars[expr.name] = new Alias(expr.name, expr.impl, { terminal: true, canonize: false });
|
|
52
|
+
// Canonized aliases won't expand with insufficient arguments,
|
|
53
|
+
// causing correct solutions to fail, so alas...
|
|
54
|
+
else if (expr instanceof SKI.classes.FreeVar)
|
|
55
|
+
this.vars[expr.name] = expr;
|
|
56
|
+
else
|
|
57
|
+
throw new Error('Unsupported given variable type: ' + term);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this.input = [];
|
|
61
|
+
for (const term of Array.isArray(input) ? input : [input])
|
|
62
|
+
this.addInput(term);
|
|
63
|
+
if (!this.input.length)
|
|
64
|
+
throw new Error('Quest needs at least one input placeholder');
|
|
65
|
+
if (subst)
|
|
66
|
+
this.input[0].fancy = this.subst[0];
|
|
67
|
+
|
|
68
|
+
this.varsFull = { ...this.vars, ...jar };
|
|
69
|
+
for (const term of this.input) {
|
|
70
|
+
if (term.name in this.varsFull)
|
|
71
|
+
throw new Error('input placeholder name is duplicated or clashes with vars: ' + term.name);
|
|
72
|
+
this.varsFull[term.name] = term.placeholder;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this.cases = [];
|
|
76
|
+
this.title = meta.title;
|
|
77
|
+
meta.descr = list2str(meta.descr);
|
|
78
|
+
this.descr = meta.descr;
|
|
79
|
+
this.meta = meta;
|
|
80
|
+
|
|
81
|
+
for (const c of cases ?? [])
|
|
82
|
+
this.add(...c);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Display allowed terms based on what engine thinks of this.vars + this.restrict.allow
|
|
87
|
+
* @return {string}
|
|
88
|
+
*/
|
|
89
|
+
allowed () {
|
|
90
|
+
const allow = this.restrict.allow ?? '';
|
|
91
|
+
const vars = Object.keys(this.vars).sort();
|
|
92
|
+
// In case vars are present and restrictions aren't, don't clutter the output with all the known terms
|
|
93
|
+
return allow
|
|
94
|
+
? this.engine.showRestrict(allow + '+' + vars.join(' '))
|
|
95
|
+
: vars.map( s => '+' + s).join(' ');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
addInput (term) {
|
|
99
|
+
if (typeof term !== 'object')
|
|
100
|
+
term = { name: term };
|
|
101
|
+
if (typeof term.name !== 'string')
|
|
102
|
+
throw new Error("quest 'input' field must be a string or a {name: string, ...} object");
|
|
103
|
+
|
|
104
|
+
[term.placeholder] = SKI.free(term.name);
|
|
105
|
+
// TODO more checks
|
|
106
|
+
this.input.push(term);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
*
|
|
111
|
+
* @param {{} | string} opt
|
|
112
|
+
* @param {string} terms
|
|
113
|
+
* @return {Quest}
|
|
114
|
+
*/
|
|
115
|
+
add (opt, ...terms) {
|
|
116
|
+
if (typeof opt === 'string') {
|
|
117
|
+
terms.unshift(opt);
|
|
118
|
+
opt = {};
|
|
119
|
+
} else
|
|
120
|
+
opt = { ...opt };
|
|
121
|
+
|
|
122
|
+
opt.engine = opt.engine ?? this.engineFull;
|
|
123
|
+
opt.vars = opt.vars ?? this.varsFull;
|
|
124
|
+
|
|
125
|
+
const input = this.input.map( t => t.placeholder );
|
|
126
|
+
this.cases.push(
|
|
127
|
+
opt.linear
|
|
128
|
+
? new LinearCase(input, opt, terms)
|
|
129
|
+
: new ExprCase(input, opt, terms)
|
|
130
|
+
);
|
|
131
|
+
return this;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @description Statefully parse a list of strings into expressions or fancy aliases thereof.
|
|
136
|
+
* @param {string[]} input
|
|
137
|
+
* @return {{terms: Expr[], weight: number}}
|
|
138
|
+
*/
|
|
139
|
+
prepare (...input) {
|
|
140
|
+
if (input.length !== this.input.length)
|
|
141
|
+
throw new Error('Solutions provided ' + input.length + ' terms where ' + this.input.length + ' are expected');
|
|
142
|
+
|
|
143
|
+
let weight = 0;
|
|
144
|
+
const prepared = [];
|
|
145
|
+
const jar = { ...this.vars };
|
|
146
|
+
for (let i = 0; i < input.length; i++) {
|
|
147
|
+
const spec = this.input[i];
|
|
148
|
+
const impl = this.engine.parse(input[i], jar, {
|
|
149
|
+
allow: spec.allow ?? this.restrict.allow,
|
|
150
|
+
numbers: spec.numbers ?? this.restrict.numbers,
|
|
151
|
+
lambdas: spec.lambdas ?? this.restrict.lambdas,
|
|
152
|
+
});
|
|
153
|
+
weight += impl.weight();
|
|
154
|
+
const expr = impl instanceof FreeVar
|
|
155
|
+
? impl
|
|
156
|
+
: new Alias(spec.fancy ?? spec.name, impl, { terminal: true, canonize: false });
|
|
157
|
+
jar[spec.name] = expr;
|
|
158
|
+
prepared.push(expr);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
prepared,
|
|
162
|
+
weight,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
*
|
|
168
|
+
* @param {string} input
|
|
169
|
+
* @return {{
|
|
170
|
+
* expr: Expr?,
|
|
171
|
+
* pass: boolean,
|
|
172
|
+
* details: CaseResult[],
|
|
173
|
+
* exception: Error?,
|
|
174
|
+
* steps: number,
|
|
175
|
+
* input: Expr[]|string[],
|
|
176
|
+
* weight: number?
|
|
177
|
+
* }}
|
|
178
|
+
*/
|
|
179
|
+
check (...input) {
|
|
180
|
+
try {
|
|
181
|
+
const { prepared, weight } = this.prepare(...input);
|
|
182
|
+
const details = this.cases.map( c => c.check(...prepared) );
|
|
183
|
+
const pass = details.reduce((acc, val) => acc && val.pass, true);
|
|
184
|
+
const steps = details.reduce((acc, val) => acc + val.steps, 0);
|
|
185
|
+
return {
|
|
186
|
+
expr: prepared[0],
|
|
187
|
+
input: prepared,
|
|
188
|
+
pass,
|
|
189
|
+
steps,
|
|
190
|
+
details,
|
|
191
|
+
weight,
|
|
192
|
+
};
|
|
193
|
+
} catch (e) {
|
|
194
|
+
return { pass: false, details: [], exception: e, steps: 0, input };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
*
|
|
200
|
+
* @return {TestCase[]}
|
|
201
|
+
*/
|
|
202
|
+
show () {
|
|
203
|
+
return [...this.cases];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
class Case {
|
|
208
|
+
constructor (input, options) {
|
|
209
|
+
this.max = options.max ?? 1000;
|
|
210
|
+
this.note = options.note;
|
|
211
|
+
this.vars = { ...(options.vars ?? {}) }; // shallow copy to avoid modifying the original
|
|
212
|
+
this.input = input;
|
|
213
|
+
this.engine = options.engine;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
parse (src) {
|
|
217
|
+
return new Lambda(this.input, this.engine.parse(src, this.vars));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* @param {Expr} expr
|
|
222
|
+
* @return {CaseResult}
|
|
223
|
+
*/
|
|
224
|
+
check ( ...expr ) {
|
|
225
|
+
throw new Error('not implemented');
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
class ExprCase extends Case {
|
|
230
|
+
/**
|
|
231
|
+
* @param {FreeVar[]} input
|
|
232
|
+
* @param {{
|
|
233
|
+
* max: number?,
|
|
234
|
+
* note: string?,
|
|
235
|
+
* vars: {string: Expr}?,
|
|
236
|
+
* engine: SKI?
|
|
237
|
+
* }} options
|
|
238
|
+
* @param {[e1: string, e2: string]} terms
|
|
239
|
+
*/
|
|
240
|
+
constructor (input, options, terms) {
|
|
241
|
+
if (terms.length !== 2)
|
|
242
|
+
throw new Error('Case accepts exactly 2 strings');
|
|
243
|
+
|
|
244
|
+
super(input, options);
|
|
245
|
+
|
|
246
|
+
[this.e1, this.e2] = terms.map(src => this.parse(src));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
check (...expr) {
|
|
250
|
+
// we do it the fancy way and instead of just "apply" to avoid
|
|
251
|
+
// displaying (foo->foo this that)(user input) as 1st step
|
|
252
|
+
const subst = (outer, inner) => outer.reduce(inner) ?? outer.apply(...inner);
|
|
253
|
+
|
|
254
|
+
const start = subst(this.e1, expr);
|
|
255
|
+
const r1 = start.run({ max: this.max });
|
|
256
|
+
const r2 = subst(this.e2, expr).run({ max: this.max });
|
|
257
|
+
let reason = null;
|
|
258
|
+
if (!r1.final || !r2.final)
|
|
259
|
+
reason = 'failed to reach normal form in ' + this.max + ' steps';
|
|
260
|
+
else if (!r1.expr.equals(r2.expr))
|
|
261
|
+
reason = 'expected: ' + r2.expr;
|
|
262
|
+
// NOTE maybe there should be expand() on both sides of equal() but we'll see.
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
pass: !reason,
|
|
266
|
+
reason,
|
|
267
|
+
steps: r1.steps,
|
|
268
|
+
start,
|
|
269
|
+
found: r1.expr,
|
|
270
|
+
expected: r2.expr,
|
|
271
|
+
note: this.note,
|
|
272
|
+
args: expr,
|
|
273
|
+
case: this,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
class LinearCase extends Case {
|
|
279
|
+
// test that an expression uses all of its inputs exactly once
|
|
280
|
+
constructor (input, options, terms) {
|
|
281
|
+
super(input, options);
|
|
282
|
+
this.expr = this.parse(terms[0]);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
check (...expr) {
|
|
286
|
+
const start = this.expr.apply(...expr);
|
|
287
|
+
const r = start.run({ max: this.max });
|
|
288
|
+
const arity = r.expr.canonize();
|
|
289
|
+
const reason = arity.linear
|
|
290
|
+
? null
|
|
291
|
+
: 'expected a linear expression, i.e. such that uses all inputs exactly once';
|
|
292
|
+
return {
|
|
293
|
+
pass: !reason,
|
|
294
|
+
reason,
|
|
295
|
+
steps: r.steps,
|
|
296
|
+
start,
|
|
297
|
+
found: r.expr,
|
|
298
|
+
case: this,
|
|
299
|
+
note: this.note,
|
|
300
|
+
args: expr,
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function list2str (str) {
|
|
306
|
+
if (str === undefined)
|
|
307
|
+
return str;
|
|
308
|
+
return Array.isArray(str) ? str.join(' ') : '' + str;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
module.exports = { Quest };
|
package/lib/util.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
class Tokenizer {
|
|
2
|
+
constructor (...terms) {
|
|
3
|
+
const src = '$|(\\s+)|' + terms
|
|
4
|
+
.map(s => '(?:' + s + ')')
|
|
5
|
+
.sort((a, b) => b.length - a.length)
|
|
6
|
+
.join('|');
|
|
7
|
+
this.rex = new RegExp(src, 'gys');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
*
|
|
12
|
+
* @param {string} str
|
|
13
|
+
* @return {string[]}
|
|
14
|
+
*/
|
|
15
|
+
split (str) {
|
|
16
|
+
this.rex.lastIndex = 0;
|
|
17
|
+
const list = [...str.matchAll(this.rex)];
|
|
18
|
+
|
|
19
|
+
// did we parse everything?
|
|
20
|
+
const eol = list.pop();
|
|
21
|
+
const last = eol?.index ?? 0;
|
|
22
|
+
|
|
23
|
+
if (last !== str.length) {
|
|
24
|
+
throw new Error('Unknown tokens at pos ' + last + '/' + str.length
|
|
25
|
+
+ ' starting with ' + str.substring(last));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// skip whitespace
|
|
29
|
+
return list.filter(x => x[1] === undefined).map(x => x[0]);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const tokRestrict = new Tokenizer('[-=+]', '[A-Z]', '\\b[a-z_][a-z_0-9]*\\b');
|
|
34
|
+
function restrict (set, spec) {
|
|
35
|
+
if (!spec)
|
|
36
|
+
return set;
|
|
37
|
+
let out = new Set([...set]);
|
|
38
|
+
let mode = 0;
|
|
39
|
+
const act = [
|
|
40
|
+
sym => { out = new Set([sym]); mode = 1; },
|
|
41
|
+
sym => { out.add(sym); },
|
|
42
|
+
sym => { out.delete(sym); },
|
|
43
|
+
];
|
|
44
|
+
for (const sym of tokRestrict.split(spec)) {
|
|
45
|
+
if (sym === '=')
|
|
46
|
+
mode = 0;
|
|
47
|
+
else if (sym === '+')
|
|
48
|
+
mode = +1;
|
|
49
|
+
else if (sym === '-')
|
|
50
|
+
mode = 2;
|
|
51
|
+
else
|
|
52
|
+
act[mode](sym);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function missingIndices (arr, set) {
|
|
58
|
+
const out = new Set();
|
|
59
|
+
for (let n = 0; n < arr.length; n++) {
|
|
60
|
+
if (!set.has(arr[n]))
|
|
61
|
+
out.add(n);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isSubset (a, b) {
|
|
67
|
+
for (const x of a) {
|
|
68
|
+
if (!b.has(x))
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { Tokenizer, restrict, missingIndices, isSubset };
|