eyeprolog 1.1.19 → 1.1.21

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.
@@ -11,7 +11,7 @@ This report summarizes the file-based conformance corpus under `test/conformance
11
11
  | builtins | 11 | 0 | 0 | 0 | 11 |
12
12
  | context | 11 | 0 | 0 | 0 | 11 |
13
13
  | control | 15 | 0 | 0 | 0 | 15 |
14
- | iso | 164 | 209 | 0 | 0 | 373 |
14
+ | iso | 165 | 209 | 0 | 0 | 374 |
15
15
  | lists | 52 | 3 | 0 | 0 | 55 |
16
16
  | modules | 2 | 0 | 0 | 0 | 2 |
17
17
  | negation | 8 | 0 | 19 | 0 | 27 |
@@ -23,4 +23,4 @@ This report summarizes the file-based conformance corpus under `test/conformance
23
23
  | terms | 26 | 3 | 0 | 0 | 29 |
24
24
  | unification | 18 | 0 | 0 | 0 | 18 |
25
25
  | variables | 16 | 9 | 0 | 0 | 25 |
26
- | **Total** | **479** | **260** | **19** | **21** | **779** |
26
+ | **Total** | **480** | **260** | **19** | **21** | **780** |
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.1.19",
6
+ "version": "1.1.21",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/iso.js CHANGED
@@ -48,7 +48,7 @@ export const isoBuiltins = {
48
48
  registry.add('!', 0, succeed, { deterministic: true });
49
49
 
50
50
  registry.add('=', 2, unification, { deterministic: true });
51
- registry.add('unify_with_occurs_check', 2, unification, { deterministic: true });
51
+ registry.add('unify_with_occurs_check', 2, unificationWithOccursCheck, { deterministic: true });
52
52
  registry.add('\\=', 2, nonUnification, { deterministic: true });
53
53
  registry.add('subsumes_term', 2, subsumesTermBuiltin, { deterministic: true });
54
54
  registry.add('==', 2, identity, { deterministic: true });
@@ -178,6 +178,14 @@ function* unification({ goal, env }) {
178
178
  const next = env.clone();
179
179
  if (unify(goal.args[0], goal.args[1], next)) yield next;
180
180
  }
181
+ function* unificationWithOccursCheck({ goal, env }) {
182
+ const next = env.clone();
183
+ // ISO unify_with_occurs_check/2 always performs finite-tree unification.
184
+ // The implementation-specific occurs_check=error mode applies to normal
185
+ // unification, but must not turn this ISO predicate's ordinary failure into
186
+ // an exception.
187
+ if (unify(goal.args[0], goal.args[1], next, { occursCheck: 'fail' })) yield next;
188
+ }
181
189
  function* nonUnification({ goal, env }) {
182
190
  if (!unify(goal.args[0], goal.args[1], env.clone())) yield env;
183
191
  }
@@ -1776,6 +1784,7 @@ function* phraseBuiltin({ solver, goal, env }) {
1776
1784
  }
1777
1785
  }
1778
1786
  function formalErrorTerm(error) {
1787
+ if (error.formalTerm != null) return compound('error', [error.formalTerm, atom('eyeprolog')]);
1779
1788
  const parse = (text) => {
1780
1789
  const open = text.indexOf('(');
1781
1790
  if (open === -1) return atom(text);
@@ -1814,7 +1823,7 @@ function* catchBuiltin({ solver, goal, env }) {
1814
1823
  const ball = error instanceof ThrownTerm
1815
1824
  ? error.term
1816
1825
  : error instanceof PrologError
1817
- ? formalErrorTerm(error)
1826
+ ? freshCopy(formalErrorTerm(error), new Env())
1818
1827
  : null;
1819
1828
  if (ball == null) throw error;
1820
1829
  const recovered = env.clone();
@@ -1827,7 +1836,9 @@ function* catchBuiltin({ solver, goal, env }) {
1827
1836
  function* throwBuiltin({ goal, env }) {
1828
1837
  const ball = deref(goal.args[0], env);
1829
1838
  if (ball.type === VAR) throw new PrologError('instantiation_error');
1830
- throw new ThrownTerm(copyResolved(ball, env));
1839
+ // ISO throw/1 copies the thrown term before control unwinds. Freshen
1840
+ // variables so the catcher cannot retain aliases to the protected goal.
1841
+ throw new ThrownTerm(freshCopy(ball, env));
1831
1842
  }
1832
1843
  function* onceBuiltin({ solver, goal, env }) {
1833
1844
  const child = solver.cloneForInnerGoal(1);
package/src/repl.js CHANGED
@@ -57,7 +57,7 @@ export async function runRepl(engine, options = {}) {
57
57
  exitCode = error.code;
58
58
  break;
59
59
  }
60
- output.write(` ${formatError(error)}\n`);
60
+ output.write(` ${formatError(engine, state, error)}\n`);
61
61
  }
62
62
  }
63
63
  } catch (error) {
@@ -368,13 +368,15 @@ function queryVariables(goal) {
368
368
 
369
369
  function formatAnswer(engine, state, variables, env) {
370
370
  const bindings = [];
371
+ const queryVariableNames = new Set(variables.map((variable) => variable.name));
371
372
  const names = new Map(variables.map((variable) => [variable.name, variable.name]));
372
373
  let generated = 0;
373
374
 
374
375
  for (const variable of variables) collectUnboundVariables(engine, variable, env, names, () => `_${letterName(generated++)}`);
375
376
  for (const variable of variables) {
376
377
  const value = engine.deref(variable, env);
377
- if (value.type === 'var' && value.name === variable.name) continue;
378
+ if (value.type === 'var' &&
379
+ (value.name === variable.name || !queryVariableNames.has(value.name))) continue;
378
380
  bindings.push(`${variable.name} = ${engine.formatTermForWrite(value, env, {
379
381
  quoted: true,
380
382
  operators: [...state.program.operators.values()],
@@ -406,7 +408,30 @@ function letterName(index) {
406
408
  return suffix === 0 ? letter : `${letter}${suffix}`;
407
409
  }
408
410
 
409
- function formatError(error) {
411
+ function formatError(engine, state, error) {
412
+ if (error?.name === 'PrologError') {
413
+ const env = new engine.Env();
414
+ const variableNames = new Map();
415
+ let generated = 0;
416
+ if (error.formalTerm != null) {
417
+ collectUnboundVariables(engine, error.formalTerm, env, variableNames, () => `_${letterName(generated++)}`);
418
+ const formal = engine.formatTermForWrite(error.formalTerm, env, {
419
+ quoted: true,
420
+ operators: [...state.program.operators.values()],
421
+ variableNames,
422
+ });
423
+ return `error(${formal}).`;
424
+ }
425
+ if (error.culprit != null) {
426
+ collectUnboundVariables(engine, error.culprit, env, variableNames, () => `_${letterName(generated++)}`);
427
+ }
428
+ const culprit = error.culprit == null ? '' : `, ${engine.formatTermForWrite(error.culprit, env, {
429
+ quoted: true,
430
+ operators: [...state.program.operators.values()],
431
+ variableNames,
432
+ })}`;
433
+ return `error(${error.formal}${culprit}).`;
434
+ }
410
435
  const message = error?.message ?? String(error);
411
436
  return message.endsWith('.') ? message : `${message}.`;
412
437
  }
package/src/solver.js CHANGED
@@ -23,6 +23,12 @@ export function nextFreshId() {
23
23
  return ++freshCounter;
24
24
  }
25
25
 
26
+ function raiseOccursCheckError(left, right, env) {
27
+ const error = new PrologError('occurs_check');
28
+ error.formalTerm = copyResolved(compound('occurs_check', [left, right]), env);
29
+ throw error;
30
+ }
31
+
26
32
  export class Solver {
27
33
  constructor(program, options = {}) {
28
34
  this.registry = options.registry ?? getEyePrologRegistry();
@@ -37,6 +43,11 @@ export class Solver {
37
43
  this.solutionLimit = options.solutionLimit ?? 10000000;
38
44
  this.solutionsSeen = 0;
39
45
  this.prologFlags = options.prologFlags ?? defaultPrologFlags(this.registry?.eyePrologLibrary ? 'fail' : 'error');
46
+ this.occursCheckHandler = (left, right, env) => {
47
+ if (this.prologFlags.get('occurs_check')?.value?.name === 'error') {
48
+ raiseOccursCheckError(left, right, env);
49
+ }
50
+ };
40
51
  this.charConversions = options.charConversions ?? new Map();
41
52
  if (!options.prologFlags) {
42
53
  if (['chars', 'codes', 'atom'].includes(program.doubleQuotes)) {
@@ -140,6 +151,7 @@ export class Solver {
140
151
 
141
152
  *solve(goals, env = new Env(), depth = 0) {
142
153
  if (!Array.isArray(goals)) goals = [goals];
154
+ env.setOccursCheckHandler(this.occursCheckHandler);
143
155
 
144
156
  const savedActive = this.active;
145
157
  let registeredStack = null;
@@ -194,6 +206,7 @@ export class Solver {
194
206
 
195
207
  goals = frame.goals;
196
208
  env = frame.env;
209
+ env.setOccursCheckHandler(this.occursCheckHandler);
197
210
  depth = frame.depth;
198
211
  let active = frame.active;
199
212
 
@@ -474,6 +487,7 @@ function defaultPrologFlags(unknown = 'error') {
474
487
  ['max_arity', { value: compound('unbounded', []), allowed: ['unbounded'], changeable: false }],
475
488
  ['unknown', { value: compound(unknown, []), allowed: ['error', 'fail', 'warning'], changeable: true }],
476
489
  ['double_quotes', { value: compound('chars', []), allowed: ['chars', 'codes', 'atom'], changeable: true }],
490
+ ['occurs_check', { value: compound('true', []), allowed: ['true', 'error'], changeable: true }],
477
491
  ]);
478
492
  }
479
493
 
package/src/term.js CHANGED
@@ -47,6 +47,7 @@ export class Env {
47
47
  };
48
48
  this._delays = null;
49
49
  this._clpz = null;
50
+ this._occursCheckHandler = null;
50
51
  }
51
52
  clone() {
52
53
  // Most speculative environments are either rejected without a binding or
@@ -58,8 +59,13 @@ export class Env {
58
59
  clone._state = this._state;
59
60
  clone._delays = this._delays;
60
61
  clone._clpz = this._clpz;
62
+ clone._occursCheckHandler = this._occursCheckHandler;
61
63
  return clone;
62
64
  }
65
+ setOccursCheckHandler(handler) {
66
+ this._occursCheckHandler = typeof handler === 'function' ? handler : null;
67
+ return this;
68
+ }
63
69
  has(name) {
64
70
  return this.get(name) !== undefined;
65
71
  }
@@ -207,11 +213,12 @@ function occurs(variableName, term, env) {
207
213
  return false;
208
214
  }
209
215
 
210
- export function unify(left, right, env) {
216
+ export function unify(left, right, env, options = {}) {
211
217
  // Iterative unification avoids deep JavaScript recursion on long lists or
212
218
  // deeply nested compounds. The occurs check gives EyeProlog finite-tree
213
219
  // unification: a variable cannot be bound to a term containing itself.
214
220
  // Bindings are written into the supplied Env.
221
+ const occursCheckHandler = options.occursCheck === 'fail' ? null : env?._occursCheckHandler;
215
222
  const stack = [[left, right]];
216
223
  while (stack.length) {
217
224
  let [a, b] = stack.pop();
@@ -226,12 +233,18 @@ export function unify(left, right, env) {
226
233
  continue;
227
234
  }
228
235
  if (a.type === VAR) {
229
- if (occurs(a.name, b, env)) return false;
236
+ if (occurs(a.name, b, env)) {
237
+ occursCheckHandler?.(a, b, env);
238
+ return false;
239
+ }
230
240
  env.bind(a.name, b);
231
241
  continue;
232
242
  }
233
243
  if (b.type === VAR) {
234
- if (occurs(b.name, a, env)) return false;
244
+ if (occurs(b.name, a, env)) {
245
+ occursCheckHandler?.(b, a, env);
246
+ return false;
247
+ }
235
248
  env.bind(b.name, a);
236
249
  continue;
237
250
  }
@@ -9,7 +9,7 @@ case totals.
9
9
  | --- | --- | --- |
10
10
  | Clause 6 lexical and term syntax | tokenizer, operator parser, lists, curly terms, quotes, numeric syntax, comments | `scryer_lexical_terms`, `lexical_and_curly_terms`, `double_quoted_lists`, `corrigendum1_double_quote_operator`, syntax error cases |
11
11
  | Clause 7 term order and unification | finite-tree unification, identity, standard order, errors | `unification_control_information`, `swipl_occurs_check`, `term_modes_and_ordering`, `logtalk_compare_standard_order` |
12
- | Clause 7 control and exceptions | call, cut, conjunction, disjunction, if-then-else, catch and throw | `cut_control`, `control_and_terms`, `exceptions_and_flags`, `corrigenda_catch_callability` |
12
+ | Clause 7 control and exceptions | call, cut, conjunction, disjunction, if-then-else, catch and throw | `cut_control`, `control_and_terms`, `exceptions_and_flags`, `corrigenda_catch_callability`, `throw_copies_ball` |
13
13
  | 8.2-8.5 term predicates | unification, Corrigendum 2 tests, comparison, sorting, creation and decomposition | `corrigenda_term_predicates`, `corrigenda_sort_keysort`, `logtalk_arg_unification`, `logtalk_univ`, associated error cases |
14
14
  | 8.6-8.7 arithmetic predicates | `is/2` and all six arithmetic comparisons | `arithmetic`, comparison cases, isolated evaluation errors |
15
15
  | 8.8-8.10 database and solutions | logical update view, dynamic mutation, all-solutions grouping | `dynamic_database`, `trealla_logical_update_view`, `corrigenda_retractall`, `grouped_solutions_and_clauses` |
@@ -94,7 +94,7 @@ Selected cases are adapted from the ISO and standard-core suites of Logtalk,
94
94
  Scryer Prolog, Trealla Prolog, and SWI-Prolog. Their upstream identifiers and licenses
95
95
  are recorded in [THIRD_PARTY.md](THIRD_PARTY.md).
96
96
 
97
- The corpus has 373 cases in `iso/` and 779 file-based conformance cases in
97
+ The corpus has 374 cases in `iso/` and 780 file-based conformance cases in
98
98
  total. The generated `conformance-report.md` is the authoritative source for
99
99
  current category totals. Together with regression, documentation-sync, API,
100
100
  example, and book-example checks, `npm test` is the release gate.
@@ -0,0 +1,17 @@
1
+ % ISO 7.12.1: throw/1 copies the ball before searching for a catcher.
2
+ %% goal: user_throw_copy
3
+ %% goal: error_throw_copy
4
+
5
+ user_throw_copy :-
6
+ catch(throw(g(X)), g(V), true),
7
+ X = protected,
8
+ V = caught.
9
+
10
+ error_throw_copy :-
11
+ catch(
12
+ number_chars(_, [1, [], X|2]),
13
+ error(type_error(list, Culprit), _),
14
+ true
15
+ ),
16
+ X = protected,
17
+ Culprit = [1, [], caught|2].
@@ -1,3 +1,3 @@
1
1
  answer(7, red, caught(eyeprolog), first, caught(eyeprolog)).
2
2
  answer(7, red, caught(eyeprolog), second, caught(eyeprolog)).
3
- flags(off, on, [pair(bounded, false), pair(integer_rounding_function, toward_zero), pair(char_conversion, on), pair(debug, off), pair(max_integer, unbounded), pair(min_integer, unbounded), pair(max_arity, unbounded), pair(unknown, fail), pair(double_quotes, chars)]).
3
+ flags(off, on, [pair(bounded, false), pair(integer_rounding_function, toward_zero), pair(char_conversion, on), pair(debug, off), pair(max_integer, unbounded), pair(min_integer, unbounded), pair(max_arity, unbounded), pair(unknown, fail), pair(double_quotes, chars), pair(occurs_check, true)]).
@@ -0,0 +1,2 @@
1
+ user_throw_copy.
2
+ error_throw_copy.
@@ -496,6 +496,66 @@ c4 ?- call((!;1)).
496
496
  assertEqual(result.stderr, '', 'stderr');
497
497
  },
498
498
  },
499
+ {
500
+ name: 'REPL hides aliases to fresh throw variables while preserving query aliases',
501
+ run: () => {
502
+ const result = runCli([], {
503
+ input: 'catch(throw(g(X)),g(V),true).\nX = Y.\nhalt.\n',
504
+ });
505
+ assertEqual(result.status, 0, 'exit status');
506
+ assertEqual(result.stdout, '?- true.\n?- X = Y.\n?- ', 'stdout');
507
+ assertEqual(result.stderr, '', 'stderr');
508
+ },
509
+ },
510
+ {
511
+ name: 'REPL freshens variables displayed in uncaught ISO errors',
512
+ run: () => {
513
+ const result = runCli([], {
514
+ input: 'number_chars(V,[1,[],X|2]).\nnumber_chars(V,[1,[],Xx|2]).\nhalt.\n',
515
+ });
516
+ assertEqual(result.status, 0, 'exit status');
517
+ assertEqual(result.stdout,
518
+ '?- error(type_error(list), [1, [], _A | 2]).\n' +
519
+ '?- error(type_error(list), [1, [], _A | 2]).\n' +
520
+ '?- ',
521
+ 'stdout');
522
+ assertEqual(result.stderr, '', 'stderr');
523
+ },
524
+ },
525
+ {
526
+ name: 'occurs_check error mode detects STO while ISO occurs-check unification still fails',
527
+ run: () => {
528
+ const filename = path.join(tmp, `occurs-check-${++tmpCounter}.pl`);
529
+ fs.writeFileSync(filename, ':- set_prolog_flag(occurs_check, error).\nsame(X, X).\n');
530
+ const result = runCli([], {
531
+ input:
532
+ 'current_prolog_flag(occurs_check, Mode).\n' +
533
+ 'set_prolog_flag(occurs_check, error).\n' +
534
+ 'X = f(X).\n' +
535
+ 'catch((Y = g(Y)), E, true).\n' +
536
+ 'unify_with_occurs_check(Z, h(Z)).\n' +
537
+ `[${sourceAtom(filename)}].\n` +
538
+ 'same(W, k(W)).\n' +
539
+ 'set_prolog_flag(occurs_check, true).\n' +
540
+ 'Q = q(Q).\n' +
541
+ 'halt.\n',
542
+ });
543
+ assertEqual(result.status, 0, 'exit status');
544
+ assertEqual(result.stdout,
545
+ '?- Mode = true.\n' +
546
+ '?- true.\n' +
547
+ '?- error(occurs_check(_A, f(_A))).\n' +
548
+ '?- E = error(occurs_check(_A, g(_A)), eyeprolog).\n' +
549
+ '?- false.\n' +
550
+ '?- true.\n' +
551
+ '?- error(occurs_check(_A, k(_A))).\n' +
552
+ '?- true.\n' +
553
+ '?- false.\n' +
554
+ '?- ',
555
+ 'stdout');
556
+ assertEqual(result.stderr, '', 'stderr');
557
+ },
558
+ },
499
559
  {
500
560
  name: 'REPL enumerates and stops answers like the Scryer top level',
501
561
  run: () => {
@@ -780,6 +780,27 @@ fails rather than constructing a cyclic term:
780
780
  (X = wrapper(X)).
781
781
  ```
782
782
 
783
+ ISO classifies unifications whose outcome depends on an occurs check as
784
+ subject-to-occurs-check (STO). EyeProlog's default remains the sound finite-tree
785
+ behavior above. For diagnosis, EyeProlog additionally provides the
786
+ implementation-specific flag `occurs_check`; setting it to `error` turns a
787
+ normal unification that would otherwise fail because of the occurs check into
788
+ an exception:
789
+
790
+ ```eyeprolog
791
+ :- set_prolog_flag(occurs_check, error).
792
+
793
+ sto_example :- X = wrapper(X).
794
+ % error(occurs_check(_A, wrapper(_A)))
795
+ ```
796
+
797
+ The supported values are `true` (the default finite-tree behavior) and `error`
798
+ (STO detection). EyeProlog deliberately does not provide `occurs_check=false`,
799
+ because its term model does not construct cyclic terms. The ISO predicate
800
+ `unify_with_occurs_check/2` is independent of the diagnostic flag: it continues
801
+ to perform finite-tree unification and fails on `unify_with_occurs_check(X,
802
+ wrapper(X))` even when `occurs_check` is `error`.
803
+
783
804
  ### Meaning is not the search strategy
784
805
 
785
806
  EyeProlog's evaluator is goal-directed. It resolves selected goals against facts,
@@ -5610,11 +5631,14 @@ silently changing a static program.
5610
5631
  | `max_arity` | `unbounded` | `unbounded` | no |
5611
5632
  | `unknown` | `fail` | `error`, `fail`, `warning` | yes |
5612
5633
  | `double_quotes` | `chars` | `chars`, `codes`, `atom` | yes |
5634
+ | `occurs_check` | `true` | `true`, `error` | yes |
5613
5635
 
5614
5636
  The isolated ISO-only registry defaults `unknown` to `error`; the normal
5615
- EyeProlog environment defaults it to `fail`. Operator and flag
5616
- directives are processed per program rather than changing global JavaScript
5617
- state. The `double_quotes` setting affects subsequent source text, included
5637
+ EyeProlog environment defaults it to `fail`. The `occurs_check` flag is an
5638
+ EyeProlog diagnostic extension rather than an ISO-defined core flag; its
5639
+ `true` default preserves the engine's finite-tree unification, while `error`
5640
+ reports STO attempts. Operator and flag directives are processed per program
5641
+ rather than changing global JavaScript state. The `double_quotes` setting affects subsequent source text, included
5618
5642
  files, command-line and API goal text, and terms read by `read_term/*`:
5619
5643
 
5620
5644
  ```text
@@ -5749,8 +5773,14 @@ instances whose message contains the corresponding Prolog error term.
5749
5773
  | *syntax_error(number)*, *syntax_error(read_term)* | Lexical number conversion or streamed term parsing failed. |
5750
5774
 
5751
5775
  `catch/3` converts a `PrologError` into a catchable
5752
- `error(Formal,eyeprolog)` term. A user ball from `throw/1` is delivered as-is.
5753
- An unmatched ball or error continues outward.
5776
+ `error(Formal,eyeprolog)` term. `throw/1` copies its ball before unwinding: bound
5777
+ parts are preserved, repeated variables remain shared within the copied ball,
5778
+ and unbound variables are fresh with respect to the protected goal and catcher.
5779
+ Catchable error terms follow the same variable-freshening rule. At the interactive
5780
+ top level, variables that occur in an uncaught ISO error are rendered with fresh
5781
+ answer names such as `_A` rather than reusing query-variable spellings such as
5782
+ `X` or `Xx`; this keeps the displayed error consistent with the copied exception
5783
+ term. An unmatched ball or error continues outward.
5754
5784
 
5755
5785
  Streams belong to one solver run and are shared by nested calls, exceptions,
5756
5786
  and solution collectors. `user_input` and `user_output` are always present.
@@ -6815,7 +6845,7 @@ node test/run-conformance-report.mjs
6815
6845
  ```
6816
6846
 
6817
6847
  The complete suite must pass before release. The file-based conformance corpus
6818
- contains 779 cases, including 373 focused ISO
6848
+ contains 780 cases, including 374 focused ISO
6819
6849
  cases derived from the success, failure, mode, and error behavior in
6820
6850
  ISO/IEC 13211-1 clauses 7 and 8, Part 2 modules, and Part 3 grammar rules.
6821
6851
  Separate exact-output suites check 189 normal