From 80c26723f1b228b2bed8a9d67dfa9a3076f75945 Mon Sep 17 00:00:00 2001 From: Hassan Abedi Date: Thu, 4 Jun 2026 10:02:06 +0200 Subject: [PATCH 1/5] The base commit --- build.zig.zon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.zig.zon b/build.zig.zon index cc77e59..06f7b35 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .zodd, - .version = "0.1.0-alpha.5", + .version = "0.1.0-alpha.6", .fingerprint = 0x2d03181bdd24914c, // Changing this has security and trust implications. .minimum_zig_version = "0.16.0", .dependencies = .{ From 10da38e7e3d4c13b92f3fbad41ccfc2607874096 Mon Sep 17 00:00:00 2001 From: Hassan Abedi Date: Thu, 4 Jun 2026 20:43:10 +0200 Subject: [PATCH 2/5] Add a help window for Zodd's Datalog dialect --- web/index.html | 203 ++++-- web/main.js | 1612 ++++++++++++++++++++++---------------------- web/smoke_test.mjs | 62 +- web/style.css | 963 ++++++++++++++------------ 4 files changed, 1521 insertions(+), 1319 deletions(-) diff --git a/web/index.html b/web/index.html index 4ddc522..2042d7a 100644 --- a/web/index.html +++ b/web/index.html @@ -1,108 +1,179 @@ - - - Zodd Datalog Engine - - - + + + Zodd Datalog Engine + + + -
+
- Zodd logo -

Zodd Datalog Engine

+ Zodd logo +

Zodd Datalog Engine

-
+
-
+
- - + +
- - - + + +
- - - + + +
- +
- +
-
+
-
+
-
-
- +
-
- - -
Initializing WebAssembly Engine...
- +
Initializing the Engine...
+
-
+
-
+
Press Ctrl+Enter to run. Programs with ?- queries answer them; otherwise all derived relations are printed. Click a row in the Table view to see how the tuple was derived. -
+
+ + +

Zodd's Datalog Help

+
+
+

Overview

+

A Datalog program in Zodd consists of facts, rules, and optional + queries. + Lines starting with % are treated as comments and are ignored. + The words not, count, sum, min, and max + are reserved keywords.

+
+ +
+

Basic Elements

+
    +
  • Constants: Integers (such as 42) or double-quoted strings (such as + "alice"). Bare lowercase identifiers (such as alice) are not valid + constants. +
  • +
  • Variables: Identifiers starting with an uppercase letter or an underscore (such as + X, Y, or the wildcard _). +
  • +
  • Facts: Assertions of the form predicate(constants). representing base + data. +
    edge(1, 2).
    +likes("alice", "tea").
    +
  • +
  • Rules: Statements of the form head :- body., where the body is a + comma-separated list of subgoals (predicates applied to variables or constants) and optional + comparison filters. +
    path(X, Y) :- edge(X, Y).
    +path(X, Z) :- path(X, Y), edge(Y, Z).
    +
  • +
  • Queries: Requests of the form ?- predicate(arguments).. When a query + is present, the engine returns only matching tuples; otherwise, all derived relations are returned. +
    ?- path(1, X).
    +
  • +
+
+ +
+

Negation

+

Negated subgoals use the not keyword. To ensure rule safety, every variable in a negated + subgoal must also appear in at least one positive subgoal within the same rule body.

+
allowed(R, P) :- has_perm(R, P), not denied(R, P).
+
- +
+

Comparison Filters

+

Zodd supports the comparison operators <, <=, >, >=, + =, and !=. Every variable in a comparison must be bound by a positive subgoal + in the rule body, and wildcards are not allowed. Ordered comparisons compare integers; a string operand + will fail the comparison.

+
hop(X, Y) :- edge(X, Y, W), W < 60.
+reach(X, Z) :- reach(X, Y), hop(Y, Z), X != Z.
+
+ +
+

Aggregates

+

Zodd supports aggregation functions in the heads of rules. The supported functions are count, + sum, min, and max. The argument to an aggregation function must + be a single variable.

+
out_deg(N, count(M)) :- hop(N, M).
+fanout(P, count(D)) :- needs(P, D).
+
+
+ +
+ +

About Zodd

-

Zodd is a small embeddable Datalog engine in Zig. -

+

Zodd is a small embeddable Datalog engine in Zig. +

-
-
Version--
-
Build--
-
License--
-
+
+
Version--
+
Build--
+
License--
+
-

- If you enjoy Zodd, please consider supporting the project by giving it a - star - and making a - donation - to help keep development going. -

+

+ If you enjoy Zodd, please consider supporting the project by giving it a + star + and making a + donation + to help keep development going. +

-
+ - + diff --git a/web/main.js b/web/main.js index 3af53b5..5ae3d39 100644 --- a/web/main.js +++ b/web/main.js @@ -3,9 +3,9 @@ // --- Example programs ------------------------------------------------------- const EXAMPLES = [ - { - name: "Transitive closure", - source: `% A directed graph and its transitive closure. + { + name: "Transitive closure", + source: `% A directed graph and its transitive closure. % % Base facts: define a directed graph with edges between nodes. edge(1, 2). @@ -21,10 +21,10 @@ path(X, Z) :- path(X, Y), edge(Y, Z). % Query: which nodes X are reachable from node 1? ?- path(1, X). `, - }, - { - name: "Bestseller join", - source: `% Which bestsellers cost what, and who wrote them? + }, + { + name: "Bestseller join", + source: `% Which bestsellers cost what, and who wrote them? % % Base facts: map authors to books, list bestselling books, and associate books with prices. author("Ursula K. Le Guin", "A Wizard of Earthsea"). @@ -47,10 +47,10 @@ q(Name, Book, Dollars) :- % Query: retrieve the names, books, and prices of all bestselling books. ?- q(Name, Book, Dollars). `, - }, - { - name: "Comparison filters", - source: `% Service latencies checked against SLA limits. + }, + { + name: "Comparison filters", + source: `% Service latencies checked against SLA limits. % % Base facts: observed latencies (milliseconds) per service and probe, and % an SLA limit per service. @@ -79,10 +79,10 @@ slower_than(A, B) :- worst(A, LA), worst(B, LB), A != B, LA > LB. % No query: all derived relations are printed. `, - }, - { - name: "Network reachability", - source: `% Which network zones can talk through routing and firewall rules? + }, + { + name: "Network reachability", + source: `% Which network zones can talk through routing and firewall rules? % % Base facts: define network links and firewall block rules. link("internet", "dmz"). @@ -117,10 +117,10 @@ exposure(Z) :- allowed("internet", Z). % Query 2: which zones can be accessed from the DMZ? ?- allowed("dmz", Z). `, - }, - { - name: "Knowledge graph", - source: `% A medical ontology: type hierarchy and property inheritance. + }, + { + name: "Knowledge graph", + source: `% A medical ontology: type hierarchy and property inheritance. % % Base facts: define disease hierarchy, symptoms, drug targets, and target associations. is_a("heart_disease", "cardiovascular"). @@ -168,10 +168,10 @@ side_effect(Drug, S) :- treats(Drug, D), symptom(D, S). % Query 2: what are the potential side effects of metoprolol? ?- side_effect("metoprolol", S). `, - }, - { - name: "Data lineage", - source: `% PII flowing through an ETL pipeline; anonymization blocks it. + }, + { + name: "Data lineage", + source: `% PII flowing through an ETL pipeline; anonymization blocks it. % % Base facts: define initial PII sources, ETL transformations, anonymization boundaries, and public datasets. source_pii("raw_users"). @@ -210,10 +210,10 @@ violation(D) :- contains_pii(D), public_dataset(D). % Query 2: which datasets violate the privacy policy? ?- violation(D). `, - }, - { - name: "RBAC authorization", - source: `% Effective permissions through role inheritance and denials. + }, + { + name: "RBAC authorization", + source: `% Effective permissions through role inheritance and denials. % % Base facts: define user roles, role hierarchies, role permissions, and explicit denials. user_role("alice", "viewer"). @@ -249,10 +249,10 @@ effective(U, P) :- can_access(U, P), not denied(U, P). % Query 2: what are the effective permissions of Bob? ?- effective("bob", P). `, - }, - { - name: "Taint analysis", - source: `% Untrusted data flowing to security-sensitive sinks. + }, + { + name: "Taint analysis", + source: `% Untrusted data flowing to security-sensitive sinks. % % Base facts: define taint sources, data flow pathways, sanitizers, and security-sensitive sinks. source("v1"). @@ -285,10 +285,10 @@ violation(S, V) :- sink(S, V), tainted(V). % Query 2: which sinks and variables trigger policy violations? ?- violation(S, V). `, - }, - { - name: "Dependency resolution", - source: `% Transitive dependencies and total install size per package. + }, + { + name: "Dependency resolution", + source: `% Transitive dependencies and total install size per package. % % Base facts: define direct dependencies and package sizes (in kilobytes or megabytes). direct_dep("app", "web_framework"). @@ -330,10 +330,10 @@ total_size(P, sum(S)) :- installs(P, D), size(D, S). % Query 2: what is the total installation size of each package? ?- total_size(P, S). `, - }, - { - name: "Package registry", - source: `% A package registry: yanked packages taint their dependents. + }, + { + name: "Package registry", + source: `% A package registry: yanked packages taint their dependents. % % Base facts: register packages, define direct dependencies, and list yanked (revoked) packages. package("app"). package("http"). package("json"). @@ -367,10 +367,10 @@ fanout(P, count(D)) :- needs(P, D). % Query 2: what is the dependency fanout count for each package? ?- fanout(X, N). `, - }, - { - name: "Same generation", - source: `% Two people are in the same generation if they share an + }, + { + name: "Same generation", + source: `% Two people are in the same generation if they share an % ancestor at the same depth. % % Base facts: define parent-child relationships. @@ -388,7 +388,7 @@ sg(X, Y) :- parent(X, P), parent(Y, Q), sg(P, Q). % Query: which people are in the same generation as alice? ?- sg("alice", X). `, - }, + }, ]; // --- Wasm glue --------------------------------------------------------------- @@ -398,305 +398,308 @@ const encoder = new TextEncoder(); const decoder = new TextDecoder(); async function loadWasm() { - const imports = {}; - try { - if (typeof WebAssembly.instantiateStreaming === "function") { - const { instance } = await WebAssembly.instantiateStreaming(fetch("zodd.wasm"), imports); - return instance.exports; + const imports = {}; + try { + if (typeof WebAssembly.instantiateStreaming === "function") { + const {instance} = await WebAssembly.instantiateStreaming(fetch("zodd.wasm"), imports); + return instance.exports; + } + } catch { + // Fall through to ArrayBuffer instantiation (e.g. file:// or MIME issues). } - } catch { - // Fall through to ArrayBuffer instantiation (e.g. file:// or MIME issues). - } - const bytes = await (await fetch("zodd.wasm")).arrayBuffer(); - const { instance } = await WebAssembly.instantiate(bytes, imports); - return instance.exports; + const bytes = await (await fetch("zodd.wasm")).arrayBuffer(); + const {instance} = await WebAssembly.instantiate(bytes, imports); + return instance.exports; } // Calls a Wasm export taking (ptr, len) pairs, one per string argument. function wasmCall(fnName, strings) { - const buffers = strings.map((s) => encoder.encode(s)); - const ptrs = buffers.map((bytes) => { - // Zero-length allocations return a dangling pointer; pass (0, 0) instead. - if (bytes.length === 0) return 0; - const ptr = wasm.alloc(bytes.length); - if (ptr === 0) throw new Error("Wasm allocation failed"); - // Views must be created after each call into Wasm: memory growth - // detaches previously created typed arrays. - new Uint8Array(wasm.memory.buffer, ptr, bytes.length).set(bytes); - return ptr; - }); - const args = []; - buffers.forEach((bytes, i) => args.push(ptrs[i], bytes.length)); - const status = wasm[fnName](...args); - buffers.forEach((bytes, i) => { - if (ptrs[i] !== 0) wasm.dealloc(ptrs[i], bytes.length); - }); - const out = decoder.decode( - new Uint8Array(wasm.memory.buffer, wasm.outputPtr(), wasm.outputLen()), - ); - return { status, out }; + const buffers = strings.map((s) => encoder.encode(s)); + const ptrs = buffers.map((bytes) => { + // Zero-length allocations return a dangling pointer; pass (0, 0) instead. + if (bytes.length === 0) return 0; + const ptr = wasm.alloc(bytes.length); + if (ptr === 0) throw new Error("Wasm allocation failed"); + // Views must be created after each call into Wasm: memory growth + // detaches previously created typed arrays. + new Uint8Array(wasm.memory.buffer, ptr, bytes.length).set(bytes); + return ptr; + }); + const args = []; + buffers.forEach((bytes, i) => args.push(ptrs[i], bytes.length)); + const status = wasm[fnName](...args); + buffers.forEach((bytes, i) => { + if (ptrs[i] !== 0) wasm.dealloc(ptrs[i], bytes.length); + }); + const out = decoder.decode( + new Uint8Array(wasm.memory.buffer, wasm.outputPtr(), wasm.outputLen()), + ); + return {status, out}; } function runProgram(source) { - return wasmCall("run", [source]); + return wasmCall("run", [source]); } // --- Syntax highlighting ------------------------------------------------------ const TOKEN_RE = new RegExp( - [ - "(%[^\\n]*)", // 1: comment - '("(?:\\\\.|[^"\\\\\\n])*"?)', // 2: string - "\\b(not|count|sum|min|max)\\b", // 3: keyword - "\\b(\\d+)\\b", // 4: number - "\\b([A-Z_][A-Za-z0-9_]*)\\b", // 5: variable - "\\b([a-z][A-Za-z0-9_]*)\\b", // 6: predicate - "(\\?-|:-|<=|>=|!=|[<>=(),.])", // 7: punctuation - ].join("|"), - "g", + [ + "(%[^\\n]*)", // 1: comment + '("(?:\\\\.|[^"\\\\\\n])*"?)', // 2: string + "\\b(not|count|sum|min|max)\\b", // 3: keyword + "\\b(\\d+)\\b", // 4: number + "\\b([A-Z_][A-Za-z0-9_]*)\\b", // 5: variable + "\\b([a-z][A-Za-z0-9_]*)\\b", // 6: predicate + "(\\?-|:-|<=|>=|!=|[<>=(),.])", // 7: punctuation + ].join("|"), + "g", ); const TOKEN_CLASSES = [ - "tok-comment", "tok-string", "tok-keyword", "tok-number", - "tok-variable", "tok-predicate", "tok-punct", + "tok-comment", "tok-string", "tok-keyword", "tok-number", + "tok-variable", "tok-predicate", "tok-punct", ]; function escapeHtml(text) { - return text.replace(/&/g, "&").replace(//g, ">"); + return text.replace(/&/g, "&").replace(//g, ">"); } function escapeAttr(text) { - return escapeHtml(text).replace(/"/g, """); + return escapeHtml(text).replace(/"/g, """); } function highlight(source, errorLine = null) { - let html = ""; - let last = 0; - for (const match of source.matchAll(TOKEN_RE)) { - html += escapeHtml(source.slice(last, match.index)); - for (let group = 1; group <= TOKEN_CLASSES.length; group++) { - if (match[group] !== undefined) { - html += `${escapeHtml(match[group])}`; - break; - } + let html = ""; + let last = 0; + for (const match of source.matchAll(TOKEN_RE)) { + html += escapeHtml(source.slice(last, match.index)); + for (let group = 1; group <= TOKEN_CLASSES.length; group++) { + if (match[group] !== undefined) { + html += `${escapeHtml(match[group])}`; + break; + } + } + last = match.index + match[0].length; } - last = match.index + match[0].length; - } - html += escapeHtml(source.slice(last)); - - if (errorLine !== null) { - const lines = html.split("\n"); - if (errorLine >= 1 && errorLine <= lines.length) { - lines[errorLine - 1] = `${lines[errorLine - 1]}`; + html += escapeHtml(source.slice(last)); + + if (errorLine !== null) { + const lines = html.split("\n"); + if (errorLine >= 1 && errorLine <= lines.length) { + lines[errorLine - 1] = `${lines[errorLine - 1]}`; + } + return lines.join("\n"); } - return lines.join("\n"); - } - return html; + return html; } // --- Permalinks ---------------------------------------------------------------- function encodeProgram(source) { - const bytes = encoder.encode(source); - let binary = ""; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + const bytes = encoder.encode(source); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } function decodeProgram(encoded) { - const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/"); - const binary = atob(base64); - const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); - return decoder.decode(bytes); + const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(base64); + const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); + return decoder.decode(bytes); } function copyToClipboard(text) { - if (navigator.clipboard && navigator.clipboard.writeText) { - return navigator.clipboard.writeText(text); - } - return new Promise((resolve, reject) => { - try { - const textarea = document.createElement("textarea"); - textarea.value = text; - textarea.style.position = "fixed"; - textarea.style.top = "0"; - textarea.style.left = "0"; - textarea.style.opacity = "0"; - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - const successful = document.execCommand("copy"); - document.body.removeChild(textarea); - if (successful) { - resolve(); - } else { - reject(new Error("Copy command failed")); - } - } catch (err) { - reject(err); + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); } - }); + return new Promise((resolve, reject) => { + try { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.top = "0"; + textarea.style.left = "0"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + const successful = document.execCommand("copy"); + document.body.removeChild(textarea); + if (successful) { + resolve(); + } else { + reject(new Error("Copy command failed")); + } + } catch (err) { + reject(err); + } + }); } // --- UI wiring ------------------------------------------------------------------ let activeErrorLine = null; -let sourceEl, highlightEl, highlightCodeEl, outputEl, outputTableEl, viewTextEl, viewTableEl, viewToggleEl, statusEl, examplesEl, runEl, planEl, shareEl, loadEl, downloadEl, clearEl, clearOutputEl, telemetryInfoEl, fileEl, themeEl, aboutEl, aboutDialogEl, aboutCloseEl, dividerEl, editorPane; +let sourceEl, highlightEl, highlightCodeEl, outputEl, outputTableEl, viewTextEl, viewTableEl, + viewToggleEl, statusEl, examplesEl, runEl, planEl, shareEl, loadEl, downloadEl, clearEl, + clearOutputEl, telemetryInfoEl, fileEl, themeEl, helpEl, helpDialogEl, helpCloseEl, aboutEl, + aboutDialogEl, aboutCloseEl, dividerEl, editorPane; function syncHighlight(errorLine = null) { - activeErrorLine = errorLine; - // A trailing newline keeps the backdrop the same height as the textarea. - highlightCodeEl.innerHTML = highlight(sourceEl.value, activeErrorLine) + "\n"; - syncScroll(); - updateLineNumbers(); - updateDropdownSelection(); + activeErrorLine = errorLine; + // A trailing newline keeps the backdrop the same height as the textarea. + highlightCodeEl.innerHTML = highlight(sourceEl.value, activeErrorLine) + "\n"; + syncScroll(); + updateLineNumbers(); + updateDropdownSelection(); } function syncScroll() { - highlightEl.scrollTop = sourceEl.scrollTop; - highlightEl.scrollLeft = sourceEl.scrollLeft; - const gutter = document.querySelector(".editor-gutter"); - if (gutter) { - gutter.scrollTop = sourceEl.scrollTop; - } + highlightEl.scrollTop = sourceEl.scrollTop; + highlightEl.scrollLeft = sourceEl.scrollLeft; + const gutter = document.querySelector(".editor-gutter"); + if (gutter) { + gutter.scrollTop = sourceEl.scrollTop; + } } function updateLineNumbers() { - const el = document.getElementById("linenos"); - if (!el) return; - const lineCount = sourceEl.value.split("\n").length || 1; - let numbers = ""; - for (let i = 1; i <= lineCount; i++) { - numbers += i + "\n"; - } - el.textContent = numbers; + const el = document.getElementById("linenos"); + if (!el) return; + const lineCount = sourceEl.value.split("\n").length || 1; + let numbers = ""; + for (let i = 1; i <= lineCount; i++) { + numbers += i + "\n"; + } + el.textContent = numbers; } function updateDropdownSelection() { - if (typeof document === "undefined" || !examplesEl) return; - const currentSource = sourceEl.value; - let matchedIndex = -1; - for (let i = 0; i < EXAMPLES.length; i++) { - if (EXAMPLES[i].source === currentSource) { - matchedIndex = i; - break; + if (typeof document === "undefined" || !examplesEl) return; + const currentSource = sourceEl.value; + let matchedIndex = -1; + for (let i = 0; i < EXAMPLES.length; i++) { + if (EXAMPLES[i].source === currentSource) { + matchedIndex = i; + break; + } + } + if (matchedIndex !== -1) { + examplesEl.value = String(matchedIndex); + } else { + examplesEl.value = "custom"; } - } - if (matchedIndex !== -1) { - examplesEl.value = String(matchedIndex); - } else { - examplesEl.value = "custom"; - } } function setSource(text) { - sourceEl.value = text; - sourceEl.scrollTop = 0; - sourceEl.scrollLeft = 0; - syncHighlight(null); - localStorage.setItem("zodd-source", text); + sourceEl.value = text; + sourceEl.scrollTop = 0; + sourceEl.scrollLeft = 0; + syncHighlight(null); + localStorage.setItem("zodd-source", text); } function setStatus(text, kind) { - statusEl.textContent = text; - statusEl.className = kind || ""; + statusEl.textContent = text; + statusEl.className = kind || ""; } function execute() { - if (!wasm) return; - const started = performance.now(); - let result; - try { - result = runProgram(sourceEl.value); - } catch (err) { - outputEl.textContent = `internal error: ${err}`; - outputEl.classList.add("error"); - outputTableEl.innerHTML = `
${escapeHtml("internal error: " + err)}
`; - setStatus("trap", "error"); - telemetryInfoEl.textContent = "Internal Trap"; - return; - } - const elapsed = (performance.now() - started).toFixed(1); - outputEl.textContent = result.out || "(no output)"; - outputEl.classList.toggle("error", result.status !== 0); - - if (result.status === 0) { - if (viewToggleEl) viewToggleEl.classList.remove("hidden"); - setStatus("SUCCESS", "ok"); - telemetryInfoEl.textContent = `Duration: ${elapsed} ms | Size: ${result.out.length} chars`; - outputTableEl.innerHTML = parseOutputToTables(result.out); - syncHighlight(null); - } else { - if (viewToggleEl) viewToggleEl.classList.add("hidden"); - setView("text"); - setStatus("error", "error"); - telemetryInfoEl.textContent = `Failed in ${elapsed} ms`; - outputTableEl.innerHTML = `
${escapeHtml(result.out)}
`; - - // Attempt to parse the error line from the diagnostic message (formatted as "line:col: message") - const match = result.out.match(/^(\d+):(\d+):/); - if (match) { - const errorLine = parseInt(match[1], 10); - syncHighlight(errorLine); + if (!wasm) return; + const started = performance.now(); + let result; + try { + result = runProgram(sourceEl.value); + } catch (err) { + outputEl.textContent = `internal error: ${err}`; + outputEl.classList.add("error"); + outputTableEl.innerHTML = `
${escapeHtml("internal error: " + err)}
`; + setStatus("trap", "error"); + telemetryInfoEl.textContent = "Internal Trap"; + return; + } + const elapsed = (performance.now() - started).toFixed(1); + outputEl.textContent = result.out || "(no output)"; + outputEl.classList.toggle("error", result.status !== 0); + + if (result.status === 0) { + if (viewToggleEl) viewToggleEl.classList.remove("hidden"); + setStatus("SUCCESS", "ok"); + telemetryInfoEl.textContent = `Duration: ${elapsed} ms | Size: ${result.out.length} chars`; + outputTableEl.innerHTML = parseOutputToTables(result.out); + syncHighlight(null); } else { - syncHighlight(null); + if (viewToggleEl) viewToggleEl.classList.add("hidden"); + setView("text"); + setStatus("error", "error"); + telemetryInfoEl.textContent = `Failed in ${elapsed} ms`; + outputTableEl.innerHTML = `
${escapeHtml(result.out)}
`; + + // Attempt to parse the error line from the diagnostic message (formatted as "line:col: message") + const match = result.out.match(/^(\d+):(\d+):/); + if (match) { + const errorLine = parseInt(match[1], 10); + syncHighlight(errorLine); + } else { + syncHighlight(null); + } } - } } // Shows engine-generated explanation text (a plan or a proof tree) in the // text view. Clears stale table results and hides the view toggle. function showExplanation(result, okStatus) { - outputEl.textContent = result.out || "(no output)"; - outputEl.classList.toggle("error", result.status !== 0); - outputTableEl.innerHTML = ""; // Clear stale table data - if (viewToggleEl) viewToggleEl.classList.add("hidden"); // Hide the view toggle - telemetryInfoEl.textContent = ""; // Clear stale telemetry info - setView("text"); - if (result.status === 0) { - setStatus(okStatus, "ok"); - } else { - setStatus("error", "error"); - } + outputEl.textContent = result.out || "(no output)"; + outputEl.classList.toggle("error", result.status !== 0); + outputTableEl.innerHTML = ""; // Clear stale table data + if (viewToggleEl) viewToggleEl.classList.add("hidden"); // Hide the view toggle + telemetryInfoEl.textContent = ""; // Clear stale telemetry info + setView("text"); + if (result.status === 0) { + setStatus(okStatus, "ok"); + } else { + setStatus("error", "error"); + } } function showPlan() { - if (!wasm) return; - try { - showExplanation(wasmCall("explainPlan", [sourceEl.value]), "plan"); - } catch (err) { - outputEl.textContent = `internal error: ${err}`; - outputEl.classList.add("error"); - setStatus("trap", "error"); - } + if (!wasm) return; + try { + showExplanation(wasmCall("explainPlan", [sourceEl.value]), "plan"); + } catch (err) { + outputEl.textContent = `internal error: ${err}`; + outputEl.classList.add("error"); + setStatus("trap", "error"); + } } function explainAtom(atom) { - if (!wasm) return; - try { - showExplanation(wasmCall("explain", [sourceEl.value, atom]), "explained"); - } catch (err) { - outputEl.textContent = `internal error: ${err}`; - outputEl.classList.add("error"); - setStatus("trap", "error"); - } + if (!wasm) return; + try { + showExplanation(wasmCall("explain", [sourceEl.value, atom]), "explained"); + } catch (err) { + outputEl.textContent = `internal error: ${err}`; + outputEl.classList.add("error"); + setStatus("trap", "error"); + } } function share() { - const url = new URL(window.location.href); - url.hash = "program=" + encodeProgram(sourceEl.value); - history.replaceState(null, "", url); - copyToClipboard(url.href) - .then(() => setStatus("link copied", "ok")) - .catch(() => setStatus("link in address bar", "ok")); + const url = new URL(window.location.href); + url.hash = "program=" + encodeProgram(sourceEl.value); + history.replaceState(null, "", url); + copyToClipboard(url.href) + .then(() => setStatus("link copied", "ok")) + .catch(() => setStatus("link in address bar", "ok")); } function applyTheme(theme) { - document.documentElement.dataset.theme = theme; - themeEl.textContent = theme === "light" ? "☾" : "☀"; - themeEl.title = theme === "light" ? "Switch to the dark theme" : "Switch to the light theme"; + document.documentElement.dataset.theme = theme; + themeEl.textContent = theme === "light" ? "☾" : "☀"; + themeEl.title = theme === "light" ? "Switch to the dark theme" : "Switch to the light theme"; } // --- Output View Toggling & Parsing ------------------------------------------- @@ -704,587 +707,602 @@ function applyTheme(theme) { let currentView = "text"; // "text" or "table" function setView(view) { - currentView = view; - if (view === "table") { - viewTableEl.classList.add("active"); - viewTextEl.classList.remove("active"); - outputEl.classList.add("hidden"); - outputTableEl.classList.remove("hidden"); - } else { - viewTextEl.classList.add("active"); - viewTableEl.classList.remove("active"); - outputTableEl.classList.add("hidden"); - outputEl.classList.remove("hidden"); - } + currentView = view; + if (view === "table") { + viewTableEl.classList.add("active"); + viewTextEl.classList.remove("active"); + outputEl.classList.add("hidden"); + outputTableEl.classList.remove("hidden"); + } else { + viewTextEl.classList.add("active"); + viewTableEl.classList.remove("active"); + outputTableEl.classList.add("hidden"); + outputEl.classList.remove("hidden"); + } } function parseOutputToTables(text) { - if (!text || text.trim() === "" || text.trim() === "(no results)" || text.trim() === "(no output)") { - return `
No results returned.
`; - } - - const lines = text.split("\n"); - const parts = []; - let currentTable = null; - let currentText = []; - - function flushText() { - if (currentText.length > 0) { - // Remove trailing empty line if it is just a spacing artifact - if (currentText[currentText.length - 1] === "") { - currentText.pop(); - } - if (currentText.length > 0) { - parts.push({ - type: "text", - content: currentText.join("\n") - }); - } - currentText = []; - } - } - - function flushTable() { - if (currentTable) { - parts.push({ - type: "table", - title: currentTable.title, - rows: currentTable.rows, - truncated: currentTable.truncated - }); - currentTable = null; - } - } - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const trimmed = line.trim(); - if (!trimmed) { - if (currentTable) { - continue; - } - if (currentText.length > 0) { - currentText.push(line); - } - continue; + if (!text || text.trim() === "" || text.trim() === "(no results)" || text.trim() === "(no output)") { + return `
No results returned.
`; } - // Check if the line is a block header: e.g. "pred_name:" or "?- q:" - if (trimmed.endsWith(":") && !trimmed.startsWith("(")) { - flushText(); - flushTable(); - const title = trimmed.slice(0, -1).trim(); - currentTable = { - title: title, - rows: [], - truncated: null - }; - continue; + const lines = text.split("\n"); + const parts = []; + let currentTable = null; + let currentText = []; + + function flushText() { + if (currentText.length > 0) { + // Remove trailing empty line if it is just a spacing artifact + if (currentText[currentText.length - 1] === "") { + currentText.pop(); + } + if (currentText.length > 0) { + parts.push({ + type: "text", + content: currentText.join("\n") + }); + } + currentText = []; + } } - // Check if the line is a tuple: e.g. "(1, 2)" or " (1, 2)" - if (trimmed.startsWith("(") && trimmed.endsWith(")") && trimmed !== "(no results)" && trimmed !== "(no output)") { - flushText(); - const content = trimmed.slice(1, -1).trim(); - const rowData = parseTuple(content); - if (currentTable) { - currentTable.rows.push(rowData); - } else { - currentTable = { title: "Results", rows: [rowData], truncated: null }; - } - continue; + function flushTable() { + if (currentTable) { + parts.push({ + type: "table", + title: currentTable.title, + rows: currentTable.rows, + truncated: currentTable.truncated + }); + currentTable = null; + } } - // Check if it is a truncation warning - if (trimmed.startsWith("... (output truncated")) { - if (currentTable) { - currentTable.truncated = trimmed; - } else { + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + if (!trimmed) { + if (currentTable) { + continue; + } + if (currentText.length > 0) { + currentText.push(line); + } + continue; + } + + // Check if the line is a block header: e.g. "pred_name:" or "?- q:" + if (trimmed.endsWith(":") && !trimmed.startsWith("(")) { + flushText(); + flushTable(); + const title = trimmed.slice(0, -1).trim(); + currentTable = { + title: title, + rows: [], + truncated: null + }; + continue; + } + + // Check if the line is a tuple: e.g. "(1, 2)" or " (1, 2)" + if (trimmed.startsWith("(") && trimmed.endsWith(")") && trimmed !== "(no results)" && trimmed !== "(no output)") { + flushText(); + const content = trimmed.slice(1, -1).trim(); + const rowData = parseTuple(content); + if (currentTable) { + currentTable.rows.push(rowData); + } else { + currentTable = {title: "Results", rows: [rowData], truncated: null}; + } + continue; + } + + // Check if it is a truncation warning + if (trimmed.startsWith("... (output truncated")) { + if (currentTable) { + currentTable.truncated = trimmed; + } else { + currentText.push(line); + } + continue; + } + + // Otherwise, it is non-tabular text + flushTable(); currentText.push(line); - } - continue; } - // Otherwise, it is non-tabular text + flushText(); flushTable(); - currentText.push(line); - } - - flushText(); - flushTable(); - - if (parts.length === 0) { - return `
No results.
`; - } - - let html = ""; - for (const part of parts) { - if (part.type === "text") { - html += `
${escapeHtml(part.content)}
`; - } else if (part.type === "table") { - html += `
`; - html += `
`; - html += `

${escapeHtml(part.title)}

`; - if (part.rows.length > 0) { - html += ``; - } - html += `
`; - if (part.rows.length === 0) { - html += `
No rows.
`; - } else { - html += ``; - const arity = part.rows[0].length; - html += ``; - html += ``; - for (let c = 1; c <= arity; c++) { - html += ``; - } - html += ``; - - // Rows of a named predicate carry their atom text so a click can - // ask the engine to explain the tuple's derivation. - const pred = part.title.replace(/^\?-\s*/, ""); - const explainable = /^[a-z][A-Za-z0-9_]*$/.test(pred); - html += ``; - for (let r = 0; r < part.rows.length; r++) { - const row = part.rows[r]; - if (explainable) { - const atom = `${pred}(${row.join(", ")})`; - html += ``; - } else { - html += ``; - } - html += ``; - for (const val of row) { - html += ``; - } - html += ``; + + if (parts.length === 0) { + return `
No results.
`; + } + + let html = ""; + for (const part of parts) { + if (part.type === "text") { + html += `
${escapeHtml(part.content)}
`; + } else if (part.type === "table") { + html += `
`; + html += `
`; + html += `

${escapeHtml(part.title)}

`; + if (part.rows.length > 0) { + html += ``; + } + html += `
`; + if (part.rows.length === 0) { + html += `
No rows.
`; + } else { + html += `
#Col ${c}
${r + 1}${escapeHtml(cleanValue(val))}
`; + const arity = part.rows[0].length; + html += ``; + html += ``; + for (let c = 1; c <= arity; c++) { + html += ``; + } + html += ``; + + // Rows of a named predicate carry their atom text so a click can + // ask the engine to explain the tuple's derivation. + const pred = part.title.replace(/^\?-\s*/, ""); + const explainable = /^[a-z][A-Za-z0-9_]*$/.test(pred); + html += ``; + for (let r = 0; r < part.rows.length; r++) { + const row = part.rows[r]; + if (explainable) { + const atom = `${pred}(${row.join(", ")})`; + html += ``; + } else { + html += ``; + } + html += ``; + for (const val of row) { + html += ``; + } + html += ``; + } + html += ``; + html += `
#Col ${c}
${r + 1}${escapeHtml(cleanValue(val))}
`; + } + if (part.truncated) { + html += `
${escapeHtml(part.truncated)}
`; + } + html += `
`; } - html += ``; - html += ``; - } - if (part.truncated) { - html += `
${escapeHtml(part.truncated)}
`; - } - html += ``; } - } - return html; + return html; } function parseTuple(str) { - if (!str.trim()) return []; - const elements = []; - let current = ""; - let inQuotes = false; - let escape = false; - for (let i = 0; i < str.length; i++) { - const char = str[i]; - if (escape) { - current += char; - escape = false; - } else if (char === '\\') { - escape = true; - } else if (char === '"') { - inQuotes = !inQuotes; - current += char; - } else if (char === ',' && !inQuotes) { - elements.push(current.trim()); - current = ""; - } else { - current += char; + if (!str.trim()) return []; + const elements = []; + let current = ""; + let inQuotes = false; + let escape = false; + for (let i = 0; i < str.length; i++) { + const char = str[i]; + if (escape) { + current += char; + escape = false; + } else if (char === '\\') { + escape = true; + } else if (char === '"') { + inQuotes = !inQuotes; + current += char; + } else if (char === ',' && !inQuotes) { + elements.push(current.trim()); + current = ""; + } else { + current += char; + } } - } - elements.push(current.trim()); - return elements; + elements.push(current.trim()); + return elements; } function cleanValue(val) { - if (val.startsWith('"') && val.endsWith('"')) { - return val.slice(1, -1).replace(/\\(.)/g, (match, g1) => { - switch (g1) { - case "n": return "\n"; - case "t": return "\t"; - case '"': return '"'; - case '\\': return '\\'; - default: return g1; - } - }); - } - return val; + if (val.startsWith('"') && val.endsWith('"')) { + return val.slice(1, -1).replace(/\\(.)/g, (match, g1) => { + switch (g1) { + case "n": + return "\n"; + case "t": + return "\t"; + case '"': + return '"'; + case '\\': + return '\\'; + default: + return g1; + } + }); + } + return val; } // Wrap all DOM execution & side-effects if (typeof document !== "undefined") { - sourceEl = document.getElementById("source"); - highlightEl = document.getElementById("highlight"); - highlightCodeEl = document.getElementById("highlight-code"); - outputEl = document.getElementById("output"); - outputTableEl = document.getElementById("output-table"); - viewTextEl = document.getElementById("view-text"); - viewTableEl = document.getElementById("view-table"); - viewToggleEl = document.getElementById("view-toggle"); - statusEl = document.getElementById("status"); - examplesEl = document.getElementById("examples"); - runEl = document.getElementById("run"); - planEl = document.getElementById("plan"); - shareEl = document.getElementById("share"); - loadEl = document.getElementById("load"); - downloadEl = document.getElementById("download"); - clearEl = document.getElementById("clear"); - clearOutputEl = document.getElementById("clear-output"); - telemetryInfoEl = document.getElementById("telemetry-info"); - fileEl = document.getElementById("file"); - themeEl = document.getElementById("theme"); - aboutEl = document.getElementById("about"); - aboutDialogEl = document.getElementById("about-dialog"); - aboutCloseEl = document.getElementById("about-close"); - dividerEl = document.getElementById("divider"); - editorPane = document.querySelector(".editor-pane"); - - // Examples dropdown. - const customOption = document.createElement("option"); - customOption.value = "custom"; - customOption.textContent = "[Custom / Edited]"; - customOption.disabled = true; - customOption.hidden = true; - examplesEl.appendChild(customOption); - - for (const [index, example] of EXAMPLES.entries()) { - const option = document.createElement("option"); - option.value = String(index); - option.textContent = example.name; - examplesEl.appendChild(option); - } - examplesEl.addEventListener("change", () => { - setSource(EXAMPLES[Number(examplesEl.value)].source); - }); - - // Editor events. - sourceEl.addEventListener("input", () => { - syncHighlight(null); - localStorage.setItem("zodd-source", sourceEl.value); - }); - sourceEl.addEventListener("scroll", syncScroll); - sourceEl.addEventListener("keydown", (event) => { - if (event.key === "Tab" && !event.ctrlKey && !event.metaKey && !event.altKey) { - event.preventDefault(); - const start = sourceEl.selectionStart; - const end = sourceEl.selectionEnd; - const val = sourceEl.value; - sourceEl.value = val.substring(0, start) + " " + val.substring(end); - sourceEl.selectionStart = sourceEl.selectionEnd = start + 4; - syncHighlight(null); - localStorage.setItem("zodd-source", sourceEl.value); - } else if (event.key === "Enter" && !event.ctrlKey && !event.metaKey && !event.altKey) { - const start = sourceEl.selectionStart; - const end = sourceEl.selectionEnd; - if (start === end) { - const val = sourceEl.value; - const lastNewline = val.lastIndexOf("\n", start - 1); - const lineStart = lastNewline + 1; - const currentLine = val.substring(lineStart, start); - const indentMatch = currentLine.match(/^\s*/); - const indent = indentMatch ? indentMatch[0] : ""; - if (indent.length > 0) { - event.preventDefault(); - const insert = "\n" + indent; - sourceEl.value = val.substring(0, start) + insert + val.substring(start); - sourceEl.selectionStart = sourceEl.selectionEnd = start + insert.length; - syncHighlight(null); - localStorage.setItem("zodd-source", sourceEl.value); - } - } - } else if ((event.key === "(" || event.key === "[" || event.key === "{" || event.key === '"') && !event.ctrlKey && !event.metaKey && !event.altKey) { - event.preventDefault(); - const start = sourceEl.selectionStart; - const end = sourceEl.selectionEnd; - const val = sourceEl.value; - const pairs = { "(": ")", "[": "]", "{": "}", '"': '"' }; - const closingChar = pairs[event.key]; - if (start !== end) { - const selectedText = val.substring(start, end); - const insert = event.key + selectedText + closingChar; - sourceEl.value = val.substring(0, start) + insert + val.substring(end); - sourceEl.selectionStart = start + 1; - sourceEl.selectionEnd = end + 1; - } else { - if (event.key === '"' && val.charAt(start) === '"') { - sourceEl.selectionStart = sourceEl.selectionEnd = start + 1; - } else { - const insert = event.key + closingChar; - sourceEl.value = val.substring(0, start) + insert + val.substring(start); - sourceEl.selectionStart = sourceEl.selectionEnd = start + 1; - } - } - syncHighlight(null); - localStorage.setItem("zodd-source", sourceEl.value); - } else if ((event.key === ")" || event.key === "]" || event.key === "}" || event.key === '"') && !event.ctrlKey && !event.metaKey && !event.altKey) { - const start = sourceEl.selectionStart; - const val = sourceEl.value; - if (start === sourceEl.selectionEnd && val.charAt(start) === event.key) { - event.preventDefault(); - sourceEl.selectionStart = sourceEl.selectionEnd = start + 1; - } - } else if (event.key === "Backspace" && !event.ctrlKey && !event.metaKey && !event.altKey) { - const start = sourceEl.selectionStart; - const end = sourceEl.selectionEnd; - if (start === end && start > 0) { - const val = sourceEl.value; - const charBefore = val.charAt(start - 1); - const charAfter = val.charAt(start); - if ( - (charBefore === "(" && charAfter === ")") || - (charBefore === "[" && charAfter === "]") || - (charBefore === "{" && charAfter === "}") || - (charBefore === '"' && charAfter === '"') - ) { - event.preventDefault(); - sourceEl.value = val.substring(0, start - 1) + val.substring(start + 1); - sourceEl.selectionStart = sourceEl.selectionEnd = start - 1; - syncHighlight(null); - localStorage.setItem("zodd-source", sourceEl.value); - } - } - } else if ((event.ctrlKey || event.metaKey) && event.key === "/") { - event.preventDefault(); - const start = sourceEl.selectionStart; - const end = sourceEl.selectionEnd; - const val = sourceEl.value; - const lastNewline = val.lastIndexOf("\n", start - 1); - const lineStart = lastNewline + 1; - const nextNewline = val.indexOf("\n", end); - const lineEnd = nextNewline === -1 ? val.length : nextNewline; - const lineText = val.substring(lineStart, lineEnd); - let newLineText; - let offset; - if (lineText.trim().startsWith("%")) { - newLineText = lineText.replace(/^\s*% ?/, (match) => { - const indent = match.match(/^\s*/)[0]; - return indent; - }); - offset = newLineText.length - lineText.length; - } else { - const indentMatch = lineText.match(/^\s*/); - const indent = indentMatch ? indentMatch[0] : ""; - const content = lineText.substring(indent.length); - newLineText = indent + "% " + content; - offset = 2; - } - sourceEl.value = val.substring(0, lineStart) + newLineText + val.substring(lineEnd); - sourceEl.selectionStart = start + offset; - sourceEl.selectionEnd = end + offset; - syncHighlight(null); - localStorage.setItem("zodd-source", sourceEl.value); - } else if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { - event.preventDefault(); - execute(); - } - }); - - runEl.addEventListener("click", execute); - planEl.addEventListener("click", showPlan); - shareEl.addEventListener("click", share); - - downloadEl.addEventListener("click", () => { - const blob = new Blob([sourceEl.value], { type: "text/plain;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "program.dl"; - a.click(); - URL.revokeObjectURL(url); - setStatus("downloaded", "ok"); - }); - - clearEl.addEventListener("click", () => { - setSource(""); - setStatus("CLEARED", "cleared"); - }); - - clearOutputEl.addEventListener("click", () => { - outputEl.textContent = ""; - outputEl.classList.remove("error"); - outputTableEl.innerHTML = ""; - if (viewToggleEl) viewToggleEl.classList.add("hidden"); - setStatus("CLEARED", "cleared"); - telemetryInfoEl.textContent = ""; - }); - - // Loading a Datalog script from a file. - loadEl.addEventListener("click", () => fileEl.click()); - fileEl.addEventListener("change", async () => { - const file = fileEl.files[0]; - if (!file) return; - setSource(await file.text()); - // Reset so selecting the same file again still fires a change event. - fileEl.value = ""; - execute(); - }); - - applyTheme(localStorage.getItem("zodd-theme") ?? "dark"); - - themeEl.addEventListener("click", () => { - const next = document.documentElement.dataset.theme === "light" ? "dark" : "light"; - localStorage.setItem("zodd-theme", next); - applyTheme(next); - }); - - // About dialog. - aboutEl.addEventListener("click", () => aboutDialogEl.showModal()); - aboutCloseEl.addEventListener("click", () => aboutDialogEl.close()); - aboutDialogEl.addEventListener("click", (event) => { - // A click on the backdrop targets the dialog element itself. - if (event.target === aboutDialogEl) aboutDialogEl.close(); - }); - - // Resizable split view. - dividerEl.addEventListener("pointerdown", (event) => { - event.preventDefault(); - dividerEl.classList.add("dragging"); - dividerEl.setPointerCapture(event.pointerId); - const onMove = (move) => { - const bounds = document.getElementById("split").getBoundingClientRect(); - const fraction = Math.min(0.85, Math.max(0.15, (move.clientX - bounds.left) / bounds.width)); - editorPane.style.flexBasis = `${(fraction * 100).toFixed(1)}%`; - }; - const onUp = () => { - dividerEl.classList.remove("dragging"); - dividerEl.releasePointerCapture(event.pointerId); - dividerEl.removeEventListener("pointermove", onMove); - dividerEl.removeEventListener("pointerup", onUp); - dividerEl.removeEventListener("pointercancel", onUp); - }; - dividerEl.addEventListener("pointermove", onMove); - dividerEl.addEventListener("pointerup", onUp); - dividerEl.addEventListener("pointercancel", onUp); - }); - - // Initial program: a permalink if present, the autosaved progress if available, or the first example otherwise. - (function initSource() { - const hash = window.location.hash; - if (hash.startsWith("#program=")) { - try { - setSource(decodeProgram(hash.slice("#program=".length))); - return; - } catch { - // Bad permalink; fall back to the first example. - } - } - const saved = localStorage.getItem("zodd-source"); - if (saved !== null) { - setSource(saved); - return; + sourceEl = document.getElementById("source"); + highlightEl = document.getElementById("highlight"); + highlightCodeEl = document.getElementById("highlight-code"); + outputEl = document.getElementById("output"); + outputTableEl = document.getElementById("output-table"); + viewTextEl = document.getElementById("view-text"); + viewTableEl = document.getElementById("view-table"); + viewToggleEl = document.getElementById("view-toggle"); + statusEl = document.getElementById("status"); + examplesEl = document.getElementById("examples"); + runEl = document.getElementById("run"); + planEl = document.getElementById("plan"); + shareEl = document.getElementById("share"); + loadEl = document.getElementById("load"); + downloadEl = document.getElementById("download"); + clearEl = document.getElementById("clear"); + clearOutputEl = document.getElementById("clear-output"); + telemetryInfoEl = document.getElementById("telemetry-info"); + fileEl = document.getElementById("file"); + themeEl = document.getElementById("theme"); + helpEl = document.getElementById("help"); + helpDialogEl = document.getElementById("help-dialog"); + helpCloseEl = document.getElementById("help-close"); + aboutEl = document.getElementById("about"); + aboutDialogEl = document.getElementById("about-dialog"); + aboutCloseEl = document.getElementById("about-close"); + dividerEl = document.getElementById("divider"); + editorPane = document.querySelector(".editor-pane"); + + // Examples dropdown. + const customOption = document.createElement("option"); + customOption.value = "custom"; + customOption.textContent = "[Custom / Edited]"; + customOption.disabled = true; + customOption.hidden = true; + examplesEl.appendChild(customOption); + + for (const [index, example] of EXAMPLES.entries()) { + const option = document.createElement("option"); + option.value = String(index); + option.textContent = example.name; + examplesEl.appendChild(option); } - setSource(EXAMPLES[0].source); - })(); + examplesEl.addEventListener("change", () => { + setSource(EXAMPLES[Number(examplesEl.value)].source); + }); + + // Editor events. + sourceEl.addEventListener("input", () => { + syncHighlight(null); + localStorage.setItem("zodd-source", sourceEl.value); + }); + sourceEl.addEventListener("scroll", syncScroll); + sourceEl.addEventListener("keydown", (event) => { + if (event.key === "Tab" && !event.ctrlKey && !event.metaKey && !event.altKey) { + event.preventDefault(); + const start = sourceEl.selectionStart; + const end = sourceEl.selectionEnd; + const val = sourceEl.value; + sourceEl.value = val.substring(0, start) + " " + val.substring(end); + sourceEl.selectionStart = sourceEl.selectionEnd = start + 4; + syncHighlight(null); + localStorage.setItem("zodd-source", sourceEl.value); + } else if (event.key === "Enter" && !event.ctrlKey && !event.metaKey && !event.altKey) { + const start = sourceEl.selectionStart; + const end = sourceEl.selectionEnd; + if (start === end) { + const val = sourceEl.value; + const lastNewline = val.lastIndexOf("\n", start - 1); + const lineStart = lastNewline + 1; + const currentLine = val.substring(lineStart, start); + const indentMatch = currentLine.match(/^\s*/); + const indent = indentMatch ? indentMatch[0] : ""; + if (indent.length > 0) { + event.preventDefault(); + const insert = "\n" + indent; + sourceEl.value = val.substring(0, start) + insert + val.substring(start); + sourceEl.selectionStart = sourceEl.selectionEnd = start + insert.length; + syncHighlight(null); + localStorage.setItem("zodd-source", sourceEl.value); + } + } + } else if ((event.key === "(" || event.key === "[" || event.key === "{" || event.key === '"') && !event.ctrlKey && !event.metaKey && !event.altKey) { + event.preventDefault(); + const start = sourceEl.selectionStart; + const end = sourceEl.selectionEnd; + const val = sourceEl.value; + const pairs = {"(": ")", "[": "]", "{": "}", '"': '"'}; + const closingChar = pairs[event.key]; + if (start !== end) { + const selectedText = val.substring(start, end); + const insert = event.key + selectedText + closingChar; + sourceEl.value = val.substring(0, start) + insert + val.substring(end); + sourceEl.selectionStart = start + 1; + sourceEl.selectionEnd = end + 1; + } else { + if (event.key === '"' && val.charAt(start) === '"') { + sourceEl.selectionStart = sourceEl.selectionEnd = start + 1; + } else { + const insert = event.key + closingChar; + sourceEl.value = val.substring(0, start) + insert + val.substring(start); + sourceEl.selectionStart = sourceEl.selectionEnd = start + 1; + } + } + syncHighlight(null); + localStorage.setItem("zodd-source", sourceEl.value); + } else if ((event.key === ")" || event.key === "]" || event.key === "}" || event.key === '"') && !event.ctrlKey && !event.metaKey && !event.altKey) { + const start = sourceEl.selectionStart; + const val = sourceEl.value; + if (start === sourceEl.selectionEnd && val.charAt(start) === event.key) { + event.preventDefault(); + sourceEl.selectionStart = sourceEl.selectionEnd = start + 1; + } + } else if (event.key === "Backspace" && !event.ctrlKey && !event.metaKey && !event.altKey) { + const start = sourceEl.selectionStart; + const end = sourceEl.selectionEnd; + if (start === end && start > 0) { + const val = sourceEl.value; + const charBefore = val.charAt(start - 1); + const charAfter = val.charAt(start); + if ( + (charBefore === "(" && charAfter === ")") || + (charBefore === "[" && charAfter === "]") || + (charBefore === "{" && charAfter === "}") || + (charBefore === '"' && charAfter === '"') + ) { + event.preventDefault(); + sourceEl.value = val.substring(0, start - 1) + val.substring(start + 1); + sourceEl.selectionStart = sourceEl.selectionEnd = start - 1; + syncHighlight(null); + localStorage.setItem("zodd-source", sourceEl.value); + } + } + } else if ((event.ctrlKey || event.metaKey) && event.key === "/") { + event.preventDefault(); + const start = sourceEl.selectionStart; + const end = sourceEl.selectionEnd; + const val = sourceEl.value; + const lastNewline = val.lastIndexOf("\n", start - 1); + const lineStart = lastNewline + 1; + const nextNewline = val.indexOf("\n", end); + const lineEnd = nextNewline === -1 ? val.length : nextNewline; + const lineText = val.substring(lineStart, lineEnd); + let newLineText; + let offset; + if (lineText.trim().startsWith("%")) { + newLineText = lineText.replace(/^\s*% ?/, (match) => { + const indent = match.match(/^\s*/)[0]; + return indent; + }); + offset = newLineText.length - lineText.length; + } else { + const indentMatch = lineText.match(/^\s*/); + const indent = indentMatch ? indentMatch[0] : ""; + const content = lineText.substring(indent.length); + newLineText = indent + "% " + content; + offset = 2; + } + sourceEl.value = val.substring(0, lineStart) + newLineText + val.substring(lineEnd); + sourceEl.selectionStart = start + offset; + sourceEl.selectionEnd = end + offset; + syncHighlight(null); + localStorage.setItem("zodd-source", sourceEl.value); + } else if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { + event.preventDefault(); + execute(); + } + }); + + runEl.addEventListener("click", execute); + planEl.addEventListener("click", showPlan); + shareEl.addEventListener("click", share); + + downloadEl.addEventListener("click", () => { + const blob = new Blob([sourceEl.value], {type: "text/plain;charset=utf-8"}); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "program.dl"; + a.click(); + URL.revokeObjectURL(url); + setStatus("downloaded", "ok"); + }); - viewTextEl.addEventListener("click", () => setView("text")); - viewTableEl.addEventListener("click", () => setView("table")); + clearEl.addEventListener("click", () => { + setSource(""); + setStatus("CLEARED", "cleared"); + }); - // Delegated listener: clicking a result row explains its derivation. - outputTableEl.addEventListener("click", (event) => { - if (event.target.closest(".copy-table-btn")) return; - const row = event.target.closest("tr[data-atom]"); - if (!row) return; - explainAtom(row.dataset.atom); - }); + clearOutputEl.addEventListener("click", () => { + outputEl.textContent = ""; + outputEl.classList.remove("error"); + outputTableEl.innerHTML = ""; + if (viewToggleEl) viewToggleEl.classList.add("hidden"); + setStatus("CLEARED", "cleared"); + telemetryInfoEl.textContent = ""; + }); - // Delegated listener to copy a table's data in TSV format (ignoring the index column) - outputTableEl.addEventListener("click", (event) => { - const btn = event.target.closest(".copy-table-btn"); - if (!btn) return; + // Loading a Datalog script from a file. + loadEl.addEventListener("click", () => fileEl.click()); + fileEl.addEventListener("change", async () => { + const file = fileEl.files[0]; + if (!file) return; + setSource(await file.text()); + // Reset so selecting the same file again still fires a change event. + fileEl.value = ""; + execute(); + }); - const group = btn.closest(".output-table-group"); - if (!group) return; + applyTheme(localStorage.getItem("zodd-theme") ?? "dark"); - const table = group.querySelector(".output-table-el"); - if (!table) return; + themeEl.addEventListener("click", () => { + const next = document.documentElement.dataset.theme === "light" ? "dark" : "light"; + localStorage.setItem("zodd-theme", next); + applyTheme(next); + }); - const rows = table.querySelectorAll("tbody tr"); - const headers = table.querySelectorAll("thead th"); + // Help dialog. + helpEl.addEventListener("click", () => helpDialogEl.showModal()); + helpCloseEl.addEventListener("click", () => helpDialogEl.close()); + helpDialogEl.addEventListener("click", (event) => { + if (event.target === helpDialogEl) helpDialogEl.close(); + }); - let tsvLines = []; + // About dialog. + aboutEl.addEventListener("click", () => aboutDialogEl.showModal()); + aboutCloseEl.addEventListener("click", () => aboutDialogEl.close()); + aboutDialogEl.addEventListener("click", (event) => { + // A click on the backdrop targets the dialog element itself. + if (event.target === aboutDialogEl) aboutDialogEl.close(); + }); - // Headers (skipping index column) - let headerCols = []; - for (let i = 0; i < headers.length; i++) { - if (headers[i].classList.contains("index-col")) continue; - headerCols.push(headers[i].textContent); - } - tsvLines.push(headerCols.join("\t")); - - // Rows (skipping index column) - for (let r = 0; r < rows.length; r++) { - const cells = rows[r].querySelectorAll("td"); - let rowCols = []; - for (let c = 0; c < cells.length; c++) { - if (cells[c].classList.contains("index-col")) continue; - rowCols.push(cells[c].textContent); - } - tsvLines.push(rowCols.join("\t")); - } + // Resizable split view. + dividerEl.addEventListener("pointerdown", (event) => { + event.preventDefault(); + dividerEl.classList.add("dragging"); + dividerEl.setPointerCapture(event.pointerId); + const onMove = (move) => { + const bounds = document.getElementById("split").getBoundingClientRect(); + const fraction = Math.min(0.85, Math.max(0.15, (move.clientX - bounds.left) / bounds.width)); + editorPane.style.flexBasis = `${(fraction * 100).toFixed(1)}%`; + }; + const onUp = () => { + dividerEl.classList.remove("dragging"); + dividerEl.releasePointerCapture(event.pointerId); + dividerEl.removeEventListener("pointermove", onMove); + dividerEl.removeEventListener("pointerup", onUp); + dividerEl.removeEventListener("pointercancel", onUp); + }; + dividerEl.addEventListener("pointermove", onMove); + dividerEl.addEventListener("pointerup", onUp); + dividerEl.addEventListener("pointercancel", onUp); + }); - const tsvText = tsvLines.join("\n"); - copyToClipboard(tsvText).then(() => { - const originalText = btn.textContent; - btn.textContent = "Copied!"; - btn.style.borderColor = "var(--ok)"; - btn.style.color = "var(--ok)"; - setTimeout(() => { - btn.textContent = originalText; - btn.style.borderColor = ""; - btn.style.color = ""; - }, 1500); - }).catch((err) => { - console.error("Clipboard copy failed: ", err); + // Initial program: a permalink if present, the autosaved progress if available, or the first example otherwise. + (function initSource() { + const hash = window.location.hash; + if (hash.startsWith("#program=")) { + try { + setSource(decodeProgram(hash.slice("#program=".length))); + return; + } catch { + // Bad permalink; fall back to the first example. + } + } + const saved = localStorage.getItem("zodd-source"); + if (saved !== null) { + setSource(saved); + return; + } + setSource(EXAMPLES[0].source); + })(); + + viewTextEl.addEventListener("click", () => setView("text")); + viewTableEl.addEventListener("click", () => setView("table")); + + // Delegated listener: clicking a result row explains its derivation. + outputTableEl.addEventListener("click", (event) => { + if (event.target.closest(".copy-table-btn")) return; + const row = event.target.closest("tr[data-atom]"); + if (!row) return; + explainAtom(row.dataset.atom); }); - }); - - // Load the Wasm module, then run the initial program. - loadWasm() - .then((exports) => { - wasm = exports; - - // Resolve and set version, build, and license metadata from Wasm - try { - const versionStr = decoder.decode( - new Uint8Array(wasm.memory.buffer, wasm.versionPtr(), wasm.versionLen()), - ); - const commitStr = decoder.decode( - new Uint8Array(wasm.memory.buffer, wasm.commitPtr(), wasm.commitLen()), - ); - const zigStr = decoder.decode( - new Uint8Array(wasm.memory.buffer, wasm.zigVersionPtr(), wasm.zigVersionLen()), - ); - const licenseStr = decoder.decode( - new Uint8Array(wasm.memory.buffer, wasm.licensePtr(), wasm.licenseLen()), - ); - - document.getElementById("about-version").textContent = `${versionStr} (Zig ${zigStr})`; - document.getElementById("about-build").textContent = `Wasm32 (${commitStr})`; - document.getElementById("about-license").textContent = licenseStr; - } catch (e) { - // Fallback if functions are missing - } - - setStatus("ready", "ok"); - execute(); - }) - .catch((err) => { - outputEl.textContent = `Failed to load zodd.wasm: ${err}\n\nBuild it with: make web`; - outputEl.classList.add("error"); - setStatus("load failed", "error"); + + // Delegated listener to copy a table's data in TSV format (ignoring the index column) + outputTableEl.addEventListener("click", (event) => { + const btn = event.target.closest(".copy-table-btn"); + if (!btn) return; + + const group = btn.closest(".output-table-group"); + if (!group) return; + + const table = group.querySelector(".output-table-el"); + if (!table) return; + + const rows = table.querySelectorAll("tbody tr"); + const headers = table.querySelectorAll("thead th"); + + let tsvLines = []; + + // Headers (skipping index column) + let headerCols = []; + for (let i = 0; i < headers.length; i++) { + if (headers[i].classList.contains("index-col")) continue; + headerCols.push(headers[i].textContent); + } + tsvLines.push(headerCols.join("\t")); + + // Rows (skipping index column) + for (let r = 0; r < rows.length; r++) { + const cells = rows[r].querySelectorAll("td"); + let rowCols = []; + for (let c = 0; c < cells.length; c++) { + if (cells[c].classList.contains("index-col")) continue; + rowCols.push(cells[c].textContent); + } + tsvLines.push(rowCols.join("\t")); + } + + const tsvText = tsvLines.join("\n"); + copyToClipboard(tsvText).then(() => { + const originalText = btn.textContent; + btn.textContent = "Copied!"; + btn.style.borderColor = "var(--ok)"; + btn.style.color = "var(--ok)"; + setTimeout(() => { + btn.textContent = originalText; + btn.style.borderColor = ""; + btn.style.color = ""; + }, 1500); + }).catch((err) => { + console.error("Clipboard copy failed: ", err); + }); }); + + // Load the Wasm module, then run the initial program. + loadWasm() + .then((exports) => { + wasm = exports; + + // Resolve and set version, build, and license metadata from Wasm + try { + const versionStr = decoder.decode( + new Uint8Array(wasm.memory.buffer, wasm.versionPtr(), wasm.versionLen()), + ); + const commitStr = decoder.decode( + new Uint8Array(wasm.memory.buffer, wasm.commitPtr(), wasm.commitLen()), + ); + const zigStr = decoder.decode( + new Uint8Array(wasm.memory.buffer, wasm.zigVersionPtr(), wasm.zigVersionLen()), + ); + const licenseStr = decoder.decode( + new Uint8Array(wasm.memory.buffer, wasm.licensePtr(), wasm.licenseLen()), + ); + + document.getElementById("about-version").textContent = `${versionStr} (Zig ${zigStr})`; + document.getElementById("about-build").textContent = `Wasm32 (${commitStr})`; + document.getElementById("about-license").textContent = licenseStr; + } catch (e) { + // Fallback if functions are missing + } + + setStatus("ready", "ok"); + execute(); + }) + .catch((err) => { + outputEl.textContent = `Failed to load zodd.wasm: ${err}\n\nBuild it with: make web`; + outputEl.classList.add("error"); + setStatus("load failed", "error"); + }); } // --- Node exports for testing ------------------------------------------------- if (typeof exports !== "undefined") { - exports.parseTuple = parseTuple; - exports.cleanValue = cleanValue; - exports.parseOutputToTables = parseOutputToTables; - exports.highlight = highlight; - exports.encodeProgram = encodeProgram; - exports.decodeProgram = decodeProgram; + exports.parseTuple = parseTuple; + exports.cleanValue = cleanValue; + exports.parseOutputToTables = parseOutputToTables; + exports.highlight = highlight; + exports.encodeProgram = encodeProgram; + exports.decodeProgram = decodeProgram; } diff --git a/web/smoke_test.mjs b/web/smoke_test.mjs index c10f04d..997c6ab 100644 --- a/web/smoke_test.mjs +++ b/web/smoke_test.mjs @@ -1,17 +1,17 @@ // Smoke test for the web frontend Wasm module. Run with `make web-test` // (which builds the module and stages it as web/zodd.wasm first). -import { readFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; -import { createRequire } from "node:module"; +import {readFile} from "node:fs/promises"; +import {fileURLToPath} from "node:url"; +import {createRequire} from "node:module"; const require = createRequire(import.meta.url); -const { parseTuple, parseOutputToTables, cleanValue } = require("./main.js"); +const {parseTuple, parseOutputToTables, cleanValue} = require("./main.js"); const wasmPath = fileURLToPath(new URL("./zodd.wasm", import.meta.url)); const bytes = await readFile(wasmPath); -const { instance } = await WebAssembly.instantiate(bytes, {}); +const {instance} = await WebAssembly.instantiate(bytes, {}); const exports = instance.exports; const encoder = new TextEncoder(); @@ -19,38 +19,38 @@ const decoder = new TextDecoder(); // Calls a Wasm export taking (ptr, len) pairs, one per string argument. function call(fnName, strings) { - const buffers = strings.map((s) => encoder.encode(s)); - const ptrs = buffers.map((bytes) => { - // Zero-length allocations return a dangling pointer; pass (0, 0) instead. - if (bytes.length === 0) return 0; - const ptr = exports.alloc(bytes.length); - if (ptr === 0) throw new Error("alloc failed"); - // Create the view after alloc: memory growth detaches earlier views. - new Uint8Array(exports.memory.buffer, ptr, bytes.length).set(bytes); - return ptr; - }); - const args = []; - buffers.forEach((bytes, i) => args.push(ptrs[i], bytes.length)); - const status = exports[fnName](...args); - buffers.forEach((bytes, i) => { - if (ptrs[i] !== 0) exports.dealloc(ptrs[i], bytes.length); - }); - // Re-view after the call for the same reason. - const out = decoder.decode( - new Uint8Array(exports.memory.buffer, exports.outputPtr(), exports.outputLen()), - ); - return { status, out }; + const buffers = strings.map((s) => encoder.encode(s)); + const ptrs = buffers.map((bytes) => { + // Zero-length allocations return a dangling pointer; pass (0, 0) instead. + if (bytes.length === 0) return 0; + const ptr = exports.alloc(bytes.length); + if (ptr === 0) throw new Error("alloc failed"); + // Create the view after alloc: memory growth detaches earlier views. + new Uint8Array(exports.memory.buffer, ptr, bytes.length).set(bytes); + return ptr; + }); + const args = []; + buffers.forEach((bytes, i) => args.push(ptrs[i], bytes.length)); + const status = exports[fnName](...args); + buffers.forEach((bytes, i) => { + if (ptrs[i] !== 0) exports.dealloc(ptrs[i], bytes.length); + }); + // Re-view after the call for the same reason. + const out = decoder.decode( + new Uint8Array(exports.memory.buffer, exports.outputPtr(), exports.outputLen()), + ); + return {status, out}; } function run(source) { - return call("run", [source]); + return call("run", [source]); } function expect(condition, message) { - if (!condition) { - console.error(`FAIL: ${message}`); - process.exit(1); - } + if (!condition) { + console.error(`FAIL: ${message}`); + process.exit(1); + } } // Success path: recursion plus a stored query. diff --git a/web/style.css b/web/style.css index f4ed973..57596fe 100644 --- a/web/style.css +++ b/web/style.css @@ -1,724 +1,837 @@ @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap'); :root { - --bg: #15181d; /* Deeper, more immersive dark background */ - --bg-panel: #1e222b; /* Refined One Dark panel tone */ - --bg-toolbar: #1a1d24; /* Balanced toolbar background */ - --border: #2e3340; /* Soft, low-contrast dark border */ - --fg: #abb2bf; - --fg-dim: #737c8c; - --accent: #f5a623; /* Vibrant golden amber */ - --accent-hover: #f7b343; /* Brighter amber for hover interactive states */ - --accent-fg: #15181d; /* High-contrast dark text on gold background */ - --focus-ring: rgba(245, 166, 35, 0.25); - --error: #e06c75; - --ok: #98c379; - --tok-comment: #5c6370; - --tok-string: #98c379; - --tok-number: #d19a66; - --tok-variable: #61afef; - --tok-predicate: #e5c07b; - --tok-keyword: #c678dd; - --tok-punct: #abb2bf; - --mono: "JetBrains Mono", "Fira Code", "SF Mono", Consolas, "Liberation Mono", monospace; - --sans: "Plus Jakarta Sans", system-ui, -apple-system, sans-serif; + --bg: #15181d; /* Deeper, more immersive dark background */ + --bg-panel: #1e222b; /* Refined One Dark panel tone */ + --bg-toolbar: #1a1d24; /* Balanced toolbar background */ + --border: #2e3340; /* Soft, low-contrast dark border */ + --fg: #abb2bf; + --fg-dim: #737c8c; + --accent: #f5a623; /* Vibrant golden amber */ + --accent-hover: #f7b343; /* Brighter amber for hover interactive states */ + --accent-fg: #15181d; /* High-contrast dark text on gold background */ + --focus-ring: rgba(245, 166, 35, 0.25); + --error: #e06c75; + --ok: #98c379; + --tok-comment: #5c6370; + --tok-string: #98c379; + --tok-number: #d19a66; + --tok-variable: #61afef; + --tok-predicate: #e5c07b; + --tok-keyword: #c678dd; + --tok-punct: #abb2bf; + --mono: "JetBrains Mono", "Fira Code", "SF Mono", Consolas, "Liberation Mono", monospace; + --sans: "Plus Jakarta Sans", system-ui, -apple-system, sans-serif; } :root[data-theme="light"] { - --bg: #fdfdfc; /* Premium Warm Alabaster background */ - --bg-panel: #f5f4ef; /* Smooth porcelain-like container */ - --bg-toolbar: #edebe4; /* Distinct warm-gray toolbar */ - --border: #d4cfc3; /* Vintage paper border line */ - --fg: #332f29; /* Slate charcoal */ - --fg-dim: #7c7569; - --accent: #a83d00; /* Rich, high-contrast terracotta burnt orange */ - --accent-hover: #c44700; /* Vivid burnt orange for hover reactive states */ - --accent-fg: #fdfdfc; /* High-contrast warm white on terracotta */ - --focus-ring: rgba(168, 61, 0, 0.2); - --error: #d32f2f; - --ok: #388e3c; - --tok-comment: #8b8f97; - --tok-string: #388e3c; - --tok-number: #78350f; /* Rich deep amber-brown */ - --tok-variable: #1a73e8; - --tok-predicate: #0f766e; /* Deep premium teal */ - --tok-keyword: #8e24aa; - --tok-punct: #332f29; -} - -* { box-sizing: border-box; } + --bg: #fdfdfc; /* Premium Warm Alabaster background */ + --bg-panel: #f5f4ef; /* Smooth porcelain-like container */ + --bg-toolbar: #edebe4; /* Distinct warm-gray toolbar */ + --border: #d4cfc3; /* Vintage paper border line */ + --fg: #332f29; /* Slate charcoal */ + --fg-dim: #7c7569; + --accent: #a83d00; /* Rich, high-contrast terracotta burnt orange */ + --accent-hover: #c44700; /* Vivid burnt orange for hover reactive states */ + --accent-fg: #fdfdfc; /* High-contrast warm white on terracotta */ + --focus-ring: rgba(168, 61, 0, 0.2); + --error: #d32f2f; + --ok: #388e3c; + --tok-comment: #8b8f97; + --tok-string: #388e3c; + --tok-number: #78350f; /* Rich deep amber-brown */ + --tok-variable: #1a73e8; + --tok-predicate: #0f766e; /* Deep premium teal */ + --tok-keyword: #8e24aa; + --tok-punct: #332f29; +} + +* { + box-sizing: border-box; +} html, body { - height: 100%; - margin: 0; + height: 100%; + margin: 0; } body { - display: flex; - flex-direction: column; - background: var(--bg); - color: var(--fg); - font-family: var(--sans); - font-size: 14px; - transition: background-color 0.25s ease, color 0.25s ease, border-color 0.25s ease; + display: flex; + flex-direction: column; + background: var(--bg); + color: var(--fg); + font-family: var(--sans); + font-size: 14px; + transition: background-color 0.25s ease, color 0.25s ease, border-color 0.25s ease; } header, nav, .toolbar, .pane, #output, footer, dialog, button, select, #divider { - transition: background-color 0.25s ease, color 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease; + transition: background-color 0.25s ease, color 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease; } header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 16px; - background: var(--bg-toolbar); - border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + background: var(--bg-toolbar); + border-bottom: 1px solid var(--border); } .brand { - display: flex; - align-items: center; - gap: 10px; + display: flex; + align-items: center; + gap: 10px; } .brand h1 { - margin: 0; - font-size: 16px; - font-weight: 600; + margin: 0; + font-size: 16px; + font-weight: 600; } -nav { display: flex; align-items: center; gap: 16px; } +nav { + display: flex; + align-items: center; + gap: 16px; +} nav a { - color: var(--accent); - text-decoration: none; - transition: color 0.15s ease; + color: var(--accent); + text-decoration: none; + transition: color 0.15s ease; } -nav a:hover { text-decoration: underline; } +nav a:hover { + text-decoration: underline; +} nav button { - background: none; - border: 0; - padding: 0; - color: var(--accent); - font-family: inherit; - font-size: 14px; - cursor: pointer; - transition: color 0.15s ease; + background: none; + border: 0; + padding: 0; + color: var(--accent); + font-family: inherit; + font-size: 14px; + cursor: pointer; + transition: color 0.15s ease; } -nav button:hover { text-decoration: underline; } +nav button:hover { + text-decoration: underline; +} -#theme { font-size: 15px; transition: color 0.15s ease, transform 0.15s ease; } -#theme:hover { text-decoration: none; transform: scale(1.08); } +#theme { + font-size: 15px; + transition: color 0.15s ease, transform 0.15s ease; +} + +#theme:hover { + text-decoration: none; + transform: scale(1.08); +} .toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 8px 16px; - background: var(--bg-toolbar); - border-bottom: 1px solid var(--border); - flex-wrap: wrap; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 8px 16px; + background: var(--bg-toolbar); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; } .toolbar-group { - display: flex; - align-items: center; - gap: 8px; + display: flex; + align-items: center; + gap: 8px; } .toolbar label { - color: var(--fg-dim); - font-size: 13px; + color: var(--fg-dim); + font-size: 13px; } .toolbar select, .toolbar button { - background: var(--bg-panel); - color: var(--fg); - border: 1px solid var(--border); - border-radius: 4px; - padding: 5px 12px; - font-size: 13px; - cursor: pointer; - font-family: inherit; - transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease; - outline: none; + background: var(--bg-panel); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 5px 12px; + font-size: 13px; + cursor: pointer; + font-family: inherit; + transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease; + outline: none; } .toolbar select:focus, .toolbar button:focus-visible { - border-color: var(--accent); - box-shadow: 0 0 0 2px var(--focus-ring); + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--focus-ring); } .toolbar button:hover { - border-color: var(--accent); + border-color: var(--accent); } .toolbar button:active { - transform: scale(0.97); + transform: scale(0.97); } #run { - background: var(--accent); - color: var(--accent-fg); - font-weight: 600; - border-color: var(--accent); + background: var(--accent); + color: var(--accent-fg); + font-weight: 600; + border-color: var(--accent); } #run:hover { - background: var(--accent-hover); - border-color: var(--accent-hover); + background: var(--accent-hover); + border-color: var(--accent-hover); } #status-container { - display: flex; - align-items: center; - margin-left: auto; + display: flex; + align-items: center; + margin-left: auto; } #status { - display: inline-flex; - align-items: center; - padding: 3px 10px; - border-radius: 12px; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - opacity: 0; - transition: opacity 0.2s ease, background-color 0.25s ease; - line-height: 1.2; + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0; + transition: opacity 0.2s ease, background-color 0.25s ease; + line-height: 1.2; } #status:not(:empty) { - opacity: 1; + opacity: 1; } #status.ok { - background: rgba(152, 195, 121, 0.15); - color: var(--ok); - border: 1px solid rgba(152, 195, 121, 0.3); + background: rgba(152, 195, 121, 0.15); + color: var(--ok); + border: 1px solid rgba(152, 195, 121, 0.3); } #status.error { - background: rgba(224, 108, 117, 0.15); - color: var(--error); - border: 1px solid rgba(224, 108, 117, 0.3); + background: rgba(224, 108, 117, 0.15); + color: var(--error); + border: 1px solid rgba(224, 108, 117, 0.3); } #status.cleared { - background: rgba(128, 128, 128, 0.12); - color: var(--fg-dim); - border: 1px solid rgba(128, 128, 128, 0.25); + background: rgba(128, 128, 128, 0.12); + color: var(--fg-dim); + border: 1px solid rgba(128, 128, 128, 0.25); } main { - display: flex; - flex: 1; - min-height: 0; + display: flex; + flex: 1; + min-height: 0; } .pane { - min-width: 120px; - overflow: hidden; - display: flex; + min-width: 120px; + overflow: hidden; + display: flex; +} + +.editor-pane { + flex: 0 0 55%; } -.editor-pane { flex: 0 0 55%; } .output-pane { - flex: 1; - background: var(--bg-panel); - display: flex; - flex-direction: column; + flex: 1; + background: var(--bg-panel); + display: flex; + flex-direction: column; } .output-header { - display: flex; - justify-content: flex-end; - padding: 6px 12px; - background: var(--bg-toolbar); - border-bottom: 1px solid var(--border); + display: flex; + justify-content: flex-end; + padding: 6px 12px; + background: var(--bg-toolbar); + border-bottom: 1px solid var(--border); } .output-header button { - background: var(--bg-panel); - color: var(--fg); - border: 1px solid var(--border); - border-radius: 4px; - padding: 4px 10px; - font-size: 11px; - font-family: var(--sans); - cursor: pointer; - transition: border-color 0.15s ease, background-color 0.15s ease; - outline: none; + background: var(--bg-panel); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 10px; + font-size: 11px; + font-family: var(--sans); + cursor: pointer; + transition: border-color 0.15s ease, background-color 0.15s ease; + outline: none; } .output-header button:hover { - border-color: var(--accent); + border-color: var(--accent); } #divider { - flex: 0 0 5px; - cursor: col-resize; - background: var(--border); - transition: background-color 0.15s ease; + flex: 0 0 5px; + cursor: col-resize; + background: var(--border); + transition: background-color 0.15s ease; } -#divider:hover, #divider.dragging { background: var(--accent); } +#divider:hover, #divider.dragging { + background: var(--accent); +} .editor-container { - display: flex; - flex: 1; - width: 100%; - height: 100%; - background: var(--bg); - overflow: hidden; + display: flex; + flex: 1; + width: 100%; + height: 100%; + background: var(--bg); + overflow: hidden; } .editor-gutter { - flex: 0 0 auto; - width: 44px; - background: var(--bg-panel); - border-right: 1px solid var(--border); - padding: 12px 0; - text-align: right; - user-select: none; - overflow: hidden; - position: relative; + flex: 0 0 auto; + width: 44px; + background: var(--bg-panel); + border-right: 1px solid var(--border); + padding: 12px 0; + text-align: right; + user-select: none; + overflow: hidden; + position: relative; } .editor-linenos { - font-family: var(--mono); - font-size: 13px; - line-height: 1.5; - color: var(--fg-dim); - padding-right: 8px; - white-space: pre; + font-family: var(--mono); + font-size: 13px; + line-height: 1.5; + color: var(--fg-dim); + padding-right: 8px; + white-space: pre; } /* Editor: a transparent textarea over a highlighted backdrop. */ .editor { - position: relative; - flex: 1; - overflow: hidden; + position: relative; + flex: 1; + overflow: hidden; } .editor pre, .editor textarea { - position: absolute; - inset: 0; - margin: 0; - padding: 12px; - border: 0; - font-family: var(--mono); - font-size: 13px; - line-height: 1.5; - tab-size: 4; - white-space: pre; - overflow: auto; + position: absolute; + inset: 0; + margin: 0; + padding: 12px; + border: 0; + font-family: var(--mono); + font-size: 13px; + line-height: 1.5; + tab-size: 4; + white-space: pre; + overflow: auto; } .editor pre { - pointer-events: none; - color: var(--fg); - background: var(--bg); - overflow: hidden; /* Prevent double scrollbars; positioning is synced via JS */ + pointer-events: none; + color: var(--fg); + background: var(--bg); + overflow: hidden; /* Prevent double scrollbars; positioning is synced via JS */ } .editor pre::-webkit-scrollbar { - display: none; /* Hide WebKit scrollbars */ + display: none; /* Hide WebKit scrollbars */ } .editor textarea { - resize: none; - outline: none; - background: transparent; - color: transparent; - caret-color: var(--fg); + resize: none; + outline: none; + background: transparent; + color: transparent; + caret-color: var(--fg); } .editor textarea::selection { - background: rgba(97, 175, 239, 0.25); - color: transparent; + background: rgba(97, 175, 239, 0.25); + color: transparent; } -.tok-comment { color: var(--tok-comment); font-style: italic; } -.tok-string { color: var(--tok-string); } -.tok-number { color: var(--tok-number); } -.tok-variable { color: var(--tok-variable); } -.tok-predicate { color: var(--tok-predicate); } -.tok-keyword { color: var(--tok-keyword); } -.tok-punct { color: var(--tok-punct); } +.tok-comment { + color: var(--tok-comment); + font-style: italic; +} + +.tok-string { + color: var(--tok-string); +} + +.tok-number { + color: var(--tok-number); +} + +.tok-variable { + color: var(--tok-variable); +} + +.tok-predicate { + color: var(--tok-predicate); +} + +.tok-keyword { + color: var(--tok-keyword); +} + +.tok-punct { + color: var(--tok-punct); +} #output { - flex: 1; - margin: 0; - padding: 12px; - overflow: auto; - font-family: var(--mono); - font-size: 13px; - line-height: 1.5; - white-space: pre; + flex: 1; + margin: 0; + padding: 12px; + overflow: auto; + font-family: var(--mono); + font-size: 13px; + line-height: 1.5; + white-space: pre; } -#output.error { color: var(--error); } +#output.error { + color: var(--error); +} footer { - padding: 6px 16px; - background: var(--bg-toolbar); - border-top: 1px solid var(--border); - color: var(--fg-dim); - font-size: 12px; + padding: 6px 16px; + background: var(--bg-toolbar); + border-top: 1px solid var(--border); + color: var(--fg-dim); + font-size: 12px; } footer code, footer kbd { - font-family: var(--mono); - background: var(--bg-panel); - border: 1px solid var(--border); - border-radius: 3px; - padding: 0 4px; + font-family: var(--mono); + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 3px; + padding: 0 4px; } dialog { - width: min(440px, 90vw); - padding: 28px 36px; - background: var(--bg-panel); - color: var(--fg); - border: 1px solid var(--border); - border-radius: 8px; - font-size: 14px; - line-height: 1.6; - outline: none; + width: min(440px, 90vw); + padding: 28px 36px; + background: var(--bg-panel); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 14px; + line-height: 1.6; + outline: none; } dialog[open] { - animation: dialogFadeIn 0.2s ease-out forwards; + animation: dialogFadeIn 0.2s ease-out forwards; } dialog::backdrop { - background: rgba(0, 0, 0, 0.4); - backdrop-filter: blur(2px); + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(2px); } @keyframes dialogFadeIn { - from { - opacity: 0; - transform: scale(0.98) translateY(5px); - } - to { - opacity: 1; - transform: scale(1) translateY(0); - } + from { + opacity: 0; + transform: scale(0.98) translateY(5px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } } dialog h2 { - margin-top: 0; - font-size: 18px; + margin-top: 0; + font-size: 18px; } -dialog a { color: var(--accent); } +dialog a { + color: var(--accent); +} dialog code { - font-family: var(--mono); - background: var(--bg); - border: 1px solid var(--border); - border-radius: 3px; - padding: 0 4px; + font-family: var(--mono); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 3px; + padding: 0 4px; } dialog button { - background: var(--bg); - color: var(--fg); - border: 1px solid var(--border); - border-radius: 4px; - padding: 5px 12px; - font-size: 13px; - cursor: pointer; - transition: border-color 0.15s ease, color 0.15s ease; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 5px 12px; + font-size: 13px; + cursor: pointer; + transition: border-color 0.15s ease, color 0.15s ease; } -dialog button:hover { border-color: var(--accent); } +dialog button:hover { + border-color: var(--accent); +} /* Styled scrollbars */ ::-webkit-scrollbar { - width: 8px; - height: 8px; + width: 8px; + height: 8px; } ::-webkit-scrollbar-track { - background: transparent; + background: transparent; } ::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 4px; + background: var(--border); + border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { - background: var(--fg-dim); + background: var(--fg-dim); } /* About box improvements based on ocelot web design */ .about-content { - margin-top: 14px; + margin-top: 14px; } .about-content p { - color: var(--fg); - font-size: 13px; - line-height: 1.5; - margin: 0 auto 14px; + color: var(--fg); + font-size: 13px; + line-height: 1.5; + margin: 0 auto 14px; } .about-info { - margin: 0 auto 16px; - border-top: 1px solid var(--border); + margin: 0 auto 16px; + border-top: 1px solid var(--border); } .about-row { - display: flex; - justify-content: space-between; - font-size: 12px; - padding: 6px 0; - border-bottom: 1px solid var(--border); - font-family: var(--mono); + display: flex; + justify-content: space-between; + font-size: 12px; + padding: 6px 0; + border-bottom: 1px solid var(--border); + font-family: var(--mono); } .about-row span:first-child { - color: var(--fg-dim); + color: var(--fg-dim); } .about-row span:last-child { - color: var(--fg); - font-weight: 500; + color: var(--fg); + font-weight: 500; } .about-support { - margin: 10px 0 0; - font-size: 12px; - color: var(--fg-dim); - line-height: 1.5; + margin: 10px 0 0; + font-size: 12px; + color: var(--fg-dim); + line-height: 1.5; } .about-actions { - margin: 16px 0; - font-size: 13px; - text-align: center; + margin: 16px 0; + font-size: 13px; + text-align: center; } .about-actions a { - color: var(--accent); - text-decoration: none; - font-weight: 500; + color: var(--accent); + text-decoration: none; + font-weight: 500; } .about-actions a:hover { - text-decoration: underline; + text-decoration: underline; } #about-dialog button { - display: block; - margin: 16px auto 0; + display: block; + margin: 16px auto 0; +} + +#help-dialog { + width: min(640px, 95vw); + max-height: 80vh; + overflow-y: auto; +} + +#help-dialog button { + display: block; + margin: 20px auto 0; +} + +.help-content { + margin-top: 14px; +} + +.help-content section { + margin-bottom: 20px; +} + +.help-content h3 { + margin: 0 0 8px; + font-size: 15px; + color: var(--accent); + border-bottom: 1px solid var(--border); + padding-bottom: 4px; +} + +.help-content p { + margin: 0 0 10px; + font-size: 13px; + line-height: 1.5; + color: var(--fg); +} + +.help-content ul { + margin: 0 0 12px; + padding-left: 20px; + font-size: 13px; +} + +.help-content li { + margin-bottom: 8px; +} + +.help-content pre { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 8px 12px; + margin: 8px 0; + overflow-x: auto; +} + +.help-content pre code { + background: transparent; + border: none; + padding: 0; + font-size: 12px; + line-height: 1.4; + color: var(--fg); } /* Inline error highlights in syntax backdrop */ .line-error { - background: rgba(224, 108, 117, 0.12) !important; - border-left: 3px solid var(--error) !important; - display: inline-block; - width: 100%; + background: rgba(224, 108, 117, 0.12) !important; + border-left: 3px solid var(--error) !important; + display: inline-block; + width: 100%; } /* Wasm engine loading states */ .loader-container { - display: flex; - align-items: center; - gap: 12px; - color: var(--fg-dim); - font-size: 13px; - padding: 8px 0; + display: flex; + align-items: center; + gap: 12px; + color: var(--fg-dim); + font-size: 13px; + padding: 8px 0; } .loader-spinner { - width: 14px; - height: 14px; - border: 2px solid var(--border); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; - display: inline-block; + width: 14px; + height: 14px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + display: inline-block; } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } /* Output header telemetry info panel */ #telemetry-info { - font-size: 12px; - color: var(--fg-dim); - margin-right: auto; - align-self: center; + font-size: 12px; + color: var(--fg-dim); + margin-right: auto; + align-self: center; } /* Hidden utility */ .hidden { - display: none !important; + display: none !important; } /* View toggle styling */ .view-toggle { - display: flex; - margin-right: 8px; - background: var(--bg-panel); - border: 1px solid var(--border); - border-radius: 4px; - padding: 1px; - align-self: center; + display: flex; + margin-right: 8px; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1px; + align-self: center; } .view-toggle button { - background: transparent; - border: none !important; - border-radius: 3px; - padding: 3px 8px; - font-size: 11px; - font-family: var(--sans); - color: var(--fg-dim); - cursor: pointer; - transition: color 0.15s ease, background-color 0.15s ease; - line-height: 1; + background: transparent; + border: none !important; + border-radius: 3px; + padding: 3px 8px; + font-size: 11px; + font-family: var(--sans); + color: var(--fg-dim); + cursor: pointer; + transition: color 0.15s ease, background-color 0.15s ease; + line-height: 1; } .view-toggle button:hover { - color: var(--fg); + color: var(--fg); } .view-toggle button.active { - background: var(--border); - color: var(--fg); + background: var(--border); + color: var(--fg); } /* Tabular output styling */ #output-table { - flex: 1; - margin: 0; - padding: 12px; - overflow: auto; + flex: 1; + margin: 0; + padding: 12px; + overflow: auto; } .output-table-group { - margin-bottom: 24px; + margin-bottom: 24px; } .output-table-header-row { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; } .output-table-title { - font-family: var(--mono); - font-size: 14px; - font-weight: 600; - color: var(--accent); - margin: 0; + font-family: var(--mono); + font-size: 14px; + font-weight: 600; + color: var(--accent); + margin: 0; } .copy-table-btn { - background: var(--bg-panel); - color: var(--fg-dim); - border: 1px solid var(--border); - border-radius: 4px; - padding: 3px 10px; - font-size: 11px; - font-family: var(--sans); - cursor: pointer; - transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease; - line-height: 1; + background: var(--bg-panel); + color: var(--fg-dim); + border: 1px solid var(--border); + border-radius: 4px; + padding: 3px 10px; + font-size: 11px; + font-family: var(--sans); + cursor: pointer; + transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease; + line-height: 1; } .copy-table-btn:hover { - color: var(--fg); - border-color: var(--accent); + color: var(--fg); + border-color: var(--accent); } .output-table-el { - width: 100%; - border-collapse: collapse; - margin-bottom: 12px; - font-family: var(--sans); - font-size: 13px; - background: var(--bg-panel); - border: 1px solid var(--border); - border-radius: 4px; - overflow: hidden; + width: 100%; + border-collapse: collapse; + margin-bottom: 12px; + font-family: var(--sans); + font-size: 13px; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 4px; + overflow: hidden; } .output-table-el th, .output-table-el td { - padding: 8px 12px; - text-align: left; - border-bottom: 1px solid var(--border); + padding: 8px 12px; + text-align: left; + border-bottom: 1px solid var(--border); } .output-table-el th { - background: var(--bg-toolbar); - font-weight: 600; - color: var(--fg); - border-bottom: 2px solid var(--border); + background: var(--bg-toolbar); + font-weight: 600; + color: var(--fg); + border-bottom: 2px solid var(--border); } .output-table-el th.index-col, .output-table-el td.index-col { - width: 40px; - text-align: center; - color: var(--fg-dim); - background: var(--bg-toolbar); - border-right: 1px solid var(--border); - user-select: none; + width: 40px; + text-align: center; + color: var(--fg-dim); + background: var(--bg-toolbar); + border-right: 1px solid var(--border); + user-select: none; } .output-table-el tr:last-child td { - border-bottom: none; + border-bottom: none; } .output-table-el tr:hover td { - background: rgba(255, 255, 255, 0.02); + background: rgba(255, 255, 255, 0.02); } .output-table-el tbody tr[data-atom] { - cursor: pointer; + cursor: pointer; } .output-table-el tbody tr[data-atom]:hover td { - background: rgba(127, 127, 127, 0.12); + background: rgba(127, 127, 127, 0.12); } .output-table-no-results { - font-family: var(--sans); - font-size: 13px; - color: var(--fg-dim); - font-style: italic; - padding: 12px 0; + font-family: var(--sans); + font-size: 13px; + color: var(--fg-dim); + font-style: italic; + padding: 12px 0; } .output-table-text-block { - margin: 0 0 16px 0; - padding: 8px 12px; - background: var(--bg-panel); - border: 1px solid var(--border); - border-radius: 4px; - color: var(--fg); - font-family: var(--mono); - font-size: 13px; - white-space: pre-wrap; - word-break: break-all; + margin: 0 0 16px 0; + padding: 8px 12px; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--fg); + font-family: var(--mono); + font-size: 13px; + white-space: pre-wrap; + word-break: break-all; } From a7efe8b232340830c38d51419f1a044f2556133c Mon Sep 17 00:00:00 2001 From: Hassan Abedi Date: Thu, 4 Jun 2026 20:50:03 +0200 Subject: [PATCH 3/5] WIP --- web/index.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/index.html b/web/index.html index 2042d7a..75ee7ca 100644 --- a/web/index.html +++ b/web/index.html @@ -97,25 +97,25 @@

Overview

Basic Elements

    -
  • Constants: Integers (such as 42) or double-quoted strings (such as +
  • Constants: integers (such as 42) or double-quoted strings (such as "alice"). Bare lowercase identifiers (such as alice) are not valid constants.
  • -
  • Variables: Identifiers starting with an uppercase letter or an underscore (such as +
  • Variables: identifiers starting with an uppercase letter or an underscore (such as X, Y, or the wildcard _).
  • -
  • Facts: Assertions of the form predicate(constants). representing base +
  • Facts: assertions of the form predicate(constants). representing base data.
    edge(1, 2).
     likes("alice", "tea").
  • -
  • Rules: Statements of the form head :- body., where the body is a +
  • Rules: statements of the form head :- body., where the body is a comma-separated list of subgoals (predicates applied to variables or constants) and optional comparison filters.
    path(X, Y) :- edge(X, Y).
     path(X, Z) :- path(X, Y), edge(Y, Z).
  • -
  • Queries: Requests of the form ?- predicate(arguments).. When a query +
  • Queries: requests of the form ?- predicate(arguments).. When a query is present, the engine returns only matching tuples; otherwise, all derived relations are returned.
    ?- path(1, X).
  • From 458799a3494f6d85d0e0a96cc5e8370c3c35f27d Mon Sep 17 00:00:00 2001 From: Hassan Abedi Date: Thu, 4 Jun 2026 20:55:29 +0200 Subject: [PATCH 4/5] WIP --- web/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/index.html b/web/index.html index 75ee7ca..dff8222 100644 --- a/web/index.html +++ b/web/index.html @@ -97,7 +97,7 @@

    Overview

    Basic Elements

      -
    • Constants: integers (such as 42) or double-quoted strings (such as +
    • Constants: integers (such as 68) or double-quoted strings (such as "alice"). Bare lowercase identifiers (such as alice) are not valid constants.
    • From e2a77927ffe75692625fe0fd94391a728db73903 Mon Sep 17 00:00:00 2001 From: Hassan Abedi Date: Fri, 5 Jun 2026 18:46:33 +0200 Subject: [PATCH 5/5] Fix a few bugs --- src/zodd/frontend/explain.zig | 5 +---- src/zodd/frontend/interner.zig | 35 ++++++++++++++++++++++++++++++++++ src/zodd/frontend/program.zig | 32 +++++++++++++++++++++++++++---- web/main.js | 1 + web/smoke_test.mjs | 17 +++++++++++++++++ 5 files changed, 82 insertions(+), 8 deletions(-) diff --git a/src/zodd/frontend/explain.zig b/src/zodd/frontend/explain.zig index 1e469c0..0795df3 100644 --- a/src/zodd/frontend/explain.zig +++ b/src/zodd/frontend/explain.zig @@ -20,10 +20,7 @@ fn predName(program: *const ast.Program, interner: *const Interner, pred: ast.Pr } fn writeValue(writer: *std.Io.Writer, interner: *const Interner, atom: dyntuple.Atom) WriteError!void { - switch (interner.resolve(atom)) { - .int => |v| try writer.print("{d}", .{v}), - .str => |s| try writer.print("\"{s}\"", .{s}), - } + try interner_mod.writeValueLiteral(writer, interner.resolve(atom)); } fn writeVar(writer: *std.Io.Writer, rule: *const ast.Rule, var_id: ast.VarId) WriteError!void { diff --git a/src/zodd/frontend/interner.zig b/src/zodd/frontend/interner.zig index 1794070..d15fc55 100644 --- a/src/zodd/frontend/interner.zig +++ b/src/zodd/frontend/interner.zig @@ -24,6 +24,26 @@ pub const Value = union(enum) { str: []const u8, }; +/// Writes a value as a Datalog literal. +pub fn writeValueLiteral(writer: *std.Io.Writer, value: Value) std.Io.Writer.Error!void { + switch (value) { + .int => |v| try writer.print("{d}", .{v}), + .str => |s| { + try writer.writeAll("\""); + for (s) |c| { + switch (c) { + 0x22 => try writer.writeAll(&.{ 0x5c, 0x22 }), + 0x5c => try writer.writeAll(&.{ 0x5c, 0x5c }), + 0x0a => try writer.writeAll(&.{ 0x5c, 0x6e }), + 0x09 => try writer.writeAll(&.{ 0x5c, 0x74 }), + else => try writer.writeAll(&.{c}), + } + } + try writer.writeAll("\""); + }, + } +} + /// Errors produced when encoding values into the atom space. pub const EncodeError = error{IntegerTooLarge}; @@ -132,6 +152,21 @@ test "Interner: round-trip ints and strings" { try std.testing.expectEqualStrings("x", interner.resolve(str_atom).str); } +test "Interner: value literal formatting escapes strings" { + var buffer: [64]u8 = undefined; + var writer = std.Io.Writer.fixed(&buffer); + + const value = [_]u8{ 0x61, 0x22, 0x62, 0x5c, 0x63, 0x0a, 0x09 }; + try writeValueLiteral(&writer, .{ .str = &value }); + + const expected = [_]u8{ + 0x22, 0x61, 0x5c, 0x22, 0x62, 0x5c, + 0x5c, 0x63, 0x5c, 0x6e, 0x5c, 0x74, + 0x22, + }; + try std.testing.expectEqualSlices(u8, &expected, writer.buffered()); +} + test "Interner: integers must fit in 63 bits" { try std.testing.expectEqual(@as(Atom, PAYLOAD_MASK), try encodeInt(PAYLOAD_MASK)); try std.testing.expectError(error.IntegerTooLarge, encodeInt(PAYLOAD_MASK + 1)); diff --git a/src/zodd/frontend/program.zig b/src/zodd/frontend/program.zig index 841d67d..d053609 100644 --- a/src/zodd/frontend/program.zig +++ b/src/zodd/frontend/program.zig @@ -253,10 +253,7 @@ pub const Row = struct { var col: usize = 0; while (col < self.arity) : (col += 1) { if (col > 0) try writer.writeAll(", "); - switch (self.get(col)) { - .int => |v| try writer.print("{d}", .{v}), - .str => |s| try writer.print("\"{s}\"", .{s}), - } + try interner_mod.writeValueLiteral(writer, self.get(col)); } try writer.writeAll(")"); } @@ -510,6 +507,33 @@ test "Database: row formatting" { try std.testing.expectEqualStrings("(1, \"a\")", writer.buffered()); } +test "Database: row formatting escapes strings" { + const allocator = std.testing.allocator; + + var db = Database.init(allocator); + defer db.deinit(); + + const quoted = [_]u8{ 0x61, 0x22, 0x62 }; + const line = [_]u8{ 0x6c, 0x69, 0x6e, 0x65, 0x0a }; + const path = [_]u8{ 0x70, 0x61, 0x74, 0x68, 0x5c, 0x72, 0x6f, 0x6f, 0x74 }; + try db.addFact("msg", &.{ .{ .str = "ed }, .{ .str = &line }, .{ .str = &path } }); + + var it = try db.query("msg", &.{ null, null, null }); + defer it.deinit(); + + var buffer: [96]u8 = undefined; + var writer = std.Io.Writer.fixed(&buffer); + try writer.print("{f}", .{it.next().?}); + + const expected = [_]u8{ + 0x28, 0x22, 0x61, 0x5c, 0x22, 0x62, 0x22, 0x2c, + 0x20, 0x22, 0x6c, 0x69, 0x6e, 0x65, 0x5c, 0x6e, + 0x22, 0x2c, 0x20, 0x22, 0x70, 0x61, 0x74, 0x68, + 0x5c, 0x5c, 0x72, 0x6f, 0x6f, 0x74, 0x22, 0x29, + }; + try std.testing.expectEqualSlices(u8, &expected, writer.buffered()); +} + test "Database: explainPlan writes every rule's plan" { const allocator = std.testing.allocator; diff --git a/web/main.js b/web/main.js index 5ae3d39..0158d23 100644 --- a/web/main.js +++ b/web/main.js @@ -888,6 +888,7 @@ function parseTuple(str) { current += char; escape = false; } else if (char === '\\') { + current += char; escape = true; } else if (char === '"') { inQuotes = !inQuotes; diff --git a/web/smoke_test.mjs b/web/smoke_test.mjs index 997c6ab..6dd15cc 100644 --- a/web/smoke_test.mjs +++ b/web/smoke_test.mjs @@ -156,9 +156,17 @@ expect(t3.length === 0, "empty parseTuple should return empty array"); const t4 = parseTuple(" "); expect(t4.length === 0, "whitespace-only parseTuple should return empty array"); +const t5 = parseTuple(String.raw`"line\n", "quote\"", "path\\root"`); +expect( + t5.length === 3 && t5[0] === String.raw`"line\n"` && t5[1] === String.raw`"quote\""` && t5[2] === String.raw`"path\\root"`, + "parseTuple should preserve escaped string contents", +); + // 2. cleanValue expect(cleanValue('"hello"') === "hello", "cleanValue unquote failed"); expect(cleanValue('"hello\\nworld"') === "hello\nworld", "cleanValue newline escaped failed"); +expect(cleanValue(String.raw`"quote\""`) === "quote\"", "cleanValue escaped quote failed"); +expect(cleanValue(String.raw`"path\\root"`) === "path\\root", "cleanValue escaped backslash failed"); expect(cleanValue("42") === "42", "cleanValue number unquoted failed"); // 3. parseOutputToTables @@ -177,6 +185,15 @@ expect(htmlTable.includes('data-atom="path(1, 2)"'), "explain atom attribute mis const htmlStrings = parseOutputToTables('safe:\n ("a")\n'); expect(htmlStrings.includes('data-atom="safe("a")"'), "quoted atom attribute missing"); +const htmlEscapedStrings = parseOutputToTables(String.raw`msg: + ("line\n", "quote\"", "path\\root") +`); +expect(htmlEscapedStrings.includes("line\n"), "escaped newline should render as a newline in table cells"); +expect( + htmlEscapedStrings.includes(String.raw`data-atom="msg("line\n", "quote\"", "path\\root")"`), + "escaped atom attribute missing", +); + const emptyOutput = `(no results)\n`; const htmlEmpty = parseOutputToTables(emptyOutput); expect(htmlEmpty.includes("No results"), "empty output handling failed");