eyeprolog 1.2.5 → 1.2.7

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.5",
6
+ "version": "1.2.7",
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
@@ -1135,28 +1135,49 @@ function convertedTermText(text, solver) {
1135
1135
  return result;
1136
1136
  }
1137
1137
  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.
1138
+ let requestedInteractiveTerm = false;
1139
+ while (true) {
1140
+ let sawCandidate = false;
1141
+ for (const candidate of termTextCandidates(stream)) {
1142
+ sawCandidate = true;
1143
+ try {
1144
+ const operatorState = createParserOperatorState(solver.program.operators.values(), false);
1145
+ const clauses = parseClauses(convertedTermText(candidate.text, solver), {
1146
+ sourceMetadata: false,
1147
+ operatorState,
1148
+ doubleQuotes: solver.prologFlags.get('double_quotes')?.value?.name ?? 'chars',
1149
+ });
1150
+ if (clauses.length !== 1 || clauses[0].body.length) throw new Error('bad term');
1151
+ stream.position = candidate.end;
1152
+ return clauses[0].head;
1153
+ } catch (_) {
1154
+ // A dot inside a graphic operator, such as =.., is only a possible
1155
+ // terminator. Keep scanning until a complete term parses.
1156
+ }
1157
+ }
1158
+
1159
+ // The interactive top level may attach a synchronous reader to the
1160
+ // standard user_input stream. Ask it for one complete read-term only when
1161
+ // this read operation actually reaches the end of buffered input. This is
1162
+ // deliberately a stream hook, not goal-shape recognition, so conjunctions
1163
+ // and reads reached through user predicates behave the same as read/1.
1164
+ if (!sawCandidate && !requestedInteractiveTerm &&
1165
+ typeof stream.interactiveReadTerm === 'function') {
1166
+ requestedInteractiveTerm = true;
1167
+ const text = stream.interactiveReadTerm();
1168
+ if (text != null) {
1169
+ stream.content += String(text);
1170
+ stream.pastEnd = false;
1171
+ continue;
1172
+ }
1154
1173
  }
1174
+
1175
+ stream.position = String(stream.content).length;
1176
+ if (!sawCandidate) return atom('end_of_file');
1177
+ throw new PrologError('syntax_error(read_term)');
1155
1178
  }
1156
- stream.position = String(stream.content).length;
1157
- if (!sawCandidate) return atom('end_of_file');
1158
- throw new PrologError('syntax_error(read_term)');
1159
1179
  }
1180
+
1160
1181
  function* readBuiltin({ solver, goal, env }) {
1161
1182
  const stream = inputStreamFor(solver, goal, env);
1162
1183
  if (stream.type !== 'text') throw new PrologError('permission_error(input, binary_stream)', streamHandle(stream.id));
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,11 +21,11 @@ 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 {
27
- state.solver.runInitializations();
28
+ runWithTerminalSignals(reader, () => state.solver.runInitializations());
28
29
  while (true) {
29
30
  const text = await readQuery(reader);
30
31
  if (text == null) break;
@@ -34,16 +35,16 @@ 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.solver.runInitializations();
38
+ state = makeState(engine, sources, output, options, state, reader);
39
+ runWithTerminalSignals(reader, () => state.solver.runInitializations());
39
40
  output.write(' true.\n');
40
41
  continue;
41
42
  }
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.solver.runInitializations();
46
+ state = makeState(engine, sources, output, options, state, reader);
47
+ runWithTerminalSignals(reader, () => state.solver.runInitializations());
47
48
  output.write(' true.\n');
48
49
  continue;
49
50
  }
@@ -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,13 +145,91 @@ 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
+
199
+ suspendForComputation() {
200
+ if (!this.terminal || !this.readline) return false;
201
+ // Node readline installs terminal signal handling while the interface is
202
+ // open. During a synchronous Prolog search that prevents the terminal's
203
+ // normal SIGINT/SIGTSTP actions from taking effect until JavaScript yields.
204
+ // Close readline while the solver is running so Ctrl-C can terminate and
205
+ // Ctrl-Z can suspend an otherwise non-terminating computation immediately.
206
+ this.history = [...this.readline.history];
207
+ this.readline.close();
208
+ this.readline = null;
209
+ this.lines = null;
210
+ return true;
211
+ }
212
+
213
+ resumeAfterComputation(suspended) {
214
+ if (suspended && !this.readline) this.open();
215
+ }
216
+
145
217
  close() {
146
218
  if (this.input.isRaw) this.input.setRawMode(false);
147
219
  this.readline?.close();
148
220
  }
149
221
  }
150
222
 
151
- function makeState(engine, sources, output, options = {}, previousState = null) {
223
+ function runWithTerminalSignals(reader, operation) {
224
+ const suspended = reader.suspendForComputation();
225
+ try {
226
+ return operation();
227
+ } finally {
228
+ reader.resumeAfterComputation(suspended);
229
+ }
230
+ }
231
+
232
+ function makeState(engine, sources, output, options = {}, previousState = null, reader = null) {
152
233
  const strictIso = options.isoStrict === true;
153
234
  const program = engine.Program.parseSources(sources, { strictIso, sourceMetadata: strictIso });
154
235
  const solver = new engine.Solver(program, {
@@ -156,6 +237,14 @@ function makeState(engine, sources, output, options = {}, previousState = null)
156
237
  isoStrict: strictIso,
157
238
  ioOptions: { write: (text) => output.write(String(text)) },
158
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
+ }
159
248
  const flagOverrides = new Map(previousState?.flagOverrides ?? []);
160
249
  for (const [name, value] of flagOverrides) {
161
250
  const definition = solver.prologFlags.get(name);
@@ -191,6 +280,10 @@ async function readQuery(reader) {
191
280
  }
192
281
 
193
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;
194
287
  const stream = interactiveTermInputStream(state, goal);
195
288
  if (stream == null || terminalFullStop(String(stream.content).slice(stream.position)) >= 0) return;
196
289
 
@@ -369,7 +462,7 @@ async function solveQuery(engine, state, goal, reader, output) {
369
462
  const solver = state.solver;
370
463
  solver.solutionsSeen = 0;
371
464
  const solutions = solver.solve([goal], new engine.Env(), 0);
372
- let current = pullSolution(solver, solutions);
465
+ let current = pullSolution(solver, solutions, reader);
373
466
  if (current.error) {
374
467
  if (current.error?.name === 'HaltSignal') return { halted: true, code: current.error.code };
375
468
  throw current.error;
@@ -383,7 +476,7 @@ async function solveQuery(engine, state, goal, reader, output) {
383
476
  let automatic = 0;
384
477
  let firstAnswer = true;
385
478
  while (!current.result.done) {
386
- const next = pullSolution(solver, solutions);
479
+ const next = pullSolution(solver, solutions, reader);
387
480
  output.write(current.output);
388
481
  output.write(`${firstAnswer ? ' ' : ''}${formatAnswer(engine, state, variables, current.result.value)}`);
389
482
  firstAnswer = false;
@@ -436,16 +529,18 @@ async function solveQuery(engine, state, goal, reader, output) {
436
529
  return null;
437
530
  }
438
531
 
439
- function pullSolution(solver, solutions) {
532
+ function pullSolution(solver, solutions, reader) {
440
533
  const stream = solver.io.resolve('user_output');
441
534
  const originalWrite = stream?.write;
442
535
  let captured = '';
443
536
  if (stream) stream.write = (text) => { captured += String(text); };
537
+ const suspended = reader.suspendForComputation();
444
538
  try {
445
539
  return { result: solutions.next(), output: captured };
446
540
  } catch (error) {
447
541
  return { error, output: captured };
448
542
  } finally {
543
+ reader.resumeAfterComputation(suspended);
449
544
  if (stream) stream.write = originalWrite;
450
545
  }
451
546
  }
@@ -719,6 +719,74 @@ c4 ?- call((!;1)).
719
719
  assertEqual(result.stderr, '', 'stderr');
720
720
  },
721
721
  },
722
+ {
723
+ name: 'REPL term input is on demand in conjunctions and Ctrl-D does not exit the top level',
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 'read(X), read(Y).\n'; sleep 0.15; ` +
732
+ `printf 'foo.\n'; sleep 0.15; printf 'bar.\n'; sleep 0.15; ` +
733
+ `printf 'read(Z).\n'; sleep 0.15; printf '\\004'; sleep 0.15; ` +
734
+ `printf 'true.\n'; sleep 0.15; printf 'halt.\n'; } | ` +
735
+ `script -qefc ${shellQuote(command)} /dev/null`;
736
+ const result = spawnSync('sh', ['-c', scriptCommand], {
737
+ cwd: packageRoot,
738
+ encoding: 'utf8',
739
+ timeout: 5000,
740
+ });
741
+ assertEqual(result.error?.code, undefined, 'interactive read timeout');
742
+ assertEqual(result.status, 0, 'exit status');
743
+ assertIncludes(result.stdout, 'X = foo, Y = bar.', 'conjunction reads');
744
+ assertIncludes(result.stdout, 'Z = end_of_file.', 'Ctrl-D read result');
745
+ assertIncludes(result.stdout, '?- true.', 'top level resumes after Ctrl-D');
746
+ assertIncludes(result.stdout, ' true.', 'post-EOF query executes');
747
+ },
748
+ },
749
+ {
750
+ name: 'interactive user_input hook serves reads reached through user predicates',
751
+ run: () => {
752
+ const program = Program.parse('pair(A, B) :- read(A), read(B).\n');
753
+ const solver = new Solver(program, { registry: getEyePrologRegistry() });
754
+ const stream = solver.io.resolve('user_input');
755
+ const pending = ['left.\n', 'right.\n'];
756
+ let requests = 0;
757
+ stream.interactiveReadTerm = () => {
758
+ requests++;
759
+ return pending.shift() ?? null;
760
+ };
761
+ const goal = parseGoalText('pair(X, Y)');
762
+ const answers = [...solver.solve([goal], new Env(), 0)];
763
+ assertEqual(answers.length, 1, 'answer count');
764
+ assertEqual(termToString(copyResolved(goal.args[0], answers[0])), 'left', 'first read');
765
+ assertEqual(termToString(copyResolved(goal.args[1], answers[0])), 'right', 'second read');
766
+ assertEqual(requests, 2, 'on-demand read count');
767
+ },
768
+ },
769
+ {
770
+ name: 'REPL releases terminal signals while a query computes',
771
+ run: () => {
772
+ if (process.platform === 'win32') return;
773
+ const available = spawnSync('sh', ['-c',
774
+ 'command -v script >/dev/null 2>&1 && script --version 2>/dev/null | grep -qi util-linux']);
775
+ if (available.status !== 0) return;
776
+ const command = `${shellQuote(process.execPath)} ${shellQuote(bin)}`;
777
+ const scriptCommand =
778
+ `{ printf 'repeat, fail.\n'; sleep 0.2; printf '\\003'; } | ` +
779
+ `script -qefc ${shellQuote(command)} /dev/null`;
780
+ const result = spawnSync('sh', ['-c', scriptCommand], {
781
+ cwd: packageRoot,
782
+ encoding: 'utf8',
783
+ timeout: 3000,
784
+ });
785
+ assertEqual(result.error?.code, undefined, 'terminal interrupt timeout');
786
+ assertEqual(result.status, 130, 'SIGINT exit status');
787
+ assertIncludes(result.stdout, '?- repeat, fail.', 'terminal query echo');
788
+ },
789
+ },
722
790
  {
723
791
  name: 'REPL accepts multiline period-terminated queries',
724
792
  run: () => {
@@ -3109,6 +3177,10 @@ function between(text, startMarker, endMarker) {
3109
3177
  return text.slice(contentStart, end);
3110
3178
  }
3111
3179
 
3180
+ function shellQuote(value) {
3181
+ return `'${String(value).replaceAll("'", "'\"'\"'")}'`;
3182
+ }
3183
+
3112
3184
  function runCli(args, options = {}) {
3113
3185
  return spawnSync(process.execPath, [bin, ...args], {
3114
3186
  cwd: packageRoot,
@@ -6216,7 +6216,11 @@ may span lines and end with a full stop, as in Scryer Prolog:
6216
6216
  When another answer exists in an interactive terminal, press `;`, Space, or
6217
6217
  `n` to ask for it immediately; no Return is needed. Return or `.` stops
6218
6218
  enumeration, `a` enumerates all remaining answers, `f` enumerates the next
6219
- 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
6220
6224
  period-terminated query with no solutions prints `false.`; a solution without
6221
6225
  visible variable bindings prints `true.`. Answer substitutions are rendered as
6222
6226
  valid Prolog syntax under the current operator table: when a bound value would
@@ -6224,21 +6228,27 @@ not be a valid right operand of the displayed `=/2`, EyeProlog adds parentheses,
6224
6228
  for example `T = (a = b).` rather than the invalid `T = a = b.`. Use `[file].`
6225
6229
  or `['file.pl'].` to
6226
6230
  consult local source, and `halt.` or `halt(Status).` to leave the top level.
6227
- A direct top-level `read/1-2` or `read_term/2-3` from `user_input` requests the
6228
- next full-stop-terminated Prolog term with a `|: ` input prompt instead of
6229
- treating the interactive input stream as already exhausted. For example:
6231
+ When `read/1-2` or `read_term/2-3` actually reaches interactive
6232
+ `user_input`, the top level requests the next full-stop-terminated Prolog term
6233
+ with a `|: ` input prompt instead of treating the terminal stream as already
6234
+ exhausted. The request is made at execution time, so multiple reads in one goal
6235
+ and reads reached through user predicates work independently. For example:
6230
6236
 
6231
6237
  ```text
6232
- ?- read(X).
6238
+ ?- read(X), read(Y).
6233
6239
  |: hello.
6234
- X = hello.
6235
- ```
6236
-
6237
- The top-level prompt itself is a host-interface convention rather than part of
6238
- ISO/IEC 13211-1; the term that follows it is parsed by the same ISO term-input
6239
- machinery as `read/1-2` and `read_term/2-3` on other text streams. Up and Down
6240
- recall queries from the current session. Explicit `eyeprolog -h` displays
6241
- command-line help.
6240
+ |: world.
6241
+ X = hello, Y = world.
6242
+ ```
6243
+
6244
+ Typing `Ctrl-D` at an empty `|: ` prompt makes that Prolog read return
6245
+ `end_of_file`; it does not close the surrounding EyeProlog top-level loop, so a
6246
+ new `?- ` query can still be entered afterwards. The top-level prompts and this
6247
+ terminal EOF convention are host-interface behavior rather than part of
6248
+ ISO/IEC 13211-1; terms supplied to the reads are parsed by the same ISO
6249
+ term-input machinery as `read/1-2` and `read_term/2-3` on other text streams.
6250
+ Up and Down recall queries from the current session. Explicit `eyeprolog -h`
6251
+ displays command-line help.
6242
6252
 
6243
6253
  ### Selecting goals
6244
6254