1use 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#[derive(serde::Serialize, Clone, Copy, PartialEq, Eq, Debug)]
42#[serde(rename_all = "lowercase")]
43pub enum FileKind {
44 User,
46 Wiring,
48 Support,
51}
52
53#[derive(serde::Serialize)]
55pub struct EmittedFile {
56 pub kind: FileKind,
58 pub path: String,
60 pub contents: String,
62}
63
64#[derive(serde::Serialize)]
66pub struct Diagnostic {
67 pub path: Option<String>,
69 pub line: usize,
70 pub col: usize,
71 pub from: usize,
73 pub to: usize,
74 pub severity: String,
76 pub category: String,
78 pub message: String,
79 pub notes: Vec<String>,
85 pub labels: Vec<String>,
86}
87
88#[derive(serde::Serialize)]
90pub struct CompileResult {
91 pub ok: bool,
93 pub files: Vec<EmittedFile>,
95 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
106fn 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
117fn 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#[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
157fn 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
192pub 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 let diagnostics = to_diagnostics(js.warnings, &HashMap::new(), source);
212 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 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
273pub 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#[derive(serde::Serialize)]
286#[serde(untagged)]
287pub enum FormatResult {
288 Ok { ok: bool, formatted: String },
289 Err { ok: bool, error: String },
290}
291
292pub 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 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
321pub 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#[derive(serde::Serialize)]
330pub struct AnalyzeResult {
331 pub diagnostics: Vec<Diagnostic>,
332}
333
334pub 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
348pub 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#[derive(serde::Serialize)]
362pub struct HoverResult {
363 pub ty: Option<String>,
364}
365
366pub 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
378pub 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#[derive(serde::Serialize)]
388pub struct CompletionCandidate {
389 pub label: String,
390 pub kind: &'static str,
393 pub detail: Option<String>,
394 pub insert_text: Option<String>,
395}
396
397#[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
426pub 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
446struct 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
464fn 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 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 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
518pub 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
524pub 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#[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 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
583fn 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
597pub 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
615pub fn complete_project(
619 files: &HashMap<PathBuf, String>,
620 path: &str,
621 offset: usize,
622 platform: Platform,
623) -> CompleteResult {
624 catch_panic(|| {
625 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
649pub 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#[cfg(target_arch = "wasm32")]
672fn install_panic_hook() {
673 console_error_panic_hook::set_once();
674}
675
676#[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#[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#[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#[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#[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#[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#[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#[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 #[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 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 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 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 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 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 assert!(r.diagnostics.iter().any(|d| d.line >= 1));
932 }
933
934 #[test]
935 fn cloudflare_shapes_are_rejected_in_the_browser() {
936 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[test]
1408 fn project_paths_are_slash_separated_on_every_platform() {
1409 let misuse = WEB.replace("greeting(\"World\")", "greeting(1)");
1410 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 assert!(d.line > 1, "line/col from web.bynk: {}", d.line);
1427 }
1428 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 #[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}