Skip to content
Closed
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
10 changes: 6 additions & 4 deletions app/terminal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ runtime.tsx の `useRuntime(lang)` は各言語のフックを呼び出し、そ
* ランタイムの初期化が完了したか、不要である場合true
* mutex?: `MutexInterface`
* ランタイムに排他制御が必要な場合、MutexInterfaceのインスタンスを返してください。
* interrupt?: `() => Promise<void>`
* 実行中のコマンドを中断します。呼び出し側でmutexのロックはされません
* interrupt?: `() => void`
* 実行中のコマンドを中断します。
* 呼び出し側でmutexのロックはしません。interrupt()を呼ぶ際にはrunCommand()やrunFiles()が実行中であるためmutexはすでにロックされているはずです。
* interrupt()内で実行中の処理のPromiseをrejectしたあと、runtimeを再開する際の処理に必要であればmutexをロックすることも可能です。

### REPL用

Expand All @@ -25,7 +27,7 @@ runtime.tsx の `useRuntime(lang)` は各言語のフックを呼び出し、そ
* checkSyntax?: `(code: string) => Promise<SyntaxStatus>`
* コードの構文チェックを行います。行がコマンドとして完結していれば`complete`、次の行に続く場合(if文の条件式の途中など)は`incomplete`を返してください。
* REPLでEnterを押した際の動作に影響します。
* 呼び出し側でmutexのロックはされません
* 呼び出し側でmutexのロックはせず、必要であればcheckSyntax()内でロックします。
* splitReplExamples?: `(code: string) => ReplCommands[]`
* markdown内に記述されているREPLのサンプルコードをパースします。例えば
```
Expand All @@ -51,7 +53,7 @@ runtime.tsx の `useRuntime(lang)` は各言語のフックを呼び出し、そ

* runFiles: `(filenames: string[]) => Promise<ReplOutput[]>`
* 指定されたファイルを実行します。ファイルの中身はEmbedContextから取得されます。
* 呼び出し側でmutexのロックはされません
* 呼び出し側でmutexのロックはせず、必要であればrunFiles()内でロックします。
* getCommandlineStr: `(filenames: string[]) => string`
* 指定されたファイルを実行するためのコマンドライン引数文字列を返します。表示用です。

Expand Down
6 changes: 5 additions & 1 deletion app/terminal/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const AceEditor = dynamic(
await import("ace-builds/src-min-noconflict/ext-language_tools");
await import("ace-builds/src-min-noconflict/ext-searchbox");
await import("ace-builds/src-min-noconflict/mode-python");
await import("ace-builds/src-min-noconflict/mode-ruby");
await import("ace-builds/src-min-noconflict/mode-c_cpp");
await import("ace-builds/src-min-noconflict/mode-json");
await import("ace-builds/src-min-noconflict/mode-csv");
Expand All @@ -28,13 +29,16 @@ import { langConstants } from "./runtime";
// snippetを有効化するにはsnippetもimportする必要がある: import "ace-builds/src-min-noconflict/snippets/python";

// mode-xxxx.js のファイル名と、AceEditorの mode プロパティの値が対応する
export type AceLang = "python" | "c_cpp" | "json" | "csv" | "text";
export type AceLang = "python" | "ruby" | "c_cpp" | "json" | "csv" | "text";
export function getAceLang(lang: string | undefined): AceLang {
// Markdownで指定される可能性のある言語名からAceLangを取得
switch (lang) {
case "python":
case "py":
return "python";
case "ruby":
case "rb":
return "ruby";
case "cpp":
case "c++":
return "c_cpp";
Expand Down
6 changes: 5 additions & 1 deletion app/terminal/highlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ import chalk from "chalk";
import { RuntimeLang } from "./runtime";
// Python言語定義をインポート
import "prismjs/components/prism-python";
// Ruby言語定義をインポート
import "prismjs/components/prism-ruby";

type PrismLang = "python";
type PrismLang = "python" | "ruby";

function getPrismLanguage(language: RuntimeLang): PrismLang {
switch (language) {
case "python":
return "python";
case "ruby":
return "ruby";
case "cpp":
throw new Error(
`highlight for ${language} is disabled because it should not support REPL`
Expand Down
3 changes: 3 additions & 0 deletions app/terminal/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@ import "mocha/mocha.js";
import "mocha/mocha.css";
import { useEffect, useRef, useState } from "react";
import { usePyodide } from "./python/runtime";
import { useRuby } from "./ruby/runtime";
import { useWandbox } from "./wandbox/runtime";
import { RuntimeContext, RuntimeLang } from "./runtime";
import { useEmbedContext } from "./embedContext";
import { defineTests } from "./tests";

export default function RuntimeTestPage() {
const pyodide = usePyodide();
const ruby = useRuby();
const wandboxCpp = useWandbox("cpp");
const runtimeRef = useRef<Record<RuntimeLang, RuntimeContext>>(null!);
runtimeRef.current = {
python: pyodide,
ruby: ruby,
cpp: wandboxCpp,
};
const { files, writeFile } = useEmbedContext();
Expand Down
23 changes: 23 additions & 0 deletions app/terminal/ruby/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use client";

import { EditorComponent } from "../editor";
import { ExecFile } from "../exec";
import { ReplTerminal } from "../repl";

export default function RubyPage() {
return (
<div className="p-4 flex flex-col gap-4">
<ReplTerminal
terminalId=""
language="ruby"
initContent={">> puts 'hello, world!'\nhello, world!"}
/>
<EditorComponent
language="ruby"
filename="main.rb"
initContent="puts 'hello, world!'"
/>
<ExecFile filenames={["main.rb"]} language="ruby" content="" />
</div>
);
}
276 changes: 276 additions & 0 deletions app/terminal/ruby/runtime.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
"use client";

import {
useState,
useRef,
useCallback,
ReactNode,
createContext,
useContext,
useEffect,
} from "react";
import { SyntaxStatus, ReplOutput, ReplCommand } from "../repl";
import { Mutex, MutexInterface } from "async-mutex";
import { useEmbedContext } from "../embedContext";
import { RuntimeContext } from "../runtime";

const RubyContext = createContext<RuntimeContext>(null!);

export function useRuby(): RuntimeContext {
const context = useContext(RubyContext);
if (!context) {
throw new Error("useRuby must be used within a RubyProvider");
}
return context;
}

type MessageToWorker =
| {
type: "init";
payload: {};
}
| {
type: "runRuby";
payload: { code: string };
}
| {
type: "checkSyntax";
payload: { code: string };
}
| {
type: "runFile";
payload: { name: string; files: Record<string, string> };
};

type MessageFromWorker =
| { id: number; payload: unknown }
| { id: number; error: string };

type InitPayloadFromWorker = { success: boolean };
type RunPayloadFromWorker = {
output: ReplOutput[];
updatedFiles: [string, string][];
};
type StatusPayloadFromWorker = { status: SyntaxStatus };

export function RubyProvider({ children }: { children: ReactNode }) {
const workerRef = useRef<Worker | null>(null);
const [ready, setReady] = useState<boolean>(false);
const mutex = useRef<MutexInterface>(new Mutex());
const { files, writeFile } = useEmbedContext();
const messageCallbacks = useRef<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Map<number, [(payload: any) => void, (error: string) => void]>
>(new Map());
const nextMessageId = useRef<number>(0);
const commandHistory = useRef<string[]>([]);

function postMessage<T>({ type, payload }: MessageToWorker) {
const id = nextMessageId.current++;
return new Promise<T>((resolve, reject) => {
messageCallbacks.current.set(id, [resolve, reject]);
workerRef.current?.postMessage({ id, type, payload });
});
}

const initializeWorker = useCallback(() => {
const worker = new Worker("/ruby.worker.js");
workerRef.current = worker;

worker.onmessage = (event) => {
const data = event.data as MessageFromWorker;
if (messageCallbacks.current.has(data.id)) {
const [resolve, reject] = messageCallbacks.current.get(data.id)!;
if ("error" in data) {
reject(data.error);
} else {
resolve(data.payload);
}
messageCallbacks.current.delete(data.id);
}
};

return postMessage<InitPayloadFromWorker>({
type: "init",
payload: {},
});
}, []);

useEffect(() => {
initializeWorker().then(({ success }) => {
if (success) {
setReady(true);
}
});

return () => {
workerRef.current?.terminate();
};
}, [initializeWorker]);

const interrupt = useCallback(() => {
// Terminate the current worker
if (workerRef.current) {
workerRef.current.terminate();
}

// reject all pending messages
for (const [, [, reject]] of messageCallbacks.current) {
reject("Execution interrupted");
}

// Mark as not ready during reinitialization
setReady(false);

void mutex.current.runExclusive(async () => {
// Reinitialize the worker
const { success } = await initializeWorker();

if (success) {
// Re-execute all saved commands to restore state
for (const cmd of commandHistory.current) {
try {
await postMessage<RunPayloadFromWorker>({
type: "runRuby",
payload: { code: cmd },
});
} catch (e) {
console.error("Error restoring command:", cmd, e);
}
}
setReady(true);
}
});
}, [initializeWorker]);

const runCommand = useCallback(
async (code: string): Promise<ReplOutput[]> => {
if (!mutex.current.isLocked()) {
throw new Error("mutex of RubyContext must be locked for runCommand");
}
if (!workerRef.current || !ready) {
return [{ type: "error", message: "Ruby VM is not ready yet." }];
}

const { output, updatedFiles } = await postMessage<RunPayloadFromWorker>({
type: "runRuby",
payload: { code },
}).catch((error) => {
return {
output: [
{ type: "error", message: `Execution error: ${error}` },
] as ReplOutput[],
updatedFiles: [] as [string, string][],
};
});

// Check if the command succeeded (no errors)
const hasError = output.some((o) => o.type === "error");
if (!hasError) {
// Save successful command to history
commandHistory.current.push(code);
}

for (const [name, content] of updatedFiles) {
writeFile(name, content);
}
return output;
},
[ready, writeFile]
);

const checkSyntax = useCallback(
async (code: string): Promise<SyntaxStatus> => {
if (!workerRef.current || !ready) return "invalid";
const { status } = await mutex.current.runExclusive(() =>
postMessage<StatusPayloadFromWorker>({
type: "checkSyntax",
payload: { code },
})
);
return status;
},
[ready]
);

const runFiles = useCallback(
async (filenames: string[]): Promise<ReplOutput[]> => {
if (filenames.length !== 1) {
return [
{
type: "error",
message: "Ruby execution requires exactly one filename",
},
];
}
if (!workerRef.current || !ready) {
return [{ type: "error", message: "Ruby VM is not ready yet." }];
}
return mutex.current.runExclusive(async () => {
const { output, updatedFiles } =
await postMessage<RunPayloadFromWorker>({
type: "runFile",
payload: { name: filenames[0], files },
}).catch((error) => {
return {
output: [
{ type: "error", message: `Execution error: ${error}` },
] as ReplOutput[],
updatedFiles: [] as [string, string][],
};
});
for (const [newName, content] of updatedFiles) {
writeFile(newName, content);
}
return output;
});
},
[files, ready, writeFile]
);

const splitReplExamples = useCallback((content: string): ReplCommand[] => {
const initCommands: { command: string; output: ReplOutput[] }[] = [];
for (const line of content.split("\n")) {
if (line.startsWith(">> ")) {
// Ruby IRB uses >> as the prompt
initCommands.push({ command: line.slice(3), output: [] });
} else if (line.startsWith("?> ")) {
// Ruby IRB uses ?> for continuation
if (initCommands.length > 0) {
initCommands[initCommands.length - 1].command += "\n" + line.slice(3);
}
} else {
// Lines without prompt are output from the previous command
if (initCommands.length > 0) {
initCommands[initCommands.length - 1].output.push({
type: "stdout",
message: line,
});
}
}
}
return initCommands;
}, []);

const getCommandlineStr = useCallback(
(filenames: string[]) => `ruby ${filenames[0]}`,
[]
);

return (
<RubyContext.Provider
value={{
ready,
runCommand,
checkSyntax,
mutex: mutex.current,
runFiles,
interrupt,
splitReplExamples,
getCommandlineStr,
}}
>
{children}
</RubyContext.Provider>
);
}
Loading
Loading