eyeprolog 1.2.38 → 1.2.40

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.38",
6
+ "version": "1.2.40",
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
@@ -1076,6 +1076,7 @@ function* nlBuiltin({ solver, goal, env }) {
1076
1076
 
1077
1077
  function* termTextCandidates(stream) {
1078
1078
  const source = String(stream.content);
1079
+ const lastBufferedNonLayout = lastNonLayoutIndex(source, stream.position);
1079
1080
  let quote = null, lineComment = false, blockComment = false;
1080
1081
  for (let i = stream.position; i < source.length; i++) {
1081
1082
  const ch = source[i], next = source[i + 1];
@@ -1106,14 +1107,29 @@ function* termTextCandidates(stream) {
1106
1107
  }
1107
1108
  if (ch === "'" || ch === '"') { quote = ch; continue; }
1108
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;
1109
1115
  yield { text: source.slice(stream.position, i + 1), end: i + 1 };
1110
1116
  }
1111
1117
  }
1112
1118
  }
1113
1119
  function hasNonLayoutRemainder(source, start) {
1114
- return source.slice(start)
1115
- .replace(/[\u0009-\u000d\u0020]+|%[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\//g, '')
1116
- .length > 0;
1120
+ return lastNonLayoutIndex(source, start) >= start;
1121
+ }
1122
+ function lastNonLayoutIndex(source, start = 0) {
1123
+ const ignored = /[\u0009-\u000d\u0020]+|%[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\//g;
1124
+ ignored.lastIndex = start;
1125
+ let cursor = start;
1126
+ let last = -1;
1127
+ for (let match = ignored.exec(source); match != null; match = ignored.exec(source)) {
1128
+ if (match.index > cursor) last = match.index - 1;
1129
+ cursor = match.index + match[0].length;
1130
+ }
1131
+ if (cursor < source.length) last = source.length - 1;
1132
+ return last;
1117
1133
  }
1118
1134
  function convertedTermText(text, solver) {
1119
1135
  if (solver.prologFlags.get('char_conversion')?.value?.name !== 'on' || solver.charConversions.size === 0) return text;
@@ -1178,6 +1194,31 @@ function scopeReadTerm(term) {
1178
1194
  return { term: copy(term), variables };
1179
1195
  }
1180
1196
 
1197
+ function parseReadTermText(text, solver) {
1198
+ const converted = convertedTermText(text, solver);
1199
+ const operatorState = createParserOperatorState(solver.program.operators.values(), false);
1200
+ const clauses = parseClauses(converted, {
1201
+ sourceMetadata: false,
1202
+ operatorState,
1203
+ isoStrict: solver.isoStrict,
1204
+ doubleQuotes: solver.prologFlags.get('double_quotes')?.value?.name ?? 'chars',
1205
+ // The stream scanner supplies one candidate ending at this full stop.
1206
+ // Earlier ambiguous dots must remain available to maximal graphic tokens.
1207
+ readTermEnd: converted.length - 1,
1208
+ });
1209
+ if (clauses.length !== 1 || clauses[0].body.length) throw new Error('bad term');
1210
+ return clauses[0].head;
1211
+ }
1212
+
1213
+ export function isCompleteReadTermText(text, solver) {
1214
+ try {
1215
+ parseReadTermText(text, solver);
1216
+ return true;
1217
+ } catch (_) {
1218
+ return false;
1219
+ }
1220
+ }
1221
+
1181
1222
  function readTermFromStream(stream, solver) {
1182
1223
  let requestedInteractiveTerm = false;
1183
1224
  while (true) {
@@ -1189,16 +1230,9 @@ function readTermFromStream(stream, solver) {
1189
1230
  throw new PrologError('syntax_error(read_term)');
1190
1231
  }
1191
1232
  try {
1192
- const operatorState = createParserOperatorState(solver.program.operators.values(), false);
1193
- const clauses = parseClauses(convertedTermText(candidate.text, solver), {
1194
- sourceMetadata: false,
1195
- operatorState,
1196
- isoStrict: solver.isoStrict,
1197
- doubleQuotes: solver.prologFlags.get('double_quotes')?.value?.name ?? 'chars',
1198
- });
1199
- if (clauses.length !== 1 || clauses[0].body.length) throw new Error('bad term');
1233
+ const term = parseReadTermText(candidate.text, solver);
1200
1234
  stream.position = candidate.end;
1201
- return scopeReadTerm(clauses[0].head);
1235
+ return scopeReadTerm(term);
1202
1236
  } catch (_) {
1203
1237
  // A dot inside a graphic operator, such as =.., is only a possible
1204
1238
  // terminator. Keep scanning until a complete term parses.
package/src/parser.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Tokenizer and recursive-descent parser for the EyeProlog source language.
2
2
  // It preserves the compact Prolog-like syntax while producing Term objects for the solver.
3
3
  import { ATOM, COMPOUND, atom, compound, cons, emptyList, numberTerm, variable } from './term.js';
4
- import { isTerminatingFullStop } from './syntax-scan.js';
4
+ import { continuesGraphicToken, isTerminatingFullStop } from './syntax-scan.js';
5
5
 
6
6
  const TOK = {
7
7
  EOF: 'eof', ATOM: 'atom', VAR: 'var', STRING: 'string', NUMBER: 'number',
@@ -196,9 +196,25 @@ class Parser {
196
196
  this.infixOperators = operatorState.infixOperators;
197
197
  this.prefixOperators = operatorState.prefixOperators;
198
198
  this.postfixOperators = operatorState.postfixOperators;
199
+ this.readTermEnd = Number.isInteger(options.readTermEnd) ? options.readTermEnd : null;
199
200
  this.previousToken = null;
200
201
  this.token = this.nextToken();
201
202
  }
203
+ terminatingFullStop(index = this.pos) {
204
+ // read/1 tries each possible full stop in turn. While parsing a later
205
+ // candidate, a preceding dot after a graphic character belongs to that
206
+ // maximal graphic token; the candidate's final dot is the end char. Keep
207
+ // ordinary program parsing context-free by enabling this only for a
208
+ // designated read-term candidate.
209
+ if (this.readTermEnd != null) {
210
+ if (index === this.readTermEnd) return true;
211
+ if (index < this.readTermEnd && continuesGraphicToken(this.source, index)) {
212
+ const next = this.source[index + 1] ?? '';
213
+ if (next === '%' || /^[\u0009-\u000d\u0020]$/.test(next)) return false;
214
+ }
215
+ }
216
+ return isTerminatingFullStop(this.source, index);
217
+ }
202
218
  defineOperator(priority, specifier, name) {
203
219
  defineParserOperator(this, priority, specifier, name);
204
220
  }
@@ -366,15 +382,16 @@ class Parser {
366
382
  this.pos += 3;
367
383
  return { type: TOK.ATOM, text: '...', line };
368
384
  }
369
- if (ch === '?' && this.peek(1) === '-') {
385
+ if (ch === '?' && this.peek(1) === '-' &&
386
+ !(isGraphicAtomCode(this.peek(2).charCodeAt(0)) && !this.terminatingFullStop(this.pos + 2))) {
370
387
  this.pos += 2;
371
388
  return { type: TOK.ATOM, text: '?-', line };
372
389
  }
373
- if (ch === '.' && !isTerminatingFullStop(this.source, this.pos)) {
390
+ if (ch === '.' && !this.terminatingFullStop()) {
374
391
  const start = this.pos;
375
392
  this.take();
376
393
  while (isGraphicAtomCode(this.peek().charCodeAt(0)) &&
377
- !isTerminatingFullStop(this.source, this.pos)) this.take();
394
+ !this.terminatingFullStop()) this.take();
378
395
  return { type: TOK.ATOM, text: this.source.slice(start, this.pos), line };
379
396
  }
380
397
  if (ch === '!') {
@@ -394,11 +411,13 @@ class Parser {
394
411
  this.take();
395
412
  return { type: punct[ch], text: ch, line, precededByLayout };
396
413
  }
397
- if (ch === ':' && this.peek(1) === '-') {
414
+ if (ch === ':' && this.peek(1) === '-' &&
415
+ !(isGraphicAtomCode(this.peek(2).charCodeAt(0)) && !this.terminatingFullStop(this.pos + 2))) {
398
416
  this.pos += 2;
399
417
  return { type: TOK.IF, text: ':-', line };
400
418
  }
401
- if (ch === ':') {
419
+ if (ch === ':' &&
420
+ !(isGraphicAtomCode(this.peek(1).charCodeAt(0)) && !this.terminatingFullStop(this.pos + 1))) {
402
421
  this.take();
403
422
  return { type: TOK.ATOM, text: ':', line };
404
423
  }
@@ -544,7 +563,7 @@ class Parser {
544
563
  const start = this.pos;
545
564
  this.take();
546
565
  while (isGraphicAtomCode(this.peek().charCodeAt(0)) &&
547
- !isTerminatingFullStop(this.source, this.pos)) this.take();
566
+ !this.terminatingFullStop()) this.take();
548
567
  return { type: TOK.ATOM, text: this.source.slice(start, this.pos), line };
549
568
  }
550
569
 
@@ -1034,7 +1053,7 @@ export function parseClauses(source, options = {}) {
1034
1053
  const parserOptions = ownsParserFlagState
1035
1054
  ? { ...options, parserFlagState: { doubleQuotes: initialDoubleQuotes } }
1036
1055
  : options;
1037
- if (options.sourceMetadata === false) {
1056
+ if (options.sourceMetadata === false && options.readTermEnd == null) {
1038
1057
  const clauses = parseClausesFastNoSource(source, null, null, parserOptions);
1039
1058
  if (clauses) return clauses;
1040
1059
  if (ownsParserFlagState) parserOptions.parserFlagState.doubleQuotes = initialDoubleQuotes;
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 } from './iso.js';
6
+ import { formalErrorTerm, isCompleteReadTermText } from './iso.js';
7
7
  import {
8
8
  characterCodeConstantEnd, continuesGraphicToken, isTerminatingFullStop, quotedEscapeEnd,
9
9
  } from './syntax-scan.js';
@@ -162,7 +162,7 @@ class LineReader {
162
162
  return this.terminal && Number.isInteger(this.input.fd);
163
163
  }
164
164
 
165
- readInteractiveTermSync() {
165
+ readInteractiveTermSync(solver = null) {
166
166
  if (!this.canReadTermSynchronously()) return null;
167
167
  let source = '';
168
168
  let prompt = '|: ';
@@ -172,7 +172,9 @@ class LineReader {
172
172
  if (line == null) return source.trim() ? source : null;
173
173
  source += `${line}\n`;
174
174
  const end = terminalFullStop(source);
175
- if (end >= 0) return source.slice(0, end + 1) + '\n';
175
+ if (end >= 0 && acceptsReadTermBoundary(source, end, solver)) {
176
+ return source.slice(0, end + 1) + '\n';
177
+ }
176
178
  prompt = '| ';
177
179
  }
178
180
  }
@@ -256,7 +258,7 @@ function makeState(engine, sources, output, options = {}, previousState = null,
256
258
  // let ISO term input request a complete terminal term exactly when read/1-2
257
259
  // or read_term/2-3 actually executes. This also works inside conjunctions
258
260
  // and user predicates instead of only when read/* is the whole REPL goal.
259
- userInput.interactiveReadTerm = () => reader.readInteractiveTermSync();
261
+ userInput.interactiveReadTerm = () => reader.readInteractiveTermSync(solver);
260
262
  }
261
263
  const flagOverrides = new Map(previousState?.flagOverrides ?? []);
262
264
  for (const [name, value] of flagOverrides) {
@@ -300,7 +302,7 @@ async function prepareInteractiveTermInput(state, goal, reader) {
300
302
  const stream = interactiveTermInputStream(state, goal);
301
303
  if (stream == null || terminalFullStop(String(stream.content).slice(stream.position)) >= 0) return;
302
304
 
303
- const text = await readInteractiveTerm(reader);
305
+ const text = await readInteractiveTerm(reader, state.solver);
304
306
  if (text == null) return;
305
307
  stream.content += text;
306
308
  stream.pastEnd = false;
@@ -334,7 +336,7 @@ function explicitInputReference(term) {
334
336
  return null;
335
337
  }
336
338
 
337
- async function readInteractiveTerm(reader) {
339
+ async function readInteractiveTerm(reader, solver = null) {
338
340
  let source = '';
339
341
  let prompt = '|: ';
340
342
  while (true) {
@@ -342,11 +344,22 @@ async function readInteractiveTerm(reader) {
342
344
  if (line == null) return source.trim() ? source : null;
343
345
  source += `${line}\n`;
344
346
  const end = terminalFullStop(source);
345
- if (end >= 0) return source.slice(0, end + 1) + '\n';
347
+ if (end >= 0 && acceptsReadTermBoundary(source, end, solver)) {
348
+ return source.slice(0, end + 1) + '\n';
349
+ }
346
350
  prompt = '| ';
347
351
  }
348
352
  }
349
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);
361
+ }
362
+
350
363
  function terminalFullStop(source) {
351
364
  let quote = null;
352
365
  let lineComment = false;
@@ -752,6 +752,62 @@ c4 ?- call((!;1)).
752
752
  });
753
753
  assertEqual(consecutive.stdout, "answer('./*.', ok).\n", 'following read starts after the complete term');
754
754
 
755
+ // A possible full stop can fail to complete the term while still
756
+ // extending a current graphic operator into an ordinary atom. The
757
+ // later standalone full stop then completes the read term. Exercise
758
+ // every predefined graphic operator, including the tokenizer's
759
+ // special :- and ?- cases, so they cannot diverge from * again.
760
+ const graphicOperator = /^[#$&*+\-./<=>?@^~\\:]+$/;
761
+ const graphicOperators = [...new Set(ISO_OPERATOR_DEFINITIONS.map(([, , name]) => name))]
762
+ .filter((name) => graphicOperator.test(name));
763
+ for (const name of graphicOperators) {
764
+ const program = Program.parse('');
765
+ const solver = new Solver(program, {
766
+ registry: getEyePrologRegistry(),
767
+ ioOptions: { input: `!,${name}.\n.\n` },
768
+ });
769
+ const readGoal = parseGoalText('read(T)', {
770
+ operatorDefinitions: [...program.operators.values()],
771
+ });
772
+ const answers = [...solver.solve([readGoal], new Env(), 0)];
773
+ assertEqual(answers.length, 1, `graphic operator read answer for ${name}`);
774
+ const term = copyResolved(readGoal.args[0], answers[0]);
775
+ assertEqual(term.name, ',', `graphic operator conjunction for ${name}`);
776
+ assertEqual(term.args[0].name, '!', `graphic operator left operand for ${name}`);
777
+ assertEqual(term.args[1].name, `${name}.`, `graphic operator atom for ${name}`);
778
+ }
779
+
780
+ // Unlike an interactive reader waiting at a line boundary, a buffered
781
+ // stream can see later non-layout input. Its first dot therefore
782
+ // remains in the maximal graphic token, making the adjacent ! invalid
783
+ // rather than prematurely returning the shorter atom.
784
+ for (const name of [...graphicOperators, '?', '#', '@', './*', '//*']) {
785
+ let bufferedError = null;
786
+ try {
787
+ runEyeProlog('', {
788
+ goal: 'read_term(T, [])',
789
+ ioOptions: { input: `${name}.\n!\n.` },
790
+ });
791
+ } catch (caught) {
792
+ bufferedError = caught;
793
+ }
794
+ assertEqual(
795
+ bufferedError?.message,
796
+ 'error(syntax_error(read_term))',
797
+ `buffered graphic atom boundary for ${name}`,
798
+ );
799
+ }
800
+
801
+ const bufferedPath = path.join(tmp, 'graphic-atom-boundary.pl');
802
+ fs.writeFileSync(bufferedPath, '*.\n!\n.');
803
+ const namedStream = runEyeProlog([
804
+ `caught(ok) :- open(${sourceAtom(bufferedPath)}, read, S),`,
805
+ ' catch(read_term(S, _, []), error(syntax_error(read_term), _), true),',
806
+ ' close(S).',
807
+ '',
808
+ ].join('\n'), { goal: 'caught(ok)' });
809
+ assertEqual(namedStream.stdout, 'caught(ok).\n', 'named buffered stream syntax error');
810
+
755
811
  let error = null;
756
812
  try {
757
813
  runEyeProlog('', { goal: 'read(T)', ioOptions: { input: '!.!.' } });
@@ -769,6 +825,15 @@ c4 ?- call((!;1)).
769
825
  assertIncludes(repl.stdout, 'T = ok.', 'REPL following read answer');
770
826
  assertIncludes(repl.stdout, 'error(syntax_error(read_term), eyeprolog)', 'REPL syntax error');
771
827
  assertEqual(repl.stderr, '', 'REPL stderr');
828
+
829
+ const continuedGraphic = runCli([], {
830
+ input: 'read(T).\n!,*.\n.\nread(T).\na\n.\nhalt.\n',
831
+ });
832
+ assertEqual(continuedGraphic.status, 0, 'continued graphic operator REPL status');
833
+ assertIncludes(continuedGraphic.stdout, 'T = (!, *.).', 'continued graphic operator answer');
834
+ assertIncludes(continuedGraphic.stdout, 'T = a.', 'read after continued graphic operator');
835
+ assertNotIncludes(continuedGraphic.stdout, 'syntax_error', 'continued graphic operator syntax');
836
+ assertEqual(continuedGraphic.stderr, '', 'continued graphic operator REPL stderr');
772
837
  },
773
838
  },
774
839
  {