From c8e5f94f8814419c43236ffa45b27bbf59064636 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:27:33 +0000 Subject: [PATCH 01/19] =?UTF-8?q?feat:=20runFiles=E3=81=A7=E3=81=AE?= =?UTF-8?q?=E3=83=AA=E3=82=A2=E3=83=AB=E3=82=BF=E3=82=A4=E3=83=A0diagnosti?= =?UTF-8?q?c=E5=87=BA=E5=8A=9B=E6=A9=9F=E8=83=BD=E3=81=8A=E3=82=88?= =?UTF-8?q?=E3=81=B3=E3=82=A8=E3=83=87=E3=82=A3=E3=82=BF=E8=A1=A8=E7=A4=BA?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/globals.css | 18 +++ app/terminal/editor.tsx | 58 ++++++- app/terminal/embedContext.tsx | 37 ++++- app/terminal/exec.tsx | 62 +++++--- packages/runtime/src/diagnostics/index.ts | 2 + packages/runtime/src/diagnostics/python.ts | 81 ++++++++++ packages/runtime/src/diagnostics/ruby.ts | 88 +++++++++++ packages/runtime/src/interface.ts | 18 ++- packages/runtime/src/typescript/runtime.tsx | 61 +++++++- packages/runtime/src/wandbox/runtime.tsx | 8 +- packages/runtime/src/worker/jsEval.worker.ts | 16 +- packages/runtime/src/worker/pyodide.worker.ts | 12 +- packages/runtime/src/worker/ruby.worker.ts | 13 +- packages/runtime/src/worker/runtime.tsx | 14 +- packages/runtime/tests/fileExecution.ts | 40 ++++- tests/diagnostics.test.ts | 144 ++++++++++++++++++ 16 files changed, 628 insertions(+), 44 deletions(-) create mode 100644 packages/runtime/src/diagnostics/index.ts create mode 100644 packages/runtime/src/diagnostics/python.ts create mode 100644 packages/runtime/src/diagnostics/ruby.ts create mode 100644 tests/diagnostics.test.ts diff --git a/app/globals.css b/app/globals.css index 8fa9e767..f6a448af 100644 --- a/app/globals.css +++ b/app/globals.css @@ -114,6 +114,24 @@ mycdark: .ace_selected-word { @apply border-primary!; } +.ace_error-marker { + position: absolute; + background-color: rgba(239, 68, 68, 0.2); + border-bottom: 2px wavy rgb(239, 68, 68); + z-index: 20; +} +.ace_warning-marker { + position: absolute; + background-color: rgba(245, 158, 11, 0.2); + border-bottom: 2px wavy rgb(245, 158, 11); + z-index: 20; +} +.ace_info-marker { + position: absolute; + background-color: rgba(59, 130, 246, 0.2); + border-bottom: 2px dotted rgb(59, 130, 246); + z-index: 20; +} .rounded-box-modal { @apply rounded-box; diff --git a/app/terminal/editor.tsx b/app/terminal/editor.tsx index b2f910cd..c4a6381e 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,59 @@ interface EditorProps { } export function EditorComponent(props: EditorProps) { const theme = useChangeTheme(); - const { files, writeFile } = useEmbedContext(); + const { files, writeFile, diagnostics } = useEmbedContext(); + const fileDiagnostics = useMemo( + () => diagnostics[props.filename] ?? [], + [diagnostics, props.filename] + ); + + const annotations = useMemo(() => { + return fileDiagnostics.map((diag) => ({ + row: Math.max(0, diag.startLineNumber - 1), + column: Math.max(0, (diag.startColumn ?? 1) - 1), + text: diag.message, + type: diag.severity ?? "error", // "error" | "warning" | "info" + })); + }, [fileDiagnostics]); + + const markers = useMemo(() => { + return fileDiagnostics.map((diag) => { + const startRow = Math.max(0, diag.startLineNumber - 1); + const endRow = diag.endLineNumber + ? Math.max(0, diag.endLineNumber - 1) + : startRow; + const startCol = + diag.startColumn !== undefined ? Math.max(0, diag.startColumn - 1) : 0; + const endCol = + diag.endColumn !== undefined + ? Math.max(0, diag.endColumn - 1) + : Number.MAX_SAFE_INTEGER; + + const isError = (diag.severity ?? "error") === "error"; + const isWarning = diag.severity === "warning"; + const className = isError + ? "ace_error-marker" + : isWarning + ? "ace_warning-marker" + : "ace_info-marker"; + + return { + startRow, + startCol, + endRow, + endCol, + className, + type: + diag.startColumn !== undefined && + diag.endColumn !== undefined && + startRow === endRow + ? ("text" as const) + : ("fullLine" as const), + inFront: false, + }; + }); + }, [fileDiagnostics]); + const code = files[props.filename] || props.initContent; useEffect(() => { if (!files[props.filename] && props.initContent) { @@ -202,6 +254,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 7e745dde..c0422aaf 100644 --- a/app/terminal/embedContext.tsx +++ b/app/terminal/embedContext.tsx @@ -1,6 +1,6 @@ "use client"; -import { ReplCommand, ReplOutput } from "@my-code/runtime/interface"; +import { Diagnostic, ReplCommand, ReplOutput } from "@my-code/runtime/interface"; import { createContext, ReactNode, @@ -40,6 +40,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 +84,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 +189,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..456f5305 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,39 @@ export function ExecFile(props: ExecProps) { // TODO: 1つのファイル名しか受け付けないところに無理やりコンマ区切りで全部のファイル名を突っ込んでいる const filenameKey = props.filenames.join(","); clearExecResult(filenameKey); + for (const fname of props.filenames) { + clearDiagnostics(fname); + } 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(diagnostic.filename, 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 +148,8 @@ export function ExecFile(props: ExecProps) { clearExecResult, addExecOutput, writeFile, + clearDiagnostics, + addDiagnostic, terminalInstanceRef, props.language, files, diff --git a/packages/runtime/src/diagnostics/index.ts b/packages/runtime/src/diagnostics/index.ts new file mode 100644 index 00000000..4612acb2 --- /dev/null +++ b/packages/runtime/src/diagnostics/index.ts @@ -0,0 +1,2 @@ +export * from "./python"; +export * from "./ruby"; diff --git a/packages/runtime/src/diagnostics/python.ts b/packages/runtime/src/diagnostics/python.ts new file mode 100644 index 00000000..36a88d5e --- /dev/null +++ b/packages/runtime/src/diagnostics/python.ts @@ -0,0 +1,81 @@ +import { Diagnostic } from "../interface"; + +/** + * Parses Python error/traceback string to extract diagnostic information. + * + * @param traceback - The traceback string or error message from Python + * @param homePrefix - The virtual home directory prefix to strip (default: "/home/pyodide/") + * @returns Array of Diagnostic objects + */ +export function parsePythonTraceback( + traceback: string, + homePrefix: string = "/home/pyodide/" +): Diagnostic[] { + if (!traceback) return []; + + const lines = traceback.trim().split("\n"); + if (lines.length === 0) return []; + + // Extract the last error message line (e.g., "Exception: This is a test error" or "SyntaxError: ...") + let errorMessage = lines[lines.length - 1].trim(); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (line && !line.startsWith("^") && !line.startsWith("File \"") && !line.startsWith("Traceback")) { + errorMessage = line; + break; + } + } + + const diagnostics: Diagnostic[] = []; + const fileLineRegex = /File "([^"]+)", line (\d+)(?:, in (.+))?/; + + for (let i = 0; i < lines.length; i++) { + const match = fileLineRegex.exec(lines[i]); + if (match) { + let rawFilename = match[1]; + const lineNum = parseInt(match[2], 10); + + // Normalize filename by removing homePrefix or leading slashes + if (rawFilename.startsWith(homePrefix)) { + rawFilename = rawFilename.slice(homePrefix.length); + } else if (rawFilename.startsWith("/")) { + rawFilename = rawFilename.slice(1); + } + + // Ignore internal names like , if not matching normal files + if (rawFilename === "" || rawFilename === "") { + continue; + } + + // Check if there is a column indicator on subsequent lines (e.g. for SyntaxError with ^) + let startColumn: number | undefined = undefined; + let endColumn: number | undefined = undefined; + for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) { + const nextLine = lines[j]; + if (fileLineRegex.test(nextLine)) break; + const caretIndex = nextLine.indexOf("^"); + if (caretIndex !== -1) { + // In Python SyntaxError output, caret points to character (1-indexed) + startColumn = caretIndex + 1; + const caretEnd = nextLine.lastIndexOf("^"); + if (caretEnd > caretIndex) { + endColumn = caretEnd + 2; + } + break; + } + } + + diagnostics.push({ + filename: rawFilename, + startLineNumber: lineNum, + startColumn, + endLineNumber: lineNum, + endColumn, + message: errorMessage, + severity: "error", + }); + } + } + + return diagnostics; +} diff --git a/packages/runtime/src/diagnostics/ruby.ts b/packages/runtime/src/diagnostics/ruby.ts new file mode 100644 index 00000000..1333666c --- /dev/null +++ b/packages/runtime/src/diagnostics/ruby.ts @@ -0,0 +1,88 @@ +import { Diagnostic } from "../interface"; + +/** + * Parses Ruby error/traceback string to extract diagnostic information. + * + * @param errorMessage - The error message from Ruby VM + * @returns Array of Diagnostic objects + */ +export function parseRubyError(errorMessage: string): Diagnostic[] { + if (!errorMessage) return []; + + const lines = errorMessage.trim().split("\n"); + if (lines.length === 0) return []; + + const diagnostics: Diagnostic[] = []; + + // Matches formats like: + // "test_error.rb:1:in '
': This is a test error (RuntimeError)" + // "/test_error.rb:2:in 'bar': This is a test error (RuntimeError)" + // "test_syntax.rb:1: syntax error, unexpected end-of-input, expecting '}'" + // " from /test_error.rb:5:in 'foo'" + const primaryErrorRegex = /^(\/?[^:\n\t]+):(\d+)(?::in [`']([^']+)['])?: (.*)$/; + const stackFromRegex = /^\s*from (\/?[^:\n\t]+):(\d+)(?::in [`']([^']+)['])?/; + + let mainErrorMsg = ""; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + + // Skip internal evaluation files + if (line.includes("-e:in 'Kernel.eval'") || line.startsWith("eval:1:in") || line.startsWith("(eval)")) { + continue; + } + + const primaryMatch = primaryErrorRegex.exec(line); + if (primaryMatch) { + let rawFilename = primaryMatch[1]; + const lineNum = parseInt(primaryMatch[2], 10); + const message = primaryMatch[4]; + + if (!mainErrorMsg) { + mainErrorMsg = message; + } + + if (rawFilename.startsWith("/")) { + rawFilename = rawFilename.slice(1); + } + + if (rawFilename === "eval" || rawFilename === "-e" || rawFilename.startsWith("(eval)")) { + continue; + } + + diagnostics.push({ + filename: rawFilename, + startLineNumber: lineNum, + endLineNumber: lineNum, + message, + severity: "error", + }); + continue; + } + + const fromMatch = stackFromRegex.exec(line); + if (fromMatch) { + let rawFilename = fromMatch[1]; + const lineNum = parseInt(fromMatch[2], 10); + + if (rawFilename.startsWith("/")) { + rawFilename = rawFilename.slice(1); + } + + if (rawFilename === "eval" || rawFilename === "-e" || rawFilename.startsWith("(eval)")) { + continue; + } + + diagnostics.push({ + filename: rawFilename, + startLineNumber: lineNum, + endLineNumber: lineNum, + message: mainErrorMsg || line, + severity: "error", + }); + } + } + + return diagnostics; +} diff --git a/packages/runtime/src/interface.ts b/packages/runtime/src/interface.ts index 9b53a514..fe41d063 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,20 @@ export interface RuntimeInfo { } export type RuntimeErrorHandler = (error: unknown) => void; +export const DiagnosticSeveritySchema = z.enum(["error", "warning", "info"]); +export type DiagnosticSeverity = z.output; + +export const DiagnosticSchema = 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 + 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 7c05544e..c1106b3d 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, @@ -113,7 +115,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." }); @@ -126,6 +129,57 @@ 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 { + filename, + startLineNumber: line + 1, + startColumn: character + 1, + endLineNumber, + endColumn, + message, + severity, + }; + }; + for (const diagnostic of tsEnv.languageService.getSyntacticDiagnostics( filenames[0] )) { @@ -137,6 +191,7 @@ export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { getNewLine: () => "\n", }), }); + onDiagnostic?.(convertDiagnostic(diagnostic)); } for (const diagnostic of tsEnv.languageService.getSemanticDiagnostics( @@ -150,6 +205,7 @@ export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { getNewLine: () => "\n", }), }); + onDiagnostic?.(convertDiagnostic(diagnostic)); } const emitOutput = tsEnv.languageService.getEmitOutput(filenames[0]); @@ -168,7 +224,8 @@ export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { await jsEval.runFiles( [emitOutput.outputFiles[0].name], { ...files, ...emittedFiles }, - onOutput + onOutput, + onDiagnostic ); } catch (error) { onErrorRef.current?.(error); diff --git a/packages/runtime/src/wandbox/runtime.tsx b/packages/runtime/src/wandbox/runtime.tsx index ec4485e4..72ad2abd 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,9 @@ export function WandboxProvider({ children }: { children: ReactNode }) { async ( filenames: string[], files: Readonly>, - onOutput: (output: ReplOutput | UpdatedFile) => void + onOutput: (output: ReplOutput | UpdatedFile) => void, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _onDiagnostic?: (diagnostic: Diagnostic) => void ) => { if (!selectedCompiler) { onOutput({ type: "error", message: "Wandbox is not ready yet." }); diff --git a/packages/runtime/src/worker/jsEval.worker.ts b/packages/runtime/src/worker/jsEval.worker.ts index 561e8a45..bc6b1a63 100644 --- a/packages/runtime/src/worker/jsEval.worker.ts +++ b/packages/runtime/src/worker/jsEval.worker.ts @@ -1,7 +1,7 @@ /// 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"; @@ -38,10 +38,12 @@ async function runCode( try { const result = await replLikeEval(code); await Promise.all(pendingOutputPromise); - await onOutput({ - type: "return", - message: inspect(result), - }); + if (result !== undefined) { + await onOutput({ + type: "return", + message: inspect(result), + }); + } } catch (e) { originalConsole.log(e); await Promise.all(pendingOutputPromise); @@ -63,7 +65,9 @@ async function runCode( async function runFile( name: string, files: Record, - onOutput: (output: ReplOutput | UpdatedFile) => Promise + onOutput: (output: ReplOutput | UpdatedFile) => Promise, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _onDiagnostic?: (diagnostic: Diagnostic) => Promise ): Promise { // pyodide worker などと異なり、複数ファイルを読み込んでimportのようなことをするのには対応していません。 currentOutputCallback = onOutput; diff --git a/packages/runtime/src/worker/pyodide.worker.ts b/packages/runtime/src/worker/pyodide.worker.ts index 97c57e41..b18c0898 100644 --- a/packages/runtime/src/worker/pyodide.worker.ts +++ b/packages/runtime/src/worker/pyodide.worker.ts @@ -7,7 +7,8 @@ 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 { parsePythonTraceback } from "../diagnostics/python"; import execfile_py from "./pyodide/execfile.py?raw"; import check_syntax_py from "./pyodide/check_syntax.py?raw"; @@ -136,7 +137,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"); @@ -173,6 +175,12 @@ async function runFile( .join("\n") .trim(), }); + if (onDiagnostic) { + const diagnostics = parsePythonTraceback(e.message, HOME); + for (const diag of diagnostics) { + await onDiagnostic(diag); + } + } } else { await onOutput({ type: "fatalError", diff --git a/packages/runtime/src/worker/ruby.worker.ts b/packages/runtime/src/worker/ruby.worker.ts index 35e50fda..cd0727b1 100644 --- a/packages/runtime/src/worker/ruby.worker.ts +++ b/packages/runtime/src/worker/ruby.worker.ts @@ -5,7 +5,8 @@ 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 { parseRubyError } from "../diagnostics/ruby"; import init_rb from "./ruby/init.rb?raw"; @@ -154,7 +155,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"); @@ -195,6 +197,13 @@ async function runFile( type: isFatal ? "fatalError" : "error", message, }); + + if (!isFatal && onDiagnostic && e instanceof Error) { + const diagnostics = parseRubyError(e.message); + for (const diag of diagnostics) { + await onDiagnostic(diag); + } + } } const updatedFiles = readAllFiles(); diff --git a/packages/runtime/src/worker/runtime.tsx b/packages/runtime/src/worker/runtime.tsx index 82f6b676..fd2825ee 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; @@ -283,7 +285,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({ @@ -316,7 +319,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..a2cc57bb 100644 --- a/packages/runtime/tests/fileExecution.ts +++ b/packages/runtime/tests/fileExecution.ts @@ -1,6 +1,6 @@ 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 +170,42 @@ export const fileExecutionTests: Record< ).to.equal(msg); }; }, + + "should capture diagnostics on error": (lang) => { + const errorMsg = "This is a test error"; + const [filename, code, expectedLine] = ( + { + python: ["test_error.py", `raise Exception("${errorMsg}")\n`, 1], + ruby: ["test_error.rb", `raise "${errorMsg}"\n`, 1], + cpp: [null, null, null], + rust: [null, null, null], + javascript: [null, null, null], + typescript: ["test_error.ts", `const x: number = "${errorMsg}";\n`, 1], + } satisfies Record< + RuntimeLang, + [string, string, number] | [null, null, null] + > + )[lang]; + if (!filename || !code) return null; + + return async (runtimeRef) => { + const diagnostics: Diagnostic[] = []; + await runtimeRef.current![lang].runFiles( + [filename], + { + [filename]: code, + }, + () => {}, + (diagnostic) => { + diagnostics.push(diagnostic); + } + ); + console.log(`${lang} single file diagnostic test: `, diagnostics); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(diagnostics).to.not.be.empty; + expect(diagnostics[0].filename).to.equal(filename); + expect(diagnostics[0].startLineNumber).to.equal(expectedLine); + expect(diagnostics[0].message).to.include(errorMsg); + }; + }, }; diff --git a/tests/diagnostics.test.ts b/tests/diagnostics.test.ts new file mode 100644 index 00000000..582f26dc --- /dev/null +++ b/tests/diagnostics.test.ts @@ -0,0 +1,144 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { parsePythonTraceback } from "../packages/runtime/src/diagnostics/python"; +import { parseRubyError } from "../packages/runtime/src/diagnostics/ruby"; + +describe("Diagnostics parser tests", () => { + describe("Python Traceback parser", () => { + it("should parse simple Python traceback", () => { + const tb = `Traceback (most recent call last): + File "/home/pyodide/test_error.py", line 1, in + raise Exception("This is a test error") +Exception: This is a test error`; + + const diagnostics = parsePythonTraceback(tb, "/home/pyodide/"); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].filename, "test_error.py"); + assert.equal(diagnostics[0].startLineNumber, 1); + assert.equal(diagnostics[0].message, "Exception: This is a test error"); + assert.equal(diagnostics[0].severity, "error"); + }); + + it("should parse multi-frame Python traceback", () => { + const tb = `Traceback (most recent call last): + File "/home/pyodide/main.py", line 5, in + helper() + File "/home/pyodide/helper.py", line 2, in helper + raise ValueError("invalid value") +ValueError: invalid value`; + + const diagnostics = parsePythonTraceback(tb, "/home/pyodide/"); + assert.equal(diagnostics.length, 2); + assert.equal(diagnostics[0].filename, "main.py"); + assert.equal(diagnostics[0].startLineNumber, 5); + assert.equal(diagnostics[0].message, "ValueError: invalid value"); + + assert.equal(diagnostics[1].filename, "helper.py"); + assert.equal(diagnostics[1].startLineNumber, 2); + assert.equal(diagnostics[1].message, "ValueError: invalid value"); + }); + + it("should parse Python SyntaxError with column indicator", () => { + const tb = ` File "/home/pyodide/syntax.py", line 3 + def foo( + ^ +SyntaxError: '(' was never closed`; + + const diagnostics = parsePythonTraceback(tb, "/home/pyodide/"); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].filename, "syntax.py"); + assert.equal(diagnostics[0].startLineNumber, 3); + assert.equal(diagnostics[0].startColumn, 12); + assert.equal(diagnostics[0].message, "SyntaxError: '(' was never closed"); + }); + + it("should ignore and internal frames", () => { + const tb = `Traceback (most recent call last): + File "", line 1, in + File "/home/pyodide/app.py", line 10, in run + 1 / 0 +ZeroDivisionError: division by zero`; + + const diagnostics = parsePythonTraceback(tb, "/home/pyodide/"); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].filename, "app.py"); + assert.equal(diagnostics[0].startLineNumber, 10); + }); + + it("should handle empty or null input gracefully", () => { + assert.deepEqual(parsePythonTraceback(""), []); + }); + }); + + describe("Ruby Error parser", () => { + it("should parse simple Ruby runtime error", () => { + const err = `test_error.rb:1:in '
': This is a test error (RuntimeError)`; + + const diagnostics = parseRubyError(err); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].filename, "test_error.rb"); + assert.equal(diagnostics[0].startLineNumber, 1); + assert.equal(diagnostics[0].message, "This is a test error (RuntimeError)"); + assert.equal(diagnostics[0].severity, "error"); + }); + + it("should parse Ruby error with virtual filesystem slash", () => { + const err = `/test_error.rb:4:in 'bar': undefined local variable or method 'baz' (NameError)`; + + const diagnostics = parseRubyError(err); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].filename, "test_error.rb"); + assert.equal(diagnostics[0].startLineNumber, 4); + assert.equal( + diagnostics[0].message, + "undefined local variable or method 'baz' (NameError)" + ); + }); + + it("should parse Ruby stack trace with from lines", () => { + const err = `/sub.rb:2:in 'bar': Something went wrong (RuntimeError) +\tfrom /main.rb:5:in 'foo' +\tfrom /main.rb:8:in '
'`; + + const diagnostics = parseRubyError(err); + assert.equal(diagnostics.length, 3); + assert.equal(diagnostics[0].filename, "sub.rb"); + assert.equal(diagnostics[0].startLineNumber, 2); + assert.equal(diagnostics[0].message, "Something went wrong (RuntimeError)"); + + assert.equal(diagnostics[1].filename, "main.rb"); + assert.equal(diagnostics[1].startLineNumber, 5); + + assert.equal(diagnostics[2].filename, "main.rb"); + assert.equal(diagnostics[2].startLineNumber, 8); + }); + + it("should parse Ruby SyntaxError", () => { + const err = `test_syntax.rb:2: syntax error, unexpected end-of-input, expecting '}'`; + + const diagnostics = parseRubyError(err); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].filename, "test_syntax.rb"); + assert.equal(diagnostics[0].startLineNumber, 2); + assert.equal( + diagnostics[0].message, + "syntax error, unexpected end-of-input, expecting '}'" + ); + }); + + it("should ignore internal eval lines", () => { + const err = `-e:in 'Kernel.eval' +eval:1:in '
' +/app.rb:3:in 'run': error (StandardError)`; + + const diagnostics = parseRubyError(err); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].filename, "app.rb"); + assert.equal(diagnostics[0].startLineNumber, 3); + }); + + it("should handle empty input gracefully", () => { + assert.deepEqual(parseRubyError(""), []); + }); + }); +}); From db8ef44b0f17373c1b8766be2c72c4ee5e3998a8 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:07:27 +0000 Subject: [PATCH 02/19] =?UTF-8?q?refactor:=20Diagnostic=E3=82=92frames?= =?UTF-8?q?=E3=83=99=E3=83=BC=E3=82=B9=E3=81=AEN:1=E6=A7=8B=E9=80=A0?= =?UTF-8?q?=E3=81=AB=E5=A4=89=E6=9B=B4=E3=81=97=E3=80=81diagnostics?= =?UTF-8?q?=E5=8D=98=E4=BD=93=E3=83=86=E3=82=B9=E3=83=88=E3=82=92fileExecu?= =?UTF-8?q?tion=E3=81=AB=E7=B5=B1=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/editor.tsx | 91 +++++++------ app/terminal/exec.tsx | 6 +- packages/runtime/src/diagnostics/python.ts | 25 ++-- packages/runtime/src/diagnostics/ruby.ts | 29 ++-- packages/runtime/src/interface.ts | 15 +- packages/runtime/src/typescript/runtime.tsx | 16 ++- packages/runtime/tests/fileExecution.ts | 107 ++++++++++++--- tests/diagnostics.test.ts | 144 -------------------- 8 files changed, 203 insertions(+), 230 deletions(-) delete mode 100644 tests/diagnostics.test.ts diff --git a/app/terminal/editor.tsx b/app/terminal/editor.tsx index c4a6381e..38ed3c68 100644 --- a/app/terminal/editor.tsx +++ b/app/terminal/editor.tsx @@ -48,51 +48,60 @@ export function EditorComponent(props: EditorProps) { ); const annotations = useMemo(() => { - return fileDiagnostics.map((diag) => ({ - row: Math.max(0, diag.startLineNumber - 1), - column: Math.max(0, (diag.startColumn ?? 1) - 1), - text: diag.message, - type: diag.severity ?? "error", // "error" | "warning" | "info" - })); - }, [fileDiagnostics]); + return fileDiagnostics.flatMap((diag) => + diag.frames + .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.map((diag) => { - const startRow = Math.max(0, diag.startLineNumber - 1); - const endRow = diag.endLineNumber - ? Math.max(0, diag.endLineNumber - 1) - : startRow; - const startCol = - diag.startColumn !== undefined ? Math.max(0, diag.startColumn - 1) : 0; - const endCol = - diag.endColumn !== undefined - ? Math.max(0, diag.endColumn - 1) - : Number.MAX_SAFE_INTEGER; + return fileDiagnostics.flatMap((diag) => + diag.frames + .filter((f) => f.filename === props.filename) + .map((f) => { + const startRow = Math.max(0, f.startLineNumber - 1); + const endRow = f.endLineNumber + ? Math.max(0, f.endLineNumber - 1) + : startRow; + const startCol = + f.startColumn !== undefined ? Math.max(0, f.startColumn - 1) : 0; + const endCol = + f.endColumn !== undefined + ? Math.max(0, f.endColumn - 1) + : Number.MAX_SAFE_INTEGER; + + const isError = (diag.severity ?? "error") === "error"; + const isWarning = diag.severity === "warning"; + const className = isError + ? "ace_error-marker" + : isWarning + ? "ace_warning-marker" + : "ace_info-marker"; - const isError = (diag.severity ?? "error") === "error"; - const isWarning = diag.severity === "warning"; - const className = isError - ? "ace_error-marker" - : isWarning - ? "ace_warning-marker" - : "ace_info-marker"; + 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]); - return { - startRow, - startCol, - endRow, - endCol, - className, - type: - diag.startColumn !== undefined && - diag.endColumn !== undefined && - startRow === endRow - ? ("text" as const) - : ("fullLine" as const), - inFront: false, - }; - }); - }, [fileDiagnostics]); const code = files[props.filename] || props.initContent; useEffect(() => { diff --git a/app/terminal/exec.tsx b/app/terminal/exec.tsx index 456f5305..3d98388e 100644 --- a/app/terminal/exec.tsx +++ b/app/terminal/exec.tsx @@ -130,7 +130,11 @@ export function ExecFile(props: ExecProps) { setContents((prev) => prev + output.message + "\n"); }, (diagnostic) => { - addDiagnostic(diagnostic.filename, diagnostic); + // diagnosticを関連する全ファイルに登録する + const relatedFiles = new Set(diagnostic.frames.map((f) => f.filename)); + for (const fname of relatedFiles) { + addDiagnostic(fname, diagnostic); + } } ); setExecutionState("idle"); diff --git a/packages/runtime/src/diagnostics/python.ts b/packages/runtime/src/diagnostics/python.ts index 36a88d5e..6805b645 100644 --- a/packages/runtime/src/diagnostics/python.ts +++ b/packages/runtime/src/diagnostics/python.ts @@ -1,11 +1,11 @@ -import { Diagnostic } from "../interface"; +import { Diagnostic, DiagnosticFrame } from "../interface"; /** - * Parses Python error/traceback string to extract diagnostic information. + * Parses Python error/traceback string into a single Diagnostic with multiple frames. * * @param traceback - The traceback string or error message from Python * @param homePrefix - The virtual home directory prefix to strip (default: "/home/pyodide/") - * @returns Array of Diagnostic objects + * @returns Array of Diagnostic objects (at most 1 per error) */ export function parsePythonTraceback( traceback: string, @@ -17,7 +17,7 @@ export function parsePythonTraceback( if (lines.length === 0) return []; // Extract the last error message line (e.g., "Exception: This is a test error" or "SyntaxError: ...") - let errorMessage = lines[lines.length - 1].trim(); + let errorMessage = ""; for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i].trim(); if (line && !line.startsWith("^") && !line.startsWith("File \"") && !line.startsWith("Traceback")) { @@ -26,7 +26,7 @@ export function parsePythonTraceback( } } - const diagnostics: Diagnostic[] = []; + const frames: DiagnosticFrame[] = []; const fileLineRegex = /File "([^"]+)", line (\d+)(?:, in (.+))?/; for (let i = 0; i < lines.length; i++) { @@ -65,17 +65,24 @@ export function parsePythonTraceback( } } - diagnostics.push({ + frames.push({ filename: rawFilename, startLineNumber: lineNum, startColumn, endLineNumber: lineNum, endColumn, - message: errorMessage, - severity: "error", }); } } - return diagnostics; + if (frames.length === 0) return []; + + // Return a single Diagnostic with all frames (1 error = 1 Diagnostic) + return [ + { + frames, + message: errorMessage, + severity: "error", + }, + ]; } diff --git a/packages/runtime/src/diagnostics/ruby.ts b/packages/runtime/src/diagnostics/ruby.ts index 1333666c..496d7563 100644 --- a/packages/runtime/src/diagnostics/ruby.ts +++ b/packages/runtime/src/diagnostics/ruby.ts @@ -1,10 +1,10 @@ -import { Diagnostic } from "../interface"; +import { Diagnostic, DiagnosticFrame } from "../interface"; /** - * Parses Ruby error/traceback string to extract diagnostic information. + * Parses Ruby error/traceback string into a single Diagnostic with multiple frames. * * @param errorMessage - The error message from Ruby VM - * @returns Array of Diagnostic objects + * @returns Array of Diagnostic objects (at most 1 per error) */ export function parseRubyError(errorMessage: string): Diagnostic[] { if (!errorMessage) return []; @@ -12,13 +12,13 @@ export function parseRubyError(errorMessage: string): Diagnostic[] { const lines = errorMessage.trim().split("\n"); if (lines.length === 0) return []; - const diagnostics: Diagnostic[] = []; + const frames: DiagnosticFrame[] = []; // Matches formats like: // "test_error.rb:1:in '
': This is a test error (RuntimeError)" // "/test_error.rb:2:in 'bar': This is a test error (RuntimeError)" // "test_syntax.rb:1: syntax error, unexpected end-of-input, expecting '}'" - // " from /test_error.rb:5:in 'foo'" + // "\tfrom /test_error.rb:5:in 'foo'" const primaryErrorRegex = /^(\/?[^:\n\t]+):(\d+)(?::in [`']([^']+)['])?: (.*)$/; const stackFromRegex = /^\s*from (\/?[^:\n\t]+):(\d+)(?::in [`']([^']+)['])?/; @@ -51,12 +51,10 @@ export function parseRubyError(errorMessage: string): Diagnostic[] { continue; } - diagnostics.push({ + frames.push({ filename: rawFilename, startLineNumber: lineNum, endLineNumber: lineNum, - message, - severity: "error", }); continue; } @@ -74,15 +72,22 @@ export function parseRubyError(errorMessage: string): Diagnostic[] { continue; } - diagnostics.push({ + frames.push({ filename: rawFilename, startLineNumber: lineNum, endLineNumber: lineNum, - message: mainErrorMsg || line, - severity: "error", }); } } - return diagnostics; + if (frames.length === 0) return []; + + // Return a single Diagnostic with all frames (1 error = 1 Diagnostic) + return [ + { + frames, + message: mainErrorMsg || errorMessage, + severity: "error", + }, + ]; } diff --git a/packages/runtime/src/interface.ts b/packages/runtime/src/interface.ts index fe41d063..948b4617 100644 --- a/packages/runtime/src/interface.ts +++ b/packages/runtime/src/interface.ts @@ -155,12 +155,25 @@ export type RuntimeErrorHandler = (error: unknown) => void; export const DiagnosticSeveritySchema = z.enum(["error", "warning", "info"]); export type DiagnosticSeverity = z.output; -export const DiagnosticSchema = z.object({ +/** + * エラーや警告の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"), }); diff --git a/packages/runtime/src/typescript/runtime.tsx b/packages/runtime/src/typescript/runtime.tsx index c1106b3d..91875b36 100644 --- a/packages/runtime/src/typescript/runtime.tsx +++ b/packages/runtime/src/typescript/runtime.tsx @@ -22,6 +22,7 @@ import { UpdatedFile, } from "../interface"; + export const compilerOptions: CompilerOptions = { lib: ["ESNext", "WebWorker"], target: 10 satisfies ScriptTarget.ES2023, @@ -170,16 +171,21 @@ export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { ); return { - filename, - startLineNumber: line + 1, - startColumn: character + 1, - endLineNumber, - endColumn, + frames: [ + { + filename, + startLineNumber: line + 1, + startColumn: character + 1, + endLineNumber, + endColumn, + }, + ], message, severity, }; }; + for (const diagnostic of tsEnv.languageService.getSyntacticDiagnostics( filenames[0] )) { diff --git a/packages/runtime/tests/fileExecution.ts b/packages/runtime/tests/fileExecution.ts index a2cc57bb..24d2de5c 100644 --- a/packages/runtime/tests/fileExecution.ts +++ b/packages/runtime/tests/fileExecution.ts @@ -171,20 +171,28 @@ export const fileExecutionTests: Record< }; }, + /** + * 単純なエラーで診断情報が得られるかテスト + * + * Python/Ruby: `raise "UniqueError"` で1件のDiagnosticが返り、 + * frames[0].filename・startLineNumber・messageが正しいか確認 + * + * TypeScript: 存在しない型名を使うことでエラーメッセージに型名が含まれるようにする + * 例: `const x: TestDiagUniqueType9876 = 1;` + * → TSのエラーメッセージに "TestDiagUniqueType9876" が含まれる + */ "should capture diagnostics on error": (lang) => { - const errorMsg = "This is a test error"; - const [filename, code, expectedLine] = ( + // TypeScript用: 型名をユニークな識別子にしてエラーメッセージに含める + const uniqueTypeName = "TestDiagUniqueType9876"; + const [filename, code] = ( { - python: ["test_error.py", `raise Exception("${errorMsg}")\n`, 1], - ruby: ["test_error.rb", `raise "${errorMsg}"\n`, 1], - cpp: [null, null, null], - rust: [null, null, null], - javascript: [null, null, null], - typescript: ["test_error.ts", `const x: number = "${errorMsg}";\n`, 1], - } satisfies Record< - RuntimeLang, - [string, string, number] | [null, null, null] - > + python: ["test_diag.py", `raise Exception("${uniqueTypeName}")\n`], + ruby: ["test_diag.rb", `raise "${uniqueTypeName}"\n`], + cpp: [null, null], + rust: [null, null], + javascript: [null, null], + typescript: ["test_diag.ts", `const x: ${uniqueTypeName} = 1;\n`], + } satisfies Record )[lang]; if (!filename || !code) return null; @@ -200,12 +208,77 @@ export const fileExecutionTests: Record< diagnostics.push(diagnostic); } ); - console.log(`${lang} single file diagnostic test: `, diagnostics); + console.log(`${lang} single file diagnostic test: `, JSON.stringify(diagnostics, null, 2)); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(diagnostics, "diagnostics should not be empty").to.not.be.empty; + // 最初のDiagnosticの主要フレームが正しいファイル・行・メッセージを持つか確認 + const firstDiag = diagnostics[0]; // eslint-disable-next-line @typescript-eslint/no-unused-expressions - expect(diagnostics).to.not.be.empty; - expect(diagnostics[0].filename).to.equal(filename); - expect(diagnostics[0].startLineNumber).to.equal(expectedLine); - expect(diagnostics[0].message).to.include(errorMsg); + 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.message, "error message").to.include(uniqueTypeName); + }; + }, + + /** + * 関数呼び出しを挟んだ複数フレームのエラーで1つのDiagnosticにまとめられるかテスト + * + * Python/Ruby: 関数呼び出し連鎖でスタックトレースを生成し、 + * - 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: [null, null], + rust: [null, null], + javascript: [null, null], + typescript: [null, null], + } satisfies Record + )[lang]; + if (!filename || !code) return null; + + return async (runtimeRef) => { + const diagnostics: Diagnostic[] = []; + await runtimeRef.current![lang].runFiles( + [filename], + { [filename]: code }, + () => {}, + (diagnostic) => { + diagnostics.push(diagnostic); + } + ); + 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); + + // , など内部フレームが含まれないこと + 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); + } }; }, }; diff --git a/tests/diagnostics.test.ts b/tests/diagnostics.test.ts deleted file mode 100644 index 582f26dc..00000000 --- a/tests/diagnostics.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { parsePythonTraceback } from "../packages/runtime/src/diagnostics/python"; -import { parseRubyError } from "../packages/runtime/src/diagnostics/ruby"; - -describe("Diagnostics parser tests", () => { - describe("Python Traceback parser", () => { - it("should parse simple Python traceback", () => { - const tb = `Traceback (most recent call last): - File "/home/pyodide/test_error.py", line 1, in - raise Exception("This is a test error") -Exception: This is a test error`; - - const diagnostics = parsePythonTraceback(tb, "/home/pyodide/"); - assert.equal(diagnostics.length, 1); - assert.equal(diagnostics[0].filename, "test_error.py"); - assert.equal(diagnostics[0].startLineNumber, 1); - assert.equal(diagnostics[0].message, "Exception: This is a test error"); - assert.equal(diagnostics[0].severity, "error"); - }); - - it("should parse multi-frame Python traceback", () => { - const tb = `Traceback (most recent call last): - File "/home/pyodide/main.py", line 5, in - helper() - File "/home/pyodide/helper.py", line 2, in helper - raise ValueError("invalid value") -ValueError: invalid value`; - - const diagnostics = parsePythonTraceback(tb, "/home/pyodide/"); - assert.equal(diagnostics.length, 2); - assert.equal(diagnostics[0].filename, "main.py"); - assert.equal(diagnostics[0].startLineNumber, 5); - assert.equal(diagnostics[0].message, "ValueError: invalid value"); - - assert.equal(diagnostics[1].filename, "helper.py"); - assert.equal(diagnostics[1].startLineNumber, 2); - assert.equal(diagnostics[1].message, "ValueError: invalid value"); - }); - - it("should parse Python SyntaxError with column indicator", () => { - const tb = ` File "/home/pyodide/syntax.py", line 3 - def foo( - ^ -SyntaxError: '(' was never closed`; - - const diagnostics = parsePythonTraceback(tb, "/home/pyodide/"); - assert.equal(diagnostics.length, 1); - assert.equal(diagnostics[0].filename, "syntax.py"); - assert.equal(diagnostics[0].startLineNumber, 3); - assert.equal(diagnostics[0].startColumn, 12); - assert.equal(diagnostics[0].message, "SyntaxError: '(' was never closed"); - }); - - it("should ignore and internal frames", () => { - const tb = `Traceback (most recent call last): - File "", line 1, in - File "/home/pyodide/app.py", line 10, in run - 1 / 0 -ZeroDivisionError: division by zero`; - - const diagnostics = parsePythonTraceback(tb, "/home/pyodide/"); - assert.equal(diagnostics.length, 1); - assert.equal(diagnostics[0].filename, "app.py"); - assert.equal(diagnostics[0].startLineNumber, 10); - }); - - it("should handle empty or null input gracefully", () => { - assert.deepEqual(parsePythonTraceback(""), []); - }); - }); - - describe("Ruby Error parser", () => { - it("should parse simple Ruby runtime error", () => { - const err = `test_error.rb:1:in '
': This is a test error (RuntimeError)`; - - const diagnostics = parseRubyError(err); - assert.equal(diagnostics.length, 1); - assert.equal(diagnostics[0].filename, "test_error.rb"); - assert.equal(diagnostics[0].startLineNumber, 1); - assert.equal(diagnostics[0].message, "This is a test error (RuntimeError)"); - assert.equal(diagnostics[0].severity, "error"); - }); - - it("should parse Ruby error with virtual filesystem slash", () => { - const err = `/test_error.rb:4:in 'bar': undefined local variable or method 'baz' (NameError)`; - - const diagnostics = parseRubyError(err); - assert.equal(diagnostics.length, 1); - assert.equal(diagnostics[0].filename, "test_error.rb"); - assert.equal(diagnostics[0].startLineNumber, 4); - assert.equal( - diagnostics[0].message, - "undefined local variable or method 'baz' (NameError)" - ); - }); - - it("should parse Ruby stack trace with from lines", () => { - const err = `/sub.rb:2:in 'bar': Something went wrong (RuntimeError) -\tfrom /main.rb:5:in 'foo' -\tfrom /main.rb:8:in '
'`; - - const diagnostics = parseRubyError(err); - assert.equal(diagnostics.length, 3); - assert.equal(diagnostics[0].filename, "sub.rb"); - assert.equal(diagnostics[0].startLineNumber, 2); - assert.equal(diagnostics[0].message, "Something went wrong (RuntimeError)"); - - assert.equal(diagnostics[1].filename, "main.rb"); - assert.equal(diagnostics[1].startLineNumber, 5); - - assert.equal(diagnostics[2].filename, "main.rb"); - assert.equal(diagnostics[2].startLineNumber, 8); - }); - - it("should parse Ruby SyntaxError", () => { - const err = `test_syntax.rb:2: syntax error, unexpected end-of-input, expecting '}'`; - - const diagnostics = parseRubyError(err); - assert.equal(diagnostics.length, 1); - assert.equal(diagnostics[0].filename, "test_syntax.rb"); - assert.equal(diagnostics[0].startLineNumber, 2); - assert.equal( - diagnostics[0].message, - "syntax error, unexpected end-of-input, expecting '}'" - ); - }); - - it("should ignore internal eval lines", () => { - const err = `-e:in 'Kernel.eval' -eval:1:in '
' -/app.rb:3:in 'run': error (StandardError)`; - - const diagnostics = parseRubyError(err); - assert.equal(diagnostics.length, 1); - assert.equal(diagnostics[0].filename, "app.rb"); - assert.equal(diagnostics[0].startLineNumber, 3); - }); - - it("should handle empty input gracefully", () => { - assert.deepEqual(parseRubyError(""), []); - }); - }); -}); From d039d6167a5236ede57454520689cbb7fdf6f3fd Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:21:37 +0000 Subject: [PATCH 03/19] =?UTF-8?q?python=E3=81=AE=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/runtime/src/diagnostics/python.ts | 5 +++-- packages/runtime/src/worker/pyodide.worker.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/diagnostics/python.ts b/packages/runtime/src/diagnostics/python.ts index 6805b645..8ecaa601 100644 --- a/packages/runtime/src/diagnostics/python.ts +++ b/packages/runtime/src/diagnostics/python.ts @@ -38,8 +38,9 @@ export function parsePythonTraceback( // Normalize filename by removing homePrefix or leading slashes if (rawFilename.startsWith(homePrefix)) { rawFilename = rawFilename.slice(homePrefix.length); - } else if (rawFilename.startsWith("/")) { - rawFilename = rawFilename.slice(1); + } + if (rawFilename.startsWith("/")) { + rawFilename = rawFilename.replace(/^\/+/, ""); } // Ignore internal names like , if not matching normal files diff --git a/packages/runtime/src/worker/pyodide.worker.ts b/packages/runtime/src/worker/pyodide.worker.ts index b18c0898..864fe511 100644 --- a/packages/runtime/src/worker/pyodide.worker.ts +++ b/packages/runtime/src/worker/pyodide.worker.ts @@ -13,7 +13,7 @@ import { parsePythonTraceback } from "../diagnostics/python"; import execfile_py from "./pyodide/execfile.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[] = []; From cfa2490c19c7041880af51d22544ce0b9d11c2bb Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:03:17 +0000 Subject: [PATCH 04/19] =?UTF-8?q?ruby=E3=81=AE=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/runtime/src/diagnostics/ruby.ts | 65 +++++++++++++----------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/packages/runtime/src/diagnostics/ruby.ts b/packages/runtime/src/diagnostics/ruby.ts index 496d7563..f4d5ef2f 100644 --- a/packages/runtime/src/diagnostics/ruby.ts +++ b/packages/runtime/src/diagnostics/ruby.ts @@ -19,8 +19,8 @@ export function parseRubyError(errorMessage: string): Diagnostic[] { // "/test_error.rb:2:in 'bar': This is a test error (RuntimeError)" // "test_syntax.rb:1: syntax error, unexpected end-of-input, expecting '}'" // "\tfrom /test_error.rb:5:in 'foo'" - const primaryErrorRegex = /^(\/?[^:\n\t]+):(\d+)(?::in [`']([^']+)['])?: (.*)$/; - const stackFromRegex = /^\s*from (\/?[^:\n\t]+):(\d+)(?::in [`']([^']+)['])?/; + // "test_multiframe.rb:6:in 'bar'" + const stackLineRegex = /^\s*(?:from\s+)?(\/?[^:\n\t]+):(\d+)(?::in [`']([^']+)['])?(?::\s*(.*))?$/; let mainErrorMsg = ""; @@ -33,49 +33,56 @@ export function parseRubyError(errorMessage: string): Diagnostic[] { continue; } - const primaryMatch = primaryErrorRegex.exec(line); - if (primaryMatch) { - let rawFilename = primaryMatch[1]; - const lineNum = parseInt(primaryMatch[2], 10); - const message = primaryMatch[4]; + const match = stackLineRegex.exec(line); + if (match) { + let rawFilename = match[1]; + const lineNum = parseInt(match[2], 10); + const message = match[4]; - if (!mainErrorMsg) { + if (message && !mainErrorMsg) { mainErrorMsg = message; } if (rawFilename.startsWith("/")) { - rawFilename = rawFilename.slice(1); + rawFilename = rawFilename.replace(/^\/+/, ""); } - if (rawFilename === "eval" || rawFilename === "-e" || rawFilename.startsWith("(eval)")) { + if ( + rawFilename === "eval" || + rawFilename === "eval_async" || + rawFilename.startsWith("eval_async") || + rawFilename === "-e" || + rawFilename.startsWith("(eval)") || + rawFilename.startsWith("bundle/") || + rawFilename.includes("/bundle/") || + (rawFilename.startsWith("<") && rawFilename.endsWith(">")) + ) { continue; } - frames.push({ - filename: rawFilename, - startLineNumber: lineNum, - endLineNumber: lineNum, - }); - continue; - } - - const fromMatch = stackFromRegex.exec(line); - if (fromMatch) { - let rawFilename = fromMatch[1]; - const lineNum = parseInt(fromMatch[2], 10); - - if (rawFilename.startsWith("/")) { - rawFilename = rawFilename.slice(1); - } - - if (rawFilename === "eval" || rawFilename === "-e" || rawFilename.startsWith("(eval)")) { - continue; + // Check if there is a column indicator on subsequent lines (e.g. for Ruby 3.1+ error highlight with ^) + let startColumn: number | undefined = undefined; + let endColumn: number | undefined = undefined; + for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) { + const nextLine = lines[j]; + if (stackLineRegex.test(nextLine)) break; + const caretIndex = nextLine.indexOf("^"); + if (caretIndex !== -1) { + startColumn = caretIndex + 1; + const caretEnd = nextLine.lastIndexOf("^"); + if (caretEnd > caretIndex) { + endColumn = caretEnd + 2; + } + break; + } } frames.push({ filename: rawFilename, startLineNumber: lineNum, + startColumn, endLineNumber: lineNum, + endColumn, }); } } From f44c535fb9656180bbb1d65026ac6302e3db653a Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:48:35 +0900 Subject: [PATCH 05/19] =?UTF-8?q?diagnostic=E3=83=87=E3=82=A3=E3=83=AC?= =?UTF-8?q?=E3=82=AF=E3=83=88=E3=83=AA=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/runtime/src/diagnostics/index.ts | 2 - packages/runtime/src/diagnostics/python.ts | 89 --------------- packages/runtime/src/diagnostics/ruby.ts | 100 ----------------- packages/runtime/src/worker/pyodide.worker.ts | 99 ++++++++++++++++- packages/runtime/src/worker/ruby.worker.ts | 102 +++++++++++++++++- 5 files changed, 196 insertions(+), 196 deletions(-) delete mode 100644 packages/runtime/src/diagnostics/index.ts delete mode 100644 packages/runtime/src/diagnostics/python.ts delete mode 100644 packages/runtime/src/diagnostics/ruby.ts diff --git a/packages/runtime/src/diagnostics/index.ts b/packages/runtime/src/diagnostics/index.ts deleted file mode 100644 index 4612acb2..00000000 --- a/packages/runtime/src/diagnostics/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./python"; -export * from "./ruby"; diff --git a/packages/runtime/src/diagnostics/python.ts b/packages/runtime/src/diagnostics/python.ts deleted file mode 100644 index 8ecaa601..00000000 --- a/packages/runtime/src/diagnostics/python.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Diagnostic, DiagnosticFrame } from "../interface"; - -/** - * Parses Python error/traceback string into a single Diagnostic with multiple frames. - * - * @param traceback - The traceback string or error message from Python - * @param homePrefix - The virtual home directory prefix to strip (default: "/home/pyodide/") - * @returns Array of Diagnostic objects (at most 1 per error) - */ -export function parsePythonTraceback( - traceback: string, - homePrefix: string = "/home/pyodide/" -): Diagnostic[] { - if (!traceback) return []; - - const lines = traceback.trim().split("\n"); - if (lines.length === 0) return []; - - // Extract the last error message line (e.g., "Exception: This is a test error" or "SyntaxError: ...") - let errorMessage = ""; - for (let i = lines.length - 1; i >= 0; i--) { - const line = lines[i].trim(); - if (line && !line.startsWith("^") && !line.startsWith("File \"") && !line.startsWith("Traceback")) { - errorMessage = line; - break; - } - } - - const frames: DiagnosticFrame[] = []; - const fileLineRegex = /File "([^"]+)", line (\d+)(?:, in (.+))?/; - - for (let i = 0; i < lines.length; i++) { - const match = fileLineRegex.exec(lines[i]); - if (match) { - let rawFilename = match[1]; - const lineNum = parseInt(match[2], 10); - - // Normalize filename by removing homePrefix or leading slashes - if (rawFilename.startsWith(homePrefix)) { - rawFilename = rawFilename.slice(homePrefix.length); - } - if (rawFilename.startsWith("/")) { - rawFilename = rawFilename.replace(/^\/+/, ""); - } - - // Ignore internal names like , if not matching normal files - if (rawFilename === "" || rawFilename === "") { - continue; - } - - // Check if there is a column indicator on subsequent lines (e.g. for SyntaxError with ^) - let startColumn: number | undefined = undefined; - let endColumn: number | undefined = undefined; - for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) { - const nextLine = lines[j]; - if (fileLineRegex.test(nextLine)) break; - const caretIndex = nextLine.indexOf("^"); - if (caretIndex !== -1) { - // In Python SyntaxError output, caret points to character (1-indexed) - startColumn = caretIndex + 1; - const caretEnd = nextLine.lastIndexOf("^"); - if (caretEnd > caretIndex) { - endColumn = caretEnd + 2; - } - break; - } - } - - frames.push({ - filename: rawFilename, - startLineNumber: lineNum, - startColumn, - endLineNumber: lineNum, - endColumn, - }); - } - } - - if (frames.length === 0) return []; - - // Return a single Diagnostic with all frames (1 error = 1 Diagnostic) - return [ - { - frames, - message: errorMessage, - severity: "error", - }, - ]; -} diff --git a/packages/runtime/src/diagnostics/ruby.ts b/packages/runtime/src/diagnostics/ruby.ts deleted file mode 100644 index f4d5ef2f..00000000 --- a/packages/runtime/src/diagnostics/ruby.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Diagnostic, DiagnosticFrame } from "../interface"; - -/** - * Parses Ruby error/traceback string into a single Diagnostic with multiple frames. - * - * @param errorMessage - The error message from Ruby VM - * @returns Array of Diagnostic objects (at most 1 per error) - */ -export function parseRubyError(errorMessage: string): Diagnostic[] { - if (!errorMessage) return []; - - const lines = errorMessage.trim().split("\n"); - if (lines.length === 0) return []; - - const frames: DiagnosticFrame[] = []; - - // Matches formats like: - // "test_error.rb:1:in '
': This is a test error (RuntimeError)" - // "/test_error.rb:2:in 'bar': This is a test error (RuntimeError)" - // "test_syntax.rb:1: syntax error, unexpected end-of-input, expecting '}'" - // "\tfrom /test_error.rb:5:in 'foo'" - // "test_multiframe.rb:6:in 'bar'" - const stackLineRegex = /^\s*(?:from\s+)?(\/?[^:\n\t]+):(\d+)(?::in [`']([^']+)['])?(?::\s*(.*))?$/; - - let mainErrorMsg = ""; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (!line) continue; - - // Skip internal evaluation files - if (line.includes("-e:in 'Kernel.eval'") || line.startsWith("eval:1:in") || line.startsWith("(eval)")) { - continue; - } - - const match = stackLineRegex.exec(line); - if (match) { - let rawFilename = match[1]; - const lineNum = parseInt(match[2], 10); - const message = match[4]; - - if (message && !mainErrorMsg) { - mainErrorMsg = message; - } - - if (rawFilename.startsWith("/")) { - rawFilename = rawFilename.replace(/^\/+/, ""); - } - - if ( - rawFilename === "eval" || - rawFilename === "eval_async" || - rawFilename.startsWith("eval_async") || - rawFilename === "-e" || - rawFilename.startsWith("(eval)") || - rawFilename.startsWith("bundle/") || - rawFilename.includes("/bundle/") || - (rawFilename.startsWith("<") && rawFilename.endsWith(">")) - ) { - continue; - } - - // Check if there is a column indicator on subsequent lines (e.g. for Ruby 3.1+ error highlight with ^) - let startColumn: number | undefined = undefined; - let endColumn: number | undefined = undefined; - for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) { - const nextLine = lines[j]; - if (stackLineRegex.test(nextLine)) break; - const caretIndex = nextLine.indexOf("^"); - if (caretIndex !== -1) { - startColumn = caretIndex + 1; - const caretEnd = nextLine.lastIndexOf("^"); - if (caretEnd > caretIndex) { - endColumn = caretEnd + 2; - } - break; - } - } - - frames.push({ - filename: rawFilename, - startLineNumber: lineNum, - startColumn, - endLineNumber: lineNum, - endColumn, - }); - } - } - - if (frames.length === 0) return []; - - // Return a single Diagnostic with all frames (1 error = 1 Diagnostic) - return [ - { - frames, - message: mainErrorMsg || errorMessage, - severity: "error", - }, - ]; -} diff --git a/packages/runtime/src/worker/pyodide.worker.ts b/packages/runtime/src/worker/pyodide.worker.ts index 864fe511..e7bea1ea 100644 --- a/packages/runtime/src/worker/pyodide.worker.ts +++ b/packages/runtime/src/worker/pyodide.worker.ts @@ -7,8 +7,12 @@ 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 { Diagnostic, ReplOutput, UpdatedFile } from "../interface"; -import { parsePythonTraceback } from "../diagnostics/python"; +import type { + Diagnostic, + DiagnosticFrame, + ReplOutput, + UpdatedFile, +} from "../interface"; import execfile_py from "./pyodide/execfile.py?raw"; import check_syntax_py from "./pyodide/check_syntax.py?raw"; @@ -176,7 +180,7 @@ async function runFile( .trim(), }); if (onDiagnostic) { - const diagnostics = parsePythonTraceback(e.message, HOME); + const diagnostics = parsePythonTraceback(e.message); for (const diag of diagnostics) { await onDiagnostic(diag); } @@ -227,6 +231,95 @@ async function restoreState(): Promise { throw new Error("not implemented"); } +/** + * Parses Python error/traceback string into a single Diagnostic with multiple frames. + * + * @param traceback - The traceback string or error message from Python + * @returns Array of Diagnostic objects (at most 1 per error) + */ +function parsePythonTraceback(traceback: string): Diagnostic[] { + if (!traceback) return []; + + const lines = traceback.trim().split("\n"); + if (lines.length === 0) return []; + + // Extract the last error message line (e.g., "Exception: This is a test error" or "SyntaxError: ...") + let errorMessage = ""; + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if ( + line && + !line.startsWith("^") && + !line.startsWith('File "') && + !line.startsWith("Traceback") + ) { + errorMessage = line; + break; + } + } + + const frames: DiagnosticFrame[] = []; + const fileLineRegex = /File "([^"]+)", line (\d+)(?:, in (.+))?/; + + for (let i = 0; i < lines.length; i++) { + const match = fileLineRegex.exec(lines[i]); + if (match) { + let rawFilename = match[1]; + const lineNum = parseInt(match[2], 10); + + // Normalize filename by removing homePrefix or leading slashes + if (rawFilename.startsWith(HOME)) { + rawFilename = rawFilename.slice(HOME.length); + } + if (rawFilename.startsWith("/")) { + rawFilename = rawFilename.replace(/^\/+/, ""); + } + + // Ignore internal names like , if not matching normal files + if (rawFilename === "" || rawFilename === "") { + continue; + } + + // Check if there is a column indicator on subsequent lines (e.g. for SyntaxError with ^) + let startColumn: number | undefined = undefined; + let endColumn: number | undefined = undefined; + for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) { + const nextLine = lines[j]; + if (fileLineRegex.test(nextLine)) break; + const caretIndex = nextLine.indexOf("^"); + if (caretIndex !== -1) { + // In Python SyntaxError output, caret points to character (1-indexed) + startColumn = caretIndex + 1; + const caretEnd = nextLine.lastIndexOf("^"); + if (caretEnd > caretIndex) { + endColumn = caretEnd + 2; + } + break; + } + } + + frames.push({ + filename: rawFilename, + startLineNumber: lineNum, + startColumn, + endLineNumber: lineNum, + endColumn, + }); + } + } + + if (frames.length === 0) return []; + + // Return a single Diagnostic with all frames (1 error = 1 Diagnostic) + return [ + { + frames, + message: errorMessage, + severity: "error", + }, + ]; +} + const api: WorkerAPI = { init, runCode, diff --git a/packages/runtime/src/worker/ruby.worker.ts b/packages/runtime/src/worker/ruby.worker.ts index cd0727b1..f9abd1bc 100644 --- a/packages/runtime/src/worker/ruby.worker.ts +++ b/packages/runtime/src/worker/ruby.worker.ts @@ -5,8 +5,7 @@ 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 { Diagnostic, ReplOutput, ReplOutputType, UpdatedFile } from "../interface"; -import { parseRubyError } from "../diagnostics/ruby"; +import type { Diagnostic, DiagnosticFrame, ReplOutput, ReplOutputType, UpdatedFile } from "../interface"; import init_rb from "./ruby/init.rb?raw"; @@ -305,6 +304,105 @@ async function restoreState(commands: string[]): Promise { return {}; } +/** + * Parses Ruby error/traceback string into a single Diagnostic with multiple frames. + * + * @param errorMessage - The error message from Ruby VM + * @returns Array of Diagnostic objects (at most 1 per error) + */ +function parseRubyError(errorMessage: string): Diagnostic[] { + if (!errorMessage) return []; + + const lines = errorMessage.trim().split("\n"); + if (lines.length === 0) return []; + + const frames: DiagnosticFrame[] = []; + + // Matches formats like: + // "test_error.rb:1:in '
': This is a test error (RuntimeError)" + // "/test_error.rb:2:in 'bar': This is a test error (RuntimeError)" + // "test_syntax.rb:1: syntax error, unexpected end-of-input, expecting '}'" + // "\tfrom /test_error.rb:5:in 'foo'" + // "test_multiframe.rb:6:in 'bar'" + const stackLineRegex = /^\s*(?:from\s+)?(\/?[^:\n\t]+):(\d+)(?::in [`']([^']+)['])?(?::\s*(.*))?$/; + + let mainErrorMsg = ""; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + + // Skip internal evaluation files + if (line.includes("-e:in 'Kernel.eval'") || line.startsWith("eval:1:in") || line.startsWith("(eval)")) { + continue; + } + + const match = stackLineRegex.exec(line); + if (match) { + let rawFilename = match[1]; + const lineNum = parseInt(match[2], 10); + const message = match[4]; + + if (message && !mainErrorMsg) { + mainErrorMsg = message; + } + + if (rawFilename.startsWith("/")) { + rawFilename = rawFilename.replace(/^\/+/, ""); + } + + if ( + rawFilename === "eval" || + rawFilename === "eval_async" || + rawFilename.startsWith("eval_async") || + rawFilename === "-e" || + rawFilename.startsWith("(eval)") || + rawFilename.startsWith("bundle/") || + rawFilename.includes("/bundle/") || + (rawFilename.startsWith("<") && rawFilename.endsWith(">")) + ) { + continue; + } + + // Check if there is a column indicator on subsequent lines (e.g. for Ruby 3.1+ error highlight with ^) + let startColumn: number | undefined = undefined; + let endColumn: number | undefined = undefined; + for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) { + const nextLine = lines[j]; + if (stackLineRegex.test(nextLine)) break; + const caretIndex = nextLine.indexOf("^"); + if (caretIndex !== -1) { + startColumn = caretIndex + 1; + const caretEnd = nextLine.lastIndexOf("^"); + if (caretEnd > caretIndex) { + endColumn = caretEnd + 2; + } + break; + } + } + + frames.push({ + filename: rawFilename, + startLineNumber: lineNum, + startColumn, + endLineNumber: lineNum, + endColumn, + }); + } + } + + if (frames.length === 0) return []; + + // Return a single Diagnostic with all frames (1 error = 1 Diagnostic) + return [ + { + frames, + message: mainErrorMsg || errorMessage, + severity: "error", + }, + ]; +} + const api: WorkerAPI = { init, runCode, From 763d5f88563a4b8a468e9978e0ef6a179497c801 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:23:36 +0000 Subject: [PATCH 06/19] =?UTF-8?q?python,ruby=E3=81=AE=E3=82=A8=E3=83=A9?= =?UTF-8?q?=E3=83=BC=E3=82=92=E6=A7=8B=E9=80=A0=E5=8C=96=E3=81=97=E3=81=9F?= =?UTF-8?q?=E3=82=AA=E3=83=96=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E3=81=A8?= =?UTF-8?q?=E3=81=97=E3=81=A6=E8=BF=94=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/runtime/src/worker/pyodide.worker.ts | 191 ++++-------------- .../runtime/src/worker/pyodide/eval_code.py | 36 ++++ .../runtime/src/worker/pyodide/execfile.py | 90 ++++++++- packages/runtime/src/worker/ruby.worker.ts | 191 ++++-------------- packages/runtime/src/worker/ruby/eval_code.rb | 36 ++++ packages/runtime/src/worker/ruby/execfile.rb | 64 ++++++ 6 files changed, 301 insertions(+), 307 deletions(-) create mode 100644 packages/runtime/src/worker/pyodide/eval_code.py create mode 100644 packages/runtime/src/worker/ruby/eval_code.rb create mode 100644 packages/runtime/src/worker/ruby/execfile.rb diff --git a/packages/runtime/src/worker/pyodide.worker.ts b/packages/runtime/src/worker/pyodide.worker.ts index e7bea1ea..151aeece 100644 --- a/packages/runtime/src/worker/pyodide.worker.ts +++ b/packages/runtime/src/worker/pyodide.worker.ts @@ -9,12 +9,12 @@ import type { PyCallable } from "pyodide/ffi"; import type { WorkerAPI, WorkerCapabilities } from "./runtime"; import type { Diagnostic, - DiagnosticFrame, 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`; @@ -92,44 +92,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(); @@ -158,45 +146,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(), - }); - if (onDiagnostic) { - const diagnostics = parsePythonTraceback(e.message); - for (const diag of diagnostics) { - await onDiagnostic(diag); - } - } - } 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(); @@ -231,95 +201,6 @@ async function restoreState(): Promise { throw new Error("not implemented"); } -/** - * Parses Python error/traceback string into a single Diagnostic with multiple frames. - * - * @param traceback - The traceback string or error message from Python - * @returns Array of Diagnostic objects (at most 1 per error) - */ -function parsePythonTraceback(traceback: string): Diagnostic[] { - if (!traceback) return []; - - const lines = traceback.trim().split("\n"); - if (lines.length === 0) return []; - - // Extract the last error message line (e.g., "Exception: This is a test error" or "SyntaxError: ...") - let errorMessage = ""; - for (let i = lines.length - 1; i >= 0; i--) { - const line = lines[i].trim(); - if ( - line && - !line.startsWith("^") && - !line.startsWith('File "') && - !line.startsWith("Traceback") - ) { - errorMessage = line; - break; - } - } - - const frames: DiagnosticFrame[] = []; - const fileLineRegex = /File "([^"]+)", line (\d+)(?:, in (.+))?/; - - for (let i = 0; i < lines.length; i++) { - const match = fileLineRegex.exec(lines[i]); - if (match) { - let rawFilename = match[1]; - const lineNum = parseInt(match[2], 10); - - // Normalize filename by removing homePrefix or leading slashes - if (rawFilename.startsWith(HOME)) { - rawFilename = rawFilename.slice(HOME.length); - } - if (rawFilename.startsWith("/")) { - rawFilename = rawFilename.replace(/^\/+/, ""); - } - - // Ignore internal names like , if not matching normal files - if (rawFilename === "" || rawFilename === "") { - continue; - } - - // Check if there is a column indicator on subsequent lines (e.g. for SyntaxError with ^) - let startColumn: number | undefined = undefined; - let endColumn: number | undefined = undefined; - for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) { - const nextLine = lines[j]; - if (fileLineRegex.test(nextLine)) break; - const caretIndex = nextLine.indexOf("^"); - if (caretIndex !== -1) { - // In Python SyntaxError output, caret points to character (1-indexed) - startColumn = caretIndex + 1; - const caretEnd = nextLine.lastIndexOf("^"); - if (caretEnd > caretIndex) { - endColumn = caretEnd + 2; - } - break; - } - } - - frames.push({ - filename: rawFilename, - startLineNumber: lineNum, - startColumn, - endLineNumber: lineNum, - endColumn, - }); - } - } - - if (frames.length === 0) return []; - - // Return a single Diagnostic with all frames (1 error = 1 Diagnostic) - return [ - { - frames, - message: errorMessage, - severity: "error", - }, - ]; -} - const api: WorkerAPI = { init, runCode, 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..e2fadf9a 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 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 f9abd1bc..778241e9 100644 --- a/packages/runtime/src/worker/ruby.worker.ts +++ b/packages/runtime/src/worker/ruby.worker.ts @@ -5,9 +5,11 @@ 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 { Diagnostic, DiagnosticFrame, 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: ((output: ReplOutput | UpdatedFile) => Promise) | null = null; @@ -61,6 +63,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) { @@ -84,29 +88,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 @@ -120,28 +101,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()}`, }); } @@ -177,32 +165,36 @@ 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()}`, }); - - if (!isFatal && onDiagnostic && e instanceof Error) { - const diagnostics = parseRubyError(e.message); - for (const diag of diagnostics) { - await onDiagnostic(diag); - } - } } const updatedFiles = readAllFiles(); @@ -290,7 +282,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); @@ -304,105 +296,6 @@ async function restoreState(commands: string[]): Promise { return {}; } -/** - * Parses Ruby error/traceback string into a single Diagnostic with multiple frames. - * - * @param errorMessage - The error message from Ruby VM - * @returns Array of Diagnostic objects (at most 1 per error) - */ -function parseRubyError(errorMessage: string): Diagnostic[] { - if (!errorMessage) return []; - - const lines = errorMessage.trim().split("\n"); - if (lines.length === 0) return []; - - const frames: DiagnosticFrame[] = []; - - // Matches formats like: - // "test_error.rb:1:in '
': This is a test error (RuntimeError)" - // "/test_error.rb:2:in 'bar': This is a test error (RuntimeError)" - // "test_syntax.rb:1: syntax error, unexpected end-of-input, expecting '}'" - // "\tfrom /test_error.rb:5:in 'foo'" - // "test_multiframe.rb:6:in 'bar'" - const stackLineRegex = /^\s*(?:from\s+)?(\/?[^:\n\t]+):(\d+)(?::in [`']([^']+)['])?(?::\s*(.*))?$/; - - let mainErrorMsg = ""; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (!line) continue; - - // Skip internal evaluation files - if (line.includes("-e:in 'Kernel.eval'") || line.startsWith("eval:1:in") || line.startsWith("(eval)")) { - continue; - } - - const match = stackLineRegex.exec(line); - if (match) { - let rawFilename = match[1]; - const lineNum = parseInt(match[2], 10); - const message = match[4]; - - if (message && !mainErrorMsg) { - mainErrorMsg = message; - } - - if (rawFilename.startsWith("/")) { - rawFilename = rawFilename.replace(/^\/+/, ""); - } - - if ( - rawFilename === "eval" || - rawFilename === "eval_async" || - rawFilename.startsWith("eval_async") || - rawFilename === "-e" || - rawFilename.startsWith("(eval)") || - rawFilename.startsWith("bundle/") || - rawFilename.includes("/bundle/") || - (rawFilename.startsWith("<") && rawFilename.endsWith(">")) - ) { - continue; - } - - // Check if there is a column indicator on subsequent lines (e.g. for Ruby 3.1+ error highlight with ^) - let startColumn: number | undefined = undefined; - let endColumn: number | undefined = undefined; - for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) { - const nextLine = lines[j]; - if (stackLineRegex.test(nextLine)) break; - const caretIndex = nextLine.indexOf("^"); - if (caretIndex !== -1) { - startColumn = caretIndex + 1; - const caretEnd = nextLine.lastIndexOf("^"); - if (caretEnd > caretIndex) { - endColumn = caretEnd + 2; - } - break; - } - } - - frames.push({ - filename: rawFilename, - startLineNumber: lineNum, - startColumn, - endLineNumber: lineNum, - endColumn, - }); - } - } - - if (frames.length === 0) return []; - - // Return a single Diagnostic with all frames (1 error = 1 Diagnostic) - return [ - { - frames, - message: mainErrorMsg || errorMessage, - severity: "error", - }, - ]; -} - const api: WorkerAPI = { init, runCode, 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 From bec2a8944f89a5a420f892f8852e9f1c32b33d88 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:58:27 +0000 Subject: [PATCH 07/19] =?UTF-8?q?frame=E3=81=AE=E9=A0=86=E5=BA=8F=E3=82=92?= =?UTF-8?q?=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../runtime/src/worker/pyodide/execfile.py | 2 +- packages/runtime/tests/fileExecution.ts | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/worker/pyodide/execfile.py b/packages/runtime/src/worker/pyodide/execfile.py index e2fadf9a..8c78bf61 100644 --- a/packages/runtime/src/worker/pyodide/execfile.py +++ b/packages/runtime/src/worker/pyodide/execfile.py @@ -39,7 +39,7 @@ def __execfile(filepath): else: tb = e.__traceback__ extracted = traceback.extract_tb(tb) - for entry in extracted: + for entry in reversed(extracted): raw_filename = entry.filename if raw_filename in ("", "") or (raw_filename.startswith("<") and raw_filename.endswith(">")): continue diff --git a/packages/runtime/tests/fileExecution.ts b/packages/runtime/tests/fileExecution.ts index 24d2de5c..333f16e3 100644 --- a/packages/runtime/tests/fileExecution.ts +++ b/packages/runtime/tests/fileExecution.ts @@ -274,6 +274,30 @@ export const fileExecutionTests: Record< // 複数フレームがあること 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: null, + rust: null, + javascript: null, + 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(/^<.*>$/); From 4b9c0801b121d01d129b2d47f3e780ba1ab12910 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:38:40 +0000 Subject: [PATCH 08/19] =?UTF-8?q?C++,Rust=E3=81=AEdiagnostic=E5=87=BA?= =?UTF-8?q?=E5=8A=9B=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/runtime/src/wandbox/cpp.ts | 140 +++++++++- packages/runtime/src/wandbox/runtime.tsx | 9 +- packages/runtime/src/wandbox/rust.ts | 155 ++++++++++- packages/runtime/tests/fileExecution.ts | 313 ++++++++++++++++++++--- 4 files changed, 549 insertions(+), 68 deletions(-) diff --git a/packages/runtime/src/wandbox/cpp.ts b/packages/runtime/src/wandbox/cpp.ts index 56adbe2a..52a7fbe9 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,90 @@ 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 = parseInt(ldMatch[2], 10); + 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 +222,28 @@ 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/") + ) { + const filename = m[1].replace(/^\.\//, ""); + if (filename !== "_stacktrace.cpp") { + 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 +252,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 30f4ee44..fe8d11c1 100644 --- a/packages/runtime/src/wandbox/runtime.tsx +++ b/packages/runtime/src/wandbox/runtime.tsx @@ -89,8 +89,7 @@ export function WandboxProvider({ children }: { children: ReactNode }) { filenames: string[], files: Readonly>, onOutput: (output: ReplOutput | UpdatedFile) => void, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _onDiagnostic?: (diagnostic: Diagnostic) => void + onDiagnostic?: (diagnostic: Diagnostic) => void ) => { if (!selectedCompiler) { onOutput({ type: "error", message: "Wandbox is not ready yet." }); @@ -103,7 +102,8 @@ export function WandboxProvider({ children }: { children: ReactNode }) { selectedCompiler.cpp, files, filenames, - onOutput + onOutput, + onDiagnostic ); break; case "rust": @@ -111,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..68010ffa 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,7 +42,8 @@ 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+:/; @@ -43,8 +53,15 @@ export async function rustRunFiles( // 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 +85,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 +141,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; } @@ -108,23 +206,52 @@ export async function rustRunFiles( 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 ( + fn !== "prog.rs" && + !fn.startsWith("/") && + !fn.startsWith("<") + ) { + 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/tests/fileExecution.ts b/packages/runtime/tests/fileExecution.ts index 380513af..3169fb1d 100644 --- a/packages/runtime/tests/fileExecution.ts +++ b/packages/runtime/tests/fileExecution.ts @@ -176,65 +176,255 @@ export const fileExecutionTests: Record< }, /** - * 単純なエラーで診断情報が得られるかテスト - * - * Python/Ruby: `raise "UniqueError"` で1件のDiagnosticが返り、 - * frames[0].filename・startLineNumber・messageが正しいか確認 - * - * TypeScript: 存在しない型名を使うことでエラーメッセージに型名が含まれるようにする - * 例: `const x: TestDiagUniqueType9876 = 1;` - * → TSのエラーメッセージに "TestDiagUniqueType9876" が含まれる + * 単一ファイルのコンパイルエラー(構文エラーや型エラー)で診断情報が得られるかテスト */ - "should capture diagnostics on error": (lang) => { - // TypeScript用: 型名をユニークな識別子にしてエラーメッセージに含める - const uniqueTypeName = "TestDiagUniqueType9876"; + "should capture diagnostics on compile error": (lang) => { + const uniqueTypeName = "TestCompileError1234"; const [filename, code] = ( { - python: ["test_diag.py", `raise Exception("${uniqueTypeName}")\n`], - ruby: ["test_diag.rb", `raise "${uniqueTypeName}"\n`], - cpp: [null, null], - rust: [null, null], - javascript: [null, null], - typescript: ["test_diag.ts", `const x: ${uniqueTypeName} = 1;\n`], - } satisfies Record - )[lang]; + 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: null, + typescript: ["test_compile.ts", `const x: ${uniqueTypeName} = 1;\n`], + } satisfies Record + )[lang] ?? [null, null]; if (!filename || !code) return null; return async (runtimeRef) => { const diagnostics: Diagnostic[] = []; await runtimeRef.current![lang].runFiles( [filename], - { - [filename]: code, - }, + { [filename]: code }, () => {}, (diagnostic) => { diagnostics.push(diagnostic); } ); console.log( - `${lang} single file diagnostic test: `, + `${lang} compile error diagnostic test: `, JSON.stringify(diagnostics, null, 2) ); - // eslint-disable-next-line @typescript-eslint/no-unused-expressions expect(diagnostics, "diagnostics should not be empty").to.not.be.empty; - // 最初のDiagnosticの主要フレームが正しいファイル・行・メッセージを持つか確認 const firstDiag = diagnostics[0]; - // eslint-disable-next-line @typescript-eslint/no-unused-expressions 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.message, "error message").to.include(uniqueTypeName); + 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 diagnostics: Diagnostic[] = []; + await runtimeRef.current![lang].runFiles( + execFiles, + codes, + () => {}, + (diagnostic) => { + diagnostics.push(diagnostic); + } + ); + 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 diagnostics: Diagnostic[] = []; + await runtimeRef.current![lang].runFiles( + [filename], + { [filename]: code }, + () => {}, + (diagnostic) => { + diagnostics.push(diagnostic); + } + ); + 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: null, + typescript: null, + } satisfies Record + )[lang] ?? [null, null, null]; + if (!filename || !code || expectedLine === null) return null; + + return async (runtimeRef) => { + const diagnostics: Diagnostic[] = []; + await runtimeRef.current![lang].runFiles( + [filename], + { [filename]: code }, + () => {}, + (diagnostic) => { + diagnostics.push(diagnostic); + } + ); + 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 diagnostics: Diagnostic[] = []; + await runtimeRef.current![lang].runFiles( + [filename], + { [filename]: code }, + () => {}, + (diagnostic) => { + diagnostics.push(diagnostic); + } + ); + 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: 関数呼び出し連鎖でスタックトレースを生成し、 + * Python/Ruby/CPP/Rust: 関数呼び出し連鎖でスタックトレースを生成し、 * - diagnosticsが1件だけ返ること * - framesが2件以上あること * - 全フレームがユーザーファイルを指すこと(等の内部フレームが含まれないこと) @@ -254,8 +444,14 @@ export const fileExecutionTests: Record< // bar -> foo -> raise で複数フレームのエラーを生成 `def foo\n raise "${uniqueTypeName}"\nend\n\ndef bar\n foo\nend\n\nbar\n`, ], - cpp: [null, null], - rust: [null, null], + 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: [null, null], typescript: [null, null], } satisfies Record @@ -306,8 +502,8 @@ export const fileExecutionTests: Record< { python: [2, 5, 7], ruby: [2, 6, 9], - cpp: null, - rust: null, + cpp: [2, 3, 4], + rust: [2, 5, 8], javascript: null, typescript: null, } satisfies Record @@ -331,4 +527,49 @@ export const fileExecutionTests: Record< } }; }, + + /** + * コンパイル警告で診断情報(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 diagnostics: Diagnostic[] = []; + await runtimeRef.current![lang].runFiles( + [filename], + { [filename]: code }, + () => {}, + (diagnostic) => { + diagnostics.push(diagnostic); + } + ); + 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"); + }; + }, }; From 66ab789a8a0e8c440e70ef38941a1cab59ddce73 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:46:02 +0000 Subject: [PATCH 09/19] =?UTF-8?q?output=E3=81=AE=E5=87=BA=E5=8A=9B?= =?UTF-8?q?=E3=81=A8=E3=80=81regex=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/runtime/src/wandbox/api.ts | 84 ++++++++++++++----------- packages/runtime/src/wandbox/cpp.ts | 4 +- packages/runtime/src/wandbox/rust.ts | 11 ++-- packages/runtime/tests/fileExecution.ts | 42 ++++++++++--- 4 files changed, 93 insertions(+), 48 deletions(-) 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 52a7fbe9..ed3f4973 100644 --- a/packages/runtime/src/wandbox/cpp.ts +++ b/packages/runtime/src/wandbox/cpp.ts @@ -11,7 +11,7 @@ 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 .*)$/; + /^(?:(?:\/usr\/bin\/ld:\s+)?(?:.*\/)?([^:\n]+)):(?:(\d+):)?(?:\([^)]+\):)?\s*(undefined reference to .*)$/; export function selectCppCompiler( compilerList: CompilerInfo[] @@ -159,7 +159,7 @@ export async function cppRunFiles( !rawFilename.startsWith("<") && !rawFilename.includes("/include/") ) { - const lineNum = parseInt(ldMatch[2], 10); + const lineNum = ldMatch[2] ? parseInt(ldMatch[2], 10) : 1; const msg = ldMatch[3]; onDiagnostic?.({ frames: [ diff --git a/packages/runtime/src/wandbox/rust.ts b/packages/runtime/src/wandbox/rust.ts index 68010ffa..6da2bc95 100644 --- a/packages/runtime/src/wandbox/rust.ts +++ b/packages/runtime/src/wandbox/rust.ts @@ -47,8 +47,9 @@ export async function rustRunFiles( ): 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 SYSTEM_CODE_PATTERN = + /^\s*at\s+(?:(?:\.\/)?prog\.rs|.*\/prog\.rs|\/rustc\/|<)/; // Track state for processing panic traces let inPanicHook = false; @@ -208,14 +209,16 @@ export async function rustRunFiles( }); const m = - /^\s*at\s+(?:(?:\.\/)?([^:\s]+)):(\d+):?(\d+)?/.exec( + /^\s*at\s+(?:.*\/)?([^:\s]+):(\d+):?(\d+)?/.exec( output.message ); if (m) { const fn = m[1].replace(/^\.\//, "").replace(/^\//, ""); if ( fn !== "prog.rs" && - !fn.startsWith("/") && + !output.message.includes("/rustc/") && + !output.message.includes("/std/") && + !output.message.includes("/core/") && !fn.startsWith("<") ) { runtimeFrames.push({ diff --git a/packages/runtime/tests/fileExecution.ts b/packages/runtime/tests/fileExecution.ts index 3169fb1d..b5f0877b 100644 --- a/packages/runtime/tests/fileExecution.ts +++ b/packages/runtime/tests/fileExecution.ts @@ -199,15 +199,19 @@ export const fileExecutionTests: Record< 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) @@ -257,15 +261,19 @@ export const fileExecutionTests: Record< 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) @@ -299,15 +307,19 @@ export const fileExecutionTests: Record< 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) @@ -348,15 +360,19 @@ export const fileExecutionTests: Record< 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) @@ -398,15 +414,19 @@ export const fileExecutionTests: Record< 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) @@ -459,15 +479,19 @@ export const fileExecutionTests: Record< 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) @@ -551,15 +575,19 @@ export const fileExecutionTests: Record< 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) From 6e0b85a4ebe51d441a8619f524bf6e7f3f72329e Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:04:40 +0000 Subject: [PATCH 10/19] =?UTF-8?q?=E6=A8=99=E6=BA=96=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=83=96=E3=83=A9=E3=83=AA=E3=83=95=E3=83=AC=E3=83=BC=E3=83=A0?= =?UTF-8?q?=E3=81=AE=E9=99=A4=E5=A4=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/runtime/src/wandbox/cpp.ts | 6 +++-- packages/runtime/src/wandbox/rust.ts | 37 +++++++++++++++++----------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/packages/runtime/src/wandbox/cpp.ts b/packages/runtime/src/wandbox/cpp.ts index ed3f4973..3311effe 100644 --- a/packages/runtime/src/wandbox/cpp.ts +++ b/packages/runtime/src/wandbox/cpp.ts @@ -227,10 +227,12 @@ export async function cppRunFiles( m && !output.message.includes("/boost/") && !output.message.includes("/include/") && - !output.message.includes("/opt/wandbox/") + !output.message.includes("/opt/wandbox/") && + !output.message.includes("/usr/") && + !output.message.includes("/lib/") ) { const filename = m[1].replace(/^\.\//, ""); - if (filename !== "_stacktrace.cpp") { + if (filename !== "_stacktrace.cpp" && !filename.startsWith("<")) { const cleanedMessage = output.message.replace( /\s+at\s+.*\/([^\/]+:\d+.*)$/, " at $1" diff --git a/packages/runtime/src/wandbox/rust.ts b/packages/runtime/src/wandbox/rust.ts index 6da2bc95..339c425a 100644 --- a/packages/runtime/src/wandbox/rust.ts +++ b/packages/runtime/src/wandbox/rust.ts @@ -48,8 +48,20 @@ export async function rustRunFiles( // Regular expressions for parsing stack traces const STACK_FRAME_PATTERN = /^\s*\d+:/; const LOCATION_PATTERN = /^\s*at\s+/; - const SYSTEM_CODE_PATTERN = - /^\s*at\s+(?:(?:\.\/)?prog\.rs|.*\/prog\.rs|\/rustc\/|<)/; + + 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; @@ -194,14 +206,15 @@ 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", @@ -214,13 +227,7 @@ export async function rustRunFiles( ); if (m) { const fn = m[1].replace(/^\.\//, "").replace(/^\//, ""); - if ( - fn !== "prog.rs" && - !output.message.includes("/rustc/") && - !output.message.includes("/std/") && - !output.message.includes("/core/") && - !fn.startsWith("<") - ) { + if (!isSystemCode(fn)) { runtimeFrames.push({ filename: fn, startLineNumber: parseInt(m[2], 10), From 3d298fc586c5efa4e988af45bbd09998c7ca1f92 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:49:01 +0000 Subject: [PATCH 11/19] =?UTF-8?q?js=E3=81=AE=E3=82=B9=E3=82=BF=E3=83=83?= =?UTF-8?q?=E3=82=AF=E3=83=88=E3=83=AC=E3=83=BC=E3=82=B9=E3=82=92=E3=83=91?= =?UTF-8?q?=E3=83=BC=E3=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/jsEval/src/index.ts | 11 + packages/jsEval/src/stackTrace.ts | 404 +++++++++++++++++++ packages/jsEval/tests/stackTrace.spec.ts | 347 ++++++++++++++++ packages/runtime/src/worker/jsEval.worker.ts | 51 ++- packages/runtime/tests/fileExecution.ts | 11 +- 5 files changed, 793 insertions(+), 31 deletions(-) create mode 100644 packages/jsEval/src/stackTrace.ts create mode 100644 packages/jsEval/tests/stackTrace.spec.ts 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..b2341b91 --- /dev/null +++ b/packages/jsEval/src/stackTrace.ts @@ -0,0 +1,404 @@ +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; + } + + // Pattern 1: eval at ... + // 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" + const evalAtMatch = + line.match( + /^at\s+(?:async\s+)?(?:(?[^\s(]+)\s+)?\(eval at [^,]+(?:,\s*eval at [^,]+)*,\s*(?:(?[^:]+):)?(?\d+):(?\d+)\)$/ + ) || + line.match( + /^at\s+(?:async\s+)?eval at [^,]+(?:,\s*eval at [^,]+)*,\s*(?:(?[^:]+):)?(?\d+):(?\d+)$/ + ); + + if (evalAtMatch && evalAtMatch.groups) { + const rawFn = evalAtMatch.groups.fn; + const fn = + rawFn && rawFn !== "eval" && rawFn !== "" + ? rawFn + : undefined; + const rawFile = evalAtMatch.groups.file; + const filename = + rawFile && rawFile !== "" ? rawFile : defaultFilename; + const lineNumber = parseInt(evalAtMatch.groups.line, 10); + const columnNumber = parseInt(evalAtMatch.groups.col, 10); + + frames.push({ + functionName: fn, + filename, + lineNumber, + columnNumber, + }); + continue; + } + + // Pattern 2: direct file reference (e.g. with sourceURL or in Node/V8) + // e.g. "at foo (main.js:2:9)", "at eval (main.js:5:3)", "at main.js:5:3" + const directMatch = + line.match( + /^at\s+(?:async\s+)?(?:(?[^\s(]+)\s+)?\((?[^:]+):(?\d+):(?\d+)\)$/ + ) || + line.match( + /^at\s+(?:async\s+)?(?[^:()\s]+):(?\d+):(?\d+)$/ + ); + + if (directMatch && directMatch.groups) { + const file = directMatch.groups.file; + // 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 rawFn = directMatch.groups.fn; + const fn = + rawFn && + rawFn !== "eval" && + rawFn !== "" && + rawFn !== "Object." + ? rawFn + : undefined; + const filename = + file === "" + ? defaultFilename + : file.endsWith("/" + defaultFilename) + ? defaultFilename + : file; + const lineNumber = parseInt(directMatch.groups.line, 10); + const columnNumber = parseInt(directMatch.groups.col, 10); + + frames.push({ + functionName: fn, + 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 match = location.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( + /^(?[^:]+):(?\d+):(?\d+)$/ + ); + if (locMatch && locMatch.groups) { + const file = locMatch.groups.file; + 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.groups.line, 10), + columnNumber: parseInt(locMatch.groups.col, 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 { + // eslint-disable-next-line @typescript-eslint/no-implied-eval + (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..e80283bd --- /dev/null +++ b/packages/jsEval/tests/stackTrace.spec.ts @@ -0,0 +1,347 @@ +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/worker/jsEval.worker.ts b/packages/runtime/src/worker/jsEval.worker.ts index bc6b1a63..4af3b5c3 100644 --- a/packages/runtime/src/worker/jsEval.worker.ts +++ b/packages/runtime/src/worker/jsEval.worker.ts @@ -4,7 +4,12 @@ import { expose } from "comlink"; 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; @@ -47,18 +52,11 @@ async function runCode( } 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, code, "main.js"); + await onOutput({ + type: "error", + message: parsed.formattedStackTrace, + }); } } @@ -66,29 +64,28 @@ async function runFile( name: string, files: Record, onOutput: (output: ReplOutput | UpdatedFile) => Promise, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _onDiagnostic?: (diagnostic: Diagnostic) => 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/tests/fileExecution.ts b/packages/runtime/tests/fileExecution.ts index b5f0877b..24badd0a 100644 --- a/packages/runtime/tests/fileExecution.ts +++ b/packages/runtime/tests/fileExecution.ts @@ -192,7 +192,7 @@ export const fileExecutionTests: Record< "test_compile.rs", `static X: i32 = ${uniqueTypeName};\npub fn main() {}\n`, ], - javascript: null, + javascript: ["test_compile.js", `function foo(\n`], typescript: ["test_compile.ts", `const x: ${uniqueTypeName} = 1;\n`], } satisfies Record )[lang] ?? [null, null]; @@ -353,7 +353,7 @@ export const fileExecutionTests: Record< `pub fn main() {\n panic!("${errorMsg}");\n}\n`, 2, ], - javascript: null, + javascript: ["test_runtime.js", `throw new Error("${errorMsg}");\n`, 1], typescript: null, } satisfies Record )[lang] ?? [null, null, null]; @@ -472,7 +472,10 @@ export const fileExecutionTests: Record< "test_multiframe.rs", `fn foo() {\n panic!("${uniqueTypeName}");\n}\nfn bar() {\n foo();\n}\npub fn main() {\n bar();\n}\n`, ], - javascript: [null, null], + 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]; @@ -528,7 +531,7 @@ export const fileExecutionTests: Record< ruby: [2, 6, 9], cpp: [2, 3, 4], rust: [2, 5, 8], - javascript: null, + javascript: [2, 5, 7], typescript: null, } satisfies Record )[lang]; From 6dcb7d2a0b7b413ad421c0d9c349b3c6c5b95184 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:59:59 +0000 Subject: [PATCH 12/19] =?UTF-8?q?tsc=E3=81=AE=E3=82=A8=E3=83=A9=E3=83=BC?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/jsEval/src/stackTrace.ts | 116 ++++++++++++++++++++++-------- 1 file changed, 86 insertions(+), 30 deletions(-) diff --git a/packages/jsEval/src/stackTrace.ts b/packages/jsEval/src/stackTrace.ts index b2341b91..47a5f061 100644 --- a/packages/jsEval/src/stackTrace.ts +++ b/packages/jsEval/src/stackTrace.ts @@ -46,29 +46,35 @@ export function parseStackTrace( continue; } - // Pattern 1: eval at ... + // Pattern 1a: eval at ... with function name // e.g. "at foo (eval at runFile (webpack-internal://...), :2:9)" // e.g. "at eval (eval at runFile (webpack-internal://...), :5:3)" + const evalAtWithFn = line.match( + /^at\s+(?:async\s+)?([^\s(]+)\s+\(eval at [^,]+(?:,\s*eval at [^,]+)*,\s*(?:([^:]+):)?(\d+):(\d+)\)$/ + ); + + // Pattern 1b: eval at ... without function name (or at eval at ...) + // e.g. "at (eval at runFile (...), :2:9)" // e.g. "at eval at runFile (webpack-internal://...), :5:3" - const evalAtMatch = + const evalAtWithoutFn = line.match( - /^at\s+(?:async\s+)?(?:(?[^\s(]+)\s+)?\(eval at [^,]+(?:,\s*eval at [^,]+)*,\s*(?:(?[^:]+):)?(?\d+):(?\d+)\)$/ + /^at\s+(?:async\s+)?\(eval at [^,]+(?:,\s*eval at [^,]+)*,\s*(?:([^:]+):)?(\d+):(\d+)\)$/ ) || line.match( - /^at\s+(?:async\s+)?eval at [^,]+(?:,\s*eval at [^,]+)*,\s*(?:(?[^:]+):)?(?\d+):(?\d+)$/ + /^at\s+(?:async\s+)?eval at [^,]+(?:,\s*eval at [^,]+)*,\s*(?:([^:]+):)?(\d+):(\d+)$/ ); - if (evalAtMatch && evalAtMatch.groups) { - const rawFn = evalAtMatch.groups.fn; + if (evalAtWithFn) { + const rawFn = evalAtWithFn[1]; const fn = rawFn && rawFn !== "eval" && rawFn !== "" ? rawFn : undefined; - const rawFile = evalAtMatch.groups.file; + const rawFile = evalAtWithFn[2]; const filename = rawFile && rawFile !== "" ? rawFile : defaultFilename; - const lineNumber = parseInt(evalAtMatch.groups.line, 10); - const columnNumber = parseInt(evalAtMatch.groups.col, 10); + const lineNumber = parseInt(evalAtWithFn[3], 10); + const columnNumber = parseInt(evalAtWithFn[4], 10); frames.push({ functionName: fn, @@ -77,20 +83,37 @@ export function parseStackTrace( columnNumber, }); continue; + } else if (evalAtWithoutFn) { + const rawFile = evalAtWithoutFn[1]; + const filename = + rawFile && rawFile !== "" ? rawFile : defaultFilename; + const lineNumber = parseInt(evalAtWithoutFn[2], 10); + const columnNumber = parseInt(evalAtWithoutFn[3], 10); + + frames.push({ + functionName: undefined, + filename, + lineNumber, + columnNumber, + }); + continue; } - // Pattern 2: direct file reference (e.g. with sourceURL or in Node/V8) - // e.g. "at foo (main.js:2:9)", "at eval (main.js:5:3)", "at main.js:5:3" - const directMatch = - line.match( - /^at\s+(?:async\s+)?(?:(?[^\s(]+)\s+)?\((?[^:]+):(?\d+):(?\d+)\)$/ - ) || - line.match( - /^at\s+(?:async\s+)?(?[^:()\s]+):(?\d+):(?\d+)$/ - ); + // Pattern 2a: direct file reference with function name + // e.g. "at foo (main.js:2:9)", "at eval (main.js:5:3)" + const directWithFn = line.match( + /^at\s+(?:async\s+)?([^\s(]+)\s+\(([^:]+):(\d+):(\d+)\)$/ + ); - if (directMatch && directMatch.groups) { - const file = directMatch.groups.file; + // Pattern 2b: direct file reference without function name + // e.g. "at (main.js:5:3)" or "at main.js:5:3" + const directWithoutFn = + line.match(/^at\s+(?:async\s+)?\(([^:]+):(\d+):(\d+)\)$/) || + line.match(/^at\s+(?:async\s+)?([^:()\s]+):(\d+):(\d+)$/); + + if (directWithFn) { + const rawFn = directWithFn[1]; + const file = directWithFn[2]; // Skip internal runtime / bundler / worker frames if ( file.startsWith("http:") || @@ -109,7 +132,6 @@ export function parseStackTrace( } } - const rawFn = directMatch.groups.fn; const fn = rawFn && rawFn !== "eval" && @@ -123,8 +145,8 @@ export function parseStackTrace( : file.endsWith("/" + defaultFilename) ? defaultFilename : file; - const lineNumber = parseInt(directMatch.groups.line, 10); - const columnNumber = parseInt(directMatch.groups.col, 10); + const lineNumber = parseInt(directWithFn[3], 10); + const columnNumber = parseInt(directWithFn[4], 10); frames.push({ functionName: fn, @@ -133,6 +155,42 @@ export function parseStackTrace( columnNumber, }); continue; + } else if (directWithoutFn) { + const file = directWithoutFn[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(directWithoutFn[2], 10); + const columnNumber = parseInt(directWithoutFn[3], 10); + + frames.push({ + functionName: undefined, + filename, + lineNumber, + columnNumber, + }); + continue; } // Other "at " lines are runtime/framework frames; skip them. @@ -172,11 +230,9 @@ export function parseStackTrace( // 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( - /^(?[^:]+):(?\d+):(?\d+)$/ - ); - if (locMatch && locMatch.groups) { - const file = locMatch.groups.file; + const locMatch = location.match(/^([^:]+):(\d+):(\d+)$/); + if (locMatch) { + const file = locMatch[1]; if ( file.startsWith("http:") || file.startsWith("https:") || @@ -204,8 +260,8 @@ export function parseStackTrace( filename: file.endsWith("/" + defaultFilename) ? defaultFilename : file, - lineNumber: parseInt(locMatch.groups.line, 10), - columnNumber: parseInt(locMatch.groups.col, 10), + lineNumber: parseInt(locMatch[2], 10), + columnNumber: parseInt(locMatch[3], 10), }); continue; } From 9251724ecd0662f1ed6a234d3afcfcd01c982f57 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:56:15 +0900 Subject: [PATCH 13/19] =?UTF-8?q?repl=E3=81=AE=E5=A0=B4=E5=90=88=E3=81=AFm?= =?UTF-8?q?ain.js=E3=81=98=E3=82=83=E3=81=AA=E3=81=84=E3=81=BB=E3=81=86?= =?UTF-8?q?=E3=81=8C=E3=81=84=E3=81=84=E3=81=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/runtime/src/worker/jsEval.worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/worker/jsEval.worker.ts b/packages/runtime/src/worker/jsEval.worker.ts index 4af3b5c3..f143367e 100644 --- a/packages/runtime/src/worker/jsEval.worker.ts +++ b/packages/runtime/src/worker/jsEval.worker.ts @@ -52,7 +52,7 @@ async function runCode( } catch (e) { originalConsole.log(e); await Promise.all(pendingOutputPromise); - const parsed = parseError(e, code, "main.js"); + const parsed = parseError(e, code, "REPL"); await onOutput({ type: "error", message: parsed.formattedStackTrace, From bd9ad093021f942413140bb8d28d47d083f53283 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:00:41 +0900 Subject: [PATCH 14/19] format --- packages/jsEval/src/stackTrace.ts | 8 ++-- packages/jsEval/tests/stackTrace.spec.ts | 17 +++++--- packages/runtime/src/wandbox/cpp.ts | 4 +- packages/runtime/src/wandbox/rust.ts | 7 ++-- packages/runtime/tests/fileExecution.ts | 51 ++++++++++++++++++------ 5 files changed, 61 insertions(+), 26 deletions(-) diff --git a/packages/jsEval/src/stackTrace.ts b/packages/jsEval/src/stackTrace.ts index 47a5f061..c03d515a 100644 --- a/packages/jsEval/src/stackTrace.ts +++ b/packages/jsEval/src/stackTrace.ts @@ -143,8 +143,8 @@ export function parseStackTrace( file === "" ? defaultFilename : file.endsWith("/" + defaultFilename) - ? defaultFilename - : file; + ? defaultFilename + : file; const lineNumber = parseInt(directWithFn[3], 10); const columnNumber = parseInt(directWithFn[4], 10); @@ -179,8 +179,8 @@ export function parseStackTrace( file === "" ? defaultFilename : file.endsWith("/" + defaultFilename) - ? defaultFilename - : file; + ? defaultFilename + : file; const lineNumber = parseInt(directWithoutFn[2], 10); const columnNumber = parseInt(directWithoutFn[3], 10); diff --git a/packages/jsEval/tests/stackTrace.spec.ts b/packages/jsEval/tests/stackTrace.spec.ts index e80283bd..89fc1583 100644 --- a/packages/jsEval/tests/stackTrace.spec.ts +++ b/packages/jsEval/tests/stackTrace.spec.ts @@ -35,7 +35,8 @@ callback@webpack-internal:///(app-pages-browser)/./node_modules/.pnpm/comlink@4. 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)"; + const expected = + "Error: test\n at foo (main.js:2:9)\n at (main.js:5:3)"; assert.strictEqual(formatted, expected); }); }); @@ -67,7 +68,8 @@ o@https://my-code.utcode.net/_next/static/chunks/3724.2004c2fc86f4d4ce.js:1:1201 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)"; + const expected = + "Error: test\n at foo (main.js:2:9)\n at (main.js:5:3)"; assert.strictEqual(formatted, expected); }); }); @@ -159,7 +161,8 @@ o@https://my-code.utcode.net/_next/static/chunks/3724.2004c2fc86f4d4ce.js:1:1202 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)"; + const expected = + "Error: test\n at foo (main.js:2:9)\n at (main.js:5:3)"; assert.strictEqual(formatted, expected); }); }); @@ -193,7 +196,8 @@ o@https://my-code.utcode.net/_next/static/chunks/3724.2004c2fc86f4d4ce.js:1:1202 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)"; + const expected = + "Error: test\n at foo (main.js:2:9)\n at (main.js:5:3)"; assert.strictEqual(formatted, expected); }); }); @@ -339,7 +343,10 @@ const c = 3;`; 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].filename, + "test_compile.js" + ); assert.strictEqual(result.diagnostic.frames[0].startLineNumber, 1); assert.strictEqual(result.diagnostic.severity, "error"); }); diff --git a/packages/runtime/src/wandbox/cpp.ts b/packages/runtime/src/wandbox/cpp.ts index 3311effe..f59462a2 100644 --- a/packages/runtime/src/wandbox/cpp.ts +++ b/packages/runtime/src/wandbox/cpp.ts @@ -182,7 +182,9 @@ export async function cppRunFiles( 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") + output.message.includes( + "terminate called after throwing an instance of" + ) ) { const m = /terminate called after throwing an instance of '([^']+)'/.exec( diff --git a/packages/runtime/src/wandbox/rust.ts b/packages/runtime/src/wandbox/rust.ts index 339c425a..dd529cbf 100644 --- a/packages/runtime/src/wandbox/rust.ts +++ b/packages/runtime/src/wandbox/rust.ts @@ -221,10 +221,9 @@ export async function rustRunFiles( message: output.message, }); - const m = - /^\s*at\s+(?:.*\/)?([^:\s]+):(\d+):?(\d+)?/.exec( - output.message - ); + const m = /^\s*at\s+(?:.*\/)?([^:\s]+):(\d+):?(\d+)?/.exec( + output.message + ); if (m) { const fn = m[1].replace(/^\.\//, "").replace(/^\//, ""); if (!isSystemCode(fn)) { diff --git a/packages/runtime/tests/fileExecution.ts b/packages/runtime/tests/fileExecution.ts index 24badd0a..11397122 100644 --- a/packages/runtime/tests/fileExecution.ts +++ b/packages/runtime/tests/fileExecution.ts @@ -220,7 +220,10 @@ export const fileExecutionTests: Record< 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.frames[0].startLineNumber, + "frame startLineNumber" + ).to.equal(1); expect(firstDiag.severity, "severity should be error").to.equal("error"); }; }, @@ -235,7 +238,8 @@ export const fileExecutionTests: Record< ruby: null, cpp: [ { - "test_sub_main.cpp": '#include "test_sub.h"\nint main() { return 0; }\n', + "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"], @@ -244,7 +248,8 @@ export const fileExecutionTests: Record< ], rust: [ { - "test_sub_main.rs": "mod test_sub;\npub fn main() {\n test_sub::foo();\n}\n", + "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"], @@ -258,7 +263,8 @@ export const fileExecutionTests: Record< [Record, string[], string, number] | null > )[lang] ?? [null, null, null, null]; - if (!codes || !execFiles || !expectedErrorFile || !expectedLine) return null; + if (!codes || !execFiles || !expectedErrorFile || !expectedLine) + return null; return async (runtimeRef) => { const outputs: ReplOutput[] = []; @@ -281,8 +287,14 @@ export const fileExecutionTests: Record< 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.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"); }; }, @@ -328,8 +340,13 @@ export const fileExecutionTests: Record< 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.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"); }; }, @@ -381,7 +398,10 @@ export const fileExecutionTests: Record< 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.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"); }; @@ -411,7 +431,8 @@ export const fileExecutionTests: Record< typescript: null, } satisfies Record )[lang] ?? [null, null, null, null]; - if (!filename || !code || expectedLine === null || !expectedMsg) return null; + if (!filename || !code || expectedLine === null || !expectedMsg) + return null; return async (runtimeRef) => { const outputs: ReplOutput[] = []; @@ -435,7 +456,10 @@ export const fileExecutionTests: Record< 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.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"); }; @@ -599,7 +623,10 @@ export const fileExecutionTests: Record< 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!.frames[0].startLineNumber, + "frame startLineNumber" + ).to.equal(2); expect(warnDiag!.message, "warning message").to.include("unused"); }; }, From 080ef623ab348de853f778f15881618bdecdbf6b Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:06:07 +0000 Subject: [PATCH 15/19] fix redos --- packages/jsEval/src/stackTrace.ts | 139 +++++++++--------------------- 1 file changed, 39 insertions(+), 100 deletions(-) diff --git a/packages/jsEval/src/stackTrace.ts b/packages/jsEval/src/stackTrace.ts index c03d515a..51327fb7 100644 --- a/packages/jsEval/src/stackTrace.ts +++ b/packages/jsEval/src/stackTrace.ts @@ -46,74 +46,46 @@ export function parseStackTrace( continue; } - // Pattern 1a: eval at ... with function name + // 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)" - const evalAtWithFn = line.match( - /^at\s+(?:async\s+)?([^\s(]+)\s+\(eval at [^,]+(?:,\s*eval at [^,]+)*,\s*(?:([^:]+):)?(\d+):(\d+)\)$/ - ); - - // Pattern 1b: eval at ... without function name (or at eval at ...) - // e.g. "at (eval at runFile (...), :2:9)" // e.g. "at eval at runFile (webpack-internal://...), :5:3" - const evalAtWithoutFn = - line.match( - /^at\s+(?:async\s+)?\(eval at [^,]+(?:,\s*eval at [^,]+)*,\s*(?:([^:]+):)?(\d+):(\d+)\)$/ - ) || - line.match( - /^at\s+(?:async\s+)?eval at [^,]+(?:,\s*eval at [^,]+)*,\s*(?:([^:]+):)?(\d+):(\d+)$/ - ); - - if (evalAtWithFn) { - const rawFn = evalAtWithFn[1]; - const fn = - rawFn && rawFn !== "eval" && rawFn !== "" - ? rawFn - : undefined; - const rawFile = evalAtWithFn[2]; - const filename = - rawFile && rawFile !== "" ? rawFile : defaultFilename; - const lineNumber = parseInt(evalAtWithFn[3], 10); - const columnNumber = parseInt(evalAtWithFn[4], 10); - - frames.push({ - functionName: fn, - filename, - lineNumber, - columnNumber, - }); - continue; - } else if (evalAtWithoutFn) { - const rawFile = evalAtWithoutFn[1]; - const filename = - rawFile && rawFile !== "" ? rawFile : defaultFilename; - const lineNumber = parseInt(evalAtWithoutFn[2], 10); - const columnNumber = parseInt(evalAtWithoutFn[3], 10); + if (line.includes("eval at ")) { + const locMatch = line.match(/,\s*(?:([^:()\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); + + const fnMatch = line.match( + /^at\s+(?:async\s+)?([^\s(]+)\s+\(eval at\s/ + ); + const rawFn = fnMatch ? fnMatch[1] : undefined; + const fn = + rawFn && rawFn !== "eval" && rawFn !== "" + ? rawFn + : undefined; - frames.push({ - functionName: undefined, - filename, - lineNumber, - columnNumber, - }); - continue; + frames.push({ + functionName: fn, + filename, + lineNumber, + columnNumber, + }); + continue; + } } - // Pattern 2a: direct file reference with function name - // e.g. "at foo (main.js:2:9)", "at eval (main.js:5:3)" - const directWithFn = line.match( - /^at\s+(?:async\s+)?([^\s(]+)\s+\(([^:]+):(\d+):(\d+)\)$/ - ); - - // Pattern 2b: direct file reference without function name - // e.g. "at (main.js:5:3)" or "at main.js:5:3" - const directWithoutFn = - line.match(/^at\s+(?:async\s+)?\(([^:]+):(\d+):(\d+)\)$/) || - line.match(/^at\s+(?:async\s+)?([^:()\s]+):(\d+):(\d+)$/); - - if (directWithFn) { - const rawFn = directWithFn[1]; - const file = directWithFn[2]; + // Direct file reference pattern (e.g. with sourceURL or in Node/V8): + // e.g. "at foo (main.js:2:9)", "at eval (main.js:5:3)", "at main.js:5:3" + const locMatch = + line.match(/\(([^:()]+):(\d+):(\d+)\)$/) || + line.match(/([^\s:()]+):(\d+):(\d+)$/); + + if (locMatch) { + const file = locMatch[1]; // Skip internal runtime / bundler / worker frames if ( file.startsWith("http:") || @@ -132,6 +104,8 @@ export function parseStackTrace( } } + const fnMatch = line.match(/^at\s+(?:async\s+)?([^\s(]+)\s+\(/); + const rawFn = fnMatch ? fnMatch[1] : undefined; const fn = rawFn && rawFn !== "eval" && @@ -139,41 +113,6 @@ export function parseStackTrace( rawFn !== "Object." ? rawFn : undefined; - const filename = - file === "" - ? defaultFilename - : file.endsWith("/" + defaultFilename) - ? defaultFilename - : file; - const lineNumber = parseInt(directWithFn[3], 10); - const columnNumber = parseInt(directWithFn[4], 10); - - frames.push({ - functionName: fn, - filename, - lineNumber, - columnNumber, - }); - continue; - } else if (directWithoutFn) { - const file = directWithoutFn[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 === "" @@ -181,11 +120,11 @@ export function parseStackTrace( : file.endsWith("/" + defaultFilename) ? defaultFilename : file; - const lineNumber = parseInt(directWithoutFn[2], 10); - const columnNumber = parseInt(directWithoutFn[3], 10); + const lineNumber = parseInt(locMatch[2], 10); + const columnNumber = parseInt(locMatch[3], 10); frames.push({ - functionName: undefined, + functionName: fn, filename, lineNumber, columnNumber, From 1af9232e6bc989d2a8ff32b41d77a6e981985491 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:13:05 +0000 Subject: [PATCH 16/19] fix redos again --- packages/jsEval/src/stackTrace.ts | 127 +++++++++++++++++++++--------- 1 file changed, 90 insertions(+), 37 deletions(-) diff --git a/packages/jsEval/src/stackTrace.ts b/packages/jsEval/src/stackTrace.ts index 51327fb7..39bcc1a6 100644 --- a/packages/jsEval/src/stackTrace.ts +++ b/packages/jsEval/src/stackTrace.ts @@ -51,23 +51,87 @@ export function parseStackTrace( // 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 locMatch = line.match(/,\s*(?:([^:()\s]+):)?(\d+):(\d+)\)?$/); + 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 rawFile = locMatch[1]; + 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 = - rawFile && rawFile !== "" ? rawFile : defaultFilename; + file === "" + ? defaultFilename + : file.endsWith("/" + defaultFilename) + ? defaultFilename + : file; const lineNumber = parseInt(locMatch[2], 10); const columnNumber = parseInt(locMatch[3], 10); - const fnMatch = line.match( - /^at\s+(?:async\s+)?([^\s(]+)\s+\(eval at\s/ - ); - const rawFn = fnMatch ? fnMatch[1] : undefined; - const fn = - rawFn && rawFn !== "eval" && rawFn !== "" - ? rawFn - : undefined; - frames.push({ functionName: fn, filename, @@ -78,14 +142,11 @@ export function parseStackTrace( } } - // Direct file reference pattern (e.g. with sourceURL or in Node/V8): - // e.g. "at foo (main.js:2:9)", "at eval (main.js:5:3)", "at main.js:5:3" - const locMatch = - line.match(/\(([^:()]+):(\d+):(\d+)\)$/) || - line.match(/([^\s:()]+):(\d+):(\d+)$/); - - if (locMatch) { - const file = locMatch[1]; + // 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:") || @@ -104,27 +165,17 @@ export function parseStackTrace( } } - const fnMatch = line.match(/^at\s+(?:async\s+)?([^\s(]+)\s+\(/); - const rawFn = fnMatch ? fnMatch[1] : undefined; - const fn = - rawFn && - rawFn !== "eval" && - rawFn !== "" && - rawFn !== "Object." - ? rawFn - : undefined; - const filename = file === "" ? defaultFilename : file.endsWith("/" + defaultFilename) - ? defaultFilename - : file; - const lineNumber = parseInt(locMatch[2], 10); - const columnNumber = parseInt(locMatch[3], 10); + ? defaultFilename + : file; + const lineNumber = parseInt(noParenMatch[2], 10); + const columnNumber = parseInt(noParenMatch[3], 10); frames.push({ - functionName: fn, + functionName: undefined, filename, lineNumber, columnNumber, @@ -151,7 +202,9 @@ export function parseStackTrace( // 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 match = location.match(/(?:> eval|> Function):(\d+):(\d+)$/); + 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 !== "" @@ -169,7 +222,7 @@ export function parseStackTrace( // 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(/^([^:]+):(\d+):(\d+)$/); + const locMatch = location.match(/^([^:()\s]+):(\d+):(\d+)$/); if (locMatch) { const file = locMatch[1]; if ( From 085c6ae3c6f601b3dcb83a868628c69112177412 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:14:42 +0000 Subject: [PATCH 17/19] format --- packages/jsEval/src/stackTrace.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/jsEval/src/stackTrace.ts b/packages/jsEval/src/stackTrace.ts index 39bcc1a6..eb058be6 100644 --- a/packages/jsEval/src/stackTrace.ts +++ b/packages/jsEval/src/stackTrace.ts @@ -63,7 +63,9 @@ export function parseStackTrace( const columnNumber = parseInt(locMatch[3], 10); let fn: string | undefined; - const fnMatch = line.match(/^at\s+(?:async\s+)?([^\s(]+)\s+\(eval at\s/); + const fnMatch = line.match( + /^at\s+(?:async\s+)?([^\s(]+)\s+\(eval at\s/ + ); if (fnMatch) { const rawFn = fnMatch[1]; if (rawFn && rawFn !== "eval" && rawFn !== "") { @@ -127,8 +129,8 @@ export function parseStackTrace( file === "" ? defaultFilename : file.endsWith("/" + defaultFilename) - ? defaultFilename - : file; + ? defaultFilename + : file; const lineNumber = parseInt(locMatch[2], 10); const columnNumber = parseInt(locMatch[3], 10); @@ -144,7 +146,9 @@ export function parseStackTrace( // Direct file reference pattern without parentheses: // e.g. "at main.js:5:3" - const noParenMatch = line.match(/^at\s+(?:async\s+)?([^:()\s]+):(\d+):(\d+)$/); + const noParenMatch = line.match( + /^at\s+(?:async\s+)?([^:()\s]+):(\d+):(\d+)$/ + ); if (noParenMatch) { const file = noParenMatch[1]; // Skip internal runtime / bundler / worker frames @@ -169,8 +173,8 @@ export function parseStackTrace( file === "" ? defaultFilename : file.endsWith("/" + defaultFilename) - ? defaultFilename - : file; + ? defaultFilename + : file; const lineNumber = parseInt(noParenMatch[2], 10); const columnNumber = parseInt(noParenMatch[3], 10); From 39eef6cd2fc63901388bfd8ea8106a1fd8a41bfc Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:04:24 +0000 Subject: [PATCH 18/19] fix --- app/terminal/editor.tsx | 7 ++++++- app/terminal/exec.tsx | 12 ++---------- packages/jsEval/src/stackTrace.ts | 1 - packages/runtime/src/typescript/runtime.tsx | 15 ++++++++------- 4 files changed, 16 insertions(+), 19 deletions(-) diff --git a/app/terminal/editor.tsx b/app/terminal/editor.tsx index 84ea838b..41de0766 100644 --- a/app/terminal/editor.tsx +++ b/app/terminal/editor.tsx @@ -43,7 +43,12 @@ export function EditorComponent(props: EditorProps) { const theme = useChangeTheme(); const { files, writeFile, diagnostics } = useEmbedContext(); const fileDiagnostics = useMemo( - () => diagnostics[props.filename] ?? [], + () => + Object.values(diagnostics) + .flat() + .filter((diag) => + diag.frames.some((f) => f.filename === props.filename) + ), [diagnostics, props.filename] ); diff --git a/app/terminal/exec.tsx b/app/terminal/exec.tsx index 7cec12dc..834fe7d1 100644 --- a/app/terminal/exec.tsx +++ b/app/terminal/exec.tsx @@ -100,9 +100,7 @@ export function ExecFile(props: ExecProps) { // TODO: 1つのファイル名しか受け付けないところに無理やりコンマ区切りで全部のファイル名を突っ込んでいる const filenameKey = props.filenames.join(","); clearExecResult(filenameKey); - for (const fname of props.filenames) { - clearDiagnostics(fname); - } + clearDiagnostics(filenameKey); setContents(""); let isFirstOutput = true; await runFiles( @@ -130,13 +128,7 @@ export function ExecFile(props: ExecProps) { setContents((prev) => prev + output.message + "\n"); }, (diagnostic) => { - // diagnosticを関連する全ファイルに登録する - const relatedFiles = new Set( - diagnostic.frames.map((f) => f.filename) - ); - for (const fname of relatedFiles) { - addDiagnostic(fname, diagnostic); - } + addDiagnostic(filenameKey, diagnostic); } ); setExecutionState("idle"); diff --git a/packages/jsEval/src/stackTrace.ts b/packages/jsEval/src/stackTrace.ts index eb058be6..5b392767 100644 --- a/packages/jsEval/src/stackTrace.ts +++ b/packages/jsEval/src/stackTrace.ts @@ -349,7 +349,6 @@ export function findSyntaxErrorLine(code: string): { for (let i = 1; i <= rawLines.length; i++) { const slice = rawLines.slice(0, i).join("\n"); try { - // eslint-disable-next-line @typescript-eslint/no-implied-eval (0, eval)(`() => {\n${slice}\n}`); } catch (e) { if (e instanceof SyntaxError) { diff --git a/packages/runtime/src/typescript/runtime.tsx b/packages/runtime/src/typescript/runtime.tsx index 6131b856..b40b1ca6 100644 --- a/packages/runtime/src/typescript/runtime.tsx +++ b/packages/runtime/src/typescript/runtime.tsx @@ -228,13 +228,14 @@ export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { tsEnv.deleteFile(filename); } - console.log(emitOutput); - await jsEval.runFiles( - [emitOutput.outputFiles[0].name], - { ...files, ...emittedFiles }, - onOutput, - onDiagnostic - ); + if (emitOutput.outputFiles.length > 0) { + await jsEval.runFiles( + [emitOutput.outputFiles[0].name], + { ...files, ...emittedFiles }, + onOutput, + onDiagnostic + ); + } } catch (error) { onErrorRef.current?.(error); onOutput({ From c198dc9be13e93bac6973b91e94c6d1659a2ec41 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:35:43 +0900 Subject: [PATCH 19/19] =?UTF-8?q?=E3=82=A8=E3=83=A9=E3=83=BC=E3=81=AE?= =?UTF-8?q?=E8=A6=8B=E3=81=9F=E7=9B=AE=E3=82=92=E8=AA=BF=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/globals.css | 18 ------------------ app/terminal/editor.tsx | 25 ++++++++++++++++++------- 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/app/globals.css b/app/globals.css index f6a448af..8fa9e767 100644 --- a/app/globals.css +++ b/app/globals.css @@ -114,24 +114,6 @@ mycdark: .ace_selected-word { @apply border-primary!; } -.ace_error-marker { - position: absolute; - background-color: rgba(239, 68, 68, 0.2); - border-bottom: 2px wavy rgb(239, 68, 68); - z-index: 20; -} -.ace_warning-marker { - position: absolute; - background-color: rgba(245, 158, 11, 0.2); - border-bottom: 2px wavy rgb(245, 158, 11); - z-index: 20; -} -.ace_info-marker { - position: absolute; - background-color: rgba(59, 130, 246, 0.2); - border-bottom: 2px dotted rgb(59, 130, 246); - z-index: 20; -} .rounded-box-modal { @apply rounded-box; diff --git a/app/terminal/editor.tsx b/app/terminal/editor.tsx index 41de0766..f9ee089f 100644 --- a/app/terminal/editor.tsx +++ b/app/terminal/editor.tsx @@ -55,6 +55,7 @@ export function EditorComponent(props: EditorProps) { 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), @@ -68,26 +69,36 @@ export function EditorComponent(props: EditorProps) { 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(0, f.endLineNumber - 1) + ? 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(0, f.endColumn - 1) + ? Math.max(startCol + 1, f.endColumn - 1) : Number.MAX_SAFE_INTEGER; const isError = (diag.severity ?? "error") === "error"; const isWarning = diag.severity === "warning"; - const className = isError - ? "ace_error-marker" - : isWarning - ? "ace_warning-marker" - : "ace_info-marker"; + 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,