/home/noah/src/ruchy/src/wasm/notebook.rs
Line | Count | Source |
1 | | //! WebAssembly Notebook support for Ruchy |
2 | | //! |
3 | | //! Provides Jupyter-style notebook functionality in the browser. |
4 | | |
5 | | use std::collections::HashMap; |
6 | | |
7 | | #[cfg(target_arch = "wasm32")] |
8 | | use wasm_bindgen::prelude::*; |
9 | | |
10 | | #[cfg(not(target_arch = "wasm32"))] |
11 | | type JsValue = String; |
12 | | |
13 | | #[cfg(not(target_arch = "wasm32"))] |
14 | | use serde::{Serialize, Deserialize}; |
15 | | |
16 | | // ============================================================================ |
17 | | // Notebook Types |
18 | | // ============================================================================ |
19 | | |
20 | | #[derive(Debug, Clone)] |
21 | | #[cfg_attr(not(target_arch = "wasm32"), derive(Serialize, Deserialize))] |
22 | | pub struct NotebookCell { |
23 | | pub id: String, |
24 | | pub cell_type: CellType, |
25 | | pub source: String, |
26 | | pub outputs: Vec<CellOutput>, |
27 | | pub execution_count: Option<usize>, |
28 | | pub metadata: CellMetadata, |
29 | | } |
30 | | |
31 | | #[derive(Debug, Clone)] |
32 | | #[cfg_attr(not(target_arch = "wasm32"), derive(Serialize, Deserialize))] |
33 | | pub enum CellType { |
34 | | Code, |
35 | | Markdown, |
36 | | } |
37 | | |
38 | | #[derive(Debug, Clone)] |
39 | | #[cfg_attr(not(target_arch = "wasm32"), derive(Serialize, Deserialize))] |
40 | | pub enum CellOutput { |
41 | | Text(String), |
42 | | Html(String), |
43 | | Image { data: String, mime_type: String }, |
44 | | DataFrame(DataFrameOutput), |
45 | | Error { message: String, traceback: Vec<String> }, |
46 | | } |
47 | | |
48 | | #[derive(Debug, Clone)] |
49 | | #[cfg_attr(not(target_arch = "wasm32"), derive(Serialize, Deserialize))] |
50 | | pub struct DataFrameOutput { |
51 | | pub columns: Vec<String>, |
52 | | pub rows: Vec<Vec<String>>, |
53 | | pub shape: (usize, usize), |
54 | | } |
55 | | |
56 | | #[derive(Debug, Clone, Default)] |
57 | | #[cfg_attr(not(target_arch = "wasm32"), derive(Serialize, Deserialize))] |
58 | | pub struct CellMetadata { |
59 | | pub collapsed: bool, |
60 | | pub execution_time_ms: Option<f64>, |
61 | | pub tags: Vec<String>, |
62 | | } |
63 | | |
64 | | // ============================================================================ |
65 | | // Notebook Document |
66 | | // ============================================================================ |
67 | | |
68 | | #[derive(Debug, Clone)] |
69 | | #[cfg_attr(not(target_arch = "wasm32"), derive(Serialize, Deserialize))] |
70 | | pub struct Notebook { |
71 | | pub version: String, |
72 | | pub metadata: NotebookMetadata, |
73 | | pub cells: Vec<NotebookCell>, |
74 | | } |
75 | | |
76 | | #[derive(Debug, Clone)] |
77 | | #[cfg_attr(not(target_arch = "wasm32"), derive(Serialize, Deserialize))] |
78 | | pub struct NotebookMetadata { |
79 | | pub kernel: String, |
80 | | pub language: String, |
81 | | pub created: String, |
82 | | pub modified: String, |
83 | | pub ruchy_version: String, |
84 | | } |
85 | | |
86 | | // ============================================================================ |
87 | | // Notebook Runtime |
88 | | // ============================================================================ |
89 | | |
90 | | #[cfg_attr(target_arch = "wasm32", wasm_bindgen)] |
91 | | pub struct NotebookRuntime { |
92 | | notebook: Notebook, |
93 | | repl: crate::wasm::repl::WasmRepl, |
94 | | execution_count: usize, |
95 | | variables: HashMap<String, String>, |
96 | | } |
97 | | |
98 | | #[cfg_attr(target_arch = "wasm32", wasm_bindgen)] |
99 | | impl NotebookRuntime { |
100 | | /// Create a new notebook runtime |
101 | | #[cfg_attr(target_arch = "wasm32", wasm_bindgen(constructor))] |
102 | 0 | pub fn new() -> Result<NotebookRuntime, JsValue> { |
103 | | #[cfg(target_arch = "wasm32")] |
104 | | let repl = crate::wasm::repl::WasmRepl::new()?; |
105 | | #[cfg(not(target_arch = "wasm32"))] |
106 | 0 | let repl = crate::wasm::repl::WasmRepl::new().map_err(|_| "Error creating REPL".to_string())?; |
107 | 0 | Ok(NotebookRuntime { |
108 | 0 | notebook: Notebook { |
109 | 0 | version: "1.0.0".to_string(), |
110 | 0 | metadata: NotebookMetadata { |
111 | 0 | kernel: "wasm".to_string(), |
112 | 0 | language: "ruchy".to_string(), |
113 | 0 | created: current_timestamp(), |
114 | 0 | modified: current_timestamp(), |
115 | 0 | ruchy_version: env!("CARGO_PKG_VERSION").to_string(), |
116 | 0 | }, |
117 | 0 | cells: Vec::new(), |
118 | 0 | }, |
119 | 0 | repl, |
120 | 0 | execution_count: 0, |
121 | 0 | variables: HashMap::new(), |
122 | 0 | }) |
123 | 0 | } |
124 | | |
125 | | /// Add a new cell to the notebook |
126 | | #[cfg_attr(target_arch = "wasm32", wasm_bindgen)] |
127 | 0 | pub fn add_cell(&mut self, cell_type: &str, source: &str) -> String { |
128 | 0 | let id = generate_cell_id(); |
129 | 0 | let cell = NotebookCell { |
130 | 0 | id: id.clone(), |
131 | 0 | cell_type: match cell_type { |
132 | 0 | "markdown" => CellType::Markdown, |
133 | 0 | _ => CellType::Code, |
134 | | }, |
135 | 0 | source: source.to_string(), |
136 | 0 | outputs: Vec::new(), |
137 | 0 | execution_count: None, |
138 | 0 | metadata: CellMetadata::default(), |
139 | | }; |
140 | | |
141 | 0 | self.notebook.cells.push(cell); |
142 | 0 | self.notebook.metadata.modified = current_timestamp(); |
143 | | |
144 | 0 | id |
145 | 0 | } |
146 | | |
147 | | /// Execute a cell by ID |
148 | | #[cfg_attr(target_arch = "wasm32", wasm_bindgen)] |
149 | 0 | pub fn execute_cell(&mut self, cell_id: &str) -> Result<String, JsValue> { |
150 | 0 | let cell = self.notebook.cells.iter_mut() |
151 | 0 | .find(|c| c.id == cell_id) |
152 | 0 | .ok_or_else(|| { |
153 | | #[cfg(target_arch = "wasm32")] |
154 | | return JsValue::from_str("Cell not found"); |
155 | | #[cfg(not(target_arch = "wasm32"))] |
156 | 0 | return "Cell not found".to_string(); |
157 | 0 | })?; |
158 | | |
159 | 0 | match cell.cell_type { |
160 | | CellType::Code => { |
161 | 0 | let start = get_timestamp(); |
162 | | |
163 | | // Execute the code |
164 | 0 | let result = self.repl.eval(&cell.source).map_err(|e| { |
165 | | #[cfg(target_arch = "wasm32")] |
166 | | return e; |
167 | | #[cfg(not(target_arch = "wasm32"))] |
168 | 0 | return format!("Eval error: {e:?}"); |
169 | 0 | })?; |
170 | | |
171 | | // Update execution count |
172 | 0 | self.execution_count += 1; |
173 | 0 | cell.execution_count = Some(self.execution_count); |
174 | | |
175 | | // Parse result and create output |
176 | 0 | let output = if result.contains("error") { |
177 | 0 | CellOutput::Error { |
178 | 0 | message: result, |
179 | 0 | traceback: vec![], |
180 | 0 | } |
181 | | } else { |
182 | 0 | CellOutput::Text(result) |
183 | | }; |
184 | | |
185 | 0 | cell.outputs = vec![output]; |
186 | 0 | cell.metadata.execution_time_ms = Some(get_timestamp() - start); |
187 | | |
188 | 0 | Ok(serde_json::to_string(&cell).unwrap_or_else(|_| "Error".to_string())) |
189 | | } |
190 | | CellType::Markdown => { |
191 | | // Markdown cells don't execute |
192 | 0 | Ok(String::new()) |
193 | | } |
194 | | } |
195 | 0 | } |
196 | | |
197 | | /// Get all cells as JSON |
198 | | #[cfg_attr(target_arch = "wasm32", wasm_bindgen)] |
199 | 0 | pub fn get_cells(&self) -> String { |
200 | 0 | serde_json::to_string(&self.notebook.cells) |
201 | 0 | .unwrap_or_else(|_| "[]".to_string()) |
202 | 0 | } |
203 | | |
204 | | /// Save notebook to JSON |
205 | | #[cfg_attr(target_arch = "wasm32", wasm_bindgen)] |
206 | 0 | pub fn to_json(&self) -> String { |
207 | 0 | serde_json::to_string(&self.notebook) |
208 | 0 | .unwrap_or_else(|_| "{}".to_string()) |
209 | 0 | } |
210 | | |
211 | | /// Load notebook from JSON |
212 | | #[cfg_attr(target_arch = "wasm32", wasm_bindgen)] |
213 | 0 | pub fn from_json(&mut self, json: &str) -> Result<(), JsValue> { |
214 | 0 | let notebook: Notebook = serde_json::from_str(json) |
215 | 0 | .map_err(|e| { |
216 | | #[cfg(target_arch = "wasm32")] |
217 | | return JsValue::from_str(&format!("Parse error: {}", e)); |
218 | | #[cfg(not(target_arch = "wasm32"))] |
219 | 0 | return format!("Parse error: {e}"); |
220 | 0 | })?; |
221 | 0 | self.notebook = notebook; |
222 | 0 | Ok(()) |
223 | 0 | } |
224 | | } |
225 | | |
226 | | impl Default for NotebookRuntime { |
227 | 0 | fn default() -> Self { |
228 | 0 | Self::new().unwrap() |
229 | 0 | } |
230 | | } |
231 | | |
232 | | // ============================================================================ |
233 | | // Helper Functions |
234 | | // ============================================================================ |
235 | | |
236 | 0 | fn generate_cell_id() -> String { |
237 | 0 | format!("cell-{}", get_timestamp().abs() as u64) |
238 | 0 | } |
239 | | |
240 | 0 | fn current_timestamp() -> String { |
241 | | #[cfg(target_arch = "wasm32")] |
242 | | { |
243 | | js_sys::Date::new_0().to_iso_string().as_string().unwrap() |
244 | | } |
245 | | #[cfg(not(target_arch = "wasm32"))] |
246 | | { |
247 | 0 | chrono::Utc::now().to_rfc3339() |
248 | | } |
249 | 0 | } |
250 | | |
251 | 0 | fn get_timestamp() -> f64 { |
252 | | #[cfg(target_arch = "wasm32")] |
253 | | { |
254 | | js_sys::Date::now() |
255 | | } |
256 | | #[cfg(not(target_arch = "wasm32"))] |
257 | | { |
258 | 0 | std::time::SystemTime::now() |
259 | 0 | .duration_since(std::time::UNIX_EPOCH) |
260 | 0 | .unwrap() |
261 | 0 | .as_millis() as f64 |
262 | | } |
263 | 0 | } |
264 | | |
265 | | // ============================================================================ |
266 | | // WASM/Native Feature Parity |
267 | | // ============================================================================ |
268 | | |
269 | | /// Feature availability in WASM vs Native |
270 | | #[derive(Debug, Clone)] |
271 | | pub struct FeatureParity { |
272 | | pub feature: String, |
273 | | pub native_support: bool, |
274 | | pub wasm_support: bool, |
275 | | pub notes: String, |
276 | | } |
277 | | |
278 | | impl FeatureParity { |
279 | 0 | pub fn check_all() -> Vec<FeatureParity> { |
280 | 0 | vec![ |
281 | 0 | FeatureParity { |
282 | 0 | feature: "Basic Evaluation".to_string(), |
283 | 0 | native_support: true, |
284 | 0 | wasm_support: true, |
285 | 0 | notes: "Full support".to_string(), |
286 | 0 | }, |
287 | 0 | FeatureParity { |
288 | 0 | feature: "File I/O".to_string(), |
289 | 0 | native_support: true, |
290 | 0 | wasm_support: false, |
291 | 0 | notes: "WASM uses OPFS or IndexedDB".to_string(), |
292 | 0 | }, |
293 | 0 | FeatureParity { |
294 | 0 | feature: "Threading".to_string(), |
295 | 0 | native_support: true, |
296 | 0 | wasm_support: false, |
297 | 0 | notes: "WASM uses Web Workers".to_string(), |
298 | 0 | }, |
299 | 0 | FeatureParity { |
300 | 0 | feature: "Networking".to_string(), |
301 | 0 | native_support: true, |
302 | 0 | wasm_support: false, |
303 | 0 | notes: "WASM limited to Fetch API".to_string(), |
304 | 0 | }, |
305 | 0 | FeatureParity { |
306 | 0 | feature: "DataFrames".to_string(), |
307 | 0 | native_support: true, |
308 | 0 | wasm_support: true, |
309 | 0 | notes: "Limited size in WASM".to_string(), |
310 | 0 | }, |
311 | | ] |
312 | 0 | } |
313 | | } |
314 | | |
315 | | #[cfg(test)] |
316 | | mod tests { |
317 | | use super::*; |
318 | | |
319 | | #[test] |
320 | | fn test_notebook_creation() { |
321 | | let runtime = NotebookRuntime::new(); |
322 | | assert!(runtime.is_ok()); |
323 | | } |
324 | | |
325 | | #[test] |
326 | | fn test_add_cell() { |
327 | | let mut runtime = NotebookRuntime::new().unwrap(); |
328 | | let id = runtime.add_cell("code", "let x = 42"); |
329 | | assert!(id.starts_with("cell-")); |
330 | | assert_eq!(runtime.notebook.cells.len(), 1); |
331 | | } |
332 | | |
333 | | #[test] |
334 | | fn test_feature_parity() { |
335 | | let features = FeatureParity::check_all(); |
336 | | assert!(!features.is_empty()); |
337 | | |
338 | | // Check that basic evaluation is supported in both |
339 | | let basic = features.iter() |
340 | | .find(|f| f.feature == "Basic Evaluation") |
341 | | .unwrap(); |
342 | | assert!(basic.native_support); |
343 | | assert!(basic.wasm_support); |
344 | | } |
345 | | } |