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