prolog-notebook 0.1.2 → 0.3.0
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 +196 -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 +94 -0
- package/src/build-info.json +5 -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/src/session.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// The async session interface, and the in-process implementation of it.
|
|
2
|
+
//
|
|
3
|
+
// Everything above the engine talks to this, never to PrologSession directly. The
|
|
4
|
+
// methods return promises whether or not the work is actually asynchronous,
|
|
5
|
+
// because one of the two implementations runs the engine in a worker and the
|
|
6
|
+
// other does not, and a caller that has to know which is a caller that will one
|
|
7
|
+
// day be ported wrongly.
|
|
8
|
+
//
|
|
9
|
+
// await session.consult(text, 'cell-p-family')
|
|
10
|
+
// const query = session.query('is_son(X)')
|
|
11
|
+
// let r; while (!(r = await query.next()).done) …
|
|
12
|
+
// await session.abort()
|
|
13
|
+
//
|
|
14
|
+
// WHY ASYNC AT ALL: a Prolog query is synchronous WASM, so a non-terminating goal
|
|
15
|
+
// blocks whatever thread it runs on — no timer fires, no button responds, nothing
|
|
16
|
+
// repaints. Non-termination is chapter material in a Prolog book, so the engine
|
|
17
|
+
// has to live somewhere that can be terminated. See docs/modes.md and 869ejgkfq.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What every session replays after an abort.
|
|
21
|
+
*
|
|
22
|
+
* Abort is "throw the engine away and build a new one", which is only affordable
|
|
23
|
+
* because of a decision already made: one cell, one virtual file (869eddzfp), so
|
|
24
|
+
* the clause store is rebuilt from the cells in about 3.5 ms each. Nothing
|
|
25
|
+
* cooperative has to reach inside a running Prolog goal.
|
|
26
|
+
*
|
|
27
|
+
* Insertion order is document order, which is also execution order.
|
|
28
|
+
*/
|
|
29
|
+
export class ConsultLog {
|
|
30
|
+
constructor() {
|
|
31
|
+
this.entries = new Map();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
record(name, text) {
|
|
35
|
+
// Re-consulting a cell replaces it, exactly as SWI does, so the log holds one
|
|
36
|
+
// entry per cell rather than a history of edits.
|
|
37
|
+
this.entries.set(name, text);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
forget(name) {
|
|
41
|
+
this.entries.delete(name);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Is this cell already loaded, at exactly this text?
|
|
46
|
+
*
|
|
47
|
+
* What makes "Run consults the cells above it" cheap enough to do on every
|
|
48
|
+
* click: the second Run of a chapter consults nothing, because nothing has
|
|
49
|
+
* changed. An edited cell answers false for itself and only for itself.
|
|
50
|
+
*/
|
|
51
|
+
isCurrent(name, text) {
|
|
52
|
+
return this.entries.has(name) && this.entries.get(name) === text;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
clear() {
|
|
56
|
+
this.entries.clear();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
*[Symbol.iterator]() {
|
|
60
|
+
for (const [name, text] of this.entries) yield { name, text };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Take one cell's clauses back out of a running engine.
|
|
66
|
+
*
|
|
67
|
+
* Shared by both sessions because it is not really a session operation at all —
|
|
68
|
+
* it is one consult and one log edit, and two copies of that would be two things
|
|
69
|
+
* to keep in step.
|
|
70
|
+
*
|
|
71
|
+
* HOW IT WORKS, because it looks like a trick and is not: one cell is one virtual
|
|
72
|
+
* file (869eddzfp), and consulting a file replaces the clauses that came from it.
|
|
73
|
+
* Consulting nothing therefore leaves nothing. This is SWI's own semantics, not
|
|
74
|
+
* bookkeeping of ours, so a predicate whose only clauses lived in this cell goes
|
|
75
|
+
* back to being genuinely unknown — and a query that calls it says so, exactly as
|
|
76
|
+
* a real toplevel would.
|
|
77
|
+
*
|
|
78
|
+
* There is deliberately NO dependency handling here. A cell that mentioned this
|
|
79
|
+
* one is untouched, because Prolog has no load-time name binding: `q(X) :- p(X)`
|
|
80
|
+
* merely mentions p/1 and looks it up when called. The consequence surfaces as an
|
|
81
|
+
* ordinary Prolog error at call time, which is the truth rather than a cascade we
|
|
82
|
+
* would have to invent.
|
|
83
|
+
*
|
|
84
|
+
* @param {{consult: Function, log: ConsultLog}} session
|
|
85
|
+
* @param {string} name
|
|
86
|
+
*/
|
|
87
|
+
export async function unconsult(session, name) {
|
|
88
|
+
if (!session.log.entries.has(name)) return { ok: true, unloaded: false };
|
|
89
|
+
const result = await session.consult('', name);
|
|
90
|
+
// Forgotten rather than recorded as empty, so a restart does not spend a
|
|
91
|
+
// consult loading nothing.
|
|
92
|
+
session.log.forget(name);
|
|
93
|
+
return { ...result, unloaded: result.ok };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let anonymousCells = 0;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A consult with no cell name still needs a stable one, or the replay log cannot
|
|
100
|
+
* key it and an abort would lose it.
|
|
101
|
+
*/
|
|
102
|
+
export function defaultCellName() {
|
|
103
|
+
return `cell-anon-${++anonymousCells}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A session that runs the engine on the caller's own thread.
|
|
108
|
+
*
|
|
109
|
+
* Correct, simple, and NOT protected: a non-terminating goal hangs the process,
|
|
110
|
+
* because there is no second thread to notice. That is acceptable for Node — the
|
|
111
|
+
* CLI is not an interactive page and CI has its own timeouts — and it is stated
|
|
112
|
+
* here rather than implied, so nobody discovers it in a browser.
|
|
113
|
+
*/
|
|
114
|
+
export class InProcessSession {
|
|
115
|
+
/**
|
|
116
|
+
* @param {import('./engine.js').PrologSession} engine
|
|
117
|
+
* @param {() => Promise<import('./engine.js').PrologSession>} rebuild
|
|
118
|
+
*/
|
|
119
|
+
constructor(engine, rebuild) {
|
|
120
|
+
this.engine = engine;
|
|
121
|
+
this.rebuild = rebuild;
|
|
122
|
+
this.log = new ConsultLog();
|
|
123
|
+
// The one query allowed to be open. See supersede() below.
|
|
124
|
+
this.open = null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* ONE OPEN SEQUENCE PER SESSION, and the reason is not tidiness.
|
|
129
|
+
*
|
|
130
|
+
* SWI keeps open queries on a stack and swipl-wasm enforces it: stepping or
|
|
131
|
+
* closing anything but the innermost throws "Attempt to access not innermost
|
|
132
|
+
* query". A page cannot promise the order — the order is whatever the reader
|
|
133
|
+
* clicks — so the constraint is met by construction instead: there is never
|
|
134
|
+
* more than one open query, which means the one being closed is always the
|
|
135
|
+
* innermost, which means the close is always legal (869epzqpc).
|
|
136
|
+
*
|
|
137
|
+
* The caller is told, through the query's own `onSuperseded`, because a
|
|
138
|
+
* sequence that ends without saying so is exactly the silence this replaces.
|
|
139
|
+
*/
|
|
140
|
+
supersede() {
|
|
141
|
+
const previous = this.open;
|
|
142
|
+
this.open = null;
|
|
143
|
+
if (!previous) return;
|
|
144
|
+
// Straight to the engine's query: closing is synchronous in this process, and
|
|
145
|
+
// the frame must be gone before the next PL_open_query, not merely scheduled.
|
|
146
|
+
previous.query.close({ superseded: true });
|
|
147
|
+
previous.onSuperseded?.();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async consult(text, name = defaultCellName()) {
|
|
151
|
+
const result = this.engine.consult(text, name);
|
|
152
|
+
if (result.ok) this.log.record(name, text);
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
query(goal) {
|
|
157
|
+
// The frame opens here, in the engine's constructor, so the previous one has
|
|
158
|
+
// to be closed BEFORE this line rather than after: once a second query is
|
|
159
|
+
// open, the first is no longer innermost and can never be closed at all.
|
|
160
|
+
this.supersede();
|
|
161
|
+
const query = new InProcessQuery(this.engine.query(goal), this);
|
|
162
|
+
this.open = query;
|
|
163
|
+
return query;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async unconsult(name) {
|
|
167
|
+
return unconsult(this, name);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Discard the engine and replay the consults into a fresh one.
|
|
172
|
+
*
|
|
173
|
+
* In-process this can only happen BETWEEN operations — a goal that is already
|
|
174
|
+
* looping has the thread and will not give it back. Use the worker-backed
|
|
175
|
+
* session where that matters.
|
|
176
|
+
*/
|
|
177
|
+
async restart() {
|
|
178
|
+
// Every frame died with the engine. Saying so here rather than leaving a
|
|
179
|
+
// handle that points into a heap that no longer exists.
|
|
180
|
+
this.open = null;
|
|
181
|
+
this.engine = await this.rebuild();
|
|
182
|
+
for (const { name, text } of this.log) this.engine.consult(text, name);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async abort() {
|
|
186
|
+
return this.restart();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async close() {}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// No formatSolution() on a session, deliberately. The engine-backed one renders
|
|
193
|
+
// through SWI itself and the worker-backed one could not — it would have to fall
|
|
194
|
+
// back to the engine-free spelling, so the same call would quietly mean two
|
|
195
|
+
// different things depending on where it ran. Use `query.next().text`, which SWI
|
|
196
|
+
// renders in both, or the exported formatSolution() when there is no engine at all.
|
|
197
|
+
|
|
198
|
+
class InProcessQuery {
|
|
199
|
+
constructor(query, session) {
|
|
200
|
+
this.query = query;
|
|
201
|
+
this.session = session;
|
|
202
|
+
/** Set by the caller to hear that its sequence was closed for another one. */
|
|
203
|
+
this.onSuperseded = null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async next() {
|
|
207
|
+
const r = this.query.next();
|
|
208
|
+
if (r.done) this.#release();
|
|
209
|
+
return r;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async all(limit) {
|
|
213
|
+
const r = this.query.all(limit);
|
|
214
|
+
this.#release();
|
|
215
|
+
return r;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async close(options) {
|
|
219
|
+
this.query.close(options);
|
|
220
|
+
this.#release();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The frame is gone, so this query is no longer the one holding the session's. */
|
|
224
|
+
#release() {
|
|
225
|
+
if (this.session?.open === this) this.session.open = null;
|
|
226
|
+
}
|
|
227
|
+
}
|
package/src/version.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Who this is, in one place, for everything that has to say so.
|
|
2
|
+
//
|
|
3
|
+
// The command reads package.json at run time; a page cannot — there is no
|
|
4
|
+
// filesystem behind a `<script type="module">` and no build step to inline
|
|
5
|
+
// anything. So the facts live here, as constants both can import, and tests
|
|
6
|
+
// assert that they still agree with package.json and with LICENSE. Two files to
|
|
7
|
+
// touch at release, and a suite that fails loudly when only one of them is.
|
|
8
|
+
|
|
9
|
+
/** The name a person would say. `prolog-notebook` is what npm installs. */
|
|
10
|
+
export const NAME = 'Prolog Notebook';
|
|
11
|
+
|
|
12
|
+
/** Must equal package.json's `version` — test/run.test.mjs enforces it. */
|
|
13
|
+
export const VERSION = '0.3.0';
|
|
14
|
+
|
|
15
|
+
/** The two facts a licence notice is actually made of. */
|
|
16
|
+
export const YEAR = '2026';
|
|
17
|
+
export const HOLDER = 'Johnny Jarecsni';
|
|
18
|
+
|
|
19
|
+
/** Must agree with LICENSE on the year and the holder. Same enforcement. */
|
|
20
|
+
export const COPYRIGHT = `Copyright (C) ${YEAR} ${HOLDER}`;
|
|
21
|
+
|
|
22
|
+
export const LICENSE = 'MIT';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The line a command prints and a page shows in its panel, so the two cannot
|
|
26
|
+
* describe the same release differently.
|
|
27
|
+
*
|
|
28
|
+
* @returns {string} e.g. "Prolog Notebook v0.2.0 - Copyright (C) 2026 … , MIT License."
|
|
29
|
+
*/
|
|
30
|
+
export function banner() {
|
|
31
|
+
return `${NAME} v${VERSION} - ${COPYRIGHT}, ${LICENSE} License.`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The same facts for a page, which has a card to fit them in rather than a
|
|
36
|
+
* terminal to fill.
|
|
37
|
+
*
|
|
38
|
+
* TWO SHORT LINES BY CONSTRUCTION, not by shrinking the type: what is running,
|
|
39
|
+
* then who owns it. The engine's version joins the first line once it is known,
|
|
40
|
+
* because that line is the identity of the thing doing the work — and `©` rather
|
|
41
|
+
* than `Copyright (C)` because that is how a page writes it, while the words in
|
|
42
|
+
* both come from the same constants.
|
|
43
|
+
*
|
|
44
|
+
* @returns {{running: string, legal: string}}
|
|
45
|
+
*/
|
|
46
|
+
export function colophon() {
|
|
47
|
+
return {
|
|
48
|
+
running: `${NAME} v${VERSION}`,
|
|
49
|
+
legal: `© ${YEAR} ${HOLDER} · ${LICENSE} License`,
|
|
50
|
+
};
|
|
51
|
+
}
|
package/src/worker.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The engine's home. A CLASSIC worker, deliberately.
|
|
2
|
+
//
|
|
3
|
+
// swipl-wasm ships only UMD/global scripts — `var SWIPL = …` at top level, with no
|
|
4
|
+
// ESM export — so a module worker's `import()` would hand back an empty namespace.
|
|
5
|
+
// importScripts is the only thing that puts SWIPL where we can reach it. Our own
|
|
6
|
+
// engine is an ES module, and dynamic import() works fine inside a classic worker,
|
|
7
|
+
// so nothing has to be bundled or duplicated to get both here.
|
|
8
|
+
//
|
|
9
|
+
// Everything in this file is protocol plumbing. The Prolog lives in engine.js and
|
|
10
|
+
// is not aware it is in a worker.
|
|
11
|
+
|
|
12
|
+
/* global importScripts */
|
|
13
|
+
|
|
14
|
+
let session = null;
|
|
15
|
+
const queries = new Map();
|
|
16
|
+
let nextQueryId = 1;
|
|
17
|
+
|
|
18
|
+
self.onmessage = async ({ data }) => {
|
|
19
|
+
const { id, op } = data;
|
|
20
|
+
try {
|
|
21
|
+
self.postMessage({ id, ok: true, value: await handle(op, data) });
|
|
22
|
+
} catch (error) {
|
|
23
|
+
self.postMessage({ id, ok: false, error: error?.message ?? String(error) });
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
async function handle(op, args) {
|
|
28
|
+
switch (op) {
|
|
29
|
+
case 'boot': {
|
|
30
|
+
importScripts(args.swiplUrl);
|
|
31
|
+
const { PrologSession } = await import(args.engineUrl);
|
|
32
|
+
if (typeof self.SWIPL !== 'function') {
|
|
33
|
+
throw new Error(`swipl-wasm did not define SWIPL after loading ${args.swiplUrl}`);
|
|
34
|
+
}
|
|
35
|
+
session = await PrologSession.create(self.SWIPL, args.options ?? {});
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
case 'consult':
|
|
40
|
+
return session.consult(args.text, args.name);
|
|
41
|
+
|
|
42
|
+
case 'open': {
|
|
43
|
+
const qid = nextQueryId++;
|
|
44
|
+
queries.set(qid, session.query(args.goal));
|
|
45
|
+
return qid;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
case 'next': {
|
|
49
|
+
const query = queries.get(args.qid);
|
|
50
|
+
if (!query) throw new Error(`no open query ${args.qid}`);
|
|
51
|
+
const result = query.next();
|
|
52
|
+
if (result.done) queries.delete(args.qid);
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
case 'all': {
|
|
57
|
+
const query = queries.get(args.qid);
|
|
58
|
+
if (!query) throw new Error(`no open query ${args.qid}`);
|
|
59
|
+
queries.delete(args.qid);
|
|
60
|
+
return query.all(args.limit);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
case 'close': {
|
|
64
|
+
// CLOSING MUST REACH THE ENGINE. Forgetting the id here — which is all this
|
|
65
|
+
// did until 869epzqpc — leaves the query open inside SWI for the life of
|
|
66
|
+
// the session, and every later query then nests inside a frame nobody can
|
|
67
|
+
// ever step or release.
|
|
68
|
+
const query = queries.get(args.qid);
|
|
69
|
+
queries.delete(args.qid);
|
|
70
|
+
query?.close();
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
default:
|
|
75
|
+
throw new Error(`unknown worker op "${op}"`);
|
|
76
|
+
}
|
|
77
|
+
}
|