eyeprolog 1.2.7 → 1.2.9

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.
Files changed (34) hide show
  1. package/conformance-report.md +2 -2
  2. package/package.json +1 -1
  3. package/src/iso.js +16 -0
  4. package/src/parser.js +43 -7
  5. package/src/repl.js +12 -0
  6. package/src/solver.js +4 -1
  7. package/src/write.js +19 -18
  8. package/test/conformance/ISO-COMPLIANCE.md +1 -1
  9. package/test/conformance/ISO-MATRIX.md +1 -1
  10. package/test/conformance/README.md +1 -1
  11. package/test/conformance/cases/iso/streams_and_term_io.pl +11 -0
  12. package/test/conformance/cases/iso/wg17_syntax_high_risk.pl +16 -1
  13. package/test/conformance/cases/strings/trim_tabs_and_spaces.pl +1 -2
  14. package/test/conformance/cases/syntax/quoted_atom_with_newline_escape.pl +1 -2
  15. package/test/conformance/cases/syntax/string_with_tab_escape.pl +1 -1
  16. package/test/conformance/errors/iso/wg17_bad_character_code_escape.pl +1 -0
  17. package/test/conformance/errors/iso/wg17_literal_newline_in_quote.pl +2 -0
  18. package/test/conformance/errors/iso/wg17_literal_tab_in_quote.pl +1 -0
  19. package/test/conformance/errors/iso/wg17_non_iso_escape.pl +1 -0
  20. package/test/conformance/errors/iso/wg17_non_iso_unicode_escape.pl +1 -0
  21. package/test/conformance/errors/iso/wg17_unterminated_quoted_token.pl +1 -0
  22. package/test/conformance/expected/iso/streams_and_term_io.pl +1 -0
  23. package/test/conformance/expected/iso/wg17_syntax_high_risk.pl +7 -0
  24. package/test/conformance/expected-errors/iso/wg17_bad_character_code_escape.txt +1 -0
  25. package/test/conformance/expected-errors/iso/wg17_literal_newline_in_quote.txt +1 -0
  26. package/test/conformance/expected-errors/iso/wg17_literal_tab_in_quote.txt +1 -0
  27. package/test/conformance/expected-errors/iso/wg17_non_iso_escape.txt +1 -0
  28. package/test/conformance/expected-errors/iso/wg17_non_iso_unicode_escape.txt +1 -0
  29. package/test/conformance/expected-errors/iso/wg17_unterminated_quoted_token.txt +1 -0
  30. package/test/conformance/expected-errors/syntax/unclosed_quoted_atom.txt +1 -1
  31. package/test/conformance/expected-errors/syntax/unclosed_quoted_atom_rejected.txt +1 -1
  32. package/test/conformance/expected-errors/syntax/unclosed_string.txt +1 -1
  33. package/test/run-regression.mjs +136 -0
  34. package/the-art-of-eyeprolog.md +29 -13
@@ -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 | 210 | 0 | 0 | 377 |
14
+ | iso | 167 | 216 | 0 | 0 | 383 |
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** | **261** | **19** | **21** | **783** |
26
+ | **Total** | **482** | **267** | **19** | **21** | **789** |
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.2.7",
6
+ "version": "1.2.9",
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
@@ -1067,6 +1067,11 @@ function quotedEscapeEnd(source, index) {
1067
1067
  const escaped = source[index + 1] ?? '';
1068
1068
  if (!escaped) return index;
1069
1069
 
1070
+ // A quoted-token continuation escape consumes its newline. Accept CRLF as
1071
+ // the host representation of the same line boundary.
1072
+ if (escaped === '\n') return index + 1;
1073
+ if (escaped === '\r' && source[index + 2] === '\n') return index + 2;
1074
+
1070
1075
  // ISO 6.4.2.1 numeric escapes include a terminating backslash. Consume
1071
1076
  // that delimiter as part of the quoted character so stream scanning does
1072
1077
  // not mistake it for an escape of the quote which follows.
@@ -1096,6 +1101,13 @@ function* termTextCandidates(stream) {
1096
1101
  if (blockComment) { if (ch === '*' && next === '/') { blockComment = false; i++; } continue; }
1097
1102
  if (quote) {
1098
1103
  if (ch === '\\') i = quotedEscapeEnd(source, i);
1104
+ else if (ch !== ' ' && /^[\u0009-\u000d]$/.test(ch)) {
1105
+ // Literal layout characters are not quoted characters (6.4.2.1).
1106
+ // Surface the lexical error immediately even when there is no later
1107
+ // full stop; otherwise read/1 would misreport malformed input as EOF.
1108
+ yield { text: source.slice(stream.position, i + 1), end: i + 1, lexicalError: true };
1109
+ return;
1110
+ }
1099
1111
  else if (ch === quote && next === quote) i++;
1100
1112
  else if (ch === quote) quote = null;
1101
1113
  continue;
@@ -1140,6 +1152,10 @@ function readTermFromStream(stream, solver) {
1140
1152
  let sawCandidate = false;
1141
1153
  for (const candidate of termTextCandidates(stream)) {
1142
1154
  sawCandidate = true;
1155
+ if (candidate.lexicalError) {
1156
+ stream.position = candidate.end;
1157
+ throw new PrologError('syntax_error(read_term)');
1158
+ }
1143
1159
  try {
1144
1160
  const operatorState = createParserOperatorState(solver.program.operators.values(), false);
1145
1161
  const clauses = parseClauses(convertedTermText(candidate.text, solver), {
package/src/parser.js CHANGED
@@ -285,29 +285,58 @@ class Parser {
285
285
  break;
286
286
  }
287
287
  }
288
- readEscape(line) {
288
+ readEscape(line, options = {}) {
289
289
  const escaped = this.take();
290
290
  if (!escaped) throw new Error(`parse line ${line}: unterminated escape sequence`);
291
- if (escaped === '\n') return '';
291
+
292
+ // ISO 6.4.2 permits a continuation escape only inside quoted tokens: a
293
+ // backslash immediately followed by a newline. Character-code constants
294
+ // use a single quoted character and therefore cannot use continuation.
295
+ if (escaped === '\n') {
296
+ if (options.allowContinuation !== false) return '';
297
+ throw new Error(`parse line ${line}: bad escape sequence`);
298
+ }
299
+ if (escaped === '\r' && this.peek() === '\n') {
300
+ if (options.allowContinuation !== false) {
301
+ this.take();
302
+ return '';
303
+ }
304
+ throw new Error(`parse line ${line}: bad escape sequence`);
305
+ }
306
+
292
307
  const controls = { a: '\x07', b: '\b', r: '\r', f: '\f', t: '\t', n: '\n', v: '\v' };
293
308
  if (controls[escaped] != null) return controls[escaped];
309
+
294
310
  if (escaped === 'x') {
295
311
  let digits = '';
296
312
  while (/^[0-9A-Fa-f]$/.test(this.peek())) digits += this.take();
297
313
  if (!digits || this.take() !== '\\') throw new Error(`parse line ${line}: bad hexadecimal escape`);
298
- return String.fromCodePoint(Number.parseInt(digits, 16));
314
+ const code = Number.parseInt(digits, 16);
315
+ if (code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) {
316
+ throw new Error(`parse line ${line}: character escape out of range`);
317
+ }
318
+ return String.fromCodePoint(code);
299
319
  }
300
320
  if (/^[0-7]$/.test(escaped)) {
301
321
  let digits = escaped;
302
322
  while (/^[0-7]$/.test(this.peek())) digits += this.take();
303
323
  if (this.take() !== '\\') throw new Error(`parse line ${line}: bad octal escape`);
304
- return String.fromCodePoint(Number.parseInt(digits, 8));
324
+ const code = Number.parseInt(digits, 8);
325
+ if (code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) {
326
+ throw new Error(`parse line ${line}: character escape out of range`);
327
+ }
328
+ return String.fromCodePoint(code);
305
329
  }
306
330
  // A backslash followed by a decimal digit is numeric-escape syntax, but
307
331
  // ISO octal digits are limited to 0..7. Do not reinterpret \8 or \9 as
308
332
  // implementation-specific one-character escapes.
309
333
  if (/^[0-9]$/.test(escaped)) throw new Error(`parse line ${line}: bad octal escape`);
310
- return escaped;
334
+
335
+ // The only remaining ISO meta escapes are the four meta characters from
336
+ // 6.5.5. Forms such as \c, \d, \e, \u or \. are not quoted
337
+ // characters in ISO syntax and must not be silently accepted.
338
+ if (escaped === '\\' || escaped === "'" || escaped === '"' || escaped === '`') return escaped;
339
+ throw new Error(`parse line ${line}: bad escape sequence`);
311
340
  }
312
341
  nextToken() {
313
342
  // The tokenizer keeps just enough state for useful parse-line errors and
@@ -373,6 +402,11 @@ class Parser {
373
402
  }
374
403
  } else if (value === '\\' && this.peek()) {
375
404
  value = this.readEscape(line);
405
+ } else if (value !== ' ' && isWhitespaceCode(value.charCodeAt(0))) {
406
+ // ISO 6.4.2.1 allows an ordinary space in a quoted character, but
407
+ // not literal layout characters such as tab or newline. Newlines
408
+ // are permitted only through the continuation escape handled above.
409
+ throw new Error(`parse line ${line}: layout character in quoted term`);
376
410
  }
377
411
  text += value;
378
412
  }
@@ -398,8 +432,10 @@ class Parser {
398
432
  this.take();
399
433
  this.take();
400
434
  let value = this.take();
401
- if (!value || value === '\n') throw new Error(`parse line ${line}: bad character code constant`);
402
- if (value === '\\') value = this.readEscape(line);
435
+ if (!value || (value !== ' ' && isWhitespaceCode(value.charCodeAt(0)))) {
436
+ throw new Error(`parse line ${line}: bad character code constant`);
437
+ }
438
+ if (value === '\\') value = this.readEscape(line, { allowContinuation: false });
403
439
  const code = value.codePointAt(0);
404
440
  return { type: TOK.NUMBER, text: String(negative ? -code : code), line };
405
441
  }
package/src/repl.js CHANGED
@@ -338,6 +338,12 @@ function quotedEscapeEnd(source, index) {
338
338
  const escaped = source[index + 1] ?? '';
339
339
  if (!escaped) return index;
340
340
 
341
+ // A backslash-newline pair is a continuation escape (6.4.2), so the
342
+ // newline is not a literal quoted character. Accept CRLF as the host text
343
+ // representation of the same continuation boundary.
344
+ if (escaped === '\n') return index + 1;
345
+ if (escaped === '\r' && source[index + 2] === '\n') return index + 2;
346
+
341
347
  // ISO 6.4.2.1 octal and hexadecimal escapes are terminated by a
342
348
  // backslash. Consume that terminator as part of the escape so the REPL
343
349
  // scanner does not mistake it for an escape of the following quote.
@@ -383,6 +389,12 @@ function terminalFullStop(source) {
383
389
  if (quote != null) {
384
390
  if (ch === '\\') {
385
391
  i = quotedEscapeEnd(source, i);
392
+ } else if (ch === '\n' || ch === '\r') {
393
+ // A literal newline can never be repaired by a later line: ISO
394
+ // 6.4.2.1 excludes it from quoted characters. Return this boundary
395
+ // immediately so the parser reports a syntax error instead of the
396
+ // top level prompting forever for a closing quote.
397
+ return i;
386
398
  } else if (ch === quote) {
387
399
  if (next === quote) i++;
388
400
  else quote = null;
package/src/solver.js CHANGED
@@ -47,7 +47,10 @@ export class Solver {
47
47
  this.maxInferences = options.maxInferences ?? Infinity;
48
48
  this.inferences = 0;
49
49
  this.inferenceLimitExceeded = false;
50
- this.solutionLimit = options.solutionLimit ?? 10000000;
50
+ // Do not impose an implicit answer cap. Infinite and very large searches are
51
+ // part of normal Prolog semantics; callers that need a resource bound can
52
+ // still supply solutionLimit explicitly.
53
+ this.solutionLimit = options.solutionLimit ?? Infinity;
51
54
  this.solutionsSeen = 0;
52
55
  this.prologFlags = options.prologFlags ?? defaultPrologFlags('error', this.isoStrict);
53
56
  if (this.isoStrict) {
package/src/write.js CHANGED
@@ -7,6 +7,23 @@ import {
7
7
  const graphicAtomCharacters = new Set('!#$&*+-/<=>@^~\\'.split(''));
8
8
  const compactInfixOperators = new Set([':', '..']);
9
9
 
10
+ function quotedControlEscape(ch) {
11
+ if (ch === '\x00') return '\\0\\';
12
+ if (ch === '\x07') return '\\a';
13
+ if (ch === '\b') return '\\b';
14
+ if (ch === '\r') return '\\r';
15
+ if (ch === '\f') return '\\f';
16
+ if (ch === '\t') return '\\t';
17
+ if (ch === '\n') return '\\n';
18
+ if (ch === '\v') return '\\v';
19
+ const code = ch.codePointAt(0);
20
+ // Other C0 controls and DEL have no ISO symbolic-control escape. Emit an
21
+ // octal escape so quoted output remains valid read-back syntax instead of
22
+ // leaking a raw control character into the output stream.
23
+ if (code < 0x20 || code === 0x7f) return `\\${code.toString(8)}\\`;
24
+ return null;
25
+ }
26
+
10
27
  function atomNeedsQuotes(name) {
11
28
  if (!name) return true;
12
29
  if (name === '[]' || name === '{}') return false;
@@ -22,15 +39,7 @@ function quoteAtom(name) {
22
39
  for (const ch of name) {
23
40
  if (ch === "'") out += "''";
24
41
  else if (ch === '\\') out += '\\\\';
25
- else if (ch === '\x00') out += '\\0\\';
26
- else if (ch === '\x07') out += '\\a';
27
- else if (ch === '\b') out += '\\b';
28
- else if (ch === '\r') out += '\\r';
29
- else if (ch === '\f') out += '\\f';
30
- else if (ch === '\t') out += '\\t';
31
- else if (ch === '\n') out += '\\n';
32
- else if (ch === '\v') out += '\\v';
33
- else out += ch;
42
+ else out += quotedControlEscape(ch) ?? ch;
34
43
  }
35
44
  return out + "'";
36
45
  }
@@ -60,15 +69,7 @@ function writeString(value) {
60
69
  let out = '"';
61
70
  for (const ch of value) {
62
71
  if (ch === '"' || ch === '\\') out += `\\${ch}`;
63
- else if (ch === '\x00') out += '\\0\\';
64
- else if (ch === '\x07') out += '\\a';
65
- else if (ch === '\b') out += '\\b';
66
- else if (ch === '\r') out += '\\r';
67
- else if (ch === '\f') out += '\\f';
68
- else if (ch === '\t') out += '\\t';
69
- else if (ch === '\n') out += '\\n';
70
- else if (ch === '\v') out += '\\v';
71
- else out += ch;
72
+ else out += quotedControlEscape(ch) ?? ch;
72
73
  }
73
74
  return out + '"';
74
75
  }
@@ -30,7 +30,7 @@ error-ordering alternative to an individual executable assertion.
30
30
 
31
31
  | Standard area | Status | Current evidence |
32
32
  | --- | --- | --- |
33
- | Clause 6 — tokens, terms, lists, operators, quoted text | audit | `lexical_and_curly_terms`, `scryer_lexical_terms`, operator suites, syntax-error cases, `wg17_syntax_high_risk`, writer/read-back regressions. |
33
+ | Clause 6 — tokens, terms, lists, operators, quoted text | audit | `lexical_and_curly_terms`, `scryer_lexical_terms`, operator suites, syntax-error cases, `wg17_syntax_high_risk`, quoted-layout/escape error cases, writer/read-back regressions. |
34
34
  | 7.1-7.3 — term types, term order, unification | audit | Standard-order, identity, finite-tree and occurs-check suites, Corrigendum 2 term predicates. |
35
35
  | 7.4 — Prolog text and directives | audit | All Part 1 directive indicators are parsed; include/ensure-loaded/operator/flag/character-conversion behavior has executable coverage. Cross-text `multifile/1` and ordering constraints require explicit shall-by-shall audit. |
36
36
  | 7.5-7.6 — database and term/clause conversion | audit | Dynamic database and logical-update-view suites. Strict mode restores Part 1 private-static/public-dynamic `clause/2` access. Public/private and multi-text requirements still need complete mapping. |
@@ -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`, `wg17_invalid_octal_escape`, 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`, `wg17_unterminated_quoted_token`, `wg17_literal_newline_in_quote`, `wg17_non_iso_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 377 cases in `iso/` and 783 file-based conformance cases in
105
+ The corpus has 383 cases in `iso/` and 789 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.
@@ -85,3 +85,14 @@ numeric_escape_term_input(ok) :-
85
85
  A == '\a',
86
86
  B == '\a'.
87
87
 
88
+
89
+ %% goal: malformed_quoted_term_input(ok)
90
+
91
+ malformed_quoted_term_input(ok) :-
92
+ open('/tmp/eyeprolog-iso-bad-quoted-term.txt', write, Output, []),
93
+ put_code(Output, 39), nl(Output),
94
+ close(Output),
95
+ open('/tmp/eyeprolog-iso-bad-quoted-term.txt', read, Input, []),
96
+ catch(read(Input, _), error(syntax_error(read_term), _), Caught = yes),
97
+ close(Input),
98
+ Caught == yes.
@@ -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, #301).
3
+ % public WG17 conformity-testing syntax cases (#1, #7-10, #14-15, #18, #28-31, #33-34, #301, #315-316).
4
4
 
5
5
  %% goal: wg17_numeric_escape
6
6
  wg17_numeric_escape :-
@@ -28,3 +28,18 @@ wg17_canonical_list :-
28
28
  %% goal: wg17_zero_character_escape
29
29
  wg17_zero_character_escape :-
30
30
  writeq('\0\'), nl.
31
+
32
+ %% goal: wg17_continuation_escapes
33
+ wg17_continuation_escapes :-
34
+ writeq('\
35
+ '), nl,
36
+ writeq('\
37
+ a'), nl,
38
+ writeq('a\
39
+ b'), nl,
40
+ writeq('a\
41
+ b'), nl.
42
+
43
+ %% goal: wg17_non_symbolic_control_write
44
+ wg17_non_symbolic_control_write :-
45
+ writeq('\033\'), nl.
@@ -1,4 +1,3 @@
1
1
  %% goal: answer(X0)
2
2
 
3
- answer(X) :- trim(' hello
4
- ', X).
3
+ answer(X) :- trim('\t hello \n', X).
@@ -1,4 +1,3 @@
1
1
  %% goal: answer(X0)
2
2
 
3
- answer('line
4
- break').
3
+ answer('line\nbreak').
@@ -1,3 +1,3 @@
1
1
  %% goal: answer(X0)
2
2
 
3
- answer("a b").
3
+ answer("a\tb").
@@ -0,0 +1 @@
1
+ writeq('\e').
@@ -0,0 +1 @@
1
+ writeq('\u0021').
@@ -5,3 +5,4 @@ default_streams(ok).
5
5
  io_marker
6
6
  standard_write(ok).
7
7
  numeric_escape_term_input(ok).
8
+ malformed_quoted_term_input(ok).
@@ -13,3 +13,10 @@ wg17_operator_precedence.
13
13
  wg17_canonical_list.
14
14
  '\0\'
15
15
  wg17_zero_character_escape.
16
+ ''
17
+ a
18
+ ab
19
+ 'a b'
20
+ wg17_continuation_escapes.
21
+ '\33\'
22
+ wg17_non_symbolic_control_write.
@@ -0,0 +1 @@
1
+ parse line 1: bad escape sequence
@@ -0,0 +1 @@
1
+ parse line 1: layout character in quoted term
@@ -0,0 +1 @@
1
+ parse line 1: layout character in quoted term
@@ -0,0 +1 @@
1
+ parse line 1: bad escape sequence
@@ -0,0 +1 @@
1
+ parse line 1: bad escape sequence
@@ -0,0 +1 @@
1
+ parse line 1: layout character in quoted term
@@ -1 +1 @@
1
- parse line 1: unterminated quoted term
1
+ parse line 1: layout character in quoted term
@@ -1 +1 @@
1
- parse line 1: unterminated quoted term
1
+ parse line 1: layout character in quoted term
@@ -1 +1 @@
1
- parse line 1: unterminated quoted term
1
+ parse line 1: layout character in quoted term
@@ -696,6 +696,127 @@ c4 ?- call((!;1)).
696
696
  assertEqual(result.stderr, '', 'stderr');
697
697
  },
698
698
  },
699
+ {
700
+ name: 'REPL rejects an unterminated quote at the line boundary instead of waiting',
701
+ run: () => {
702
+ const result = runCli([], {
703
+ input: "'\nhalt.\n",
704
+ });
705
+ assertEqual(result.status, 0, 'exit status');
706
+ assertEqual(result.stdout,
707
+ '?- parse line 1: unterminated quoted term.\n' +
708
+ '?- ',
709
+ 'stdout');
710
+ assertEqual(result.stderr, '', 'stderr');
711
+ },
712
+ },
713
+ {
714
+ name: 'REPL rejects a literal newline in a quoted token immediately',
715
+ run: () => {
716
+ const result = runCli([], {
717
+ // The first input line ends while a quote is open. ISO 6.4.2.1
718
+ // makes that newline a lexical error unless it is escaped by the
719
+ // immediately preceding backslash. The following true/0 proves
720
+ // that the top level did not consume another line as continuation.
721
+ input: "writeq('\ntrue.\nhalt.\n",
722
+ });
723
+ assertEqual(result.status, 0, 'exit status');
724
+ assertEqual(result.stdout,
725
+ '?- parse line 1: unterminated quoted term.\n' +
726
+ '?- true.\n' +
727
+ '?- ',
728
+ 'stdout');
729
+ assertEqual(result.stderr, '', 'stderr');
730
+ },
731
+ },
732
+ {
733
+ name: 'parser matches WG17 quoted-character and escape syntax cluster',
734
+ run: () => {
735
+ const invalid = [
736
+ ['#2 lone quote', "'\n"],
737
+ ['#5 literal horizontal tab', "writeq('\t')"],
738
+ ['#6 literal newline', "writeq('\n')"],
739
+ ['#11 backslash-space', String.raw`writeq('\ ')`],
740
+ ['#12 backslash-horizontal-tab', "writeq('\\\t')"],
741
+ ['#16 non-ISO c escape', String.raw`writeq('\ca')`],
742
+ ['#241 non-ISO d escape', String.raw`writeq('\d')`],
743
+ ['#17 non-ISO e escape', String.raw`writeq('\e')`],
744
+ ['#19 non-ISO e in char_code/2', String.raw`char_code('\e', C)`],
745
+ ['#21 non-ISO d in char_code/2', String.raw`char_code('\d', C)`],
746
+ ['#22 non-ISO u escape', String.raw`writeq('\u1')`],
747
+ ['#312 non-ISO Unicode escape', String.raw`writeq('\u0021')`],
748
+ ['#314 non-ISO Unicode double-quote escape', String.raw`writeq("\u0021")`],
749
+ ['#23 non-ISO character-code escape', String.raw`X = 0'\u1`],
750
+ ['#24 unterminated quoted argument', "writeq('\n"],
751
+ ['#26 continuation followed by unterminated quote', "'\\\n''"],
752
+ ['#210 escaped dot character code', String.raw`X = 0'\.`],
753
+ ['#211 escaped dot character code before layout', String.raw`X = 0'\. `],
754
+ ];
755
+ for (const [label, source] of invalid) {
756
+ let error = null;
757
+ try {
758
+ parseGoalText(source);
759
+ } catch (caught) {
760
+ error = caught;
761
+ }
762
+ if (error == null) throw new Error(`${label} unexpectedly parsed`);
763
+ }
764
+
765
+ const valid = [
766
+ ['#7 empty continuation', "writeq('\\\n')"],
767
+ ['#8 leading continuation', "writeq('\\\na')"],
768
+ ['#9 embedded continuation', "writeq('a\\\nb')"],
769
+ ['#10 continuation before space', "writeq('a\\\n b')"],
770
+ ['#13 symbolic tab', String.raw`writeq('\t')`],
771
+ ['#14 symbolic alert', String.raw`writeq('\a')`],
772
+ ['#15 octal alert', String.raw`writeq('\7\')`],
773
+ ['#18 octal escape', String.raw`writeq('\033\')`],
774
+ ['#301 NUL escape', String.raw`writeq('\0\')`],
775
+ ['#315 hexadecimal escape', String.raw`writeq('\x21\')`],
776
+ ['#316 padded hexadecimal escape', String.raw`writeq('\x0021\')`],
777
+ ['#38 double-quoted meta escapes', "\"\\'\\`\\\"\" = \"'`\"\"\""],
778
+ ['#39 single-quoted meta escapes', "'\\'\\`\\\"' = '''`\"'"],
779
+ ['#40 writeq meta escapes', "writeq('\\'\\`\\\"\\\"')"],
780
+ ['#41 meta backslash escape', String.raw`('\\') = (\)`],
781
+ ];
782
+ for (const [label, source] of valid) {
783
+ try {
784
+ parseGoalText(source);
785
+ } catch (error) {
786
+ throw new Error(`${label} should parse: ${error?.message ?? error}`);
787
+ }
788
+ }
789
+ },
790
+ },
791
+ {
792
+ name: 'stream term input reports malformed quoted layout as syntax_error',
793
+ run: () => {
794
+ for (const [label, input] of [
795
+ ['lone quote', "'\n"],
796
+ ['literal newline', "writeq('\n').\n"],
797
+ ['literal tab', "writeq('\t').\n"],
798
+ ]) {
799
+ let error = null;
800
+ try {
801
+ runEyeProlog('', { goal: 'read(X)', ioOptions: { input } });
802
+ } catch (caught) {
803
+ error = caught;
804
+ }
805
+ assertEqual(error?.message, 'error(syntax_error(read_term))', `${label} read error`);
806
+ }
807
+ },
808
+ },
809
+ {
810
+ name: 'writeq uses ISO numeric escapes for non-symbolic control characters',
811
+ run: () => {
812
+ const result = runCli([], {
813
+ input: "writeq('\\033\\').\nhalt.\n",
814
+ });
815
+ assertEqual(result.status, 0, 'exit status');
816
+ assertEqual(result.stdout, "?- '\\33\\' true.\n?- ", 'stdout');
817
+ assertEqual(result.stderr, '', 'stderr');
818
+ },
819
+ },
699
820
  {
700
821
  name: 'REPL read predicates consume following interactive term input',
701
822
  run: () => {
@@ -1942,6 +2063,21 @@ open(X) :- candidate(X), \\+ closed(X).
1942
2063
  assertEqual(answers.join('\n'), 'p(a)\np(b)', 'answers');
1943
2064
  },
1944
2065
  },
2066
+ {
2067
+ name: 'solver has no implicit solution limit',
2068
+ run: () => {
2069
+ const program = Program.parse('p(a).\n');
2070
+ const solver = new Solver(program);
2071
+ assertEqual(String(solver.solutionLimit), 'Infinity', 'default solution limit');
2072
+ // Crossing the former 10,000,000-answer ceiling must not make an
2073
+ // otherwise available answer disappear. This exercises the boundary
2074
+ // without making the regression suite enumerate ten million answers.
2075
+ solver.solutionsSeen = 10_000_000;
2076
+ const goal = parseGoalText('p(X)');
2077
+ const answers = [...solver.solve([goal], new Env(), 0)].map((env) => termToString(goal, env, true));
2078
+ assertEqual(answers.join('\n'), 'p(a)', 'answer beyond former default ceiling');
2079
+ },
2080
+ },
1945
2081
  {
1946
2082
  name: 'solver honors solution limits',
1947
2083
  run: () => {
@@ -1805,7 +1805,13 @@ const solver = new Solver(program, {
1805
1805
  ```
1806
1806
 
1807
1807
  The limits are safety ceilings, not logical declarations. Reaching one may
1808
- truncate search; it does not prove that no further answer exists.
1808
+ truncate search; it does not prove that no further answer exists. At the `Solver`
1809
+ API boundary, `solutionLimit` is opt-in: if it is omitted, ordinary solving and
1810
+ child searches that inherit the solver limit do not stop after a fixed number of
1811
+ solutions. This matters for re-executable goals such as `repeat/0` and for
1812
+ library relations such as `call_nth/2`; an implementation safety threshold must
1813
+ not turn a still re-executable search into logical failure. Embedders that need
1814
+ a finite answer budget should pass `solutionLimit` explicitly.
1809
1815
 
1810
1816
  ### Implementation boundary
1811
1817
 
@@ -5276,17 +5282,27 @@ city('München').
5276
5282
  message("café").
5277
5283
  ```
5278
5284
 
5279
- Inside a quoted atom, a single quote is doubled: `'don''t'`. Quoted characters
5280
- support the ISO symbolic control escapes such as `\a`, `\n`, and `\t`, and numeric
5281
- octal or hexadecimal escapes are terminated by a backslash; for example, `'\7\'`
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
5286
- quoted-character escapes. Whitespace is insignificant
5287
- between tokens, and a `%` comment continues to the end of its line. Doubling
5288
- the active delimiter is also accepted inside either quoted form, so `""`
5289
- inside double-quoted notation denotes one literal double quote character.
5285
+ Inside a quoted atom, a single quote is doubled: `'don''t'`. EyeProlog follows
5286
+ the ISO quoted-character grammar rather than accepting arbitrary backslash
5287
+ escapes. The symbolic control escapes are `\a`, `\b`, `\r`, `\f`, `\t`,
5288
+ `\n`, and `\v`; the meta characters backslash, single quote, double quote, and
5289
+ back quote may be escaped after a backslash; and numeric octal or hexadecimal
5290
+ escapes are terminated by a backslash. For example, `'\7\'` and `'\x7\'`
5291
+ both denote the alert character. Forms such as `\c`, `\d`, `\e`, `\u`, `\.`
5292
+ and `\ ` are not ISO quoted-character escapes and are syntax errors.
5293
+
5294
+ A literal layout character other than ordinary space is not a quoted
5295
+ character. In particular, a literal tab or newline inside quotes is a syntax
5296
+ error. A quoted token can cross a line boundary only through a continuation
5297
+ escape: a backslash immediately followed by the newline, which contributes no
5298
+ character to the atom. The NUL character is written readably as `'\0\'`;
5299
+ digits `8` and `9` are not octal digits, so forms such as `'\8\'` are syntax
5300
+ errors. `writeq/1` uses octal escapes for other non-symbolic control characters,
5301
+ for example ESC is written as `'\33\'`. Double-quoted lists use the same
5302
+ quoted-character rules. Whitespace is insignificant between tokens, and a `%`
5303
+ comment continues to the end of its line. Doubling the active delimiter is
5304
+ also accepted inside either quoted form, so `""` inside double-quoted notation
5305
+ denotes one literal double quote character.
5290
5306
 
5291
5307
  Graphic atoms may contain `#$&*+-/<=>@^~\;`. A colon is the Part 2 module
5292
5308
  qualification operator in `Module:Goal`; quote an atom whose name itself
@@ -6959,7 +6975,7 @@ precedence still need one-by-one closure. `test/conformance/ISO-MATRIX.md`
6959
6975
  maps language families to representative executable cases.
6960
6976
 
6961
6977
  The complete suite must pass before release. The file-based conformance corpus
6962
- contains 783 cases, including 377 focused ISO
6978
+ contains 789 cases, including 383 focused ISO
6963
6979
  cases derived from the success, failure, mode, and error behavior in
6964
6980
  ISO/IEC 13211-1 clauses 7 and 8, Part 2 modules, and Part 3 grammar rules.
6965
6981
  Separate exact-output suites check 189 normal