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}