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(""), []); + }); + }); +});