eyelang 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eyelang",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "type": "module",
5
5
  "description": "A small rule engine for Prolog-style Horn clauses",
6
6
  "keywords": [
@@ -44,3 +44,10 @@ export function createDefaultRegistry() {
44
44
  }
45
45
  return registry;
46
46
  }
47
+
48
+ let defaultRegistry = null;
49
+
50
+ export function getDefaultRegistry() {
51
+ if (defaultRegistry == null) defaultRegistry = createDefaultRegistry();
52
+ return defaultRegistry;
53
+ }
package/src/cli.js CHANGED
@@ -3,17 +3,13 @@
3
3
  import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import process from 'node:process';
6
- import { Env, copyResolved, termIsGround, termToString } from './term.js';
7
- import { Program } from './program.js';
8
- import { Solver } from './solver.js';
9
- import { parseQueryGoal } from './parser.js';
10
- import { whyNoProof, whyProof } from './explain.js';
11
6
 
12
- const VERSION = await packageVersion();
7
+ let engineModule = null;
8
+ let explanationModule = null;
13
9
 
14
10
  export async function main(argv) {
15
11
  if (argv.length === 0) {
16
- usage(process.stdout);
12
+ await usage(process.stdout);
17
13
  return;
18
14
  }
19
15
 
@@ -35,7 +31,7 @@ export async function main(argv) {
35
31
  } else if (!endOptions && (arg === '--version' || arg === '-v')) {
36
32
  options.version = true;
37
33
  } else if (!endOptions && (arg === '--help' || arg === '-h')) {
38
- usage(process.stdout);
34
+ await usage(process.stdout);
39
35
  return;
40
36
  } else if (!endOptions && (arg === '--proof' || arg === '-p')) {
41
37
  options.proof = true;
@@ -52,7 +48,7 @@ export async function main(argv) {
52
48
  }
53
49
 
54
50
  if (options.version) {
55
- process.stdout.write(`eyelang ${VERSION}\n`);
51
+ process.stdout.write(`eyelang ${await packageVersion()}\n`);
56
52
  return;
57
53
  }
58
54
 
@@ -77,45 +73,69 @@ export async function main(argv) {
77
73
  }
78
74
  }
79
75
 
80
- const program = Program.parseSources(sourceParts, { sourceMetadata: options.proof, markRecursive: options.proof });
76
+ const engine = await loadEngine();
77
+ const program = engine.Program.parseSources(sourceParts, { sourceMetadata: options.proof, markRecursive: options.proof });
81
78
 
82
- if (options.query != null) runQuery(program, options.query, options);
83
- else runDefault(program, options);
79
+ if (options.query != null) await runQuery(engine, program, options.query, options);
80
+ else await runDefault(engine, program, options);
84
81
  }
85
82
 
86
- function runQuery(program, query, options) {
87
- const goal = parseQueryGoal(query);
88
- const solver = new Solver(program);
83
+ async function loadEngine() {
84
+ if (engineModule == null) {
85
+ const [term, program, solver, parser, registry] = await Promise.all([
86
+ import('./term.js'),
87
+ import('./program.js'),
88
+ import('./solver.js'),
89
+ import('./parser.js'),
90
+ import('./builtins/registry.js'),
91
+ ]);
92
+ engineModule = { ...term, ...program, ...solver, ...parser, ...registry };
93
+ }
94
+ return engineModule;
95
+ }
96
+
97
+ async function loadExplanation() {
98
+ if (explanationModule == null) explanationModule = await import('./explain.js');
99
+ return explanationModule;
100
+ }
101
+
102
+ async function runQuery(engine, program, query, options) {
103
+ const goal = engine.parseQueryGoal(query);
104
+ const registry = engine.getDefaultRegistry();
105
+ const solver = new engine.Solver(program, { registry });
106
+ const explanation = options.proof ? await loadExplanation() : null;
89
107
 
90
- for (const env of solver.solve([goal], new Env(), 0)) {
91
- process.stdout.write(`${termToString(goal, env, true)}.\n`);
108
+ for (const env of solver.solve([goal], new engine.Env(), 0)) {
109
+ process.stdout.write(`${engine.termToString(goal, env, true)}.\n`);
92
110
 
93
- if (options.proof) writeExplanation(program, copyResolved(goal, env));
111
+ if (options.proof) writeExplanation(explanation, program, engine.copyResolved(goal, env), registry);
94
112
  }
95
113
 
96
114
  if (options.stats) printStats(solver.stats);
97
115
  }
98
116
 
99
- function runDefault(program, options) {
117
+ async function runDefault(engine, program, options) {
100
118
  const goals = program.materializationGoals();
101
119
  const materializedKeys = new Set(goals.map((goal) => `${goal.name}/${goal.arity}`));
102
120
  const facts = program.sourceFactLines(materializedKeys);
103
121
  const lines = new Set();
104
122
  let lastStats = null;
123
+ const registry = engine.getDefaultRegistry();
124
+ const explanation = options.proof ? await loadExplanation() : null;
105
125
 
106
126
  for (const goal of goals) {
107
- const solver = new Solver(program);
127
+ const solver = new engine.Solver(program, { registry });
108
128
 
109
- for (const env of solver.solve([goal], new Env(), 0)) {
110
- if (!termIsGround(goal, env)) continue;
129
+ for (const env of solver.solve([goal], new engine.Env(), 0)) {
130
+ if (!engine.termIsGround(goal, env)) continue;
111
131
 
112
- const line = `${termToString(goal, env, true)}.\n`;
132
+ const line = `${engine.termToString(goal, env, true)}.\n`;
113
133
  if (facts.has(line) || lines.has(line)) continue;
114
134
 
115
135
  lines.add(line);
116
136
 
117
137
  process.stdout.write(line);
118
- if (options.proof) writeExplanation(program, copyResolved(goal, env));
138
+ if (options.proof) writeExplanation(explanation, program, engine.copyResolved(goal, env), registry);
119
139
  }
120
140
 
121
141
  lastStats = solver.stats;
@@ -124,14 +144,14 @@ function runDefault(program, options) {
124
144
  if (options.stats && lastStats) printStats(lastStats);
125
145
  }
126
146
 
127
- function writeExplanation(program, resolved) {
128
- const proof = whyProof(program, resolved);
147
+ function writeExplanation(explanation, program, resolved, registry) {
148
+ const proof = explanation.whyProof(program, resolved, { registry });
129
149
  process.stdout.write(proof.text);
130
- if (!proof.ok) process.stdout.write(whyNoProof(resolved));
150
+ if (!proof.ok) process.stdout.write(explanation.whyNoProof(resolved));
131
151
  }
132
152
 
133
- function usage(stream) {
134
- stream.write(`eyelang ${VERSION}
153
+ async function usage(stream) {
154
+ stream.write(`eyelang ${await packageVersion()}
135
155
 
136
156
  Usage:
137
157
  eyelang [options] [file-or-url.pl|- ...]
package/src/index.js CHANGED
@@ -4,25 +4,27 @@ export { Program, makeProgram } from './program.js';
4
4
  export { parseClauses, parseProgramText, parseQueryGoal } from './parser.js';
5
5
  export { Solver } from './solver.js';
6
6
  export * from './term.js';
7
- export { BuiltinRegistry, createDefaultRegistry } from './builtins/registry.js';
7
+ export { BuiltinRegistry, createDefaultRegistry, getDefaultRegistry } from './builtins/registry.js';
8
8
 
9
9
  import { Env, copyResolved, termIsGround, termToString } from './term.js';
10
10
  import { Program } from './program.js';
11
11
  import { Solver } from './solver.js';
12
12
  import { parseQueryGoal } from './parser.js';
13
13
  import { whyNoProof, whyProof } from './explain.js';
14
+ import { getDefaultRegistry } from './builtins/registry.js';
14
15
 
15
16
  export function run(source, options = {}) {
16
17
  const includeWhy = options.proof === true || options.why === true || options.explain === true;
17
18
  const parseOptions = { ...options, sourceMetadata: includeWhy, markRecursive: includeWhy };
18
19
  const program = source instanceof Program ? source : Program.parse(source, parseOptions);
19
- const solver = new Solver(program, options);
20
+ const runOptions = options.registry ? options : { ...options, registry: getDefaultRegistry() };
21
+ const solver = new Solver(program, runOptions);
20
22
  const output = [];
21
23
  if (options.query) {
22
24
  const goal = typeof options.query === 'string' ? parseQueryGoal(options.query) : options.query;
23
25
  for (const env of solver.solve([goal], new Env(), 0)) {
24
26
  output.push(`${termToString(goal, env, true)}.\n`);
25
- if (includeWhy) appendExplanation(output, program, copyResolved(goal, env));
27
+ if (includeWhy) appendExplanation(output, program, copyResolved(goal, env), runOptions.registry);
26
28
  }
27
29
  } else {
28
30
  const goals = program.materializationGoals();
@@ -30,7 +32,7 @@ export function run(source, options = {}) {
30
32
  const facts = program.sourceFactLines(materializedKeys);
31
33
  const seen = new Set();
32
34
  for (const goal of goals) {
33
- const localSolver = new Solver(program, options);
35
+ const localSolver = new Solver(program, runOptions);
34
36
  for (const env of localSolver.solve([goal], new Env(), 0)) {
35
37
  const resolved = copyResolved(goal, env);
36
38
  if (!termIsGround(resolved)) continue;
@@ -38,15 +40,15 @@ export function run(source, options = {}) {
38
40
  if (facts.has(line) || seen.has(line)) continue;
39
41
  seen.add(line);
40
42
  output.push(line);
41
- if (includeWhy) appendExplanation(output, program, resolved);
43
+ if (includeWhy) appendExplanation(output, program, resolved, runOptions.registry);
42
44
  }
43
45
  }
44
46
  }
45
47
  return { stdout: output.join(''), stats: solver.stats };
46
48
  }
47
49
 
48
- function appendExplanation(output, program, resolved) {
49
- const proof = whyProof(program, resolved);
50
+ function appendExplanation(output, program, resolved, registry) {
51
+ const proof = whyProof(program, resolved, { registry });
50
52
  output.push(proof.text);
51
53
  if (!proof.ok) output.push(whyNoProof(resolved));
52
54
  }
@@ -1,31 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  // Conformance test runner.
3
- // It executes each case through the CLI so tests cover the same path users run from the shell.
3
+ // It executes cases in-process so the conformance corpus measures engine behavior instead of Node process startup.
4
4
  import fs from 'node:fs';
5
- import os from 'node:os';
6
5
  import path from 'node:path';
7
6
  import { spawnSync } from 'node:child_process';
7
+ import { Program, run } from '../src/index.js';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { TestReporter, isMainModule } from './test-style.js';
10
10
 
11
11
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
- const bin = path.join(root, 'bin', 'eyelang');
13
12
  const profileArg = process.argv[2] ?? 'conformance';
14
13
 
15
14
  export function runConformance(reporter = new TestReporter(), requestedProfiles = null) {
16
15
  const profiles = requestedProfiles ?? (profileArg === 'conformance' ? ['core', 'extension'] : [profileArg]);
17
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'eyelang-conformance.'));
18
- const actualFile = path.join(tmp, 'actual.out');
19
- const errFile = path.join(tmp, 'stderr.out');
20
-
21
- try {
22
- for (const profile of profiles) runProfile(reporter, profile, actualFile, errFile);
23
- } finally {
24
- fs.rmSync(tmp, { recursive: true, force: true });
25
- }
16
+ for (const profile of profiles) runProfile(reporter, profile);
26
17
  }
27
18
 
28
- function runProfile(reporter, profile, actualFile, errFile) {
19
+ function runProfile(reporter, profile) {
29
20
  const casesDir = path.join(root, 'conformance', 'cases', profile);
30
21
  const expectedDir = path.join(root, 'conformance', 'expected', profile);
31
22
 
@@ -38,56 +29,42 @@ function runProfile(reporter, profile, actualFile, errFile) {
38
29
  for (const file of files) {
39
30
  const name = file.slice(0, -3);
40
31
  const label = `${profile}/${name}`;
41
- reporter.test(label, () => runCase(profile, name, file, casesDir, expectedDir, actualFile, errFile));
32
+ reporter.test(label, () => runCase(profile, name, file, casesDir, expectedDir));
42
33
  }
43
34
 
44
35
  reporter.sectionTotal(`conformance ${profile}`);
45
36
  }
46
37
 
47
- function runCase(profile, name, file, casesDir, expectedDir, actualFile, errFile) {
48
- const program = path.join(casesDir, file);
38
+ function runCase(profile, name, file, casesDir, expectedDir) {
39
+ const programFile = path.join(casesDir, file);
49
40
  const queryFile = path.join(casesDir, `${name}.query`);
50
41
  const expected = path.join(expectedDir, `${name}.out`);
51
- const args = [bin];
52
- if (fs.existsSync(queryFile)) args.push('--query', fs.readFileSync(queryFile, 'utf8').trim());
53
- args.push(program);
54
-
55
- const outFd = fs.openSync(actualFile, 'w');
56
- const errFd = fs.openSync(errFile, 'w');
57
- const result = spawnSync(process.execPath, args, {
58
- cwd: root,
59
- stdio: ['ignore', outFd, errFd],
60
- });
61
- fs.closeSync(outFd);
62
- fs.closeSync(errFd);
63
-
64
- if (result.status !== 0) {
65
- const stderr = fs.readFileSync(errFile, 'utf8');
66
- const stdout = fs.readFileSync(actualFile, 'utf8');
67
- throw new Error(`case ${profile}/${name} exited with ${result.status}\n${stderr}${stdout}`.trimEnd());
68
- }
42
+ const text = fs.readFileSync(programFile, 'utf8');
43
+ const program = Program.parseSources([{ text, filename: file }], { sourceMetadata: false, markRecursive: false });
44
+ const options = fs.existsSync(queryFile) ? { query: fs.readFileSync(queryFile, 'utf8').trim() } : {};
45
+ const actual = run(program, options).stdout;
69
46
 
70
47
  if (!fs.existsSync(expected)) {
71
48
  throw new Error(`missing expected file: ${path.relative(root, expected)}`);
72
49
  }
73
50
 
74
- const expectedBuffer = fs.readFileSync(expected);
75
- const actualBuffer = fs.readFileSync(actualFile);
76
- if (!expectedBuffer.equals(actualBuffer)) {
77
- throw new Error(`output mismatch for ${profile}/${name}\n${diffText(expected, actualFile)}`.trimEnd());
51
+ const expectedText = fs.readFileSync(expected, 'utf8');
52
+ if (expectedText !== actual) {
53
+ throw new Error(`output mismatch for ${profile}/${name}
54
+ ${diffText(expected, actual)}`.trimEnd());
78
55
  }
79
56
  }
80
57
 
81
- function diffText(expected, actual) {
82
- const diff = spawnSync('diff', ['-u', expected, actual], { encoding: 'utf8' });
58
+ function diffText(expected, actualText) {
59
+ const diff = spawnSync('diff', ['-u', expected, '-'], { input: actualText, encoding: 'utf8' });
83
60
  if (diff.stdout) return diff.stdout;
84
61
 
85
62
  const expectedText = fs.readFileSync(expected, 'utf8').split('\n');
86
- const actualText = fs.readFileSync(actual, 'utf8').split('\n');
87
- const limit = Math.max(expectedText.length, actualText.length);
63
+ const actualLines = actualText.split('\n');
64
+ const limit = Math.max(expectedText.length, actualLines.length);
88
65
  for (let i = 0; i < limit; i++) {
89
- if (expectedText[i] !== actualText[i]) {
90
- return `first difference at line ${i + 1}\nexpected: ${expectedText[i] ?? '<missing>'}\nactual: ${actualText[i] ?? '<missing>'}`;
66
+ if (expectedText[i] !== actualLines[i]) {
67
+ return `first difference at line ${i + 1}\nexpected: ${expectedText[i] ?? '<missing>'}\nactual: ${actualLines[i] ?? '<missing>'}`;
91
68
  }
92
69
  }
93
70
 
@@ -2,14 +2,13 @@
2
2
  // Example-output test runner.
3
3
  // It compares examples byte-for-byte against golden output so answer and proof changes cannot silently alter results.
4
4
  import fs from 'node:fs';
5
- import os from 'node:os';
6
5
  import path from 'node:path';
7
6
  import { spawnSync } from 'node:child_process';
7
+ import { Program, run } from '../src/index.js';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { TestReporter, isMainModule } from './test-style.js';
10
10
 
11
11
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
- const bin = path.join(root, 'bin', 'eyelang');
13
12
  const examplesDir = path.join(root, 'examples');
14
13
  const expectedDir = path.join(examplesDir, 'output');
15
14
  const expectedProofDir = path.join(examplesDir, 'proof');
@@ -39,77 +38,70 @@ const proofExamples = [
39
38
  ];
40
39
 
41
40
  export function runExamples(reporter = new TestReporter()) {
42
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'eyelang-examples.'));
43
- const actualFile = path.join(tmp, 'actual.out');
44
- const errFile = path.join(tmp, 'stderr.out');
41
+ const files = fs.readdirSync(examplesDir)
42
+ .filter((name) => name.endsWith('.pl'))
43
+ .sort();
45
44
 
46
- try {
47
- const files = fs.readdirSync(examplesDir)
48
- .filter((name) => name.endsWith('.pl'))
49
- .sort();
50
-
51
- reporter.section('Examples');
52
- for (const name of files) reporter.test(name, () => runExample(name, actualFile, errFile));
53
- reporter.sectionTotal('examples');
45
+ reporter.section('Examples');
46
+ for (const name of files) reporter.test(name, () => runExample(name));
47
+ reporter.sectionTotal('examples');
54
48
 
55
- reporter.section('Proof examples');
56
- for (const name of proofExamples) reporter.test(name, () => runProofExample(name, actualFile, errFile));
57
- reporter.sectionTotal('proof examples');
58
- } finally {
59
- fs.rmSync(tmp, { recursive: true, force: true });
60
- }
49
+ reporter.section('Proof examples');
50
+ for (const name of proofExamples) reporter.test(name, () => runProofExample(name));
51
+ reporter.sectionTotal('proof examples');
61
52
  }
62
53
 
63
- function runExample(name, actualFile, errFile) {
64
- const program = path.join(examplesDir, name);
54
+ function runExample(name) {
55
+ const programFile = path.join(examplesDir, name);
65
56
  const expected = path.join(expectedDir, name);
66
- runAndCompare(name, program, expected, [], actualFile, errFile, 'output');
57
+ const actual = runProgramExample(programFile, name, { proof: false });
58
+ compareOutput(name, expected, actual, 'output');
67
59
  }
68
60
 
69
- function runProofExample(name, actualFile, errFile) {
70
- const program = path.join(examplesDir, name);
61
+ function runProofExample(name) {
62
+ const programFile = path.join(examplesDir, name);
71
63
  const expected = path.join(expectedProofDir, name);
72
- runAndCompare(name, program, expected, ['--proof'], actualFile, errFile, 'proof output');
64
+ const actual = runProgramExample(programFile, name, { proof: true });
65
+ compareOutput(name, expected, actual, 'proof output');
73
66
  }
74
67
 
75
- function runAndCompare(name, program, expected, args, actualFile, errFile, label) {
76
- const outFd = fs.openSync(actualFile, 'w');
77
- const errFd = fs.openSync(errFile, 'w');
78
- const result = spawnSync(process.execPath, [bin, ...args, program], {
79
- cwd: root,
80
- env: { ...process.env, EYELANG_LOCAL_TIME: fixedExampleDate },
81
- stdio: ['ignore', outFd, errFd],
82
- });
83
- fs.closeSync(outFd);
84
- fs.closeSync(errFd);
85
-
86
- if (result.status !== 0) {
87
- const stderr = fs.readFileSync(errFile, 'utf8');
88
- const stdout = fs.readFileSync(actualFile, 'utf8');
89
- throw new Error(`example ${name} ${label} exited with ${result.status}\n${stderr}${stdout}`.trimEnd());
68
+ function runProgramExample(programFile, filename, options) {
69
+ const oldLocalTime = process.env.EYELANG_LOCAL_TIME;
70
+ process.env.EYELANG_LOCAL_TIME = fixedExampleDate;
71
+ try {
72
+ const text = fs.readFileSync(programFile, 'utf8');
73
+ const program = Program.parseSources([{ text, filename }], {
74
+ sourceMetadata: options.proof,
75
+ markRecursive: options.proof,
76
+ });
77
+ return run(program, options).stdout;
78
+ } finally {
79
+ if (oldLocalTime == null) delete process.env.EYELANG_LOCAL_TIME;
80
+ else process.env.EYELANG_LOCAL_TIME = oldLocalTime;
90
81
  }
82
+ }
91
83
 
84
+ function compareOutput(name, expected, actual, label) {
92
85
  if (!fs.existsSync(expected)) {
93
86
  throw new Error(`missing expected ${label} file: ${path.relative(root, expected)}`);
94
87
  }
95
88
 
96
- const expectedBuffer = fs.readFileSync(expected);
97
- const actualBuffer = fs.readFileSync(actualFile);
98
- if (!expectedBuffer.equals(actualBuffer)) {
99
- throw new Error(`${label} mismatch for ${name}\n${diffText(expected, actualFile)}`.trimEnd());
89
+ const expectedText = fs.readFileSync(expected, 'utf8');
90
+ if (expectedText !== actual) {
91
+ throw new Error(`${label} mismatch for ${name}\n${diffText(expected, actual)}`.trimEnd());
100
92
  }
101
93
  }
102
94
 
103
- function diffText(expected, actual) {
104
- const diff = spawnSync('diff', ['-u', expected, actual], { encoding: 'utf8' });
95
+ function diffText(expected, actualText) {
96
+ const diff = spawnSync('diff', ['-u', expected, '-'], { input: actualText, encoding: 'utf8' });
105
97
  if (diff.stdout) return diff.stdout;
106
98
 
107
99
  const expectedText = fs.readFileSync(expected, 'utf8').split('\n');
108
- const actualText = fs.readFileSync(actual, 'utf8').split('\n');
109
- const limit = Math.max(expectedText.length, actualText.length);
100
+ const actualLines = actualText.split('\n');
101
+ const limit = Math.max(expectedText.length, actualLines.length);
110
102
  for (let i = 0; i < limit; i++) {
111
- if (expectedText[i] !== actualText[i]) {
112
- return `first difference at line ${i + 1}\nexpected: ${expectedText[i] ?? '<missing>'}\nactual: ${actualText[i] ?? '<missing>'}`;
103
+ if (expectedText[i] !== actualLines[i]) {
104
+ return `first difference at line ${i + 1}\nexpected: ${expectedText[i] ?? '<missing>'}\nactual: ${actualLines[i] ?? '<missing>'}`;
113
105
  }
114
106
  }
115
107