Skip to main content

merman_core/
lib.rs

1#![forbid(unsafe_code)]
2//! Mermaid parser + semantic model (headless).
3//!
4//! Design goals:
5//! - 1:1 parity with the repository's pinned upstream Mermaid baseline
6//! - deterministic, testable outputs (semantic snapshot goldens)
7//! - runtime-agnostic async APIs (no specific executor required)
8
9pub mod baseline;
10pub mod common;
11pub mod common_db;
12pub mod config;
13pub mod detect;
14pub mod diagram;
15pub mod diagrams;
16pub mod entities;
17pub mod error;
18mod family;
19pub mod generated;
20pub mod geom;
21pub mod models;
22pub mod preprocess;
23mod runtime;
24pub mod sanitize;
25mod theme;
26pub mod time;
27pub mod utils;
28
29pub use config::MermaidConfig;
30pub use detect::{Detector, DetectorRegistry};
31pub use diagram::{
32    DiagramRegistry, DiagramSemanticParser, ParsedDiagram, ParsedDiagramRender,
33    RenderDiagramRegistry, RenderSemanticModel, RenderSemanticParser,
34};
35pub use error::{Error, Result};
36pub use preprocess::{PreprocessResult, preprocess_diagram, preprocess_diagram_with_known_type};
37
38/// Maximum nested diagram/include depth accepted by recursive parsers.
39pub const MAX_DIAGRAM_NESTING_DEPTH: usize = 256;
40
41/// Returns Mermaid theme names supported by the pinned baseline.
42pub fn supported_themes() -> &'static [&'static str] {
43    theme::SUPPORTED_THEME_NAMES
44}
45
46/// Returns supported diagram metadata names for binding and host capability discovery.
47pub fn supported_diagrams() -> &'static [&'static str] {
48    family::supported_diagram_metadata_ids()
49}
50
51/// Parser behavior switches shared by metadata, semantic JSON, and typed render-model parsing.
52#[derive(Debug, Clone, Copy, Default)]
53pub struct ParseOptions {
54    /// Return an `error` diagram model instead of an error when diagram parsing fails.
55    pub suppress_errors: bool,
56}
57
58impl ParseOptions {
59    /// Strict parsing (errors are returned).
60    pub fn strict() -> Self {
61        Self {
62            suppress_errors: false,
63        }
64    }
65
66    /// Lenient parsing: on parse failures, return an `error` diagram instead of returning an error.
67    pub fn lenient() -> Self {
68        Self {
69            suppress_errors: true,
70        }
71    }
72}
73
74/// Metadata extracted before semantic diagram parsing.
75#[derive(Debug, Clone)]
76pub struct ParseMetadata {
77    /// Mermaid diagram type id selected by detection or supplied by a known-type parse entrypoint.
78    pub diagram_type: String,
79    /// Parsed config overrides extracted from front-matter and directives.
80    /// This mirrors Mermaid's `mermaidAPI.parse()` return shape.
81    pub config: MermaidConfig,
82    /// The effective config used for detection/parsing after applying site defaults.
83    pub effective_config: MermaidConfig,
84    /// Sanitized Mermaid title from front-matter/directives, when present.
85    pub title: Option<String>,
86}
87
88/// Headless Mermaid parser engine.
89///
90/// An engine owns detector/parser registries and a site-level Mermaid configuration. It is cheap
91/// to clone when callers need per-request option variants.
92#[derive(Debug, Clone)]
93pub struct Engine {
94    registry: DetectorRegistry,
95    diagram_registry: DiagramRegistry,
96    render_diagram_registry: RenderDiagramRegistry,
97    site_config: MermaidConfig,
98    fixed_today_local: Option<chrono::NaiveDate>,
99    fixed_local_offset_minutes: Option<i32>,
100}
101
102impl Default for Engine {
103    fn default() -> Self {
104        let site_config = generated::default_site_config();
105
106        Self {
107            registry: DetectorRegistry::for_pinned_mermaid_baseline(),
108            diagram_registry: DiagramRegistry::for_pinned_mermaid_baseline(),
109            render_diagram_registry: RenderDiagramRegistry::for_pinned_mermaid_baseline(),
110            site_config,
111            fixed_today_local: None,
112            fixed_local_offset_minutes: None,
113        }
114    }
115}
116
117impl Engine {
118    fn parse_timing_enabled() -> bool {
119        static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
120        *ENABLED.get_or_init(|| {
121            matches!(
122                std::env::var("MERMAN_PARSE_TIMING").as_deref(),
123                Ok("1") | Ok("true")
124            )
125        })
126    }
127
128    /// Creates an engine using the pinned Mermaid baseline registries and default site config.
129    pub fn new() -> Self {
130        Self::default()
131    }
132
133    /// Overrides the "today" value used by diagrams that depend on local time (e.g. Gantt).
134    ///
135    /// This exists primarily to make fixture snapshots deterministic. By default, Mermaid uses the
136    /// current local date.
137    pub fn with_fixed_today(mut self, today: Option<chrono::NaiveDate>) -> Self {
138        self.fixed_today_local = today;
139        self
140    }
141
142    /// Overrides the local timezone offset (in minutes) used by diagrams that depend on local time
143    /// semantics (notably Gantt).
144    ///
145    /// This exists primarily to make fixture snapshots deterministic across CI runners. When
146    /// `None`, the system local timezone is used.
147    pub fn with_fixed_local_offset_minutes(mut self, offset_minutes: Option<i32>) -> Self {
148        self.fixed_local_offset_minutes = offset_minutes;
149        self
150    }
151
152    /// Applies site-level Mermaid config defaults.
153    pub fn with_site_config(mut self, mut site_config: MermaidConfig) -> Self {
154        // Merge overrides onto Mermaid schema defaults so detectors keep working.
155        config::mirror_legacy_font_family_into_theme_variables(&mut site_config);
156        self.site_config.deep_merge(site_config.as_value());
157        self
158    }
159
160    /// Returns the detector registry used for automatic diagram type detection.
161    pub fn registry(&self) -> &DetectorRegistry {
162        &self.registry
163    }
164
165    /// Returns a mutable detector registry for custom diagram detection.
166    pub fn registry_mut(&mut self) -> &mut DetectorRegistry {
167        &mut self.registry
168    }
169
170    /// Returns the semantic JSON parser registry.
171    pub fn diagram_registry(&self) -> &DiagramRegistry {
172        &self.diagram_registry
173    }
174
175    /// Returns a mutable semantic JSON parser registry for custom diagram adapters.
176    pub fn diagram_registry_mut(&mut self) -> &mut DiagramRegistry {
177        &mut self.diagram_registry
178    }
179
180    /// Returns the typed render-model parser registry.
181    pub fn render_diagram_registry(&self) -> &RenderDiagramRegistry {
182        &self.render_diagram_registry
183    }
184
185    /// Returns a mutable typed render-model parser registry.
186    pub fn render_diagram_registry_mut(&mut self) -> &mut RenderDiagramRegistry {
187        &mut self.render_diagram_registry
188    }
189
190    /// Synchronous variant of [`Engine::parse_metadata`].
191    ///
192    /// This is useful for UI render pipelines that are synchronous (e.g. immediate-mode UI),
193    /// where introducing an async executor would be awkward. The parsing work is CPU-bound and
194    /// does not perform I/O.
195    pub fn parse_metadata_sync(
196        &self,
197        text: &str,
198        options: ParseOptions,
199    ) -> Result<Option<ParseMetadata>> {
200        let Some((_, meta)) = self.preprocess_and_detect(text, options)? else {
201            return Ok(None);
202        };
203        Ok(Some(meta))
204    }
205
206    /// Parses metadata for an already-known diagram type (skips type detection).
207    ///
208    /// This is intended for integrations that already know the diagram type, e.g. Markdown fences
209    /// like ````mermaid` / ` ```flowchart` / ` ```sequenceDiagram`.
210    ///
211    /// ## Example (Markdown fence)
212    ///
213    /// ```no_run
214    /// use merman_core::{Engine, ParseOptions};
215    ///
216    /// let engine = Engine::new();
217    ///
218    /// // Your markdown parser provides the fence info string (e.g. "flowchart", "sequenceDiagram").
219    /// let fence = "sequenceDiagram";
220    /// let diagram = r#"sequenceDiagram
221    ///   Alice->>Bob: Hello
222    /// "#;
223    ///
224    /// // Map fence info strings to merman's internal diagram ids.
225    /// let diagram_type = match fence {
226    ///     "sequenceDiagram" => "sequence",
227    ///     "flowchart" | "graph" => "flowchart-v2",
228    ///     "stateDiagram" | "stateDiagram-v2" => "stateDiagram",
229    ///     other => other,
230    /// };
231    ///
232    /// let meta = engine
233    ///     .parse_metadata_with_type_sync(diagram_type, diagram, ParseOptions::strict())?
234    ///     .expect("diagram detected");
235    /// # Ok::<(), merman_core::Error>(())
236    /// ```
237    pub fn parse_metadata_with_type_sync(
238        &self,
239        diagram_type: &str,
240        text: &str,
241        options: ParseOptions,
242    ) -> Result<Option<ParseMetadata>> {
243        let Some((_, meta)) = self.preprocess_and_assume_type(diagram_type, text, options)? else {
244            return Ok(None);
245        };
246        Ok(Some(meta))
247    }
248
249    /// Async facade for [`Engine::parse_metadata_sync`].
250    ///
251    /// The work is CPU-bound and executes synchronously; this method exists for callers that
252    /// prefer an async-shaped API.
253    pub async fn parse_metadata(
254        &self,
255        text: &str,
256        options: ParseOptions,
257    ) -> Result<Option<ParseMetadata>> {
258        self.parse_metadata_sync(text, options)
259    }
260
261    /// Async facade for [`Engine::parse_metadata_with_type_sync`].
262    ///
263    /// The work is CPU-bound and executes synchronously.
264    pub async fn parse_metadata_with_type(
265        &self,
266        diagram_type: &str,
267        text: &str,
268        options: ParseOptions,
269    ) -> Result<Option<ParseMetadata>> {
270        self.parse_metadata_with_type_sync(diagram_type, text, options)
271    }
272
273    /// Synchronous variant of [`Engine::parse_diagram`].
274    ///
275    /// Note: callers that want “always returns a diagram” behavior can set
276    /// [`ParseOptions::suppress_errors`] to `true` to get an `error` diagram on parse failures.
277    pub fn parse_diagram_sync(
278        &self,
279        text: &str,
280        options: ParseOptions,
281    ) -> Result<Option<ParsedDiagram>> {
282        let timing_enabled = Self::parse_timing_enabled();
283        let total_start = timing_enabled.then(web_time::Instant::now);
284
285        let preprocess_start = timing_enabled.then(web_time::Instant::now);
286        let Some((code, meta)) = self.preprocess_and_detect(text, options)? else {
287            return Ok(None);
288        };
289        let preprocess = preprocess_start.map(|s| s.elapsed());
290
291        let parse_start = timing_enabled.then(web_time::Instant::now);
292        let parse = crate::runtime::with_fixed_today_local(self.fixed_today_local, || {
293            crate::runtime::with_fixed_local_offset_minutes(self.fixed_local_offset_minutes, || {
294                diagram::parse_or_unsupported(
295                    &self.diagram_registry,
296                    &meta.diagram_type,
297                    &code,
298                    &meta,
299                )
300            })
301        });
302
303        let mut model = match parse {
304            Ok(v) => v,
305            Err(err) => {
306                if !options.suppress_errors {
307                    return Err(err);
308                }
309
310                if let Some(start) = total_start {
311                    let parse = parse_start.map(|s| s.elapsed()).unwrap_or_default();
312                    eprintln!(
313                        "[parse-timing] diagram=error total={:?} preprocess={:?} parse={:?} sanitize={:?} input_bytes={}",
314                        start.elapsed(),
315                        preprocess.unwrap_or_default(),
316                        parse,
317                        web_time::Duration::default(),
318                        text.len(),
319                    );
320                }
321                return Ok(Some(
322                    crate::diagrams::error_diagram::suppressed_error_diagram(&meta),
323                ));
324            }
325        };
326        let parse = parse_start.map(|s| s.elapsed());
327
328        let sanitize_start = timing_enabled.then(web_time::Instant::now);
329        common_db::apply_common_db_sanitization(&mut model, &meta.effective_config);
330        let sanitize = sanitize_start.map(|s| s.elapsed());
331
332        if let Some(start) = total_start {
333            eprintln!(
334                "[parse-timing] diagram={} total={:?} preprocess={:?} parse={:?} sanitize={:?} input_bytes={}",
335                meta.diagram_type,
336                start.elapsed(),
337                preprocess.unwrap_or_default(),
338                parse.unwrap_or_default(),
339                sanitize.unwrap_or_default(),
340                text.len(),
341            );
342        }
343        Ok(Some(ParsedDiagram { meta, model }))
344    }
345
346    /// Async facade for [`Engine::parse_diagram_sync`].
347    ///
348    /// The work is CPU-bound and executes synchronously.
349    pub async fn parse_diagram(
350        &self,
351        text: &str,
352        options: ParseOptions,
353    ) -> Result<Option<ParsedDiagram>> {
354        self.parse_diagram_sync(text, options)
355    }
356
357    /// Parses a diagram into a typed semantic model optimized for headless layout + SVG rendering.
358    ///
359    /// Unlike [`Engine::parse_diagram_sync`], this avoids constructing large
360    /// `serde_json::Value` object trees for high-impact typed-first diagrams and instead returns
361    /// typed semantic structs that the renderer can consume directly.
362    ///
363    /// Callers that need the semantic JSON model should continue using
364    /// [`Engine::parse_diagram_sync`].
365    pub fn parse_diagram_for_render_model_sync(
366        &self,
367        text: &str,
368        options: ParseOptions,
369    ) -> Result<Option<ParsedDiagramRender>> {
370        self.parse_diagram_for_render_model_inner(text, options, |engine| {
371            engine.preprocess_and_detect(text, options)
372        })
373    }
374
375    /// Async facade for [`Engine::parse_diagram_for_render_model_sync`].
376    ///
377    /// The work is CPU-bound and executes synchronously.
378    pub async fn parse_diagram_for_render_model(
379        &self,
380        text: &str,
381        options: ParseOptions,
382    ) -> Result<Option<ParsedDiagramRender>> {
383        self.parse_diagram_for_render_model_sync(text, options)
384    }
385
386    /// Parses a diagram into a typed semantic render model when the diagram type is already known
387    /// (skips type detection).
388    ///
389    /// This is the preferred entrypoint for Markdown renderers and editors that already know the
390    /// diagram type from the code fence info string. It avoids the detection pass and can reduce a
391    /// small fixed overhead in tight render loops.
392    pub fn parse_diagram_for_render_model_with_type_sync(
393        &self,
394        diagram_type: &str,
395        text: &str,
396        options: ParseOptions,
397    ) -> Result<Option<ParsedDiagramRender>> {
398        self.parse_diagram_for_render_model_inner(text, options, |engine| {
399            engine.preprocess_and_assume_type(diagram_type, text, options)
400        })
401    }
402
403    fn parse_diagram_for_render_model_inner(
404        &self,
405        text: &str,
406        options: ParseOptions,
407        preprocess: impl FnOnce(&Self) -> Result<Option<(String, ParseMetadata)>>,
408    ) -> Result<Option<ParsedDiagramRender>> {
409        let timing_enabled = Self::parse_timing_enabled();
410        let total_start = timing_enabled.then(web_time::Instant::now);
411
412        let preprocess_start = timing_enabled.then(web_time::Instant::now);
413        let Some((code, meta)) = preprocess(self)? else {
414            return Ok(None);
415        };
416        let preprocess = preprocess_start.map(|s| s.elapsed());
417
418        let parse_start = timing_enabled.then(web_time::Instant::now);
419        let parse_res = crate::runtime::with_fixed_today_local(self.fixed_today_local, || {
420            crate::runtime::with_fixed_local_offset_minutes(self.fixed_local_offset_minutes, || {
421                self.parse_render_semantic_model(&code, &meta)
422            })
423        });
424        let parse = parse_start.map(|s| s.elapsed());
425
426        let mut model = match parse_res {
427            Ok(v) => v,
428            Err(err) => {
429                if !options.suppress_errors {
430                    return Err(err);
431                }
432
433                if let Some(start) = total_start {
434                    eprintln!(
435                        "[parse-render-timing] diagram=error model=json total={:?} preprocess={:?} parse={:?} sanitize={:?} input_bytes={}",
436                        start.elapsed(),
437                        preprocess.unwrap_or_default(),
438                        parse.unwrap_or_default(),
439                        web_time::Duration::default(),
440                        text.len(),
441                    );
442                }
443                return Ok(Some(
444                    crate::diagrams::error_diagram::suppressed_error_render_diagram(&meta),
445                ));
446            }
447        };
448
449        let sanitize_start = timing_enabled.then(web_time::Instant::now);
450        model.sanitize_common_db_fields(&meta.effective_config);
451        let sanitize = sanitize_start.map(|s| s.elapsed());
452
453        if let Some(start) = total_start {
454            eprintln!(
455                "[parse-render-timing] diagram={} model={} total={:?} preprocess={:?} parse={:?} sanitize={:?} input_bytes={}",
456                meta.diagram_type,
457                model.kind(),
458                start.elapsed(),
459                preprocess.unwrap_or_default(),
460                parse.unwrap_or_default(),
461                sanitize.unwrap_or_default(),
462                text.len(),
463            );
464        }
465
466        Ok(Some(ParsedDiagramRender { meta, model }))
467    }
468
469    fn parse_render_semantic_model(
470        &self,
471        code: &str,
472        meta: &ParseMetadata,
473    ) -> Result<RenderSemanticModel> {
474        if let Some(parser) = self.render_diagram_registry.get(&meta.diagram_type) {
475            return parser(code, meta);
476        }
477
478        if !family::permits_json_render_fallback(&meta.diagram_type) {
479            return Err(Error::DiagramParse {
480                diagram_type: meta.diagram_type.clone(),
481                message: format!(
482                    "built-in diagram type `{}` is missing a typed render parser; JSON render fallback is reserved for error and custom diagram adapters",
483                    meta.diagram_type
484                ),
485            });
486        }
487
488        diagram::parse_or_unsupported(&self.diagram_registry, &meta.diagram_type, code, meta)
489            .map(RenderSemanticModel::Json)
490    }
491
492    /// Async facade for [`Engine::parse_diagram_for_render_model_with_type_sync`].
493    ///
494    /// The work is CPU-bound and executes synchronously.
495    pub async fn parse_diagram_for_render_model_with_type(
496        &self,
497        diagram_type: &str,
498        text: &str,
499        options: ParseOptions,
500    ) -> Result<Option<ParsedDiagramRender>> {
501        self.parse_diagram_for_render_model_with_type_sync(diagram_type, text, options)
502    }
503
504    /// Parses a diagram when the diagram type is already known (skips type detection).
505    ///
506    /// This is the preferred entrypoint for Markdown renderers and editors that already know the
507    /// diagram type from the code fence info string. It avoids the detection pass and can reduce a
508    /// small fixed overhead in tight render loops.
509    ///
510    /// ## Example
511    ///
512    /// ```no_run
513    /// use merman_core::{Engine, ParseOptions};
514    ///
515    /// let engine = Engine::new();
516    /// let input = "flowchart TD; A-->B;";
517    ///
518    /// let parsed = engine
519    ///     .parse_diagram_with_type_sync("flowchart-v2", input, ParseOptions::strict())?
520    ///     .expect("diagram detected");
521    ///
522    /// assert_eq!(parsed.meta.diagram_type, "flowchart-v2");
523    /// # Ok::<(), merman_core::Error>(())
524    /// ```
525    pub fn parse_diagram_with_type_sync(
526        &self,
527        diagram_type: &str,
528        text: &str,
529        options: ParseOptions,
530    ) -> Result<Option<ParsedDiagram>> {
531        let Some((code, meta)) = self.preprocess_and_assume_type(diagram_type, text, options)?
532        else {
533            return Ok(None);
534        };
535
536        let parse = crate::runtime::with_fixed_today_local(self.fixed_today_local, || {
537            crate::runtime::with_fixed_local_offset_minutes(self.fixed_local_offset_minutes, || {
538                diagram::parse_or_unsupported(
539                    &self.diagram_registry,
540                    &meta.diagram_type,
541                    &code,
542                    &meta,
543                )
544            })
545        });
546
547        let mut model = match parse {
548            Ok(v) => v,
549            Err(err) => {
550                if !options.suppress_errors {
551                    return Err(err);
552                }
553
554                return Ok(Some(
555                    crate::diagrams::error_diagram::suppressed_error_diagram(&meta),
556                ));
557            }
558        };
559        common_db::apply_common_db_sanitization(&mut model, &meta.effective_config);
560        Ok(Some(ParsedDiagram { meta, model }))
561    }
562
563    /// Async facade for [`Engine::parse_diagram_with_type_sync`].
564    ///
565    /// The work is CPU-bound and executes synchronously.
566    pub async fn parse_diagram_with_type(
567        &self,
568        diagram_type: &str,
569        text: &str,
570        options: ParseOptions,
571    ) -> Result<Option<ParsedDiagram>> {
572        self.parse_diagram_with_type_sync(diagram_type, text, options)
573    }
574
575    /// Backward-compatible shorthand for [`Engine::parse_metadata`].
576    pub async fn parse(&self, text: &str, options: ParseOptions) -> Result<Option<ParseMetadata>> {
577        self.parse_metadata(text, options).await
578    }
579
580    fn preprocess_and_detect(
581        &self,
582        text: &str,
583        options: ParseOptions,
584    ) -> Result<Option<(String, ParseMetadata)>> {
585        let pre = preprocess_diagram(text, &self.registry)?;
586        if pre.code.trim_start().starts_with("---") {
587            return Err(Error::MalformedFrontMatter);
588        }
589
590        let mut effective_config = self.site_config.clone();
591        effective_config.deep_merge(pre.config.as_value());
592
593        let diagram_type = match self
594            .registry
595            .detect_type_precleaned(&pre.code, &mut effective_config)
596        {
597            Ok(t) => t.to_string(),
598            Err(err) => {
599                if options.suppress_errors {
600                    return Ok(None);
601                }
602                return Err(err);
603            }
604        };
605        theme::apply_theme_defaults(&mut effective_config);
606
607        let title = pre
608            .title
609            .as_ref()
610            .map(|t| crate::sanitize::sanitize_text(t, &effective_config))
611            .filter(|t| !t.is_empty());
612
613        Ok(Some((
614            pre.code,
615            ParseMetadata {
616                diagram_type,
617                config: pre.config,
618                effective_config,
619                title,
620            },
621        )))
622    }
623
624    fn preprocess_and_assume_type(
625        &self,
626        diagram_type: &str,
627        text: &str,
628        _options: ParseOptions,
629    ) -> Result<Option<(String, ParseMetadata)>> {
630        let pre = preprocess_diagram_with_known_type(text, &self.registry, Some(diagram_type))?;
631        if pre.code.trim_start().starts_with("---") {
632            return Err(Error::MalformedFrontMatter);
633        }
634
635        let mut effective_config = self.site_config.clone();
636        effective_config.deep_merge(pre.config.as_value());
637        family::apply_known_type_detector_side_effects(diagram_type, &mut effective_config);
638        theme::apply_theme_defaults(&mut effective_config);
639
640        let title = pre
641            .title
642            .as_ref()
643            .map(|t| crate::sanitize::sanitize_text(t, &effective_config))
644            .filter(|t| !t.is_empty());
645
646        Ok(Some((
647            pre.code,
648            ParseMetadata {
649                diagram_type: diagram_type.to_string(),
650                config: pre.config,
651                effective_config,
652                title,
653            },
654        )))
655    }
656}
657
658#[cfg(test)]
659mod tests;