From 90a27975d91d05425864df50e15ab1a637510537 Mon Sep 17 00:00:00 2001
From: Earlopain <14981592+Earlopain@users.noreply.github.com>
Date: Wed, 26 Aug 2026 22:16:33 +0200
Subject: [PATCH 1/2] Some playground improvements
* Add color to empty locations, numbers
* Format constant type like ruby symbol
* Fix display of string type as `[Object Object]`
* Include slice for location fields
* Add `location: ` to node locations like in ruby inspect output
---
doc/playground.css | 10 +++++++---
doc/playground.js | 42 +++++++++++++++++++++++++++++++++---------
2 files changed, 40 insertions(+), 12 deletions(-)
diff --git a/doc/playground.css b/doc/playground.css
index 9666ae4bfe..f41dba0395 100644
--- a/doc/playground.css
+++ b/doc/playground.css
@@ -263,12 +263,16 @@ main {
color: var(--color-text);
}
-.tree-string {
- color: #953800;
+.tree-string, .tree-constant {
+ color: #a31515;
+}
+
+.tree-number {
+ color: #098658;
}
.tree-null {
- color: var(--color-text-light);
+ color: #7e22ce;
font-style: italic;
}
diff --git a/doc/playground.js b/doc/playground.js
index f593b96afe..dbeff6431b 100644
--- a/doc/playground.js
+++ b/doc/playground.js
@@ -252,11 +252,18 @@ function offsetToLineCol(source, offset) {
}
-function formatLoc(source, loc) {
+function formatLoc(source, loc, includeSlice) {
if (!loc || loc.startOffset === undefined) return null;
const start = offsetToLineCol(source, loc.startOffset);
const end = offsetToLineCol(source, loc.startOffset + loc.length);
- return { start, end, text: `${start.line}:${start.col}-${end.line}:${end.col}` };
+
+ let text = `${start.line}:${start.col}-${end.line}:${end.col}`;
+
+ if (includeSlice) {
+ const slice = source.slice(loc.startOffset, loc.startOffset + loc.length);
+ text = `${text} = ${escapeHtml(JSON.stringify(slice))} `
+ }
+ return { start, end, text };
}
function locDataAttrs(loc) {
@@ -284,6 +291,14 @@ function isNode(value) {
return value && typeof value === "object" && !Array.isArray(value) && value.location && value.constructor && value.constructor.name !== "Object";
}
+function isString(value) {
+ return value && typeof value === "object" && Object.hasOwn(value, "encoding")
+}
+
+function isConstant(value) {
+ return typeof value === "string";
+}
+
// Get the node type name from the class name
function nodeType(node) {
return node.constructor?.name || "Unknown";
@@ -346,11 +361,11 @@ function renderNode(node, source, prefix, isLast, isRoot) {
if (!isRoot) html += `${prefix}${isLast ? CONNECTOR.last : CONNECTOR.mid} `;
if (foldable) html += `▼ `;
- const loc = formatLoc(source, node.location);
+ const loc = formatLoc(source, node.location, false);
const locAttrs = locDataAttrs(loc);
html += `@ ${escapedType} `;
- if (loc) html += ` (${loc.text}) `;
+ if (loc) html += ` (location: ${loc.text}) `;
html += ``;
html += `
`;
@@ -378,7 +393,11 @@ function renderNode(node, source, prefix, isLast, isRoot) {
html += renderNode(item, source, fieldChildPrefix, i === value.length - 1);
} else {
const itemConnector = i === value.length - 1 ? CONNECTOR.last : CONNECTOR.mid;
- html += `
${fieldChildPrefix}${itemConnector} ${escapeHtml(JSON.stringify(item))}
`;
+ if (isConstant(item)) {
+ html += `
${fieldChildPrefix}${itemConnector} :${escapeHtml(item)}
`;
+ } else {
+ html += `
${fieldChildPrefix}${itemConnector} ${escapeHtml(JSON.stringify(item))}
`;
+ }
}
});
}
@@ -386,13 +405,18 @@ function renderNode(node, source, prefix, isLast, isRoot) {
html += `
${childPrefix}${fieldConnector} ${escapeHtml(field)} :
`;
html += renderNode(value, source, fieldChildPrefix, true);
} else if (typeof value === "object" && value.startOffset !== undefined) {
- const fieldLoc = formatLoc(source, value);
+ const fieldLoc = formatLoc(source, value, true);
if (fieldLoc) {
html += `
${childPrefix}${fieldConnector} ${escapeHtml(field)} : ${fieldLoc.text}
`;
}
- } else if (typeof value === "string") {
- html += `
${childPrefix}${fieldConnector} ${escapeHtml(field)} : ${escapeHtml(JSON.stringify(value))}
`;
+ } else if (isString(value)) {
+ html += `
${childPrefix}${fieldConnector} ${escapeHtml(field)} : ${escapeHtml(JSON.stringify(value.value))}
`;
+ } else if (isConstant(value)) {
+ html += `
${childPrefix}${fieldConnector} ${escapeHtml(field)} : :${escapeHtml(value)}
`;
+ } else if (typeof value === "number"){
+ html += `
${childPrefix}${fieldConnector} ${escapeHtml(field)} : ${value}
`;
} else {
+ // Should not reach
html += `
${childPrefix}${fieldConnector} ${escapeHtml(field)} : ${escapeHtml(String(value))}
`;
}
});
@@ -408,7 +432,7 @@ function escapeHtml(str) {
// Render a single diagnostic line
function renderDiagnostic(source, item, kind) {
- const loc = formatLoc(source, item.location);
+ const loc = formatLoc(source, item.location, false);
const cssClass = kind === "Error" ? "error-text" : "warning-text";
return `
${kind}: ${escapeHtml(item.message)}${loc ? ` (${loc.text}) ` : ""}
`;
}
From b4a5db96477cb7c9bfb213c2421d0672a1370f1d Mon Sep 17 00:00:00 2001
From: Earlopain <14981592+Earlopain@users.noreply.github.com>
Date: Wed, 26 Aug 2026 23:11:02 +0200
Subject: [PATCH 2/2] Correctly handle multibyte chars in the playground
Javascript strings are always utf16, TextEncoder
converts it to utf-8 bytes.
Doing it like this seems like a fine solution
---
doc/playground.js | 45 +++++++++++++++++++++++++--------------------
1 file changed, 25 insertions(+), 20 deletions(-)
diff --git a/doc/playground.js b/doc/playground.js
index dbeff6431b..ee33a0f965 100644
--- a/doc/playground.js
+++ b/doc/playground.js
@@ -6,6 +6,9 @@ const editorDiv = document.getElementById("editor");
const loading = document.getElementById("loading");
const toast = document.getElementById("toast");
+const encoder = new TextEncoder();
+const decoder = new TextDecoder();
+
// Load Prism WASM and Monaco, show error if either fails
let instance, monaco;
try {
@@ -115,7 +118,7 @@ end
// URL-safe base64 encode/decode (RFC 4648 §5)
function encodeSource(str) {
- const bytes = new TextEncoder().encode(str);
+ const bytes = encoder.encode(str);
let binary = "";
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
@@ -123,7 +126,7 @@ function encodeSource(str) {
function decodeSource(str) {
const padded = str.replace(/-/g, "+").replace(/_/g, "/") + "==".slice(0, (4 - str.length % 4) % 4);
- return new TextDecoder().decode(Uint8Array.from(atob(padded), ch => ch.codePointAt(0)));
+ return decoder.decode(Uint8Array.from(atob(padded), ch => ch.codePointAt(0)));
}
// Read initial source from URL hash or use default
@@ -241,26 +244,27 @@ document.getElementById("expand-all").addEventListener("click", () => {
output.querySelectorAll(".tree-toggle").forEach(toggle => setToggleState(toggle, false));
});
-// Convert byte offset to line:column using the source string
-function offsetToLineCol(source, offset) {
+// Convert byte offset to line:column using the utf8 bytes
+function offsetToLineCol(utf8Bytes, offset) {
let line = 1, col = 0;
- for (let i = 0; i < offset && i < source.length; i++) {
- if (source[i] === "\n") { line++; col = 0; }
+ for (let i = 0; i < offset && i < utf8Bytes.length; i++) {
+ // Check for newline
+ if (utf8Bytes[i] === 10) { line++; col = 0; }
else { col++; }
}
return { line, col };
}
-function formatLoc(source, loc, includeSlice) {
+function formatLoc(utf8Bytes, loc, includeSlice) {
if (!loc || loc.startOffset === undefined) return null;
- const start = offsetToLineCol(source, loc.startOffset);
- const end = offsetToLineCol(source, loc.startOffset + loc.length);
+ const start = offsetToLineCol(utf8Bytes, loc.startOffset);
+ const end = offsetToLineCol(utf8Bytes, loc.startOffset + loc.length);
let text = `${start.line}:${start.col}-${end.line}:${end.col}`;
if (includeSlice) {
- const slice = source.slice(loc.startOffset, loc.startOffset + loc.length);
+ const slice = decoder.decode(utf8Bytes.slice(loc.startOffset, loc.startOffset + loc.length));
text = `${text} =
${escapeHtml(JSON.stringify(slice))} `
}
return { start, end, text };
@@ -348,7 +352,7 @@ function hasChildNodes(fields, node) {
const CONNECTOR = { last: "└── ", mid: "├── ", lastPad: " ", midPad: "│ " };
// Build the AST tree as interactive HTML
-function renderNode(node, source, prefix, isLast, isRoot) {
+function renderNode(node, utf8Bytes, prefix, isLast, isRoot) {
if (!isNode(node)) return "";
const type = nodeType(node);
@@ -361,7 +365,7 @@ function renderNode(node, source, prefix, isLast, isRoot) {
if (!isRoot) html += `
${prefix}${isLast ? CONNECTOR.last : CONNECTOR.mid} `;
if (foldable) html += `
▼ `;
- const loc = formatLoc(source, node.location, false);
+ const loc = formatLoc(utf8Bytes, node.location, false);
const locAttrs = locDataAttrs(loc);
html += `
@ ${escapedType} `;
@@ -390,7 +394,7 @@ function renderNode(node, source, prefix, isLast, isRoot) {
html += `
${childPrefix}${fieldConnector} ${escapeHtml(field)} : (${value.length} item${value.length === 1 ? "" : "s"})
`;
value.forEach((item, i) => {
if (isNode(item)) {
- html += renderNode(item, source, fieldChildPrefix, i === value.length - 1);
+ html += renderNode(item, utf8Bytes, fieldChildPrefix, i === value.length - 1);
} else {
const itemConnector = i === value.length - 1 ? CONNECTOR.last : CONNECTOR.mid;
if (isConstant(item)) {
@@ -403,9 +407,9 @@ function renderNode(node, source, prefix, isLast, isRoot) {
}
} else if (isNode(value)) {
html += `
${childPrefix}${fieldConnector} ${escapeHtml(field)} :
`;
- html += renderNode(value, source, fieldChildPrefix, true);
+ html += renderNode(value, utf8Bytes, fieldChildPrefix, true);
} else if (typeof value === "object" && value.startOffset !== undefined) {
- const fieldLoc = formatLoc(source, value, true);
+ const fieldLoc = formatLoc(utf8Bytes, value, true);
if (fieldLoc) {
html += `
${childPrefix}${fieldConnector} ${escapeHtml(field)} : ${fieldLoc.text}
`;
}
@@ -431,8 +435,8 @@ function escapeHtml(str) {
}
// Render a single diagnostic line
-function renderDiagnostic(source, item, kind) {
- const loc = formatLoc(source, item.location, false);
+function renderDiagnostic(utf8Bytes, item, kind) {
+ const loc = formatLoc(utf8Bytes, item.location, false);
const cssClass = kind === "Error" ? "error-text" : "warning-text";
return `
${kind}: ${escapeHtml(item.message)}${loc ? ` (${loc.text}) ` : ""}
`;
}
@@ -477,9 +481,10 @@ function render() {
output.setAttribute("aria-labelledby", currentTab === "ast" ? "tab-ast" : "tab-diagnostics");
+ const utf8Bytes = encoder.encode(lastSource);
switch (currentTab) {
case "ast":
- const tree = renderNode(lastResult.value, lastSource, "", true, true);
+ const tree = renderNode(lastResult.value, utf8Bytes, "", true, true);
output.innerHTML = tree
? `
${tree}
`
: `
${escapeHtml(lastResult.error || "Failed to parse.")}
`;
@@ -492,8 +497,8 @@ function render() {
output.innerHTML = `
No errors or warnings.
`;
} else {
let html = "";
- for (const err of errors) html += renderDiagnostic(lastSource, err, "Error");
- for (const warn of warnings) html += renderDiagnostic(lastSource, warn, "Warning");
+ for (const err of errors) html += renderDiagnostic(utf8Bytes, err, "Error");
+ for (const warn of warnings) html += renderDiagnostic(utf8Bytes, warn, "Warning");
output.innerHTML = html;
}
break;