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, kind, 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;
23use std::sync::Arc;
24
25use bynk_check::checker::{TyId, Types};
26use bynk_check::expr_types::type_at_offset;
27use bynk_check::firstparty::Platform;
28use bynk_check::locals::{LocalBinding, locals_at};
29use bynk_emit::project::{
30    AttributedError, BuildTarget, InMemoryAnalysis, analyse_in_memory, analyse_in_memory_project,
31    analyse_in_memory_with_types, compile_in_memory, slash_key,
32};
33use bynk_ide::completion;
34use bynk_syntax::CompileError;
35use bynk_syntax::span::Span;
36
37/// Where an emitted module comes from, so a consumer can show only the code
38/// that corresponds to what the user wrote (#2425). The graph itself is
39/// unchanged: the sandbox still links every file.
40#[derive(serde::Serialize, Clone, Copy, PartialEq, Eq, Debug)]
41#[serde(rename_all = "lowercase")]
42pub enum FileKind {
43    /// A module authored from the user's source (e.g. `hello/world.js`).
44    User,
45    /// The generated wiring that composes the program (`compose.js`).
46    Wiring,
47    /// `runtime.js`, the platform binding, and injected first-party units
48    /// (`bynk.js`, `bynk/locale/types.js`, …).
49    Support,
50}
51
52/// One emitted JavaScript module of the compiled program.
53#[derive(serde::Serialize)]
54pub struct EmittedFile {
55    /// Whether this is the user's code, the wiring, or support code.
56    pub kind: FileKind,
57    /// Output-relative path (e.g. `main.js`, `runtime.js`, `bynk-browser.js`).
58    pub path: String,
59    /// The JavaScript source.
60    pub contents: String,
61}
62
63/// A diagnostic flattened for the JS side, with a 1-indexed line/column.
64#[derive(serde::Serialize)]
65pub struct Diagnostic {
66    /// The source module the diagnostic belongs to, if attributable.
67    pub path: Option<String>,
68    pub line: usize,
69    pub col: usize,
70    /// Byte offsets of the diagnostic span (for the editor's inline lint range).
71    pub from: usize,
72    pub to: usize,
73    /// `"error"` or `"warning"`.
74    pub severity: String,
75    /// The stable diagnostic category (e.g. `bynk.parse.expected_token`).
76    pub category: String,
77    pub message: String,
78    /// Finding #47: previously dropped entirely for the playground. Plain
79    /// text, not positioned — the CLI/LSP renderers already carry the
80    /// harder problem of a label's span belonging to a different module
81    /// than `path` (finding #46); this flattening keeps to text only rather
82    /// than getting that wrong here too.
83    pub notes: Vec<String>,
84    pub labels: Vec<String>,
85}
86
87/// The outcome of compiling one in-memory source.
88#[derive(serde::Serialize)]
89pub struct CompileResult {
90    /// Whether a runnable JavaScript graph was produced.
91    pub ok: bool,
92    /// The runnable JS module graph (empty on failure).
93    pub files: Vec<EmittedFile>,
94    /// Errors on failure, or non-failing warnings on success.
95    pub diagnostics: Vec<Diagnostic>,
96}
97
98fn severity_str(err: &CompileError) -> &'static str {
99    match bynk_syntax::Severity::for_error(err) {
100        bynk_syntax::Severity::Error => "error",
101        bynk_syntax::Severity::Warning => "warning",
102    }
103}
104
105/// The human-readable message carried by a caught panic payload, if any.
106fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
107    if let Some(s) = payload.downcast_ref::<&str>() {
108        (*s).to_string()
109    } else if let Some(s) = payload.downcast_ref::<String>() {
110        s.clone()
111    } else {
112        "unknown panic".to_string()
113    }
114}
115
116/// A synthetic `bynk.wasm.panic` diagnostic standing in for an internal compiler
117/// panic, so an unexpected `panic!`/index-out-of-bounds/`unreachable!` in the
118/// pipeline becomes a structured error rather than propagating past the boundary.
119fn panic_diagnostic(payload: Box<dyn std::any::Any + Send>) -> Diagnostic {
120    Diagnostic {
121        path: None,
122        line: 0,
123        col: 0,
124        from: 0,
125        to: 0,
126        severity: "error".to_string(),
127        category: "bynk.wasm.panic".to_string(),
128        message: format!("internal compiler panic: {}", panic_message(&*payload)),
129        notes: Vec::new(),
130        labels: Vec::new(),
131    }
132}
133
134/// Run a pipeline entry point, converting an unexpected panic into a diagnostic.
135///
136/// On the native `rlib` path (the tests and any host embedding) this genuinely
137/// unwinds the panic and returns `Err(diagnostic)`, so a reachable-in-principle
138/// `panic!` no longer propagates past the wasm boundary. On the actual
139/// `wasm32-unknown-unknown` target a panic still traps (`RuntimeError:
140/// unreachable`) because the stock target lowers unwinding to a trap — there the
141/// blast radius is bounded instead by the `console_error_panic_hook` (a legible
142/// console error and location) set in the wasm entry points, and this wrapper
143/// becomes effective for free if the playground build ever adopts wasm exception
144/// handling. Fixing the underlying panic sites remains the real fix (#717).
145///
146/// `Diagnostic` grew past clippy's large-`Err` threshold once `notes`/`labels`
147/// (finding #47) joined it; boxing it here would need every one of this
148/// module's several `Diagnostic { .. }` construction sites and its `serde`
149/// serialisation to route through a `Box` for one lint, so it's overridden
150/// instead — this is a single-error return on the panic path, not a hot loop.
151#[allow(clippy::result_large_err)]
152fn catch_panic<T>(f: impl FnOnce() -> T) -> Result<T, Diagnostic> {
153    std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(panic_diagnostic)
154}
155
156/// Flatten attributed errors to [`Diagnostic`]s, resolving line/col against the
157/// owning source where known (`sources`), else the user source (`fallback`).
158fn to_diagnostics(
159    errs: Vec<AttributedError>,
160    sources: &HashMap<PathBuf, String>,
161    fallback: &str,
162) -> Vec<Diagnostic> {
163    errs.into_iter()
164        .map(|a| {
165            let src = a
166                .source_path
167                .as_ref()
168                .and_then(|p| sources.get(p))
169                .map(String::as_str)
170                .unwrap_or(fallback);
171            let (line, col) = bynk_syntax::span::line_col(src, a.error.span.start);
172            Diagnostic {
173                path: a
174                    .source_path
175                    .as_ref()
176                    .map(|p| p.to_string_lossy().into_owned()),
177                line,
178                col,
179                from: a.error.span.start,
180                to: a.error.span.end,
181                severity: severity_str(&a.error).to_string(),
182                category: a.error.category.to_string(),
183                message: a.error.message.clone(),
184                notes: a.error.notes.clone(),
185                labels: a.error.labels.iter().map(|(_, msg)| msg.clone()).collect(),
186            }
187        })
188        .collect()
189}
190
191/// Compile a single in-memory Bynk source to a JavaScript module graph for the
192/// given platform (the playground passes [`Platform::Browser`]). Pure: no
193/// filesystem, no `tsc`. The in-process `Bundle` subset only; programs that reach
194/// Workers/Cloudflare-only shapes are reported as diagnostics (slice-2 platform
195/// lock), never silently mis-compiled.
196pub fn compile(source: &str, platform: Platform) -> CompileResult {
197    catch_panic(|| compile_inner(source, platform)).unwrap_or_else(|d| CompileResult {
198        ok: false,
199        files: Vec::new(),
200        diagnostics: vec![d],
201    })
202}
203
204fn compile_inner(source: &str, platform: Platform) -> CompileResult {
205    match compile_in_memory(source, BuildTarget::Bundle, platform) {
206        Ok(out) => match bynk_strip::strip_project_to_js(out) {
207            Ok(js) => {
208                // The user program is the single in-memory source, so warnings
209                // resolve their line/col against it (the fallback).
210                let diagnostics = to_diagnostics(js.warnings, &HashMap::new(), source);
211                // #2148: the graph is the modules the browser links, so the
212                // output's `package.json` (the ES-module marker for Node and
213                // `tsc`) is left out.
214                let user_paths: std::collections::HashSet<std::path::PathBuf> = js
215                    .snapshots
216                    .iter()
217                    .map(|(p, _)| p.with_extension("js"))
218                    .collect();
219                let files = js
220                    .artefacts
221                    .docs
222                    .into_iter()
223                    .filter(|(_, doc)| !matches!(doc, bynk_emit::project::Document::Json(_)))
224                    .map(|(path, doc)| EmittedFile {
225                        kind: if user_paths.contains(&path) {
226                            FileKind::User
227                        } else if path == std::path::Path::new("compose.js") {
228                            FileKind::Wiring
229                        } else {
230                            FileKind::Support
231                        },
232                        path: path.to_string_lossy().into_owned(),
233                        contents: doc.text(),
234                    })
235                    .collect();
236                CompileResult {
237                    ok: true,
238                    files,
239                    diagnostics,
240                }
241            }
242            // The emitter is strip-only (ADR 0136), so this is unreachable for a
243            // successful compile — surfaced as a diagnostic rather than a panic.
244            Err(e) => CompileResult {
245                ok: false,
246                files: Vec::new(),
247                diagnostics: vec![Diagnostic {
248                    path: None,
249                    line: 0,
250                    col: 0,
251                    from: 0,
252                    to: 0,
253                    severity: "error".to_string(),
254                    category: "bynk.wasm.strip_failed".to_string(),
255                    message: e.to_string(),
256                    notes: Vec::new(),
257                    labels: Vec::new(),
258                }],
259            },
260        },
261        Err(failure) => {
262            let sources: HashMap<PathBuf, String> = failure.snapshots.iter().cloned().collect();
263            CompileResult {
264                ok: false,
265                files: Vec::new(),
266                diagnostics: to_diagnostics(failure.errors, &sources, source),
267            }
268        }
269    }
270}
271
272/// Compile to a JSON string — the wasm boundary representation of [`CompileResult`].
273pub fn compile_to_json(source: &str, platform: Platform) -> String {
274    serde_json::to_string(&compile(source, platform)).unwrap_or_else(|e| {
275        format!(
276            "{{\"ok\":false,\"files\":[],\"diagnostics\":[{{\"path\":null,\"line\":0,\"col\":0,\"from\":0,\"to\":0,\
277             \"severity\":\"error\",\"category\":\"bynk.wasm.serialize_failed\",\"message\":{:?}}}]}}",
278            e.to_string()
279        )
280    })
281}
282
283/// The diagnostics of a single in-memory source — non-bailing analysis, no emission
284/// (the editor's live, on-type diagnostics — slice 5d).
285#[derive(serde::Serialize)]
286pub struct AnalyzeResult {
287    pub diagnostics: Vec<Diagnostic>,
288}
289
290/// Analyse a source for diagnostics only (no compile/emit), for the given platform.
291pub fn analyze(source: &str, platform: Platform) -> AnalyzeResult {
292    catch_panic(|| analyze_inner(source, platform)).unwrap_or_else(|d| AnalyzeResult {
293        diagnostics: vec![d],
294    })
295}
296
297fn analyze_inner(source: &str, platform: Platform) -> AnalyzeResult {
298    let errs = analyse_in_memory(source, BuildTarget::Bundle, platform);
299    AnalyzeResult {
300        diagnostics: to_diagnostics(errs, &HashMap::new(), source),
301    }
302}
303
304/// Analyse to a JSON string — `{ diagnostics: [{ from, to, line, col, severity,
305/// category, message }] }`.
306pub fn analyze_to_json(source: &str, platform: Platform) -> String {
307    serde_json::to_string(&analyze(source, platform))
308        .unwrap_or_else(|_| "{\"diagnostics\":[]}".to_string())
309}
310
311/// The inferred type at a cursor position in a single in-memory source, or
312/// `None` if the expression at that position failed to type — per ADR
313/// 0094, a well-typed function still contributes types even when a *different*
314/// function in the same file has an error, so this isn't blanked by every
315/// mid-edit error, only by one at the position itself (or upstream of it, an
316/// unresolved name). The editor's hover tooltip (#397).
317#[derive(serde::Serialize)]
318pub struct HoverResult {
319    pub ty: Option<String>,
320}
321
322/// Hover for a byte `offset` into `source`, for the given platform.
323pub fn hover(source: &str, offset: usize, platform: Platform) -> HoverResult {
324    catch_panic(|| hover_inner(source, offset, platform)).unwrap_or(HoverResult { ty: None })
325}
326
327fn hover_inner(source: &str, offset: usize, platform: Platform) -> HoverResult {
328    let analysis = analyse_in_memory_with_types(source, BuildTarget::Bundle, platform);
329    let ty = type_at_offset(&analysis.expr_types, offset, &analysis.ty_intern)
330        .map(|t| t.display(&analysis.ty_intern));
331    HoverResult { ty }
332}
333
334/// Hover to a JSON string — `{ ty: string | null }`.
335pub fn hover_to_json(source: &str, offset: usize, platform: Platform) -> String {
336    serde_json::to_string(&hover(source, offset, platform))
337        .unwrap_or_else(|_| "{\"ty\":null}".to_string())
338}
339
340/// One completion candidate, serialised for the JS side — a shadow of
341/// `bynk_ide::completion::Completion`/`CompletionKind` (that crate stays
342/// serde-free; this is the wire DTO, same pattern as [`EmittedFile`]/[`Diagnostic`]).
343#[derive(serde::Serialize)]
344pub struct CompletionCandidate {
345    pub label: String,
346    /// "unit"/"capability"/"type"/"keyword"/"snippet"/"variant"/"member"/
347    /// "field"/"constructor"/"function"/"local".
348    pub kind: &'static str,
349    pub detail: Option<String>,
350    pub insert_text: Option<String>,
351}
352
353/// The editor's completion list at a cursor position (#808).
354#[derive(serde::Serialize)]
355pub struct CompleteResult {
356    pub items: Vec<CompletionCandidate>,
357}
358
359fn to_candidate(c: completion::Completion) -> CompletionCandidate {
360    use completion::CompletionKind::*;
361    let kind = match c.kind {
362        Unit => "unit",
363        Capability => "capability",
364        Type => "type",
365        Keyword => "keyword",
366        Snippet => "snippet",
367        Variant => "variant",
368        Member => "member",
369        Field => "field",
370        Constructor => "constructor",
371        Function => "function",
372        Constant => "constant",
373    };
374    CompletionCandidate {
375        label: c.label,
376        kind,
377        detail: c.detail,
378        insert_text: c.insert_text,
379    }
380}
381
382/// Completion at a byte `offset` into an in-memory Bynk source, for the given
383/// platform (capability methods, types, keywords, in-scope locals, and
384/// value-receiver members — #808, the other half of #397 hover shipped).
385/// Single buffer, single call — no project files, no multi-doc overlay/caching
386/// (the wasm boundary has none of those, so `files: None` throughout).
387pub fn complete(source: &str, offset: usize, platform: Platform) -> CompleteResult {
388    catch_panic(|| complete_inner(source, offset, platform))
389        .unwrap_or(CompleteResult { items: Vec::new() })
390}
391
392fn complete_inner(source: &str, offset: usize, platform: Platform) -> CompleteResult {
393    complete_with(source, offset, None, |text| {
394        FileTypes::from(analyse_in_memory_with_types(
395            text,
396            BuildTarget::Bundle,
397            platform,
398        ))
399    })
400}
401
402/// One file's expression types and local bindings, from either the
403/// single-source or the project analysis.
404struct FileTypes {
405    expr_types: Vec<(Span, TyId)>,
406    ty_intern: Arc<Types>,
407    locals: Vec<LocalBinding>,
408}
409
410impl From<InMemoryAnalysis> for FileTypes {
411    fn from(a: InMemoryAnalysis) -> Self {
412        FileTypes {
413            expr_types: a.expr_types,
414            ty_intern: a.ty_intern,
415            locals: a.locals,
416        }
417    }
418}
419
420/// Completion over one buffer, with `analyse` typing that buffer (or a
421/// rewrite of it) in whatever context the caller has: alone, or inside its
422/// project (#2102 slice 2). `files` is the project's `.bynk` sources, for the
423/// candidates that look across units (`consumes U { … }`). An offset past the
424/// end or inside a character yields no candidates rather than a panic.
425fn complete_with(
426    source: &str,
427    offset: usize,
428    files: Option<&HashMap<PathBuf, String>>,
429    analyse: impl Fn(&str) -> FileTypes,
430) -> CompleteResult {
431    if !source.is_char_boundary(offset) {
432        return CompleteResult { items: Vec::new() };
433    }
434    let line_prefix = source[..offset].rsplit('\n').next().unwrap_or("");
435    let mut items: Vec<CompletionCandidate> = completion::complete(line_prefix, source, files)
436        .into_iter()
437        .map(to_candidate)
438        .collect();
439
440    // ADR 0093 D3: in-scope locals/params, alongside keywords/constructors at
441    // a keyword or expression position — the same two disjoint positions
442    // `bynk-lsp`'s handler merges locals into.
443    if completion::is_keyword_position(line_prefix)
444        || completion::is_expression_position(line_prefix)
445    {
446        let analysis = analyse(source);
447        items.extend(locals_at(&analysis.locals, offset).into_iter().map(|b| {
448            CompletionCandidate {
449                label: b.name.clone(),
450                kind: "local",
451                detail: Some(b.ty.clone()),
452                insert_text: None,
453            }
454        }));
455    }
456    // A lowercase `receiver.` is a value receiver: `complete()` yields nothing
457    // there directly (ADR 0093 D4), so retype the rewritten buffer (dropping
458    // the trailing partial member) and offer the receiver's kernel methods /
459    // record fields.
460    if items.is_empty()
461        && let Some((rewritten, recv_offset)) = completion::value_receiver_rewrite(source, offset)
462    {
463        let analysis = analyse(&rewritten);
464        if let Some(ty) = type_at_offset(&analysis.expr_types, recv_offset, &analysis.ty_intern) {
465            items = completion::value_member_candidates(ty, &analysis.ty_intern, source, files)
466                .into_iter()
467                .map(to_candidate)
468                .collect();
469        }
470    }
471    CompleteResult { items }
472}
473
474/// Complete to a JSON string — `{ items: [{ label, kind, detail, insert_text }] }`.
475pub fn complete_to_json(source: &str, offset: usize, platform: Platform) -> String {
476    serde_json::to_string(&complete(source, offset, platform))
477        .unwrap_or_else(|_| "{\"items\":[]}".to_string())
478}
479
480// #2102 slice 2: the same three editor queries over a whole in-memory project,
481// for the VS Code web extension, which loads the workspace through
482// `vscode.workspace.fs`. A project is a map of project-relative paths to text
483// (`bynk.toml` and every `.bynk` file; see
484// `bynk_emit::project::analyse_in_memory_project`), sent as the JSON object
485// `{ "<path>": "<text>", … }`.
486
487/// Parse a project's `{ path: text }` JSON. Malformed JSON is an empty project.
488pub fn project_files(files_json: &str) -> HashMap<PathBuf, String> {
489    serde_json::from_str::<HashMap<String, String>>(files_json)
490        .unwrap_or_default()
491        .into_iter()
492        .map(|(path, text)| (PathBuf::from(path), text))
493        .collect()
494}
495
496/// Every file's diagnostics, each carrying its project-relative `path`. A
497/// project-level diagnostic (a manifest problem, say) has `path: null`.
498/// A project analysis: every file's diagnostics, plus `warnings` about the
499/// project itself that aren't tied to a file (an ignored absolute `[paths]`
500/// entry), for the caller to show.
501#[derive(serde::Serialize)]
502pub struct ProjectAnalyzeResult {
503    pub diagnostics: Vec<Diagnostic>,
504    pub warnings: Vec<String>,
505}
506
507pub fn analyze_project(
508    files: &HashMap<PathBuf, String>,
509    platform: Platform,
510) -> ProjectAnalyzeResult {
511    catch_panic(|| {
512        let analysis = analyse_in_memory_project(files, BuildTarget::Bundle, platform);
513        // The analysis reports `/`-separated paths; look sources up the same way.
514        let sources: HashMap<PathBuf, String> = files
515            .iter()
516            .map(|(path, text)| (slash_key(path), text.clone()))
517            .collect();
518        ProjectAnalyzeResult {
519            diagnostics: to_diagnostics(analysis.errors, &sources, ""),
520            warnings: analysis
521                .ignored_paths
522                .into_iter()
523                .map(|(key, entry)| {
524                    format!(
525                        "bynk.toml: `[paths] {key}` entry `{entry}` is an absolute path, which \
526                         can't name a file in the project, so it is ignored (use a path \
527                         relative to the project root)"
528                    )
529                })
530                .collect(),
531        }
532    })
533    .unwrap_or_else(|d| ProjectAnalyzeResult {
534        diagnostics: vec![d],
535        warnings: Vec::new(),
536    })
537}
538
539/// The project's analysis, narrowed to one file's types and locals.
540fn project_file_types(
541    files: &HashMap<PathBuf, String>,
542    path: &std::path::Path,
543    platform: Platform,
544) -> FileTypes {
545    let mut analysis = analyse_in_memory_project(files, BuildTarget::Bundle, platform);
546    FileTypes {
547        expr_types: analysis.expr_types.remove(path).unwrap_or_default(),
548        ty_intern: analysis.ty_intern,
549        locals: analysis.locals.remove(path).unwrap_or_default(),
550    }
551}
552
553/// Hover at a byte `offset` into the project file at `path`, typed in the
554/// context of the whole project.
555pub fn hover_project(
556    files: &HashMap<PathBuf, String>,
557    path: &str,
558    offset: usize,
559    platform: Platform,
560) -> HoverResult {
561    catch_panic(|| {
562        let types = project_file_types(files, &slash_key(std::path::Path::new(path)), platform);
563        HoverResult {
564            ty: type_at_offset(&types.expr_types, offset, &types.ty_intern)
565                .map(|t| t.display(&types.ty_intern)),
566        }
567    })
568    .unwrap_or(HoverResult { ty: None })
569}
570
571/// Completion at a byte `offset` into the project file at `path`: the
572/// single-source candidates, plus the ones that need the rest of the project
573/// (names a `uses` brings in, another unit's capabilities).
574pub fn complete_project(
575    files: &HashMap<PathBuf, String>,
576    path: &str,
577    offset: usize,
578    platform: Platform,
579) -> CompleteResult {
580    catch_panic(|| {
581        // One spelling for every key, so the edited buffer below replaces its
582        // file rather than sitting beside a differently separated copy.
583        let files: &HashMap<PathBuf, String> = &files
584            .iter()
585            .map(|(p, text)| (slash_key(p), text.clone()))
586            .collect();
587        let path = slash_key(std::path::Path::new(path));
588        let Some(source) = files.get(&path) else {
589            return CompleteResult { items: Vec::new() };
590        };
591        let sources: HashMap<PathBuf, String> = files
592            .iter()
593            .filter(|(p, _)| p.extension().is_some_and(|e| e == "bynk"))
594            .map(|(p, t)| (p.clone(), t.clone()))
595            .collect();
596        complete_with(source, offset, Some(&sources), |text| {
597            let mut edited = files.clone();
598            edited.insert(path.clone(), text.to_string());
599            project_file_types(&edited, &path, platform)
600        })
601    })
602    .unwrap_or(CompleteResult { items: Vec::new() })
603}
604
605/// The platform an editor entry point analyses for, from the optional name the JS
606/// side passes (#2102). `None` keeps the playground's `browser` (its programs run
607/// in the page). The VS Code web extension passes `"cloudflare"`, the platform
608/// `bynk-lsp` analyses a project for (`Platform::default()`), so a unit that
609/// consumes `bynk.cloudflare` doesn't draw `bynk.target.vendor_required` in
610/// vscode.dev when it checks clean on the desktop. An unrecognised name falls
611/// back to `browser`, the same as `None`.
612pub fn platform_named(name: Option<&str>) -> Platform {
613    match name {
614        Some("cloudflare") => Platform::Cloudflare,
615        Some("node") => Platform::Node,
616        _ => Platform::Browser,
617    }
618}
619
620#[cfg(target_arch = "wasm32")]
621use wasm_bindgen::prelude::wasm_bindgen;
622
623/// Route panics to `console.error` with a readable message and location. Idempotent
624/// (`set_once` installs the hook exactly once), so every entry point may call it.
625/// Without this a panic on adversarial input surfaces as an opaque `RuntimeError:
626/// unreachable` with no clue to its origin (#717).
627#[cfg(target_arch = "wasm32")]
628fn install_panic_hook() {
629    console_error_panic_hook::set_once();
630}
631
632/// The wasm entry point for live editor diagnostics: analyse an in-memory Bynk
633/// source for the browser and return `{ diagnostics: [...] }` (with byte `from`/`to`
634/// spans for inline marking). Non-bailing — all diagnostics at once. `platform`
635/// is optional (see [`platform_named`]); the playground omits it.
636#[cfg(target_arch = "wasm32")]
637#[wasm_bindgen]
638pub fn bynk_analyze(source: &str, platform: Option<String>) -> String {
639    install_panic_hook();
640    analyze_to_json(source, platform_named(platform.as_deref()))
641}
642
643/// The wasm entry point for the editor's hover tooltip: the inferred type at a
644/// byte `offset` into an in-memory Bynk source, as `{ "ty": string | null }`
645/// (#397). `platform` is optional (see [`platform_named`]).
646#[cfg(target_arch = "wasm32")]
647#[wasm_bindgen]
648pub fn bynk_hover(source: &str, offset: u32, platform: Option<String>) -> String {
649    install_panic_hook();
650    hover_to_json(source, offset as usize, platform_named(platform.as_deref()))
651}
652
653/// The wasm entry point for the editor's completion: context-aware candidates
654/// at a byte `offset` into an in-memory Bynk source, as
655/// `{ "items": [{ "label", "kind", "detail", "insert_text" }] }` (#808).
656/// `platform` is optional (see [`platform_named`]).
657#[cfg(target_arch = "wasm32")]
658#[wasm_bindgen]
659pub fn bynk_complete(source: &str, offset: u32, platform: Option<String>) -> String {
660    install_panic_hook();
661    complete_to_json(source, offset as usize, platform_named(platform.as_deref()))
662}
663
664/// The wasm entry point for project diagnostics (#2102 slice 2): analyse a
665/// whole in-memory project, given as `{ "<project-relative path>": "<text>" }`
666/// JSON, and return `{ diagnostics: [...] }`, each with its file's `path`.
667/// `platform` is optional (see [`platform_named`]).
668#[cfg(target_arch = "wasm32")]
669#[wasm_bindgen]
670pub fn bynk_analyze_project(files_json: &str, platform: Option<String>) -> String {
671    install_panic_hook();
672    let result = analyze_project(
673        &project_files(files_json),
674        platform_named(platform.as_deref()),
675    );
676    serde_json::to_string(&result).unwrap_or_else(|_| "{\"diagnostics\":[]}".to_string())
677}
678
679/// The wasm entry point for project hover (#2102 slice 2): the type at a byte
680/// `offset` into the project file at `path`, as `{ "ty": string | null }`.
681#[cfg(target_arch = "wasm32")]
682#[wasm_bindgen]
683pub fn bynk_hover_project(
684    files_json: &str,
685    path: &str,
686    offset: u32,
687    platform: Option<String>,
688) -> String {
689    install_panic_hook();
690    let result = hover_project(
691        &project_files(files_json),
692        path,
693        offset as usize,
694        platform_named(platform.as_deref()),
695    );
696    serde_json::to_string(&result).unwrap_or_else(|_| "{\"ty\":null}".to_string())
697}
698
699/// The wasm entry point for project completion (#2102 slice 2): candidates at a
700/// byte `offset` into the project file at `path`, as `{ "items": [...] }`.
701#[cfg(target_arch = "wasm32")]
702#[wasm_bindgen]
703pub fn bynk_complete_project(
704    files_json: &str,
705    path: &str,
706    offset: u32,
707    platform: Option<String>,
708) -> String {
709    install_panic_hook();
710    let result = complete_project(
711        &project_files(files_json),
712        path,
713        offset as usize,
714        platform_named(platform.as_deref()),
715    );
716    serde_json::to_string(&result).unwrap_or_else(|_| "{\"items\":[]}".to_string())
717}
718
719/// The wasm entry point: compile an in-memory Bynk source for the browser
720/// playground, returning a JSON document
721/// `{ ok, files: [{ path, contents }], diagnostics: [{ path, line, col, severity,
722/// category, message }] }`.
723#[cfg(target_arch = "wasm32")]
724#[wasm_bindgen]
725pub fn bynk_compile(source: &str) -> String {
726    install_panic_hook();
727    compile_to_json(source, Platform::Browser)
728}
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733    use std::path::Path;
734
735    const PROG: &str = "context app.demo\n\
736        \n\
737        consumes bynk { Clock, Logger }\n\
738        \n\
739        service demo {\n\
740        \x20 on call() -> Effect[Instant] given Clock, Logger {\n\
741        \x20   let _ <- Logger.info(\"hi\")\n\
742        \x20   let now <- Clock.now()\n\
743        \x20   now\n\
744        \x20 }\n\
745        }\n";
746
747    #[test]
748    fn compiles_browser_program_to_js_graph() {
749        let r = compile(PROG, Platform::Browser);
750        assert!(
751            r.ok,
752            "should compile: {:?}",
753            r.diagnostics.first().map(|d| &d.message)
754        );
755        // The full runnable graph: user module + runtime + browser binding + compose.
756        let paths: Vec<&str> = r.files.iter().map(|f| f.path.as_str()).collect();
757        assert!(
758            paths.iter().all(|p| p.ends_with(".js")),
759            "all JS: {paths:?}"
760        );
761        assert!(
762            paths.contains(&"runtime.js"),
763            "runtime.js present: {paths:?}"
764        );
765        assert!(
766            paths.contains(&"bynk-browser.js"),
767            "browser binding present: {paths:?}"
768        );
769        // #2425: each file is tagged by origin.
770        let kind = |p: &str| r.files.iter().find(|f| f.path == p).map(|f| f.kind);
771        assert_eq!(kind("app/demo.js"), Some(FileKind::User));
772        assert_eq!(kind("compose.js"), Some(FileKind::Wiring));
773        assert_eq!(kind("runtime.js"), Some(FileKind::Support));
774        assert_eq!(kind("bynk-browser.js"), Some(FileKind::Support));
775        assert_eq!(
776            r.files.iter().filter(|f| f.kind == FileKind::User).count(),
777            1,
778            "only the user's module is `user`"
779        );
780        // Injected first-party units are never mistaken for the user's code.
781        assert!(
782            r.files
783                .iter()
784                .filter(|f| f.path == "bynk.js" || f.path.starts_with("bynk/"))
785                .all(|f| f.kind == FileKind::Support),
786            "first-party units are `support`"
787        );
788        // No residual TypeScript type syntax survived the strip.
789        let user = r
790            .files
791            .iter()
792            .find(|f| f.path == "app/demo.js")
793            .expect("user module");
794        assert!(
795            !user.contents.contains(": Promise<"),
796            "annotations stripped:\n{}",
797            user.contents
798        );
799    }
800
801    #[test]
802    fn surfaces_diagnostics_for_a_bad_program() {
803        let r = compile("context app.demo\n\nthis is not bynk\n", Platform::Browser);
804        assert!(!r.ok);
805        assert!(r.files.is_empty());
806        assert!(!r.diagnostics.is_empty());
807        assert!(r.diagnostics.iter().all(|d| d.severity == "error"));
808        // Line/col point into the user source.
809        assert!(r.diagnostics.iter().any(|d| d.line >= 1));
810    }
811
812    #[test]
813    fn cloudflare_shapes_are_rejected_in_the_browser() {
814        // The slice-2 platform lock fires through the in-memory path too.
815        let prog = "context cache.store\n\
816            \n\
817            consumes bynk.cloudflare { Kv }\n\
818            \n\
819            service cache {\n\
820            \x20 on call(k: String) -> Effect[Option[String]] given Kv {\n\
821            \x20   let v <- Kv.get(k)\n\
822            \x20   v\n\
823            \x20 }\n\
824            }\n";
825        let r = compile(prog, Platform::Browser);
826        assert!(
827            !r.ok,
828            "a cloudflare-only program must not compile for the browser"
829        );
830        assert!(
831            r.diagnostics
832                .iter()
833                .any(|d| d.category == "bynk.target.vendor_required"),
834            "expected the platform lock: {:?}",
835            r.diagnostics
836                .iter()
837                .map(|d| &d.category)
838                .collect::<Vec<_>>()
839        );
840    }
841
842    /// #2102: the VS Code web extension analyses for `cloudflare`, as `bynk-lsp`
843    /// does, so a unit consuming `bynk.cloudflare` checks clean there; the
844    /// playground's default (`browser`) still reports the vendor lock.
845    #[test]
846    fn platform_named_selects_the_analysis_platform() {
847        assert_eq!(platform_named(None), Platform::Browser);
848        assert_eq!(platform_named(Some("cloudflare")), Platform::Cloudflare);
849        assert_eq!(platform_named(Some("node")), Platform::Node);
850        assert_eq!(platform_named(Some("browser")), Platform::Browser);
851        assert_eq!(platform_named(Some("mainframe")), Platform::Browser);
852
853        let prog = "context kv.index\n\n\
854            consumes bynk.cloudflare { Kv }\n\n\
855            service scan {\n\
856            \x20 on call(prefix: String) -> Effect[Result[List[String], ()]] given Kv {\n\
857            \x20   let keys <- Kv.list(Some(prefix))\n\
858            \x20   Ok(keys)\n\
859            \x20 }\n\
860            }\n";
861        let categories = |p: Platform| -> Vec<String> {
862            analyze(prog, p)
863                .diagnostics
864                .into_iter()
865                .map(|d| d.category)
866                .collect()
867        };
868        assert_eq!(
869            categories(platform_named(Some("cloudflare"))),
870            Vec::<String>::new()
871        );
872        assert!(
873            categories(platform_named(None)).contains(&"bynk.target.vendor_required".to_string()),
874            "the browser default keeps the vendor lock: {:?}",
875            categories(platform_named(None))
876        );
877    }
878
879    #[test]
880    fn compile_to_json_is_valid_json() {
881        let json = compile_to_json(PROG, Platform::Browser);
882        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
883        assert_eq!(v["ok"], true);
884        assert!(v["files"].as_array().is_some_and(|a| !a.is_empty()));
885    }
886
887    #[test]
888    fn analyze_reports_check_errors_for_a_context() {
889        // A type mismatch in a *context* — returning a String where Int is declared.
890        // The non-bailing analyse must report it (slice 5d's reason to exist: plain
891        // single-source `diagnose` only checks commons, not contexts).
892        let prog = "context app.demo\n\n\
893            consumes bynk { Logger }\n\n\
894            service demo {\n\
895            \x20 on call() -> Effect[Int] given Logger {\n\
896            \x20   let _ <- Logger.info(\"x\")\n\
897            \x20   \"not an int\"\n\
898            \x20 }\n\
899            }\n";
900        let r = analyze(prog, Platform::Browser);
901        assert!(
902            r.diagnostics.iter().any(|d| d.severity == "error"),
903            "a type mismatch should be reported: {:?}",
904            r.diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>()
905        );
906        // A real diagnostic carries a span for inline marking.
907        assert!(r.diagnostics.iter().any(|d| d.to > d.from));
908    }
909
910    #[test]
911    fn hover_reports_the_inferred_type_of_an_expression() {
912        // The tail expression `now` (the *reference*, not the `let now <-`
913        // binding) — the last occurrence of the substring in `PROG`.
914        let offset = PROG.rfind("now").expect("PROG mentions `now`");
915        let r = hover(PROG, offset, Platform::Browser);
916        assert_eq!(r.ty.as_deref(), Some("Instant"));
917    }
918
919    #[test]
920    fn hover_outside_any_expression_is_none() {
921        // Offset 0 sits in the `context` keyword — a declaration, not an
922        // expression, so nothing is recorded there.
923        let r = hover(PROG, 0, Platform::Browser);
924        assert_eq!(r.ty, None);
925    }
926
927    #[test]
928    fn hover_to_json_is_valid_json() {
929        let offset = PROG.rfind("now").expect("PROG mentions `now`");
930        let json = hover_to_json(PROG, offset, Platform::Browser);
931        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
932        assert_eq!(v["ty"], "Instant");
933    }
934
935    #[test]
936    fn hover_survives_a_sibling_error() {
937        // ADR 0094: hovering a well-typed expression must not go blank just
938        // because a *different* function in the same buffer is mid-edit and
939        // broken — the whole point of exposing the checker's partial
940        // `expr_types` map rather than its old all-or-nothing gate.
941        let prog = "commons app.demo\n\n\
942            fn good() -> Int {\n  42\n}\n\n\
943            fn bad() -> Int {\n  \"oops\"\n}\n";
944        let offset = prog.find("42").expect("prog mentions 42");
945        let r = hover(prog, offset, Platform::Browser);
946        assert_eq!(r.ty.as_deref(), Some("Int"));
947    }
948
949    #[test]
950    fn hover_on_an_expression_that_failed_to_type_is_none() {
951        // The checker records `Ty::Error` for `"oops" + 1`; hover shows no
952        // type rather than the `<type error>` placeholder.
953        let prog = "commons app.demo\n\nfn bad() -> Int {\n  \"oops\" + 1\n}\n";
954        let offset = prog.find("+ 1").expect("prog mentions + 1");
955        let r = hover(prog, offset, Platform::Browser);
956        assert_eq!(r.ty, None);
957    }
958
959    #[test]
960    fn complete_offers_in_scope_capability_after_given() {
961        let prog = "context app.demo\n\n\
962            consumes bynk { Clock, Logger }\n\n\
963            service demo {\n\
964            \x20 on call() -> Effect[Instant] given \n\
965            \x20   Clock.now()\n\
966            \x20 }\n\
967            }\n";
968        let offset = prog.find("given \n").expect("prog mentions given") + "given ".len();
969        let r = complete(prog, offset, Platform::Browser);
970        assert!(
971            r.items
972                .iter()
973                .any(|c| c.label == "Logger" && c.kind == "capability"),
974            "{:?}",
975            r.items.iter().map(|c| &c.label).collect::<Vec<_>>()
976        );
977    }
978
979    #[test]
980    fn complete_offers_in_scope_locals_at_expression_position() {
981        // ADR 0093 D3/D4: `bynk_complete` folds the two contexts that live
982        // handler-side in `bynk-lsp` (locals, value-receiver members) into
983        // the one wasm call — no analysis overlay/caching, single buffer.
984        let offset = PROG.rfind("now").expect("PROG mentions `now`");
985        let r = complete(PROG, offset, Platform::Browser);
986        assert!(
987            r.items.iter().any(|c| c.label == "now"
988                && c.kind == "local"
989                && c.detail.as_deref() == Some("Instant")),
990            "{:?}",
991            r.items
992                .iter()
993                .map(|c| (&c.label, c.kind))
994                .collect::<Vec<_>>()
995        );
996    }
997
998    #[test]
999    fn complete_offers_value_receiver_members_after_dot() {
1000        let prog = "commons app.demo\n\n\
1001            fn f() -> String {\n\
1002            \x20 let value = \"hi\"\n\
1003            \x20 value.\n\
1004            }\n";
1005        let offset = prog.find("value.\n").expect("prog mentions value.") + "value.".len();
1006        let r = complete(prog, offset, Platform::Browser);
1007        assert!(
1008            r.items
1009                .iter()
1010                .any(|c| c.label == "split" && c.kind == "member"),
1011            "{:?}",
1012            r.items.iter().map(|c| &c.label).collect::<Vec<_>>()
1013        );
1014    }
1015
1016    #[test]
1017    fn complete_survives_a_sibling_error() {
1018        // Same ADR 0094 ceiling as hover: a broken sibling function must not
1019        // blank out completion in a well-typed one.
1020        let prog = "commons app.demo\n\n\
1021            fn good() -> Int {\n  let count = 42\n  count\n}\n\n\
1022            fn bad() -> Int {\n  \"oops\"\n}\n";
1023        let offset = prog.rfind("count").expect("prog mentions count");
1024        let r = complete(prog, offset, Platform::Browser);
1025        assert!(
1026            r.items
1027                .iter()
1028                .any(|c| c.label == "count" && c.kind == "local"),
1029            "{:?}",
1030            r.items
1031                .iter()
1032                .map(|c| (&c.label, c.kind))
1033                .collect::<Vec<_>>()
1034        );
1035    }
1036
1037    #[test]
1038    fn complete_to_json_is_valid_json() {
1039        let offset = PROG.rfind("now").expect("PROG mentions `now`");
1040        let json = complete_to_json(PROG, offset, Platform::Browser);
1041        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
1042        assert!(v["items"].as_array().is_some_and(|a| !a.is_empty()));
1043    }
1044
1045    #[test]
1046    fn complete_survives_a_panic() {
1047        // An out-of-bounds offset panics inside `complete_inner`'s slicing
1048        // (`source[..offset]`); `complete`'s own `catch_panic` wrapper must
1049        // still degrade to empty items rather than propagate, same guarantee
1050        // `catch_panic_converts_panic_to_a_diagnostic` proves for the wrapper
1051        // in general.
1052        let prev = std::panic::take_hook();
1053        std::panic::set_hook(Box::new(|_| {}));
1054        let r = complete("", usize::MAX, Platform::Browser);
1055        std::panic::set_hook(prev);
1056        assert!(r.items.is_empty());
1057    }
1058
1059    #[test]
1060    fn catch_panic_converts_panic_to_a_diagnostic() {
1061        // Silence the default hook's stderr backtrace for this deliberate panic,
1062        // then restore it so no other test is affected.
1063        let prev = std::panic::take_hook();
1064        std::panic::set_hook(Box::new(|_| {}));
1065        let caught = catch_panic(|| -> i32 { panic!("boom {}", 42) });
1066        std::panic::set_hook(prev);
1067
1068        let d = caught.expect_err("a panic must become an Err(diagnostic)");
1069        assert_eq!(d.severity, "error");
1070        assert_eq!(d.category, "bynk.wasm.panic");
1071        assert!(
1072            d.message.contains("boom 42"),
1073            "the panic message is carried through: {}",
1074            d.message
1075        );
1076    }
1077
1078    #[test]
1079    fn catch_panic_passes_a_value_through() {
1080        assert_eq!(catch_panic(|| 7).ok(), Some(7));
1081    }
1082
1083    #[test]
1084    fn analyze_clean_program_has_no_errors() {
1085        let r = analyze(PROG, Platform::Browser);
1086        assert!(
1087            r.diagnostics.iter().all(|d| d.severity != "error"),
1088            "clean program should have no errors: {:?}",
1089            r.diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>()
1090        );
1091    }
1092
1093    // #2102 slice 2: project analysis for the VS Code web extension.
1094
1095    const TEXT: &str = "commons app.text\n\n\
1096        fn greeting(subject: String) -> String {\n\
1097        \x20 \"Hello, \\(subject)!\"\n\
1098        }\n";
1099
1100    const WEB: &str = "context app.web\n\n\
1101        uses app.text\n\n\
1102        consumes bynk { Logger }\n\n\
1103        service api {\n\
1104        \x20 on call() -> Effect[String] given Logger {\n\
1105        \x20   let _ <- Logger.info(\"hi\")\n\
1106        \x20   let message = greeting(\"World\")\n\
1107        \x20   message\n\
1108        \x20 }\n\
1109        }\n";
1110
1111    fn project(web: &str) -> HashMap<PathBuf, String> {
1112        HashMap::from([
1113            (
1114                PathBuf::from("bynk.toml"),
1115                "[project]\nname = \"p\"\nversion = \"0.1.0\"\n".to_string(),
1116            ),
1117            (PathBuf::from("src/app/text.bynk"), TEXT.to_string()),
1118            (PathBuf::from("src/app/web.bynk"), web.to_string()),
1119        ])
1120    }
1121
1122    fn categories(r: &ProjectAnalyzeResult) -> Vec<(Option<String>, String)> {
1123        r.diagnostics
1124            .iter()
1125            .map(|d| (d.path.clone(), d.category.clone()))
1126            .collect()
1127    }
1128
1129    #[test]
1130    fn a_project_resolves_uses_across_files() {
1131        // Alone, the context can't see `app.text`.
1132        let alone = analyze(WEB, Platform::Cloudflare);
1133        assert!(
1134            alone
1135                .diagnostics
1136                .iter()
1137                .any(|d| d.category == "bynk.uses.unknown_commons")
1138        );
1139        // With its project, it checks clean.
1140        let r = analyze_project(&project(WEB), Platform::Cloudflare);
1141        assert!(r.diagnostics.is_empty(), "{:?}", categories(&r));
1142    }
1143
1144    #[test]
1145    fn a_project_reports_a_cross_file_misuse_against_its_file() {
1146        let r = analyze_project(
1147            &project(&WEB.replace("greeting(\"World\")", "greeting(1)")),
1148            Platform::Cloudflare,
1149        );
1150        let cats = categories(&r);
1151        assert!(!cats.is_empty(), "the misuse is reported");
1152        assert!(
1153            cats.iter()
1154                .all(|(path, _)| path.as_deref() == Some("src/app/web.bynk")),
1155            "attributed to the using file: {cats:?}"
1156        );
1157    }
1158
1159    #[test]
1160    fn a_project_honours_paths_include() {
1161        // With `include = ["lib"]`, files under `src/` aren't part of it.
1162        let mut files = project(WEB);
1163        files.insert(
1164            PathBuf::from("bynk.toml"),
1165            "[project]\nname = \"p\"\nversion = \"0.1.0\"\n\n[paths]\ninclude = [\"lib\"]\n"
1166                .to_string(),
1167        );
1168        let text = files.remove(Path::new("src/app/text.bynk")).unwrap();
1169        files.insert(PathBuf::from("lib/app/text.bynk"), text);
1170        let web = files.remove(Path::new("src/app/web.bynk")).unwrap();
1171        files.insert(PathBuf::from("lib/app/web.bynk"), web);
1172        files.insert(PathBuf::from("src/app/stray.bynk"), "not bynk".to_string());
1173        let r = analyze_project(&files, Platform::Cloudflare);
1174        assert!(r.diagnostics.is_empty(), "{:?}", categories(&r));
1175    }
1176
1177    fn manifest(paths: &str) -> String {
1178        format!("[project]\nname = \"p\"\nversion = \"0.1.0\"\n\n[paths]\n{paths}\n")
1179    }
1180
1181    #[test]
1182    fn a_project_honours_a_dot_slash_include() {
1183        // `./src` names the same tree as `src`. Were it left as written, no key
1184        // would start with it and every file would silently drop out, so the
1185        // cross-file misuse below would go unreported.
1186        let mut files = project(&WEB.replace("greeting(\"World\")", "greeting(1)"));
1187        files.insert(
1188            PathBuf::from("bynk.toml"),
1189            manifest("include = [\"./src\"]"),
1190        );
1191        let r = analyze_project(&files, Platform::Cloudflare);
1192        let cats = categories(&r);
1193        assert!(!cats.is_empty(), "the files under ./src are analysed");
1194        assert!(
1195            cats.iter()
1196                .all(|(path, _)| path.as_deref() == Some("src/app/web.bynk")),
1197            "{cats:?}"
1198        );
1199    }
1200
1201    #[test]
1202    fn a_project_warns_about_absolute_paths_entries() {
1203        // An absolute include can't name a file in the project: it is ignored
1204        // and reported, and with no include left the conventional `src`
1205        // layout applies, so the project still checks.
1206        let mut files = project(WEB);
1207        files.insert(
1208            PathBuf::from("bynk.toml"),
1209            manifest("include = [\"/home/me/proj/src\"]\nexclude = [\"C:/gen\", \"gen\"]"),
1210        );
1211        let r = analyze_project(&files, Platform::Cloudflare);
1212        assert!(r.diagnostics.is_empty(), "{:?}", categories(&r));
1213        assert_eq!(r.warnings.len(), 2, "{:?}", r.warnings);
1214        assert!(r.warnings[0].contains("`[paths] include` entry `/home/me/proj/src`"));
1215        assert!(r.warnings[1].contains("`[paths] exclude` entry `C:/gen`"));
1216        // Relative entries draw no warning.
1217        let r = analyze_project(&project(WEB), Platform::Cloudflare);
1218        assert!(r.warnings.is_empty());
1219    }
1220
1221    #[test]
1222    fn a_project_honours_a_dot_slash_exclude_and_a_root_include() {
1223        // A flat layout (`include = ["./"]`, the root) with `exclude = ["./gen"]`:
1224        // the broken file under gen/ is left out, the rest checks clean.
1225        let mut files: HashMap<PathBuf, String> = project(WEB)
1226            .into_iter()
1227            .map(|(p, t)| (PathBuf::from(p.to_string_lossy().replace("src/", "")), t))
1228            .collect();
1229        files.insert(
1230            PathBuf::from("bynk.toml"),
1231            manifest("include = [\"./\"]\nexclude = [\"./gen\"]"),
1232        );
1233        files.insert(PathBuf::from("gen/junk.bynk"), "not bynk".to_string());
1234        let r = analyze_project(&files, Platform::Cloudflare);
1235        assert!(r.diagnostics.is_empty(), "{:?}", categories(&r));
1236        // Without the exclude, gen/junk.bynk is analysed and fails to parse.
1237        files.insert(PathBuf::from("bynk.toml"), manifest("include = [\".\"]"));
1238        let r = analyze_project(&files, Platform::Cloudflare);
1239        assert!(
1240            categories(&r)
1241                .iter()
1242                .any(|(path, _)| path.as_deref() == Some("gen/junk.bynk")),
1243            "{:?}",
1244            categories(&r)
1245        );
1246    }
1247
1248    #[test]
1249    fn a_project_hovers_a_name_from_another_file() {
1250        let files = project(WEB);
1251        let offset = WEB.rfind("message").expect("WEB mentions `message`");
1252        let r = hover_project(&files, "src/app/web.bynk", offset, Platform::Cloudflare);
1253        assert_eq!(r.ty.as_deref(), Some("String"));
1254        // An unknown path hovers nothing.
1255        let r = hover_project(&files, "src/app/nope.bynk", offset, Platform::Cloudflare);
1256        assert_eq!(r.ty, None);
1257    }
1258
1259    #[test]
1260    fn a_project_completes_locals_typed_across_files() {
1261        let src = WEB.replace("    message\n", "    m\n");
1262        let files = project(&src);
1263        let offset = src.rfind("    m\n").expect("the partial local") + "    m".len();
1264        let r = complete_project(&files, "src/app/web.bynk", offset, Platform::Cloudflare);
1265        let message = r
1266            .items
1267            .iter()
1268            .find(|c| c.label == "message")
1269            .unwrap_or_else(|| panic!("`message` is offered: {:?}", labels(&r)));
1270        assert_eq!(message.detail.as_deref(), Some("String"));
1271        // An offset inside a multi-byte character offers nothing, not a panic.
1272        let files = project(&WEB.replace("hi", "hé"));
1273        let inside = WEB.find("hi").unwrap() + 2;
1274        let r = complete_project(&files, "src/app/web.bynk", inside, Platform::Cloudflare);
1275        assert!(r.items.is_empty());
1276    }
1277
1278    fn labels(r: &CompleteResult) -> Vec<&str> {
1279        r.items.iter().map(|c| c.label.as_str()).collect()
1280    }
1281
1282    /// Windows CI caught `src\app/web.bynk`: the project model joins identity
1283    /// paths with the platform separator. Every path a project analysis hands
1284    /// back, and every key it takes, is `/`-separated on every platform.
1285    #[test]
1286    fn project_paths_are_slash_separated_on_every_platform() {
1287        let misuse = WEB.replace("greeting(\"World\")", "greeting(1)");
1288        // Keys spelled with the platform's own joins (`\` on Windows).
1289        let native: HashMap<PathBuf, String> = project(&misuse)
1290            .into_iter()
1291            .map(|(p, text)| {
1292                let joined = p.iter().fold(PathBuf::new(), |acc, part| acc.join(part));
1293                (joined, text)
1294            })
1295            .collect();
1296        for files in [project(&misuse), native] {
1297            let r = analyze_project(&files, Platform::Cloudflare);
1298            assert!(!r.diagnostics.is_empty(), "the misuse is reported");
1299            for d in &r.diagnostics {
1300                let path = d.path.as_deref().unwrap_or_default();
1301                assert!(!path.contains('\\'), "a `/` path, not {path:?}");
1302                assert_eq!(path, "src/app/web.bynk");
1303                // Resolved against the file's own text, so a real line.
1304                assert!(d.line > 1, "line/col from web.bynk: {}", d.line);
1305            }
1306            // The `"hi"` literal still types next to the misuse.
1307            let offset = misuse.find("\"hi\"").unwrap() + 1;
1308            let r = hover_project(&files, "src/app/web.bynk", offset, Platform::Cloudflare);
1309            assert_eq!(
1310                r.ty.as_deref(),
1311                Some("String"),
1312                "hover finds the file under its `/` key"
1313            );
1314        }
1315    }
1316
1317    #[test]
1318    fn slash_key_joins_components_with_slashes() {
1319        let joined = Path::new("src").join("app").join("web.bynk");
1320        assert_eq!(slash_key(&joined), PathBuf::from("src/app/web.bynk"));
1321        assert_eq!(
1322            slash_key(Path::new("./src/a.bynk")),
1323            PathBuf::from("src/a.bynk")
1324        );
1325        #[cfg(windows)]
1326        assert_eq!(
1327            slash_key(Path::new(r"src\app/web.bynk")),
1328            PathBuf::from("src/app/web.bynk")
1329        );
1330        // On Unix a `\` is part of a file name, not a separator.
1331        #[cfg(unix)]
1332        assert_eq!(
1333            slash_key(Path::new(r"src/a\b.bynk")),
1334            PathBuf::from(r"src/a\b.bynk")
1335        );
1336    }
1337
1338    #[test]
1339    fn project_files_parses_the_wire_map() {
1340        let files = project_files(r#"{"bynk.toml":"x","src/a.bynk":"y"}"#);
1341        assert_eq!(
1342            files.get(Path::new("src/a.bynk")).map(String::as_str),
1343            Some("y")
1344        );
1345        assert!(project_files("not json").is_empty());
1346    }
1347}