eyeprolog 1.2.4 → 1.2.6

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 | 167 | 209 | 0 | 0 | 376 |
14
+ | iso | 167 | 210 | 0 | 0 | 377 |
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** | **482** | **260** | **19** | **21** | **782** |
26
+ | **Total** | **482** | **261** | **19** | **21** | **783** |
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.2.4",
6
+ "version": "1.2.6",
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
@@ -1075,9 +1075,12 @@ function quotedEscapeEnd(source, index) {
1075
1075
  while (/^[0-9A-Fa-f]$/.test(source[cursor] ?? '')) cursor++;
1076
1076
  return source[cursor] === '\\' ? cursor : Math.max(index + 1, cursor - 1);
1077
1077
  }
1078
- if (/^[0-7]$/.test(escaped)) {
1078
+ if (/^[0-9]$/.test(escaped)) {
1079
1079
  let cursor = index + 1;
1080
- while (/^[0-7]$/.test(source[cursor] ?? '')) cursor++;
1080
+ // Scan all decimal digits here, including 8 and 9. This scanner only
1081
+ // locates the end of a candidate quoted escape; the parser remains
1082
+ // authoritative and rejects non-octal digits.
1083
+ while (/^[0-9]$/.test(source[cursor] ?? '')) cursor++;
1081
1084
  return source[cursor] === '\\' ? cursor : Math.max(index + 1, cursor - 1);
1082
1085
  }
1083
1086
 
package/src/parser.js CHANGED
@@ -303,6 +303,10 @@ class Parser {
303
303
  if (this.take() !== '\\') throw new Error(`parse line ${line}: bad octal escape`);
304
304
  return String.fromCodePoint(Number.parseInt(digits, 8));
305
305
  }
306
+ // A backslash followed by a decimal digit is numeric-escape syntax, but
307
+ // ISO octal digits are limited to 0..7. Do not reinterpret \8 or \9 as
308
+ // implementation-specific one-character escapes.
309
+ if (/^[0-9]$/.test(escaped)) throw new Error(`parse line ${line}: bad octal escape`);
306
310
  return escaped;
307
311
  }
308
312
  nextToken() {
package/src/repl.js CHANGED
@@ -24,7 +24,7 @@ export async function runRepl(engine, options = {}) {
24
24
  let exitCode = 0;
25
25
 
26
26
  try {
27
- state.solver.runInitializations();
27
+ runWithTerminalSignals(reader, () => state.solver.runInitializations());
28
28
  while (true) {
29
29
  const text = await readQuery(reader);
30
30
  if (text == null) break;
@@ -35,7 +35,7 @@ export async function runRepl(engine, options = {}) {
35
35
  if (!options.isoStrict && isUseModuleGoal(goal)) {
36
36
  sources.push({ text: `:- ${text}.\n`, filename: '<repl>' });
37
37
  state = makeState(engine, sources, output, options, state);
38
- state.solver.runInitializations();
38
+ runWithTerminalSignals(reader, () => state.solver.runInitializations());
39
39
  output.write(' true.\n');
40
40
  continue;
41
41
  }
@@ -43,7 +43,7 @@ export async function runRepl(engine, options = {}) {
43
43
  if (consultFiles != null) {
44
44
  for (const filename of consultFiles) sources.push(await readSource(filename));
45
45
  state = makeState(engine, sources, output, options, state);
46
- state.solver.runInitializations();
46
+ runWithTerminalSignals(reader, () => state.solver.runInitializations());
47
47
  output.write(' true.\n');
48
48
  continue;
49
49
  }
@@ -142,12 +142,39 @@ class LineReader {
142
142
  return control;
143
143
  }
144
144
 
145
+ suspendForComputation() {
146
+ if (!this.terminal || !this.readline) return false;
147
+ // Node readline installs terminal signal handling while the interface is
148
+ // open. During a synchronous Prolog search that prevents the terminal's
149
+ // normal SIGINT/SIGTSTP actions from taking effect until JavaScript yields.
150
+ // Close readline while the solver is running so Ctrl-C can terminate and
151
+ // Ctrl-Z can suspend an otherwise non-terminating computation immediately.
152
+ this.history = [...this.readline.history];
153
+ this.readline.close();
154
+ this.readline = null;
155
+ this.lines = null;
156
+ return true;
157
+ }
158
+
159
+ resumeAfterComputation(suspended) {
160
+ if (suspended && !this.readline) this.open();
161
+ }
162
+
145
163
  close() {
146
164
  if (this.input.isRaw) this.input.setRawMode(false);
147
165
  this.readline?.close();
148
166
  }
149
167
  }
150
168
 
169
+ function runWithTerminalSignals(reader, operation) {
170
+ const suspended = reader.suspendForComputation();
171
+ try {
172
+ return operation();
173
+ } finally {
174
+ reader.resumeAfterComputation(suspended);
175
+ }
176
+ }
177
+
151
178
  function makeState(engine, sources, output, options = {}, previousState = null) {
152
179
  const strictIso = options.isoStrict === true;
153
180
  const program = engine.Program.parseSources(sources, { strictIso, sourceMetadata: strictIso });
@@ -253,9 +280,12 @@ function quotedEscapeEnd(source, index) {
253
280
  while (/^[0-9A-Fa-f]$/.test(source[cursor] ?? '')) cursor++;
254
281
  return source[cursor] === '\\' ? cursor : Math.max(index + 1, cursor - 1);
255
282
  }
256
- if (/^[0-7]$/.test(escaped)) {
283
+ if (/^[0-9]$/.test(escaped)) {
257
284
  let cursor = index + 1;
258
- while (/^[0-7]$/.test(source[cursor] ?? '')) cursor++;
285
+ // Scan all decimal digits here, including 8 and 9. This scanner only
286
+ // locates the end of a candidate quoted escape; the parser remains
287
+ // authoritative and rejects non-octal digits.
288
+ while (/^[0-9]$/.test(source[cursor] ?? '')) cursor++;
259
289
  return source[cursor] === '\\' ? cursor : Math.max(index + 1, cursor - 1);
260
290
  }
261
291
 
@@ -366,7 +396,7 @@ async function solveQuery(engine, state, goal, reader, output) {
366
396
  const solver = state.solver;
367
397
  solver.solutionsSeen = 0;
368
398
  const solutions = solver.solve([goal], new engine.Env(), 0);
369
- let current = pullSolution(solver, solutions);
399
+ let current = pullSolution(solver, solutions, reader);
370
400
  if (current.error) {
371
401
  if (current.error?.name === 'HaltSignal') return { halted: true, code: current.error.code };
372
402
  throw current.error;
@@ -380,7 +410,7 @@ async function solveQuery(engine, state, goal, reader, output) {
380
410
  let automatic = 0;
381
411
  let firstAnswer = true;
382
412
  while (!current.result.done) {
383
- const next = pullSolution(solver, solutions);
413
+ const next = pullSolution(solver, solutions, reader);
384
414
  output.write(current.output);
385
415
  output.write(`${firstAnswer ? ' ' : ''}${formatAnswer(engine, state, variables, current.result.value)}`);
386
416
  firstAnswer = false;
@@ -433,16 +463,18 @@ async function solveQuery(engine, state, goal, reader, output) {
433
463
  return null;
434
464
  }
435
465
 
436
- function pullSolution(solver, solutions) {
466
+ function pullSolution(solver, solutions, reader) {
437
467
  const stream = solver.io.resolve('user_output');
438
468
  const originalWrite = stream?.write;
439
469
  let captured = '';
440
470
  if (stream) stream.write = (text) => { captured += String(text); };
471
+ const suspended = reader.suspendForComputation();
441
472
  try {
442
473
  return { result: solutions.next(), output: captured };
443
474
  } catch (error) {
444
475
  return { error, output: captured };
445
476
  } finally {
477
+ reader.resumeAfterComputation(suspended);
446
478
  if (stream) stream.write = originalWrite;
447
479
  }
448
480
  }
package/src/write.js CHANGED
@@ -22,6 +22,7 @@ function quoteAtom(name) {
22
22
  for (const ch of name) {
23
23
  if (ch === "'") out += "''";
24
24
  else if (ch === '\\') out += '\\\\';
25
+ else if (ch === '\x00') out += '\\0\\';
25
26
  else if (ch === '\x07') out += '\\a';
26
27
  else if (ch === '\b') out += '\\b';
27
28
  else if (ch === '\r') out += '\\r';
@@ -59,6 +60,7 @@ function writeString(value) {
59
60
  let out = '"';
60
61
  for (const ch of value) {
61
62
  if (ch === '"' || ch === '\\') out += `\\${ch}`;
63
+ else if (ch === '\x00') out += '\\0\\';
62
64
  else if (ch === '\x07') out += '\\a';
63
65
  else if (ch === '\b') out += '\\b';
64
66
  else if (ch === '\r') out += '\\r';
@@ -8,7 +8,7 @@ compliance audit and the remaining work before a full conformance claim.
8
8
 
9
9
  | Standard area | Implementation | Representative executable coverage |
10
10
  | --- | --- | --- |
11
- | 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`, `wg17_syntax_high_risk`, syntax error cases |
11
+ | 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`, `wg17_syntax_high_risk`, `wg17_invalid_octal_escape`, syntax error cases |
12
12
  | 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` |
13
13
  | 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` |
14
14
  | 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 |
@@ -102,7 +102,7 @@ Selected cases are adapted from the ISO and standard-core suites of Logtalk,
102
102
  Scryer Prolog, Trealla Prolog, and SWI-Prolog. Their upstream identifiers and licenses
103
103
  are recorded in [THIRD_PARTY.md](THIRD_PARTY.md).
104
104
 
105
- The corpus has 376 cases in `iso/` and 782 file-based conformance cases in
105
+ The corpus has 377 cases in `iso/` and 783 file-based conformance cases in
106
106
  total. The generated `conformance-report.md` is the authoritative source for
107
107
  current category totals. Together with regression, documentation-sync, API,
108
108
  example, and book-example checks, `npm test` is the release gate.
@@ -1,6 +1,6 @@
1
1
  % High-risk ISO syntax/write regressions, independently derived from
2
2
  % ISO/IEC 13211-1 clauses 6.3, 6.4 and 7.10 and cross-checked against the
3
- % public WG17 conformity-testing syntax cases (#1, #14-15, #28-31, #33-34).
3
+ % public WG17 conformity-testing syntax cases (#1, #14-15, #28-31, #33-34, #301).
4
4
 
5
5
  %% goal: wg17_numeric_escape
6
6
  wg17_numeric_escape :-
@@ -24,3 +24,7 @@ wg17_operator_precedence :-
24
24
  %% goal: wg17_canonical_list
25
25
  wg17_canonical_list :-
26
26
  write_canonical([a]), nl.
27
+
28
+ %% goal: wg17_zero_character_escape
29
+ wg17_zero_character_escape :-
30
+ writeq('\0\'), nl.
@@ -11,3 +11,5 @@ a :- b, c
11
11
  wg17_operator_precedence.
12
12
  '.'(a,[])
13
13
  wg17_canonical_list.
14
+ '\0\'
15
+ wg17_zero_character_escape.
@@ -0,0 +1 @@
1
+ parse line 1: bad octal escape
@@ -671,6 +671,31 @@ c4 ?- call((!;1)).
671
671
  assertEqual(result.stderr, '', 'stderr');
672
672
  },
673
673
  },
674
+ {
675
+ name: 'writeq preserves the NUL character with an ISO octal escape',
676
+ run: () => {
677
+ const result = runCli([], {
678
+ input: "writeq('\\0\\').\nhalt.\n",
679
+ });
680
+ assertEqual(result.status, 0, 'exit status');
681
+ assertEqual(result.stdout, "?- '\\0\\' true.\n?- ", 'stdout');
682
+ assertEqual(result.stderr, '', 'stderr');
683
+ },
684
+ },
685
+ {
686
+ name: 'REPL rejects non-octal numeric escapes without waiting for continuation',
687
+ run: () => {
688
+ const result = runCli([], {
689
+ input: "'\\8\\'.\nhalt.\n",
690
+ });
691
+ assertEqual(result.status, 0, 'exit status');
692
+ assertEqual(result.stdout,
693
+ '?- parse line 1: bad octal escape.\n' +
694
+ '?- ',
695
+ 'stdout');
696
+ assertEqual(result.stderr, '', 'stderr');
697
+ },
698
+ },
674
699
  {
675
700
  name: 'REPL read predicates consume following interactive term input',
676
701
  run: () => {
@@ -694,6 +719,27 @@ c4 ?- call((!;1)).
694
719
  assertEqual(result.stderr, '', 'stderr');
695
720
  },
696
721
  },
722
+ {
723
+ name: 'REPL releases terminal signals while a query computes',
724
+ run: () => {
725
+ if (process.platform === 'win32') return;
726
+ const available = spawnSync('sh', ['-c',
727
+ 'command -v script >/dev/null 2>&1 && script --version 2>/dev/null | grep -qi util-linux']);
728
+ if (available.status !== 0) return;
729
+ const command = `${shellQuote(process.execPath)} ${shellQuote(bin)}`;
730
+ const scriptCommand =
731
+ `{ printf 'repeat, fail.\n'; sleep 0.2; printf '\\003'; } | ` +
732
+ `script -qefc ${shellQuote(command)} /dev/null`;
733
+ const result = spawnSync('sh', ['-c', scriptCommand], {
734
+ cwd: packageRoot,
735
+ encoding: 'utf8',
736
+ timeout: 3000,
737
+ });
738
+ assertEqual(result.error?.code, undefined, 'terminal interrupt timeout');
739
+ assertEqual(result.status, 130, 'SIGINT exit status');
740
+ assertIncludes(result.stdout, '?- repeat, fail.', 'terminal query echo');
741
+ },
742
+ },
697
743
  {
698
744
  name: 'REPL accepts multiline period-terminated queries',
699
745
  run: () => {
@@ -1312,6 +1358,16 @@ c4 ?- call((!;1)).
1312
1358
  "answer('\\a').\n",
1313
1359
  'read/1 user_input numeric escape',
1314
1360
  );
1361
+
1362
+ const invalidOctal = String.fromCharCode(39, 92, 56, 92, 39, 46);
1363
+ assertEqual(
1364
+ run('answer(T) :- catch(read(T), E, T=E).\n', {
1365
+ goal: 'answer(T)',
1366
+ ioOptions: { input: invalidOctal },
1367
+ }).stdout,
1368
+ 'answer(error(syntax_error(read_term), eyeprolog)).\n',
1369
+ 'read/1 rejects non-octal numeric escape',
1370
+ );
1315
1371
  },
1316
1372
  },
1317
1373
  {
@@ -3074,6 +3130,10 @@ function between(text, startMarker, endMarker) {
3074
3130
  return text.slice(contentStart, end);
3075
3131
  }
3076
3132
 
3133
+ function shellQuote(value) {
3134
+ return `'${String(value).replaceAll("'", "'\"'\"'")}'`;
3135
+ }
3136
+
3077
3137
  function runCli(args, options = {}) {
3078
3138
  return spawnSync(process.execPath, [bin, ...args], {
3079
3139
  cwd: packageRoot,
@@ -5279,7 +5279,10 @@ message("café").
5279
5279
  Inside a quoted atom, a single quote is doubled: `'don''t'`. Quoted characters
5280
5280
  support the ISO symbolic control escapes such as `\a`, `\n`, and `\t`, and numeric
5281
5281
  octal or hexadecimal escapes are terminated by a backslash; for example, `'\7\'`
5282
- and `'\x7\'` both denote the alert character. Double-quoted lists use the same
5282
+ and `'\x7\'` both denote the alert character. The NUL character, when present in
5283
+ the processor character set, is written readably as `'\0\'`; digits `8` and `9`
5284
+ are not octal digits, so forms such as `'\8\'` are syntax errors rather than
5285
+ continuation input. Double-quoted lists use the same
5283
5286
  quoted-character escapes. Whitespace is insignificant
5284
5287
  between tokens, and a `%` comment continues to the end of its line. Doubling
5285
5288
  the active delimiter is also accepted inside either quoted form, so `""`
@@ -6213,7 +6216,11 @@ may span lines and end with a full stop, as in Scryer Prolog:
6213
6216
  When another answer exists in an interactive terminal, press `;`, Space, or
6214
6217
  `n` to ask for it immediately; no Return is needed. Return or `.` stops
6215
6218
  enumeration, `a` enumerates all remaining answers, `f` enumerates the next
6216
- five, and `h` displays the answer-control help. A
6219
+ five, and `h` displays the answer-control help. While a query is actively
6220
+ computing, EyeProlog releases readline's terminal signal handling: `Ctrl-C`
6221
+ therefore terminates the current EyeProlog process immediately, and on POSIX
6222
+ terminals `Ctrl-Z` suspends it in the usual shell-managed way. This remains a
6223
+ host top-level convention rather than an ISO/IEC 13211-1 language feature. A
6217
6224
  period-terminated query with no solutions prints `false.`; a solution without
6218
6225
  visible variable bindings prints `true.`. Answer substitutions are rendered as
6219
6226
  valid Prolog syntax under the current operator table: when a bound value would
@@ -6946,7 +6953,7 @@ precedence still need one-by-one closure. `test/conformance/ISO-MATRIX.md`
6946
6953
  maps language families to representative executable cases.
6947
6954
 
6948
6955
  The complete suite must pass before release. The file-based conformance corpus
6949
- contains 782 cases, including 376 focused ISO
6956
+ contains 783 cases, including 377 focused ISO
6950
6957
  cases derived from the success, failure, mode, and error behavior in
6951
6958
  ISO/IEC 13211-1 clauses 7 and 8, Part 2 modules, and Part 3 grammar rules.
6952
6959
  Separate exact-output suites check 189 normal