eyeprolog 1.1.6 → 1.1.8

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.
Files changed (31) hide show
  1. package/README.md +9 -0
  2. package/examples/book/README.md +4 -0
  3. package/examples/book/chapter-40/01-color.pl +10 -0
  4. package/index.d.ts +28 -2
  5. package/package.json +1 -1
  6. package/src/cli.js +16 -5
  7. package/src/dcg.js +51 -3
  8. package/src/index.js +1 -0
  9. package/src/iso.js +24 -9
  10. package/src/parser.js +61 -1
  11. package/src/program.js +13 -0
  12. package/src/quads.js +377 -0
  13. package/src/solver.js +6 -0
  14. package/src/write.js +1 -0
  15. package/test/conformance/THIRD_PARTY.md +2 -2
  16. package/test/conformance/expected-errors/iso/dcg_phrase_bad_input.txt +1 -1
  17. package/test/conformance/expected-errors/iso/dcg_phrase_bad_output.txt +1 -1
  18. package/test/conformance/expected-errors/iso/logtalk_dcg_bad_input_precedence.txt +1 -1
  19. package/test/conformance/expected-errors/iso/logtalk_dcg_bad_output_precedence.txt +1 -1
  20. package/test/conformance/expected-errors/iso/logtalk_dcg_embedded_noncallable.txt +1 -1
  21. package/test/conformance/expected-errors/iso/logtalk_dcg_improper_semicontext.txt +1 -1
  22. package/test/conformance/expected-errors/iso/logtalk_dcg_improper_terminal_rule.txt +1 -1
  23. package/test/conformance/expected-errors/iso/logtalk_dcg_invalid_semicontext.txt +1 -1
  24. package/test/conformance/expected-errors/iso/logtalk_dcg_nested_noncallable.txt +1 -1
  25. package/test/conformance/expected-errors/iso/logtalk_dcg_noncallable_after_failure.txt +1 -1
  26. package/test/conformance/expected-errors/iso/logtalk_dcg_noncallable_after_success.txt +1 -1
  27. package/test/conformance/expected-errors/iso/logtalk_dcg_phrase_improper_input.txt +1 -1
  28. package/test/conformance/expected-errors/iso/logtalk_dcg_phrase_improper_output.txt +1 -1
  29. package/test/conformance/expected-errors/syntax/extra_double_period_rejected.txt +1 -1
  30. package/test/run-regression.mjs +164 -0
  31. package/the-art-of-eyeprolog.md +58 -3
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.8",
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/dcg.js CHANGED
@@ -37,14 +37,14 @@ function terminalItems(term, env = new Env()) {
37
37
  const seen = new Set();
38
38
  let cursor = deref(term, env);
39
39
  while (cursor.type === COMPOUND && cursor.name === '.' && cursor.arity === 2) {
40
- if (seen.has(cursor)) throw new PrologError('type_error(terminal_sequence)', original);
40
+ if (seen.has(cursor)) throw new PrologError('type_error(list)', original);
41
41
  seen.add(cursor);
42
42
  items.push(cursor.args[0]);
43
43
  cursor = deref(cursor.args[1], env);
44
44
  }
45
45
  if (cursor.type === ATOM && cursor.name === '[]') return items;
46
46
  if (cursor.type === VAR) throw new PrologError('instantiation_error');
47
- throw new PrologError('type_error(terminal_sequence)', original);
47
+ throw new PrologError('type_error(list)', original);
48
48
  }
49
49
 
50
50
  function terminalsGoal(terminals, input, output, env) {
@@ -155,8 +155,12 @@ export function expandDcgBody(body, input, output, options = {}) {
155
155
  // definitions and documents these choices in its conformance profile.
156
156
  if (body.type === COMPOUND && body.name === '\\+' && body.arity === 1) {
157
157
  const ignored = freshDcgVariable('negated');
158
+ const negated = body.args[0];
159
+ const expandedNegated = negated.type === ATOM || negated.type === COMPOUND || negated.type === VAR
160
+ ? expandDcgBody(negated, input, ignored, options)
161
+ : negated;
158
162
  return conjunction(
159
- compound('\\+', [expandDcgBody(body.args[0], input, ignored, options)]),
163
+ compound('\\+', [expandedNegated]),
160
164
  equality(input, output),
161
165
  );
162
166
  }
@@ -175,6 +179,50 @@ export function expandDcgBody(body, input, output, options = {}) {
175
179
  return appendStateArguments(body, input, output, module);
176
180
  }
177
181
 
182
+ // Embedded goals are validated before the translated grammar is executed.
183
+ // This keeps a non-callable goal visible even when an earlier terminal or
184
+ // branch would otherwise prevent the host goal from being reached.
185
+ export function validateDcgEmbeddedGoals(body, input, output) {
186
+ const invalidBody = invalidDcgControl(body);
187
+ if (invalidBody != null) throw new PrologError('type_error(callable)', invalidBody);
188
+
189
+ const visit = (term) => {
190
+ if (term.type !== COMPOUND) return;
191
+ if (term.name === '{}' && term.arity === 1) {
192
+ if (invalidControlGoal(term.args[0])) {
193
+ // Report the translated host-language conjunction, matching the
194
+ // convention used by Trealla and the ISO Part 3 quad corpus.
195
+ throw new PrologError('type_error(callable)', conjunction(term.args[0], equality(input, output)));
196
+ }
197
+ return;
198
+ }
199
+ if ([',', ';', '|', '->', '\\+'].includes(term.name)) {
200
+ for (const argument of term.args) visit(argument);
201
+ }
202
+ };
203
+ visit(body);
204
+ }
205
+
206
+ function invalidDcgControl(term) {
207
+ if (term.type !== ATOM && term.type !== COMPOUND && term.type !== VAR) return term;
208
+ if (term.type !== COMPOUND || ![',', ';', '|', '->'].includes(term.name)) return null;
209
+ for (const argument of term.args) {
210
+ if (argument.type === COMPOUND && argument.name === '{}' && argument.arity === 1) continue;
211
+ const invalid = invalidDcgControl(argument);
212
+ if (invalid != null) return invalid;
213
+ }
214
+ return null;
215
+ }
216
+
217
+ function invalidControlGoal(goal) {
218
+ if (goal.type === VAR) return false;
219
+ if (goal.type !== ATOM && goal.type !== COMPOUND) return true;
220
+ if (goal.type === COMPOUND && [',', ';', '->'].includes(goal.name) && goal.arity === 2) {
221
+ return goal.args.some(invalidControlGoal);
222
+ }
223
+ return false;
224
+ }
225
+
178
226
  function splitGrammarHead(head) {
179
227
  let terminals = null;
180
228
  if (head.type === COMPOUND && head.name === ',' && head.arity === 2) {
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/iso.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  } from './term.js';
8
8
  import { createParserOperatorState, parseClauses } from './parser.js';
9
9
  import { formatTermForWrite } from './write.js';
10
- import { emptyTerminalSequence, expandDcgBody, isListOrPartialList } from './dcg.js';
10
+ import { emptyTerminalSequence, expandDcgBody, isListOrPartialList, validateDcgEmbeddedGoals } from './dcg.js';
11
11
 
12
12
  let isoFresh = 0;
13
13
 
@@ -490,7 +490,7 @@ function* clauseBuiltin({ solver, goal, env }) {
490
490
  if (head.type !== ATOM && head.type !== COMPOUND) throw new PrologError('type_error(callable)', head);
491
491
  callableOrVariable(goal.args[1], env);
492
492
  const indicator = compound('/', [atom(head.name), numberTerm(head.arity)]);
493
- if (solver.registry.get(head.name, head.arity)) {
493
+ if (solver.registry.get(head.name, head.arity) || isGrammarRuleProcedure(head)) {
494
494
  throw new PrologError('permission_error(access, private_procedure)', indicator);
495
495
  }
496
496
  const group = solver.program.findGroup(head.name, head.arity, head.module ?? goal.module ?? 'user');
@@ -529,9 +529,13 @@ function procedureIndicator(head) {
529
529
  return compound('/', [atom(head.name), numberTerm(head.arity)]);
530
530
  }
531
531
 
532
+ function isGrammarRuleProcedure(head) {
533
+ return head.name === '-->' && head.arity === 2;
534
+ }
535
+
532
536
  function assertModifiable(solver, head, module = 'user') {
533
537
  const group = solver.program.findGroup(head.name, head.arity, head.module ?? module);
534
- if (solver.registry.get(head.name, head.arity) || (group && !group.dynamic)) {
538
+ if (solver.registry.get(head.name, head.arity) || isGrammarRuleProcedure(head) || (group && !group.dynamic)) {
535
539
  throw new PrologError('permission_error(modify, static_procedure)', procedureIndicator(head));
536
540
  }
537
541
  }
@@ -558,7 +562,7 @@ function* retractBuiltin({ solver, goal, env }) {
558
562
  const parts = clauseParts(goal.args[0], env);
559
563
  requireClauseHead(parts.head);
560
564
  const group = solver.program.findGroup(parts.head.name, parts.head.arity, parts.head.module ?? goal.module ?? 'user');
561
- if (solver.registry.get(parts.head.name, parts.head.arity) || (group && !group.dynamic)) {
565
+ if (solver.registry.get(parts.head.name, parts.head.arity) || isGrammarRuleProcedure(parts.head) || (group && !group.dynamic)) {
562
566
  throw new PrologError('permission_error(modify, static_procedure)', procedureIndicator(parts.head));
563
567
  }
564
568
  if (!group) return;
@@ -581,7 +585,7 @@ function* retractAllBuiltin({ solver, goal, env }) {
581
585
  const head = deref(goal.args[0], env);
582
586
  requireClauseHead(head);
583
587
  const group = solver.program.findGroup(head.name, head.arity, head.module ?? goal.module ?? 'user');
584
- if (solver.registry.get(head.name, head.arity) || (group && !group.dynamic)) {
588
+ if (solver.registry.get(head.name, head.arity) || isGrammarRuleProcedure(head) || (group && !group.dynamic)) {
585
589
  throw new PrologError('permission_error(modify, static_procedure)', procedureIndicator(head));
586
590
  }
587
591
  if (group) {
@@ -615,7 +619,7 @@ function* abolishBuiltin({ solver, goal, env }) {
615
619
  const target = predicateIndicatorParts(goal.args[0], env);
616
620
  const module = goal.module ?? 'user';
617
621
  const group = solver.program.findGroup(target.name, target.arity, module);
618
- if (solver.registry.get(target.name, target.arity) || (group && !group.dynamic)) {
622
+ if (solver.registry.get(target.name, target.arity) || isGrammarRuleProcedure(target) || (group && !group.dynamic)) {
619
623
  throw new PrologError('permission_error(modify, static_procedure)', target.indicator);
620
624
  }
621
625
  solver.program.abolishDynamicGroup(target.name, target.arity, module);
@@ -1519,8 +1523,19 @@ function callable(term, env) {
1519
1523
  term = resolveCallable(term, env);
1520
1524
  if (term.type === VAR) throw new PrologError('instantiation_error');
1521
1525
  if (term.type !== ATOM && term.type !== COMPOUND) throw new PrologError('type_error(callable)', term);
1526
+ validateControlCallable(term, term);
1522
1527
  return term;
1523
1528
  }
1529
+ function validateControlCallable(term, culprit) {
1530
+ if (term.type !== COMPOUND || ![',', ';', '->'].includes(term.name) || term.arity !== 2) return;
1531
+ for (const argument of term.args) {
1532
+ if (argument.type === VAR) throw new PrologError('instantiation_error');
1533
+ if (argument.type !== ATOM && argument.type !== COMPOUND) {
1534
+ throw new PrologError('type_error(callable)', culprit);
1535
+ }
1536
+ validateControlCallable(argument, culprit);
1537
+ }
1538
+ }
1524
1539
  function resolveCallable(term, env) {
1525
1540
  const resolved = deref(term, env);
1526
1541
  if (resolved.type !== COMPOUND) return resolved;
@@ -1555,14 +1570,14 @@ function* phraseBuiltin({ solver, goal, env }) {
1555
1570
  if (grammarBody.type !== ATOM && grammarBody.type !== COMPOUND) {
1556
1571
  throw new PrologError('type_error(callable)', grammarBody);
1557
1572
  }
1558
-
1559
1573
  const input = goal.args[1];
1560
1574
  const requestedOutput = goal.arity === 2 ? emptyTerminalSequence() : goal.args[2];
1575
+ validateDcgEmbeddedGoals(grammarBody, input, requestedOutput);
1561
1576
  if (!isListOrPartialList(input, env)) {
1562
- throw new PrologError('type_error(terminal_sequence)', deref(input, env));
1577
+ throw new PrologError('type_error(list)', deref(input, env));
1563
1578
  }
1564
1579
  if (!isListOrPartialList(requestedOutput, env)) {
1565
- throw new PrologError('type_error(terminal_sequence)', deref(requestedOutput, env));
1580
+ throw new PrologError('type_error(list)', deref(requestedOutput, env));
1566
1581
  }
1567
1582
 
1568
1583
  // Delay the final output unification to keep phrase/3 steadfast in its
package/src/parser.js CHANGED
@@ -33,7 +33,7 @@ function isPlainAtomStartCode(code) {
33
33
  return code >= 97 && code <= 122;
34
34
  }
35
35
 
36
- const graphicAtomChars = '#$&*+-./<=>@^~\\;:';
36
+ const graphicAtomChars = '#$&*+-./<=>@^~\\:';
37
37
 
38
38
  // ISO operator syntax is lowered to the same ordinary compound terms used by
39
39
  // canonical notation. Commas remain separators except inside parentheses.
@@ -277,10 +277,30 @@ 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
+ }
288
+ if (ch === '.' && this.peek(1) &&
289
+ !isWhitespaceCode(this.peek(1).charCodeAt(0)) &&
290
+ this.peek(1) !== '%' && !(this.peek(1) === '/' && this.peek(2) === '*')) {
291
+ const start = this.pos;
292
+ this.take();
293
+ while (isGraphicAtomCode(this.peek().charCodeAt(0))) this.take();
294
+ return { type: TOK.ATOM, text: this.source.slice(start, this.pos), line };
295
+ }
280
296
  if (ch === '!') {
281
297
  this.take();
282
298
  return { type: TOK.ATOM, text: '!', line };
283
299
  }
300
+ if (ch === ';') {
301
+ this.take();
302
+ return { type: TOK.ATOM, text: ';', line };
303
+ }
284
304
 
285
305
  const punct = {
286
306
  '(': TOK.LPAREN, ')': TOK.RPAREN, '[': TOK.LBRACKET, ']': TOK.RBRACKET,
@@ -591,12 +611,47 @@ class Parser {
591
611
  }
592
612
  throw new Error(`parse line ${this.token.line}: bad term`);
593
613
  }
614
+ sourceLineIsIndented(line) {
615
+ let start = 0;
616
+ for (let current = 1; current < line; current++) {
617
+ const newline = this.source.indexOf('\n', start);
618
+ if (newline < 0) return false;
619
+ start = newline + 1;
620
+ }
621
+ return this.source[start] === ' ' || this.source[start] === '\t';
622
+ }
623
+ parseQuad(id, line, accept) {
624
+ const query = this.parseTerm(0, true);
625
+ this.expect(TOK.DOT, '.');
626
+ this.advance();
627
+
628
+ const answers = [];
629
+ while (this.token.type !== TOK.EOF && this.sourceLineIsIndented(this.token.line)) {
630
+ answers.push(this.parseTerm(0, true));
631
+ this.expect(TOK.DOT, '.');
632
+ this.advance();
633
+ }
634
+ if (answers.length === 0) throw new Error(`parse line ${line}: quad requires an indented answer description`);
635
+
636
+ accept({
637
+ kind: 'quad',
638
+ id,
639
+ query,
640
+ answers,
641
+ source: { filename: this.filename, line },
642
+ });
643
+ }
594
644
  parseProgram(emit = null) {
595
645
  const clauses = emit ? null : [];
596
646
  let clauseNumber = 0;
597
647
  const accept = emit ?? ((clause) => clauses.push(clause));
598
648
  while (this.token.type !== TOK.EOF) {
599
649
  const line = this.token.line;
650
+ if (this.operatorTokenName() === '?-') {
651
+ this.advance();
652
+ this.parseQuad(null, line, accept);
653
+ continue;
654
+ }
600
655
  if (this.token.type === TOK.IF) {
601
656
  this.advance();
602
657
  const directive = this.parseTerm();
@@ -622,6 +677,11 @@ class Parser {
622
677
  continue;
623
678
  }
624
679
  let head = this.parseTerm(3);
680
+ if (this.operatorTokenName() === '?-') {
681
+ this.advance();
682
+ this.parseQuad(head, line, accept);
683
+ continue;
684
+ }
625
685
  // ISO/IEC TS 13211-3 grammar rules use -->/2 at the same top-level
626
686
  // priority as clauses. The left side may include an unparenthesized
627
687
  // 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,377 @@
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
+ // The solver's counter also observes completed nested searches (for
196
+ // example each arm of a DCG disjunction). Bound the public iterator here
197
+ // instead of letting those internal completions consume the quad's answer
198
+ // allowance.
199
+ solutionLimit: Math.max(maxSolutions, options.solutionLimit ?? 10000000),
200
+ ioOptions: {
201
+ input,
202
+ write: (text) => { pendingOutput += String(text); },
203
+ },
204
+ });
205
+ // Undefined predicates are test failures rather than silent negative
206
+ // answers unless the source explicitly selected another unknown policy.
207
+ if (!(program.prologFlagDirectives ?? []).some(([flag]) => flag.type === ATOM && flag.name === 'unknown')) {
208
+ solver.prologFlags.get('unknown').value = atom('error');
209
+ }
210
+ const solutions = [];
211
+ let error = null;
212
+ let tailOutput = '';
213
+ try {
214
+ const iterator = solver.solve([query], new Env(), 0);
215
+ while (solutions.length < maxSolutions) {
216
+ pendingOutput = '';
217
+ const result = iterator.next();
218
+ if (result.done) {
219
+ tailOutput += pendingOutput;
220
+ break;
221
+ }
222
+ solutions.push({ env: result.value, output: pendingOutput });
223
+ }
224
+ } catch (caught) {
225
+ error = { term: errorTerm(caught), output: pendingOutput };
226
+ }
227
+ const inputPosition = solver.io.resolve('user_input')?.position ?? 0;
228
+ return { solutions, error, tailOutput, inputPosition };
229
+ }
230
+
231
+ function matchLeaf(query, leaf, actual, position) {
232
+ if (leaf.false) {
233
+ return position >= actual.solutions.length && actual.error == null && outputMatches(leaf.output, actual.tailOutput);
234
+ }
235
+ if (leaf.error != null) {
236
+ return position === actual.solutions.length && actual.error != null &&
237
+ errorMatches(leaf.error, actual.error.term) && outputMatches(leaf.output, actual.error.output);
238
+ }
239
+ const solution = actual.solutions[position];
240
+ if (!solution || !outputMatches(leaf.output, solution.output)) return false;
241
+ return substitutionMatches(query, leaf.bindings, solution.env);
242
+ }
243
+
244
+ function substitutionMatches(query, bindings, actualEnv) {
245
+ const queryVariables = namedVariables(query);
246
+ const queryNames = new Set(queryVariables.map((variable) => variable.name));
247
+ const expectedEnv = new Env();
248
+ const rebound = new Set();
249
+ for (const binding of bindings) {
250
+ const variable = binding.args[0];
251
+ if (!queryNames.has(variable.name) || rebound.has(variable.name)) return false;
252
+ rebound.add(variable.name);
253
+ if (!unify(variable, binding.args[1], expectedEnv)) return false;
254
+ }
255
+ const expected = compound('$quad_answer', queryVariables.map((variable) => copyResolved(variable, expectedEnv)));
256
+ const actual = compound('$quad_answer', queryVariables.map((variable) => copyResolved(variable, actualEnv)));
257
+ return patternVariant(expected, new Env(), actual, new Env());
258
+ }
259
+
260
+ function namedVariables(term) {
261
+ const found = [];
262
+ const seen = new Set();
263
+ const stack = [term];
264
+ while (stack.length) {
265
+ const current = stack.pop();
266
+ if (current.type === VAR) {
267
+ if (!current.name.startsWith('__anon') && !seen.has(current.name)) {
268
+ seen.add(current.name);
269
+ found.push(current);
270
+ }
271
+ } else {
272
+ for (let index = current.args.length - 1; index >= 0; index--) stack.push(current.args[index]);
273
+ }
274
+ }
275
+ return found;
276
+ }
277
+
278
+ function patternVariant(pattern, patternEnv, actual, actualEnv, pairs = new Map(), reverse = new Map()) {
279
+ pattern = deref(pattern, patternEnv);
280
+ actual = deref(actual, actualEnv);
281
+ if (pattern.type === ATOM && pattern.name === '...') return true;
282
+ if (pattern.type === VAR || actual.type === VAR) {
283
+ if (pattern.type !== VAR || actual.type !== VAR) return false;
284
+ const paired = pairs.get(pattern.name);
285
+ const reversed = reverse.get(actual.name);
286
+ if (paired != null || reversed != null) return paired === actual.name && reversed === pattern.name;
287
+ pairs.set(pattern.name, actual.name);
288
+ reverse.set(actual.name, pattern.name);
289
+ return true;
290
+ }
291
+ if (pattern.type !== actual.type || pattern.name !== actual.name || pattern.arity !== actual.arity) return false;
292
+ for (let index = 0; index < pattern.arity; index++) {
293
+ if (!patternVariant(pattern.args[index], patternEnv, actual.args[index], actualEnv, pairs, reverse)) return false;
294
+ }
295
+ return true;
296
+ }
297
+
298
+ function errorTerm(error) {
299
+ if (error?.name === 'ThrownTerm' && error.term) return compound('$quad_thrown', [error.term]);
300
+ if (error?.name === 'PrologError') {
301
+ let formal;
302
+ try {
303
+ formal = parseGoalText(error.formal);
304
+ } catch (_) {
305
+ formal = atom(error.formal ?? 'system_error');
306
+ }
307
+ if (error.culprit != null) formal = formal.type === COMPOUND
308
+ ? compound(formal.name, [...formal.args, error.culprit])
309
+ : compound(formal.name, [error.culprit]);
310
+ return compound('error', [formal, variable('$quad_context')]);
311
+ }
312
+ return compound('error', [atom('system_error'), variable('$quad_context')]);
313
+ }
314
+
315
+ function errorMatches(expected, actual) {
316
+ if (expected.type === COMPOUND && expected.name === 'throw' && expected.arity === 1 &&
317
+ actual.type === COMPOUND && actual.name === '$quad_thrown' && actual.arity === 1) {
318
+ return patternVariant(expected.args[0], new Env(), actual.args[0], new Env());
319
+ }
320
+ if (actual.type !== COMPOUND || actual.name !== 'error' || actual.arity !== 2) return false;
321
+ if (expected.type === COMPOUND && expected.name === 'error' && expected.arity === 2) {
322
+ return patternVariant(expected, new Env(), actual, new Env());
323
+ }
324
+ return patternVariant(expected, new Env(), actual.args[0], new Env());
325
+ }
326
+
327
+ function isErrorDescription(term) {
328
+ if (term.type === ATOM) return ['instantiation_error', 'system_error'].includes(term.name);
329
+ return term.type === COMPOUND && [
330
+ 'error', 'throw', 'type_error', 'domain_error', 'existence_error',
331
+ 'permission_error', 'evaluation_error', 'representation_error',
332
+ 'resource_error', 'syntax_error', 'uninstantiation_error',
333
+ ].includes(term.name);
334
+ }
335
+
336
+ function characterText(term) {
337
+ const items = properListItems(term, new Env());
338
+ if (items == null) return null;
339
+ let text = '';
340
+ for (const item of items) {
341
+ if (item.type === ATOM && Array.from(item.name).length === 1) text += item.name;
342
+ else if (item.type === 'number' && /^\d+$/.test(item.name)) text += String.fromCodePoint(Number(item.name));
343
+ else return null;
344
+ }
345
+ return text;
346
+ }
347
+
348
+ function outputMatches(expected, actual) {
349
+ return expected == null || expected === actual;
350
+ }
351
+
352
+ function splitOperator(term, name) {
353
+ if (term.type === COMPOUND && term.name === name && term.arity === 2) {
354
+ return [term.args[0], ...splitOperator(term.args[1], name)];
355
+ }
356
+ return [term];
357
+ }
358
+
359
+ function formatFailure(program, quad, result) {
360
+ const source = quad.source ?? { filename: '<input>', line: 1 };
361
+ const label = quad.id == null ? '' : `${formatQuadTerm(program, quad.id)}, `;
362
+ const reason = result.kind === 'malformed' ? 'MALFORMED'
363
+ : result.kind === 'bad_identifier' ? 'BAD_ID'
364
+ : result.kind === 'unsupported' ? 'UNSUPPORTED'
365
+ : 'FAILED';
366
+ const expected = result.expected ?? quad.answers[0];
367
+ return `quads: ${reason} ${label}${source.filename}:${source.line}\n` +
368
+ ` ?- ${formatQuadTerm(program, quad.query)}.\n` +
369
+ ` expected: ${formatQuadTerm(program, expected)}.\n`;
370
+ }
371
+
372
+ function formatQuadTerm(program, term) {
373
+ return formatTermForWrite(term, new Env(), {
374
+ quoted: true,
375
+ operators: [...program.operators.values()],
376
+ });
377
+ }
package/src/solver.js CHANGED
@@ -281,6 +281,12 @@ export class Solver {
281
281
  this.stats.solve_one_goal_calls++;
282
282
  const group = this.program.findGroup(goal.name, goal.arity, goal.module ?? 'user');
283
283
  if (!group) {
284
+ if (goal.name === '-->' && goal.arity === 2) {
285
+ throw new PrologError(
286
+ 'existence_error(procedure)',
287
+ compound('/', [compound('-->', []), numberTerm(2)]),
288
+ );
289
+ }
284
290
  if (this.prologFlags.get('unknown')?.value?.name === 'error') {
285
291
  throw new PrologError(
286
292
  'existence_error(procedure)',
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;
@@ -13,8 +13,8 @@ Part 3 grammar cases are additionally adapted from Logtalk's
13
13
  `tests/logtalk/methods/phrase_2_3/tests.lgt` and
14
14
  `tests/logtalk/dcgs/tests.lgt` suites. Object and unit-test scaffolding was
15
15
  removed, translator-only assertions were converted to executable grammar
16
- behavior where possible, and expected list errors follow the Part 3
17
- `terminal_sequence` terminology used by EyeProlog.
16
+ behavior where possible, and expected list errors use the portable ISO
17
+ `type_error(list)` term.
18
18
 
19
19
  Copyright 1998-2026 Paulo Moura <pmoura@logtalk.org>
20
20
 
@@ -1 +1 @@
1
- error(type_error(terminal_sequence), not_a_terminal_sequence)
1
+ error(type_error(list), not_a_terminal_sequence)
@@ -1 +1 @@
1
- error(type_error(terminal_sequence), not_a_terminal_sequence)
1
+ error(type_error(list), not_a_terminal_sequence)
@@ -1 +1 @@
1
- error(type_error(terminal_sequence), not_a_terminal_sequence)
1
+ error(type_error(list), not_a_terminal_sequence)
@@ -1 +1 @@
1
- error(type_error(terminal_sequence), not_a_terminal_sequence)
1
+ error(type_error(list), not_a_terminal_sequence)
@@ -1 +1 @@
1
- error(type_error(callable), 1)
1
+ error(type_error(callable), (1, =([], [])))
@@ -1 +1 @@
1
- error(type_error(terminal_sequence), [a | b])
1
+ error(type_error(list), [a | b])
@@ -1 +1 @@
1
- error(type_error(terminal_sequence), [a | b])
1
+ error(type_error(list), [a | b])
@@ -1 +1 @@
1
- error(type_error(terminal_sequence), b)
1
+ error(type_error(list), b)
@@ -1 +1 @@
1
- error(type_error(callable), 1)
1
+ error(type_error(callable), (1, =([__anon1], [])))
@@ -1 +1 @@
1
- error(type_error(callable), 1)
1
+ error(type_error(callable), (1, =("y", [])))
@@ -1 +1 @@
1
- error(type_error(callable), 1)
1
+ error(type_error(callable), (1, =([__anon1], [])))
@@ -1 +1 @@
1
- error(type_error(terminal_sequence), [a | b])
1
+ error(type_error(list), [a | b])
@@ -1 +1 @@
1
- error(type_error(terminal_sequence), [a | b])
1
+ error(type_error(list), [a | b])
@@ -1 +1 @@
1
- parse line 1: bad term
1
+ parse line 1: expected ., got ..
@@ -183,6 +183,169 @@ 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: 'parser separates compact ISO solo tokens and atom dots',
203
+ run: () => {
204
+ const program = Program.parse(
205
+ `compact ?- call((!;\\+1)).\n true.\n\n` +
206
+ `dot ?- functor([_],.,2).\n true.\n`,
207
+ );
208
+ assertEqual(program.quads.length, 2, 'quad count');
209
+ assertEqual(program.quads[0].query.args[0].name, ';', 'disjunction');
210
+ assertEqual(program.quads[0].query.args[0].args[1].name, '\\+', 'negation');
211
+ assertEqual(program.quads[1].query.args[1].name, '.', 'dot atom');
212
+ },
213
+ },
214
+ {
215
+ name: 'runQuads checks portable answer descriptions',
216
+ run: () => {
217
+ const source = `p(1).\np(2).\np(3).\n\n` +
218
+ `ordered ?- p(X).\n X = 1 ; X = 2 ; X = 3.\n\n` +
219
+ `?- p(4).\n false.\n\n` +
220
+ `?- X = 1.\n X = 2, unexpected.\n\n` +
221
+ `?- p(X).\n X = 1, ... .\n\n` +
222
+ `?- atom_length(1, L).\n type_error(atom, 1).\n\n` +
223
+ `?- atom_length(1, L).\n error(type_error(atom, 1), _).\n\n` +
224
+ `?- throw(ball).\n throw(ball).\n\n` +
225
+ `?- write(ok), nl.\n outputs("ok\\n"), true.\n\n` +
226
+ `?- get_char(C).\n inputs("a"), C = a.\n\n` +
227
+ `?- get_char(C).\n inputs("ab"), C = a, unexpected.\n\n` +
228
+ `?- X = 1.\n X = 2, unexpected.\n X = 1.\n\n` +
229
+ `?- catch(throw(ball), E, true).\n E = ball | error(system_error, ...).\n`;
230
+ const result = publicApi.runQuads(Program.parseSources([{ text: source, filename: 'quads.pl' }]));
231
+ assertEqual(result.total, 12, 'quad total');
232
+ assertEqual(result.passed, 12, 'quad passed');
233
+ assertEqual(result.failed, 0, 'quad failed');
234
+ assertEqual(result.stdout, 'quads: 12 run, 12 passed, 0 failed.\n', 'quad report');
235
+ },
236
+ },
237
+ {
238
+ name: 'runQuads matches the corrected ISO phrase quad boundaries',
239
+ run: () => {
240
+ const source = String.raw`c2 ?- call((1,fail)).
241
+ type_error(callable,(1,fail)).
242
+
243
+ c3 ?- call((fail,1)).
244
+ type_error(callable,(fail,1)).
245
+
246
+ c4 ?- call((!;1)).
247
+ type_error(callable,(!;1)).
248
+
249
+ 24 ?- asserta((a-->b)).
250
+ permission_error(modify,static_procedure,(-->)/2).
251
+
252
+ 25 ?- clause((a-->b),B).
253
+ permission_error(access,private_procedure,(-->)/2).
254
+
255
+ 26 ?- (X-->Y).
256
+ existence_error(procedure,(-->)/2).
257
+
258
+ 5 ?- phrase([a|b],L).
259
+ type_error(list,[a|b]).
260
+
261
+ 10 ?- phrase(([a],{1}),[]).
262
+ type_error(callable,(...,...)).
263
+
264
+ 37 ?- phrase((!,[a],{1}),[]).
265
+ type_error(callable,(...,...)).
266
+
267
+ 12 ?- phrase('|'([],[a]),[a]).
268
+ true.
269
+
270
+ 14 ?- phrase(([a];[]),L).
271
+ L=[a] ; L=[].
272
+
273
+ 15 ?- phrase({fail,1},L).
274
+ type_error(callable,((fail,1),...)).
275
+
276
+ 29 ?- phrase(([a],\+1),[]).
277
+ false.
278
+
279
+ 30 ?- phrase(([a],\+1;[]),[]).
280
+ true.
281
+
282
+ 31 ?- phrase(phrase(phrase,[]),L).
283
+ existence_error(procedure,phrase/4).
284
+
285
+ 32 ?- phrase(call([]),[]).
286
+ existence_error(procedure,[]/2).
287
+
288
+ 41 ?- phrase([],non_list).
289
+ type_error(list,non_list).
290
+
291
+ 42 ?- phrase([],[a|non_list]).
292
+ type_error(list,[a|non_list]).
293
+
294
+ 43 ?- phrase([],L,non_list).
295
+ type_error(list,non_list).
296
+
297
+ 44 ?- phrase([],L,[a|non_list]).
298
+ type_error(list,[a|non_list]).
299
+
300
+ 46 ?- phrase((1,{2}),[]).
301
+ type_error(callable,1).
302
+
303
+ 47 ?- phrase(({2},1),[]).
304
+ type_error(callable,1).
305
+ `;
306
+ const result = publicApi.runQuads(source);
307
+ assertEqual(result.total, 22, 'quad total');
308
+ assertEqual(result.passed, 22, 'quad passed');
309
+ assertEqual(result.stdout, 'quads: 22 run, 22 passed, 0 failed.\n', 'quad report');
310
+ },
311
+ },
312
+ {
313
+ name: 'runQuads rejects malformed answer substitutions',
314
+ run: () => {
315
+ const source = `?- X = f(Y), Y = 1.\n X = f(Y), Y = 1.\n`;
316
+ const result = publicApi.runQuads(Program.parseSources([{ text: source, filename: 'malformed-quad.pl' }]));
317
+ assertEqual(result.total, 1, 'quad total');
318
+ assertEqual(result.failed, 1, 'quad failed');
319
+ assertIncludes(result.stdout, 'quads: MALFORMED malformed-quad.pl:1', 'malformed report');
320
+ },
321
+ },
322
+ {
323
+ name: 'runQuads reports annotations that cannot be checked safely',
324
+ run: () => {
325
+ const result = publicApi.runQuads(`?- repeat, fail.\n loops.\n`);
326
+ assertEqual(result.results[0].kind, 'unsupported', 'quad result');
327
+ assertIncludes(result.stdout, 'quads: UNSUPPORTED <input>:1', 'unsupported report');
328
+ },
329
+ },
330
+ {
331
+ name: '--quads runs embedded tests and reports failures through exit status',
332
+ run: () => {
333
+ const passing = runCli(['--quads', '-'], {
334
+ input: `p(ok).\n\nsmoke ?- p(X).\n X = ok.\n`,
335
+ });
336
+ assertEqual(passing.status, 0, 'passing quad exit status');
337
+ assertEqual(passing.stdout, 'quads: 1 run, 1 passed, 0 failed.\n', 'passing quad stdout');
338
+ assertEqual(passing.stderr, '', 'passing quad stderr');
339
+
340
+ const failing = runCli(['-q', '-'], {
341
+ input: `p(actual).\n\nsmoke ?- p(X).\n X = expected.\n`,
342
+ });
343
+ assertEqual(failing.status, 1, 'failing quad exit status');
344
+ assertIncludes(failing.stdout, 'quads: FAILED smoke, <stdin>:3', 'failing quad report');
345
+ assertIncludes(failing.stdout, 'quads: 1 run, 0 passed, 1 failed.', 'failing quad summary');
346
+ assertEqual(failing.stderr, '', 'failing quad stderr');
347
+ },
348
+ },
186
349
  {
187
350
  name: 'seeded random/3 sequence is reproducible',
188
351
  run: () => {
@@ -212,6 +375,7 @@ why(
212
375
  assertIncludes(result.stdout, 'With no arguments, start a Prolog REPL.', 'stdout');
213
376
  assertIncludes(result.stdout, '-g, --goal goal', 'stdout');
214
377
  assertIncludes(result.stdout, '-p, --proof', 'stdout');
378
+ assertIncludes(result.stdout, '-q, --quads', 'stdout');
215
379
  assertIncludes(result.stdout, '-s, --stats', 'stdout');
216
380
  assertIncludes(result.stdout, '-v, --version', 'stdout');
217
381
  assertIncludes(result.stdout, '-w, --warnings', 'stdout');
@@ -5341,8 +5341,8 @@ look_ahead(X), [X] --> [X].
5341
5341
  `phrase(+Body,?Sequence)` accepts or generates a complete sequence.
5342
5342
  `phrase(+Body,?Sequence,?Rest)` leaves `Rest` unconsumed and is steadfast in
5343
5343
  that argument. A variable body raises `instantiation_error`; a non-callable
5344
- body raises `type_error(callable)`. EyeProlog performs the optional Part 3
5345
- terminal-sequence checks and reports `type_error(terminal_sequence)`.
5344
+ body raises `type_error(callable)`. EyeProlog performs terminal-sequence checks
5345
+ and reports the portable ISO `type_error(list)` error term.
5346
5346
 
5347
5347
  Part 3 leaves `\+//1` and standalone `->//2` implementation dependent.
5348
5348
  EyeProlog uses non-consuming negation (`\+ Body` tests from the current state)
@@ -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,60 @@ 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
+ Unless the source explicitly selects another `unknown` flag, quad execution
6142
+ uses `unknown=error`, so an undefined predicate is reported rather than being
6143
+ accepted as a negative answer.
6144
+
6145
+ Answer descriptions support ordered answers separated by `;`, acceptable
6146
+ alternatives separated by `|`, `true`, `false`, standard error descriptions,
6147
+ and the `unexpected` annotation for an answer that must not occur (`inattendue`
6148
+ is its synonym). `...` and `ad_infinitum` accept further answers. Multiple
6149
+ indented descriptions after one query must all hold. `inputs/1` supplies and
6150
+ checks consumed characters; `outputs/1` checks emitted characters. `sto` marks
6151
+ an answer description that this finite-tree implementation skips. The
6152
+ nontermination and advanced stream annotations `loops`, `peeks/1`, and `waits`,
6153
+ and the unordered `other_answer_sequence` annotation, are not executed by the
6154
+ current runner.
6155
+
6156
+ The JavaScript API exposes the same operation without process I/O:
6157
+
6158
+ ```js
6159
+ import { Program, runQuads } from 'eyeprolog';
6160
+
6161
+ const program = Program.parse(source);
6162
+ const report = runQuads(program);
6163
+ console.log(report.passed, report.failed, report.stdout);
6164
+ ```
6165
+
6166
+ The syntax follows the “queries using answer descriptions” convention used by
6167
+ Trealla and the ISO Prolog working examples. Because answer descriptions are
6168
+ layout-sensitive, indent every description while keeping ordinary clause heads
6169
+ and the next quad query at the left margin.
6170
+
6116
6171
  Statistics are comparative evidence, not a score in isolation. Preserve the
6117
6172
  program, input, runtime version, selected query, answers, and counters together.
6118
6173
  An optimization is acceptable only when the intended answers remain unchanged