Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
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
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>
);
}
224 changes: 224 additions & 0 deletions app/terminal/ruby/runtime.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
"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: { RUBY_WASM_URL: string };
}
| {
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);

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 });
});
}

useEffect(() => {
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);
}
};

// Use CDN URL for Ruby WASM with stdlib
const RUBY_WASM_URL =
"https://cdn.jsdelivr.net/npm/@ruby/[email protected]/dist/ruby+stdlib.wasm";

postMessage<InitPayloadFromWorker>({
type: "init",
payload: { RUBY_WASM_URL },
}).then(({ success }) => {
if (success) {
setReady(true);
}
});

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

const interrupt = useCallback(() => {
// TODO: Implement interrupt functionality for Ruby
// Ruby WASM doesn't currently support interrupts like Pyodide does
console.warn("Ruby interrupt is not yet implemented");
}, []);

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 },
});
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 },
});
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>
);
}
19 changes: 17 additions & 2 deletions app/terminal/runtime.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { MutexInterface } from "async-mutex";
import { ReplOutput, SyntaxStatus, ReplCommand } from "./repl";
import { PyodideProvider, usePyodide } from "./python/runtime";
import { RubyProvider, useRuby } from "./ruby/runtime";
import { useWandbox, WandboxProvider } from "./wandbox/runtime";
import { AceLang } from "./editor";
import { ReactNode } from "react";
Expand Down Expand Up @@ -28,7 +29,7 @@ export interface LangConstants {
prompt?: string;
promptMore?: string;
}
export type RuntimeLang = "python" | "cpp";
export type RuntimeLang = "python" | "ruby" | "cpp";

export function getRuntimeLang(
lang: string | undefined
Expand All @@ -38,6 +39,9 @@ export function getRuntimeLang(
case "python":
case "py":
return "python";
case "ruby":
case "rb":
return "ruby";
case "cpp":
case "c++":
return "cpp";
Expand All @@ -49,11 +53,14 @@ export function getRuntimeLang(
export function useRuntime(language: RuntimeLang): RuntimeContext {
// すべての言語のcontextをインスタンス化
const pyodide = usePyodide();
const ruby = useRuby();
const wandboxCpp = useWandbox("cpp");

switch (language) {
case "python":
return pyodide;
case "ruby":
return ruby;
case "cpp":
return wandboxCpp;
default:
Expand All @@ -64,7 +71,9 @@ export function useRuntime(language: RuntimeLang): RuntimeContext {
export function RuntimeProvider({ children }: { children: ReactNode }) {
return (
<PyodideProvider>
<WandboxProvider>{children}</WandboxProvider>
<RubyProvider>
<WandboxProvider>{children}</WandboxProvider>
</RubyProvider>
</PyodideProvider>
);
}
Expand All @@ -77,6 +86,12 @@ export function langConstants(lang: RuntimeLang | AceLang): LangConstants {
prompt: ">>> ",
promptMore: "... ",
};
case "ruby":
return {
tabSize: 2,
prompt: ">> ",
promptMore: "?> ",
};
case "c_cpp":
case "cpp":
return {
Expand Down
Loading