@dallaylaen/ski-interpreter 2.2.1 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,52 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.3.1] - 2026-03-01
9
+
10
+ ### Fixed
11
+ - Links in documentation.
12
+
13
+ ### Added
14
+ - `ski.js extract <expression> <term> ...` - rewrite the given expression to an equivalent using provided terms where possible.
15
+ - `ski.js search <expression> <term> ...` - brute-force search for an expression equivalent to the given one using the provided terms.
16
+
17
+ ## [2.3.0] - 2026-02-28
18
+
19
+ ### BREAKING CHANGES
20
+
21
+ - `ski.js` CLI interface changed (see below)
22
+ - Quest page css changes (see below)
23
+ - `diag()` does not indent `Alias` to reduce diffs
24
+ - Swapped `Expr.expect()` arguments: expected.expect(actual).
25
+ Mnemonic: expected is always an expr, actual may be whatever.
26
+
27
+ ### Added
28
+
29
+ - `Quest.Group` class and `Quest.verify()` for quest self-check capability.
30
+ - `Quest.selfCheck({ accepted: ..., rejected: ... })` replaces the homegrown `solution` mechanism.
31
+ - `{order: LI|LO}` option to `Expr.traverse()` for left-innermost / left-outermost traversal order.
32
+ - `FreeVar.global` constant as an explicit replacement for the `context=SKI` convention.
33
+ - `TermInfo` typedef for `infer()` results.
34
+ - CLI (`bin/ski.js`) rewritten with `commander`, now supports subcommands: `repl`, `eval`, `file`, `quest-check`.
35
+ - Quest authoring guide added to docs.
36
+ - `expr.expect(actual)` now returns `diag()` as -actual / +expected.
37
+
38
+ ### Fixed
39
+
40
+ - `infer()` now limits recursion depth to prevent stack overflows (#10).
41
+ - Alias indentation in `diag()` output corrected.
42
+ - Quest page links fixed after previous rewrite.
43
+ - Aliases are no longer canonized without an explicit request.
44
+
45
+ ### Changed
46
+
47
+ - `toLambda()` and `toSKI()` rewritten using left-innermost traverse.
48
+ - `Church` is now a descendant of `Expr` instead of `Native` (minor speedup).
49
+ - `Expr.constructor` removed for a minor speedup.
50
+ - `parser.add()` now accepts an options object in place of a plain note string.
51
+ - Quest solutions moved to `data/quest-solutions.json`; removed from quest-data.
52
+ - Quest nav items use stable `.ski-quest-nav-item` class instead of fragile `.ski-quest-index a`.
53
+
8
54
  ## [2.2.1] - 2026-02-22
9
55
 
10
56
  ### Added
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Simple Kombinator Interpreter
2
2
 
3
+ > **A humane tooling for inhuman logic**
4
+
3
5
  This package contains a
4
6
  [combinatory logic](https://en.wikipedia.org/wiki/Combinatory_logic)
5
7
  and [lambda calculus](https://en.wikipedia.org/wiki/Lambda_calculus)
@@ -49,11 +51,12 @@ back to human readable form.
49
51
 
50
52
  # Execution strategy
51
53
 
52
- Applications and native terms use normal strategy, i.e. the first term in the tree
54
+ Applications and native terms use leftmost-outermost strategy, i.e. the first term in the tree
53
55
  that has enough arguments is executed and the step ends there.
54
56
 
55
- Lambda terms are lazy, i.e. the body is not touched until
56
- all free variables are bound.
57
+ Lambda terms are lazy, i.e. the body is not touched whatsoever
58
+ until all the free variables are bound.
59
+ This is consistent with combinator behavior under LO order.
57
60
 
58
61
  # Playground
59
62
 
@@ -62,11 +65,13 @@ all free variables are bound.
62
65
  * all of the above features (except comparison and JS-native terms) in your browser
63
66
  * expressions have permalinks
64
67
  * can configure verbosity and execution speed
68
+ * switchable visual highlighting of redexes and outline of subexpressions
65
69
 
66
70
  * [Quests](https://dallaylaen.github.io/ski-interpreter/quest.html)
67
71
 
68
- This page contains small tasks of increasing complexity.
72
+ This page contains small combinatory logic exercises of increasing (hopefully) diffuculty.
69
73
  Each task requires the user to build a combinator with specific properties.
74
+ New combinators are unlocked as the user progresses.
70
75
 
71
76
  # Installation
72
77
 
@@ -76,7 +81,29 @@ npm install @dallaylaen/ski-interpreter
76
81
 
77
82
  # CLI
78
83
 
79
- REPL comes with the package as [bin/ski.js](bin/ski.js).
84
+ [bin/ski.js](bin/ski.js) - also available as `npx ski` - contains several subcommands:
85
+
86
+ ## Subcommands
87
+
88
+ * **`repl`** - Start an interactive REPL
89
+ * `--verbose` - Show all evaluation steps
90
+ * Built-in commands (type `!help` in REPL):
91
+ * `!ls` - List all defined terms
92
+ * `!help` - Show available commands
93
+
94
+ * **`eval <expression>`** - Evaluate a single expression
95
+ * `--verbose` - Show all evaluation steps
96
+ * Example: `ski eval "S K K x"`
97
+
98
+ * **`file <filepath>`** - Evaluate expressions from a file
99
+ * `--verbose` - Show all evaluation steps
100
+ * Example: `ski file script.ski`
101
+
102
+ * **`quest-check <files...>`** - Validate quest definition files
103
+ * `--solution <file>` - Load solutions from a JSON file for verification
104
+ * Example: `ski quest-check quest1.json quest2.json --solution solutions.json`
105
+
106
+ If no subcommand is provided, help is displayed.
80
107
 
81
108
  Running `SKI_REPL=1 node -r @dallaylaen/ski-interpreter/bin/ski.js`
82
109
  will start a node shell with the `SKI` class available as a global variable.
@@ -149,12 +176,38 @@ const lambdaSteps = [...skiExpr.toLambda()];
149
176
 
150
177
  ## Fancy formatting
151
178
 
152
- The `format` methods of the `Expr` class supports
153
- a number of options, see [the source code](src/expr.js) for details.
179
+ `expr.format(options?)` converts an expression to a string with fine-grained
180
+ control over notation. Called without arguments it is equivalent to
181
+ `expr.toString()`.
182
+
183
+ ```javascript
184
+ const expr = ski.parse('S K K');
185
+
186
+ expr.format() // 'S K K' (default, terse)
187
+ expr.format({ terse: false }) // 'S(K)(K)' — every argument gets parentheses
188
+ expr.format({ html: true }) // HTML-safe: free vars wrapped in <var>,
189
+ // '->' becomes '-&gt;', fancyName used when set
190
+
191
+ // Custom lambda notation
192
+ expr.format({ lambda: ['', '=>', ''], terse: false }) // JavaScript style
193
+ expr.format({ lambda: ['&lambda;', '.', ''] }) // math style
194
+ expr.format({ lambda: ['(', '->', ')'], around: ['(', ')'], brackets: ['', ''] })
195
+ // Lisp style, still parseable
196
+
197
+ // Redex highlighting (e.g. for step-by-step HTML output)
198
+ ski.parse('I x').format({ html: true, redex: ['<b>', '</b>'] })
199
+ // '<b>I</b> <var>x</var>'
200
+
201
+ // inventory: show listed aliases by name, expand everything else
202
+ const { T } = ski.getTerms();
203
+ expr.format({ inventory: { T } }) // keeps T as 'T', expands any other aliases
204
+ ```
205
+
206
+ The `brackets`, `var`, `around`, and `redex` options each take a `[open, close]`
207
+ pair of strings; `lambda` takes a `[prefix, separator, suffix]` triple.
154
208
 
155
- `expr.diag()` will instead output indented
156
- expression tree (breadth-first) with class information
157
- and variables labeled for disambiguation.
209
+ `expr.diag()` will instead output an indented expression tree (breadth-first)
210
+ with class information and variables labeled for disambiguation.
158
211
 
159
212
  ## Variable scoping
160
213
 
@@ -217,7 +270,7 @@ q.check('K'); // fail
217
270
  q.check('K(K(y x))') // nope! the variable scopes won't match
218
271
  ```
219
272
 
220
- See [quest page data](docs/quest-data/) for more examples.
273
+ See also [the quest guide](quest-intro.md) for more details on building your own quests or even interactive quest pages.
221
274
 
222
275
  # Package contents
223
276
 
@@ -234,6 +287,7 @@ for building interactive quest pages from JSON-encoded quest data;
234
287
 
235
288
  * [@ivanaxe](https://github.com/ivanaxe) for luring me into [icfpc 2011](http://icfpc2011.blogspot.com/2011/06/task-description-contest-starts-now.html) where I was introduced to combinators.
236
289
  * [@akuklev](https://github.com/akuklev) for explaining functional programming to me so many times that I actually got some idea.
290
+ * [One happy fellow](https://github.com/happyfellow-one) whose [riddle](https://blog.happyfellow.dev/a-riddle/) trolled me into writing an early `traverse` prototype.
237
291
 
238
292
  # Prior art and inspiration
239
293
 
package/bin/ski.js CHANGED
@@ -1,121 +1,308 @@
1
1
  #!/usr/bin/env -S node --stack-size=20600
2
2
 
3
3
  const fs = require('node:fs/promises');
4
+ const { Command } = require('commander');
4
5
 
5
6
  const { SKI } = require('../lib/ski-interpreter.cjs');
7
+ const { Quest } = require('../src/quest.js');
6
8
 
7
- const [myname, options, positional] = parseArgs(process.argv);
9
+ const program = new Command();
8
10
 
9
- if (options.help) {
10
- console.error(myname + ': usage: ' + myname + '[-q | -v ] -e <expression>');
11
- process.exit(1);
12
- }
11
+ program
12
+ .name('ski')
13
+ .description('Simple Kombinator Interpreter - a combinatory logic & lambda calculus parser and interpreter')
14
+ .version('2.2.1');
13
15
 
14
- if ((typeof options.e === 'string' && positional.length) > 0 || positional.length > 1) {
15
- console.error(myname + ': either -e <expr> or exactly one filename must be given');
16
- process.exit(1);
17
- }
16
+ // REPL subcommand
17
+ program
18
+ .command('repl')
19
+ .description('Start interactive REPL')
20
+ .option('--verbose', 'Show all evaluation steps')
21
+ .action((options) => {
22
+ startRepl(options.verbose);
23
+ });
24
+
25
+ // Eval subcommand
26
+ program
27
+ .command('eval <expression>')
28
+ .description('Evaluate a single expression')
29
+ .option('--verbose', 'Show all evaluation steps')
30
+ .action((expression, options) => {
31
+ evaluateExpression(expression, options.verbose);
32
+ });
33
+
34
+ // File subcommand
35
+ program
36
+ .command('file <filepath>')
37
+ .description('Evaluate expressions from a file')
38
+ .option('--verbose', 'Show all evaluation steps')
39
+ .action((filepath, options) => {
40
+ evaluateFile(filepath, options.verbose);
41
+ });
42
+
43
+ // Search subcommand
44
+ program
45
+ .command('search <target> <terms...>')
46
+ .description('Search for an expression equivalent to target using known terms')
47
+ .action((target, terms) => {
48
+ searchExpression(target, terms);
49
+ });
50
+
51
+ // Extract subcommand
52
+ program
53
+ .command('extract <target> <terms...>')
54
+ .description('Rewrite target expression using known terms where possible')
55
+ .action((target, terms) => {
56
+ extractExpression(target, terms);
57
+ });
58
+
59
+ // Quest-check subcommand
60
+ program
61
+ .command('quest-check <files...>')
62
+ .description('Check quest files for validity')
63
+ .option('--solution <file>', 'Load solutions from file')
64
+ .action((files, options) => {
65
+ questCheck(files, options.solution);
66
+ });
67
+
68
+ // Default to REPL if no command provided
69
+ program
70
+ .showHelpAfterError(true)
71
+ .parse(process.argv);
18
72
 
19
- const ski = new SKI();
73
+ if (!process.argv.slice(2).length)
74
+ startRepl(false);
20
75
 
21
- if (options.e === undefined && !positional.length) {
22
- // interactive console
76
+ function startRepl (verbose) {
23
77
  const readline = require('readline');
78
+ const ski = new SKI();
79
+
24
80
  const rl = readline.createInterface({
25
81
  input: process.stdin,
26
82
  output: process.stdout,
27
83
  prompt: '> ',
28
84
  terminal: true,
29
85
  });
30
- if (!options.q)
31
- console.log('Welcome to SKI interactive shell. Known combinators: ' + ski.showRestrict());
86
+
87
+ console.log('Welcome to SKI interactive shell. Known combinators: ' + ski.showRestrict());
88
+
32
89
  rl.on('line', str => {
33
- const flag = str.match(/^\s*([-+])([qvt])\s*$/);
34
- if (flag)
35
- options[flag[2]] = flag[1] === '+';
36
- else {
37
- runLine(err => {
38
- console.log('' + err)
39
- })(str);
90
+ if (str.match(/\S/)) {
91
+ if (str.startsWith('!'))
92
+ handleCommand(str, ski);
93
+ else {
94
+ processLine(str, ski, verbose, err => {
95
+ console.log('' + err);
96
+ });
97
+ }
40
98
  }
41
99
  rl.prompt();
42
100
  });
101
+
43
102
  rl.once('close', () => {
44
- if (!options.q)
45
- console.log('Bye, and may your bird fly high!');
46
- process.exit(0)
103
+ console.log('Bye, and may thy bird fly high!');
104
+ process.exit(0);
47
105
  });
106
+
48
107
  rl.prompt();
49
- } else {
50
- const prom = positional.length > 0
51
- ? fs.readFile(positional[0], 'utf8')
52
- : Promise.resolve(options.e);
108
+ }
53
109
 
54
- prom.then(runLine(err => { console.error('' + err); process.exit(3) })).catch(err => {
55
- console.error(myname + ': ' + err);
56
- process.exit(2);
110
+ function evaluateExpression (expression, verbose) {
111
+ const ski = new SKI();
112
+ processLine(expression, ski, verbose, err => {
113
+ console.error('' + err);
114
+ process.exit(3);
57
115
  });
58
116
  }
59
117
 
60
- function runLine (onErr) {
61
- return function (source) {
62
- if (!source.match(/\S/))
63
- return 0; // nothing to see here
64
- try {
65
- const expr = ski.parse(source);
66
-
67
- const t0 = new Date();
68
- for (const state of expr.walk()) {
69
- if (state.final && !options.q)
70
- console.log(`// ${state.steps} step(s) in ${new Date() - t0}ms`);
71
- if (options.v || state.final)
72
- console.log('' + state.expr.format({ terse: options.t }));
73
- if (state.final && expr instanceof SKI.classes.Alias)
74
- ski.add(expr.name, state.expr);
75
- }
76
- return 0;
77
- } catch (err) {
78
- onErr(err);
79
- return 1;
118
+ function evaluateFile (filepath, verbose) {
119
+ const ski = new SKI();
120
+ fs.readFile(filepath, 'utf8')
121
+ .then(source => {
122
+ processLine(source, ski, verbose, err => {
123
+ console.error('' + err);
124
+ process.exit(3);
125
+ });
126
+ })
127
+ .catch(err => {
128
+ console.error('ski: ' + err);
129
+ process.exit(2);
130
+ });
131
+ }
132
+
133
+ function processLine (source, ski, verbose, onErr) {
134
+ if (!source.match(/\S/))
135
+ return; // nothing to see here
136
+
137
+ try {
138
+ const expr = ski.parse(source);
139
+ const t0 = new Date();
140
+ const isAlias = expr instanceof SKI.classes.Alias;
141
+ const aliasName = isAlias ? expr.name : null;
142
+
143
+ for (const state of expr.walk()) {
144
+ if (state.final)
145
+ console.log(`// ${state.steps} step(s) in ${new Date() - t0}ms`);
146
+
147
+ if (verbose || state.final)
148
+ console.log('' + state.expr.format());
149
+
150
+ if (state.final && isAlias && aliasName)
151
+ ski.add(aliasName, state.expr);
80
152
  }
153
+ } catch (err) {
154
+ onErr(err);
81
155
  }
82
156
  }
83
157
 
84
- function parseArgs (argv) {
85
- const [_, script, ...list] = argv;
86
-
87
- const todo = {
88
- '--': () => { pos.push(...list) },
89
- '--help': () => { opt.help = true },
90
- '-q': () => { opt.q = true },
91
- '-v': () => { opt.v = true },
92
- '-c': () => { opt.t = false },
93
- '-t': () => { opt.t = true },
94
- '-e': () => {
95
- if (list.length < 1)
96
- throw new Error('option -e requires an argument');
97
- opt.e = list.shift();
98
- },
99
- };
158
+ async function questCheck (files, solutionFile) {
159
+ try {
160
+ // Load solutions if provided
161
+ let solutions = null;
162
+ if (solutionFile) {
163
+ const data = await fs.readFile(solutionFile, 'utf8');
164
+ solutions = JSON.parse(data);
165
+ }
166
+
167
+ // Load and verify each quest file
168
+ let hasErrors = false;
169
+ const seenIds = new Set();
170
+
171
+ for (const file of files) {
172
+ try {
173
+ const data = await fs.readFile(file, 'utf8');
174
+ const questData = JSON.parse(data);
175
+
176
+ // Handle both single quest objects and quest groups
177
+ const entry = Array.isArray(questData) ? { content: questData } : questData;
100
178
 
101
- // TODO replace with a relevant dependency
102
- const pos = [];
103
- const opt = {};
179
+ try {
180
+ const group = new Quest.Group(entry);
104
181
 
105
- while (list.length > 0) {
106
- const next = list.shift();
182
+ // Verify the group
183
+ const findings = group.verify({
184
+ date: true,
185
+ solutions,
186
+ seen: seenIds
187
+ });
107
188
 
108
- if (!next.match(/^-/)) {
109
- pos.push(next);
110
- continue;
189
+ // Check for errors
190
+ const hasGroupErrors = Object.keys(findings).some(key => {
191
+ if (key === 'content') {
192
+ const contentErrors = findings.content?.filter(item => item !== null);
193
+ return contentErrors && contentErrors.length > 0;
194
+ }
195
+ return findings[key];
196
+ });
197
+
198
+ if (hasGroupErrors) {
199
+ hasErrors = true;
200
+ console.error(`Error in ${file}:`);
201
+ console.error(JSON.stringify(findings, null, 2));
202
+ } else
203
+ console.log(`✓ ${file}`);
204
+ } catch (err) {
205
+ hasErrors = true;
206
+ console.error(`Error parsing quest group in ${file}:`, err.message);
207
+ }
208
+ } catch (err) {
209
+ hasErrors = true;
210
+ console.error(`Error reading file ${file}:`, err.message);
211
+ }
111
212
  }
112
213
 
113
- const action = todo[next];
114
- if (typeof action !== 'function')
115
- throw new Error('Unknown option ' + next + '; see ski.js --help');
214
+ // Exit with appropriate code
215
+ process.exit(hasErrors ? 1 : 0);
216
+ } catch (err) {
217
+ console.error('Error in quest-check:', err.message);
218
+ process.exit(2);
219
+ }
220
+ }
221
+
222
+ function searchExpression (targetStr, termStrs) {
223
+ const ski = new SKI();
224
+ const jar = {};
225
+ const target = ski.parse(targetStr, { vars: jar });
226
+ const seed = termStrs.map(s => ski.parse(s, { vars: jar }));
227
+
228
+ const { expr } = target.infer();
229
+ if (!expr) {
230
+ console.error('target expression is not normalizable: ' + target);
231
+ process.exit(1);
232
+ }
233
+
234
+ const res = SKI.extras.search(seed, { tries: 10_000_000, depth: 100 }, (e, p) => {
235
+ if (!p.expr)
236
+ return -1;
237
+ if (p.expr.equals(expr))
238
+ return 1;
239
+ return 0;
240
+ });
116
241
 
117
- action();
242
+ if (res.expr) {
243
+ console.log(`Found ${res.expr} after ${res.total} tries.`);
244
+ process.exit(0);
245
+ } else {
246
+ console.error(`No equivalent expression found for ${target} after ${res.total} tries.`);
247
+ process.exit(1);
118
248
  }
249
+ }
250
+
251
+ function extractExpression (targetStr, termStrs) {
252
+ const ski = new SKI();
253
+ const expr = ski.parse(targetStr);
254
+ const pairs = termStrs
255
+ .map(s => ski.parse(s))
256
+ .map(e => [e.infer().expr, e]);
257
+
258
+ const uncanonical = pairs.filter(pair => !pair[0]);
259
+ if (uncanonical.length) {
260
+ console.error('Some expressions could not be canonized: '
261
+ + uncanonical.map(p => p[1].toString()).join(', '));
262
+ process.exit(1);
263
+ }
264
+
265
+ const replaced = expr.traverse(e => {
266
+ const canon = e.infer().expr;
267
+ for (const [lambda, term] of pairs) {
268
+ if (canon.equals(lambda))
269
+ return term;
270
+ }
271
+ return null;
272
+ });
273
+
274
+ if (replaced)
275
+ console.log(replaced.toString());
276
+ else
277
+ console.log('// unchanged');
278
+ }
279
+
280
+ function handleCommand (input, ski) {
281
+ const parts = input.trim().split(/\s+/);
282
+ const cmd = parts[0];
283
+
284
+ const dispatch = {
285
+ '!ls': () => {
286
+ const terms = ski.getTerms();
287
+ const list = Object.keys(terms).sort();
288
+ for (const name of list) {
289
+ const term = terms[name];
290
+ if (term instanceof SKI.classes.Alias)
291
+ console.log(` ${name} = ${term.impl}`);
292
+ else if (term instanceof SKI.classes.Native)
293
+ console.log(` ${name} ${term.props?.expr ?? '(native)'}`);
294
+ }
295
+ },
296
+ '!help': () => {
297
+ console.log('Available commands:');
298
+ console.log(' !ls - List term inventory');
299
+ console.log(' !help - Show this help message');
300
+ },
301
+ '': () => {
302
+ console.log(`Unknown command: ${cmd}`);
303
+ console.log('Type !help for available commands.');
304
+ }
305
+ };
119
306
 
120
- return [script, opt, pos];
307
+ (dispatch[cmd] || dispatch[''])(...parts.slice(1));
121
308
  }