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 1/4] =?UTF-8?q?feat:=20runFiles=E3=81=A7=E3=81=AE=E3=83=AA?= =?UTF-8?q?=E3=82=A2=E3=83=AB=E3=82=BF=E3=82=A4=E3=83=A0diagnostic?= =?UTF-8?q?=E5=87=BA=E5=8A=9B=E6=A9=9F=E8=83=BD=E3=81=8A=E3=82=88=E3=81=B3?= =?UTF-8?q?=E3=82=A8=E3=83=87=E3=82=A3=E3=82=BF=E8=A1=A8=E7=A4=BA=E3=82=92?= =?UTF-8?q?=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 2/4] =?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 3/4] =?UTF-8?q?python=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/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 4/4] =?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, }); } }