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