elly_wasm/lib.rs
1//! A `wasm32-unknown-unknown` wrapper exposing the [`elly`] parse + eval
2//! pipeline to JavaScript, with `IO` and imports through [`elly_host`].
3//!
4//! The `elly` and `muon` crates are `no_std`; this thin shim links `std` only
5//! for the allocator that `wasm32-unknown-unknown` provides, so no
6//! `wasm-bindgen` (or any post-processing tool) is needed — the `.wasm` is
7//! loaded directly.
8//!
9//! ## Session
10//!
11//! The module keeps one realm — an [`elly::Ctx`] and the `IO` built over it —
12//! for as long as the instance lives. Every `elly_run` evaluates in it, so the
13//! builtin modules keep their identity across runs and an imported module loads
14//! once. `IO.Proc.exit` ends the session: the host is expected to drop the
15//! instance and make a new one (see `elly_host_exit` below).
16//!
17//! ## ABI
18//!
19//! Strings cross the boundary as `(ptr, len)` into wasm linear memory:
20//!
21//! - `elly_alloc(len) -> ptr` — reserve `len` bytes for JS to write UTF-8 into.
22//! - `elly_run(ptr, len) -> out` — parse and evaluate those bytes;
23//! returns a pointer to a length-prefixed result: a little-endian `u32` byte
24//! length followed by that many bytes of UTF-8 JSON (see below).
25//! - `elly_free(ptr, len)` — release a block obtained from `elly_alloc`.
26//!
27//! The module imports its system surface from `elly_host` (the [`Sys`] shim):
28//!
29//! - `elly_host_write(stream, ptr, len)` — `IO.print`/`write` (stream 1) and
30//! `IO.error` (stream 2).
31//! - `elly_host_read(ptr, len, out_len) -> ptr` — the source of the module file
32//! at the path `(ptr, len)`, or `0` if there is none. The host allocates the
33//! block with `elly_alloc`, writes its length as a `u32` at `out_len`, and
34//! hands ownership over.
35//! - `elly_host_exit(code)` — `IO.Proc.exit`. It must not return: the host
36//! throws, which abandons the run and the instance with it.
37//!
38//! The source is one of two things:
39//!
40//! - an **expression**, resolved with `IO` in scope;
41//! - a **module** declaring `__main`, run the way the `elly` binary runs a
42//! program, with its imports resolved below `/lib` through
43//! [`elly_host::FsResolver`]. A source is taken as a module when it does not
44//! parse as an expression but does compile as a module.
45//!
46//! The JSON result is one of:
47//!
48//! - `{"ast":<expr>,"value":"<display>"}` — an expression, evaluated. `<expr>`
49//! is the tagged AST dump (`elly::Expr::to_json`).
50//! - `{"ast":<expr>,"exit":N}` — the expression ran `IO.Proc.return N`.
51//! - `{"ast":<expr>,"error":{"stage":"eval","raised":"<display>"}}` — parsed,
52//! but evaluation raised a value that unwound to the top level (an explicit
53//! `__Err.raise` or a host failure like `.div_by_zero`); the AST is still shown.
54//! - `{"module":true,…}` — the same `value` / `exit` / `error` shapes for a
55//! module's `__main`, without an AST, plus `"error":{"stage":"load","kind":"…"}`
56//! when the module or one of its imports would not load, or it has no `__main`.
57//! - `{"error":{"stage":"parse","kind":"…"}}` — a valid Muon tree but not a
58//! valid Elly program in this subset.
59//! - `{"error":{"stage":"syntax","offset":N,"kind":"…"}}` — the syntax layer
60//! (Muon) rejected the input.
61
62use std::cell::OnceCell;
63use std::path::{Component, Path, PathBuf};
64use std::rc::Rc;
65
66use elly_core as elly;
67use elly_core::{Ctx, Error, LoadError, Text};
68use elly_host::{FsResolver, Io, Run, Stream, Sys};
69
70/// Reserve `len` bytes of linear memory and hand ownership to the caller.
71#[no_mangle]
72pub extern "C" fn elly_alloc(len: usize) -> *mut u8 {
73 let mut buf = Vec::<u8>::with_capacity(len);
74 let ptr = buf.as_mut_ptr();
75 core::mem::forget(buf);
76 ptr
77}
78
79/// Release a block previously returned by [`elly_alloc`] (same `len`).
80///
81/// # Safety
82/// `ptr`/`len` must come from a prior `elly_alloc(len)` and not be freed twice.
83#[no_mangle]
84pub unsafe extern "C" fn elly_free(ptr: *mut u8, len: usize) {
85 drop(Vec::from_raw_parts(ptr, 0, len));
86}
87
88/// Run `len` UTF-8 bytes at `ptr` through the pipeline; return length-prefixed
89/// JSON.
90///
91/// # Safety
92/// `ptr`/`len` must describe a valid, initialized block of linear memory (e.g.
93/// one from [`elly_alloc`] that JS has filled). The returned block is owned by
94/// the caller and must be released with [`elly_free`].
95#[no_mangle]
96pub unsafe extern "C" fn elly_run(ptr: *const u8, len: usize) -> *mut u8 {
97 let input = core::slice::from_raw_parts(ptr, len);
98 let json = match core::str::from_utf8(input) {
99 Ok(src) => run(src),
100 Err(_) => r#"{"error":{"stage":"parse","offset":0,"kind":"NotUtf8"}}"#.to_string(),
101 };
102
103 // Frame as: [u32 little-endian length][UTF-8 bytes].
104 let bytes = json.into_bytes();
105 let mut out = Vec::with_capacity(4 + bytes.len());
106 out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
107 out.extend_from_slice(&bytes);
108 let p = out.as_mut_ptr();
109 core::mem::forget(out);
110 p
111}
112
113/// The directory a module's imports are searched in. The host serves files
114/// below it through `elly_host_read`.
115const LIB: &str = "/lib";
116
117/// The name a module typed into the playground is loaded under.
118const MAIN: &str = "main";
119
120thread_local! {
121 /// The realm every run evaluates in, made on first use.
122 static SESSION: OnceCell<Session> = const { OnceCell::new() };
123}
124
125fn run(src: &str) -> String {
126 SESSION.with(|session| session.get_or_init(Session::new).run(src).to_json())
127}
128
129/// One realm and the `IO` capability built over it. `elly_run` keeps one per
130/// instance; a native test makes its own.
131pub struct Session {
132 ctx: Ctx,
133 io: Io,
134}
135
136/// How one run of a source ended. Each kind maps to one shape of the JSON
137/// described in the module docs. The `stage` there mirrors the pipeline
138/// vocabulary: `syntax` (the Muon layer), `parse` (Muon tree → Elly AST),
139/// `load` (a module graph), `eval` (a raise that unwound to the top level).
140pub enum Outcome {
141 /// The syntax layer (Muon) rejected the source.
142 Syntax { offset: usize, kind: String },
143 /// Neither an expression nor a module.
144 Parse(String),
145 /// An expression ran. `ast` is its tagged JSON dump.
146 Expr { ast: String, run: Run },
147 /// A module's `__main` ran, or the module would not load, which includes
148 /// having no `__main`. A library is reported as the latter, so it never
149 /// appears as [`Run::Library`] here.
150 Module(Result<Run, String>),
151}
152
153impl Default for Session {
154 fn default() -> Self {
155 Session::new()
156 }
157}
158
159impl Session {
160 pub fn new() -> Session {
161 let sys: Rc<dyn Sys> = Rc::new(JsSys);
162 let resolver = FsResolver::new(sys.clone(), None, vec![PathBuf::from(LIB)]);
163 let ctx = Ctx::with_resolver(Box::new(resolver));
164 let io = elly_host::io(sys, &[], &ctx);
165 Session { ctx, io }
166 }
167
168 /// Run `src` as an expression with `IO` in scope or, failing that, as a
169 /// module.
170 pub fn run(&self, src: &str) -> Outcome {
171 let expr = match elly::parse_preluded(src, &[Text::from("IO")]) {
172 Ok(expr) => expr,
173 Err(Error::Parse(_)) if elly::compile_module(src).is_ok() => {
174 return Outcome::Module(self.run_module(src));
175 }
176 Err(Error::Syntax(e)) => {
177 return Outcome::Syntax {
178 offset: e.offset,
179 kind: format!("{:?}", e.kind),
180 }
181 }
182 Err(Error::Parse(e)) => return Outcome::Parse(format!("{e:?}")),
183 };
184 let env = elly::Env::EMPTY.bind(self.io.value.clone());
185 let run = self.io.finish(elly::eval_env(&expr, &env, &self.ctx));
186 Outcome::Expr {
187 ast: expr.to_json(),
188 run,
189 }
190 }
191
192 /// Load `src` as the module `main` — replacing the previous run's — and run
193 /// its `__main` against `IO`.
194 fn run_module(&self, src: &str) -> Result<Run, String> {
195 let instance = self
196 .ctx
197 .load_source(MAIN, src)
198 .map_err(|e| describe_load(&e))?;
199 match self.io.run(&instance, &self.ctx) {
200 Run::Library => Err("no `__main`: a library, not a program".to_string()),
201 run => Ok(run),
202 }
203 }
204}
205
206impl Outcome {
207 /// The JSON `elly_run` hands the page.
208 pub fn to_json(&self) -> String {
209 match self {
210 Outcome::Syntax { offset, kind } => format!(
211 r#"{{"error":{{"stage":"syntax","offset":{offset},"kind":{}}}}}"#,
212 json_string(kind)
213 ),
214 // The `{:?}` of a `ParseError` may embed a quoted payload (e.g.
215 // `UnboundName("__List_")`), so escape it as a JSON string rather than
216 // splicing the raw debug text — otherwise the inner quotes break the JSON.
217 Outcome::Parse(kind) => {
218 format!(
219 r#"{{"error":{{"stage":"parse","kind":{}}}}}"#,
220 json_string(kind)
221 )
222 }
223 Outcome::Expr { ast, run } => format!(r#"{{"ast":{ast},{}}}"#, run_json(run)),
224 Outcome::Module(Ok(run)) => format!(r#"{{"module":true,{}}}"#, run_json(run)),
225 Outcome::Module(Err(kind)) => format!(
226 r#"{{"module":true,"error":{{"stage":"load","kind":{}}}}}"#,
227 json_string(kind)
228 ),
229 }
230 }
231}
232
233/// The JSON members for how a run ended (everything but a library).
234fn run_json(run: &Run) -> String {
235 match run {
236 Run::Returned(value) => format!(r#""value":{}"#, json_string(&value.to_display())),
237 Run::Exit(code) => format!(r#""exit":{code}"#),
238 // A raise unwound to the top level: report the raised value's display.
239 Run::Raised(r) => format!(
240 r#""error":{{"stage":"eval","raised":{}}}"#,
241 json_string(&r.value().to_display())
242 ),
243 Run::Library => unreachable!("a library is reported as a load error"),
244 }
245}
246
247/// Why a module graph would not load, in one line.
248fn describe_load(e: &LoadError) -> String {
249 match e {
250 LoadError::NotFound { spec } => format!("cannot resolve import {spec:?} (searched {LIB})"),
251 LoadError::Cycle { path } => {
252 let names: Vec<&str> = path.iter().map(|n| n.as_str()).collect();
253 format!("import cycle: {}", names.join(" → "))
254 }
255 LoadError::Compile { name, error } => format!("{name}: {error:?}"),
256 }
257}
258
259/// [`Sys`] over the functions the page imports. There is no environment and no
260/// real file system: a path is normalized lexically, and whether a file exists
261/// is the host's to say when it is read.
262struct JsSys;
263
264impl Sys for JsSys {
265 fn write(&self, stream: Stream, s: &str) -> std::io::Result<()> {
266 let stream = match stream {
267 Stream::Out => 1,
268 Stream::Err => 2,
269 };
270 host::write(stream, s);
271 Ok(())
272 }
273
274 fn real_path(&self, path: &Path) -> Option<PathBuf> {
275 let mut real = PathBuf::from("/");
276 for c in path.components() {
277 match c {
278 Component::ParentDir => {
279 real.pop();
280 }
281 Component::Normal(part) => real.push(part),
282 Component::RootDir | Component::CurDir | Component::Prefix(_) => {}
283 }
284 }
285 Some(real)
286 }
287
288 fn read_source(&self, path: &Path) -> Option<String> {
289 host::read(path.to_str()?)
290 }
291
292 fn var(&self, _name: &str) -> Option<String> {
293 None
294 }
295
296 fn vars(&self) -> Vec<(String, String)> {
297 Vec::new()
298 }
299
300 fn exit(&self, code: i32) -> ! {
301 host::exit(code)
302 }
303}
304
305/// The page's side of [`JsSys`].
306#[cfg(target_arch = "wasm32")]
307mod host {
308 #[link(wasm_import_module = "elly_host")]
309 extern "C" {
310 fn elly_host_write(stream: u32, ptr: *const u8, len: usize);
311 fn elly_host_read(ptr: *const u8, len: usize, out_len: *mut usize) -> *mut u8;
312 fn elly_host_exit(code: i32);
313 }
314
315 pub fn write(stream: u32, s: &str) {
316 unsafe { elly_host_write(stream, s.as_ptr(), s.len()) }
317 }
318
319 pub fn read(path: &str) -> Option<String> {
320 let mut len = 0usize;
321 let ptr = unsafe { elly_host_read(path.as_ptr(), path.len(), &mut len) };
322 if ptr.is_null() {
323 return None;
324 }
325 // The host filled a block from `elly_alloc(len)` and handed it over.
326 let bytes = unsafe { Vec::from_raw_parts(ptr, len, len) };
327 String::from_utf8(bytes).ok()
328 }
329
330 pub fn exit(code: i32) -> ! {
331 unsafe { elly_host_exit(code) };
332 // The host throws instead of returning; if it did return, stop here.
333 core::arch::wasm32::unreachable()
334 }
335}
336
337/// Native builds have no page to import from. This side stands in for it, so
338/// a test can run the playground's examples (`tests/examples.rs`, which
339/// compiles this file in as a module): files come
340/// from a table the test sets, writes are recorded, and an exit unwinds with an
341/// [`Exit`](native::Exit) payload.
342#[cfg(not(target_arch = "wasm32"))]
343pub mod native {
344 use std::cell::RefCell;
345
346 use elly_host::Stream;
347
348 thread_local! {
349 static FILES: RefCell<Vec<(String, String)>> = const { RefCell::new(Vec::new()) };
350 static OUTPUT: RefCell<Vec<(Stream, String)>> = const { RefCell::new(Vec::new()) };
351 }
352
353 /// The panic payload `IO.Proc.exit` unwinds with.
354 #[derive(Debug)]
355 pub struct Exit(pub i32);
356
357 /// Serve `(path, source)` files to imports, as the page's `FILES` table does.
358 pub fn set_files(files: Vec<(String, String)>) {
359 FILES.with(|f| *f.borrow_mut() = files);
360 }
361
362 /// What was written since the last call, in order.
363 pub fn take_output() -> Vec<(Stream, String)> {
364 OUTPUT.with(|o| std::mem::take(&mut *o.borrow_mut()))
365 }
366
367 pub(crate) fn write(stream: u32, s: &str) {
368 let stream = if stream == 2 {
369 Stream::Err
370 } else {
371 Stream::Out
372 };
373 OUTPUT.with(|o| o.borrow_mut().push((stream, s.to_string())));
374 }
375
376 pub(crate) fn read(path: &str) -> Option<String> {
377 FILES.with(|f| {
378 f.borrow()
379 .iter()
380 .find(|(p, _)| p == path)
381 .map(|(_, src)| src.clone())
382 })
383 }
384
385 pub(crate) fn exit(code: i32) -> ! {
386 std::panic::panic_any(Exit(code))
387 }
388}
389
390#[cfg(not(target_arch = "wasm32"))]
391use native as host;
392
393/// Quote and escape `s` as a JSON string literal.
394fn json_string(s: &str) -> String {
395 let mut out = String::with_capacity(s.len() + 2);
396 out.push('"');
397 for ch in s.chars() {
398 match ch {
399 '"' => out.push_str("\\\""),
400 '\\' => out.push_str("\\\\"),
401 '\n' => out.push_str("\\n"),
402 '\r' => out.push_str("\\r"),
403 '\t' => out.push_str("\\t"),
404 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
405 c => out.push(c),
406 }
407 }
408 out.push('"');
409 out
410}