1use std::collections::HashMap;
22use std::path::PathBuf;
23use std::sync::Arc;
24
25use bynk_check::checker::{TyId, Types};
26use bynk_check::expr_types::type_at_offset;
27use bynk_check::firstparty::Platform;
28use bynk_check::locals::{LocalBinding, locals_at};
29use bynk_emit::project::{
30 AttributedError, BuildTarget, InMemoryAnalysis, analyse_in_memory, analyse_in_memory_project,
31 analyse_in_memory_with_types, compile_in_memory, slash_key,
32};
33use bynk_ide::completion;
34use bynk_syntax::CompileError;
35use bynk_syntax::span::Span;
36
37#[derive(serde::Serialize, Clone, Copy, PartialEq, Eq, Debug)]
41#[serde(rename_all = "lowercase")]
42pub enum FileKind {
43 User,
45 Wiring,
47 Support,
50}
51
52#[derive(serde::Serialize)]
54pub struct EmittedFile {
55 pub kind: FileKind,
57 pub path: String,
59 pub contents: String,
61}
62
63#[derive(serde::Serialize)]
65pub struct Diagnostic {
66 pub path: Option<String>,
68 pub line: usize,
69 pub col: usize,
70 pub from: usize,
72 pub to: usize,
73 pub severity: String,
75 pub category: String,
77 pub message: String,
78 pub notes: Vec<String>,
84 pub labels: Vec<String>,
85}
86
87#[derive(serde::Serialize)]
89pub struct CompileResult {
90 pub ok: bool,
92 pub files: Vec<EmittedFile>,
94 pub diagnostics: Vec<Diagnostic>,
96}
97
98fn severity_str(err: &CompileError) -> &'static str {
99 match bynk_syntax::Severity::for_error(err) {
100 bynk_syntax::Severity::Error => "error",
101 bynk_syntax::Severity::Warning => "warning",
102 }
103}
104
105fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
107 if let Some(s) = payload.downcast_ref::<&str>() {
108 (*s).to_string()
109 } else if let Some(s) = payload.downcast_ref::<String>() {
110 s.clone()
111 } else {
112 "unknown panic".to_string()
113 }
114}
115
116fn panic_diagnostic(payload: Box<dyn std::any::Any + Send>) -> Diagnostic {
120 Diagnostic {
121 path: None,
122 line: 0,
123 col: 0,
124 from: 0,
125 to: 0,
126 severity: "error".to_string(),
127 category: "bynk.wasm.panic".to_string(),
128 message: format!("internal compiler panic: {}", panic_message(&*payload)),
129 notes: Vec::new(),
130 labels: Vec::new(),
131 }
132}
133
134#[allow(clippy::result_large_err)]
152fn catch_panic<T>(f: impl FnOnce() -> T) -> Result<T, Diagnostic> {
153 std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(panic_diagnostic)
154}
155
156fn to_diagnostics(
159 errs: Vec<AttributedError>,
160 sources: &HashMap<PathBuf, String>,
161 fallback: &str,
162) -> Vec<Diagnostic> {
163 errs.into_iter()
164 .map(|a| {
165 let src = a
166 .source_path
167 .as_ref()
168 .and_then(|p| sources.get(p))
169 .map(String::as_str)
170 .unwrap_or(fallback);
171 let (line, col) = bynk_syntax::span::line_col(src, a.error.span.start);
172 Diagnostic {
173 path: a
174 .source_path
175 .as_ref()
176 .map(|p| p.to_string_lossy().into_owned()),
177 line,
178 col,
179 from: a.error.span.start,
180 to: a.error.span.end,
181 severity: severity_str(&a.error).to_string(),
182 category: a.error.category.to_string(),
183 message: a.error.message.clone(),
184 notes: a.error.notes.clone(),
185 labels: a.error.labels.iter().map(|(_, msg)| msg.clone()).collect(),
186 }
187 })
188 .collect()
189}
190
191pub fn compile(source: &str, platform: Platform) -> CompileResult {
197 catch_panic(|| compile_inner(source, platform)).unwrap_or_else(|d| CompileResult {
198 ok: false,
199 files: Vec::new(),
200 diagnostics: vec![d],
201 })
202}
203
204fn compile_inner(source: &str, platform: Platform) -> CompileResult {
205 match compile_in_memory(source, BuildTarget::Bundle, platform) {
206 Ok(out) => match bynk_strip::strip_project_to_js(out) {
207 Ok(js) => {
208 let diagnostics = to_diagnostics(js.warnings, &HashMap::new(), source);
211 let user_paths: std::collections::HashSet<std::path::PathBuf> = js
215 .snapshots
216 .iter()
217 .map(|(p, _)| p.with_extension("js"))
218 .collect();
219 let files = js
220 .artefacts
221 .docs
222 .into_iter()
223 .filter(|(_, doc)| !matches!(doc, bynk_emit::project::Document::Json(_)))
224 .map(|(path, doc)| EmittedFile {
225 kind: if user_paths.contains(&path) {
226 FileKind::User
227 } else if path == std::path::Path::new("compose.js") {
228 FileKind::Wiring
229 } else {
230 FileKind::Support
231 },
232 path: path.to_string_lossy().into_owned(),
233 contents: doc.text(),
234 })
235 .collect();
236 CompileResult {
237 ok: true,
238 files,
239 diagnostics,
240 }
241 }
242 Err(e) => CompileResult {
245 ok: false,
246 files: Vec::new(),
247 diagnostics: vec![Diagnostic {
248 path: None,
249 line: 0,
250 col: 0,
251 from: 0,
252 to: 0,
253 severity: "error".to_string(),
254 category: "bynk.wasm.strip_failed".to_string(),
255 message: e.to_string(),
256 notes: Vec::new(),
257 labels: Vec::new(),
258 }],
259 },
260 },
261 Err(failure) => {
262 let sources: HashMap<PathBuf, String> = failure.snapshots.iter().cloned().collect();
263 CompileResult {
264 ok: false,
265 files: Vec::new(),
266 diagnostics: to_diagnostics(failure.errors, &sources, source),
267 }
268 }
269 }
270}
271
272pub fn compile_to_json(source: &str, platform: Platform) -> String {
274 serde_json::to_string(&compile(source, platform)).unwrap_or_else(|e| {
275 format!(
276 "{{\"ok\":false,\"files\":[],\"diagnostics\":[{{\"path\":null,\"line\":0,\"col\":0,\"from\":0,\"to\":0,\
277 \"severity\":\"error\",\"category\":\"bynk.wasm.serialize_failed\",\"message\":{:?}}}]}}",
278 e.to_string()
279 )
280 })
281}
282
283#[derive(serde::Serialize)]
286pub struct AnalyzeResult {
287 pub diagnostics: Vec<Diagnostic>,
288}
289
290pub fn analyze(source: &str, platform: Platform) -> AnalyzeResult {
292 catch_panic(|| analyze_inner(source, platform)).unwrap_or_else(|d| AnalyzeResult {
293 diagnostics: vec![d],
294 })
295}
296
297fn analyze_inner(source: &str, platform: Platform) -> AnalyzeResult {
298 let errs = analyse_in_memory(source, BuildTarget::Bundle, platform);
299 AnalyzeResult {
300 diagnostics: to_diagnostics(errs, &HashMap::new(), source),
301 }
302}
303
304pub fn analyze_to_json(source: &str, platform: Platform) -> String {
307 serde_json::to_string(&analyze(source, platform))
308 .unwrap_or_else(|_| "{\"diagnostics\":[]}".to_string())
309}
310
311#[derive(serde::Serialize)]
318pub struct HoverResult {
319 pub ty: Option<String>,
320}
321
322pub fn hover(source: &str, offset: usize, platform: Platform) -> HoverResult {
324 catch_panic(|| hover_inner(source, offset, platform)).unwrap_or(HoverResult { ty: None })
325}
326
327fn hover_inner(source: &str, offset: usize, platform: Platform) -> HoverResult {
328 let analysis = analyse_in_memory_with_types(source, BuildTarget::Bundle, platform);
329 let ty = type_at_offset(&analysis.expr_types, offset, &analysis.ty_intern)
330 .map(|t| t.display(&analysis.ty_intern));
331 HoverResult { ty }
332}
333
334pub fn hover_to_json(source: &str, offset: usize, platform: Platform) -> String {
336 serde_json::to_string(&hover(source, offset, platform))
337 .unwrap_or_else(|_| "{\"ty\":null}".to_string())
338}
339
340#[derive(serde::Serialize)]
344pub struct CompletionCandidate {
345 pub label: String,
346 pub kind: &'static str,
349 pub detail: Option<String>,
350 pub insert_text: Option<String>,
351}
352
353#[derive(serde::Serialize)]
355pub struct CompleteResult {
356 pub items: Vec<CompletionCandidate>,
357}
358
359fn to_candidate(c: completion::Completion) -> CompletionCandidate {
360 use completion::CompletionKind::*;
361 let kind = match c.kind {
362 Unit => "unit",
363 Capability => "capability",
364 Type => "type",
365 Keyword => "keyword",
366 Snippet => "snippet",
367 Variant => "variant",
368 Member => "member",
369 Field => "field",
370 Constructor => "constructor",
371 Function => "function",
372 Constant => "constant",
373 };
374 CompletionCandidate {
375 label: c.label,
376 kind,
377 detail: c.detail,
378 insert_text: c.insert_text,
379 }
380}
381
382pub fn complete(source: &str, offset: usize, platform: Platform) -> CompleteResult {
388 catch_panic(|| complete_inner(source, offset, platform))
389 .unwrap_or(CompleteResult { items: Vec::new() })
390}
391
392fn complete_inner(source: &str, offset: usize, platform: Platform) -> CompleteResult {
393 complete_with(source, offset, None, |text| {
394 FileTypes::from(analyse_in_memory_with_types(
395 text,
396 BuildTarget::Bundle,
397 platform,
398 ))
399 })
400}
401
402struct FileTypes {
405 expr_types: Vec<(Span, TyId)>,
406 ty_intern: Arc<Types>,
407 locals: Vec<LocalBinding>,
408}
409
410impl From<InMemoryAnalysis> for FileTypes {
411 fn from(a: InMemoryAnalysis) -> Self {
412 FileTypes {
413 expr_types: a.expr_types,
414 ty_intern: a.ty_intern,
415 locals: a.locals,
416 }
417 }
418}
419
420fn complete_with(
426 source: &str,
427 offset: usize,
428 files: Option<&HashMap<PathBuf, String>>,
429 analyse: impl Fn(&str) -> FileTypes,
430) -> CompleteResult {
431 if !source.is_char_boundary(offset) {
432 return CompleteResult { items: Vec::new() };
433 }
434 let line_prefix = source[..offset].rsplit('\n').next().unwrap_or("");
435 let mut items: Vec<CompletionCandidate> = completion::complete(line_prefix, source, files)
436 .into_iter()
437 .map(to_candidate)
438 .collect();
439
440 if completion::is_keyword_position(line_prefix)
444 || completion::is_expression_position(line_prefix)
445 {
446 let analysis = analyse(source);
447 items.extend(locals_at(&analysis.locals, offset).into_iter().map(|b| {
448 CompletionCandidate {
449 label: b.name.clone(),
450 kind: "local",
451 detail: Some(b.ty.clone()),
452 insert_text: None,
453 }
454 }));
455 }
456 if items.is_empty()
461 && let Some((rewritten, recv_offset)) = completion::value_receiver_rewrite(source, offset)
462 {
463 let analysis = analyse(&rewritten);
464 if let Some(ty) = type_at_offset(&analysis.expr_types, recv_offset, &analysis.ty_intern) {
465 items = completion::value_member_candidates(ty, &analysis.ty_intern, source, files)
466 .into_iter()
467 .map(to_candidate)
468 .collect();
469 }
470 }
471 CompleteResult { items }
472}
473
474pub fn complete_to_json(source: &str, offset: usize, platform: Platform) -> String {
476 serde_json::to_string(&complete(source, offset, platform))
477 .unwrap_or_else(|_| "{\"items\":[]}".to_string())
478}
479
480pub fn project_files(files_json: &str) -> HashMap<PathBuf, String> {
489 serde_json::from_str::<HashMap<String, String>>(files_json)
490 .unwrap_or_default()
491 .into_iter()
492 .map(|(path, text)| (PathBuf::from(path), text))
493 .collect()
494}
495
496#[derive(serde::Serialize)]
502pub struct ProjectAnalyzeResult {
503 pub diagnostics: Vec<Diagnostic>,
504 pub warnings: Vec<String>,
505}
506
507pub fn analyze_project(
508 files: &HashMap<PathBuf, String>,
509 platform: Platform,
510) -> ProjectAnalyzeResult {
511 catch_panic(|| {
512 let analysis = analyse_in_memory_project(files, BuildTarget::Bundle, platform);
513 let sources: HashMap<PathBuf, String> = files
515 .iter()
516 .map(|(path, text)| (slash_key(path), text.clone()))
517 .collect();
518 ProjectAnalyzeResult {
519 diagnostics: to_diagnostics(analysis.errors, &sources, ""),
520 warnings: analysis
521 .ignored_paths
522 .into_iter()
523 .map(|(key, entry)| {
524 format!(
525 "bynk.toml: `[paths] {key}` entry `{entry}` is an absolute path, which \
526 can't name a file in the project, so it is ignored (use a path \
527 relative to the project root)"
528 )
529 })
530 .collect(),
531 }
532 })
533 .unwrap_or_else(|d| ProjectAnalyzeResult {
534 diagnostics: vec![d],
535 warnings: Vec::new(),
536 })
537}
538
539fn project_file_types(
541 files: &HashMap<PathBuf, String>,
542 path: &std::path::Path,
543 platform: Platform,
544) -> FileTypes {
545 let mut analysis = analyse_in_memory_project(files, BuildTarget::Bundle, platform);
546 FileTypes {
547 expr_types: analysis.expr_types.remove(path).unwrap_or_default(),
548 ty_intern: analysis.ty_intern,
549 locals: analysis.locals.remove(path).unwrap_or_default(),
550 }
551}
552
553pub fn hover_project(
556 files: &HashMap<PathBuf, String>,
557 path: &str,
558 offset: usize,
559 platform: Platform,
560) -> HoverResult {
561 catch_panic(|| {
562 let types = project_file_types(files, &slash_key(std::path::Path::new(path)), platform);
563 HoverResult {
564 ty: type_at_offset(&types.expr_types, offset, &types.ty_intern)
565 .map(|t| t.display(&types.ty_intern)),
566 }
567 })
568 .unwrap_or(HoverResult { ty: None })
569}
570
571pub fn complete_project(
575 files: &HashMap<PathBuf, String>,
576 path: &str,
577 offset: usize,
578 platform: Platform,
579) -> CompleteResult {
580 catch_panic(|| {
581 let files: &HashMap<PathBuf, String> = &files
584 .iter()
585 .map(|(p, text)| (slash_key(p), text.clone()))
586 .collect();
587 let path = slash_key(std::path::Path::new(path));
588 let Some(source) = files.get(&path) else {
589 return CompleteResult { items: Vec::new() };
590 };
591 let sources: HashMap<PathBuf, String> = files
592 .iter()
593 .filter(|(p, _)| p.extension().is_some_and(|e| e == "bynk"))
594 .map(|(p, t)| (p.clone(), t.clone()))
595 .collect();
596 complete_with(source, offset, Some(&sources), |text| {
597 let mut edited = files.clone();
598 edited.insert(path.clone(), text.to_string());
599 project_file_types(&edited, &path, platform)
600 })
601 })
602 .unwrap_or(CompleteResult { items: Vec::new() })
603}
604
605pub fn platform_named(name: Option<&str>) -> Platform {
613 match name {
614 Some("cloudflare") => Platform::Cloudflare,
615 Some("node") => Platform::Node,
616 _ => Platform::Browser,
617 }
618}
619
620#[cfg(target_arch = "wasm32")]
621use wasm_bindgen::prelude::wasm_bindgen;
622
623#[cfg(target_arch = "wasm32")]
628fn install_panic_hook() {
629 console_error_panic_hook::set_once();
630}
631
632#[cfg(target_arch = "wasm32")]
637#[wasm_bindgen]
638pub fn bynk_analyze(source: &str, platform: Option<String>) -> String {
639 install_panic_hook();
640 analyze_to_json(source, platform_named(platform.as_deref()))
641}
642
643#[cfg(target_arch = "wasm32")]
647#[wasm_bindgen]
648pub fn bynk_hover(source: &str, offset: u32, platform: Option<String>) -> String {
649 install_panic_hook();
650 hover_to_json(source, offset as usize, platform_named(platform.as_deref()))
651}
652
653#[cfg(target_arch = "wasm32")]
658#[wasm_bindgen]
659pub fn bynk_complete(source: &str, offset: u32, platform: Option<String>) -> String {
660 install_panic_hook();
661 complete_to_json(source, offset as usize, platform_named(platform.as_deref()))
662}
663
664#[cfg(target_arch = "wasm32")]
669#[wasm_bindgen]
670pub fn bynk_analyze_project(files_json: &str, platform: Option<String>) -> String {
671 install_panic_hook();
672 let result = analyze_project(
673 &project_files(files_json),
674 platform_named(platform.as_deref()),
675 );
676 serde_json::to_string(&result).unwrap_or_else(|_| "{\"diagnostics\":[]}".to_string())
677}
678
679#[cfg(target_arch = "wasm32")]
682#[wasm_bindgen]
683pub fn bynk_hover_project(
684 files_json: &str,
685 path: &str,
686 offset: u32,
687 platform: Option<String>,
688) -> String {
689 install_panic_hook();
690 let result = hover_project(
691 &project_files(files_json),
692 path,
693 offset as usize,
694 platform_named(platform.as_deref()),
695 );
696 serde_json::to_string(&result).unwrap_or_else(|_| "{\"ty\":null}".to_string())
697}
698
699#[cfg(target_arch = "wasm32")]
702#[wasm_bindgen]
703pub fn bynk_complete_project(
704 files_json: &str,
705 path: &str,
706 offset: u32,
707 platform: Option<String>,
708) -> String {
709 install_panic_hook();
710 let result = complete_project(
711 &project_files(files_json),
712 path,
713 offset as usize,
714 platform_named(platform.as_deref()),
715 );
716 serde_json::to_string(&result).unwrap_or_else(|_| "{\"items\":[]}".to_string())
717}
718
719#[cfg(target_arch = "wasm32")]
724#[wasm_bindgen]
725pub fn bynk_compile(source: &str) -> String {
726 install_panic_hook();
727 compile_to_json(source, Platform::Browser)
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733 use std::path::Path;
734
735 const PROG: &str = "context app.demo\n\
736 \n\
737 consumes bynk { Clock, Logger }\n\
738 \n\
739 service demo {\n\
740 \x20 on call() -> Effect[Instant] given Clock, Logger {\n\
741 \x20 let _ <- Logger.info(\"hi\")\n\
742 \x20 let now <- Clock.now()\n\
743 \x20 now\n\
744 \x20 }\n\
745 }\n";
746
747 #[test]
748 fn compiles_browser_program_to_js_graph() {
749 let r = compile(PROG, Platform::Browser);
750 assert!(
751 r.ok,
752 "should compile: {:?}",
753 r.diagnostics.first().map(|d| &d.message)
754 );
755 let paths: Vec<&str> = r.files.iter().map(|f| f.path.as_str()).collect();
757 assert!(
758 paths.iter().all(|p| p.ends_with(".js")),
759 "all JS: {paths:?}"
760 );
761 assert!(
762 paths.contains(&"runtime.js"),
763 "runtime.js present: {paths:?}"
764 );
765 assert!(
766 paths.contains(&"bynk-browser.js"),
767 "browser binding present: {paths:?}"
768 );
769 let kind = |p: &str| r.files.iter().find(|f| f.path == p).map(|f| f.kind);
771 assert_eq!(kind("app/demo.js"), Some(FileKind::User));
772 assert_eq!(kind("compose.js"), Some(FileKind::Wiring));
773 assert_eq!(kind("runtime.js"), Some(FileKind::Support));
774 assert_eq!(kind("bynk-browser.js"), Some(FileKind::Support));
775 assert_eq!(
776 r.files.iter().filter(|f| f.kind == FileKind::User).count(),
777 1,
778 "only the user's module is `user`"
779 );
780 assert!(
782 r.files
783 .iter()
784 .filter(|f| f.path == "bynk.js" || f.path.starts_with("bynk/"))
785 .all(|f| f.kind == FileKind::Support),
786 "first-party units are `support`"
787 );
788 let user = r
790 .files
791 .iter()
792 .find(|f| f.path == "app/demo.js")
793 .expect("user module");
794 assert!(
795 !user.contents.contains(": Promise<"),
796 "annotations stripped:\n{}",
797 user.contents
798 );
799 }
800
801 #[test]
802 fn surfaces_diagnostics_for_a_bad_program() {
803 let r = compile("context app.demo\n\nthis is not bynk\n", Platform::Browser);
804 assert!(!r.ok);
805 assert!(r.files.is_empty());
806 assert!(!r.diagnostics.is_empty());
807 assert!(r.diagnostics.iter().all(|d| d.severity == "error"));
808 assert!(r.diagnostics.iter().any(|d| d.line >= 1));
810 }
811
812 #[test]
813 fn cloudflare_shapes_are_rejected_in_the_browser() {
814 let prog = "context cache.store\n\
816 \n\
817 consumes bynk.cloudflare { Kv }\n\
818 \n\
819 service cache {\n\
820 \x20 on call(k: String) -> Effect[Option[String]] given Kv {\n\
821 \x20 let v <- Kv.get(k)\n\
822 \x20 v\n\
823 \x20 }\n\
824 }\n";
825 let r = compile(prog, Platform::Browser);
826 assert!(
827 !r.ok,
828 "a cloudflare-only program must not compile for the browser"
829 );
830 assert!(
831 r.diagnostics
832 .iter()
833 .any(|d| d.category == "bynk.target.vendor_required"),
834 "expected the platform lock: {:?}",
835 r.diagnostics
836 .iter()
837 .map(|d| &d.category)
838 .collect::<Vec<_>>()
839 );
840 }
841
842 #[test]
846 fn platform_named_selects_the_analysis_platform() {
847 assert_eq!(platform_named(None), Platform::Browser);
848 assert_eq!(platform_named(Some("cloudflare")), Platform::Cloudflare);
849 assert_eq!(platform_named(Some("node")), Platform::Node);
850 assert_eq!(platform_named(Some("browser")), Platform::Browser);
851 assert_eq!(platform_named(Some("mainframe")), Platform::Browser);
852
853 let prog = "context kv.index\n\n\
854 consumes bynk.cloudflare { Kv }\n\n\
855 service scan {\n\
856 \x20 on call(prefix: String) -> Effect[Result[List[String], ()]] given Kv {\n\
857 \x20 let keys <- Kv.list(Some(prefix))\n\
858 \x20 Ok(keys)\n\
859 \x20 }\n\
860 }\n";
861 let categories = |p: Platform| -> Vec<String> {
862 analyze(prog, p)
863 .diagnostics
864 .into_iter()
865 .map(|d| d.category)
866 .collect()
867 };
868 assert_eq!(
869 categories(platform_named(Some("cloudflare"))),
870 Vec::<String>::new()
871 );
872 assert!(
873 categories(platform_named(None)).contains(&"bynk.target.vendor_required".to_string()),
874 "the browser default keeps the vendor lock: {:?}",
875 categories(platform_named(None))
876 );
877 }
878
879 #[test]
880 fn compile_to_json_is_valid_json() {
881 let json = compile_to_json(PROG, Platform::Browser);
882 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
883 assert_eq!(v["ok"], true);
884 assert!(v["files"].as_array().is_some_and(|a| !a.is_empty()));
885 }
886
887 #[test]
888 fn analyze_reports_check_errors_for_a_context() {
889 let prog = "context app.demo\n\n\
893 consumes bynk { Logger }\n\n\
894 service demo {\n\
895 \x20 on call() -> Effect[Int] given Logger {\n\
896 \x20 let _ <- Logger.info(\"x\")\n\
897 \x20 \"not an int\"\n\
898 \x20 }\n\
899 }\n";
900 let r = analyze(prog, Platform::Browser);
901 assert!(
902 r.diagnostics.iter().any(|d| d.severity == "error"),
903 "a type mismatch should be reported: {:?}",
904 r.diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>()
905 );
906 assert!(r.diagnostics.iter().any(|d| d.to > d.from));
908 }
909
910 #[test]
911 fn hover_reports_the_inferred_type_of_an_expression() {
912 let offset = PROG.rfind("now").expect("PROG mentions `now`");
915 let r = hover(PROG, offset, Platform::Browser);
916 assert_eq!(r.ty.as_deref(), Some("Instant"));
917 }
918
919 #[test]
920 fn hover_outside_any_expression_is_none() {
921 let r = hover(PROG, 0, Platform::Browser);
924 assert_eq!(r.ty, None);
925 }
926
927 #[test]
928 fn hover_to_json_is_valid_json() {
929 let offset = PROG.rfind("now").expect("PROG mentions `now`");
930 let json = hover_to_json(PROG, offset, Platform::Browser);
931 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
932 assert_eq!(v["ty"], "Instant");
933 }
934
935 #[test]
936 fn hover_survives_a_sibling_error() {
937 let prog = "commons app.demo\n\n\
942 fn good() -> Int {\n 42\n}\n\n\
943 fn bad() -> Int {\n \"oops\"\n}\n";
944 let offset = prog.find("42").expect("prog mentions 42");
945 let r = hover(prog, offset, Platform::Browser);
946 assert_eq!(r.ty.as_deref(), Some("Int"));
947 }
948
949 #[test]
950 fn hover_on_an_expression_that_failed_to_type_is_none() {
951 let prog = "commons app.demo\n\nfn bad() -> Int {\n \"oops\" + 1\n}\n";
954 let offset = prog.find("+ 1").expect("prog mentions + 1");
955 let r = hover(prog, offset, Platform::Browser);
956 assert_eq!(r.ty, None);
957 }
958
959 #[test]
960 fn complete_offers_in_scope_capability_after_given() {
961 let prog = "context app.demo\n\n\
962 consumes bynk { Clock, Logger }\n\n\
963 service demo {\n\
964 \x20 on call() -> Effect[Instant] given \n\
965 \x20 Clock.now()\n\
966 \x20 }\n\
967 }\n";
968 let offset = prog.find("given \n").expect("prog mentions given") + "given ".len();
969 let r = complete(prog, offset, Platform::Browser);
970 assert!(
971 r.items
972 .iter()
973 .any(|c| c.label == "Logger" && c.kind == "capability"),
974 "{:?}",
975 r.items.iter().map(|c| &c.label).collect::<Vec<_>>()
976 );
977 }
978
979 #[test]
980 fn complete_offers_in_scope_locals_at_expression_position() {
981 let offset = PROG.rfind("now").expect("PROG mentions `now`");
985 let r = complete(PROG, offset, Platform::Browser);
986 assert!(
987 r.items.iter().any(|c| c.label == "now"
988 && c.kind == "local"
989 && c.detail.as_deref() == Some("Instant")),
990 "{:?}",
991 r.items
992 .iter()
993 .map(|c| (&c.label, c.kind))
994 .collect::<Vec<_>>()
995 );
996 }
997
998 #[test]
999 fn complete_offers_value_receiver_members_after_dot() {
1000 let prog = "commons app.demo\n\n\
1001 fn f() -> String {\n\
1002 \x20 let value = \"hi\"\n\
1003 \x20 value.\n\
1004 }\n";
1005 let offset = prog.find("value.\n").expect("prog mentions value.") + "value.".len();
1006 let r = complete(prog, offset, Platform::Browser);
1007 assert!(
1008 r.items
1009 .iter()
1010 .any(|c| c.label == "split" && c.kind == "member"),
1011 "{:?}",
1012 r.items.iter().map(|c| &c.label).collect::<Vec<_>>()
1013 );
1014 }
1015
1016 #[test]
1017 fn complete_survives_a_sibling_error() {
1018 let prog = "commons app.demo\n\n\
1021 fn good() -> Int {\n let count = 42\n count\n}\n\n\
1022 fn bad() -> Int {\n \"oops\"\n}\n";
1023 let offset = prog.rfind("count").expect("prog mentions count");
1024 let r = complete(prog, offset, Platform::Browser);
1025 assert!(
1026 r.items
1027 .iter()
1028 .any(|c| c.label == "count" && c.kind == "local"),
1029 "{:?}",
1030 r.items
1031 .iter()
1032 .map(|c| (&c.label, c.kind))
1033 .collect::<Vec<_>>()
1034 );
1035 }
1036
1037 #[test]
1038 fn complete_to_json_is_valid_json() {
1039 let offset = PROG.rfind("now").expect("PROG mentions `now`");
1040 let json = complete_to_json(PROG, offset, Platform::Browser);
1041 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
1042 assert!(v["items"].as_array().is_some_and(|a| !a.is_empty()));
1043 }
1044
1045 #[test]
1046 fn complete_survives_a_panic() {
1047 let prev = std::panic::take_hook();
1053 std::panic::set_hook(Box::new(|_| {}));
1054 let r = complete("", usize::MAX, Platform::Browser);
1055 std::panic::set_hook(prev);
1056 assert!(r.items.is_empty());
1057 }
1058
1059 #[test]
1060 fn catch_panic_converts_panic_to_a_diagnostic() {
1061 let prev = std::panic::take_hook();
1064 std::panic::set_hook(Box::new(|_| {}));
1065 let caught = catch_panic(|| -> i32 { panic!("boom {}", 42) });
1066 std::panic::set_hook(prev);
1067
1068 let d = caught.expect_err("a panic must become an Err(diagnostic)");
1069 assert_eq!(d.severity, "error");
1070 assert_eq!(d.category, "bynk.wasm.panic");
1071 assert!(
1072 d.message.contains("boom 42"),
1073 "the panic message is carried through: {}",
1074 d.message
1075 );
1076 }
1077
1078 #[test]
1079 fn catch_panic_passes_a_value_through() {
1080 assert_eq!(catch_panic(|| 7).ok(), Some(7));
1081 }
1082
1083 #[test]
1084 fn analyze_clean_program_has_no_errors() {
1085 let r = analyze(PROG, Platform::Browser);
1086 assert!(
1087 r.diagnostics.iter().all(|d| d.severity != "error"),
1088 "clean program should have no errors: {:?}",
1089 r.diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>()
1090 );
1091 }
1092
1093 const TEXT: &str = "commons app.text\n\n\
1096 fn greeting(subject: String) -> String {\n\
1097 \x20 \"Hello, \\(subject)!\"\n\
1098 }\n";
1099
1100 const WEB: &str = "context app.web\n\n\
1101 uses app.text\n\n\
1102 consumes bynk { Logger }\n\n\
1103 service api {\n\
1104 \x20 on call() -> Effect[String] given Logger {\n\
1105 \x20 let _ <- Logger.info(\"hi\")\n\
1106 \x20 let message = greeting(\"World\")\n\
1107 \x20 message\n\
1108 \x20 }\n\
1109 }\n";
1110
1111 fn project(web: &str) -> HashMap<PathBuf, String> {
1112 HashMap::from([
1113 (
1114 PathBuf::from("bynk.toml"),
1115 "[project]\nname = \"p\"\nversion = \"0.1.0\"\n".to_string(),
1116 ),
1117 (PathBuf::from("src/app/text.bynk"), TEXT.to_string()),
1118 (PathBuf::from("src/app/web.bynk"), web.to_string()),
1119 ])
1120 }
1121
1122 fn categories(r: &ProjectAnalyzeResult) -> Vec<(Option<String>, String)> {
1123 r.diagnostics
1124 .iter()
1125 .map(|d| (d.path.clone(), d.category.clone()))
1126 .collect()
1127 }
1128
1129 #[test]
1130 fn a_project_resolves_uses_across_files() {
1131 let alone = analyze(WEB, Platform::Cloudflare);
1133 assert!(
1134 alone
1135 .diagnostics
1136 .iter()
1137 .any(|d| d.category == "bynk.uses.unknown_commons")
1138 );
1139 let r = analyze_project(&project(WEB), Platform::Cloudflare);
1141 assert!(r.diagnostics.is_empty(), "{:?}", categories(&r));
1142 }
1143
1144 #[test]
1145 fn a_project_reports_a_cross_file_misuse_against_its_file() {
1146 let r = analyze_project(
1147 &project(&WEB.replace("greeting(\"World\")", "greeting(1)")),
1148 Platform::Cloudflare,
1149 );
1150 let cats = categories(&r);
1151 assert!(!cats.is_empty(), "the misuse is reported");
1152 assert!(
1153 cats.iter()
1154 .all(|(path, _)| path.as_deref() == Some("src/app/web.bynk")),
1155 "attributed to the using file: {cats:?}"
1156 );
1157 }
1158
1159 #[test]
1160 fn a_project_honours_paths_include() {
1161 let mut files = project(WEB);
1163 files.insert(
1164 PathBuf::from("bynk.toml"),
1165 "[project]\nname = \"p\"\nversion = \"0.1.0\"\n\n[paths]\ninclude = [\"lib\"]\n"
1166 .to_string(),
1167 );
1168 let text = files.remove(Path::new("src/app/text.bynk")).unwrap();
1169 files.insert(PathBuf::from("lib/app/text.bynk"), text);
1170 let web = files.remove(Path::new("src/app/web.bynk")).unwrap();
1171 files.insert(PathBuf::from("lib/app/web.bynk"), web);
1172 files.insert(PathBuf::from("src/app/stray.bynk"), "not bynk".to_string());
1173 let r = analyze_project(&files, Platform::Cloudflare);
1174 assert!(r.diagnostics.is_empty(), "{:?}", categories(&r));
1175 }
1176
1177 fn manifest(paths: &str) -> String {
1178 format!("[project]\nname = \"p\"\nversion = \"0.1.0\"\n\n[paths]\n{paths}\n")
1179 }
1180
1181 #[test]
1182 fn a_project_honours_a_dot_slash_include() {
1183 let mut files = project(&WEB.replace("greeting(\"World\")", "greeting(1)"));
1187 files.insert(
1188 PathBuf::from("bynk.toml"),
1189 manifest("include = [\"./src\"]"),
1190 );
1191 let r = analyze_project(&files, Platform::Cloudflare);
1192 let cats = categories(&r);
1193 assert!(!cats.is_empty(), "the files under ./src are analysed");
1194 assert!(
1195 cats.iter()
1196 .all(|(path, _)| path.as_deref() == Some("src/app/web.bynk")),
1197 "{cats:?}"
1198 );
1199 }
1200
1201 #[test]
1202 fn a_project_warns_about_absolute_paths_entries() {
1203 let mut files = project(WEB);
1207 files.insert(
1208 PathBuf::from("bynk.toml"),
1209 manifest("include = [\"/home/me/proj/src\"]\nexclude = [\"C:/gen\", \"gen\"]"),
1210 );
1211 let r = analyze_project(&files, Platform::Cloudflare);
1212 assert!(r.diagnostics.is_empty(), "{:?}", categories(&r));
1213 assert_eq!(r.warnings.len(), 2, "{:?}", r.warnings);
1214 assert!(r.warnings[0].contains("`[paths] include` entry `/home/me/proj/src`"));
1215 assert!(r.warnings[1].contains("`[paths] exclude` entry `C:/gen`"));
1216 let r = analyze_project(&project(WEB), Platform::Cloudflare);
1218 assert!(r.warnings.is_empty());
1219 }
1220
1221 #[test]
1222 fn a_project_honours_a_dot_slash_exclude_and_a_root_include() {
1223 let mut files: HashMap<PathBuf, String> = project(WEB)
1226 .into_iter()
1227 .map(|(p, t)| (PathBuf::from(p.to_string_lossy().replace("src/", "")), t))
1228 .collect();
1229 files.insert(
1230 PathBuf::from("bynk.toml"),
1231 manifest("include = [\"./\"]\nexclude = [\"./gen\"]"),
1232 );
1233 files.insert(PathBuf::from("gen/junk.bynk"), "not bynk".to_string());
1234 let r = analyze_project(&files, Platform::Cloudflare);
1235 assert!(r.diagnostics.is_empty(), "{:?}", categories(&r));
1236 files.insert(PathBuf::from("bynk.toml"), manifest("include = [\".\"]"));
1238 let r = analyze_project(&files, Platform::Cloudflare);
1239 assert!(
1240 categories(&r)
1241 .iter()
1242 .any(|(path, _)| path.as_deref() == Some("gen/junk.bynk")),
1243 "{:?}",
1244 categories(&r)
1245 );
1246 }
1247
1248 #[test]
1249 fn a_project_hovers_a_name_from_another_file() {
1250 let files = project(WEB);
1251 let offset = WEB.rfind("message").expect("WEB mentions `message`");
1252 let r = hover_project(&files, "src/app/web.bynk", offset, Platform::Cloudflare);
1253 assert_eq!(r.ty.as_deref(), Some("String"));
1254 let r = hover_project(&files, "src/app/nope.bynk", offset, Platform::Cloudflare);
1256 assert_eq!(r.ty, None);
1257 }
1258
1259 #[test]
1260 fn a_project_completes_locals_typed_across_files() {
1261 let src = WEB.replace(" message\n", " m\n");
1262 let files = project(&src);
1263 let offset = src.rfind(" m\n").expect("the partial local") + " m".len();
1264 let r = complete_project(&files, "src/app/web.bynk", offset, Platform::Cloudflare);
1265 let message = r
1266 .items
1267 .iter()
1268 .find(|c| c.label == "message")
1269 .unwrap_or_else(|| panic!("`message` is offered: {:?}", labels(&r)));
1270 assert_eq!(message.detail.as_deref(), Some("String"));
1271 let files = project(&WEB.replace("hi", "hé"));
1273 let inside = WEB.find("hi").unwrap() + 2;
1274 let r = complete_project(&files, "src/app/web.bynk", inside, Platform::Cloudflare);
1275 assert!(r.items.is_empty());
1276 }
1277
1278 fn labels(r: &CompleteResult) -> Vec<&str> {
1279 r.items.iter().map(|c| c.label.as_str()).collect()
1280 }
1281
1282 #[test]
1286 fn project_paths_are_slash_separated_on_every_platform() {
1287 let misuse = WEB.replace("greeting(\"World\")", "greeting(1)");
1288 let native: HashMap<PathBuf, String> = project(&misuse)
1290 .into_iter()
1291 .map(|(p, text)| {
1292 let joined = p.iter().fold(PathBuf::new(), |acc, part| acc.join(part));
1293 (joined, text)
1294 })
1295 .collect();
1296 for files in [project(&misuse), native] {
1297 let r = analyze_project(&files, Platform::Cloudflare);
1298 assert!(!r.diagnostics.is_empty(), "the misuse is reported");
1299 for d in &r.diagnostics {
1300 let path = d.path.as_deref().unwrap_or_default();
1301 assert!(!path.contains('\\'), "a `/` path, not {path:?}");
1302 assert_eq!(path, "src/app/web.bynk");
1303 assert!(d.line > 1, "line/col from web.bynk: {}", d.line);
1305 }
1306 let offset = misuse.find("\"hi\"").unwrap() + 1;
1308 let r = hover_project(&files, "src/app/web.bynk", offset, Platform::Cloudflare);
1309 assert_eq!(
1310 r.ty.as_deref(),
1311 Some("String"),
1312 "hover finds the file under its `/` key"
1313 );
1314 }
1315 }
1316
1317 #[test]
1318 fn slash_key_joins_components_with_slashes() {
1319 let joined = Path::new("src").join("app").join("web.bynk");
1320 assert_eq!(slash_key(&joined), PathBuf::from("src/app/web.bynk"));
1321 assert_eq!(
1322 slash_key(Path::new("./src/a.bynk")),
1323 PathBuf::from("src/a.bynk")
1324 );
1325 #[cfg(windows)]
1326 assert_eq!(
1327 slash_key(Path::new(r"src\app/web.bynk")),
1328 PathBuf::from("src/app/web.bynk")
1329 );
1330 #[cfg(unix)]
1332 assert_eq!(
1333 slash_key(Path::new(r"src/a\b.bynk")),
1334 PathBuf::from(r"src/a\b.bynk")
1335 );
1336 }
1337
1338 #[test]
1339 fn project_files_parses_the_wire_map() {
1340 let files = project_files(r#"{"bynk.toml":"x","src/a.bynk":"y"}"#);
1341 assert_eq!(
1342 files.get(Path::new("src/a.bynk")).map(String::as_str),
1343 Some("y")
1344 );
1345 assert!(project_files("not json").is_empty());
1346 }
1347}