prolog-notebook 0.1.2 → 0.3.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/CHANGELOG.md +211 -0
- package/README.md +130 -27
- package/bin/prolog-notebook.mjs +206 -0
- package/package.json +19 -4
- package/src/browser.js +252 -16
- package/src/build-info.js +103 -0
- package/src/build-info.json +4 -0
- package/src/clauses.js +236 -0
- package/src/engine.js +227 -16
- package/src/export.js +126 -0
- package/src/format.js +649 -0
- package/src/node.js +14 -4
- package/src/notebook.css +319 -1
- package/src/notebook.js +1261 -60
- package/src/page.js +85 -0
- package/src/render.js +367 -0
- package/src/run.js +128 -0
- package/src/session.js +227 -0
- package/src/version.js +51 -0
- package/src/worker.js +77 -0
package/package.json
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prolog-notebook",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Jupyter-style notebooks for Prolog. Runs in the browser, installs nothing.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/node.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"prolog-notebook": "./bin/prolog-notebook.mjs"
|
|
9
|
+
},
|
|
7
10
|
"exports": {
|
|
8
11
|
".": "./src/node.js",
|
|
9
12
|
"./browser": "./src/browser.js",
|
|
10
13
|
"./engine": "./src/engine.js",
|
|
11
|
-
"./
|
|
14
|
+
"./format": "./src/format.js",
|
|
15
|
+
"./notebook.css": "./src/notebook.css",
|
|
16
|
+
"./page": "./src/page.js",
|
|
17
|
+
"./render": "./src/render.js",
|
|
18
|
+
"./session": "./src/session.js",
|
|
19
|
+
"./worker": "./src/worker.js",
|
|
20
|
+
"./run": "./src/run.js",
|
|
21
|
+
"./export": "./src/export.js"
|
|
12
22
|
},
|
|
13
23
|
"files": [
|
|
24
|
+
"bin",
|
|
14
25
|
"src",
|
|
15
26
|
"README.md",
|
|
16
27
|
"LICENSE",
|
|
@@ -18,7 +29,7 @@
|
|
|
18
29
|
],
|
|
19
30
|
"scripts": {
|
|
20
31
|
"test": "node --test test/*.test.mjs",
|
|
21
|
-
"
|
|
32
|
+
"dev": "python3 -m http.server 8777",
|
|
22
33
|
"prepublishOnly": "npm test"
|
|
23
34
|
},
|
|
24
35
|
"keywords": [
|
|
@@ -45,9 +56,13 @@
|
|
|
45
56
|
"node": ">=18"
|
|
46
57
|
},
|
|
47
58
|
"dependencies": {
|
|
48
|
-
"
|
|
59
|
+
"markdown-it": "^15.0.0",
|
|
60
|
+
"swipl-wasm": "8.0.7"
|
|
49
61
|
},
|
|
50
62
|
"publishConfig": {
|
|
51
63
|
"access": "public"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"jsdom": "^29.1.1"
|
|
52
67
|
}
|
|
53
68
|
}
|
package/src/browser.js
CHANGED
|
@@ -1,25 +1,261 @@
|
|
|
1
|
-
// Browser entry point. The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
1
|
+
// Browser entry point. The engine runs in a Web Worker, so a query that never
|
|
2
|
+
// terminates costs the reader a click on Stop rather than the whole tab.
|
|
3
|
+
//
|
|
4
|
+
// The 5.9 MB swipl bundle is still fetched by the page's own URL rather than by a
|
|
5
|
+
// bundler — the worker is told where to find it.
|
|
6
|
+
import { ConsultLog, defaultCellName, unconsult } from './session.js';
|
|
5
7
|
|
|
6
8
|
export * from './engine.js';
|
|
9
|
+
export { ConsultLog } from './session.js';
|
|
7
10
|
|
|
8
|
-
|
|
11
|
+
const DEFAULT_SWIPL_URL = new URL(
|
|
12
|
+
'../node_modules/swipl-wasm/dist/swipl/swipl-bundle.js',
|
|
13
|
+
import.meta.url
|
|
14
|
+
).href;
|
|
9
15
|
|
|
10
16
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
17
|
+
* A session whose engine lives in a worker.
|
|
18
|
+
*
|
|
19
|
+
* Every method returns a promise. `abort()` terminates the worker outright and
|
|
20
|
+
* replays the consult log into a new one, which is the only thing that works: a
|
|
21
|
+
* thread blocked inside WASM cannot be asked politely to stop.
|
|
13
22
|
*/
|
|
14
|
-
export
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
export class WorkerSession {
|
|
24
|
+
#worker = null;
|
|
25
|
+
#pending = new Map();
|
|
26
|
+
#nextId = 1;
|
|
27
|
+
#booting = null;
|
|
28
|
+
/** The one query allowed to hold a frame. See supersede(). */
|
|
29
|
+
#open = null;
|
|
30
|
+
|
|
31
|
+
constructor({ workerUrl, swiplUrl = DEFAULT_SWIPL_URL, engineUrl, options = {} } = {}) {
|
|
32
|
+
this.workerUrl = workerUrl ?? new URL('./worker.js', import.meta.url).href;
|
|
33
|
+
this.swiplUrl = swiplUrl;
|
|
34
|
+
// The worker imports the engine itself, so it needs an absolute URL: a
|
|
35
|
+
// relative specifier would resolve against the worker script, which may have
|
|
36
|
+
// been served from anywhere.
|
|
37
|
+
this.engineUrl = engineUrl ?? new URL('./engine.js', import.meta.url).href;
|
|
38
|
+
this.options = options;
|
|
39
|
+
this.log = new ConsultLog();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Boot the worker and the engine inside it. Idempotent. */
|
|
43
|
+
async start() {
|
|
44
|
+
if (this.#worker) return this;
|
|
45
|
+
if (this.#booting) return this.#booting;
|
|
46
|
+
this.#booting = (async () => {
|
|
47
|
+
this.#spawn();
|
|
48
|
+
await this.#send('boot', {
|
|
49
|
+
swiplUrl: this.swiplUrl,
|
|
50
|
+
engineUrl: this.engineUrl,
|
|
51
|
+
options: this.options,
|
|
52
|
+
});
|
|
53
|
+
this.#booting = null;
|
|
54
|
+
return this;
|
|
55
|
+
})();
|
|
56
|
+
return this.#booting;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async consult(text, name = defaultCellName()) {
|
|
60
|
+
await this.start();
|
|
61
|
+
const result = await this.#send('consult', { text, name });
|
|
62
|
+
if (result.ok) this.log.record(name, text);
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Open a query. Nothing runs until the first `next()` or `all()`, so opening
|
|
68
|
+
* one is always safe even if the goal is a disaster.
|
|
69
|
+
*/
|
|
70
|
+
query(goal) {
|
|
71
|
+
return new WorkerQuery(this, goal);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* ONE OPEN SEQUENCE PER SESSION, and the reason is not tidiness.
|
|
76
|
+
*
|
|
77
|
+
* SWI keeps open queries on a stack and swipl-wasm enforces it: stepping or
|
|
78
|
+
* closing anything but the innermost throws "Attempt to access not innermost
|
|
79
|
+
* query". A page cannot promise the order — the order is whatever the reader
|
|
80
|
+
* clicks — so the constraint is met by construction instead: there is never
|
|
81
|
+
* more than one open query, which means the one being closed is always the
|
|
82
|
+
* innermost, which means the close is always legal (869epzqpc).
|
|
83
|
+
*
|
|
84
|
+
* Called at the moment a frame is about to be opened, never when the query
|
|
85
|
+
* OBJECT is made: a cell whose Run fails before it ever steps must not end
|
|
86
|
+
* someone else's sequence for nothing.
|
|
87
|
+
*
|
|
88
|
+
* @internal
|
|
89
|
+
*/
|
|
90
|
+
async supersede() {
|
|
91
|
+
const previous = this.#open;
|
|
92
|
+
this.#open = null;
|
|
93
|
+
if (!previous) return;
|
|
94
|
+
await previous.close({ superseded: true });
|
|
95
|
+
// Said only after the frame is actually gone, so a listener that starts a new
|
|
96
|
+
// query cannot race the close it was told about.
|
|
97
|
+
previous.onSuperseded?.();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @internal a query's frame is gone — exhausted, closed, or died with the engine. */
|
|
101
|
+
release(query) {
|
|
102
|
+
if (this.#open === query) this.#open = null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @internal a query has just taken the session's one frame. */
|
|
106
|
+
hold(query) {
|
|
107
|
+
this.#open = query;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Take one cell's clauses back out. See unconsult() in session.js. */
|
|
111
|
+
async unconsult(name) {
|
|
112
|
+
return unconsult(this, name);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Throw the engine away and rebuild it from the consult log.
|
|
117
|
+
*
|
|
118
|
+
* Terminating is not a last resort here, it is the mechanism: it reclaims the
|
|
119
|
+
* whole WASM heap as well as the stuck goal, so a memory blow-up and an
|
|
120
|
+
* infinite loop have the same cure.
|
|
121
|
+
*
|
|
122
|
+
* A `:- dynamic` cell's assert/retract state does not survive this, which is
|
|
123
|
+
* already the documented behaviour of "restart engine and run all"
|
|
124
|
+
* (format §8) rather than a new surprise.
|
|
125
|
+
*/
|
|
126
|
+
async restart() {
|
|
127
|
+
this.#teardown(new Error('aborted'));
|
|
128
|
+
// Every frame died with the worker, so nothing is holding the session's.
|
|
129
|
+
this.#open = null;
|
|
130
|
+
await this.start();
|
|
131
|
+
for (const { name, text } of this.log) {
|
|
132
|
+
await this.#send('consult', { text, name });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Stop whatever is running. Identical to restart() here, and named separately
|
|
138
|
+
* because the two are different intentions: one rescues a page, the other
|
|
139
|
+
* throws away assert/retract state deliberately.
|
|
140
|
+
*/
|
|
141
|
+
async abort() {
|
|
142
|
+
return this.restart();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async close() {
|
|
146
|
+
this.#teardown(new Error('session closed'));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#spawn() {
|
|
150
|
+
// Classic, not module: swipl-wasm has no ESM entry, and importScripts is the
|
|
151
|
+
// only way to get its global. See the comment at the top of worker.js.
|
|
152
|
+
this.#worker = new Worker(this.workerUrl);
|
|
153
|
+
this.#worker.onmessage = ({ data }) => {
|
|
154
|
+
const entry = this.#pending.get(data.id);
|
|
155
|
+
if (!entry) return;
|
|
156
|
+
this.#pending.delete(data.id);
|
|
157
|
+
if (data.ok) entry.resolve(data.value);
|
|
158
|
+
else entry.reject(new Error(data.error));
|
|
159
|
+
};
|
|
160
|
+
this.#worker.onerror = (event) => {
|
|
161
|
+
this.#teardown(new Error(event.message ?? 'worker failed'));
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#teardown(reason) {
|
|
166
|
+
this.#open = null;
|
|
167
|
+
this.#worker?.terminate();
|
|
168
|
+
this.#worker = null;
|
|
169
|
+
this.#booting = null;
|
|
170
|
+
// Anything still waiting will never hear back, so say so rather than leaving
|
|
171
|
+
// a promise dangling forever — a silent hang is what this whole change exists
|
|
172
|
+
// to remove.
|
|
173
|
+
for (const { reject } of this.#pending.values()) reject(reason);
|
|
174
|
+
this.#pending.clear();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#send(op, args = {}) {
|
|
178
|
+
if (!this.#worker) return Promise.reject(new Error('worker is not running'));
|
|
179
|
+
const id = this.#nextId++;
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
this.#pending.set(id, { resolve, reject });
|
|
182
|
+
this.#worker.postMessage({ id, op, ...args });
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** @internal */
|
|
187
|
+
send(op, args) {
|
|
188
|
+
return this.#send(op, args);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
class WorkerQuery {
|
|
193
|
+
#qid = null;
|
|
194
|
+
|
|
195
|
+
constructor(session, goal) {
|
|
196
|
+
this.session = session;
|
|
197
|
+
this.goal = goal;
|
|
198
|
+
this.done = false;
|
|
199
|
+
// Ended by another query taking the session's one frame, rather than by its
|
|
200
|
+
// own search finishing. Kept apart from `done` because only one of the two
|
|
201
|
+
// may ever be written down as an exhausted search (format §6).
|
|
202
|
+
this.superseded = false;
|
|
203
|
+
/** Set by the caller to hear that its sequence was closed for another one. */
|
|
204
|
+
this.onSuperseded = null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async #open() {
|
|
208
|
+
if (this.#qid === null) {
|
|
209
|
+
await this.session.start();
|
|
210
|
+
// Before the frame exists, never after: once a second query is open the
|
|
211
|
+
// first is no longer innermost and can never be closed at all.
|
|
212
|
+
await this.session.supersede();
|
|
213
|
+
this.#qid = await this.session.send('open', { goal: this.goal });
|
|
214
|
+
this.session.hold(this);
|
|
21
215
|
}
|
|
22
|
-
|
|
216
|
+
return this.#qid;
|
|
23
217
|
}
|
|
24
|
-
|
|
218
|
+
|
|
219
|
+
async next() {
|
|
220
|
+
if (this.done) return this.superseded ? { done: true, superseded: true } : { done: true };
|
|
221
|
+
const qid = await this.#open();
|
|
222
|
+
const result = await this.session.send('next', { qid });
|
|
223
|
+
// The worker forgets a query that reports done — swipl-wasm has closed it —
|
|
224
|
+
// so the frame is already back and nothing here needs to ask for it.
|
|
225
|
+
if (result.done) this.#finish();
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async all(limit) {
|
|
230
|
+
if (this.done) return { solutions: [], truncated: false };
|
|
231
|
+
const qid = await this.#open();
|
|
232
|
+
this.#finish();
|
|
233
|
+
return this.session.send('all', { qid, limit });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async close({ superseded = false } = {}) {
|
|
237
|
+
if (superseded) this.superseded = true;
|
|
238
|
+
if (this.#qid === null || this.done) {
|
|
239
|
+
this.#finish();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
this.#finish();
|
|
243
|
+
await this.session.send('close', { qid: this.#qid });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#finish() {
|
|
247
|
+
this.done = true;
|
|
248
|
+
this.session.release(this);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let shared = null;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Boot (once) and return the shared session for this page.
|
|
256
|
+
* @returns {Promise<WorkerSession>}
|
|
257
|
+
*/
|
|
258
|
+
export function createSession(options = {}) {
|
|
259
|
+
if (!shared) shared = new WorkerSession(options).start();
|
|
260
|
+
return shared;
|
|
25
261
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Which copy of this you are actually running.
|
|
2
|
+
//
|
|
3
|
+
// A version number answers "which release"; it does not answer "which of the
|
|
4
|
+
// four things on this machine claiming to be 0.2.0". A published install, a
|
|
5
|
+
// checkout with the branch still on it, and an npm-linked working copy with
|
|
6
|
+
// uncommitted edits are three different programs, and a bug report against the
|
|
7
|
+
// wrong one costs an afternoon.
|
|
8
|
+
//
|
|
9
|
+
// TWO STATES, NAMED, because they are not the same claim:
|
|
10
|
+
//
|
|
11
|
+
// Built from commit ccf8e5b on 2026-08-30 14:32:51 UTC
|
|
12
|
+
// Working copy ccf8e5b (modified)
|
|
13
|
+
//
|
|
14
|
+
// The first is baked in by the release workflow just before publish — git exists
|
|
15
|
+
// there and does not exist inside an installed package. The second is read from
|
|
16
|
+
// git at run time, and says `(modified)` when the tree has edits, because a bare
|
|
17
|
+
// SHA over a dirty tree names a program that nobody has.
|
|
18
|
+
//
|
|
19
|
+
// ONE DATE, AND IT IS THE BUILD'S. The commit's own date is not printed because
|
|
20
|
+
// the hash already identifies it — anyone who wants it can ask git — and two
|
|
21
|
+
// dates where one was asked for is noise dressed as rigour. A working copy has no
|
|
22
|
+
// build time at all, which is why that state carries no date: there is nothing to
|
|
23
|
+
// date yet.
|
|
24
|
+
import { execFileSync } from 'node:child_process';
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
|
|
27
|
+
/** Where prepack leaves the facts. Inside `src`, so `files` already ships it. */
|
|
28
|
+
const BAKED = new URL('./build-info.json', import.meta.url);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The provenance line, or null when nothing is known.
|
|
32
|
+
*
|
|
33
|
+
* Pure, so both states can be tested without a filesystem or a git repository.
|
|
34
|
+
*
|
|
35
|
+
* @param {{commit: string, built?: string, modified?: boolean}|null} info
|
|
36
|
+
* @returns {string|null}
|
|
37
|
+
*/
|
|
38
|
+
export function buildLine(info) {
|
|
39
|
+
if (!info?.commit) return null;
|
|
40
|
+
if (info.built) return `Built from commit ${info.commit} on ${info.built}`;
|
|
41
|
+
return `Working copy ${info.commit}${info.modified ? ' (modified)' : ''}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What this copy is, from whichever of the two sources exists.
|
|
46
|
+
*
|
|
47
|
+
* Null rather than a guess when neither does — a tarball built before any of
|
|
48
|
+
* this existed, or a source tree with no history. A line that says "unknown"
|
|
49
|
+
* three times is worse than no line.
|
|
50
|
+
*
|
|
51
|
+
* @returns {{commit: string, built?: string, modified?: boolean}|null}
|
|
52
|
+
*/
|
|
53
|
+
export function currentBuild() {
|
|
54
|
+
try {
|
|
55
|
+
const baked = JSON.parse(readFileSync(BAKED, 'utf8'));
|
|
56
|
+
if (baked?.commit) return baked;
|
|
57
|
+
} catch {
|
|
58
|
+
// No file, or a damaged one. Either way git is the better authority here.
|
|
59
|
+
}
|
|
60
|
+
return fromGit();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Ask git, which is only there in a working copy.
|
|
65
|
+
*
|
|
66
|
+
* `execFileSync` with fixed arguments and no shell. It costs about ten
|
|
67
|
+
* milliseconds and only ever runs in development, where the alternative is a
|
|
68
|
+
* command that cannot tell you which of your own commits it is.
|
|
69
|
+
*/
|
|
70
|
+
function fromGit() {
|
|
71
|
+
const root = new URL('..', import.meta.url);
|
|
72
|
+
const git = (...args) => execFileSync('git', ['-C', root.pathname, ...args], {
|
|
73
|
+
encoding: 'utf8',
|
|
74
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
75
|
+
}).trim();
|
|
76
|
+
try {
|
|
77
|
+
return {
|
|
78
|
+
commit: git('log', '-1', '--format=%h'),
|
|
79
|
+
modified: git('status', '--porcelain') !== '',
|
|
80
|
+
};
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The facts, as the release writes them. Exported so the script that runs at
|
|
88
|
+
* publish time and the code that reads the result agree on the shape.
|
|
89
|
+
*
|
|
90
|
+
* UTC, spelled out. A build stamp is read by whoever is holding the package,
|
|
91
|
+
* wherever they are, and a bare local time from someone else's machine says less
|
|
92
|
+
* than nothing.
|
|
93
|
+
*
|
|
94
|
+
* @param {{commit: string}} head
|
|
95
|
+
* @param {Date} [now]
|
|
96
|
+
* @returns {{commit: string, built: string}}
|
|
97
|
+
*/
|
|
98
|
+
export function bakedFrom(head, now = new Date()) {
|
|
99
|
+
return {
|
|
100
|
+
commit: head.commit,
|
|
101
|
+
built: `${now.toISOString().slice(0, 19).replace('T', ' ')} UTC`,
|
|
102
|
+
};
|
|
103
|
+
}
|
package/src/clauses.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// What does this program cell define?
|
|
2
|
+
//
|
|
3
|
+
// A deliberately shallow reading of Prolog source — clause heads only, no terms,
|
|
4
|
+
// no operators, no module qualification. That is not laziness, it is the design:
|
|
5
|
+
// the answer is only ever used to IMPROVE AN ERROR MESSAGE that the engine has
|
|
6
|
+
// already produced. A head this misses costs a hint; a head it invents costs a
|
|
7
|
+
// hint that is wrong about a predicate nobody asked about. Neither can change
|
|
8
|
+
// what a query does, which is why a regex is an honest tool here and would not
|
|
9
|
+
// be anywhere near the execution path.
|
|
10
|
+
//
|
|
11
|
+
// DOM-free and engine-free, like format.js: the same reading serves the browser,
|
|
12
|
+
// the CLI runner and eventually the `:- dynamic` detection that 869eddzfp needs.
|
|
13
|
+
|
|
14
|
+
/** `name` and `'quoted name'`, the two spellings of a functor. */
|
|
15
|
+
const FUNCTOR = /^(?:'((?:[^'\\]|\\.)*)'|([a-z][a-zA-Z0-9_]*))/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The predicate indicators a cell defines, as `name/arity`.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} source Prolog text
|
|
21
|
+
* @returns {Set<string>}
|
|
22
|
+
*/
|
|
23
|
+
export function definedPredicates(source) {
|
|
24
|
+
const found = new Set();
|
|
25
|
+
for (const line of clauseStarts(source)) {
|
|
26
|
+
const indicator = headOf(line);
|
|
27
|
+
if (indicator) found.add(indicator);
|
|
28
|
+
}
|
|
29
|
+
return found;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Lines that can begin a clause.
|
|
34
|
+
*
|
|
35
|
+
* A clause head starts at column 0 — the same rule the notebook format relies on
|
|
36
|
+
* for cells (format §1), and the reason both can be line scanners. Continuation
|
|
37
|
+
* lines of a clause body are indented by every convention in use, including
|
|
38
|
+
* SWI's own portray_clause.
|
|
39
|
+
*/
|
|
40
|
+
function clauseStarts(source) {
|
|
41
|
+
const lines = [];
|
|
42
|
+
let inBlockComment = false;
|
|
43
|
+
for (const raw of source.split('\n')) {
|
|
44
|
+
let line = raw;
|
|
45
|
+
if (inBlockComment) {
|
|
46
|
+
const end = line.indexOf('*/');
|
|
47
|
+
if (end === -1) continue;
|
|
48
|
+
line = line.slice(end + 2);
|
|
49
|
+
inBlockComment = false;
|
|
50
|
+
}
|
|
51
|
+
// A block comment opening on this line takes the rest of it with it.
|
|
52
|
+
const open = line.indexOf('/*');
|
|
53
|
+
if (open !== -1 && line.indexOf('*/', open) === -1) {
|
|
54
|
+
inBlockComment = true;
|
|
55
|
+
line = line.slice(0, open);
|
|
56
|
+
}
|
|
57
|
+
if (/^\s/.test(line) || line.trim() === '') continue;
|
|
58
|
+
if (line.startsWith('%')) continue;
|
|
59
|
+
// A directive is an instruction to the loader, not a definition. `:- dynamic
|
|
60
|
+
// counter/1.` declares one, but the clauses are still what define it.
|
|
61
|
+
if (line.startsWith(':-') || line.startsWith('?-')) continue;
|
|
62
|
+
lines.push(line);
|
|
63
|
+
}
|
|
64
|
+
return lines;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `foo(a, b) :- …` → `foo/2`. Null if the line does not start with a functor.
|
|
69
|
+
*/
|
|
70
|
+
function headOf(line) {
|
|
71
|
+
const m = FUNCTOR.exec(line);
|
|
72
|
+
if (!m) return null;
|
|
73
|
+
const name = m[1] !== undefined ? m[1].replace(/\\(.)/g, '$1') : m[2];
|
|
74
|
+
const rest = line.slice(m[0].length);
|
|
75
|
+
|
|
76
|
+
let arity = 0;
|
|
77
|
+
let after = rest;
|
|
78
|
+
if (rest.startsWith('(')) {
|
|
79
|
+
const args = countArguments(rest);
|
|
80
|
+
if (args === null) return null;
|
|
81
|
+
arity = args.count;
|
|
82
|
+
after = rest.slice(args.end + 1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A DCG rule defines a predicate with two extra arguments — the difference list
|
|
86
|
+
// SWI threads through it. `greeting --> [hello]` is greeting/2, and a reader
|
|
87
|
+
// told otherwise would go looking for greeting/0.
|
|
88
|
+
if (/^\s*-->/.test(after)) arity += 2;
|
|
89
|
+
|
|
90
|
+
return `${name}/${arity}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Count top-level arguments in `(…)`, respecting nesting and quotes.
|
|
95
|
+
*
|
|
96
|
+
* @returns {{count: number, end: number}|null} null if the parenthesis never closes
|
|
97
|
+
*/
|
|
98
|
+
function countArguments(text) {
|
|
99
|
+
let depth = 0;
|
|
100
|
+
let count = 1;
|
|
101
|
+
let quote = null;
|
|
102
|
+
for (let i = 0; i < text.length; i++) {
|
|
103
|
+
const c = text[i];
|
|
104
|
+
if (quote) {
|
|
105
|
+
if (c === '\\') i++;
|
|
106
|
+
else if (c === quote) quote = null;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (c === "'" || c === '"' || c === '`') { quote = c; continue; }
|
|
110
|
+
if (c === '(' || c === '[' || c === '{') { depth++; continue; }
|
|
111
|
+
if (c === ')' || c === ']' || c === '}') {
|
|
112
|
+
depth--;
|
|
113
|
+
if (depth === 0) return { count, end: i };
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
// `foo(a, b)` has two arguments; `foo()` is not valid Prolog, so a comma at
|
|
117
|
+
// depth 1 is always an argument separator.
|
|
118
|
+
if (c === ',' && depth === 1) count++;
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The predicates a cell declares `:- dynamic`, as `name/arity`.
|
|
125
|
+
*
|
|
126
|
+
* WHY THIS IS WORTH KNOWING WITHOUT RUNNING ANYTHING (format §8): a cell that
|
|
127
|
+
* declares one is **stateful**. Its assert/retract state lives in no file, so
|
|
128
|
+
* re-consulting the cell does not undo it and neither does resetting the cell —
|
|
129
|
+
* only throwing the engine away does. That is the one place where the otherwise
|
|
130
|
+
* reliable promise "the clause store self-heals" stops being true, and a reader
|
|
131
|
+
* who does not know it will conclude something false about Prolog rather than
|
|
132
|
+
* about us.
|
|
133
|
+
*
|
|
134
|
+
* Read statically so the page can say so BEFORE the reader has asserted anything,
|
|
135
|
+
* rather than after they are already confused. Shallow like the rest of this file
|
|
136
|
+
* and for the same reason: at worst it fails to warn, and it can never change
|
|
137
|
+
* what a goal does.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} source Prolog text
|
|
140
|
+
* @returns {Set<string>} predicate indicators
|
|
141
|
+
*/
|
|
142
|
+
export function declaredDynamic(source) {
|
|
143
|
+
const found = new Set();
|
|
144
|
+
for (const body of directives(source)) {
|
|
145
|
+
// `:- dynamic foo/1.` and `:- dynamic(foo/1).` are the same declaration.
|
|
146
|
+
const m = /^dynamic\b\s*(.*)$/s.exec(body);
|
|
147
|
+
if (!m) continue;
|
|
148
|
+
let list = m[1].trim();
|
|
149
|
+
if (list.startsWith('(') && list.endsWith(')')) list = list.slice(1, -1);
|
|
150
|
+
for (const item of splitTopLevel(list)) {
|
|
151
|
+
const indicator = /^\s*(?:'((?:[^'\\]|\\.)*)'|([a-z][a-zA-Z0-9_]*))\s*\/\s*(\d+)\s*$/.exec(item);
|
|
152
|
+
if (indicator) {
|
|
153
|
+
const name = indicator[1] !== undefined ? indicator[1].replace(/\\(.)/g, '$1') : indicator[2];
|
|
154
|
+
found.add(`${name}/${indicator[3]}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return found;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The body of every `:- …` directive, with comments stripped.
|
|
163
|
+
*
|
|
164
|
+
* Directives wrap across lines far more often than clauses do — a chapter that
|
|
165
|
+
* declares six dynamic predicates will list them one per line — so this cannot be
|
|
166
|
+
* the line scanner the rest of the file uses. It reads to the terminating full
|
|
167
|
+
* stop instead.
|
|
168
|
+
*/
|
|
169
|
+
function directives(source) {
|
|
170
|
+
const bodies = [];
|
|
171
|
+
const text = stripComments(source);
|
|
172
|
+
const pattern = /(^|\n)\s*:-\s*/g;
|
|
173
|
+
let m;
|
|
174
|
+
while ((m = pattern.exec(text)) !== null) {
|
|
175
|
+
const start = m.index + m[0].length;
|
|
176
|
+
const end = endOfTerm(text, start);
|
|
177
|
+
if (end === -1) break;
|
|
178
|
+
bodies.push(text.slice(start, end).trim());
|
|
179
|
+
pattern.lastIndex = end;
|
|
180
|
+
}
|
|
181
|
+
return bodies;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Index of the `.` that ends a term, skipping quotes. -1 if it never ends. */
|
|
185
|
+
function endOfTerm(text, from) {
|
|
186
|
+
let quote = null;
|
|
187
|
+
for (let i = from; i < text.length; i++) {
|
|
188
|
+
const c = text[i];
|
|
189
|
+
if (quote) {
|
|
190
|
+
if (c === '\\') i++;
|
|
191
|
+
else if (c === quote) quote = null;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (c === "'" || c === '"' || c === '`') { quote = c; continue; }
|
|
195
|
+
// A full stop ends a term only when whitespace or the end of input follows,
|
|
196
|
+
// which is exactly SWI's own rule — otherwise `1.5` would end one.
|
|
197
|
+
if (c === '.' && (i + 1 === text.length || /\s/.test(text[i + 1]))) return i;
|
|
198
|
+
}
|
|
199
|
+
return -1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function stripComments(source) {
|
|
203
|
+
return source
|
|
204
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
205
|
+
.split('\n')
|
|
206
|
+
.map((line) => line.replace(/(^|\s)%.*$/, '$1'))
|
|
207
|
+
.join('\n');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Split on commas that are not inside brackets or quotes. */
|
|
211
|
+
function splitTopLevel(text) {
|
|
212
|
+
const parts = [];
|
|
213
|
+
let depth = 0;
|
|
214
|
+
let quote = null;
|
|
215
|
+
let start = 0;
|
|
216
|
+
for (let i = 0; i < text.length; i++) {
|
|
217
|
+
const c = text[i];
|
|
218
|
+
if (quote) {
|
|
219
|
+
if (c === '\\') i++;
|
|
220
|
+
else if (c === quote) quote = null;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (c === "'" || c === '"' || c === '`') { quote = c; continue; }
|
|
224
|
+
if (c === '(' || c === '[' || c === '{') depth++;
|
|
225
|
+
else if (c === ')' || c === ']' || c === '}') depth--;
|
|
226
|
+
else if (c === ',' && depth === 0) { parts.push(text.slice(start, i)); start = i + 1; }
|
|
227
|
+
}
|
|
228
|
+
parts.push(text.slice(start));
|
|
229
|
+
return parts;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The predicate indicator an "Unknown procedure" error is complaining about. */
|
|
233
|
+
export function unknownProcedure(message) {
|
|
234
|
+
const m = /Unknown procedure:\s*(?:[a-z][a-zA-Z0-9_]*:)?((?:'[^']*'|[^\s/]+)\/\d+)/.exec(message ?? '');
|
|
235
|
+
return m ? m[1] : null;
|
|
236
|
+
}
|