prolog-notebook 0.1.2 → 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/package.json CHANGED
@@ -1,16 +1,27 @@
1
1
  {
2
2
  "name": "prolog-notebook",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Jupyter-style notebooks for Prolog. Runs in the browser, installs nothing.",
5
5
  "type": "module",
6
6
  "main": "./src/node.js",
7
+ "bin": {
8
+ "prolog-notebook": "./bin/prolog-notebook.mjs"
9
+ },
7
10
  "exports": {
8
11
  ".": "./src/node.js",
9
12
  "./browser": "./src/browser.js",
10
13
  "./engine": "./src/engine.js",
11
- "./notebook.css": "./src/notebook.css"
14
+ "./format": "./src/format.js",
15
+ "./notebook.css": "./src/notebook.css",
16
+ "./page": "./src/page.js",
17
+ "./render": "./src/render.js",
18
+ "./session": "./src/session.js",
19
+ "./worker": "./src/worker.js",
20
+ "./run": "./src/run.js",
21
+ "./export": "./src/export.js"
12
22
  },
13
23
  "files": [
24
+ "bin",
14
25
  "src",
15
26
  "README.md",
16
27
  "LICENSE",
@@ -18,7 +29,7 @@
18
29
  ],
19
30
  "scripts": {
20
31
  "test": "node --test test/*.test.mjs",
21
- "example": "python3 -m http.server 8777",
32
+ "dev": "python3 -m http.server 8777",
22
33
  "prepublishOnly": "npm test"
23
34
  },
24
35
  "keywords": [
@@ -45,9 +56,13 @@
45
56
  "node": ">=18"
46
57
  },
47
58
  "dependencies": {
48
- "swipl-wasm": "^8.0.4"
59
+ "markdown-it": "^15.0.0",
60
+ "swipl-wasm": "8.0.7"
49
61
  },
50
62
  "publishConfig": {
51
63
  "access": "public"
64
+ },
65
+ "devDependencies": {
66
+ "jsdom": "^29.1.1"
52
67
  }
53
68
  }
package/src/browser.js CHANGED
@@ -1,25 +1,261 @@
1
- // Browser entry point. The swipl-wasm bundle is loaded by a <script> tag and
2
- // exposes a global SWIPL factory; keeping the load out of this module means the
3
- // 5.9 MB bundle is fetched by the page, not by a bundler.
4
- import { PrologSession } from './engine.js';
1
+ // Browser entry point. The engine runs in a Web Worker, so a query that never
2
+ // terminates costs the reader a click on Stop rather than the whole tab.
3
+ //
4
+ // The 5.9 MB swipl bundle is still fetched by the page's own URL rather than by a
5
+ // bundler — the worker is told where to find it.
6
+ import { ConsultLog, defaultCellName, unconsult } from './session.js';
5
7
 
6
8
  export * from './engine.js';
9
+ export { ConsultLog } from './session.js';
7
10
 
8
- let session = null;
11
+ const DEFAULT_SWIPL_URL = new URL(
12
+ '../node_modules/swipl-wasm/dist/swipl/swipl-bundle.js',
13
+ import.meta.url
14
+ ).href;
9
15
 
10
16
  /**
11
- * Boot (once) and return the shared session for this page.
12
- * @returns {Promise<PrologSession>}
17
+ * A session whose engine lives in a worker.
18
+ *
19
+ * Every method returns a promise. `abort()` terminates the worker outright and
20
+ * replays the consult log into a new one, which is the only thing that works: a
21
+ * thread blocked inside WASM cannot be asked politely to stop.
13
22
  */
14
- export function createSession(options = {}) {
15
- if (!session) {
16
- if (typeof globalThis.SWIPL !== 'function') {
17
- throw new Error(
18
- 'swipl-wasm not found. Load it first, e.g.\n' +
19
- '<script src="node_modules/swipl-wasm/dist/swipl/swipl-bundle.js"></script>'
20
- );
23
+ export class WorkerSession {
24
+ #worker = null;
25
+ #pending = new Map();
26
+ #nextId = 1;
27
+ #booting = null;
28
+ /** The one query allowed to hold a frame. See supersede(). */
29
+ #open = null;
30
+
31
+ constructor({ workerUrl, swiplUrl = DEFAULT_SWIPL_URL, engineUrl, options = {} } = {}) {
32
+ this.workerUrl = workerUrl ?? new URL('./worker.js', import.meta.url).href;
33
+ this.swiplUrl = swiplUrl;
34
+ // The worker imports the engine itself, so it needs an absolute URL: a
35
+ // relative specifier would resolve against the worker script, which may have
36
+ // been served from anywhere.
37
+ this.engineUrl = engineUrl ?? new URL('./engine.js', import.meta.url).href;
38
+ this.options = options;
39
+ this.log = new ConsultLog();
40
+ }
41
+
42
+ /** Boot the worker and the engine inside it. Idempotent. */
43
+ async start() {
44
+ if (this.#worker) return this;
45
+ if (this.#booting) return this.#booting;
46
+ this.#booting = (async () => {
47
+ this.#spawn();
48
+ await this.#send('boot', {
49
+ swiplUrl: this.swiplUrl,
50
+ engineUrl: this.engineUrl,
51
+ options: this.options,
52
+ });
53
+ this.#booting = null;
54
+ return this;
55
+ })();
56
+ return this.#booting;
57
+ }
58
+
59
+ async consult(text, name = defaultCellName()) {
60
+ await this.start();
61
+ const result = await this.#send('consult', { text, name });
62
+ if (result.ok) this.log.record(name, text);
63
+ return result;
64
+ }
65
+
66
+ /**
67
+ * Open a query. Nothing runs until the first `next()` or `all()`, so opening
68
+ * one is always safe even if the goal is a disaster.
69
+ */
70
+ query(goal) {
71
+ return new WorkerQuery(this, goal);
72
+ }
73
+
74
+ /**
75
+ * ONE OPEN SEQUENCE PER SESSION, and the reason is not tidiness.
76
+ *
77
+ * SWI keeps open queries on a stack and swipl-wasm enforces it: stepping or
78
+ * closing anything but the innermost throws "Attempt to access not innermost
79
+ * query". A page cannot promise the order — the order is whatever the reader
80
+ * clicks — so the constraint is met by construction instead: there is never
81
+ * more than one open query, which means the one being closed is always the
82
+ * innermost, which means the close is always legal (869epzqpc).
83
+ *
84
+ * Called at the moment a frame is about to be opened, never when the query
85
+ * OBJECT is made: a cell whose Run fails before it ever steps must not end
86
+ * someone else's sequence for nothing.
87
+ *
88
+ * @internal
89
+ */
90
+ async supersede() {
91
+ const previous = this.#open;
92
+ this.#open = null;
93
+ if (!previous) return;
94
+ await previous.close({ superseded: true });
95
+ // Said only after the frame is actually gone, so a listener that starts a new
96
+ // query cannot race the close it was told about.
97
+ previous.onSuperseded?.();
98
+ }
99
+
100
+ /** @internal a query's frame is gone — exhausted, closed, or died with the engine. */
101
+ release(query) {
102
+ if (this.#open === query) this.#open = null;
103
+ }
104
+
105
+ /** @internal a query has just taken the session's one frame. */
106
+ hold(query) {
107
+ this.#open = query;
108
+ }
109
+
110
+ /** Take one cell's clauses back out. See unconsult() in session.js. */
111
+ async unconsult(name) {
112
+ return unconsult(this, name);
113
+ }
114
+
115
+ /**
116
+ * Throw the engine away and rebuild it from the consult log.
117
+ *
118
+ * Terminating is not a last resort here, it is the mechanism: it reclaims the
119
+ * whole WASM heap as well as the stuck goal, so a memory blow-up and an
120
+ * infinite loop have the same cure.
121
+ *
122
+ * A `:- dynamic` cell's assert/retract state does not survive this, which is
123
+ * already the documented behaviour of "restart engine and run all"
124
+ * (format §8) rather than a new surprise.
125
+ */
126
+ async restart() {
127
+ this.#teardown(new Error('aborted'));
128
+ // Every frame died with the worker, so nothing is holding the session's.
129
+ this.#open = null;
130
+ await this.start();
131
+ for (const { name, text } of this.log) {
132
+ await this.#send('consult', { text, name });
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Stop whatever is running. Identical to restart() here, and named separately
138
+ * because the two are different intentions: one rescues a page, the other
139
+ * throws away assert/retract state deliberately.
140
+ */
141
+ async abort() {
142
+ return this.restart();
143
+ }
144
+
145
+ async close() {
146
+ this.#teardown(new Error('session closed'));
147
+ }
148
+
149
+ #spawn() {
150
+ // Classic, not module: swipl-wasm has no ESM entry, and importScripts is the
151
+ // only way to get its global. See the comment at the top of worker.js.
152
+ this.#worker = new Worker(this.workerUrl);
153
+ this.#worker.onmessage = ({ data }) => {
154
+ const entry = this.#pending.get(data.id);
155
+ if (!entry) return;
156
+ this.#pending.delete(data.id);
157
+ if (data.ok) entry.resolve(data.value);
158
+ else entry.reject(new Error(data.error));
159
+ };
160
+ this.#worker.onerror = (event) => {
161
+ this.#teardown(new Error(event.message ?? 'worker failed'));
162
+ };
163
+ }
164
+
165
+ #teardown(reason) {
166
+ this.#open = null;
167
+ this.#worker?.terminate();
168
+ this.#worker = null;
169
+ this.#booting = null;
170
+ // Anything still waiting will never hear back, so say so rather than leaving
171
+ // a promise dangling forever — a silent hang is what this whole change exists
172
+ // to remove.
173
+ for (const { reject } of this.#pending.values()) reject(reason);
174
+ this.#pending.clear();
175
+ }
176
+
177
+ #send(op, args = {}) {
178
+ if (!this.#worker) return Promise.reject(new Error('worker is not running'));
179
+ const id = this.#nextId++;
180
+ return new Promise((resolve, reject) => {
181
+ this.#pending.set(id, { resolve, reject });
182
+ this.#worker.postMessage({ id, op, ...args });
183
+ });
184
+ }
185
+
186
+ /** @internal */
187
+ send(op, args) {
188
+ return this.#send(op, args);
189
+ }
190
+ }
191
+
192
+ class WorkerQuery {
193
+ #qid = null;
194
+
195
+ constructor(session, goal) {
196
+ this.session = session;
197
+ this.goal = goal;
198
+ this.done = false;
199
+ // Ended by another query taking the session's one frame, rather than by its
200
+ // own search finishing. Kept apart from `done` because only one of the two
201
+ // may ever be written down as an exhausted search (format §6).
202
+ this.superseded = false;
203
+ /** Set by the caller to hear that its sequence was closed for another one. */
204
+ this.onSuperseded = null;
205
+ }
206
+
207
+ async #open() {
208
+ if (this.#qid === null) {
209
+ await this.session.start();
210
+ // Before the frame exists, never after: once a second query is open the
211
+ // first is no longer innermost and can never be closed at all.
212
+ await this.session.supersede();
213
+ this.#qid = await this.session.send('open', { goal: this.goal });
214
+ this.session.hold(this);
21
215
  }
22
- session = PrologSession.create(globalThis.SWIPL, options);
216
+ return this.#qid;
23
217
  }
24
- return session;
218
+
219
+ async next() {
220
+ if (this.done) return this.superseded ? { done: true, superseded: true } : { done: true };
221
+ const qid = await this.#open();
222
+ const result = await this.session.send('next', { qid });
223
+ // The worker forgets a query that reports done — swipl-wasm has closed it —
224
+ // so the frame is already back and nothing here needs to ask for it.
225
+ if (result.done) this.#finish();
226
+ return result;
227
+ }
228
+
229
+ async all(limit) {
230
+ if (this.done) return { solutions: [], truncated: false };
231
+ const qid = await this.#open();
232
+ this.#finish();
233
+ return this.session.send('all', { qid, limit });
234
+ }
235
+
236
+ async close({ superseded = false } = {}) {
237
+ if (superseded) this.superseded = true;
238
+ if (this.#qid === null || this.done) {
239
+ this.#finish();
240
+ return;
241
+ }
242
+ this.#finish();
243
+ await this.session.send('close', { qid: this.#qid });
244
+ }
245
+
246
+ #finish() {
247
+ this.done = true;
248
+ this.session.release(this);
249
+ }
250
+ }
251
+
252
+ let shared = null;
253
+
254
+ /**
255
+ * Boot (once) and return the shared session for this page.
256
+ * @returns {Promise<WorkerSession>}
257
+ */
258
+ export function createSession(options = {}) {
259
+ if (!shared) shared = new WorkerSession(options).start();
260
+ return shared;
25
261
  }
@@ -0,0 +1,94 @@
1
+ // Which copy of this you are actually running.
2
+ //
3
+ // A version number answers "which release"; it does not answer "which of the
4
+ // four things on this machine claiming to be 0.2.0". A published install, a
5
+ // checkout with the branch still on it, and an npm-linked working copy with
6
+ // uncommitted edits are three different programs, and a bug report against the
7
+ // wrong one costs an afternoon.
8
+ //
9
+ // TWO STATES, NAMED, because they are not the same claim:
10
+ //
11
+ // Build ccf8e5b, committed 2026-08-30, packaged 2026-08-30
12
+ // Working copy ccf8e5b (modified), committed 2026-08-30
13
+ //
14
+ // The first is baked in by the release workflow just before publish — git exists
15
+ // there and does not exist inside an installed package. The second is read from
16
+ // git at run time, and says `(modified)` when the tree has edits, because a bare
17
+ // SHA over a dirty tree names a program that nobody has.
18
+ //
19
+ // NOTHING IS BUILT HERE. The package is plain ES modules, published as written,
20
+ // so "packaged" is the honest word for the third date — there is no compiler and
21
+ // no output to date-stamp. A working copy has no packaging time at all, which is
22
+ // why the second state has two fields rather than three.
23
+ import { execFileSync } from 'node:child_process';
24
+ import { readFileSync } from 'node:fs';
25
+
26
+ /** Where prepack leaves the facts. Inside `src`, so `files` already ships it. */
27
+ const BAKED = new URL('./build-info.json', import.meta.url);
28
+
29
+ /**
30
+ * The provenance line, or null when nothing is known.
31
+ *
32
+ * Pure, so both states can be tested without a filesystem or a git repository.
33
+ *
34
+ * @param {{commit: string, committed: string, packaged?: string, modified?: boolean}|null} info
35
+ * @returns {string|null}
36
+ */
37
+ export function buildLine(info) {
38
+ if (!info?.commit) return null;
39
+ if (info.packaged) {
40
+ return `Build ${info.commit}, committed ${info.committed}, packaged ${info.packaged}`;
41
+ }
42
+ return `Working copy ${info.commit}${info.modified ? ' (modified)' : ''}, committed ${info.committed}`;
43
+ }
44
+
45
+ /**
46
+ * What this copy is, from whichever of the two sources exists.
47
+ *
48
+ * Null rather than a guess when neither does — a tarball built before any of
49
+ * this existed, or a source tree with no history. A line that says "unknown"
50
+ * three times is worse than no line.
51
+ *
52
+ * @returns {{commit: string, committed: string, packaged?: string, modified?: boolean}|null}
53
+ */
54
+ export function currentBuild() {
55
+ try {
56
+ const baked = JSON.parse(readFileSync(BAKED, 'utf8'));
57
+ if (baked?.commit) return baked;
58
+ } catch {
59
+ // No file, or a damaged one. Either way git is the better authority here.
60
+ }
61
+ return fromGit();
62
+ }
63
+
64
+ /**
65
+ * Ask git, which is only there in a working copy.
66
+ *
67
+ * `execFileSync` with fixed arguments and no shell. It costs about ten
68
+ * milliseconds and only ever runs in development, where the alternative is a
69
+ * command that cannot tell you which of your own commits it is.
70
+ */
71
+ function fromGit() {
72
+ const root = new URL('..', import.meta.url);
73
+ const git = (...args) => execFileSync('git', ['-C', root.pathname, ...args], {
74
+ encoding: 'utf8',
75
+ stdio: ['ignore', 'pipe', 'ignore'],
76
+ }).trim();
77
+ try {
78
+ const [commit, committed] = git('log', '-1', '--format=%h %cs').split(' ');
79
+ return { commit, committed, modified: git('status', '--porcelain') !== '' };
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * The facts, as prepack writes them. Exported so the script that runs at pack
87
+ * time and the code that reads the result agree on the shape.
88
+ *
89
+ * @param {{commit: string, committed: string}} head
90
+ * @param {Date} [now]
91
+ */
92
+ export function bakedFrom(head, now = new Date()) {
93
+ return { ...head, packaged: now.toISOString().slice(0, 10) };
94
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "commit": "ca69c2a",
3
+ "committed": "2026-08-30",
4
+ "packaged": "2026-08-30"
5
+ }
package/src/clauses.js ADDED
@@ -0,0 +1,236 @@
1
+ // What does this program cell define?
2
+ //
3
+ // A deliberately shallow reading of Prolog source — clause heads only, no terms,
4
+ // no operators, no module qualification. That is not laziness, it is the design:
5
+ // the answer is only ever used to IMPROVE AN ERROR MESSAGE that the engine has
6
+ // already produced. A head this misses costs a hint; a head it invents costs a
7
+ // hint that is wrong about a predicate nobody asked about. Neither can change
8
+ // what a query does, which is why a regex is an honest tool here and would not
9
+ // be anywhere near the execution path.
10
+ //
11
+ // DOM-free and engine-free, like format.js: the same reading serves the browser,
12
+ // the CLI runner and eventually the `:- dynamic` detection that 869eddzfp needs.
13
+
14
+ /** `name` and `'quoted name'`, the two spellings of a functor. */
15
+ const FUNCTOR = /^(?:'((?:[^'\\]|\\.)*)'|([a-z][a-zA-Z0-9_]*))/;
16
+
17
+ /**
18
+ * The predicate indicators a cell defines, as `name/arity`.
19
+ *
20
+ * @param {string} source Prolog text
21
+ * @returns {Set<string>}
22
+ */
23
+ export function definedPredicates(source) {
24
+ const found = new Set();
25
+ for (const line of clauseStarts(source)) {
26
+ const indicator = headOf(line);
27
+ if (indicator) found.add(indicator);
28
+ }
29
+ return found;
30
+ }
31
+
32
+ /**
33
+ * Lines that can begin a clause.
34
+ *
35
+ * A clause head starts at column 0 — the same rule the notebook format relies on
36
+ * for cells (format §1), and the reason both can be line scanners. Continuation
37
+ * lines of a clause body are indented by every convention in use, including
38
+ * SWI's own portray_clause.
39
+ */
40
+ function clauseStarts(source) {
41
+ const lines = [];
42
+ let inBlockComment = false;
43
+ for (const raw of source.split('\n')) {
44
+ let line = raw;
45
+ if (inBlockComment) {
46
+ const end = line.indexOf('*/');
47
+ if (end === -1) continue;
48
+ line = line.slice(end + 2);
49
+ inBlockComment = false;
50
+ }
51
+ // A block comment opening on this line takes the rest of it with it.
52
+ const open = line.indexOf('/*');
53
+ if (open !== -1 && line.indexOf('*/', open) === -1) {
54
+ inBlockComment = true;
55
+ line = line.slice(0, open);
56
+ }
57
+ if (/^\s/.test(line) || line.trim() === '') continue;
58
+ if (line.startsWith('%')) continue;
59
+ // A directive is an instruction to the loader, not a definition. `:- dynamic
60
+ // counter/1.` declares one, but the clauses are still what define it.
61
+ if (line.startsWith(':-') || line.startsWith('?-')) continue;
62
+ lines.push(line);
63
+ }
64
+ return lines;
65
+ }
66
+
67
+ /**
68
+ * `foo(a, b) :- …` → `foo/2`. Null if the line does not start with a functor.
69
+ */
70
+ function headOf(line) {
71
+ const m = FUNCTOR.exec(line);
72
+ if (!m) return null;
73
+ const name = m[1] !== undefined ? m[1].replace(/\\(.)/g, '$1') : m[2];
74
+ const rest = line.slice(m[0].length);
75
+
76
+ let arity = 0;
77
+ let after = rest;
78
+ if (rest.startsWith('(')) {
79
+ const args = countArguments(rest);
80
+ if (args === null) return null;
81
+ arity = args.count;
82
+ after = rest.slice(args.end + 1);
83
+ }
84
+
85
+ // A DCG rule defines a predicate with two extra arguments — the difference list
86
+ // SWI threads through it. `greeting --> [hello]` is greeting/2, and a reader
87
+ // told otherwise would go looking for greeting/0.
88
+ if (/^\s*-->/.test(after)) arity += 2;
89
+
90
+ return `${name}/${arity}`;
91
+ }
92
+
93
+ /**
94
+ * Count top-level arguments in `(…)`, respecting nesting and quotes.
95
+ *
96
+ * @returns {{count: number, end: number}|null} null if the parenthesis never closes
97
+ */
98
+ function countArguments(text) {
99
+ let depth = 0;
100
+ let count = 1;
101
+ let quote = null;
102
+ for (let i = 0; i < text.length; i++) {
103
+ const c = text[i];
104
+ if (quote) {
105
+ if (c === '\\') i++;
106
+ else if (c === quote) quote = null;
107
+ continue;
108
+ }
109
+ if (c === "'" || c === '"' || c === '`') { quote = c; continue; }
110
+ if (c === '(' || c === '[' || c === '{') { depth++; continue; }
111
+ if (c === ')' || c === ']' || c === '}') {
112
+ depth--;
113
+ if (depth === 0) return { count, end: i };
114
+ continue;
115
+ }
116
+ // `foo(a, b)` has two arguments; `foo()` is not valid Prolog, so a comma at
117
+ // depth 1 is always an argument separator.
118
+ if (c === ',' && depth === 1) count++;
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * The predicates a cell declares `:- dynamic`, as `name/arity`.
125
+ *
126
+ * WHY THIS IS WORTH KNOWING WITHOUT RUNNING ANYTHING (format §8): a cell that
127
+ * declares one is **stateful**. Its assert/retract state lives in no file, so
128
+ * re-consulting the cell does not undo it and neither does resetting the cell —
129
+ * only throwing the engine away does. That is the one place where the otherwise
130
+ * reliable promise "the clause store self-heals" stops being true, and a reader
131
+ * who does not know it will conclude something false about Prolog rather than
132
+ * about us.
133
+ *
134
+ * Read statically so the page can say so BEFORE the reader has asserted anything,
135
+ * rather than after they are already confused. Shallow like the rest of this file
136
+ * and for the same reason: at worst it fails to warn, and it can never change
137
+ * what a goal does.
138
+ *
139
+ * @param {string} source Prolog text
140
+ * @returns {Set<string>} predicate indicators
141
+ */
142
+ export function declaredDynamic(source) {
143
+ const found = new Set();
144
+ for (const body of directives(source)) {
145
+ // `:- dynamic foo/1.` and `:- dynamic(foo/1).` are the same declaration.
146
+ const m = /^dynamic\b\s*(.*)$/s.exec(body);
147
+ if (!m) continue;
148
+ let list = m[1].trim();
149
+ if (list.startsWith('(') && list.endsWith(')')) list = list.slice(1, -1);
150
+ for (const item of splitTopLevel(list)) {
151
+ const indicator = /^\s*(?:'((?:[^'\\]|\\.)*)'|([a-z][a-zA-Z0-9_]*))\s*\/\s*(\d+)\s*$/.exec(item);
152
+ if (indicator) {
153
+ const name = indicator[1] !== undefined ? indicator[1].replace(/\\(.)/g, '$1') : indicator[2];
154
+ found.add(`${name}/${indicator[3]}`);
155
+ }
156
+ }
157
+ }
158
+ return found;
159
+ }
160
+
161
+ /**
162
+ * The body of every `:- …` directive, with comments stripped.
163
+ *
164
+ * Directives wrap across lines far more often than clauses do — a chapter that
165
+ * declares six dynamic predicates will list them one per line — so this cannot be
166
+ * the line scanner the rest of the file uses. It reads to the terminating full
167
+ * stop instead.
168
+ */
169
+ function directives(source) {
170
+ const bodies = [];
171
+ const text = stripComments(source);
172
+ const pattern = /(^|\n)\s*:-\s*/g;
173
+ let m;
174
+ while ((m = pattern.exec(text)) !== null) {
175
+ const start = m.index + m[0].length;
176
+ const end = endOfTerm(text, start);
177
+ if (end === -1) break;
178
+ bodies.push(text.slice(start, end).trim());
179
+ pattern.lastIndex = end;
180
+ }
181
+ return bodies;
182
+ }
183
+
184
+ /** Index of the `.` that ends a term, skipping quotes. -1 if it never ends. */
185
+ function endOfTerm(text, from) {
186
+ let quote = null;
187
+ for (let i = from; i < text.length; i++) {
188
+ const c = text[i];
189
+ if (quote) {
190
+ if (c === '\\') i++;
191
+ else if (c === quote) quote = null;
192
+ continue;
193
+ }
194
+ if (c === "'" || c === '"' || c === '`') { quote = c; continue; }
195
+ // A full stop ends a term only when whitespace or the end of input follows,
196
+ // which is exactly SWI's own rule — otherwise `1.5` would end one.
197
+ if (c === '.' && (i + 1 === text.length || /\s/.test(text[i + 1]))) return i;
198
+ }
199
+ return -1;
200
+ }
201
+
202
+ function stripComments(source) {
203
+ return source
204
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
205
+ .split('\n')
206
+ .map((line) => line.replace(/(^|\s)%.*$/, '$1'))
207
+ .join('\n');
208
+ }
209
+
210
+ /** Split on commas that are not inside brackets or quotes. */
211
+ function splitTopLevel(text) {
212
+ const parts = [];
213
+ let depth = 0;
214
+ let quote = null;
215
+ let start = 0;
216
+ for (let i = 0; i < text.length; i++) {
217
+ const c = text[i];
218
+ if (quote) {
219
+ if (c === '\\') i++;
220
+ else if (c === quote) quote = null;
221
+ continue;
222
+ }
223
+ if (c === "'" || c === '"' || c === '`') { quote = c; continue; }
224
+ if (c === '(' || c === '[' || c === '{') depth++;
225
+ else if (c === ')' || c === ']' || c === '}') depth--;
226
+ else if (c === ',' && depth === 0) { parts.push(text.slice(start, i)); start = i + 1; }
227
+ }
228
+ parts.push(text.slice(start));
229
+ return parts;
230
+ }
231
+
232
+ /** The predicate indicator an "Unknown procedure" error is complaining about. */
233
+ export function unknownProcedure(message) {
234
+ const m = /Unknown procedure:\s*(?:[a-z][a-zA-Z0-9_]*:)?((?:'[^']*'|[^\s/]+)\/\d+)/.exec(message ?? '');
235
+ return m ? m[1] : null;
236
+ }