Skip to main content

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}