eyeprolog 1.2.34 → 1.2.36

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.2.34",
6
+ "version": "1.2.36",
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
@@ -9,7 +9,9 @@ import { sameNumberValue } from './number-value.js';
9
9
  import { createParserOperatorState, parseClauses, parseGoalText, parseNumberTokenText } from './parser.js';
10
10
  import { formatTermForWrite } from './write.js';
11
11
  import { emptyTerminalSequence, expandDcgBody, isListOrPartialList, validateDcgEmbeddedGoals } from './dcg.js';
12
- import { characterCodeConstantEnd, quotedEscapeEnd } from './syntax-scan.js';
12
+ import {
13
+ characterCodeConstantEnd, continuesGraphicToken, isTerminatingFullStop, quotedEscapeEnd,
14
+ } from './syntax-scan.js';
13
15
 
14
16
  let isoFresh = 0;
15
17
 
@@ -1072,20 +1074,6 @@ function* nlBuiltin({ solver, goal, env }) {
1072
1074
  yield env;
1073
1075
  }
1074
1076
 
1075
- function isTerminatingFullStop(source, index) {
1076
- const previous = source[index - 1] ?? '';
1077
- const next = source[index + 1] ?? '';
1078
- if (previous === '.' || next === '.') return false;
1079
- if (/\d/.test(previous) && /\d/.test(next)) return false;
1080
- if (/[A-Za-z0-9_]/.test(previous) && /[A-Za-z0-9_]/.test(next)) return false;
1081
- return true;
1082
- }
1083
-
1084
- const termGraphicCharacters = new Set('#$&*+-./<=>?@^~\\:');
1085
- function continuesGraphicToken(source, index) {
1086
- return index > 0 && termGraphicCharacters.has(source[index - 1]);
1087
- }
1088
-
1089
1077
  function* termTextCandidates(stream) {
1090
1078
  const source = String(stream.content);
1091
1079
  let quote = null, lineComment = false, blockComment = false;
@@ -2029,35 +2017,39 @@ function* negationBuiltin({ solver, goal, env }) {
2029
2017
  for (const _ of solver.cloneForInnerGoal(1).solve([callable(goal.args[0], env)], env.clone(), 0)) return;
2030
2018
  yield env;
2031
2019
  }
2020
+ function* solveControlBranch(solver, goal, env) {
2021
+ for (const answer of solver.solve([callable(goal, env)], env, 0)) {
2022
+ // A branch answer is internal to its enclosing control construct. The
2023
+ // surrounding solve will count the completed control goal after the
2024
+ // builtin yields it. Leaving both counts in place makes a bounded search
2025
+ // such as once/1 or negation stop before it can observe the branch answer.
2026
+ if (solver.solutionsSeen > 0) solver.solutionsSeen--;
2027
+ yield answer;
2028
+ }
2029
+ }
2032
2030
  function* disjunctionBuiltin({ solver, goal, env }) {
2033
2031
  const left = deref(goal.args[0], env);
2034
2032
  if (left.type === COMPOUND && left.name === '->' && left.arity === 2) {
2035
2033
  for (const conditionEnv of solver.cloneForInnerGoal(1).solve([callable(left.args[0], env)], env.clone(), 0)) {
2036
- yield* solver.solve([callable(left.args[1], conditionEnv)], conditionEnv, 0);
2034
+ yield* solveControlBranch(solver, left.args[1], conditionEnv);
2037
2035
  return;
2038
2036
  }
2039
- yield* solver.solve([callable(goal.args[1], env)], env.clone(), 0);
2037
+ yield* solveControlBranch(solver, goal.args[1], env.clone());
2040
2038
  return;
2041
2039
  }
2042
2040
  const marker = solver.active[solver.active.length - 1] ?? null;
2043
2041
  const markerCutEpoch = marker?.cutEpoch ?? 0;
2044
2042
  const solverCutEpoch = solver.cutEpoch;
2045
- yield* solver.solve([callable(goal.args[0], env)], env.clone(), 0);
2043
+ yield* solveControlBranch(solver, goal.args[0], env.clone());
2046
2044
  const cutThisScope = marker == null
2047
2045
  ? solver.cutEpoch !== solverCutEpoch
2048
2046
  : (marker.cutEpoch ?? 0) !== markerCutEpoch;
2049
2047
  if (cutThisScope) return;
2050
- yield* solver.solve([callable(goal.args[1], env)], env.clone(), 0);
2048
+ yield* solveControlBranch(solver, goal.args[1], env.clone());
2051
2049
  }
2052
2050
  function* ifThenBuiltin({ solver, goal, env }) {
2053
2051
  for (const conditionEnv of solver.cloneForInnerGoal(1).solve([callable(goal.args[0], env)], env.clone(), 0)) {
2054
- for (const consequentEnv of solver.solve([callable(goal.args[1], conditionEnv)], conditionEnv, 0)) {
2055
- // The consequent is an internal part of the current solution, not a
2056
- // completed top-level solution. Keep a surrounding bounded search (for
2057
- // example nested ISO once-as-if-then) from consuming its limit early.
2058
- if (solver.solutionsSeen > 0) solver.solutionsSeen--;
2059
- yield consequentEnv;
2060
- }
2052
+ yield* solveControlBranch(solver, goal.args[1], conditionEnv);
2061
2053
  return;
2062
2054
  }
2063
2055
  }
package/src/parser.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Tokenizer and recursive-descent parser for the EyeProlog source language.
2
2
  // It preserves the compact Prolog-like syntax while producing Term objects for the solver.
3
3
  import { ATOM, COMPOUND, atom, compound, cons, emptyList, numberTerm, variable } from './term.js';
4
+ import { isTerminatingFullStop } from './syntax-scan.js';
4
5
 
5
6
  const TOK = {
6
7
  EOF: 'eof', ATOM: 'atom', VAR: 'var', STRING: 'string', NUMBER: 'number',
@@ -369,12 +370,11 @@ class Parser {
369
370
  this.pos += 2;
370
371
  return { type: TOK.ATOM, text: '?-', line };
371
372
  }
372
- if (ch === '.' && this.peek(1) &&
373
- !isWhitespaceCode(this.peek(1).charCodeAt(0)) &&
374
- this.peek(1) !== '%' && !(this.peek(1) === '/' && this.peek(2) === '*')) {
373
+ if (ch === '.' && !isTerminatingFullStop(this.source, this.pos)) {
375
374
  const start = this.pos;
376
375
  this.take();
377
- while (isGraphicAtomCode(this.peek().charCodeAt(0))) this.take();
376
+ while (isGraphicAtomCode(this.peek().charCodeAt(0)) &&
377
+ !isTerminatingFullStop(this.source, this.pos)) this.take();
378
378
  return { type: TOK.ATOM, text: this.source.slice(start, this.pos), line };
379
379
  }
380
380
  if (ch === '!') {
@@ -543,7 +543,8 @@ class Parser {
543
543
  if (isGraphicAtomCode(ch.charCodeAt(0))) {
544
544
  const start = this.pos;
545
545
  this.take();
546
- while (isGraphicAtomCode(this.peek().charCodeAt(0))) this.take();
546
+ while (isGraphicAtomCode(this.peek().charCodeAt(0)) &&
547
+ !isTerminatingFullStop(this.source, this.pos)) this.take();
547
548
  return { type: TOK.ATOM, text: this.source.slice(start, this.pos), line };
548
549
  }
549
550
 
package/src/repl.js CHANGED
@@ -4,7 +4,9 @@ import { readSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { createInterface } from 'node:readline';
6
6
  import { formalErrorTerm } from './iso.js';
7
- import { characterCodeConstantEnd, quotedEscapeEnd } from './syntax-scan.js';
7
+ import {
8
+ characterCodeConstantEnd, continuesGraphicToken, isTerminatingFullStop, quotedEscapeEnd,
9
+ } from './syntax-scan.js';
8
10
 
9
11
  const ANSWER_HELP = `
10
12
  SPACE, "n" or ";": next solution, if any
@@ -389,7 +391,7 @@ function terminalFullStop(source) {
389
391
  lineComment = true;
390
392
  continue;
391
393
  }
392
- if (ch === '/' && next === '*') {
394
+ if (ch === '/' && next === '*' && !continuesGraphicToken(source, i)) {
393
395
  blockComment = true;
394
396
  i++;
395
397
  continue;
@@ -400,7 +402,8 @@ function terminalFullStop(source) {
400
402
  }
401
403
  if ('([{'.includes(ch)) depth++;
402
404
  else if (')]}'.includes(ch)) depth = Math.max(0, depth - 1);
403
- else if (ch === '.' && depth === 0 && onlyLayoutAndComments(source.slice(i + 1))) return i;
405
+ else if (ch === '.' && depth === 0 && isTerminatingFullStop(source, i) &&
406
+ onlyLayoutAndComments(source.slice(i + 1))) return i;
404
407
  }
405
408
  return -1;
406
409
  }
@@ -2,6 +2,26 @@
2
2
  // readers. These locate token boundaries only; parser.js remains responsible
3
3
  // for accepting or rejecting the token itself.
4
4
 
5
+ const graphicTokenCharacters = new Set('#$&*+-./<=>?@^~\\:');
6
+
7
+ export function continuesGraphicToken(source, index) {
8
+ return index > 0 && graphicTokenCharacters.has(source[index - 1]);
9
+ }
10
+
11
+ export function isTerminatingFullStop(source, index) {
12
+ if (source[index] !== '.') return false;
13
+ const next = source[index + 1] ?? '';
14
+ // At a line boundary, after a single-line comment marker, or at end of
15
+ // input, the dot is the read-term end char. Before horizontal layout it is
16
+ // instead part of an already-started graphic token: `./*. .` is the atom
17
+ // `./*.` followed by its separate end char. A directly following /* also
18
+ // stays in the current graphic token; bracketed comments are recognized
19
+ // only when /* begins a token.
20
+ if (next === '' || next === '%' || next === '\n' || next === '\r') return true;
21
+ if (/^[\u0009\u000b\u000c\u0020]$/.test(next)) return !continuesGraphicToken(source, index);
22
+ return false;
23
+ }
24
+
5
25
  export function quotedEscapeEnd(source, index) {
6
26
  const escaped = source[index + 1] ?? '';
7
27
  if (!escaped) return index;
@@ -5,7 +5,7 @@ text_roundtrip(Term, Peek, Code, Mode, Alias) :-
5
5
  open('/tmp/eyeprolog-iso-text.txt', write, Output, [alias(iso_text_output), type(text)]),
6
6
  writeq(iso_text_output, sample(42)),
7
7
  put_char(iso_text_output, '.'),
8
- put_char(iso_text_output, 'Z'),
8
+ put_char(iso_text_output, ' '),
9
9
  close(Output),
10
10
  open('/tmp/eyeprolog-iso-text.txt', read, Input, [alias(iso_text_input), eof_action(eof_code)]),
11
11
  stream_property(Input, mode(Mode)),
@@ -1,4 +1,4 @@
1
- text_roundtrip(sample(42), 'Z', 90, read, iso_text_input).
1
+ text_roundtrip(sample(42), ' ', 32, read, iso_text_input).
2
2
  binary_roundtrip(65, 65, -1).
3
3
  read_term_metadata(ok).
4
4
  default_streams(ok).
@@ -1 +1 @@
1
- parse line 1: expected ., got ..
1
+ parse line 1: expected ., got .
@@ -477,6 +477,32 @@ c4 ?- call((!;1)).
477
477
  assertEqual(result.stdout, 'quads: 22 run, 22 passed, 0 failed.\n', 'quad report');
478
478
  },
479
479
  },
480
+ {
481
+ name: 'negation observes disjunction through direct and call/1 execution',
482
+ run: () => {
483
+ const reported = publicApi.runQuads(String.raw`?- \+ (true ; true).
484
+ false.
485
+
486
+ ?- call(\+ (true ; true)).
487
+ false.
488
+ `);
489
+ assertEqual(reported.total, 2, 'reported query count');
490
+ assertEqual(reported.passed, 2, 'reported queries pass');
491
+
492
+ const program = Program.parse('');
493
+ const answerCount = (text) => {
494
+ const solver = new Solver(program, { registry: getEyePrologRegistry() });
495
+ return [...solver.solve([parseGoalText(text)], new Env(), 0)].length;
496
+ };
497
+
498
+ assertEqual(answerCount(String.raw`\+ (true ; true)`), 0, 'direct successful disjunction is negated');
499
+ assertEqual(answerCount(String.raw`call(\+ (true ; true))`), 0, 'called negation fails');
500
+ assertEqual(answerCount(String.raw`\+ (true ; fail)`), 0, 'successful left branch is observed');
501
+ assertEqual(answerCount(String.raw`\+ (fail ; true)`), 0, 'successful right branch is observed');
502
+ assertEqual(answerCount(String.raw`\+ (fail ; fail)`), 1, 'failed disjunction is negated');
503
+ assertEqual(answerCount('once((true ; true))'), 1, 'once keeps the first disjunction answer');
504
+ },
505
+ },
480
506
  {
481
507
  name: 'runQuads passes the complete vendored ISO phrase quad corpus',
482
508
  run: () => {
@@ -707,6 +733,43 @@ c4 ?- call((!;1)).
707
733
  assertIncludes(upstreamResult.stdout, '46 true.', 'WG17 #367 output');
708
734
  },
709
735
  },
736
+ {
737
+ name: 'readers distinguish graphic tokens, comments, and full stops (issue #41)',
738
+ run: () => {
739
+ const parsed = parseProgramText('./*.');
740
+ assertEqual(parsed.length, 1, 'graphic atom clause count');
741
+ assertEqual(parsed[0].head.name, './*', 'comment opener stays inside graphic atom');
742
+
743
+ const read = runEyeProlog('', {
744
+ goal: 'read(T)',
745
+ ioOptions: { input: './*.' },
746
+ });
747
+ assertEqual(read.stdout, "read('./*').\n", 'graphic atom writeq readback');
748
+
749
+ const consecutive = runEyeProlog('answer(A, B) :- read(A), read(B).\n', {
750
+ goal: 'answer(A, B)',
751
+ ioOptions: { input: './*. .\nok.\n' },
752
+ });
753
+ assertEqual(consecutive.stdout, "answer('./*.', ok).\n", 'following read starts after the complete term');
754
+
755
+ let error = null;
756
+ try {
757
+ runEyeProlog('', { goal: 'read(T)', ioOptions: { input: '!.!.' } });
758
+ } catch (caught) {
759
+ error = caught;
760
+ }
761
+ assertEqual(error?.message, 'error(syntax_error(read_term))', 'solo-token sequence rejection');
762
+
763
+ const repl = runCli([], {
764
+ input: 'read(T).\n./*. .\nread(T).\nok.\nread(T).\n!.!.\nhalt.\n',
765
+ });
766
+ assertEqual(repl.status, 0, 'REPL exit status');
767
+ assertIncludes(repl.stdout, "T = './*.'.", 'REPL dotted graphic atom answer');
768
+ assertIncludes(repl.stdout, 'T = ok.', 'REPL following read answer');
769
+ assertIncludes(repl.stdout, 'error(syntax_error(read_term), eyeprolog)', 'REPL syntax error');
770
+ assertEqual(repl.stderr, '', 'REPL stderr');
771
+ },
772
+ },
710
773
  {
711
774
  name: 'question mark is a graphic character and writeq keeps graphic atoms unquoted',
712
775
  run: () => {
@@ -2755,20 +2818,22 @@ open(X) :- candidate(X), \\+ closed(X).
2755
2818
  const goal = parseGoalText('trial(Chars)');
2756
2819
  let count = 0;
2757
2820
  for (const _ of solver.solve([goal], new Env(), 0)) {
2758
- if (++count === 500000) break;
2821
+ if (++count === 50000) break;
2759
2822
  }
2760
- if (count !== 500000) throw new Error('unexpected answer count: ' + count);
2823
+ if (count !== 50000) throw new Error('unexpected answer count: ' + count);
2761
2824
  process.stdout.write(String(count));
2762
2825
  `;
2763
2826
  const result = spawnSync(process.execPath, [
2764
- '--max-old-space-size=64',
2827
+ // A smaller heap preserves the original false-exhaustion signal
2828
+ // without requiring half a million transient conversion attempts.
2829
+ '--max-old-space-size=32',
2765
2830
  '--input-type=module',
2766
2831
  '--eval',
2767
2832
  script,
2768
- ], { cwd: packageRoot, encoding: 'utf8', timeout: 45000 });
2833
+ ], { cwd: packageRoot, encoding: 'utf8', timeout: 10000 });
2769
2834
  if (result.error) throw result.error;
2770
2835
  assertEqual(result.status, 0, `bounded-heap child status; stderr=${result.stderr}`);
2771
- assertEqual(result.stdout, '500000', 'distinct number syntax attempts');
2836
+ assertEqual(result.stdout, '50000', 'distinct number syntax attempts');
2772
2837
  },
2773
2838
  },
2774
2839
  {
@@ -1844,7 +1844,10 @@ normalized at the solver boundary instead of leaking a JavaScript `RangeError`.
1844
1844
  ISO 13211-1 leaves the resource atom implementation dependent. EyeProlog uses
1845
1845
  `memory` for a finite host allocation/capacity ceiling and reserves the
1846
1846
  `finite_memory` spelling for the distinct convention where no finite amount of
1847
- memory could complete the computation.
1847
+ memory could complete the computation. After a recoverable memory error, the
1848
+ solver keeps a bounded recovery window while the failed search unwinds so the
1849
+ host can collect released query terms. The same solver can then run later
1850
+ queries; this recovery does not resume the query that exhausted its limit.
1848
1851
 
1849
1852
  The iterative solver keeps active-call frames only where they are semantically
1850
1853
  needed for cut scope or recursive variant guards. Bundled-library helpers whose
@@ -1853,9 +1856,15 @@ guard therefore do not copy a growing active-call sequence at every step.
1853
1856
  Under the normal EyeProlog registry, the bundled Prologue `length/2` also has a
1854
1857
  scoped iterative execution path: named lists are counted or constructed without
1855
1858
  recursive interpreter frames, and an anonymous list is not materialized because
1856
- its binding cannot be observed. The ordinary clauses remain the authoritative
1857
- module definition and are used unchanged by the ISO-only registry and whenever
1858
- delays or finite-domain constraints require their normal wake-up points.
1859
+ its binding cannot be observed. A newly constructed fixed-length suffix starts
1860
+ as a lazy compact skeleton and expands one ordinary `./2` cell at a time when
1861
+ unification, another list predicate, or answer readback inspects it. This is a
1862
+ storage optimization, not a distinct Prolog term or list semantics. Embedders
1863
+ that inspect the JavaScript term model can recognize this representation with
1864
+ `CompactListTerm`, `isCompactList`, and `compactListLength`, or construct one
1865
+ with `compactVariableList`. The ordinary clauses remain the authoritative module
1866
+ definition and are used unchanged by the ISO-only registry and whenever delays
1867
+ or finite-domain constraints require their normal wake-up points.
1859
1868
 
1860
1869
  ### Implementation boundary
1861
1870
 
@@ -5360,9 +5369,16 @@ comment continues to the end of its line. Doubling the active delimiter is
5360
5369
  also accepted inside either quoted form, so `""` inside double-quoted notation
5361
5370
  denotes one literal double quote character.
5362
5371
 
5363
- Graphic atoms may contain `#$&*+-/<=>@^~\;`. A colon is the Part 2 module
5364
- qualification operator in `Module:Goal`; quote an atom whose name itself
5365
- contains a colon. Unquoted angle-bracket IRIs are not syntax.
5372
+ Graphic tokens use the characters `#$&*+-./<=>?@^~\`; `!` and `;` are solo
5373
+ atoms. A colon is the Part 2 module qualification operator in `Module:Goal`;
5374
+ quote an atom whose name itself contains a colon. Unquoted angle-bracket IRIs
5375
+ are not syntax.
5376
+
5377
+ A `/*` sequence opens a block comment only when it begins a token; inside a
5378
+ maximal graphic token the slash and star remain atom characters. A period ends
5379
+ a term only when it is recognized as the terminating full stop. Consequently,
5380
+ `./*.` at the end of a line reads the atom `./*`, whereas `./*. .` reads the
5381
+ atom `./*.` and consumes the second period as the terminator.
5366
5382
 
5367
5383
  In the grammar below, `{ x }` means zero or more repetitions of `x`, `[ x ]`
5368
5384
  means that `x` is optional, and parentheses group alternatives. These marks