stack traces in the brush editor

after 35 thousand years it's finally here
good erro message
This commit is contained in:
りき萌 2025-06-25 20:51:34 +02:00
parent c1612b2a94
commit e49885c83a
11 changed files with 710 additions and 150 deletions

View file

@ -2,6 +2,7 @@
:root {
--color-text: #111;
--color-text-dim: #777;
--color-error: #db344b;
--color-brand-blue: #40b1f4;
@ -190,6 +191,12 @@ pre:has(code) {
overflow: auto;
}
/* Abbreviations */
abbr {
cursor: help;
}
/* Icons */
:root {

View file

@ -1,4 +1,4 @@
import { CodeEditor } from "rkgk/code-editor.js";
import { CodeEditor, Selection } from "rkgk/code-editor.js";
import { SaveData } from "rkgk/framework.js";
import { builtInPresets } from "rkgk/brush-box.js";
@ -33,6 +33,10 @@ export class BrushEditor extends HTMLElement {
this.codeEditor = this.appendChild(
new CodeEditor([
{
className: "layer-syntax",
render: (code, element) => this.#renderSyntax(code, element),
},
{
className: "layer-error-squiggles",
render: (code, element) => this.#renderErrorSquiggles(code, element),
@ -116,8 +120,32 @@ export class BrushEditor extends HTMLElement {
} else if (result.errorKind == "plain") {
this.errorHeader.textContent = result.message;
} else if (result.errorKind == "exception") {
// TODO: This should show a stack trace.
this.errorArea.textContent = `an exception occurred: ${result.message}`;
let renderer = new ErrorException(result);
let squiggles = renderer.prepareSquiggles();
this.codeEditor.rebuildLineMap();
this.errorSquiggles = this.#computeErrorSquiggles(
this.codeEditor.lineMap,
squiggles.diagnostics,
);
this.codeEditor.renderLayer("layer-error-squiggles");
renderer.addEventListener(".functionNameMouseEnter", (event) => {
squiggles.highlightSegment(event.frameIndex);
});
renderer.addEventListener(".functionNameMouseLeave", () => {
squiggles.highlightSegment(null);
});
renderer.addEventListener(".functionNameClick", (event) => {
let span = event.frame.span;
this.codeEditor.textArea.focus();
this.codeEditor.setSelection(new Selection(span.start, span.end));
});
this.errorArea.replaceChildren();
this.errorArea.appendChild(renderer);
} else {
console.warn(`unknown error kind: ${result.errorKind}`);
this.errorHeader.textContent = "(unknown error kind)";
@ -206,6 +234,10 @@ export class BrushEditor extends HTMLElement {
let spanElement = lineElement.appendChild(document.createElement("span"));
spanElement.classList.add("squiggle", "squiggle-error");
spanElement.textContent = text;
for (let diagnostic of segment.diagnostics) {
diagnostic.customizeSegment?.(diagnostic, spanElement);
}
}
}
} else {
@ -213,6 +245,151 @@ export class BrushEditor extends HTMLElement {
}
}
}
#renderSyntax(lines, element) {
// TODO: Syntax highlighting.
// Right now we just render a layer of black text to have the text visible in the code editor.
element.textContent = lines.string;
}
}
customElements.define("rkgk-brush-editor", BrushEditor);
class ErrorException extends HTMLElement {
constructor(result) {
super();
this.result = result;
}
prepareSquiggles() {
let diagnostics = [];
let segments = [];
const customizeSegment = (diagnostic, segment) => {
// The control flow here is kind of spaghetti, but basically this fills out `segments`
// as the squiggles are being rendered.
// Once squiggles are rendered, you're free to call highlightSegment(i).
segments.push({
frameIndex: diagnostic.frameIndex,
colorIndex: diagnostic.colorIndex,
segment,
});
if (diagnostic.colorIndex != null) {
segment.classList.add("squiggle-colored");
segment.style.setProperty("--color-index", diagnostic.colorIndex);
}
};
for (let i = 0; i < this.result.stackTrace.length; ++i) {
let frame = this.result.stackTrace[i];
if (frame.span != null) {
diagnostics.push({
start: frame.span.start,
end: frame.span.end,
customizeSegment,
frameIndex: i,
colorIndex: i / this.result.stackTrace.length,
});
}
}
return {
diagnostics,
highlightSegment(frameIndex) {
let justHighlighted = new Set();
for (let entry of segments.values()) {
let segment = entry.segment;
if (entry.frameIndex == frameIndex) {
segment.classList.add("squiggle-highlighted");
// This logic exists so that a specific segment can display on top of
// another one if it's highlighted.
segment.style.setProperty("--highlight-color-index", entry.colorIndex);
justHighlighted.add(segment);
} else if (!justHighlighted.has(segment)) {
segment.classList.remove("squiggle-highlighted");
}
}
},
};
}
connectedCallback() {
let message = this.appendChild(document.createElement("p"));
message.classList.add("message");
message.textContent = this.result.message;
let stackTrace = this.appendChild(document.createElement("ol"));
stackTrace.classList.add("stack-trace");
for (let i = 0; i < this.result.stackTrace.length; ++i) {
let frame = this.result.stackTrace[i];
let line = stackTrace.appendChild(document.createElement("li"));
let [_, name, lambdasString] = frame.functionName.match(/([^λ]+)(λ*)/);
let inCaption = line.appendChild(document.createElement("span"));
inCaption.classList.add("in");
inCaption.innerText = "in ";
let functionName = line.appendChild(document.createElement("button"));
functionName.classList.add("function-name");
functionName.disabled = true;
functionName.innerText = name;
if (name == "(brush)") {
functionName.classList.add("function-name-brush");
functionName.title = "Top-level brush code";
}
let lambdas = functionName.appendChild(document.createElement("abbr"));
lambdas.classList.add("lambdas");
lambdas.textContent = lambdasString;
lambdas.title =
"λ - unnamed function\n" +
"Symbolizes functions that were not given a name.\n" +
"Each additional λ is one level of nesting inside another function.";
if (frame.isSystem) {
line.append(" ");
let system = line.appendChild(document.createElement("abbr"));
system.classList.add("system");
system.innerText = "(built-in)";
system.title =
"This function is built into rakugaki and does not exist in your brush's source code, so you can't see it in the editor.";
}
if (frame.span != null) {
functionName.disabled = false;
functionName.classList.add("source-link");
functionName.style.setProperty("--color-index", i / this.result.stackTrace.length);
functionName.addEventListener("mouseenter", () => {
this.dispatchEvent(
Object.assign(new Event(".functionNameMouseEnter"), {
frameIndex: i,
frame,
}),
);
});
functionName.addEventListener("mouseleave", () => {
this.dispatchEvent(
Object.assign(new Event(".functionNameMouseLeave"), {
frameIndex: i,
frame,
}),
);
});
functionName.addEventListener("click", () => {
this.dispatchEvent(
Object.assign(new Event(".functionNameClick"), {
frameIndex: i,
frame,
}),
);
});
}
}
}
}
customElements.define("rkgk-brush-editor-error-exception", ErrorException);

View file

@ -244,7 +244,7 @@ export class Haku {
w2.haku2_vm_destroy(this.#pVm2);
w2.haku2_defs_destroy(this.#pDefs2);
w2.haku2_limits_destroy(this.#pLimits2);
w2.haku_dealloc(this.#bytecode2.ptr);
freeString2(this.#bytecode2);
}
setBrush(code) {
@ -332,12 +332,49 @@ export class Haku {
return exn;
}
#stackTrace() {
let count = w2.haku2_vm_stackframe_count(this.#pVm2);
let trace = [];
for (let i = 0; i < count; ++i) {
let isSystem = w2.haku2_vm_stackframe_is_system(this.#pVm2, i) != 0;
let pc = w2.haku2_vm_stackframe_pc(this.#pVm2, i);
let span = null;
if (!isSystem && pc > 0) {
// NOTE: We find the span at (pc - 1), because stack frames' program counters are
// situated at where control flow _ought_ to return, and not where the function call
// is located. Therefore, to pull the program counter back into the function call,
// we subtract 1.
let pSpan = w.haku_bytecode_find_span(this.#pInstance, Math.max(0, pc - 1));
if (pSpan != 0) {
span = {
start: w.haku_span_start(pSpan),
end: w.haku_span_end(pSpan),
};
}
}
trace.push({
isSystem,
pc,
span,
functionName: readString(
memory2,
w2.haku2_vm_stackframe_function_name_len(this.#pVm2, i),
w2.haku2_vm_stackframe_function_name(this.#pVm2, i),
),
});
}
return trace;
}
#exceptionResult() {
return {
status: "error",
errorKind: "exception",
description: "Runtime error",
message: this.#exceptionMessage(),
stackTrace: this.#stackTrace(),
};
}

View file

@ -1,7 +1,7 @@
/* index.css - styles for index.html and generally main parts of the app
For shared styles (such as color definitions) check out base.css. */
* {
html {
/* On the main page, we don't really want to permit selecting things.
It comes off as janky-looking. */
user-select: none;
@ -412,22 +412,47 @@ rkgk-code-editor {
& > .squiggle-error {
text-decoration-color: var(--color-error);
&.squiggle-colored {
--color: oklch(40% 100% calc(var(--color-index) * 300));
text-decoration-color: var(--color);
background-color: oklch(from var(--color) 60% c h / 0.13);
&.squiggle-highlighted {
--highlight-color-index: var(--color-index);
text-decoration: none;
background-color: oklch(
40% 100% calc(var(--highlight-color-index) * 300)
);
color: white;
font-weight: bold;
}
}
}
}
}
& > .layer-syntax {
white-space: pre-wrap;
}
& > textarea {
display: block;
width: calc(100% - var(--gutter-width));
margin: 0;
margin-left: var(--gutter-width);
padding: 0;
box-sizing: border-box;
border: none;
overflow: hidden;
resize: none;
white-space: pre-wrap;
border: none;
background: none;
color: transparent;
caret-color: var(--color-text);
&:focus {
/* The outline is displayed on the parent element to also surround the gutter. */
@ -490,6 +515,67 @@ rkgk-brush-editor.rkgk-panel {
margin: 0;
color: var(--color-error);
white-space: pre-wrap;
user-select: text;
max-height: 20em;
overflow-y: auto;
}
}
rkgk-brush-editor-error-exception {
& > .message {
margin: 4px 0;
}
& > .stack-trace {
margin: 0;
margin-left: 4ch;
color: var(--color-text);
& > li {
&::marker {
color: var(--color-text-dim);
}
& > button.function-name {
border: none;
border-radius: 0;
padding: 0;
background: none;
user-select: text;
font-weight: bold;
&:not(:disabled) {
text-decoration: underline;
cursor: pointer;
}
&:disabled {
opacity: 100%;
}
&.source-link {
--color-index: 0; /* set by JavaScript */
--color: oklch(40% 100% calc(var(--color-index) * 300));
color: var(--color);
&:hover {
text-decoration: none;
background-color: var(--color);
color: white;
}
}
}
& > .system {
color: var(--color-text-dim);
}
}
}
}