1use std::collections::HashMap;
22use std::path::PathBuf;
23
24use bynk_check::expr_types::type_at_offset;
25use bynk_check::firstparty::Platform;
26use bynk_check::locals::locals_at;
27use bynk_emit::project::{
28 AttributedError, BuildTarget, analyse_in_memory, analyse_in_memory_with_types,
29 compile_in_memory,
30};
31use bynk_ide::completion;
32use bynk_syntax::CompileError;
33
34#[derive(serde::Serialize)]
36pub struct EmittedFile {
37 pub path: String,
39 pub contents: String,
41}
42
43#[derive(serde::Serialize)]
45pub struct Diagnostic {
46 pub path: Option<String>,
48 pub line: usize,
49 pub col: usize,
50 pub from: usize,
52 pub to: usize,
53 pub severity: String,
55 pub category: String,
57 pub message: String,
58 pub notes: Vec<String>,
64 pub labels: Vec<String>,
65}
66
67#[derive(serde::Serialize)]
69pub struct CompileResult {
70 pub ok: bool,
72 pub files: Vec<EmittedFile>,
74 pub diagnostics: Vec<Diagnostic>,
76}
77
78fn severity_str(err: &CompileError) -> &'static str {
79 match bynk_syntax::Severity::for_error(err) {
80 bynk_syntax::Severity::Error => "error",
81 bynk_syntax::Severity::Warning => "warning",
82 }
83}
84
85fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
87 if let Some(s) = payload.downcast_ref::<&str>() {
88 (*s).to_string()
89 } else if let Some(s) = payload.downcast_ref::<String>() {
90 s.clone()
91 } else {
92 "unknown panic".to_string()
93 }
94}
95
96fn panic_diagnostic(payload: Box<dyn std::any::Any + Send>) -> Diagnostic {
100 Diagnostic {
101 path: None,
102 line: 0,
103 col: 0,
104 from: 0,
105 to: 0,
106 severity: "error".to_string(),
107 category: "bynk.wasm.panic".to_string(),
108 message: format!("internal compiler panic: {}", panic_message(&*payload)),
109 notes: Vec::new(),
110 labels: Vec::new(),
111 }
112}
113
114#[allow(clippy::result_large_err)]
132fn catch_panic<T>(f: impl FnOnce() -> T) -> Result<T, Diagnostic> {
133 std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(panic_diagnostic)
134}
135
136fn to_diagnostics(
139 errs: Vec<AttributedError>,
140 sources: &HashMap<PathBuf, String>,
141 fallback: &str,
142) -> Vec<Diagnostic> {
143 errs.into_iter()
144 .map(|a| {
145 let src = a
146 .source_path
147 .as_ref()
148 .and_then(|p| sources.get(p))
149 .map(String::as_str)
150 .unwrap_or(fallback);
151 let (line, col) = bynk_syntax::span::line_col(src, a.error.span.start);
152 Diagnostic {
153 path: a
154 .source_path
155 .as_ref()
156 .map(|p| p.to_string_lossy().into_owned()),
157 line,
158 col,
159 from: a.error.span.start,
160 to: a.error.span.end,
161 severity: severity_str(&a.error).to_string(),
162 category: a.error.category.to_string(),
163 message: a.error.message.clone(),
164 notes: a.error.notes.clone(),
165 labels: a.error.labels.iter().map(|(_, msg)| msg.clone()).collect(),
166 }
167 })
168 .collect()
169}
170
171pub fn compile(source: &str, platform: Platform) -> CompileResult {
177 catch_panic(|| compile_inner(source, platform)).unwrap_or_else(|d| CompileResult {
178 ok: false,
179 files: Vec::new(),
180 diagnostics: vec![d],
181 })
182}
183
184fn compile_inner(source: &str, platform: Platform) -> CompileResult {
185 match compile_in_memory(source, BuildTarget::Bundle, platform) {
186 Ok(out) => match bynk_strip::strip_project_to_js(out) {
187 Ok(js) => {
188 let diagnostics = to_diagnostics(js.warnings, &HashMap::new(), source);
191 let files = js
192 .files
193 .into_iter()
194 .map(|f| EmittedFile {
195 path: f.output_path.to_string_lossy().into_owned(),
196 contents: f.typescript,
197 })
198 .collect();
199 CompileResult {
200 ok: true,
201 files,
202 diagnostics,
203 }
204 }
205 Err(e) => CompileResult {
208 ok: false,
209 files: Vec::new(),
210 diagnostics: vec![Diagnostic {
211 path: None,
212 line: 0,
213 col: 0,
214 from: 0,
215 to: 0,
216 severity: "error".to_string(),
217 category: "bynk.wasm.strip_failed".to_string(),
218 message: e.to_string(),
219 notes: Vec::new(),
220 labels: Vec::new(),
221 }],
222 },
223 },
224 Err(failure) => {
225 let sources: HashMap<PathBuf, String> = failure.snapshots.iter().cloned().collect();
226 CompileResult {
227 ok: false,
228 files: Vec::new(),
229 diagnostics: to_diagnostics(failure.errors, &sources, source),
230 }
231 }
232 }
233}
234
235pub fn compile_to_json(source: &str, platform: Platform) -> String {
237 serde_json::to_string(&compile(source, platform)).unwrap_or_else(|e| {
238 format!(
239 "{{\"ok\":false,\"files\":[],\"diagnostics\":[{{\"path\":null,\"line\":0,\"col\":0,\"from\":0,\"to\":0,\
240 \"severity\":\"error\",\"category\":\"bynk.wasm.serialize_failed\",\"message\":{:?}}}]}}",
241 e.to_string()
242 )
243 })
244}
245
246#[derive(serde::Serialize)]
249pub struct AnalyzeResult {
250 pub diagnostics: Vec<Diagnostic>,
251}
252
253pub fn analyze(source: &str, platform: Platform) -> AnalyzeResult {
255 catch_panic(|| analyze_inner(source, platform)).unwrap_or_else(|d| AnalyzeResult {
256 diagnostics: vec![d],
257 })
258}
259
260fn analyze_inner(source: &str, platform: Platform) -> AnalyzeResult {
261 let errs = analyse_in_memory(source, BuildTarget::Bundle, platform);
262 AnalyzeResult {
263 diagnostics: to_diagnostics(errs, &HashMap::new(), source),
264 }
265}
266
267pub fn analyze_to_json(source: &str, platform: Platform) -> String {
270 serde_json::to_string(&analyze(source, platform))
271 .unwrap_or_else(|_| "{\"diagnostics\":[]}".to_string())
272}
273
274#[derive(serde::Serialize)]
281pub struct HoverResult {
282 pub ty: Option<String>,
283}
284
285pub fn hover(source: &str, offset: usize, platform: Platform) -> HoverResult {
287 catch_panic(|| hover_inner(source, offset, platform)).unwrap_or(HoverResult { ty: None })
288}
289
290fn hover_inner(source: &str, offset: usize, platform: Platform) -> HoverResult {
291 let analysis = analyse_in_memory_with_types(source, BuildTarget::Bundle, platform);
292 let ty = type_at_offset(&analysis.expr_types, offset).map(|t| t.display(&analysis.ty_intern));
293 HoverResult { ty }
294}
295
296pub fn hover_to_json(source: &str, offset: usize, platform: Platform) -> String {
298 serde_json::to_string(&hover(source, offset, platform))
299 .unwrap_or_else(|_| "{\"ty\":null}".to_string())
300}
301
302#[derive(serde::Serialize)]
306pub struct CompletionCandidate {
307 pub label: String,
308 pub kind: &'static str,
311 pub detail: Option<String>,
312 pub insert_text: Option<String>,
313}
314
315#[derive(serde::Serialize)]
317pub struct CompleteResult {
318 pub items: Vec<CompletionCandidate>,
319}
320
321fn to_candidate(c: completion::Completion) -> CompletionCandidate {
322 use completion::CompletionKind::*;
323 let kind = match c.kind {
324 Unit => "unit",
325 Capability => "capability",
326 Type => "type",
327 Keyword => "keyword",
328 Snippet => "snippet",
329 Variant => "variant",
330 Member => "member",
331 Field => "field",
332 Constructor => "constructor",
333 Function => "function",
334 };
335 CompletionCandidate {
336 label: c.label,
337 kind,
338 detail: c.detail,
339 insert_text: c.insert_text,
340 }
341}
342
343pub fn complete(source: &str, offset: usize, platform: Platform) -> CompleteResult {
349 catch_panic(|| complete_inner(source, offset, platform))
350 .unwrap_or(CompleteResult { items: Vec::new() })
351}
352
353fn complete_inner(source: &str, offset: usize, platform: Platform) -> CompleteResult {
354 let line_prefix = source[..offset].rsplit('\n').next().unwrap_or("");
355 let mut items: Vec<CompletionCandidate> = completion::complete(line_prefix, source, None)
356 .into_iter()
357 .map(to_candidate)
358 .collect();
359
360 if completion::is_keyword_position(line_prefix)
364 || completion::is_expression_position(line_prefix)
365 {
366 let analysis = analyse_in_memory_with_types(source, BuildTarget::Bundle, platform);
367 items.extend(locals_at(&analysis.locals, offset).into_iter().map(|b| {
368 CompletionCandidate {
369 label: b.name.clone(),
370 kind: "local",
371 detail: Some(b.ty.clone()),
372 insert_text: None,
373 }
374 }));
375 }
376 if items.is_empty()
381 && let Some((rewritten, recv_offset)) = completion::value_receiver_rewrite(source, offset)
382 {
383 let analysis = analyse_in_memory_with_types(&rewritten, BuildTarget::Bundle, platform);
384 if let Some(ty) = type_at_offset(&analysis.expr_types, recv_offset) {
385 items = completion::value_member_candidates(ty, &analysis.ty_intern, source, None)
386 .into_iter()
387 .map(to_candidate)
388 .collect();
389 }
390 }
391 CompleteResult { items }
392}
393
394pub fn complete_to_json(source: &str, offset: usize, platform: Platform) -> String {
396 serde_json::to_string(&complete(source, offset, platform))
397 .unwrap_or_else(|_| "{\"items\":[]}".to_string())
398}
399
400#[cfg(target_arch = "wasm32")]
401use wasm_bindgen::prelude::wasm_bindgen;
402
403#[cfg(target_arch = "wasm32")]
408fn install_panic_hook() {
409 console_error_panic_hook::set_once();
410}
411
412#[cfg(target_arch = "wasm32")]
416#[wasm_bindgen]
417pub fn bynk_analyze(source: &str) -> String {
418 install_panic_hook();
419 analyze_to_json(source, Platform::Browser)
420}
421
422#[cfg(target_arch = "wasm32")]
426#[wasm_bindgen]
427pub fn bynk_hover(source: &str, offset: u32) -> String {
428 install_panic_hook();
429 hover_to_json(source, offset as usize, Platform::Browser)
430}
431
432#[cfg(target_arch = "wasm32")]
436#[wasm_bindgen]
437pub fn bynk_complete(source: &str, offset: u32) -> String {
438 install_panic_hook();
439 complete_to_json(source, offset as usize, Platform::Browser)
440}
441
442#[cfg(target_arch = "wasm32")]
447#[wasm_bindgen]
448pub fn bynk_compile(source: &str) -> String {
449 install_panic_hook();
450 compile_to_json(source, Platform::Browser)
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 const PROG: &str = "context app.demo\n\
458 \n\
459 consumes bynk { Clock, Logger }\n\
460 \n\
461 service demo {\n\
462 \x20 on call() -> Effect[Instant] given Clock, Logger {\n\
463 \x20 let _ <- Logger.info(\"hi\")\n\
464 \x20 let now <- Clock.now()\n\
465 \x20 now\n\
466 \x20 }\n\
467 }\n";
468
469 #[test]
470 fn compiles_browser_program_to_js_graph() {
471 let r = compile(PROG, Platform::Browser);
472 assert!(
473 r.ok,
474 "should compile: {:?}",
475 r.diagnostics.first().map(|d| &d.message)
476 );
477 let paths: Vec<&str> = r.files.iter().map(|f| f.path.as_str()).collect();
479 assert!(
480 paths.iter().all(|p| p.ends_with(".js")),
481 "all JS: {paths:?}"
482 );
483 assert!(
484 paths.contains(&"runtime.js"),
485 "runtime.js present: {paths:?}"
486 );
487 assert!(
488 paths.contains(&"bynk-browser.js"),
489 "browser binding present: {paths:?}"
490 );
491 let user = r
493 .files
494 .iter()
495 .find(|f| f.path == "app/demo.js")
496 .expect("user module");
497 assert!(
498 !user.contents.contains(": Promise<"),
499 "annotations stripped:\n{}",
500 user.contents
501 );
502 }
503
504 #[test]
505 fn surfaces_diagnostics_for_a_bad_program() {
506 let r = compile("context app.demo\n\nthis is not bynk\n", Platform::Browser);
507 assert!(!r.ok);
508 assert!(r.files.is_empty());
509 assert!(!r.diagnostics.is_empty());
510 assert!(r.diagnostics.iter().all(|d| d.severity == "error"));
511 assert!(r.diagnostics.iter().any(|d| d.line >= 1));
513 }
514
515 #[test]
516 fn cloudflare_shapes_are_rejected_in_the_browser() {
517 let prog = "context cache.store\n\
519 \n\
520 consumes bynk.cloudflare { Kv }\n\
521 \n\
522 service cache {\n\
523 \x20 on call(k: String) -> Effect[Option[String]] given Kv {\n\
524 \x20 let v <- Kv.get(k)\n\
525 \x20 v\n\
526 \x20 }\n\
527 }\n";
528 let r = compile(prog, Platform::Browser);
529 assert!(
530 !r.ok,
531 "a cloudflare-only program must not compile for the browser"
532 );
533 assert!(
534 r.diagnostics
535 .iter()
536 .any(|d| d.category == "bynk.target.vendor_required"),
537 "expected the platform lock: {:?}",
538 r.diagnostics
539 .iter()
540 .map(|d| &d.category)
541 .collect::<Vec<_>>()
542 );
543 }
544
545 #[test]
546 fn compile_to_json_is_valid_json() {
547 let json = compile_to_json(PROG, Platform::Browser);
548 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
549 assert_eq!(v["ok"], true);
550 assert!(v["files"].as_array().is_some_and(|a| !a.is_empty()));
551 }
552
553 #[test]
554 fn analyze_reports_check_errors_for_a_context() {
555 let prog = "context app.demo\n\n\
559 consumes bynk { Logger }\n\n\
560 service demo {\n\
561 \x20 on call() -> Effect[Int] given Logger {\n\
562 \x20 let _ <- Logger.info(\"x\")\n\
563 \x20 \"not an int\"\n\
564 \x20 }\n\
565 }\n";
566 let r = analyze(prog, Platform::Browser);
567 assert!(
568 r.diagnostics.iter().any(|d| d.severity == "error"),
569 "a type mismatch should be reported: {:?}",
570 r.diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>()
571 );
572 assert!(r.diagnostics.iter().any(|d| d.to > d.from));
574 }
575
576 #[test]
577 fn hover_reports_the_inferred_type_of_an_expression() {
578 let offset = PROG.rfind("now").expect("PROG mentions `now`");
581 let r = hover(PROG, offset, Platform::Browser);
582 assert_eq!(r.ty.as_deref(), Some("Instant"));
583 }
584
585 #[test]
586 fn hover_outside_any_expression_is_none() {
587 let r = hover(PROG, 0, Platform::Browser);
590 assert_eq!(r.ty, None);
591 }
592
593 #[test]
594 fn hover_to_json_is_valid_json() {
595 let offset = PROG.rfind("now").expect("PROG mentions `now`");
596 let json = hover_to_json(PROG, offset, Platform::Browser);
597 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
598 assert_eq!(v["ty"], "Instant");
599 }
600
601 #[test]
602 fn hover_survives_a_sibling_error() {
603 let prog = "commons app.demo\n\n\
608 fn good() -> Int {\n 42\n}\n\n\
609 fn bad() -> Int {\n \"oops\"\n}\n";
610 let offset = prog.find("42").expect("prog mentions 42");
611 let r = hover(prog, offset, Platform::Browser);
612 assert_eq!(r.ty.as_deref(), Some("Int"));
613 }
614
615 #[test]
616 fn complete_offers_in_scope_capability_after_given() {
617 let prog = "context app.demo\n\n\
618 consumes bynk { Clock, Logger }\n\n\
619 service demo {\n\
620 \x20 on call() -> Effect[Instant] given \n\
621 \x20 Clock.now()\n\
622 \x20 }\n\
623 }\n";
624 let offset = prog.find("given \n").expect("prog mentions given") + "given ".len();
625 let r = complete(prog, offset, Platform::Browser);
626 assert!(
627 r.items
628 .iter()
629 .any(|c| c.label == "Logger" && c.kind == "capability"),
630 "{:?}",
631 r.items.iter().map(|c| &c.label).collect::<Vec<_>>()
632 );
633 }
634
635 #[test]
636 fn complete_offers_in_scope_locals_at_expression_position() {
637 let offset = PROG.rfind("now").expect("PROG mentions `now`");
641 let r = complete(PROG, offset, Platform::Browser);
642 assert!(
643 r.items.iter().any(|c| c.label == "now"
644 && c.kind == "local"
645 && c.detail.as_deref() == Some("Instant")),
646 "{:?}",
647 r.items
648 .iter()
649 .map(|c| (&c.label, c.kind))
650 .collect::<Vec<_>>()
651 );
652 }
653
654 #[test]
655 fn complete_offers_value_receiver_members_after_dot() {
656 let prog = "commons app.demo\n\n\
657 fn f() -> String {\n\
658 \x20 let value = \"hi\"\n\
659 \x20 value.\n\
660 }\n";
661 let offset = prog.find("value.\n").expect("prog mentions value.") + "value.".len();
662 let r = complete(prog, offset, Platform::Browser);
663 assert!(
664 r.items
665 .iter()
666 .any(|c| c.label == "split" && c.kind == "member"),
667 "{:?}",
668 r.items.iter().map(|c| &c.label).collect::<Vec<_>>()
669 );
670 }
671
672 #[test]
673 fn complete_survives_a_sibling_error() {
674 let prog = "commons app.demo\n\n\
677 fn good() -> Int {\n let count = 42\n count\n}\n\n\
678 fn bad() -> Int {\n \"oops\"\n}\n";
679 let offset = prog.rfind("count").expect("prog mentions count");
680 let r = complete(prog, offset, Platform::Browser);
681 assert!(
682 r.items
683 .iter()
684 .any(|c| c.label == "count" && c.kind == "local"),
685 "{:?}",
686 r.items
687 .iter()
688 .map(|c| (&c.label, c.kind))
689 .collect::<Vec<_>>()
690 );
691 }
692
693 #[test]
694 fn complete_to_json_is_valid_json() {
695 let offset = PROG.rfind("now").expect("PROG mentions `now`");
696 let json = complete_to_json(PROG, offset, Platform::Browser);
697 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
698 assert!(v["items"].as_array().is_some_and(|a| !a.is_empty()));
699 }
700
701 #[test]
702 fn complete_survives_a_panic() {
703 let prev = std::panic::take_hook();
709 std::panic::set_hook(Box::new(|_| {}));
710 let r = complete("", usize::MAX, Platform::Browser);
711 std::panic::set_hook(prev);
712 assert!(r.items.is_empty());
713 }
714
715 #[test]
716 fn catch_panic_converts_panic_to_a_diagnostic() {
717 let prev = std::panic::take_hook();
720 std::panic::set_hook(Box::new(|_| {}));
721 let caught = catch_panic(|| -> i32 { panic!("boom {}", 42) });
722 std::panic::set_hook(prev);
723
724 let d = caught.expect_err("a panic must become an Err(diagnostic)");
725 assert_eq!(d.severity, "error");
726 assert_eq!(d.category, "bynk.wasm.panic");
727 assert!(
728 d.message.contains("boom 42"),
729 "the panic message is carried through: {}",
730 d.message
731 );
732 }
733
734 #[test]
735 fn catch_panic_passes_a_value_through() {
736 assert_eq!(catch_panic(|| 7).ok(), Some(7));
737 }
738
739 #[test]
740 fn analyze_clean_program_has_no_errors() {
741 let r = analyze(PROG, Platform::Browser);
742 assert!(
743 r.diagnostics.iter().all(|d| d.severity != "error"),
744 "clean program should have no errors: {:?}",
745 r.diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>()
746 );
747 }
748}