use super::{Budgets, Enhancement, Extension, Shape, SupportLevel};
pub const INFO: &str = "mermaid";
pub const BUDGETS: Budgets = Budgets {
source_bytes: 16 * 1024,
nodes: 256,
edges: 512,
depth: 32,
output_bytes: 256 * 1024,
time_nanos: 250_000_000,
};
const EDGES: &[&str] = &[
"<-->", "-.->", "==>", "-->", "---", "-.-", "===", "--x", "--o", "->", "~~~",
];
#[must_use]
pub fn measure(source: &str) -> Shape {
let mut edges = 0usize;
let mut nodes = 0usize;
let mut depth = 0usize;
for line in source.lines() {
let indent = line.len() - line.trim_start().len();
depth = depth.max(indent / 2);
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with("%%") {
continue;
}
let mut rest = trimmed;
let mut endpoint_has_content = false;
'scan: while !rest.is_empty() {
for token in EDGES {
if let Some(tail) = rest.strip_prefix(token) {
edges += 1;
if endpoint_has_content {
nodes += 1;
endpoint_has_content = false;
}
rest = tail;
continue 'scan;
}
}
let mut chars = rest.chars();
if chars.next().is_some_and(char::is_alphanumeric) {
endpoint_has_content = true;
}
rest = chars.as_str();
}
if endpoint_has_content {
nodes += 1;
}
}
Shape {
nodes,
edges,
depth,
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Mermaid;
pub static MERMAID: Mermaid = Mermaid;
impl Extension for Mermaid {
fn info(&self) -> &'static str {
INFO
}
fn budgets(&self) -> Budgets {
BUDGETS
}
fn measure(&self, source: &str) -> Shape {
measure(source)
}
fn render(&self, source: &str, level: SupportLevel) -> Option<Enhancement> {
if level < SupportLevel::Graphics {
return None;
}
let (svg, alt) = super::flowchart::render(source)?;
Enhancement::graphics(svg, alt)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::markup::extension::{Capabilities, FallbackReason, Registry, SupportLevel};
struct Frozen;
impl crate::markup::extension::Stopwatch for Frozen {
fn read_nanos(&self) -> u128 {
0
}
}
fn graphics() -> Capabilities {
Capabilities {
highest: SupportLevel::Graphics,
}
}
#[test]
fn a_supported_flowchart_renders_and_keeps_its_source() {
let reg = Registry::new().with(&MERMAID);
let src = "flowchart TD\n A[Start] --> B[Finish]";
let p = reg.present("mermaid", src, graphics(), &Frozen);
assert_eq!(p.source(), src, "the source rides along even when drawn");
let e = p.enhancement().expect("this subset renders");
assert_eq!(e.level(), SupportLevel::Graphics);
assert!(e.payload().starts_with("<svg"), "payload: {}", e.payload());
assert!(
e.accessible_text().contains("Start"),
"alt: {}",
e.accessible_text()
);
}
#[test]
fn syntax_outside_the_subset_falls_back_to_source() {
let reg = Registry::new().with(&MERMAID);
for src in [
"sequenceDiagram\n A->>B: hi",
"flowchart TD\n subgraph one\n A --> B\n end",
"flowchart TD\n A --> B\n click A callback",
"flowchart XX\n A --> B",
"gantt\n title X",
"not a diagram at all",
] {
let p = reg.present("mermaid", src, graphics(), &Frozen);
assert!(p.enhancement().is_none(), "should decline: {src:?}");
assert_eq!(p.source(), src, "and keep the source: {src:?}");
assert_eq!(p.fallback(), Some(FallbackReason::HandlerDeclined));
}
}
#[test]
fn a_source_only_surface_gets_source_for_a_renderable_diagram() {
let reg = Registry::new().with(&MERMAID);
let src = "flowchart TD\n A --> B";
let p = reg.present("mermaid", src, Capabilities::source_only(), &Frozen);
assert!(p.enhancement().is_none());
assert_eq!(p.source(), src);
}
#[test]
fn the_scan_counts_each_arrow_once() {
for (src, edges) in [
("A --> B", 1),
("A <--> B", 1),
("A -.-> B", 1),
("A --- B", 1),
("A ==> B", 1),
("A --> B --> C", 2),
("A --> B\nB --> C", 2),
("plain text", 0),
] {
assert_eq!(measure(src).edges, edges, "edges in {src:?}");
}
}
#[test]
fn the_scan_reports_nesting_depth() {
assert_eq!(measure("a").depth, 0);
assert_eq!(measure(" a").depth, 1);
assert_eq!(measure("a\n b\n c").depth, 2);
}
#[test]
fn comments_and_blanks_are_not_nodes() {
assert_eq!(measure("%% a comment\n\n \n").nodes, 0);
assert_eq!(measure("%% c\nA --> B").nodes, 2);
}
#[test]
fn the_adversarial_corpus_is_bounded_and_never_loses_source() {
let corpus: Vec<(&str, String)> = vec![
("empty", String::new()),
("only whitespace", " \n\t\n ".into()),
("no newline at all", "A-->B".repeat(50)),
("one enormous line", format!("A{}B", "-".repeat(40_000))),
("arrow soup", "-->".repeat(20_000)),
("nested arrows", "<-->-.->==>---".repeat(5_000)),
(
"deep indentation",
(0..500)
.map(|i| format!("{}n\n", " ".repeat(i * 2)))
.collect(),
),
(
"many nodes",
(0..5_000).map(|i| format!("n{i}\n")).collect(),
),
("crlf line endings", "A --> B\r\nB --> C\r\n".repeat(100)),
(
"unicode identifiers",
"日本 --> 中文\nこれ --> それ".repeat(200),
),
(
"combining marks",
"e\u{0301}\u{0301}\u{0301} --> b".repeat(500),
),
("rtl override", "A \u{202E}--> B".repeat(500)),
("nul and control bytes", "A\u{0}-->\u{7}B".repeat(500)),
("lone surrogate escape text", "A --> \\ud800".repeat(500)),
(
"comment that never ends",
format!("%%{}", "x".repeat(30_000)),
),
(
"html in a label",
"A[<script>alert(1)</script>] --> B".repeat(300),
),
("markdown fence inside", "```\nA --> B\n```".repeat(500)),
("giant single token", "n".repeat(60_000)),
("only comments", "%% c\n".repeat(10_000)),
("tabs as indentation", "\t\t\tA --> B\n".repeat(500)),
];
let reg = Registry::new().with(&MERMAID);
for (what, src) in &corpus {
let shape = measure(src);
assert!(shape.nodes <= src.len() + 1, "[{what}] nodes exceed bytes");
assert!(shape.edges <= src.len() + 1, "[{what}] edges exceed bytes");
let p = reg.present("mermaid", src, graphics(), &Frozen);
assert_eq!(p.source(), src, "[{what}] source was lost");
assert!(
p.enhancement().is_none(),
"[{what}] this crate draws nothing"
);
assert!(p.fallback().is_some(), "[{what}] a fallback must say why");
}
}
#[test]
fn an_oversized_source_is_refused_by_the_byte_budget() {
let reg = Registry::new().with(&MERMAID);
let huge = "A --> B\n".repeat(BUDGETS.source_bytes);
let p = reg.present("mermaid", &huge, graphics(), &Frozen);
assert_eq!(
p.fallback(),
Some(FallbackReason::OverBudget(
crate::markup::extension::Budget::SourceBytes
))
);
assert_eq!(p.source(), huge, "even refused, the source comes back");
}
}
#[cfg(test)]
mod e0a_shape_scan {
use super::measure;
#[test]
fn an_arrow_with_no_endpoints_declares_no_nodes() {
let shape = measure("->");
assert_eq!(shape.nodes, 0, "an arrow joining nothing declared nodes");
assert_eq!(shape.edges, 1, "the arrow itself is still an edge");
}
#[test]
fn real_endpoints_are_still_counted() {
assert_eq!(measure("A->B").nodes, 2);
assert_eq!(measure("graph TD").nodes, 1);
}
#[test]
fn a_chain_counts_every_endpoint_not_two() {
assert_eq!(measure("A->B->C").nodes, 3, "a chain under-counted");
assert_eq!(measure("A-->B\nC-->D").nodes, 4);
}
#[test]
fn punctuation_is_not_an_endpoint() {
assert_eq!(measure("!!!").nodes, 0);
assert_eq!(measure("!!!->???").nodes, 0);
assert_eq!(measure("!!!->B").nodes, 1);
}
#[test]
fn nodes_are_bounded_by_edges_plus_lines() {
for src in ["->", "A->B", "A->B->C", "graph TD", "!!!", "A-->B\nC-->D"] {
let shape = measure(src);
assert!(
shape.nodes <= shape.edges + src.lines().count(),
"{src:?}: {} nodes exceeds {} edges + {} lines",
shape.nodes,
shape.edges,
src.lines().count()
);
}
}
}