Skip to main content

merman_wasm/
lib.rs

1#![forbid(unsafe_code)]
2
3//! WebAssembly bindings for browser integrations.
4//!
5//! The crate intentionally stays thin: all parsing, rendering, options parsing, and error
6//! classification are delegated to `merman-bindings-core`.
7
8use 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}