eyeprolog 1.2.40 → 1.2.42

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.40",
6
+ "version": "1.2.42",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/cli.js CHANGED
@@ -4,6 +4,7 @@ import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import process from 'node:process';
6
6
  import { goalsFromSource } from './goal-metadata.js';
7
+ import { memoryStatistics } from './platform.js';
7
8
 
8
9
  let engineModule = null;
9
10
  let explanationModule = null;
@@ -230,9 +231,9 @@ async function runDefault(engine, program, options) {
230
231
  } catch (error) {
231
232
  if (error?.name !== 'HaltSignal') throw error;
232
233
  process.exitCode = error.code;
234
+ } finally {
235
+ if (options.stats) printStats(solver.stats);
233
236
  }
234
-
235
- if (options.stats) printStats(solver.stats);
236
237
  }
237
238
 
238
239
  function writeExplanation(explanation, program, resolved, registry) {
@@ -259,7 +260,7 @@ Options:
259
260
  -h, --help Show this help text and exit.
260
261
  -p, --proof Enable proof explanations.
261
262
  -q, --quads Run embedded quad tests and fail if any do not hold.
262
- -s, --stats Print solver statistics to stderr after execution.
263
+ -s, --stats Print solver and memory statistics to stderr after execution.
263
264
  --iso-strict Use ISO/IEC 13211-1 core + Corrigenda 1-3 only;
264
265
  reject EyeProlog language extensions and disable automatic tabling.
265
266
  -v, --version Show the package version and exit.
@@ -294,7 +295,7 @@ function printWarnings(program) {
294
295
 
295
296
  function printStats(stats) {
296
297
  process.stderr.write('eyeprolog stats:\n');
297
- for (const [key, value] of Object.entries(stats)) {
298
+ for (const [key, value] of Object.entries({ ...stats, ...memoryStatistics() })) {
298
299
  process.stderr.write(` ${key}: ${value}\n`);
299
300
  }
300
301
  }
package/src/iso.js CHANGED
@@ -1074,9 +1074,16 @@ function* nlBuiltin({ solver, goal, env }) {
1074
1074
  yield env;
1075
1075
  }
1076
1076
 
1077
- function* termTextCandidates(stream) {
1077
+ function activeCharConverter(solver) {
1078
+ if (solver.prologFlags.get('char_conversion')?.value?.name !== 'on' || solver.charConversions.size === 0) {
1079
+ return null;
1080
+ }
1081
+ return (character) => solver.charConversions.get(character) ?? character;
1082
+ }
1083
+
1084
+ function* termTextCandidates(stream, solver) {
1078
1085
  const source = String(stream.content);
1079
- const lastBufferedNonLayout = lastNonLayoutIndex(source, stream.position);
1086
+ const convert = activeCharConverter(solver);
1080
1087
  let quote = null, lineComment = false, blockComment = false;
1081
1088
  for (let i = stream.position; i < source.length; i++) {
1082
1089
  const ch = source[i], next = source[i + 1];
@@ -1106,12 +1113,7 @@ function* termTextCandidates(stream) {
1106
1113
  continue;
1107
1114
  }
1108
1115
  if (ch === "'" || ch === '"') { quote = ch; continue; }
1109
- if (ch === '.' && isTerminatingFullStop(source, i)) {
1110
- // A buffered stream exposes what follows the layout after this dot. If
1111
- // the dot continues a graphic token and later non-layout input exists,
1112
- // it is part of that maximal token rather than an early end char. The
1113
- // interactive reader makes the corresponding decision incrementally.
1114
- if (continuesGraphicToken(source, i) && lastBufferedNonLayout > i) continue;
1116
+ if (isTerminatingFullStop(source, i, convert)) {
1115
1117
  yield { text: source.slice(stream.position, i + 1), end: i + 1 };
1116
1118
  }
1117
1119
  }
@@ -1223,7 +1225,7 @@ function readTermFromStream(stream, solver) {
1223
1225
  let requestedInteractiveTerm = false;
1224
1226
  while (true) {
1225
1227
  let sawCandidate = false;
1226
- for (const candidate of termTextCandidates(stream)) {
1228
+ for (const candidate of termTextCandidates(stream, solver)) {
1227
1229
  sawCandidate = true;
1228
1230
  if (candidate.lexicalError) {
1229
1231
  stream.position = candidate.end;
@@ -1713,21 +1715,22 @@ function numberListBuiltin(kind) {
1713
1715
  const list = deref(goal.args[1], env);
1714
1716
  if (value.type === VAR && list.type === VAR) throw new PrologError('instantiation_error');
1715
1717
  const text = numberListText(list, env, kind, value.type === NUMBER);
1716
- const next = env.clone();
1717
1718
  if (value.type === NUMBER) {
1718
1719
  if (text != null) {
1719
1720
  const parsed = parseIsoNumber(text);
1720
1721
  if (parsed == null) throw numberSyntaxError;
1721
- if (sameNumber(value, parsed)) yield next;
1722
+ if (sameNumber(value, parsed)) yield env.clone();
1722
1723
  return;
1723
1724
  }
1724
1725
  const items = characters(canonicalNumberText(value)).map((ch) =>
1725
1726
  kind === 'chars' ? atom(ch) : numberTerm(ch.codePointAt(0)));
1727
+ const next = env.clone();
1726
1728
  if (unify(goal.args[1], listFromItems(items), next)) yield next;
1727
1729
  return;
1728
1730
  }
1729
1731
  const parsed = parseIsoNumber(text);
1730
1732
  if (parsed == null) throw numberSyntaxError;
1733
+ const next = env.clone();
1731
1734
  if (unify(goal.args[0], parsed, next)) yield next;
1732
1735
  };
1733
1736
  }
@@ -1977,36 +1980,59 @@ function* phraseBuiltin({ solver, goal, env }) {
1977
1980
  solver.absorbStatsFrom(child);
1978
1981
  }
1979
1982
  }
1980
- export function formalErrorTerm(error) {
1981
- const context = error.contextTerm ?? atom('eyeprolog');
1982
- if (error.formalTerm != null) return compound('error', [error.formalTerm, context]);
1983
- const parse = (text) => {
1984
- const open = text.indexOf('(');
1985
- if (open === -1) return atom(text);
1986
- const name = text.slice(0, open);
1987
- const inner = text.slice(open + 1, -1);
1988
- const args = [];
1989
- let start = 0;
1990
- let depth = 0;
1991
- for (let i = 0; i <= inner.length; i++) {
1992
- const ch = inner[i];
1993
- if (ch === '(') depth++;
1994
- else if (ch === ')') depth--;
1995
- else if ((ch === ',' || i === inner.length) && depth === 0) {
1996
- args.push(parse(inner.slice(start, i).trim()));
1997
- start = i + 1;
1998
- }
1983
+ const defaultErrorContext = atom('eyeprolog');
1984
+
1985
+ function parseFormalErrorTerm(text) {
1986
+ const open = text.indexOf('(');
1987
+ if (open === -1) return atom(text);
1988
+ const name = text.slice(0, open);
1989
+ const inner = text.slice(open + 1, -1);
1990
+ const args = [];
1991
+ let start = 0;
1992
+ let depth = 0;
1993
+ for (let i = 0; i <= inner.length; i++) {
1994
+ const ch = inner[i];
1995
+ if (ch === '(') depth++;
1996
+ else if (ch === ')') depth--;
1997
+ else if ((ch === ',' || i === inner.length) && depth === 0) {
1998
+ args.push(parseFormalErrorTerm(inner.slice(start, i).trim()));
1999
+ start = i + 1;
1999
2000
  }
2000
- return compound(name, args);
2001
- };
2002
- let formal = parse(error.formal);
2001
+ }
2002
+ return compound(name, args);
2003
+ }
2004
+
2005
+ function hasDefaultGroundErrorShape(error) {
2006
+ return error.formalTerm == null && error.culprit == null && error.contextTerm == null;
2007
+ }
2008
+
2009
+ export function formalErrorTerm(error) {
2010
+ // Reused processor errors can occur hundreds of thousands of times in a
2011
+ // caught failure loop. Cache their immutable ground error/2 term on the
2012
+ // error object itself instead of rebuilding the same tree on every catch.
2013
+ if (hasDefaultGroundErrorShape(error) && error._groundErrorTerm != null) {
2014
+ return error._groundErrorTerm;
2015
+ }
2016
+
2017
+ const context = error.contextTerm ?? defaultErrorContext;
2018
+ let formal = error.formalTerm ?? parseFormalErrorTerm(error.formal);
2003
2019
  if (error.culprit != null) {
2004
2020
  if (formal.type === COMPOUND) formal = compound(formal.name, [...formal.args, error.culprit]);
2005
2021
  else if (formal.type === ATOM && formal.name === 'uninstantiation_error') {
2006
2022
  formal = compound(formal.name, [error.culprit]);
2007
2023
  }
2008
2024
  }
2009
- return compound('error', [formal, context]);
2025
+ const term = compound('error', [formal, context]);
2026
+ if (hasDefaultGroundErrorShape(error)) error._groundErrorTerm = term;
2027
+ return term;
2028
+ }
2029
+
2030
+ function prologErrorBall(error) {
2031
+ const term = formalErrorTerm(error);
2032
+ // Ground terms are immutable from unification's perspective: bindings are
2033
+ // recorded only in the catcher Env, so copying them is unnecessary.
2034
+ if (hasDefaultGroundErrorShape(error) || termIsGround(term)) return term;
2035
+ return freshCopy(term, new Env());
2010
2036
  }
2011
2037
  function* catchBuiltin({ solver, goal, env }) {
2012
2038
  const child = solver.cloneForInnerGoal();
@@ -2018,7 +2044,7 @@ function* catchBuiltin({ solver, goal, env }) {
2018
2044
  const ball = error instanceof ThrownTerm
2019
2045
  ? error.term
2020
2046
  : error instanceof PrologError
2021
- ? freshCopy(formalErrorTerm(error), new Env())
2047
+ ? prologErrorBall(error)
2022
2048
  : null;
2023
2049
  if (ball == null) throw error;
2024
2050
  const recovered = env.clone();
package/src/parser.js CHANGED
@@ -378,10 +378,6 @@ class Parser {
378
378
  const line = this.line;
379
379
  const ch = this.peek();
380
380
  if (!ch) return { type: TOK.EOF, text: '', line };
381
- if (this.source.startsWith('...', this.pos) && this.peek(3) !== '.') {
382
- this.pos += 3;
383
- return { type: TOK.ATOM, text: '...', line };
384
- }
385
381
  if (ch === '?' && this.peek(1) === '-' &&
386
382
  !(isGraphicAtomCode(this.peek(2).charCodeAt(0)) && !this.terminatingFullStop(this.pos + 2))) {
387
383
  this.pos += 2;
package/src/platform.js CHANGED
@@ -23,6 +23,13 @@ export function currentWorkingDirectory() {
23
23
  return isNode && typeof process.cwd === 'function' ? process.cwd() : '/';
24
24
  }
25
25
 
26
+ function oldGenerationUsedSize() {
27
+ if (!isNode || typeof v8?.getHeapSpaceStatistics !== 'function') return null;
28
+ return v8.getHeapSpaceStatistics()
29
+ .filter(({ space_name: name }) => name !== 'read_only_space' && !name.startsWith('new_'))
30
+ .reduce((total, { space_used_size: size }) => total + size, 0);
31
+ }
32
+
26
33
  export function usedHeapSize() {
27
34
  if (isNode && typeof process.memoryUsage === 'function') {
28
35
  // --max-old-space-size constrains V8's old generation, not the complete
@@ -30,11 +37,9 @@ export function usedHeapSize() {
30
37
  // with total heap use makes bursts of collectible new-space objects look
31
38
  // like retained memory. Measure the corresponding non-young spaces when
32
39
  // an old-space limit was supplied.
33
- if (configuredOldSpaceLimit != null && typeof v8?.getHeapSpaceStatistics === 'function') {
34
- const spaces = v8.getHeapSpaceStatistics();
35
- return spaces
36
- .filter(({ space_name: name }) => name !== 'read_only_space' && !name.startsWith('new_'))
37
- .reduce((total, { space_used_size: size }) => total + size, 0);
40
+ if (configuredOldSpaceLimit != null) {
41
+ const oldGeneration = oldGenerationUsedSize();
42
+ if (oldGeneration != null) return oldGeneration;
38
43
  }
39
44
  return process.memoryUsage().heapUsed;
40
45
  }
@@ -42,6 +47,31 @@ export function usedHeapSize() {
42
47
  return Number.isFinite(memory?.usedJSHeapSize) ? memory.usedJSHeapSize : null;
43
48
  }
44
49
 
50
+ export function memoryStatistics() {
51
+ const stats = {};
52
+ if (isNode && typeof process.memoryUsage === 'function') {
53
+ const memory = process.memoryUsage();
54
+ stats.memory_heap_used_bytes = memory.heapUsed;
55
+ const oldGeneration = oldGenerationUsedSize();
56
+ if (oldGeneration != null) stats.memory_old_generation_used_bytes = oldGeneration;
57
+ stats.memory_guard_used_bytes = configuredOldSpaceLimit != null && oldGeneration != null
58
+ ? oldGeneration
59
+ : memory.heapUsed;
60
+ stats.memory_rss_bytes = memory.rss;
61
+ } else {
62
+ const memory = globalThis.performance?.memory;
63
+ if (Number.isFinite(memory?.usedJSHeapSize)) {
64
+ stats.memory_heap_used_bytes = memory.usedJSHeapSize;
65
+ stats.memory_guard_used_bytes = memory.usedJSHeapSize;
66
+ }
67
+ }
68
+ const softLimit = softHeapLimit();
69
+ const hardLimit = hardHeapLimit();
70
+ if (Number.isFinite(softLimit)) stats.memory_soft_limit_bytes = softLimit;
71
+ if (Number.isFinite(hardLimit)) stats.memory_hard_limit_bytes = hardLimit;
72
+ return stats;
73
+ }
74
+
45
75
  export function softHeapLimit() {
46
76
  const limit = hardHeapLimit();
47
77
  // Leave ample room for the generator stack to unwind and for the top level
package/src/repl.js CHANGED
@@ -3,7 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import { readSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { createInterface } from 'node:readline';
6
- import { formalErrorTerm, isCompleteReadTermText } from './iso.js';
6
+ import { formalErrorTerm } from './iso.js';
7
7
  import {
8
8
  characterCodeConstantEnd, continuesGraphicToken, isTerminatingFullStop, quotedEscapeEnd,
9
9
  } from './syntax-scan.js';
@@ -171,8 +171,8 @@ class LineReader {
171
171
  const line = this.readTerminalLineSync();
172
172
  if (line == null) return source.trim() ? source : null;
173
173
  source += `${line}\n`;
174
- const end = terminalFullStop(source);
175
- if (end >= 0 && acceptsReadTermBoundary(source, end, solver)) {
174
+ const end = terminalFullStop(source, solver);
175
+ if (end >= 0) {
176
176
  return source.slice(0, end + 1) + '\n';
177
177
  }
178
178
  prompt = '| ';
@@ -300,7 +300,7 @@ async function prepareInteractiveTermInput(state, goal, reader) {
300
300
  // fallback for piped/non-TTY REPL tests and scripted input.
301
301
  if (reader.canReadTermSynchronously()) return;
302
302
  const stream = interactiveTermInputStream(state, goal);
303
- if (stream == null || terminalFullStop(String(stream.content).slice(stream.position)) >= 0) return;
303
+ if (stream == null || terminalFullStop(String(stream.content).slice(stream.position), state.solver) >= 0) return;
304
304
 
305
305
  const text = await readInteractiveTerm(reader, state.solver);
306
306
  if (text == null) return;
@@ -343,24 +343,23 @@ async function readInteractiveTerm(reader, solver = null) {
343
343
  const line = await reader.read(prompt);
344
344
  if (line == null) return source.trim() ? source : null;
345
345
  source += `${line}\n`;
346
- const end = terminalFullStop(source);
347
- if (end >= 0 && acceptsReadTermBoundary(source, end, solver)) {
346
+ const end = terminalFullStop(source, solver);
347
+ if (end >= 0) {
348
348
  return source.slice(0, end + 1) + '\n';
349
349
  }
350
350
  prompt = '| ';
351
351
  }
352
352
  }
353
353
 
354
- function acceptsReadTermBoundary(source, end, solver) {
355
- // A dot after a graphic character has two possible readings. Stop at once
356
- // when the candidate is already a complete term (for example `./*.`);
357
- // otherwise keep reading so a later end char can make it part of an atom
358
- // (for example the first dot in `!,*.\n.`).
359
- return !continuesGraphicToken(source, end) || solver == null ||
360
- isCompleteReadTermText(source.slice(0, end + 1), solver);
354
+ function activeCharConverter(solver) {
355
+ if (solver?.prologFlags.get('char_conversion')?.value?.name !== 'on' || solver.charConversions.size === 0) {
356
+ return null;
357
+ }
358
+ return (character) => solver.charConversions.get(character) ?? character;
361
359
  }
362
360
 
363
- function terminalFullStop(source) {
361
+ function terminalFullStop(source, solver = null) {
362
+ const convert = activeCharConverter(solver);
364
363
  let quote = null;
365
364
  let lineComment = false;
366
365
  let blockComment = false;
@@ -415,7 +414,7 @@ function terminalFullStop(source) {
415
414
  }
416
415
  if ('([{'.includes(ch)) depth++;
417
416
  else if (')]}'.includes(ch)) depth = Math.max(0, depth - 1);
418
- else if (ch === '.' && depth === 0 && isTerminatingFullStop(source, i) &&
417
+ else if (depth === 0 && isTerminatingFullStop(source, i, convert) &&
419
418
  onlyLayoutAndComments(source.slice(i + 1))) return i;
420
419
  }
421
420
  return -1;
@@ -503,11 +502,15 @@ async function solveQuery(engine, state, goal, reader, output) {
503
502
  if (formattingAfterAdvance) output.write(' ');
504
503
  formattingAfterAdvance = false;
505
504
  output.write(current.output);
506
- output.write(`${firstAnswer ? ' ' : ''}${formatAnswer(engine, state, variables, current.result.value)}`);
505
+ const answer = formatAnswer(engine, state, variables, current.result.value);
506
+ output.write(`${firstAnswer ? ' ' : ''}${answer}`);
507
507
  answersShown++;
508
508
  firstAnswer = false;
509
509
  if (!next.error && next.result.done) {
510
- output.write('.\n');
510
+ // A terminal full stop cannot immediately follow a graphic token: the
511
+ // scanner would absorb it into that token. Insert layout so the printed
512
+ // answer remains valid Prolog text (issue #44).
513
+ output.write(`${continuesGraphicToken(answer, answer.length) ? ' ' : ''}.\n`);
511
514
  return null;
512
515
  }
513
516
 
@@ -4,21 +4,49 @@
4
4
 
5
5
  const graphicTokenCharacters = new Set('#$&*+-./<=>?@^~\\:');
6
6
 
7
- export function continuesGraphicToken(source, index) {
8
- return index > 0 && graphicTokenCharacters.has(source[index - 1]);
7
+ export function continuesGraphicToken(source, index, convert = null) {
8
+ if (index <= 0) return false;
9
+ const rawPrevious = source[index - 1];
10
+ const previous = convert == null ? rawPrevious : convert(rawPrevious);
11
+ // Most full stops follow a non-graphic token. Reject those in O(1) before
12
+ // doing the rarer character-code/comment disambiguation below; otherwise a
13
+ // large source with many term-ending dots degenerates into repeated backward
14
+ // scans (notably multi-megabyte generated data files).
15
+ if (!graphicTokenCharacters.has(previous)) return false;
16
+
17
+ // A graphic-looking character can be the payload or closing escape of a
18
+ // character-code constant rather than a graphic token. For example, the
19
+ // backslash immediately before the full stop in `0'\x41\.` belongs to
20
+ // the number token, so that full stop still terminates the term.
21
+ const apostrophe = source.lastIndexOf("'", index - 1);
22
+ if (apostrophe >= 0 && characterCodeConstantEnd(source, apostrophe) === index - 1) return false;
23
+ // The slash that closes a bracketed comment is layout, not the tail of a
24
+ // graphic token. Distinguish it from spellings such as `//*.*/`, where the
25
+ // apparent /* is itself embedded in a graphic token and therefore never
26
+ // opens a comment.
27
+ if (source[index - 1] === '/' && source[index - 2] === '*') {
28
+ for (let open = source.lastIndexOf('/*', index - 3); open >= 0;
29
+ open = source.lastIndexOf('/*', open - 1)) {
30
+ if (open === 0 || !graphicTokenCharacters.has(source[open - 1])) return false;
31
+ }
32
+ }
33
+ return true;
9
34
  }
10
35
 
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.
36
+ export function isTerminatingFullStop(source, index, convert = null) {
37
+ const current = convert == null ? source[index] : convert(source[index]);
38
+ if (current !== '.') return false;
39
+ const rawNext = source[index + 1] ?? '';
40
+ const next = convert == null ? rawNext : convert(rawNext);
41
+ // A full stop cannot terminate a term when it can still extend the graphic
42
+ // token immediately before it. This remains true at a line boundary and at
43
+ // the current end of interactive input: `*.\n` is the graphic token `*.`
44
+ // followed by layout, so read/1 must keep waiting for a separate end char.
45
+ // Conversely `!.\n` terminates because ! is a solo token, not a graphic
46
+ // token character accepted by continuesGraphicToken().
47
+ if (continuesGraphicToken(source, index, convert)) return false;
20
48
  if (next === '' || next === '%' || next === '\n' || next === '\r') return true;
21
- if (/^[\u0009\u000b\u000c\u0020]$/.test(next)) return !continuesGraphicToken(source, index);
49
+ if (/^[\u0009\u000b\u000c\u0020]$/.test(next)) return true;
22
50
  return false;
23
51
  }
24
52
 
package/src/write.js CHANGED
@@ -4,8 +4,8 @@ import {
4
4
  Env, deref, isCons, isEmptyList,
5
5
  } from './term.js';
6
6
 
7
- const graphicAtomCharacters = new Set('!#$&*+-/<=>?@^~\\'.split(''));
8
- const dottedGraphicAtomCharacters = new Set([...graphicAtomCharacters, '.']);
7
+ const graphicAtomCharacters = new Set('!#$&*+-./<=>?@^~\\'.split(''));
8
+ const dottedGraphicAtomCharacters = graphicAtomCharacters;
9
9
  const compactInfixOperators = new Set([':', '..']);
10
10
 
11
11
  function quotedControlEscape(ch) {
@@ -28,7 +28,11 @@ function quotedControlEscape(ch) {
28
28
  function atomNeedsQuotes(name) {
29
29
  if (!name) return true;
30
30
  if (name === '[]' || name === '{}') return false;
31
- if (name === '...') return false;
31
+ // A lone full stop is the end token, not a graphic atom. Longer
32
+ // graphic tokens may contain dots and are valid unquoted writeq/1 output
33
+ // (WG17 #371-373: ./*, .*, ...*). Only a token beginning with /* would
34
+ // be read as a bracketed comment and therefore still requires quoting.
35
+ if (name === '.') return true;
32
36
  if (name.startsWith('/*')) return true;
33
37
  if (/^[a-z][A-Za-z0-9_]*$/.test(name)) return false;
34
38
  for (const ch of name) if (!graphicAtomCharacters.has(ch)) return true;
@@ -1 +1 @@
1
- parse line 1: bad term
1
+ parse line 1: expected ., got :-.
@@ -1 +1 @@
1
- parse line 1: expected ., got .
1
+ parse line 1: expected ., got ..
@@ -671,7 +671,7 @@
671
671
  "type": "success",
672
672
  "stages": [
673
673
  {
674
- "output": "'//*.*/'",
674
+ "output": "//*.*/",
675
675
  "variables": "[]"
676
676
  }
677
677
  ]
@@ -4302,7 +4302,7 @@
4302
4302
  "variables": "[]"
4303
4303
  },
4304
4304
  {
4305
- "output": ">('>.'(a),b)",
4305
+ "output": ">(>.(a),b)",
4306
4306
  "variables": "[]"
4307
4307
  }
4308
4308
  ]
@@ -4322,7 +4322,7 @@
4322
4322
  "variables": "[]"
4323
4323
  },
4324
4324
  {
4325
- "output": "=('>.'(a),b)",
4325
+ "output": "=(>.(a),b)",
4326
4326
  "variables": "[]"
4327
4327
  }
4328
4328
  ]
@@ -4342,7 +4342,7 @@
4342
4342
  "variables": "[]"
4343
4343
  },
4344
4344
  {
4345
- "output": "','('>.'(a),b)",
4345
+ "output": "','(>.(a),b)",
4346
4346
  "variables": "[]"
4347
4347
  }
4348
4348
  ]
@@ -4362,7 +4362,7 @@
4362
4362
  "variables": "[]"
4363
4363
  },
4364
4364
  {
4365
- "output": "'>.'(a)",
4365
+ "output": ">.(a)",
4366
4366
  "variables": "[]"
4367
4367
  }
4368
4368
  ]
@@ -5022,7 +5022,7 @@
5022
5022
  "type": "success",
5023
5023
  "stages": [
5024
5024
  {
5025
- "output": "'.+'",
5025
+ "output": ".+",
5026
5026
  "variables": "[]"
5027
5027
  }
5028
5028
  ]
@@ -5273,7 +5273,7 @@
5273
5273
  "variables": "[]"
5274
5274
  },
5275
5275
  {
5276
- "output": "'.>'('.>'(a))",
5276
+ "output": ".>(.>(a))",
5277
5277
  "variables": "[]"
5278
5278
  }
5279
5279
  ]
@@ -39,6 +39,7 @@ import {
39
39
  parseProgramText,
40
40
  } from '../src/index.js';
41
41
  import { ISO_OPERATOR_DEFINITIONS, parseGoalText, parseNumberTokenText } from '../src/parser.js';
42
+ import { PrologError, formalErrorTerm } from '../src/iso.js';
42
43
  import { compareTerms } from '../src/term.js';
43
44
  import { formatTermForWrite } from '../src/write.js';
44
45
  import { selectClauseCandidates } from '../src/program.js';
@@ -102,6 +103,16 @@ export function runRegression(reporter = new TestReporter()) {
102
103
 
103
104
  function regressionCases() {
104
105
  return [
106
+ {
107
+ name: 'large source scanning avoids quadratic full-stop lookback',
108
+ run: () => {
109
+ const result = runCli(['examples/path-discovery.pl'], { timeout: 10000 });
110
+ if (result.error) throw new Error(`path-discovery timed out or failed to launch: ${result.error.message}`);
111
+ assertEqual(result.status, 0, `path-discovery status; stderr=${result.stderr}`);
112
+ assertIncludes(result.stdout, "airroute('Ostend-Bruges International Airport', 'Václav Havel Airport Prague'",
113
+ 'path-discovery result');
114
+ },
115
+ },
105
116
  {
106
117
  name: '--proof rule fact explanation output',
107
118
  run: () => runWhy({
@@ -733,24 +744,77 @@ c4 ?- call((!;1)).
733
744
  assertIncludes(upstreamResult.stdout, '46 true.', 'WG17 #367 output');
734
745
  },
735
746
  },
747
+ {
748
+ name: 'top level separates terminal full stop from graphic answers (issue #44)',
749
+ run: () => {
750
+ const result = runCli([], { input: 'X = .* .\nhalt.\n' });
751
+ assertEqual(result.status, 0, 'issue #44 exit status');
752
+ assertIncludes(result.stdout, 'X = .* .\n', 'graphic binding is separated from terminal full stop');
753
+ assertNotIncludes(result.stdout, 'X = .*.\n', 'terminal full stop is not absorbed into graphic atom');
754
+ assertEqual(result.stderr, '', 'issue #44 stderr');
755
+ },
756
+ },
757
+ {
758
+ name: 'writeq leaves ISO dotted graphic atoms unquoted (WG17 #371-373)',
759
+ run: () => {
760
+ const result = runCli([], {
761
+ input: 'writeq(./*).\nwriteq(.*).\nwriteq(...*).\nhalt.\n',
762
+ });
763
+ assertEqual(result.status, 0, 'dotted graphic writeq exit status');
764
+ assertIncludes(result.stdout, ' ./* true.\n', 'writeq ./* is unquoted');
765
+ assertIncludes(result.stdout, ' .* true.\n', 'writeq .* is unquoted');
766
+ assertIncludes(result.stdout, ' ...* true.\n', 'writeq ...* is unquoted');
767
+ assertNotIncludes(result.stdout, "'./*'", 'writeq ./* has no quotes');
768
+ assertNotIncludes(result.stdout, "'.*'", 'writeq .* has no quotes');
769
+ assertNotIncludes(result.stdout, "'...*'", 'writeq ...* has no quotes');
770
+ },
771
+ },
736
772
  {
737
773
  name: 'readers distinguish graphic tokens, comments, and full stops (issue #41)',
738
774
  run: () => {
739
- const parsed = parseProgramText('./*.');
775
+ const parsed = parseProgramText('./* .');
740
776
  assertEqual(parsed.length, 1, 'graphic atom clause count');
741
777
  assertEqual(parsed[0].head.name, './*', 'comment opener stays inside graphic atom');
742
778
 
779
+ // A dot immediately after a graphic token belongs to that maximal
780
+ // token. A separate end char is therefore required even at a line
781
+ // boundary; this is the waiting behavior called out in WG17 #370-373.
782
+ const waitProgram = Program.parse('');
783
+ const waitSolver = new Solver(waitProgram, {
784
+ registry: getEyePrologRegistry(),
785
+ ioOptions: { input: '*.\n' },
786
+ });
787
+ const waitStream = waitSolver.io.resolve('user_input');
788
+ let refillRequests = 0;
789
+ waitStream.interactiveReadTerm = () => {
790
+ refillRequests++;
791
+ return '.\n';
792
+ };
793
+ const waitGoal = parseGoalText('read(T)', {
794
+ operatorDefinitions: [...waitProgram.operators.values()],
795
+ });
796
+ const waitAnswers = [...waitSolver.solve([waitGoal], new Env(), 0)];
797
+ assertEqual(waitAnswers.length, 1, 'graphic token read answer after refill');
798
+ assertEqual(refillRequests, 1, 'graphic token boundary waits for a separate end char');
799
+ assertEqual(copyResolved(waitGoal.args[0], waitAnswers[0]).name, '*.', 'maximal graphic token after refill');
800
+
743
801
  const read = runEyeProlog('', {
744
802
  goal: 'read(T)',
745
- ioOptions: { input: './*.' },
803
+ ioOptions: { input: './*. .' },
746
804
  });
747
- assertEqual(read.stdout, "read('./*').\n", 'graphic atom writeq readback');
805
+ assertEqual(read.stdout, "read(./*.).\n", 'dotted graphic atom writeq readback');
806
+
807
+ const ellipsisGraphic = runEyeProlog('', {
808
+ goal: 'read(T)',
809
+ ioOptions: { input: '...*\n.\n' },
810
+ });
811
+ assertEqual(ellipsisGraphic.stdout, "read(...*).\n", 'ellipsis prefix remains inside maximal graphic atom');
748
812
 
749
813
  const consecutive = runEyeProlog('answer(A, B) :- read(A), read(B).\n', {
750
814
  goal: 'answer(A, B)',
751
815
  ioOptions: { input: './*. .\nok.\n' },
752
816
  });
753
- assertEqual(consecutive.stdout, "answer('./*.', ok).\n", 'following read starts after the complete term');
817
+ assertEqual(consecutive.stdout, "answer(./*., ok).\n", 'following read starts after the complete term');
754
818
 
755
819
  // A possible full stop can fail to complete the term while still
756
820
  // extending a current graphic operator into an ordinary atom. The
@@ -820,7 +884,7 @@ c4 ?- call((!;1)).
820
884
  input: 'read(T).\n./*. .\nread(T).\nok.\nread(T).\n!.!.\nhalt.\n',
821
885
  });
822
886
  assertEqual(repl.status, 0, 'REPL exit status');
823
- assertIncludes(repl.stdout, 'T = ./*..', 'REPL dotted graphic atom answer');
887
+ assertIncludes(repl.stdout, 'T = ./*. .', 'REPL dotted graphic atom answer');
824
888
  assertNotIncludes(repl.stdout, "T = './*.'", 'REPL dotted graphic atom has no spurious quotes');
825
889
  assertIncludes(repl.stdout, 'T = ok.', 'REPL following read answer');
826
890
  assertIncludes(repl.stdout, 'error(syntax_error(read_term), eyeprolog)', 'REPL syntax error');
@@ -1562,10 +1626,10 @@ c4 ?- call((!;1)).
1562
1626
  if (available.status !== 0) return;
1563
1627
  const command = `${shellQuote(process.execPath)} ${shellQuote(bin)}`;
1564
1628
  const scriptCommand =
1565
- `{ printf 'read(X), read(Y).\n'; sleep 0.15; ` +
1566
- `printf 'foo.\n'; sleep 0.15; printf 'bar.\n'; sleep 0.15; ` +
1567
- `printf 'read(Z).\n'; sleep 0.15; printf '\\004'; sleep 0.15; ` +
1568
- `printf 'true.\n'; sleep 0.15; printf 'halt.\n'; } | ` +
1629
+ `{ printf 'read(X), read(Y).\n'; sleep 0.3; ` +
1630
+ `printf 'foo.\n'; sleep 0.3; printf 'bar.\n'; sleep 0.3; ` +
1631
+ `printf 'read(Z).\n'; sleep 0.3; printf '\\004'; sleep 0.3; ` +
1632
+ `printf 'true.\n'; sleep 0.3; printf 'halt.\n'; } | ` +
1569
1633
  `script -qefc ${shellQuote(command)} /dev/null`;
1570
1634
  const result = spawnSync('sh', ['-c', scriptCommand], {
1571
1635
  cwd: packageRoot,
@@ -1876,13 +1940,29 @@ c4 ?- call((!;1)).
1876
1940
 
1877
1941
 
1878
1942
  {
1879
- name: '--stats prints solver statistics to stderr',
1943
+ name: '--stats prints solver and memory statistics to stderr',
1880
1944
  run: () => {
1881
1945
  const result = runCli(['--stats', '-'], { input: '%% goal: q(X, Y)\np(a, b).\nq(X, Y) :- p(X, Y).\n' });
1882
1946
  assertEqual(result.status, 0, 'exit status');
1883
1947
  assertEqual(result.stdout, 'q(a, b).\n', 'stdout');
1884
1948
  assertIncludes(result.stderr, 'eyeprolog stats:\n', 'stderr');
1885
1949
  assertIncludes(result.stderr, ' solve_goals_calls:', 'stderr');
1950
+ assertIncludes(result.stderr, ' memory_heap_used_bytes:', 'stderr');
1951
+ assertIncludes(result.stderr, ' memory_old_generation_used_bytes:', 'stderr');
1952
+ assertIncludes(result.stderr, ' memory_guard_used_bytes:', 'stderr');
1953
+ assertIncludes(result.stderr, ' memory_rss_bytes:', 'stderr');
1954
+ assertIncludes(result.stderr, ' memory_soft_limit_bytes:', 'stderr');
1955
+ assertIncludes(result.stderr, ' memory_hard_limit_bytes:', 'stderr');
1956
+ },
1957
+ },
1958
+ {
1959
+ name: '--stats is still printed when a query raises an error',
1960
+ run: () => {
1961
+ const result = runCli(['--stats', '-'], { input: "%% goal: number_chars(N, ['x'])\n" });
1962
+ assertEqual(result.status, 1, 'exit status');
1963
+ assertIncludes(result.stderr, 'eyeprolog stats:\n', 'stderr');
1964
+ assertIncludes(result.stderr, ' memory_heap_used_bytes:', 'stderr');
1965
+ assertIncludes(result.stderr, 'eyeprolog: error(syntax_error(number))', 'stderr');
1886
1966
  },
1887
1967
  },
1888
1968
  {
@@ -2896,6 +2976,15 @@ open(X) :- candidate(X), \\+ closed(X).
2896
2976
  assertEqual(result.stdout, '300000', 'fresh-variable answer count');
2897
2977
  },
2898
2978
  },
2979
+ {
2980
+ name: 'ground Prolog error terms are reused across catches',
2981
+ run: () => {
2982
+ const error = new PrologError('syntax_error(number)');
2983
+ const first = formalErrorTerm(error);
2984
+ const second = formalErrorTerm(error);
2985
+ assertEqual(first === second, true, 'reused ground error term');
2986
+ },
2987
+ },
2899
2988
  {
2900
2989
  name: 'caught number syntax errors do not exhaust memory on distinct inputs',
2901
2990
  run: () => {
@@ -2916,22 +3005,22 @@ open(X) :- candidate(X), \\+ closed(X).
2916
3005
  const goal = parseGoalText('trial(Chars)');
2917
3006
  let count = 0;
2918
3007
  for (const _ of solver.solve([goal], new Env(), 0)) {
2919
- if (++count === 50000) break;
3008
+ if (++count === 250000) break;
2920
3009
  }
2921
- if (count !== 50000) throw new Error('unexpected answer count: ' + count);
3010
+ if (count !== 250000) throw new Error('unexpected answer count: ' + count);
2922
3011
  process.stdout.write(String(count));
2923
3012
  `;
2924
3013
  const result = spawnSync(process.execPath, [
2925
- // A smaller heap preserves the original false-exhaustion signal
2926
- // without requiring half a million transient conversion attempts.
3014
+ // Run well past the roughly 126,000-answer failure reported in #28
3015
+ // while keeping the host heap deliberately constrained.
2927
3016
  '--max-old-space-size=32',
2928
3017
  '--input-type=module',
2929
3018
  '--eval',
2930
3019
  script,
2931
- ], { cwd: packageRoot, encoding: 'utf8', timeout: 10000 });
3020
+ ], { cwd: packageRoot, encoding: 'utf8', timeout: 30000 });
2932
3021
  if (result.error) throw result.error;
2933
3022
  assertEqual(result.status, 0, `bounded-heap child status; stderr=${result.stderr}`);
2934
- assertEqual(result.stdout, '50000', 'distinct number syntax attempts');
3023
+ assertEqual(result.stdout, '250000', 'distinct number syntax attempts');
2935
3024
  },
2936
3025
  },
2937
3026
  {
@@ -4369,6 +4458,7 @@ function runCli(args, options = {}) {
4369
4458
  encoding: 'utf8',
4370
4459
  env: options.env ? { ...process.env, ...options.env } : process.env,
4371
4460
  input: options.input ?? undefined,
4461
+ timeout: options.timeout ?? undefined,
4372
4462
  });
4373
4463
  }
4374
4464
 
@@ -4633,7 +4633,11 @@ testing is a powerful guard during program transformation.
4633
4633
  `--stats` reports work, not meaning. A high solution count may be necessary or
4634
4634
  may indicate a generator that should be constrained. Many table hits may show
4635
4635
  effective reuse; many distinct table entries may reveal an argument that
4636
- prevents calls from sharing.
4636
+ prevents calls from sharing. On the Node CLI it also reports current heap use,
4637
+ non-young/old-generation use, the amount currently compared with the memory
4638
+ guard, resident-set size, and the soft and hard memory ceilings in bytes. These memory figures are printed even when execution ends by
4639
+ raising a Prolog error, which makes `--stats` useful when reproducing bounded
4640
+ memory failures such as long-running conversion stress tests.
4637
4641
 
4638
4642
  Compare statistics only between runs with the same query, data, and observable
4639
4643
  answer contract. A faster program that silently loses answers is not an
@@ -5253,10 +5257,12 @@ write_event(Path, Event) :-
5253
5257
 
5254
5258
  The period is essential when another Prolog processor will read the result as
5255
5259
  a term. `write/1-2` uses readable conventional syntax, `writeq/1-2` quotes
5256
- where needed, and `write_canonical/1-2` exposes canonical structure. ISO term
5257
- output uses only the separator characters needed by the syntax, so functional
5258
- arguments and list elements are emitted compactly; for example `writeq([a,b])`
5259
- outputs `[a,b]`.
5260
+ where needed, and `write_canonical/1-2` exposes canonical structure. Dotted
5261
+ graphic atoms do not need quotes merely because they contain a period:
5262
+ `writeq(./*)`, `writeq(.*)`, and `writeq(...*)` output `./*`, `.*`, and `...*`
5263
+ respectively. ISO term output uses only the separator characters needed by the
5264
+ syntax, so functional arguments and list elements are emitted compactly; for
5265
+ example `writeq([a,b])` outputs `[a,b]`.
5260
5266
  `write_term/2-3` supports `quoted/1`, `ignore_ops/1`, `numbervars/1`, and
5261
5267
  `variable_names/1`.
5262
5268
 
@@ -5377,10 +5383,12 @@ quote an atom whose name itself contains a colon. Unquoted angle-bracket IRIs
5377
5383
  are not syntax.
5378
5384
 
5379
5385
  A `/*` sequence opens a block comment only when it begins a token; inside a
5380
- maximal graphic token the slash and star remain atom characters. A period ends
5381
- a term only when it is recognized as the terminating full stop. Consequently,
5382
- `./*.` at the end of a line reads the atom `./*`, whereas `./*. .` reads the
5383
- atom `./*.` and consumes the second period as the terminator.
5386
+ maximal graphic token the slash and star remain atom characters. Graphic tokens
5387
+ are formed maximally before a period can be recognized as the terminating full
5388
+ stop. Consequently, interactive input `*.` or `./*.` is not yet a complete term:
5389
+ the period is part of the graphic atom and the reader waits for a separate
5390
+ terminating full stop. Thus `./*. .` reads the atom `./*.` and consumes the
5391
+ second period as the terminator.
5384
5392
 
5385
5393
  In the grammar below, `{ x }` means zero or more repetitions of `x`, `[ x ]`
5386
5394
  means that `x` is optional, and parentheses group alternatives. These marks
@@ -6344,8 +6352,10 @@ period-terminated query with no solutions prints `false.`; a solution without
6344
6352
  visible variable bindings prints `true.`. Answer substitutions are rendered as
6345
6353
  valid Prolog syntax under the current operator table: when a bound value would
6346
6354
  not be a valid right operand of the displayed `=/2`, EyeProlog adds parentheses,
6347
- for example `T = (a = b).` rather than the invalid `T = a = b.`. Use `[file].`
6348
- or `['file.pl'].` to
6355
+ for example `T = (a = b).` rather than the invalid `T = a = b.`. When an answer
6356
+ ends in a graphic token, the top level inserts layout before its terminating
6357
+ full stop so the two tokens cannot merge; for example `?- X = .* .` displays
6358
+ `X = .* .`, not `X = .*.`. Use `[file].` or `['file.pl'].` to
6349
6359
  consult local source, and `halt.` or `halt(Status).` to leave the top level.
6350
6360
  When `read/1-2` or `read_term/2-3` actually reaches interactive
6351
6361
  `user_input`, the top level requests the next full-stop-terminated Prolog term