diff --git a/app/terminal/editor.tsx b/app/terminal/editor.tsx index 14d2547b..70e9b94e 100644 --- a/app/terminal/editor.tsx +++ b/app/terminal/editor.tsx @@ -26,6 +26,7 @@ const AceEditor = lazy(async () => { await import("ace-builds/src-min-noconflict/mode-json"); await import("ace-builds/src-min-noconflict/mode-csv"); await import("ace-builds/src-min-noconflict/mode-text"); + await import("ace-builds/src-min-noconflict/mode-dart"); return ace; } else { throw new Error("should not try SSR"); diff --git a/app/terminal/icons.tsx b/app/terminal/icons.tsx index 872ae930..9c356a0f 100644 --- a/app/terminal/icons.tsx +++ b/app/terminal/icons.tsx @@ -113,6 +113,16 @@ export function LanguageIcon(props: Props) { ); + case "dart": + return ( + + + + ); default: props.lang satisfies never; console.warn("unknown lang for LanguageIcon:", props.lang); diff --git a/app/terminal/page.tsx b/app/terminal/page.tsx index ba9a32ec..b40bef1c 100644 --- a/app/terminal/page.tsx +++ b/app/terminal/page.tsx @@ -26,6 +26,7 @@ import sub_h from "./samples/sub.h?raw"; import sub_cpp from "./samples/sub.cpp?raw"; import main2_rs from "./samples/main2.rs?raw"; import sub_rs from "./samples/sub.rs?raw"; +import main_dart from "./samples/main.dart?raw"; import { DaisyInfoIcon } from "@/daisyAlertIcon"; export default function RuntimeTestPage() { @@ -127,6 +128,13 @@ const sampleConfig: Record = { }, exec: ["main2.rs"], }, + dart: { + repl: false, + editor: { + "main.dart": main_dart, + }, + exec: ["main.dart"], + }, }; function RuntimeSample({ lang, diff --git a/app/terminal/samples/main.dart b/app/terminal/samples/main.dart new file mode 100644 index 00000000..a506f97c --- /dev/null +++ b/app/terminal/samples/main.dart @@ -0,0 +1,3 @@ +void main() { + print("Hello, Dart!"); +} diff --git a/packages/runtime/src/context.tsx b/packages/runtime/src/context.tsx index 9874812e..4a7e5ac4 100644 --- a/packages/runtime/src/context.tsx +++ b/packages/runtime/src/context.tsx @@ -3,6 +3,7 @@ import { ReactNode, useEffect } from "react"; import { RuntimeContext } from "./interface"; import { RuntimeLang } from "./languages"; +import { DartProvider, useDart } from "./dart/runtime"; import { TypeScriptProvider, useTypeScript } from "./typescript/runtime"; import { useWandbox, WandboxProvider } from "./wandbox/runtime"; import { JSEvalContext, useJSEval } from "./worker/jsEval"; @@ -34,6 +35,7 @@ export function useRuntimeAll(): Record { const typescript = useTypeScript(jsEval); const wandboxCpp = useWandbox("cpp"); const wandboxRust = useWandbox("rust"); + const dart = useDart(); // initはしない。呼び出し側でする必要がある return { @@ -43,6 +45,7 @@ export function useRuntimeAll(): Record { typescript: typescript, cpp: wandboxCpp, rust: wandboxRust, + dart: dart, }; } export function RuntimeProvider({ children }: { children: ReactNode }) { @@ -51,10 +54,13 @@ export function RuntimeProvider({ children }: { children: ReactNode }) { - {children} + + {children} + ); } + diff --git a/packages/runtime/src/dart/runtime.tsx b/packages/runtime/src/dart/runtime.tsx new file mode 100644 index 00000000..b94bcdac --- /dev/null +++ b/packages/runtime/src/dart/runtime.tsx @@ -0,0 +1,371 @@ +"use client"; + +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, +} from "react"; +import useSWR from "swr"; +import { + ReplOutput, + RuntimeContext, + RuntimeErrorHandler, + RuntimeInfo, + UpdatedFile, +} from "../interface"; + +const DART_PAD_API_BASE = "https://stable.api.dartpad.dev/api/v3"; +const DART_PAD_ARTIFACTS_BASE = "https://stable.api.dartpad.dev/artifacts"; + +interface DartVersionResponse { + dartVersion?: string; + flutterVersion?: string; +} + +interface AnalysisIssue { + kind: "error" | "warning" | "info"; + message: string; + location: { + charStart: number; + charLength: number; + line: number; + column: number; + }; + code?: string; + correction?: string; + url?: string; +} + +interface AnalysisResponse { + issues: AnalysisIssue[]; + imports?: unknown[]; +} + +const versionFetcher = async (url: string): Promise => { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to fetch Dart version: ${res.statusText}`); + } + return res.json(); +}; + +const DartContext = createContext<{ + init: (onError?: RuntimeErrorHandler) => void; + ready: boolean; + dartVersion?: string; +}>({ + init: () => undefined, + ready: true, +}); + +export function DartProvider({ children }: { children: ReactNode }) { + const onErrorRef = useRef(undefined); + const init = useCallback((onError?: RuntimeErrorHandler) => { + onErrorRef.current = onError; + }, []); + + const { data, error } = useSWR( + `${DART_PAD_API_BASE}/version`, + versionFetcher + ); + + useEffect(() => { + if (error) { + console.error("Failed to fetch Dart version info:", error); + onErrorRef.current?.(error); + } + }, [error]); + + return ( + + {children} + + ); +} + +async function performAnalysis( + source: string, + onOutput: (output: ReplOutput | UpdatedFile) => void +): Promise { + try { + const res = await fetch(`${DART_PAD_API_BASE}/analyze`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ source }), + }); + + if (!res.ok) return; + + const data: AnalysisResponse = await res.json(); + if (Array.isArray(data.issues)) { + for (const issue of data.issues) { + const line = issue.location?.line ?? 1; + const column = issue.location?.column ?? 1; + const kindStr = (issue.kind || "info").toUpperCase(); + const correctionStr = issue.correction ? ` (${issue.correction})` : ""; + const formattedMsg = `[ANALYZER ${kindStr}] line ${line}:${column} - ${issue.message}${correctionStr}`; + + onOutput({ + type: issue.kind === "error" ? "error" : "stderr", + message: formattedMsg, + }); + } + } + } catch (err) { + console.warn("Failed to perform Dart static analysis:", err); + } +} + +export function useDart(): RuntimeContext { + const { init: dartInit, ready, dartVersion } = useContext(DartContext); + const onErrorRef = useRef(undefined); + const activeIframeRef = useRef(null); + + const init = useCallback( + (onError?: RuntimeErrorHandler) => { + onErrorRef.current = onError; + dartInit(onError); + }, + [dartInit] + ); + + const interrupt = useCallback(() => { + if (activeIframeRef.current) { + if (activeIframeRef.current.parentNode) { + activeIframeRef.current.parentNode.removeChild(activeIframeRef.current); + } + activeIframeRef.current = null; + } + }, []); + + const runFiles = useCallback( + async ( + filenames: string[], + files: Readonly>, + onOutput: (output: ReplOutput | UpdatedFile) => void + ) => { + if (typeof window === "undefined") { + onOutput({ + type: "error", + message: "Dart runtime requires browser environment.", + }); + return; + } + + const filename = filenames[0] ?? Object.keys(files)[0]; + const source = files[filename] ?? Object.values(files)[0]; + + if (!source) { + onOutput({ type: "error", message: "No source code provided to run." }); + return; + } + + try { + const [_, response] = await Promise.all([ + performAnalysis(source, onOutput), + fetch(`${DART_PAD_API_BASE}/compileNewDDC`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ source }), + }), + ]); + + if (!response.ok) { + const errorText = await response.text(); + onOutput({ + type: "error", + message: + errorText || + `Compilation failed with status ${response.status}`, + }); + return; + } + + const data = await response.json(); + if (!data.result) { + onOutput({ + type: "error", + message: "Compilation returned empty result.", + }); + return; + } + + const jsCode: string = data.result; + + // Execute compiled JS inside a temporary iframe + await new Promise((resolve) => { + const iframe = document.createElement("iframe"); + iframe.style.display = "none"; + document.body.appendChild(iframe); + activeIframeRef.current = iframe; + + let resolved = false; + const cleanup = () => { + if (resolved) return; + resolved = true; + window.removeEventListener("message", handleMessage); + if (activeIframeRef.current === iframe) { + activeIframeRef.current = null; + } + if (iframe.parentNode) { + iframe.parentNode.removeChild(iframe); + } + resolve(); + }; + + const handleMessage = (event: MessageEvent) => { + if (event.source !== iframe.contentWindow) return; + const msgData = event.data; + if (!msgData || msgData.sender !== "dart_frame") return; + + if (msgData.type === "stdout") { + onOutput({ type: "stdout", message: String(msgData.message) }); + } else if (msgData.type === "stderr") { + onOutput({ type: "stderr", message: String(msgData.message) }); + } else if (msgData.type === "done") { + cleanup(); + } else if (msgData.type === "error") { + onOutput({ type: "error", message: String(msgData.message) }); + cleanup(); + } + }; + + window.addEventListener("message", handleMessage); + + const iframeDoc = iframe.contentDocument; + if (!iframeDoc) { + onOutput({ + type: "error", + message: "Failed to access iframe document.", + }); + cleanup(); + return; + } + + const htmlContent = ` + + + + + + + +`; + + iframeDoc.open(); + iframeDoc.write(htmlContent); + iframeDoc.close(); + + setTimeout(() => { + cleanup(); + }, 15000); + }); + } catch (error) { + onErrorRef.current?.(error); + onOutput({ + type: "fatalError", + message: error instanceof Error ? error.message : String(error), + }); + } + }, + [] + ); + + const runtimeInfo = useMemo( + () => ({ + prettyLangName: "Dart", + version: dartVersion, + }), + [dartVersion] + ); + + return { + init, + ready, + runFiles, + interrupt, + getCommandlineStr, + runtimeInfo, + }; +} + +function getCommandlineStr(filenames: string[]) { + return `dart run ${filenames[0] ?? "main.dart"}`; +} diff --git a/packages/runtime/src/languages.ts b/packages/runtime/src/languages.ts index 3c2ceb72..2b4ff665 100644 --- a/packages/runtime/src/languages.ts +++ b/packages/runtime/src/languages.ts @@ -22,7 +22,8 @@ export type MarkdownLang = | "makefile" | "cmake" | "text" - | "txt"; + | "txt" + | "dart"; export type RuntimeLang = | "python" @@ -30,7 +31,8 @@ export type RuntimeLang = | "cpp" | "rust" | "javascript" - | "typescript"; + | "typescript" + | "dart"; export type LangConstants = { originalLang: MarkdownLang | undefined; @@ -50,7 +52,8 @@ export type LangConstants = { | "json" | "ini" | "makefile" - | "cmake"; + | "cmake" + | "dart"; } & ( | { // terminal/editor.tsx でimportする mode-xxxx.js のファイル名と、AceEditorの mode プロパティの値と対応する @@ -63,7 +66,8 @@ export type LangConstants = { | "typescript" | "json" | "csv" - | "text"; + | "text" + | "dart"; tabSize: number; } | { @@ -158,6 +162,14 @@ export function langConstants(lang: MarkdownLang | undefined): LangConstants { tabSize: 4, runtime: "rust", }; + case "dart": + return { + originalLang: lang, + rsh: "dart", + ace: "dart", + tabSize: 2, + runtime: "dart", + }; case "bash": case "sh": return { originalLang: lang, rsh: "bash" }; @@ -198,3 +210,4 @@ export function langConstants(lang: MarkdownLang | undefined): LangConstants { return { originalLang: lang }; } } + diff --git a/packages/runtime/tests/fileExecution.ts b/packages/runtime/tests/fileExecution.ts index ee1b9617..88542853 100644 --- a/packages/runtime/tests/fileExecution.ts +++ b/packages/runtime/tests/fileExecution.ts @@ -20,6 +20,7 @@ export const fileExecutionTests: Record< rust: ["test.rs", `fn main() {\n println!("${msg}");\n}\n`], javascript: ["test.js", `console.log("${msg}")`], typescript: ["test.ts", `console.log("${msg}")`], + dart: ["main.dart", `void main() {\n print("${msg}");\n}\n`], } satisfies Record )[lang]; if (!filename || !code) return null; @@ -53,6 +54,10 @@ export const fileExecutionTests: Record< rust: ["test_error.rs", `fn main() {\n panic!("${errorMsg}");\n}\n`], javascript: ["test_error.js", `throw new Error("${errorMsg}");\n`], typescript: ["test_error.ts", `throw new Error("${errorMsg}");\n`], + dart: [ + "test_error.dart", + `void main() {\n throw Exception("${errorMsg}");\n}\n`, + ], } satisfies Record )[lang]; if (!filename || !code) return null; @@ -114,6 +119,7 @@ export const fileExecutionTests: Record< ], javascript: [null, null], typescript: [null, null], + dart: [null, null], } satisfies Record< RuntimeLang, [Record, string[]] | [null, null] @@ -148,6 +154,7 @@ export const fileExecutionTests: Record< rust: [null, null], javascript: [null, null], typescript: [null, null], + dart: [null, null], } satisfies Record )[lang]; if (!filename || !code) return null; diff --git a/packages/runtime/tests/repl.ts b/packages/runtime/tests/repl.ts index e47d5da3..999ff2b4 100644 --- a/packages/runtime/tests/repl.ts +++ b/packages/runtime/tests/repl.ts @@ -19,6 +19,7 @@ export const replTests: Record TestBody | null> = rust: null, javascript: `console.log("${msg}")`, typescript: null, + dart: null, } satisfies Record )[lang]; if (!printCode) return null; @@ -49,6 +50,7 @@ export const replTests: Record TestBody | null> = `console.log(${varName})`, ], typescript: [null, null], + dart: [null, null], } satisfies Record )[lang]; if (!setIntVarCode || !printIntVarCode) return null; @@ -87,6 +89,7 @@ export const replTests: Record TestBody | null> = rust: null, javascript: `throw new Error("${errorMsg}")`, typescript: null, + dart: null, } satisfies Record )[lang]; if (!errorCode) return null; @@ -118,6 +121,7 @@ export const replTests: Record TestBody | null> = `console.log(testVar)`, ], typescript: [null, null, null], + dart: [null, null, null], } satisfies Record )[lang]; if (!setIntVarCode || !infLoopCode || !printIntVarCode) return null; @@ -168,6 +172,7 @@ export const replTests: Record TestBody | null> = rust: null, javascript: null, typescript: null, + dart: null, } satisfies Record )[lang]; if (!writeCode) return null; diff --git a/packages/runtime/tests/utils.ts b/packages/runtime/tests/utils.ts index f9878cb0..15c98d29 100644 --- a/packages/runtime/tests/utils.ts +++ b/packages/runtime/tests/utils.ts @@ -9,6 +9,7 @@ export const RUNTIME_TIMEOUTS: Record = { typescript: 2000, cpp: 10000, rust: 20000, + dart: 15000, }; export async function waitForRuntimeReady(