prolog-notebook 0.1.1 → 0.1.2
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 +37 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/engine.js +150 -12
- package/src/notebook.css +3 -0
- package/src/notebook.js +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.2] — 2026-08-04
|
|
4
|
+
|
|
5
|
+
Two bugs, both of which made the library quietly say something untrue. Found by
|
|
6
|
+
running the published entry point, not by reading the code.
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **Compound terms lost their arguments.** `foo(1,2)` rendered as `foo()`,
|
|
11
|
+
`a-b` as `-()`, `f(g(h))` as `f()`. The arguments of a compound arrive under
|
|
12
|
+
the key *named by the functor* and wrapped in one extra array, not under
|
|
13
|
+
`args` as the formatter assumed. Atoms, numbers and lists were unaffected,
|
|
14
|
+
which is why the `once/1` example never showed it — that chapter only ever
|
|
15
|
+
binds variables to atoms.
|
|
16
|
+
- **A syntax error reported a successful consult.** SWI prints the offending
|
|
17
|
+
clause, skips it and carries on, so `consult/1` still succeeded and the cell
|
|
18
|
+
said `✓ consulted` while the predicate was not there.
|
|
19
|
+
- **A cell could silently destroy another cell's clauses.** Two cells defining
|
|
20
|
+
the same predicate make SWI print "Redefined static procedure" and keep only
|
|
21
|
+
the later one. That warning went to the console and nothing reached the page.
|
|
22
|
+
- `consult` no longer produces paths like `/chapter.pl.pl` when the cell name
|
|
23
|
+
already ends in `.pl`.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- `PrologSession#formatTerm` and `#formatSolution` render through SWI itself, so
|
|
28
|
+
operators, quoting and every other rule of the writer come out right —
|
|
29
|
+
`X = a-b`, not `X = -(a, b)`. `query.next()` now carries a `text` field with
|
|
30
|
+
the solution already rendered this way; prefer it over `formatSolution`.
|
|
31
|
+
- `consult` returns `messages: [{kind, text}]` — SWI's warnings and errors for
|
|
32
|
+
that cell, captured through `message_hook/3`.
|
|
33
|
+
- `argumentsOf`, `textOf` and `toEngineTerm` are exported for anything that
|
|
34
|
+
needs to walk a term.
|
|
35
|
+
- Eleven more tests, including the reconsult behaviour the notebook renderer
|
|
36
|
+
will depend on: re-consulting one cell replaces exactly that cell's clauses,
|
|
37
|
+
leaves dependent cells working, and leaves no ghost behind when a predicate is
|
|
38
|
+
renamed.
|
|
39
|
+
|
|
3
40
|
## [0.1.1] — 2026-08-02
|
|
4
41
|
|
|
5
42
|
No functional change. Published from CI via npm trusted publishing (OIDC) to
|
package/README.md
CHANGED
package/package.json
CHANGED
package/src/engine.js
CHANGED
|
@@ -6,6 +6,21 @@
|
|
|
6
6
|
|
|
7
7
|
let fileSerial = 0;
|
|
8
8
|
|
|
9
|
+
// SWI reports "Redefined static procedure" and friends by printing to user_error
|
|
10
|
+
// and then carrying on, so a consult that quietly destroyed another cell's
|
|
11
|
+
// clauses still succeeds. message_hook/3 is the documented way to intercept
|
|
12
|
+
// those; failing at the end lets the normal printing happen as well.
|
|
13
|
+
const MESSAGE_HOOK = `
|
|
14
|
+
:- dynamic '$nb_message'/2.
|
|
15
|
+
user:message_hook(_Term, Kind, Lines) :-
|
|
16
|
+
memberchk(Kind, [warning, error]),
|
|
17
|
+
catch(with_output_to(string(S),
|
|
18
|
+
print_message_lines(current_output, '', Lines)),
|
|
19
|
+
_, S = ''),
|
|
20
|
+
assertz('$nb_message'(Kind, S)),
|
|
21
|
+
fail.
|
|
22
|
+
`;
|
|
23
|
+
|
|
9
24
|
export class PrologSession {
|
|
10
25
|
/**
|
|
11
26
|
* @param {Function} swiplFactory the SWIPL factory from swipl-wasm
|
|
@@ -13,7 +28,12 @@ export class PrologSession {
|
|
|
13
28
|
*/
|
|
14
29
|
static async create(swiplFactory, options = {}) {
|
|
15
30
|
const module = await swiplFactory({ arguments: ['-q'], ...options });
|
|
16
|
-
|
|
31
|
+
const session = new PrologSession(module);
|
|
32
|
+
// No `$` in the path: SWI expands $var in file names like a shell does, so
|
|
33
|
+
// /$nb-hook.pl resolves to nothing and the consult fails silently.
|
|
34
|
+
module.FS.writeFile('/nb-hook.pl', MESSAGE_HOOK);
|
|
35
|
+
module.prolog.query("user:consult('/nb-hook.pl')").once();
|
|
36
|
+
return session;
|
|
17
37
|
}
|
|
18
38
|
|
|
19
39
|
constructor(module) {
|
|
@@ -22,18 +42,31 @@ export class PrologSession {
|
|
|
22
42
|
|
|
23
43
|
/**
|
|
24
44
|
* Load a clause base into the `user` module.
|
|
45
|
+
*
|
|
46
|
+
* Each cell should pass its own stable `name`: SWI attributes clauses to the
|
|
47
|
+
* file they came from, so re-consulting the same name replaces exactly that
|
|
48
|
+
* cell's clauses and leaves every other cell alone.
|
|
49
|
+
*
|
|
25
50
|
* @param {string} text Prolog source
|
|
26
|
-
* @
|
|
51
|
+
* @param {string} [name] virtual file name; identifies the cell
|
|
52
|
+
* @returns {{ok: boolean, error?: string, messages: {kind: string, text: string}[]}}
|
|
27
53
|
*/
|
|
28
54
|
consult(text, name = `cell${fileSerial++}`) {
|
|
29
|
-
const path = `/${name}.pl`;
|
|
55
|
+
const path = `/${name.replace(/\.pl$/, '')}.pl`;
|
|
30
56
|
try {
|
|
31
57
|
this.module.FS.writeFile(path, text);
|
|
58
|
+
this.#drainMessages();
|
|
32
59
|
const r = this.module.prolog.query(`user:consult('${path}')`).once();
|
|
33
|
-
|
|
34
|
-
return { ok:
|
|
60
|
+
const messages = this.#drainMessages();
|
|
61
|
+
if (r && r.error) return { ok: false, error: r.message, messages };
|
|
62
|
+
// A clause SWI could not read is reported and then skipped, so consult
|
|
63
|
+
// itself still succeeds. Reporting that as "✓ consulted" would leave the
|
|
64
|
+
// reader with a cell that looks loaded and a predicate that is not there.
|
|
65
|
+
const failed = messages.find((m) => m.kind === 'error');
|
|
66
|
+
if (failed) return { ok: false, error: failed.text, messages };
|
|
67
|
+
return { ok: true, messages };
|
|
35
68
|
} catch (e) {
|
|
36
|
-
return { ok: false, error: e.message };
|
|
69
|
+
return { ok: false, error: e.message, messages: [] };
|
|
37
70
|
}
|
|
38
71
|
}
|
|
39
72
|
|
|
@@ -47,20 +80,68 @@ export class PrologSession {
|
|
|
47
80
|
// Cells consult into `user`, but prolog.query/1 runs with `system` as the
|
|
48
81
|
// context module, so an unqualified goal resolves against the wrong one.
|
|
49
82
|
const cleaned = goal.trim().replace(/\.$/, '');
|
|
50
|
-
return new PrologQuery(this.module.prolog.query(`user:( ${cleaned} )`));
|
|
83
|
+
return new PrologQuery(this.module.prolog.query(`user:( ${cleaned} )`), this);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Render a term the way SWI's own top level would, by asking SWI. Worth the
|
|
88
|
+
* round trip: it gets operators (a-b, not -(a,b)), atom quoting and every
|
|
89
|
+
* other rule of the writer right, none of which we want to reimplement.
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
formatTerm(term) {
|
|
93
|
+
try {
|
|
94
|
+
const r = this.module.prolog
|
|
95
|
+
.query('term_string(T, S)', { T: toEngineTerm(term) })
|
|
96
|
+
.once();
|
|
97
|
+
const s = r && r.S;
|
|
98
|
+
if (typeof s === 'string') return s;
|
|
99
|
+
if (s && typeof s.v === 'string') return s.v;
|
|
100
|
+
} catch {
|
|
101
|
+
// fall through to the DOM-free renderer below
|
|
102
|
+
}
|
|
103
|
+
return formatTerm(term);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Render a full solution the way a top level would. */
|
|
107
|
+
formatSolution(solution) {
|
|
108
|
+
const pairs = Object.entries(solution);
|
|
109
|
+
if (!pairs.length) return 'true';
|
|
110
|
+
return pairs.map(([k, v]) => `${k} = ${this.formatTerm(v)}`).join(', ');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#drainMessages() {
|
|
114
|
+
try {
|
|
115
|
+
const r = this.module.prolog
|
|
116
|
+
.query("user:( findall(m(K,T), '$nb_message'(K,T), L ), retractall('$nb_message'(_,_)) )")
|
|
117
|
+
.once();
|
|
118
|
+
if (!r || !Array.isArray(r.L)) return [];
|
|
119
|
+
return r.L.map((m) => {
|
|
120
|
+
const [kind, text] = argumentsOf(m);
|
|
121
|
+
return { kind: String(kind), text: textOf(text).trim() };
|
|
122
|
+
});
|
|
123
|
+
} catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
51
126
|
}
|
|
52
127
|
}
|
|
53
128
|
|
|
54
129
|
export class PrologQuery {
|
|
55
|
-
constructor(handle) {
|
|
130
|
+
constructor(handle, session) {
|
|
56
131
|
this.handle = handle;
|
|
132
|
+
this.session = session;
|
|
57
133
|
this.exhausted = false;
|
|
58
134
|
this.count = 0;
|
|
59
135
|
}
|
|
60
136
|
|
|
61
137
|
/**
|
|
62
138
|
* Pull the next solution.
|
|
63
|
-
*
|
|
139
|
+
*
|
|
140
|
+
* `text` is the solution rendered by SWI itself; prefer it over calling
|
|
141
|
+
* formatSolution, which has no engine to ask and so cannot know about
|
|
142
|
+
* operators or quoting.
|
|
143
|
+
*
|
|
144
|
+
* @returns {{done: boolean, solution?: object, text?: string, error?: string}}
|
|
64
145
|
*/
|
|
65
146
|
next() {
|
|
66
147
|
if (this.exhausted) return { done: true };
|
|
@@ -83,6 +164,9 @@ export class PrologQuery {
|
|
|
83
164
|
if (r.value) {
|
|
84
165
|
this.count += 1;
|
|
85
166
|
out.solution = bindingsOf(r.value);
|
|
167
|
+
// Safe while the outer query is still open — verified by stepping a
|
|
168
|
+
// three-solution query with a term_string call between every step.
|
|
169
|
+
if (this.session) out.text = this.session.formatSolution(out.solution);
|
|
86
170
|
}
|
|
87
171
|
if (r.done) this.exhausted = true;
|
|
88
172
|
return out;
|
|
@@ -121,13 +205,67 @@ export function formatSolution(solution) {
|
|
|
121
205
|
return pairs.map(([k, v]) => `${k} = ${formatTerm(v)}`).join(', ');
|
|
122
206
|
}
|
|
123
207
|
|
|
208
|
+
// swipl-wasm tags every non-atomic value it hands back:
|
|
209
|
+
// compound { $t: 't', functor: 'f', f: [[arg, ...]] }
|
|
210
|
+
// string { $t: 's', v: 'text' }
|
|
211
|
+
// variable { $t: 'v', v: '_123' }
|
|
212
|
+
// rational { $t: 'r', n, d }
|
|
213
|
+
// list { $t: 'l', v: [...], tail }
|
|
214
|
+
// Note the compound's arguments live under the key NAMED BY THE FUNCTOR, and
|
|
215
|
+
// arrive wrapped in one extra array — swipl-wasm builds them with
|
|
216
|
+
// `new Compound(name, args)` against a `(name, ...args)` signature. Its own
|
|
217
|
+
// arguments()/arity()/arg() accessors are wrong for the same reason, so read
|
|
218
|
+
// the arguments here rather than trusting them.
|
|
219
|
+
export function argumentsOf(term) {
|
|
220
|
+
if (!term || typeof term !== 'object' || !term.functor) return [];
|
|
221
|
+
const raw = term[term.functor];
|
|
222
|
+
if (!Array.isArray(raw)) return [];
|
|
223
|
+
return raw.length === 1 && Array.isArray(raw[0]) ? raw[0] : raw;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Undo the extra wrapping so a term can be handed back to the engine.
|
|
228
|
+
* swipl-wasm's JS-to-Prolog direction expects `term[functor]` to BE the
|
|
229
|
+
* argument list, which is not what its Prolog-to-JS direction produces — round
|
|
230
|
+
* tripping an untouched term turns point(1,2) into point([1,2]).
|
|
231
|
+
*/
|
|
232
|
+
export function toEngineTerm(v) {
|
|
233
|
+
if (Array.isArray(v)) return v.map(toEngineTerm);
|
|
234
|
+
if (!v || typeof v !== 'object') return v;
|
|
235
|
+
if (v.$t !== 't' || !v.functor) return v;
|
|
236
|
+
return { $t: 't', functor: v.functor, [v.functor]: argumentsOf(v).map(toEngineTerm) };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The text of a Prolog string, which arrives tagged rather than as a JS string. */
|
|
240
|
+
export function textOf(v) {
|
|
241
|
+
if (typeof v === 'string') return v;
|
|
242
|
+
if (v && typeof v === 'object' && typeof v.v === 'string') return v.v;
|
|
243
|
+
return String(v);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Render a term without an engine to ask.
|
|
248
|
+
*
|
|
249
|
+
* This is the fallback: it writes compounds in canonical functional notation,
|
|
250
|
+
* so `a-b` comes out as `-(a, b)`. Correct, but not what a top level shows —
|
|
251
|
+
* prefer PrologSession#formatTerm whenever a session is at hand.
|
|
252
|
+
*/
|
|
124
253
|
export function formatTerm(v) {
|
|
125
254
|
if (v === null || v === undefined) return '_';
|
|
126
255
|
if (Array.isArray(v)) return `[${v.map(formatTerm).join(', ')}]`;
|
|
127
256
|
if (typeof v === 'object') {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
257
|
+
switch (v.$t) {
|
|
258
|
+
case 't': return `${v.functor}(${argumentsOf(v).map(formatTerm).join(', ')})`;
|
|
259
|
+
case 's': return `"${textOf(v)}"`;
|
|
260
|
+
case 'v': return String(v.v ?? '_');
|
|
261
|
+
case 'r': return `${v.d}r${v.n}`;
|
|
262
|
+
case 'l': {
|
|
263
|
+
const items = (v.v || []).map(formatTerm).join(', ');
|
|
264
|
+
return v.tail === undefined ? `[${items}]` : `[${items}|${formatTerm(v.tail)}]`;
|
|
265
|
+
}
|
|
266
|
+
default: break;
|
|
267
|
+
}
|
|
268
|
+
if (v.functor) return `${v.functor}(${argumentsOf(v).map(formatTerm).join(', ')})`;
|
|
131
269
|
return JSON.stringify(v);
|
|
132
270
|
}
|
|
133
271
|
return String(v);
|
package/src/notebook.css
CHANGED
|
@@ -100,6 +100,9 @@ button.primary:hover { background: #98380f; }
|
|
|
100
100
|
.status.ok { color: var(--ok); }
|
|
101
101
|
.status.err { color: var(--err); }
|
|
102
102
|
.status.busy { color: #8a8371; }
|
|
103
|
+
/* Loaded, but SWI said something the reader needs to see — most often that this
|
|
104
|
+
cell has redefined a predicate another cell owns. */
|
|
105
|
+
.status.warn { color: #a8730f; white-space: pre-line; line-height: 1.4; }
|
|
103
106
|
|
|
104
107
|
.out {
|
|
105
108
|
font: 13.5px/1.7 ui-monospace, Menlo, monospace;
|
package/src/notebook.js
CHANGED
|
@@ -45,8 +45,11 @@ function mountProgram(cell) {
|
|
|
45
45
|
try {
|
|
46
46
|
const session = await boot(status);
|
|
47
47
|
const r = session.consult(source.value, name);
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
// A warning here usually means this cell has just destroyed another
|
|
49
|
+
// cell's clauses, which the reader has no other way of finding out.
|
|
50
|
+
const warning = r.messages && r.messages.find((m) => m.kind === 'warning');
|
|
51
|
+
status.textContent = r.ok ? warning ? warning.text : '✓ consulted' : r.error;
|
|
52
|
+
status.className = `status ${r.ok ? (warning ? 'warn' : 'ok') : 'err'}`;
|
|
50
53
|
} catch (e) {
|
|
51
54
|
status.textContent = e.message;
|
|
52
55
|
status.className = 'status err';
|
|
@@ -84,7 +87,8 @@ function mountQuery(cell) {
|
|
|
84
87
|
const step = () => {
|
|
85
88
|
if (!query) return;
|
|
86
89
|
const r = query.next();
|
|
87
|
-
|
|
90
|
+
// r.text is rendered by SWI itself, so operators and quoting are right.
|
|
91
|
+
if (r.solution) write(`${++count}. ${r.text ?? formatSolution(r.solution)}`, 'sol');
|
|
88
92
|
if (r.error) {
|
|
89
93
|
write(r.error, 'err');
|
|
90
94
|
finish();
|