From 667624d0ca2be661adb0eef58a345cf3e86cbcee Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 29 Oct 2025 21:02:56 -0700 Subject: [PATCH] editor --- build-wasm.sh | 90 +++++++ rust/Cargo.lock | 14 + rust/Cargo.toml | 2 +- rust/rhai-codemirror/Cargo.toml | 22 ++ rust/rhai-codemirror/src/codemirror.rs | 64 +++++ rust/rhai-codemirror/src/lib.rs | 20 ++ rust/rhai-codemirror/src/rhai_mode.rs | 353 +++++++++++++++++++++++++ webui/src/components/Editor.tsx | 80 ++++-- webui/src/components/Playground.tsx | 2 +- webui/src/components/Terminal.tsx | 2 +- webui/src/styles/Editor.module.css | 21 ++ webui/src/utils/wasmLoader.ts | 38 +++ 12 files changed, 681 insertions(+), 27 deletions(-) create mode 100644 build-wasm.sh create mode 100644 rust/rhai-codemirror/Cargo.toml create mode 100644 rust/rhai-codemirror/src/codemirror.rs create mode 100644 rust/rhai-codemirror/src/lib.rs create mode 100644 rust/rhai-codemirror/src/rhai_mode.rs create mode 100644 webui/src/utils/wasmLoader.ts diff --git a/build-wasm.sh b/build-wasm.sh new file mode 100644 index 0000000..0bc52b1 --- /dev/null +++ b/build-wasm.sh @@ -0,0 +1,90 @@ +#!/bin/bash + +# Build script for all WASM libraries in the MMX repository + +set -e # Exit on any error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}🚀 Building all WASM libraries for MMX${NC}" + +# Set PATH to include cargo +export PATH="$HOME/.cargo/bin:$PATH" + +# Check if wasm-pack is installed +if ! command -v wasm-pack &> /dev/null; then + echo -e "${RED}❌ wasm-pack not found. Please install it first:${NC}" + echo "curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh" + exit 1 +fi + +# Change to rust directory +cd rust + +echo -e "${YELLOW}📋 Found WASM crates to build:${NC}" + +# List all WASM crates (crates with crate-type = ["cdylib"]) +wasm_crates=() +for dir in */; do + if [[ -f "$dir/Cargo.toml" ]]; then + if grep -q 'crate-type.*=.*\["cdylib"\]' "$dir/Cargo.toml"; then + wasm_crates+=("${dir%/}") + echo -e " • ${dir%/}" + fi + fi +done + +if [[ ${#wasm_crates[@]} -eq 0 ]]; then + echo -e "${YELLOW}⚠️ No WASM crates found${NC}" + exit 0 +fi + +echo "" + +# Build each WASM crate +for crate in "${wasm_crates[@]}"; do + echo -e "${BLUE}🔨 Building $crate...${NC}" + + cd "$crate" + + # Determine output directory based on crate name + case "$crate" in + "rhai-codemirror") + output_dir="../../webui/src/wasm/rhai-codemirror" + ;; + *) + output_dir="../../webui/src/wasm/$crate" + ;; + esac + + # Build with wasm-pack + if wasm-pack build --target web --out-dir "$output_dir"; then + echo -e "${GREEN}✅ Successfully built $crate${NC}" + else + echo -e "${RED}❌ Failed to build $crate${NC}" + exit 1 + fi + + cd .. + echo "" +done + +echo -e "${GREEN}🎉 All WASM libraries built successfully!${NC}" +echo "" +echo -e "${YELLOW}📦 Built libraries:${NC}" +for crate in "${wasm_crates[@]}"; do + case "$crate" in + "rhai-codemirror") + output_dir="../webui/src/wasm/rhai-codemirror" + ;; + *) + output_dir="../webui/src/wasm/$crate" + ;; + esac + echo -e " • $crate → $output_dir" +done \ No newline at end of file diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 1adb66d..fca27f2 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -314,6 +314,20 @@ dependencies = [ "thin-vec", ] +[[package]] +name = "rhai-codemirror" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "js-sys", + "rhai", + "serde", + "serde-wasm-bindgen", + "wasm-bindgen", + "web-sys", + "wee_alloc", +] + [[package]] name = "rhai_codegen" version = "3.1.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 985c307..aad3872 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["script-runner", "minimax"] +members = ["script-runner", "minimax", "rhai-codemirror"] resolver = "2" [workspace.dependencies] diff --git a/rust/rhai-codemirror/Cargo.toml b/rust/rhai-codemirror/Cargo.toml new file mode 100644 index 0000000..1bd1b22 --- /dev/null +++ b/rust/rhai-codemirror/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rhai-codemirror" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[profile.release] +lto = true +codegen-units = 1 +opt-level = 's' + +[dependencies] +rhai = { workspace = true, features = ["internals", "wasm-bindgen"] } +wasm-bindgen = { workspace = true } +js-sys = { workspace = true } +web-sys = { workspace = true, features = ["console"] } +console_error_panic_hook = { workspace = true } +wee_alloc = { workspace = true } +serde = { workspace = true } +serde-wasm-bindgen = { workspace = true } \ No newline at end of file diff --git a/rust/rhai-codemirror/src/codemirror.rs b/rust/rhai-codemirror/src/codemirror.rs new file mode 100644 index 0000000..6249f01 --- /dev/null +++ b/rust/rhai-codemirror/src/codemirror.rs @@ -0,0 +1,64 @@ +use js_sys::{Array, JsString, RegExp}; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(module = "codemirror")] +extern "C" { + pub type StringStream; + + #[wasm_bindgen(method)] + #[must_use] + pub fn eol(this: &StringStream) -> bool; + + #[wasm_bindgen(method)] + #[must_use] + pub fn sol(this: &StringStream) -> bool; + + #[wasm_bindgen(method)] + #[must_use] + pub fn peek(this: &StringStream) -> JsString; + + #[wasm_bindgen(method)] + pub fn next(this: &StringStream) -> JsString; + + #[wasm_bindgen(method, js_name = eat)] + pub fn eat_regexp(this: &StringStream, m: &RegExp) -> bool; + + #[wasm_bindgen(method, js_name = eatWhile)] + pub fn eat_while_regexp(this: &StringStream, m: &RegExp) -> bool; + + #[wasm_bindgen(method, js_name = eatSpace)] + pub fn eat_space(this: &StringStream) -> bool; + + #[wasm_bindgen(method, js_name = skipToEnd)] + pub fn skip_to_end(this: &StringStream); + + #[wasm_bindgen(method, js_name = skipTo)] + pub fn skip_to(this: &StringStream, str: &str) -> bool; + + #[wasm_bindgen(method, js_name = match)] + #[must_use] + pub fn match_str(this: &StringStream, pattern: &str, consume: bool, case_fold: bool) -> bool; + + #[wasm_bindgen(method, js_name = match)] + #[must_use] + pub fn match_regexp(this: &StringStream, pattern: &RegExp, consume: bool) -> Array; + + #[wasm_bindgen(method, js_name = backUp)] + pub fn back_up(this: &StringStream, n: u32); + + #[wasm_bindgen(method)] + #[must_use] + pub fn column(this: &StringStream) -> u32; + + #[wasm_bindgen(method)] + #[must_use] + pub fn indentation(this: &StringStream) -> u32; + + #[wasm_bindgen(method)] + #[must_use] + pub fn current(this: &StringStream) -> String; + + #[wasm_bindgen(method, js_name = lookAhead)] + #[must_use] + pub fn look_ahead(this: &StringStream, n: u32) -> Option; +} diff --git a/rust/rhai-codemirror/src/lib.rs b/rust/rhai-codemirror/src/lib.rs new file mode 100644 index 0000000..6ee28b0 --- /dev/null +++ b/rust/rhai-codemirror/src/lib.rs @@ -0,0 +1,20 @@ +use wasm_bindgen::prelude::*; + +mod codemirror; +mod rhai_mode; + +pub use rhai_mode::*; + +// Use `wee_alloc` as the global allocator for smaller WASM size. +#[global_allocator] +static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; + +#[wasm_bindgen(start)] +pub fn main_js() -> Result<(), JsValue> { + // This provides better error messages in debug mode. + // It's disabled in release mode so it doesn't bloat up the file size. + #[cfg(debug_assertions)] + console_error_panic_hook::set_once(); + + Ok(()) +} diff --git a/rust/rhai-codemirror/src/rhai_mode.rs b/rust/rhai-codemirror/src/rhai_mode.rs new file mode 100644 index 0000000..d4b14ed --- /dev/null +++ b/rust/rhai-codemirror/src/rhai_mode.rs @@ -0,0 +1,353 @@ +use crate::codemirror; +use js_sys::RegExp; +use std::cell::RefCell; +use wasm_bindgen::prelude::*; +use web_sys::console; + +#[wasm_bindgen] +pub struct RhaiMode { + indent_unit: u32, +} + +#[wasm_bindgen] +#[derive(Clone, Debug)] +pub struct State { + token_state: rhai::TokenizeState, + unclosed_bracket_count: i32, + line_indent: u32, + is_defining_identifier: bool, + /// Buffered character, if any. (For use by `StreamAdapter`.) + buf: Option, + /// Interpolated string brace counting stack + interpolated_str_brace_stack: Vec, +} + +thread_local! { + static ELECTRIC_INPUT: RegExp = RegExp::new("^\\s*[}\\])]$", ""); + static LINE_COMMENT: JsValue = JsValue::from_str("//"); + static CODEMIRROR_PASS: RefCell = RefCell::new(JsValue::null()); +} + +#[wasm_bindgen] +#[allow(dead_code)] +pub fn init_codemirror_pass(codemirror_pass: JsValue) { + CODEMIRROR_PASS.with(|v| v.replace(codemirror_pass)); +} + +#[wasm_bindgen] +impl RhaiMode { + #[wasm_bindgen(constructor)] + pub fn new(indent_unit: u32) -> Self { + Self { indent_unit } + } + + #[wasm_bindgen(js_name = startState)] + pub fn start_state(&self) -> State { + State { + token_state: rhai::TokenizeState { + include_comments: true, + ..Default::default() + }, + unclosed_bracket_count: 0, + line_indent: 0, + is_defining_identifier: false, + buf: None, + interpolated_str_brace_stack: vec![], + } + } + + #[wasm_bindgen(js_name = copyState)] + pub fn copy_state(&self, state: &State) -> State { + state.clone() + } + + pub fn token( + &self, + stream: codemirror::StringStream, + state: &mut State, + ) -> Result, JsValue> { + token(stream, state) + } + + pub fn indent(&self, state: &mut State, text_after: String) -> JsValue { + indent(self, state, text_after) + .map(JsValue::from) + .unwrap_or_else(|| CODEMIRROR_PASS.with(|v| v.borrow().clone())) + } + + #[wasm_bindgen(getter, js_name = electricInput)] + pub fn electric_input(&self) -> RegExp { + ELECTRIC_INPUT.with(|v| v.clone()) + } + + #[wasm_bindgen(getter, js_name = lineComment)] + pub fn line_comment(&self) -> JsValue { + LINE_COMMENT.with(|v| v.clone()) + } +} + +struct StreamAdapter { + /// Buffered character, if any. + buf: Option, + stream: codemirror::StringStream, +} + +impl rhai::InputStream for StreamAdapter { + fn unget(&mut self, ch: char) { + self.buf = Some(ch); + } + + fn get_next(&mut self) -> Option { + if let Some(ch) = self.buf.take() { + return Some(ch); + } + + let first = self.stream.next(); + if first.is_falsy() { + return None; + } + assert_eq!(first.length(), 1); + let first_code_unit = first.char_code_at(0) as u16; + if let Some(Ok(c)) = std::char::decode_utf16(std::iter::once(first_code_unit)).next() { + Some(c) + } else { + // The first value is likely an unpared surrogate, so we get one + // more UTF-16 unit to attempt to make a proper Unicode scalar. + let second = self.stream.next(); + if second.is_falsy() { + return Some(std::char::REPLACEMENT_CHARACTER); + } + assert_eq!(second.length(), 1); + let second_code_unit = second.char_code_at(0) as u16; + if let Some(Ok(c)) = + std::char::decode_utf16([first_code_unit, second_code_unit].iter().copied()).next() + { + Some(c) + } else { + // Turns out to not be a proper surrogate pair, so back up one + // unit for it to be decoded separately. + self.stream.back_up(1); + Some(std::char::REPLACEMENT_CHARACTER) + } + } + } + + fn peek_next(&mut self) -> Option { + if let Some(ch) = self.buf { + return Some(ch); + } + + let first = self.stream.peek(); + if first.is_falsy() { + return None; + } + assert_eq!(first.length(), 1); + let first_code_unit = first.char_code_at(0) as u16; + if let Some(Ok(c)) = std::char::decode_utf16(std::iter::once(first_code_unit)).next() { + Some(c) + } else { + // The first value is likely an unpared surrogate, so we get one more + // value to attempt to make a proper Unicode scalar value. + self.stream.next(); + let second = self.stream.peek(); + if second.is_falsy() { + return Some(std::char::REPLACEMENT_CHARACTER); + } + self.stream.back_up(1); + assert_eq!(second.length(), 1); + let second_code_unit = second.char_code_at(0) as u16; + if let Some(Ok(c)) = + std::char::decode_utf16([first_code_unit, second_code_unit].iter().copied()).next() + { + Some(c) + } else { + Some(std::char::REPLACEMENT_CHARACTER) + } + } + } +} + +fn token(stream: codemirror::StringStream, state: &mut State) -> Result, JsValue> { + if stream.sol() { + state.line_indent = stream.indentation(); + state.unclosed_bracket_count = 0; + } + + let mut stream_adapter = StreamAdapter { + stream, + buf: state.buf, + }; + let (next_token, _) = rhai::get_next_token( + &mut stream_adapter, + &mut state.token_state, + &mut rhai::Position::default(), + ); + state.buf = stream_adapter.buf; + match &next_token { + rhai::Token::LeftBrace + | rhai::Token::LeftBracket + | rhai::Token::LeftParen + | rhai::Token::MapStart => { + if state.unclosed_bracket_count < 0 { + state.unclosed_bracket_count = 0; + } + state.unclosed_bracket_count += 1; + } + rhai::Token::RightBrace | rhai::Token::RightBracket | rhai::Token::RightParen => { + state.unclosed_bracket_count -= 1; + } + _ => {} + }; + let res = match &next_token { + rhai::Token::IntegerConstant(_) => "number", + rhai::Token::FloatConstant(_) => "number", + rhai::Token::Identifier(_) => { + if state.is_defining_identifier { + "def" + } else { + "variable" + } + } + rhai::Token::CharConstant(_) => "string-2", + rhai::Token::StringConstant(_) => "string", + rhai::Token::InterpolatedString(_) => { + state.interpolated_str_brace_stack.push(0); + "string" + } + rhai::Token::LeftBrace => { + if let Some(brace_counting) = state.interpolated_str_brace_stack.last_mut() { + *brace_counting += 1; + } + "bracket" + } + rhai::Token::RightBrace => { + if let Some(brace_counting) = state.interpolated_str_brace_stack.last_mut() { + *brace_counting -= 1; + if *brace_counting == 0 { + state.interpolated_str_brace_stack.pop(); + state.token_state.is_within_text_terminated_by = Some("`".into()); + } + } + "bracket" + } + rhai::Token::LeftParen => "bracket", + rhai::Token::RightParen => "bracket", + rhai::Token::LeftBracket => "bracket", + rhai::Token::RightBracket => "bracket", + rhai::Token::QuestionBracket => "bracket", + rhai::Token::Unit => "bracket", // empty fn parens are parsed as this + rhai::Token::Plus => "operator", + rhai::Token::UnaryPlus => "operator", + rhai::Token::Minus => "operator", + rhai::Token::UnaryMinus => "operator", + rhai::Token::Multiply => "operator", + rhai::Token::Divide => "operator", + rhai::Token::Modulo => "operator", + rhai::Token::PowerOf => "operator", + rhai::Token::LeftShift => "operator", + rhai::Token::RightShift => "operator", + rhai::Token::SemiColon => "operator", + rhai::Token::Colon => "operator", + rhai::Token::DoubleColon => "operator", + rhai::Token::Comma => "operator", + rhai::Token::Period => "operator", + rhai::Token::ExclusiveRange => "operator", + rhai::Token::InclusiveRange => "operator", + rhai::Token::MapStart => "bracket", + rhai::Token::Equals => "operator", + rhai::Token::True => "builtin", + rhai::Token::False => "builtin", + rhai::Token::Let => "keyword", + rhai::Token::Const => "keyword", + rhai::Token::If => "keyword", + rhai::Token::Else => "keyword", + rhai::Token::While => "keyword", + rhai::Token::Loop => "keyword", + rhai::Token::For => "keyword", + rhai::Token::In => "keyword", + rhai::Token::NotIn => "keyword", + rhai::Token::LessThan => "operator", + rhai::Token::GreaterThan => "operator", + rhai::Token::LessThanEqualsTo => "operator", + rhai::Token::GreaterThanEqualsTo => "operator", + rhai::Token::EqualsTo => "operator", + rhai::Token::NotEqualsTo => "operator", + rhai::Token::Bang => "operator", + rhai::Token::Elvis => "operator", + rhai::Token::DoubleQuestion => "operator", + rhai::Token::Pipe => "operator", + rhai::Token::Or => "operator", + rhai::Token::XOr => "operator", + rhai::Token::Ampersand => "operator", + rhai::Token::And => "operator", + rhai::Token::Fn => "keyword", + rhai::Token::Continue => "keyword", + rhai::Token::Break => "keyword", + rhai::Token::Return => "keyword", + rhai::Token::Throw => "keyword", + rhai::Token::PlusAssign => "operator", + rhai::Token::MinusAssign => "operator", + rhai::Token::MultiplyAssign => "operator", + rhai::Token::DivideAssign => "operator", + rhai::Token::LeftShiftAssign => "operator", + rhai::Token::RightShiftAssign => "operator", + rhai::Token::AndAssign => "operator", + rhai::Token::OrAssign => "operator", + rhai::Token::XOrAssign => "operator", + rhai::Token::ModuloAssign => "operator", + rhai::Token::PowerOfAssign => "operator", + rhai::Token::Private => "keyword", + // Import/Export/As tokens not available in this Rhai version + rhai::Token::DoubleArrow => "operator", + rhai::Token::Underscore => "operator", + rhai::Token::Switch => "keyword", + rhai::Token::Do => "keyword", + rhai::Token::Until => "keyword", + rhai::Token::Try => "keyword", + rhai::Token::Catch => "keyword", + rhai::Token::Comment(_) => "comment", + rhai::Token::LexError(e) => { + console::log_1(&JsValue::from_str(&format!("LexError: {}", e))); + "error" + } + rhai::Token::Reserved(_) => "keyword", + // Custom token not available in this Rhai version + rhai::Token::EOF => return Ok(None), + token @ _ => { + console::log_1(&JsValue::from_str(&format!("Unhandled token {:?}", token))); + "error" + } + }; + match &next_token { + rhai::Token::Fn | rhai::Token::Let | rhai::Token::Const | rhai::Token::For => { + state.is_defining_identifier = true; + } + rhai::Token::Comment(_) => {} + _ => { + state.is_defining_identifier = false; + } + }; + Ok(Some(res.to_owned())) +} + +fn indent(mode: &RhaiMode, state: &State, text_after: String) -> Option { + let should_dedent = || { + text_after + .trim_start() + .starts_with(['}', ']', ')'].as_ref()) + }; + #[allow(clippy::collapsible_if)] + if state.unclosed_bracket_count > 0 { + if should_dedent() { + Some(state.line_indent) + } else { + Some(state.line_indent + mode.indent_unit) + } + } else { + if should_dedent() { + Some(state.line_indent.saturating_sub(mode.indent_unit)) + } else { + None + } + } +} diff --git a/webui/src/components/Editor.tsx b/webui/src/components/Editor.tsx index 4c14dab..203aa20 100644 --- a/webui/src/components/Editor.tsx +++ b/webui/src/components/Editor.tsx @@ -2,19 +2,51 @@ import { useEffect, useRef, forwardRef, useImperativeHandle, useState } from "react"; import styles from "@/styles/Editor.module.css"; +import { loadRhaiWasm, initRhaiMode } from "@/utils/wasmLoader"; // Dynamic import for CodeMirror to avoid SSR issues let CodeMirror: any = null; +let isCodeMirrorReady = false; + if (typeof window !== "undefined") { import("codemirror") .then(async (cm) => { CodeMirror = cm.default; - await import("codemirror/mode/javascript/javascript"); await import("codemirror/addon/edit/matchbrackets"); await import("codemirror/addon/edit/closebrackets"); await import("codemirror/addon/selection/active-line"); - await import("codemirror/lib/codemirror.css"); - await import("codemirror/theme/monokai.css"); + await import("codemirror/addon/comment/comment"); + // @ts-ignore - CodeMirror addon type issues + await import("codemirror/addon/fold/brace-fold"); + // @ts-ignore - CodeMirror addon type issues + await import("codemirror/addon/fold/foldgutter"); + // @ts-ignore - CodeMirror addon type issues + await import("codemirror/addon/search/match-highlighter"); + require("codemirror/lib/codemirror.css"); + require("codemirror/theme/monokai.css"); + require("codemirror/addon/fold/foldgutter.css"); + + try { + await loadRhaiWasm(); + initRhaiMode(CodeMirror); + console.log('✅ WASM-based Rhai mode initialized successfully'); + } catch (error) { + console.warn('⚠️ Failed to load WASM Rhai mode, falling back to JavaScript mode:', error); + // Fallback to JavaScript mode if WASM fails + CodeMirror.defineMode("rhai", (config: any) => { + const jsMode = CodeMirror.getMode(config, "javascript"); + return { + ...jsMode, + name: "rhai", + helperType: "rhai" + }; + }); + } + + isCodeMirrorReady = true; + }) + .catch(error => { + console.error('Failed to load CodeMirror:', error); }); } @@ -26,7 +58,7 @@ interface EditorProps { } export const Editor = forwardRef(function Editor( - { initialValue = "", onChange, onRequestRun, onReady }, + { initialValue = "", onChange, onReady }, ref, ) { const textareaRef = useRef(null); @@ -37,19 +69,30 @@ export const Editor = forwardRef(function Editor( // Initialize editor only once useEffect(() => { - if (!CodeMirror || !textareaRef.current || editorRef.current) return; + if (!isCodeMirrorReady || !CodeMirror || !textareaRef.current || editorRef.current) return; const editor = CodeMirror.fromTextArea(textareaRef.current, { lineNumbers: true, - mode: "javascript", // Placeholder mode, will be 'rhai' when WASM is integrated + mode: "rhai", theme: "monokai", + indentUnit: 4, matchBrackets: true, - autoCloseBrackets: true, + foldGutter: { + rangeFinder: CodeMirror.fold.brace, + }, + gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"], styleActiveLine: true, - extraKeys: { - "Ctrl-Enter": () => { - onRequestRun?.(); - }, + highlightSelectionMatches: { + minChars: 3, + showToken: true, + annotateScrollbar: true, + }, + rulers: [], + autoCloseBrackets: { + pairs: `()[]{}''""`, + closeBefore: `)]}'":;,`, + triples: "", + explode: "()[]{}", }, }); @@ -70,25 +113,14 @@ export const Editor = forwardRef(function Editor( editorRef.current = null; } }; - }, []); // Empty dependency array - only initialize once - - // Update keyboard shortcut when onRequestRun changes - useEffect(() => { - if (editorRef.current && onRequestRun) { - editorRef.current.setOption("extraKeys", { - "Ctrl-Enter": () => { - onRequestRun(); - }, - }); - } - }, [onRequestRun]); + }, []); // Only run once return (