eyeprolog 1.1.2 → 1.1.3

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/README.md CHANGED
@@ -25,6 +25,18 @@ EyeProlog requires Node.js 18 or newer.
25
25
 
26
26
  ```sh
27
27
  npm install --global eyeprolog
28
+ eyeprolog
29
+ ?- use_module(library(lists)).
30
+ true.
31
+ ?- member(X, [prolog, logic]).
32
+ X = prolog
33
+ ; X = logic.
34
+ ?- halt.
35
+ ```
36
+
37
+ For a non-interactive run:
38
+
39
+ ```sh
28
40
  printf 'human(socrates).\nmortal(X) :- human(X).\n' |
29
41
  eyeprolog --proof --goal 'mortal(socrates)' -
30
42
  ```
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.1.2",
6
+ "version": "1.1.3",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/cli.js CHANGED
@@ -10,7 +10,14 @@ let explanationModule = null;
10
10
 
11
11
  export async function main(argv) {
12
12
  if (argv.length === 0) {
13
- await usage(process.stdout);
13
+ const engine = await loadEngine();
14
+ const { runRepl } = await import('./repl.js');
15
+ const exitCode = await runRepl(engine, {
16
+ input: process.stdin,
17
+ output: process.stdout,
18
+ errorOutput: process.stderr,
19
+ });
20
+ if (exitCode !== 0) process.exitCode = exitCode;
14
21
  return;
15
22
  }
16
23
 
@@ -109,15 +116,16 @@ export async function main(argv) {
109
116
 
110
117
  async function loadEngine() {
111
118
  if (engineModule == null) {
112
- const [term, parser, program, solver, iso, library] = await Promise.all([
119
+ const [term, parser, program, solver, iso, library, write] = await Promise.all([
113
120
  import('./term.js'),
114
121
  import('./parser.js'),
115
122
  import('./program.js'),
116
123
  import('./solver.js'),
117
124
  import('./iso.js'),
118
125
  import('./standard-library.js'),
126
+ import('./write.js'),
119
127
  ]);
120
- engineModule = { ...term, ...parser, ...program, ...solver, ...iso, ...library };
128
+ engineModule = { ...term, ...parser, ...program, ...solver, ...iso, ...library, ...write };
121
129
  }
122
130
  return engineModule;
123
131
  }
@@ -186,8 +194,12 @@ async function usage(stream) {
186
194
  stream.write(`eyeprolog ${await packageVersion()}
187
195
 
188
196
  Usage:
197
+ eyeprolog
189
198
  eyeprolog [options] [file-or-url.pl|- ...]
190
199
 
200
+ Interactive:
201
+ With no arguments, start a Prolog REPL. Use eyeprolog -h for help.
202
+
191
203
  Input:
192
204
  file-or-url.pl Read an EyeProlog program from a local file or http(s) URL.
193
205
  - Read an EyeProlog program from standard input.
package/src/repl.js ADDED
@@ -0,0 +1,412 @@
1
+ // Interactive top level for the eyeprolog command.
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { createInterface } from 'node:readline';
5
+
6
+ const ANSWER_HELP = `
7
+ SPACE, "n" or ";": next solution, if any
8
+ RETURN or ".": stop enumeration
9
+ "a": enumerate all solutions
10
+ "f": enumerate the next 5 solutions
11
+ "h": display this help message
12
+ "w": write terms without depth limit
13
+ "p": print terms with depth limit
14
+ `;
15
+
16
+ export async function runRepl(engine, options = {}) {
17
+ const input = options.input ?? process.stdin;
18
+ const output = options.output ?? process.stdout;
19
+ const errorOutput = options.errorOutput ?? process.stderr;
20
+ const reader = new LineReader(input, output);
21
+ const sources = [];
22
+ let state = makeState(engine, sources, output);
23
+ let exitCode = 0;
24
+
25
+ try {
26
+ state.solver.runInitializations();
27
+ while (true) {
28
+ const text = await readQuery(reader);
29
+ if (text == null) break;
30
+ if (!text.trim()) continue;
31
+
32
+ try {
33
+ const goal = parseGoal(engine, state, text);
34
+ if (isUseModuleGoal(goal)) {
35
+ sources.push({ text: `:- ${text}.\n`, filename: '<repl>' });
36
+ state = makeState(engine, sources, output);
37
+ state.solver.runInitializations();
38
+ output.write(' true.\n');
39
+ continue;
40
+ }
41
+ const consultFiles = consultDesignations(engine, goal);
42
+ if (consultFiles != null) {
43
+ for (const filename of consultFiles) sources.push(await readSource(filename));
44
+ state = makeState(engine, sources, output);
45
+ state.solver.runInitializations();
46
+ output.write(' true.\n');
47
+ continue;
48
+ }
49
+
50
+ const result = await solveQuery(engine, state, goal, reader, output);
51
+ if (result?.halted) {
52
+ exitCode = result.code;
53
+ break;
54
+ }
55
+ } catch (error) {
56
+ if (error?.name === 'HaltSignal') {
57
+ exitCode = error.code;
58
+ break;
59
+ }
60
+ output.write(` ${formatError(error)}\n`);
61
+ }
62
+ }
63
+ } catch (error) {
64
+ errorOutput.write(`eyeprolog: ${error?.message ?? String(error)}\n`);
65
+ exitCode = 1;
66
+ } finally {
67
+ reader.close();
68
+ }
69
+
70
+ return exitCode;
71
+ }
72
+
73
+ class LineReader {
74
+ constructor(input, output) {
75
+ this.input = input;
76
+ this.output = output;
77
+ this.terminal = Boolean(input.isTTY && output.isTTY && typeof input.setRawMode === 'function');
78
+ this.history = [];
79
+ this.currentPrompt = '?- ';
80
+ this.open();
81
+ }
82
+
83
+ open() {
84
+ this.readline = createInterface({
85
+ input: this.input,
86
+ output: this.output,
87
+ terminal: Boolean(this.input.isTTY && this.output.isTTY),
88
+ prompt: this.currentPrompt,
89
+ });
90
+ if (this.terminal && this.history.length > 0) {
91
+ this.readline.history.push(...this.history);
92
+ }
93
+ this.lines = this.readline[Symbol.asyncIterator]();
94
+ }
95
+
96
+ async read(prompt) {
97
+ this.currentPrompt = prompt;
98
+ this.readline.setPrompt(prompt);
99
+ this.output.write(prompt);
100
+ const result = await this.lines.next();
101
+ return result.done ? null : result.value;
102
+ }
103
+
104
+ async readControl(prompt) {
105
+ if (!this.terminal) return this.read(prompt);
106
+ this.output.write(prompt);
107
+ this.history = [...this.readline.history];
108
+ this.currentPrompt = '?- ';
109
+ this.readline.close();
110
+ this.readline = null;
111
+ this.lines = null;
112
+ this.input.setRawMode(true);
113
+ this.input.resume();
114
+
115
+ const control = await new Promise((resolve, reject) => {
116
+ const cleanup = () => {
117
+ this.input.off('data', onData);
118
+ this.input.off('error', onError);
119
+ };
120
+ const onData = (data) => {
121
+ cleanup();
122
+ const text = String(data);
123
+ resolve(text === '\x04' ? null : text[0] ?? null);
124
+ };
125
+ const onError = (error) => {
126
+ cleanup();
127
+ reject(error);
128
+ };
129
+ this.input.once('data', onData);
130
+ this.input.once('error', onError);
131
+ });
132
+
133
+ this.input.setRawMode(false);
134
+ this.open();
135
+ return control;
136
+ }
137
+
138
+ close() {
139
+ if (this.input.isRaw) this.input.setRawMode(false);
140
+ this.readline?.close();
141
+ }
142
+ }
143
+
144
+ function makeState(engine, sources, output) {
145
+ const program = engine.Program.parseSources(sources);
146
+ const solver = new engine.Solver(program, {
147
+ registry: engine.getEyePrologRegistry(),
148
+ ioOptions: { write: (text) => output.write(String(text)) },
149
+ });
150
+ return { program: solver.program, solver };
151
+ }
152
+
153
+ async function readQuery(reader) {
154
+ let source = '';
155
+ let prompt = '?- ';
156
+ while (true) {
157
+ const line = await reader.read(prompt);
158
+ if (line == null) return source.trim() ? source : null;
159
+ source += `${line}\n`;
160
+ const end = terminalFullStop(source);
161
+ if (end >= 0) return source.slice(0, end);
162
+ prompt = '| ';
163
+ }
164
+ }
165
+
166
+ function terminalFullStop(source) {
167
+ let quote = null;
168
+ let lineComment = false;
169
+ let blockComment = false;
170
+ let escaped = false;
171
+ let depth = 0;
172
+
173
+ for (let i = 0; i < source.length; i++) {
174
+ const ch = source[i];
175
+ const next = source[i + 1];
176
+ if (lineComment) {
177
+ if (ch === '\n') lineComment = false;
178
+ continue;
179
+ }
180
+ if (blockComment) {
181
+ if (ch === '*' && next === '/') {
182
+ blockComment = false;
183
+ i++;
184
+ }
185
+ continue;
186
+ }
187
+ if (quote != null) {
188
+ if (escaped) {
189
+ escaped = false;
190
+ } else if (ch === '\\') {
191
+ escaped = true;
192
+ } else if (ch === quote) {
193
+ if (next === quote) i++;
194
+ else quote = null;
195
+ }
196
+ continue;
197
+ }
198
+ if (ch === '%') {
199
+ lineComment = true;
200
+ continue;
201
+ }
202
+ if (ch === '/' && next === '*') {
203
+ blockComment = true;
204
+ i++;
205
+ continue;
206
+ }
207
+ if (ch === "'" || ch === '"' || ch === '`') {
208
+ quote = ch;
209
+ continue;
210
+ }
211
+ if ('([{'.includes(ch)) depth++;
212
+ else if (')]}'.includes(ch)) depth = Math.max(0, depth - 1);
213
+ else if (ch === '.' && depth === 0 && onlyLayoutAndComments(source.slice(i + 1))) return i;
214
+ }
215
+ return -1;
216
+ }
217
+
218
+ function onlyLayoutAndComments(source) {
219
+ return source.replace(/\s|%[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\//g, '').length === 0;
220
+ }
221
+
222
+ function parseGoal(engine, state, text) {
223
+ const goal = engine.parseGoalText(text, {
224
+ doubleQuotes: state.solver.prologFlags.get('double_quotes')?.value?.name ?? 'chars',
225
+ operatorDefinitions: [...state.program.operators.values()],
226
+ });
227
+ if (goal.type === 'var') throw new engine.PrologError('instantiation_error');
228
+ if (goal.type !== 'atom' && goal.type !== 'compound') {
229
+ throw new engine.PrologError('type_error(callable)', goal);
230
+ }
231
+ return goal;
232
+ }
233
+
234
+ function isUseModuleGoal(goal) {
235
+ return goal.type === 'compound' && goal.name === 'use_module' && [1, 2].includes(goal.arity);
236
+ }
237
+
238
+ function consultDesignations(engine, goal) {
239
+ if (goal.type === 'atom' && goal.name === '[]') return [];
240
+ if (goal.type !== 'compound' || goal.name !== '.' || goal.arity !== 2) return null;
241
+ const items = engine.properListItems(goal, new engine.Env());
242
+ if (items == null) return null;
243
+ return items.map((item) => {
244
+ if (item.type === 'var') throw new engine.PrologError('instantiation_error');
245
+ if (item.type !== 'atom') throw new engine.PrologError('type_error(atom)', item);
246
+ return item.name;
247
+ });
248
+ }
249
+
250
+ async function readSource(designation) {
251
+ let filename = path.resolve(designation);
252
+ try {
253
+ await fs.access(filename);
254
+ } catch (error) {
255
+ if (path.extname(filename)) throw error;
256
+ filename += '.pl';
257
+ }
258
+ return {
259
+ text: await fs.readFile(filename, 'utf8'),
260
+ filename: path.basename(filename),
261
+ baseDir: path.dirname(filename),
262
+ };
263
+ }
264
+
265
+ async function solveQuery(engine, state, goal, reader, output) {
266
+ const variables = queryVariables(goal);
267
+ const solver = state.solver;
268
+ solver.solutionsSeen = 0;
269
+ const solutions = solver.solve([goal], new engine.Env(), 0);
270
+ let current = pullSolution(solver, solutions);
271
+ if (current.error) {
272
+ if (current.error?.name === 'HaltSignal') return { halted: true, code: current.error.code };
273
+ throw current.error;
274
+ }
275
+
276
+ if (current.result.done) {
277
+ output.write(' false.\n');
278
+ return null;
279
+ }
280
+
281
+ let automatic = 0;
282
+ let firstAnswer = true;
283
+ while (!current.result.done) {
284
+ const next = pullSolution(solver, solutions);
285
+ output.write(current.output);
286
+ output.write(`${firstAnswer ? ' ' : ''}${formatAnswer(engine, state, variables, current.result.value)}`);
287
+ firstAnswer = false;
288
+ if (!next.error && next.result.done) {
289
+ output.write('.\n');
290
+ return null;
291
+ }
292
+
293
+ if (automatic > 0 || automatic === Infinity) {
294
+ if (automatic !== Infinity) automatic--;
295
+ output.write('\n; ');
296
+ } else {
297
+ while (true) {
298
+ const controlLine = await reader.readControl('\n; ');
299
+ if (controlLine == null || controlLine === '' || controlLine === '\r' || controlLine === '\n' ||
300
+ controlLine.trimStart().startsWith('.')) {
301
+ if (typeof solutions.return === 'function') solutions.return();
302
+ output.write('... .\n');
303
+ return null;
304
+ }
305
+ const control = controlLine === ' ' ? ' ' : controlLine.trimStart()[0];
306
+ if (control === ';' || control === 'n' || control === ' ') break;
307
+ if (control === 'a') {
308
+ automatic = Infinity;
309
+ break;
310
+ }
311
+ if (control === 'f') {
312
+ automatic = 4;
313
+ break;
314
+ }
315
+ if (control === 'w' || control === 'p') {
316
+ output.write(`${formatAnswer(engine, state, variables, current.result.value)}`);
317
+ continue;
318
+ }
319
+ if (control === 'h') {
320
+ output.write(ANSWER_HELP);
321
+ continue;
322
+ }
323
+ output.write('Action? ');
324
+ }
325
+ }
326
+
327
+ if (next.error) {
328
+ output.write(next.output);
329
+ if (next.error?.name === 'HaltSignal') return { halted: true, code: next.error.code };
330
+ throw next.error;
331
+ }
332
+ current = next;
333
+ }
334
+ return null;
335
+ }
336
+
337
+ function pullSolution(solver, solutions) {
338
+ const stream = solver.io.resolve('user_output');
339
+ const originalWrite = stream?.write;
340
+ let captured = '';
341
+ if (stream) stream.write = (text) => { captured += String(text); };
342
+ try {
343
+ return { result: solutions.next(), output: captured };
344
+ } catch (error) {
345
+ return { error, output: captured };
346
+ } finally {
347
+ if (stream) stream.write = originalWrite;
348
+ }
349
+ }
350
+
351
+ function queryVariables(goal) {
352
+ const variables = [];
353
+ const seen = new Set();
354
+ const stack = [goal];
355
+ while (stack.length) {
356
+ const term = stack.pop();
357
+ if (term.type === 'var') {
358
+ if (!term.name.startsWith('__anon') && !seen.has(term.name)) {
359
+ seen.add(term.name);
360
+ variables.push(term);
361
+ }
362
+ } else {
363
+ for (let i = term.args.length - 1; i >= 0; i--) stack.push(term.args[i]);
364
+ }
365
+ }
366
+ return variables;
367
+ }
368
+
369
+ function formatAnswer(engine, state, variables, env) {
370
+ const bindings = [];
371
+ const names = new Map(variables.map((variable) => [variable.name, variable.name]));
372
+ let generated = 0;
373
+
374
+ for (const variable of variables) collectUnboundVariables(engine, variable, env, names, () => `_${letterName(generated++)}`);
375
+ for (const variable of variables) {
376
+ const value = engine.deref(variable, env);
377
+ if (value.type === 'var' && value.name === variable.name) continue;
378
+ bindings.push(`${variable.name} = ${engine.formatTermForWrite(value, env, {
379
+ quoted: true,
380
+ operators: [...state.program.operators.values()],
381
+ variableNames: names,
382
+ })}`);
383
+ }
384
+ return bindings.length === 0 ? 'true' : bindings.join(', ');
385
+ }
386
+
387
+ function collectUnboundVariables(engine, term, env, names, nextName) {
388
+ const stack = [term];
389
+ const seen = new Set();
390
+ while (stack.length) {
391
+ const current = engine.deref(stack.pop(), env);
392
+ if (current.type === 'var') {
393
+ if (!seen.has(current.name)) {
394
+ seen.add(current.name);
395
+ if (!names.has(current.name)) names.set(current.name, nextName());
396
+ }
397
+ } else {
398
+ for (let i = current.args.length - 1; i >= 0; i--) stack.push(current.args[i]);
399
+ }
400
+ }
401
+ }
402
+
403
+ function letterName(index) {
404
+ const letter = String.fromCharCode(65 + (index % 26));
405
+ const suffix = Math.floor(index / 26);
406
+ return suffix === 0 ? letter : `${letter}${suffix}`;
407
+ }
408
+
409
+ function formatError(error) {
410
+ const message = error?.message ?? String(error);
411
+ return message.endsWith('.') ? message : `${message}.`;
412
+ }
@@ -204,11 +204,12 @@ why(
204
204
  },
205
205
  },
206
206
  {
207
- name: 'help with no arguments',
207
+ name: '-h shows CLI help',
208
208
  run: () => {
209
- const result = runCli([]);
209
+ const result = runCli(['-h']);
210
210
  assertEqual(result.status, 0, 'exit status');
211
- assertIncludes(result.stdout, 'Usage:\n eyeprolog [options] [file-or-url.pl|- ...]', 'stdout');
211
+ assertIncludes(result.stdout, 'Usage:\n eyeprolog\n eyeprolog [options] [file-or-url.pl|- ...]', 'stdout');
212
+ assertIncludes(result.stdout, 'With no arguments, start a Prolog REPL.', 'stdout');
212
213
  assertIncludes(result.stdout, '-p, --proof', 'stdout');
213
214
  assertIncludes(result.stdout, '-s, --stats', 'stdout');
214
215
  assertIncludes(result.stdout, '-v, --version', 'stdout');
@@ -218,6 +219,68 @@ why(
218
219
  assertEqual(result.stderr, '', 'stderr');
219
220
  },
220
221
  },
222
+ {
223
+ name: 'bare CLI starts a REPL with truth, failure, and bindings',
224
+ run: () => {
225
+ const result = runCli([], { input: 'true.\nfalse.\nX = hello.\nhalt.\n' });
226
+ assertEqual(result.status, 0, 'exit status');
227
+ assertEqual(result.stdout, '?- true.\n?- false.\n?- X = hello.\n?- ', 'stdout');
228
+ assertEqual(result.stderr, '', 'stderr');
229
+ },
230
+ },
231
+ {
232
+ name: 'REPL enumerates and stops answers like the Scryer top level',
233
+ run: () => {
234
+ const result = runCli([], {
235
+ input: '(X = a; X = b).\n;\n(X = one; X = two).\n\nhalt.\n',
236
+ });
237
+ assertEqual(result.status, 0, 'exit status');
238
+ assertEqual(result.stdout, '?- X = a\n; X = b.\n?- X = one\n; ... .\n?- ', 'stdout');
239
+ assertEqual(result.stderr, '', 'stderr');
240
+ },
241
+ },
242
+ {
243
+ name: 'REPL accepts multiline period-terminated queries',
244
+ run: () => {
245
+ const result = runCli([], { input: '(X =\n one).\nhalt.\n' });
246
+ assertEqual(result.status, 0, 'exit status');
247
+ assertEqual(result.stdout, '?- | X = one.\n?- ', 'stdout');
248
+ assertEqual(result.stderr, '', 'stderr');
249
+ },
250
+ },
251
+ {
252
+ name: 'REPL consult shorthand loads local Prolog files',
253
+ run: () => {
254
+ const filename = path.join(tmp, `repl-consult-${++tmpCounter}.pl`);
255
+ fs.writeFileSync(filename, 'color(red).\ncolor(blue).\n');
256
+ const result = runCli([], {
257
+ input: `[${sourceAtom(filename)}].\ncolor(X).\n;\nhalt.\n`,
258
+ });
259
+ assertEqual(result.status, 0, 'exit status');
260
+ assertEqual(result.stdout, '?- true.\n?- X = red\n; X = blue.\n?- ', 'stdout');
261
+ assertEqual(result.stderr, '', 'stderr');
262
+ },
263
+ },
264
+ {
265
+ name: 'REPL use_module imports Part 2 library predicates',
266
+ run: () => {
267
+ const result = runCli([], {
268
+ input: 'append(X, Y, [1, 2, 3, 4]).\nuse_module(library(lists)).\nappend(X, Y, [1, 2, 3, 4]).\n\nhalt.\n',
269
+ });
270
+ assertEqual(result.status, 0, 'exit status');
271
+ assertEqual(result.stdout, '?- false.\n?- true.\n?- X = [], Y = [1, 2, 3, 4]\n; ... .\n?- ', 'stdout');
272
+ assertEqual(result.stderr, '', 'stderr');
273
+ },
274
+ },
275
+ {
276
+ name: 'REPL halt status is returned by the CLI',
277
+ run: () => {
278
+ const result = runCli([], { input: 'halt(7).\n' });
279
+ assertEqual(result.status, 7, 'exit status');
280
+ assertEqual(result.stdout, '?- ', 'stdout');
281
+ assertEqual(result.stderr, '', 'stderr');
282
+ },
283
+ },
221
284
  {
222
285
  name: 'CLI loads an explicitly imported standard library module',
223
286
  run: () => {
@@ -6015,9 +6015,34 @@ search behavior.
6015
6015
  </figure>
6016
6016
 
6017
6017
  ```text
6018
+ eyeprolog
6018
6019
  eyeprolog [options] [file-or-url.pl|- ...]
6019
6020
  ```
6020
6021
 
6022
+ ### Interactive queries
6023
+
6024
+ Run `eyeprolog` without arguments to enter the interactive top level. Queries
6025
+ may span lines and end with a full stop, as in Scryer Prolog:
6026
+
6027
+ ```text
6028
+ ?- use_module(library(lists)).
6029
+ true.
6030
+ ?- member(X, [prolog, logic]).
6031
+ X = prolog
6032
+ ; X = logic.
6033
+ ?- halt.
6034
+ ```
6035
+
6036
+ When another answer exists in an interactive terminal, press `;`, Space, or
6037
+ `n` to ask for it immediately; no Return is needed. Return or `.` stops
6038
+ enumeration, `a` enumerates all remaining answers, `f` enumerates the next
6039
+ five, and `h` displays the answer-control help. A
6040
+ period-terminated query with no solutions prints `false.`; a solution without
6041
+ visible variable bindings prints `true.`. Use `[file].` or `['file.pl'].` to
6042
+ consult local source, and `halt.` or `halt(Status).` to leave the top level.
6043
+ Up and Down recall queries from the current session. Explicit `eyeprolog -h`
6044
+ displays command-line help.
6045
+
6021
6046
  ### Selecting goals
6022
6047
 
6023
6048
  A Prolog source file states facts, rules, and ISO directives; the command line
@@ -6056,8 +6081,8 @@ make the observed question explicit.
6056
6081
  Short flags may be combined, so `-pw` is equivalent to `-p -w`.
6057
6082
 
6058
6083
  Inputs may be local files, HTTP(S) URLs, or one `-` for stdin. The bare command
6059
- `eyeprolog` prints help. When options are present but no input is named, stdin is
6060
- used; writing `-` explicitly is clearer in scripts. Multiple sources are
6084
+ `eyeprolog` starts the REPL. When options are present but no input is named,
6085
+ stdin is used; writing `-` explicitly is clearer in scripts. Multiple sources are
6061
6086
  parsed as one program, so facts, rules, and directives can be separated across
6062
6087
  files. A relative `include/1` inside a local file resolves from that file's
6063
6088
  directory.