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/CHANGELOG.md CHANGED
@@ -1,5 +1,216 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.1] — 2026-08-30
4
+
5
+ ### Changed
6
+
7
+ - **The build line in `--version` carries one date, and it is the build's.**
8
+
9
+ Built from commit ca69c2a on 2026-08-30 14:42:46 UTC
10
+ Working copy ca69c2a (modified)
11
+
12
+ The commit's own date is gone: the hash already identifies it, and anyone who wants it can
13
+ ask git. A working copy now carries no date at all, which follows from the same rule rather
14
+ than being a separate decision — the only date left is the build's, and a working copy has
15
+ not been built. UTC, to the second, because a build stamp is read by whoever is holding the
16
+ package, wherever they are.
17
+
18
+ ## [0.3.0] — 2026-08-30
19
+
20
+ **A chapter is a file you can read, run, and fill in from the command line.** The renderer,
21
+ the page's own behaviour, and the first half of the CLI.
22
+
23
+ ### Why
24
+
25
+ 0.2.0 made a `.prolog.md` executable. It did not make one *publishable*: the answers a chapter
26
+ shows had to be typed by its author, which means they were the author's guess at what SWI
27
+ prints, published as though it ran. Everything here follows from closing that gap and from
28
+ what the closing revealed.
29
+
30
+ `prolog-notebook run` now fills a chapter's answers in from a real engine. Run against the
31
+ chapter in this repository it changes nothing — the hand-written answers were already exactly
32
+ what SWI produces, hashes included, which is now a test.
33
+
34
+ ### Added
35
+
36
+ - **`prolog-notebook run <file>…`** — consults every program cell, runs every query below it,
37
+ and writes the solution sequences back with an `input-hash` for each. `--limit`, `--stdout`,
38
+ `--quiet`. The logic is in `src/run.js` and takes a parsed notebook and a session, so
39
+ `--check` and a VS Code "run all" will get the same behaviour without a shell.
40
+ - **`--version`**, which says which SWI-Prolog will produce your answers. swipl-wasm 8.0.7
41
+ ships SWI-Prolog 10.1.13, and the two numbers are unrelated — so the engine version is the
42
+ one fact there that nobody could have looked up. A published install also reports the commit
43
+ it was built from; a working copy says so, and says when it has uncommitted edits.
44
+ - **A chapter is readable cold.** Saved answers render with no engine anywhere, and are marked
45
+ stale when the program above them has moved.
46
+ - **`hold`** — a query cell can withhold its saved answers until the reader runs it, or until
47
+ they have written the prediction above it. A page that has already printed all six answers is
48
+ arguing with prose that says "press Run".
49
+ - **`rerun="auto"`** — the answers follow the program. On consult, never on edit; never
50
+ starting work nobody asked for; and a held cell stays manual until its wait ends.
51
+ - **The page's own controls**: a lozenge that raises a card — what the engine is holding, the
52
+ chapter's answers shown or hidden, and the notebook itself. It says which version is on
53
+ screen, and once the two differ, lets you download either.
54
+ - **Per-cell reset**, on both runnable kinds. On a program cell that means out of the engine
55
+ as well as back to the chapter's text.
56
+ - **A stateful cell says so** before anything is asserted into it.
57
+ - **Download your own copy** — the reader leaves with a real `.prolog.md`, their edits and
58
+ their answers in it, hashed against the program that produced them.
59
+
60
+ ### Fixed
61
+
62
+ - **An answer containing variables was not what SWI would print.** `app([1,2], Tail, L)` came
63
+ out as `L = [1,2|_20306], Tail = _20428` where a toplevel prints `L = [1, 2|Tail]` — the
64
+ same variable shown as two, with the reader's own name for it discarded. Each binding was
65
+ rendered in a separate round trip into Prolog, and two round trips cannot share a variable.
66
+ Prolog now renders the whole answer, once, with the goal's own `variable_names`. Invisible
67
+ until now because every answer in the shipped chapter is a ground atom.
68
+ - **A half-walked query was destroyed by running any other cell.** SWI keeps open queries on a
69
+ stack; nothing here released one, and the next query nested inside it. There is now one open
70
+ sequence per engine, and the cell that loses its own is told so in words about the notebook
71
+ rather than about SWI's internals.
72
+ - **A `hold` release, a stuck Hide control, and a panel that resized under the cursor** — all
73
+ found by reading the page rather than the tests, which is why `src/notebook.js` now has a
74
+ jsdom harness and the page's behaviour is asserted from Node.
75
+ - **A compile error is reported in the cell's own terms**, not as a path the reader never
76
+ chose.
77
+
78
+ ### Changed
79
+
80
+ - **The engine is pinned exactly** (`swipl-wasm 8.0.7`). Two installs of one release, ten
81
+ minutes apart, ran SWI-Prolog 10.1.10 and 10.1.13 — and a chapter's saved answers are only
82
+ ever true of the engine that produced them. Moving the engine is now a commit with the
83
+ chapters re-run in it.
84
+ - Solutions are spelled as SWI's own writer spells them, so compounds gain a space after each
85
+ comma: `foo(1, 2)`, not `foo(1,2)`. Any file with saved answers containing a compound will
86
+ differ on its next `run`.
87
+ - `offerDownload()` takes an options object rather than positional arguments.
88
+ - The engine is imported where it is used rather than at the top of the CLI, so `--help` still
89
+ works on an install whose WebAssembly is missing.
90
+
91
+ ### Notes
92
+
93
+ `--check` — run a chapter in CI and fail the build when its answers have drifted — is
94
+ deliberately **not** here. It needs a timeout first: the Node engine runs in-process, so a
95
+ non-terminating goal hangs the command, and a test suite that can hang forever is not a test
96
+ suite. The command says so on every run.
97
+
98
+ ## [0.2.0] — 2026-08-16
99
+
100
+ **Breaking: the session API is asynchronous.** `consult`, `next` and `all` now return
101
+ promises, and in the browser the engine runs in a Web Worker.
102
+
103
+ ### Why
104
+
105
+ A Prolog query is synchronous WASM, so a goal that never terminates blocks the thread it
106
+ runs on. Measured, not assumed: `consult('loop :- loop.')` followed by `query('loop').next()`
107
+ stopped Node's event loop dead — a timer scheduled for 3 seconds never fired. In a browser
108
+ that is not a slow page, it is a dead one: no repaint, no button, no way back except closing
109
+ the tab.
110
+
111
+ That is unacceptable here specifically, because **non-termination is chapter material**. Left
112
+ recursion and a generator with no base case are things a Prolog book has to demonstrate, and
113
+ the demonstration has to be survivable.
114
+
115
+ ### Added
116
+
117
+ - **The page says what the engine is holding.** A program cell's tick carries the time it was
118
+ consulted (`title="consulted at 14:32:05"`, absolute — a relative time baked into an
119
+ attribute is wrong the moment it is written), and flips to **edited since consulted** as soon
120
+ as the textarea diverges from what was actually loaded. That state, not the clock, is the
121
+ reader's real question, and it became easy to get wrong the moment Run started consulting
122
+ cells by itself.
123
+ - **Reset, at two scales.** Per cell: restore the chapter's text *and re-consult it*, because
124
+ putting the page back without putting the engine back leaves them disagreeing. Page-level:
125
+ **restart engine**, which throws the worker away and replays the consult log — the documented
126
+ answer for a `:- dynamic` cell whose state lives in no file. Verified in a browser: a counter
127
+ mutated to 41 is 0 again afterwards. Per-cell *unconsult* is deliberately absent; it raises
128
+ "what happens to the cells that depended on it", and page-level restart has no such question.
129
+ - **The engine's state is visible**: *engine not started* until something needs it, which is
130
+ also the plainest evidence that a cold chapter is readable without it.
131
+ - **The chapter is readable before the engine arrives, and without it.** A query cell renders
132
+ the answers stored in the file — labelled as the chapter's, not the reader's (docs/modes.md
133
+ §3) — and the 5.9 MB WASM bundle is fetched only when someone presses Run. Verified in a
134
+ browser by watching the network: on a cold load there is no request for it at all. This is
135
+ what makes a published chapter degrade to a book rather than to a blank page.
136
+ - **Saved answers that no longer follow from the program above them are marked**, before first
137
+ paint and with no engine: the stored `input-hash` is compared against a 64-bit FNV-1a of the
138
+ goal and the program cells preceding it. Marked rather than hidden — never silently
139
+ discarded and never silently trusted.
140
+ - **A chapter is a file.** `prolog-notebook/page` fetches a `.prolog.md`, parses it, renders
141
+ the whole page and wires up the cells: `await load('chapter-04-cut.prolog.md')`. Program and
142
+ query cells are generated from the model rather than marked up by hand, which is what makes
143
+ writing a chapter *writing markdown*.
144
+ - **The `once/1` chapter is now that file**, and the hand-written page it was ported from is
145
+ deleted. Same prose, same four queries, same answers — six duplicated sons, one from `son_a`,
146
+ three from `son_b`, `true` for the ground goal — driven from
147
+ [`notebooks/ch04-cut.prolog.md`](notebooks/ch04-cut.prolog.md) instead of 200 lines of HTML.
148
+ It carries its saved answers and their `input-hash`es, and a test asserts the chapter agrees
149
+ with them, so an edit to a program cell that invalidates an answer below it fails CI.
150
+ On the repo page it reads as a document: prose as prose, Prolog syntax-highlighted, and the
151
+ prediction still hidden behind a `<details>` you have to click.
152
+ - **Run brings its own context.** Pressing Run on a query consults the program cells above it
153
+ first, so a reader who lands halfway down a chapter gets an answer rather than
154
+ `Unknown procedure`. Cells already loaded at their current text are skipped, so the second
155
+ Run of a chapter consults nothing and an edited cell invalidates only itself.
156
+ There is no dependency graph, and there is no need for one: **Prolog has no load-time name
157
+ binding** — `q(X) :- p(X)` merely mentions `p/1`, which is looked up when it is *called* — so
158
+ consult order cannot affect correctness, and at ~3.5 ms a cell there is nothing to gain by
159
+ computing one.
160
+ - **An error a reader can act on.** `wasm:wasm_call_string/3: Unknown procedure: son_a/1` is
161
+ our own plumbing in the middle of a lesson; it now reads `Unknown procedure: son_a/1`, and
162
+ where the predicate is defined by a cell *below* the query, the notebook says so and names
163
+ it. Context frames belonging to the reader's own code are untouched — `//2: Arithmetic:
164
+ evaluation error` still names the division that failed.
165
+ - `prolog-notebook/render` — the cell model to HTML **strings**, DOM-free. The same emitter
166
+ serves the browser today, the static build in v0.3 and the VS Code renderer later; one
167
+ implementation, four consumers.
168
+ - A generated program cell carries its notebook id as `data-cell`, and the consult is named by
169
+ it. SWI now says `Previously defined at /p-family.pl:20` — a warning that names a cell the
170
+ reader can find in the source, rather than `/cell-3.pl`.
171
+ - **`notebooks/` holds chapters and `viewer/` holds the one page that renders them.** They had
172
+ both been living in `example/`, under a script (`npm run example`, now `npm run dev`) that
173
+ actually served the repo root. A chapter is not an example, there will be many of them, and
174
+ the first one had been doubling as a parser fixture *with a deliberately wrong `input-hash`* —
175
+ which a file anybody is meant to read must never carry. The wrong hash now lives in
176
+ `test/fixtures/stale-output.prolog.md`, where being wrong is the point.
177
+ - **The engine runs in a Web Worker** in the browser, so a runaway goal costs a click on
178
+ Stop rather than the tab. Verified by driving Chrome: with `loop` spinning, timers still
179
+ fire, layout still runs, and the notebook is usable again afterwards.
180
+ - `session.abort()` and `session.restart()` — terminate the worker and replay the consults
181
+ into a new engine. Affordable only because one cell is one virtual file, so a chapter's
182
+ clause store rebuilds in milliseconds; terminating also reclaims the whole WASM heap, so a
183
+ memory blow-up and an infinite loop have the same cure.
184
+ - A `stop` button in the query cell, and a `ConsultLog` holding one entry per cell — the
185
+ latest text, not a history — so a replay restores what the reader actually has.
186
+ - `prolog-notebook/format` (0.1.3, listed here for completeness): the notebook parser,
187
+ canonical serialiser and `input-hash`.
188
+
189
+ ### Fixed
190
+
191
+ - **The browser session had no `restart()`**, while the in-process one and the README both did
192
+ — found by wiring a button to it. Both sessions now implement one interface, and a test
193
+ compares them, because nothing else could: Node never runs the worker session and a browser
194
+ never runs the other.
195
+ - `session.formatSolution()` is **removed** rather than added to the worker session. The
196
+ engine-backed one renders through SWI; a worker-backed one could only fall back to the
197
+ engine-free spelling, so the same call would have quietly meant two different things
198
+ depending on where it ran. Use `query.next().text`, or the exported `formatSolution()`.
199
+
200
+ ### Changed
201
+
202
+ - `createSession()` returns a session whose methods are all async. Migration is mechanical:
203
+ `session.consult(…)` → `await session.consult(…)`, `q.next()` → `await q.next()`.
204
+ - The browser page no longer loads the WASM bundle with a `<script>` tag; the worker loads
205
+ it. A page may pass `swiplUrl` if it lives somewhere unusual.
206
+ - Node still runs the engine in-process and is **not** protected against a runaway goal. The
207
+ CLI is not an interactive page; the limitation is documented rather than implied.
208
+
209
+ ### Notes
210
+
211
+ `assert`/`retract` state does not survive an abort, which is already the documented
212
+ behaviour of "restart engine and run all" (`docs/format.md` §8) rather than a new surprise.
213
+
3
214
  ## [0.1.2] — 2026-08-04
4
215
 
5
216
  Two bugs, both of which made the library quietly say something untrue. Found by
package/README.md CHANGED
@@ -32,7 +32,7 @@ the reader clicks a link.
32
32
  The reason this is not "Jupyter with a different kernel" is the button marked `; next`.
33
33
 
34
34
  Jupyter's model is request/response: run a cell, get a result. Prolog's model is a stream of
35
- solutions you walk through. In the included example, `is_son(X)` reports edward *twice* — and
35
+ solutions you walk through. In the included chapter, `is_son(X)` reports edward *twice* — and
36
36
  that duplication **is the lesson**, because it means Prolog found two proofs. A notebook that
37
37
  showed only a final list of results would have hidden the very thing worth teaching.
38
38
 
@@ -44,15 +44,63 @@ So a query cell gives you the first solution, and then you step.
44
44
  git clone https://github.com/jarecsni/prolog-notebook
45
45
  cd prolog-notebook
46
46
  npm install
47
- npm run example # then open http://localhost:8777/example/
47
+ npm run dev # serves the repo root on :8777
48
48
  ```
49
49
 
50
- The example is a real worked section — the `once/1` placement puzzle — not a widget demo.
51
- Predict what each version returns before you press Run.
50
+ Then open **http://localhost:8777/viewer/**. That is a chapter — the `once/1` placement puzzle,
51
+ a real worked section rather than a widget demo — rendered from
52
+ [`notebooks/ch04-cut.prolog.md`](notebooks/ch04-cut.prolog.md). Predict what each version
53
+ returns before you press Run.
52
54
 
53
- It has to be **served over HTTP**. Opening `example/index.html` straight from disk leaves the
54
- buttons inert, because browsers block ES modules over `file://` the page detects this and
55
- says so rather than failing silently.
55
+ Edit that file, reload, and the chapter changes: prose, Prolog, margin note and prediction box
56
+ are all in it, and there is no HTML anywhere. Point the viewer at any other notebook with
57
+ `?src=`.
58
+
59
+ | | |
60
+ |---|---|
61
+ | `notebooks/` | chapters. The product. |
62
+ | `viewer/` | one shell page that renders any of them. Replaced by `build` in v0.3. |
63
+
64
+ The chapter also reads on the repo page, [as a file](notebooks/ch04-cut.prolog.md), with no
65
+ build step and no site: prose as prose, Prolog syntax-highlighted, the saved answers in place,
66
+ and the prediction still hidden behind a `<details>` you have to click. That is the whole
67
+ reason the format is markdown.
68
+
69
+ It has to be **served over HTTP**. Opening the page straight from disk leaves the buttons
70
+ inert, because browsers block ES modules over `file://` — the page detects this and says so
71
+ rather than failing silently.
72
+
73
+ ## Fill in a chapter's answers
74
+
75
+ A chapter's saved answers have to come from a real run — a hand-written output block is the
76
+ author's *guess* at what SWI prints, published as though it ran. So write the file with no
77
+ output blocks and let the engine fill them in:
78
+
79
+ ```sh
80
+ npx prolog-notebook run notebooks/ch04-cut.prolog.md
81
+ ```
82
+
83
+ It consults every program cell, runs every query below it, and writes the solution sequences
84
+ back into the file along with an `input-hash` for each — which is what makes the chapter render
85
+ complete, and render as *current*, before the engine arrives. Running it again on an unchanged
86
+ chapter changes nothing.
87
+
88
+ | flag | |
89
+ |---|---|
90
+ | `--limit <n>` | solutions to take from one query before stopping. Default 100. |
91
+ | `--stdout` | print the result instead of writing the file |
92
+ | `--quiet` | report only failures |
93
+ | `--version` | the tool's version, **the SWI-Prolog version it will run your chapters with**, and the copyright |
94
+
95
+ Two things it will not do. A query stopped at the limit is written **without** a terminator,
96
+ which is the format's way of saying the search was never exhausted — `false.` there would be a
97
+ forgery. And if a program cell fails to load, nothing is written at all: every answer below it
98
+ was produced against a chapter that does not exist.
99
+
100
+ It has no defence against a non-terminating goal yet — the engine runs in this process, so
101
+ `loop :- loop.` hangs the command. Say the word `--limit` all you like; a runaway *consult* is
102
+ not a solution count. Fixing it properly means a worker thread, and it is the prerequisite for
103
+ putting this in CI.
56
104
 
57
105
  ## Use it
58
106
 
@@ -62,36 +110,70 @@ Headless, in Node — this is how you test that every example in a document stil
62
110
  import { createSession, formatSolution } from 'prolog-notebook';
63
111
 
64
112
  const session = await createSession();
65
- session.consult(`
113
+ await session.consult(`
66
114
  male(edward). male(alfred).
67
115
  father(albert, edward). mother(victoria, edward).
68
116
  parent(X, Y) :- father(X, Y) ; mother(X, Y).
69
117
  is_son(X) :- male(X), parent(_, X).
70
- `);
118
+ `, 'cell-family');
71
119
 
72
120
  // Step solutions one at a time, as at the prompt
73
121
  const q = session.query('is_son(X)');
74
122
  let r;
75
- while (!(r = q.next()).done) console.log(formatSolution(r.solution));
123
+ while (!(r = await q.next()).done) console.log(r.text);
76
124
 
77
125
  // …or drain it
78
- const { solutions } = session.query('is_son(X)').all();
126
+ const { solutions } = await session.query('is_son(X)').all();
79
127
  ```
80
128
 
81
- In a browser, load the WASM bundle with a `<script>` tag, then import from
82
- `prolog-notebook/browser`. See [`example/index.html`](example/index.html) for the cell
83
- markup and [`src/notebook.js`](src/notebook.js) for the wiring.
129
+ **Every call is awaited**, because in the browser the engine runs in a Web Worker. A Prolog
130
+ query is synchronous WASM, so a goal that never terminates blocks whatever thread it is on —
131
+ and non-termination is *chapter material* in a Prolog book. Running the engine somewhere that
132
+ can be terminated is what turns `loop :- loop.` from a dead tab into a Stop button.
133
+
134
+ In a browser, import from `prolog-notebook/browser` and let it start the worker; the page does
135
+ **not** need a `<script>` tag for the WASM bundle any more, because the worker loads it. See
136
+ [`viewer/index.html`](viewer/index.html) for the whole of a host page, and
137
+ [`src/notebook.js`](src/notebook.js) for the wiring.
138
+
139
+ ```js
140
+ import { createSession } from 'prolog-notebook/browser';
141
+ const session = await createSession(); // boots the worker
142
+ await session.abort(); // stop a runaway goal; cells are re-consulted
143
+ ```
144
+
145
+ To render a notebook file instead of marking up cells by hand — the whole page from one call:
146
+
147
+ ```js
148
+ import { load } from 'prolog-notebook/page';
149
+ await load('chapter-04-cut.prolog.md'); // parse, render into <main>, wire up the cells
150
+ ```
151
+
152
+ `prolog-notebook/format` (parse, serialise, `inputHash`) and `prolog-notebook/render` (model to
153
+ HTML strings) are exported separately, and neither touches the DOM — the same two modules back
154
+ the browser, the CLI runner and a future VS Code serializer.
155
+
156
+ Node runs the engine in-process and is deliberately **not** protected: a non-terminating goal
157
+ will hang it. The CLI is not an interactive page, and pretending otherwise would hide the
158
+ difference.
84
159
 
85
160
  ### API
86
161
 
87
162
  | | |
88
163
  |---|---|
89
- | `createSession(options?)` | boots SWI-Prolog; returns a `PrologSession` |
90
- | `session.consult(text, name?)` | loads a clause base into `user`; `{ok, error?}` |
91
- | `session.query(goal)` | opens a query; returns a `PrologQuery` |
92
- | `query.next()` | one solution: `{done, solution?, error?}` |
93
- | `query.all(limit?)` | drains it: `{solutions, error?, truncated}` |
94
- | `formatSolution(s)` | renders bindings the way a top level would |
164
+ | `createSession(options?)` | boots SWI-Prolog; returns a session |
165
+ | `await session.consult(text, name?)` | loads a clause base into `user`; `{ok, error?, messages}` |
166
+ | `session.query(goal)` | opens a query; nothing runs until you pull a solution |
167
+ | `await query.next()` | one solution: `{done, solution?, text?, error?}` |
168
+ | `await query.all(limit?)` | drains it: `{solutions, error?, truncated}` |
169
+ | `await session.abort()` | terminates a running goal and replays the consults |
170
+ | `await session.restart()` | same, without anything needing to be running |
171
+ | `formatSolution(s)` | renders bindings the way a top level would, with no engine |
172
+
173
+ Abort is cheap because of a decision made elsewhere: one cell is one virtual file, so the
174
+ clause store rebuilds from the cells in milliseconds. Terminating the worker also reclaims the
175
+ whole WASM heap, so a runaway loop and a memory blow-up have the same cure. Assert/retract
176
+ state does not survive it — see [`docs/format.md`](docs/format.md) §8.
95
177
 
96
178
  ## Two things that will bite you if you build this yourself
97
179
 
@@ -99,7 +181,19 @@ Both were found by driving a real browser, not by reading documentation.
99
181
 
100
182
  **Module context.** `prolog.query(Goal)` runs with `system` as its context module, while
101
183
  `consult/1` loads into `user`. Unqualified goals raise `Unknown procedure: system:foo/1`
102
- *even though the consult reported success*. Goals are wrapped as `user:( Goal )`.
184
+ *even though the consult reported success*. Goals are read and called in `user`.
185
+
186
+ **One round trip per answer, or the variables come apart.** Formatting each binding with its
187
+ own `term_string/2` call loses the fact that two of them are the *same* variable:
188
+ `app([1,2], Tail, L)` came out as `L = [1,2|_20306], Tail = _20428` where a real toplevel
189
+ prints `L = [1, 2|Tail]`. Render the whole answer inside Prolog, in one call, with the goal's
190
+ own `variable_names`.
191
+
192
+ **The engine version is not the package version, and it must not float.**
193
+ `swipl-wasm@8.0.4` ships SWI-Prolog 10.1.10; `8.0.7` ships 10.1.13. Two installs of the same
194
+ release, ten minutes apart, ran different Prologs. Since a notebook's saved answers are only
195
+ true of the engine that produced them — and SWI's answer spelling changes between releases —
196
+ the dependency is pinned exactly, and moving it is a commit with the chapters re-run in it.
103
197
 
104
198
  **The last solution arrives with `done`.** `next()` can return `{done: true, value: {...}}` —
105
199
  a final binding and the end of the search in a single step. Treating `done` as "stop, no more
@@ -110,18 +204,27 @@ lesson about backtracking quietly teaches the wrong thing.
110
204
 
111
205
  Working and tested:
112
206
 
113
- - execution core, environment-agnostic (`src/engine.js`), 8 passing tests
207
+ - execution core, environment-agnostic (`src/engine.js`), run in a Web Worker in the browser
114
208
  - Node entry point, browser entry point
115
- - program cells and query cells with `Run` / `; next` / `all`
116
- - the worked example
209
+ - **a chapter is a file** parse, render and mount a `.prolog.md`, cells and all
210
+ - **a chapter is readable cold** — saved answers render with no engine, and are marked stale
211
+ when the program above them has moved
212
+ - program cells and query cells with `Run` / `; next` / `all` / `stop`, per-cell reset, and a
213
+ page that says what the engine is holding
214
+ - `hold` and `rerun="auto"` — the author decides what a reader may see and when it refreshes
215
+ - **the CLI runner**: `prolog-notebook run` executes a chapter headlessly and writes its
216
+ answers back
217
+ - download your own copy of a chapter, answers and all
218
+ - 186 passing tests
117
219
 
118
220
  Not built yet:
119
221
 
120
- - **a file-backed renderer** reading `.ipynb` or markdown and *generating* the cells.
121
- Today the example's cells are hand-written HTML. This is the next real piece of work.
222
+ - `--check` run a chapter in CI and fail the build when its answers have drifted. Needs a
223
+ timeout first: a test suite that can hang forever is not a test suite.
224
+ - `build` — a static page a reader can open, without a dev server
122
225
  - custom elements (`<prolog-program>`, `<prolog-query>`) so notebooks drop into any static site
123
226
  - a VS Code notebook controller — VS Code supplies the UI, this supplies the kernel, still no Python
124
- - a CLI runner, to execute a document's cells in CI and fail the build when an example rots
227
+ - persistence, so a reader's edits survive a reload
125
228
  - `trace/0` integration, for visible backtracking — where [prolog-trace-viz][ptv] would plug in
126
229
  - syntax highlighting
127
230
 
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ // The command line. Thin on purpose: argument parsing, files, and words for a
3
+ // terminal. Everything it does lives in src/run.js and src/export.js, so a VS
4
+ // Code "run all" and a future --check get the same behaviour without going
5
+ // through a shell (869ectt38, 869ectt3e).
6
+ import { createRequire } from 'node:module';
7
+ import { readFileSync, writeFileSync } from 'node:fs';
8
+ import { basename } from 'node:path';
9
+ import { parse, NotebookError } from '../src/format.js';
10
+ import { prologVersion } from '../src/engine.js';
11
+ import { buildLine, currentBuild } from '../src/build-info.js';
12
+ import { banner } from '../src/version.js';
13
+ import { exportSource } from '../src/export.js';
14
+ import { runNotebook, DEFAULT_LIMIT } from '../src/run.js';
15
+
16
+ // The engine is imported WHERE IT IS USED, never at the top. src/node.js pulls in
17
+ // 5.9 MB of WebAssembly at module scope, so a static import here would mean that
18
+ // `--help` on a broken install fails before it can print anything — and the two
19
+ // commands most likely to be typed at a broken install are --help and --version.
20
+ const engine = () => import('../src/node.js');
21
+
22
+ // `prolog-notebook run --stdout file | head` closes the pipe while we are still
23
+ // writing to it. That is the reader using the shell correctly, not an error, and
24
+ // a command that answers it with an unhandled EPIPE and a stack trace is
25
+ // complaining about being used properly.
26
+ for (const stream of [process.stdout, process.stderr]) {
27
+ stream.on('error', (e) => {
28
+ if (e.code !== 'EPIPE') throw e;
29
+ });
30
+ }
31
+
32
+ // Only for swipl-wasm's own version: everything about THIS package is in
33
+ // src/version.js, where a page can import it too.
34
+ const require = createRequire(import.meta.url);
35
+
36
+ const USAGE = `prolog-notebook — Jupyter-style notebooks for Prolog
37
+
38
+ prolog-notebook run <file.prolog.md>... run every cell, write the answers back
39
+
40
+ Options
41
+ --limit <n> solutions to take from one query before stopping (default ${DEFAULT_LIMIT})
42
+ --stdout print the result instead of writing the file
43
+ --quiet report only failures
44
+ --version version, engine and copyright
45
+ -h, --help this
46
+
47
+ A query that stops at the limit is written without a terminator, which is the
48
+ format's way of saying the search was never exhausted. Nothing is invented.
49
+ `;
50
+
51
+ /**
52
+ * A runaway goal hangs this process — the engine is in-process here, so there is
53
+ * no thread left to notice (869ejgyax). Stated rather than implied, because the
54
+ * moment this runs a file someone else wrote it stops being an annoyance.
55
+ */
56
+ const RUNAWAY_WARNING = 'note: a non-terminating goal will hang this command; it has no timeout yet (869ejgyax)';
57
+
58
+ /**
59
+ * Who this is, and — the part that is not on anyone's disk — which Prolog it
60
+ * will run your chapters with.
61
+ *
62
+ * THE ENGINE LINE EARNS ITS 59 MILLISECONDS. swipl-wasm's own version says
63
+ * nothing about SWI's: 8.0.4 ships 10.1.10. A notebook's saved answers are only
64
+ * true of the engine that produced them, so this is the one fact here that a
65
+ * reader could not have looked up.
66
+ *
67
+ * An engine that will not load is REPORTED, not fatal. "I cannot start Prolog"
68
+ * is exactly what someone running --version to diagnose a broken install needs
69
+ * to be told, and exiting non-zero would hide it behind a shell error.
70
+ */
71
+ async function version() {
72
+ // The same line the page shows in its panel: src/version.js, imported by both.
73
+ const lines = [banner()];
74
+ try {
75
+ const { createSession } = await engine();
76
+ const swipl = await prologVersion(await createSession());
77
+ const wasm = `swipl-wasm ${require('swipl-wasm/package.json').version}`;
78
+ lines.push(swipl ? `Powered by SWI-Prolog ${swipl}, ${wasm}` : `Powered by ${wasm}`);
79
+ } catch (e) {
80
+ // Not "powered by" anything, so it does not say so. Someone running this to
81
+ // find out why nothing works needs the reason, not a formula.
82
+ lines.push(`SWI-Prolog could not be started: ${e.message}`);
83
+ }
84
+ // Omitted rather than guessed at when there is neither a baked file nor a git
85
+ // repository — a line that says "unknown" three times is worse than no line.
86
+ lines.push(buildLine(currentBuild()));
87
+ // The blank line is deliberate: this is a banner, and a banner that runs into
88
+ // the next shell prompt reads as an error message.
89
+ return `${lines.join('\n')}\n\n`;
90
+ }
91
+
92
+ async function main(argv) {
93
+ const args = argv.slice(2);
94
+ if (!args.length || args.includes('-h') || args.includes('--help')) {
95
+ process.stdout.write(USAGE);
96
+ return 0;
97
+ }
98
+ if (args.includes('--version') || args.includes('-V')) {
99
+ process.stdout.write(await version());
100
+ return 0;
101
+ }
102
+
103
+ const command = args.shift();
104
+ if (command !== 'run') {
105
+ process.stderr.write(`unknown command "${command}"\n\n${USAGE}`);
106
+ return 2;
107
+ }
108
+
109
+ const options = { limit: DEFAULT_LIMIT, stdout: false, quiet: false };
110
+ const files = [];
111
+ while (args.length) {
112
+ const arg = args.shift();
113
+ if (arg === '--limit') {
114
+ const value = Number(args.shift());
115
+ if (!Number.isInteger(value) || value < 1) {
116
+ process.stderr.write('--limit takes a positive whole number\n');
117
+ return 2;
118
+ }
119
+ options.limit = value;
120
+ } else if (arg === '--stdout') options.stdout = true;
121
+ else if (arg === '--quiet') options.quiet = true;
122
+ else if (arg.startsWith('-')) {
123
+ process.stderr.write(`unknown option "${arg}"\n\n${USAGE}`);
124
+ return 2;
125
+ } else files.push(arg);
126
+ }
127
+
128
+ if (!files.length) {
129
+ process.stderr.write('run needs at least one file\n');
130
+ return 2;
131
+ }
132
+ if (!options.quiet) process.stderr.write(`${RUNAWAY_WARNING}\n`);
133
+
134
+ // One engine for the whole invocation, restarted between files. A notebook is
135
+ // a world of its own — one cell is one virtual file, and two chapters may
136
+ // define the same predicate — so carrying clauses across would let a file pass
137
+ // because of what the file before it happened to load.
138
+ const { createSession } = await engine();
139
+ const session = await createSession();
140
+ let status = 0;
141
+
142
+ for (const file of files) {
143
+ await session.restart();
144
+ status = Math.max(status, await runFile(file, session, options));
145
+ }
146
+ return status;
147
+ }
148
+
149
+ async function runFile(file, session, options) {
150
+ const name = basename(file);
151
+ let notebook;
152
+ let source;
153
+ try {
154
+ source = readFileSync(file, 'utf8');
155
+ notebook = parse(source);
156
+ } catch (e) {
157
+ // The parser's line numbers are the file's own, so its message is already
158
+ // the most useful thing anyone could say here.
159
+ process.stderr.write(`${file}: ${e instanceof NotebookError ? e.message : e.message}\n`);
160
+ return 1;
161
+ }
162
+
163
+ const { edits, failures, warnings } = await runNotebook(notebook, session, {
164
+ limit: options.limit,
165
+ onCell: (event) => {
166
+ if (options.quiet) return;
167
+ if (event.kind === 'program') {
168
+ process.stderr.write(` ${event.ok ? '✓' : '✗'} ${event.id}\n`);
169
+ return;
170
+ }
171
+ const answers = event.error
172
+ ? `error: ${event.error}`
173
+ : `${event.solutions.length} solution${event.solutions.length === 1 ? '' : 's'}`
174
+ + (event.truncated ? ` (stopped at ${options.limit}, not exhausted)` : '');
175
+ process.stderr.write(` ${event.error ? '✗' : '✓'} ${event.id} — ${answers}\n`);
176
+ },
177
+ });
178
+
179
+ for (const warning of warnings) process.stderr.write(` ! ${warning.id}: ${warning.text}\n`);
180
+
181
+ if (failures.length) {
182
+ // NOTHING IS WRITTEN when a program cell failed to load. Every answer below
183
+ // it was produced against a chapter that does not exist, and writing those
184
+ // into the file would publish them as though they did.
185
+ for (const failure of failures) {
186
+ process.stderr.write(`${file}: cell ${failure.id} did not load: ${failure.error}\n`);
187
+ }
188
+ process.stderr.write(`${name}: not written\n`);
189
+ return 1;
190
+ }
191
+
192
+ const text = exportSource(notebook, edits);
193
+ if (options.stdout) {
194
+ process.stdout.write(text);
195
+ return 0;
196
+ }
197
+ if (text === source) {
198
+ if (!options.quiet) process.stderr.write(`${name}: unchanged\n`);
199
+ return 0;
200
+ }
201
+ writeFileSync(file, text);
202
+ if (!options.quiet) process.stderr.write(`${name}: written\n`);
203
+ return 0;
204
+ }
205
+
206
+ process.exitCode = await main(process.argv);