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::expr_types::type_at_offset;
25use bynk_check::firstparty::Platform;
26use bynk_check::locals::locals_at;
27use bynk_emit::project::{
28    AttributedError, BuildTarget, analyse_in_memory, analyse_in_memory_with_types,
29    compile_in_memory,
30};
31use bynk_ide::completion;
32use bynk_syntax::CompileError;
33
34/// One emitted JavaScript module of the compiled program.
35#[derive(serde::Serialize)]
36pub struct EmittedFile {
37    /// Output-relative path (e.g. `main.js`, `runtime.js`, `bynk-browser.js`).
38    pub path: String,
39    /// The JavaScript source.
40    pub contents: String,
41}
42
43/// A diagnostic flattened for the JS side, with a 1-indexed line/column.
44#[derive(serde::Serialize)]
45pub struct Diagnostic {
46    /// The source module the diagnostic belongs to, if attributable.
47    pub path: Option<String>,
48    pub line: usize,
49    pub col: usize,
50    /// Byte offsets of the diagnostic span (for the editor's inline lint range).
51    pub from: usize,
52    pub to: usize,
53    /// `"error"` or `"warning"`.
54    pub severity: String,
55    /// The stable diagnostic category (e.g. `bynk.parse.expected_token`).
56    pub category: String,
57    pub message: String,
58    /// Finding #47: previously dropped entirely for the playground. Plain
59    /// text, not positioned — the CLI/LSP renderers already carry the
60    /// harder problem of a label's span belonging to a different module
61    /// than `path` (finding #46); this flattening keeps to text only rather
62    /// than getting that wrong here too.
63    pub notes: Vec<String>,
64    pub labels: Vec<String>,
65}
66
67/// The outcome of compiling one in-memory source.
68#[derive(serde::Serialize)]
69pub struct CompileResult {
70    /// Whether a runnable JavaScript graph was produced.
71    pub ok: bool,
72    /// The runnable JS module graph (empty on failure).
73    pub files: Vec<EmittedFile>,
74    /// Errors on failure, or non-failing warnings on success.
75    pub diagnostics: Vec<Diagnostic>,
76}
77
78fn severity_str(err: &CompileError) -> &'static str {
79    match bynk_syntax::Severity::for_error(err) {
80        bynk_syntax::Severity::Error => "error",
81        bynk_syntax::Severity::Warning => "warning",
82    }
83}
84
85/// The human-readable message carried by a caught panic payload, if any.
86fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
87    if let Some(s) = payload.downcast_ref::<&str>() {
88        (*s).to_string()
89    } else if let Some(s) = payload.downcast_ref::<String>() {
90        s.clone()
91    } else {
92        "unknown panic".to_string()
93    }
94}
95
96/// A synthetic `bynk.wasm.panic` diagnostic standing in for an internal compiler
97/// panic, so an unexpected `panic!`/index-out-of-bounds/`unreachable!` in the
98/// pipeline becomes a structured error rather than propagating past the boundary.
99fn panic_diagnostic(payload: Box<dyn std::any::Any + Send>) -> Diagnostic {
100    Diagnostic {
101        path: None,
102        line: 0,
103        col: 0,
104        from: 0,
105        to: 0,
106        severity: "error".to_string(),
107        category: "bynk.wasm.panic".to_string(),
108        message: format!("internal compiler panic: {}", panic_message(&*payload)),
109        notes: Vec::new(),
110        labels: Vec::new(),
111    }
112}
113
114/// Run a pipeline entry point, converting an unexpected panic into a diagnostic.
115///
116/// On the native `rlib` path (the tests and any host embedding) this genuinely
117/// unwinds the panic and returns `Err(diagnostic)`, so a reachable-in-principle
118/// `panic!` no longer propagates past the wasm boundary. On the actual
119/// `wasm32-unknown-unknown` target a panic still traps (`RuntimeError:
120/// unreachable`) because the stock target lowers unwinding to a trap — there the
121/// blast radius is bounded instead by the `console_error_panic_hook` (a legible
122/// console error and location) set in the wasm entry points, and this wrapper
123/// becomes effective for free if the playground build ever adopts wasm exception
124/// handling. Fixing the underlying panic sites remains the real fix (#717).
125///
126/// `Diagnostic` grew past clippy's large-`Err` threshold once `notes`/`labels`
127/// (finding #47) joined it; boxing it here would need every one of this
128/// module's several `Diagnostic { .. }` construction sites and its `serde`
129/// serialisation to route through a `Box` for one lint, so it's overridden
130/// instead — this is a single-error return on the panic path, not a hot loop.
131#[allow(clippy::result_large_err)]
132fn catch_panic<T>(f: impl FnOnce() -> T) -> Result<T, Diagnostic> {
133    std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(panic_diagnostic)
134}
135
136/// Flatten attributed errors to [`Diagnostic`]s, resolving line/col against the
137/// owning source where known (`sources`), else the user source (`fallback`).
138fn to_diagnostics(
139    errs: Vec<AttributedError>,
140    sources: &HashMap<PathBuf, String>,
141    fallback: &str,
142) -> Vec<Diagnostic> {
143    errs.into_iter()
144        .map(|a| {
145            let src = a
146                .source_path
147                .as_ref()
148                .and_then(|p| sources.get(p))
149                .map(String::as_str)
150                .unwrap_or(fallback);
151            let (line, col) = bynk_syntax::span::line_col(src, a.error.span.start);
152            Diagnostic {
153                path: a
154                    .source_path
155                    .as_ref()
156                    .map(|p| p.to_string_lossy().into_owned()),
157                line,
158                col,
159                from: a.error.span.start,
160                to: a.error.span.end,
161                severity: severity_str(&a.error).to_string(),
162                category: a.error.category.to_string(),
163                message: a.error.message.clone(),
164                notes: a.error.notes.clone(),
165                labels: a.error.labels.iter().map(|(_, msg)| msg.clone()).collect(),
166            }
167        })
168        .collect()
169}
170
171/// Compile a single in-memory Bynk source to a JavaScript module graph for the
172/// given platform (the playground passes [`Platform::Browser`]). Pure: no
173/// filesystem, no `tsc`. The in-process `Bundle` subset only; programs that reach
174/// Workers/Cloudflare-only shapes are reported as diagnostics (slice-2 platform
175/// lock), never silently mis-compiled.
176pub fn compile(source: &str, platform: Platform) -> CompileResult {
177    catch_panic(|| compile_inner(source, platform)).unwrap_or_else(|d| CompileResult {
178        ok: false,
179        files: Vec::new(),
180        diagnostics: vec![d],
181    })
182}
183
184fn compile_inner(source: &str, platform: Platform) -> CompileResult {
185    match compile_in_memory(source, BuildTarget::Bundle, platform) {
186        Ok(out) => match bynk_strip::strip_project_to_js(out) {
187            Ok(js) => {
188                // The user program is the single in-memory source, so warnings
189                // resolve their line/col against it (the fallback).
190                let diagnostics = to_diagnostics(js.warnings, &HashMap::new(), source);
191                let files = js
192                    .artefacts
193                    .docs
194                    .into_iter()
195                    .map(|(path, doc)| EmittedFile {
196                        path: path.to_string_lossy().into_owned(),
197                        contents: doc.text(),
198                    })
199                    .collect();
200                CompileResult {
201                    ok: true,
202                    files,
203                    diagnostics,
204                }
205            }
206            // The emitter is strip-only (ADR 0136), so this is unreachable for a
207            // successful compile — surfaced as a diagnostic rather than a panic.
208            Err(e) => CompileResult {
209                ok: false,
210                files: Vec::new(),
211                diagnostics: vec![Diagnostic {
212                    path: None,
213                    line: 0,
214                    col: 0,
215                    from: 0,
216                    to: 0,
217                    severity: "error".to_string(),
218                    category: "bynk.wasm.strip_failed".to_string(),
219                    message: e.to_string(),
220                    notes: Vec::new(),
221                    labels: Vec::new(),
222                }],
223            },
224        },
225        Err(failure) => {
226            let sources: HashMap<PathBuf, String> = failure.snapshots.iter().cloned().collect();
227            CompileResult {
228                ok: false,
229                files: Vec::new(),
230                diagnostics: to_diagnostics(failure.errors, &sources, source),
231            }
232        }
233    }
234}
235
236/// Compile to a JSON string — the wasm boundary representation of [`CompileResult`].
237pub fn compile_to_json(source: &str, platform: Platform) -> String {
238    serde_json::to_string(&compile(source, platform)).unwrap_or_else(|e| {
239        format!(
240            "{{\"ok\":false,\"files\":[],\"diagnostics\":[{{\"path\":null,\"line\":0,\"col\":0,\"from\":0,\"to\":0,\
241             \"severity\":\"error\",\"category\":\"bynk.wasm.serialize_failed\",\"message\":{:?}}}]}}",
242            e.to_string()
243        )
244    })
245}
246
247/// The diagnostics of a single in-memory source — non-bailing analysis, no emission
248/// (the editor's live, on-type diagnostics — slice 5d).
249#[derive(serde::Serialize)]
250pub struct AnalyzeResult {
251    pub diagnostics: Vec<Diagnostic>,
252}
253
254/// Analyse a source for diagnostics only (no compile/emit), for the given platform.
255pub fn analyze(source: &str, platform: Platform) -> AnalyzeResult {
256    catch_panic(|| analyze_inner(source, platform)).unwrap_or_else(|d| AnalyzeResult {
257        diagnostics: vec![d],
258    })
259}
260
261fn analyze_inner(source: &str, platform: Platform) -> AnalyzeResult {
262    let errs = analyse_in_memory(source, BuildTarget::Bundle, platform);
263    AnalyzeResult {
264        diagnostics: to_diagnostics(errs, &HashMap::new(), source),
265    }
266}
267
268/// Analyse to a JSON string — `{ diagnostics: [{ from, to, line, col, severity,
269/// category, message }] }`.
270pub fn analyze_to_json(source: &str, platform: Platform) -> String {
271    serde_json::to_string(&analyze(source, platform))
272        .unwrap_or_else(|_| "{\"diagnostics\":[]}".to_string())
273}
274
275/// The inferred type at a cursor position in a single in-memory source, or
276/// `None` if the expression at that position never typed at all — per ADR
277/// 0094, a well-typed function still contributes types even when a *different*
278/// function in the same file has an error, so this isn't blanked by every
279/// mid-edit error, only by one at the position itself (or upstream of it, an
280/// unresolved name). The editor's hover tooltip (#397).
281#[derive(serde::Serialize)]
282pub struct HoverResult {
283    pub ty: Option<String>,
284}
285
286/// Hover for a byte `offset` into `source`, for the given platform.
287pub fn hover(source: &str, offset: usize, platform: Platform) -> HoverResult {
288    catch_panic(|| hover_inner(source, offset, platform)).unwrap_or(HoverResult { ty: None })
289}
290
291fn hover_inner(source: &str, offset: usize, platform: Platform) -> HoverResult {
292    let analysis = analyse_in_memory_with_types(source, BuildTarget::Bundle, platform);
293    let ty = type_at_offset(&analysis.expr_types, offset).map(|t| t.display(&analysis.ty_intern));
294    HoverResult { ty }
295}
296
297/// Hover to a JSON string — `{ ty: string | null }`.
298pub fn hover_to_json(source: &str, offset: usize, platform: Platform) -> String {
299    serde_json::to_string(&hover(source, offset, platform))
300        .unwrap_or_else(|_| "{\"ty\":null}".to_string())
301}
302
303/// One completion candidate, serialised for the JS side — a shadow of
304/// `bynk_ide::completion::Completion`/`CompletionKind` (that crate stays
305/// serde-free; this is the wire DTO, same pattern as [`EmittedFile`]/[`Diagnostic`]).
306#[derive(serde::Serialize)]
307pub struct CompletionCandidate {
308    pub label: String,
309    /// "unit"/"capability"/"type"/"keyword"/"snippet"/"variant"/"member"/
310    /// "field"/"constructor"/"function"/"local".
311    pub kind: &'static str,
312    pub detail: Option<String>,
313    pub insert_text: Option<String>,
314}
315
316/// The editor's completion list at a cursor position (#808).
317#[derive(serde::Serialize)]
318pub struct CompleteResult {
319    pub items: Vec<CompletionCandidate>,
320}
321
322fn to_candidate(c: completion::Completion) -> CompletionCandidate {
323    use completion::CompletionKind::*;
324    let kind = match c.kind {
325        Unit => "unit",
326        Capability => "capability",
327        Type => "type",
328        Keyword => "keyword",
329        Snippet => "snippet",
330        Variant => "variant",
331        Member => "member",
332        Field => "field",
333        Constructor => "constructor",
334        Function => "function",
335    };
336    CompletionCandidate {
337        label: c.label,
338        kind,
339        detail: c.detail,
340        insert_text: c.insert_text,
341    }
342}
343
344/// Completion at a byte `offset` into an in-memory Bynk source, for the given
345/// platform (capability methods, types, keywords, in-scope locals, and
346/// value-receiver members — #808, the other half of #397 hover shipped).
347/// Single buffer, single call — no project files, no multi-doc overlay/caching
348/// (the wasm boundary has none of those, so `files: None` throughout).
349pub fn complete(source: &str, offset: usize, platform: Platform) -> CompleteResult {
350    catch_panic(|| complete_inner(source, offset, platform))
351        .unwrap_or(CompleteResult { items: Vec::new() })
352}
353
354fn complete_inner(source: &str, offset: usize, platform: Platform) -> CompleteResult {
355    let line_prefix = source[..offset].rsplit('\n').next().unwrap_or("");
356    let mut items: Vec<CompletionCandidate> = completion::complete(line_prefix, source, None)
357        .into_iter()
358        .map(to_candidate)
359        .collect();
360
361    // ADR 0093 D3: in-scope locals/params, alongside keywords/constructors at
362    // a keyword or expression position — the same two disjoint positions
363    // `bynk-lsp`'s handler merges locals into.
364    if completion::is_keyword_position(line_prefix)
365        || completion::is_expression_position(line_prefix)
366    {
367        let analysis = analyse_in_memory_with_types(source, BuildTarget::Bundle, platform);
368        items.extend(locals_at(&analysis.locals, offset).into_iter().map(|b| {
369            CompletionCandidate {
370                label: b.name.clone(),
371                kind: "local",
372                detail: Some(b.ty.clone()),
373                insert_text: None,
374            }
375        }));
376    }
377    // A lowercase `receiver.` is a value receiver: `complete()` yields nothing
378    // there directly (ADR 0093 D4), so retype the rewritten buffer (dropping
379    // the trailing partial member) and offer the receiver's kernel methods /
380    // record fields.
381    if items.is_empty()
382        && let Some((rewritten, recv_offset)) = completion::value_receiver_rewrite(source, offset)
383    {
384        let analysis = analyse_in_memory_with_types(&rewritten, BuildTarget::Bundle, platform);
385        if let Some(ty) = type_at_offset(&analysis.expr_types, recv_offset) {
386            items = completion::value_member_candidates(ty, &analysis.ty_intern, source, None)
387                .into_iter()
388                .map(to_candidate)
389                .collect();
390        }
391    }
392    CompleteResult { items }
393}
394
395/// Complete to a JSON string — `{ items: [{ label, kind, detail, insert_text }] }`.
396pub fn complete_to_json(source: &str, offset: usize, platform: Platform) -> String {
397    serde_json::to_string(&complete(source, offset, platform))
398        .unwrap_or_else(|_| "{\"items\":[]}".to_string())
399}
400
401#[cfg(target_arch = "wasm32")]
402use wasm_bindgen::prelude::wasm_bindgen;
403
404/// Route panics to `console.error` with a readable message and location. Idempotent
405/// (`set_once` installs the hook exactly once), so every entry point may call it.
406/// Without this a panic on adversarial input surfaces as an opaque `RuntimeError:
407/// unreachable` with no clue to its origin (#717).
408#[cfg(target_arch = "wasm32")]
409fn install_panic_hook() {
410    console_error_panic_hook::set_once();
411}
412
413/// The wasm entry point for live editor diagnostics: analyse an in-memory Bynk
414/// source for the browser and return `{ diagnostics: [...] }` (with byte `from`/`to`
415/// spans for inline marking). Non-bailing — all diagnostics at once.
416#[cfg(target_arch = "wasm32")]
417#[wasm_bindgen]
418pub fn bynk_analyze(source: &str) -> String {
419    install_panic_hook();
420    analyze_to_json(source, Platform::Browser)
421}
422
423/// The wasm entry point for the editor's hover tooltip: the inferred type at a
424/// byte `offset` into an in-memory Bynk source, as `{ "ty": string | null }`
425/// (#397).
426#[cfg(target_arch = "wasm32")]
427#[wasm_bindgen]
428pub fn bynk_hover(source: &str, offset: u32) -> String {
429    install_panic_hook();
430    hover_to_json(source, offset as usize, Platform::Browser)
431}
432
433/// The wasm entry point for the editor's completion: context-aware candidates
434/// at a byte `offset` into an in-memory Bynk source, as
435/// `{ "items": [{ "label", "kind", "detail", "insert_text" }] }` (#808).
436#[cfg(target_arch = "wasm32")]
437#[wasm_bindgen]
438pub fn bynk_complete(source: &str, offset: u32) -> String {
439    install_panic_hook();
440    complete_to_json(source, offset as usize, Platform::Browser)
441}
442
443/// The wasm entry point: compile an in-memory Bynk source for the browser
444/// playground, returning a JSON document
445/// `{ ok, files: [{ path, contents }], diagnostics: [{ path, line, col, severity,
446/// category, message }] }`.
447#[cfg(target_arch = "wasm32")]
448#[wasm_bindgen]
449pub fn bynk_compile(source: &str) -> String {
450    install_panic_hook();
451    compile_to_json(source, Platform::Browser)
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    const PROG: &str = "context app.demo\n\
459        \n\
460        consumes bynk { Clock, Logger }\n\
461        \n\
462        service demo {\n\
463        \x20 on call() -> Effect[Instant] given Clock, Logger {\n\
464        \x20   let _ <- Logger.info(\"hi\")\n\
465        \x20   let now <- Clock.now()\n\
466        \x20   now\n\
467        \x20 }\n\
468        }\n";
469
470    #[test]
471    fn compiles_browser_program_to_js_graph() {
472        let r = compile(PROG, Platform::Browser);
473        assert!(
474            r.ok,
475            "should compile: {:?}",
476            r.diagnostics.first().map(|d| &d.message)
477        );
478        // The full runnable graph: user module + runtime + browser binding + compose.
479        let paths: Vec<&str> = r.files.iter().map(|f| f.path.as_str()).collect();
480        assert!(
481            paths.iter().all(|p| p.ends_with(".js")),
482            "all JS: {paths:?}"
483        );
484        assert!(
485            paths.contains(&"runtime.js"),
486            "runtime.js present: {paths:?}"
487        );
488        assert!(
489            paths.contains(&"bynk-browser.js"),
490            "browser binding present: {paths:?}"
491        );
492        // No residual TypeScript type syntax survived the strip.
493        let user = r
494            .files
495            .iter()
496            .find(|f| f.path == "app/demo.js")
497            .expect("user module");
498        assert!(
499            !user.contents.contains(": Promise<"),
500            "annotations stripped:\n{}",
501            user.contents
502        );
503    }
504
505    #[test]
506    fn surfaces_diagnostics_for_a_bad_program() {
507        let r = compile("context app.demo\n\nthis is not bynk\n", Platform::Browser);
508        assert!(!r.ok);
509        assert!(r.files.is_empty());
510        assert!(!r.diagnostics.is_empty());
511        assert!(r.diagnostics.iter().all(|d| d.severity == "error"));
512        // Line/col point into the user source.
513        assert!(r.diagnostics.iter().any(|d| d.line >= 1));
514    }
515
516    #[test]
517    fn cloudflare_shapes_are_rejected_in_the_browser() {
518        // The slice-2 platform lock fires through the in-memory path too.
519        let prog = "context cache.store\n\
520            \n\
521            consumes bynk.cloudflare { Kv }\n\
522            \n\
523            service cache {\n\
524            \x20 on call(k: String) -> Effect[Option[String]] given Kv {\n\
525            \x20   let v <- Kv.get(k)\n\
526            \x20   v\n\
527            \x20 }\n\
528            }\n";
529        let r = compile(prog, Platform::Browser);
530        assert!(
531            !r.ok,
532            "a cloudflare-only program must not compile for the browser"
533        );
534        assert!(
535            r.diagnostics
536                .iter()
537                .any(|d| d.category == "bynk.target.vendor_required"),
538            "expected the platform lock: {:?}",
539            r.diagnostics
540                .iter()
541                .map(|d| &d.category)
542                .collect::<Vec<_>>()
543        );
544    }
545
546    #[test]
547    fn compile_to_json_is_valid_json() {
548        let json = compile_to_json(PROG, Platform::Browser);
549        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
550        assert_eq!(v["ok"], true);
551        assert!(v["files"].as_array().is_some_and(|a| !a.is_empty()));
552    }
553
554    #[test]
555    fn analyze_reports_check_errors_for_a_context() {
556        // A type mismatch in a *context* — returning a String where Int is declared.
557        // The non-bailing analyse must report it (slice 5d's reason to exist: plain
558        // single-source `diagnose` only checks commons, not contexts).
559        let prog = "context app.demo\n\n\
560            consumes bynk { Logger }\n\n\
561            service demo {\n\
562            \x20 on call() -> Effect[Int] given Logger {\n\
563            \x20   let _ <- Logger.info(\"x\")\n\
564            \x20   \"not an int\"\n\
565            \x20 }\n\
566            }\n";
567        let r = analyze(prog, Platform::Browser);
568        assert!(
569            r.diagnostics.iter().any(|d| d.severity == "error"),
570            "a type mismatch should be reported: {:?}",
571            r.diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>()
572        );
573        // A real diagnostic carries a span for inline marking.
574        assert!(r.diagnostics.iter().any(|d| d.to > d.from));
575    }
576
577    #[test]
578    fn hover_reports_the_inferred_type_of_an_expression() {
579        // The tail expression `now` (the *reference*, not the `let now <-`
580        // binding) — the last occurrence of the substring in `PROG`.
581        let offset = PROG.rfind("now").expect("PROG mentions `now`");
582        let r = hover(PROG, offset, Platform::Browser);
583        assert_eq!(r.ty.as_deref(), Some("Instant"));
584    }
585
586    #[test]
587    fn hover_outside_any_expression_is_none() {
588        // Offset 0 sits in the `context` keyword — a declaration, not an
589        // expression, so nothing is recorded there.
590        let r = hover(PROG, 0, Platform::Browser);
591        assert_eq!(r.ty, None);
592    }
593
594    #[test]
595    fn hover_to_json_is_valid_json() {
596        let offset = PROG.rfind("now").expect("PROG mentions `now`");
597        let json = hover_to_json(PROG, offset, Platform::Browser);
598        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
599        assert_eq!(v["ty"], "Instant");
600    }
601
602    #[test]
603    fn hover_survives_a_sibling_error() {
604        // ADR 0094: hovering a well-typed expression must not go blank just
605        // because a *different* function in the same buffer is mid-edit and
606        // broken — the whole point of exposing the checker's partial
607        // `expr_types` map rather than its old all-or-nothing gate.
608        let prog = "commons app.demo\n\n\
609            fn good() -> Int {\n  42\n}\n\n\
610            fn bad() -> Int {\n  \"oops\"\n}\n";
611        let offset = prog.find("42").expect("prog mentions 42");
612        let r = hover(prog, offset, Platform::Browser);
613        assert_eq!(r.ty.as_deref(), Some("Int"));
614    }
615
616    #[test]
617    fn complete_offers_in_scope_capability_after_given() {
618        let prog = "context app.demo\n\n\
619            consumes bynk { Clock, Logger }\n\n\
620            service demo {\n\
621            \x20 on call() -> Effect[Instant] given \n\
622            \x20   Clock.now()\n\
623            \x20 }\n\
624            }\n";
625        let offset = prog.find("given \n").expect("prog mentions given") + "given ".len();
626        let r = complete(prog, offset, Platform::Browser);
627        assert!(
628            r.items
629                .iter()
630                .any(|c| c.label == "Logger" && c.kind == "capability"),
631            "{:?}",
632            r.items.iter().map(|c| &c.label).collect::<Vec<_>>()
633        );
634    }
635
636    #[test]
637    fn complete_offers_in_scope_locals_at_expression_position() {
638        // ADR 0093 D3/D4: `bynk_complete` folds the two contexts that live
639        // handler-side in `bynk-lsp` (locals, value-receiver members) into
640        // the one wasm call — no analysis overlay/caching, single buffer.
641        let offset = PROG.rfind("now").expect("PROG mentions `now`");
642        let r = complete(PROG, offset, Platform::Browser);
643        assert!(
644            r.items.iter().any(|c| c.label == "now"
645                && c.kind == "local"
646                && c.detail.as_deref() == Some("Instant")),
647            "{:?}",
648            r.items
649                .iter()
650                .map(|c| (&c.label, c.kind))
651                .collect::<Vec<_>>()
652        );
653    }
654
655    #[test]
656    fn complete_offers_value_receiver_members_after_dot() {
657        let prog = "commons app.demo\n\n\
658            fn f() -> String {\n\
659            \x20 let value = \"hi\"\n\
660            \x20 value.\n\
661            }\n";
662        let offset = prog.find("value.\n").expect("prog mentions value.") + "value.".len();
663        let r = complete(prog, offset, Platform::Browser);
664        assert!(
665            r.items
666                .iter()
667                .any(|c| c.label == "split" && c.kind == "member"),
668            "{:?}",
669            r.items.iter().map(|c| &c.label).collect::<Vec<_>>()
670        );
671    }
672
673    #[test]
674    fn complete_survives_a_sibling_error() {
675        // Same ADR 0094 ceiling as hover: a broken sibling function must not
676        // blank out completion in a well-typed one.
677        let prog = "commons app.demo\n\n\
678            fn good() -> Int {\n  let count = 42\n  count\n}\n\n\
679            fn bad() -> Int {\n  \"oops\"\n}\n";
680        let offset = prog.rfind("count").expect("prog mentions count");
681        let r = complete(prog, offset, Platform::Browser);
682        assert!(
683            r.items
684                .iter()
685                .any(|c| c.label == "count" && c.kind == "local"),
686            "{:?}",
687            r.items
688                .iter()
689                .map(|c| (&c.label, c.kind))
690                .collect::<Vec<_>>()
691        );
692    }
693
694    #[test]
695    fn complete_to_json_is_valid_json() {
696        let offset = PROG.rfind("now").expect("PROG mentions `now`");
697        let json = complete_to_json(PROG, offset, Platform::Browser);
698        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
699        assert!(v["items"].as_array().is_some_and(|a| !a.is_empty()));
700    }
701
702    #[test]
703    fn complete_survives_a_panic() {
704        // An out-of-bounds offset panics inside `complete_inner`'s slicing
705        // (`source[..offset]`); `complete`'s own `catch_panic` wrapper must
706        // still degrade to empty items rather than propagate, same guarantee
707        // `catch_panic_converts_panic_to_a_diagnostic` proves for the wrapper
708        // in general.
709        let prev = std::panic::take_hook();
710        std::panic::set_hook(Box::new(|_| {}));
711        let r = complete("", usize::MAX, Platform::Browser);
712        std::panic::set_hook(prev);
713        assert!(r.items.is_empty());
714    }
715
716    #[test]
717    fn catch_panic_converts_panic_to_a_diagnostic() {
718        // Silence the default hook's stderr backtrace for this deliberate panic,
719        // then restore it so no other test is affected.
720        let prev = std::panic::take_hook();
721        std::panic::set_hook(Box::new(|_| {}));
722        let caught = catch_panic(|| -> i32 { panic!("boom {}", 42) });
723        std::panic::set_hook(prev);
724
725        let d = caught.expect_err("a panic must become an Err(diagnostic)");
726        assert_eq!(d.severity, "error");
727        assert_eq!(d.category, "bynk.wasm.panic");
728        assert!(
729            d.message.contains("boom 42"),
730            "the panic message is carried through: {}",
731            d.message
732        );
733    }
734
735    #[test]
736    fn catch_panic_passes_a_value_through() {
737        assert_eq!(catch_panic(|| 7).ok(), Some(7));
738    }
739
740    #[test]
741    fn analyze_clean_program_has_no_errors() {
742        let r = analyze(PROG, Platform::Browser);
743        assert!(
744            r.diagnostics.iter().all(|d| d.severity != "error"),
745            "clean program should have no errors: {:?}",
746            r.diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>()
747        );
748    }
749}