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 d3b67d14..a45ede33 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,68 @@ 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.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.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"; + + return { + startRow, + startCol, + endRow, + endCol, + className, + type: + f.startColumn !== undefined && + f.endColumn !== undefined && + startRow === endRow + ? ("text" as const) + : ("fullLine" as const), + inFront: false, + }; + }) + ); + }, [fileDiagnostics, props.filename]); + + const code = files[props.filename] || props.initContent; useEffect(() => { if (!files[props.filename] && props.initContent) { @@ -204,6 +265,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..3d98388e 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,43 @@ 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) => { + // diagnosticを関連する全ファイルに登録する + const relatedFiles = new Set(diagnostic.frames.map((f) => f.filename)); + for (const fname of relatedFiles) { + addDiagnostic(fname, 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 +152,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..8ecaa601 --- /dev/null +++ b/packages/runtime/src/diagnostics/python.ts @@ -0,0 +1,89 @@ +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 new file mode 100644 index 00000000..f4d5ef2f --- /dev/null +++ b/packages/runtime/src/diagnostics/ruby.ts @@ -0,0 +1,100 @@ +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/interface.ts b/packages/runtime/src/interface.ts index 9b53a514..948b4617 100644 --- a/packages/runtime/src/interface.ts +++ b/packages/runtime/src/interface.ts @@ -121,6 +121,7 @@ export interface RuntimeContext { * @param filenames - 実行するファイル名 * @param files - 実行環境に渡すファイル(実行するものと無関係のものを含んでも良い) * @param onOutput - 実行結果を返すコールバック + * @param onDiagnostic - 診断情報 (エラーや警告など) を返すコールバック * @returns 実行が完了した際に解決するPromise * ただし、onOutputコールバックは実行完了後に呼ばれる可能性もあります(実行したコマンドが非同期処理を含む場合)。 * @@ -132,7 +133,8 @@ export interface RuntimeContext { runFiles: ( filenames: string[], files: Readonly>, - onOutput: (output: ReplOutput | UpdatedFile) => void + onOutput: (output: ReplOutput | UpdatedFile) => void, + onDiagnostic?: (diagnostic: Diagnostic) => void ) => Promise; /** * 指定されたファイルを実行するためのコマンドライン引数文字列を返します。 @@ -150,6 +152,33 @@ export interface RuntimeInfo { } export type RuntimeErrorHandler = (error: unknown) => void; +export const DiagnosticSeveritySchema = z.enum(["error", "warning", "info"]); +export type DiagnosticSeverity = z.output; + +/** + * エラーや警告の1つのスタックフレーム(ファイル・行・列情報) + */ +export const DiagnosticFrameSchema = z.object({ + filename: z.string(), + startLineNumber: z.number(), // 1-indexed + startColumn: z.number().optional(), // 1-indexed + endLineNumber: z.number().optional(), // 1-indexed + endColumn: z.number().optional(), // 1-indexed +}); +export type DiagnosticFrame = z.output; + +/** + * 1つのエラー・警告・情報メッセージ。 + * 複数のスタックフレームが存在する場合、framesに複数の要素が含まれる。 + * framesは順序通りで、最初の要素が主要フレーム(エラーが発生した場所)。 + */ +export const DiagnosticSchema = z.object({ + frames: z.array(DiagnosticFrameSchema).min(1), + message: z.string(), + severity: DiagnosticSeveritySchema.default("error"), +}); +export type Diagnostic = z.output; + export const ReplOutputTypeSchema = z.enum([ "stdout", "stderr", diff --git a/packages/runtime/src/typescript/runtime.tsx b/packages/runtime/src/typescript/runtime.tsx index 7c05544e..91875b36 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, @@ -20,6 +22,7 @@ import { UpdatedFile, } from "../interface"; + export const compilerOptions: CompilerOptions = { lib: ["ESNext", "WebWorker"], target: 10 satisfies ScriptTarget.ES2023, @@ -113,7 +116,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 +130,62 @@ export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { const ts = await import("typescript"); + const convertDiagnostic = (diag: import("typescript").Diagnostic): Diagnostic => { + let line = 0; + let character = 0; + let endLineNumber: number | undefined = undefined; + let endColumn: number | undefined = undefined; + + if (diag.file && diag.start !== undefined) { + const pos = diag.file.getLineAndCharacterOfPosition(diag.start); + line = pos.line; + character = pos.character; + + if (diag.length !== undefined) { + const endPos = diag.file.getLineAndCharacterOfPosition( + diag.start + diag.length + ); + endLineNumber = endPos.line + 1; + endColumn = endPos.character + 1; + } + } + + const message = + typeof diag.messageText === "string" + ? diag.messageText + : ts.flattenDiagnosticMessageText(diag.messageText, "\n"); + + let severity: DiagnosticSeverity = "error"; + if (diag.category === ts.DiagnosticCategory.Warning) { + severity = "warning"; + } else if ( + diag.category === ts.DiagnosticCategory.Suggestion || + diag.category === ts.DiagnosticCategory.Message + ) { + severity = "info"; + } + + const filename = (diag.file ? diag.file.fileName : filenames[0]).replace( + /^\//, + "" + ); + + return { + frames: [ + { + filename, + startLineNumber: line + 1, + startColumn: character + 1, + endLineNumber, + endColumn, + }, + ], + message, + severity, + }; + }; + + for (const diagnostic of tsEnv.languageService.getSyntacticDiagnostics( filenames[0] )) { @@ -137,6 +197,7 @@ export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { getNewLine: () => "\n", }), }); + onDiagnostic?.(convertDiagnostic(diagnostic)); } for (const diagnostic of tsEnv.languageService.getSemanticDiagnostics( @@ -150,6 +211,7 @@ export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { getNewLine: () => "\n", }), }); + onDiagnostic?.(convertDiagnostic(diagnostic)); } const emitOutput = tsEnv.languageService.getEmitOutput(filenames[0]); @@ -168,7 +230,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..864fe511 100644 --- a/packages/runtime/src/worker/pyodide.worker.ts +++ b/packages/runtime/src/worker/pyodide.worker.ts @@ -7,12 +7,13 @@ import { loadPyodide } from "pyodide"; import { version as pyodideVersion } from "pyodide/package.json"; import type { PyCallable } from "pyodide/ffi"; import type { WorkerAPI, WorkerCapabilities } from "./runtime"; -import type { ReplOutput, UpdatedFile } from "../interface"; +import type { Diagnostic, ReplOutput, UpdatedFile } from "../interface"; +import { 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[] = []; @@ -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..24d2de5c 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,115 @@ export const fileExecutionTests: Record< ).to.equal(msg); }; }, + + /** + * 単純なエラーで診断情報が得られるかテスト + * + * 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"; + 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]; + 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: `, 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); + }; + }, + + /** + * 関数呼び出しを挟んだ複数フレームのエラーで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); + } + }; + }, };