Skip to main content

merman_core/
lib.rs

1#![forbid(unsafe_code)]
2// LALRPOP generates code that can contain an "empty line after outer attribute" in its output.
3// We keep the generated sources as-is and suppress this lint at the crate level.
4#![allow(clippy::empty_line_after_outer_attr)]
5
6//! Mermaid parser + semantic model (headless).
7//!
8//! Design goals:
9//! - 1:1 parity with upstream Mermaid (`mermaid@11.12.3`)
10//! - deterministic, testable outputs (semantic snapshot goldens)
11//! - runtime-agnostic async APIs (no specific executor required)
12
13pub mod common;
14pub mod common_db;
15pub mod config;
16pub mod detect;
17pub mod diagram;
18pub mod diagrams;
19pub mod entities;
20pub mod error;
21pub mod generated;
22pub mod geom;
23pub mod models;
24pub mod preprocess;
25mod runtime;
26pub mod sanitize;
27mod theme;
28pub mod time;
29pub mod utils;
30
31pub use config::MermaidConfig;
32pub use detect::{Detector, DetectorRegistry};
33pub use diagram::{
34    DiagramRegistry, DiagramSemanticParser, ParsedDiagram, ParsedDiagramRender, RenderSemanticModel,
35};
36pub use error::{Error, Result};
37pub use preprocess::{PreprocessResult, preprocess_diagram, preprocess_diagram_with_known_type};
38
39#[derive(Debug, Clone, Copy, Default)]
40pub struct ParseOptions {
41    pub suppress_errors: bool,
42}
43
44impl ParseOptions {
45    /// Strict parsing (errors are returned).
46    pub fn strict() -> Self {
47        Self {
48            suppress_errors: false,
49        }
50    }
51
52    /// Lenient parsing: on parse failures, return an `error` diagram instead of returning an error.
53    pub fn lenient() -> Self {
54        Self {
55            suppress_errors: true,
56        }
57    }
58}
59
60#[derive(Debug, Clone)]
61pub struct ParseMetadata {
62    pub diagram_type: String,
63    /// Parsed config overrides extracted from front-matter and directives.
64    /// This mirrors Mermaid's `mermaidAPI.parse()` return shape.
65    pub config: MermaidConfig,
66    /// The effective config used for detection/parsing after applying site defaults.
67    pub effective_config: MermaidConfig,
68    pub title: Option<String>,
69}
70
71#[derive(Debug, Clone)]
72pub struct Engine {
73    registry: DetectorRegistry,
74    diagram_registry: DiagramRegistry,
75    site_config: MermaidConfig,
76    fixed_today_local: Option<chrono::NaiveDate>,
77    fixed_local_offset_minutes: Option<i32>,
78}
79
80impl Default for Engine {
81    fn default() -> Self {
82        let site_config = generated::default_site_config();
83
84        Self {
85            registry: DetectorRegistry::default_mermaid_11_12_2(),
86            diagram_registry: DiagramRegistry::default_mermaid_11_12_2(),
87            site_config,
88            fixed_today_local: None,
89            fixed_local_offset_minutes: None,
90        }
91    }
92}
93
94impl Engine {
95    fn parse_timing_enabled() -> bool {
96        static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
97        *ENABLED.get_or_init(|| match std::env::var("MERMAN_PARSE_TIMING").as_deref() {
98            Ok("1") | Ok("true") => true,
99            _ => false,
100        })
101    }
102
103    pub fn new() -> Self {
104        Self::default()
105    }
106
107    /// Overrides the "today" value used by diagrams that depend on local time (e.g. Gantt).
108    ///
109    /// This exists primarily to make fixture snapshots deterministic. By default, Mermaid uses the
110    /// current local date.
111    pub fn with_fixed_today(mut self, today: Option<chrono::NaiveDate>) -> Self {
112        self.fixed_today_local = today;
113        self
114    }
115
116    /// Overrides the local timezone offset (in minutes) used by diagrams that depend on local time
117    /// semantics (notably Gantt).
118    ///
119    /// This exists primarily to make fixture snapshots deterministic across CI runners. When
120    /// `None`, the system local timezone is used.
121    pub fn with_fixed_local_offset_minutes(mut self, offset_minutes: Option<i32>) -> Self {
122        self.fixed_local_offset_minutes = offset_minutes;
123        self
124    }
125
126    pub fn with_site_config(mut self, site_config: MermaidConfig) -> Self {
127        // Merge overrides onto Mermaid schema defaults so detectors keep working.
128        self.site_config.deep_merge(site_config.as_value());
129        self
130    }
131
132    pub fn registry(&self) -> &DetectorRegistry {
133        &self.registry
134    }
135
136    pub fn registry_mut(&mut self) -> &mut DetectorRegistry {
137        &mut self.registry
138    }
139
140    pub fn diagram_registry(&self) -> &DiagramRegistry {
141        &self.diagram_registry
142    }
143
144    pub fn diagram_registry_mut(&mut self) -> &mut DiagramRegistry {
145        &mut self.diagram_registry
146    }
147
148    /// Synchronous variant of [`Engine::parse_metadata`].
149    ///
150    /// This is useful for UI render pipelines that are synchronous (e.g. immediate-mode UI),
151    /// where introducing an async executor would be awkward. The parsing work is CPU-bound and
152    /// does not perform I/O.
153    pub fn parse_metadata_sync(
154        &self,
155        text: &str,
156        options: ParseOptions,
157    ) -> Result<Option<ParseMetadata>> {
158        let Some((_, meta)) = self.preprocess_and_detect(text, options)? else {
159            return Ok(None);
160        };
161        Ok(Some(meta))
162    }
163
164    /// Parses metadata for an already-known diagram type (skips type detection).
165    ///
166    /// This is intended for integrations that already know the diagram type, e.g. Markdown fences
167    /// like ````mermaid` / ` ```flowchart` / ` ```sequenceDiagram`.
168    ///
169    /// ## Example (Markdown fence)
170    ///
171    /// ```no_run
172    /// use merman_core::{Engine, ParseOptions};
173    ///
174    /// let engine = Engine::new();
175    ///
176    /// // Your markdown parser provides the fence info string (e.g. "flowchart", "sequenceDiagram").
177    /// let fence = "sequenceDiagram";
178    /// let diagram = r#"sequenceDiagram
179    ///   Alice->>Bob: Hello
180    /// "#;
181    ///
182    /// // Map fence info strings to merman's internal diagram ids.
183    /// let diagram_type = match fence {
184    ///     "sequenceDiagram" => "sequence",
185    ///     "flowchart" | "graph" => "flowchart-v2",
186    ///     "stateDiagram" | "stateDiagram-v2" => "stateDiagram",
187    ///     other => other,
188    /// };
189    ///
190    /// let meta = engine
191    ///     .parse_metadata_as_sync(diagram_type, diagram, ParseOptions::strict())?
192    ///     .expect("diagram detected");
193    /// # Ok::<(), merman_core::Error>(())
194    /// ```
195    pub fn parse_metadata_as_sync(
196        &self,
197        diagram_type: &str,
198        text: &str,
199        options: ParseOptions,
200    ) -> Result<Option<ParseMetadata>> {
201        let Some((_, meta)) = self.preprocess_and_assume_type(diagram_type, text, options)? else {
202            return Ok(None);
203        };
204        Ok(Some(meta))
205    }
206
207    pub async fn parse_metadata(
208        &self,
209        text: &str,
210        options: ParseOptions,
211    ) -> Result<Option<ParseMetadata>> {
212        self.parse_metadata_sync(text, options)
213    }
214
215    pub async fn parse_metadata_as(
216        &self,
217        diagram_type: &str,
218        text: &str,
219        options: ParseOptions,
220    ) -> Result<Option<ParseMetadata>> {
221        self.parse_metadata_as_sync(diagram_type, text, options)
222    }
223
224    /// Synchronous variant of [`Engine::parse_diagram`].
225    ///
226    /// Note: callers that want “always returns a diagram” behavior can set
227    /// [`ParseOptions::suppress_errors`] to `true` to get an `error` diagram on parse failures.
228    pub fn parse_diagram_sync(
229        &self,
230        text: &str,
231        options: ParseOptions,
232    ) -> Result<Option<ParsedDiagram>> {
233        let timing_enabled = Self::parse_timing_enabled();
234        let total_start = timing_enabled.then(std::time::Instant::now);
235
236        let preprocess_start = timing_enabled.then(std::time::Instant::now);
237        let Some((code, meta)) = self.preprocess_and_detect(text, options)? else {
238            return Ok(None);
239        };
240        let preprocess = preprocess_start.map(|s| s.elapsed());
241
242        let parse_start = timing_enabled.then(std::time::Instant::now);
243        let parse = crate::runtime::with_fixed_today_local(self.fixed_today_local, || {
244            crate::runtime::with_fixed_local_offset_minutes(self.fixed_local_offset_minutes, || {
245                diagram::parse_or_unsupported(
246                    &self.diagram_registry,
247                    &meta.diagram_type,
248                    &code,
249                    &meta,
250                )
251            })
252        });
253
254        let mut model = match parse {
255            Ok(v) => v,
256            Err(err) => {
257                if !options.suppress_errors {
258                    return Err(err);
259                }
260
261                let mut error_meta = meta.clone();
262                error_meta.diagram_type = "error".to_string();
263                let mut error_model = serde_json::json!({ "type": "error" });
264                common_db::apply_common_db_sanitization(
265                    &mut error_model,
266                    &error_meta.effective_config,
267                );
268                if let Some(start) = total_start {
269                    let parse = parse_start.map(|s| s.elapsed()).unwrap_or_default();
270                    eprintln!(
271                        "[parse-timing] diagram=error total={:?} preprocess={:?} parse={:?} sanitize={:?} input_bytes={}",
272                        start.elapsed(),
273                        preprocess.unwrap_or_default(),
274                        parse,
275                        std::time::Duration::default(),
276                        text.len(),
277                    );
278                }
279                return Ok(Some(ParsedDiagram {
280                    meta: error_meta,
281                    model: error_model,
282                }));
283            }
284        };
285        let parse = parse_start.map(|s| s.elapsed());
286
287        let sanitize_start = timing_enabled.then(std::time::Instant::now);
288        common_db::apply_common_db_sanitization(&mut model, &meta.effective_config);
289        let sanitize = sanitize_start.map(|s| s.elapsed());
290
291        if let Some(start) = total_start {
292            eprintln!(
293                "[parse-timing] diagram={} total={:?} preprocess={:?} parse={:?} sanitize={:?} input_bytes={}",
294                meta.diagram_type,
295                start.elapsed(),
296                preprocess.unwrap_or_default(),
297                parse.unwrap_or_default(),
298                sanitize.unwrap_or_default(),
299                text.len(),
300            );
301        }
302        Ok(Some(ParsedDiagram { meta, model }))
303    }
304
305    pub async fn parse_diagram(
306        &self,
307        text: &str,
308        options: ParseOptions,
309    ) -> Result<Option<ParsedDiagram>> {
310        self.parse_diagram_sync(text, options)
311    }
312
313    /// Parses a diagram for layout/render pipelines.
314    ///
315    /// Compared to [`Engine::parse_diagram_sync`], this may omit semantic-model keys that are not
316    /// required by merman's layout/SVG renderers (e.g. embedding the full effective config into the
317    /// returned model). This keeps the public `parse_diagram*` APIs stable while allowing render
318    /// pipelines to avoid paying large JSON clone costs.
319    pub fn parse_diagram_for_render_sync(
320        &self,
321        text: &str,
322        options: ParseOptions,
323    ) -> Result<Option<ParsedDiagram>> {
324        let Some((code, meta)) = self.preprocess_and_detect(text, options)? else {
325            return Ok(None);
326        };
327
328        let parse_res = match meta.diagram_type.as_str() {
329            "mindmap" => crate::diagrams::mindmap::parse_mindmap_for_render(&code, &meta),
330            "stateDiagram" | "state" => {
331                crate::diagrams::state::parse_state_for_render(&code, &meta)
332            }
333            _ => diagram::parse_or_unsupported(
334                &self.diagram_registry,
335                &meta.diagram_type,
336                &code,
337                &meta,
338            ),
339        };
340
341        let mut model = match parse_res {
342            Ok(v) => v,
343            Err(err) => {
344                if !options.suppress_errors {
345                    return Err(err);
346                }
347
348                let mut error_meta = meta.clone();
349                error_meta.diagram_type = "error".to_string();
350                let mut error_model = serde_json::json!({ "type": "error" });
351                common_db::apply_common_db_sanitization(
352                    &mut error_model,
353                    &error_meta.effective_config,
354                );
355                return Ok(Some(ParsedDiagram {
356                    meta: error_meta,
357                    model: error_model,
358                }));
359            }
360        };
361
362        common_db::apply_common_db_sanitization(&mut model, &meta.effective_config);
363        Ok(Some(ParsedDiagram { meta, model }))
364    }
365
366    pub async fn parse_diagram_for_render(
367        &self,
368        text: &str,
369        options: ParseOptions,
370    ) -> Result<Option<ParsedDiagram>> {
371        self.parse_diagram_for_render_sync(text, options)
372    }
373
374    /// Parses a diagram into a typed semantic model optimized for headless layout + SVG rendering.
375    ///
376    /// Unlike [`Engine::parse_diagram_for_render_sync`], this avoids constructing large
377    /// `serde_json::Value` object trees for some high-impact diagrams (currently `stateDiagram` and
378    /// `mindmap`) and instead returns typed semantic structs that the renderer can consume
379    /// directly.
380    ///
381    /// Callers that need the semantic JSON model should continue using [`Engine::parse_diagram_sync`]
382    /// or [`Engine::parse_diagram_for_render_sync`].
383    pub fn parse_diagram_for_render_model_sync(
384        &self,
385        text: &str,
386        options: ParseOptions,
387    ) -> Result<Option<ParsedDiagramRender>> {
388        let timing_enabled = Self::parse_timing_enabled();
389        let total_start = timing_enabled.then(std::time::Instant::now);
390
391        let preprocess_start = timing_enabled.then(std::time::Instant::now);
392        let Some((code, meta)) = self.preprocess_and_detect(text, options)? else {
393            return Ok(None);
394        };
395        let preprocess = preprocess_start.map(|s| s.elapsed());
396
397        let parse_start = timing_enabled.then(std::time::Instant::now);
398        let parse_res: Result<RenderSemanticModel> = match meta.diagram_type.as_str() {
399            "mindmap" => crate::diagrams::mindmap::parse_mindmap_model_for_render(&code, &meta)
400                .map(RenderSemanticModel::Mindmap),
401            "stateDiagram" | "state" => {
402                crate::diagrams::state::parse_state_model_for_render(&code, &meta)
403                    .map(RenderSemanticModel::State)
404            }
405            "flowchart-v2" | "flowchart" | "flowchart-elk" => {
406                crate::diagrams::flowchart::parse_flowchart_model_for_render(&code, &meta)
407                    .map(RenderSemanticModel::Flowchart)
408            }
409            "classDiagram" | "class" => crate::diagrams::class::parse_class_typed(&code, &meta)
410                .map(RenderSemanticModel::Class),
411            "architecture" => {
412                crate::diagrams::architecture::parse_architecture_model_for_render(&code, &meta)
413                    .map(RenderSemanticModel::Architecture)
414            }
415            _ => diagram::parse_or_unsupported(
416                &self.diagram_registry,
417                &meta.diagram_type,
418                &code,
419                &meta,
420            )
421            .map(RenderSemanticModel::Json),
422        };
423        let parse = parse_start.map(|s| s.elapsed());
424
425        let mut model = match parse_res {
426            Ok(v) => v,
427            Err(err) => {
428                if !options.suppress_errors {
429                    return Err(err);
430                }
431
432                let mut error_meta = meta.clone();
433                error_meta.diagram_type = "error".to_string();
434                let mut error_model = serde_json::json!({ "type": "error" });
435                common_db::apply_common_db_sanitization(
436                    &mut error_model,
437                    &error_meta.effective_config,
438                );
439                if let Some(start) = total_start {
440                    eprintln!(
441                        "[parse-render-timing] diagram=error model=json total={:?} preprocess={:?} parse={:?} sanitize={:?} input_bytes={}",
442                        start.elapsed(),
443                        preprocess.unwrap_or_default(),
444                        parse.unwrap_or_default(),
445                        std::time::Duration::default(),
446                        text.len(),
447                    );
448                }
449                return Ok(Some(ParsedDiagramRender {
450                    meta: error_meta,
451                    model: RenderSemanticModel::Json(error_model),
452                }));
453            }
454        };
455
456        let sanitize_start = timing_enabled.then(std::time::Instant::now);
457        match &mut model {
458            RenderSemanticModel::Json(v) => {
459                common_db::apply_common_db_sanitization(v, &meta.effective_config);
460            }
461            RenderSemanticModel::State(v) => {
462                if let Some(s) = v.acc_title.as_deref() {
463                    v.acc_title = Some(common_db::sanitize_acc_title(s, &meta.effective_config));
464                }
465                if let Some(s) = v.acc_descr.as_deref() {
466                    v.acc_descr = Some(common_db::sanitize_acc_descr(s, &meta.effective_config));
467                }
468            }
469            RenderSemanticModel::Mindmap(_) => {}
470            RenderSemanticModel::Flowchart(_) => {}
471            RenderSemanticModel::Class(v) => {
472                if let Some(s) = v.acc_title.as_deref() {
473                    v.acc_title = Some(common_db::sanitize_acc_title(s, &meta.effective_config));
474                }
475                if let Some(s) = v.acc_descr.as_deref() {
476                    v.acc_descr = Some(common_db::sanitize_acc_descr(s, &meta.effective_config));
477                }
478            }
479            RenderSemanticModel::Architecture(v) => {
480                if let Some(s) = v.title.as_deref() {
481                    v.title = Some(crate::sanitize::sanitize_text(s, &meta.effective_config));
482                }
483                if let Some(s) = v.acc_title.as_deref() {
484                    v.acc_title = Some(common_db::sanitize_acc_title(s, &meta.effective_config));
485                }
486                if let Some(s) = v.acc_descr.as_deref() {
487                    v.acc_descr = Some(common_db::sanitize_acc_descr(s, &meta.effective_config));
488                }
489            }
490        }
491        let sanitize = sanitize_start.map(|s| s.elapsed());
492
493        if let Some(start) = total_start {
494            let model_kind = match &model {
495                RenderSemanticModel::Json(_) => "json",
496                RenderSemanticModel::State(_) => "state",
497                RenderSemanticModel::Mindmap(_) => "mindmap",
498                RenderSemanticModel::Flowchart(_) => "flowchart",
499                RenderSemanticModel::Architecture(_) => "architecture",
500                RenderSemanticModel::Class(_) => "class",
501            };
502            eprintln!(
503                "[parse-render-timing] diagram={} model={} total={:?} preprocess={:?} parse={:?} sanitize={:?} input_bytes={}",
504                meta.diagram_type,
505                model_kind,
506                start.elapsed(),
507                preprocess.unwrap_or_default(),
508                parse.unwrap_or_default(),
509                sanitize.unwrap_or_default(),
510                text.len(),
511            );
512        }
513
514        Ok(Some(ParsedDiagramRender { meta, model }))
515    }
516
517    pub async fn parse_diagram_for_render_model(
518        &self,
519        text: &str,
520        options: ParseOptions,
521    ) -> Result<Option<ParsedDiagramRender>> {
522        self.parse_diagram_for_render_model_sync(text, options)
523    }
524
525    /// Parses a diagram into a typed semantic render model when the diagram type is already known
526    /// (skips type detection).
527    ///
528    /// This is the preferred entrypoint for Markdown renderers and editors that already know the
529    /// diagram type from the code fence info string. It avoids the detection pass and can reduce a
530    /// small fixed overhead in tight render loops.
531    pub fn parse_diagram_for_render_model_as_sync(
532        &self,
533        diagram_type: &str,
534        text: &str,
535        options: ParseOptions,
536    ) -> Result<Option<ParsedDiagramRender>> {
537        let timing_enabled = Self::parse_timing_enabled();
538        let total_start = timing_enabled.then(std::time::Instant::now);
539
540        let preprocess_start = timing_enabled.then(std::time::Instant::now);
541        let Some((code, meta)) = self.preprocess_and_assume_type(diagram_type, text, options)?
542        else {
543            return Ok(None);
544        };
545        let preprocess = preprocess_start.map(|s| s.elapsed());
546
547        let parse_start = timing_enabled.then(std::time::Instant::now);
548        let parse_res: Result<RenderSemanticModel> = match meta.diagram_type.as_str() {
549            "mindmap" => crate::diagrams::mindmap::parse_mindmap_model_for_render(&code, &meta)
550                .map(RenderSemanticModel::Mindmap),
551            "stateDiagram" | "state" => {
552                crate::diagrams::state::parse_state_model_for_render(&code, &meta)
553                    .map(RenderSemanticModel::State)
554            }
555            "flowchart-v2" | "flowchart" | "flowchart-elk" => {
556                crate::diagrams::flowchart::parse_flowchart_model_for_render(&code, &meta)
557                    .map(RenderSemanticModel::Flowchart)
558            }
559            "classDiagram" | "class" => crate::diagrams::class::parse_class_typed(&code, &meta)
560                .map(RenderSemanticModel::Class),
561            "architecture" => {
562                crate::diagrams::architecture::parse_architecture_model_for_render(&code, &meta)
563                    .map(RenderSemanticModel::Architecture)
564            }
565            _ => diagram::parse_or_unsupported(
566                &self.diagram_registry,
567                &meta.diagram_type,
568                &code,
569                &meta,
570            )
571            .map(RenderSemanticModel::Json),
572        };
573        let parse = parse_start.map(|s| s.elapsed());
574
575        let mut model = match parse_res {
576            Ok(v) => v,
577            Err(err) => {
578                if !options.suppress_errors {
579                    return Err(err);
580                }
581
582                let mut error_meta = meta.clone();
583                error_meta.diagram_type = "error".to_string();
584                let mut error_model = serde_json::json!({ "type": "error" });
585                common_db::apply_common_db_sanitization(
586                    &mut error_model,
587                    &error_meta.effective_config,
588                );
589                if let Some(start) = total_start {
590                    eprintln!(
591                        "[parse-render-timing] diagram=error model=json total={:?} preprocess={:?} parse={:?} sanitize={:?} input_bytes={}",
592                        start.elapsed(),
593                        preprocess.unwrap_or_default(),
594                        parse.unwrap_or_default(),
595                        std::time::Duration::default(),
596                        text.len(),
597                    );
598                }
599                return Ok(Some(ParsedDiagramRender {
600                    meta: error_meta,
601                    model: RenderSemanticModel::Json(error_model),
602                }));
603            }
604        };
605
606        let sanitize_start = timing_enabled.then(std::time::Instant::now);
607        match &mut model {
608            RenderSemanticModel::Json(v) => {
609                common_db::apply_common_db_sanitization(v, &meta.effective_config);
610            }
611            RenderSemanticModel::State(v) => {
612                if let Some(s) = v.acc_title.as_deref() {
613                    v.acc_title = Some(common_db::sanitize_acc_title(s, &meta.effective_config));
614                }
615                if let Some(s) = v.acc_descr.as_deref() {
616                    v.acc_descr = Some(common_db::sanitize_acc_descr(s, &meta.effective_config));
617                }
618            }
619            RenderSemanticModel::Mindmap(_) => {}
620            RenderSemanticModel::Flowchart(_) => {}
621            RenderSemanticModel::Class(v) => {
622                if let Some(s) = v.acc_title.as_deref() {
623                    v.acc_title = Some(common_db::sanitize_acc_title(s, &meta.effective_config));
624                }
625                if let Some(s) = v.acc_descr.as_deref() {
626                    v.acc_descr = Some(common_db::sanitize_acc_descr(s, &meta.effective_config));
627                }
628            }
629            RenderSemanticModel::Architecture(v) => {
630                if let Some(s) = v.title.as_deref() {
631                    v.title = Some(crate::sanitize::sanitize_text(s, &meta.effective_config));
632                }
633                if let Some(s) = v.acc_title.as_deref() {
634                    v.acc_title = Some(common_db::sanitize_acc_title(s, &meta.effective_config));
635                }
636                if let Some(s) = v.acc_descr.as_deref() {
637                    v.acc_descr = Some(common_db::sanitize_acc_descr(s, &meta.effective_config));
638                }
639            }
640        }
641        let sanitize = sanitize_start.map(|s| s.elapsed());
642
643        if let Some(start) = total_start {
644            let model_kind = match &model {
645                RenderSemanticModel::Json(_) => "json",
646                RenderSemanticModel::State(_) => "state",
647                RenderSemanticModel::Mindmap(_) => "mindmap",
648                RenderSemanticModel::Flowchart(_) => "flowchart",
649                RenderSemanticModel::Architecture(_) => "architecture",
650                RenderSemanticModel::Class(_) => "class",
651            };
652            eprintln!(
653                "[parse-render-timing] diagram={} model={} total={:?} preprocess={:?} parse={:?} sanitize={:?} input_bytes={}",
654                meta.diagram_type,
655                model_kind,
656                start.elapsed(),
657                preprocess.unwrap_or_default(),
658                parse.unwrap_or_default(),
659                sanitize.unwrap_or_default(),
660                text.len(),
661            );
662        }
663
664        Ok(Some(ParsedDiagramRender { meta, model }))
665    }
666
667    pub async fn parse_diagram_for_render_model_as(
668        &self,
669        diagram_type: &str,
670        text: &str,
671        options: ParseOptions,
672    ) -> Result<Option<ParsedDiagramRender>> {
673        self.parse_diagram_for_render_model_as_sync(diagram_type, text, options)
674    }
675
676    /// Parses a diagram when the diagram type is already known (skips type detection).
677    ///
678    /// This is the preferred entrypoint for Markdown renderers and editors that already know the
679    /// diagram type from the code fence info string. It avoids the detection pass and can reduce a
680    /// small fixed overhead in tight render loops.
681    ///
682    /// ## Example
683    ///
684    /// ```no_run
685    /// use merman_core::{Engine, ParseOptions};
686    ///
687    /// let engine = Engine::new();
688    /// let input = "flowchart TD; A-->B;";
689    ///
690    /// let parsed = engine
691    ///     .parse_diagram_as_sync("flowchart-v2", input, ParseOptions::strict())?
692    ///     .expect("diagram detected");
693    ///
694    /// assert_eq!(parsed.meta.diagram_type, "flowchart-v2");
695    /// # Ok::<(), merman_core::Error>(())
696    /// ```
697    pub fn parse_diagram_as_sync(
698        &self,
699        diagram_type: &str,
700        text: &str,
701        options: ParseOptions,
702    ) -> Result<Option<ParsedDiagram>> {
703        let Some((code, meta)) = self.preprocess_and_assume_type(diagram_type, text, options)?
704        else {
705            return Ok(None);
706        };
707
708        let parse = crate::runtime::with_fixed_today_local(self.fixed_today_local, || {
709            crate::runtime::with_fixed_local_offset_minutes(self.fixed_local_offset_minutes, || {
710                diagram::parse_or_unsupported(
711                    &self.diagram_registry,
712                    &meta.diagram_type,
713                    &code,
714                    &meta,
715                )
716            })
717        });
718
719        let mut model = match parse {
720            Ok(v) => v,
721            Err(err) => {
722                if !options.suppress_errors {
723                    return Err(err);
724                }
725
726                let mut error_meta = meta.clone();
727                error_meta.diagram_type = "error".to_string();
728                let mut error_model = serde_json::json!({ "type": "error" });
729                common_db::apply_common_db_sanitization(
730                    &mut error_model,
731                    &error_meta.effective_config,
732                );
733                return Ok(Some(ParsedDiagram {
734                    meta: error_meta,
735                    model: error_model,
736                }));
737            }
738        };
739        common_db::apply_common_db_sanitization(&mut model, &meta.effective_config);
740        Ok(Some(ParsedDiagram { meta, model }))
741    }
742
743    pub async fn parse_diagram_as(
744        &self,
745        diagram_type: &str,
746        text: &str,
747        options: ParseOptions,
748    ) -> Result<Option<ParsedDiagram>> {
749        self.parse_diagram_as_sync(diagram_type, text, options)
750    }
751
752    pub async fn parse(&self, text: &str, options: ParseOptions) -> Result<Option<ParseMetadata>> {
753        self.parse_metadata(text, options).await
754    }
755
756    fn preprocess_and_detect(
757        &self,
758        text: &str,
759        options: ParseOptions,
760    ) -> Result<Option<(String, ParseMetadata)>> {
761        let pre = preprocess_diagram(text, &self.registry)?;
762        if pre.code.trim_start().starts_with("---") {
763            return Err(Error::MalformedFrontMatter);
764        }
765
766        let mut effective_config = self.site_config.clone();
767        effective_config.deep_merge(pre.config.as_value());
768
769        let diagram_type = match self
770            .registry
771            .detect_type_precleaned(&pre.code, &mut effective_config)
772        {
773            Ok(t) => t.to_string(),
774            Err(err) => {
775                if options.suppress_errors {
776                    return Ok(None);
777                }
778                return Err(err);
779            }
780        };
781        theme::apply_theme_defaults(&mut effective_config);
782
783        let title = pre
784            .title
785            .as_ref()
786            .map(|t| crate::sanitize::sanitize_text(t, &effective_config))
787            .filter(|t| !t.is_empty());
788
789        Ok(Some((
790            pre.code,
791            ParseMetadata {
792                diagram_type,
793                config: pre.config,
794                effective_config,
795                title,
796            },
797        )))
798    }
799
800    fn preprocess_and_assume_type(
801        &self,
802        diagram_type: &str,
803        text: &str,
804        _options: ParseOptions,
805    ) -> Result<Option<(String, ParseMetadata)>> {
806        let pre = preprocess_diagram_with_known_type(text, &self.registry, Some(diagram_type))?;
807        if pre.code.trim_start().starts_with("---") {
808            return Err(Error::MalformedFrontMatter);
809        }
810
811        let mut effective_config = self.site_config.clone();
812        effective_config.deep_merge(pre.config.as_value());
813        apply_detector_side_effects_for_known_type(diagram_type, &mut effective_config);
814        theme::apply_theme_defaults(&mut effective_config);
815
816        let title = pre
817            .title
818            .as_ref()
819            .map(|t| crate::sanitize::sanitize_text(t, &effective_config))
820            .filter(|t| !t.is_empty());
821
822        Ok(Some((
823            pre.code,
824            ParseMetadata {
825                diagram_type: diagram_type.to_string(),
826                config: pre.config,
827                effective_config,
828                title,
829            },
830        )))
831    }
832}
833
834fn apply_detector_side_effects_for_known_type(
835    diagram_type: &str,
836    effective_config: &mut MermaidConfig,
837) {
838    // Some Mermaid detectors have side effects on config (e.g. selecting ELK layout).
839    // When the diagram type is known ahead of time, we must preserve these side effects so the
840    // downstream layout/render pipeline behaves like the auto-detect path.
841    if diagram_type == "flowchart-elk" {
842        effective_config.set_value("layout", serde_json::Value::String("elk".to_string()));
843        return;
844    }
845
846    if matches!(diagram_type, "flowchart-v2" | "flowchart")
847        && effective_config.get_str("flowchart.defaultRenderer") == Some("elk")
848    {
849        effective_config.set_value("layout", serde_json::Value::String("elk".to_string()));
850    }
851}
852
853#[cfg(test)]
854mod tests;