diff --git a/app/terminal/editor.tsx b/app/terminal/editor.tsx index f86dde1e..f9ee089f 100644 --- a/app/terminal/editor.tsx +++ b/app/terminal/editor.tsx @@ -1,6 +1,6 @@ "use client"; -import { lazy, Suspense, useEffect, useState } from "react"; +import { lazy, Suspense, useEffect, useMemo, useState } from "react"; import clsx from "clsx"; import { useChangeTheme } from "@/themeToggle"; import { useEmbedContext } from "./embedContext"; @@ -41,7 +41,83 @@ interface EditorProps { } export function EditorComponent(props: EditorProps) { const theme = useChangeTheme(); - const { files, writeFile } = useEmbedContext(); + const { files, writeFile, diagnostics } = useEmbedContext(); + const fileDiagnostics = useMemo( + () => + Object.values(diagnostics) + .flat() + .filter((diag) => + diag.frames.some((f) => f.filename === props.filename) + ), + [diagnostics, props.filename] + ); + + const annotations = useMemo(() => { + return fileDiagnostics.flatMap((diag) => + diag.frames + .slice(0, 1) + .filter((f) => f.filename === props.filename) + .map((f) => ({ + row: Math.max(0, f.startLineNumber - 1), + column: Math.max(0, (f.startColumn ?? 1) - 1), + text: diag.message, + type: diag.severity ?? "error", // "error" | "warning" | "info" + })) + ); + }, [fileDiagnostics, props.filename]); + + const markers = useMemo(() => { + return fileDiagnostics.flatMap((diag) => + diag.frames + .map((f, i) => ({ ...f, isFirstFrame: i === 0 })) + .filter((f) => f.filename === props.filename) + .map((f) => { + const startRow = Math.max(0, f.startLineNumber - 1); + const endRow = f.endLineNumber + ? Math.max(startRow, f.endLineNumber - 1) + : startRow; + const startCol = + f.startColumn !== undefined ? Math.max(0, f.startColumn - 1) : 0; + const endCol = + f.endColumn !== undefined + ? Math.max(startCol + 1, f.endColumn - 1) + : Number.MAX_SAFE_INTEGER; + + const isError = (diag.severity ?? "error") === "error"; + const isWarning = diag.severity === "warning"; + const className = clsx( + "absolute rounded-b-none! border-dashed border-b-1", + isError + ? "border-error" + : isWarning + ? "border-warning" + : "border-accent", + f.isFirstFrame && + (isError + ? "bg-error/20" + : isWarning + ? "bg-warning/20" + : "bg-accent/20") + ); + + return { + startRow, + startCol, + endRow, + endCol, + className, + type: + f.startColumn !== undefined && + f.endColumn !== undefined && + startRow === endRow + ? ("text" as const) + : ("fullLine" as const), + inFront: false, + }; + }) + ); + }, [fileDiagnostics, props.filename]); + const code = files[props.filename] || props.initContent; useEffect(() => { if (!files[props.filename] && props.initContent) { @@ -207,6 +283,8 @@ export function EditorComponent(props: EditorProps) { value={code} onChange={(code: string) => writeFile({ [props.filename]: code })} setOptions={{ useWorker: false }} + annotations={annotations} + markers={markers} /> ) : ( diff --git a/app/terminal/embedContext.tsx b/app/terminal/embedContext.tsx index f1bcbcc7..5adc2232 100644 --- a/app/terminal/embedContext.tsx +++ b/app/terminal/embedContext.tsx @@ -1,6 +1,10 @@ "use client"; -import { ReplCommand, ReplOutput } from "@my-code/runtime/interface"; +import { + Diagnostic, + ReplCommand, + ReplOutput, +} from "@my-code/runtime/interface"; import { createContext, ReactNode, @@ -40,6 +44,10 @@ interface IEmbedContext { execResults: Readonly>; clearExecResult: (filename: Filename) => void; addExecOutput: (filename: Filename, output: ReplOutput) => void; + + diagnostics: Readonly>; + clearDiagnostics: (filename?: Filename) => void; + addDiagnostic: (filename: Filename, diagnostic: Diagnostic) => void; } const EmbedContext = createContext(null!); @@ -80,11 +88,15 @@ export function EmbedContextProvider({ const [execResults, setExecResults] = useState< Record >({}); + const [diagnostics, setDiagnostics] = useState< + Record + >({}); if (pageKey && pageKey !== prevPageKey) { setPrevPageKey(pageKey); setReplOutputs({}); setCommandIdCounters({}); setExecResults({}); + setDiagnostics({}); } const writeFile = useCallback( @@ -181,6 +193,30 @@ export function EmbedContextProvider({ [] ); + const clearDiagnostics = useCallback( + (filename?: Filename) => + setDiagnostics((diags) => { + if (filename !== undefined) { + const next = { ...diags }; + delete next[filename]; + return next; + } + return {}; + }), + [] + ); + const addDiagnostic = useCallback( + (filename: Filename, diagnostic: Diagnostic) => + setDiagnostics((diags) => { + const current = diags[filename] ? [...diags[filename]] : []; + return { + ...diags, + [filename]: [...current, diagnostic], + }; + }), + [] + ); + return ( {children} diff --git a/app/terminal/exec.tsx b/app/terminal/exec.tsx index d3b1e7ed..834fe7d1 100644 --- a/app/terminal/exec.tsx +++ b/app/terminal/exec.tsx @@ -69,8 +69,14 @@ export function ExecFile(props: ExecProps) { } }, }); - const { files, clearExecResult, addExecOutput, writeFile } = - useEmbedContext(); + const { + files, + clearExecResult, + addExecOutput, + writeFile, + clearDiagnostics, + addDiagnostic, + } = useEmbedContext(); if (props.language.runtime === undefined) { throw new Error( @@ -94,29 +100,37 @@ export function ExecFile(props: ExecProps) { // TODO: 1つのファイル名しか受け付けないところに無理やりコンマ区切りで全部のファイル名を突っ込んでいる const filenameKey = props.filenames.join(","); clearExecResult(filenameKey); + clearDiagnostics(filenameKey); setContents(""); let isFirstOutput = true; - await runFiles(props.filenames, files, (output) => { - if (output.type === "file") { - writeFile({ [output.filename]: output.content }); - return; - } - addExecOutput(filenameKey, output); - if (isFirstOutput) { - // Clear "実行中です..." message only on first output - clearTerminal(terminalInstanceRef.current!); - isFirstOutput = false; + await runFiles( + props.filenames, + files, + (output) => { + if (output.type === "file") { + writeFile({ [output.filename]: output.content }); + return; + } + addExecOutput(filenameKey, output); + if (isFirstOutput) { + // Clear "実行中です..." message only on first output + clearTerminal(terminalInstanceRef.current!); + isFirstOutput = false; + } + // Append only the new output + writeOutput( + terminalInstanceRef.current!, + output, + undefined, + null, // ファイル実行で"return"メッセージが返ってくることはないはずなので、Prismを渡す必要はない + props.language + ); + setContents((prev) => prev + output.message + "\n"); + }, + (diagnostic) => { + addDiagnostic(filenameKey, diagnostic); } - // Append only the new output - writeOutput( - terminalInstanceRef.current!, - output, - undefined, - null, // ファイル実行で"return"メッセージが返ってくることはないはずなので、Prismを渡す必要はない - props.language - ); - setContents((prev) => prev + output.message + "\n"); - }); + ); setExecutionState("idle"); if (isFirstOutput) { // If there was no output, clear the "実行中です..." message @@ -132,6 +146,8 @@ export function ExecFile(props: ExecProps) { clearExecResult, addExecOutput, writeFile, + clearDiagnostics, + addDiagnostic, terminalInstanceRef, props.language, files, diff --git a/packages/jsEval/src/index.ts b/packages/jsEval/src/index.ts index a90839c0..43ea50a4 100644 --- a/packages/jsEval/src/index.ts +++ b/packages/jsEval/src/index.ts @@ -1,4 +1,15 @@ export { replLikeEval } from "./eval"; export { checkSyntax } from "./syntax"; export { createReplConsole } from "./console"; +export { + parseStackTrace, + formatStackTrace, + findSyntaxErrorLine, + parseError, +} from "./stackTrace"; export type { ConsoleOutput, ConsoleEmitter, ReplConsole } from "./console"; +export type { + ParsedStackFrame, + DiagnosticFrameInfo, + ParsedErrorInfo, +} from "./stackTrace"; diff --git a/packages/jsEval/src/stackTrace.ts b/packages/jsEval/src/stackTrace.ts new file mode 100644 index 00000000..5b392767 --- /dev/null +++ b/packages/jsEval/src/stackTrace.ts @@ -0,0 +1,455 @@ +export interface ParsedStackFrame { + functionName?: string; + filename?: string; + lineNumber?: number; + columnNumber?: number; +} + +export interface DiagnosticFrameInfo { + filename: string; + startLineNumber: number; + startColumn?: number; + endLineNumber?: number; + endColumn?: number; +} + +export interface ParsedErrorInfo { + formattedStackTrace: string; + diagnostic: { + frames: DiagnosticFrameInfo[]; + message: string; + severity: "error"; + } | null; +} + +/** + * Parses the raw `Error.stack` string from various browser JavaScript engines + * (V8 / Chrome, SpiderMonkey / Firefox, JavaScriptCore / Safari) + * and extracts frames belonging to the evaluated user code. + */ +export function parseStackTrace( + stack: string, + defaultFilename: string = "main.js" +): ParsedStackFrame[] { + if (!stack) return []; + const lines = stack + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + const frames: ParsedStackFrame[] = []; + + for (const line of lines) { + // 1. Chrome / V8 / Node format (starts with "at ") + if (line.startsWith("at ")) { + // Skip pure eval invocation frames (e.g. "at eval ()", "at eval (native)") + if (/^at\s+eval\s*\((?:|native)\)$/.test(line)) { + continue; + } + + // V8 eval at pattern: + // e.g. "at foo (eval at runFile (webpack-internal://...), :2:9)" + // e.g. "at eval (eval at runFile (webpack-internal://...), :5:3)" + // e.g. "at eval at runFile (webpack-internal://...), :5:3" + if (line.includes("eval at ")) { + const lastCommaIdx = line.lastIndexOf(","); + if (lastCommaIdx !== -1) { + const afterComma = line.slice(lastCommaIdx + 1).trim(); + const locMatch = afterComma.match(/^([^:()\s]+):(\d+):(\d+)\)?$/); + if (locMatch) { + const rawFile = locMatch[1]; + const filename = + rawFile && rawFile !== "" ? rawFile : defaultFilename; + const lineNumber = parseInt(locMatch[2], 10); + const columnNumber = parseInt(locMatch[3], 10); + + let fn: string | undefined; + const fnMatch = line.match( + /^at\s+(?:async\s+)?([^\s(]+)\s+\(eval at\s/ + ); + if (fnMatch) { + const rawFn = fnMatch[1]; + if (rawFn && rawFn !== "eval" && rawFn !== "") { + fn = rawFn; + } + } + + frames.push({ + functionName: fn, + filename, + lineNumber, + columnNumber, + }); + continue; + } + } + } + + // Direct file reference pattern with parentheses: + // e.g. "at foo (main.js:2:9)", "at eval (main.js:5:3)", "at (main.js:5:3)" + const openParenIdx = line.lastIndexOf("("); + const closeParenIdx = line.lastIndexOf(")"); + if (openParenIdx !== -1 && closeParenIdx > openParenIdx) { + const insideParen = line.slice(openParenIdx + 1, closeParenIdx).trim(); + const locMatch = insideParen.match(/^([^:()\s]+):(\d+):(\d+)$/); + if (locMatch) { + const file = locMatch[1]; + // Skip internal runtime / bundler / worker frames + if ( + file.startsWith("http:") || + file.startsWith("https:") || + file.startsWith("webpack-internal:") || + file.startsWith("webpack:") || + file.startsWith("node:") || + file.includes("node_modules") || + file.includes("worker") + ) { + if ( + file !== defaultFilename && + !file.endsWith("/" + defaultFilename) + ) { + continue; + } + } + + let fn: string | undefined; + const fnMatch = line.match(/^at\s+(?:async\s+)?([^\s(]+)\s+\(/); + if (fnMatch) { + const rawFn = fnMatch[1]; + if ( + rawFn && + rawFn !== "eval" && + rawFn !== "" && + rawFn !== "Object." + ) { + fn = rawFn; + } + } + + const filename = + file === "" + ? defaultFilename + : file.endsWith("/" + defaultFilename) + ? defaultFilename + : file; + const lineNumber = parseInt(locMatch[2], 10); + const columnNumber = parseInt(locMatch[3], 10); + + frames.push({ + functionName: fn, + filename, + lineNumber, + columnNumber, + }); + continue; + } + } + + // Direct file reference pattern without parentheses: + // e.g. "at main.js:5:3" + const noParenMatch = line.match( + /^at\s+(?:async\s+)?([^:()\s]+):(\d+):(\d+)$/ + ); + if (noParenMatch) { + const file = noParenMatch[1]; + // Skip internal runtime / bundler / worker frames + if ( + file.startsWith("http:") || + file.startsWith("https:") || + file.startsWith("webpack-internal:") || + file.startsWith("webpack:") || + file.startsWith("node:") || + file.includes("node_modules") || + file.includes("worker") + ) { + if ( + file !== defaultFilename && + !file.endsWith("/" + defaultFilename) + ) { + continue; + } + } + + const filename = + file === "" + ? defaultFilename + : file.endsWith("/" + defaultFilename) + ? defaultFilename + : file; + const lineNumber = parseInt(noParenMatch[2], 10); + const columnNumber = parseInt(noParenMatch[3], 10); + + frames.push({ + functionName: undefined, + filename, + lineNumber, + columnNumber, + }); + continue; + } + + // Other "at " lines are runtime/framework frames; skip them. + continue; + } + + // 2. Firefox / Safari format (contains "@") + if (line.includes("@")) { + const atIdx = line.indexOf("@"); + const rawFn = line.slice(0, atIdx).trim(); + const location = line.slice(atIdx + 1).trim(); + + // Safari eval boundary: "eval@[native code]" + if (rawFn === "eval" && location === "[native code]") { + // Stop parsing further down the stack as lower frames are worker/comlink infrastructure + break; + } + + // Firefox eval pattern: location contains "> eval" or "> Function" + // e.g. "... line 84 > eval line 66 > eval:2:9" + if (location.includes("> eval") || location.includes("> Function")) { + const lastGtIdx = location.lastIndexOf(">"); + const afterGt = location.slice(lastGtIdx + 1).trim(); + const match = afterGt.match(/^(?:eval|Function):(\d+):(\d+)$/); + if (match) { + const fn = + rawFn && rawFn !== "eval" && rawFn !== "" + ? rawFn + : undefined; + frames.push({ + functionName: fn, + filename: defaultFilename, + lineNumber: parseInt(match[1], 10), + columnNumber: parseInt(match[2], 10), + }); + continue; + } + } + + // Direct location pattern in Firefox / Safari: + // e.g. "foo@main.js:2:9", "@main.js:5:3", "eval code@main.js:5:3" + const locMatch = location.match(/^([^:()\s]+):(\d+):(\d+)$/); + if (locMatch) { + const file = locMatch[1]; + if ( + file.startsWith("http:") || + file.startsWith("https:") || + file.startsWith("webpack-internal:") || + file.startsWith("webpack:") || + file.includes("node_modules") || + file.includes("worker") + ) { + if ( + file !== defaultFilename && + !file.endsWith("/" + defaultFilename) + ) { + continue; + } + } + const fn = + rawFn && + rawFn !== "eval" && + rawFn !== "eval code" && + rawFn !== "" + ? rawFn + : undefined; + frames.push({ + functionName: fn, + filename: file.endsWith("/" + defaultFilename) + ? defaultFilename + : file, + lineNumber: parseInt(locMatch[2], 10), + columnNumber: parseInt(locMatch[3], 10), + }); + continue; + } + + // Safari without line numbers: + // e.g. "foo@", "eval code@", "@" + if (location === "") { + const fn = + rawFn && + rawFn !== "eval" && + rawFn !== "eval code" && + rawFn !== "" + ? rawFn + : undefined; + frames.push({ + functionName: fn, + filename: defaultFilename, + }); + continue; + } + + // Skip other frames (e.g. runFile@..., L@https://...) + continue; + } + } + + return frames; +} + +/** + * Formats parsed stack frames into a standardized stack trace output. + * Example: + * ``` + * Error: test + * at foo (main.js:2:9) + * at (main.js:5:3) + * ``` + */ +export function formatStackTrace( + error: unknown, + frames: ParsedStackFrame[], + defaultFilename: string = "main.js" +): string { + let header: string; + if (error instanceof Error) { + header = `${error.name}: ${error.message}`; + } else { + header = String(error); + } + + if (frames.length === 0) { + return header; + } + + const lines = [header]; + for (const frame of frames) { + const fnStr = frame.functionName ? ` ${frame.functionName}` : ""; + const filename = frame.filename || defaultFilename; + let locationStr = ""; + if (frame.lineNumber !== undefined && frame.columnNumber !== undefined) { + locationStr = `${filename}:${frame.lineNumber}:${frame.columnNumber}`; + } else if (frame.lineNumber !== undefined) { + locationStr = `${filename}:${frame.lineNumber}`; + } else if (filename) { + locationStr = `${filename}`; + } + + if (locationStr) { + lines.push(` at${fnStr} (${locationStr})`); + } else if (fnStr) { + lines.push(` at${fnStr}`); + } + } + + return lines.join("\n"); +} + +/** + * Finds the line number where a SyntaxError occurred by progressively + * checking prefixes of the code. + */ +export function findSyntaxErrorLine(code: string): { + lineNumber: number; + columnNumber?: number; +} { + if (!code) return { lineNumber: 1 }; + const rawLines = code.split("\n"); + + for (let i = 1; i <= rawLines.length; i++) { + const slice = rawLines.slice(0, i).join("\n"); + try { + (0, eval)(`() => {\n${slice}\n}`); + } catch (e) { + if (e instanceof SyntaxError) { + const msg = e.message; + if ( + !msg.includes("Unexpected end of input") && + !msg.includes("Unexpected token '}'") && + !msg.includes("Expected '}'") + ) { + return { lineNumber: i }; + } + } + } + } + + // If entire code had "Unexpected end of input", find the last non-empty line + for (let i = rawLines.length; i >= 1; i--) { + if (rawLines[i - 1].trim().length > 0) { + return { lineNumber: i }; + } + } + + return { lineNumber: 1 }; +} + +/** + * Parses an error object, formats its stack trace, and constructs Diagnostic data. + */ +export function parseError( + error: unknown, + code?: string, + filename: string = "main.js" +): ParsedErrorInfo { + const errorMessage = + error instanceof Error ? `${error.name}: ${error.message}` : String(error); + + const rawStack = + error instanceof Error && typeof error.stack === "string" + ? error.stack + : ""; + + let frames = parseStackTrace(rawStack, filename); + + // If it's a SyntaxError or no frames with line numbers were found, try finding line number + if ( + error instanceof SyntaxError || + (frames.length === 0 && code !== undefined) + ) { + // Check if error object itself has line info (e.g. in some engines e.lineNumber) + const errObj = error as { + lineNumber?: number; + columnNumber?: number; + line?: number; + column?: number; + }; + const errLine = errObj?.lineNumber ?? errObj?.line; + const errCol = errObj?.columnNumber ?? errObj?.column; + + if (errLine !== undefined) { + frames = [ + { + filename, + lineNumber: errLine, + columnNumber: errCol, + }, + ]; + } else if (code) { + const loc = findSyntaxErrorLine(code); + frames = [ + { + filename, + lineNumber: loc.lineNumber, + columnNumber: loc.columnNumber, + }, + ]; + } + } + + const formattedStackTrace = formatStackTrace(error, frames, filename); + + const diagnosticFrames: DiagnosticFrameInfo[] = frames + .filter((f) => f.lineNumber !== undefined) + .map((f) => ({ + filename: f.filename || filename, + startLineNumber: f.lineNumber!, + startColumn: f.columnNumber, + endLineNumber: f.lineNumber!, + endColumn: f.columnNumber, + })); + + const diagnostic = + diagnosticFrames.length > 0 + ? { + frames: diagnosticFrames, + message: errorMessage, + severity: "error" as const, + } + : null; + + return { + formattedStackTrace, + diagnostic, + }; +} diff --git a/packages/jsEval/tests/stackTrace.spec.ts b/packages/jsEval/tests/stackTrace.spec.ts new file mode 100644 index 00000000..89fc1583 --- /dev/null +++ b/packages/jsEval/tests/stackTrace.spec.ts @@ -0,0 +1,354 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + parseStackTrace, + formatStackTrace, + findSyntaxErrorLine, + parseError, +} from "../src/index.js"; + +describe("stackTrace", () => { + describe("Firefox dev environment stack", () => { + const firefoxDevStack = `foo@http://localhost:3000/_next/static/chunks/_app-pages-browser_packages_runtime_src_worker_jsEval_worker_ts.js line 84 > eval line 66 > eval:2:9 +@http://localhost:3000/_next/static/chunks/_app-pages-browser_packages_runtime_src_worker_jsEval_worker_ts.js line 84 > eval line 66 > eval:5:3 +runFile@webpack-internal:///(app-pages-browser)/./packages/runtime/src/worker/jsEval.worker.ts:66:14 +callback@webpack-internal:///(app-pages-browser)/./node_modules/.pnpm/comlink@4.4.2/node_modules/comlink/dist/esm/comlink.mjs:116:48`; + + it("parses frames accurately", () => { + const frames = parseStackTrace(firefoxDevStack, "main.js"); + assert.strictEqual(frames.length, 2); + assert.deepStrictEqual(frames[0], { + functionName: "foo", + filename: "main.js", + lineNumber: 2, + columnNumber: 9, + }); + assert.deepStrictEqual(frames[1], { + functionName: undefined, + filename: "main.js", + lineNumber: 5, + columnNumber: 3, + }); + }); + + it("formats stack trace correctly", () => { + const frames = parseStackTrace(firefoxDevStack, "main.js"); + const err = new Error("test"); + const formatted = formatStackTrace(err, frames, "main.js"); + const expected = + "Error: test\n at foo (main.js:2:9)\n at (main.js:5:3)"; + assert.strictEqual(formatted, expected); + }); + }); + + describe("Firefox production stack", () => { + const firefoxProdStack = `foo@https://my-code.utcode.net/_next/static/chunks/3724.2004c2fc86f4d4ce.js line 1 > eval:2:9 +@https://my-code.utcode.net/_next/static/chunks/3724.2004c2fc86f4d4ce.js line 1 > eval:5:3 +L@https://my-code.utcode.net/_next/static/chunks/3724.2004c2fc86f4d4ce.js:1:14823 +o@https://my-code.utcode.net/_next/static/chunks/3724.2004c2fc86f4d4ce.js:1:12019`; + + it("parses frames accurately", () => { + const frames = parseStackTrace(firefoxProdStack, "main.js"); + assert.strictEqual(frames.length, 2); + assert.deepStrictEqual(frames[0], { + functionName: "foo", + filename: "main.js", + lineNumber: 2, + columnNumber: 9, + }); + assert.deepStrictEqual(frames[1], { + functionName: undefined, + filename: "main.js", + lineNumber: 5, + columnNumber: 3, + }); + }); + + it("formats stack trace correctly", () => { + const frames = parseStackTrace(firefoxProdStack, "main.js"); + const err = new Error("test"); + const formatted = formatStackTrace(err, frames, "main.js"); + const expected = + "Error: test\n at foo (main.js:2:9)\n at (main.js:5:3)"; + assert.strictEqual(formatted, expected); + }); + }); + + describe("Safari dev environment stack", () => { + const safariDevStack = `foo@ +eval code@ +eval@[native code] +runFile@ +callback@`; + + it("parses frames without line numbers up to eval boundary", () => { + const frames = parseStackTrace(safariDevStack, "main.js"); + assert.strictEqual(frames.length, 2); + assert.deepStrictEqual(frames[0], { + functionName: "foo", + filename: "main.js", + }); + assert.deepStrictEqual(frames[1], { + functionName: undefined, + filename: "main.js", + }); + }); + + it("formats stack trace without line numbers", () => { + const frames = parseStackTrace(safariDevStack, "main.js"); + const err = new Error("test"); + const formatted = formatStackTrace(err, frames, "main.js"); + const expected = "Error: test\n at foo (main.js)\n at (main.js)"; + assert.strictEqual(formatted, expected); + }); + }); + + describe("Safari production stack", () => { + const safariProdStack = `foo@ +eval code@ +eval@[native code] +L@https://my-code.utcode.net/_next/static/chunks/3724.2004c2fc86f4d4ce.js:1:14827 +o@https://my-code.utcode.net/_next/static/chunks/3724.2004c2fc86f4d4ce.js:1:12024`; + + it("parses frames accurately up to eval boundary", () => { + const frames = parseStackTrace(safariProdStack, "main.js"); + assert.strictEqual(frames.length, 2); + assert.deepStrictEqual(frames[0], { + functionName: "foo", + filename: "main.js", + }); + assert.deepStrictEqual(frames[1], { + functionName: undefined, + filename: "main.js", + }); + }); + + it("formats stack trace correctly", () => { + const frames = parseStackTrace(safariProdStack, "main.js"); + const err = new Error("test"); + const formatted = formatStackTrace(err, frames, "main.js"); + const expected = "Error: test\n at foo (main.js)\n at (main.js)"; + assert.strictEqual(formatted, expected); + }); + }); + + describe("Chrome dev environment stack", () => { + const chromeDevStack = `Error: test + at foo (eval at runFile (webpack-internal:///(app-pages-browser)/./packages/runtime/src/worker/jsEval.worker.ts), :2:9) + at eval (eval at runFile (webpack-internal:///(app-pages-browser)/./packages/runtime/src/worker/jsEval.worker.ts), :5:3) + at eval () + at Object.runFile (webpack-internal:///(app-pages-browser)/./packages/runtime/src/worker/jsEval.worker.ts:66:14) + at callback (webpack-internal:///(app-pages-browser)/./node_modules/.pnpm/comlink@4.4.2/node_modules/comlink/dist/esm/comlink.mjs:116:48)`; + + it("parses frames accurately and ignores internal frames", () => { + const frames = parseStackTrace(chromeDevStack, "main.js"); + assert.strictEqual(frames.length, 2); + assert.deepStrictEqual(frames[0], { + functionName: "foo", + filename: "main.js", + lineNumber: 2, + columnNumber: 9, + }); + assert.deepStrictEqual(frames[1], { + functionName: undefined, + filename: "main.js", + lineNumber: 5, + columnNumber: 3, + }); + }); + + it("formats stack trace correctly", () => { + const frames = parseStackTrace(chromeDevStack, "main.js"); + const err = new Error("test"); + const formatted = formatStackTrace(err, frames, "main.js"); + const expected = + "Error: test\n at foo (main.js:2:9)\n at (main.js:5:3)"; + assert.strictEqual(formatted, expected); + }); + }); + + describe("Chrome production stack", () => { + const chromeProdStack = `Error: test + at foo (eval at L (https://my-code.utcode.net/_next/static/chunks/3724.2004c2fc86f4d4ce.js:1:14823), :2:9) + at eval (eval at L (https://my-code.utcode.net/_next/static/chunks/3724.2004c2fc86f4d4ce.js:1:14823), :5:3) + at eval () + at Object.L [as runFile] (https://my-code.utcode.net/_next/static/chunks/3724.2004c2fc86f4d4ce.js:1:14823) + at o (https://my-code.utcode.net/_next/static/chunks/3724.2004c2fc86f4d4ce.js:1:12019)`; + + it("parses frames accurately", () => { + const frames = parseStackTrace(chromeProdStack, "main.js"); + assert.strictEqual(frames.length, 2); + assert.deepStrictEqual(frames[0], { + functionName: "foo", + filename: "main.js", + lineNumber: 2, + columnNumber: 9, + }); + assert.deepStrictEqual(frames[1], { + functionName: undefined, + filename: "main.js", + lineNumber: 5, + columnNumber: 3, + }); + }); + + it("formats stack trace correctly", () => { + const frames = parseStackTrace(chromeProdStack, "main.js"); + const err = new Error("test"); + const formatted = formatStackTrace(err, frames, "main.js"); + const expected = + "Error: test\n at foo (main.js:2:9)\n at (main.js:5:3)"; + assert.strictEqual(formatted, expected); + }); + }); + + describe("sourceURL stack traces", () => { + it("parses Chrome/V8 direct sourceURL stack", () => { + const stack = `Error: test + at foo (main.js:2:9) + at eval (main.js:5:3) + at Object.runFile (webpack-internal:///(app-pages-browser)/./packages/runtime/src/worker/jsEval.worker.ts:66:14)`; + const frames = parseStackTrace(stack, "main.js"); + assert.strictEqual(frames.length, 2); + assert.deepStrictEqual(frames[0], { + functionName: "foo", + filename: "main.js", + lineNumber: 2, + columnNumber: 9, + }); + assert.deepStrictEqual(frames[1], { + functionName: undefined, + filename: "main.js", + lineNumber: 5, + columnNumber: 3, + }); + }); + + it("parses Firefox sourceURL stack", () => { + const stack = `foo@main.js:2:9 +@main.js:5:3 +runFile@webpack-internal:///(app-pages-browser)/./packages/runtime/src/worker/jsEval.worker.ts:66:14`; + const frames = parseStackTrace(stack, "main.js"); + assert.strictEqual(frames.length, 2); + assert.deepStrictEqual(frames[0], { + functionName: "foo", + filename: "main.js", + lineNumber: 2, + columnNumber: 9, + }); + assert.deepStrictEqual(frames[1], { + functionName: undefined, + filename: "main.js", + lineNumber: 5, + columnNumber: 3, + }); + }); + + it("parses Safari sourceURL stack", () => { + const stack = `foo@main.js:2:9 +eval code@main.js:5:3 +eval@[native code] +runFile@webpack-internal:///(app-pages-browser)/./packages/runtime/src/worker/jsEval.worker.ts:66:14`; + const frames = parseStackTrace(stack, "main.js"); + assert.strictEqual(frames.length, 2); + assert.deepStrictEqual(frames[0], { + functionName: "foo", + filename: "main.js", + lineNumber: 2, + columnNumber: 9, + }); + assert.deepStrictEqual(frames[1], { + functionName: undefined, + filename: "main.js", + lineNumber: 5, + columnNumber: 3, + }); + }); + }); + + describe("Multi-frame call stacks (foo -> bar -> baz)", () => { + const multiStack = `Error: multi test + at baz (eval at runFile (...), :2:9) + at bar (eval at runFile (...), :5:3) + at foo (eval at runFile (...), :8:3) + at eval (eval at runFile (...), :10:1) + at eval ()`; + + it("preserves order from innermost to outermost", () => { + const frames = parseStackTrace(multiStack, "main.js"); + assert.strictEqual(frames.length, 4); + assert.strictEqual(frames[0].functionName, "baz"); + assert.strictEqual(frames[0].lineNumber, 2); + assert.strictEqual(frames[1].functionName, "bar"); + assert.strictEqual(frames[1].lineNumber, 5); + assert.strictEqual(frames[2].functionName, "foo"); + assert.strictEqual(frames[2].lineNumber, 8); + assert.strictEqual(frames[3].functionName, undefined); + assert.strictEqual(frames[3].lineNumber, 10); + }); + }); + + describe("findSyntaxErrorLine", () => { + it("locates syntax error in single-line invalid syntax", () => { + const loc = findSyntaxErrorLine("function foo(\n"); + assert.strictEqual(loc.lineNumber, 1); + }); + + it("locates syntax error on the exact line in multi-line code", () => { + const code = `const a = 1; +const = 2; +const c = 3;`; + const loc = findSyntaxErrorLine(code); + assert.strictEqual(loc.lineNumber, 2); + }); + }); + + describe("parseError integration", () => { + it("creates Diagnostic and formattedStackTrace for runtime error", () => { + const err = new Error("test"); + err.stack = `Error: test + at foo (eval at runFile (...), :2:9) + at eval (eval at runFile (...), :5:3)`; + + const result = parseError(err, undefined, "test.js"); + assert.strictEqual( + result.formattedStackTrace, + "Error: test\n at foo (test.js:2:9)\n at (test.js:5:3)" + ); + assert.deepStrictEqual(result.diagnostic, { + frames: [ + { + filename: "test.js", + startLineNumber: 2, + startColumn: 9, + endLineNumber: 2, + endColumn: 9, + }, + { + filename: "test.js", + startLineNumber: 5, + startColumn: 3, + endLineNumber: 5, + endColumn: 3, + }, + ], + message: "Error: test", + severity: "error", + }); + }); + + it("creates Diagnostic for SyntaxError without stack line numbers", () => { + const err = new SyntaxError("Unexpected token ')'"); + const code = "function foo(\n"; + const result = parseError(err, code, "test_compile.js"); + assert.ok(result.diagnostic); + assert.strictEqual(result.diagnostic.frames.length, 1); + assert.strictEqual( + result.diagnostic.frames[0].filename, + "test_compile.js" + ); + assert.strictEqual(result.diagnostic.frames[0].startLineNumber, 1); + assert.strictEqual(result.diagnostic.severity, "error"); + }); + }); +}); diff --git a/packages/runtime/src/interface.ts b/packages/runtime/src/interface.ts index 9b53a514..948b4617 100644 --- a/packages/runtime/src/interface.ts +++ b/packages/runtime/src/interface.ts @@ -121,6 +121,7 @@ export interface RuntimeContext { * @param filenames - 実行するファイル名 * @param files - 実行環境に渡すファイル(実行するものと無関係のものを含んでも良い) * @param onOutput - 実行結果を返すコールバック + * @param onDiagnostic - 診断情報 (エラーや警告など) を返すコールバック * @returns 実行が完了した際に解決するPromise * ただし、onOutputコールバックは実行完了後に呼ばれる可能性もあります(実行したコマンドが非同期処理を含む場合)。 * @@ -132,7 +133,8 @@ export interface RuntimeContext { runFiles: ( filenames: string[], files: Readonly>, - onOutput: (output: ReplOutput | UpdatedFile) => void + onOutput: (output: ReplOutput | UpdatedFile) => void, + onDiagnostic?: (diagnostic: Diagnostic) => void ) => Promise; /** * 指定されたファイルを実行するためのコマンドライン引数文字列を返します。 @@ -150,6 +152,33 @@ export interface RuntimeInfo { } export type RuntimeErrorHandler = (error: unknown) => void; +export const DiagnosticSeveritySchema = z.enum(["error", "warning", "info"]); +export type DiagnosticSeverity = z.output; + +/** + * エラーや警告の1つのスタックフレーム(ファイル・行・列情報) + */ +export const DiagnosticFrameSchema = z.object({ + filename: z.string(), + startLineNumber: z.number(), // 1-indexed + startColumn: z.number().optional(), // 1-indexed + endLineNumber: z.number().optional(), // 1-indexed + endColumn: z.number().optional(), // 1-indexed +}); +export type DiagnosticFrame = z.output; + +/** + * 1つのエラー・警告・情報メッセージ。 + * 複数のスタックフレームが存在する場合、framesに複数の要素が含まれる。 + * framesは順序通りで、最初の要素が主要フレーム(エラーが発生した場所)。 + */ +export const DiagnosticSchema = z.object({ + frames: z.array(DiagnosticFrameSchema).min(1), + message: z.string(), + severity: DiagnosticSeveritySchema.default("error"), +}); +export type Diagnostic = z.output; + export const ReplOutputTypeSchema = z.enum([ "stdout", "stderr", diff --git a/packages/runtime/src/typescript/runtime.tsx b/packages/runtime/src/typescript/runtime.tsx index 84e5dee7..b40b1ca6 100644 --- a/packages/runtime/src/typescript/runtime.tsx +++ b/packages/runtime/src/typescript/runtime.tsx @@ -13,6 +13,8 @@ import { useState, } from "react"; import { + Diagnostic, + DiagnosticSeverity, ReplOutput, RuntimeContext, RuntimeErrorHandler, @@ -116,7 +118,8 @@ export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { async ( filenames: string[], files: Readonly>, - onOutput: (output: ReplOutput | UpdatedFile) => void + onOutput: (output: ReplOutput | UpdatedFile) => void, + onDiagnostic?: (diagnostic: Diagnostic) => void ) => { if (tsEnv === null || typeof window === "undefined") { onOutput({ type: "error", message: "TypeScript is not ready yet." }); @@ -129,6 +132,62 @@ export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { const ts = await import("typescript"); + const convertDiagnostic = ( + diag: import("typescript").Diagnostic + ): Diagnostic => { + let line = 0; + let character = 0; + let endLineNumber: number | undefined = undefined; + let endColumn: number | undefined = undefined; + + if (diag.file && diag.start !== undefined) { + const pos = diag.file.getLineAndCharacterOfPosition(diag.start); + line = pos.line; + character = pos.character; + + if (diag.length !== undefined) { + const endPos = diag.file.getLineAndCharacterOfPosition( + diag.start + diag.length + ); + endLineNumber = endPos.line + 1; + endColumn = endPos.character + 1; + } + } + + const message = + typeof diag.messageText === "string" + ? diag.messageText + : ts.flattenDiagnosticMessageText(diag.messageText, "\n"); + + let severity: DiagnosticSeverity = "error"; + if (diag.category === ts.DiagnosticCategory.Warning) { + severity = "warning"; + } else if ( + diag.category === ts.DiagnosticCategory.Suggestion || + diag.category === ts.DiagnosticCategory.Message + ) { + severity = "info"; + } + + const filename = ( + diag.file ? diag.file.fileName : filenames[0] + ).replace(/^\//, ""); + + return { + frames: [ + { + filename, + startLineNumber: line + 1, + startColumn: character + 1, + endLineNumber, + endColumn, + }, + ], + message, + severity, + }; + }; + for (const diagnostic of tsEnv.languageService.getSyntacticDiagnostics( filenames[0] )) { @@ -140,6 +199,7 @@ export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { getNewLine: () => "\n", }), }); + onDiagnostic?.(convertDiagnostic(diagnostic)); } for (const diagnostic of tsEnv.languageService.getSemanticDiagnostics( @@ -153,6 +213,7 @@ export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { getNewLine: () => "\n", }), }); + onDiagnostic?.(convertDiagnostic(diagnostic)); } const emitOutput = tsEnv.languageService.getEmitOutput(filenames[0]); @@ -167,12 +228,14 @@ export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { tsEnv.deleteFile(filename); } - console.log(emitOutput); - await jsEval.runFiles( - [emitOutput.outputFiles[0].name], - { ...files, ...emittedFiles }, - onOutput - ); + if (emitOutput.outputFiles.length > 0) { + await jsEval.runFiles( + [emitOutput.outputFiles[0].name], + { ...files, ...emittedFiles }, + onOutput, + onDiagnostic + ); + } } catch (error) { onErrorRef.current?.(error); onOutput({ diff --git a/packages/runtime/src/wandbox/api.ts b/packages/runtime/src/wandbox/api.ts index aba94461..dcfef534 100644 --- a/packages/runtime/src/wandbox/api.ts +++ b/packages/runtime/src/wandbox/api.ts @@ -116,50 +116,63 @@ export async function compileAndRun( options: CompileProps, onOutput: (event: CompileOutputEvent) => void ): Promise { + const streamBuffers: Record = { + CompilerMessageS: "", + CompilerMessageE: "", + StdOut: "", + StdErr: "", + }; + + const emitStreamLines = ( + type: "CompilerMessageS" | "CompilerMessageE" | "StdOut" | "StdErr", + data: string, + flush = false + ) => { + streamBuffers[type] += data; + const lines = streamBuffers[type].split("\n"); + if (!flush) { + streamBuffers[type] = lines.pop() ?? ""; + } else { + streamBuffers[type] = ""; + } + const outputType = + type === "CompilerMessageS" || type === "StdOut" + ? ("stdout" as const) + : type === "CompilerMessageE" + ? ("error" as const) + : ("stderr" as const); + for (const line of lines) { + if (line.length > 0) { + onOutput({ + ndjsonType: type, + output: { type: outputType, message: line }, + }); + } + } + }; + + const flushAllStreamBuffers = () => { + for (const type of [ + "CompilerMessageS", + "CompilerMessageE", + "StdOut", + "StdErr", + ] as const) { + emitStreamLines(type, "", true); + } + }; + // Helper function to process NDJSON result and call onOutput const processNdjsonResult = (r: CompileNdjsonResult) => { switch (r.type) { case "CompilerMessageS": - if (r.data.trim()) { - for (const line of r.data.trim().split("\n")) { - onOutput({ - ndjsonType: r.type, - output: { type: "stdout", message: line }, - }); - } - } - break; case "CompilerMessageE": - if (r.data.trim()) { - for (const line of r.data.trim().split("\n")) { - onOutput({ - ndjsonType: r.type, - output: { type: "error", message: line }, - }); - } - } - break; case "StdOut": - if (r.data.trim()) { - for (const line of r.data.trim().split("\n")) { - onOutput({ - ndjsonType: r.type, - output: { type: "stdout", message: line }, - }); - } - } - break; case "StdErr": - if (r.data.trim()) { - for (const line of r.data.trim().split("\n")) { - onOutput({ - ndjsonType: r.type, - output: { type: "stderr", message: line }, - }); - } - } + emitStreamLines(r.type, r.data); break; case "ExitCode": + flushAllStreamBuffers(); if (r.data !== "0") { onOutput({ ndjsonType: r.type, @@ -245,6 +258,7 @@ export async function compileAndRun( processNdjsonResult(r); } } finally { + flushAllStreamBuffers(); reader.releaseLock(); } } diff --git a/packages/runtime/src/wandbox/cpp.ts b/packages/runtime/src/wandbox/cpp.ts index 56adbe2a..f59462a2 100644 --- a/packages/runtime/src/wandbox/cpp.ts +++ b/packages/runtime/src/wandbox/cpp.ts @@ -1,8 +1,18 @@ -import { ReplOutput } from "../interface"; +import { + Diagnostic, + DiagnosticFrame, + DiagnosticSeverity, + ReplOutput, +} from "../interface"; import { compileAndRun, CompilerInfo, SelectedCompiler } from "./api"; import _stacktrace_cpp from "./cpp/_stacktrace.cpp?raw"; +const GCC_DIAG_REGEX = + /^(?:.*\/)?([^:\n]+):(\d+):(?:(\d+):)?\s*(fatal error|error|warning|note):\s*(.*)$/; +const LD_DIAG_REGEX = + /^(?:(?:\/usr\/bin\/ld:\s+)?(?:.*\/)?([^:\n]+)):(?:(\d+):)?(?:\([^)]+\):)?\s*(undefined reference to .*)$/; + export function selectCppCompiler( compilerList: CompilerInfo[] ): SelectedCompiler { @@ -73,8 +83,8 @@ export function selectCppCompiler( } // その他オプション - options.compilerOptionsRaw.push("-g"); - commandline.push("-g"); + options.compilerOptionsRaw.push("-g", "-no-pie"); + commandline.push("-g", "-no-pie"); options.getCommandlineStr = (filenames: string[]) => { return [...commandline, ...filenames, "&&", "./a.out"].join(" "); @@ -87,13 +97,14 @@ export async function cppRunFiles( options: SelectedCompiler, files: Record, filenames: string[], - onOutput: (output: ReplOutput) => void + onOutput: (output: ReplOutput) => void, + onDiagnostic?: (diagnostic: Diagnostic) => void ): Promise { - // Constants for stack trace processing - const WANDBOX_PATH = "/home/wandbox"; - // Track state for processing stack traces let inStackTrace = false; + let signal = ""; + let exceptionMessage = ""; + const runtimeFrames: DiagnosticFrame[] = []; await compileAndRun( { @@ -108,14 +119,92 @@ export async function cppRunFiles( (event) => { const { ndjsonType, output } = event; + // Parse compiler messages for diagnostics + if (ndjsonType === "CompilerMessageE") { + const gccMatch = GCC_DIAG_REGEX.exec(output.message); + if (gccMatch) { + const rawFilename = gccMatch[1].replace(/^\.\//, ""); + if ( + rawFilename !== "_stacktrace.cpp" && + !rawFilename.startsWith("<") && + !rawFilename.includes("/include/") + ) { + const lineNum = parseInt(gccMatch[2], 10); + const colNum = gccMatch[3] ? parseInt(gccMatch[3], 10) : undefined; + const sev = gccMatch[4]; + const msg = gccMatch[5]; + + let severity: DiagnosticSeverity = "error"; + if (sev === "warning") severity = "warning"; + else if (sev === "note") severity = "info"; + + onDiagnostic?.({ + frames: [ + { + filename: rawFilename, + startLineNumber: lineNum, + startColumn: colNum, + }, + ], + message: msg, + severity, + }); + } + } else { + const ldMatch = LD_DIAG_REGEX.exec(output.message); + if (ldMatch) { + const rawFilename = ldMatch[1].replace(/^\.\//, ""); + if ( + rawFilename !== "_stacktrace.cpp" && + !rawFilename.startsWith("<") && + !rawFilename.includes("/include/") + ) { + const lineNum = ldMatch[2] ? parseInt(ldMatch[2], 10) : 1; + const msg = ldMatch[3]; + onDiagnostic?.({ + frames: [ + { + filename: rawFilename, + startLineNumber: lineNum, + }, + ], + message: msg, + severity: "error", + }); + } + } + } + } + + // Check for exception / terminate message in stderr + if (ndjsonType === "StdErr") { + if (output.message.includes("what():")) { + const idx = output.message.indexOf("what():"); + exceptionMessage = output.message.slice(idx + 7).trim(); + } else if ( + output.message.includes( + "terminate called after throwing an instance of" + ) + ) { + const m = + /terminate called after throwing an instance of '([^']+)'/.exec( + output.message + ); + if (m && !exceptionMessage) { + exceptionMessage = m[1]; + } + } + } + // Check for signal marker in stderr if ( ndjsonType === "StdErr" && output.message.startsWith("#!my_code_signal:") ) { + signal = output.message.slice(17).trim(); onOutput({ type: "error", - message: output.message.slice(17), + message: signal, }); return; } @@ -135,12 +224,30 @@ export async function cppRunFiles( // Process stack trace lines if (inStackTrace && ndjsonType === "StdErr") { - // Filter to show only user source code - if (output.message.includes(WANDBOX_PATH)) { - onOutput({ - type: "trace", - message: output.message.replace(`${WANDBOX_PATH}/`, ""), - }); + const m = /\sat\s+(?:.*\/)?([^:\s]+):(\d+)/.exec(output.message); + if ( + m && + !output.message.includes("/boost/") && + !output.message.includes("/include/") && + !output.message.includes("/opt/wandbox/") && + !output.message.includes("/usr/") && + !output.message.includes("/lib/") + ) { + const filename = m[1].replace(/^\.\//, ""); + if (filename !== "_stacktrace.cpp" && !filename.startsWith("<")) { + const cleanedMessage = output.message.replace( + /\s+at\s+.*\/([^\/]+:\d+.*)$/, + " at $1" + ); + onOutput({ + type: "trace", + message: cleanedMessage, + }); + runtimeFrames.push({ + filename, + startLineNumber: parseInt(m[2], 10), + }); + } } return; } @@ -149,4 +256,13 @@ export async function cppRunFiles( onOutput(output); } ); + + if (runtimeFrames.length > 0) { + const message = exceptionMessage || signal || "Runtime error"; + onDiagnostic?.({ + frames: runtimeFrames, + message, + severity: "error", + }); + } } diff --git a/packages/runtime/src/wandbox/runtime.tsx b/packages/runtime/src/wandbox/runtime.tsx index 8bd6bcdb..fe8d11c1 100644 --- a/packages/runtime/src/wandbox/runtime.tsx +++ b/packages/runtime/src/wandbox/runtime.tsx @@ -15,6 +15,7 @@ import { cppRunFiles, selectCppCompiler } from "./cpp"; import { RuntimeLang } from "../languages"; import { rustRunFiles, selectRustCompiler } from "./rust"; import { + Diagnostic, ReplOutput, RuntimeContext, RuntimeErrorHandler, @@ -35,7 +36,8 @@ interface IWandboxContext { ) => ( filenames: string[], files: Readonly>, - onOutput: (output: ReplOutput | UpdatedFile) => void + onOutput: (output: ReplOutput | UpdatedFile) => void, + onDiagnostic?: (diagnostic: Diagnostic) => void ) => Promise; runtimeInfo: Record | undefined; } @@ -86,7 +88,8 @@ export function WandboxProvider({ children }: { children: ReactNode }) { async ( filenames: string[], files: Readonly>, - onOutput: (output: ReplOutput | UpdatedFile) => void + onOutput: (output: ReplOutput | UpdatedFile) => void, + onDiagnostic?: (diagnostic: Diagnostic) => void ) => { if (!selectedCompiler) { onOutput({ type: "error", message: "Wandbox is not ready yet." }); @@ -99,7 +102,8 @@ export function WandboxProvider({ children }: { children: ReactNode }) { selectedCompiler.cpp, files, filenames, - onOutput + onOutput, + onDiagnostic ); break; case "rust": @@ -107,7 +111,8 @@ export function WandboxProvider({ children }: { children: ReactNode }) { selectedCompiler.rust, files, filenames, - onOutput + onOutput, + onDiagnostic ); break; default: diff --git a/packages/runtime/src/wandbox/rust.ts b/packages/runtime/src/wandbox/rust.ts index c1f8e425..dd529cbf 100644 --- a/packages/runtime/src/wandbox/rust.ts +++ b/packages/runtime/src/wandbox/rust.ts @@ -1,8 +1,17 @@ -import { ReplOutput } from "../interface"; +import { + Diagnostic, + DiagnosticFrame, + DiagnosticSeverity, + ReplOutput, +} from "../interface"; import { compileAndRun, CompilerInfo, SelectedCompiler } from "./api"; import prog_rs from "./rust/prog.rs?raw"; +const RUSTC_HEADER_REGEX = + /^(error(?:\[[A-Z0-9]+\])?|warning(?:\[[A-Z0-9]+\])?|note(?:\[[A-Z0-9]+\])?):\s*(.*)$/; +const RUSTC_SPAN_REGEX = /^\s*-->\s*([^:\n]+):(\d+):(\d+)/; + export function selectRustCompiler( compilerList: CompilerInfo[] ): SelectedCompiler { @@ -33,18 +42,39 @@ export async function rustRunFiles( options: SelectedCompiler, files: Record, filenames: string[], - onOutput: (output: ReplOutput) => void + onOutput: (output: ReplOutput) => void, + onDiagnostic?: (diagnostic: Diagnostic) => void ): Promise { // Regular expressions for parsing stack traces const STACK_FRAME_PATTERN = /^\s*\d+:/; - const LOCATION_PATTERN = /^\s*at .\//; - const SYSTEM_CODE_PATTERN = /^\s*at .\/prog.rs/; + const LOCATION_PATTERN = /^\s*at\s+/; + + const isSystemCode = (msg: string) => { + return ( + msg.includes("prog.rs") || + msg.includes("/rustc/") || + msg.includes("/library/") || + msg.includes("/alloc/") || + msg.includes("/core/") || + msg.includes("/std/") || + msg.includes("/.cargo/") || + msg.includes("/.rustup/") || + msg.includes("<") + ); + }; // Track state for processing panic traces let inPanicHook = false; let foundBacktraceHeader = false; + let panicLoc: DiagnosticFrame | null = null; + const panicMessages: string[] = []; + const runtimeFrames: DiagnosticFrame[] = []; const traceLines: string[] = []; + // Track state for processing compile diagnostics + let currentHeader: { level: DiagnosticSeverity; message: string } | null = + null; + const mainModule = filenames[0].replace(/\.rs$/, ""); await compileAndRun( { @@ -68,6 +98,52 @@ export async function rustRunFiles( (event) => { const { ndjsonType, output } = event; + // Parse compiler messages for diagnostics + if (ndjsonType === "CompilerMessageE") { + const headerMatch = RUSTC_HEADER_REGEX.exec(output.message); + if (headerMatch) { + const level = headerMatch[1]; + const msg = headerMatch[2]; + if ( + !msg.startsWith("aborting due to") && + !msg.startsWith("For more information about this error") + ) { + const severity: DiagnosticSeverity = level.startsWith("error") + ? "error" + : level.startsWith("warning") + ? "warning" + : "info"; + currentHeader = { level: severity, message: msg }; + } else { + currentHeader = null; + } + } + + const spanMatch = RUSTC_SPAN_REGEX.exec(output.message); + if (spanMatch && currentHeader) { + const rawFilename = spanMatch[1] + .replace(/^\.\//, "") + .replace(/^\//, ""); + const lineNum = parseInt(spanMatch[2], 10); + const colNum = parseInt(spanMatch[3], 10); + + if (rawFilename !== "prog.rs" && !rawFilename.startsWith("<")) { + onDiagnostic?.({ + frames: [ + { + filename: rawFilename, + startLineNumber: lineNum, + startColumn: colNum, + }, + ], + message: currentHeader.message, + severity: currentHeader.level, + }); + } + currentHeader = null; + } + } + // Check for panic hook marker if ( ndjsonType === "StdErr" && @@ -78,12 +154,47 @@ export async function rustRunFiles( } if (inPanicHook && ndjsonType === "StdErr") { - // Check for stack backtrace header - if (output.message === "stack backtrace:") { - foundBacktraceHeader = true; + if (!foundBacktraceHeader) { + // Check for panic location in header line (e.g. thread 'main' panicked at sub.rs:2:5:) + const locMatch = + /thread '.*?' panicked at (?:(?:\.\/)?([^:\s]+)):(\d+):(\d+):/.exec( + output.message + ); + if (locMatch) { + const fn = locMatch[1].replace(/^\.\//, "").replace(/^\//, ""); + if (fn !== "prog.rs" && !fn.startsWith("<")) { + panicLoc = { + filename: fn, + startLineNumber: parseInt(locMatch[2], 10), + startColumn: parseInt(locMatch[3], 10), + }; + } + onOutput({ + type: "error", + message: output.message, + }); + return; + } + + // Check for stack backtrace header + if (output.message === "stack backtrace:") { + foundBacktraceHeader = true; + onOutput({ + type: "trace", + message: "Stack trace (filtered):", + }); + return; + } + + // Capture panic message lines + if (output.message.trim() && !output.message.startsWith("thread ")) { + panicMessages.push(output.message.trim()); + } + + // Output panic messages as errors onOutput({ - type: "trace", - message: "Stack trace (filtered):", + type: "error", + message: output.message, }); return; } @@ -95,36 +206,61 @@ export async function rustRunFiles( traceLines.push(output.message); } else if (LOCATION_PATTERN.test(output.message)) { if (traceLines.length > 0) { - // Check if this is user code (not prog.rs) - if (!SYSTEM_CODE_PATTERN.test(output.message)) { + const lastTraceLine = traceLines[traceLines.length - 1]; + // Check if this is user code (not system / std library / prog.rs) + if ( + !isSystemCode(output.message) && + !isSystemCode(lastTraceLine) + ) { onOutput({ type: "trace", - message: traceLines[traceLines.length - 1].replace( - "prog::", - "" - ), + message: lastTraceLine.replace("prog::", ""), }); onOutput({ type: "trace", message: output.message, }); + + const m = /^\s*at\s+(?:.*\/)?([^:\s]+):(\d+):?(\d+)?/.exec( + output.message + ); + if (m) { + const fn = m[1].replace(/^\.\//, "").replace(/^\//, ""); + if (!isSystemCode(fn)) { + runtimeFrames.push({ + filename: fn, + startLineNumber: parseInt(m[2], 10), + startColumn: m[3] ? parseInt(m[3], 10) : undefined, + }); + } + } } traceLines.pop(); // Remove the associated trace line (regardless of match) } } return; } - - // Output panic messages as errors - onOutput({ - type: "error", - message: output.message, - }); - return; } // Output normally onOutput(output); } ); + + if (inPanicHook) { + const loc = panicLoc as DiagnosticFrame | null; + const finalFrames = + runtimeFrames.length > 0 ? runtimeFrames : loc ? [loc] : []; + if (finalFrames.length > 0) { + const fallbackMsg = loc + ? `panicked at ${loc.filename}:${loc.startLineNumber}` + : "Panic"; + const message = panicMessages.filter(Boolean).join("\n") || fallbackMsg; + onDiagnostic?.({ + frames: finalFrames, + message, + severity: "error", + }); + } + } } diff --git a/packages/runtime/src/worker/jsEval.worker.ts b/packages/runtime/src/worker/jsEval.worker.ts index 561e8a45..f143367e 100644 --- a/packages/runtime/src/worker/jsEval.worker.ts +++ b/packages/runtime/src/worker/jsEval.worker.ts @@ -1,10 +1,15 @@ /// import { expose } from "comlink"; -import type { ReplOutput, UpdatedFile } from "../interface"; +import type { Diagnostic, ReplOutput, UpdatedFile } from "../interface"; import type { WorkerAPI, WorkerCapabilities } from "./runtime"; import inspect from "object-inspect"; -import { replLikeEval, checkSyntax, createReplConsole } from "@my-code/js-eval"; +import { + replLikeEval, + checkSyntax, + createReplConsole, + parseError, +} from "@my-code/js-eval"; let currentOutputCallback: ((output: ReplOutput) => Promise) | null = null; @@ -38,53 +43,49 @@ async function runCode( try { const result = await replLikeEval(code); await Promise.all(pendingOutputPromise); - await onOutput({ - type: "return", - message: inspect(result), - }); - } catch (e) { - originalConsole.log(e); - await Promise.all(pendingOutputPromise); - // TODO: stack trace? - if (e instanceof Error) { - await onOutput({ - type: "error", - message: `${e.name}: ${e.message}`, - }); - } else { + if (result !== undefined) { await onOutput({ - type: "error", - message: `${String(e)}`, + type: "return", + message: inspect(result), }); } + } catch (e) { + originalConsole.log(e); + await Promise.all(pendingOutputPromise); + const parsed = parseError(e, code, "REPL"); + await onOutput({ + type: "error", + message: parsed.formattedStackTrace, + }); } } async function runFile( name: string, files: Record, - onOutput: (output: ReplOutput | UpdatedFile) => Promise + onOutput: (output: ReplOutput | UpdatedFile) => Promise, + onDiagnostic?: (diagnostic: Diagnostic) => Promise ): Promise { // pyodide worker などと異なり、複数ファイルを読み込んでimportのようなことをするのには対応していません。 currentOutputCallback = onOutput; pendingOutputPromise = []; try { - self.eval(files[name]); + const code = files[name] ?? ""; + const sourceUrlComment = code.endsWith("\n") + ? `//# sourceURL=${name}` + : `\n//# sourceURL=${name}`; + self.eval(`${code}${sourceUrlComment}`); await Promise.all(pendingOutputPromise); } catch (e) { originalConsole.log(e); await Promise.all(pendingOutputPromise); - // TODO: stack trace? - if (e instanceof Error) { - await onOutput({ - type: "error", - message: `${e.name}: ${e.message}`, - }); - } else { - await onOutput({ - type: "error", - message: `${String(e)}`, - }); + const parsed = parseError(e, files[name], name); + await onOutput({ + type: "error", + message: parsed.formattedStackTrace, + }); + if (onDiagnostic && parsed.diagnostic) { + await onDiagnostic(parsed.diagnostic); } } } diff --git a/packages/runtime/src/worker/pyodide.worker.ts b/packages/runtime/src/worker/pyodide.worker.ts index 97c57e41..2e46b188 100644 --- a/packages/runtime/src/worker/pyodide.worker.ts +++ b/packages/runtime/src/worker/pyodide.worker.ts @@ -7,12 +7,13 @@ import { loadPyodide } from "pyodide"; import { version as pyodideVersion } from "pyodide/package.json"; import type { PyCallable } from "pyodide/ffi"; import type { WorkerAPI, WorkerCapabilities } from "./runtime"; -import type { ReplOutput, UpdatedFile } from "../interface"; +import type { Diagnostic, ReplOutput, UpdatedFile } from "../interface"; import execfile_py from "./pyodide/execfile.py?raw"; +import eval_code_py from "./pyodide/eval_code.py?raw"; import check_syntax_py from "./pyodide/check_syntax.py?raw"; -const HOME = `/home/pyodide/`; +const HOME = `/home/pyodide`; let pyodide: PyodideInterface; let pendingOutputPromise: Promise[] = []; @@ -87,44 +88,32 @@ async function runCode( currentOutputCallback = onOutput; pendingOutputPromise = []; try { - const result = await pyodide.runPythonAsync(code); + const pyEvalCode = pyodide.runPython(eval_code_py) as PyCallable; + const resultJson = await pyEvalCode(code); await Promise.all(pendingOutputPromise); - if (result !== undefined) { - await onOutput({ - type: "return", - message: String(result), - }); - } - } catch (e: unknown) { - console.log(e); - await Promise.all(pendingOutputPromise); - if (e instanceof Error) { - // エラーがPyodideのTracebackの場合、2行目からが出てくるまでを隠す - if (e.name === "PythonError" && e.message.startsWith("Traceback")) { - const lines = e.message.split("\n"); - const execLineIndex = lines.findIndex((line) => - line.includes("") - ); - await onOutput({ - type: "error", - message: lines - .slice(0, 1) - .concat(lines.slice(execLineIndex)) - .join("\n") - .trim(), - }); - } else { + + const result = JSON.parse(resultJson); + if (result.success) { + if (result.has_return && result.result !== null) { await onOutput({ - type: "fatalError", - message: `予期せぬエラー: ${e.message.trim()}`, + type: "return", + message: result.result, }); } } else { await onOutput({ - type: "fatalError", - message: `予期せぬエラー: ${String(e).trim()}`, + type: result.is_fatal ? "fatalError" : "error", + message: result.error_message, }); } + } catch (e: unknown) { + console.log(e); + await Promise.all(pendingOutputPromise); + const message = e instanceof Error ? e.message : String(e); + await onOutput({ + type: "fatalError", + message: `予期せぬエラー: ${message.trim()}`, + }); } const updatedFiles = readAllFiles(); @@ -136,7 +125,8 @@ async function runCode( async function runFile( name: string, files: Record, - onOutput: (output: ReplOutput | UpdatedFile) => Promise + onOutput: (output: ReplOutput | UpdatedFile) => Promise, + onDiagnostic?: (diagnostic: Diagnostic) => Promise ): Promise { if (!pyodide) { throw new Error("Pyodide not initialized"); @@ -152,39 +142,27 @@ async function runFile( } const pyExecFile = pyodide.runPython(execfile_py) as PyCallable; - pyExecFile(`${HOME}/${name}`); + const resultJson = pyExecFile(`${HOME}/${name}`); await Promise.all(pendingOutputPromise); - } catch (e: unknown) { - console.log(e); - await Promise.all(pendingOutputPromise); - if (e instanceof Error) { - // エラーがPyodideのTracebackの場合、2行目からが出てくるまでを隠す - // 自身も隠す - if (e.name === "PythonError" && e.message.startsWith("Traceback")) { - const lines = e.message.split("\n"); - const execLineIndex = lines.findLastIndex((line) => - line.includes("") - ); - await onOutput({ - type: "error", - message: lines - .slice(0, 1) - .concat(lines.slice(execLineIndex + 1)) - .join("\n") - .trim(), - }); - } else { - await onOutput({ - type: "fatalError", - message: `予期せぬエラー: ${e.message.trim()}`, - }); - } - } else { + + const result = JSON.parse(resultJson); + if (!result.success) { await onOutput({ - type: "fatalError", - message: `予期せぬエラー: ${String(e).trim()}`, + type: result.is_fatal ? "fatalError" : "error", + message: result.error_message, }); + if (onDiagnostic && result.diagnostic) { + await onDiagnostic(result.diagnostic); + } } + } catch (e: unknown) { + console.log(e); + await Promise.all(pendingOutputPromise); + const message = e instanceof Error ? e.message : String(e); + await onOutput({ + type: "fatalError", + message: `予期せぬエラー: ${message.trim()}`, + }); } const updatedFiles = readAllFiles(); diff --git a/packages/runtime/src/worker/pyodide/eval_code.py b/packages/runtime/src/worker/pyodide/eval_code.py new file mode 100644 index 00000000..ca27dcd6 --- /dev/null +++ b/packages/runtime/src/worker/pyodide/eval_code.py @@ -0,0 +1,36 @@ +import sys +import json +import traceback +import pyodide.code + +async def __eval_code(code): + try: + result = await pyodide.code.eval_code_async(code, globals()) + return json.dumps({ + "success": True, + "result": str(result) if result is not None else None, + "has_return": result is not None, + }) + except (KeyboardInterrupt, SystemExit, GeneratorExit): + raise + except BaseException as e: + tb = e.__traceback__ + entries = traceback.extract_tb(tb) + user_entries = [ + entry for entry in entries + if "_pyodide" not in entry.filename and entry.name != "__eval_code" + ] + + formatted_lines = ["Traceback (most recent call last):\n"] + formatted_lines.extend(traceback.format_list(user_entries)) + formatted_lines.extend(traceback.format_exception_only(type(e), e)) + formatted_tb = "".join(formatted_lines).strip() + + return json.dumps({ + "success": False, + "error_message": formatted_tb, + "is_fatal": False, + }) + + +__eval_code diff --git a/packages/runtime/src/worker/pyodide/execfile.py b/packages/runtime/src/worker/pyodide/execfile.py index 972e23d6..8c78bf61 100644 --- a/packages/runtime/src/worker/pyodide/execfile.py +++ b/packages/runtime/src/worker/pyodide/execfile.py @@ -1,11 +1,95 @@ +import sys +import json +import traceback + def __execfile(filepath): - # https://stackoverflow.com/questions/436198/what-alternative-is-there-to-execfile-in-python-3-how-to-include-a-python-fil - with open(filepath, "rb") as file: + HOME = "/home/pyodide" + try: + with open(filepath, "rb") as file: + code_bytes = file.read() + exec_globals = { "__file__": filepath, "__name__": "__main__", } - exec(compile(file.read(), filepath, "exec"), exec_globals) + code_obj = compile(code_bytes, filepath, "exec") + exec(code_obj, exec_globals) + return json.dumps({"success": True}) + except (KeyboardInterrupt, SystemExit, GeneratorExit): + raise + except BaseException as e: + frames = [] + if isinstance(e, SyntaxError): + raw_filename = e.filename or filepath + if raw_filename.startswith(HOME): + raw_filename = raw_filename[len(HOME):].lstrip("/") + else: + raw_filename = raw_filename.lstrip("/") + + frame = { + "filename": raw_filename, + "startLineNumber": e.lineno or 1, + "endLineNumber": e.end_lineno or e.lineno or 1, + } + if e.offset is not None: + frame["startColumn"] = e.offset + if e.end_offset is not None: + frame["endColumn"] = e.end_offset + frames.append(frame) + else: + tb = e.__traceback__ + extracted = traceback.extract_tb(tb) + for entry in reversed(extracted): + raw_filename = entry.filename + if raw_filename in ("", "") or (raw_filename.startswith("<") and raw_filename.endswith(">")): + continue + if raw_filename.startswith(HOME): + raw_filename = raw_filename[len(HOME):].lstrip("/") + else: + raw_filename = raw_filename.lstrip("/") + + frame = { + "filename": raw_filename, + "startLineNumber": entry.lineno, + "endLineNumber": entry.end_lineno if entry.end_lineno is not None else entry.lineno, + } + if entry.colno is not None: + frame["startColumn"] = entry.colno + 1 + if entry.end_colno is not None: + frame["endColumn"] = entry.end_colno + 1 + frames.append(frame) + + error_msg_lines = traceback.format_exception_only(type(e), e) + error_message = "".join(error_msg_lines).strip() + + tb = e.__traceback__ + if tb is not None: + entries = traceback.extract_tb(tb) + user_entries = [ + entry for entry in entries + if entry.name != "__execfile" and not (entry.filename.startswith("<") and entry.filename.endswith(">")) + ] + formatted_lines = ["Traceback (most recent call last):\n"] + formatted_lines.extend(traceback.format_list(user_entries)) + formatted_lines.extend(traceback.format_exception_only(type(e), e)) + formatted_tb = "".join(formatted_lines).strip() + else: + formatted_tb = "".join(traceback.format_exception(type(e), e, None)).strip() + + diagnostic = None + if frames: + diagnostic = { + "frames": frames, + "message": error_message, + "severity": "error", + } + + return json.dumps({ + "success": False, + "error_message": formatted_tb, + "diagnostic": diagnostic, + "is_fatal": False, + }) __execfile \ No newline at end of file diff --git a/packages/runtime/src/worker/ruby.worker.ts b/packages/runtime/src/worker/ruby.worker.ts index 97c8955b..e12d85e6 100644 --- a/packages/runtime/src/worker/ruby.worker.ts +++ b/packages/runtime/src/worker/ruby.worker.ts @@ -5,9 +5,16 @@ import { expose } from "comlink"; import { DefaultRubyVM } from "@ruby/wasm-wasi/dist/browser"; import type { RubyVM } from "@ruby/wasm-wasi/dist/vm"; import type { WorkerAPI, WorkerCapabilities } from "./runtime"; -import type { ReplOutput, ReplOutputType, UpdatedFile } from "../interface"; +import type { + Diagnostic, + ReplOutput, + ReplOutputType, + UpdatedFile, +} from "../interface"; import init_rb from "./ruby/init.rb?raw"; +import execfile_rb from "./ruby/execfile.rb?raw"; +import eval_code_rb from "./ruby/eval_code.rb?raw"; let rubyVM: RubyVM | null = null; let currentOutputCallback: @@ -66,6 +73,8 @@ async function init(/*_interruptBuffer?: Uint8Array*/): Promise<{ rubyVM = vm; rubyVM.eval(init_rb); + rubyVM.eval(execfile_rb); + rubyVM.eval(eval_code_rb); return { capabilities: { interrupt: "restart" } }; } catch (e: unknown) { @@ -89,32 +98,6 @@ async function flushOutput() { stderrBuffer = ""; } -function formatRubyError( - error: unknown, - isFile: boolean -): { message: string; isFatal: boolean } { - if (!(error instanceof Error)) { - return { - message: `予期せぬエラー: ${String(error).trim()}`, - isFatal: true, - }; - } - - let errorMessage = error.message; - - // Clean up Ruby error messages by filtering out internal eval lines - if (errorMessage.includes("Traceback") || errorMessage.includes("Error")) { - let lines = errorMessage.split("\n"); - lines = lines.filter((line) => line !== "-e:in 'Kernel.eval'"); - if (isFile) { - lines = lines.filter((line) => !line.startsWith("eval:1:in")); - } - errorMessage = lines.join("\n"); - } - - return { message: errorMessage, isFatal: false }; -} - async function runCode( code: string, onOutput: (output: ReplOutput | UpdatedFile) => Promise @@ -128,28 +111,35 @@ async function runCode( stdoutBuffer = ""; stderrBuffer = ""; - const result = await rubyVM.evalAsync(code); - - const resultStr = await result.callAsync("inspect"); + const resultVal = await rubyVM.evalAsync( + `__ruby_eval_code(${JSON.stringify(code)})` + ); // Flush any buffered output await flushOutput(); - // Add result to output if it's not nil and not empty - await onOutput({ - type: "return", - message: resultStr.toString(), - }); + const result = JSON.parse(resultVal.toString()); + if (result.success) { + await onOutput({ + type: "return", + message: result.result, + }); + } else { + await onOutput({ + type: result.is_fatal ? "fatalError" : "error", + message: result.error_message, + }); + } } catch (e) { console.log(e); // Flush any buffered output await flushOutput(); - const { message, isFatal } = formatRubyError(e, false); + const message = e instanceof Error ? e.message : String(e); await onOutput({ - type: isFatal ? "fatalError" : "error", - message, + type: "fatalError", + message: `予期せぬエラー: ${message.trim()}`, }); } @@ -162,7 +152,8 @@ async function runCode( async function runFile( name: string, files: Record, - onOutput: (output: ReplOutput | UpdatedFile) => Promise + onOutput: (output: ReplOutput | UpdatedFile) => Promise, + onDiagnostic?: (diagnostic: Diagnostic) => Promise ): Promise { if (!rubyVM) { throw new Error("Ruby VM not initialized"); @@ -184,24 +175,35 @@ async function runFile( } } - // clear LOADED_FEATURES so that `require` can reload files - rubyVM.eval(`$LOADED_FEATURES.reject! { |f| f =~ /^\\/[^\\/]*\\.rb$/ }`); - // Run the specified file - await rubyVM.evalAsync(`load ${JSON.stringify(name)}`); + const resultVal = await rubyVM.evalAsync( + `__ruby_exec_file(${JSON.stringify(name)})` + ); // Flush any buffered output await flushOutput(); + + const result = JSON.parse(resultVal.toString()); + if (!result.success) { + await onOutput({ + type: result.is_fatal ? "fatalError" : "error", + message: result.error_message, + }); + + if (onDiagnostic && result.diagnostic) { + await onDiagnostic(result.diagnostic); + } + } } catch (e) { console.log(e); // Flush any buffered output await flushOutput(); - const { message, isFatal } = formatRubyError(e, true); + const message = e instanceof Error ? e.message : String(e); await onOutput({ - type: isFatal ? "fatalError" : "error", - message, + type: "fatalError", + message: `予期せぬエラー: ${message.trim()}`, }); } @@ -290,7 +292,7 @@ async function restoreState(commands: string[]): Promise { for (const command of commands) { try { - await rubyVM.evalAsync(command); + await rubyVM.evalAsync(`__ruby_eval_code(${JSON.stringify(command)})`); } catch (e) { // If restoration fails, we still continue with other commands console.error("Failed to restore command:", command, e); diff --git a/packages/runtime/src/worker/ruby/eval_code.rb b/packages/runtime/src/worker/ruby/eval_code.rb new file mode 100644 index 00000000..8a339a46 --- /dev/null +++ b/packages/runtime/src/worker/ruby/eval_code.rb @@ -0,0 +1,36 @@ +require "json" + +def __ruby_eval_code(code) + begin + result = Kernel.eval(code, TOPLEVEL_BINDING) + JSON.generate({ + success: true, + result: result.inspect + }) + rescue Exception => e + clean_lines = [] + if e.backtrace + e.backtrace.each do |line| + next if line.include?("eval_async") || line.include?("-e:") || line.include?("/bundle/") || line.include?("(eval)") || line.include?("Kernel.eval") || line.include?("__ruby_eval_code") + clean_lines << line.sub(%r{\A/+}, "") + end + end + + if clean_lines.empty? + formatted_msg = "#{e.message} (#{e.class})" + else + first = clean_lines.first + rest = clean_lines[1..] + formatted_msg = "#{first}: #{e.message} (#{e.class})" + if rest && !rest.empty? + formatted_msg += "\n" + rest.map { |l| "\tfrom #{l}" }.join("\n") + end + end + + JSON.generate({ + success: false, + error_message: formatted_msg, + is_fatal: false + }) + end +end diff --git a/packages/runtime/src/worker/ruby/execfile.rb b/packages/runtime/src/worker/ruby/execfile.rb new file mode 100644 index 00000000..9793b005 --- /dev/null +++ b/packages/runtime/src/worker/ruby/execfile.rb @@ -0,0 +1,64 @@ +require "json" + +def __ruby_exec_file(filepath) + begin + # clear LOADED_FEATURES so that `require` can reload files + $LOADED_FEATURES.reject! { |f| f =~ %r{\A/[^/]*\.rb\z} } + load filepath + JSON.generate({ success: true }) + rescue Exception => e + frames = [] + if e.backtrace_locations + e.backtrace_locations.each do |loc| + path = loc.path + next if path.nil? + next if path == "eval" || path == "eval_async" || path.start_with?("eval_async") || + path == "-e" || path.start_with?("(eval)") || + path.start_with?("bundle/") || path.include?("/bundle/") || + (path.start_with?("<") && path.end_with?(">")) + + clean_path = path.sub(%r{\A/+}, "") + frames << { + filename: clean_path, + startLineNumber: loc.lineno, + endLineNumber: loc.lineno + } + end + end + + clean_lines = [] + if e.backtrace + e.backtrace.each do |line| + next if line.include?("eval_async") || line.include?("-e:") || line.include?("/bundle/") || line.include?("(eval)") || line.include?("Kernel#load") || line.include?("__ruby_exec_file") + clean_lines << line.sub(%r{\A/+}, "") + end + end + + if clean_lines.empty? + formatted_msg = "#{e.message} (#{e.class})" + else + first = clean_lines.first + rest = clean_lines[1..] + formatted_msg = "#{first}: #{e.message} (#{e.class})" + if rest && !rest.empty? + formatted_msg += "\n" + rest.map { |l| "\tfrom #{l}" }.join("\n") + end + end + + diagnostic = nil + if !frames.empty? + diagnostic = { + frames: frames, + message: "#{e.message} (#{e.class})", + severity: "error" + } + end + + JSON.generate({ + success: false, + error_message: formatted_msg, + diagnostic: diagnostic, + is_fatal: false + }) + end +end diff --git a/packages/runtime/src/worker/runtime.tsx b/packages/runtime/src/worker/runtime.tsx index 34a064fb..8c910474 100644 --- a/packages/runtime/src/worker/runtime.tsx +++ b/packages/runtime/src/worker/runtime.tsx @@ -13,6 +13,7 @@ import { wrap, Remote, proxy } from "comlink"; import { RuntimeLang } from "../languages"; import { Mutex, MutexInterface } from "async-mutex"; import { + Diagnostic, ReplOutput, RuntimeErrorHandler, RuntimeContext, @@ -38,7 +39,8 @@ export interface WorkerAPI { runFile( name: string, files: Record, - onOutput: (output: ReplOutput | UpdatedFile) => Promise + onOutput: (output: ReplOutput | UpdatedFile) => Promise, + onDiagnostic?: (diagnostic: Diagnostic) => Promise ): Promise; checkSyntax(code: string): Promise<{ status: SyntaxStatus }>; restoreState(commands: string[]): Promise; @@ -284,7 +286,8 @@ export function WorkerProvider({ async ( filenames: string[], files: Readonly>, - onOutput: (output: ReplOutput | UpdatedFile) => void + onOutput: (output: ReplOutput | UpdatedFile) => void, + onDiagnostic?: (diagnostic: Diagnostic) => void ): Promise => { if (filenames.length !== 1) { onOutput({ @@ -317,7 +320,12 @@ export function WorkerProvider({ onErrorRef.current?.(new Error(item.message)); } onOutput(item); - }) + }), + onDiagnostic + ? proxy(async (diag: Diagnostic) => { + onDiagnostic(diag); + }) + : undefined ) ); }); diff --git a/packages/runtime/tests/fileExecution.ts b/packages/runtime/tests/fileExecution.ts index ee1b9617..11397122 100644 --- a/packages/runtime/tests/fileExecution.ts +++ b/packages/runtime/tests/fileExecution.ts @@ -1,6 +1,10 @@ import { RuntimeLang } from "@my-code/runtime/languages"; import { TestBody } from "./utils"; -import { ReplOutput, UpdatedFile } from "@my-code/runtime/interface"; +import { + Diagnostic, + ReplOutput, + UpdatedFile, +} from "@my-code/runtime/interface"; import { expect } from "chai"; export const fileExecutionTests: Record< @@ -170,4 +174,460 @@ export const fileExecutionTests: Record< ).to.equal(msg); }; }, + + /** + * 単一ファイルのコンパイルエラー(構文エラーや型エラー)で診断情報が得られるかテスト + */ + "should capture diagnostics on compile error": (lang) => { + const uniqueTypeName = "TestCompileError1234"; + const [filename, code] = ( + { + python: ["test_compile.py", `def foo(\n`], + ruby: null, + cpp: [ + "test_compile.cpp", + `int ${uniqueTypeName} = "type error";\nint main() {}\n`, + ], + rust: [ + "test_compile.rs", + `static X: i32 = ${uniqueTypeName};\npub fn main() {}\n`, + ], + javascript: ["test_compile.js", `function foo(\n`], + typescript: ["test_compile.ts", `const x: ${uniqueTypeName} = 1;\n`], + } satisfies Record + )[lang] ?? [null, null]; + if (!filename || !code) return null; + + return async (runtimeRef) => { + const outputs: ReplOutput[] = []; + const diagnostics: Diagnostic[] = []; + await runtimeRef.current![lang].runFiles( + [filename], + { [filename]: code }, + (output) => { + if (output.type !== "file") outputs.push(output); + }, + (diagnostic) => { + diagnostics.push(diagnostic); + } + ); + console.log(`${lang} compile error output: `, outputs); + console.log( + `${lang} compile error diagnostic test: `, + JSON.stringify(diagnostics, null, 2) + ); + expect(diagnostics, "diagnostics should not be empty").to.not.be.empty; + const firstDiag = diagnostics[0]; + expect(firstDiag.frames, "frames should not be empty").to.not.be.empty; + expect(firstDiag.frames[0].filename, "frame filename").to.equal(filename); + expect( + firstDiag.frames[0].startLineNumber, + "frame startLineNumber" + ).to.equal(1); + expect(firstDiag.severity, "severity should be error").to.equal("error"); + }; + }, + + /** + * 複数ファイル構成でサブモジュール/ヘッダーファイル内のコンパイルエラーを検出できるかテスト + */ + "should capture diagnostics on compile error in submodule": (lang) => { + const [codes, execFiles, expectedErrorFile, expectedLine] = ( + { + python: null, + ruby: null, + cpp: [ + { + "test_sub_main.cpp": + '#include "test_sub.h"\nint main() { return 0; }\n', + "test_sub.h": 'inline void foo() {\n int x = "err";\n}\n', + }, + ["test_sub_main.cpp"], + "test_sub.h", + 2, + ], + rust: [ + { + "test_sub_main.rs": + "mod test_sub;\npub fn main() {\n test_sub::foo();\n}\n", + "test_sub.rs": 'pub fn foo() {\n let x: i32 = "err";\n}\n', + }, + ["test_sub_main.rs"], + "test_sub.rs", + 2, + ], + javascript: null, + typescript: null, + } satisfies Record< + RuntimeLang, + [Record, string[], string, number] | null + > + )[lang] ?? [null, null, null, null]; + if (!codes || !execFiles || !expectedErrorFile || !expectedLine) + return null; + + return async (runtimeRef) => { + const outputs: ReplOutput[] = []; + const diagnostics: Diagnostic[] = []; + await runtimeRef.current![lang].runFiles( + execFiles, + codes, + (output) => { + if (output.type !== "file") outputs.push(output); + }, + (diagnostic) => { + diagnostics.push(diagnostic); + } + ); + console.log(`${lang} submodule compile error output: `, outputs); + console.log( + `${lang} submodule compile error diagnostic test: `, + JSON.stringify(diagnostics, null, 2) + ); + expect(diagnostics, "diagnostics should not be empty").to.not.be.empty; + const firstDiag = diagnostics[0]; + expect(firstDiag.frames, "frames should not be empty").to.not.be.empty; + expect( + firstDiag.frames[0].filename, + "frame filename should point to submodule" + ).to.equal(expectedErrorFile); + expect( + firstDiag.frames[0].startLineNumber, + "frame startLineNumber" + ).to.equal(expectedLine); + expect(firstDiag.severity, "severity should be error").to.equal("error"); + }; + }, + + /** + * リンクエラー(未定義参照など)で診断情報が得られるかテスト + */ + "should capture diagnostics on link error": (lang) => { + const [filename, code] = ( + { + python: null, + ruby: null, + cpp: [ + "test_link.cpp", + "void undefined_function();\nint main() {\n undefined_function();\n return 0;\n}\n", + ], + rust: null, + javascript: null, + typescript: null, + } satisfies Record + )[lang] ?? [null, null]; + if (!filename || !code) return null; + + return async (runtimeRef) => { + const outputs: ReplOutput[] = []; + const diagnostics: Diagnostic[] = []; + await runtimeRef.current![lang].runFiles( + [filename], + { [filename]: code }, + (output) => { + if (output.type !== "file") outputs.push(output); + }, + (diagnostic) => { + diagnostics.push(diagnostic); + } + ); + console.log(`${lang} link error output: `, outputs); + console.log( + `${lang} link error diagnostic test: `, + JSON.stringify(diagnostics, null, 2) + ); + expect(diagnostics, "diagnostics should not be empty").to.not.be.empty; + const firstDiag = diagnostics[0]; + expect(firstDiag.frames, "frames should not be empty").to.not.be.empty; + expect(firstDiag.frames[0].filename, "frame filename").to.equal(filename); + expect( + firstDiag.frames[0].startLineNumber, + "frame startLineNumber" + ).to.equal(3); + expect(firstDiag.message, "error message").to.include( + "undefined reference" + ); + expect(firstDiag.severity, "severity should be error").to.equal("error"); + }; + }, + + /** + * 単一フレームの実行時エラー(例外やpanic)で診断情報が得られるかテスト + */ + "should capture diagnostics on runtime error": (lang) => { + const errorMsg = "RuntimeErrorUnique9876"; + const [filename, code, expectedLine] = ( + { + python: ["test_runtime.py", `raise Exception("${errorMsg}")\n`, 1], + ruby: ["test_runtime.rb", `raise "${errorMsg}"\n`, 1], + cpp: [ + "test_runtime.cpp", + `#include \nint main() {\n throw std::runtime_error("${errorMsg}");\n return 0;\n}\n`, + 3, + ], + rust: [ + "test_runtime.rs", + `pub fn main() {\n panic!("${errorMsg}");\n}\n`, + 2, + ], + javascript: ["test_runtime.js", `throw new Error("${errorMsg}");\n`, 1], + typescript: null, + } satisfies Record + )[lang] ?? [null, null, null]; + if (!filename || !code || expectedLine === null) return null; + + return async (runtimeRef) => { + const outputs: ReplOutput[] = []; + const diagnostics: Diagnostic[] = []; + await runtimeRef.current![lang].runFiles( + [filename], + { [filename]: code }, + (output) => { + if (output.type !== "file") outputs.push(output); + }, + (diagnostic) => { + diagnostics.push(diagnostic); + } + ); + console.log(`${lang} runtime error output: `, outputs); + console.log( + `${lang} runtime error diagnostic test: `, + JSON.stringify(diagnostics, null, 2) + ); + expect(diagnostics, "diagnostics should not be empty").to.not.be.empty; + const firstDiag = diagnostics[0]; + expect(firstDiag.frames, "frames should not be empty").to.not.be.empty; + expect(firstDiag.frames[0].filename, "frame filename").to.equal(filename); + expect( + firstDiag.frames[0].startLineNumber, + "frame startLineNumber" + ).to.equal(expectedLine); + expect(firstDiag.message, "error message").to.include(errorMsg); + expect(firstDiag.severity, "severity should be error").to.equal("error"); + }; + }, + + /** + * クラッシュやシグナル(Segfault、配列外参照パニックなど)で診断情報が得られるかテスト + */ + "should capture diagnostics on runtime crash or signal": (lang) => { + const [filename, code, expectedLine, expectedMsg] = ( + { + python: null, + ruby: null, + cpp: [ + "test_crash.cpp", + "int main() {\n int* ptr = nullptr;\n *ptr = 42;\n return 0;\n}\n", + 3, + "Segmentation fault", + ], + rust: [ + "test_crash.rs", + "pub fn main() {\n let v = vec![1, 2];\n let _ = v[5];\n}\n", + 3, + "index out of bounds", + ], + javascript: null, + typescript: null, + } satisfies Record + )[lang] ?? [null, null, null, null]; + if (!filename || !code || expectedLine === null || !expectedMsg) + return null; + + return async (runtimeRef) => { + const outputs: ReplOutput[] = []; + const diagnostics: Diagnostic[] = []; + await runtimeRef.current![lang].runFiles( + [filename], + { [filename]: code }, + (output) => { + if (output.type !== "file") outputs.push(output); + }, + (diagnostic) => { + diagnostics.push(diagnostic); + } + ); + console.log(`${lang} crash output: `, outputs); + console.log( + `${lang} crash diagnostic test: `, + JSON.stringify(diagnostics, null, 2) + ); + expect(diagnostics, "diagnostics should not be empty").to.not.be.empty; + const firstDiag = diagnostics[0]; + expect(firstDiag.frames, "frames should not be empty").to.not.be.empty; + expect(firstDiag.frames[0].filename, "frame filename").to.equal(filename); + expect( + firstDiag.frames[0].startLineNumber, + "frame startLineNumber" + ).to.equal(expectedLine); + expect(firstDiag.message, "error message").to.include(expectedMsg); + expect(firstDiag.severity, "severity should be error").to.equal("error"); + }; + }, + + /** + * 関数呼び出しを挟んだ複数フレームのエラーで1つのDiagnosticにまとめられるかテスト + * + * Python/Ruby/CPP/Rust: 関数呼び出し連鎖でスタックトレースを生成し、 + * - diagnosticsが1件だけ返ること + * - framesが2件以上あること + * - 全フレームがユーザーファイルを指すこと(等の内部フレームが含まれないこと) + * を確認する + */ + "should capture multi-frame diagnostics as single Diagnostic": (lang) => { + const uniqueTypeName = "TestMultiFrameError5678"; + const [filename, code] = ( + { + python: [ + "test_multiframe.py", + // bar() -> foo() -> raise で3フレームのトレースバックを生成 + `def foo():\n raise Exception("${uniqueTypeName}")\n\ndef bar():\n foo()\n\nbar()\n`, + ], + ruby: [ + "test_multiframe.rb", + // bar -> foo -> raise で複数フレームのエラーを生成 + `def foo\n raise "${uniqueTypeName}"\nend\n\ndef bar\n foo\nend\n\nbar\n`, + ], + cpp: [ + "test_multiframe.cpp", + `#include \nvoid foo() { throw std::runtime_error("${uniqueTypeName}"); }\nvoid bar() { foo(); }\nint main() { bar(); }\n`, + ], + rust: [ + "test_multiframe.rs", + `fn foo() {\n panic!("${uniqueTypeName}");\n}\nfn bar() {\n foo();\n}\npub fn main() {\n bar();\n}\n`, + ], + javascript: [ + "test_multiframe.js", + `function foo() {\n throw new Error("${uniqueTypeName}");\n}\nfunction bar() {\n foo();\n}\nbar();\n`, + ], + typescript: [null, null], + } satisfies Record + )[lang]; + if (!filename || !code) return null; + + return async (runtimeRef) => { + const outputs: ReplOutput[] = []; + const diagnostics: Diagnostic[] = []; + await runtimeRef.current![lang].runFiles( + [filename], + { [filename]: code }, + (output) => { + if (output.type !== "file") outputs.push(output); + }, + (diagnostic) => { + diagnostics.push(diagnostic); + } + ); + console.log(`${lang} multi-frame output: `, outputs); + console.log( + `${lang} multi-frame diagnostic test: `, + JSON.stringify(diagnostics, null, 2) + ); + + // 1エラー → 1 Diagnostic + expect(diagnostics, "should have exactly 1 diagnostic").to.have.lengthOf( + 1 + ); + const diag = diagnostics[0]; + + // メッセージにユニーク文字列が含まれる + expect( + diag.message, + "error message should include unique string" + ).to.include(uniqueTypeName); + + // 複数フレームがあること + expect( + diag.frames, + "should have multiple frames" + ).to.have.length.greaterThan(1); + + // 最新のフレームが先頭に来ること(innermost frame first) + expect( + diag.frames[0].startLineNumber, + "first frame should be the innermost frame where error was raised" + ).to.equal(2); + + // フレームの順序が最新(エラー発生箇所)から呼び出し元への順になっていること + const expectedLines = ( + { + python: [2, 5, 7], + ruby: [2, 6, 9], + cpp: [2, 3, 4], + rust: [2, 5, 8], + javascript: [2, 5, 7], + typescript: null, + } satisfies Record + )[lang]; + if (expectedLines) { + expect( + diag.frames.map((f) => f.startLineNumber), + "frames should be ordered from newest (innermost) to oldest (outermost)" + ).to.deep.equal(expectedLines); + } + + // , など内部フレームが含まれないこと + for (const frame of diag.frames) { + expect( + frame.filename, + "frame filename should not be internal" + ).to.not.match(/^<.*>$/); + expect(frame.filename, "frame filename should be user file").to.equal( + filename + ); + } + }; + }, + + /** + * コンパイル警告で診断情報(severity: 'warning')が得られるかテスト + */ + "should capture diagnostics on warning": (lang) => { + const [filename, code] = ( + { + python: null, + ruby: null, + cpp: [ + "test_warning.cpp", + "int main() {\n int unused_var = 42;\n return 0;\n}\n", + ], + rust: [ + "test_warning.rs", + "pub fn main() {\n let unused_var = 42;\n}\n", + ], + javascript: null, + typescript: null, + } satisfies Record + )[lang] ?? [null, null]; + if (!filename || !code) return null; + + return async (runtimeRef) => { + const outputs: ReplOutput[] = []; + const diagnostics: Diagnostic[] = []; + await runtimeRef.current![lang].runFiles( + [filename], + { [filename]: code }, + (output) => { + if (output.type !== "file") outputs.push(output); + }, + (diagnostic) => { + diagnostics.push(diagnostic); + } + ); + console.log(`${lang} warning output: `, outputs); + console.log( + `${lang} warning diagnostic test: `, + JSON.stringify(diagnostics, null, 2) + ); + expect(diagnostics, "diagnostics should not be empty").to.not.be.empty; + const warnDiag = diagnostics.find((d) => d.severity === "warning"); + expect(warnDiag, "should have warning diagnostic").to.exist; + expect(warnDiag!.frames[0].filename, "frame filename").to.equal(filename); + expect( + warnDiag!.frames[0].startLineNumber, + "frame startLineNumber" + ).to.equal(2); + expect(warnDiag!.message, "warning message").to.include("unused"); + }; + }, };