eyelang 0.1.11 → 1.1.13

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/README.md CHANGED
@@ -45,7 +45,7 @@ console.log(result.stdout);
45
45
 
46
46
  ## Documentation
47
47
 
48
- - [Playground](playground.html)
48
+ - [Playground](https://eyereasoner.github.io/eyelang/playground)
49
49
  - [Guide](docs/guide.md)
50
50
  - [Language reference](docs/language-reference.md)
51
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eyelang",
3
- "version": "0.1.11",
3
+ "version": "1.1.13",
4
4
  "description": "A small Prolog-syntax-subset logic programming language for rules, goals, answers, and proofs.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
package/playground.html CHANGED
@@ -16,15 +16,24 @@
16
16
  --accent: #0969da;
17
17
  --accent-strong: #0757b8;
18
18
  --danger: #b42318;
19
+ --editor-bg: #ffffff;
20
+ --editor-text: #172033;
21
+ --editor-caret: #111827;
22
+ --editor-selection: rgba(9, 105, 218, 0.18);
23
+ --editor-error-line: #fee2e2;
24
+ --editor-error-border: #dc2626;
25
+ --line-number-bg: #f8fafc;
26
+ --line-number-text: #64748b;
27
+ --line-number-border: #e2e8f0;
19
28
  --code-bg: #0f172a;
20
29
  --code-text: #dbeafe;
21
- --comment: #94a3b8;
22
- --string: #86efac;
23
- --number: #fbbf24;
24
- --variable: #93c5fd;
25
- --keyword: #c084fc;
26
- --predicate: #f0abfc;
27
- --punctuation: #cbd5e1;
30
+ --comment: #64748b;
31
+ --string: #047857;
32
+ --number: #b45309;
33
+ --variable: #1d4ed8;
34
+ --keyword: #7c3aed;
35
+ --predicate: #be123c;
36
+ --punctuation: #475569;
28
37
  }
29
38
 
30
39
  @media (prefers-color-scheme: dark) {
@@ -50,15 +59,15 @@
50
59
  line-height: 1.45;
51
60
  }
52
61
 
53
- header {
54
- padding: 1rem clamp(1rem, 4vw, 2rem);
55
- border-bottom: 1px solid var(--border);
56
- background: var(--panel);
57
- position: sticky;
58
- top: 0;
59
- z-index: 5;
62
+ header,
63
+ main,
64
+ footer {
65
+ width: min(100% - 2rem, 72rem);
66
+ margin: 0 auto;
60
67
  }
61
68
 
69
+ header { padding: 1.25rem 0 0.9rem; }
70
+
62
71
  h1 {
63
72
  margin: 0;
64
73
  font-size: clamp(1.35rem, 4vw, 2rem);
@@ -67,21 +76,17 @@
67
76
  header p { margin: 0.35rem 0 0; color: var(--muted); }
68
77
 
69
78
  main {
70
- display: grid;
71
- grid-template-columns: minmax(0, 1fr) minmax(18rem, 32rem);
72
- gap: 1rem;
73
- padding: 1rem clamp(1rem, 4vw, 2rem) 2rem;
74
- max-width: 1500px;
75
- margin: 0 auto;
79
+ display: block;
80
+ padding: 0 0 2rem;
76
81
  }
77
82
 
78
- section,
79
- aside {
83
+ section {
80
84
  background: var(--panel);
81
85
  border: 1px solid var(--border);
82
86
  border-radius: 0.9rem;
83
87
  box-shadow: 0 8px 28px rgba(15, 23, 42, 0.06);
84
88
  overflow: hidden;
89
+ margin: 1rem 0;
85
90
  }
86
91
 
87
92
  .panel-body { padding: 1rem; }
@@ -89,7 +94,8 @@
89
94
  .toolbar,
90
95
  .example-row,
91
96
  .button-row,
92
- .option-row {
97
+ .option-row,
98
+ .background-row {
93
99
  display: flex;
94
100
  flex-wrap: wrap;
95
101
  gap: 0.6rem;
@@ -102,6 +108,18 @@
102
108
  background: var(--panel-soft);
103
109
  }
104
110
 
111
+ .toolbar:last-child { border-bottom: 0; }
112
+
113
+ details summary {
114
+ cursor: pointer;
115
+ font-weight: 700;
116
+ padding: 0.85rem 1rem;
117
+ background: var(--panel-soft);
118
+ border-bottom: 1px solid var(--border);
119
+ }
120
+
121
+ details:not([open]) summary { border-bottom: 0; }
122
+
105
123
  label { font-weight: 650; }
106
124
 
107
125
  select,
@@ -147,7 +165,8 @@
147
165
  button.primary:hover { background: var(--accent-strong); }
148
166
  button:disabled { cursor: not-allowed; opacity: 0.6; }
149
167
 
150
- .option-row label {
168
+ .option-row label,
169
+ .background-row label {
151
170
  display: inline-flex;
152
171
  gap: 0.35rem;
153
172
  align-items: center;
@@ -168,9 +187,48 @@
168
187
 
169
188
  .editor {
170
189
  position: relative;
171
- min-height: 58vh;
172
- background: var(--code-bg);
190
+ min-height: 56vh;
191
+ background: var(--editor-bg);
192
+ overflow: hidden;
193
+ }
194
+
195
+ .line-numbers,
196
+ .editor pre,
197
+ .editor textarea {
198
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
199
+ font-size: 0.95rem;
200
+ line-height: 1.55;
201
+ }
202
+
203
+ .line-numbers {
204
+ position: absolute;
205
+ inset: 0 auto 0 0;
206
+ width: 3.6rem;
207
+ padding: 1rem 0.65rem 1rem 0.35rem;
173
208
  overflow: hidden;
209
+ text-align: right;
210
+ color: var(--line-number-text);
211
+ background: var(--line-number-bg);
212
+ border-right: 1px solid var(--line-number-border);
213
+ user-select: none;
214
+ pointer-events: none;
215
+ z-index: 3;
216
+ }
217
+
218
+ .line-numbers-inner { will-change: transform; }
219
+
220
+ .line-number {
221
+ display: block;
222
+ min-height: 1.55em;
223
+ padding-right: 0.1rem;
224
+ }
225
+
226
+ .line-number.error {
227
+ color: var(--danger);
228
+ background: var(--editor-error-line);
229
+ font-weight: 700;
230
+ margin: 0 -0.65rem 0 -0.35rem;
231
+ padding: 0 0.65rem 0 0.35rem;
174
232
  }
175
233
 
176
234
  .editor pre,
@@ -178,7 +236,7 @@
178
236
  position: absolute;
179
237
  inset: 0;
180
238
  margin: 0;
181
- padding: 1rem;
239
+ padding: 1rem 1rem 1rem 4.35rem;
182
240
  border: 0;
183
241
  width: 100%;
184
242
  height: 100%;
@@ -186,25 +244,36 @@
186
244
  overflow: auto;
187
245
  white-space: pre;
188
246
  tab-size: 2;
189
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
190
- font-size: 0.95rem;
191
- line-height: 1.55;
247
+ }
248
+
249
+ .error-line-marker {
250
+ position: absolute;
251
+ left: 0;
252
+ right: 0;
253
+ top: 0;
254
+ display: none;
255
+ pointer-events: none;
256
+ background: var(--editor-error-line);
257
+ border-left: 0.28rem solid var(--editor-error-border);
258
+ z-index: 0;
192
259
  }
193
260
 
194
261
  .editor pre {
195
262
  pointer-events: none;
196
- color: var(--code-text);
263
+ color: var(--editor-text);
264
+ z-index: 1;
197
265
  }
198
266
 
199
267
  .editor textarea {
200
268
  background: transparent;
201
- color: rgba(255, 255, 255, 0.02);
202
- caret-color: white;
269
+ color: rgba(17, 24, 39, 0.025);
270
+ caret-color: var(--editor-caret);
203
271
  outline: none;
204
- -webkit-text-fill-color: rgba(255, 255, 255, 0.02);
272
+ z-index: 2;
273
+ -webkit-text-fill-color: rgba(17, 24, 39, 0.025);
205
274
  }
206
275
 
207
- .editor textarea::selection { background: rgba(96, 165, 250, 0.35); }
276
+ .editor textarea::selection { background: var(--editor-selection); }
208
277
 
209
278
  .tok-comment { color: var(--comment); font-style: italic; }
210
279
  .tok-string { color: var(--string); }
@@ -250,31 +319,34 @@
250
319
 
251
320
  footer {
252
321
  color: var(--muted);
253
- padding: 0 1rem 1.5rem;
322
+ padding: 0 0 1.5rem;
254
323
  text-align: center;
255
324
  font-size: 0.95rem;
256
325
  }
257
326
 
258
327
  footer a { color: var(--accent); }
259
328
 
260
- @media (max-width: 900px) {
261
- header { position: static; }
262
- main { grid-template-columns: 1fr; }
263
- .editor { min-height: 54vh; }
264
- .output { max-height: 42vh; }
265
- }
266
-
267
329
  @media (max-width: 560px) {
330
+ header,
331
+ main,
332
+ footer { width: min(100% - 1rem, 72rem); }
268
333
  .toolbar,
269
334
  .panel-body,
270
- .editor-label { padding: 0.8rem; }
335
+ .editor-label,
336
+ details summary { padding: 0.8rem; }
271
337
  button,
272
338
  select,
273
339
  input[type="url"],
274
340
  input[type="search"] { width: 100%; }
275
341
  .button-row > button { flex: 1 1 8rem; }
342
+ .editor { min-height: 58vh; }
343
+ .line-numbers,
344
+ .editor pre,
345
+ .editor textarea { font-size: 0.9rem; }
346
+ .line-numbers { width: 3.2rem; padding: 0.8rem 0.55rem 0.8rem 0.25rem; }
347
+ .line-number.error { margin: 0 -0.55rem 0 -0.25rem; padding: 0 0.55rem 0 0.25rem; }
276
348
  .editor pre,
277
- .editor textarea { font-size: 0.9rem; padding: 0.8rem; }
349
+ .editor textarea { padding: 0.8rem 0.8rem 0.8rem 3.85rem; }
278
350
  }
279
351
  </style>
280
352
  </head>
@@ -285,45 +357,61 @@
285
357
  </header>
286
358
 
287
359
  <main>
288
- <section aria-labelledby="editor-heading">
360
+ <section aria-labelledby="examples-heading">
289
361
  <div class="toolbar" aria-label="Example loader">
290
- <label for="example-search">Load example</label>
362
+ <label id="examples-heading" for="example-search">Load an example</label>
291
363
  <input id="example-search" type="search" placeholder="Filter examples" autocomplete="off">
292
364
  <select id="examples" aria-label="Examples"></select>
293
- <button id="load-example" type="button">Load</button>
294
- </div>
295
-
296
- <div class="toolbar" aria-label="URL loader">
297
- <label for="source-url">Load from URL</label>
298
- <input id="source-url" type="url" placeholder="https://example.org/program.pl">
299
- <button id="load-url" type="button">Load URL</button>
365
+ <button id="load-example" type="button">Load example</button>
300
366
  </div>
367
+ </section>
301
368
 
302
- <div class="toolbar option-row" aria-label="Run options">
303
- <label><input id="proof" type="checkbox"> proof explanations</label>
304
- <label><input id="stats" type="checkbox"> show stats</label>
305
- </div>
369
+ <section aria-labelledby="advanced-heading">
370
+ <details>
371
+ <summary id="advanced-heading">⚙ Advanced configuration</summary>
372
+ <div class="toolbar" aria-label="URL loader">
373
+ <label for="source-url">Load Eyelang from URL</label>
374
+ <input id="source-url" type="url" placeholder="https://example.org/program.pl">
375
+ <button id="load-url" type="button">Load from URL</button>
376
+ </div>
377
+ <div class="toolbar background-row">
378
+ <label><input id="load-background" type="checkbox"> Load into background knowledge instead of overwriting the editor</label>
379
+ <span id="background-status" class="hint">No background knowledge loaded.</span>
380
+ <button id="clear-background" type="button">Clear background</button>
381
+ </div>
382
+ <div class="toolbar option-row" aria-label="Run options">
383
+ <label><input id="proof" type="checkbox"> Show proof explanations</label>
384
+ <label><input id="stats" type="checkbox"> Show stats</label>
385
+ </div>
386
+ <div class="panel-body">
387
+ <p class="hint">Enter any Eyelang file URL and click “Load from URL”. Background knowledge is prepended when the program runs.</p>
388
+ </div>
389
+ </details>
390
+ </section>
306
391
 
392
+ <section aria-labelledby="editor-heading">
307
393
  <div class="editor-shell">
308
394
  <div class="editor-label">
309
395
  <strong id="editor-heading">Editable Eyelang program</strong>
310
396
  <span id="source-name" class="hint">custom program</span>
311
397
  </div>
312
398
  <div class="editor">
399
+ <div id="line-numbers" class="line-numbers" aria-hidden="true"><div class="line-numbers-inner"></div></div>
400
+ <div id="error-line-marker" class="error-line-marker" aria-hidden="true"></div>
313
401
  <pre id="highlight" aria-hidden="true"><code></code></pre>
314
402
  <textarea id="source" spellcheck="false" autocapitalize="off" autocomplete="off" aria-label="Editable Eyelang source"></textarea>
315
403
  </div>
316
404
  </div>
317
- </section>
318
-
319
- <aside aria-labelledby="output-heading">
320
405
  <div class="toolbar button-row">
321
- <button id="run" type="button" class="primary">Run</button>
406
+ <button id="run" type="button" class="primary">Run reasoning</button>
322
407
  <button id="stop" type="button" disabled>Stop</button>
323
408
  <button id="copy-source" type="button">Copy source</button>
324
409
  <button id="copy-output" type="button">Copy output</button>
325
410
  <button id="share" type="button">Copy share link</button>
326
411
  </div>
412
+ </section>
413
+
414
+ <section aria-labelledby="output-heading">
327
415
  <div class="editor-label">
328
416
  <strong id="output-heading">Output</strong>
329
417
  <span id="elapsed" class="hint">Idle.</span>
@@ -336,7 +424,7 @@
336
424
  <div class="panel-body">
337
425
  <p class="hint">For local use, serve the checkout with a small HTTP server first; browser modules and example loading usually do not work from <code>file://</code>.</p>
338
426
  </div>
339
- </aside>
427
+ </section>
340
428
  </main>
341
429
 
342
430
  <footer>
@@ -477,6 +565,7 @@
477
565
  const FALLBACK_SOURCE = `materialize(answer, 1).
478
566
  answer(ok) :- eq(ok, ok).
479
567
  `;
568
+ const HIGHLIGHT_LIMIT = 200000;
480
569
  const KEYWORDS = new Set(['materialize', 'memoize']);
481
570
  const BUILTINS = new Set([
482
571
  "abs",
@@ -561,28 +650,41 @@ answer(ok) :- eq(ok, ok).
561
650
 
562
651
  const source = document.querySelector('#source');
563
652
  const highlight = document.querySelector('#highlight');
653
+ const lineNumbers = document.querySelector('#line-numbers');
654
+ const lineNumbersInner = lineNumbers.firstElementChild;
655
+ const errorLineMarker = document.querySelector('#error-line-marker');
564
656
  const exampleSelect = document.querySelector('#examples');
565
657
  const exampleSearch = document.querySelector('#example-search');
566
658
  const output = document.querySelector('#output');
567
659
  const status = document.querySelector('#status');
568
660
  const elapsed = document.querySelector('#elapsed');
569
661
  const sourceName = document.querySelector('#source-name');
662
+ const backgroundStatus = document.querySelector('#background-status');
663
+ const loadBackground = document.querySelector('#load-background');
570
664
  const proof = document.querySelector('#proof');
571
665
  const stats = document.querySelector('#stats');
572
666
  const runButton = document.querySelector('#run');
573
667
  const stopButton = document.querySelector('#stop');
668
+ let backgroundSource = '';
669
+ let backgroundName = '';
574
670
  let activeWorker = null;
575
671
  let activeWorkerUrl = null;
672
+ let renderToken = 0;
673
+ let syntaxErrorLine = null;
576
674
 
577
675
  populateExamples(EXAMPLES);
578
676
  restoreFromHash() || loadExample('ancestor');
579
677
  loadVersion();
580
678
 
581
- source.addEventListener('input', render);
679
+ source.addEventListener('input', () => {
680
+ clearSyntaxError();
681
+ render();
682
+ });
582
683
  source.addEventListener('scroll', syncScroll);
583
684
  exampleSearch.addEventListener('input', () => populateExamples(filterExamples(exampleSearch.value)));
584
685
  document.querySelector('#load-example').addEventListener('click', () => loadExample(exampleSelect.value));
585
686
  document.querySelector('#load-url').addEventListener('click', loadUrl);
687
+ document.querySelector('#clear-background').addEventListener('click', clearBackground);
586
688
  document.querySelector('#run').addEventListener('click', runProgram);
587
689
  document.querySelector('#stop').addEventListener('click', stopProgram);
588
690
  document.querySelector('#copy-source').addEventListener('click', () => copyText(source.value, 'Source copied.'));
@@ -629,27 +731,71 @@ answer(ok) :- eq(ok, ok).
629
731
  setStatus(`Loading ${url}…`);
630
732
  const response = await fetch(url);
631
733
  if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
632
- setSource(await response.text(), url);
633
- setStatus(`Loaded ${url}.`);
734
+ const text = await response.text();
735
+ if (loadBackground.checked) {
736
+ backgroundSource = text;
737
+ backgroundName = url;
738
+ updateBackgroundStatus();
739
+ setStatus(`Loaded background knowledge from ${url}.`);
740
+ } else {
741
+ setSource(text, url);
742
+ setStatus(`Loaded ${url}.`);
743
+ }
634
744
  } catch (error) {
635
745
  setStatus(`Could not load URL: ${formatError(error)}`, true);
636
746
  }
637
747
  }
638
748
 
749
+ function clearBackground() {
750
+ backgroundSource = '';
751
+ backgroundName = '';
752
+ updateBackgroundStatus();
753
+ setStatus('Background knowledge cleared.');
754
+ }
755
+
756
+ function updateBackgroundStatus() {
757
+ backgroundStatus.textContent = backgroundSource
758
+ ? `Background loaded from ${backgroundName} (${backgroundSource.length.toLocaleString()} chars).`
759
+ : 'No background knowledge loaded.';
760
+ }
761
+
639
762
  function setSource(text, name) {
640
763
  source.value = text;
641
764
  sourceName.textContent = name;
765
+ clearSyntaxError();
642
766
  render();
643
767
  }
644
768
 
645
769
  function render() {
646
- highlight.firstElementChild.innerHTML = colorize(source.value) + (source.value.endsWith('\n') ? ' ' : '');
647
- syncScroll();
770
+ const token = ++renderToken;
771
+ const text = source.value;
772
+ requestAnimationFrame(() => {
773
+ if (token !== renderToken) return;
774
+ highlight.firstElementChild.innerHTML = renderHighlightedSource(text);
775
+ updateLineNumbers(text);
776
+ syncScroll();
777
+ });
778
+ }
779
+
780
+ function renderHighlightedSource(text) {
781
+ const rendered = text.length > HIGHLIGHT_LIMIT ? escapeHtml(text) : colorize(text);
782
+ return rendered + (text.endsWith('\n') ? ' ' : '');
648
783
  }
649
784
 
650
785
  function syncScroll() {
651
786
  highlight.scrollTop = source.scrollTop;
652
787
  highlight.scrollLeft = source.scrollLeft;
788
+ lineNumbersInner.style.transform = `translateY(${-source.scrollTop}px)`;
789
+ updateErrorLineMarker();
790
+ }
791
+
792
+ function updateLineNumbers(text = source.value) {
793
+ const count = Math.max(1, text.split('\n').length);
794
+ lineNumbersInner.innerHTML = Array.from({ length: count }, (_, index) => {
795
+ const line = index + 1;
796
+ const className = line === syntaxErrorLine ? 'line-number error' : 'line-number';
797
+ return `<span class="${className}">${line}</span>`;
798
+ }).join('');
653
799
  }
654
800
 
655
801
  function colorize(text) {
@@ -708,6 +854,7 @@ answer(ok) :- eq(ok, ok).
708
854
 
709
855
  function runProgram() {
710
856
  stopProgram(false);
857
+ clearSyntaxError();
711
858
  output.textContent = '';
712
859
  elapsed.textContent = 'Running…';
713
860
  setStatus('Running Eyelang…');
@@ -737,20 +884,83 @@ answer(ok) :- eq(ok, ok).
737
884
  activeWorker.onmessage = (event) => finishRun(event.data);
738
885
  activeWorker.onerror = (event) => finishRun({ ok: false, error: event.message || 'worker error' });
739
886
  activeWorker.postMessage({
740
- source: source.value,
887
+ source: combinedSource(),
741
888
  options: { proof: proof.checked, stats: stats.checked },
742
889
  });
743
890
  }
744
891
 
892
+ function combinedSource() {
893
+ if (!backgroundSource.trim()) return source.value;
894
+ return `${backgroundSource}\n${source.value}`;
895
+ }
896
+
897
+ function extractParseErrorLine(text) {
898
+ const match = String(text).match(/parse line (\d+)/i);
899
+ return match ? Number(match[1]) : null;
900
+ }
901
+
902
+ function editorLineForParseLine(parseLine) {
903
+ if (!Number.isFinite(parseLine) || parseLine < 1) return null;
904
+ if (!backgroundSource.trim()) return parseLine;
905
+ const backgroundLines = backgroundSource.split('\n').length;
906
+ const editorLine = parseLine - backgroundLines;
907
+ return editorLine >= 1 ? editorLine : null;
908
+ }
909
+
910
+ function markSyntaxErrorLine(line) {
911
+ syntaxErrorLine = line;
912
+ updateLineNumbers();
913
+ revealLine(line);
914
+ updateErrorLineMarker();
915
+ }
916
+
917
+ function clearSyntaxError() {
918
+ syntaxErrorLine = null;
919
+ updateLineNumbers();
920
+ updateErrorLineMarker();
921
+ }
922
+
923
+ function revealLine(line) {
924
+ const metrics = editorMetrics();
925
+ const y = metrics.paddingTop + (line - 1) * metrics.lineHeight;
926
+ const top = source.scrollTop;
927
+ const bottom = top + source.clientHeight - metrics.lineHeight;
928
+ if (y < top || y > bottom) {
929
+ source.scrollTop = Math.max(0, y - source.clientHeight / 3);
930
+ }
931
+ }
932
+
933
+ function updateErrorLineMarker() {
934
+ if (syntaxErrorLine == null) {
935
+ errorLineMarker.style.display = 'none';
936
+ return;
937
+ }
938
+ const metrics = editorMetrics();
939
+ errorLineMarker.style.display = 'block';
940
+ errorLineMarker.style.height = `${metrics.lineHeight}px`;
941
+ errorLineMarker.style.top = `${metrics.paddingTop + (syntaxErrorLine - 1) * metrics.lineHeight - source.scrollTop}px`;
942
+ }
943
+
944
+ function editorMetrics() {
945
+ const style = getComputedStyle(source);
946
+ const fontSize = parseFloat(style.fontSize) || 15;
947
+ const lineHeight = parseFloat(style.lineHeight) || fontSize * 1.55;
948
+ const paddingTop = parseFloat(style.paddingTop) || 0;
949
+ return { lineHeight, paddingTop };
950
+ }
951
+
745
952
  function finishRun(message) {
746
- const worker = activeWorker;
747
953
  cleanupWorker();
748
954
  runButton.disabled = false;
749
955
  stopButton.disabled = true;
750
956
  if (!message.ok) {
751
- output.textContent = message.error || 'Unknown error.';
957
+ const errorText = message.error || 'Unknown error.';
958
+ const parseLine = extractParseErrorLine(errorText);
959
+ const editorLine = editorLineForParseLine(parseLine);
960
+ if (editorLine != null) markSyntaxErrorLine(editorLine);
961
+ output.textContent = errorText;
752
962
  elapsed.textContent = 'Failed.';
753
- setStatus('Run failed.', true);
963
+ setStatus(editorLine == null ? 'Run failed.' : `Syntax error on line ${editorLine}.`, true);
754
964
  return;
755
965
  }
756
966
  let text = message.stdout || '(no materialized output)\n';
@@ -758,7 +968,7 @@ answer(ok) :- eq(ok, ok).
758
968
  output.textContent = text;
759
969
  const seconds = (message.elapsedMs / 1000).toFixed(3);
760
970
  elapsed.textContent = `${seconds} sec`;
761
- setStatus(worker ? 'Run complete.' : 'Run complete.');
971
+ setStatus('Run complete.');
762
972
  }
763
973
 
764
974
  function stopProgram(report = true) {
@@ -791,7 +1001,13 @@ answer(ok) :- eq(ok, ok).
791
1001
  }
792
1002
 
793
1003
  async function copyShareLink() {
794
- const payload = JSON.stringify({ source: source.value, proof: proof.checked, stats: stats.checked });
1004
+ const payload = JSON.stringify({
1005
+ source: source.value,
1006
+ proof: proof.checked,
1007
+ stats: stats.checked,
1008
+ backgroundSource,
1009
+ backgroundName,
1010
+ });
795
1011
  const data = new TextEncoder().encode(payload);
796
1012
  let binary = '';
797
1013
  for (const byte of data) binary += String.fromCharCode(byte);
@@ -809,6 +1025,9 @@ answer(ok) :- eq(ok, ok).
809
1025
  setSource(String(payload.source || ''), 'shared program');
810
1026
  proof.checked = Boolean(payload.proof);
811
1027
  stats.checked = Boolean(payload.stats);
1028
+ backgroundSource = String(payload.backgroundSource || '');
1029
+ backgroundName = String(payload.backgroundName || 'shared background');
1030
+ updateBackgroundStatus();
812
1031
  setStatus('Loaded shared program.');
813
1032
  return true;
814
1033
  } catch (error) {
package/src/solver.js CHANGED
@@ -38,50 +38,122 @@ export class Solver {
38
38
  }
39
39
 
40
40
  *solve(goals, env = new Env(), depth = 0) {
41
- this.stats.solve_goals_calls++;
42
- this.stats.max_depth = Math.max(this.stats.max_depth, depth);
43
- this.stats.max_goal_count = Math.max(this.stats.max_goal_count, goals.length);
44
- if (depth > this.maxDepth || this.solutionsSeen >= this.solutionLimit) return;
45
41
  if (!Array.isArray(goals)) goals = [goals];
46
42
 
47
- if (goals.length === 0) {
48
- this.solutionsSeen++;
49
- this.stats.completed_goal_lists++;
50
- yield env;
51
- return;
52
- }
43
+ const savedActive = this.active;
44
+ try {
45
+ const stack = [{ kind: 'goals', goals, env, depth, active: savedActive.slice() }];
46
+ while (stack.length) {
47
+ const frame = stack.pop();
48
+ if (frame.kind === 'completeMemo') {
49
+ frame.entry.computing = false;
50
+ frame.entry.complete = true;
51
+ continue;
52
+ }
53
53
 
54
- // eyelang normally solves left-to-right, but ready deterministic builtins can
55
- // be run early as pure filters. This gives large pruning wins without
56
- // reordering user predicates or nondeterministic generators.
57
- const selectedIndex = selectReadyDeterministicBuiltin(goals, env, this.registry);
58
- const goal = goals[selectedIndex];
59
- const rest = selectedIndex === 0 ? goals.slice(1) : [...goals.slice(0, selectedIndex), ...goals.slice(selectedIndex + 1)];
60
- if (goal.type === COMPOUND && goal.name === ',' && goal.arity === 2) {
61
- yield* this.solve([...flattenConjunction(goal), ...rest], env, depth + 1);
62
- return;
63
- }
54
+ goals = frame.goals;
55
+ env = frame.env;
56
+ depth = frame.depth;
57
+ let active = frame.active;
64
58
 
65
- const def = goal.type === COMPOUND ? this.registry.get(goal.name, goal.arity) : null;
66
- if (def && builtinIsReadyOrAuthoritative(def, this, goal, env)) {
67
- let produced = false;
68
- for (const next of def.handler({ solver: this, goal, env })) {
69
- produced = true;
70
- yield* this.solve(rest, next, depth + 1);
71
- if (this.solutionsSeen >= this.solutionLimit) return;
59
+ while (true) {
60
+ this.stats.solve_goals_calls++;
61
+ this.stats.max_depth = Math.max(this.stats.max_depth, depth);
62
+ this.stats.max_goal_count = Math.max(this.stats.max_goal_count, goals.length);
63
+ if (depth > this.maxDepth || this.solutionsSeen >= this.solutionLimit) break;
64
+
65
+ if (goals.length === 0) {
66
+ this.solutionsSeen++;
67
+ this.stats.completed_goal_lists++;
68
+ this.active = active;
69
+ yield env;
70
+ break;
71
+ }
72
+
73
+ const first = goals[0];
74
+ if (first?.kind === 'releaseActive') {
75
+ active = active.slice(0, -1);
76
+ goals = goals.slice(1);
77
+ continue;
78
+ }
79
+ if (first?.kind === 'memoStore') {
80
+ rememberMemoAnswer(first.entry, first.goal, env);
81
+ goals = goals.slice(1);
82
+ continue;
83
+ }
84
+
85
+ // eyelang normally solves left-to-right, but ready deterministic builtins can
86
+ // be run early as pure filters. Stop at internal sentinels so rule-body
87
+ // active guards are released before the caller's remaining goals are seen.
88
+ const selectedIndex = selectReadyDeterministicBuiltin(goals, env, this.registry);
89
+ const goal = goals[selectedIndex];
90
+ const rest = selectedIndex === 0 ? goals.slice(1) : [...goals.slice(0, selectedIndex), ...goals.slice(selectedIndex + 1)];
91
+ if (goal.type === COMPOUND && goal.name === ',' && goal.arity === 2) {
92
+ goals = [...flattenConjunction(goal), ...rest];
93
+ depth++;
94
+ continue;
95
+ }
96
+
97
+ const def = goal.type === COMPOUND ? this.registry.get(goal.name, goal.arity) : null;
98
+ this.active = active;
99
+ if (def && builtinIsReadyOrAuthoritative(def, this, goal, env)) {
100
+ const nextEnvs = [];
101
+ for (const next of def.handler({ solver: this, goal, env })) nextEnvs.push(next);
102
+ if (def.deterministic) {
103
+ if (nextEnvs.length) this.stats.deterministic_builtin_successes++;
104
+ else this.stats.deterministic_builtin_failures++;
105
+ }
106
+ if (nextEnvs.length === 0) break;
107
+ if (nextEnvs.length === 1) {
108
+ goals = rest;
109
+ env = nextEnvs[0];
110
+ depth++;
111
+ continue;
112
+ }
113
+ for (let i = nextEnvs.length - 1; i >= 0; i--) {
114
+ stack.push({ kind: 'goals', goals: rest, env: nextEnvs[i], depth: depth + 1, active });
115
+ }
116
+ break;
117
+ }
118
+
119
+ this.stats.solve_one_goal_calls++;
120
+ if (goal.type !== COMPOUND) break;
121
+ const group = this.program.findGroup(goal.name, goal.arity);
122
+ if (!group) break;
123
+
124
+ if (group.memoized) {
125
+ const key = memoKey(goal, env);
126
+ if (key.hasBound) {
127
+ const mapKey = `${goal.name}/${goal.arity}:${key.text}`;
128
+ let entry = this.memo.get(mapKey);
129
+ if (!entry) {
130
+ entry = { computing: false, complete: false, answers: [], answerKeys: new Set() };
131
+ this.memo.set(mapKey, entry);
132
+ }
133
+ if (entry.complete) {
134
+ pushMemoAnswerFrames(stack, entry, goal, rest, env, depth, active, this);
135
+ break;
136
+ }
137
+ if (!entry.computing) {
138
+ entry.computing = true;
139
+ stack.push({ kind: 'completeMemo', entry });
140
+ pushUserGoalUncachedFrames(stack, this, group, goal, [{ kind: 'memoStore', entry, goal }, ...rest], env, depth, active);
141
+ break;
142
+ }
143
+ }
144
+ }
145
+
146
+ pushUserGoalUncachedFrames(stack, this, group, goal, rest, env, depth, active);
147
+ break;
72
148
  }
73
- if (def.deterministic) {
74
- if (produced) this.stats.deterministic_builtin_successes++;
75
- else this.stats.deterministic_builtin_failures++;
76
149
  }
77
- return;
150
+ } finally {
151
+ this.active = savedActive;
78
152
  }
79
-
80
- yield* this.solveUserGoal(goal, rest, env, depth);
81
153
  }
82
154
 
83
155
  activeVariant(goal, env) {
84
- return this.active.some((entry) => variantTerms(goal, env, entry.goal, entry.env));
156
+ return activeVariantIn(goal, env, this.active);
85
157
  }
86
158
 
87
159
  *solveUserGoal(goal, rest, env, depth) {
@@ -180,6 +252,61 @@ export class Solver {
180
252
  }
181
253
 
182
254
 
255
+ function pushMemoAnswerFrames(stack, entry, goal, rest, env, depth, active, solver) {
256
+ for (let answerIndex = entry.answers.length - 1; answerIndex >= 0; answerIndex--) {
257
+ const answerArgs = entry.answers[answerIndex];
258
+ const next = env.clone();
259
+ let ok = true;
260
+ for (let i = 0; i < goal.arity; i++) {
261
+ solver.stats.unify_calls++;
262
+ if (!unify(goal.args[i], answerArgs[i], next)) { ok = false; break; }
263
+ }
264
+ if (ok) stack.push({ kind: 'goals', goals: rest, env: next, depth: depth + 1, active });
265
+ }
266
+ }
267
+
268
+ function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth, active) {
269
+ if (activeVariantIn(goal, env, active)) return;
270
+ const candidates = selectClauseCandidates(group, goal, env);
271
+ const frames = [];
272
+ for (const pass of [candidates.primary, candidates.fallback]) {
273
+ for (const clause of pass) {
274
+ if (headCannotMatch(goal, clause.head, env)) continue;
275
+ const id = nextFreshId();
276
+ const freshHead = freshTerm(clause.head, id);
277
+ const freshBody = clause.body.map((term) => freshTerm(term, id));
278
+ const next = env.clone();
279
+ solver.stats.unify_calls++;
280
+ if (!unify(goal, freshHead, next)) continue;
281
+ if (freshBody.length === 0) {
282
+ frames.push({ kind: 'goals', goals: rest, env: next, depth: depth + 1, active });
283
+ } else {
284
+ frames.push({
285
+ kind: 'goals',
286
+ goals: [...freshBody, { kind: 'releaseActive' }, ...rest],
287
+ env: next,
288
+ depth: depth + 1,
289
+ active: [...active, { goal, env }],
290
+ });
291
+ }
292
+ }
293
+ }
294
+ for (let i = frames.length - 1; i >= 0; i--) stack.push(frames[i]);
295
+ }
296
+
297
+ function rememberMemoAnswer(entry, goal, env) {
298
+ const answerArgs = goal.args.map((arg) => importResolved(arg, env));
299
+ const key = answerArgs.map((arg) => termToString(arg, new Env(), true)).join('\x1f');
300
+ if (entry.answerKeys.has(key)) return;
301
+ entry.answerKeys.add(key);
302
+ entry.answers.push(answerArgs);
303
+ }
304
+
305
+ function activeVariantIn(goal, env, active) {
306
+ return active.some((entry) => variantTerms(goal, env, entry.goal, entry.env));
307
+ }
308
+
309
+
183
310
  function builtinIsReadyOrAuthoritative(def, solver, goal, env) {
184
311
  if (typeof def.shouldUse === 'function' && !def.shouldUse({ solver, goal, env })) return false;
185
312
  if (typeof def.ready !== 'function') return true;
@@ -190,6 +317,7 @@ function builtinIsReadyOrAuthoritative(def, solver, goal, env) {
190
317
  function selectReadyDeterministicBuiltin(goals, env, registry) {
191
318
  for (let i = 0; i < goals.length; i++) {
192
319
  const goal = goals[i];
320
+ if (goal?.kind === 'releaseActive' || goal?.kind === 'memoStore') return 0;
193
321
  if (goal.type !== COMPOUND) continue;
194
322
  const def = registry.get(goal.name, goal.arity);
195
323
  if (!def?.deterministic || typeof def.ready !== 'function') continue;
@@ -360,6 +360,16 @@ function apiCases() {
360
360
  assertEqual(result.stdout, 'q(a, b).\n', 'stdout');
361
361
  },
362
362
  },
363
+ {
364
+ name: 'run keeps recursive materializations independent in one solver',
365
+ run: () => {
366
+ const text = fs.readFileSync(path.join(packageRoot, 'examples', 'alignment-demo.pl'), 'utf8');
367
+ const program = Program.parseSources([{ text, filename: 'alignment-demo.pl' }]);
368
+ const result = run(program);
369
+ assertIncludes(result.stdout, 'broaderTransitive(anpr_passenger_car, ref_car).\n', 'stdout');
370
+ assertIncludes(result.stdout, 'narrowerOrEqualOf(anpr_passenger_car, ref_car).\n', 'stdout');
371
+ },
372
+ },
363
373
  {
364
374
  name: 'makeProgram creates indexed programs',
365
375
  run: () => {
@@ -525,6 +535,20 @@ function whiteBoxCases() {
525
535
  assertEqual(group.recursive, true, 'collatz/2 recursive');
526
536
  },
527
537
  },
538
+ {
539
+ name: 'collatz example remains stack-safe for browser-sized stacks',
540
+ run: () => {
541
+ // Use a deliberately tiny stack to catch browser-worker recursion regressions.
542
+ const result = spawnSync(process.execPath, ['--stack-size=100', bin, 'examples/collatz-1000.pl'], {
543
+ cwd: packageRoot,
544
+ encoding: 'utf8',
545
+ });
546
+ assertEqual(result.status, 0, `exit status${result.stderr ? `\nstderr: ${result.stderr}` : ''}`);
547
+ assertEqual(result.stderr, '', 'stderr');
548
+ assertIncludes(result.stdout, 'collatzTrajectory(1000, [1000, 500, 250, 125', 'stdout');
549
+ assertIncludes(result.stdout, 'collatzTrajectory(1, [1]).\n', 'stdout');
550
+ },
551
+ },
528
552
  ];
529
553
  }
530
554
 
@@ -623,19 +647,37 @@ function playgroundStaticIssues() {
623
647
  const html = fs.readFileSync(playgroundPath, 'utf8');
624
648
  const readme = fs.readFileSync(path.join(packageRoot, 'README.md'), 'utf8');
625
649
  if (!pkg.files?.includes('playground.html')) issues.push('package files must include playground.html');
626
- if (!readme.includes('[Playground](playground.html)')) issues.push('README must link to playground.html');
650
+ if (!readme.includes('[Playground](https://eyereasoner.github.io/eyelang/playground)')) issues.push('README must link to the GitHub Pages playground URL');
627
651
  if (!html.includes('<meta name="viewport" content="width=device-width, initial-scale=1">')) issues.push('missing mobile viewport meta');
628
- if (!html.includes('@media (max-width: 900px)') || !html.includes('main { grid-template-columns: 1fr; }')) {
629
- issues.push('playground must collapse the editor/output grid on tablet/mobile widths');
652
+ if (!html.includes('main {') || !html.includes('display: block;')) {
653
+ issues.push('playground must use a simple vertical layout');
630
654
  }
631
655
  if (!html.includes('@media (max-width: 560px)') || !html.includes('button,') || !html.includes('width: 100%')) {
632
656
  issues.push('playground must make controls usable at phone widths');
633
657
  }
658
+ if (!html.includes('<summary id="advanced-heading">⚙ Advanced configuration</summary>')) {
659
+ issues.push('playground must keep URL/proof controls inside advanced configuration');
660
+ }
661
+ if (!html.includes('id="load-background"') || !html.includes('backgroundSource') || !html.includes('combinedSource()')) {
662
+ issues.push('playground must support loading URL content as background knowledge');
663
+ }
664
+ if (!html.includes('HIGHLIGHT_LIMIT') || !html.includes('text.length > HIGHLIGHT_LIMIT')) {
665
+ issues.push('playground must avoid full syntax coloring for very large examples');
666
+ }
634
667
  if (!html.includes('<script type="module">')) issues.push('playground script must be an ES module');
635
668
  if (!html.includes("new URL('./src/index.js', location.href)")) issues.push('playground must import the public browser API');
636
669
  if (!html.includes('class="editor"') || !html.includes('id="highlight"') || !html.includes('id="source"')) {
637
670
  issues.push('playground must include layered syntax-colored editor');
638
671
  }
672
+ if (!html.includes('--editor-bg: #ffffff') || !html.includes('background: var(--editor-bg)')) {
673
+ issues.push('playground editor must use a light editor background');
674
+ }
675
+ if (!html.includes('id="error-line-marker"') || !html.includes('extractParseErrorLine') || !html.includes('markSyntaxErrorLine') || !html.includes('--editor-error-line')) {
676
+ issues.push('playground must highlight syntax-error lines in the editor');
677
+ }
678
+ if (!html.includes('id="line-numbers"') || !html.includes('updateLineNumbers') || !html.includes('lineNumbersInner.style.transform') || !html.includes('--line-number-bg')) {
679
+ issues.push('playground editor must include synced line numbers');
680
+ }
639
681
  if (!html.includes('id="example-search"') || !html.includes('id="examples"')) issues.push('playground must include searchable examples');
640
682
  const scriptMatch = html.match(new RegExp('<script type="module">\\n([\\s\\S]*?)\\n <\\/script>'));
641
683
  if (scriptMatch == null) {