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/src/engine.js CHANGED
@@ -6,11 +6,29 @@
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 = `
9
+ // What we teach the engine about itself, consulted once at startup.
10
+ //
11
+ // TWO THINGS, and both are about telling the truth to a reader.
12
+ //
13
+ // 1. SWI reports "Redefined static procedure" and friends by printing to
14
+ // user_error and then carrying on, so a consult that quietly destroyed
15
+ // another cell's clauses still succeeds. message_hook/3 is the documented way
16
+ // to intercept those; failing at the end lets the normal printing happen too.
17
+ //
18
+ // 2. AN ANSWER IS RENDERED BY PROLOG, NOT BY US. Formatting each binding in a
19
+ // separate round trip loses the one thing a Prolog answer is mostly about —
20
+ // which variables are the SAME variable. `app([1,2], Tail, L)` really does
21
+ // print `L = [1, 2|Tail]` at a toplevel, and we printed
22
+ // `L = [1,2|_20306], Tail = _20428`: two differently-numbered variables
23
+ // where there is one, with the reader's own name for it thrown away
24
+ // (869erjw27). For a chapter about partial lists that is the opposite of the
25
+ // lesson.
26
+ //
27
+ // The rule the toplevel follows, and this reproduces: a variable is NAMED by the
28
+ // last binding that mentions it, and that binding is then omitted — so `X = Y`
29
+ // prints as `X = Y`, and an unbound `Tail` disappears from the list of bindings
30
+ // and reappears inside `L`. Anything still unnamed becomes _A, _B, … as SWI does.
31
+ const HOOK = String.raw`
14
32
  :- dynamic '$nb_message'/2.
15
33
  user:message_hook(_Term, Kind, Lines) :-
16
34
  memberchk(Kind, [warning, error]),
@@ -19,6 +37,68 @@ user:message_hook(_Term, Kind, Lines) :-
19
37
  _, S = ''),
20
38
  assertz('$nb_message'(Kind, S)),
21
39
  fail.
40
+
41
+ % One solution: the text a toplevel would print, and the bindings by name.
42
+ % The goal arrives as a STRING BOUND TO A VARIABLE, never interpolated into this
43
+ % query, so a goal containing quotes or brackets needs no escaping anywhere.
44
+ '$nb_answer'(GoalText, Text, Names, Values) :-
45
+ read_term_from_atom(GoalText, Goal, [variable_names(Bindings)]),
46
+ call(Goal),
47
+ '$nb_render'(Bindings, Text),
48
+ findall(N, member(N=_, Bindings), Names),
49
+ findall(V, member(_=V, Bindings), Values).
50
+
51
+ '$nb_render'(Bindings, Text) :-
52
+ '$nb_names'(Bindings, Bindings, Named),
53
+ exclude('$nb_named_itself'(Named), Bindings, Shown),
54
+ ( Shown == []
55
+ -> Text = true
56
+ ; '$nb_anonymous'(Shown, Named, All),
57
+ maplist('$nb_pair'(All), Shown, Parts),
58
+ % An ATOM, not a string: swipl-wasm hands an atom to JavaScript as a
59
+ % plain string and a Prolog string as a wrapper object, and one
60
+ % representation crossing the boundary is one fewer thing to unwrap.
61
+ atomic_list_concat(Parts, ', ', Text)
62
+ ).
63
+
64
+ % Each unbound variable takes the LAST name bound to it.
65
+ '$nb_names'([], _, []).
66
+ '$nb_names'([Name=Value|T], All, Named) :-
67
+ ( var(Value),
68
+ '$nb_last_name'(All, Value, Name)
69
+ -> Named = [Name=Value|Rest]
70
+ ; Named = Rest
71
+ ),
72
+ '$nb_names'(T, All, Rest).
73
+
74
+ '$nb_last_name'(All, Var, Name) :-
75
+ findall(N, (member(N=V, All), V == Var), Names),
76
+ last(Names, Name).
77
+
78
+ % A binding that only says "this variable is called what it is called".
79
+ '$nb_named_itself'(Named, Name=Value) :-
80
+ var(Value),
81
+ member(N=V, Named),
82
+ V == Value,
83
+ N == Name.
84
+
85
+ '$nb_anonymous'(Shown, Named, All) :-
86
+ term_variables(Shown, Vars),
87
+ exclude('$nb_has_name'(Named), Vars, Unnamed),
88
+ findall(A, ( between(0'A, 0'Z, C), char_code(Ch, C), atom_concat('_', Ch, A) ), Alphabet),
89
+ '$nb_zip'(Unnamed, Alphabet, Extra),
90
+ append(Named, Extra, All).
91
+
92
+ '$nb_has_name'(Named, Var) :- member(_=V, Named), V == Var.
93
+
94
+ '$nb_zip'([], _, []).
95
+ '$nb_zip'([V|Vs], [N|Ns], [N=V|T]) :- '$nb_zip'(Vs, Ns, T).
96
+
97
+ '$nb_pair'(Names, Name=Value, Part) :-
98
+ with_output_to(string(S),
99
+ write_term(Value, [ quoted(true), portray(true), numbervars(true),
100
+ spacing(next_argument), variable_names(Names) ])),
101
+ format(atom(Part), '~w = ~w', [Name, S]).
22
102
  `;
23
103
 
24
104
  export class PrologSession {
@@ -31,7 +111,7 @@ export class PrologSession {
31
111
  const session = new PrologSession(module);
32
112
  // No `$` in the path: SWI expands $var in file names like a shell does, so
33
113
  // /$nb-hook.pl resolves to nothing and the consult fails silently.
34
- module.FS.writeFile('/nb-hook.pl', MESSAGE_HOOK);
114
+ module.FS.writeFile('/nb-hook.pl', HOOK);
35
115
  module.prolog.query("user:consult('/nb-hook.pl')").once();
36
116
  return session;
37
117
  }
@@ -77,10 +157,16 @@ export class PrologSession {
77
157
  * @returns {PrologQuery}
78
158
  */
79
159
  query(goal) {
80
- // Cells consult into `user`, but prolog.query/1 runs with `system` as the
81
- // context module, so an unqualified goal resolves against the wrong one.
160
+ // The goal is BOUND, not interpolated: `'$nb_answer'` reads it in the `user`
161
+ // module (where cells consult) and renders each solution there, so operators,
162
+ // quoting and shared variables are SWI's own work rather than ours. It also
163
+ // means a goal containing quotes or brackets needs no escaping at any point.
82
164
  const cleaned = goal.trim().replace(/\.$/, '');
83
- return new PrologQuery(this.module.prolog.query(`user:( ${cleaned} )`), this);
165
+ const handle = this.module.prolog.query(
166
+ "user:'$nb_answer'(G, Text, Names, Values)",
167
+ { G: cleaned }
168
+ );
169
+ return new PrologQuery(handle, this);
84
170
  }
85
171
 
86
172
  /**
@@ -131,9 +217,38 @@ export class PrologQuery {
131
217
  this.handle = handle;
132
218
  this.session = session;
133
219
  this.exhausted = false;
220
+ // Ended by something other than its own search — see close(). Kept apart from
221
+ // `exhausted` because "the search finished" and "we stopped it" are different
222
+ // facts, and only the first may ever be written down as `false.` (format §6).
223
+ this.superseded = false;
134
224
  this.count = 0;
135
225
  }
136
226
 
227
+ /**
228
+ * Give the query frame back to the engine.
229
+ *
230
+ * SWI KEEPS OPEN QUERIES ON A STACK, and swipl-wasm enforces it: both
231
+ * `next()` and `close()` call `__must_be_innermost_query`, which throws
232
+ * "Attempt to access not innermost query". An abandoned query is therefore not
233
+ * merely untidy — it is a frame every later query has to nest inside, and the
234
+ * abandoned one can never be stepped again (869epzqpc).
235
+ *
236
+ * Nothing else releases it. Running the search to `done` does, because
237
+ * swipl-wasm closes the query itself at that point, which is why a drained
238
+ * sequence costs nothing. Everything else must come through here.
239
+ *
240
+ * @param {{superseded?: boolean}} [options] `superseded` when the session
241
+ * closed this to make room for another query rather than the caller being
242
+ * finished with it. It changes what next() reports, and it must: a caller
243
+ * that reads plain `done` concludes the search was exhausted.
244
+ */
245
+ close({ superseded = false } = {}) {
246
+ if (superseded) this.superseded = true;
247
+ if (this.exhausted) return;
248
+ this.exhausted = true;
249
+ this.handle.close();
250
+ }
251
+
137
252
  /**
138
253
  * Pull the next solution.
139
254
  *
@@ -144,17 +259,20 @@ export class PrologQuery {
144
259
  * @returns {{done: boolean, solution?: object, text?: string, error?: string}}
145
260
  */
146
261
  next() {
147
- if (this.exhausted) return { done: true };
262
+ // `superseded` travels with the done, because a closed query and an exhausted
263
+ // one are indistinguishable from `{done: true}` alone — and the difference is
264
+ // whether the caller may write `false.` under it.
265
+ if (this.exhausted) return this.superseded ? { done: true, superseded: true } : { done: true };
148
266
  let r;
149
267
  try {
150
268
  r = this.handle.next();
151
269
  } catch (e) {
152
270
  this.exhausted = true;
153
- return { done: true, error: e.message };
271
+ return { done: true, error: readableError(e.message) };
154
272
  }
155
273
  if (r.error) {
156
274
  this.exhausted = true;
157
- return { done: true, error: r.message };
275
+ return { done: true, error: readableError(r.message) };
158
276
  }
159
277
 
160
278
  // The engine can deliver the final solution *together with* done:true — a
@@ -163,10 +281,11 @@ export class PrologQuery {
163
281
  const out = { done: !!r.done };
164
282
  if (r.value) {
165
283
  this.count += 1;
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);
284
+ // `Text` is what a toplevel would print for this solution, rendered inside
285
+ // Prolog (see HOOK). `Names`/`Values` are the same answer as data, for a
286
+ // caller that wants the bindings rather than the line.
287
+ out.text = r.value.Text;
288
+ out.solution = zipBindings(r.value.Names, r.value.Values);
170
289
  }
171
290
  if (r.done) this.exhausted = true;
172
291
  return out;
@@ -188,7 +307,74 @@ export class PrologQuery {
188
307
  }
189
308
  }
190
309
 
310
+ /**
311
+ * Drop the frame that is ours rather than the reader's.
312
+ *
313
+ * SWI prefixes an error with the goal it was raised from, which is usually worth
314
+ * keeping — `//2: Arithmetic: evaluation error` names the division. But every
315
+ * goal we run is wrapped for the WASM boundary, so an unknown predicate reads
316
+ * `wasm:wasm_call_string/3: Unknown procedure: is_son/1`. That prefix is our
317
+ * plumbing in the middle of a teaching page: the reader did not write it, cannot
318
+ * act on it, and it is the same words whatever they got wrong.
319
+ *
320
+ * Only that one frame is removed. Any other context is the reader's own code.
321
+ */
322
+ export function readableError(message) {
323
+ return String(message ?? '')
324
+ .replace(/^wasm:wasm_call_string\/\d+:\s*/, '')
325
+ // Our own wrapper, which the reader did not write and cannot act on. It is
326
+ // the same plumbing argument as the frame above: `'$nb_answer'/4: Unknown
327
+ // procedure: son_a/1` names a predicate of ours in the middle of a teaching
328
+ // page.
329
+ .replace(/^'\$nb_answer'\/\d+:\s*/, '');
330
+ }
331
+
332
+ /**
333
+ * A message about THIS cell, said the way the cell would say it.
334
+ *
335
+ * One cell is one virtual file, so SWI's line numbers are already the cell's own
336
+ * — that half of the problem solved itself. What is left is the path: a reader
337
+ * looking at a syntax error printed on the very cell that caused it does not need
338
+ * to be told which file it was in, and `/p-family.pl` is a filename they never
339
+ * chose and cannot open.
340
+ *
341
+ * /p-family.pl:4:6: Syntax error: Operator expected
342
+ * line 4, column 6: Syntax error: Operator expected
343
+ *
344
+ * ONLY THIS CELL'S OWN PATH IS REMOVED, which is the whole reason the name is a
345
+ * parameter rather than a wildcard. A consult warning naming a DIFFERENT cell —
346
+ * "Redefined static procedure male/1", the one that says another cell's clauses
347
+ * have just been destroyed — is only useful because it names that other file, and
348
+ * a regex that stripped any path would delete exactly the part worth reading.
349
+ *
350
+ * @param {string} message
351
+ * @param {string} name the cell's own consult name
352
+ */
353
+ export function readableInCell(message, name) {
354
+ const path = `/${String(name ?? '')}.pl:`;
355
+ const text = readableError(message);
356
+ if (!text.startsWith(path)) return text;
357
+ return text
358
+ .slice(path.length)
359
+ .replace(/^(\d+):(\d+):\s*/, 'line $1, column $2: ')
360
+ .replace(/^(\d+):\s*/, 'line $1: ');
361
+ }
362
+
191
363
  /** Strip the engine's bookkeeping keys from a solution. */
364
+ /**
365
+ * The query's own variable names, against their values.
366
+ *
367
+ * Two parallel lists rather than a list of `Name=Value` terms, because a term
368
+ * would have to be taken apart on this side and the lists arrive as arrays
369
+ * already. Order is the order the variables appear in the goal, which is the
370
+ * order a toplevel reports them in.
371
+ */
372
+ export function zipBindings(names = [], values = []) {
373
+ const out = {};
374
+ names.forEach((name, i) => { out[name] = values[i]; });
375
+ return out;
376
+ }
377
+
192
378
  export function bindingsOf(value) {
193
379
  const out = {};
194
380
  for (const [k, v] of Object.entries(value)) {
@@ -198,6 +384,31 @@ export function bindingsOf(value) {
198
384
  return out;
199
385
  }
200
386
 
387
+ /**
388
+ * Which SWI-Prolog this is.
389
+ *
390
+ * Worth asking, and not derivable from anything on disk: swipl-wasm 8.0.4 ships
391
+ * SWI-Prolog 10.1.10, and the two numbers have no relationship at all. A
392
+ * chapter's saved answers are only true of the engine that produced them, so
393
+ * the version is part of their attribution rather than a footnote.
394
+ *
395
+ * `version` rather than `version_git`: the integer flag is always present, and
396
+ * its encoding is documented — MAJOR*10000 + MINOR*100 + PATCH.
397
+ *
398
+ * @param {{query: Function}} session any session, in either environment
399
+ * @returns {Promise<string|null>} e.g. "10.1.10", or null if the engine will not say
400
+ */
401
+ export async function prologVersion(session) {
402
+ try {
403
+ const result = await session.query('current_prolog_flag(version, V)').all(1);
404
+ const encoded = result.solutions?.[0]?.V;
405
+ if (!Number.isInteger(encoded)) return null;
406
+ return `${Math.floor(encoded / 10000)}.${Math.floor(encoded / 100) % 100}.${encoded % 100}`;
407
+ } catch {
408
+ return null;
409
+ }
410
+ }
411
+
201
412
  /** Render a solution the way a Prolog top level would. */
202
413
  export function formatSolution(solution) {
203
414
  const pairs = Object.entries(solution);
package/src/export.js ADDED
@@ -0,0 +1,126 @@
1
+ // The reader leaves with a real file.
2
+ //
3
+ // Explore mode lets a reader edit a published chapter and run it, and until now
4
+ // gave them no way to keep the result. That is a trap rather than a feature: work
5
+ // that lives only in a page is one navigation from gone, and telling someone to
6
+ // experiment while quietly discarding what they produce teaches them not to.
7
+ //
8
+ // The answer is FORK BY DOWNLOAD, never save-back (docs/modes.md §3). The
9
+ // published chapter stays canonical; the reader gets their own `.prolog.md`,
10
+ // which they can commit, open in VS Code, or send back as a pull request. That
11
+ // last one is free: the format is markdown in git, so the contribution path
12
+ // already exists.
13
+ //
14
+ // Cheap by construction — serialise() already emits canonical bytes — so all the
15
+ // work here is in being honest about WHOSE ANSWERS ARE WHOSE.
16
+ import { hashFor, serialise } from './format.js';
17
+
18
+ /**
19
+ * The notebook as it now stands on screen.
20
+ *
21
+ * @param {{frontMatter: Map<string, string>, cells: object[]}} notebook the published model
22
+ * @param {Map<string, {source?: string, goal?: string, output?: object|null}>} edits by cell id
23
+ * @returns {{frontMatter: Map<string, string>, cells: object[]}}
24
+ */
25
+ export function withEdits(notebook, edits) {
26
+ const cells = notebook.cells.map((cell) => {
27
+ const edit = edits.get(cell.id);
28
+ if (!edit) return cell;
29
+ if (cell.kind === 'program' && edit.source !== undefined) {
30
+ return { ...cell, source: edit.source };
31
+ }
32
+ if (cell.kind === 'query') {
33
+ const next = { ...cell };
34
+ if (edit.goal !== undefined) next.goal = edit.goal;
35
+ // `output: null` is a deliberate erasure, not a missing key: it is how a
36
+ // query the reader has half-run says "I have no answers to give you".
37
+ //
38
+ // An output is more than its answers — it carries the fence's language and
39
+ // whatever attributes the author wrote on it, and the serialiser needs
40
+ // every one of them. So the reader's answers are laid OVER the author's
41
+ // output rather than replacing the object, and a cell that never had one
42
+ // gets the defaults the parser would have produced.
43
+ if ('output' in edit) {
44
+ next.output = edit.output && { ...blankOutput(), ...cell.output, ...edit.output };
45
+ }
46
+ return next;
47
+ }
48
+ return cell;
49
+ });
50
+
51
+ const updated = { ...notebook, cells };
52
+
53
+ // THE HASHES ARE THE WHOLE ARGUMENT. A downloaded file has to say, of every
54
+ // output in it, whether it follows from the program above it — and the format
55
+ // already has the spelling for that (format §6).
56
+ //
57
+ // - An answer the READER produced is hashed against the READER's program,
58
+ // because that is what produced it. It opens as current, which it is.
59
+ // - An answer from the CHAPTER keeps the AUTHOR's hash, untouched. If the
60
+ // reader edited a program above it, the hash no longer matches and the file
61
+ // opens with that output marked stale — which is exactly the truth, and
62
+ // exactly what the page they downloaded it from was showing.
63
+ //
64
+ // Rehashing everything would be the tempting one-liner and it would be a
65
+ // forgery: it would certify the author's answers as following from the
66
+ // reader's program. That is the one failure this project may not have.
67
+ for (const cell of cells) {
68
+ if (cell.kind !== 'query' || !cell.output) continue;
69
+ if (edits.get(cell.id)?.output) {
70
+ cell.output = { ...cell.output, inputHash: hashFor(updated, cell) };
71
+ }
72
+ }
73
+
74
+ return updated;
75
+ }
76
+
77
+ /**
78
+ * What the parser would have produced for an output that has none of its own.
79
+ *
80
+ * A query the chapter never ran has no fence for its answers, so there is
81
+ * nothing to inherit language or attributes from, and the serialiser reads both
82
+ * unconditionally — `attrs` in particular is iterated, so a missing one is a
83
+ * TypeError rather than a silently absent attribute.
84
+ */
85
+ function blankOutput() {
86
+ return { solutions: [], terminator: '', inputHash: null, language: 'text', attrs: new Map() };
87
+ }
88
+
89
+ /**
90
+ * Serialise the notebook as it now stands.
91
+ * @returns {string} canonical `.prolog.md` bytes
92
+ */
93
+ export function exportSource(notebook, edits) {
94
+ return serialise(withEdits(notebook, edits));
95
+ }
96
+
97
+ /**
98
+ * A filename for the reader's copy.
99
+ *
100
+ * From the SOURCE, never the title: a title can contain anything, including
101
+ * slashes, and the reader recognises the file they came from. The name is not
102
+ * decorated with "my-copy" either — this is their file now, and their filesystem
103
+ * is where the distinction between copies belongs.
104
+ */
105
+ export function filenameFor(url) {
106
+ const path = String(url ?? '').split(/[?#]/)[0];
107
+ const base = path.slice(path.lastIndexOf('/') + 1);
108
+ return base || 'notebook.prolog.md';
109
+ }
110
+
111
+ /**
112
+ * Hand a file to the reader.
113
+ *
114
+ * An object URL and a synthetic click, revoked on the next turn of the event
115
+ * loop. No network, no server, nothing to own.
116
+ */
117
+ export function download(filename, text, document_ = document) {
118
+ const url = URL.createObjectURL(new Blob([text], { type: 'text/markdown;charset=utf-8' }));
119
+ const anchor = document_.createElement('a');
120
+ anchor.href = url;
121
+ anchor.download = filename;
122
+ document_.body.appendChild(anchor);
123
+ anchor.click();
124
+ anchor.remove();
125
+ setTimeout(() => URL.revokeObjectURL(url), 0);
126
+ }