Skip to main content

merman_core/preprocess/
mod.rs

1use crate::{DetectorRegistry, Error, MermaidConfig, Result};
2use serde_json::Value;
3use std::borrow::Cow;
4
5#[derive(Debug, Clone)]
6pub struct PreprocessResult {
7    pub code: String,
8    pub title: Option<String>,
9    pub config: MermaidConfig,
10}
11
12const FRONTMATTER_DIAGRAM_CONFIG_KEYS: &[&str] = &[
13    "architecture",
14    "block",
15    "c4",
16    "class",
17    "er",
18    "flowchart",
19    "gantt",
20    "gitGraph",
21    "journey",
22    "kanban",
23    "mindmap",
24    "packet",
25    "pie",
26    "quadrantChart",
27    "radar",
28    "requirement",
29    "sankey",
30    "sequence",
31    "state",
32    "timeline",
33    "venn",
34    "xyChart",
35];
36
37const FRONTMATTER_DIAGRAM_CONFIG_ALIASES: &[(&str, &str)] = &[
38    ("classDiagram", "class"),
39    ("erDiagram", "er"),
40    ("stateDiagram", "state"),
41    ("xychart", "xyChart"),
42];
43
44const MAX_CONFIG_NESTING_DEPTH: usize = crate::MAX_DIAGRAM_NESTING_DEPTH;
45
46pub fn preprocess_diagram(input: &str, registry: &DetectorRegistry) -> Result<PreprocessResult> {
47    preprocess_diagram_with_known_type(input, registry, None)
48}
49
50pub fn preprocess_diagram_with_known_type(
51    input: &str,
52    registry: &DetectorRegistry,
53    diagram_type: Option<&str>,
54) -> Result<PreprocessResult> {
55    let cleaned = cleanup_text(input);
56    let (without_frontmatter, title, mut frontmatter_config) =
57        process_frontmatter(cleaned.as_ref())?;
58    let (without_directives, directive_config) =
59        process_directives(without_frontmatter, registry, diagram_type)?;
60
61    frontmatter_config.deep_merge(directive_config.as_value());
62
63    let code = crate::utils::cleanup_mermaid_comments(without_directives.as_ref());
64    Ok(PreprocessResult {
65        code: code.into_owned(),
66        title,
67        config: frontmatter_config,
68    })
69}
70
71fn cleanup_text(input: &str) -> Cow<'_, str> {
72    let mut s: Cow<'_, str> = if input.contains('\r') {
73        Cow::Owned(normalize_crlf(input))
74    } else {
75        Cow::Borrowed(input)
76    };
77
78    // Mermaid encodes `#quot;`-style sequences before parsing (`encodeEntities(...)`).
79    // This is required because `#` and `;` are significant in several grammars (comments and
80    // statement separators), and the encoded placeholders are later decoded by the renderer.
81    //
82    // Source of truth: `packages/mermaid/src/utils.ts::encodeEntities` at Mermaid@11.12.2.
83    if s.contains('#') {
84        s = Cow::Owned(encode_mermaid_entities_like_upstream(s.as_ref()));
85    }
86
87    // Mermaid performs this HTML attribute rewrite as part of preprocessing.
88    if s.contains('<') && s.contains("=\"") {
89        s = Cow::Owned(normalize_html_tag_attributes_like_upstream(s.as_ref()));
90    }
91
92    s
93}
94
95fn normalize_crlf(input: &str) -> String {
96    let mut out = String::with_capacity(input.len());
97    let mut chars = input.chars().peekable();
98    while let Some(ch) = chars.next() {
99        if ch == '\r' {
100            out.push('\n');
101            if chars.peek() == Some(&'\n') {
102                chars.next();
103            }
104        } else {
105            out.push(ch);
106        }
107    }
108    out
109}
110
111fn normalize_html_tag_attributes_like_upstream(text: &str) -> String {
112    let mut out = String::with_capacity(text.len());
113    let bytes = text.as_bytes();
114    let mut cursor = 0usize;
115    let mut probe = 0usize;
116
117    while let Some(rel_start) = text[probe..].find('<') {
118        let start = probe + rel_start;
119        let tag_start = start + 1;
120        if tag_start >= bytes.len() || !is_mermaid_js_word_byte(bytes[tag_start]) {
121            probe = tag_start;
122            continue;
123        }
124
125        let mut tag_end = tag_start + 1;
126        while tag_end < bytes.len() && is_mermaid_js_word_byte(bytes[tag_end]) {
127            tag_end += 1;
128        }
129
130        let Some(rel_end) = text[tag_end..].find('>') else {
131            probe = tag_start;
132            continue;
133        };
134        let end = tag_end + rel_end;
135
136        out.push_str(&text[cursor..start]);
137        out.push('<');
138        out.push_str(&text[tag_start..tag_end]);
139        out.push_str(&normalize_html_attributes_like_upstream(
140            &text[tag_end..end],
141        ));
142        out.push('>');
143
144        cursor = end + 1;
145        probe = end + 1;
146    }
147
148    out.push_str(&text[cursor..]);
149    out
150}
151
152fn normalize_html_attributes_like_upstream(attributes: &str) -> String {
153    let mut out = String::with_capacity(attributes.len());
154    let mut cursor = 0usize;
155    let mut probe = 0usize;
156
157    while let Some(rel_start) = attributes[probe..].find("=\"") {
158        let start = probe + rel_start;
159        let value_start = start + 2;
160        let Some(rel_end) = attributes[value_start..].find('"') else {
161            probe = value_start;
162            continue;
163        };
164        let end = value_start + rel_end;
165
166        out.push_str(&attributes[cursor..start]);
167        out.push_str("='");
168        out.push_str(&attributes[value_start..end]);
169        out.push('\'');
170
171        cursor = end + 1;
172        probe = end + 1;
173    }
174
175    out.push_str(&attributes[cursor..]);
176    out
177}
178
179fn encode_mermaid_entities_like_upstream(text: &str) -> String {
180    if !text.contains('#') {
181        return text.to_string();
182    }
183
184    // Mirrors Mermaid `encodeEntities` (Mermaid@11.12.2):
185    //
186    // 1) Protect `style...:#...;` and `classDef...:#...;` so color hex fragments are not mistaken
187    //    as entities by the `/#\\w+;/g` pass.
188    // 2) Encode `#<name>;` and `#<number>;` sequences into placeholders that do not contain `#`/`;`.
189    let mut txt = text.to_string();
190
191    if txt.contains("style") && txt.contains(';') {
192        txt = strip_hex_style_semicolons_like_upstream(&txt, "style");
193    }
194
195    if txt.contains("classDef") && txt.contains(';') {
196        txt = strip_hex_style_semicolons_like_upstream(&txt, "classDef");
197    }
198
199    if txt.contains(';') {
200        txt = encode_entity_placeholders_like_upstream(&txt);
201    }
202
203    txt
204}
205
206fn encode_entity_placeholders_like_upstream(text: &str) -> String {
207    let mut out = String::with_capacity(text.len());
208    let bytes = text.as_bytes();
209    let mut cursor = 0usize;
210
211    while let Some(rel_hash) = text[cursor..].find('#') {
212        let start = cursor + rel_hash;
213        let mut end = start + 1;
214        while end < bytes.len() && is_mermaid_js_word_byte(bytes[end]) {
215            end += 1;
216        }
217
218        if end > start + 1 && bytes.get(end) == Some(&b';') {
219            out.push_str(&text[cursor..start]);
220            let inner = &text[start + 1..end];
221            if inner.bytes().all(|b| b.is_ascii_digit()) {
222                out.push_str("fl°°");
223            } else {
224                out.push_str("fl°");
225            }
226            out.push_str(inner);
227            out.push_str("¶ß");
228            cursor = end + 1;
229        } else {
230            out.push_str(&text[cursor..=start]);
231            cursor = start + 1;
232        }
233    }
234
235    out.push_str(&text[cursor..]);
236    out
237}
238
239fn is_mermaid_js_word_byte(byte: u8) -> bool {
240    byte.is_ascii_alphanumeric() || byte == b'_'
241}
242
243fn strip_hex_style_semicolons_like_upstream(text: &str, keyword: &str) -> String {
244    let mut out = String::with_capacity(text.len());
245    let mut line_start = 0usize;
246
247    for (idx, ch) in text.char_indices() {
248        if ch == '\n' {
249            strip_hex_style_semicolons_from_line(&text[line_start..idx], keyword, &mut out);
250            out.push('\n');
251            line_start = idx + ch.len_utf8();
252        }
253    }
254
255    strip_hex_style_semicolons_from_line(&text[line_start..], keyword, &mut out);
256    out
257}
258
259fn strip_hex_style_semicolons_from_line(line: &str, keyword: &str, out: &mut String) {
260    let mut cursor = 0usize;
261    while let Some(semicolon) = find_hex_style_match(line, keyword, cursor) {
262        out.push_str(&line[cursor..semicolon]);
263        cursor = semicolon + 1;
264    }
265    out.push_str(&line[cursor..]);
266}
267
268fn find_hex_style_match(line: &str, keyword: &str, search_start: usize) -> Option<usize> {
269    let mut probe = search_start;
270    while let Some(rel_start) = line[probe..].find(keyword) {
271        let start = probe + rel_start;
272        if let Some(semicolon) = find_hex_style_match_end(line, start + keyword.len()) {
273            return Some(semicolon);
274        }
275        probe = start + keyword.len();
276    }
277    None
278}
279
280fn find_hex_style_match_end(line: &str, search_start: usize) -> Option<usize> {
281    let mut probe = search_start;
282    while let Some(rel_colon) = line[probe..].find(':') {
283        let colon = probe + rel_colon;
284        let mut hash = None;
285        for (rel, ch) in line[colon + 1..].char_indices() {
286            if ch.is_whitespace() {
287                break;
288            }
289            if ch == '#' {
290                hash = Some(colon + 1 + rel);
291                break;
292            }
293        }
294
295        if let Some(hash) = hash {
296            return line[hash + 1..].rfind(';').map(|rel| hash + 1 + rel);
297        }
298
299        probe = colon + 1;
300    }
301    None
302}
303
304fn process_frontmatter(input: &str) -> Result<(&str, Option<String>, MermaidConfig)> {
305    if !input.trim_start().starts_with("---") {
306        return Ok((input, None, MermaidConfig::empty_object()));
307    }
308
309    let Some((yaml_body, stripped)) = split_frontmatter(input) else {
310        return Ok((input, None, MermaidConfig::empty_object()));
311    };
312
313    if config_nesting_exceeds_limit(yaml_body) {
314        return Err(Error::InvalidFrontMatterYaml {
315            message: format!("config nesting exceeds {MAX_CONFIG_NESTING_DEPTH} levels"),
316        });
317    }
318
319    let raw_yaml: serde_yaml::Value =
320        serde_yaml::from_str(yaml_body).map_err(|e| Error::InvalidFrontMatterYaml {
321            message: e.to_string(),
322        })?;
323    let parsed = serde_json::to_value(raw_yaml).unwrap_or(Value::Null);
324    let parsed_obj = match parsed {
325        Value::Object(obj) => obj,
326        other => {
327            crate::config::drop_value_nonrecursive(other);
328            Default::default()
329        }
330    };
331
332    let mut title = None;
333    let mut display_mode = None;
334
335    if let Some(Value::String(t)) = parsed_obj.get("title") {
336        title = Some(t.clone());
337    }
338    if let Some(Value::String(dm)) = parsed_obj.get("displayMode") {
339        display_mode = Some(dm.clone());
340    }
341
342    let mut config = MermaidConfig::empty_object();
343    merge_top_level_frontmatter_diagram_configs(&parsed_obj, &mut config);
344    if let Some(v) = parsed_obj.get("config") {
345        config.deep_merge(v);
346    }
347    crate::config::mirror_legacy_font_family_into_theme_variables(&mut config);
348    if let Some(dm) = display_mode {
349        config.set_value("gantt.displayMode", Value::String(dm));
350    }
351
352    crate::config::drop_value_nonrecursive(Value::Object(parsed_obj));
353    Ok((stripped, title, config))
354}
355
356fn split_frontmatter(input: &str) -> Option<(&str, &str)> {
357    let after_marker = input.strip_prefix("---")?;
358    let open_line_end = after_marker.find('\n')?;
359    if !after_marker[..open_line_end].trim().is_empty() {
360        return None;
361    }
362
363    let body_start = 3 + open_line_end + 1;
364    let rest = &input[body_start..];
365    let mut offset = 0usize;
366
367    for line in rest.split_inclusive('\n') {
368        let without_newline = line.trim_end_matches(['\r', '\n']);
369        if without_newline.trim() == "---" {
370            let body = &rest[..offset];
371            let stripped = &rest[offset + line.len()..];
372            return Some((body, stripped));
373        }
374        offset += line.len();
375    }
376
377    None
378}
379
380fn merge_top_level_frontmatter_diagram_configs(
381    parsed_obj: &serde_json::Map<String, Value>,
382    config: &mut MermaidConfig,
383) {
384    // Mermaid upstream only consumes `config`, but users commonly read docs examples as allowing
385    // diagram config namespaces at the YAML root. Keep this compatibility narrow and explicit.
386    for &(source_key, target_key) in FRONTMATTER_DIAGRAM_CONFIG_ALIASES {
387        if let Some(value) = parsed_obj.get(source_key) {
388            config.set_value(target_key, crate::config::clone_value_nonrecursive(value));
389        }
390    }
391
392    for &key in FRONTMATTER_DIAGRAM_CONFIG_KEYS {
393        if let Some(value) = parsed_obj.get(key) {
394            config.set_value(key, crate::config::clone_value_nonrecursive(value));
395        }
396    }
397}
398
399fn process_directives<'a>(
400    input: &'a str,
401    registry: &DetectorRegistry,
402    diagram_type: Option<&str>,
403) -> Result<(Cow<'a, str>, MermaidConfig)> {
404    let directives = detect_directives(input)?;
405    if directives.is_empty() {
406        return Ok((Cow::Borrowed(input), MermaidConfig::empty_object()));
407    }
408    let init = detect_init(&directives, input, registry, diagram_type)?;
409    let wrap = directives.iter().any(|d| d.ty == "wrap");
410
411    let mut merged = init;
412    if wrap {
413        merged.set_value("wrap", Value::Bool(true));
414    }
415
416    Ok((Cow::Owned(remove_directives(input)), merged))
417}
418
419fn detect_init(
420    directives: &[Directive],
421    input: &str,
422    registry: &DetectorRegistry,
423    diagram_type: Option<&str>,
424) -> Result<MermaidConfig> {
425    let mut merged = MermaidConfig::empty_object();
426    let mut config_for_detect = MermaidConfig::empty_object();
427
428    for d in directives {
429        if d.ty != "init" && d.ty != "initialize" {
430            continue;
431        }
432
433        let mut args = match &d.args {
434            Some(v) => crate::config::clone_value_nonrecursive(v),
435            None => Value::Object(Default::default()),
436        };
437
438        sanitize_directive(&mut args);
439
440        // Mermaid moves a top-level `config` directive field into the diagram-type-specific config.
441        if let Some(diagram_specific) = args
442            .get("config")
443            .map(crate::config::clone_value_nonrecursive)
444        {
445            let detected = diagram_type.map(|t| t.to_string()).or_else(|| {
446                registry
447                    .detect_type(input, &mut config_for_detect)
448                    .ok()
449                    .map(ToString::to_string)
450            });
451
452            if let Some(mut ty) = detected {
453                if ty == "flowchart-v2" {
454                    ty = "flowchart".to_string();
455                }
456                if let Value::Object(obj) = &mut args {
457                    if let Some(old) = obj.insert(ty, diagram_specific) {
458                        crate::config::drop_value_nonrecursive(old);
459                    }
460                    if let Some(old) = obj.remove("config") {
461                        crate::config::drop_value_nonrecursive(old);
462                    }
463                }
464            }
465        }
466        crate::config::mirror_legacy_font_family_into_theme_variables_value(&mut args);
467
468        merged.deep_merge(&args);
469    }
470
471    Ok(merged)
472}
473
474#[derive(Debug, Clone)]
475struct Directive {
476    ty: String,
477    args: Option<Value>,
478}
479
480fn detect_directives(input: &str) -> Result<Vec<Directive>> {
481    let mut out = Vec::new();
482    let mut pos = 0;
483    let trimmed = input.trim();
484    if !trimmed.contains("%%{") {
485        return Ok(out);
486    }
487
488    // Mermaid's directive parser effectively treats single quotes as double quotes for JSON-like
489    // directive bodies. Keep this behavior, but only pay the allocation when directives exist.
490    let text = trimmed.replace('\'', "\"");
491
492    while let Some(rel) = text[pos..].find("%%{") {
493        let start = pos + rel;
494        let content_start = start + 3;
495        let Some(rel_end) = text[content_start..].find("}%%") else {
496            break;
497        };
498        let content_end = content_start + rel_end;
499        let raw = text[content_start..content_end].trim();
500
501        if let Some(d) = parse_directive(raw)? {
502            out.push(d);
503        }
504
505        pos = content_end + 3;
506    }
507
508    Ok(out)
509}
510
511#[derive(Clone)]
512enum DirectiveValuePathSegment {
513    Key(String),
514    Index(usize),
515}
516
517fn sanitize_directive(value: &mut Value) {
518    let mut stack = vec![Vec::<DirectiveValuePathSegment>::new()];
519
520    while let Some(path) = stack.pop() {
521        let Some(current) = directive_value_at_path_mut(value, &path) else {
522            continue;
523        };
524
525        match current {
526            Value::Object(map) => {
527                if let Some(old) = map.remove("secure") {
528                    crate::config::drop_value_nonrecursive(old);
529                }
530
531                let blocked_keys = map
532                    .keys()
533                    .filter(|key| key.starts_with("__"))
534                    .cloned()
535                    .collect::<Vec<_>>();
536                for key in blocked_keys {
537                    if let Some(old) = map.remove(&key) {
538                        crate::config::drop_value_nonrecursive(old);
539                    }
540                }
541
542                let child_keys = map.keys().cloned().collect::<Vec<_>>();
543                for key in child_keys.into_iter().rev() {
544                    let mut child_path = path.clone();
545                    child_path.push(DirectiveValuePathSegment::Key(key));
546                    stack.push(child_path);
547                }
548            }
549            Value::Array(arr) => {
550                for idx in (0..arr.len()).rev() {
551                    let mut child_path = path.clone();
552                    child_path.push(DirectiveValuePathSegment::Index(idx));
553                    stack.push(child_path);
554                }
555            }
556            Value::String(s) => {
557                let blocked = s.contains('<') || s.contains('>') || s.contains("url(data:");
558                if blocked {
559                    s.clear();
560                }
561            }
562            _ => {}
563        }
564    }
565}
566
567fn directive_value_at_path_mut<'a>(
568    mut value: &'a mut Value,
569    path: &[DirectiveValuePathSegment],
570) -> Option<&'a mut Value> {
571    for segment in path {
572        match segment {
573            DirectiveValuePathSegment::Key(key) => {
574                value = value.as_object_mut()?.get_mut(key)?;
575            }
576            DirectiveValuePathSegment::Index(idx) => {
577                value = value.as_array_mut()?.get_mut(*idx)?;
578            }
579        }
580    }
581    Some(value)
582}
583
584fn remove_directives(text: &str) -> String {
585    let mut out = String::with_capacity(text.len());
586    let mut pos = 0;
587    while let Some(rel) = text[pos..].find("%%{") {
588        let start = pos + rel;
589        out.push_str(&text[pos..start]);
590        let after_start = start + 3;
591        if let Some(rel_end) = text[after_start..].find("}%%") {
592            let end = after_start + rel_end + 3;
593            pos = end;
594        } else {
595            return out;
596        }
597    }
598    out.push_str(&text[pos..]);
599    out
600}
601
602fn parse_directive(raw: &str) -> Result<Option<Directive>> {
603    let raw = raw.trim();
604    if raw.is_empty() {
605        return Ok(None);
606    }
607
608    let mut chars = raw.chars().peekable();
609    let mut ty = String::new();
610    while let Some(&c) = chars.peek() {
611        if c.is_ascii_alphanumeric() || c == '_' {
612            ty.push(c);
613            chars.next();
614            continue;
615        }
616        break;
617    }
618    if ty.is_empty() {
619        return Ok(None);
620    }
621
622    while matches!(chars.peek(), Some(c) if c.is_whitespace()) {
623        chars.next();
624    }
625
626    let args = if matches!(chars.peek(), Some(':')) {
627        chars.next();
628        while matches!(chars.peek(), Some(c) if c.is_whitespace()) {
629            chars.next();
630        }
631        let rest: String = chars.collect();
632        let rest = rest.trim();
633        if rest.is_empty() {
634            None
635        } else if rest.starts_with('{') || rest.starts_with('[') {
636            if config_nesting_exceeds_limit(rest) {
637                return Err(Error::InvalidDirectiveJson {
638                    message: format!("config nesting exceeds {MAX_CONFIG_NESTING_DEPTH} levels"),
639                });
640            }
641            Some(
642                json5::from_str::<Value>(rest).map_err(|e| Error::InvalidDirectiveJson {
643                    message: e.to_string(),
644                })?,
645            )
646        } else {
647            Some(Value::String(rest.to_string()))
648        }
649    } else {
650        None
651    };
652
653    Ok(Some(Directive { ty, args }))
654}
655
656fn config_nesting_exceeds_limit(text: &str) -> bool {
657    max_flow_collection_depth(text) > MAX_CONFIG_NESTING_DEPTH
658        || max_yaml_indent_depth(text) > MAX_CONFIG_NESTING_DEPTH
659}
660
661fn max_flow_collection_depth(text: &str) -> usize {
662    let mut max_depth = 0usize;
663    let mut depth = 0usize;
664    let mut quote = None;
665    let mut escaped = false;
666
667    for ch in text.chars() {
668        if let Some(q) = quote {
669            if escaped {
670                escaped = false;
671                continue;
672            }
673            if ch == '\\' {
674                escaped = true;
675                continue;
676            }
677            if ch == q {
678                quote = None;
679            }
680            continue;
681        }
682
683        match ch {
684            '"' | '\'' => quote = Some(ch),
685            '{' | '[' => {
686                depth = depth.saturating_add(1);
687                max_depth = max_depth.max(depth);
688            }
689            '}' | ']' => {
690                depth = depth.saturating_sub(1);
691            }
692            _ => {}
693        }
694    }
695
696    max_depth
697}
698
699fn max_yaml_indent_depth(text: &str) -> usize {
700    let mut indents = Vec::<usize>::new();
701    let mut max_depth = 0usize;
702
703    for line in text.lines() {
704        let trimmed = line.trim();
705        if trimmed.is_empty() || trimmed.starts_with('#') {
706            continue;
707        }
708
709        let indent = line.len() - line.trim_start_matches(' ').len();
710        while indents.last().is_some_and(|prev| indent <= *prev) {
711            indents.pop();
712        }
713        indents.push(indent);
714        let inline_sequence_depth = yaml_inline_sequence_indicator_count(trimmed);
715        max_depth = max_depth.max(indents.len() + inline_sequence_depth.saturating_sub(1));
716    }
717
718    max_depth
719}
720
721fn yaml_inline_sequence_indicator_count(mut text: &str) -> usize {
722    let mut count = 0usize;
723    loop {
724        let Some(after_dash) = text.strip_prefix('-') else {
725            return count;
726        };
727        if after_dash
728            .chars()
729            .next()
730            .is_some_and(|ch| !ch.is_whitespace())
731        {
732            return count;
733        }
734        count += 1;
735        text = after_dash.trim_start();
736    }
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742    use serde_json::Map;
743
744    #[test]
745    fn normalize_crlf_matches_mermaid_line_ending_cleanup() {
746        assert_eq!(
747            normalize_crlf("flowchart TD\r\nA-->B\rC-->D\n"),
748            "flowchart TD\nA-->B\nC-->D\n"
749        );
750        assert_eq!(normalize_crlf("\r\r\n\n"), "\n\n\n");
751    }
752
753    #[test]
754    fn normalize_html_tag_attributes_matches_mermaid_cleanup_shape() {
755        assert_eq!(
756            normalize_html_tag_attributes_like_upstream(
757                r#"<span title="A" data-empty="">Label</span><br disabled="yes">"#
758            ),
759            r#"<span title='A' data-empty=''>Label</span><br disabled='yes'>"#
760        );
761        assert_eq!(
762            normalize_html_tag_attributes_like_upstream(r#"<é title="A"><_x value="B"><1 n="C">"#),
763            r#"<é title="A"><_x value='B'><1 n='C'>"#
764        );
765        assert_eq!(
766            normalize_html_tag_attributes_like_upstream(r#"<span a="x" title="A>B">"#),
767            r#"<span a='x' title="A>B">"#
768        );
769        assert_eq!(
770            normalize_html_tag_attributes_like_upstream(r#"<<span title="A">"#),
771            r#"<<span title='A'>"#
772        );
773    }
774
775    #[test]
776    fn encode_entity_placeholders_matches_mermaid_ascii_word_shape() {
777        assert_eq!(
778            encode_mermaid_entities_like_upstream("Hello #there; #andHere;#77653;"),
779            "Hello fl°there¶ß fl°andHere¶ßfl°°77653¶ß"
780        );
781        assert_eq!(
782            encode_mermaid_entities_like_upstream(
783                "style this; is ; everything :something#not-nothing; and this too;"
784            ),
785            "style this; is ; everything :something#not-nothing; and this too"
786        );
787        assert_eq!(
788            encode_mermaid_entities_like_upstream(
789                "classDef this; is ; everything :something#not-nothing; and this too;"
790            ),
791            "classDef this; is ; everything :something#not-nothing; and this too"
792        );
793        assert_eq!(
794            encode_mermaid_entities_like_upstream("style a fill:#fff; style b fill:#000;"),
795            "style a fill:fl°fff¶ß style b fill:#000"
796        );
797        assert_eq!(
798            encode_mermaid_entities_like_upstream("style a fill: #fff;"),
799            "style a fill: fl°fff¶ß"
800        );
801        assert_eq!(
802            encode_mermaid_entities_like_upstream("#é; #+123; #has-dash;"),
803            "#é; #+123; #has-dash;"
804        );
805    }
806
807    #[test]
808    fn sanitize_directive_handles_deep_values_with_small_stack() {
809        const DEPTH: usize = 2_048;
810        let mut value = deep_directive_value(DEPTH, Value::String("<blocked>".to_string()));
811
812        let handle = std::thread::Builder::new()
813            .name("preprocess-deep-directive-sanitize".to_string())
814            .stack_size(64 * 1024)
815            .spawn(move || {
816                sanitize_directive(&mut value);
817                assert_eq!(
818                    deep_directive_leaf(&value, DEPTH).and_then(Value::as_str),
819                    Some("")
820                );
821                crate::config::drop_value_nonrecursive(value);
822            })
823            .expect("spawn deep directive sanitizer test");
824        handle
825            .join()
826            .expect("deep directive sanitizer should finish without stack overflow");
827    }
828
829    #[test]
830    fn config_nesting_counts_inline_yaml_sequence_indicators() {
831        let yaml = format!(
832            "config:\n  {}\"leaf\"",
833            "- ".repeat(MAX_CONFIG_NESTING_DEPTH + 1)
834        );
835        assert!(config_nesting_exceeds_limit(&yaml));
836    }
837
838    fn deep_directive_value(depth: usize, leaf: Value) -> Value {
839        let mut value = leaf;
840        for idx in (0..depth).rev() {
841            let mut map = Map::new();
842            map.insert(format!("k{idx}"), value);
843            value = Value::Object(map);
844        }
845        value
846    }
847
848    fn deep_directive_leaf(mut value: &Value, depth: usize) -> Option<&Value> {
849        for idx in 0..depth {
850            value = value.as_object()?.get(&format!("k{idx}"))?;
851        }
852        Some(value)
853    }
854}