1#![forbid(unsafe_code)]
2
3use merman_bindings_core::BindingError;
9use serde::Serialize;
10use wasm_bindgen::prelude::*;
11
12#[cfg(all(feature = "render", target_arch = "wasm32"))]
13use std::{cell::RefCell, sync::Arc};
14
15#[cfg(feature = "editor-language")]
16mod editor_language;
17
18#[cfg(feature = "editor-language")]
19pub use editor_language::{
20 editor_code_actions, editor_completions, editor_definition, editor_diagnostics,
21 editor_document_symbols, editor_hover, editor_prepare_rename, editor_references, editor_rename,
22 editor_semantic_token_legend, editor_semantic_tokens, editor_workspace_symbols,
23};
24
25#[cfg(all(feature = "render", target_arch = "wasm32"))]
26use merman_bindings_core::{TextMeasurer, TextMetrics, TextStyle, WrapMode};
27#[cfg(all(feature = "render", target_arch = "wasm32"))]
28use serde::Deserialize;
29
30const WASM_ABI_VERSION: u32 = 2;
31
32#[derive(Debug, Serialize)]
33struct WasmErrorPayload<'a> {
34 version: u32,
35 ok: bool,
36 code: i32,
37 code_name: &'a str,
38 message: &'a str,
39}
40
41#[wasm_bindgen(start)]
42pub fn start() {
43 console_error_panic_hook::set_once();
44}
45
46#[wasm_bindgen(js_name = abiVersion)]
47pub fn abi_version() -> u32 {
48 WASM_ABI_VERSION
49}
50
51#[wasm_bindgen(js_name = packageVersion)]
52pub fn package_version() -> String {
53 env!("CARGO_PKG_VERSION").to_string()
54}
55
56#[wasm_bindgen(js_name = renderSvg)]
57pub fn render_svg(source: &str, options_json: Option<String>) -> Result<String, JsValue> {
58 string_result(merman_bindings_core::render_svg(
59 source.as_bytes(),
60 options_bytes(options_json.as_deref()),
61 ))
62}
63
64#[cfg(all(feature = "render", target_arch = "wasm32"))]
65#[wasm_bindgen(js_name = renderSvgWithTextMeasurer)]
66pub fn render_svg_with_text_measurer(
67 source: &str,
68 options_json: Option<String>,
69 callback: js_sys::Function,
70) -> Result<String, JsValue> {
71 with_host_text_measure_callback(callback, || {
72 let engine =
73 merman_bindings_core::BindingEngine::new(options_bytes(options_json.as_deref()))
74 .map_err(binding_error_to_js)?;
75 let engine = engine.with_text_measurer(Arc::new(WasmHostTextMeasurer::default()));
76 host_text_measure_result(string_result(engine.render_svg(source.as_bytes())))
77 })
78}
79
80#[cfg(all(feature = "render", target_arch = "wasm32"))]
81#[wasm_bindgen(js_name = layoutJsonWithTextMeasurer)]
82pub fn layout_json_with_text_measurer(
83 source: &str,
84 options_json: Option<String>,
85 callback: js_sys::Function,
86) -> Result<String, JsValue> {
87 with_host_text_measure_callback(callback, || {
88 let engine =
89 merman_bindings_core::BindingEngine::new(options_bytes(options_json.as_deref()))
90 .map_err(binding_error_to_js)?;
91 let engine = engine.with_text_measurer(Arc::new(WasmHostTextMeasurer::default()));
92 host_text_measure_result(string_result(engine.layout_json(source.as_bytes())))
93 })
94}
95
96#[wasm_bindgen(js_name = parseJson)]
97pub fn parse_json(source: &str, options_json: Option<String>) -> Result<String, JsValue> {
98 string_result(merman_bindings_core::parse_json(
99 source.as_bytes(),
100 options_bytes(options_json.as_deref()),
101 ))
102}
103
104#[wasm_bindgen(js_name = layoutJson)]
105pub fn layout_json(source: &str, options_json: Option<String>) -> Result<String, JsValue> {
106 string_result(merman_bindings_core::layout_json(
107 source.as_bytes(),
108 options_bytes(options_json.as_deref()),
109 ))
110}
111
112#[wasm_bindgen(js_name = renderAscii)]
113pub fn render_ascii(source: &str, options_json: Option<String>) -> Result<String, JsValue> {
114 string_result(merman_bindings_core::render_ascii(
115 source.as_bytes(),
116 options_bytes(options_json.as_deref()),
117 ))
118}
119
120#[wasm_bindgen]
121pub fn analyze(source: &str, options_json: Option<String>) -> Result<JsValue, JsValue> {
122 json_value_result(merman_bindings_core::analyze_json(
123 source.as_bytes(),
124 options_bytes(options_json.as_deref()),
125 ))
126}
127
128#[wasm_bindgen(js_name = analyzeJson)]
129pub fn analyze_json(source: &str, options_json: Option<String>) -> Result<JsValue, JsValue> {
130 analyze(source, options_json)
131}
132
133#[wasm_bindgen(js_name = analysisFacts)]
134pub fn analysis_facts(source: &str, options_json: Option<String>) -> Result<JsValue, JsValue> {
135 json_value_result(merman_bindings_core::analysis_facts_json(
136 source.as_bytes(),
137 options_bytes(options_json.as_deref()),
138 ))
139}
140
141#[wasm_bindgen(js_name = analyzeDocument)]
142pub fn analyze_document(
143 source: &str,
144 options_json: Option<String>,
145 uri: Option<String>,
146) -> Result<JsValue, JsValue> {
147 let uri = document_uri(uri);
148 json_value_result(merman_bindings_core::analyze_document_json(
149 source.as_bytes(),
150 options_bytes(options_json.as_deref()),
151 uri.as_bytes(),
152 ))
153}
154
155#[wasm_bindgen(js_name = analyzeDocumentFacts)]
156pub fn analyze_document_facts(
157 source: &str,
158 options_json: Option<String>,
159 uri: Option<String>,
160) -> Result<JsValue, JsValue> {
161 let uri = document_uri(uri);
162 json_value_result(merman_bindings_core::analyze_document_facts_json(
163 source.as_bytes(),
164 options_bytes(options_json.as_deref()),
165 uri.as_bytes(),
166 ))
167}
168
169#[wasm_bindgen]
170pub fn validate(source: &str, options_json: Option<String>) -> Result<JsValue, JsValue> {
171 json_value_result(merman_bindings_core::validate_json(
172 source.as_bytes(),
173 options_bytes(options_json.as_deref()),
174 ))
175}
176
177#[wasm_bindgen(js_name = supportedDiagrams)]
178pub fn supported_diagrams() -> Result<JsValue, JsValue> {
179 serde_wasm_bindgen::to_value(merman_bindings_core::supported_diagrams())
180 .map_err(|err| JsValue::from_str(&err.to_string()))
181}
182
183#[wasm_bindgen(js_name = bindingCapabilities)]
184pub fn binding_capabilities() -> Result<JsValue, JsValue> {
185 serde_wasm_bindgen::to_value(&merman_bindings_core::binding_capabilities())
186 .map_err(|err| JsValue::from_str(&err.to_string()))
187}
188
189#[wasm_bindgen(js_name = selectedRegistryProfile)]
190pub fn selected_registry_profile() -> String {
191 merman_bindings_core::selected_registry_profile().to_string()
192}
193
194#[wasm_bindgen(js_name = diagramFamilyCapabilities)]
195pub fn diagram_family_capabilities() -> Result<JsValue, JsValue> {
196 serde_wasm_bindgen::to_value(&merman_bindings_core::diagram_family_capabilities())
197 .map_err(|err| JsValue::from_str(&err.to_string()))
198}
199
200#[wasm_bindgen(js_name = lintRuleCatalog)]
201pub fn lint_rule_catalog() -> Result<JsValue, JsValue> {
202 json_value_result(merman_bindings_core::lint_rule_catalog_json())
203}
204
205#[wasm_bindgen(js_name = supportedThemes)]
206pub fn supported_themes() -> Result<JsValue, JsValue> {
207 serde_wasm_bindgen::to_value(merman_bindings_core::supported_themes())
208 .map_err(|err| JsValue::from_str(&err.to_string()))
209}
210
211#[wasm_bindgen(js_name = supportedHostThemePresets)]
212pub fn supported_host_theme_presets() -> Result<JsValue, JsValue> {
213 serde_wasm_bindgen::to_value(merman_bindings_core::supported_host_theme_presets())
214 .map_err(|err| JsValue::from_str(&err.to_string()))
215}
216
217#[wasm_bindgen(js_name = asciiSupportedDiagrams)]
218pub fn ascii_supported_diagrams() -> Result<JsValue, JsValue> {
219 serde_wasm_bindgen::to_value(merman_bindings_core::ascii_supported_diagrams())
220 .map_err(|err| JsValue::from_str(&err.to_string()))
221}
222
223#[wasm_bindgen(js_name = asciiCapabilities)]
224pub fn ascii_capabilities() -> Result<JsValue, JsValue> {
225 serde_wasm_bindgen::to_value(&merman_bindings_core::ascii_capabilities())
226 .map_err(|err| JsValue::from_str(&err.to_string()))
227}
228
229fn options_bytes(options_json: Option<&str>) -> &[u8] {
230 options_json.unwrap_or_default().as_bytes()
231}
232
233pub(crate) fn document_uri(uri: Option<String>) -> String {
234 uri.filter(|value| !value.trim().is_empty())
235 .unwrap_or_else(|| "file:///merman/document.mmd".to_string())
236}
237
238fn string_result(result: Result<Vec<u8>, BindingError>) -> Result<String, JsValue> {
239 let bytes = result.map_err(binding_error_to_js)?;
240 String::from_utf8(bytes).map_err(|err| JsValue::from_str(&err.to_string()))
241}
242
243fn json_value_result(result: Result<Vec<u8>, BindingError>) -> Result<JsValue, JsValue> {
244 let bytes = result.map_err(binding_error_to_js)?;
245 let value: serde_json::Value =
246 serde_json::from_slice(&bytes).map_err(|err| JsValue::from_str(&err.to_string()))?;
247 value
248 .serialize(&serde_wasm_bindgen::Serializer::json_compatible())
249 .map_err(|err| JsValue::from_str(&err.to_string()))
250}
251
252pub(crate) fn binding_error_to_js(err: BindingError) -> JsValue {
253 let payload = wasm_error_payload(&err);
254 payload
255 .serialize(&serde_wasm_bindgen::Serializer::json_compatible())
256 .unwrap_or_else(|_| {
257 JsValue::from_str(&format!("{}: {}", payload.code_name, payload.message))
258 })
259}
260
261fn wasm_error_payload(err: &BindingError) -> WasmErrorPayload<'_> {
262 WasmErrorPayload {
263 version: 1,
264 ok: false,
265 code: err.status().code(),
266 code_name: err.status().code_name(),
267 message: err.message(),
268 }
269}
270
271#[cfg(all(feature = "render", target_arch = "wasm32"))]
272thread_local! {
273 static HOST_TEXT_MEASURE_CALLBACK: RefCell<Option<js_sys::Function>> = const { RefCell::new(None) };
274 static HOST_TEXT_MEASURE_ERROR: RefCell<Option<JsValue>> = const { RefCell::new(None) };
275}
276
277#[cfg(all(feature = "render", target_arch = "wasm32"))]
278#[derive(Debug, Serialize)]
279struct WasmHostTextMeasureRequest<'a> {
280 text: &'a str,
281 font_family: Option<&'a str>,
282 font_size: f64,
283 font_weight: Option<&'a str>,
284 font_style: &'static str,
285 max_width: Option<f64>,
286 has_max_width: bool,
287 line_height: f64,
288 letter_spacing: f64,
289 word_spacing: f64,
290 wrap_mode: &'static str,
291 direction: &'static str,
292 white_space: &'static str,
293}
294
295#[cfg(all(feature = "render", target_arch = "wasm32"))]
296#[derive(Debug, Deserialize)]
297struct WasmHostTextMeasureResult {
298 handled: Option<bool>,
299 width: f64,
300 height: f64,
301 line_count: Option<usize>,
302}
303
304#[cfg(all(feature = "render", target_arch = "wasm32"))]
305#[derive(Default)]
306struct WasmHostTextMeasurer {
307 fallback: merman_bindings_core::VendoredFontMetricsTextMeasurer,
308}
309
310#[cfg(all(feature = "render", target_arch = "wasm32"))]
311impl WasmHostTextMeasurer {
312 fn call_host(
313 &self,
314 text: &str,
315 style: &TextStyle,
316 max_width: Option<f64>,
317 wrap_mode: WrapMode,
318 ) -> Option<TextMetrics> {
319 let request = WasmHostTextMeasureRequest {
320 text,
321 font_family: style.font_family.as_deref(),
322 font_size: style.font_size,
323 font_weight: style.font_weight.as_deref(),
324 font_style: "normal",
325 max_width,
326 has_max_width: max_width.is_some(),
327 line_height: wasm_line_height(style, wrap_mode),
328 letter_spacing: 0.0,
329 word_spacing: 0.0,
330 wrap_mode: wasm_wrap_mode(wrap_mode),
331 direction: "auto",
332 white_space: wasm_white_space(max_width, wrap_mode),
333 };
334 let request = serde_wasm_bindgen::to_value(&request).ok()?;
335
336 HOST_TEXT_MEASURE_CALLBACK.with(|slot| {
337 let callback = slot.borrow().clone()?;
338 let value = match callback.call1(&JsValue::NULL, &request) {
339 Ok(value) => value,
340 Err(err) => {
341 record_host_text_measure_error(err);
342 return None;
343 }
344 };
345 if value.is_null() || value.is_undefined() {
346 return None;
347 }
348
349 let result: WasmHostTextMeasureResult = match serde_wasm_bindgen::from_value(value) {
350 Ok(result) => result,
351 Err(err) => {
352 record_host_text_measure_error(JsValue::from_str(&err.to_string()));
353 return None;
354 }
355 };
356 if result.handled == Some(false)
357 || !result.width.is_finite()
358 || !result.height.is_finite()
359 || result.width < 0.0
360 || result.height < 0.0
361 {
362 if result.handled != Some(false) {
363 record_host_text_measure_error(JsValue::from_str(
364 "host text measurer returned invalid metrics",
365 ));
366 }
367 return None;
368 }
369
370 let line_count = result.line_count.unwrap_or(1);
371 if line_count == 0 {
372 record_host_text_measure_error(JsValue::from_str(
373 "host text measurer returned zero line_count",
374 ));
375 return None;
376 }
377
378 Some(TextMetrics {
379 width: result.width,
380 height: result.height,
381 line_count,
382 })
383 })
384 }
385
386 fn measure_with_fallback(
387 &self,
388 text: &str,
389 style: &TextStyle,
390 max_width: Option<f64>,
391 wrap_mode: WrapMode,
392 ) -> TextMetrics {
393 self.call_host(text, style, max_width, wrap_mode)
394 .unwrap_or_else(|| {
395 self.fallback
396 .measure_wrapped(text, style, max_width, wrap_mode)
397 })
398 }
399}
400
401#[cfg(all(feature = "render", target_arch = "wasm32"))]
402impl TextMeasurer for WasmHostTextMeasurer {
403 fn measure(&self, text: &str, style: &TextStyle) -> TextMetrics {
404 self.call_host(text, style, None, WrapMode::SvgLike)
405 .unwrap_or_else(|| self.fallback.measure(text, style))
406 }
407
408 fn measure_wrapped(
409 &self,
410 text: &str,
411 style: &TextStyle,
412 max_width: Option<f64>,
413 wrap_mode: WrapMode,
414 ) -> TextMetrics {
415 self.measure_with_fallback(text, style, max_width, wrap_mode)
416 }
417
418 fn measure_wrapped_with_raw_width(
419 &self,
420 text: &str,
421 style: &TextStyle,
422 max_width: Option<f64>,
423 wrap_mode: WrapMode,
424 ) -> (TextMetrics, Option<f64>) {
425 if let Some(metrics) = self.call_host(text, style, max_width, wrap_mode) {
426 let raw_width = max_width
427 .and_then(|_| self.call_host(text, style, None, wrap_mode))
428 .map(|raw| raw.width);
429 return (metrics, raw_width);
430 }
431 self.fallback
432 .measure_wrapped_with_raw_width(text, style, max_width, wrap_mode)
433 }
434
435 fn measure_wrapped_raw(
436 &self,
437 text: &str,
438 style: &TextStyle,
439 max_width: Option<f64>,
440 wrap_mode: WrapMode,
441 ) -> TextMetrics {
442 self.call_host(text, style, max_width, wrap_mode)
443 .unwrap_or_else(|| {
444 self.fallback
445 .measure_wrapped_raw(text, style, max_width, wrap_mode)
446 })
447 }
448}
449
450#[cfg(all(feature = "render", target_arch = "wasm32"))]
451struct HostTextMeasureCallbackGuard {
452 previous_callback: Option<js_sys::Function>,
453 previous_error: Option<JsValue>,
454}
455
456#[cfg(all(feature = "render", target_arch = "wasm32"))]
457impl Drop for HostTextMeasureCallbackGuard {
458 fn drop(&mut self) {
459 HOST_TEXT_MEASURE_CALLBACK.with(|slot| {
460 slot.replace(self.previous_callback.take());
461 });
462 HOST_TEXT_MEASURE_ERROR.with(|slot| {
463 slot.replace(self.previous_error.take());
464 });
465 }
466}
467
468#[cfg(all(feature = "render", target_arch = "wasm32"))]
469fn with_host_text_measure_callback<R>(callback: js_sys::Function, f: impl FnOnce() -> R) -> R {
470 let previous_callback = HOST_TEXT_MEASURE_CALLBACK.with(|slot| slot.replace(Some(callback)));
471 let previous_error = HOST_TEXT_MEASURE_ERROR.with(|slot| slot.replace(None));
472 let _guard = HostTextMeasureCallbackGuard {
473 previous_callback,
474 previous_error,
475 };
476 f()
477}
478
479#[cfg(all(feature = "render", target_arch = "wasm32"))]
480fn record_host_text_measure_error(err: JsValue) {
481 HOST_TEXT_MEASURE_ERROR.with(|slot| {
482 if slot.borrow().is_none() {
483 slot.replace(Some(err));
484 }
485 });
486}
487
488#[cfg(all(feature = "render", target_arch = "wasm32"))]
489fn take_host_text_measure_error() -> Option<JsValue> {
490 HOST_TEXT_MEASURE_ERROR.with(|slot| slot.replace(None))
491}
492
493#[cfg(all(feature = "render", target_arch = "wasm32"))]
494fn host_text_measure_result<T>(result: Result<T, JsValue>) -> Result<T, JsValue> {
495 if let Some(err) = take_host_text_measure_error() {
496 Err(err)
497 } else {
498 result
499 }
500}
501
502#[cfg(all(feature = "render", target_arch = "wasm32"))]
503fn wasm_wrap_mode(wrap_mode: WrapMode) -> &'static str {
504 match wrap_mode {
505 WrapMode::SvgLike => "svg-like",
506 WrapMode::SvgLikeSingleRun => "svg-like-single-run",
507 WrapMode::HtmlLike => "html-like",
508 }
509}
510
511#[cfg(all(feature = "render", target_arch = "wasm32"))]
512fn wasm_line_height(style: &TextStyle, wrap_mode: WrapMode) -> f64 {
513 let factor = match wrap_mode {
514 WrapMode::SvgLike | WrapMode::SvgLikeSingleRun => 1.1,
515 WrapMode::HtmlLike => 1.5,
516 };
517 style.font_size.max(1.0) * factor
518}
519
520#[cfg(all(feature = "render", target_arch = "wasm32"))]
521fn wasm_white_space(max_width: Option<f64>, wrap_mode: WrapMode) -> &'static str {
522 match wrap_mode {
523 WrapMode::HtmlLike if max_width.is_some() => "break-spaces",
524 WrapMode::HtmlLike => "nowrap",
525 WrapMode::SvgLike | WrapMode::SvgLikeSingleRun => "normal",
526 }
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532 #[cfg(feature = "analysis")]
533 use serde_json::Value;
534
535 #[test]
536 fn package_version_matches_crate_version() {
537 assert_eq!(package_version(), env!("CARGO_PKG_VERSION"));
538 }
539
540 #[test]
541 fn render_svg_impl_returns_svg() {
542 let result = merman_bindings_core::render_svg(b"flowchart TD\nA[Hello] --> B[World]", b"");
543
544 if cfg!(feature = "render") {
545 let svg = string_result(result).unwrap();
546 assert!(svg.contains("<svg"));
547 assert!(svg.contains("Hello"));
548 } else {
549 let error = result.unwrap_err();
550 assert_eq!(
551 error.status(),
552 merman_bindings_core::BindingStatus::UnsupportedFormat
553 );
554 }
555 }
556
557 #[cfg(feature = "analysis")]
558 #[test]
559 fn validation_error_uses_binding_status() {
560 let json: Value =
561 serde_json::from_slice(&merman_bindings_core::validate_json(b"", b"").unwrap())
562 .unwrap();
563
564 assert_eq!(json["valid"], false);
565 assert_eq!(json["code_name"], "MERMAN_NO_DIAGRAM");
566 assert!(
567 json["error"]
568 .as_str()
569 .unwrap()
570 .contains("no Mermaid diagram")
571 );
572 }
573
574 #[cfg(not(feature = "analysis"))]
575 #[test]
576 fn analysis_entry_points_report_missing_analysis_feature() {
577 let err = merman_bindings_core::validate_json(b"flowchart TD\nA", b"").unwrap_err();
578 assert_eq!(
579 err.status(),
580 merman_bindings_core::BindingStatus::UnsupportedFormat
581 );
582 assert!(err.message().contains("analysis feature"));
583 }
584
585 #[cfg(all(target_arch = "wasm32", feature = "analysis"))]
586 #[test]
587 fn analyze_json_exposes_diagnostics_payload() {
588 let value: Value = serde_wasm_bindgen::from_value(analyze_json("", None).unwrap()).unwrap();
589 assert_no_diagram_analysis_payload(&value);
590 }
591
592 #[cfg(all(not(target_arch = "wasm32"), feature = "analysis"))]
593 #[test]
594 fn analyze_json_exposes_diagnostics_payload() {
595 let value: Value =
596 serde_json::from_slice(&merman_bindings_core::analyze_json(b"", b"").unwrap()).unwrap();
597 assert_no_diagram_analysis_payload(&value);
598 }
599
600 #[cfg(feature = "analysis")]
601 fn assert_no_diagram_analysis_payload(value: &Value) {
602 assert_eq!(value["version"], 1);
603 assert_eq!(value["valid"], false);
604 assert_eq!(value["diagnostics"][0]["code_name"], "MERMAN_NO_DIAGRAM");
605 }
606
607 #[cfg(all(target_arch = "wasm32", feature = "analysis"))]
608 #[test]
609 fn analysis_facts_exposes_parser_backed_syntax_payload() {
610 let value: Value =
611 serde_wasm_bindgen::from_value(analysis_facts("flowchart TD\nA-->B\n", None).unwrap())
612 .unwrap();
613 assert_parser_backed_analysis_facts_payload(&value);
614 }
615
616 #[cfg(all(not(target_arch = "wasm32"), feature = "analysis"))]
617 #[test]
618 fn analysis_facts_exposes_parser_backed_syntax_payload() {
619 let value: Value = serde_json::from_slice(
620 &merman_bindings_core::analysis_facts_json(b"flowchart TD\nA-->B\n", b"").unwrap(),
621 )
622 .unwrap();
623 assert_parser_backed_analysis_facts_payload(&value);
624 }
625
626 #[cfg(feature = "analysis")]
627 fn assert_parser_backed_analysis_facts_payload(value: &Value) {
628 assert_eq!(value["valid"], true);
629 assert_eq!(
630 value["diagrams"][0]["syntax"]["fact_source"],
631 "parser_complete"
632 );
633 assert_eq!(value["diagrams"][0]["syntax"]["source_mapped_spans"], true);
634 assert!(
635 value["diagrams"][0]["syntax"]["semantic_items"]
636 .as_array()
637 .unwrap()
638 .iter()
639 .any(|item| item["name"] == "A" && item["span"]["document"].is_object())
640 );
641 }
642
643 #[cfg(all(target_arch = "wasm32", feature = "analysis"))]
644 #[test]
645 fn analyze_document_exposes_markdown_diagnostics_payload() {
646 let value: Value = serde_wasm_bindgen::from_value(
647 analyze_document(
648 "before\n```mermaid\nflowchart TD\nA-->\n```\nafter\n",
649 None,
650 Some("file:///tmp/example.md".to_string()),
651 )
652 .unwrap(),
653 )
654 .unwrap();
655 assert_markdown_document_analysis_payload(&value);
656 }
657
658 #[cfg(all(not(target_arch = "wasm32"), feature = "analysis"))]
659 #[test]
660 fn analyze_document_exposes_markdown_diagnostics_payload() {
661 let value: Value = serde_json::from_slice(
662 &merman_bindings_core::analyze_document_json(
663 b"before\n```mermaid\nflowchart TD\nA-->\n```\nafter\n",
664 b"",
665 b"file:///tmp/example.md",
666 )
667 .unwrap(),
668 )
669 .unwrap();
670 assert_markdown_document_analysis_payload(&value);
671 }
672
673 #[cfg(feature = "analysis")]
674 fn assert_markdown_document_analysis_payload(value: &Value) {
675 assert_eq!(value["valid"], false);
676 assert_eq!(value["source"]["kind"], "markdown");
677 assert_eq!(value["diagnostics"][0]["span"]["line"], 4);
678 assert!(
679 value["diagnostics"][0]["related"]
680 .as_array()
681 .unwrap()
682 .iter()
683 .any(|related| related["message"] == "Mermaid fence 1")
684 );
685 }
686
687 #[cfg(all(target_arch = "wasm32", feature = "analysis"))]
688 #[test]
689 fn analyze_document_facts_exposes_markdown_syntax_payload() {
690 let value: Value = serde_wasm_bindgen::from_value(
691 analyze_document_facts(
692 "before\n```mermaid\nflowchart TD\nA@{\n shape: rou\n}\n```\nafter\n",
693 None,
694 Some("file:///tmp/example.md".to_string()),
695 )
696 .unwrap(),
697 )
698 .unwrap();
699 assert_markdown_document_analysis_facts_payload(&value);
700 }
701
702 #[cfg(all(not(target_arch = "wasm32"), feature = "analysis"))]
703 #[test]
704 fn analyze_document_facts_exposes_markdown_syntax_payload() {
705 let value: Value = serde_json::from_slice(
706 &merman_bindings_core::analyze_document_facts_json(
707 b"before\n```mermaid\nflowchart TD\nA@{\n shape: rou\n}\n```\nafter\n",
708 b"",
709 b"file:///tmp/example.md",
710 )
711 .unwrap(),
712 )
713 .unwrap();
714 assert_markdown_document_analysis_facts_payload(&value);
715 }
716
717 #[cfg(feature = "analysis")]
718 fn assert_markdown_document_analysis_facts_payload(value: &Value) {
719 assert_eq!(value["valid"], false);
720 assert_eq!(value["source"]["kind"], "markdown");
721 assert_eq!(value["diagrams"][0]["source_id"], "mermaid-fence-1");
722 assert_eq!(value["diagrams"][0]["syntax"]["parser_backed"], true);
723 assert!(
724 value["diagrams"][0]["syntax"]["expected_syntax"]
725 .as_array()
726 .unwrap()
727 .iter()
728 .any(|expected| {
729 expected["kind"] == "shape" && expected["span"]["document"].is_object()
730 })
731 );
732 }
733
734 #[test]
735 fn wasm_error_payload_is_structured() {
736 let err = merman_bindings_core::render_svg(b"flowchart TD\nA", b"{").unwrap_err();
737 let json = serde_json::to_value(wasm_error_payload(&err)).unwrap();
738
739 assert_eq!(json["version"], 1);
740 assert_eq!(json["ok"], false);
741 if cfg!(feature = "render") {
742 assert_eq!(json["code_name"], "MERMAN_OPTIONS_JSON_ERROR");
743 assert!(json["message"].as_str().unwrap().contains("options_json"));
744 } else {
745 assert_eq!(json["code_name"], "MERMAN_UNSUPPORTED_FORMAT");
746 }
747 }
748
749 #[test]
750 fn binding_capabilities_follow_features() {
751 let capabilities = merman_bindings_core::binding_capabilities();
752
753 assert_eq!(capabilities.render, cfg!(feature = "render"));
754 assert_eq!(capabilities.analysis, cfg!(feature = "analysis"));
755 assert_eq!(capabilities.ascii, cfg!(feature = "ascii"));
756 assert_eq!(capabilities.core_full, cfg!(feature = "core-full"));
757 assert_eq!(capabilities.core_host, cfg!(feature = "core-host"));
758 assert_eq!(capabilities.elk_layout, cfg!(feature = "elk-layout"));
759 assert_eq!(capabilities.ratex_math, cfg!(feature = "ratex-math"));
760 assert_eq!(
761 capabilities.editor_language,
762 cfg!(feature = "editor-language")
763 );
764 }
765
766 #[test]
767 fn registry_profile_and_family_capabilities_are_exposed() {
768 let expected_profile = if cfg!(feature = "core-full") {
769 "full"
770 } else {
771 "tiny"
772 };
773 assert_eq!(selected_registry_profile(), expected_profile);
774
775 let capabilities = merman_bindings_core::diagram_family_capabilities();
776 assert!(
777 capabilities
778 .iter()
779 .any(|capability| capability.diagram_type == "flowchart"
780 && capability.has_semantic_parser
781 && capability.has_render_parser)
782 );
783 assert_eq!(
784 capabilities
785 .iter()
786 .any(|capability| capability.diagram_type == "mindmap"),
787 cfg!(feature = "core-full")
788 );
789 }
790
791 #[cfg(feature = "ascii")]
792 #[test]
793 fn render_ascii_impl_returns_text() {
794 let text = string_result(merman_bindings_core::render_ascii(
795 b"flowchart TD\nA[Hello] --> B[World]",
796 b"",
797 ))
798 .unwrap();
799
800 assert!(text.contains("Hello"));
801 assert!(text.contains("World"));
802 }
803}