1use std::collections::HashMap;
22use std::path::PathBuf;
23
24use bynk_check::firstparty::Platform;
25use bynk_emit::project::{AttributedError, BuildTarget, analyse_in_memory, compile_in_memory};
26use bynk_syntax::CompileError;
27
28#[derive(serde::Serialize)]
30pub struct EmittedFile {
31 pub path: String,
33 pub contents: String,
35}
36
37#[derive(serde::Serialize)]
39pub struct Diagnostic {
40 pub path: Option<String>,
42 pub line: usize,
43 pub col: usize,
44 pub from: usize,
46 pub to: usize,
47 pub severity: String,
49 pub category: String,
51 pub message: String,
52}
53
54#[derive(serde::Serialize)]
56pub struct CompileResult {
57 pub ok: bool,
59 pub files: Vec<EmittedFile>,
61 pub diagnostics: Vec<Diagnostic>,
63}
64
65fn severity_str(err: &CompileError) -> &'static str {
66 match bynk_syntax::Severity::for_error(err) {
67 bynk_syntax::Severity::Error => "error",
68 bynk_syntax::Severity::Warning => "warning",
69 }
70}
71
72fn to_diagnostics(
75 errs: Vec<AttributedError>,
76 sources: &HashMap<PathBuf, String>,
77 fallback: &str,
78) -> Vec<Diagnostic> {
79 errs.into_iter()
80 .map(|a| {
81 let src = a
82 .source_path
83 .as_ref()
84 .and_then(|p| sources.get(p))
85 .map(String::as_str)
86 .unwrap_or(fallback);
87 let (line, col) = bynk_syntax::span::line_col(src, a.error.span.start);
88 Diagnostic {
89 path: a
90 .source_path
91 .as_ref()
92 .map(|p| p.to_string_lossy().into_owned()),
93 line,
94 col,
95 from: a.error.span.start,
96 to: a.error.span.end,
97 severity: severity_str(&a.error).to_string(),
98 category: a.error.category.to_string(),
99 message: a.error.message.clone(),
100 }
101 })
102 .collect()
103}
104
105pub fn compile(source: &str, platform: Platform) -> CompileResult {
111 match compile_in_memory(source, BuildTarget::Bundle, platform) {
112 Ok(out) => match bynk_strip::strip_project_to_js(out) {
113 Ok(js) => {
114 let diagnostics = to_diagnostics(js.warnings, &HashMap::new(), source);
117 let files = js
118 .files
119 .into_iter()
120 .map(|f| EmittedFile {
121 path: f.output_path.to_string_lossy().into_owned(),
122 contents: f.typescript,
123 })
124 .collect();
125 CompileResult {
126 ok: true,
127 files,
128 diagnostics,
129 }
130 }
131 Err(e) => CompileResult {
134 ok: false,
135 files: Vec::new(),
136 diagnostics: vec![Diagnostic {
137 path: None,
138 line: 0,
139 col: 0,
140 from: 0,
141 to: 0,
142 severity: "error".to_string(),
143 category: "bynk.wasm.strip_failed".to_string(),
144 message: e.to_string(),
145 }],
146 },
147 },
148 Err(failure) => {
149 let sources: HashMap<PathBuf, String> = failure.snapshots.iter().cloned().collect();
150 CompileResult {
151 ok: false,
152 files: Vec::new(),
153 diagnostics: to_diagnostics(failure.errors, &sources, source),
154 }
155 }
156 }
157}
158
159pub fn compile_to_json(source: &str, platform: Platform) -> String {
161 serde_json::to_string(&compile(source, platform)).unwrap_or_else(|e| {
162 format!(
163 "{{\"ok\":false,\"files\":[],\"diagnostics\":[{{\"path\":null,\"line\":0,\"col\":0,\"from\":0,\"to\":0,\
164 \"severity\":\"error\",\"category\":\"bynk.wasm.serialize_failed\",\"message\":{:?}}}]}}",
165 e.to_string()
166 )
167 })
168}
169
170#[derive(serde::Serialize)]
173pub struct AnalyzeResult {
174 pub diagnostics: Vec<Diagnostic>,
175}
176
177pub fn analyze(source: &str, platform: Platform) -> AnalyzeResult {
179 let errs = analyse_in_memory(source, BuildTarget::Bundle, platform);
180 AnalyzeResult {
181 diagnostics: to_diagnostics(errs, &HashMap::new(), source),
182 }
183}
184
185pub fn analyze_to_json(source: &str, platform: Platform) -> String {
188 serde_json::to_string(&analyze(source, platform))
189 .unwrap_or_else(|_| "{\"diagnostics\":[]}".to_string())
190}
191
192#[cfg(target_arch = "wasm32")]
193use wasm_bindgen::prelude::wasm_bindgen;
194
195#[cfg(target_arch = "wasm32")]
199#[wasm_bindgen]
200pub fn bynk_analyze(source: &str) -> String {
201 analyze_to_json(source, Platform::Browser)
202}
203
204#[cfg(target_arch = "wasm32")]
209#[wasm_bindgen]
210pub fn bynk_compile(source: &str) -> String {
211 compile_to_json(source, Platform::Browser)
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 const PROG: &str = "context app.demo\n\
219 \n\
220 consumes bynk { Clock, Logger }\n\
221 \n\
222 service demo {\n\
223 \x20 on call() -> Effect[Instant] given Clock, Logger {\n\
224 \x20 let _ <- Logger.info(\"hi\")\n\
225 \x20 let now <- Clock.now()\n\
226 \x20 now\n\
227 \x20 }\n\
228 }\n";
229
230 #[test]
231 fn compiles_browser_program_to_js_graph() {
232 let r = compile(PROG, Platform::Browser);
233 assert!(
234 r.ok,
235 "should compile: {:?}",
236 r.diagnostics.first().map(|d| &d.message)
237 );
238 let paths: Vec<&str> = r.files.iter().map(|f| f.path.as_str()).collect();
240 assert!(
241 paths.iter().all(|p| p.ends_with(".js")),
242 "all JS: {paths:?}"
243 );
244 assert!(
245 paths.contains(&"runtime.js"),
246 "runtime.js present: {paths:?}"
247 );
248 assert!(
249 paths.contains(&"bynk-browser.js"),
250 "browser binding present: {paths:?}"
251 );
252 let user = r
254 .files
255 .iter()
256 .find(|f| f.path == "app/demo.js")
257 .expect("user module");
258 assert!(
259 !user.contents.contains(": Promise<"),
260 "annotations stripped:\n{}",
261 user.contents
262 );
263 }
264
265 #[test]
266 fn surfaces_diagnostics_for_a_bad_program() {
267 let r = compile("context app.demo\n\nthis is not bynk\n", Platform::Browser);
268 assert!(!r.ok);
269 assert!(r.files.is_empty());
270 assert!(!r.diagnostics.is_empty());
271 assert!(r.diagnostics.iter().all(|d| d.severity == "error"));
272 assert!(r.diagnostics.iter().any(|d| d.line >= 1));
274 }
275
276 #[test]
277 fn cloudflare_shapes_are_rejected_in_the_browser() {
278 let prog = "context cache.store\n\
280 \n\
281 consumes bynk.cloudflare { Kv }\n\
282 \n\
283 service cache {\n\
284 \x20 on call(k: String) -> Effect[Option[String]] given Kv {\n\
285 \x20 let v <- Kv.get(k)\n\
286 \x20 v\n\
287 \x20 }\n\
288 }\n";
289 let r = compile(prog, Platform::Browser);
290 assert!(
291 !r.ok,
292 "a cloudflare-only program must not compile for the browser"
293 );
294 assert!(
295 r.diagnostics
296 .iter()
297 .any(|d| d.category == "bynk.target.vendor_required"),
298 "expected the platform lock: {:?}",
299 r.diagnostics
300 .iter()
301 .map(|d| &d.category)
302 .collect::<Vec<_>>()
303 );
304 }
305
306 #[test]
307 fn compile_to_json_is_valid_json() {
308 let json = compile_to_json(PROG, Platform::Browser);
309 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
310 assert_eq!(v["ok"], true);
311 assert!(v["files"].as_array().is_some_and(|a| !a.is_empty()));
312 }
313
314 #[test]
315 fn analyze_reports_check_errors_for_a_context() {
316 let prog = "context app.demo\n\n\
320 consumes bynk { Logger }\n\n\
321 service demo {\n\
322 \x20 on call() -> Effect[Int] given Logger {\n\
323 \x20 let _ <- Logger.info(\"x\")\n\
324 \x20 \"not an int\"\n\
325 \x20 }\n\
326 }\n";
327 let r = analyze(prog, Platform::Browser);
328 assert!(
329 r.diagnostics.iter().any(|d| d.severity == "error"),
330 "a type mismatch should be reported: {:?}",
331 r.diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>()
332 );
333 assert!(r.diagnostics.iter().any(|d| d.to > d.from));
335 }
336
337 #[test]
338 fn analyze_clean_program_has_no_errors() {
339 let r = analyze(PROG, Platform::Browser);
340 assert!(
341 r.diagnostics.iter().all(|d| d.severity != "error"),
342 "clean program should have no errors: {:?}",
343 r.diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>()
344 );
345 }
346}