eyeprolog 1.3.67 → 1.3.68

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
@@ -159,12 +159,14 @@ variable representatives and ordinary Prolog backtracking, and the REPL projects
159
159
  module `attribute_goals//1` hooks as residual goals. See
160
160
  [`examples/attributed-variables.pl`](examples/attributed-variables.pl).
161
161
 
162
- This is the first migration stage toward reusing Markus Triska's MIT-licensed
162
+ The second migration stage toward reusing Markus Triska's MIT-licensed
163
163
  [Scryer `library(clpz)`](https://github.com/mthom/scryer-prolog/blob/master/src/lib/clpz.pl)
164
- instead of maintaining a separate constraint solver in `src/clpz.js`. The
165
- current CLP(Z) implementation remains active while compatibility gaps such as
166
- Scryer's compile-time term/goal expansion and supporting libraries are closed.
167
- The staged plan and exact compatibility boundary are documented in
164
+ is now in place as well. Normal-profile loading supports user-defined
165
+ `term_expansion/2` and `goal_expansion/2`, clause-list generation, and
166
+ `expand_term/2` for processor DCG lowering, including the qualified hook shape
167
+ used by Scryer libraries. The current CLP(Z) implementation remains active while
168
+ the remaining Scryer support-library surface is brought across. The staged plan
169
+ and exact compatibility boundary are documented in
168
170
  [`src/CLPZ-MIGRATION.md`](src/CLPZ-MIGRATION.md).
169
171
 
170
172
  Recursion through negation is explicit. EyeProlog provides `tnot/1` for
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.3.67",
6
+ "version": "1.3.68",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
@@ -7,8 +7,8 @@ higher-level frontends.
7
7
 
8
8
  1. **Kernel representation and syntax** — `term.js`, `number-value.js`,
9
9
  `syntax-scan.js`, `parser.js`, `write.js`, `errors.js`.
10
- 2. **Program preparation** — `program.js` plus `program-analysis.js` and
11
- `program-indexing.js`. Static recursion/Datalog/WFS classification lives in
10
+ 2. **Program preparation** — `program.js`, `source-expansion.js`, plus
11
+ `program-analysis.js` and `program-indexing.js`. Static recursion/Datalog/WFS classification lives in
12
12
  `program-analysis.js`; compact clauses and candidate indexes live in
13
13
  `program-indexing.js`.
14
14
  3. **Execution** — `solver.js`, `cleanup.js`, `io.js`, `datalog.js`, `wfs.js`,
@@ -48,6 +48,16 @@ needed by Scryer's `library(atts)` and `library(clpz)`. The current CLP(Z)-speci
48
48
  `Env._clpz` store remains transitional until the Scryer Prolog implementation can
49
49
  replace it without losing test or performance parity; see `src/CLPZ-MIGRATION.md`.
50
50
 
51
+ `source-expansion.js` is the explicit compile-time execution boundary. When an
52
+ already-loaded `term_expansion/2` or `goal_expansion/2` hook exists, program
53
+ preparation invokes it with a fresh bounded `Solver` against the partially built
54
+ program. `solver.js` therefore imports clause-selection primitives directly from
55
+ `program-indexing.js` rather than importing the `program.js` facade. This is the
56
+ one deliberate preparation-to-execution service edge and keeps the JavaScript
57
+ import graph acyclic while avoiding a second meta-interpreter solely for source
58
+ expansion. `expansion-builtins.js` provides the lower-level `expand_term/2` DCG
59
+ service without depending on `Solver`.
60
+
51
61
  The JavaScript runtime stays flat directly under `src/`; the existing `src/lib/`
52
62
  contains Prolog library sources rather than JavaScript runtime modules. The architecture
53
63
  test rejects JavaScript import cycles under `src/`. Cleanup lifecycle hooks are
@@ -32,20 +32,40 @@ attributes, post-binding goals, backtracking, `term_attributed_variables/2`, and
32
32
  REPL projection through the conventional module `attribute_goals//1` hook.
33
33
  `examples/attributed-variables.pl` is the runnable source-level example.
34
34
 
35
- ## What still prevents a drop-in Scryer `clpz.pl`
35
+ ## Stage 2: source expansion substrate
36
+
37
+ This stage is implemented. Normal-profile source loading now invokes already
38
+ loaded module-local and `user` `term_expansion/2` and `goal_expansion/2` hooks
39
+ left-to-right. A term expansion may yield one clause or a list of clauses, and
40
+ `expand_term/2` exposes the processor's DCG lowering so a hook can generate the
41
+ ordinary clauses used by Scryer's CLP(Z) parser generators. Goal expansion is
42
+ recursive across control constructs and preserves sharing with variables in the
43
+ surrounding clause.
44
+
45
+ Module-qualified hook clauses such as `user:goal_expansion/2` are also compiled
46
+ with the correct split between procedure ownership and lexical body context. A
47
+ hook defined from a library module therefore lives in `user` while its
48
+ unqualified helper calls still resolve in the defining library. The compiler
49
+ executes hooks with a fresh bounded solver against the partially built program;
50
+ `solver.js` imports clause-selection helpers directly from `program-indexing.js`,
51
+ so this compile-time service remains acyclic. Strict ISO Part 1 mode does not run
52
+ these extension hooks.
36
53
 
37
- The upstream solver is not only an attributed-variable client. It also relies on
38
- compile-time facilities that EyeProlog does not yet provide generically:
54
+ The regression corpus covers clause-list generation, variable-sharing goal
55
+ expansion, qualified hook lexical context, and the Scryer pattern where a
56
+ `term_expansion/2` hook calls `expand_term/2` to generate a DCG predicate.
57
+
58
+ ## What still prevents a drop-in Scryer `clpz.pl`
39
59
 
40
- - user-defined `term_expansion/2` and `goal_expansion/2` during module loading;
41
- - the generated predicates at the end of Scryer's `clpz.pl` and its custom Duo
42
- DCG `++>` expansion;
43
- - the supporting Scryer library surface (`assoc`, `pairs`, `between`, `dcgs`,
44
- `terms`, `error`, `si`, `freeze`, `arithmetic`, `debug`, and `format`) at the
45
- interfaces expected by that source;
60
+ The remaining compatibility work is the supporting Scryer library surface
61
+ (`assoc`, `pairs`, `between`, `dcgs`, `terms`, `error`, `si`, `freeze`,
62
+ `arithmetic`, `debug`, and `format`) at the interfaces expected by that source,
63
+ plus any small builtin gaps exposed while loading those modules. The generated
64
+ predicates and custom Duo DCG `++>` expansion are no longer VM/compiler
65
+ blockers: Scryer's own `term_expansion/2` can now perform that lowering.
46
66
 
47
- These are compatibility gaps, not reasons to add more CLP(Z)-specific code to
48
- `term.js` or `solver.js`.
67
+ These are reusable library compatibility gaps, not reasons to add more
68
+ CLP(Z)-specific code to `term.js` or `solver.js`.
49
69
 
50
70
  ## Migration rule
51
71
 
@@ -0,0 +1,99 @@
1
+ // Generic source-expansion helpers exposed to Prolog code.
2
+ //
3
+ // expand_term/2 provides the system part of source expansion that libraries
4
+ // such as Scryer's CLP(Z) use while generating predicates. User-defined
5
+ // term_expansion/2 and goal_expansion/2 are invoked by program preparation in
6
+ // source-expansion.js; keeping this builtin independent of Solver avoids an
7
+ // import cycle through the standard-library registry.
8
+
9
+ import {
10
+ ATOM, COMPOUND, Env, atom, compound, deref, flattenConjunction, unify,
11
+ } from './term.js';
12
+ import { expandDcgRuleClause } from './dcg.js';
13
+ import { PrologError } from './errors.js';
14
+
15
+ function conjunctionFromGoals(goals) {
16
+ if (goals.length === 0) return atom('true');
17
+ let body = goals[goals.length - 1];
18
+ for (let index = goals.length - 2; index >= 0; index--) {
19
+ body = compound(',', [goals[index], body]);
20
+ }
21
+ return body;
22
+ }
23
+
24
+ export function clauseToSourceTerm(clause) {
25
+ if (clause == null || clause.kind === 'quad') return null;
26
+ if (!Array.isArray(clause.body) || clause.body.length === 0) return clause.head;
27
+ return compound(':-', [clause.head, conjunctionFromGoals(clause.body)]);
28
+ }
29
+
30
+ export function sourceTermToClauses(term, env = new Env()) {
31
+ term = deref(term, env);
32
+ const list = sourceTermList(term, env);
33
+ if (list != null) {
34
+ const clauses = [];
35
+ for (const item of list) clauses.push(...sourceTermToClauses(item, env));
36
+ return clauses;
37
+ }
38
+ if (term.type === COMPOUND && term.name === ':-' && term.arity === 2) {
39
+ const head = deref(term.args[0], env);
40
+ const body = deref(term.args[1], env);
41
+ return [{ head, body: body.type === ATOM && body.name === 'true' ? [] : flattenConjunction(body) }];
42
+ }
43
+ if (term.type === COMPOUND && term.name === ':-' && term.arity === 1) {
44
+ return [{ head: term, body: [] }];
45
+ }
46
+ if (term.type === COMPOUND && term.name === '?-' && term.arity >= 1) {
47
+ return [{ head: term, body: [] }];
48
+ }
49
+ if (term.type !== ATOM && term.type !== COMPOUND) {
50
+ throw new PrologError('type_error(callable)', term);
51
+ }
52
+ return [{ head: term, body: [] }];
53
+ }
54
+
55
+ function sourceTermList(term, env) {
56
+ const items = [];
57
+ const seen = new Set();
58
+ let cursor = deref(term, env);
59
+ while (cursor.type === COMPOUND && cursor.name === '.' && cursor.arity === 2) {
60
+ if (seen.has(cursor)) throw new PrologError('type_error(list)', term);
61
+ seen.add(cursor);
62
+ items.push(deref(cursor.args[0], env));
63
+ cursor = deref(cursor.args[1], env);
64
+ }
65
+ if (cursor.type === ATOM && cursor.name === '[]') return items;
66
+ return null;
67
+ }
68
+
69
+ export function systemExpandTerm(term, module = 'user') {
70
+ const resolved = deref(term, new Env());
71
+ if (resolved.type === COMPOUND && resolved.name === '-->' && resolved.arity === 2) {
72
+ const expanded = expandDcgRuleClause({ head: resolved, body: [] }, module);
73
+ if (expanded == null) return resolved;
74
+ return clauseToSourceTerm(expanded);
75
+ }
76
+ return resolved;
77
+ }
78
+
79
+ function* expandTermBuiltin({ goal, env }) {
80
+ const input = deref(goal.args[0], env);
81
+ if (input.type === 'var') throw new PrologError('instantiation_error');
82
+ const expanded = systemExpandTerm(input, goal.module ?? 'user');
83
+ const next = env.clone();
84
+ if (unify(goal.args[1], expanded, next)) yield next;
85
+ }
86
+
87
+ function* expandGoalBuiltin({ goal, env }) {
88
+ const input = deref(goal.args[0], env);
89
+ if (input.type === 'var') throw new PrologError('instantiation_error');
90
+ const next = env.clone();
91
+ if (unify(goal.args[1], input, next)) yield next;
92
+ }
93
+
94
+ export const expansionBuiltins = {
95
+ register(registry) {
96
+ registry.add('expand_term', 2, expandTermBuiltin, { deterministic: true, eyePrologLibrary: true });
97
+ registry.add('expand_goal', 2, expandGoalBuiltin, { deterministic: true, eyePrologLibrary: true });
98
+ },
99
+ };
package/src/parser.js CHANGED
@@ -1119,6 +1119,32 @@ class Parser {
1119
1119
  head = items.pop();
1120
1120
  while (items.length > 0) head = compound(',', [items.pop(), head]);
1121
1121
  }
1122
+ // The clause grammar keeps priority-1200 neck/DCG tokens outside the
1123
+ // initial head parse. A user-defined operator at priority 1199 or 1200,
1124
+ // however, can itself be the outermost source term. Scryer's CLP(Z) uses
1125
+ // exactly this shape for its private `++>` Duo-DCG rules. Fold such a
1126
+ // custom low-strength infix operator back into the source term while
1127
+ // leaving the processor-defined :-, -->, and ?- dispatch below intact.
1128
+ const topLevelOperator = this.operatorTokenName();
1129
+ const topLevelInfo = topLevelOperator == null ? null : this.infixOperators.get(topLevelOperator);
1130
+ if (topLevelInfo && topLevelInfo.precedence < 3 &&
1131
+ ![':-', '-->', '?-'].includes(topLevelOperator)) {
1132
+ this.advance();
1133
+ const right = this.parseTerm(
1134
+ topLevelInfo.associativity === 'right' ? topLevelInfo.precedence : topLevelInfo.precedence + 1,
1135
+ true,
1136
+ true,
1137
+ false,
1138
+ );
1139
+ head = compound(topLevelOperator, [head, right]);
1140
+ if (topLevelInfo.associativity === 'none') {
1141
+ const nextOperator = this.operatorTokenName();
1142
+ if (this.infixOperators.get(nextOperator)?.precedence === topLevelInfo.precedence) {
1143
+ throw new Error(`parse line ${this.token.line}: non-associative operator ${topLevelOperator} requires parentheses`);
1144
+ }
1145
+ }
1146
+ }
1147
+
1122
1148
  // Parentheses and other ordinary term syntax may hide the surface ?-
1123
1149
  // token from the program-level dispatch above. Once the complete head
1124
1150
  // term has been parsed, recognize the same ?-/1 or ?-/2 structure here.
package/src/program.js CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  eyePrologInteropLibraryModules,
22
22
  } from './standard-library.js';
23
23
  import { expandDcgRuleClause } from './dcg.js';
24
+ import { expandClauseGoals, expandSourceClause } from './source-expansion.js';
24
25
  import {
25
26
  CompactBinaryClause, clauseBodyLength, clauseHasCut, compactHeadArgName, compactHeadArgType, modulePredicateKey,
26
27
  indexCompactOne, indexOne, isCompactBinaryClause, makeArgumentIndex, rebuildGroupIndexes, termContainsCut, termHasNoVariables,
@@ -1069,18 +1070,56 @@ function loadSourceIntoBuilder(builder, source, options, ensured, loadedModules,
1069
1070
  builder.addClauses(batch);
1070
1071
  batch.length = 0;
1071
1072
  };
1072
- const accept = (clause) => {
1073
+
1074
+ const processClause = (inputClause) => {
1075
+ let clause = inputClause;
1073
1076
  if (clause?.kind === 'quad') {
1074
1077
  flush();
1075
1078
  clause.module = context.module;
1076
1079
  builder.addClauses([clause]);
1077
1080
  return;
1078
1081
  }
1082
+
1083
+ // User source expansion is a normal-profile compile-time facility. Hooks
1084
+ // execute against the clauses that have already been prepared, matching
1085
+ // Prolog's left-to-right source loading model. Strict ISO Part 1 keeps its
1086
+ // processor-defined preparation semantics unchanged.
1087
+ if (!builder.program.strictIso) {
1088
+ const expansion = expandSourceClause(builder.program, clause, context.module, {
1089
+ module: context.module,
1090
+ filename: options.filename ?? '<input>',
1091
+ });
1092
+ if (expansion.expanded) {
1093
+ for (const generated of expansion.clauses) processPreparedClause(generated);
1094
+ return;
1095
+ }
1096
+ }
1097
+ processPreparedClause(clause);
1098
+ };
1099
+
1100
+ const processPreparedClause = (inputClause) => {
1101
+ let clause = inputClause;
1102
+ const lexicalModule = context.module;
1103
+
1079
1104
  // Grammar-rule expansion belongs to ISO/IEC TS 13211-3 rather than the
1080
1105
  // Part 1 strict-core language. In strict core mode -->/2 remains the
1081
1106
  // ordinary predefined operator term from Table 7 and is not rewritten.
1082
- const grammarClause = builder.program.strictIso ? null : expandDcgRuleClause(clause, context.module);
1107
+ const grammarClause = builder.program.strictIso ? null : expandDcgRuleClause(clause, lexicalModule);
1083
1108
  if (grammarClause) clause = grammarClause;
1109
+
1110
+ // A source module qualifier on a clause head selects the procedure owner,
1111
+ // while unqualified calls in the body retain the lexical module of the
1112
+ // source text. This distinction is essential for conventional hooks such
1113
+ // as user:term_expansion/2 defined from library modules.
1114
+ clause = normalizeQualifiedClauseHead(clause, lexicalModule);
1115
+
1116
+ if (!builder.program.strictIso && !isDirectiveClause(clause)) {
1117
+ expandClauseGoals(builder.program, clause, lexicalModule, {
1118
+ module: lexicalModule,
1119
+ filename: options.filename ?? '<input>',
1120
+ });
1121
+ }
1122
+
1084
1123
  const moduleDeclaration = moduleDirective(clause);
1085
1124
  if (moduleDeclaration) {
1086
1125
  flush();
@@ -1121,10 +1160,20 @@ function loadSourceIntoBuilder(builder, source, options, ensured, loadedModules,
1121
1160
  clause.module ??= context.module;
1122
1161
  clause.textUnit ??= context.textUnit;
1123
1162
  if (!isDirectiveClause(clause)) {
1124
- for (const goal of clause.body) annotateGoalModule(goal, clause.module);
1163
+ const bodyModule = clause.lexicalModule ?? lexicalModule;
1164
+ for (const goal of clause.body) annotateGoalModule(goal, bodyModule);
1165
+ }
1166
+ const isExpansionHook = !isDirectiveClause(clause) &&
1167
+ clause.head?.arity === 2 && ['term_expansion', 'goal_expansion'].includes(clause.head?.name);
1168
+ if (isExpansionHook) {
1169
+ // A hook becomes visible to the very next source term, so it cannot
1170
+ // wait in the large streaming build batch.
1171
+ flush();
1172
+ builder.addClauses([clause]);
1173
+ } else {
1174
+ batch.push(clause);
1175
+ if (batch.length >= PROGRAM_BUILD_BATCH_SIZE) flush();
1125
1176
  }
1126
- batch.push(clause);
1127
- if (batch.length >= PROGRAM_BUILD_BATCH_SIZE) flush();
1128
1177
  return;
1129
1178
  }
1130
1179
  flush();
@@ -1152,15 +1201,32 @@ function loadSourceIntoBuilder(builder, source, options, ensured, loadedModules,
1152
1201
  batch[batch.length - 1].textUnit = context.textUnit;
1153
1202
  if (batch.length >= PROGRAM_BUILD_BATCH_SIZE) flush();
1154
1203
  };
1155
- const parsed = tryParseClausesFastInto(source, accept, acceptBinary, options);
1204
+ const parsed = tryParseClausesFastInto(source, processClause, acceptBinary, options);
1156
1205
  if (parsed) flush();
1157
1206
  return parsed;
1158
1207
  }
1159
- parseClausesInto(source, options, accept);
1208
+ parseClausesInto(source, options, processClause);
1160
1209
  flush();
1161
1210
  return true;
1162
1211
  }
1163
1212
 
1213
+ function normalizeQualifiedClauseHead(clause, lexicalModule = 'user') {
1214
+ if (clause == null || clause.kind === 'quad' || isDirectiveClause(clause)) return clause;
1215
+ const head = clause.head;
1216
+ if (head?.type !== COMPOUND || head.name !== ':' || head.arity !== 2) return clause;
1217
+ const qualifier = head.args[0];
1218
+ const qualifiedHead = head.args[1];
1219
+ if (qualifier.type === VAR) throw new PrologError('instantiation_error');
1220
+ if (qualifier.type !== ATOM) throw new PrologError('type_error(atom)', qualifier);
1221
+ if (qualifiedHead.type !== ATOM && qualifiedHead.type !== COMPOUND) {
1222
+ throw new PrologError('type_error(callable)', qualifiedHead);
1223
+ }
1224
+ clause.head = qualifiedHead;
1225
+ clause.module = qualifier.name;
1226
+ clause.lexicalModule = lexicalModule;
1227
+ return clause;
1228
+ }
1229
+
1164
1230
  function includeDirective(clause) {
1165
1231
  if (isCompactBinaryClause(clause) || !isDirectiveClause(clause)) return null;
1166
1232
  const directive = clause.head.args[0];
package/src/solver.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  import { numberValueKey, sameNumberValue } from './number-value.js';
9
9
  import { PrologError, getStrictIsoRegistry } from './iso.js';
10
10
  import { getEyePrologRegistry } from './standard-library.js';
11
- import { selectClauseCandidates, selectClauseCandidatesForValues, selectGroundClauseCandidates } from './program.js';
11
+ import { selectClauseCandidates, selectClauseCandidatesForValues, selectGroundClauseCandidates } from './program-indexing.js';
12
12
  import { StreamManager } from './io.js';
13
13
  import { clpzStateConsistent } from './clpz.js';
14
14
  import { hardHeapLimit, softHeapLimit, usedHeapSize } from './platform.js';
@@ -0,0 +1,173 @@
1
+ // User-defined source expansion for the normal EyeProlog profile.
2
+ //
3
+ // This deliberately sits beside program preparation rather than inside the
4
+ // term kernel. A small, fresh Solver executes already-loaded expansion hooks
5
+ // against the partially built Program. The solver itself imports clause-index
6
+ // helpers directly, so this compiler service does not introduce a module cycle.
7
+
8
+ import {
9
+ ATOM, COMPOUND, Env, Term, VAR, atom, compound, deref, variable, variantTerms,
10
+ } from './term.js';
11
+ import { modulePredicateKey } from './program-indexing.js';
12
+ import { Solver } from './solver.js';
13
+ import { clauseToSourceTerm, sourceTermToClauses } from './expansion-builtins.js';
14
+ import { PrologError } from './errors.js';
15
+
16
+ let expansionFresh = 0;
17
+ const MAX_EXPANSION_DEPTH = 64;
18
+ const MAX_EXPANSION_INFERENCES = 1_000_000;
19
+
20
+ function exactHookGroup(program, module, name) {
21
+ return program.groups.get(modulePredicateKey(module, name, 2)) ?? null;
22
+ }
23
+
24
+ function hookModules(module) {
25
+ return module === 'user' ? ['user'] : [module, 'user'];
26
+ }
27
+
28
+ function inputVariableNames(term) {
29
+ const names = new Set();
30
+ const stack = [term];
31
+ const seen = new Set();
32
+ while (stack.length > 0) {
33
+ const current = stack.pop();
34
+ if (current == null || seen.has(current)) continue;
35
+ seen.add(current);
36
+ if (current.type === VAR) {
37
+ names.add(current.name);
38
+ continue;
39
+ }
40
+ if (current.type === COMPOUND) {
41
+ for (const arg of current.args) stack.push(arg);
42
+ }
43
+ }
44
+ return names;
45
+ }
46
+
47
+ function copyHookResult(term, env, inputNames) {
48
+ const rootNames = new Map();
49
+ for (const name of inputNames) {
50
+ const root = deref(variable(name), env);
51
+ if (root.type === VAR && !rootNames.has(root.name)) rootNames.set(root.name, name);
52
+ }
53
+ const copy = (value) => {
54
+ const resolved = deref(value, env);
55
+ if (resolved.type === VAR) return variable(rootNames.get(resolved.name) ?? resolved.name);
56
+ const copied = resolved.type === COMPOUND && resolved.arity === 0
57
+ ? atom(resolved.name)
58
+ : new Term(resolved.type, resolved.name, resolved.args.map(copy));
59
+ if (resolved.module != null) copied.module = resolved.module;
60
+ return copied;
61
+ };
62
+ return copy(term);
63
+ }
64
+
65
+ function runHook(program, module, name, input, loadContext = null) {
66
+ for (const owner of hookModules(module)) {
67
+ const group = exactHookGroup(program, owner, name);
68
+ if (group == null || group.clauses.length === 0) continue;
69
+
70
+ const output = variable(`\u0000source-expansion:${++expansionFresh}`);
71
+ const goal = compound(name, [input, output]);
72
+ goal.module = owner;
73
+ const solver = new Solver(program, {
74
+ solutionLimit: 1,
75
+ maxInferences: MAX_EXPANSION_INFERENCES,
76
+ sourceLoadContext: loadContext,
77
+ });
78
+ const inputNames = inputVariableNames(input);
79
+ for (const answer of solver.solve([goal], new Env())) {
80
+ return copyHookResult(output, answer, inputNames);
81
+ }
82
+ }
83
+ return null;
84
+ }
85
+
86
+ function sourceTermsAreVariants(left, right) {
87
+ return variantTerms(left, new Env(), right, new Env());
88
+ }
89
+
90
+ function expandTermRecursively(program, module, term, loadContext, depth) {
91
+ if (depth > MAX_EXPANSION_DEPTH) throw new PrologError('resource_error(source_expansion)');
92
+ const expanded = runHook(program, module, 'term_expansion', term, loadContext);
93
+ if (expanded == null) return sourceTermToClauses(term);
94
+
95
+ const clauses = sourceTermToClauses(expanded);
96
+ if (clauses.length === 1 && sourceTermsAreVariants(term, clauseToSourceTerm(clauses[0]))) return clauses;
97
+ const result = [];
98
+ for (const clause of clauses) {
99
+ const generatedTerm = clauseToSourceTerm(clause);
100
+ result.push(...expandTermRecursively(program, module, generatedTerm, loadContext, depth + 1));
101
+ }
102
+ return result;
103
+ }
104
+
105
+ export function expandSourceClause(program, clause, module = 'user', loadContext = null) {
106
+ const sourceTerm = clauseToSourceTerm(clause);
107
+ if (sourceTerm == null) return { expanded: false, clauses: [clause] };
108
+ if (exactHookGroup(program, module, 'term_expansion') == null &&
109
+ (module === 'user' || exactHookGroup(program, 'user', 'term_expansion') == null)) {
110
+ return { expanded: false, clauses: [clause] };
111
+ }
112
+ const first = runHook(program, module, 'term_expansion', sourceTerm, loadContext);
113
+ if (first == null) return { expanded: false, clauses: [clause] };
114
+
115
+ const firstClauses = sourceTermToClauses(first);
116
+ if (firstClauses.length === 1 && sourceTermsAreVariants(sourceTerm, clauseToSourceTerm(firstClauses[0]))) {
117
+ return { expanded: false, clauses: [clause] };
118
+ }
119
+ const result = [];
120
+ for (const generated of firstClauses) {
121
+ const generatedTerm = clauseToSourceTerm(generated);
122
+ result.push(...expandTermRecursively(program, module, generatedTerm, loadContext, 1));
123
+ }
124
+ return { expanded: true, clauses: result };
125
+ }
126
+
127
+ function isCallable(term) {
128
+ return term?.type === ATOM || term?.type === COMPOUND;
129
+ }
130
+
131
+ function expandGoalChildren(program, goal, module, loadContext, depth) {
132
+ if (goal.type !== COMPOUND) return goal;
133
+
134
+ if (goal.name === ':' && goal.arity === 2) {
135
+ const qualifier = deref(goal.args[0], new Env());
136
+ if (qualifier.type === VAR) return goal;
137
+ if (qualifier.type !== ATOM) return goal;
138
+ const child = expandGoalRecursively(program, goal.args[1], qualifier.name, loadContext, depth + 1);
139
+ return compound(':', [goal.args[0], child]);
140
+ }
141
+
142
+ if ([',', ';', '->'].includes(goal.name) && goal.arity === 2) {
143
+ return compound(goal.name, [
144
+ expandGoalRecursively(program, goal.args[0], module, loadContext, depth + 1),
145
+ expandGoalRecursively(program, goal.args[1], module, loadContext, depth + 1),
146
+ ]);
147
+ }
148
+ if (goal.name === '\\+' && goal.arity === 1) {
149
+ return compound('\\+', [expandGoalRecursively(program, goal.args[0], module, loadContext, depth + 1)]);
150
+ }
151
+ return goal;
152
+ }
153
+
154
+ function expandGoalRecursively(program, goal, module, loadContext, depth = 0) {
155
+ if (depth > MAX_EXPANSION_DEPTH) throw new PrologError('resource_error(source_expansion)');
156
+ const resolved = deref(goal, new Env());
157
+ if (!isCallable(resolved)) return resolved;
158
+
159
+ const expanded = runHook(program, module, 'goal_expansion', resolved, loadContext);
160
+ if (expanded != null) {
161
+ if (!isCallable(expanded)) throw new PrologError('type_error(callable)', expanded);
162
+ if (sourceTermsAreVariants(resolved, expanded)) return expandGoalChildren(program, resolved, module, loadContext, depth);
163
+ return expandGoalRecursively(program, expanded, module, loadContext, depth + 1);
164
+ }
165
+ return expandGoalChildren(program, resolved, module, loadContext, depth);
166
+ }
167
+
168
+ export function expandClauseGoals(program, clause, lexicalModule = 'user', loadContext = null) {
169
+ if (!Array.isArray(clause.body) || clause.body.length === 0) return clause;
170
+ clause.body = clause.body.map((goal) =>
171
+ expandGoalRecursively(program, goal, lexicalModule, loadContext));
172
+ return clause;
173
+ }
@@ -5,6 +5,7 @@
5
5
  import { PrologError, createDefaultRegistry, eyePrologLibraryBuiltins } from './iso.js';
6
6
  import { clpzBuiltins } from './clpz.js';
7
7
  import { attsBuiltins } from './atts.js';
8
+ import { expansionBuiltins } from './expansion-builtins.js';
8
9
  import { registerCleanupBuiltins } from './cleanup.js';
9
10
  import { fs, isNode, memoryStatistics } from './platform.js';
10
11
  import { ATOM, VAR, atom, deref, numberTerm, unify } from './term.js';
@@ -188,6 +189,7 @@ export function createEyePrologRegistry() {
188
189
  registerCleanupBuiltins(registry);
189
190
  eyePrologLibraryBuiltins.register(registry);
190
191
  attsBuiltins.register(registry);
192
+ expansionBuiltins.register(registry);
191
193
  clpzBuiltins.register(registry);
192
194
  registry.eyePrologLibrary = true;
193
195
  return registry;
@@ -185,6 +185,50 @@ probe.
185
185
  'Scryer-style operator exports and multi-attribute directives load');
186
186
  },
187
187
  },
188
+ {
189
+ name: 'user source expansion supports Scryer-style generated predicates and DCGs',
190
+ run: () => {
191
+ const termSource = `term_expansion(make_generated, [generated(one),generated(two)]).
192
+ make_generated.
193
+ `;
194
+ const generated = Program.parse(termSource, { sourceMetadata: false });
195
+ assertEqual(generated.findGroup('generated', 1, 'user')?.clauses.length, 2,
196
+ 'term_expansion/2 may emit a list of clauses');
197
+ assertEqual(generated.findGroup('make_generated', 0, 'user'), null,
198
+ 'the source marker is replaced by its expansion');
199
+
200
+ const dcgSource = `term_expansion(make_dcg, Clause) :-
201
+ expand_term((generated_nt --> [a]), Clause).
202
+ make_dcg.
203
+ check :- phrase(generated_nt, [a]).
204
+ `;
205
+ assertIncludes(run(dcgSource, { goal: 'check' }).stdout, 'check.',
206
+ 'expand_term/2 exposes system DCG expansion to a source hook');
207
+
208
+ const duoSource = `:- op(1200, xfx, ++>).
209
+ term_expansion((Head ++> Body), (Head :- Body)).
210
+ generated_duo(ok) ++> true.
211
+ `;
212
+ assertEqual(run(duoSource, { goal: 'generated_duo(ok)' }).stats.completed_goal_lists, 1,
213
+ 'custom Duo-DCG-style operators can be lowered by term_expansion/2');
214
+
215
+ const goalSource = `:- module(expansion_probe, [check/0]).
216
+ helper(ok).
217
+ user:goal_expansion(old(X), new(X)) :- helper(ok).
218
+ new(ok).
219
+ check :- old(X), X = ok.
220
+ `;
221
+ const goalProgram = Program.parse(goalSource, { sourceMetadata: false });
222
+ const check = goalProgram.findGroup('check', 0, 'expansion_probe')?.clauses[0];
223
+ assertEqual(check?.body[0]?.name, 'new', 'goal_expansion/2 rewrites a later source goal');
224
+ assertEqual(check?.body[0]?.args[0]?.name, check?.body[1]?.args[0]?.name,
225
+ 'goal expansion preserves sharing with the surrounding clause');
226
+ assertEqual(goalProgram.findGroup('goal_expansion', 2, 'user')?.clauses[0]?.body[0]?.module,
227
+ 'expansion_probe', 'qualified hook clauses retain their lexical body module');
228
+ assertIncludes(run(goalSource, { goal: 'expansion_probe:check' }).stdout, 'expansion_probe:check.',
229
+ 'a user hook defined by a library module executes in its lexical module');
230
+ },
231
+ },
188
232
  {
189
233
  name: 'write_term variable_names/1 errors preserve the instantiated option culprit (issue #69)',
190
234
  run: () => {
@@ -4785,7 +4829,7 @@ answer(ok) :-
4785
4829
  assertEqual(Boolean(registry.get('is', 2)), true, 'ISO is/2 exists');
4786
4830
  assertEqual(Boolean(registry.get('append', 3)), false, 'append/3 is not ISO core');
4787
4831
  assertEqual(library.eyePrologLibrary, true, 'complete registry marker');
4788
- assertEqual(library.defs.size, 171, 'EyeProlog registry contains ISO definitions, cleanup controls, observability extensions, WFS tnot/1, and private library adapters');
4832
+ assertEqual(library.defs.size, 173, 'EyeProlog registry contains ISO definitions, cleanup controls, observability extensions, WFS tnot/1, and private library adapters');
4789
4833
  assertEqual(Boolean(registry.get('phrase', 2)), true, 'Part 3 phrase/2 exists');
4790
4834
  assertEqual(Boolean(registry.get('phrase', 3)), true, 'Part 3 phrase/3 exists');
4791
4835
  assertEqual(registry.get('statistics', 0), null, 'statistics/0 is absent from the ISO registry');
@@ -6424,11 +6424,15 @@ Hamiltonian circuits, three-way comparison, and domain reflection. Its existing
6424
6424
  `src/clpz.js` store is now explicitly transitional: the attributed-variable layer
6425
6425
  is intended to support staged reuse of Markus Triska's MIT-licensed
6426
6426
  [Scryer `clpz.pl`](https://github.com/mthom/scryer-prolog/blob/master/src/lib/clpz.pl).
6427
- The remaining blockers are compile-time term/goal expansion and the supporting
6428
- library surface used by that source; the migration state is tracked in
6429
- [`src/CLPZ-MIGRATION.md`](src/CLPZ-MIGRATION.md). Until parity is reached, the JS
6430
- kernel remains the active implementation so existing CLP(Z) behavior does not
6431
- regress.
6427
+ The generic compile-time expansion blocker is now closed: normal-profile source
6428
+ loading runs module-local and `user` `term_expansion/2` and `goal_expansion/2`
6429
+ hooks, accepts clause-list expansions, preserves surrounding variable sharing,
6430
+ and provides `expand_term/2` for processor DCG lowering. This is sufficient for
6431
+ Scryer's generated-predicate and Duo-DCG expansion strategy. The remaining
6432
+ blocker is the supporting Scryer library surface used by that source; the
6433
+ migration state is tracked in [`src/CLPZ-MIGRATION.md`](src/CLPZ-MIGRATION.md).
6434
+ Until parity is reached, the JS kernel remains the active implementation so
6435
+ existing CLP(Z) behavior does not regress.
6432
6436
 
6433
6437
  Alongside its interop entries `call_nth/2`, `time/1`, and `.../2`,
6434
6438
  `library(iso_ext)` also exports EyeProlog's extension relations `countall/2`,