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 .artefacts
193 .docs
194 .into_iter()
195 .map(|(path, doc)| EmittedFile {
196 path: path.to_string_lossy().into_owned(),
197 contents: doc.text(),
198 })
199 .collect();
200 CompileResult {
201 ok: true,
202 files,
203 diagnostics,
204 }
205 }
206 Err(e) => CompileResult {
209 ok: false,
210 files: Vec::new(),
211 diagnostics: vec![Diagnostic {
212 path: None,
213 line: 0,
214 col: 0,
215 from: 0,
216 to: 0,
217 severity: "error".to_string(),
218 category: "bynk.wasm.strip_failed".to_string(),
219 message: e.to_string(),
220 notes: Vec::new(),
221 labels: Vec::new(),
222 }],
223 },
224 },
225 Err(failure) => {
226 let sources: HashMap<PathBuf, String> = failure.snapshots.iter().cloned().collect();
227 CompileResult {
228 ok: false,
229 files: Vec::new(),
230 diagnostics: to_diagnostics(failure.errors, &sources, source),
231 }
232 }
233 }
234}
235
236pub fn compile_to_json(source: &str, platform: Platform) -> String {
238 serde_json::to_string(&compile(source, platform)).unwrap_or_else(|e| {
239 format!(
240 "{{\"ok\":false,\"files\":[],\"diagnostics\":[{{\"path\":null,\"line\":0,\"col\":0,\"from\":0,\"to\":0,\
241 \"severity\":\"error\",\"category\":\"bynk.wasm.serialize_failed\",\"message\":{:?}}}]}}",
242 e.to_string()
243 )
244 })
245}
246
247#[derive(serde::Serialize)]
250pub struct AnalyzeResult {
251 pub diagnostics: Vec<Diagnostic>,
252}
253
254pub fn analyze(source: &str, platform: Platform) -> AnalyzeResult {
256 catch_panic(|| analyze_inner(source, platform)).unwrap_or_else(|d| AnalyzeResult {
257 diagnostics: vec![d],
258 })
259}
260
261fn analyze_inner(source: &str, platform: Platform) -> AnalyzeResult {
262 let errs = analyse_in_memory(source, BuildTarget::Bundle, platform);
263 AnalyzeResult {
264 diagnostics: to_diagnostics(errs, &HashMap::new(), source),
265 }
266}
267
268pub fn analyze_to_json(source: &str, platform: Platform) -> String {
271 serde_json::to_string(&analyze(source, platform))
272 .unwrap_or_else(|_| "{\"diagnostics\":[]}".to_string())
273}
274
275#[derive(serde::Serialize)]
282pub struct HoverResult {
283 pub ty: Option<String>,
284}
285
286pub fn hover(source: &str, offset: usize, platform: Platform) -> HoverResult {
288 catch_panic(|| hover_inner(source, offset, platform)).unwrap_or(HoverResult { ty: None })
289}
290
291fn hover_inner(source: &str, offset: usize, platform: Platform) -> HoverResult {
292 let analysis = analyse_in_memory_with_types(source, BuildTarget::Bundle, platform);
293 let ty = type_at_offset(&analysis.expr_types, offset).map(|t| t.display(&analysis.ty_intern));
294 HoverResult { ty }
295}
296
297pub fn hover_to_json(source: &str, offset: usize, platform: Platform) -> String {
299 serde_json::to_string(&hover(source, offset, platform))
300 .unwrap_or_else(|_| "{\"ty\":null}".to_string())
301}
302
303#[derive(serde::Serialize)]
307pub struct CompletionCandidate {
308 pub label: String,
309 pub kind: &'static str,
312 pub detail: Option<String>,
313 pub insert_text: Option<String>,
314}
315
316#[derive(serde::Serialize)]
318pub struct CompleteResult {
319 pub items: Vec<CompletionCandidate>,
320}
321
322fn to_candidate(c: completion::Completion) -> CompletionCandidate {
323 use completion::CompletionKind::*;
324 let kind = match c.kind {
325 Unit => "unit",
326 Capability => "capability",
327 Type => "type",
328 Keyword => "keyword",
329 Snippet => "snippet",
330 Variant => "variant",
331 Member => "member",
332 Field => "field",
333 Constructor => "constructor",
334 Function => "function",
335 };
336 CompletionCandidate {
337 label: c.label,
338 kind,
339 detail: c.detail,
340 insert_text: c.insert_text,
341 }
342}
343
344pub fn complete(source: &str, offset: usize, platform: Platform) -> CompleteResult {
350 catch_panic(|| complete_inner(source, offset, platform))
351 .unwrap_or(CompleteResult { items: Vec::new() })
352}
353
354fn complete_inner(source: &str, offset: usize, platform: Platform) -> CompleteResult {
355 let line_prefix = source[..offset].rsplit('\n').next().unwrap_or("");
356 let mut items: Vec<CompletionCandidate> = completion::complete(line_prefix, source, None)
357 .into_iter()
358 .map(to_candidate)
359 .collect();
360
361 if completion::is_keyword_position(line_prefix)
365 || completion::is_expression_position(line_prefix)
366 {
367 let analysis = analyse_in_memory_with_types(source, BuildTarget::Bundle, platform);
368 items.extend(locals_at(&analysis.locals, offset).into_iter().map(|b| {
369 CompletionCandidate {
370 label: b.name.clone(),
371 kind: "local",
372 detail: Some(b.ty.clone()),
373 insert_text: None,
374 }
375 }));
376 }
377 if items.is_empty()
382 && let Some((rewritten, recv_offset)) = completion::value_receiver_rewrite(source, offset)
383 {
384 let analysis = analyse_in_memory_with_types(&rewritten, BuildTarget::Bundle, platform);
385 if let Some(ty) = type_at_offset(&analysis.expr_types, recv_offset) {
386 items = completion::value_member_candidates(ty, &analysis.ty_intern, source, None)
387 .into_iter()
388 .map(to_candidate)
389 .collect();
390 }
391 }
392 CompleteResult { items }
393}
394
395pub fn complete_to_json(source: &str, offset: usize, platform: Platform) -> String {
397 serde_json::to_string(&complete(source, offset, platform))
398 .unwrap_or_else(|_| "{\"items\":[]}".to_string())
399}
400
401#[cfg(target_arch = "wasm32")]
402use wasm_bindgen::prelude::wasm_bindgen;
403
404#[cfg(target_arch = "wasm32")]
409fn install_panic_hook() {
410 console_error_panic_hook::set_once();
411}
412
413#[cfg(target_arch = "wasm32")]
417#[wasm_bindgen]
418pub fn bynk_analyze(source: &str) -> String {
419 install_panic_hook();
420 analyze_to_json(source, Platform::Browser)
421}
422
423#[cfg(target_arch = "wasm32")]
427#[wasm_bindgen]
428pub fn bynk_hover(source: &str, offset: u32) -> String {
429 install_panic_hook();
430 hover_to_json(source, offset as usize, Platform::Browser)
431}
432
433#[cfg(target_arch = "wasm32")]
437#[wasm_bindgen]
438pub fn bynk_complete(source: &str, offset: u32) -> String {
439 install_panic_hook();
440 complete_to_json(source, offset as usize, Platform::Browser)
441}
442
443#[cfg(target_arch = "wasm32")]
448#[wasm_bindgen]
449pub fn bynk_compile(source: &str) -> String {
450 install_panic_hook();
451 compile_to_json(source, Platform::Browser)
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 const PROG: &str = "context app.demo\n\
459 \n\
460 consumes bynk { Clock, Logger }\n\
461 \n\
462 service demo {\n\
463 \x20 on call() -> Effect[Instant] given Clock, Logger {\n\
464 \x20 let _ <- Logger.info(\"hi\")\n\
465 \x20 let now <- Clock.now()\n\
466 \x20 now\n\
467 \x20 }\n\
468 }\n";
469
470 #[test]
471 fn compiles_browser_program_to_js_graph() {
472 let r = compile(PROG, Platform::Browser);
473 assert!(
474 r.ok,
475 "should compile: {:?}",
476 r.diagnostics.first().map(|d| &d.message)
477 );
478 let paths: Vec<&str> = r.files.iter().map(|f| f.path.as_str()).collect();
480 assert!(
481 paths.iter().all(|p| p.ends_with(".js")),
482 "all JS: {paths:?}"
483 );
484 assert!(
485 paths.contains(&"runtime.js"),
486 "runtime.js present: {paths:?}"
487 );
488 assert!(
489 paths.contains(&"bynk-browser.js"),
490 "browser binding present: {paths:?}"
491 );
492 let user = r
494 .files
495 .iter()
496 .find(|f| f.path == "app/demo.js")
497 .expect("user module");
498 assert!(
499 !user.contents.contains(": Promise<"),
500 "annotations stripped:\n{}",
501 user.contents
502 );
503 }
504
505 #[test]
506 fn surfaces_diagnostics_for_a_bad_program() {
507 let r = compile("context app.demo\n\nthis is not bynk\n", Platform::Browser);
508 assert!(!r.ok);
509 assert!(r.files.is_empty());
510 assert!(!r.diagnostics.is_empty());
511 assert!(r.diagnostics.iter().all(|d| d.severity == "error"));
512 assert!(r.diagnostics.iter().any(|d| d.line >= 1));
514 }
515
516 #[test]
517 fn cloudflare_shapes_are_rejected_in_the_browser() {
518 let prog = "context cache.store\n\
520 \n\
521 consumes bynk.cloudflare { Kv }\n\
522 \n\
523 service cache {\n\
524 \x20 on call(k: String) -> Effect[Option[String]] given Kv {\n\
525 \x20 let v <- Kv.get(k)\n\
526 \x20 v\n\
527 \x20 }\n\
528 }\n";
529 let r = compile(prog, Platform::Browser);
530 assert!(
531 !r.ok,
532 "a cloudflare-only program must not compile for the browser"
533 );
534 assert!(
535 r.diagnostics
536 .iter()
537 .any(|d| d.category == "bynk.target.vendor_required"),
538 "expected the platform lock: {:?}",
539 r.diagnostics
540 .iter()
541 .map(|d| &d.category)
542 .collect::<Vec<_>>()
543 );
544 }
545
546 #[test]
547 fn compile_to_json_is_valid_json() {
548 let json = compile_to_json(PROG, Platform::Browser);
549 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
550 assert_eq!(v["ok"], true);
551 assert!(v["files"].as_array().is_some_and(|a| !a.is_empty()));
552 }
553
554 #[test]
555 fn analyze_reports_check_errors_for_a_context() {
556 let prog = "context app.demo\n\n\
560 consumes bynk { Logger }\n\n\
561 service demo {\n\
562 \x20 on call() -> Effect[Int] given Logger {\n\
563 \x20 let _ <- Logger.info(\"x\")\n\
564 \x20 \"not an int\"\n\
565 \x20 }\n\
566 }\n";
567 let r = analyze(prog, Platform::Browser);
568 assert!(
569 r.diagnostics.iter().any(|d| d.severity == "error"),
570 "a type mismatch should be reported: {:?}",
571 r.diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>()
572 );
573 assert!(r.diagnostics.iter().any(|d| d.to > d.from));
575 }
576
577 #[test]
578 fn hover_reports_the_inferred_type_of_an_expression() {
579 let offset = PROG.rfind("now").expect("PROG mentions `now`");
582 let r = hover(PROG, offset, Platform::Browser);
583 assert_eq!(r.ty.as_deref(), Some("Instant"));
584 }
585
586 #[test]
587 fn hover_outside_any_expression_is_none() {
588 let r = hover(PROG, 0, Platform::Browser);
591 assert_eq!(r.ty, None);
592 }
593
594 #[test]
595 fn hover_to_json_is_valid_json() {
596 let offset = PROG.rfind("now").expect("PROG mentions `now`");
597 let json = hover_to_json(PROG, offset, Platform::Browser);
598 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
599 assert_eq!(v["ty"], "Instant");
600 }
601
602 #[test]
603 fn hover_survives_a_sibling_error() {
604 let prog = "commons app.demo\n\n\
609 fn good() -> Int {\n 42\n}\n\n\
610 fn bad() -> Int {\n \"oops\"\n}\n";
611 let offset = prog.find("42").expect("prog mentions 42");
612 let r = hover(prog, offset, Platform::Browser);
613 assert_eq!(r.ty.as_deref(), Some("Int"));
614 }
615
616 #[test]
617 fn complete_offers_in_scope_capability_after_given() {
618 let prog = "context app.demo\n\n\
619 consumes bynk { Clock, Logger }\n\n\
620 service demo {\n\
621 \x20 on call() -> Effect[Instant] given \n\
622 \x20 Clock.now()\n\
623 \x20 }\n\
624 }\n";
625 let offset = prog.find("given \n").expect("prog mentions given") + "given ".len();
626 let r = complete(prog, offset, Platform::Browser);
627 assert!(
628 r.items
629 .iter()
630 .any(|c| c.label == "Logger" && c.kind == "capability"),
631 "{:?}",
632 r.items.iter().map(|c| &c.label).collect::<Vec<_>>()
633 );
634 }
635
636 #[test]
637 fn complete_offers_in_scope_locals_at_expression_position() {
638 let offset = PROG.rfind("now").expect("PROG mentions `now`");
642 let r = complete(PROG, offset, Platform::Browser);
643 assert!(
644 r.items.iter().any(|c| c.label == "now"
645 && c.kind == "local"
646 && c.detail.as_deref() == Some("Instant")),
647 "{:?}",
648 r.items
649 .iter()
650 .map(|c| (&c.label, c.kind))
651 .collect::<Vec<_>>()
652 );
653 }
654
655 #[test]
656 fn complete_offers_value_receiver_members_after_dot() {
657 let prog = "commons app.demo\n\n\
658 fn f() -> String {\n\
659 \x20 let value = \"hi\"\n\
660 \x20 value.\n\
661 }\n";
662 let offset = prog.find("value.\n").expect("prog mentions value.") + "value.".len();
663 let r = complete(prog, offset, Platform::Browser);
664 assert!(
665 r.items
666 .iter()
667 .any(|c| c.label == "split" && c.kind == "member"),
668 "{:?}",
669 r.items.iter().map(|c| &c.label).collect::<Vec<_>>()
670 );
671 }
672
673 #[test]
674 fn complete_survives_a_sibling_error() {
675 let prog = "commons app.demo\n\n\
678 fn good() -> Int {\n let count = 42\n count\n}\n\n\
679 fn bad() -> Int {\n \"oops\"\n}\n";
680 let offset = prog.rfind("count").expect("prog mentions count");
681 let r = complete(prog, offset, Platform::Browser);
682 assert!(
683 r.items
684 .iter()
685 .any(|c| c.label == "count" && c.kind == "local"),
686 "{:?}",
687 r.items
688 .iter()
689 .map(|c| (&c.label, c.kind))
690 .collect::<Vec<_>>()
691 );
692 }
693
694 #[test]
695 fn complete_to_json_is_valid_json() {
696 let offset = PROG.rfind("now").expect("PROG mentions `now`");
697 let json = complete_to_json(PROG, offset, Platform::Browser);
698 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
699 assert!(v["items"].as_array().is_some_and(|a| !a.is_empty()));
700 }
701
702 #[test]
703 fn complete_survives_a_panic() {
704 let prev = std::panic::take_hook();
710 std::panic::set_hook(Box::new(|_| {}));
711 let r = complete("", usize::MAX, Platform::Browser);
712 std::panic::set_hook(prev);
713 assert!(r.items.is_empty());
714 }
715
716 #[test]
717 fn catch_panic_converts_panic_to_a_diagnostic() {
718 let prev = std::panic::take_hook();
721 std::panic::set_hook(Box::new(|_| {}));
722 let caught = catch_panic(|| -> i32 { panic!("boom {}", 42) });
723 std::panic::set_hook(prev);
724
725 let d = caught.expect_err("a panic must become an Err(diagnostic)");
726 assert_eq!(d.severity, "error");
727 assert_eq!(d.category, "bynk.wasm.panic");
728 assert!(
729 d.message.contains("boom 42"),
730 "the panic message is carried through: {}",
731 d.message
732 );
733 }
734
735 #[test]
736 fn catch_panic_passes_a_value_through() {
737 assert_eq!(catch_panic(|| 7).ok(), Some(7));
738 }
739
740 #[test]
741 fn analyze_clean_program_has_no_errors() {
742 let r = analyze(PROG, Platform::Browser);
743 assert!(
744 r.diagnostics.iter().all(|d| d.severity != "error"),
745 "clean program should have no errors: {:?}",
746 r.diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>()
747 );
748 }
749}