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 += ``; 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 += `
${escapeHtml(JSON.stringify(item))}
`; + if (isConstant(item)) { + html += `
:${escapeHtml(item)}
`; + } else { + html += `
${escapeHtml(JSON.stringify(item))}
`; + } } }); } @@ -386,13 +405,18 @@ function renderNode(node, source, prefix, isLast, isRoot) { html += `
${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 += `
${escapeHtml(field)}: ${fieldLoc.text}
`; } - } else if (typeof value === "string") { - html += `
${escapeHtml(field)}: ${escapeHtml(JSON.stringify(value))}
`; + } else if (isString(value)) { + html += `
${escapeHtml(field)}: ${escapeHtml(JSON.stringify(value.value))}
`; + } else if (isConstant(value)) { + html += `
${escapeHtml(field)}: :${escapeHtml(value)}
`; + } else if (typeof value === "number"){ + html += `
${escapeHtml(field)}: ${value}
`; } else { + // Should not reach html += `
${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 += ``; 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 += `
${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 += `
${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 += `
${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;