eyeprolog 1.2.6 → 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.6",
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,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
 
@@ -719,6 +719,53 @@ 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
+ },
722
769
  {
723
770
  name: 'REPL releases terminal signals while a query computes',
724
771
  run: () => {
@@ -6228,21 +6228,27 @@ not be a valid right operand of the displayed `=/2`, EyeProlog adds parentheses,
6228
6228
  for example `T = (a = b).` rather than the invalid `T = a = b.`. Use `[file].`
6229
6229
  or `['file.pl'].` to
6230
6230
  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:
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:
6234
6236
 
6235
6237
  ```text
6236
- ?- read(X).
6238
+ ?- read(X), read(Y).
6237
6239
  |: hello.
6238
- X = hello.
6239
- ```
6240
-
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.
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.
6246
6252
 
6247
6253
  ### Selecting goals
6248
6254