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::Ctx::to_json`).
50//! - `{"ast":<expr>,"exit":N}` — the expression ran `IO.Proc.return N`.
51//! - `{"ast":<expr>,"error":{"stage":"eval","raised":"<display>","report":"…"}}`
52//!   — parsed, but evaluation raised a value that unwound to the top level (an
53//!   explicit `__Err.raise` or a host failure like `.div_by_zero`); the AST is
54//!   still shown. `report` lists the calls it unwound through, and for a
55//!   `.no_match` what refuted which value, each at its line
56//!   (`elly::Ctx::report_raised`).
57//! - `{"module":true,…}` — the same `value` / `exit` / `error` shapes for a
58//!   module's `__main`, without an AST, plus `"error":{"stage":"load","kind":"…"}`
59//!   when the module or one of its imports would not load, or it has no `__main`.
60//!   A module that would not compile is reported in `kind` as `report` is below.
61//! - `{"error":{"stage":"parse","offset":N,"length":L,"kind":"…","report":"…"}}`
62//!   — a valid Muon tree but not a valid Elly program in this subset, at the
63//!   `L` bytes from byte offset `N`. `report` is the error with the source
64//!   line it is on, the span underlined (`elly::Error::report`).
65//! - `{"error":{"stage":"syntax","offset":N,"kind":"…","report":"…"}}` — the
66//!   syntax layer (Muon) rejected the input at byte offset `N`.
67
68use std::cell::OnceCell;
69use std::path::{Component, Path, PathBuf};
70use std::rc::Rc;
71
72use elly_core as elly;
73use elly_core::{Ctx, DocSource, Error, LoadError, ReportStyle, Text};
74use elly_host::{FsResolver, Io, Run, Stream, Sys};
75
76/// Reserve `len` bytes of linear memory and hand ownership to the caller.
77#[no_mangle]
78pub extern "C" fn elly_alloc(len: usize) -> *mut u8 {
79    let mut buf = Vec::<u8>::with_capacity(len);
80    let ptr = buf.as_mut_ptr();
81    core::mem::forget(buf);
82    ptr
83}
84
85/// Release a block previously returned by [`elly_alloc`] (same `len`).
86///
87/// # Safety
88/// `ptr`/`len` must come from a prior `elly_alloc(len)` and not be freed twice.
89#[no_mangle]
90pub unsafe extern "C" fn elly_free(ptr: *mut u8, len: usize) {
91    drop(Vec::from_raw_parts(ptr, 0, len));
92}
93
94/// Run `len` UTF-8 bytes at `ptr` through the pipeline; return length-prefixed
95/// JSON.
96///
97/// # Safety
98/// `ptr`/`len` must describe a valid, initialized block of linear memory (e.g.
99/// one from [`elly_alloc`] that JS has filled). The returned block is owned by
100/// the caller and must be released with [`elly_free`].
101#[no_mangle]
102pub unsafe extern "C" fn elly_run(ptr: *const u8, len: usize) -> *mut u8 {
103    let input = core::slice::from_raw_parts(ptr, len);
104    let json = match core::str::from_utf8(input) {
105        Ok(src) => run(src),
106        Err(_) => r#"{"error":{"stage":"parse","offset":0,"kind":"NotUtf8"}}"#.to_string(),
107    };
108
109    // Frame as: [u32 little-endian length][UTF-8 bytes].
110    let bytes = json.into_bytes();
111    let mut out = Vec::with_capacity(4 + bytes.len());
112    out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
113    out.extend_from_slice(&bytes);
114    let p = out.as_mut_ptr();
115    core::mem::forget(out);
116    p
117}
118
119/// The directory a module's imports are searched in. The host serves files
120/// below it through `elly_host_read`.
121const LIB: &str = "/lib";
122
123/// The name a module typed into the playground is loaded under, which names its
124/// chunk too. `__` keeps it apart from a library module `main.ly`, and it has
125/// no number since only one run is alive at a time.
126const EVAL: &str = "__eval";
127
128/// The name a report gives the source being run.
129const INPUT: &str = "<input>";
130
131thread_local! {
132    /// The realm every run evaluates in, made on first use.
133    static SESSION: OnceCell<Session> = const { OnceCell::new() };
134}
135
136fn run(src: &str) -> String {
137    SESSION.with(|session| {
138        let session = session.get_or_init(Session::new);
139        session.run(src).to_json(session.ctx(), src)
140    })
141}
142
143/// One realm and the `IO` capability built over it. `elly_run` keeps one per
144/// instance; a native test makes its own.
145pub struct Session {
146    ctx: Ctx,
147    io: Io,
148}
149
150/// How one run of a source ended. Each kind maps to one shape of the JSON
151/// described in the module docs. The `stage` there mirrors the pipeline
152/// vocabulary: `syntax` (the Muon layer), `parse` (Muon tree → Elly AST),
153/// `load` (a module graph), `eval` (a raise that unwound to the top level).
154pub enum Outcome {
155    /// The syntax layer (Muon) rejected the source. `report` shows it at its
156    /// line.
157    Syntax {
158        offset: usize,
159        kind: String,
160        report: String,
161    },
162    /// Neither an expression nor a module.
163    Parse {
164        offset: u32,
165        length: u32,
166        kind: String,
167        report: String,
168    },
169    /// An expression ran. `ast` is its tagged JSON dump, and `chunk` the
170    /// name of the chunk it was parsed into.
171    Expr {
172        ast: String,
173        chunk: String,
174        run: Run,
175    },
176    /// A module's `__main` ran, or the module would not load, which includes
177    /// having no `__main`. A library is reported as the latter, so it never
178    /// appears as [`Run::Library`] here.
179    Module(Result<Run, String>),
180}
181
182impl Default for Session {
183    fn default() -> Self {
184        Session::new()
185    }
186}
187
188impl Session {
189    pub fn new() -> Session {
190        let sys: Rc<dyn Sys> = Rc::new(JsSys);
191        let resolver = FsResolver::new(sys.clone(), None, vec![PathBuf::from(LIB)]);
192        let ctx = Ctx::with_resolver(Box::new(resolver));
193        let io = elly_host::io(sys, &[], &ctx);
194        Session { ctx, io }
195    }
196
197    /// The realm the session runs in, which renders what its runs return.
198    pub fn ctx(&self) -> &Ctx {
199        &self.ctx
200    }
201
202    /// Run `src` as an expression with `IO` in scope or, failing that, as a
203    /// module.
204    pub fn run(&self, src: &str) -> Outcome {
205        let expr = match self.ctx.parse_preluded(src, &[Text::from("IO")]) {
206            Ok(expr) => expr,
207            Err(Error::Parse(..)) if self.ctx.compile_module(src).is_ok() => {
208                return Outcome::Module(self.run_module(src));
209            }
210            Err(error @ Error::Syntax(_)) => {
211                let Error::Syntax(e) = &error else {
212                    unreachable!()
213                };
214                return Outcome::Syntax {
215                    offset: e.offset,
216                    kind: format!("{:?}", e.kind),
217                    report: error.report(INPUT, src),
218                };
219            }
220            Err(error @ Error::Parse(..)) => {
221                let Error::Parse(e, span) = &error else {
222                    unreachable!()
223                };
224                return Outcome::Parse {
225                    offset: span.offset,
226                    length: span.length,
227                    kind: format!("{e:?}"),
228                    report: error.report(INPUT, src),
229                };
230            }
231        };
232        let env = elly::Env::EMPTY.bind(self.io.value.clone());
233        let run = self
234            .io
235            .finish(elly::eval_env(expr, &env, &self.ctx), &self.ctx);
236        Outcome::Expr {
237            ast: self.ctx.to_json(expr),
238            chunk: self.ctx.chunk(expr).name().to_string(),
239            run,
240        }
241    }
242
243    /// Load `src` as the module `__eval` — replacing the previous run's — and run
244    /// its `__main` against `IO`.
245    fn run_module(&self, src: &str) -> Result<Run, String> {
246        let instance = self
247            .ctx
248            .load_source(EVAL, src)
249            .map_err(|e| describe_load(&e))?;
250        match self.io.run(&instance, &self.ctx) {
251            Run::Library => Err("no `__main`: a library, not a program".to_string()),
252            run => Ok(run),
253        }
254    }
255}
256
257impl Outcome {
258    /// The JSON `elly_run` hands the page, rendering values in `ctx`, the
259    /// session's realm, and reporting a raise in `src`, the source run.
260    pub fn to_json(&self, ctx: &Ctx, src: &str) -> String {
261        match self {
262            Outcome::Syntax {
263                offset,
264                kind,
265                report,
266            } => format!(
267                r#"{{"error":{{"stage":"syntax","offset":{offset},"kind":{},"report":{}}}}}"#,
268                json_string(kind),
269                json_string(report)
270            ),
271            // The `{:?}` of a `ParseError` may embed a quoted payload (e.g.
272            // `UnboundName("__List_")`), so escape it as a JSON string rather than
273            // splicing the raw debug text — otherwise the inner quotes break the JSON.
274            Outcome::Parse {
275                offset,
276                length,
277                kind,
278                report,
279            } => format!(
280                r#"{{"error":{{"stage":"parse","offset":{offset},"length":{length},"kind":{},"report":{}}}}}"#,
281                json_string(kind),
282                json_string(report)
283            ),
284            Outcome::Expr { ast, chunk, run } => {
285                format!(r#"{{"ast":{ast},{}}}"#, run_json(run, ctx, src, chunk))
286            }
287            Outcome::Module(Ok(run)) => {
288                format!(r#"{{"module":true,{}}}"#, run_json(run, ctx, src, EVAL))
289            }
290            Outcome::Module(Err(kind)) => format!(
291                r#"{{"module":true,"error":{{"stage":"load","kind":{}}}}}"#,
292                json_string(kind)
293            ),
294        }
295    }
296}
297
298/// The JSON members for how a run ended (everything but a library). `src`
299/// is the source run, parsed into the chunk named `input`.
300fn run_json(run: &Run, ctx: &Ctx, src: &str, input: &str) -> String {
301    match run {
302        Run::Returned(value) => format!(r#""value":{}"#, json_string(&value.to_display(ctx))),
303        Run::Exit(code) => format!(r#""exit":{code}"#),
304        // A raise unwound to the top level: the raised value's display, and
305        // the report of what it unwound through. Only the input's text is at
306        // hand; a library module is named by its path.
307        Run::Raised(r, trace) => {
308            let doc = |source: &DocSource, chunk: &str| match source {
309                _ if chunk == input => (INPUT.to_string(), Some(src.to_string())),
310                DocSource::Path(path) => (path.to_string(), None),
311                _ => (chunk.to_string(), None),
312            };
313            format!(
314                r#""error":{{"stage":"eval","raised":{},"report":{}}}"#,
315                json_string(&r.value().to_display(ctx)),
316                json_string(&ctx.report_raised(r, trace, &doc, ReportStyle::Snippets))
317            )
318        }
319        Run::Library => unreachable!("a library is reported as a load error"),
320    }
321}
322
323/// Why a module graph would not load, in one line.
324fn describe_load(e: &LoadError) -> String {
325    match e {
326        LoadError::NotFound { spec } => format!("cannot resolve import {spec:?} (searched {LIB})"),
327        LoadError::Cycle { path } => {
328            let names: Vec<&str> = path.iter().map(|n| n.as_str()).collect();
329            format!("import cycle: {}", names.join(" → "))
330        }
331        LoadError::Compile {
332            name,
333            error,
334            path,
335            source,
336        } => error.report(path.as_deref().unwrap_or(name.as_str()), source),
337    }
338}
339
340/// [`Sys`] over the functions the page imports. There is no environment and no
341/// real file system: a path is normalized lexically, and whether a file exists
342/// is the host's to say when it is read.
343struct JsSys;
344
345impl Sys for JsSys {
346    fn write(&self, stream: Stream, s: &str) -> std::io::Result<()> {
347        let stream = match stream {
348            Stream::Out => 1,
349            Stream::Err => 2,
350        };
351        host::write(stream, s);
352        Ok(())
353    }
354
355    fn real_path(&self, path: &Path) -> Option<PathBuf> {
356        let mut real = PathBuf::from("/");
357        for c in path.components() {
358            match c {
359                Component::ParentDir => {
360                    real.pop();
361                }
362                Component::Normal(part) => real.push(part),
363                Component::RootDir | Component::CurDir | Component::Prefix(_) => {}
364            }
365        }
366        Some(real)
367    }
368
369    fn read_source(&self, path: &Path) -> Option<String> {
370        host::read(path.to_str()?)
371    }
372
373    fn var(&self, _name: &str) -> Option<String> {
374        None
375    }
376
377    fn vars(&self) -> Vec<(String, String)> {
378        Vec::new()
379    }
380
381    fn exit(&self, code: i32) -> ! {
382        host::exit(code)
383    }
384}
385
386/// The page's side of [`JsSys`].
387#[cfg(target_arch = "wasm32")]
388mod host {
389    #[link(wasm_import_module = "elly_host")]
390    extern "C" {
391        fn elly_host_write(stream: u32, ptr: *const u8, len: usize);
392        fn elly_host_read(ptr: *const u8, len: usize, out_len: *mut usize) -> *mut u8;
393        fn elly_host_exit(code: i32);
394    }
395
396    pub fn write(stream: u32, s: &str) {
397        unsafe { elly_host_write(stream, s.as_ptr(), s.len()) }
398    }
399
400    pub fn read(path: &str) -> Option<String> {
401        let mut len = 0usize;
402        let ptr = unsafe { elly_host_read(path.as_ptr(), path.len(), &mut len) };
403        if ptr.is_null() {
404            return None;
405        }
406        // The host filled a block from `elly_alloc(len)` and handed it over.
407        let bytes = unsafe { Vec::from_raw_parts(ptr, len, len) };
408        String::from_utf8(bytes).ok()
409    }
410
411    pub fn exit(code: i32) -> ! {
412        unsafe { elly_host_exit(code) };
413        // The host throws instead of returning; if it did return, stop here.
414        core::arch::wasm32::unreachable()
415    }
416}
417
418/// Native builds have no page to import from. This side stands in for it, so
419/// a test can run the playground's examples (`tests/examples.rs`, which
420/// compiles this file in as a module): files come
421/// from a table the test sets, writes are recorded, and an exit unwinds with an
422/// [`Exit`](native::Exit) payload.
423#[cfg(not(target_arch = "wasm32"))]
424pub mod native {
425    use std::cell::RefCell;
426
427    use elly_host::Stream;
428
429    thread_local! {
430        static FILES: RefCell<Vec<(String, String)>> = const { RefCell::new(Vec::new()) };
431        static OUTPUT: RefCell<Vec<(Stream, String)>> = const { RefCell::new(Vec::new()) };
432    }
433
434    /// The panic payload `IO.Proc.exit` unwinds with.
435    #[derive(Debug)]
436    pub struct Exit(pub i32);
437
438    /// Serve `(path, source)` files to imports, as the page's `FILES` table does.
439    pub fn set_files(files: Vec<(String, String)>) {
440        FILES.with(|f| *f.borrow_mut() = files);
441    }
442
443    /// What was written since the last call, in order.
444    pub fn take_output() -> Vec<(Stream, String)> {
445        OUTPUT.with(|o| std::mem::take(&mut *o.borrow_mut()))
446    }
447
448    pub(crate) fn write(stream: u32, s: &str) {
449        let stream = if stream == 2 {
450            Stream::Err
451        } else {
452            Stream::Out
453        };
454        OUTPUT.with(|o| o.borrow_mut().push((stream, s.to_string())));
455    }
456
457    pub(crate) fn read(path: &str) -> Option<String> {
458        FILES.with(|f| {
459            f.borrow()
460                .iter()
461                .find(|(p, _)| p == path)
462                .map(|(_, src)| src.clone())
463        })
464    }
465
466    pub(crate) fn exit(code: i32) -> ! {
467        std::panic::panic_any(Exit(code))
468    }
469}
470
471#[cfg(not(target_arch = "wasm32"))]
472use native as host;
473
474/// Quote and escape `s` as a JSON string literal.
475fn json_string(s: &str) -> String {
476    let mut out = String::with_capacity(s.len() + 2);
477    out.push('"');
478    for ch in s.chars() {
479        match ch {
480            '"' => out.push_str("\\\""),
481            '\\' => out.push_str("\\\\"),
482            '\n' => out.push_str("\\n"),
483            '\r' => out.push_str("\\r"),
484            '\t' => out.push_str("\\t"),
485            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
486            c => out.push(c),
487        }
488    }
489    out.push('"');
490    out
491}