eyeprolog 1.2.6 → 1.2.8

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 (33) hide show
  1. package/conformance-report.md +2 -2
  2. package/package.json +1 -1
  3. package/src/iso.js +56 -19
  4. package/src/parser.js +43 -7
  5. package/src/repl.js +82 -4
  6. package/src/write.js +19 -18
  7. package/test/conformance/ISO-COMPLIANCE.md +1 -1
  8. package/test/conformance/ISO-MATRIX.md +1 -1
  9. package/test/conformance/README.md +1 -1
  10. package/test/conformance/cases/iso/streams_and_term_io.pl +11 -0
  11. package/test/conformance/cases/iso/wg17_syntax_high_risk.pl +16 -1
  12. package/test/conformance/cases/strings/trim_tabs_and_spaces.pl +1 -2
  13. package/test/conformance/cases/syntax/quoted_atom_with_newline_escape.pl +1 -2
  14. package/test/conformance/cases/syntax/string_with_tab_escape.pl +1 -1
  15. package/test/conformance/errors/iso/wg17_bad_character_code_escape.pl +1 -0
  16. package/test/conformance/errors/iso/wg17_literal_newline_in_quote.pl +2 -0
  17. package/test/conformance/errors/iso/wg17_literal_tab_in_quote.pl +1 -0
  18. package/test/conformance/errors/iso/wg17_non_iso_escape.pl +1 -0
  19. package/test/conformance/errors/iso/wg17_non_iso_unicode_escape.pl +1 -0
  20. package/test/conformance/errors/iso/wg17_unterminated_quoted_token.pl +1 -0
  21. package/test/conformance/expected/iso/streams_and_term_io.pl +1 -0
  22. package/test/conformance/expected/iso/wg17_syntax_high_risk.pl +7 -0
  23. package/test/conformance/expected-errors/iso/wg17_bad_character_code_escape.txt +1 -0
  24. package/test/conformance/expected-errors/iso/wg17_literal_newline_in_quote.txt +1 -0
  25. package/test/conformance/expected-errors/iso/wg17_literal_tab_in_quote.txt +1 -0
  26. package/test/conformance/expected-errors/iso/wg17_non_iso_escape.txt +1 -0
  27. package/test/conformance/expected-errors/iso/wg17_non_iso_unicode_escape.txt +1 -0
  28. package/test/conformance/expected-errors/iso/wg17_unterminated_quoted_token.txt +1 -0
  29. package/test/conformance/expected-errors/syntax/unclosed_quoted_atom.txt +1 -1
  30. package/test/conformance/expected-errors/syntax/unclosed_quoted_atom_rejected.txt +1 -1
  31. package/test/conformance/expected-errors/syntax/unclosed_string.txt +1 -1
  32. package/test/run-regression.mjs +168 -0
  33. package/the-art-of-eyeprolog.md +38 -22
@@ -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.6",
6
+ "version": "1.2.8",
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;
@@ -1135,28 +1147,53 @@ function convertedTermText(text, solver) {
1135
1147
  return result;
1136
1148
  }
1137
1149
  function readTermFromStream(stream, solver) {
1138
- let sawCandidate = false;
1139
- for (const candidate of termTextCandidates(stream)) {
1140
- sawCandidate = true;
1141
- try {
1142
- const operatorState = createParserOperatorState(solver.program.operators.values(), false);
1143
- const clauses = parseClauses(convertedTermText(candidate.text, solver), {
1144
- sourceMetadata: false,
1145
- operatorState,
1146
- doubleQuotes: solver.prologFlags.get('double_quotes')?.value?.name ?? 'chars',
1147
- });
1148
- if (clauses.length !== 1 || clauses[0].body.length) throw new Error('bad term');
1149
- stream.position = candidate.end;
1150
- return clauses[0].head;
1151
- } catch (_) {
1152
- // A dot inside a graphic operator, such as =.., is only a possible
1153
- // terminator. Keep scanning until a complete term parses.
1150
+ let requestedInteractiveTerm = false;
1151
+ while (true) {
1152
+ let sawCandidate = false;
1153
+ for (const candidate of termTextCandidates(stream)) {
1154
+ sawCandidate = true;
1155
+ if (candidate.lexicalError) {
1156
+ stream.position = candidate.end;
1157
+ throw new PrologError('syntax_error(read_term)');
1158
+ }
1159
+ try {
1160
+ const operatorState = createParserOperatorState(solver.program.operators.values(), false);
1161
+ const clauses = parseClauses(convertedTermText(candidate.text, solver), {
1162
+ sourceMetadata: false,
1163
+ operatorState,
1164
+ doubleQuotes: solver.prologFlags.get('double_quotes')?.value?.name ?? 'chars',
1165
+ });
1166
+ if (clauses.length !== 1 || clauses[0].body.length) throw new Error('bad term');
1167
+ stream.position = candidate.end;
1168
+ return clauses[0].head;
1169
+ } catch (_) {
1170
+ // A dot inside a graphic operator, such as =.., is only a possible
1171
+ // terminator. Keep scanning until a complete term parses.
1172
+ }
1154
1173
  }
1174
+
1175
+ // The interactive top level may attach a synchronous reader to the
1176
+ // standard user_input stream. Ask it for one complete read-term only when
1177
+ // this read operation actually reaches the end of buffered input. This is
1178
+ // deliberately a stream hook, not goal-shape recognition, so conjunctions
1179
+ // and reads reached through user predicates behave the same as read/1.
1180
+ if (!sawCandidate && !requestedInteractiveTerm &&
1181
+ typeof stream.interactiveReadTerm === 'function') {
1182
+ requestedInteractiveTerm = true;
1183
+ const text = stream.interactiveReadTerm();
1184
+ if (text != null) {
1185
+ stream.content += String(text);
1186
+ stream.pastEnd = false;
1187
+ continue;
1188
+ }
1189
+ }
1190
+
1191
+ stream.position = String(stream.content).length;
1192
+ if (!sawCandidate) return atom('end_of_file');
1193
+ throw new PrologError('syntax_error(read_term)');
1155
1194
  }
1156
- stream.position = String(stream.content).length;
1157
- if (!sawCandidate) return atom('end_of_file');
1158
- throw new PrologError('syntax_error(read_term)');
1159
1195
  }
1196
+
1160
1197
  function* readBuiltin({ solver, goal, env }) {
1161
1198
  const stream = inputStreamFor(solver, goal, env);
1162
1199
  if (stream.type !== 'text') throw new PrologError('permission_error(input, binary_stream)', streamHandle(stream.id));
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
@@ -1,5 +1,6 @@
1
1
  // Interactive top level for the eyeprolog command.
2
2
  import fs from 'node:fs/promises';
3
+ import { readSync } from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import { createInterface } from 'node:readline';
5
6
  import { formalErrorTerm } from './iso.js';
@@ -20,7 +21,7 @@ export async function runRepl(engine, options = {}) {
20
21
  const errorOutput = options.errorOutput ?? process.stderr;
21
22
  const reader = new LineReader(input, output);
22
23
  const sources = [];
23
- let state = makeState(engine, sources, output, options);
24
+ let state = makeState(engine, sources, output, options, null, reader);
24
25
  let exitCode = 0;
25
26
 
26
27
  try {
@@ -34,7 +35,7 @@ export async function runRepl(engine, options = {}) {
34
35
  const goal = parseGoal(engine, state, text);
35
36
  if (!options.isoStrict && isUseModuleGoal(goal)) {
36
37
  sources.push({ text: `:- ${text}.\n`, filename: '<repl>' });
37
- state = makeState(engine, sources, output, options, state);
38
+ state = makeState(engine, sources, output, options, state, reader);
38
39
  runWithTerminalSignals(reader, () => state.solver.runInitializations());
39
40
  output.write(' true.\n');
40
41
  continue;
@@ -42,7 +43,7 @@ export async function runRepl(engine, options = {}) {
42
43
  const consultFiles = options.isoStrict ? null : consultDesignations(engine, goal);
43
44
  if (consultFiles != null) {
44
45
  for (const filename of consultFiles) sources.push(await readSource(filename));
45
- state = makeState(engine, sources, output, options, state);
46
+ state = makeState(engine, sources, output, options, state, reader);
46
47
  runWithTerminalSignals(reader, () => state.solver.runInitializations());
47
48
  output.write(' true.\n');
48
49
  continue;
@@ -78,6 +79,8 @@ export async function runRepl(engine, options = {}) {
78
79
  }
79
80
 
80
81
  class LineReader {
82
+ static syncWait = new Int32Array(new SharedArrayBuffer(4));
83
+
81
84
  constructor(input, output) {
82
85
  this.input = input;
83
86
  this.output = output;
@@ -142,6 +145,57 @@ class LineReader {
142
145
  return control;
143
146
  }
144
147
 
148
+ canReadTermSynchronously() {
149
+ return this.terminal && Number.isInteger(this.input.fd);
150
+ }
151
+
152
+ readInteractiveTermSync() {
153
+ if (!this.canReadTermSynchronously()) return null;
154
+ let source = '';
155
+ let prompt = '|: ';
156
+ while (true) {
157
+ this.output.write(prompt);
158
+ const line = this.readTerminalLineSync();
159
+ if (line == null) return source.trim() ? source : null;
160
+ source += `${line}\n`;
161
+ const end = terminalFullStop(source);
162
+ if (end >= 0) return source.slice(0, end + 1) + '\n';
163
+ prompt = '| ';
164
+ }
165
+ }
166
+
167
+ readTerminalLineSync() {
168
+ const byte = Buffer.allocUnsafe(1);
169
+ const bytes = [];
170
+ while (true) {
171
+ let count;
172
+ try {
173
+ count = readSync(this.input.fd, byte, 0, 1, null);
174
+ } catch (error) {
175
+ // Node keeps terminal fds non-blocking. Once readline is suspended,
176
+ // a synchronous read can therefore report EAGAIN while waiting for
177
+ // the user. Sleep briefly and retry; terminal signals still retain
178
+ // their native action because no readline signal handler is installed.
179
+ if (error?.code === 'EAGAIN' || error?.code === 'EWOULDBLOCK') {
180
+ Atomics.wait(LineReader.syncWait, 0, 0, 10);
181
+ continue;
182
+ }
183
+ throw error;
184
+ }
185
+ // In canonical terminal mode Ctrl-D on an empty line makes read(2)
186
+ // return zero bytes. Scope that EOF to the current Prolog read rather
187
+ // than closing the outer readline iterator / top-level loop.
188
+ if (count === 0) {
189
+ return bytes.length === 0 ? null : Buffer.from(bytes).toString('utf8');
190
+ }
191
+ if (byte[0] === 10) {
192
+ if (bytes.at(-1) === 13) bytes.pop();
193
+ return Buffer.from(bytes).toString('utf8');
194
+ }
195
+ bytes.push(byte[0]);
196
+ }
197
+ }
198
+
145
199
  suspendForComputation() {
146
200
  if (!this.terminal || !this.readline) return false;
147
201
  // Node readline installs terminal signal handling while the interface is
@@ -175,7 +229,7 @@ function runWithTerminalSignals(reader, operation) {
175
229
  }
176
230
  }
177
231
 
178
- function makeState(engine, sources, output, options = {}, previousState = null) {
232
+ function makeState(engine, sources, output, options = {}, previousState = null, reader = null) {
179
233
  const strictIso = options.isoStrict === true;
180
234
  const program = engine.Program.parseSources(sources, { strictIso, sourceMetadata: strictIso });
181
235
  const solver = new engine.Solver(program, {
@@ -183,6 +237,14 @@ function makeState(engine, sources, output, options = {}, previousState = null)
183
237
  isoStrict: strictIso,
184
238
  ioOptions: { write: (text) => output.write(String(text)) },
185
239
  });
240
+ const userInput = solver.io.resolve('user_input');
241
+ if (userInput && reader?.canReadTermSynchronously()) {
242
+ // The solver is synchronous. While pullSolution() has readline suspended,
243
+ // let ISO term input request a complete terminal term exactly when read/1-2
244
+ // or read_term/2-3 actually executes. This also works inside conjunctions
245
+ // and user predicates instead of only when read/* is the whole REPL goal.
246
+ userInput.interactiveReadTerm = () => reader.readInteractiveTermSync();
247
+ }
186
248
  const flagOverrides = new Map(previousState?.flagOverrides ?? []);
187
249
  for (const [name, value] of flagOverrides) {
188
250
  const definition = solver.prologFlags.get(name);
@@ -218,6 +280,10 @@ async function readQuery(reader) {
218
280
  }
219
281
 
220
282
  async function prepareInteractiveTermInput(state, goal, reader) {
283
+ // Real terminals are serviced on demand from readTermFromStream() while the
284
+ // synchronous solver is running. Keep the older async preloader only as a
285
+ // fallback for piped/non-TTY REPL tests and scripted input.
286
+ if (reader.canReadTermSynchronously()) return;
221
287
  const stream = interactiveTermInputStream(state, goal);
222
288
  if (stream == null || terminalFullStop(String(stream.content).slice(stream.position)) >= 0) return;
223
289
 
@@ -272,6 +338,12 @@ function quotedEscapeEnd(source, index) {
272
338
  const escaped = source[index + 1] ?? '';
273
339
  if (!escaped) return index;
274
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
+
275
347
  // ISO 6.4.2.1 octal and hexadecimal escapes are terminated by a
276
348
  // backslash. Consume that terminator as part of the escape so the REPL
277
349
  // scanner does not mistake it for an escape of the following quote.
@@ -317,6 +389,12 @@ function terminalFullStop(source) {
317
389
  if (quote != null) {
318
390
  if (ch === '\\') {
319
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;
320
398
  } else if (ch === quote) {
321
399
  if (next === quote) i++;
322
400
  else quote = null;
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: () => {
@@ -719,6 +840,53 @@ c4 ?- call((!;1)).
719
840
  assertEqual(result.stderr, '', 'stderr');
720
841
  },
721
842
  },
843
+ {
844
+ name: 'REPL term input is on demand in conjunctions and Ctrl-D does not exit the top level',
845
+ run: () => {
846
+ if (process.platform === 'win32') return;
847
+ const available = spawnSync('sh', ['-c',
848
+ 'command -v script >/dev/null 2>&1 && script --version 2>/dev/null | grep -qi util-linux']);
849
+ if (available.status !== 0) return;
850
+ const command = `${shellQuote(process.execPath)} ${shellQuote(bin)}`;
851
+ const scriptCommand =
852
+ `{ printf 'read(X), read(Y).\n'; sleep 0.15; ` +
853
+ `printf 'foo.\n'; sleep 0.15; printf 'bar.\n'; sleep 0.15; ` +
854
+ `printf 'read(Z).\n'; sleep 0.15; printf '\\004'; sleep 0.15; ` +
855
+ `printf 'true.\n'; sleep 0.15; printf 'halt.\n'; } | ` +
856
+ `script -qefc ${shellQuote(command)} /dev/null`;
857
+ const result = spawnSync('sh', ['-c', scriptCommand], {
858
+ cwd: packageRoot,
859
+ encoding: 'utf8',
860
+ timeout: 5000,
861
+ });
862
+ assertEqual(result.error?.code, undefined, 'interactive read timeout');
863
+ assertEqual(result.status, 0, 'exit status');
864
+ assertIncludes(result.stdout, 'X = foo, Y = bar.', 'conjunction reads');
865
+ assertIncludes(result.stdout, 'Z = end_of_file.', 'Ctrl-D read result');
866
+ assertIncludes(result.stdout, '?- true.', 'top level resumes after Ctrl-D');
867
+ assertIncludes(result.stdout, ' true.', 'post-EOF query executes');
868
+ },
869
+ },
870
+ {
871
+ name: 'interactive user_input hook serves reads reached through user predicates',
872
+ run: () => {
873
+ const program = Program.parse('pair(A, B) :- read(A), read(B).\n');
874
+ const solver = new Solver(program, { registry: getEyePrologRegistry() });
875
+ const stream = solver.io.resolve('user_input');
876
+ const pending = ['left.\n', 'right.\n'];
877
+ let requests = 0;
878
+ stream.interactiveReadTerm = () => {
879
+ requests++;
880
+ return pending.shift() ?? null;
881
+ };
882
+ const goal = parseGoalText('pair(X, Y)');
883
+ const answers = [...solver.solve([goal], new Env(), 0)];
884
+ assertEqual(answers.length, 1, 'answer count');
885
+ assertEqual(termToString(copyResolved(goal.args[0], answers[0])), 'left', 'first read');
886
+ assertEqual(termToString(copyResolved(goal.args[1], answers[0])), 'right', 'second read');
887
+ assertEqual(requests, 2, 'on-demand read count');
888
+ },
889
+ },
722
890
  {
723
891
  name: 'REPL releases terminal signals while a query computes',
724
892
  run: () => {
@@ -5276,17 +5276,27 @@ city('München').
5276
5276
  message("café").
5277
5277
  ```
5278
5278
 
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.
5279
+ Inside a quoted atom, a single quote is doubled: `'don''t'`. EyeProlog follows
5280
+ the ISO quoted-character grammar rather than accepting arbitrary backslash
5281
+ escapes. The symbolic control escapes are `\a`, `\b`, `\r`, `\f`, `\t`,
5282
+ `\n`, and `\v`; the meta characters backslash, single quote, double quote, and
5283
+ back quote may be escaped after a backslash; and numeric octal or hexadecimal
5284
+ escapes are terminated by a backslash. For example, `'\7\'` and `'\x7\'`
5285
+ both denote the alert character. Forms such as `\c`, `\d`, `\e`, `\u`, `\.`
5286
+ and `\ ` are not ISO quoted-character escapes and are syntax errors.
5287
+
5288
+ A literal layout character other than ordinary space is not a quoted
5289
+ character. In particular, a literal tab or newline inside quotes is a syntax
5290
+ error. A quoted token can cross a line boundary only through a continuation
5291
+ escape: a backslash immediately followed by the newline, which contributes no
5292
+ character to the atom. The NUL character is written readably as `'\0\'`;
5293
+ digits `8` and `9` are not octal digits, so forms such as `'\8\'` are syntax
5294
+ errors. `writeq/1` uses octal escapes for other non-symbolic control characters,
5295
+ for example ESC is written as `'\33\'`. Double-quoted lists use the same
5296
+ quoted-character rules. Whitespace is insignificant between tokens, and a `%`
5297
+ comment continues to the end of its line. Doubling the active delimiter is
5298
+ also accepted inside either quoted form, so `""` inside double-quoted notation
5299
+ denotes one literal double quote character.
5290
5300
 
5291
5301
  Graphic atoms may contain `#$&*+-/<=>@^~\;`. A colon is the Part 2 module
5292
5302
  qualification operator in `Module:Goal`; quote an atom whose name itself
@@ -6228,21 +6238,27 @@ not be a valid right operand of the displayed `=/2`, EyeProlog adds parentheses,
6228
6238
  for example `T = (a = b).` rather than the invalid `T = a = b.`. Use `[file].`
6229
6239
  or `['file.pl'].` to
6230
6240
  consult local source, and `halt.` or `halt(Status).` to leave the top level.
6231
- A direct top-level `read/1-2` or `read_term/2-3` from `user_input` requests the
6232
- next full-stop-terminated Prolog term with a `|: ` input prompt instead of
6233
- treating the interactive input stream as already exhausted. For example:
6241
+ When `read/1-2` or `read_term/2-3` actually reaches interactive
6242
+ `user_input`, the top level requests the next full-stop-terminated Prolog term
6243
+ with a `|: ` input prompt instead of treating the terminal stream as already
6244
+ exhausted. The request is made at execution time, so multiple reads in one goal
6245
+ and reads reached through user predicates work independently. For example:
6234
6246
 
6235
6247
  ```text
6236
- ?- read(X).
6248
+ ?- read(X), read(Y).
6237
6249
  |: hello.
6238
- X = hello.
6250
+ |: world.
6251
+ X = hello, Y = world.
6239
6252
  ```
6240
6253
 
6241
- The top-level prompt itself is a host-interface convention rather than part of
6242
- ISO/IEC 13211-1; the term that follows it is parsed by the same ISO term-input
6243
- machinery as `read/1-2` and `read_term/2-3` on other text streams. Up and Down
6244
- recall queries from the current session. Explicit `eyeprolog -h` displays
6245
- command-line help.
6254
+ Typing `Ctrl-D` at an empty `|: ` prompt makes that Prolog read return
6255
+ `end_of_file`; it does not close the surrounding EyeProlog top-level loop, so a
6256
+ new `?- ` query can still be entered afterwards. The top-level prompts and this
6257
+ terminal EOF convention are host-interface behavior rather than part of
6258
+ ISO/IEC 13211-1; terms supplied to the reads are parsed by the same ISO
6259
+ term-input machinery as `read/1-2` and `read_term/2-3` on other text streams.
6260
+ Up and Down recall queries from the current session. Explicit `eyeprolog -h`
6261
+ displays command-line help.
6246
6262
 
6247
6263
  ### Selecting goals
6248
6264
 
@@ -6953,7 +6969,7 @@ precedence still need one-by-one closure. `test/conformance/ISO-MATRIX.md`
6953
6969
  maps language families to representative executable cases.
6954
6970
 
6955
6971
  The complete suite must pass before release. The file-based conformance corpus
6956
- contains 783 cases, including 377 focused ISO
6972
+ contains 789 cases, including 383 focused ISO
6957
6973
  cases derived from the success, failure, mode, and error behavior in
6958
6974
  ISO/IEC 13211-1 clauses 7 and 8, Part 2 modules, and Part 3 grammar rules.
6959
6975
  Separate exact-output suites check 189 normal