eyeprolog 1.1.6 → 1.1.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/README.md CHANGED
@@ -45,6 +45,15 @@ Programs may declare their default queries with `%% goal:` comments.
45
45
  Double-quoted text follows the ISO `double_quotes` flag and defaults to a
46
46
  proper list of one-character atoms (`chars`), matching Trealla and Scryer.
47
47
 
48
+ Portable unit tests can be embedded as quads—a query followed by its expected
49
+ top-level answer—and run with `eyeprolog --quads program.pl`:
50
+
51
+ ```prolog
52
+ member_test ?- member(X, [prolog, logic]).
53
+ X = prolog
54
+ ; X = logic.
55
+ ```
56
+
48
57
  ## ISO modules and definite clause grammars
49
58
 
50
59
  EyeProlog implements ISO/IEC 13211-2 modules and the grammar rules and
@@ -242,3 +242,7 @@ npm run generate
242
242
  - [03-answer-3.pl](chapter-39/03-answer-3.pl)
243
243
  - [04-cost.pl](chapter-39/04-cost.pl)
244
244
  - [05-message.pl](chapter-39/05-message.pl)
245
+
246
+ ## Chapter 40: Running EyeProlog: command line and corpus
247
+
248
+ - [01-color.pl](chapter-40/01-color.pl) — Embedded quad tests
@@ -0,0 +1,10 @@
1
+ % From The Art of EyeProlog, Chapter 40 — Embedded quad tests.
2
+ color(red).
3
+ color(green).
4
+
5
+ colors ?- color(X).
6
+ X = red
7
+ ; X = green.
8
+
9
+ ?- color(blue).
10
+ false.
package/index.d.ts CHANGED
@@ -55,6 +55,29 @@ export interface EyePrologClause {
55
55
  module?: string;
56
56
  }
57
57
 
58
+ export interface EyePrologQuad {
59
+ kind: 'quad';
60
+ id: EyePrologTerm | null;
61
+ query: EyePrologTerm;
62
+ answers: EyePrologTerm[];
63
+ module?: string;
64
+ source: { filename: string; line: number };
65
+ }
66
+
67
+ export interface EyePrologQuadResult {
68
+ ok: boolean;
69
+ kind?: 'failed' | 'malformed' | 'bad_identifier' | 'unsupported';
70
+ expected?: EyePrologTerm;
71
+ }
72
+
73
+ export interface EyePrologQuadRunResult {
74
+ stdout: string;
75
+ total: number;
76
+ passed: number;
77
+ failed: number;
78
+ results: EyePrologQuadResult[];
79
+ }
80
+
58
81
  export interface EyePrologPredicateGroup {
59
82
  name: string;
60
83
  arity: number;
@@ -94,6 +117,7 @@ export class Program {
94
117
  groups: Map<string, EyePrologPredicateGroup>;
95
118
  modules: Map<string, { name: string; exports: Map<string, unknown>; filename: string }>;
96
119
  moduleImports: Map<string, Map<string, string>>;
120
+ quads: EyePrologQuad[];
97
121
  doubleQuotes: 'chars' | 'codes' | 'atom';
98
122
  negationDependencies: Array<{ from: string; to: string; negative: boolean }>;
99
123
  negationStratificationErrors: Array<{ from: string; to: string }>;
@@ -186,8 +210,8 @@ export function numberTextFromDouble(value: number): string | null;
186
210
  export function compareNumberText(left: string, right: string): number;
187
211
 
188
212
  export function makeProgram(source: string, options?: EyePrologRunOptions): Program;
189
- export function parseClauses(source: string, options?: EyePrologRunOptions): EyePrologClause[];
190
- export function parseProgramText(source: string, options?: EyePrologRunOptions): EyePrologClause[];
213
+ export function parseClauses(source: string, options?: EyePrologRunOptions): Array<EyePrologClause | EyePrologQuad>;
214
+ export function parseProgramText(source: string, options?: EyePrologRunOptions): Array<EyePrologClause | EyePrologQuad>;
191
215
  export function parseGoalText(source: string, options?: EyePrologRunOptions): EyePrologTerm;
192
216
  export function createDefaultRegistry(): BuiltinRegistry;
193
217
  export function createEyePrologRegistry(): BuiltinRegistry;
@@ -208,6 +232,7 @@ export class HaltSignal extends Error {
208
232
  constructor(code?: number);
209
233
  }
210
234
  export function run(source: string | Program, options?: EyePrologRunOptions): EyePrologRunResult;
235
+ export function runQuads(source: string | Program, options?: EyePrologRunOptions & { initialize?: boolean }): EyePrologQuadRunResult;
211
236
  export function whyProof(program: Program, goal: EyePrologTerm, options?: EyePrologRunOptions): { ok: boolean; text: string };
212
237
  export function whyNoProof(goal: EyePrologTerm): string;
213
238
  export function explainProof(program: Program, goal: EyePrologTerm, options?: EyePrologRunOptions): { ok: boolean; text: string };
@@ -269,6 +294,7 @@ declare const eyeprolog: {
269
294
  eyePrologNativeLibraryIndicators: typeof eyePrologNativeLibraryIndicators;
270
295
  eyePrologPortableLibraryIndicators: typeof eyePrologPortableLibraryIndicators;
271
296
  run: typeof run;
297
+ runQuads: typeof runQuads;
272
298
  whyProof: typeof whyProof;
273
299
  whyNoProof: typeof whyNoProof;
274
300
  explainProof: typeof explainProof;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.1.6",
6
+ "version": "1.1.7",
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
@@ -24,6 +24,7 @@ export async function main(argv) {
24
24
  const options = {
25
25
  files: [],
26
26
  proof: false,
27
+ quads: false,
27
28
  stats: false,
28
29
  version: false,
29
30
  warnings: false,
@@ -42,6 +43,8 @@ export async function main(argv) {
42
43
  return;
43
44
  } else if (!endOptions && (arg === '--proof' || arg === '-p')) {
44
45
  options.proof = true;
46
+ } else if (!endOptions && (arg === '--quads' || arg === '-q')) {
47
+ options.quads = true;
45
48
  } else if (!endOptions && (arg === '--stats' || arg === '-s')) {
46
49
  options.stats = true;
47
50
  } else if (!endOptions && (arg === '--version' || arg === '-v')) {
@@ -55,13 +58,14 @@ export async function main(argv) {
55
58
  } else if (!endOptions && arg.startsWith('-') && !arg.startsWith('--') && arg.length > 2) {
56
59
  const flags = arg.slice(1);
57
60
  for (const flag of flags) {
58
- if (!'hpsvw'.includes(flag)) throw new Error(`unknown option: ${arg}`);
61
+ if (!'hpqsvw'.includes(flag)) throw new Error(`unknown option: ${arg}`);
59
62
  }
60
63
  if (flags.includes('h')) {
61
64
  await usage(process.stdout);
62
65
  return;
63
66
  }
64
67
  if (flags.includes('p')) options.proof = true;
68
+ if (flags.includes('q')) options.quads = true;
65
69
  if (flags.includes('s')) options.stats = true;
66
70
  if (flags.includes('v')) options.version = true;
67
71
  if (flags.includes('w')) options.warnings = true;
@@ -102,7 +106,7 @@ export async function main(argv) {
102
106
  }
103
107
  }
104
108
 
105
- if (options.goals.length === 0) {
109
+ if (options.goals.length === 0 && !options.quads) {
106
110
  for (const source of sourceParts) options.goals.push(...goalsFromSource(source.text));
107
111
  }
108
112
 
@@ -111,12 +115,17 @@ export async function main(argv) {
111
115
 
112
116
  if (options.warnings) printWarnings(program);
113
117
 
114
- await runDefault(engine, program, options);
118
+ if (!options.quads || options.goals.length > 0) await runDefault(engine, program, options);
119
+ if (options.quads) {
120
+ const result = engine.runQuads(program, { initialize: options.goals.length === 0 });
121
+ process.stdout.write(result.stdout);
122
+ if (result.failed > 0) process.exitCode = 1;
123
+ }
115
124
  }
116
125
 
117
126
  async function loadEngine() {
118
127
  if (engineModule == null) {
119
- const [term, parser, program, solver, iso, library, write] = await Promise.all([
128
+ const [term, parser, program, solver, iso, library, write, quads] = await Promise.all([
120
129
  import('./term.js'),
121
130
  import('./parser.js'),
122
131
  import('./program.js'),
@@ -124,8 +133,9 @@ async function loadEngine() {
124
133
  import('./iso.js'),
125
134
  import('./standard-library.js'),
126
135
  import('./write.js'),
136
+ import('./quads.js'),
127
137
  ]);
128
- engineModule = { ...term, ...parser, ...program, ...solver, ...iso, ...library, ...write };
138
+ engineModule = { ...term, ...parser, ...program, ...solver, ...iso, ...library, ...write, ...quads };
129
139
  }
130
140
  return engineModule;
131
141
  }
@@ -207,6 +217,7 @@ Input:
207
217
  Options:
208
218
  -h, --help Show this help text and exit.
209
219
  -p, --proof Enable proof explanations.
220
+ -q, --quads Run embedded quad tests and fail if any do not hold.
210
221
  -s, --stats Print solver statistics to stderr after execution.
211
222
  -v, --version Show the package version and exit.
212
223
  -w, --warnings Print non-fatal portability warnings to stderr.
package/src/index.js CHANGED
@@ -20,6 +20,7 @@ export {
20
20
  eyePrologPortableLibraryIndicators,
21
21
  } from './standard-library.js';
22
22
  export { StreamManager } from './io.js';
23
+ export { runQuads } from './quads.js';
23
24
 
24
25
  import { ATOM, COMPOUND, VAR, Env, copyResolved, termIsGround, termToString } from './term.js';
25
26
  import { Program } from './program.js';
package/src/parser.js CHANGED
@@ -277,6 +277,14 @@ class Parser {
277
277
  const line = this.line;
278
278
  const ch = this.peek();
279
279
  if (!ch) return { type: TOK.EOF, text: '', line };
280
+ if (this.source.startsWith('...', this.pos) && this.peek(3) !== '.') {
281
+ this.pos += 3;
282
+ return { type: TOK.ATOM, text: '...', line };
283
+ }
284
+ if (ch === '?' && this.peek(1) === '-') {
285
+ this.pos += 2;
286
+ return { type: TOK.ATOM, text: '?-', line };
287
+ }
280
288
  if (ch === '!') {
281
289
  this.take();
282
290
  return { type: TOK.ATOM, text: '!', line };
@@ -591,12 +599,47 @@ class Parser {
591
599
  }
592
600
  throw new Error(`parse line ${this.token.line}: bad term`);
593
601
  }
602
+ sourceLineIsIndented(line) {
603
+ let start = 0;
604
+ for (let current = 1; current < line; current++) {
605
+ const newline = this.source.indexOf('\n', start);
606
+ if (newline < 0) return false;
607
+ start = newline + 1;
608
+ }
609
+ return this.source[start] === ' ' || this.source[start] === '\t';
610
+ }
611
+ parseQuad(id, line, accept) {
612
+ const query = this.parseTerm(0, true);
613
+ this.expect(TOK.DOT, '.');
614
+ this.advance();
615
+
616
+ const answers = [];
617
+ while (this.token.type !== TOK.EOF && this.sourceLineIsIndented(this.token.line)) {
618
+ answers.push(this.parseTerm(0, true));
619
+ this.expect(TOK.DOT, '.');
620
+ this.advance();
621
+ }
622
+ if (answers.length === 0) throw new Error(`parse line ${line}: quad requires an indented answer description`);
623
+
624
+ accept({
625
+ kind: 'quad',
626
+ id,
627
+ query,
628
+ answers,
629
+ source: { filename: this.filename, line },
630
+ });
631
+ }
594
632
  parseProgram(emit = null) {
595
633
  const clauses = emit ? null : [];
596
634
  let clauseNumber = 0;
597
635
  const accept = emit ?? ((clause) => clauses.push(clause));
598
636
  while (this.token.type !== TOK.EOF) {
599
637
  const line = this.token.line;
638
+ if (this.operatorTokenName() === '?-') {
639
+ this.advance();
640
+ this.parseQuad(null, line, accept);
641
+ continue;
642
+ }
600
643
  if (this.token.type === TOK.IF) {
601
644
  this.advance();
602
645
  const directive = this.parseTerm();
@@ -622,6 +665,11 @@ class Parser {
622
665
  continue;
623
666
  }
624
667
  let head = this.parseTerm(3);
668
+ if (this.operatorTokenName() === '?-') {
669
+ this.advance();
670
+ this.parseQuad(head, line, accept);
671
+ continue;
672
+ }
625
673
  // ISO/IEC TS 13211-3 grammar rules use -->/2 at the same top-level
626
674
  // priority as clauses. The left side may include an unparenthesized
627
675
  // semicontext: NonTerminal, Terminals --> Body.
package/src/program.js CHANGED
@@ -104,6 +104,7 @@ export class Program {
104
104
  this.defineOperator(priority, specifier, name);
105
105
  }
106
106
  this.initializations = [];
107
+ this.quads = [];
107
108
  this.prologFlagDirectives = [];
108
109
  this.charConversionDirectives = [];
109
110
  this.doubleQuotes = options.doubleQuotes ?? 'chars';
@@ -471,6 +472,12 @@ class ProgramBuilder {
471
472
  let lastGroup = this.lastGroup;
472
473
 
473
474
  for (const clause of clauses) {
475
+ if (clause?.kind === 'quad') {
476
+ const module = clause.module ?? 'user';
477
+ annotateGoalModule(clause.query, module);
478
+ program.quads.push({ ...clause, module });
479
+ continue;
480
+ }
474
481
  clause.index = program.clauses.length;
475
482
  program.clauses.push(clause);
476
483
 
@@ -664,6 +671,12 @@ function loadSourceIntoBuilder(builder, source, options, ensured, loadedModules,
664
671
  batch.length = 0;
665
672
  };
666
673
  const accept = (clause) => {
674
+ if (clause?.kind === 'quad') {
675
+ flush();
676
+ clause.module = context.module;
677
+ builder.addClauses([clause]);
678
+ return;
679
+ }
667
680
  const grammarClause = expandDcgRuleClause(clause, context.module);
668
681
  if (grammarClause) clause = grammarClause;
669
682
  const moduleDeclaration = moduleDirective(clause);
package/src/quads.js ADDED
@@ -0,0 +1,368 @@
1
+ // Embedded quad tests: a query followed by one or more answer descriptions.
2
+ // The syntax follows the portable "queries using answer descriptions" format
3
+ // used by Trealla and the ISO Prolog working examples linked from issue #1.
4
+ import {
5
+ ATOM, COMPOUND, VAR, Env, atom, compound, copyResolved, deref,
6
+ flattenConjunction, properListItems, termIsGround,
7
+ unify, variable,
8
+ } from './term.js';
9
+ import { parseGoalText } from './parser.js';
10
+ import { Program } from './program.js';
11
+ import { Solver } from './solver.js';
12
+ import { getEyePrologRegistry } from './standard-library.js';
13
+ import { formatTermForWrite } from './write.js';
14
+
15
+ export function runQuads(source, options = {}) {
16
+ const program = source instanceof Program
17
+ ? source
18
+ : Program.parse(source, { ...options, sourceMetadata: true });
19
+ const quads = program.quads ?? [];
20
+ if (quads.length === 0) {
21
+ return { stdout: 'quads: nothing to run.\n', total: 0, passed: 0, failed: 0, results: [] };
22
+ }
23
+
24
+ if (options.initialize !== false) {
25
+ const initializer = new Solver(program, {
26
+ ...options,
27
+ registry: options.registry ?? getEyePrologRegistry(),
28
+ ioOptions: { write: () => {} },
29
+ });
30
+ initializer.runInitializations();
31
+ }
32
+
33
+ const results = [];
34
+ const lines = [];
35
+ for (const quad of quads) {
36
+ const result = checkQuad(program, quad, options);
37
+ results.push(result);
38
+ if (!result.ok) lines.push(formatFailure(program, quad, result));
39
+ }
40
+ const passed = results.filter((result) => result.ok).length;
41
+ const failed = results.length - passed;
42
+ lines.push(`quads: ${results.length} run, ${passed} passed, ${failed} failed.\n`);
43
+ return { stdout: lines.join(''), total: results.length, passed, failed, results };
44
+ }
45
+
46
+ function checkQuad(program, quad, options) {
47
+ if (quad.id != null && !termIsGround(quad.id, new Env())) {
48
+ return { ok: false, kind: 'bad_identifier', expected: quad.id };
49
+ }
50
+ for (const description of quad.answers) {
51
+ const checked = checkDescription(program, quad, description, options);
52
+ if (!checked.ok) return checked;
53
+ }
54
+ return { ok: true };
55
+ }
56
+
57
+ function checkDescription(program, quad, description, options) {
58
+ const alternatives = splitOperator(description, '|');
59
+ for (const alternative of alternatives) {
60
+ const malformed = malformedAlternative(quad.query, alternative);
61
+ if (malformed != null) return { ok: false, kind: 'malformed', expected: malformed };
62
+ }
63
+ let unsupported = null;
64
+ for (const alternative of alternatives) {
65
+ const checked = checkAlternative(program, quad, alternative, options);
66
+ if (checked.ok) return checked;
67
+ if (checked.kind === 'unsupported') unsupported ??= checked;
68
+ }
69
+ return unsupported ?? { ok: false, kind: 'failed', expected: description };
70
+ }
71
+
72
+ function checkAlternative(program, quad, alternative, options) {
73
+ const leaves = splitOperator(alternative, ';').map(describeLeaf);
74
+ if (leaves.some((leaf) => leaf.sto)) return { ok: true };
75
+ const unsupported = leaves.find((leaf) => leaf.unsupported != null)?.unsupported;
76
+ if (unsupported != null) {
77
+ return { ok: false, kind: 'unsupported', expected: unsupported };
78
+ }
79
+
80
+ const inputSpecs = [...new Set(leaves.filter((leaf) => leaf.input != null).map((leaf) => leaf.input))];
81
+ if (inputSpecs.length > 1) return { ok: false, kind: 'malformed', expected: alternative };
82
+ const input = inputSpecs[0] ?? '';
83
+ if (inputSpecs.length > 0 && leaves.length !== 1) return { ok: false };
84
+ const moreAt = leaves.findIndex((leaf) => leaf.more);
85
+ const describedCount = moreAt < 0 ? leaves.length : moreAt + (leaves[moreAt].hasExpectation ? 1 : 0);
86
+ const maxSolutions = inputSpecs.length > 0
87
+ ? 1
88
+ : moreAt < 0 ? describedCount + 1 : Math.max(describedCount, 1);
89
+ const actual = executeQuery(program, quad.query, input, maxSolutions, options);
90
+
91
+ if (inputSpecs.length > 0) {
92
+ const leaf = leaves[0];
93
+ const matches = actual.inputPosition === input.length && matchLeaf(quad.query, leaf, actual, 0);
94
+ return { ok: leaf.unexpected ? !matches : matches };
95
+ }
96
+
97
+ let position = 0;
98
+ for (const leaf of leaves) {
99
+ if (leaf.more && !leaf.hasExpectation) return { ok: true };
100
+ const matches = matchLeaf(quad.query, leaf, actual, position);
101
+ if (leaf.unexpected ? matches : !matches) return { ok: false };
102
+ if (leaf.more) return { ok: true };
103
+ if (!leaf.unexpected && (leaf.false || leaf.error != null)) {
104
+ return { ok: position === leaves.length - 1 };
105
+ }
106
+ position++;
107
+ }
108
+
109
+ const ended = position >= actual.solutions.length && actual.error == null;
110
+ return { ok: ended };
111
+ }
112
+
113
+ function malformedAlternative(query, alternative) {
114
+ const queryNames = new Set(namedVariables(query).map((variable) => variable.name));
115
+ for (const leaf of splitOperator(alternative, ';').map(describeLeaf)) {
116
+ if (leaf.malformed != null) return leaf.malformed;
117
+ const names = new Set();
118
+ for (const binding of leaf.bindings) {
119
+ const name = binding.args[0].name;
120
+ if (!queryNames.has(name) || names.has(name)) return binding;
121
+ names.add(name);
122
+ }
123
+ if (!leaf.sto) {
124
+ for (const binding of leaf.bindings) {
125
+ if (namedVariables(binding.args[1]).some((variable) => names.has(variable.name))) return binding;
126
+ }
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+
132
+ function describeLeaf(term) {
133
+ const leaf = {
134
+ bindings: [],
135
+ unexpected: false,
136
+ more: false,
137
+ sto: false,
138
+ false: false,
139
+ truth: false,
140
+ error: null,
141
+ input: null,
142
+ output: null,
143
+ unsupported: null,
144
+ malformed: null,
145
+ hasExpectation: false,
146
+ };
147
+ for (const item of flattenConjunction(term)) {
148
+ if (item.type === ATOM) {
149
+ if (item.name === 'unexpected' || item.name === 'inattendue') leaf.unexpected = true;
150
+ else if (item.name === '...' || item.name === 'ad_infinitum') leaf.more = true;
151
+ else if (item.name === 'sto') leaf.sto = true;
152
+ else if (item.name === 'loops' || item.name === 'waits' || item.name === 'other_answer_sequence') {
153
+ leaf.unsupported ??= item;
154
+ } else if (item.name === 'false') leaf.false = true;
155
+ else if (item.name === 'true') leaf.truth = true;
156
+ else if (isErrorDescription(item)) leaf.error = item;
157
+ else leaf.malformed ??= item;
158
+ continue;
159
+ }
160
+ if (item.type === COMPOUND && item.name === '=' && item.arity === 2) {
161
+ if (item.args[0].type !== VAR) leaf.malformed ??= item;
162
+ else leaf.bindings.push(item);
163
+ continue;
164
+ }
165
+ if (item.type === COMPOUND && item.name === 'inputs' && item.arity === 1) {
166
+ const text = characterText(item.args[0]);
167
+ if (text == null || leaf.input != null) leaf.malformed ??= item;
168
+ else leaf.input = text;
169
+ continue;
170
+ }
171
+ if (item.type === COMPOUND && item.name === 'outputs' && item.arity === 1) {
172
+ const text = characterText(item.args[0]);
173
+ if (text == null || leaf.output != null) leaf.malformed ??= item;
174
+ else leaf.output = text;
175
+ continue;
176
+ }
177
+ if (item.type === COMPOUND && item.name === 'peeks' && item.arity === 1) {
178
+ leaf.unsupported ??= item;
179
+ continue;
180
+ }
181
+ if (isErrorDescription(item)) leaf.error = item;
182
+ else leaf.malformed ??= item;
183
+ }
184
+ leaf.hasExpectation = leaf.bindings.length > 0 || leaf.truth || leaf.false || leaf.error != null || leaf.output != null;
185
+ if (!leaf.hasExpectation && !leaf.more && !leaf.sto && leaf.unsupported == null) leaf.malformed ??= term;
186
+ if ([leaf.false, leaf.truth, leaf.error != null].filter(Boolean).length > 1) leaf.malformed ??= term;
187
+ return leaf;
188
+ }
189
+
190
+ function executeQuery(program, query, input, maxSolutions, options) {
191
+ let pendingOutput = '';
192
+ const solver = new Solver(program, {
193
+ ...options,
194
+ registry: options.registry ?? getEyePrologRegistry(),
195
+ solutionLimit: Math.max(1, maxSolutions),
196
+ ioOptions: {
197
+ input,
198
+ write: (text) => { pendingOutput += String(text); },
199
+ },
200
+ });
201
+ const solutions = [];
202
+ let error = null;
203
+ let tailOutput = '';
204
+ try {
205
+ const iterator = solver.solve([query], new Env(), 0);
206
+ while (solutions.length < maxSolutions) {
207
+ pendingOutput = '';
208
+ const result = iterator.next();
209
+ if (result.done) {
210
+ tailOutput += pendingOutput;
211
+ break;
212
+ }
213
+ solutions.push({ env: result.value, output: pendingOutput });
214
+ }
215
+ } catch (caught) {
216
+ error = { term: errorTerm(caught), output: pendingOutput };
217
+ }
218
+ const inputPosition = solver.io.resolve('user_input')?.position ?? 0;
219
+ return { solutions, error, tailOutput, inputPosition };
220
+ }
221
+
222
+ function matchLeaf(query, leaf, actual, position) {
223
+ if (leaf.false) {
224
+ return position >= actual.solutions.length && actual.error == null && outputMatches(leaf.output, actual.tailOutput);
225
+ }
226
+ if (leaf.error != null) {
227
+ return position === actual.solutions.length && actual.error != null &&
228
+ errorMatches(leaf.error, actual.error.term) && outputMatches(leaf.output, actual.error.output);
229
+ }
230
+ const solution = actual.solutions[position];
231
+ if (!solution || !outputMatches(leaf.output, solution.output)) return false;
232
+ return substitutionMatches(query, leaf.bindings, solution.env);
233
+ }
234
+
235
+ function substitutionMatches(query, bindings, actualEnv) {
236
+ const queryVariables = namedVariables(query);
237
+ const queryNames = new Set(queryVariables.map((variable) => variable.name));
238
+ const expectedEnv = new Env();
239
+ const rebound = new Set();
240
+ for (const binding of bindings) {
241
+ const variable = binding.args[0];
242
+ if (!queryNames.has(variable.name) || rebound.has(variable.name)) return false;
243
+ rebound.add(variable.name);
244
+ if (!unify(variable, binding.args[1], expectedEnv)) return false;
245
+ }
246
+ const expected = compound('$quad_answer', queryVariables.map((variable) => copyResolved(variable, expectedEnv)));
247
+ const actual = compound('$quad_answer', queryVariables.map((variable) => copyResolved(variable, actualEnv)));
248
+ return patternVariant(expected, new Env(), actual, new Env());
249
+ }
250
+
251
+ function namedVariables(term) {
252
+ const found = [];
253
+ const seen = new Set();
254
+ const stack = [term];
255
+ while (stack.length) {
256
+ const current = stack.pop();
257
+ if (current.type === VAR) {
258
+ if (!current.name.startsWith('__anon') && !seen.has(current.name)) {
259
+ seen.add(current.name);
260
+ found.push(current);
261
+ }
262
+ } else {
263
+ for (let index = current.args.length - 1; index >= 0; index--) stack.push(current.args[index]);
264
+ }
265
+ }
266
+ return found;
267
+ }
268
+
269
+ function patternVariant(pattern, patternEnv, actual, actualEnv, pairs = new Map(), reverse = new Map()) {
270
+ pattern = deref(pattern, patternEnv);
271
+ actual = deref(actual, actualEnv);
272
+ if (pattern.type === ATOM && pattern.name === '...') return true;
273
+ if (pattern.type === VAR || actual.type === VAR) {
274
+ if (pattern.type !== VAR || actual.type !== VAR) return false;
275
+ const paired = pairs.get(pattern.name);
276
+ const reversed = reverse.get(actual.name);
277
+ if (paired != null || reversed != null) return paired === actual.name && reversed === pattern.name;
278
+ pairs.set(pattern.name, actual.name);
279
+ reverse.set(actual.name, pattern.name);
280
+ return true;
281
+ }
282
+ if (pattern.type !== actual.type || pattern.name !== actual.name || pattern.arity !== actual.arity) return false;
283
+ for (let index = 0; index < pattern.arity; index++) {
284
+ if (!patternVariant(pattern.args[index], patternEnv, actual.args[index], actualEnv, pairs, reverse)) return false;
285
+ }
286
+ return true;
287
+ }
288
+
289
+ function errorTerm(error) {
290
+ if (error?.name === 'ThrownTerm' && error.term) return compound('$quad_thrown', [error.term]);
291
+ if (error?.name === 'PrologError') {
292
+ let formal;
293
+ try {
294
+ formal = parseGoalText(error.formal);
295
+ } catch (_) {
296
+ formal = atom(error.formal ?? 'system_error');
297
+ }
298
+ if (error.culprit != null) formal = formal.type === COMPOUND
299
+ ? compound(formal.name, [...formal.args, error.culprit])
300
+ : compound(formal.name, [error.culprit]);
301
+ return compound('error', [formal, variable('$quad_context')]);
302
+ }
303
+ return compound('error', [atom('system_error'), variable('$quad_context')]);
304
+ }
305
+
306
+ function errorMatches(expected, actual) {
307
+ if (expected.type === COMPOUND && expected.name === 'throw' && expected.arity === 1 &&
308
+ actual.type === COMPOUND && actual.name === '$quad_thrown' && actual.arity === 1) {
309
+ return patternVariant(expected.args[0], new Env(), actual.args[0], new Env());
310
+ }
311
+ if (actual.type !== COMPOUND || actual.name !== 'error' || actual.arity !== 2) return false;
312
+ if (expected.type === COMPOUND && expected.name === 'error' && expected.arity === 2) {
313
+ return patternVariant(expected, new Env(), actual, new Env());
314
+ }
315
+ return patternVariant(expected, new Env(), actual.args[0], new Env());
316
+ }
317
+
318
+ function isErrorDescription(term) {
319
+ if (term.type === ATOM) return ['instantiation_error', 'system_error'].includes(term.name);
320
+ return term.type === COMPOUND && [
321
+ 'error', 'throw', 'type_error', 'domain_error', 'existence_error',
322
+ 'permission_error', 'evaluation_error', 'representation_error',
323
+ 'resource_error', 'syntax_error', 'uninstantiation_error',
324
+ ].includes(term.name);
325
+ }
326
+
327
+ function characterText(term) {
328
+ const items = properListItems(term, new Env());
329
+ if (items == null) return null;
330
+ let text = '';
331
+ for (const item of items) {
332
+ if (item.type === ATOM && Array.from(item.name).length === 1) text += item.name;
333
+ else if (item.type === 'number' && /^\d+$/.test(item.name)) text += String.fromCodePoint(Number(item.name));
334
+ else return null;
335
+ }
336
+ return text;
337
+ }
338
+
339
+ function outputMatches(expected, actual) {
340
+ return expected == null || expected === actual;
341
+ }
342
+
343
+ function splitOperator(term, name) {
344
+ if (term.type === COMPOUND && term.name === name && term.arity === 2) {
345
+ return [term.args[0], ...splitOperator(term.args[1], name)];
346
+ }
347
+ return [term];
348
+ }
349
+
350
+ function formatFailure(program, quad, result) {
351
+ const source = quad.source ?? { filename: '<input>', line: 1 };
352
+ const label = quad.id == null ? '' : `${formatQuadTerm(program, quad.id)}, `;
353
+ const reason = result.kind === 'malformed' ? 'MALFORMED'
354
+ : result.kind === 'bad_identifier' ? 'BAD_ID'
355
+ : result.kind === 'unsupported' ? 'UNSUPPORTED'
356
+ : 'FAILED';
357
+ const expected = result.expected ?? quad.answers[0];
358
+ return `quads: ${reason} ${label}${source.filename}:${source.line}\n` +
359
+ ` ?- ${formatQuadTerm(program, quad.query)}.\n` +
360
+ ` expected: ${formatQuadTerm(program, expected)}.\n`;
361
+ }
362
+
363
+ function formatQuadTerm(program, term) {
364
+ return formatTermForWrite(term, new Env(), {
365
+ quoted: true,
366
+ operators: [...program.operators.values()],
367
+ });
368
+ }
package/src/write.js CHANGED
@@ -9,6 +9,7 @@ const graphicAtomCharacters = new Set('!#$&*+-/<=>@^~\\'.split(''));
9
9
  function atomNeedsQuotes(name) {
10
10
  if (!name) return true;
11
11
  if (name === '[]' || name === '{}') return false;
12
+ if (name === '...') return false;
12
13
  if (name === '\\+' || name === '+' || name === '-' || name === '\\') return true;
13
14
  if (/^[a-z][A-Za-z0-9_]*$/.test(name)) return false;
14
15
  for (const ch of name) if (!graphicAtomCharacters.has(ch)) return true;
@@ -183,6 +183,81 @@ why(
183
183
  assertNotIncludes(result.stdout, 'no_proof', 'stdout');
184
184
  },
185
185
  },
186
+ {
187
+ name: 'parser records embedded quads without indexing them as clauses',
188
+ run: () => {
189
+ const source = `p(1).\n\nnamed ?- p(X).\n X = 1.\n\nq(2).\n`;
190
+ const program = Program.parseSources([{ text: source, filename: 'embedded-quads.pl' }]);
191
+ assertEqual(program.clauses.length, 2, 'ordinary clause count');
192
+ assertEqual(program.quads.length, 1, 'quad count');
193
+ assertEqual(program.quads[0].id.name, 'named', 'quad label');
194
+ assertEqual(program.quads[0].source.filename, 'embedded-quads.pl', 'quad filename');
195
+ assertEqual(program.quads[0].source.line, 3, 'quad line');
196
+ assertEqual(Boolean(program.findGroup('p', 1)), true, 'preceding clause indexed');
197
+ assertEqual(Boolean(program.findGroup('q', 1)), true, 'following clause indexed');
198
+ assertEqual(Boolean(program.findGroup('?-', 2)), false, 'quad is inert');
199
+ },
200
+ },
201
+ {
202
+ name: 'runQuads checks portable answer descriptions',
203
+ run: () => {
204
+ const source = `p(1).\np(2).\np(3).\n\n` +
205
+ `ordered ?- p(X).\n X = 1 ; X = 2 ; X = 3.\n\n` +
206
+ `?- p(4).\n false.\n\n` +
207
+ `?- X = 1.\n X = 2, unexpected.\n\n` +
208
+ `?- p(X).\n X = 1, ... .\n\n` +
209
+ `?- atom_length(1, L).\n type_error(atom, 1).\n\n` +
210
+ `?- atom_length(1, L).\n error(type_error(atom, 1), _).\n\n` +
211
+ `?- throw(ball).\n throw(ball).\n\n` +
212
+ `?- write(ok), nl.\n outputs("ok\\n"), true.\n\n` +
213
+ `?- get_char(C).\n inputs("a"), C = a.\n\n` +
214
+ `?- get_char(C).\n inputs("ab"), C = a, unexpected.\n\n` +
215
+ `?- X = 1.\n X = 2, unexpected.\n X = 1.\n\n` +
216
+ `?- catch(throw(ball), E, true).\n E = ball | error(system_error, ...).\n`;
217
+ const result = publicApi.runQuads(Program.parseSources([{ text: source, filename: 'quads.pl' }]));
218
+ assertEqual(result.total, 12, 'quad total');
219
+ assertEqual(result.passed, 12, 'quad passed');
220
+ assertEqual(result.failed, 0, 'quad failed');
221
+ assertEqual(result.stdout, 'quads: 12 run, 12 passed, 0 failed.\n', 'quad report');
222
+ },
223
+ },
224
+ {
225
+ name: 'runQuads rejects malformed answer substitutions',
226
+ run: () => {
227
+ const source = `?- X = f(Y), Y = 1.\n X = f(Y), Y = 1.\n`;
228
+ const result = publicApi.runQuads(Program.parseSources([{ text: source, filename: 'malformed-quad.pl' }]));
229
+ assertEqual(result.total, 1, 'quad total');
230
+ assertEqual(result.failed, 1, 'quad failed');
231
+ assertIncludes(result.stdout, 'quads: MALFORMED malformed-quad.pl:1', 'malformed report');
232
+ },
233
+ },
234
+ {
235
+ name: 'runQuads reports annotations that cannot be checked safely',
236
+ run: () => {
237
+ const result = publicApi.runQuads(`?- repeat, fail.\n loops.\n`);
238
+ assertEqual(result.results[0].kind, 'unsupported', 'quad result');
239
+ assertIncludes(result.stdout, 'quads: UNSUPPORTED <input>:1', 'unsupported report');
240
+ },
241
+ },
242
+ {
243
+ name: '--quads runs embedded tests and reports failures through exit status',
244
+ run: () => {
245
+ const passing = runCli(['--quads', '-'], {
246
+ input: `p(ok).\n\nsmoke ?- p(X).\n X = ok.\n`,
247
+ });
248
+ assertEqual(passing.status, 0, 'passing quad exit status');
249
+ assertEqual(passing.stdout, 'quads: 1 run, 1 passed, 0 failed.\n', 'passing quad stdout');
250
+ assertEqual(passing.stderr, '', 'passing quad stderr');
251
+
252
+ const failing = runCli(['-q', '-'], {
253
+ input: `p(actual).\n\nsmoke ?- p(X).\n X = expected.\n`,
254
+ });
255
+ assertEqual(failing.status, 1, 'failing quad exit status');
256
+ assertIncludes(failing.stdout, 'quads: FAILED smoke, <stdin>:3', 'failing quad report');
257
+ assertIncludes(failing.stdout, 'quads: 1 run, 0 passed, 1 failed.', 'failing quad summary');
258
+ assertEqual(failing.stderr, '', 'failing quad stderr');
259
+ },
260
+ },
186
261
  {
187
262
  name: 'seeded random/3 sequence is reproducible',
188
263
  run: () => {
@@ -212,6 +287,7 @@ why(
212
287
  assertIncludes(result.stdout, 'With no arguments, start a Prolog REPL.', 'stdout');
213
288
  assertIncludes(result.stdout, '-g, --goal goal', 'stdout');
214
289
  assertIncludes(result.stdout, '-p, --proof', 'stdout');
290
+ assertIncludes(result.stdout, '-q, --quads', 'stdout');
215
291
  assertIncludes(result.stdout, '-s, --stats', 'stdout');
216
292
  assertIncludes(result.stdout, '-v, --version', 'stdout');
217
293
  assertIncludes(result.stdout, '-w, --warnings', 'stdout');
@@ -6072,13 +6072,14 @@ make the observed question explicit.
6072
6072
  | --- | --- |
6073
6073
  | `-h`, `--help` | Show usage |
6074
6074
  | `-p`, `--proof` | Print `why/2` explanations |
6075
+ | `-q`, `--quads` | Run embedded quad tests and fail if any do not hold |
6075
6076
  | `-s`, `--stats` | Print solver counters to stderr |
6076
6077
  | `-v`, `--version` | Print the package version |
6077
6078
  | `-w`, `--warnings` | Print non-fatal portability warnings |
6078
6079
  | `-g`, `--goal Goal` | Solve a callable goal; may be repeated; overrides `%% goal:` comments |
6079
6080
  | `--` | Treat following arguments as inputs |
6080
6081
 
6081
- Short flags may be combined, so `-pw` is equivalent to `-p -w`.
6082
+ Short flags may be combined, so `-pqw` is equivalent to `-p -q -w`.
6082
6083
 
6083
6084
  Inputs may be local files, HTTP(S) URLs, or one `-` for stdin. The bare command
6084
6085
  `eyeprolog` starts the REPL. When options are present but no input is named,
@@ -6113,6 +6114,56 @@ they do not corrupt that logical stream. A successful run normally exits with
6113
6114
  status zero; loading, syntax, option, and other uncaught errors use status `1`. `halt/0-1` can deliberately choose the
6114
6115
  process status from inside a program.
6115
6116
 
6117
+ ### Embedded quad tests
6118
+
6119
+ A quad places a query directly before a description of its expected top-level
6120
+ answer. The answer is ordinary Prolog syntax rather than quoted text or a
6121
+ comment, so a small test reads like the interaction it checks:
6122
+
6123
+ ```eyeprolog
6124
+ color(red).
6125
+ color(green).
6126
+
6127
+ colors ?- color(X).
6128
+ X = red
6129
+ ; X = green.
6130
+
6131
+ ?- color(blue).
6132
+ false.
6133
+ ```
6134
+
6135
+ Run all quads in a file with `eyeprolog --quads file.pl` or `eyeprolog -q
6136
+ file.pl`. A label such as `colors` is optional. Loading the file normally only
6137
+ records its quads; it does not execute them or add their queries and answers as
6138
+ program clauses. A quad run prints a summary and exits with status `1` when any
6139
+ description fails.
6140
+
6141
+ Answer descriptions support ordered answers separated by `;`, acceptable
6142
+ alternatives separated by `|`, `true`, `false`, standard error descriptions,
6143
+ and the `unexpected` annotation for an answer that must not occur (`inattendue`
6144
+ is its synonym). `...` and `ad_infinitum` accept further answers. Multiple
6145
+ indented descriptions after one query must all hold. `inputs/1` supplies and
6146
+ checks consumed characters; `outputs/1` checks emitted characters. `sto` marks
6147
+ an answer description that this finite-tree implementation skips. The
6148
+ nontermination and advanced stream annotations `loops`, `peeks/1`, and `waits`,
6149
+ and the unordered `other_answer_sequence` annotation, are not executed by the
6150
+ current runner.
6151
+
6152
+ The JavaScript API exposes the same operation without process I/O:
6153
+
6154
+ ```js
6155
+ import { Program, runQuads } from 'eyeprolog';
6156
+
6157
+ const program = Program.parse(source);
6158
+ const report = runQuads(program);
6159
+ console.log(report.passed, report.failed, report.stdout);
6160
+ ```
6161
+
6162
+ The syntax follows the “queries using answer descriptions” convention used by
6163
+ Trealla and the ISO Prolog working examples. Because answer descriptions are
6164
+ layout-sensitive, indent every description while keeping ordinary clause heads
6165
+ and the next quad query at the left margin.
6166
+
6116
6167
  Statistics are comparative evidence, not a score in isolation. Preserve the
6117
6168
  program, input, runtime version, selected query, answers, and counters together.
6118
6169
  An optimization is acceptable only when the intended answers remain unchanged