diff --git a/README.md b/README.md index 9c910b7d7..fa069a694 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A [feature-rich](https://livecodes.io/docs/features/), open-source, **client-sid [![LiveCodes: npm version](https://img.shields.io/npm/v/livecodes)](https://www.npmjs.com/package/livecodes) [![LiveCodes: npm downloads](https://img.shields.io/npm/dm/livecodes)](https://www.npmjs.com/package/livecodes) [![LiveCodes: jsdelivr downloads](https://data.jsdelivr.com/v1/package/npm/livecodes/badge?style=rounded)](https://www.jsdelivr.com/package/npm/livecodes) -[![LiveCodes: languages](https://img.shields.io/badge/languages-101-blue)](https://livecodes.io/docs/languages/) +[![LiveCodes: languages](https://img.shields.io/badge/languages-102-blue)](https://livecodes.io/docs/languages/) [![LiveCodes: docs](https://img.shields.io/badge/Documentation-575757?logo=gitbook&logoColor=white)](https://livecodes.io/docs/) [![LiveCodes: llms.txt](https://img.shields.io/badge/llms.txt-575757?logo=googledocs&logoColor=white)](https://livecodes.io/docs/llms.txt) [![LiveCodes: llms-full.txt](https://img.shields.io/badge/llms--full.txt-575757?logo=googledocs&logoColor=white)](https://livecodes.io/docs/llms-full.txt) diff --git a/docs/docs/languages/rust-wasm.mdx b/docs/docs/languages/rust-wasm.mdx new file mode 100644 index 000000000..13c687808 --- /dev/null +++ b/docs/docs/languages/rust-wasm.mdx @@ -0,0 +1,101 @@ +# Rust (Wasm) + +Rust is a general-purpose programming language designed for performance and safety. + +In LiveCodes, Rust runs in the browser by interpreting the source with [Miri](https://github.com/rust-lang/miri) +(the Rust mid-level IR interpreter) compiled to WebAssembly. No backend is involved. + +## Usage + +Demo: + +import LiveCodes from '../../src/components/LiveCodes.tsx'; +export const rustConfig = { + activeEditor: 'script', + script: { + language: 'rust-wasm', + content: `use std::collections::HashMap;\n +fn main() { + let text = "the quick brown fox jumps over the lazy dog";\n + let mut counts: HashMap<&str, u32> = HashMap::new(); + for word in text.split_whitespace() { + *counts.entry(word).or_insert(0) += 1; + }\n + let mut words: Vec<(&str, u32)> = counts.into_iter().collect(); + words.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));\n + for (word, count) in &words { + println!("{word:<6} {count}"); + } +}`, + }, + mode: 'simple', + editor: 'auto', + tools: { + status: 'full', + }, +}; + + + +### Communication with JavaScript + +The Rust code runs in the context of the result page. A few helper properties and methods are available in the browser global `livecodes.rust` object: + +- `livecodes.rust.input`: The standard input passed to the Rust program. It can be set before the initial run, or passed to `run` for subsequent runs. +- `livecodes.rust.loaded`: A promise that resolves when the Rust environment (WebAssembly interpreter and standard library) is fully loaded (and rejects if it fails to load). Other helpers should be used after this promise resolves. +- `livecodes.rust.output`: The standard output from the Rust code execution. +- `livecodes.rust.exitCode`: The exit code of the last run. +- `livecodes.rust.run`: A function that runs the Rust code again with new input. It takes a string as input and returns a promise that resolves with an object containing the `output`, `error`, and `exitCode` properties. + +Example: + + + +## Language Info + +### Name + +`rust-wasm` + +### Aliases / Extensions + +`rust`, `rs`, `rust-wasm`, `rs-wasm`, `wasm.rs` + +### Editor + +`script` + +## Compiler + +[Miri](https://github.com/rust-lang/miri), the Rust mid-level IR interpreter, compiled to WebAssembly. + +### Version + +Miri built against Rust 1.79 (2024-06-15). + +## Code Formatting + +Using [Prettier](https://prettier.io) with the [Prettier Rust plugin](https://github.com/live-codes/prettier-plugin-rust). + +## Limitations + +- Programs are single-file. Crates and external dependencies are not supported. +- Execution is interpreted, so it is considerably slower than native Rust. +- Threads (`std::thread`) are not supported. +- Undefined-behaviour checks are disabled in this build, because a playground is interested in whether a program works rather than whether it is sound. + +## Live Reload + +By default, new code changes are sent to the result page for re-evaluation without a full page reload, avoiding the need to reinitialize the Rust WebAssembly environment. This behavior can be disabled by adding the code comment `// __livecodes_reload__` to the Rust code, which forces a full page reload. + +This comment can be added in the `hiddenContent` property of the editor for embedded playgrounds. + +## Starter Template + +https://livecodes.io/?template=rust-wasm + +## Links + +- [Rust](https://www.rust-lang.org/) +- [The Rust Book](https://doc.rust-lang.org/book/) +- [Miri](https://github.com/rust-lang/miri) diff --git a/docs/src/components/LanguageSliders.tsx b/docs/src/components/LanguageSliders.tsx index ab75efe4c..218b897f9 100644 --- a/docs/src/components/LanguageSliders.tsx +++ b/docs/src/components/LanguageSliders.tsx @@ -88,6 +88,8 @@ export default function Sliders() { { name: 'php-wasm', title: 'PHP (Wasm)' }, { name: 'cpp', title: 'C++' }, { name: 'cpp-wasm', title: 'C++ (Wasm)' }, + { name: 'rust-wasm', title: 'Rust (Wasm)' }, + { name: 'zig-wasm', title: 'Zig (Wasm)' }, { name: 'java', title: 'Java' }, { name: 'csharp-wasm', title: 'C# (Wasm)' }, { name: 'fsharp', title: 'F#' }, @@ -110,7 +112,6 @@ export default function Sliders() { { name: 'prolog', title: 'Prolog' }, { name: 'minizinc', title: 'MiniZinc' }, { name: 'blockly', title: 'Blockly' }, - { name: 'zig-wasm', title: 'Zig (Wasm)' }, ], }; const slides = ['markup', 'style', 'script']; diff --git a/docs/src/components/TemplateList.tsx b/docs/src/components/TemplateList.tsx index 933dc2252..0cd91bbec 100644 --- a/docs/src/components/TemplateList.tsx +++ b/docs/src/components/TemplateList.tsx @@ -47,6 +47,8 @@ const templates = [ { name: 'php-wasm', title: 'PHP (Wasm) Starter', thumbnail: 'php.svg' }, { name: 'cpp', title: 'C++ Starter', thumbnail: 'cpp.svg' }, { name: 'cpp-wasm', title: 'C++ (Wasm) Starter', thumbnail: 'cpp.svg' }, + { name: 'rust-wasm', title: 'Rust (Wasm) Starter', thumbnail: 'rust.svg' }, + { name: 'zig-wasm', title: 'Zig (Wasm) Starter', thumbnail: 'zig.svg' }, { name: 'java', title: 'Java Starter', thumbnail: 'java.svg' }, { name: 'csharp-wasm', title: 'C# (Wasm)', thumbnail: 'csharp.svg' }, { name: 'fsharp', title: 'F# Starter', thumbnail: 'fsharp.svg' }, @@ -71,7 +73,6 @@ const templates = [ { name: 'minizinc', title: 'MiniZinc Starter', thumbnail: 'minizinc.png' }, { name: 'blockly', title: 'Blockly Starter', thumbnail: 'blockly.svg' }, { name: 'diagrams', title: 'Diagrams Starter', thumbnail: 'diagrams.svg' }, - { name: 'zig-wasm', title: 'Zig (Wasm) Starter', thumbnail: 'zig.svg' }, ]; export default function TemplateList() { diff --git a/e2e/specs/starter.spec.ts b/e2e/specs/starter.spec.ts index d90bc48ec..8fe814fe2 100644 --- a/e2e/specs/starter.spec.ts +++ b/e2e/specs/starter.spec.ts @@ -242,6 +242,34 @@ test.describe('Starter Templates from UI', () => { expect(counterText).toBe('You clicked 3 times.'); }); + test('rust-wasm Starter', async ({ page, getTestUrl, editor }) => { + // the interpreter and the stdlib sysroot are downloaded on the first run + test.setTimeout(300_000); + + await page.goto(getTestUrl()); + + const { app, getResult, waitForResultUpdate } = await getLoadedApp(page); + + await app.click('[aria-label="Project"]'); + await app.click('text=New'); + await app.click('text=Rust (Wasm) Starter'); + + await waitForEditorFocus(app); + await waitForResultUpdate(); + + // the counter stays disabled until the runtime has loaded and run once + await expect(getResult().locator('#counter-button')).toBeEnabled({ timeout: 280_000 }); + + await getResult().click('text=Click me'); + await getResult().click('text=Click me'); + await getResult().click('text=Click me'); + + await expect(getResult().locator('h1')).toHaveText('Hello, Rust!'); + // Each click triggers an asynchronous run, so the counter is asserted with + // retries rather than read once. + await expect(getResult().locator('text=You clicked')).toHaveText('You clicked 3 times.'); + }); + test('d3 Starter', async ({ page, getTestUrl, editor }) => { test.slow(); diff --git a/functions/vendors/templates.js b/functions/vendors/templates.js index 20cea016a..4e7b01a08 100644 --- a/functions/vendors/templates.js +++ b/functions/vendors/templates.js @@ -46,6 +46,7 @@ export const starterTemplates = { "php-wasm": "PHP (Wasm) Starter", "cpp": "C++ Starter", "cpp-wasm": "C++ (Wasm) Starter", + "rust-wasm": "Rust (Wasm) Starter", "zig-wasm": "Zig (Wasm) Starter", "java": "Java Starter", "csharp-wasm": "C# (Wasm) Starter", diff --git a/scripts/build.js b/scripts/build.js index 665646348..9baae3708 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -257,6 +257,7 @@ const iifeBuild = () => 'languages/rescript/lang-rescript-formatter.ts', 'languages/riot/lang-riot-compiler.ts', 'languages/ruby-wasm/lang-ruby-wasm-script.ts', + 'languages/rust-wasm/lang-rust-wasm-script.ts', 'languages/scss/lang-scss-compiler.ts', 'languages/solid/lang-solid-compiler.ts', 'languages/sql/lang-sql-compiler.ts', diff --git a/server/php/inc/starter-templates.json b/server/php/inc/starter-templates.json index 0cd29ee21..230756f39 100644 --- a/server/php/inc/starter-templates.json +++ b/server/php/inc/starter-templates.json @@ -46,6 +46,7 @@ "php-wasm": "PHP (Wasm) Starter", "cpp": "C++ Starter", "cpp-wasm": "C++ (Wasm) Starter", + "rust-wasm": "Rust (Wasm) Starter", "zig-wasm": "Zig (Wasm) Starter", "java": "Java Starter", "csharp-wasm": "C# (Wasm) Starter", diff --git a/src/livecodes/UI/command-menu-actions.ts b/src/livecodes/UI/command-menu-actions.ts index 232bfe376..fdd62f649 100644 --- a/src/livecodes/UI/command-menu-actions.ts +++ b/src/livecodes/UI/command-menu-actions.ts @@ -302,6 +302,7 @@ export const getCommandMenuActions = ({ 'php-wasm', 'cpp', 'cpp-wasm', + 'rust-wasm', 'zig-wasm', 'java', 'csharp-wasm', diff --git a/src/livecodes/assets/templates/rust.svg b/src/livecodes/assets/templates/rust.svg new file mode 100644 index 000000000..b95ce42ae --- /dev/null +++ b/src/livecodes/assets/templates/rust.svg @@ -0,0 +1 @@ +Rust \ No newline at end of file diff --git a/src/livecodes/editor/codejar/codejar.ts b/src/livecodes/editor/codejar/codejar.ts index 18be340f1..01ca2d00c 100644 --- a/src/livecodes/editor/codejar/codejar.ts +++ b/src/livecodes/editor/codejar/codejar.ts @@ -262,7 +262,7 @@ export const createEditor = async (options: EditorOptions): Promise const newValue = await formatter(oldValue, offset, getFormatterConfig()); setValue(newValue.formatted); const newOffset = - newValue.cursorOffset != null && newValue.cursorOffset >= 0 ? newValue.cursorOffset : 0; + newValue.cursorOffset != null && newValue.cursorOffset >= 0 ? newValue.cursorOffset : offset; codejar?.restore({ start: newOffset, end: newOffset }); }; diff --git a/src/livecodes/editor/codemirror/codemirror.ts b/src/livecodes/editor/codemirror/codemirror.ts index a2210246d..e20ab28ef 100644 --- a/src/livecodes/editor/codemirror/codemirror.ts +++ b/src/livecodes/editor/codemirror/codemirror.ts @@ -435,7 +435,7 @@ export const createEditor = async (options: EditorOptions): Promise const newValue = await formatter(oldValue, offset, getFormatterConfig()); setValue(newValue.formatted, false); const newOffset = - newValue.cursorOffset != null && newValue.cursorOffset >= 0 ? newValue.cursorOffset : 0; + newValue.cursorOffset != null && newValue.cursorOffset >= 0 ? newValue.cursorOffset : offset; view.dispatch({ selection: { anchor: newOffset } }); }; diff --git a/src/livecodes/html/language-info.html b/src/livecodes/html/language-info.html index da69a65b2..a32dd9146 100644 --- a/src/livecodes/html/language-info.html +++ b/src/livecodes/html/language-info.html @@ -1137,6 +1137,37 @@

Nunjucks

+
+

Rust (Wasm)

+
+ Rust is interpreted by + Miri (the Rust + mid-level IR interpreter) compiled to WebAssembly, running entirely in the browser. +
+ +

Twig

diff --git a/src/livecodes/i18n/locales/en/language-info.lokalise.json b/src/livecodes/i18n/locales/en/language-info.lokalise.json index 7fe8c02dc..8db6f3dc8 100644 --- a/src/livecodes/i18n/locales/en/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/en/language-info.lokalise.json @@ -836,6 +836,18 @@ "notes": "", "translation": "Ruby (WASM)" }, + "rustWasm.desc": { + "notes": "### ###\n\n\n", + "translation": "Rust is interpreted by Miri (the Rust mid-level IR interpreter) compiled to WebAssembly, running entirely in the browser." + }, + "rustWasm.link": { + "notes": "### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n", + "translation": " Rust official website The Rust Book Learn X in Y minutes, where X=Rust LiveCodes Documentation Load starter template " + }, + "rustWasm.name": { + "notes": "", + "translation": "Rust (Wasm)" + }, "sass.desc": { "notes": "", "translation": "Syntactically Awesome Style Sheets." diff --git a/src/livecodes/i18n/locales/en/language-info.ts b/src/livecodes/i18n/locales/en/language-info.ts index a17ee49be..d273855ed 100644 --- a/src/livecodes/i18n/locales/en/language-info.ts +++ b/src/livecodes/i18n/locales/en/language-info.ts @@ -360,6 +360,11 @@ const languageInfo = { link: '<1> <2>Ruby official website <3> <4>Ruby documentation <5> <6>ruby.wasm website <7><8>CRuby <9> <10>Learn X in Y minutes, where X=ruby <11> <12>LiveCodes Documentations <13> <14>Load starter template ', name: 'Ruby (WASM)', }, + rustWasm: { + desc: 'Rust is interpreted by <1>Miri (the Rust mid-level IR interpreter) compiled to WebAssembly, running entirely in the browser.', + link: '<1> <2>Rust official website <3> <4>The Rust Book <5> <6>Learn X in Y minutes, where X=Rust <7> <8>LiveCodes Documentation <9> <10>Load starter template ', + name: 'Rust (Wasm)', + }, sass: { desc: 'Syntactically Awesome Style Sheets.', link: '<1> <2>Sass official website <3> <4>Sass documentation <5> <6>Sass (the indented) syntax <7> <8>Learn X in Y minutes, where X=sass ', diff --git a/src/livecodes/i18n/locales/en/translation.lokalise.json b/src/livecodes/i18n/locales/en/translation.lokalise.json index a9bc5f2ba..ab7991964 100644 --- a/src/livecodes/i18n/locales/en/translation.lokalise.json +++ b/src/livecodes/i18n/locales/en/translation.lokalise.json @@ -2784,6 +2784,10 @@ "notes": "", "translation": "Ruby (Wasm) Starter" }, + "templates.starter.rust-wasm": { + "notes": "", + "translation": "Rust (Wasm) Starter" + }, "templates.starter.scheme": { "notes": "", "translation": "Scheme Starter" diff --git a/src/livecodes/i18n/locales/en/translation.ts b/src/livecodes/i18n/locales/en/translation.ts index 085d53a53..3786a4b68 100644 --- a/src/livecodes/i18n/locales/en/translation.ts +++ b/src/livecodes/i18n/locales/en/translation.ts @@ -1042,6 +1042,7 @@ const translation = { riot: 'Riot.js Starter', ruby: 'Ruby Starter', 'ruby-wasm': 'Ruby (Wasm) Starter', + 'rust-wasm': 'Rust (Wasm) Starter', scheme: 'Scheme Starter', shadcnui: 'shadcn/ui Starter', solid: 'Solid Starter', diff --git a/src/livecodes/languages/languages.ts b/src/livecodes/languages/languages.ts index 2cdfec143..7ae04b272 100644 --- a/src/livecodes/languages/languages.ts +++ b/src/livecodes/languages/languages.ts @@ -65,6 +65,7 @@ import { richtext } from './richtext'; import { riot } from './riot'; import { ruby } from './ruby'; import { rubyWasm } from './ruby-wasm'; +import { rustWasm } from './rust-wasm'; import { scheme } from './scheme'; import { sass, scss } from './scss'; import { solid, solidTsx } from './solid'; @@ -153,6 +154,7 @@ export const languages: LanguageSpecs[] = [ phpWasm, cpp, cppWasm, + rustWasm, zigWasm, java, csharpWasm, diff --git a/src/livecodes/languages/prettier.ts b/src/livecodes/languages/prettier.ts index 45bcc30e1..1807517b0 100644 --- a/src/livecodes/languages/prettier.ts +++ b/src/livecodes/languages/prettier.ts @@ -1,4 +1,10 @@ -import { prettierBaseUrl, prettierMinizincUrl, prettierPhpUrl, vendorsBaseUrl } from '../vendors'; +import { + prettierBaseUrl, + prettierMinizincUrl, + prettierPhpUrl, + prettierRustUrl, + vendorsBaseUrl, +} from '../vendors'; export const prettierUrl = prettierBaseUrl + 'standalone.js'; export const parserPlugins = { @@ -12,4 +18,5 @@ export const parserPlugins = { minizinc: prettierMinizincUrl, pug: vendorsBaseUrl + 'prettier/parser-pug.js', java: vendorsBaseUrl + 'prettier/parser-java.js', + rust: prettierRustUrl, }; diff --git a/src/livecodes/languages/rust-wasm/index.ts b/src/livecodes/languages/rust-wasm/index.ts new file mode 100644 index 000000000..c813e7b57 --- /dev/null +++ b/src/livecodes/languages/rust-wasm/index.ts @@ -0,0 +1 @@ +export * from './lang-rust-wasm'; diff --git a/src/livecodes/languages/rust-wasm/lang-rust-wasm-script.ts b/src/livecodes/languages/rust-wasm/lang-rust-wasm-script.ts new file mode 100644 index 000000000..c7eb7ceff --- /dev/null +++ b/src/livecodes/languages/rust-wasm/lang-rust-wasm-script.ts @@ -0,0 +1,260 @@ +import { getWorkerDataURL } from '../../utils'; +import { rustWasmUrl, wasmRustcBaseUrl } from '../../vendors'; + +// Miri cannot be interrupted once it is running, so a run that exceeds this +// budget means the worker is discarded and a fresh one is spawned next time. +const RUN_TIMEOUT_MS = 30_000; +// The first run downloads ~55 MB of toolchain, so boot gets a much larger budget. +const BOOT_TIMEOUT_MS = 5 * 60_000; + +interface RunResult { + stdout: string; + stderr: string; + exitCode: number; + trap?: string | null; + durationMs: number; +} + +interface Pending { + resolve: (result: RunResult) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +declare const window: Window & { + livecodes: { + rust: { + ready?: boolean; + init?: Promise | null; + run?: ( + input?: string, + ) => Promise<{ output: string | null; error: string | null; exitCode: number }>; + loaded?: Promise; + input?: string; + output?: string | null; + stderr?: string | null; + exitCode?: number | null; + runner?: ReturnType; + }; + }; +}; + +function createRunner() { + let worker: Worker | null = null; + let ready: Promise | null = null; + let settleReady: ((error?: Error) => void) | null = null; + let bootTimer: ReturnType | null = null; + let pending: Record = {}; + let nextId = 1; + + const clearBootTimer = () => { + if (bootTimer !== null) { + clearTimeout(bootTimer); + bootTimer = null; + } + }; + + const teardown = () => { + clearBootTimer(); + worker?.terminate(); + worker = null; + ready = null; + settleReady = null; + }; + + const failAll = (error: Error) => { + for (const id of Object.keys(pending)) { + const p = pending[Number(id)]; + clearTimeout(p.timer); + p.reject(error); + } + pending = {}; + }; + + function onMessage(e: MessageEvent) { + const msg = e.data ?? {}; + if (msg.type === 'ready') { + clearBootTimer(); + settleReady?.(); + settleReady = null; + } else if (msg.type === 'result') { + const p = pending[msg.id]; + if (p) { + delete pending[msg.id]; + clearTimeout(p.timer); + p.resolve(msg); + } + } else if (msg.type === 'run-error') { + const p = pending[msg.id]; + if (p) { + delete pending[msg.id]; + clearTimeout(p.timer); + p.reject(new Error(msg.message)); + } + } else if (msg.type === 'error') { + // The runtime failed to load (network, or the browser is unsupported). + clearBootTimer(); + settleReady?.(new Error(msg.message)); + settleReady = null; + failAll(new Error(msg.message)); + } + } + + function onError(err: ErrorEvent) { + const error = new Error(`Rust worker crashed: ${err?.message ?? 'unknown error'}`); + clearBootTimer(); + settleReady?.(error); + settleReady = null; + failAll(error); + teardown(); + } + + async function spawn() { + ready = new Promise((resolve, reject) => { + settleReady = (error?: Error) => (error ? reject(error) : resolve()); + }); + const workerUrl = getWorkerDataURL(rustWasmUrl); + worker = new Worker(workerUrl); + worker.onmessage = onMessage; + worker.onerror = onError; + // `gzipped` is true because the published package stores the artifacts with + // a `.gz`suffix; unpkg serves them as-is and the worker inflates them. + worker.postMessage({ + type: 'init', + toolchain: { baseUrl: wasmRustcBaseUrl, gzipped: true }, + }); + + bootTimer = setTimeout(() => { + teardown(); + failAll(new Error('Timed out while loading the Rust toolchain.')); + }, BOOT_TIMEOUT_MS); + } + + /** Spawn the worker if needed and resolve once the toolchain is loaded. */ + async function ensureReady() { + if (!ready) await spawn(); + await ready; + } + + function run(code: string, input: string): Promise { + return ensureReady().then( + () => + new Promise((resolve, reject) => { + const id = nextId++; + const timer = setTimeout(() => { + delete pending[id]; + // Miri has no way to be preempted, so abandon the worker entirely. + teardown(); + failAll(new Error('Rust execution timed out; the interpreter was restarted.')); + }, RUN_TIMEOUT_MS); + pending[id] = { resolve, reject, timer }; + worker?.postMessage({ type: 'run', id, code, input }); + }), + ); + } + + return { ensureReady, run }; +} + +window.livecodes.rust ??= {}; + +const rust = window.livecodes.rust; +rust.ready = false; +rust.runner ??= createRunner(); + +/** Start (once) loading the interpreter and sysroot. */ +const ensureLoaded = (): Promise => { + let init = rust.init; + if (!init) { + init = (async () => { + parent.postMessage({ type: 'loading', payload: true }, '*'); + try { + await rust.runner?.ensureReady(); + } finally { + parent.postMessage({ type: 'loading', payload: false }, '*'); + } + })().catch((err: Error) => { + // Reset so a later run can retry the download. + rust.init = null; + throw err; + }); + // The failure is surfaced through `run`; do not also report it unhandled. + init.catch(() => undefined); + rust.init = init; + } + return init; +}; + +const setResult = (output: string | null, error: string | null, exitCode: number) => { + rust.output = output; + rust.stderr = error; + rust.exitCode = exitCode; + rust.ready = true; + + if (error != null) { + // eslint-disable-next-line no-console + console.error(error); + } else if (output != null) { + // eslint-disable-next-line no-console + console.log(output); + } + return { output, error, exitCode }; +}; + +rust.run = async (input?: string) => { + let code = ''; + document.querySelectorAll('script[type="text/rust-wasm"]').forEach((script) => { + code += `${script.innerHTML}\n`; + }); + rust.input = input; + + if (!code.trim()) return setResult(null, null, 0); + + try { + await ensureLoaded(); + } catch (err) { + return setResult(null, `Error: ${(err as Error).message}`, 1); + } + + try { + const result = await rust.runner!.run(code, `${input ?? ''}`); + const stdout = result.stdout ?? ''; + // Compiler diagnostics and a program's own stderr both arrive here, which is + // how rustc behaves natively, so they share one stream. + const stderr = (result.stderr ?? '').trim(); + + if (result.exitCode !== 0) { + return setResult( + stdout || null, + stderr || `Exited with code ${result.exitCode}`, + result.exitCode, + ); + } + + if (stderr) { + // Warnings on a successful run are worth surfacing, but are not errors. + // eslint-disable-next-line no-console + console.warn(stderr); + } + return setResult(stdout, null, 0); + } catch (err) { + return setResult(null, `Error: ${(err as Error).message}`, 1); + } +}; + +ensureLoaded(); + +rust.loaded = new Promise((resolve) => { + const interval = setInterval(() => { + if (rust.ready) { + clearInterval(interval); + resolve(); + } + }, 50); +}); + +window.addEventListener('load', async () => { + parent.postMessage({ type: 'loading', payload: true }, '*'); + await rust.run?.(rust.input); + parent.postMessage({ type: 'loading', payload: false }, '*'); +}); diff --git a/src/livecodes/languages/rust-wasm/lang-rust-wasm.ts b/src/livecodes/languages/rust-wasm/lang-rust-wasm.ts new file mode 100644 index 000000000..3dcb42cd6 --- /dev/null +++ b/src/livecodes/languages/rust-wasm/lang-rust-wasm.ts @@ -0,0 +1,33 @@ +import { codemirrorLegacy } from '../../editor/codemirror/utils'; +import type { LanguageSpecs } from '../../models'; +import { codeMirrorBaseUrl, monacoLanguagesBaseUrl } from '../../vendors'; +import { parserPlugins } from '../prettier'; + +export const rustWasm: LanguageSpecs = { + name: 'rust-wasm', + title: 'Rust (Wasm)', + formatter: { + prettier: { + name: 'rust', + pluginUrls: [parserPlugins.rust], + }, + }, + compiler: { + factory: () => async (code) => code, + scripts: ({ baseUrl }) => [baseUrl + '{{hash:lang-rust-wasm-script.js}}'], + scriptType: 'text/rust-wasm', + compiledCodeLanguage: 'rust', + liveReload: true, + }, + extensions: ['rs', 'rust', 'wasm.rs', 'rs-wasm'], + editor: 'script', + editorSupport: { + monaco: { languageSupport: monacoLanguagesBaseUrl + 'rust.js', language: 'rust' }, + codemirror: { + languageSupport: async () => + codemirrorLegacy((await import(codeMirrorBaseUrl + 'codemirror-lang-rust.js')).rust), + }, + codejar: { language: 'rust' }, + }, + largeDownload: true, +}; diff --git a/src/livecodes/models.ts b/src/livecodes/models.ts index 33b9fad0c..d22c84c47 100644 --- a/src/livecodes/models.ts +++ b/src/livecodes/models.ts @@ -99,6 +99,7 @@ export type ParserName = | 'php' | 'pug' | 'java' + | 'rust' | 'minizinc'; export interface PrettierParser { @@ -193,6 +194,8 @@ export interface Compiler { | 'text/x-uniter-php' | 'text/php-wasm' | 'text/cpp' + | 'text/rust-wasm' + | 'text/zig-wasm' | 'text/java' | 'text/csharp-wasm' | 'text/fsharp-wasm' @@ -204,7 +207,6 @@ export interface Compiler { | 'text/prolog' | 'text/minizinc' | 'text/go-wasm' - | 'text/zig-wasm' | 'application/json' | 'application/lua' | 'text/fennel' @@ -247,6 +249,8 @@ export type TemplateAlias = | 'fs' | 'f#-wasm' | 'fs-wasm' + | 'rust' + | 'rs' | 'pl' | 'lisp' | 'cljs' diff --git a/src/livecodes/templates/starter/index.ts b/src/livecodes/templates/starter/index.ts index 7ffaa6b4a..dd6711e6c 100644 --- a/src/livecodes/templates/starter/index.ts +++ b/src/livecodes/templates/starter/index.ts @@ -58,6 +58,7 @@ import { rescriptStarter } from './rescript-starter'; import { riotStarter } from './riot-starter'; import { rubyStarter } from './ruby-starter'; import { rubyWasmStarter } from './ruby-wasm-starter'; +import { rustWasmStarter } from './rust-wasm-starter'; import { schemeStarter } from './scheme-starter'; import { shadcnuiStarter } from './shadcn-ui-starter'; import { solidStarter } from './solid-starter'; @@ -121,6 +122,7 @@ export const starterTemplates = [ phpWasmStarter, cppStarter, cppWasmStarter, + rustWasmStarter, zigWasmStarter, javaStarter, csharpWasmStarter, diff --git a/src/livecodes/templates/starter/rust-wasm-starter.ts b/src/livecodes/templates/starter/rust-wasm-starter.ts new file mode 100644 index 000000000..38c648bcd --- /dev/null +++ b/src/livecodes/templates/starter/rust-wasm-starter.ts @@ -0,0 +1,93 @@ +import type { Template } from '../../models'; + +export const rustWasmStarter: Template = { + name: 'rust-wasm', + aliases: ['rust', 'rs'], + title: window.deps.translateString('templates.starter.rust-wasm', 'Rust (Wasm) Starter'), + thumbnail: 'assets/templates/rust.svg', + activeEditor: 'script', + markup: { + language: 'html', + content: ` +
    +

    Hello, World!

    + +

    You clicked 0 times.

    + +
    + + +`.trimStart(), + }, + style: { + language: 'css', + content: ` +.container, +.container button { + text-align: center; + font: 1em sans-serif; +} +.logo { + width: 150px; +} +`.trimStart(), + }, + script: { + language: 'rust-wasm', + content: ` +use std::io::BufRead; + +fn main() { + let title = "Rust"; + println!("{title}"); + + let mut input = String::new(); + let bytes = std::io::stdin().lock().read_line(&mut input).unwrap(); + let count: i64 = if bytes == 0 { + 0 + } else { + input.trim().parse().unwrap_or(0) + }; + + println!("{}", count + 1); +} +`.trimStart(), + }, +}; diff --git a/src/livecodes/vendors.ts b/src/livecodes/vendors.ts index 7aeab4c9c..1556615cf 100644 --- a/src/livecodes/vendors.ts +++ b/src/livecodes/vendors.ts @@ -324,7 +324,7 @@ export const monacoBaseUrl = /* @__PURE__ */ getUrl('@live-codes/monaco-editor@0 export const monacoEmacsUrl = /* @__PURE__ */ getUrl('monaco-emacs@0.3.0/dist/monaco-emacs.js'); export const monacoLanguagesBaseUrl = /* @__PURE__ */ getUrl( - '@live-codes/monaco-languages@0.3.1/dist/', + '@live-codes/monaco-languages@0.3.2/dist/', ); export const monacoThemesBaseUrl = /* @__PURE__ */ getUrl('monaco-themes@0.4.4/themes/'); @@ -369,6 +369,10 @@ export const prettierMinizincUrl = /* @__PURE__ */ getUrl( '@live-codes/prettier-plugin-minizinc@0.2.0/dist/standalone.js', ); +export const prettierRustUrl = /* @__PURE__ */ getUrl( + '@live-codes/prettier-plugin-rust@0.2.0/index.global.js', +); + export const prettierPhpUrl = /* @__PURE__ */ getUrl('@prettier/plugin-php@0.22.2/standalone.js'); export const prismBaseUrl = /* @__PURE__ */ getUrl('prismjs@1.29.0/components/'); @@ -429,6 +433,11 @@ export const rubyWasmScriptUrl = /* @__PURE__ */ getUrl( '@ruby/wasm-wasi@2.7.2/dist/browser.umd.js', ); +export const rustWasmUrl = /* @__PURE__ */ getUrl( + '@live-codes/rust-wasm@0.3.0/dist/worker.iife.js', +); +export const wasmRustcBaseUrl = /* @__PURE__ */ getUrl('@live-codes/wasm-rustc@0.2.0/'); + export const snackbarUrl = /* @__PURE__ */ getUrl('@snackbar/core@1.7.0/dist/snackbar.css'); export const spacingJsUrl = /* @__PURE__ */ getUrl('spacingjs@1.0.7/dist/bundle.js'); diff --git a/src/sdk/models.ts b/src/sdk/models.ts index 1ac43e0ab..3fbce38be 100644 --- a/src/sdk/models.ts +++ b/src/sdk/models.ts @@ -188,6 +188,11 @@ export type Language = | 'fs-wasm' | 'wasm.fs' | 'wasm.fsx' + | 'rust' + | 'rs' + | 'rust-wasm' + | 'rs-wasm' + | 'wasm.rs' | 'perl' | 'pl' | 'pm' @@ -407,6 +412,7 @@ export type TemplateName = | 'csharp-wasm' | 'fsharp' | 'fsharp-wasm' + | 'rust-wasm' | 'zig-wasm' | 'perl' | 'lua' diff --git a/storybook/_stories/EmbedOptions/template.ts b/storybook/_stories/EmbedOptions/template.ts index fe77280d5..2f6d51305 100644 --- a/storybook/_stories/EmbedOptions/template.ts +++ b/storybook/_stories/EmbedOptions/template.ts @@ -47,6 +47,8 @@ const storyDef: StoryDef = { PHPWasm: { props: { template: 'php-wasm' }, storyName: 'PHP Wasm' }, Cpp: { props: { template: 'cpp' } }, CppWasm: { props: { template: 'cpp-wasm' } }, + RustWasm: { props: { template: 'rust-wasm' } }, + ZigWasm: { props: { template: 'zig-wasm' } }, Java: { props: { template: 'java' } }, CSharpWasm: { props: { template: 'csharp-wasm' } }, FSharp: { props: { template: 'fsharp' } }, diff --git a/vendor-licenses.md b/vendor-licenses.md index b12fd8267..faae2c537 100644 --- a/vendor-licenses.md +++ b/vendor-licenses.md @@ -28,6 +28,8 @@ BiwaScheme: [MIT License](https://github.com/biwascheme/biwascheme/blob/7a95e757 Blockly: [Apache-2.0 License](https://github.com/google/blockly/blob/3ae4a618429c87fc002e512e5a2504af382325fd/LICENSE) +browser_wasi_shim: [MIT License](https://github.com/bjorn3/browser_wasi_shim/blob/b068ec2c22d68581c48f2592f8cca1681bf71a98/LICENSE-MIT) OR [Apache License 2.0](https://github.com/bjorn3/browser_wasi_shim/blob/b068ec2c22d68581c48f2592f8cca1681bf71a98/LICENSE-APACHE) + BrowserFS: [MIT License](https://github.com/jvilk/BrowserFS/blob/76fd5122fcf3ad6bff3315550aafb041cfb6a72e/license.md) brython: [BSD-3-Clause license](https://github.com/brython-dev/brython/blob/c579e26d7e24c37c77f00fc345af0248ca6be8eb/LICENCE.txt) @@ -156,6 +158,8 @@ MDX: [MIT License](https://github.com/mdx-js/mdx/blob/7fd1d9a4272754951e70dbaecf minizinc-js: [MPL-2.0 License](https://github.com/MiniZinc/minizinc-js/blob/3f7c34f0549195e5a66cf0f2d6f34cb5bce867f4/LICENSE) +Miri: [MIT License](https://github.com/rust-lang/miri/blob/92ea60bf0effd37aea533cd19c9702c43b90abe7/LICENSE-MIT) OR [Apache License 2.0](https://github.com/rust-lang/miri/blob/92ea60bf0effd37aea533cd19c9702c43b90abe7/LICENSE-APACHE) + MJML: [MIT License](https://github.com/mjmlio/mjml/blob/988819de3375867c09585d28f555166b97415200/LICENSE.md) Monaco-editor: [MIT License](https://github.com/microsoft/monaco-editor/blob/f849d3f2653d1097652a7d9e1d01d242cc225da8/LICENSE.md) @@ -246,6 +250,8 @@ Riot: [MIT License](https://github.com/riot/riot/blob/2b08ebf8c7fa3f338d24b7320e ruby.wasm: [MIT License](https://github.com/ruby/ruby.wasm/blob/097b7ca8d2ed2a98eea4dcf2da8089bb8ff06e07/LICENSE) +Rust (compiler and standard library): [MIT License](https://github.com/rust-lang/rust/blob/018018e881e2db0956f229dbb543e21f058d1ce7/LICENSE-MIT) OR [Apache License 2.0](https://github.com/rust-lang/rust/blob/018018e881e2db0956f229dbb543e21f058d1ce7/LICENSE-APACHE) + Sass.js: [MIT License](https://github.com/medialize/sass.js/blob/71d9bed2cad10969efda9905aa1bddacc480f372/LICENSE) SnackBar: [MIT License](https://github.com/egoist/snackbar/blob/4bc2fb7afd32d53a39661418fa5189dbb6e4aa86/LICENSE)