prolog-notebook 0.1.1 → 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 +233 -0
- package/README.md +131 -28
- 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 +367 -18
- package/src/export.js +126 -0
- package/src/format.js +649 -0
- package/src/node.js +14 -4
- package/src/notebook.css +322 -1
- package/src/notebook.js +1261 -56
- 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/engine.js
CHANGED
|
@@ -6,6 +6,101 @@
|
|
|
6
6
|
|
|
7
7
|
let fileSerial = 0;
|
|
8
8
|
|
|
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`
|
|
32
|
+
:- dynamic '$nb_message'/2.
|
|
33
|
+
user:message_hook(_Term, Kind, Lines) :-
|
|
34
|
+
memberchk(Kind, [warning, error]),
|
|
35
|
+
catch(with_output_to(string(S),
|
|
36
|
+
print_message_lines(current_output, '', Lines)),
|
|
37
|
+
_, S = ''),
|
|
38
|
+
assertz('$nb_message'(Kind, S)),
|
|
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]).
|
|
102
|
+
`;
|
|
103
|
+
|
|
9
104
|
export class PrologSession {
|
|
10
105
|
/**
|
|
11
106
|
* @param {Function} swiplFactory the SWIPL factory from swipl-wasm
|
|
@@ -13,7 +108,12 @@ export class PrologSession {
|
|
|
13
108
|
*/
|
|
14
109
|
static async create(swiplFactory, options = {}) {
|
|
15
110
|
const module = await swiplFactory({ arguments: ['-q'], ...options });
|
|
16
|
-
|
|
111
|
+
const session = new PrologSession(module);
|
|
112
|
+
// No `$` in the path: SWI expands $var in file names like a shell does, so
|
|
113
|
+
// /$nb-hook.pl resolves to nothing and the consult fails silently.
|
|
114
|
+
module.FS.writeFile('/nb-hook.pl', HOOK);
|
|
115
|
+
module.prolog.query("user:consult('/nb-hook.pl')").once();
|
|
116
|
+
return session;
|
|
17
117
|
}
|
|
18
118
|
|
|
19
119
|
constructor(module) {
|
|
@@ -22,18 +122,31 @@ export class PrologSession {
|
|
|
22
122
|
|
|
23
123
|
/**
|
|
24
124
|
* Load a clause base into the `user` module.
|
|
125
|
+
*
|
|
126
|
+
* Each cell should pass its own stable `name`: SWI attributes clauses to the
|
|
127
|
+
* file they came from, so re-consulting the same name replaces exactly that
|
|
128
|
+
* cell's clauses and leaves every other cell alone.
|
|
129
|
+
*
|
|
25
130
|
* @param {string} text Prolog source
|
|
26
|
-
* @
|
|
131
|
+
* @param {string} [name] virtual file name; identifies the cell
|
|
132
|
+
* @returns {{ok: boolean, error?: string, messages: {kind: string, text: string}[]}}
|
|
27
133
|
*/
|
|
28
134
|
consult(text, name = `cell${fileSerial++}`) {
|
|
29
|
-
const path = `/${name}.pl`;
|
|
135
|
+
const path = `/${name.replace(/\.pl$/, '')}.pl`;
|
|
30
136
|
try {
|
|
31
137
|
this.module.FS.writeFile(path, text);
|
|
138
|
+
this.#drainMessages();
|
|
32
139
|
const r = this.module.prolog.query(`user:consult('${path}')`).once();
|
|
33
|
-
|
|
34
|
-
return { ok:
|
|
140
|
+
const messages = this.#drainMessages();
|
|
141
|
+
if (r && r.error) return { ok: false, error: r.message, messages };
|
|
142
|
+
// A clause SWI could not read is reported and then skipped, so consult
|
|
143
|
+
// itself still succeeds. Reporting that as "✓ consulted" would leave the
|
|
144
|
+
// reader with a cell that looks loaded and a predicate that is not there.
|
|
145
|
+
const failed = messages.find((m) => m.kind === 'error');
|
|
146
|
+
if (failed) return { ok: false, error: failed.text, messages };
|
|
147
|
+
return { ok: true, messages };
|
|
35
148
|
} catch (e) {
|
|
36
|
-
return { ok: false, error: e.message };
|
|
149
|
+
return { ok: false, error: e.message, messages: [] };
|
|
37
150
|
}
|
|
38
151
|
}
|
|
39
152
|
|
|
@@ -44,36 +157,122 @@ export class PrologSession {
|
|
|
44
157
|
* @returns {PrologQuery}
|
|
45
158
|
*/
|
|
46
159
|
query(goal) {
|
|
47
|
-
//
|
|
48
|
-
//
|
|
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.
|
|
49
164
|
const cleaned = goal.trim().replace(/\.$/, '');
|
|
50
|
-
|
|
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);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Render a term the way SWI's own top level would, by asking SWI. Worth the
|
|
174
|
+
* round trip: it gets operators (a-b, not -(a,b)), atom quoting and every
|
|
175
|
+
* other rule of the writer right, none of which we want to reimplement.
|
|
176
|
+
* @returns {string}
|
|
177
|
+
*/
|
|
178
|
+
formatTerm(term) {
|
|
179
|
+
try {
|
|
180
|
+
const r = this.module.prolog
|
|
181
|
+
.query('term_string(T, S)', { T: toEngineTerm(term) })
|
|
182
|
+
.once();
|
|
183
|
+
const s = r && r.S;
|
|
184
|
+
if (typeof s === 'string') return s;
|
|
185
|
+
if (s && typeof s.v === 'string') return s.v;
|
|
186
|
+
} catch {
|
|
187
|
+
// fall through to the DOM-free renderer below
|
|
188
|
+
}
|
|
189
|
+
return formatTerm(term);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Render a full solution the way a top level would. */
|
|
193
|
+
formatSolution(solution) {
|
|
194
|
+
const pairs = Object.entries(solution);
|
|
195
|
+
if (!pairs.length) return 'true';
|
|
196
|
+
return pairs.map(([k, v]) => `${k} = ${this.formatTerm(v)}`).join(', ');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#drainMessages() {
|
|
200
|
+
try {
|
|
201
|
+
const r = this.module.prolog
|
|
202
|
+
.query("user:( findall(m(K,T), '$nb_message'(K,T), L ), retractall('$nb_message'(_,_)) )")
|
|
203
|
+
.once();
|
|
204
|
+
if (!r || !Array.isArray(r.L)) return [];
|
|
205
|
+
return r.L.map((m) => {
|
|
206
|
+
const [kind, text] = argumentsOf(m);
|
|
207
|
+
return { kind: String(kind), text: textOf(text).trim() };
|
|
208
|
+
});
|
|
209
|
+
} catch {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
51
212
|
}
|
|
52
213
|
}
|
|
53
214
|
|
|
54
215
|
export class PrologQuery {
|
|
55
|
-
constructor(handle) {
|
|
216
|
+
constructor(handle, session) {
|
|
56
217
|
this.handle = handle;
|
|
218
|
+
this.session = session;
|
|
57
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;
|
|
58
224
|
this.count = 0;
|
|
59
225
|
}
|
|
60
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
|
+
|
|
61
252
|
/**
|
|
62
253
|
* Pull the next solution.
|
|
63
|
-
*
|
|
254
|
+
*
|
|
255
|
+
* `text` is the solution rendered by SWI itself; prefer it over calling
|
|
256
|
+
* formatSolution, which has no engine to ask and so cannot know about
|
|
257
|
+
* operators or quoting.
|
|
258
|
+
*
|
|
259
|
+
* @returns {{done: boolean, solution?: object, text?: string, error?: string}}
|
|
64
260
|
*/
|
|
65
261
|
next() {
|
|
66
|
-
|
|
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 };
|
|
67
266
|
let r;
|
|
68
267
|
try {
|
|
69
268
|
r = this.handle.next();
|
|
70
269
|
} catch (e) {
|
|
71
270
|
this.exhausted = true;
|
|
72
|
-
return { done: true, error: e.message };
|
|
271
|
+
return { done: true, error: readableError(e.message) };
|
|
73
272
|
}
|
|
74
273
|
if (r.error) {
|
|
75
274
|
this.exhausted = true;
|
|
76
|
-
return { done: true, error: r.message };
|
|
275
|
+
return { done: true, error: readableError(r.message) };
|
|
77
276
|
}
|
|
78
277
|
|
|
79
278
|
// The engine can deliver the final solution *together with* done:true — a
|
|
@@ -82,7 +281,11 @@ export class PrologQuery {
|
|
|
82
281
|
const out = { done: !!r.done };
|
|
83
282
|
if (r.value) {
|
|
84
283
|
this.count += 1;
|
|
85
|
-
|
|
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);
|
|
86
289
|
}
|
|
87
290
|
if (r.done) this.exhausted = true;
|
|
88
291
|
return out;
|
|
@@ -104,7 +307,74 @@ export class PrologQuery {
|
|
|
104
307
|
}
|
|
105
308
|
}
|
|
106
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
|
+
|
|
107
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
|
+
|
|
108
378
|
export function bindingsOf(value) {
|
|
109
379
|
const out = {};
|
|
110
380
|
for (const [k, v] of Object.entries(value)) {
|
|
@@ -114,6 +384,31 @@ export function bindingsOf(value) {
|
|
|
114
384
|
return out;
|
|
115
385
|
}
|
|
116
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
|
+
|
|
117
412
|
/** Render a solution the way a Prolog top level would. */
|
|
118
413
|
export function formatSolution(solution) {
|
|
119
414
|
const pairs = Object.entries(solution);
|
|
@@ -121,13 +416,67 @@ export function formatSolution(solution) {
|
|
|
121
416
|
return pairs.map(([k, v]) => `${k} = ${formatTerm(v)}`).join(', ');
|
|
122
417
|
}
|
|
123
418
|
|
|
419
|
+
// swipl-wasm tags every non-atomic value it hands back:
|
|
420
|
+
// compound { $t: 't', functor: 'f', f: [[arg, ...]] }
|
|
421
|
+
// string { $t: 's', v: 'text' }
|
|
422
|
+
// variable { $t: 'v', v: '_123' }
|
|
423
|
+
// rational { $t: 'r', n, d }
|
|
424
|
+
// list { $t: 'l', v: [...], tail }
|
|
425
|
+
// Note the compound's arguments live under the key NAMED BY THE FUNCTOR, and
|
|
426
|
+
// arrive wrapped in one extra array — swipl-wasm builds them with
|
|
427
|
+
// `new Compound(name, args)` against a `(name, ...args)` signature. Its own
|
|
428
|
+
// arguments()/arity()/arg() accessors are wrong for the same reason, so read
|
|
429
|
+
// the arguments here rather than trusting them.
|
|
430
|
+
export function argumentsOf(term) {
|
|
431
|
+
if (!term || typeof term !== 'object' || !term.functor) return [];
|
|
432
|
+
const raw = term[term.functor];
|
|
433
|
+
if (!Array.isArray(raw)) return [];
|
|
434
|
+
return raw.length === 1 && Array.isArray(raw[0]) ? raw[0] : raw;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Undo the extra wrapping so a term can be handed back to the engine.
|
|
439
|
+
* swipl-wasm's JS-to-Prolog direction expects `term[functor]` to BE the
|
|
440
|
+
* argument list, which is not what its Prolog-to-JS direction produces — round
|
|
441
|
+
* tripping an untouched term turns point(1,2) into point([1,2]).
|
|
442
|
+
*/
|
|
443
|
+
export function toEngineTerm(v) {
|
|
444
|
+
if (Array.isArray(v)) return v.map(toEngineTerm);
|
|
445
|
+
if (!v || typeof v !== 'object') return v;
|
|
446
|
+
if (v.$t !== 't' || !v.functor) return v;
|
|
447
|
+
return { $t: 't', functor: v.functor, [v.functor]: argumentsOf(v).map(toEngineTerm) };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** The text of a Prolog string, which arrives tagged rather than as a JS string. */
|
|
451
|
+
export function textOf(v) {
|
|
452
|
+
if (typeof v === 'string') return v;
|
|
453
|
+
if (v && typeof v === 'object' && typeof v.v === 'string') return v.v;
|
|
454
|
+
return String(v);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Render a term without an engine to ask.
|
|
459
|
+
*
|
|
460
|
+
* This is the fallback: it writes compounds in canonical functional notation,
|
|
461
|
+
* so `a-b` comes out as `-(a, b)`. Correct, but not what a top level shows —
|
|
462
|
+
* prefer PrologSession#formatTerm whenever a session is at hand.
|
|
463
|
+
*/
|
|
124
464
|
export function formatTerm(v) {
|
|
125
465
|
if (v === null || v === undefined) return '_';
|
|
126
466
|
if (Array.isArray(v)) return `[${v.map(formatTerm).join(', ')}]`;
|
|
127
467
|
if (typeof v === 'object') {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
468
|
+
switch (v.$t) {
|
|
469
|
+
case 't': return `${v.functor}(${argumentsOf(v).map(formatTerm).join(', ')})`;
|
|
470
|
+
case 's': return `"${textOf(v)}"`;
|
|
471
|
+
case 'v': return String(v.v ?? '_');
|
|
472
|
+
case 'r': return `${v.d}r${v.n}`;
|
|
473
|
+
case 'l': {
|
|
474
|
+
const items = (v.v || []).map(formatTerm).join(', ');
|
|
475
|
+
return v.tail === undefined ? `[${items}]` : `[${items}|${formatTerm(v.tail)}]`;
|
|
476
|
+
}
|
|
477
|
+
default: break;
|
|
478
|
+
}
|
|
479
|
+
if (v.functor) return `${v.functor}(${argumentsOf(v).map(formatTerm).join(', ')})`;
|
|
131
480
|
return JSON.stringify(v);
|
|
132
481
|
}
|
|
133
482
|
return String(v);
|
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
|
+
}
|