prolog-notebook 0.1.0 → 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 CHANGED
@@ -1,5 +1,57 @@
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
+
40
+ ## [0.1.1] — 2026-08-02
41
+
42
+ No functional change. Published from CI via npm trusted publishing (OIDC) to
43
+ verify the release pipeline end to end — v0.1.0 was published by hand to claim
44
+ the name.
45
+
46
+ ### Changed
47
+
48
+ - Release workflow authenticates over OIDC instead of an `NPM_TOKEN`. npm
49
+ deprecates 2FA-bypass granular tokens for direct publishing around January
50
+ 2027 and points at trusted publishing instead; it also removes the 90-day
51
+ token expiry from the loop.
52
+ - The example page now says so when it has not started, instead of leaving the
53
+ buttons silently inert (browsers block ES modules over `file://`).
54
+
3
55
  ## [0.1.0] — 2026-08-02
4
56
 
5
57
  First release. The execution core works; the file-backed renderer does not exist yet.
package/README.md CHANGED
@@ -140,4 +140,4 @@ MIT
140
140
 
141
141
  [kernel]: https://github.com/hhu-stups/prolog-jupyter-kernel
142
142
  [wasm]: https://github.com/SWI-Prolog/swipl-wasm
143
- [ptv]: https://github.com/jarecsni/prolog-trace-viz
143
+ [ptv]: https://github.com/textologylabs/prolog-trace-viz
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prolog-notebook",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Jupyter-style notebooks for Prolog. Runs in the browser, installs nothing.",
5
5
  "type": "module",
6
6
  "main": "./src/node.js",
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
- return new PrologSession(module);
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
- * @returns {{ok: boolean, error?: string}}
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
- if (r && r.error) return { ok: false, error: r.message };
34
- return { ok: true };
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
- * @returns {{done: boolean, solution?: object, error?: string}}
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
- if (v.$tag === 'string') return `"${v.text}"`;
129
- if (v.functor) return `${v.functor}(${(v.args || []).map(formatTerm).join(', ')})`;
130
- if (v.v !== undefined) return `_${v.v}`;
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
- status.textContent = r.ok ? '✓ consulted' : r.error;
49
- status.className = `status ${r.ok ? 'ok' : 'err'}`;
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
- if (r.solution) write(`${++count}. ${formatSolution(r.solution)}`, 'sol');
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();