@dallaylaen/ski-interpreter 2.7.0 → 2.8.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,45 @@ 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.8.1] - 2026-04-26
9
+
10
+ ### Added
11
+
12
+ - `SKI.extras.equiv(expr1, expr2, options: InferOptions?)` to check if two expressions are computationally equivalent.
13
+ - `./bin/ski.js compare <expr1> <expr2>` command to compare two expressions for equivalence.
14
+
15
+ ### Changed
16
+
17
+ - `Expr.infer()` keeps adding variables to nonterminating expressions
18
+ to catch e.g. `CK(WWW)` (never terminates but eqivalent to I).
19
+
20
+ ## [2.8.0] - 2026-04-23
21
+
22
+ ### BREAKING CHANGES
23
+
24
+ - `parse()` no longer auto-inlines aliases unless they are redefined; known aliases are preserved by name.
25
+ - `toposort()` signature changed to use options object instead of positional parameters.
26
+
27
+ ### Added
28
+
29
+ - `Expr.declare()` — emits a complete, round-trippable declaration of an expression including its dependencies.
30
+ - `parse(..., { canonize: true })` — calculates properties of intermediate aliases during parsing.
31
+ - `annotate()` — public rename of the semi-official `_setup()` method, with added documentation.
32
+ - Quest UI: "reveal solution" button added.
33
+ - Quest UI: raw user input is now saved when a quest is solved.
34
+ - CLI (`bin/ski.js`): outputs full declarations instead of bare expressions.
35
+
36
+ ### Changed
37
+
38
+ - `toJSON()` now outputs back-parsable declaration instead of just string.
39
+ - `toposort` moved into `expr.ts`.
40
+ - `extras.ts` delegates to `Expr.declare()` instead of re-implementing the logic.
41
+ - Internal type `{ ... }` renamed to `Record<string, Named>` for clarity.
42
+
43
+ ### Fixed
44
+
45
+ - `Expr.toLambda` was incorrectly preparing term (#24).
46
+
8
47
  ## [2.7.0] - 2026-04-03
9
48
 
10
49
  ### BREAKING CHANGES
package/bin/ski.js CHANGED
@@ -71,6 +71,23 @@ program
71
71
  inferExpression(expression);
72
72
  });
73
73
 
74
+ program
75
+ .command('compare <expr1> <expr2>')
76
+ .description('Check if two expressions are equivalent')
77
+ .action((expr1, expr2) => {
78
+ const ski = new SKI();
79
+ const e1 = ski.parse(expr1);
80
+ const e2 = ski.parse(expr2);
81
+ const res = SKI.extras.equiv(e1, e2, runOptions);
82
+ if (res.equal)
83
+ console.log('Both expressions are equivalent to ' + res.canonical[0].format(format));
84
+ else
85
+ console.log(`Expressions differ:\n${res.canonical[0].format(format)}\n vs \n${res.canonical[1].format(format)}`);
86
+
87
+ console.log(`// ${res.steps} step(s)`);
88
+ process.exit(res.equal ? 0 : 1);
89
+ });
90
+
74
91
  // Extract subcommand
75
92
  program
76
93
  .command('extract <target> <terms...>')
@@ -166,20 +183,17 @@ function processLine (source, ski, onErr) {
166
183
  return; // nothing to see here
167
184
 
168
185
  try {
169
- const expr = ski.parse(source);
186
+ const expr = ski.parse(source, { canonize: true });
187
+ if (expr instanceof SKI.classes.Alias)
188
+ ski.add(expr);
170
189
  const t0 = new Date();
171
- const isAlias = expr instanceof SKI.classes.Alias;
172
- const aliasName = isAlias ? expr.name : null;
173
190
 
174
191
  for (const state of expr.walk(runOptions)) {
175
- if (state.final)
192
+ if (state.final) {
176
193
  console.log(`// ${state.steps} step(s) in ${new Date() - t0}ms`);
177
-
178
- if (verbose || state.final)
179
- console.log(state.expr.format(format));
180
-
181
- if (state.final && isAlias && aliasName)
182
- ski.add(aliasName, state.expr);
194
+ console.log(state.expr.declare({ ...format, inventory: ski.getTerms() }));
195
+ } else if (verbose)
196
+ console.log(state.expr.format(format) + ';');
183
197
  }
184
198
  } catch (err) {
185
199
  onErr(err);
@@ -111,10 +111,13 @@ var control = {
111
111
  var native = {};
112
112
  var Expr = class _Expr {
113
113
  /**
114
+ * Add metadata based on user-supplied values and/or the properties of the term itself.
114
115
  *
115
- * Define properties of the term based on user supplied options and/or inference results.
116
- * Typically useful for declaring Native and Alias terms.
117
- * @protected
116
+ * Typically applied to named terms shortly after instantiation.
117
+ *
118
+ * Experimental. Name and meaning may change in the future.
119
+ *
120
+ * @experimental
118
121
  * @param {Object} options
119
122
  * @param {string} [options.note] - a brief description what the term does
120
123
  * @param {number} [options.arity] - number of arguments the term is waiting for (if known)
@@ -124,7 +127,7 @@ var Expr = class _Expr {
124
127
  * @param {number} [options.maxArgs] - maximum number of arguments for inference, if canonize is true
125
128
  * @return {this}
126
129
  */
127
- _setup(options = {}) {
130
+ annotate(options = {}) {
128
131
  if (options.fancy !== void 0 && this instanceof Named)
129
132
  this.fancyName = options.fancy;
130
133
  if (options.note !== void 0)
@@ -354,11 +357,9 @@ var Expr = class _Expr {
354
357
  let steps = 0;
355
358
  let expr = this;
356
359
  main: for (let i = 0; i < options.maxArgs; i++) {
357
- const next = expr.run({ max: options.max - steps, maxSize: options.maxSize });
360
+ const next = expr.run({ max: Math.max((options.max - steps) / 2, 10), maxSize: options.maxSize });
358
361
  steps += next.steps;
359
- if (!next.final)
360
- break;
361
- if (firstVar(next.expr)) {
362
+ if (next.final && firstVar(next.expr)) {
362
363
  expr = next.expr;
363
364
  if (!expr.any((e) => !(e instanceof FreeVar || e instanceof App)))
364
365
  return maybeLambda(probe, expr, { steps });
@@ -428,7 +429,9 @@ var Expr = class _Expr {
428
429
  */
429
430
  *toLambda(options = {}) {
430
431
  let expr = this.traverse((e) => {
431
- if (e instanceof FreeVar || e instanceof App || e instanceof Lambda || e instanceof Alias)
432
+ if (e instanceof FreeVar)
433
+ return e;
434
+ if (e instanceof App || e instanceof Lambda || e instanceof Alias)
432
435
  return null;
433
436
  const guess = e.infer({ max: options.max, maxArgs: options.maxArgs });
434
437
  if (!guess.normal)
@@ -794,16 +797,26 @@ var Expr = class _Expr {
794
797
  diag(indent = "") {
795
798
  return indent + this.constructor.name + ": " + this;
796
799
  }
800
+ declare(options = {}) {
801
+ const { declaration: d = ["", "=", "; "], ...format } = options;
802
+ const res = toposort({ list: [this], env: format.inventory });
803
+ return res.list.map((s) => {
804
+ if (s instanceof Alias)
805
+ return d[0] + s.name + d[1] + s.impl.format({ ...format, inventory: res.env });
806
+ if (s instanceof FreeVar)
807
+ return d[0] + s.name + d[1];
808
+ return s.format({ ...format, inventory: res.env });
809
+ }).join(d[2]);
810
+ }
797
811
  /**
798
812
  * Convert the expression to a JSON-serializable format.
799
813
  * Sadly the format is not yet finalized and may change in the future.
800
814
  *
801
815
  * @experimental
802
- * @returns {string}
803
816
  * @sealed
804
817
  */
805
818
  toJSON() {
806
- return this.format();
819
+ return this.declare();
807
820
  }
808
821
  };
809
822
  var App = class _App extends Expr {
@@ -970,7 +983,7 @@ var Native = class extends Named {
970
983
  constructor(name, impl, opt = {}) {
971
984
  super(name);
972
985
  this.invoke = impl;
973
- this._setup({ canonize: true, ...opt });
986
+ this.annotate({ canonize: true, ...opt });
974
987
  }
975
988
  };
976
989
  var Lambda = class _Lambda extends Expr {
@@ -1077,7 +1090,7 @@ var Alias = class extends Named {
1077
1090
  if (!(impl instanceof Expr))
1078
1091
  throw new Error("Attempt to create an alias for a non-expression: " + impl);
1079
1092
  this.impl = impl;
1080
- this._setup(options);
1093
+ this.annotate(options);
1081
1094
  this.invoke = waitn(options.inline ? 0 : this.arity ?? 0)(impl);
1082
1095
  this.size = impl.size;
1083
1096
  if (options.inline)
@@ -1215,40 +1228,43 @@ function maybeLambda(args, expr, caps) {
1215
1228
  function nthvar(n) {
1216
1229
  return new FreeVar("abcdefgh"[n] ?? "x" + n);
1217
1230
  }
1218
- var classes = { Expr, App, Named, FreeVar, Native, Lambda, Church, Alias };
1219
-
1220
- // src/toposort.ts
1221
- function toposort(list, env) {
1222
- if (list instanceof Expr)
1223
- list = [list];
1224
- if (env) {
1225
- if (!list)
1226
- list = Object.keys(env).sort().map((k) => env[k]);
1227
- } else {
1228
- if (!list)
1229
- return { list: [], env: {} };
1230
- env = {};
1231
- for (const item of list) {
1232
- if (!(item instanceof Named))
1233
- continue;
1234
- if (env[item.name])
1235
- throw new Error("duplicate name " + item);
1236
- env[item.name] = item;
1231
+ function toposort(options) {
1232
+ if (typeof options !== "object" || options === null || Array.isArray(options) || options instanceof Expr)
1233
+ throw new Error("positional arguments to toposort are deprecated, use { list: ..., env: ... } instead");
1234
+ const allow = options.allow ? { ...options.allow } : null;
1235
+ const env = { ...options.env ?? {} };
1236
+ const list = options.list instanceof Expr ? [options.list] : options.list ?? [];
1237
+ if (allow) {
1238
+ for (const term of list) {
1239
+ if (term instanceof Named)
1240
+ allow[term.name] = term;
1237
1241
  }
1238
1242
  }
1243
+ for (const term of list) {
1244
+ if (term instanceof Named && env[term.name] === term)
1245
+ delete env[term.name];
1246
+ }
1239
1247
  const out = [];
1240
- const seen = /* @__PURE__ */ new Set();
1248
+ const seen = new Set(Object.values(env));
1241
1249
  const rec = (term) => {
1242
1250
  if (seen.has(term))
1243
1251
  return;
1244
- term.fold(false, (acc, e) => {
1245
- if (e !== term && e instanceof Named && env[e.name] === e) {
1252
+ term.fold(void 0, (_, e) => {
1253
+ if (!(e instanceof Named))
1254
+ return;
1255
+ if (allow && allow[e.name] !== e)
1256
+ return;
1257
+ if (!allow && e instanceof Alias && e.inline)
1258
+ return;
1259
+ if (e !== term) {
1246
1260
  rec(e);
1247
- return control.prune(false);
1261
+ return control.prune();
1248
1262
  }
1249
1263
  });
1250
1264
  out.push(term);
1251
1265
  seen.add(term);
1266
+ if (term instanceof Named)
1267
+ env[term.name] ||= term;
1252
1268
  };
1253
1269
  for (const term of list)
1254
1270
  rec(term);
@@ -1257,6 +1273,7 @@ function toposort(list, env) {
1257
1273
  env
1258
1274
  };
1259
1275
  }
1276
+ var classes = { Expr, App, Named, FreeVar, Native, Lambda, Church, Alias };
1260
1277
 
1261
1278
  // src/parser.ts
1262
1279
  var Empty = class extends Expr {
@@ -1359,7 +1376,7 @@ var Parser = class {
1359
1376
  add(term, impl, options) {
1360
1377
  const named = this._named(term, impl);
1361
1378
  const opts = typeof options === "string" ? { note: options, canonize: false } : options ?? {};
1362
- named._setup({ canonize: this.annotate, ...opts });
1379
+ named.annotate({ canonize: this.annotate, ...opts });
1363
1380
  const old = this.known[named.name];
1364
1381
  if (old instanceof Alias)
1365
1382
  old.makeInline();
@@ -1504,7 +1521,7 @@ var Parser = class {
1504
1521
  env[temp.name] = temp;
1505
1522
  delete env[name];
1506
1523
  }
1507
- const list = toposort(void 0, env).list;
1524
+ const list = toposort({ list: Object.values(env), allow: {} }).list;
1508
1525
  const detour = /* @__PURE__ */ new Map();
1509
1526
  if (Object.keys(needDetour).length) {
1510
1527
  const rework = (expr) => {
@@ -1531,14 +1548,15 @@ var Parser = class {
1531
1548
  return out;
1532
1549
  }
1533
1550
  /**
1534
- * @template T
1535
1551
  * @param {string} source
1536
1552
  * @param {Object} [options]
1537
- * @param {{[keys: string]: Expr}} [options.env]
1538
- * @param {T} [options.scope]
1539
- * @param {boolean} [options.numbers]
1540
- * @param {boolean} [options.lambdas]
1541
- * @param {string} [options.allow]
1553
+ * @param [options.env] - additional
1554
+ * @param [options.scope] - assign this scope to unknown free variables
1555
+ * @param {boolean} [options.numbers] - whether numbers are allowed
1556
+ * @param {boolean} [options.lambdas] - whether lambdas are allowed
1557
+ * @param {string} [options.allow] - restrict known terms
1558
+ * @param [options.canonize] - whether to calculate canonical form, arity, and properties
1559
+ * of intermediate aliases
1542
1560
  * @return {Expr}
1543
1561
  */
1544
1562
  parse(source, options = {}) {
@@ -1548,18 +1566,19 @@ var Parser = class {
1548
1566
  const jar = { ...options.env };
1549
1567
  let expr = new Empty();
1550
1568
  for (const item of lines) {
1551
- if (expr instanceof Alias)
1552
- expr.makeInline();
1553
- const def = item.match(/^([A-Z]|[a-z][a-z_0-9]*)\s*=(.*)$/s);
1554
- if (def && def[2] === "")
1555
- expr = new FreeVar(def[1], options.scope ?? FreeVar.global);
1569
+ const [_, name, def] = item.match(/^([A-Z]|[a-z][a-z_0-9]*)\s*=(.*)$/s) || [];
1570
+ if (name !== void 0) {
1571
+ if (jar[name] instanceof Alias && jar[name] !== options.env?.[name]) {
1572
+ jar[name].makeInline();
1573
+ }
1574
+ delete jar[name];
1575
+ }
1576
+ if (def === "")
1577
+ expr = new FreeVar(name, options.scope ?? FreeVar.global);
1556
1578
  else
1557
1579
  expr = this.parseLine(item, jar, options);
1558
- if (def) {
1559
- if (jar[def[1]] !== void 0)
1560
- throw new Error("Attempt to redefine a known term: " + def[1]);
1561
- jar[def[1]] = expr;
1562
- }
1580
+ if (name)
1581
+ jar[name] = expr;
1563
1582
  }
1564
1583
  if (this.addContext) {
1565
1584
  if (expr instanceof Named)
@@ -1591,7 +1610,7 @@ var Parser = class {
1591
1610
  parseLine(source, env = {}, options = {}) {
1592
1611
  const aliased = source.match(/^\s*([A-Z]|[a-z][a-z_0-9]*)\s*=\s*(.*)$/s);
1593
1612
  if (aliased)
1594
- return new Alias(aliased[1], this.parseLine(aliased[2], env, options));
1613
+ return new Alias(aliased[1], this.parseLine(aliased[2], env, options), { canonize: options.canonize });
1595
1614
  const opt = {
1596
1615
  numbers: options.numbers ?? this.hasNumbers,
1597
1616
  lambdas: options.lambdas ?? this.hasLambdas,
@@ -2066,6 +2085,77 @@ function canonize(term, options = {}) {
2066
2085
  }
2067
2086
 
2068
2087
  // src/extras.ts
2088
+ var formatSchema = {
2089
+ html: (x) => typeof x === "boolean" ? void 0 : "must be a boolean",
2090
+ terse: (x) => typeof x === "boolean" ? void 0 : "must be a boolean",
2091
+ space: (x) => typeof x === "string" ? void 0 : "must be a string",
2092
+ brackets: isStringPair,
2093
+ var: isStringPair,
2094
+ around: isStringPair,
2095
+ redex: isStringPair,
2096
+ lambda: isStringTriple,
2097
+ inventory: (x) => {
2098
+ if (typeof x !== "object" || x === null || x.constructor !== Object)
2099
+ return "must be an object, not " + (x?.constructor?.name ?? typeof x);
2100
+ const refined = x;
2101
+ for (const key of Object.keys(refined)) {
2102
+ if (!(refined[key] instanceof Expr))
2103
+ return "key " + key + "is not an Expr";
2104
+ }
2105
+ return void 0;
2106
+ }
2107
+ };
2108
+ function checkFormatOptions(raw) {
2109
+ if (raw === null || raw === void 0)
2110
+ return { value: {} };
2111
+ if (typeof raw !== "object" || Array.isArray(raw) || raw.constructor !== Object)
2112
+ return { error: { object: "Format options must be an object, not " + (raw?.constructor?.name ?? typeof raw) } };
2113
+ const rec = raw;
2114
+ const error = {};
2115
+ for (const key in rec) {
2116
+ if (formatSchema[key]) {
2117
+ const err = formatSchema[key](rec[key]);
2118
+ if (err)
2119
+ error[key] = err;
2120
+ } else
2121
+ error[key] = "unknown option";
2122
+ }
2123
+ return Object.keys(error).length > 0 ? { error } : { value: rec };
2124
+ }
2125
+ function equiv(e1, e2, options = {}) {
2126
+ let steps = 0;
2127
+ const [n1, n2] = [e1, e2].map((x) => x.traverse((e) => {
2128
+ const props = e.infer(options);
2129
+ steps += props.steps ?? 0;
2130
+ return props.expr;
2131
+ }));
2132
+ const normal = !!(n1 && n2);
2133
+ return {
2134
+ steps,
2135
+ normal,
2136
+ equal: normal ? n1.equals(n2) : false,
2137
+ canonical: [n1, n2]
2138
+ };
2139
+ }
2140
+ function declare(expr, env) {
2141
+ return expr.declare({ inventory: env });
2142
+ }
2143
+ function deepFormat(obj, options = {}) {
2144
+ if (obj instanceof Expr)
2145
+ return obj.format(options);
2146
+ if (obj instanceof Quest)
2147
+ return "Quest(" + obj.name + ")";
2148
+ if (obj instanceof Quest.Case)
2149
+ return "Quest.Case";
2150
+ if (Array.isArray(obj))
2151
+ return obj.map((item) => deepFormat(item, options));
2152
+ if (typeof obj !== "object" || obj === null || obj.constructor !== Object)
2153
+ return obj;
2154
+ const out = {};
2155
+ for (const key in obj)
2156
+ out[key] = deepFormat(obj[key], options);
2157
+ return out;
2158
+ }
2069
2159
  function search(seed, options, predicate) {
2070
2160
  const {
2071
2161
  depth = 16,
@@ -2129,72 +2219,13 @@ function search(seed, options, predicate) {
2129
2219
  }
2130
2220
  return { total, probed, gen: depth, ...options.retain ? { cache } : {} };
2131
2221
  }
2132
- function deepFormat(obj, options = {}) {
2133
- if (obj instanceof Expr)
2134
- return obj.format(options);
2135
- if (obj instanceof Quest)
2136
- return "Quest(" + obj.name + ")";
2137
- if (obj instanceof Quest.Case)
2138
- return "Quest.Case";
2139
- if (Array.isArray(obj))
2140
- return obj.map((item) => deepFormat(item, options));
2141
- if (typeof obj !== "object" || obj === null || obj.constructor !== Object)
2142
- return obj;
2143
- const out = {};
2144
- for (const key in obj)
2145
- out[key] = deepFormat(obj[key], options);
2146
- return out;
2222
+ function isStringPair(x) {
2223
+ return Array.isArray(x) && x.length === 2 && typeof x[0] === "string" && typeof x[1] === "string" ? void 0 : "must be a pair of strings";
2147
2224
  }
2148
- function declare(expr, env = {}) {
2149
- const res = toposort([expr], env);
2150
- return res.list.map((s) => {
2151
- if (s instanceof Alias)
2152
- return s.name + "=" + s.impl.format({ inventory: res.env });
2153
- if (s instanceof FreeVar)
2154
- return s.name + "=";
2155
- return s.format({ inventory: res.env });
2156
- }).join("; ");
2157
- }
2158
- var isStringPair = (x) => Array.isArray(x) && x.length === 2 && typeof x[0] === "string" && typeof x[1] === "string" ? void 0 : "must be a pair of strings";
2159
- var isStringTriple = (x) => Array.isArray(x) && x.length === 3 && typeof x[0] === "string" && typeof x[1] === "string" && typeof x[2] === "string" ? void 0 : "must be a triplet of strings";
2160
- var schema = {
2161
- html: (x) => typeof x === "boolean" ? void 0 : "must be a boolean",
2162
- terse: (x) => typeof x === "boolean" ? void 0 : "must be a boolean",
2163
- space: (x) => typeof x === "string" ? void 0 : "must be a string",
2164
- brackets: isStringPair,
2165
- var: isStringPair,
2166
- around: isStringPair,
2167
- redex: isStringPair,
2168
- lambda: isStringTriple,
2169
- inventory: (x) => {
2170
- if (typeof x !== "object" || x === null || x.constructor !== Object)
2171
- return "must be an object, not " + (x?.constructor?.name ?? typeof x);
2172
- const refined = x;
2173
- for (const key of Object.keys(refined)) {
2174
- if (!(refined[key] instanceof Expr))
2175
- return "key " + key + "is not an Expr";
2176
- }
2177
- return void 0;
2178
- }
2179
- };
2180
- function checkFormatOptions(raw) {
2181
- if (raw === null || raw === void 0)
2182
- return { value: {} };
2183
- if (typeof raw !== "object" || Array.isArray(raw) || raw.constructor !== Object)
2184
- return { error: { object: "Format options must be an object, not " + (raw?.constructor?.name ?? typeof raw) } };
2185
- const rec = raw;
2186
- const error = {};
2187
- for (const key in rec) {
2188
- if (schema[key]) {
2189
- const err = schema[key](rec[key]);
2190
- if (err)
2191
- error[key] = err;
2192
- } else
2193
- error[key] = "unknown option";
2194
- }
2195
- return Object.keys(error).length > 0 ? { error } : { value: rec };
2225
+ function isStringTriple(x) {
2226
+ return Array.isArray(x) && x.length === 3 && typeof x[0] === "string" && typeof x[1] === "string" && typeof x[2] === "string" ? void 0 : "must be a triplet of strings";
2196
2227
  }
2197
- var extras = { search, deepFormat, declare, toposort, checkFormatOptions };
2228
+ var extras = { search, deepFormat, declare, toposort, checkFormatOptions, equiv };
2198
2229
 
2199
2230
  // src/index.ts
2200
2231
  extras.toposort = toposort;