Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
58 changes: 56 additions & 2 deletions app/terminal/editor.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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}
/>
</Suspense>
) : (
Expand Down
37 changes: 36 additions & 1 deletion app/terminal/embedContext.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -40,6 +40,10 @@ interface IEmbedContext {
execResults: Readonly<Record<Filename, ReplOutput[]>>;
clearExecResult: (filename: Filename) => void;
addExecOutput: (filename: Filename, output: ReplOutput) => void;

diagnostics: Readonly<Record<Filename, Diagnostic[]>>;
clearDiagnostics: (filename?: Filename) => void;
addDiagnostic: (filename: Filename, diagnostic: Diagnostic) => void;
}
const EmbedContext = createContext<IEmbedContext>(null!);

Expand Down Expand Up @@ -80,11 +84,15 @@ export function EmbedContextProvider({
const [execResults, setExecResults] = useState<
Record<Filename, ReplOutput[]>
>({});
const [diagnostics, setDiagnostics] = useState<
Record<Filename, Diagnostic[]>
>({});
if (pageKey && pageKey !== prevPageKey) {
setPrevPageKey(pageKey);
setReplOutputs({});
setCommandIdCounters({});
setExecResults({});
setDiagnostics({});
}

const writeFile = useCallback(
Expand Down Expand Up @@ -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 (
<EmbedContext.Provider
value={{
Expand All @@ -192,6 +224,9 @@ export function EmbedContextProvider({
execResults,
clearExecResult,
addExecOutput,
diagnostics,
clearDiagnostics,
addDiagnostic,
}}
>
{children}
Expand Down
62 changes: 40 additions & 22 deletions app/terminal/exec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -132,6 +148,8 @@ export function ExecFile(props: ExecProps) {
clearExecResult,
addExecOutput,
writeFile,
clearDiagnostics,
addDiagnostic,
terminalInstanceRef,
props.language,
files,
Expand Down
2 changes: 2 additions & 0 deletions packages/runtime/src/diagnostics/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./python";
export * from "./ruby";
81 changes: 81 additions & 0 deletions packages/runtime/src/diagnostics/python.ts
Original file line number Diff line number Diff line change
@@ -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 <exec>, <string> if not matching normal files
if (rawFilename === "<exec>" || rawFilename === "<string>") {
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;
}
Loading
Loading