Skip to main content

bynk_wasm/
lib.rs

1//! The Bynk compiler as a wasm module for the in-browser REPL/playground (the
2//! in-browser track, slice 3 — ADR 0139).
3//!
4//! One entry — `bynk_compile` (wasm) / `compile` (native) — takes an in-memory
5//! Bynk source and returns a runnable **JavaScript module graph** plus diagnostics,
6//! with **no filesystem and no `tsc`**:
7//!
8//! ```text
9//! source ─▶ bynk_emit::compile_in_memory (Bundle / Browser)  ─▶ ProjectOutput (TS)
10//!        ─▶ bynk_strip::strip_project_to_js                   ─▶ ProjectOutput (JS)
11//!        ─▶ { files: [{ path, contents }], diagnostics }
12//! ```
13//!
14//! The pipeline reuses the on-disk path wholesale (first-party injection, the
15//! per-platform binding, the strip-only emitter), so the returned graph is the
16//! complete set the browser links: the user module, `runtime.js`, the
17//! `bynk-browser.js` binding, and `compose.js`. The crate compiles to `wasm32`
18//! (the `cdylib`); the same logic is exercised natively (the `rlib`) by the
19//! slice-3 tests, with the browser harness deferred to the REPL shell (slice 4).
20
21use std::collections::HashMap;
22use std::path::PathBuf;
23
24use bynk_check::firstparty::Platform;
25use bynk_emit::project::{AttributedError, BuildTarget, analyse_in_memory, compile_in_memory};
26use bynk_syntax::CompileError;
27
28/// One emitted JavaScript module of the compiled program.
29#[derive(serde::Serialize)]
30pub struct EmittedFile {
31    /// Output-relative path (e.g. `main.js`, `runtime.js`, `bynk-browser.js`).
32    pub path: String,
33    /// The JavaScript source.
34    pub contents: String,
35}
36
37/// A diagnostic flattened for the JS side, with a 1-indexed line/column.
38#[derive(serde::Serialize)]
39pub struct Diagnostic {
40    /// The source module the diagnostic belongs to, if attributable.
41    pub path: Option<String>,
42    pub line: usize,
43    pub col: usize,
44    /// Byte offsets of the diagnostic span (for the editor's inline lint range).
45    pub from: usize,
46    pub to: usize,
47    /// `"error"` or `"warning"`.
48    pub severity: String,
49    /// The stable diagnostic category (e.g. `bynk.parse.expected_token`).
50    pub category: String,
51    pub message: String,
52}
53
54/// The outcome of compiling one in-memory source.
55#[derive(serde::Serialize)]
56pub struct CompileResult {
57    /// Whether a runnable JavaScript graph was produced.
58    pub ok: bool,
59    /// The runnable JS module graph (empty on failure).
60    pub files: Vec<EmittedFile>,
61    /// Errors on failure, or non-failing warnings on success.
62    pub diagnostics: Vec<Diagnostic>,
63}
64
65fn severity_str(err: &CompileError) -> &'static str {
66    match bynk_syntax::Severity::for_error(err) {
67        bynk_syntax::Severity::Error => "error",
68        bynk_syntax::Severity::Warning => "warning",
69    }
70}
71
72/// Flatten attributed errors to [`Diagnostic`]s, resolving line/col against the
73/// owning source where known (`sources`), else the user source (`fallback`).
74fn to_diagnostics(
75    errs: Vec<AttributedError>,
76    sources: &HashMap<PathBuf, String>,
77    fallback: &str,
78) -> Vec<Diagnostic> {
79    errs.into_iter()
80        .map(|a| {
81            let src = a
82                .source_path
83                .as_ref()
84                .and_then(|p| sources.get(p))
85                .map(String::as_str)
86                .unwrap_or(fallback);
87            let (line, col) = bynk_syntax::span::line_col(src, a.error.span.start);
88            Diagnostic {
89                path: a
90                    .source_path
91                    .as_ref()
92                    .map(|p| p.to_string_lossy().into_owned()),
93                line,
94                col,
95                from: a.error.span.start,
96                to: a.error.span.end,
97                severity: severity_str(&a.error).to_string(),
98                category: a.error.category.to_string(),
99                message: a.error.message.clone(),
100            }
101        })
102        .collect()
103}
104
105/// Compile a single in-memory Bynk source to a JavaScript module graph for the
106/// given platform (the playground passes [`Platform::Browser`]). Pure: no
107/// filesystem, no `tsc`. The in-process `Bundle` subset only; programs that reach
108/// Workers/Cloudflare-only shapes are reported as diagnostics (slice-2 platform
109/// lock), never silently mis-compiled.
110pub fn compile(source: &str, platform: Platform) -> CompileResult {
111    match compile_in_memory(source, BuildTarget::Bundle, platform) {
112        Ok(out) => match bynk_strip::strip_project_to_js(out) {
113            Ok(js) => {
114                // The user program is the single in-memory source, so warnings
115                // resolve their line/col against it (the fallback).
116                let diagnostics = to_diagnostics(js.warnings, &HashMap::new(), source);
117                let files = js
118                    .files
119                    .into_iter()
120                    .map(|f| EmittedFile {
121                        path: f.output_path.to_string_lossy().into_owned(),
122                        contents: f.typescript,
123                    })
124                    .collect();
125                CompileResult {
126                    ok: true,
127                    files,
128                    diagnostics,
129                }
130            }
131            // The emitter is strip-only (ADR 0136), so this is unreachable for a
132            // successful compile — surfaced as a diagnostic rather than a panic.
133            Err(e) => CompileResult {
134                ok: false,
135                files: Vec::new(),
136                diagnostics: vec![Diagnostic {
137                    path: None,
138                    line: 0,
139                    col: 0,
140                    from: 0,
141                    to: 0,
142                    severity: "error".to_string(),
143                    category: "bynk.wasm.strip_failed".to_string(),
144                    message: e.to_string(),
145                }],
146            },
147        },
148        Err(failure) => {
149            let sources: HashMap<PathBuf, String> = failure.snapshots.iter().cloned().collect();
150            CompileResult {
151                ok: false,
152                files: Vec::new(),
153                diagnostics: to_diagnostics(failure.errors, &sources, source),
154            }
155        }
156    }
157}
158
159/// Compile to a JSON string — the wasm boundary representation of [`CompileResult`].
160pub fn compile_to_json(source: &str, platform: Platform) -> String {
161    serde_json::to_string(&compile(source, platform)).unwrap_or_else(|e| {
162        format!(
163            "{{\"ok\":false,\"files\":[],\"diagnostics\":[{{\"path\":null,\"line\":0,\"col\":0,\"from\":0,\"to\":0,\
164             \"severity\":\"error\",\"category\":\"bynk.wasm.serialize_failed\",\"message\":{:?}}}]}}",
165            e.to_string()
166        )
167    })
168}
169
170/// The diagnostics of a single in-memory source — non-bailing analysis, no emission
171/// (the editor's live, on-type diagnostics — slice 5d).
172#[derive(serde::Serialize)]
173pub struct AnalyzeResult {
174    pub diagnostics: Vec<Diagnostic>,
175}
176
177/// Analyse a source for diagnostics only (no compile/emit), for the given platform.
178pub fn analyze(source: &str, platform: Platform) -> AnalyzeResult {
179    let errs = analyse_in_memory(source, BuildTarget::Bundle, platform);
180    AnalyzeResult {
181        diagnostics: to_diagnostics(errs, &HashMap::new(), source),
182    }
183}
184
185/// Analyse to a JSON string — `{ diagnostics: [{ from, to, line, col, severity,
186/// category, message }] }`.
187pub fn analyze_to_json(source: &str, platform: Platform) -> String {
188    serde_json::to_string(&analyze(source, platform))
189        .unwrap_or_else(|_| "{\"diagnostics\":[]}".to_string())
190}
191
192#[cfg(target_arch = "wasm32")]
193use wasm_bindgen::prelude::wasm_bindgen;
194
195/// The wasm entry point for live editor diagnostics: analyse an in-memory Bynk
196/// source for the browser and return `{ diagnostics: [...] }` (with byte `from`/`to`
197/// spans for inline marking). Non-bailing — all diagnostics at once.
198#[cfg(target_arch = "wasm32")]
199#[wasm_bindgen]
200pub fn bynk_analyze(source: &str) -> String {
201    analyze_to_json(source, Platform::Browser)
202}
203
204/// The wasm entry point: compile an in-memory Bynk source for the browser
205/// playground, returning a JSON document
206/// `{ ok, files: [{ path, contents }], diagnostics: [{ path, line, col, severity,
207/// category, message }] }`.
208#[cfg(target_arch = "wasm32")]
209#[wasm_bindgen]
210pub fn bynk_compile(source: &str) -> String {
211    compile_to_json(source, Platform::Browser)
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    const PROG: &str = "context app.demo\n\
219        \n\
220        consumes bynk { Clock, Logger }\n\
221        \n\
222        service demo {\n\
223        \x20 on call() -> Effect[Instant] given Clock, Logger {\n\
224        \x20   let _ <- Logger.info(\"hi\")\n\
225        \x20   let now <- Clock.now()\n\
226        \x20   now\n\
227        \x20 }\n\
228        }\n";
229
230    #[test]
231    fn compiles_browser_program_to_js_graph() {
232        let r = compile(PROG, Platform::Browser);
233        assert!(
234            r.ok,
235            "should compile: {:?}",
236            r.diagnostics.first().map(|d| &d.message)
237        );
238        // The full runnable graph: user module + runtime + browser binding + compose.
239        let paths: Vec<&str> = r.files.iter().map(|f| f.path.as_str()).collect();
240        assert!(
241            paths.iter().all(|p| p.ends_with(".js")),
242            "all JS: {paths:?}"
243        );
244        assert!(
245            paths.contains(&"runtime.js"),
246            "runtime.js present: {paths:?}"
247        );
248        assert!(
249            paths.contains(&"bynk-browser.js"),
250            "browser binding present: {paths:?}"
251        );
252        // No residual TypeScript type syntax survived the strip.
253        let user = r
254            .files
255            .iter()
256            .find(|f| f.path == "app/demo.js")
257            .expect("user module");
258        assert!(
259            !user.contents.contains(": Promise<"),
260            "annotations stripped:\n{}",
261            user.contents
262        );
263    }
264
265    #[test]
266    fn surfaces_diagnostics_for_a_bad_program() {
267        let r = compile("context app.demo\n\nthis is not bynk\n", Platform::Browser);
268        assert!(!r.ok);
269        assert!(r.files.is_empty());
270        assert!(!r.diagnostics.is_empty());
271        assert!(r.diagnostics.iter().all(|d| d.severity == "error"));
272        // Line/col point into the user source.
273        assert!(r.diagnostics.iter().any(|d| d.line >= 1));
274    }
275
276    #[test]
277    fn cloudflare_shapes_are_rejected_in_the_browser() {
278        // The slice-2 platform lock fires through the in-memory path too.
279        let prog = "context cache.store\n\
280            \n\
281            consumes bynk.cloudflare { Kv }\n\
282            \n\
283            service cache {\n\
284            \x20 on call(k: String) -> Effect[Option[String]] given Kv {\n\
285            \x20   let v <- Kv.get(k)\n\
286            \x20   v\n\
287            \x20 }\n\
288            }\n";
289        let r = compile(prog, Platform::Browser);
290        assert!(
291            !r.ok,
292            "a cloudflare-only program must not compile for the browser"
293        );
294        assert!(
295            r.diagnostics
296                .iter()
297                .any(|d| d.category == "bynk.target.vendor_required"),
298            "expected the platform lock: {:?}",
299            r.diagnostics
300                .iter()
301                .map(|d| &d.category)
302                .collect::<Vec<_>>()
303        );
304    }
305
306    #[test]
307    fn compile_to_json_is_valid_json() {
308        let json = compile_to_json(PROG, Platform::Browser);
309        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
310        assert_eq!(v["ok"], true);
311        assert!(v["files"].as_array().is_some_and(|a| !a.is_empty()));
312    }
313
314    #[test]
315    fn analyze_reports_check_errors_for_a_context() {
316        // A type mismatch in a *context* — returning a String where Int is declared.
317        // The non-bailing analyse must report it (slice 5d's reason to exist: plain
318        // single-source `diagnose` only checks commons, not contexts).
319        let prog = "context app.demo\n\n\
320            consumes bynk { Logger }\n\n\
321            service demo {\n\
322            \x20 on call() -> Effect[Int] given Logger {\n\
323            \x20   let _ <- Logger.info(\"x\")\n\
324            \x20   \"not an int\"\n\
325            \x20 }\n\
326            }\n";
327        let r = analyze(prog, Platform::Browser);
328        assert!(
329            r.diagnostics.iter().any(|d| d.severity == "error"),
330            "a type mismatch should be reported: {:?}",
331            r.diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>()
332        );
333        // A real diagnostic carries a span for inline marking.
334        assert!(r.diagnostics.iter().any(|d| d.to > d.from));
335    }
336
337    #[test]
338    fn analyze_clean_program_has_no_errors() {
339        let r = analyze(PROG, Platform::Browser);
340        assert!(
341            r.diagnostics.iter().all(|d| d.severity != "error"),
342            "clean program should have no errors: {:?}",
343            r.diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>()
344        );
345    }
346}