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                    .files
193                    .into_iter()
194                    .map(|f| EmittedFile {
195                        path: f.output_path.to_string_lossy().into_owned(),
196                        contents: f.typescript,
197                    })
198                    .collect();
199                CompileResult {
200                    ok: true,
201                    files,
202                    diagnostics,
203                }
204            }
205            // The emitter is strip-only (ADR 0136), so this is unreachable for a
206            // successful compile — surfaced as a diagnostic rather than a panic.
207            Err(e) => CompileResult {
208                ok: false,
209                files: Vec::new(),
210                diagnostics: vec![Diagnostic {
211                    path: None,
212                    line: 0,
213                    col: 0,
214                    from: 0,
215                    to: 0,
216                    severity: "error".to_string(),
217                    category: "bynk.wasm.strip_failed".to_string(),
218                    message: e.to_string(),
219                    notes: Vec::new(),
220                    labels: Vec::new(),
221                }],
222            },
223        },
224        Err(failure) => {
225            let sources: HashMap<PathBuf, String> = failure.snapshots.iter().cloned().collect();
226            CompileResult {
227                ok: false,
228                files: Vec::new(),
229                diagnostics: to_diagnostics(failure.errors, &sources, source),
230            }
231        }
232    }
233}
234
235/// Compile to a JSON string — the wasm boundary representation of [`CompileResult`].
236pub fn compile_to_json(source: &str, platform: Platform) -> String {
237    serde_json::to_string(&compile(source, platform)).unwrap_or_else(|e| {
238        format!(
239            "{{\"ok\":false,\"files\":[],\"diagnostics\":[{{\"path\":null,\"line\":0,\"col\":0,\"from\":0,\"to\":0,\
240             \"severity\":\"error\",\"category\":\"bynk.wasm.serialize_failed\",\"message\":{:?}}}]}}",
241            e.to_string()
242        )
243    })
244}
245
246/// The diagnostics of a single in-memory source — non-bailing analysis, no emission
247/// (the editor's live, on-type diagnostics — slice 5d).
248#[derive(serde::Serialize)]
249pub struct AnalyzeResult {
250    pub diagnostics: Vec<Diagnostic>,
251}
252
253/// Analyse a source for diagnostics only (no compile/emit), for the given platform.
254pub fn analyze(source: &str, platform: Platform) -> AnalyzeResult {
255    catch_panic(|| analyze_inner(source, platform)).unwrap_or_else(|d| AnalyzeResult {
256        diagnostics: vec![d],
257    })
258}
259
260fn analyze_inner(source: &str, platform: Platform) -> AnalyzeResult {
261    let errs = analyse_in_memory(source, BuildTarget::Bundle, platform);
262    AnalyzeResult {
263        diagnostics: to_diagnostics(errs, &HashMap::new(), source),
264    }
265}
266
267/// Analyse to a JSON string — `{ diagnostics: [{ from, to, line, col, severity,
268/// category, message }] }`.
269pub fn analyze_to_json(source: &str, platform: Platform) -> String {
270    serde_json::to_string(&analyze(source, platform))
271        .unwrap_or_else(|_| "{\"diagnostics\":[]}".to_string())
272}
273
274/// The inferred type at a cursor position in a single in-memory source, or
275/// `None` if the expression at that position never typed at all — per ADR
276/// 0094, a well-typed function still contributes types even when a *different*
277/// function in the same file has an error, so this isn't blanked by every
278/// mid-edit error, only by one at the position itself (or upstream of it, an
279/// unresolved name). The editor's hover tooltip (#397).
280#[derive(serde::Serialize)]
281pub struct HoverResult {
282    pub ty: Option<String>,
283}
284
285/// Hover for a byte `offset` into `source`, for the given platform.
286pub fn hover(source: &str, offset: usize, platform: Platform) -> HoverResult {
287    catch_panic(|| hover_inner(source, offset, platform)).unwrap_or(HoverResult { ty: None })
288}
289
290fn hover_inner(source: &str, offset: usize, platform: Platform) -> HoverResult {
291    let analysis = analyse_in_memory_with_types(source, BuildTarget::Bundle, platform);
292    let ty = type_at_offset(&analysis.expr_types, offset).map(|t| t.display(&analysis.ty_intern));
293    HoverResult { ty }
294}
295
296/// Hover to a JSON string — `{ ty: string | null }`.
297pub fn hover_to_json(source: &str, offset: usize, platform: Platform) -> String {
298    serde_json::to_string(&hover(source, offset, platform))
299        .unwrap_or_else(|_| "{\"ty\":null}".to_string())
300}
301
302/// One completion candidate, serialised for the JS side — a shadow of
303/// `bynk_ide::completion::Completion`/`CompletionKind` (that crate stays
304/// serde-free; this is the wire DTO, same pattern as [`EmittedFile`]/[`Diagnostic`]).
305#[derive(serde::Serialize)]
306pub struct CompletionCandidate {
307    pub label: String,
308    /// "unit"/"capability"/"type"/"keyword"/"snippet"/"variant"/"member"/
309    /// "field"/"constructor"/"function"/"local".
310    pub kind: &'static str,
311    pub detail: Option<String>,
312    pub insert_text: Option<String>,
313}
314
315/// The editor's completion list at a cursor position (#808).
316#[derive(serde::Serialize)]
317pub struct CompleteResult {
318    pub items: Vec<CompletionCandidate>,
319}
320
321fn to_candidate(c: completion::Completion) -> CompletionCandidate {
322    use completion::CompletionKind::*;
323    let kind = match c.kind {
324        Unit => "unit",
325        Capability => "capability",
326        Type => "type",
327        Keyword => "keyword",
328        Snippet => "snippet",
329        Variant => "variant",
330        Member => "member",
331        Field => "field",
332        Constructor => "constructor",
333        Function => "function",
334    };
335    CompletionCandidate {
336        label: c.label,
337        kind,
338        detail: c.detail,
339        insert_text: c.insert_text,
340    }
341}
342
343/// Completion at a byte `offset` into an in-memory Bynk source, for the given
344/// platform (capability methods, types, keywords, in-scope locals, and
345/// value-receiver members — #808, the other half of #397 hover shipped).
346/// Single buffer, single call — no project files, no multi-doc overlay/caching
347/// (the wasm boundary has none of those, so `files: None` throughout).
348pub fn complete(source: &str, offset: usize, platform: Platform) -> CompleteResult {
349    catch_panic(|| complete_inner(source, offset, platform))
350        .unwrap_or(CompleteResult { items: Vec::new() })
351}
352
353fn complete_inner(source: &str, offset: usize, platform: Platform) -> CompleteResult {
354    let line_prefix = source[..offset].rsplit('\n').next().unwrap_or("");
355    let mut items: Vec<CompletionCandidate> = completion::complete(line_prefix, source, None)
356        .into_iter()
357        .map(to_candidate)
358        .collect();
359
360    // ADR 0093 D3: in-scope locals/params, alongside keywords/constructors at
361    // a keyword or expression position — the same two disjoint positions
362    // `bynk-lsp`'s handler merges locals into.
363    if completion::is_keyword_position(line_prefix)
364        || completion::is_expression_position(line_prefix)
365    {
366        let analysis = analyse_in_memory_with_types(source, BuildTarget::Bundle, platform);
367        items.extend(locals_at(&analysis.locals, offset).into_iter().map(|b| {
368            CompletionCandidate {
369                label: b.name.clone(),
370                kind: "local",
371                detail: Some(b.ty.clone()),
372                insert_text: None,
373            }
374        }));
375    }
376    // A lowercase `receiver.` is a value receiver: `complete()` yields nothing
377    // there directly (ADR 0093 D4), so retype the rewritten buffer (dropping
378    // the trailing partial member) and offer the receiver's kernel methods /
379    // record fields.
380    if items.is_empty()
381        && let Some((rewritten, recv_offset)) = completion::value_receiver_rewrite(source, offset)
382    {
383        let analysis = analyse_in_memory_with_types(&rewritten, BuildTarget::Bundle, platform);
384        if let Some(ty) = type_at_offset(&analysis.expr_types, recv_offset) {
385            items = completion::value_member_candidates(ty, &analysis.ty_intern, source, None)
386                .into_iter()
387                .map(to_candidate)
388                .collect();
389        }
390    }
391    CompleteResult { items }
392}
393
394/// Complete to a JSON string — `{ items: [{ label, kind, detail, insert_text }] }`.
395pub fn complete_to_json(source: &str, offset: usize, platform: Platform) -> String {
396    serde_json::to_string(&complete(source, offset, platform))
397        .unwrap_or_else(|_| "{\"items\":[]}".to_string())
398}
399
400#[cfg(target_arch = "wasm32")]
401use wasm_bindgen::prelude::wasm_bindgen;
402
403/// Route panics to `console.error` with a readable message and location. Idempotent
404/// (`set_once` installs the hook exactly once), so every entry point may call it.
405/// Without this a panic on adversarial input surfaces as an opaque `RuntimeError:
406/// unreachable` with no clue to its origin (#717).
407#[cfg(target_arch = "wasm32")]
408fn install_panic_hook() {
409    console_error_panic_hook::set_once();
410}
411
412/// The wasm entry point for live editor diagnostics: analyse an in-memory Bynk
413/// source for the browser and return `{ diagnostics: [...] }` (with byte `from`/`to`
414/// spans for inline marking). Non-bailing — all diagnostics at once.
415#[cfg(target_arch = "wasm32")]
416#[wasm_bindgen]
417pub fn bynk_analyze(source: &str) -> String {
418    install_panic_hook();
419    analyze_to_json(source, Platform::Browser)
420}
421
422/// The wasm entry point for the editor's hover tooltip: the inferred type at a
423/// byte `offset` into an in-memory Bynk source, as `{ "ty": string | null }`
424/// (#397).
425#[cfg(target_arch = "wasm32")]
426#[wasm_bindgen]
427pub fn bynk_hover(source: &str, offset: u32) -> String {
428    install_panic_hook();
429    hover_to_json(source, offset as usize, Platform::Browser)
430}
431
432/// The wasm entry point for the editor's completion: context-aware candidates
433/// at a byte `offset` into an in-memory Bynk source, as
434/// `{ "items": [{ "label", "kind", "detail", "insert_text" }] }` (#808).
435#[cfg(target_arch = "wasm32")]
436#[wasm_bindgen]
437pub fn bynk_complete(source: &str, offset: u32) -> String {
438    install_panic_hook();
439    complete_to_json(source, offset as usize, Platform::Browser)
440}
441
442/// The wasm entry point: compile an in-memory Bynk source for the browser
443/// playground, returning a JSON document
444/// `{ ok, files: [{ path, contents }], diagnostics: [{ path, line, col, severity,
445/// category, message }] }`.
446#[cfg(target_arch = "wasm32")]
447#[wasm_bindgen]
448pub fn bynk_compile(source: &str) -> String {
449    install_panic_hook();
450    compile_to_json(source, Platform::Browser)
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    const PROG: &str = "context app.demo\n\
458        \n\
459        consumes bynk { Clock, Logger }\n\
460        \n\
461        service demo {\n\
462        \x20 on call() -> Effect[Instant] given Clock, Logger {\n\
463        \x20   let _ <- Logger.info(\"hi\")\n\
464        \x20   let now <- Clock.now()\n\
465        \x20   now\n\
466        \x20 }\n\
467        }\n";
468
469    #[test]
470    fn compiles_browser_program_to_js_graph() {
471        let r = compile(PROG, Platform::Browser);
472        assert!(
473            r.ok,
474            "should compile: {:?}",
475            r.diagnostics.first().map(|d| &d.message)
476        );
477        // The full runnable graph: user module + runtime + browser binding + compose.
478        let paths: Vec<&str> = r.files.iter().map(|f| f.path.as_str()).collect();
479        assert!(
480            paths.iter().all(|p| p.ends_with(".js")),
481            "all JS: {paths:?}"
482        );
483        assert!(
484            paths.contains(&"runtime.js"),
485            "runtime.js present: {paths:?}"
486        );
487        assert!(
488            paths.contains(&"bynk-browser.js"),
489            "browser binding present: {paths:?}"
490        );
491        // No residual TypeScript type syntax survived the strip.
492        let user = r
493            .files
494            .iter()
495            .find(|f| f.path == "app/demo.js")
496            .expect("user module");
497        assert!(
498            !user.contents.contains(": Promise<"),
499            "annotations stripped:\n{}",
500            user.contents
501        );
502    }
503
504    #[test]
505    fn surfaces_diagnostics_for_a_bad_program() {
506        let r = compile("context app.demo\n\nthis is not bynk\n", Platform::Browser);
507        assert!(!r.ok);
508        assert!(r.files.is_empty());
509        assert!(!r.diagnostics.is_empty());
510        assert!(r.diagnostics.iter().all(|d| d.severity == "error"));
511        // Line/col point into the user source.
512        assert!(r.diagnostics.iter().any(|d| d.line >= 1));
513    }
514
515    #[test]
516    fn cloudflare_shapes_are_rejected_in_the_browser() {
517        // The slice-2 platform lock fires through the in-memory path too.
518        let prog = "context cache.store\n\
519            \n\
520            consumes bynk.cloudflare { Kv }\n\
521            \n\
522            service cache {\n\
523            \x20 on call(k: String) -> Effect[Option[String]] given Kv {\n\
524            \x20   let v <- Kv.get(k)\n\
525            \x20   v\n\
526            \x20 }\n\
527            }\n";
528        let r = compile(prog, Platform::Browser);
529        assert!(
530            !r.ok,
531            "a cloudflare-only program must not compile for the browser"
532        );
533        assert!(
534            r.diagnostics
535                .iter()
536                .any(|d| d.category == "bynk.target.vendor_required"),
537            "expected the platform lock: {:?}",
538            r.diagnostics
539                .iter()
540                .map(|d| &d.category)
541                .collect::<Vec<_>>()
542        );
543    }
544
545    #[test]
546    fn compile_to_json_is_valid_json() {
547        let json = compile_to_json(PROG, Platform::Browser);
548        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
549        assert_eq!(v["ok"], true);
550        assert!(v["files"].as_array().is_some_and(|a| !a.is_empty()));
551    }
552
553    #[test]
554    fn analyze_reports_check_errors_for_a_context() {
555        // A type mismatch in a *context* — returning a String where Int is declared.
556        // The non-bailing analyse must report it (slice 5d's reason to exist: plain
557        // single-source `diagnose` only checks commons, not contexts).
558        let prog = "context app.demo\n\n\
559            consumes bynk { Logger }\n\n\
560            service demo {\n\
561            \x20 on call() -> Effect[Int] given Logger {\n\
562            \x20   let _ <- Logger.info(\"x\")\n\
563            \x20   \"not an int\"\n\
564            \x20 }\n\
565            }\n";
566        let r = analyze(prog, Platform::Browser);
567        assert!(
568            r.diagnostics.iter().any(|d| d.severity == "error"),
569            "a type mismatch should be reported: {:?}",
570            r.diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>()
571        );
572        // A real diagnostic carries a span for inline marking.
573        assert!(r.diagnostics.iter().any(|d| d.to > d.from));
574    }
575
576    #[test]
577    fn hover_reports_the_inferred_type_of_an_expression() {
578        // The tail expression `now` (the *reference*, not the `let now <-`
579        // binding) — the last occurrence of the substring in `PROG`.
580        let offset = PROG.rfind("now").expect("PROG mentions `now`");
581        let r = hover(PROG, offset, Platform::Browser);
582        assert_eq!(r.ty.as_deref(), Some("Instant"));
583    }
584
585    #[test]
586    fn hover_outside_any_expression_is_none() {
587        // Offset 0 sits in the `context` keyword — a declaration, not an
588        // expression, so nothing is recorded there.
589        let r = hover(PROG, 0, Platform::Browser);
590        assert_eq!(r.ty, None);
591    }
592
593    #[test]
594    fn hover_to_json_is_valid_json() {
595        let offset = PROG.rfind("now").expect("PROG mentions `now`");
596        let json = hover_to_json(PROG, offset, Platform::Browser);
597        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
598        assert_eq!(v["ty"], "Instant");
599    }
600
601    #[test]
602    fn hover_survives_a_sibling_error() {
603        // ADR 0094: hovering a well-typed expression must not go blank just
604        // because a *different* function in the same buffer is mid-edit and
605        // broken — the whole point of exposing the checker's partial
606        // `expr_types` map rather than its old all-or-nothing gate.
607        let prog = "commons app.demo\n\n\
608            fn good() -> Int {\n  42\n}\n\n\
609            fn bad() -> Int {\n  \"oops\"\n}\n";
610        let offset = prog.find("42").expect("prog mentions 42");
611        let r = hover(prog, offset, Platform::Browser);
612        assert_eq!(r.ty.as_deref(), Some("Int"));
613    }
614
615    #[test]
616    fn complete_offers_in_scope_capability_after_given() {
617        let prog = "context app.demo\n\n\
618            consumes bynk { Clock, Logger }\n\n\
619            service demo {\n\
620            \x20 on call() -> Effect[Instant] given \n\
621            \x20   Clock.now()\n\
622            \x20 }\n\
623            }\n";
624        let offset = prog.find("given \n").expect("prog mentions given") + "given ".len();
625        let r = complete(prog, offset, Platform::Browser);
626        assert!(
627            r.items
628                .iter()
629                .any(|c| c.label == "Logger" && c.kind == "capability"),
630            "{:?}",
631            r.items.iter().map(|c| &c.label).collect::<Vec<_>>()
632        );
633    }
634
635    #[test]
636    fn complete_offers_in_scope_locals_at_expression_position() {
637        // ADR 0093 D3/D4: `bynk_complete` folds the two contexts that live
638        // handler-side in `bynk-lsp` (locals, value-receiver members) into
639        // the one wasm call — no analysis overlay/caching, single buffer.
640        let offset = PROG.rfind("now").expect("PROG mentions `now`");
641        let r = complete(PROG, offset, Platform::Browser);
642        assert!(
643            r.items.iter().any(|c| c.label == "now"
644                && c.kind == "local"
645                && c.detail.as_deref() == Some("Instant")),
646            "{:?}",
647            r.items
648                .iter()
649                .map(|c| (&c.label, c.kind))
650                .collect::<Vec<_>>()
651        );
652    }
653
654    #[test]
655    fn complete_offers_value_receiver_members_after_dot() {
656        let prog = "commons app.demo\n\n\
657            fn f() -> String {\n\
658            \x20 let value = \"hi\"\n\
659            \x20 value.\n\
660            }\n";
661        let offset = prog.find("value.\n").expect("prog mentions value.") + "value.".len();
662        let r = complete(prog, offset, Platform::Browser);
663        assert!(
664            r.items
665                .iter()
666                .any(|c| c.label == "split" && c.kind == "member"),
667            "{:?}",
668            r.items.iter().map(|c| &c.label).collect::<Vec<_>>()
669        );
670    }
671
672    #[test]
673    fn complete_survives_a_sibling_error() {
674        // Same ADR 0094 ceiling as hover: a broken sibling function must not
675        // blank out completion in a well-typed one.
676        let prog = "commons app.demo\n\n\
677            fn good() -> Int {\n  let count = 42\n  count\n}\n\n\
678            fn bad() -> Int {\n  \"oops\"\n}\n";
679        let offset = prog.rfind("count").expect("prog mentions count");
680        let r = complete(prog, offset, Platform::Browser);
681        assert!(
682            r.items
683                .iter()
684                .any(|c| c.label == "count" && c.kind == "local"),
685            "{:?}",
686            r.items
687                .iter()
688                .map(|c| (&c.label, c.kind))
689                .collect::<Vec<_>>()
690        );
691    }
692
693    #[test]
694    fn complete_to_json_is_valid_json() {
695        let offset = PROG.rfind("now").expect("PROG mentions `now`");
696        let json = complete_to_json(PROG, offset, Platform::Browser);
697        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
698        assert!(v["items"].as_array().is_some_and(|a| !a.is_empty()));
699    }
700
701    #[test]
702    fn complete_survives_a_panic() {
703        // An out-of-bounds offset panics inside `complete_inner`'s slicing
704        // (`source[..offset]`); `complete`'s own `catch_panic` wrapper must
705        // still degrade to empty items rather than propagate, same guarantee
706        // `catch_panic_converts_panic_to_a_diagnostic` proves for the wrapper
707        // in general.
708        let prev = std::panic::take_hook();
709        std::panic::set_hook(Box::new(|_| {}));
710        let r = complete("", usize::MAX, Platform::Browser);
711        std::panic::set_hook(prev);
712        assert!(r.items.is_empty());
713    }
714
715    #[test]
716    fn catch_panic_converts_panic_to_a_diagnostic() {
717        // Silence the default hook's stderr backtrace for this deliberate panic,
718        // then restore it so no other test is affected.
719        let prev = std::panic::take_hook();
720        std::panic::set_hook(Box::new(|_| {}));
721        let caught = catch_panic(|| -> i32 { panic!("boom {}", 42) });
722        std::panic::set_hook(prev);
723
724        let d = caught.expect_err("a panic must become an Err(diagnostic)");
725        assert_eq!(d.severity, "error");
726        assert_eq!(d.category, "bynk.wasm.panic");
727        assert!(
728            d.message.contains("boom 42"),
729            "the panic message is carried through: {}",
730            d.message
731        );
732    }
733
734    #[test]
735    fn catch_panic_passes_a_value_through() {
736        assert_eq!(catch_panic(|| 7).ok(), Some(7));
737    }
738
739    #[test]
740    fn analyze_clean_program_has_no_errors() {
741        let r = analyze(PROG, Platform::Browser);
742        assert!(
743            r.diagnostics.iter().all(|d| d.severity != "error"),
744            "clean program should have no errors: {:?}",
745            r.diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>()
746        );
747    }
748}