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/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
+ }