1#![deny(unsafe_op_in_unsafe_fn)]
2
3#[cfg(feature = "render")]
9use merman_bindings_core::TextMeasurer;
10use merman_bindings_core::{BindingEngine, BindingError, BindingStatus, error_payload_json_bytes};
11use std::ffi::c_char;
12use std::panic::{AssertUnwindSafe, catch_unwind};
13use std::ptr::{self, NonNull};
14#[cfg(feature = "render")]
15use std::sync::Arc;
16use std::sync::Mutex;
17
18#[cfg(target_os = "android")]
19mod android_jni;
20
21pub const MERMAN_ABI_VERSION: u32 = 2;
22
23const PACKAGE_VERSION: &[u8] = concat!(env!("CARGO_PKG_VERSION"), "\0").as_bytes();
24
25pub const MERMAN_WRAP_MODE_SVG_LIKE: i32 = 0;
26pub const MERMAN_WRAP_MODE_SVG_LIKE_SINGLE_RUN: i32 = 1;
27pub const MERMAN_WRAP_MODE_HTML_LIKE: i32 = 2;
28
29pub const MERMAN_TEXT_DIRECTION_AUTO: i32 = 0;
30pub const MERMAN_TEXT_DIRECTION_LTR: i32 = 1;
31pub const MERMAN_TEXT_DIRECTION_RTL: i32 = 2;
32
33pub const MERMAN_TEXT_WHITE_SPACE_NORMAL: i32 = 0;
34pub const MERMAN_TEXT_WHITE_SPACE_NOWRAP: i32 = 1;
35pub const MERMAN_TEXT_WHITE_SPACE_BREAK_SPACES: i32 = 2;
36pub const MERMAN_TEXT_WHITE_SPACE_PRE_WRAP: i32 = 3;
37
38#[repr(C)]
39#[derive(Debug, Clone, Copy)]
40pub struct MermanBuffer {
41 pub data: *mut u8,
42 pub len: usize,
43}
44
45impl MermanBuffer {
46 const fn empty() -> Self {
47 Self {
48 data: ptr::null_mut(),
49 len: 0,
50 }
51 }
52}
53
54#[repr(C)]
55#[derive(Debug, Clone, Copy)]
56pub struct MermanResult {
57 pub code: i32,
58 pub data: MermanBuffer,
59}
60
61pub struct MermanEngine {
62 #[cfg(feature = "render")]
63 base: BindingEngine,
64 inner: BindingEngine,
65 lifecycle: Mutex<FfiEngineLifecycle>,
66}
67
68#[derive(Debug, Default)]
69struct FfiEngineLifecycle {
70 active_calls: usize,
71 active_mutations: usize,
72 free_requested: bool,
73}
74
75struct FfiEngineCallGuard {
76 engine: NonNull<MermanEngine>,
77}
78
79#[cfg(feature = "render")]
80struct FfiEngineMutationGuard {
81 engine: NonNull<MermanEngine>,
82}
83
84impl Drop for FfiEngineCallGuard {
85 fn drop(&mut self) {
86 unsafe {
87 release_engine_call(self.engine);
88 }
89 }
90}
91
92#[cfg(feature = "render")]
93impl Drop for FfiEngineMutationGuard {
94 fn drop(&mut self) {
95 unsafe {
96 release_engine_mutation(self.engine);
97 }
98 }
99}
100
101#[repr(C)]
102#[derive(Debug, Clone, Copy)]
103pub struct MermanEngineResult {
104 pub code: i32,
105 pub engine: *mut MermanEngine,
106 pub data: MermanBuffer,
107}
108
109#[repr(C)]
110#[derive(Debug, Clone, Copy)]
111pub struct MermanHostTextMeasureRequest {
112 pub text: *const u8,
113 pub text_len: usize,
114 pub font_family: *const u8,
115 pub font_family_len: usize,
116 pub font_size: f64,
117 pub font_weight: *const u8,
118 pub font_weight_len: usize,
119 pub font_style: *const u8,
120 pub font_style_len: usize,
121 pub max_width: f64,
122 pub line_height: f64,
123 pub letter_spacing: f64,
124 pub word_spacing: f64,
125 pub wrap_mode: i32,
126 pub direction: i32,
127 pub white_space: i32,
128 pub has_max_width: u8,
129}
130
131#[repr(C)]
132#[derive(Debug, Clone, Copy)]
133pub struct MermanHostTextMeasureResult {
134 pub handled: u8,
135 pub width: f64,
136 pub height: f64,
137 pub line_count: usize,
138}
139
140pub type MermanHostTextMeasureCallback = unsafe extern "C" fn(
141 request: MermanHostTextMeasureRequest,
142 user_data: *mut std::ffi::c_void,
143) -> MermanHostTextMeasureResult;
144
145#[cfg(feature = "render")]
146#[derive(Clone)]
147struct FfiHostTextMeasurer {
148 callback: MermanHostTextMeasureCallback,
149 user_data: usize,
150 fallback: merman_bindings_core::VendoredFontMetricsTextMeasurer,
151}
152
153#[cfg(feature = "render")]
154impl FfiHostTextMeasurer {
155 const DEFAULT_FONT_STYLE: &'static [u8] = b"normal";
156 const DEFAULT_FONT_WEIGHT: &'static [u8] = b"normal";
157
158 fn new(callback: MermanHostTextMeasureCallback, user_data: *mut std::ffi::c_void) -> Self {
159 Self {
160 callback,
161 user_data: user_data as usize,
162 fallback: merman_bindings_core::VendoredFontMetricsTextMeasurer::default(),
163 }
164 }
165
166 fn call_host(
167 &self,
168 text: &str,
169 style: &merman_bindings_core::TextStyle,
170 max_width: Option<f64>,
171 wrap_mode: merman_bindings_core::WrapMode,
172 ) -> Option<merman_bindings_core::TextMetrics> {
173 let font_family = style.font_family.as_deref().unwrap_or_default().as_bytes();
174 let font_weight = style
175 .font_weight
176 .as_deref()
177 .map(str::as_bytes)
178 .unwrap_or(Self::DEFAULT_FONT_WEIGHT);
179 let font_style = Self::DEFAULT_FONT_STYLE;
180 let result = unsafe {
181 (self.callback)(
182 MermanHostTextMeasureRequest {
183 text: text.as_ptr(),
184 text_len: text.len(),
185 font_family: font_family.as_ptr(),
186 font_family_len: font_family.len(),
187 font_size: style.font_size,
188 font_weight: font_weight.as_ptr(),
189 font_weight_len: font_weight.len(),
190 font_style: font_style.as_ptr(),
191 font_style_len: font_style.len(),
192 max_width: max_width.unwrap_or(0.0),
193 line_height: ffi_line_height(style, wrap_mode),
194 letter_spacing: 0.0,
195 word_spacing: 0.0,
196 wrap_mode: ffi_wrap_mode(wrap_mode),
197 direction: MERMAN_TEXT_DIRECTION_AUTO,
198 white_space: ffi_white_space(max_width, wrap_mode),
199 has_max_width: u8::from(max_width.is_some()),
200 },
201 self.user_data as *mut std::ffi::c_void,
202 )
203 };
204
205 if result.handled == 0
206 || !result.width.is_finite()
207 || !result.height.is_finite()
208 || result.width < 0.0
209 || result.height < 0.0
210 || result.line_count == 0
211 {
212 return None;
213 }
214
215 Some(merman_bindings_core::TextMetrics {
216 width: result.width,
217 height: result.height,
218 line_count: result.line_count,
219 })
220 }
221
222 fn measure_with_fallback(
223 &self,
224 text: &str,
225 style: &merman_bindings_core::TextStyle,
226 max_width: Option<f64>,
227 wrap_mode: merman_bindings_core::WrapMode,
228 ) -> merman_bindings_core::TextMetrics {
229 self.call_host(text, style, max_width, wrap_mode)
230 .unwrap_or_else(|| {
231 self.fallback
232 .measure_wrapped(text, style, max_width, wrap_mode)
233 })
234 }
235}
236
237#[cfg(feature = "render")]
238impl merman_bindings_core::TextMeasurer for FfiHostTextMeasurer {
239 fn measure(
240 &self,
241 text: &str,
242 style: &merman_bindings_core::TextStyle,
243 ) -> merman_bindings_core::TextMetrics {
244 self.call_host(text, style, None, merman_bindings_core::WrapMode::SvgLike)
245 .unwrap_or_else(|| self.fallback.measure(text, style))
246 }
247
248 fn measure_wrapped(
249 &self,
250 text: &str,
251 style: &merman_bindings_core::TextStyle,
252 max_width: Option<f64>,
253 wrap_mode: merman_bindings_core::WrapMode,
254 ) -> merman_bindings_core::TextMetrics {
255 self.measure_with_fallback(text, style, max_width, wrap_mode)
256 }
257
258 fn measure_wrapped_with_raw_width(
259 &self,
260 text: &str,
261 style: &merman_bindings_core::TextStyle,
262 max_width: Option<f64>,
263 wrap_mode: merman_bindings_core::WrapMode,
264 ) -> (merman_bindings_core::TextMetrics, Option<f64>) {
265 if let Some(metrics) = self.call_host(text, style, max_width, wrap_mode) {
266 let raw_width = max_width
267 .and_then(|_| self.call_host(text, style, None, wrap_mode))
268 .map(|raw| raw.width);
269 return (metrics, raw_width);
270 }
271 self.fallback
272 .measure_wrapped_with_raw_width(text, style, max_width, wrap_mode)
273 }
274
275 fn measure_wrapped_raw(
276 &self,
277 text: &str,
278 style: &merman_bindings_core::TextStyle,
279 max_width: Option<f64>,
280 wrap_mode: merman_bindings_core::WrapMode,
281 ) -> merman_bindings_core::TextMetrics {
282 self.call_host(text, style, max_width, wrap_mode)
283 .unwrap_or_else(|| {
284 self.fallback
285 .measure_wrapped_raw(text, style, max_width, wrap_mode)
286 })
287 }
288}
289
290#[cfg(feature = "render")]
291fn ffi_wrap_mode(wrap_mode: merman_bindings_core::WrapMode) -> i32 {
292 match wrap_mode {
293 merman_bindings_core::WrapMode::SvgLike => MERMAN_WRAP_MODE_SVG_LIKE,
294 merman_bindings_core::WrapMode::SvgLikeSingleRun => MERMAN_WRAP_MODE_SVG_LIKE_SINGLE_RUN,
295 merman_bindings_core::WrapMode::HtmlLike => MERMAN_WRAP_MODE_HTML_LIKE,
296 }
297}
298
299#[cfg(feature = "render")]
300fn ffi_line_height(
301 style: &merman_bindings_core::TextStyle,
302 wrap_mode: merman_bindings_core::WrapMode,
303) -> f64 {
304 let factor = match wrap_mode {
305 merman_bindings_core::WrapMode::SvgLike
306 | merman_bindings_core::WrapMode::SvgLikeSingleRun => 1.1,
307 merman_bindings_core::WrapMode::HtmlLike => 1.5,
308 };
309 style.font_size.max(1.0) * factor
310}
311
312#[cfg(feature = "render")]
313fn ffi_white_space(max_width: Option<f64>, wrap_mode: merman_bindings_core::WrapMode) -> i32 {
314 match wrap_mode {
315 merman_bindings_core::WrapMode::HtmlLike if max_width.is_some() => {
316 MERMAN_TEXT_WHITE_SPACE_BREAK_SPACES
317 }
318 merman_bindings_core::WrapMode::HtmlLike => MERMAN_TEXT_WHITE_SPACE_NOWRAP,
319 merman_bindings_core::WrapMode::SvgLike
320 | merman_bindings_core::WrapMode::SvgLikeSingleRun => MERMAN_TEXT_WHITE_SPACE_NORMAL,
321 }
322}
323
324#[unsafe(no_mangle)]
326pub extern "C" fn merman_abi_version() -> u32 {
327 MERMAN_ABI_VERSION
328}
329
330#[unsafe(no_mangle)]
332pub extern "C" fn merman_package_version() -> *const c_char {
333 PACKAGE_VERSION.as_ptr().cast()
334}
335
336#[unsafe(no_mangle)]
338pub extern "C" fn merman_buffer_struct_size() -> usize {
339 std::mem::size_of::<MermanBuffer>()
340}
341
342#[unsafe(no_mangle)]
344pub extern "C" fn merman_result_struct_size() -> usize {
345 std::mem::size_of::<MermanResult>()
346}
347
348#[unsafe(no_mangle)]
350pub extern "C" fn merman_engine_result_struct_size() -> usize {
351 std::mem::size_of::<MermanEngineResult>()
352}
353
354#[unsafe(no_mangle)]
356pub extern "C" fn merman_host_text_measure_request_struct_size() -> usize {
357 std::mem::size_of::<MermanHostTextMeasureRequest>()
358}
359
360#[unsafe(no_mangle)]
362pub extern "C" fn merman_host_text_measure_result_struct_size() -> usize {
363 std::mem::size_of::<MermanHostTextMeasureResult>()
364}
365
366#[unsafe(no_mangle)]
374pub unsafe extern "C" fn merman_engine_new(
375 options_json: *const u8,
376 options_len: usize,
377) -> MermanEngineResult {
378 ffi_engine_result(|| unsafe { engine_new_impl(options_json, options_len) })
379}
380
381#[unsafe(no_mangle)]
391pub unsafe extern "C" fn merman_engine_free(engine: *mut MermanEngine) {
392 if engine.is_null() {
393 return;
394 }
395 unsafe {
396 request_engine_free(engine);
397 }
398}
399
400#[unsafe(no_mangle)]
412pub unsafe extern "C" fn merman_engine_set_text_measure_callback(
413 engine: *mut MermanEngine,
414 callback: Option<MermanHostTextMeasureCallback>,
415 user_data: *mut std::ffi::c_void,
416) -> MermanResult {
417 ffi_result(|| unsafe { engine_set_text_measure_callback_impl(engine, callback, user_data) })
418}
419
420#[unsafe(no_mangle)]
429pub unsafe extern "C" fn merman_engine_render_svg(
430 engine: *const MermanEngine,
431 source: *const u8,
432 source_len: usize,
433) -> MermanResult {
434 ffi_result(|| unsafe {
435 ffi_engine_source_call(engine, source, source_len, BindingEngine::render_svg)
436 })
437}
438
439#[unsafe(no_mangle)]
445pub unsafe extern "C" fn merman_engine_render_ascii(
446 engine: *const MermanEngine,
447 source: *const u8,
448 source_len: usize,
449) -> MermanResult {
450 ffi_result(|| unsafe {
451 ffi_engine_source_call(engine, source, source_len, BindingEngine::render_ascii)
452 })
453}
454
455#[unsafe(no_mangle)]
461pub unsafe extern "C" fn merman_engine_parse_json(
462 engine: *const MermanEngine,
463 source: *const u8,
464 source_len: usize,
465) -> MermanResult {
466 ffi_result(|| unsafe {
467 ffi_engine_source_call(engine, source, source_len, BindingEngine::parse_json)
468 })
469}
470
471#[unsafe(no_mangle)]
477pub unsafe extern "C" fn merman_engine_layout_json(
478 engine: *const MermanEngine,
479 source: *const u8,
480 source_len: usize,
481) -> MermanResult {
482 ffi_result(|| unsafe {
483 ffi_engine_source_call(engine, source, source_len, BindingEngine::layout_json)
484 })
485}
486
487#[unsafe(no_mangle)]
493pub unsafe extern "C" fn merman_engine_analyze_json(
494 engine: *const MermanEngine,
495 source: *const u8,
496 source_len: usize,
497) -> MermanResult {
498 ffi_result(|| unsafe {
499 ffi_engine_source_call(engine, source, source_len, BindingEngine::analyze_json)
500 })
501}
502
503#[unsafe(no_mangle)]
512pub unsafe extern "C" fn merman_engine_analyze_document_json(
513 engine: *const MermanEngine,
514 source: *const u8,
515 source_len: usize,
516 uri: *const u8,
517 uri_len: usize,
518) -> MermanResult {
519 ffi_result(|| unsafe {
520 ffi_engine_source_uri_call(
521 engine,
522 source,
523 source_len,
524 uri,
525 uri_len,
526 BindingEngine::analyze_document_json,
527 )
528 })
529}
530
531#[unsafe(no_mangle)]
537pub unsafe extern "C" fn merman_engine_analyze_document_facts_json(
538 engine: *const MermanEngine,
539 source: *const u8,
540 source_len: usize,
541 uri: *const u8,
542 uri_len: usize,
543) -> MermanResult {
544 ffi_result(|| unsafe {
545 ffi_engine_source_uri_call(
546 engine,
547 source,
548 source_len,
549 uri,
550 uri_len,
551 BindingEngine::analyze_document_facts_json,
552 )
553 })
554}
555
556#[unsafe(no_mangle)]
562pub unsafe extern "C" fn merman_engine_validate_json(
563 engine: *const MermanEngine,
564 source: *const u8,
565 source_len: usize,
566) -> MermanResult {
567 ffi_result(|| unsafe {
568 ffi_engine_source_call(engine, source, source_len, BindingEngine::validate_json)
569 })
570}
571
572#[unsafe(no_mangle)]
581pub unsafe extern "C" fn merman_analyze_json(
582 source: *const u8,
583 source_len: usize,
584 options_json: *const u8,
585 options_len: usize,
586) -> MermanResult {
587 ffi_result(|| unsafe {
588 ffi_source_options_call(
589 source,
590 source_len,
591 options_json,
592 options_len,
593 merman_bindings_core::analyze_json,
594 )
595 })
596}
597
598#[unsafe(no_mangle)]
607pub unsafe extern "C" fn merman_analyze_document_json(
608 source: *const u8,
609 source_len: usize,
610 options_json: *const u8,
611 options_len: usize,
612 uri: *const u8,
613 uri_len: usize,
614) -> MermanResult {
615 ffi_result(|| unsafe {
616 ffi_source_options_uri_call(
617 source,
618 source_len,
619 options_json,
620 options_len,
621 uri,
622 uri_len,
623 merman_bindings_core::analyze_document_json,
624 )
625 })
626}
627
628#[unsafe(no_mangle)]
634pub unsafe extern "C" fn merman_analyze_document_facts_json(
635 source: *const u8,
636 source_len: usize,
637 options_json: *const u8,
638 options_len: usize,
639 uri: *const u8,
640 uri_len: usize,
641) -> MermanResult {
642 ffi_result(|| unsafe {
643 ffi_source_options_uri_call(
644 source,
645 source_len,
646 options_json,
647 options_len,
648 uri,
649 uri_len,
650 merman_bindings_core::analyze_document_facts_json,
651 )
652 })
653}
654
655#[unsafe(no_mangle)]
664pub unsafe extern "C" fn merman_render_svg(
665 source: *const u8,
666 source_len: usize,
667 options_json: *const u8,
668 options_len: usize,
669) -> MermanResult {
670 ffi_result(|| unsafe {
671 ffi_source_options_call(
672 source,
673 source_len,
674 options_json,
675 options_len,
676 merman_bindings_core::render_svg,
677 )
678 })
679}
680
681#[unsafe(no_mangle)]
690pub unsafe extern "C" fn merman_render_ascii(
691 source: *const u8,
692 source_len: usize,
693 options_json: *const u8,
694 options_len: usize,
695) -> MermanResult {
696 ffi_result(|| unsafe {
697 ffi_source_options_call(
698 source,
699 source_len,
700 options_json,
701 options_len,
702 merman_bindings_core::render_ascii,
703 )
704 })
705}
706
707#[unsafe(no_mangle)]
716pub unsafe extern "C" fn merman_parse_json(
717 source: *const u8,
718 source_len: usize,
719 options_json: *const u8,
720 options_len: usize,
721) -> MermanResult {
722 ffi_result(|| unsafe {
723 ffi_source_options_call(
724 source,
725 source_len,
726 options_json,
727 options_len,
728 merman_bindings_core::parse_json,
729 )
730 })
731}
732
733#[unsafe(no_mangle)]
742pub unsafe extern "C" fn merman_layout_json(
743 source: *const u8,
744 source_len: usize,
745 options_json: *const u8,
746 options_len: usize,
747) -> MermanResult {
748 ffi_result(|| unsafe {
749 ffi_source_options_call(
750 source,
751 source_len,
752 options_json,
753 options_len,
754 merman_bindings_core::layout_json,
755 )
756 })
757}
758
759#[unsafe(no_mangle)]
768pub unsafe extern "C" fn merman_validate_json(
769 source: *const u8,
770 source_len: usize,
771 options_json: *const u8,
772 options_len: usize,
773) -> MermanResult {
774 ffi_result(|| unsafe {
775 ffi_source_options_call(
776 source,
777 source_len,
778 options_json,
779 options_len,
780 merman_bindings_core::validate_json,
781 )
782 })
783}
784
785#[unsafe(no_mangle)]
787pub extern "C" fn merman_supported_diagrams_json() -> MermanResult {
788 ffi_result(merman_bindings_core::supported_diagrams_json)
789}
790
791#[unsafe(no_mangle)]
793pub extern "C" fn merman_ascii_capabilities_json() -> MermanResult {
794 ffi_result(merman_bindings_core::ascii_capabilities_json)
795}
796
797#[unsafe(no_mangle)]
799pub extern "C" fn merman_diagram_family_capabilities_json() -> MermanResult {
800 ffi_result(merman_bindings_core::diagram_family_capabilities_json)
801}
802
803#[unsafe(no_mangle)]
805pub extern "C" fn merman_lint_rule_catalog_json() -> MermanResult {
806 ffi_result(merman_bindings_core::lint_rule_catalog_json)
807}
808
809#[unsafe(no_mangle)]
811pub extern "C" fn merman_supported_themes_json() -> MermanResult {
812 ffi_result(merman_bindings_core::supported_themes_json)
813}
814
815#[unsafe(no_mangle)]
817pub extern "C" fn merman_supported_host_theme_presets_json() -> MermanResult {
818 ffi_result(merman_bindings_core::supported_host_theme_presets_json)
819}
820
821#[unsafe(no_mangle)]
829pub unsafe extern "C" fn merman_buffer_free(buffer: MermanBuffer) {
830 if buffer.data.is_null() || buffer.len == 0 {
831 return;
832 }
833
834 let raw = ptr::slice_from_raw_parts_mut(buffer.data, buffer.len);
835 unsafe {
836 drop(Box::from_raw(raw));
837 }
838}
839
840fn ffi_result<F>(f: F) -> MermanResult
841where
842 F: FnOnce() -> Result<Vec<u8>, BindingError>,
843{
844 match catch_unwind(AssertUnwindSafe(f)) {
845 Ok(Ok(bytes)) => MermanResult {
846 code: BindingStatus::Ok.code(),
847 data: buffer_from_vec(bytes),
848 },
849 Ok(Err(err)) => error_result(err.status(), err.message()),
850 Err(_) => error_result(BindingStatus::Panic, "panic caught at merman FFI boundary"),
851 }
852}
853
854fn ffi_engine_result<F>(f: F) -> MermanEngineResult
855where
856 F: FnOnce() -> Result<BindingEngine, BindingError>,
857{
858 match catch_unwind(AssertUnwindSafe(f)) {
859 Ok(Ok(inner)) => MermanEngineResult {
860 code: BindingStatus::Ok.code(),
861 engine: Box::into_raw(Box::new(MermanEngine {
862 #[cfg(feature = "render")]
863 base: inner.clone(),
864 inner,
865 lifecycle: Mutex::new(FfiEngineLifecycle::default()),
866 })),
867 data: MermanBuffer::empty(),
868 },
869 Ok(Err(err)) => MermanEngineResult {
870 code: err.status().code(),
871 engine: ptr::null_mut(),
872 data: buffer_from_vec(error_payload_json_bytes(err.status(), err.message())),
873 },
874 Err(_) => MermanEngineResult {
875 code: BindingStatus::Panic.code(),
876 engine: ptr::null_mut(),
877 data: buffer_from_vec(error_payload_json_bytes(
878 BindingStatus::Panic,
879 "panic caught at merman FFI boundary",
880 )),
881 },
882 }
883}
884
885unsafe fn engine_new_impl(
886 options_json: *const u8,
887 options_len: usize,
888) -> Result<BindingEngine, BindingError> {
889 let options_bytes = unsafe { raw_bytes(options_json, options_len, "options_json")? };
890 BindingEngine::new(options_bytes)
891}
892
893#[cfg(feature = "render")]
894unsafe fn engine_mut<'a>(engine: *mut MermanEngine) -> Result<&'a mut MermanEngine, BindingError> {
895 if engine.is_null() {
896 return Err(BindingError::new(
897 BindingStatus::InvalidArgument,
898 "engine pointer is null",
899 ));
900 }
901 Ok(unsafe { &mut *engine })
902}
903
904unsafe fn engine_ref<'a>(engine: *const MermanEngine) -> Result<&'a MermanEngine, BindingError> {
905 if engine.is_null() {
906 return Err(BindingError::new(
907 BindingStatus::InvalidArgument,
908 "engine pointer is null",
909 ));
910 }
911 Ok(unsafe { &*engine })
912}
913
914unsafe fn begin_engine_call(
915 engine: *const MermanEngine,
916) -> Result<FfiEngineCallGuard, BindingError> {
917 let ptr = NonNull::new(engine.cast_mut()).ok_or_else(|| {
918 BindingError::new(BindingStatus::InvalidArgument, "engine pointer is null")
919 })?;
920 let engine = unsafe { ptr.as_ref() };
921 let mut lifecycle = engine.lifecycle.lock().map_err(|_| {
922 BindingError::new(
923 BindingStatus::InternalError,
924 "engine lifecycle lock poisoned",
925 )
926 })?;
927 if lifecycle.free_requested {
928 return Err(BindingError::new(
929 BindingStatus::InvalidArgument,
930 "engine free was requested during an active call",
931 ));
932 }
933 if lifecycle.active_mutations > 0 {
934 return Err(BindingError::new(
935 BindingStatus::InvalidArgument,
936 "engine cannot be used during an active mutation",
937 ));
938 }
939 lifecycle.active_calls += 1;
940 Ok(FfiEngineCallGuard { engine: ptr })
941}
942
943#[cfg(feature = "render")]
944unsafe fn begin_engine_mutation(
945 engine: *mut MermanEngine,
946) -> Result<FfiEngineMutationGuard, BindingError> {
947 let ptr = NonNull::new(engine).ok_or_else(|| {
948 BindingError::new(BindingStatus::InvalidArgument, "engine pointer is null")
949 })?;
950 let engine = unsafe { ptr.as_ref() };
951 let mut lifecycle = engine.lifecycle.lock().map_err(|_| {
952 BindingError::new(
953 BindingStatus::InternalError,
954 "engine lifecycle lock poisoned",
955 )
956 })?;
957 if lifecycle.free_requested {
958 return Err(BindingError::new(
959 BindingStatus::InvalidArgument,
960 "engine free was requested during an active call",
961 ));
962 }
963 if lifecycle.active_calls > 0 || lifecycle.active_mutations > 0 {
964 return Err(BindingError::new(
965 BindingStatus::InvalidArgument,
966 "engine cannot be mutated during an active call",
967 ));
968 }
969 lifecycle.active_mutations += 1;
970 Ok(FfiEngineMutationGuard { engine: ptr })
971}
972
973unsafe fn release_engine_call(engine: NonNull<MermanEngine>) {
974 let should_free = unsafe { release_engine_lifecycle(engine, true) };
975 if should_free {
976 unsafe {
977 drop(Box::from_raw(engine.as_ptr()));
978 }
979 }
980}
981
982#[cfg(feature = "render")]
983unsafe fn release_engine_mutation(engine: NonNull<MermanEngine>) {
984 let should_free = unsafe { release_engine_lifecycle(engine, false) };
985 if should_free {
986 unsafe {
987 drop(Box::from_raw(engine.as_ptr()));
988 }
989 }
990}
991
992unsafe fn release_engine_lifecycle(engine: NonNull<MermanEngine>, call: bool) -> bool {
993 let engine_ref = unsafe { engine.as_ref() };
994 let Ok(mut lifecycle) = engine_ref.lifecycle.lock() else {
995 return false;
996 };
997 if call {
998 lifecycle.active_calls = lifecycle.active_calls.saturating_sub(1);
999 } else {
1000 lifecycle.active_mutations = lifecycle.active_mutations.saturating_sub(1);
1001 }
1002 lifecycle.free_requested && lifecycle.active_calls == 0 && lifecycle.active_mutations == 0
1003}
1004
1005unsafe fn request_engine_free(engine: *mut MermanEngine) {
1006 let Some(ptr) = NonNull::new(engine) else {
1007 return;
1008 };
1009 let should_free = {
1010 let engine_ref = unsafe { ptr.as_ref() };
1011 let Ok(mut lifecycle) = engine_ref.lifecycle.lock() else {
1012 return;
1013 };
1014 if lifecycle.active_calls > 0 || lifecycle.active_mutations > 0 {
1015 lifecycle.free_requested = true;
1016 false
1017 } else {
1018 true
1019 }
1020 };
1021 if should_free {
1022 unsafe {
1023 drop(Box::from_raw(ptr.as_ptr()));
1024 }
1025 }
1026}
1027
1028unsafe fn engine_set_text_measure_callback_impl(
1029 engine: *mut MermanEngine,
1030 callback: Option<MermanHostTextMeasureCallback>,
1031 user_data: *mut std::ffi::c_void,
1032) -> Result<Vec<u8>, BindingError> {
1033 #[cfg(not(feature = "render"))]
1034 {
1035 let _ = (engine, callback, user_data);
1036 return Err(BindingError::new(
1037 BindingStatus::UnsupportedFormat,
1038 "host text measurement requires the render feature",
1039 ));
1040 }
1041
1042 #[cfg(feature = "render")]
1043 {
1044 let _guard = unsafe { begin_engine_mutation(engine)? };
1045 let engine = unsafe { engine_mut(engine)? };
1046 if let Some(callback) = callback {
1047 let measurer = FfiHostTextMeasurer::new(callback, user_data);
1048 engine.inner = engine.inner.with_text_measurer(Arc::new(measurer));
1049 } else {
1050 engine.inner = engine.base.clone();
1051 }
1052 Ok(Vec::new())
1053 }
1054}
1055
1056unsafe fn ffi_engine_source_call<F>(
1057 engine: *const MermanEngine,
1058 source: *const u8,
1059 source_len: usize,
1060 f: F,
1061) -> Result<Vec<u8>, BindingError>
1062where
1063 F: FnOnce(&BindingEngine, &[u8]) -> Result<Vec<u8>, BindingError>,
1064{
1065 let _guard = unsafe { begin_engine_call(engine)? };
1066 let engine = unsafe { engine_ref(engine)? };
1067 let source_bytes = unsafe { raw_bytes(source, source_len, "source")? };
1068 f(&engine.inner, source_bytes)
1069}
1070
1071unsafe fn ffi_engine_source_uri_call<F>(
1072 engine: *const MermanEngine,
1073 source: *const u8,
1074 source_len: usize,
1075 uri: *const u8,
1076 uri_len: usize,
1077 f: F,
1078) -> Result<Vec<u8>, BindingError>
1079where
1080 F: FnOnce(&BindingEngine, &[u8], &[u8]) -> Result<Vec<u8>, BindingError>,
1081{
1082 let _guard = unsafe { begin_engine_call(engine)? };
1083 let engine = unsafe { engine_ref(engine)? };
1084 let source_bytes = unsafe { raw_bytes(source, source_len, "source")? };
1085 let uri_bytes = unsafe { raw_bytes(uri, uri_len, "uri")? };
1086 f(&engine.inner, source_bytes, uri_bytes)
1087}
1088
1089unsafe fn ffi_source_options_call<F>(
1090 source: *const u8,
1091 source_len: usize,
1092 options_json: *const u8,
1093 options_len: usize,
1094 f: F,
1095) -> Result<Vec<u8>, BindingError>
1096where
1097 F: FnOnce(&[u8], &[u8]) -> Result<Vec<u8>, BindingError>,
1098{
1099 let request = unsafe {
1100 FfiSourceOptionsRequest::from_raw(source, source_len, options_json, options_len)?
1101 };
1102 f(request.source, request.options_json)
1103}
1104
1105unsafe fn ffi_source_options_uri_call<F>(
1106 source: *const u8,
1107 source_len: usize,
1108 options_json: *const u8,
1109 options_len: usize,
1110 uri: *const u8,
1111 uri_len: usize,
1112 f: F,
1113) -> Result<Vec<u8>, BindingError>
1114where
1115 F: FnOnce(&[u8], &[u8], &[u8]) -> Result<Vec<u8>, BindingError>,
1116{
1117 let request = unsafe {
1118 FfiSourceOptionsUriRequest::from_raw(
1119 source,
1120 source_len,
1121 options_json,
1122 options_len,
1123 uri,
1124 uri_len,
1125 )?
1126 };
1127 f(request.source, request.options_json, request.uri)
1128}
1129
1130struct FfiSourceOptionsRequest<'a> {
1131 source: &'a [u8],
1132 options_json: &'a [u8],
1133}
1134
1135impl<'a> FfiSourceOptionsRequest<'a> {
1136 unsafe fn from_raw(
1137 source: *const u8,
1138 source_len: usize,
1139 options_json: *const u8,
1140 options_len: usize,
1141 ) -> Result<Self, BindingError> {
1142 let source = unsafe { raw_bytes(source, source_len, "source")? };
1143 let options_json = unsafe { raw_bytes(options_json, options_len, "options_json")? };
1144 Ok(Self {
1145 source,
1146 options_json,
1147 })
1148 }
1149}
1150
1151struct FfiSourceOptionsUriRequest<'a> {
1152 source: &'a [u8],
1153 options_json: &'a [u8],
1154 uri: &'a [u8],
1155}
1156
1157impl<'a> FfiSourceOptionsUriRequest<'a> {
1158 unsafe fn from_raw(
1159 source: *const u8,
1160 source_len: usize,
1161 options_json: *const u8,
1162 options_len: usize,
1163 uri: *const u8,
1164 uri_len: usize,
1165 ) -> Result<Self, BindingError> {
1166 let source = unsafe { raw_bytes(source, source_len, "source")? };
1167 let options_json = unsafe { raw_bytes(options_json, options_len, "options_json")? };
1168 let uri = unsafe { raw_bytes(uri, uri_len, "uri")? };
1169 Ok(Self {
1170 source,
1171 options_json,
1172 uri,
1173 })
1174 }
1175}
1176
1177unsafe fn raw_bytes<'a>(
1178 data: *const u8,
1179 len: usize,
1180 name: &'static str,
1181) -> Result<&'a [u8], BindingError> {
1182 if data.is_null() {
1183 if len == 0 {
1184 return Ok(&[]);
1185 }
1186 return Err(BindingError::new(
1187 BindingStatus::InvalidArgument,
1188 format!("{name} pointer is null but length is {len}"),
1189 ));
1190 }
1191
1192 if len == 0 {
1193 return Ok(&[]);
1194 }
1195
1196 Ok(unsafe { std::slice::from_raw_parts(data, len) })
1197}
1198
1199fn buffer_from_vec(bytes: Vec<u8>) -> MermanBuffer {
1200 if bytes.is_empty() {
1201 return MermanBuffer::empty();
1202 }
1203 let mut boxed = bytes.into_boxed_slice();
1204 let buffer = MermanBuffer {
1205 data: boxed.as_mut_ptr(),
1206 len: boxed.len(),
1207 };
1208 std::mem::forget(boxed);
1209 buffer
1210}
1211
1212fn error_result(status: BindingStatus, message: &str) -> MermanResult {
1213 MermanResult {
1214 code: status.code(),
1215 data: buffer_from_vec(error_payload_json_bytes(status, message)),
1216 }
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221 use super::*;
1222 use serde_json::Value;
1223 use std::ffi::CStr;
1224
1225 fn call_render(source: &[u8], options: &[u8]) -> MermanResult {
1226 unsafe {
1227 merman_render_svg(
1228 source.as_ptr(),
1229 source.len(),
1230 options.as_ptr(),
1231 options.len(),
1232 )
1233 }
1234 }
1235
1236 fn call_render_ascii(source: &[u8], options: &[u8]) -> MermanResult {
1237 unsafe {
1238 merman_render_ascii(
1239 source.as_ptr(),
1240 source.len(),
1241 options.as_ptr(),
1242 options.len(),
1243 )
1244 }
1245 }
1246
1247 fn call_parse(source: &[u8], options: &[u8]) -> MermanResult {
1248 unsafe {
1249 merman_parse_json(
1250 source.as_ptr(),
1251 source.len(),
1252 options.as_ptr(),
1253 options.len(),
1254 )
1255 }
1256 }
1257
1258 fn call_validate(source: &[u8], options: &[u8]) -> MermanResult {
1259 unsafe {
1260 merman_validate_json(
1261 source.as_ptr(),
1262 source.len(),
1263 options.as_ptr(),
1264 options.len(),
1265 )
1266 }
1267 }
1268
1269 fn call_analyze(source: &[u8], options: &[u8]) -> MermanResult {
1270 unsafe {
1271 merman_analyze_json(
1272 source.as_ptr(),
1273 source.len(),
1274 options.as_ptr(),
1275 options.len(),
1276 )
1277 }
1278 }
1279
1280 fn call_analyze_document(source: &[u8], options: &[u8], uri: &[u8]) -> MermanResult {
1281 unsafe {
1282 merman_analyze_document_json(
1283 source.as_ptr(),
1284 source.len(),
1285 options.as_ptr(),
1286 options.len(),
1287 uri.as_ptr(),
1288 uri.len(),
1289 )
1290 }
1291 }
1292
1293 fn call_analyze_document_facts(source: &[u8], options: &[u8], uri: &[u8]) -> MermanResult {
1294 unsafe {
1295 merman_analyze_document_facts_json(
1296 source.as_ptr(),
1297 source.len(),
1298 options.as_ptr(),
1299 options.len(),
1300 uri.as_ptr(),
1301 uri.len(),
1302 )
1303 }
1304 }
1305
1306 fn call_layout(source: &[u8], options: &[u8]) -> MermanResult {
1307 unsafe {
1308 merman_layout_json(
1309 source.as_ptr(),
1310 source.len(),
1311 options.as_ptr(),
1312 options.len(),
1313 )
1314 }
1315 }
1316
1317 fn call_engine(options: &[u8]) -> MermanEngineResult {
1318 unsafe { merman_engine_new(options.as_ptr(), options.len()) }
1319 }
1320
1321 fn call_engine_render(engine: *const MermanEngine, source: &[u8]) -> MermanResult {
1322 unsafe { merman_engine_render_svg(engine, source.as_ptr(), source.len()) }
1323 }
1324
1325 fn call_engine_analyze_document(
1326 engine: *const MermanEngine,
1327 source: &[u8],
1328 uri: &[u8],
1329 ) -> MermanResult {
1330 unsafe {
1331 merman_engine_analyze_document_json(
1332 engine,
1333 source.as_ptr(),
1334 source.len(),
1335 uri.as_ptr(),
1336 uri.len(),
1337 )
1338 }
1339 }
1340
1341 fn take_buffer(buffer: MermanBuffer) -> Vec<u8> {
1342 if buffer.data.is_null() || buffer.len == 0 {
1343 return Vec::new();
1344 }
1345 let bytes = unsafe { std::slice::from_raw_parts(buffer.data, buffer.len).to_vec() };
1346 unsafe { merman_buffer_free(buffer) };
1347 bytes
1348 }
1349
1350 fn take_text(buffer: MermanBuffer) -> String {
1351 String::from_utf8(take_buffer(buffer)).expect("FFI output should be UTF-8")
1352 }
1353
1354 fn take_error(result: MermanResult) -> Value {
1355 serde_json::from_str(&take_text(result.data)).expect("error payload should be JSON")
1356 }
1357
1358 fn foreign_object_width_before_label(svg: &str, label: &str) -> f64 {
1359 let label_start = svg.find(label).expect("label text");
1360 let before_label = &svg[..label_start];
1361 let width_marker = r#"<foreignObject width=""#;
1362 let width_start = before_label
1363 .rfind(width_marker)
1364 .map(|idx| idx + width_marker.len())
1365 .expect("foreignObject width marker");
1366 let width_end = svg[width_start..]
1367 .find('"')
1368 .map(|idx| width_start + idx)
1369 .expect("foreignObject width end");
1370 svg[width_start..width_end]
1371 .parse::<f64>()
1372 .expect("foreignObject width number")
1373 }
1374
1375 fn expect_render_feature_error(result: MermanResult) {
1376 assert_eq!(result.code, BindingStatus::UnsupportedFormat.code());
1377 let error = take_error(result);
1378 assert_eq!(
1379 error["code_name"],
1380 BindingStatus::UnsupportedFormat.code_name()
1381 );
1382 assert!(
1383 error["message"]
1384 .as_str()
1385 .unwrap()
1386 .contains("render feature")
1387 );
1388 }
1389
1390 fn expect_analysis_feature_error(result: MermanResult) {
1391 assert_eq!(result.code, BindingStatus::UnsupportedFormat.code());
1392 let error = take_error(result);
1393 assert_eq!(
1394 error["code_name"],
1395 BindingStatus::UnsupportedFormat.code_name()
1396 );
1397 assert!(
1398 error["message"]
1399 .as_str()
1400 .unwrap()
1401 .contains("analysis feature")
1402 );
1403 }
1404
1405 #[test]
1406 fn abi_introspection_reports_contract_values() {
1407 assert_eq!(merman_abi_version(), MERMAN_ABI_VERSION);
1408 assert_eq!(
1409 merman_buffer_struct_size(),
1410 std::mem::size_of::<MermanBuffer>()
1411 );
1412 assert_eq!(
1413 merman_result_struct_size(),
1414 std::mem::size_of::<MermanResult>()
1415 );
1416 assert_eq!(
1417 merman_host_text_measure_request_struct_size(),
1418 std::mem::size_of::<MermanHostTextMeasureRequest>()
1419 );
1420 assert_eq!(
1421 merman_host_text_measure_result_struct_size(),
1422 std::mem::size_of::<MermanHostTextMeasureResult>()
1423 );
1424
1425 let version = unsafe { CStr::from_ptr(merman_package_version()) };
1426 assert_eq!(version.to_str().unwrap(), env!("CARGO_PKG_VERSION"));
1427 }
1428
1429 #[test]
1430 fn render_svg_returns_svg_for_flowchart() {
1431 let result = call_render(b"flowchart TD\nA[Hello] --> B[World]", b"");
1432
1433 if cfg!(feature = "render") {
1434 assert_eq!(result.code, BindingStatus::Ok.code());
1435 let svg = take_text(result.data);
1436 assert!(svg.contains("<svg"));
1437 assert!(svg.contains("Hello"));
1438 assert!(svg.contains("World"));
1439 } else {
1440 expect_render_feature_error(result);
1441 }
1442 }
1443
1444 #[test]
1445 fn render_svg_accepts_options_json() {
1446 let options = br#"{
1447 "layout": { "text_measurer": "deterministic", "viewport_width": 640, "viewport_height": 480 },
1448 "svg": { "diagram_id": "ffi diagram", "pipeline": "readable" }
1449 }"#;
1450 let result = call_render(b"flowchart TD\nA[Hello]", options);
1451
1452 if cfg!(feature = "render") {
1453 assert_eq!(result.code, BindingStatus::Ok.code());
1454 let svg = take_text(result.data);
1455 assert!(svg.contains("id=\"ffi-diagram\""));
1456 assert!(svg.contains("data-merman-foreignobject"));
1457 } else {
1458 expect_render_feature_error(result);
1459 }
1460 }
1461
1462 #[test]
1463 fn render_ascii_returns_text_or_feature_error() {
1464 let result = call_render_ascii(b"flowchart TD\nA[Hello] --> B[World]", b"");
1465
1466 if cfg!(feature = "ascii") {
1467 assert_eq!(result.code, BindingStatus::Ok.code());
1468 let text = take_text(result.data);
1469 assert!(text.contains("Hello"));
1470 assert!(text.contains("World"));
1471 } else {
1472 assert_eq!(result.code, BindingStatus::UnsupportedFormat.code());
1473 let error = take_error(result);
1474 assert_eq!(
1475 error["code_name"],
1476 BindingStatus::UnsupportedFormat.code_name()
1477 );
1478 }
1479 }
1480
1481 #[test]
1482 fn parse_json_returns_semantic_model() {
1483 let result = call_parse(b"flowchart TD\nA[Hello] --> B[World]", b"");
1484
1485 if cfg!(feature = "render") {
1486 assert_eq!(result.code, BindingStatus::Ok.code());
1487 let json: Value = serde_json::from_str(&take_text(result.data)).unwrap();
1488 assert!(json.is_object());
1489 assert_eq!(
1490 json.get("type").and_then(Value::as_str),
1491 Some("flowchart-v2")
1492 );
1493 assert!(json.get("nodes").and_then(Value::as_array).is_some());
1494 assert!(json.get("edges").and_then(Value::as_array).is_some());
1495 } else {
1496 expect_render_feature_error(result);
1497 }
1498 }
1499
1500 #[test]
1501 fn layout_json_returns_layouted_diagram() {
1502 let result = call_layout(b"flowchart TD\nA[Hello] --> B[World]", b"");
1503
1504 if cfg!(feature = "render") {
1505 assert_eq!(result.code, BindingStatus::Ok.code());
1506 let json: Value = serde_json::from_str(&take_text(result.data)).unwrap();
1507 assert!(json.get("meta").is_some());
1508 assert!(json.get("layout").is_some());
1509 } else {
1510 expect_render_feature_error(result);
1511 }
1512 }
1513
1514 #[test]
1515 fn validate_json_returns_status_payload() {
1516 let valid = call_validate(b"flowchart TD\nA[Hello]", b"");
1517 if cfg!(feature = "analysis") {
1518 assert_eq!(valid.code, BindingStatus::Ok.code());
1519 let json: Value = serde_json::from_str(&take_text(valid.data)).unwrap();
1520 assert_eq!(json["valid"], true);
1521 assert_eq!(json["code_name"], BindingStatus::Ok.code_name());
1522 } else {
1523 expect_analysis_feature_error(valid);
1524 }
1525
1526 let invalid = call_validate(b"", b"");
1527 if cfg!(feature = "analysis") {
1528 assert_eq!(invalid.code, BindingStatus::Ok.code());
1529 let json: Value = serde_json::from_str(&take_text(invalid.data)).unwrap();
1530 assert_eq!(json["valid"], false);
1531 assert_eq!(json["code_name"], BindingStatus::NoDiagram.code_name());
1532 } else {
1533 expect_analysis_feature_error(invalid);
1534 }
1535 }
1536
1537 #[test]
1538 fn analyze_json_returns_diagnostics_payload() {
1539 let valid = call_analyze(b"flowchart TD\nA[Hello]", b"");
1540 if cfg!(feature = "analysis") {
1541 assert_eq!(valid.code, BindingStatus::Ok.code());
1542 let json: Value = serde_json::from_str(&take_text(valid.data)).unwrap();
1543 assert_eq!(json["version"], 1);
1544 assert_eq!(json["valid"], true);
1545 assert_eq!(json["summary"]["errors"], 0);
1546 } else {
1547 expect_analysis_feature_error(valid);
1548 }
1549
1550 let invalid = call_analyze(b"", b"");
1551 if cfg!(feature = "analysis") {
1552 assert_eq!(invalid.code, BindingStatus::Ok.code());
1553 let json: Value = serde_json::from_str(&take_text(invalid.data)).unwrap();
1554 assert_eq!(json["version"], 1);
1555 assert_eq!(json["valid"], false);
1556 assert_eq!(json["diagnostics"][0]["code_name"], "MERMAN_NO_DIAGRAM");
1557 } else {
1558 expect_analysis_feature_error(invalid);
1559 }
1560 }
1561
1562 #[test]
1563 fn analyze_document_json_returns_markdown_document_payload() {
1564 let source = b"# Example\n\n```mermaid\nflowchart TD\nA[Hello]\n```\n";
1565 let result = call_analyze_document(source, b"", b"file:///tmp/example.md");
1566
1567 if cfg!(feature = "analysis") {
1568 assert_eq!(result.code, BindingStatus::Ok.code());
1569 let json: Value = serde_json::from_str(&take_text(result.data)).unwrap();
1570 assert_eq!(json["version"], 1);
1571 assert_eq!(json["source"]["kind"], "markdown");
1572 assert_eq!(json["valid"], true);
1573 } else {
1574 expect_analysis_feature_error(result);
1575 }
1576 }
1577
1578 #[test]
1579 fn analyze_document_facts_json_returns_host_ranges() {
1580 let source = b"# Example\n\n```mermaid\nflowchart TD\nA[Hello]\n```\n";
1581 let result = call_analyze_document_facts(source, b"", b"file:///tmp/example.md");
1582
1583 if cfg!(feature = "analysis") {
1584 assert_eq!(result.code, BindingStatus::Ok.code());
1585 let json: Value = serde_json::from_str(&take_text(result.data)).unwrap();
1586 assert_eq!(json["version"], 1);
1587 assert_eq!(json["source"]["kind"], "markdown");
1588 assert_eq!(json["diagrams"][0]["source_id"], "mermaid-fence-1");
1589 } else {
1590 expect_analysis_feature_error(result);
1591 }
1592 }
1593
1594 #[test]
1595 fn metadata_entry_points_return_json_contracts() {
1596 let diagrams = merman_supported_diagrams_json();
1597 let ascii_capabilities = merman_ascii_capabilities_json();
1598 let family_capabilities = merman_diagram_family_capabilities_json();
1599 let lint_rules = merman_lint_rule_catalog_json();
1600 let themes = merman_supported_themes_json();
1601 let host_theme_presets = merman_supported_host_theme_presets_json();
1602
1603 assert_eq!(diagrams.code, BindingStatus::Ok.code());
1604 assert_eq!(ascii_capabilities.code, BindingStatus::Ok.code());
1605 assert_eq!(family_capabilities.code, BindingStatus::Ok.code());
1606 let lint_rules_json = if cfg!(feature = "analysis") {
1607 assert_eq!(lint_rules.code, BindingStatus::Ok.code());
1608 Some(take_text(lint_rules.data))
1609 } else {
1610 expect_analysis_feature_error(lint_rules);
1611 None
1612 };
1613 assert_eq!(themes.code, BindingStatus::Ok.code());
1614 assert_eq!(host_theme_presets.code, BindingStatus::Ok.code());
1615
1616 let diagrams: Value = serde_json::from_str(&take_text(diagrams.data)).unwrap();
1617 let ascii_capabilities: Value =
1618 serde_json::from_str(&take_text(ascii_capabilities.data)).unwrap();
1619 let family_capabilities: Value =
1620 serde_json::from_str(&take_text(family_capabilities.data)).unwrap();
1621 let themes: Value = serde_json::from_str(&take_text(themes.data)).unwrap();
1622 let host_theme_presets: Value =
1623 serde_json::from_str(&take_text(host_theme_presets.data)).unwrap();
1624
1625 assert!(
1626 diagrams
1627 .as_array()
1628 .unwrap()
1629 .contains(&Value::String("flowchart".to_string()))
1630 );
1631 let ascii_capabilities = ascii_capabilities.as_array().unwrap();
1632 if cfg!(feature = "ascii") {
1633 let sequence = ascii_capabilities
1634 .iter()
1635 .find(|capability| capability["diagram_type"] == "sequence")
1636 .expect("expected ASCII capability metadata to include sequence");
1637 assert_eq!(sequence["support_level"], "full");
1638
1639 let gantt = ascii_capabilities
1640 .iter()
1641 .find(|capability| capability["diagram_type"] == "gantt")
1642 .expect("expected ASCII capability metadata to include gantt");
1643 assert_eq!(gantt["support_level"], "summary");
1644
1645 let class = ascii_capabilities
1646 .iter()
1647 .find(|capability| capability["diagram_type"] == "class")
1648 .expect("expected ASCII capability metadata to include class");
1649 assert_eq!(class["summary_fallback"], true);
1650 } else {
1651 assert!(ascii_capabilities.is_empty());
1652 }
1653 assert!(family_capabilities.as_array().unwrap().iter().any(
1654 |capability| capability["diagram_type"] == "flowchart"
1655 && capability["metadata_id"] == "flowchart"
1656 && capability["has_semantic_parser"] == true
1657 && capability["has_render_parser"] == true
1658 ));
1659 if let Some(lint_rules_json) = lint_rules_json {
1660 let lint_rules: Value = serde_json::from_str(&lint_rules_json).unwrap();
1661 assert_eq!(lint_rules["version"], 1);
1662 let lint_rules = lint_rules["rules"].as_array().unwrap();
1663 assert!(lint_rules.iter().any(|rule| {
1664 rule["id"] == "merman.authoring.config.prefer_init_directive"
1665 && rule["origin"] == "merman_authoring"
1666 && rule["evidence"]
1667 .as_array()
1668 .unwrap()
1669 .iter()
1670 .any(|value| value == "docs/adr/0072-lint-rule-governance.md")
1671 }));
1672 }
1673 assert!(
1674 themes
1675 .as_array()
1676 .unwrap()
1677 .contains(&Value::String("default".to_string()))
1678 );
1679 assert!(host_theme_presets.is_array());
1680 if cfg!(feature = "render") {
1681 assert!(
1682 host_theme_presets
1683 .as_array()
1684 .unwrap()
1685 .contains(&Value::String("one-dark".to_string()))
1686 );
1687 }
1688 }
1689
1690 #[test]
1691 fn parse_json_uses_same_error_payload() {
1692 let result = call_parse(&[0xff], b"");
1693
1694 if cfg!(feature = "render") {
1695 assert_eq!(result.code, BindingStatus::Utf8Error.code());
1696 let error = take_error(result);
1697 assert_eq!(error["code_name"], BindingStatus::Utf8Error.code_name());
1698 } else {
1699 expect_render_feature_error(result);
1700 }
1701 }
1702
1703 #[test]
1704 fn null_source_with_nonzero_len_returns_invalid_argument() {
1705 let result = unsafe { merman_render_svg(ptr::null(), 1, ptr::null(), 0) };
1706
1707 assert_eq!(result.code, BindingStatus::InvalidArgument.code());
1708 let error = take_error(result);
1709 assert_eq!(
1710 error["code_name"],
1711 BindingStatus::InvalidArgument.code_name()
1712 );
1713 }
1714
1715 #[test]
1716 fn invalid_source_utf8_returns_utf8_error() {
1717 let result = call_render(&[0xff], b"");
1718
1719 if cfg!(feature = "render") {
1720 assert_eq!(result.code, BindingStatus::Utf8Error.code());
1721 let error = take_error(result);
1722 assert_eq!(error["code_name"], BindingStatus::Utf8Error.code_name());
1723 } else {
1724 expect_render_feature_error(result);
1725 }
1726 }
1727
1728 #[test]
1729 fn empty_source_returns_no_diagram() {
1730 let result = unsafe { merman_render_svg(ptr::null(), 0, ptr::null(), 0) };
1731
1732 if cfg!(feature = "render") {
1733 assert_eq!(result.code, BindingStatus::NoDiagram.code());
1734 let error = take_error(result);
1735 assert_eq!(error["code_name"], BindingStatus::NoDiagram.code_name());
1736 } else {
1737 expect_render_feature_error(result);
1738 }
1739 }
1740
1741 #[test]
1742 fn invalid_options_json_returns_options_json_error() {
1743 let result = call_render(b"flowchart TD\nA", b"{");
1744
1745 if cfg!(feature = "render") {
1746 assert_eq!(result.code, BindingStatus::OptionsJsonError.code());
1747 let error = take_error(result);
1748 assert_eq!(
1749 error["code_name"],
1750 BindingStatus::OptionsJsonError.code_name()
1751 );
1752 } else {
1753 expect_render_feature_error(result);
1754 }
1755 }
1756
1757 #[test]
1758 fn render_resource_limit_error_uses_dedicated_status() {
1759 let result = call_render(
1760 b"flowchart TD\nA[Hello]",
1761 br#"{ "resources": { "max_source_bytes": 4 } }"#,
1762 );
1763
1764 if cfg!(feature = "render") {
1765 assert_eq!(result.code, BindingStatus::ResourceLimitExceeded.code());
1766 let error = take_error(result);
1767 assert_eq!(
1768 error["code_name"],
1769 BindingStatus::ResourceLimitExceeded.code_name()
1770 );
1771 assert!(
1772 error["message"]
1773 .as_str()
1774 .unwrap()
1775 .contains("max_source_bytes")
1776 );
1777 } else {
1778 expect_render_feature_error(result);
1779 }
1780 }
1781
1782 #[test]
1783 fn unsupported_ratex_without_feature_returns_unsupported_format() {
1784 let result = call_render(
1785 b"flowchart TD\nA[Hello]",
1786 br#"{ "layout": { "math_renderer": "ratex" } }"#,
1787 );
1788
1789 if cfg!(feature = "ratex-math") {
1790 assert_eq!(result.code, BindingStatus::Ok.code());
1791 unsafe { merman_buffer_free(result.data) };
1792 } else {
1793 assert_eq!(result.code, BindingStatus::UnsupportedFormat.code());
1794 let error = take_error(result);
1795 assert_eq!(
1796 error["code_name"],
1797 BindingStatus::UnsupportedFormat.code_name()
1798 );
1799 }
1800 }
1801
1802 #[test]
1803 fn buffer_free_accepts_null_buffer() {
1804 unsafe { merman_buffer_free(MermanBuffer::empty()) };
1805 }
1806
1807 #[test]
1808 fn ffi_result_catches_panic() {
1809 let result = ffi_result(|| -> Result<Vec<u8>, BindingError> { panic!("boom") });
1810
1811 assert_eq!(result.code, BindingStatus::Panic.code());
1812 let error = take_error(result);
1813 assert_eq!(error["code_name"], BindingStatus::Panic.code_name());
1814 }
1815
1816 #[test]
1817 fn ffi_source_options_request_decodes_source_and_null_options() {
1818 let source = b"flowchart TD\nA[Hello]";
1819 let request = unsafe {
1820 FfiSourceOptionsRequest::from_raw(source.as_ptr(), source.len(), ptr::null(), 0)
1821 }
1822 .unwrap();
1823
1824 assert_eq!(request.source, source);
1825 assert!(request.options_json.is_empty());
1826 }
1827
1828 #[test]
1829 fn ffi_source_options_uri_request_decodes_uri() {
1830 let source = b"flowchart TD\nA[Hello]";
1831 let uri = b"file:///tmp/example.mmd";
1832 let request = unsafe {
1833 FfiSourceOptionsUriRequest::from_raw(
1834 source.as_ptr(),
1835 source.len(),
1836 ptr::null(),
1837 0,
1838 uri.as_ptr(),
1839 uri.len(),
1840 )
1841 }
1842 .unwrap();
1843
1844 assert_eq!(request.source, source);
1845 assert!(request.options_json.is_empty());
1846 assert_eq!(request.uri, uri);
1847 }
1848
1849 #[test]
1850 fn ffi_engine_source_call_decodes_engine_and_source() {
1851 let base = BindingEngine::new(b"").unwrap();
1852 let engine = MermanEngine {
1853 #[cfg(feature = "render")]
1854 base: base.clone(),
1855 inner: base,
1856 lifecycle: Mutex::new(FfiEngineLifecycle::default()),
1857 };
1858 let source = b"flowchart TD\nA[Hello]";
1859 let output = unsafe {
1860 ffi_engine_source_call(&engine, source.as_ptr(), source.len(), |_engine, source| {
1861 Ok(source.to_vec())
1862 })
1863 }
1864 .unwrap();
1865
1866 assert_eq!(output, source);
1867 }
1868
1869 #[cfg(feature = "render")]
1870 #[test]
1871 fn ffi_engine_source_call_rejects_active_mutation() {
1872 let base = BindingEngine::new(b"").unwrap();
1873 let engine = MermanEngine {
1874 #[cfg(feature = "render")]
1875 base: base.clone(),
1876 inner: base,
1877 lifecycle: Mutex::new(FfiEngineLifecycle::default()),
1878 };
1879 let source = b"flowchart TD\nA[Hello]";
1880 let _mutation =
1881 unsafe { begin_engine_mutation((&engine as *const MermanEngine).cast_mut()).unwrap() };
1882
1883 let err = unsafe {
1884 ffi_engine_source_call(
1885 &engine,
1886 source.as_ptr(),
1887 source.len(),
1888 |_engine, _source| Ok(Vec::new()),
1889 )
1890 }
1891 .unwrap_err();
1892
1893 assert_eq!(err.status(), BindingStatus::InvalidArgument);
1894 assert!(err.message().contains("active mutation"));
1895 }
1896
1897 #[test]
1898 fn ffi_engine_source_uri_call_decodes_engine_source_and_uri() {
1899 let base = BindingEngine::new(b"").unwrap();
1900 let engine = MermanEngine {
1901 #[cfg(feature = "render")]
1902 base: base.clone(),
1903 inner: base,
1904 lifecycle: Mutex::new(FfiEngineLifecycle::default()),
1905 };
1906 let source = b"flowchart TD\nA[Hello]";
1907 let uri = b"file:///tmp/example.mmd";
1908 let output = unsafe {
1909 ffi_engine_source_uri_call(
1910 &engine,
1911 source.as_ptr(),
1912 source.len(),
1913 uri.as_ptr(),
1914 uri.len(),
1915 |_engine, source, uri| {
1916 let mut output = source.to_vec();
1917 output.push(b'\n');
1918 output.extend_from_slice(uri);
1919 Ok(output)
1920 },
1921 )
1922 }
1923 .unwrap();
1924
1925 assert_eq!(output, b"flowchart TD\nA[Hello]\nfile:///tmp/example.mmd");
1926 }
1927
1928 #[test]
1929 fn reusable_engine_renders_with_cached_options() {
1930 let options = br#"{
1931 "layout": { "text_measurer": "deterministic" },
1932 "svg": { "diagram_id": "ffi engine", "pipeline": "readable" }
1933 }"#;
1934 let engine = call_engine(options);
1935 assert_eq!(engine.code, BindingStatus::Ok.code());
1936 assert!(!engine.engine.is_null());
1937 assert!(engine.data.data.is_null());
1938
1939 let result = call_engine_render(engine.engine, b"flowchart TD\nA[Hello]");
1940 if cfg!(feature = "render") {
1941 assert_eq!(result.code, BindingStatus::Ok.code());
1942 let svg = take_text(result.data);
1943 assert!(svg.contains("id=\"ffi-engine\""));
1944 assert!(svg.contains("data-merman-foreignobject"));
1945 } else {
1946 expect_render_feature_error(result);
1947 }
1948
1949 unsafe { merman_engine_free(engine.engine) };
1950 }
1951
1952 #[test]
1953 fn reusable_engine_analyzes_documents_with_cached_options() {
1954 let engine = call_engine(br#"{ "analysis": { "profile": "strict" } }"#);
1955 assert_eq!(engine.code, BindingStatus::Ok.code());
1956 assert!(!engine.engine.is_null());
1957
1958 let source = b"# Example\n\n```mermaid\nflowchart TD\nA[Hello]\n```\n";
1959 let result = call_engine_analyze_document(engine.engine, source, b"file:///tmp/example.md");
1960
1961 if cfg!(feature = "analysis") {
1962 assert_eq!(result.code, BindingStatus::Ok.code());
1963 let json: Value = serde_json::from_str(&take_text(result.data)).unwrap();
1964 assert_eq!(json["source"]["kind"], "markdown");
1965 } else {
1966 expect_analysis_feature_error(result);
1967 }
1968
1969 unsafe { merman_engine_free(engine.engine) };
1970 }
1971
1972 #[test]
1973 fn reusable_engine_can_use_host_text_measure_callback() {
1974 #[derive(Default)]
1975 struct CallbackProbe {
1976 saw_condition: bool,
1977 saw_nowrap: bool,
1978 saw_break_spaces: bool,
1979 saw_font_style: bool,
1980 saw_spacing_defaults: bool,
1981 }
1982
1983 unsafe extern "C" fn measure_condition(
1984 request: MermanHostTextMeasureRequest,
1985 user_data: *mut std::ffi::c_void,
1986 ) -> MermanHostTextMeasureResult {
1987 if user_data.is_null() {
1988 return MermanHostTextMeasureResult {
1989 handled: 0,
1990 width: 0.0,
1991 height: 0.0,
1992 line_count: 0,
1993 };
1994 }
1995 let text = unsafe { std::slice::from_raw_parts(request.text, request.text_len) };
1996 if text == b"Condition?" && request.wrap_mode == MERMAN_WRAP_MODE_HTML_LIKE {
1997 let probe = unsafe { &mut *(user_data.cast::<CallbackProbe>()) };
1998 probe.saw_condition = true;
1999 let font_style = unsafe {
2000 std::slice::from_raw_parts(request.font_style, request.font_style_len)
2001 };
2002 probe.saw_font_style |= font_style == b"normal"
2003 && request.direction == MERMAN_TEXT_DIRECTION_AUTO
2004 && request.line_height > request.font_size;
2005 probe.saw_spacing_defaults |=
2006 request.letter_spacing == 0.0 && request.word_spacing == 0.0;
2007 if request.has_max_width == 0 {
2008 probe.saw_nowrap |= request.white_space == MERMAN_TEXT_WHITE_SPACE_NOWRAP;
2009 } else {
2010 probe.saw_break_spaces |=
2011 request.white_space == MERMAN_TEXT_WHITE_SPACE_BREAK_SPACES;
2012 }
2013 return MermanHostTextMeasureResult {
2014 handled: 1,
2015 width: 140.0,
2016 height: 24.0,
2017 line_count: 1,
2018 };
2019 }
2020 MermanHostTextMeasureResult {
2021 handled: 0,
2022 width: 0.0,
2023 height: 0.0,
2024 line_count: 0,
2025 }
2026 }
2027
2028 let engine = call_engine(b"");
2029 assert_eq!(engine.code, BindingStatus::Ok.code());
2030 assert!(!engine.engine.is_null());
2031 let source = b"flowchart TD\nA[Start] --> B{Condition?}";
2032
2033 let baseline = call_engine_render(engine.engine, source);
2034 if !cfg!(feature = "render") {
2035 expect_render_feature_error(baseline);
2036 unsafe { merman_engine_free(engine.engine) };
2037 return;
2038 }
2039 assert_eq!(baseline.code, BindingStatus::Ok.code());
2040 let baseline_svg = take_text(baseline.data);
2041 let baseline_width = foreign_object_width_before_label(&baseline_svg, "Condition?");
2042
2043 let mut callback_probe = CallbackProbe::default();
2044 let set_result = unsafe {
2045 merman_engine_set_text_measure_callback(
2046 engine.engine,
2047 Some(measure_condition),
2048 (&mut callback_probe as *mut CallbackProbe).cast(),
2049 )
2050 };
2051 assert_eq!(set_result.code, BindingStatus::Ok.code());
2052 assert!(set_result.data.data.is_null());
2053
2054 let measured = call_engine_render(engine.engine, source);
2055 assert_eq!(measured.code, BindingStatus::Ok.code());
2056 let measured_svg = take_text(measured.data);
2057 let measured_width = foreign_object_width_before_label(&measured_svg, "Condition?");
2058 assert!(
2059 measured_width > baseline_width + 40.0,
2060 "expected host callback width to affect layout; baseline={baseline_width}, measured={measured_width}"
2061 );
2062 assert!(callback_probe.saw_condition);
2063 assert!(callback_probe.saw_nowrap);
2064 assert!(callback_probe.saw_break_spaces);
2065 assert!(callback_probe.saw_font_style);
2066 assert!(callback_probe.saw_spacing_defaults);
2067
2068 let reset = unsafe {
2069 merman_engine_set_text_measure_callback(engine.engine, None, ptr::null_mut())
2070 };
2071 assert_eq!(reset.code, BindingStatus::Ok.code());
2072
2073 let reset_result = call_engine_render(engine.engine, source);
2074 assert_eq!(reset_result.code, BindingStatus::Ok.code());
2075 let reset_svg = take_text(reset_result.data);
2076 let reset_width = foreign_object_width_before_label(&reset_svg, "Condition?");
2077 assert!(
2078 (reset_width - baseline_width).abs() < 0.001,
2079 "expected null callback to restore base text measurer; baseline={baseline_width}, reset={reset_width}"
2080 );
2081
2082 unsafe { merman_engine_free(engine.engine) };
2083 }
2084
2085 #[cfg(feature = "render")]
2086 #[test]
2087 fn reusable_engine_rejects_text_measure_callback_mutation_from_callback() {
2088 struct CallbackMutationProbe {
2089 engine: *mut MermanEngine,
2090 set_code: i32,
2091 }
2092
2093 unsafe extern "C" fn measure_and_mutate(
2094 request: MermanHostTextMeasureRequest,
2095 user_data: *mut std::ffi::c_void,
2096 ) -> MermanHostTextMeasureResult {
2097 let probe = unsafe { &mut *(user_data.cast::<CallbackMutationProbe>()) };
2098 if probe.set_code == BindingStatus::Ok.code() {
2099 let result = unsafe {
2100 merman_engine_set_text_measure_callback(probe.engine, None, ptr::null_mut())
2101 };
2102 probe.set_code = result.code;
2103 if !result.data.data.is_null() {
2104 unsafe { merman_buffer_free(result.data) };
2105 }
2106 }
2107 MermanHostTextMeasureResult {
2108 handled: 1,
2109 width: (request.text_len as f64 * 8.0).max(1.0),
2110 height: request.line_height.max(1.0),
2111 line_count: 1,
2112 }
2113 }
2114
2115 let engine = call_engine(b"");
2116 assert_eq!(engine.code, BindingStatus::Ok.code());
2117 let mut probe = CallbackMutationProbe {
2118 engine: engine.engine,
2119 set_code: BindingStatus::Ok.code(),
2120 };
2121 let set_result = unsafe {
2122 merman_engine_set_text_measure_callback(
2123 engine.engine,
2124 Some(measure_and_mutate),
2125 (&mut probe as *mut CallbackMutationProbe).cast(),
2126 )
2127 };
2128 assert_eq!(set_result.code, BindingStatus::Ok.code());
2129
2130 let rendered = call_engine_render(engine.engine, b"flowchart TD\nA[Measured] --> B[Done]");
2131 assert_eq!(rendered.code, BindingStatus::Ok.code());
2132 let svg = take_text(rendered.data);
2133 assert!(svg.contains("<svg"));
2134 assert_eq!(probe.set_code, BindingStatus::InvalidArgument.code());
2135
2136 unsafe { merman_engine_free(engine.engine) };
2137 }
2138
2139 #[cfg(feature = "render")]
2140 #[test]
2141 fn reusable_engine_defers_free_requested_from_text_measure_callback() {
2142 struct CallbackFreeProbe {
2143 engine: *mut MermanEngine,
2144 free_called: bool,
2145 }
2146
2147 unsafe extern "C" fn measure_and_free(
2148 request: MermanHostTextMeasureRequest,
2149 user_data: *mut std::ffi::c_void,
2150 ) -> MermanHostTextMeasureResult {
2151 let probe = unsafe { &mut *(user_data.cast::<CallbackFreeProbe>()) };
2152 if !probe.free_called {
2153 probe.free_called = true;
2154 unsafe { merman_engine_free(probe.engine) };
2155 }
2156 MermanHostTextMeasureResult {
2157 handled: 1,
2158 width: (request.text_len as f64 * 8.0).max(1.0),
2159 height: request.line_height.max(1.0),
2160 line_count: 1,
2161 }
2162 }
2163
2164 let engine = call_engine(b"");
2165 assert_eq!(engine.code, BindingStatus::Ok.code());
2166 let mut probe = CallbackFreeProbe {
2167 engine: engine.engine,
2168 free_called: false,
2169 };
2170 let set_result = unsafe {
2171 merman_engine_set_text_measure_callback(
2172 engine.engine,
2173 Some(measure_and_free),
2174 (&mut probe as *mut CallbackFreeProbe).cast(),
2175 )
2176 };
2177 assert_eq!(set_result.code, BindingStatus::Ok.code());
2178
2179 let rendered = call_engine_render(engine.engine, b"flowchart TD\nA[Measured] --> B[Done]");
2180 assert_eq!(rendered.code, BindingStatus::Ok.code());
2181 let svg = take_text(rendered.data);
2182 assert!(svg.contains("<svg"));
2183 assert!(probe.free_called);
2184 }
2185
2186 #[test]
2187 fn reusable_engine_reports_invalid_options_json() {
2188 let engine = call_engine(b"{");
2189
2190 if cfg!(any(
2191 feature = "analysis",
2192 feature = "render",
2193 feature = "ascii"
2194 )) {
2195 assert_eq!(engine.code, BindingStatus::OptionsJsonError.code());
2196 assert!(engine.engine.is_null());
2197 let error: Value = serde_json::from_str(&take_text(engine.data)).unwrap();
2198 assert_eq!(
2199 error["code_name"],
2200 BindingStatus::OptionsJsonError.code_name()
2201 );
2202 } else {
2203 assert_eq!(engine.code, BindingStatus::Ok.code());
2204 unsafe { merman_engine_free(engine.engine) };
2205 }
2206 }
2207
2208 #[test]
2209 fn reusable_engine_rejects_null_engine() {
2210 let result = unsafe {
2211 merman_engine_render_svg(
2212 ptr::null(),
2213 b"flowchart TD\nA".as_ptr(),
2214 b"flowchart TD\nA".len(),
2215 )
2216 };
2217
2218 assert_eq!(result.code, BindingStatus::InvalidArgument.code());
2219 let error = take_error(result);
2220 assert_eq!(
2221 error["code_name"],
2222 BindingStatus::InvalidArgument.code_name()
2223 );
2224 assert!(error["message"].as_str().unwrap().contains("engine"));
2225 }
2226
2227 #[test]
2228 fn engine_result_struct_size_is_reported() {
2229 assert_eq!(
2230 merman_engine_result_struct_size(),
2231 std::mem::size_of::<MermanEngineResult>()
2232 );
2233 }
2234
2235 #[test]
2236 fn reusable_engine_can_render_concurrently_through_c_abi() {
2237 let engine = call_engine(b"");
2238 assert_eq!(engine.code, BindingStatus::Ok.code());
2239 assert!(!engine.engine.is_null());
2240 let engine_addr = engine.engine as usize;
2241
2242 let mut handles = Vec::new();
2243 for _ in 0..8 {
2244 handles.push(std::thread::spawn(move || {
2245 let engine = engine_addr as *const MermanEngine;
2246 for _ in 0..8 {
2247 let result = call_engine_render(engine, b"flowchart TD\nA[Hello] --> B[World]");
2248 if cfg!(feature = "render") {
2249 assert_eq!(result.code, BindingStatus::Ok.code());
2250 let svg = take_text(result.data);
2251 assert!(svg.contains("<svg"));
2252 } else {
2253 expect_render_feature_error(result);
2254 }
2255 }
2256 }));
2257 }
2258
2259 for handle in handles {
2260 handle.join().unwrap();
2261 }
2262
2263 unsafe { merman_engine_free(engine.engine) };
2264 }
2265}