Skip to main content

merman_ffi/
lib.rs

1#![deny(unsafe_op_in_unsafe_fn)]
2
3//! C ABI exports for embedding `merman` in non-Rust hosts.
4//!
5//! This crate is the only place where the public FFI boundary owns unsafe code. The core
6//! parser/render crates and shared binding facade remain safe Rust APIs.
7
8#[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/// Return the C ABI protocol version implemented by this library.
325#[unsafe(no_mangle)]
326pub extern "C" fn merman_abi_version() -> u32 {
327    MERMAN_ABI_VERSION
328}
329
330/// Return the `merman-ffi` crate package version as a static C string.
331#[unsafe(no_mangle)]
332pub extern "C" fn merman_package_version() -> *const c_char {
333    PACKAGE_VERSION.as_ptr().cast()
334}
335
336/// Return the Rust-side size of `MermanBuffer`.
337#[unsafe(no_mangle)]
338pub extern "C" fn merman_buffer_struct_size() -> usize {
339    std::mem::size_of::<MermanBuffer>()
340}
341
342/// Return the Rust-side size of `MermanResult`.
343#[unsafe(no_mangle)]
344pub extern "C" fn merman_result_struct_size() -> usize {
345    std::mem::size_of::<MermanResult>()
346}
347
348/// Return the Rust-side size of `MermanEngineResult`.
349#[unsafe(no_mangle)]
350pub extern "C" fn merman_engine_result_struct_size() -> usize {
351    std::mem::size_of::<MermanEngineResult>()
352}
353
354/// Return the Rust-side size of `MermanHostTextMeasureRequest`.
355#[unsafe(no_mangle)]
356pub extern "C" fn merman_host_text_measure_request_struct_size() -> usize {
357    std::mem::size_of::<MermanHostTextMeasureRequest>()
358}
359
360/// Return the Rust-side size of `MermanHostTextMeasureResult`.
361#[unsafe(no_mangle)]
362pub extern "C" fn merman_host_text_measure_result_struct_size() -> usize {
363    std::mem::size_of::<MermanHostTextMeasureResult>()
364}
365
366/// Create a reusable engine for repeated calls with the same options.
367///
368/// # Safety
369///
370/// - `options_json` may be null only when `options_len == 0`.
371/// - Non-null pointers must be valid for reads of `options_len` bytes for the duration of the call.
372/// - A returned non-null engine must be released with `merman_engine_free`.
373#[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/// Free an engine returned by `merman_engine_new`.
382///
383/// Passing null is a no-op.
384///
385/// # Safety
386///
387/// Non-null engines must have been returned by this crate and must not be freed more than once.
388/// Calling this function consumes the host handle. If a call is active, release is deferred until
389/// active calls return; callers must not use the pointer again after requesting release.
390#[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/// Install a host-provided text measurer on a reusable engine.
401///
402/// The callback is used for future layout/render calls made through this engine. Passing a null
403/// callback resets the engine to the measurer configured by `merman_engine_new`.
404///
405/// # Safety
406///
407/// - `engine` must be a live pointer returned by `merman_engine_new`.
408/// - `callback`, when non-null, must remain callable for as long as the engine can call it.
409/// - `user_data` is never dereferenced by merman; it is passed back unchanged.
410/// - Mutating the callback while any call or callback is active returns `MERMAN_INVALID_ARGUMENT`.
411#[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/// Render Mermaid source to SVG bytes using a reusable engine.
421///
422/// # Safety
423///
424/// - `engine` must be a live pointer returned by `merman_engine_new`.
425/// - `source` may be null only when `source_len == 0`.
426/// - Non-null source pointers must be valid for reads of `source_len` bytes.
427/// - Returned non-empty buffers must be released with `merman_buffer_free`.
428#[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/// Render Mermaid source to Unicode ASCII-art text using a reusable engine.
440///
441/// # Safety
442///
443/// Safety rules are identical to `merman_engine_render_svg`.
444#[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/// Parse Mermaid source to semantic JSON bytes using a reusable engine.
456///
457/// # Safety
458///
459/// Safety rules are identical to `merman_engine_render_svg`.
460#[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/// Layout Mermaid source to layout JSON bytes using a reusable engine.
472///
473/// # Safety
474///
475/// Safety rules are identical to `merman_engine_render_svg`.
476#[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/// Analyze Mermaid source to diagnostics JSON bytes using a reusable engine.
488///
489/// # Safety
490///
491/// Safety rules are identical to `merman_engine_render_svg`.
492#[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/// Analyze a host document to diagnostics JSON bytes using a reusable engine.
504///
505/// # Safety
506///
507/// - `engine` must be a live pointer returned by `merman_engine_new`.
508/// - `source` and `uri` may be null only when their paired length is zero.
509/// - Non-null pointers must be valid for reads of their paired length for the duration of the call.
510/// - Returned non-empty buffers must be released with `merman_buffer_free`.
511#[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/// Analyze a host document to syntax/facts JSON bytes using a reusable engine.
532///
533/// # Safety
534///
535/// Safety rules are identical to `merman_engine_analyze_document_json`.
536#[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/// Validate Mermaid source using a reusable engine.
557///
558/// # Safety
559///
560/// Safety rules are identical to `merman_engine_render_svg`.
561#[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/// Analyze Mermaid source to diagnostics JSON bytes.
573///
574/// # Safety
575///
576/// - `source` may be null only when `source_len == 0`.
577/// - `options_json` may be null only when `options_len == 0`.
578/// - Non-null pointers must be valid for reads of their paired length for the duration of the call.
579/// - Returned non-empty buffers must be released with `merman_buffer_free`.
580#[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/// Analyze a host document to diagnostics JSON bytes.
599///
600/// # Safety
601///
602/// - `source` and `uri` may be null only when their paired length is zero.
603/// - `options_json` may be null only when `options_len == 0`.
604/// - Non-null pointers must be valid for reads of their paired length for the duration of the call.
605/// - Returned non-empty buffers must be released with `merman_buffer_free`.
606#[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/// Analyze a host document to syntax/facts JSON bytes.
629///
630/// # Safety
631///
632/// Safety rules are identical to `merman_analyze_document_json`.
633#[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/// Render Mermaid source to SVG bytes.
656///
657/// # Safety
658///
659/// - `source` may be null only when `source_len == 0`.
660/// - `options_json` may be null only when `options_len == 0`.
661/// - Non-null pointers must be valid for reads of their paired length for the duration of the call.
662/// - Returned non-empty buffers must be released with `merman_buffer_free`.
663#[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/// Render Mermaid source to Unicode ASCII-art text.
682///
683/// # Safety
684///
685/// - `source` may be null only when `source_len == 0`.
686/// - `options_json` may be null only when `options_len == 0`.
687/// - Non-null pointers must be valid for reads of their paired length for the duration of the call.
688/// - Returned non-empty buffers must be released with `merman_buffer_free`.
689#[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/// Parse Mermaid source to semantic JSON bytes.
708///
709/// # Safety
710///
711/// - `source` may be null only when `source_len == 0`.
712/// - `options_json` may be null only when `options_len == 0`.
713/// - Non-null pointers must be valid for reads of their paired length for the duration of the call.
714/// - Returned non-empty buffers must be released with `merman_buffer_free`.
715#[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/// Layout Mermaid source to layout JSON bytes.
734///
735/// # Safety
736///
737/// - `source` may be null only when `source_len == 0`.
738/// - `options_json` may be null only when `options_len == 0`.
739/// - Non-null pointers must be valid for reads of their paired length for the duration of the call.
740/// - Returned non-empty buffers must be released with `merman_buffer_free`.
741#[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/// Validate Mermaid source and return a JSON validation payload.
760///
761/// # Safety
762///
763/// - `source` may be null only when `source_len == 0`.
764/// - `options_json` may be null only when `options_len == 0`.
765/// - Non-null pointers must be valid for reads of their paired length for the duration of the call.
766/// - Returned non-empty buffers must be released with `merman_buffer_free`.
767#[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/// Return supported diagram type metadata as a JSON string array.
786#[unsafe(no_mangle)]
787pub extern "C" fn merman_supported_diagrams_json() -> MermanResult {
788    ffi_result(merman_bindings_core::supported_diagrams_json)
789}
790
791/// Return ASCII rendering capability metadata as a JSON array.
792#[unsafe(no_mangle)]
793pub extern "C" fn merman_ascii_capabilities_json() -> MermanResult {
794    ffi_result(merman_bindings_core::ascii_capabilities_json)
795}
796
797/// Return diagram family parser/render capability metadata as a JSON array.
798#[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/// Return lint rule catalog metadata as a versioned JSON response object.
804#[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/// Return supported theme metadata as a JSON string array.
810#[unsafe(no_mangle)]
811pub extern "C" fn merman_supported_themes_json() -> MermanResult {
812    ffi_result(merman_bindings_core::supported_themes_json)
813}
814
815/// Return supported host/editor theme preset metadata as a JSON string array.
816#[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/// Free a buffer returned by this crate.
822///
823/// Passing a null buffer is a no-op.
824///
825/// # Safety
826///
827/// Non-null buffers must have been returned by this crate and must not be freed more than once.
828#[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}