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