use std::collections::{HashMap, VecDeque};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::style::{self, TextRenderSettings};
const CONNECT_UP: u8 = 1;
const CONNECT_DOWN: u8 = 2;
const CONNECT_LEFT: u8 = 4;
const CONNECT_RIGHT: u8 = 8;
const LAYER_GAP_COLUMNS: usize = 3;
const LAYER_GAP_ROWS: usize = 1;
const MAX_DUMMY_NODE_COUNT: usize = 32;
const MAX_EDGE_COUNT: usize = 24;
const MAX_LABEL_WIDTH: usize = 32;
const MAX_NODE_COUNT: usize = 16;
pub const MAX_SOURCE_BYTE_COUNT: usize = 16 * 1024;
pub const MAX_SOURCE_LINE_COUNT: usize = 128;
const NODE_BOX_HEIGHT: usize = 3;
const SEQUENCE_MAX_GAP_COLUMNS: usize = MAX_LABEL_WIDTH + 2;
const SEQUENCE_MIN_GAP_COLUMNS: usize = 8;
const SEQUENCE_SELF_LOOP_COLUMNS: usize = 3;
pub struct MermaidDiagram {
pub lines: Vec<Line<'static>>,
pub width: usize,
}
pub fn render_mermaid(source: &str) -> Option<MermaidDiagram> {
render_mermaid_active_settings(source)
}
pub fn render_mermaid_with_settings(
source: &str,
settings: TextRenderSettings,
) -> Option<MermaidDiagram> {
style::with_render_settings(settings, || render_mermaid_active_settings(source))
}
fn render_mermaid_active_settings(source: &str) -> Option<MermaidDiagram> {
if !is_source_within_bounds(source) {
return None;
}
if let Some(sequence_diagram) = parse_sequence_diagram(source) {
return Some(draw_sequence_diagram(&sequence_diagram));
}
let graph = parse_graph(source)?;
if let Some(diagram) = draw_left_right_feedback_graph(&graph) {
return Some(diagram);
}
let graph = expand_long_edges(graph)?;
let layout = layout_layers(&graph)?;
let diagram = match graph.direction {
FlowDirection::TopDown => draw_top_down(&graph, &layout),
FlowDirection::LeftRight => draw_left_right(&graph, &layout),
};
Some(diagram)
}
fn is_source_within_bounds(source: &str) -> bool {
source.len() <= MAX_SOURCE_BYTE_COUNT
&& source.split('\n').take(MAX_SOURCE_LINE_COUNT + 1).count() <= MAX_SOURCE_LINE_COUNT
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FlowDirection {
LeftRight,
TopDown,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum NodeShape {
Rectangle,
Rounded,
}
struct MermaidNode {
is_hidden: bool,
is_visible: bool,
label: String,
shape: NodeShape,
}
struct MermaidEdge {
from_index: usize,
has_arrow: bool,
has_source_arrow: bool,
is_visible: bool,
label: Option<String>,
source_marker: Option<char>,
target_marker: Option<char>,
to_index: usize,
}
struct MermaidGraph {
direction: FlowDirection,
edges: Vec<MermaidEdge>,
nodes: Vec<MermaidNode>,
}
struct SequenceParticipant {
label: String,
}
struct SequenceMessage {
from_index: usize,
label: String,
to_index: usize,
}
struct SequenceDiagram {
messages: Vec<SequenceMessage>,
participants: Vec<SequenceParticipant>,
}
fn parse_sequence_diagram(source: &str) -> Option<SequenceDiagram> {
let mut lines = source
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with("%%"));
if !lines.next()?.eq_ignore_ascii_case("sequenceDiagram") {
return None;
}
let mut participant_indexes: HashMap<String, usize> = HashMap::new();
let mut participants = Vec::new();
let mut messages = Vec::new();
for line in lines {
let participant_text = line
.strip_prefix("participant ")
.or_else(|| line.strip_prefix("actor "));
if let Some(participant_text) = participant_text {
parse_sequence_participant(
participant_text,
&mut participant_indexes,
&mut participants,
)?;
continue;
}
if is_ignorable_sequence_statement(line) {
continue;
}
parse_sequence_message(
line,
&mut participant_indexes,
&mut participants,
&mut messages,
)?;
}
if participants.is_empty() || messages.is_empty() {
return None;
}
Some(SequenceDiagram {
messages,
participants,
})
}
fn is_ignorable_sequence_statement(line: &str) -> bool {
const IGNORABLE_KEYWORDS: [&str; 16] = [
"activate",
"alt",
"and",
"autonumber",
"box",
"break",
"critical",
"deactivate",
"else",
"end",
"loop",
"note",
"opt",
"option",
"par",
"rect",
];
let Some(first_token) = line.split_whitespace().next() else {
return true;
};
IGNORABLE_KEYWORDS
.iter()
.any(|keyword| first_token.eq_ignore_ascii_case(keyword))
}
fn parse_sequence_participant(
participant_text: &str,
participant_indexes: &mut HashMap<String, usize>,
participants: &mut Vec<SequenceParticipant>,
) -> Option<()> {
let (identifier, label) = if let Some((identifier, label)) = participant_text.split_once(" as ")
{
(identifier.trim(), label.trim().trim_matches('"').trim())
} else {
let identifier = participant_text.trim();
(identifier, identifier)
};
sequence_participant_index(identifier, label, participant_indexes, participants)?;
Some(())
}
fn parse_sequence_message(
line: &str,
participant_indexes: &mut HashMap<String, usize>,
participants: &mut Vec<SequenceParticipant>,
messages: &mut Vec<SequenceMessage>,
) -> Option<()> {
if messages.len() >= MAX_EDGE_COUNT {
return None;
}
let (link_text, label_text) = line.split_once(':')?;
let (from_identifier, to_identifier) = split_sequence_link(link_text)?;
let label = truncated_renderable_label(label_text)?;
let from_index = sequence_participant_index(
from_identifier,
from_identifier,
participant_indexes,
participants,
)?;
let to_index = sequence_participant_index(
to_identifier,
to_identifier,
participant_indexes,
participants,
)?;
messages.push(SequenceMessage {
from_index,
label,
to_index,
});
Some(())
}
fn split_sequence_link(link_text: &str) -> Option<(&str, &str)> {
const OPERATORS: [&str; 8] = ["-->>", "->>", "--x", "--)", "-->", "-x", "-)", "->"];
for operator in OPERATORS {
let Some((from_identifier, to_identifier)) = link_text.split_once(operator) else {
continue;
};
let to_identifier = to_identifier.trim().trim_start_matches(['+', '-']).trim();
return Some((from_identifier.trim(), to_identifier));
}
None
}
fn truncated_renderable_label(label_text: &str) -> Option<String> {
let label = normalized_mermaid_label(label_text);
if label.is_empty()
|| !label
.chars()
.all(|character| UnicodeWidthChar::width(character) == Some(1))
{
return None;
}
if label.chars().count() <= MAX_LABEL_WIDTH {
return Some(label.to_string());
}
let mut truncated: String = label.chars().take(MAX_LABEL_WIDTH - 1).collect();
truncated.push('…');
Some(truncated)
}
enum EdgeLabel {
Absent,
Present(String),
}
impl EdgeLabel {
fn into_label(self) -> Option<String> {
match self {
Self::Absent => None,
Self::Present(label) => Some(label),
}
}
}
fn parsed_edge_label(label_text: &str) -> Option<EdgeLabel> {
if normalized_mermaid_label(label_text).is_empty() {
return Some(EdgeLabel::Absent);
}
Some(EdgeLabel::Present(truncated_renderable_label(label_text)?))
}
fn sequence_participant_index(
identifier: &str,
label: &str,
participant_indexes: &mut HashMap<String, usize>,
participants: &mut Vec<SequenceParticipant>,
) -> Option<usize> {
if !is_renderable_identifier(identifier) {
return None;
}
if let Some(existing_index) = participant_indexes.get(identifier) {
return Some(*existing_index);
}
if participants.len() >= MAX_NODE_COUNT {
return None;
}
let label = truncated_renderable_label(label)?;
participants.push(SequenceParticipant { label });
participant_indexes.insert(identifier.to_string(), participants.len() - 1);
Some(participants.len() - 1)
}
fn parse_graph(source: &str) -> Option<MermaidGraph> {
let mut lines = source
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with("%%"))
.peekable();
if lines.peek()?.eq_ignore_ascii_case("erDiagram") {
lines.next();
return parse_er_graph(lines);
}
parse_flow_graph(lines)
}
fn parse_er_graph<'source>(lines: impl Iterator<Item = &'source str>) -> Option<MermaidGraph> {
let mut node_indexes: HashMap<String, usize> = HashMap::new();
let mut nodes: Vec<MermaidNode> = Vec::new();
let mut edges: Vec<MermaidEdge> = Vec::new();
let mut in_attribute_block = false;
for line in lines {
if in_attribute_block {
in_attribute_block = line != "}";
continue;
}
let (statement, opens_attribute_block) = match line.strip_suffix('{') {
Some(before_brace) => (before_brace.trim(), true),
None => (line, false),
};
if !statement.contains(char::is_whitespace) {
er_entity_index(statement, &mut node_indexes, &mut nodes)?;
in_attribute_block = opens_attribute_block;
continue;
}
if opens_attribute_block {
return None;
}
parse_er_relationship(statement, &mut node_indexes, &mut nodes, &mut edges)?;
}
bounded_graph(FlowDirection::TopDown, nodes, edges)
}
fn parse_er_relationship(
statement: &str,
node_indexes: &mut HashMap<String, usize>,
nodes: &mut Vec<MermaidNode>,
edges: &mut Vec<MermaidEdge>,
) -> Option<()> {
let (link_text, label_text) = match statement.split_once(':') {
Some((link_text, label_text)) => (link_text, Some(label_text)),
None => (statement, None),
};
let mut tokens = link_text.split_whitespace();
let from_index = er_entity_index(tokens.next()?, node_indexes, nodes)?;
let operator = tokens.next()?;
let to_index = er_entity_index(tokens.next()?, node_indexes, nodes)?;
if tokens.next().is_some() {
return None;
}
let source_marker = er_cardinality_marker(operator.get(..2)?)?;
let connector = operator.get(2..4)?;
if connector != "--" && connector != ".." {
return None;
}
let target_marker = er_cardinality_marker(operator.get(4..)?)?;
let label = match label_text {
Some(label_text) => parsed_edge_label(label_text)?.into_label(),
None => None,
};
if edges.len() >= MAX_EDGE_COUNT {
return None;
}
edges.push(MermaidEdge {
from_index,
has_arrow: false,
has_source_arrow: false,
is_visible: true,
label,
source_marker: Some(source_marker),
target_marker: Some(target_marker),
to_index,
});
Some(())
}
fn er_entity_index(
identifier: &str,
node_indexes: &mut HashMap<String, usize>,
nodes: &mut Vec<MermaidNode>,
) -> Option<usize> {
if !is_renderable_identifier(identifier) {
return None;
}
if let Some(existing_index) = node_indexes.get(identifier) {
return Some(*existing_index);
}
if nodes.len() >= MAX_NODE_COUNT {
return None;
}
nodes.push(MermaidNode {
is_hidden: false,
is_visible: true,
label: truncated_renderable_label(identifier)?,
shape: NodeShape::Rectangle,
});
node_indexes.insert(identifier.to_string(), nodes.len() - 1);
Some(nodes.len() - 1)
}
fn er_cardinality_marker(token: &str) -> Option<char> {
match token {
"||" => Some('1'),
"|o" | "o|" => Some('?'),
"}o" | "o{" => Some('*'),
"}|" | "|{" => Some('+'),
_ => None,
}
}
fn parse_flow_graph<'source>(lines: impl Iterator<Item = &'source str>) -> Option<MermaidGraph> {
let mut direction: Option<FlowDirection> = None;
let mut node_indexes: HashMap<String, usize> = HashMap::new();
let mut nodes: Vec<MermaidNode> = Vec::new();
let mut edges: Vec<MermaidEdge> = Vec::new();
for line in lines {
let mut statements = line.split(';');
if direction.is_none() {
direction = Some(parse_direction_header(statements.next()?.trim())?);
}
for statement in statements {
let statement = statement.trim();
if statement.is_empty() || is_ignorable_flow_statement(statement) {
continue;
}
parse_statement(statement, &mut node_indexes, &mut nodes, &mut edges)?;
}
}
bounded_graph(direction?, nodes, edges)
}
fn is_ignorable_flow_statement(statement: &str) -> bool {
const IGNORABLE_KEYWORDS: [&str; 8] = [
"class",
"classDef",
"click",
"direction",
"end",
"linkStyle",
"style",
"subgraph",
];
let Some(first_token) = statement.split_whitespace().next() else {
return true;
};
IGNORABLE_KEYWORDS
.iter()
.any(|keyword| first_token.eq_ignore_ascii_case(keyword))
}
fn bounded_graph(
direction: FlowDirection,
mut nodes: Vec<MermaidNode>,
edges: Vec<MermaidEdge>,
) -> Option<MermaidGraph> {
if nodes.is_empty() || nodes.len() > MAX_NODE_COUNT || edges.len() > MAX_EDGE_COUNT {
return None;
}
for node in &mut nodes {
node.label = truncated_renderable_label(&node.label)?;
}
Some(MermaidGraph {
direction,
edges,
nodes,
})
}
fn expand_long_edges(mut graph: MermaidGraph) -> Option<MermaidGraph> {
let node_layers = assign_node_layers(&graph)?;
let mut edges = Vec::with_capacity(graph.edges.len());
let mut dummy_node_count = 0;
for edge in graph.edges {
let from_layer = node_layers[edge.from_index];
let to_layer = node_layers[edge.to_index];
if to_layer <= from_layer {
return None;
}
if to_layer == from_layer + 1 {
edges.push(edge);
continue;
}
let mut from_index = edge.from_index;
let mut has_source_arrow = edge.has_source_arrow;
let mut source_marker = edge.source_marker;
let mut label = edge.label;
for _ in from_layer + 1..to_layer {
if dummy_node_count >= MAX_DUMMY_NODE_COUNT {
return None;
}
let dummy_index = graph.nodes.len();
graph.nodes.push(MermaidNode {
is_hidden: true,
is_visible: edge.is_visible,
label: String::new(),
shape: NodeShape::Rectangle,
});
dummy_node_count += 1;
edges.push(MermaidEdge {
from_index,
has_arrow: false,
has_source_arrow,
is_visible: edge.is_visible,
label: label.take(),
source_marker: source_marker.take(),
target_marker: None,
to_index: dummy_index,
});
from_index = dummy_index;
has_source_arrow = false;
}
edges.push(MermaidEdge {
from_index,
has_arrow: edge.has_arrow,
has_source_arrow,
is_visible: edge.is_visible,
label,
source_marker,
target_marker: edge.target_marker,
to_index: edge.to_index,
});
}
graph.edges = edges;
Some(graph)
}
fn parse_direction_header(line: &str) -> Option<FlowDirection> {
let mut tokens = line.split_whitespace();
let keyword = tokens.next()?;
if !keyword.eq_ignore_ascii_case("graph") && !keyword.eq_ignore_ascii_case("flowchart") {
return None;
}
let direction_token = tokens.next()?;
let direction = if direction_token.eq_ignore_ascii_case("TD")
|| direction_token.eq_ignore_ascii_case("TB")
{
FlowDirection::TopDown
} else if direction_token.eq_ignore_ascii_case("LR") {
FlowDirection::LeftRight
} else {
return None;
};
if tokens.next().is_some() {
return None;
}
Some(direction)
}
fn parse_statement(
statement: &str,
node_indexes: &mut HashMap<String, usize>,
nodes: &mut Vec<MermaidNode>,
edges: &mut Vec<MermaidEdge>,
) -> Option<()> {
let mut cursor = StatementCursor { rest: statement };
let mut from_indexes = cursor.parse_node_group(node_indexes, nodes)?;
loop {
cursor.skip_whitespace();
if cursor.rest.is_empty() {
return Some(());
}
let (has_source_arrow, has_arrow, is_visible, label) = cursor.parse_edge_operator()?;
let to_indexes = cursor.parse_node_group(node_indexes, nodes)?;
append_group_edges(
&from_indexes,
&to_indexes,
has_source_arrow,
has_arrow,
is_visible,
label.as_deref(),
edges,
)?;
from_indexes = to_indexes;
}
}
fn append_group_edges(
from_indexes: &[usize],
to_indexes: &[usize],
has_source_arrow: bool,
has_arrow: bool,
is_visible: bool,
label: Option<&str>,
edges: &mut Vec<MermaidEdge>,
) -> Option<()> {
let is_reversed = has_source_arrow && !has_arrow;
for from_index in from_indexes {
for to_index in to_indexes {
if edges.len() >= MAX_EDGE_COUNT {
return None;
}
let (source_index, target_index) = if is_reversed {
(*to_index, *from_index)
} else {
(*from_index, *to_index)
};
edges.push(MermaidEdge {
from_index: source_index,
has_arrow: has_arrow || is_reversed,
has_source_arrow: has_source_arrow && !is_reversed,
is_visible,
label: label.map(str::to_owned),
source_marker: None,
target_marker: None,
to_index: target_index,
});
}
}
Some(())
}
enum NodeShapeParse {
Bare,
Labeled(NodeShape, String),
}
enum InlineLabelParse {
Absent,
Present {
has_arrow: bool,
label: Option<String>,
},
}
struct StatementCursor<'a> {
rest: &'a str,
}
impl StatementCursor<'_> {
fn parse_node_group(
&mut self,
node_indexes: &mut HashMap<String, usize>,
nodes: &mut Vec<MermaidNode>,
) -> Option<Vec<usize>> {
let mut group = vec![self.parse_node(node_indexes, nodes)?];
loop {
self.skip_whitespace();
let Some(after_ampersand) = self.rest.strip_prefix('&') else {
break;
};
self.rest = after_ampersand;
group.push(self.parse_node(node_indexes, nodes)?);
}
Some(group)
}
fn parse_node(
&mut self,
node_indexes: &mut HashMap<String, usize>,
nodes: &mut Vec<MermaidNode>,
) -> Option<usize> {
self.skip_whitespace();
let identifier_length = self
.rest
.chars()
.take_while(|character| character.is_alphanumeric() || *character == '_')
.map(char::len_utf8)
.sum::<usize>();
if identifier_length == 0 {
return None;
}
let (identifier, remaining) = self.rest.split_at(identifier_length);
self.rest = remaining;
let shape_parse = self.parse_node_shape()?;
self.skip_class_annotation();
let node_index = if let Some(existing_index) = node_indexes.get(identifier) {
*existing_index
} else {
if nodes.len() >= MAX_NODE_COUNT {
return None;
}
nodes.push(MermaidNode {
is_hidden: false,
is_visible: true,
label: identifier.to_string(),
shape: NodeShape::Rectangle,
});
node_indexes.insert(identifier.to_string(), nodes.len() - 1);
nodes.len() - 1
};
if let NodeShapeParse::Labeled(shape, label) = shape_parse {
nodes[node_index].label = label;
nodes[node_index].shape = shape;
}
Some(node_index)
}
fn parse_node_shape(&mut self) -> Option<NodeShapeParse> {
let delimiters: [(&str, &str, NodeShape); 10] = [
("(((", ")))", NodeShape::Rounded),
("((", "))", NodeShape::Rounded),
("([", "])", NodeShape::Rounded),
("[[", "]]", NodeShape::Rectangle),
("[(", ")]", NodeShape::Rounded),
("{{", "}}", NodeShape::Rectangle),
("(", ")", NodeShape::Rounded),
("[", "]", NodeShape::Rectangle),
("{", "}", NodeShape::Rectangle),
(">", "]", NodeShape::Rectangle),
];
for (open_delimiter, close_delimiter, shape) in delimiters {
let Some(after_open) = self.rest.strip_prefix(open_delimiter) else {
continue;
};
let close_index = after_open.find(close_delimiter)?;
let label = normalized_mermaid_label(&after_open[..close_index]).to_string();
self.rest = &after_open[close_index + close_delimiter.len()..];
return Some(NodeShapeParse::Labeled(shape, label));
}
Some(NodeShapeParse::Bare)
}
fn parse_edge_operator(&mut self) -> Option<(bool, bool, bool, Option<String>)> {
self.skip_whitespace();
if let InlineLabelParse::Present { has_arrow, label } =
self.parse_inline_label_operator()?
{
let label = match label {
Some(label) => Some(label),
None => self.parse_edge_label_suffix()?.into_label(),
};
return Some((false, has_arrow, true, label));
}
let (has_source_arrow, has_arrow, is_visible) = self.parse_plain_edge_operator()?;
let label = self.parse_edge_label_suffix()?.into_label();
Some((has_source_arrow, has_arrow, is_visible, label))
}
fn parse_plain_edge_operator(&mut self) -> Option<(bool, bool, bool)> {
let operator_bytes = self.rest.as_bytes();
let mut operator_length = 0;
let mut has_source_arrow = false;
let mut has_target_arrow = false;
if let Some(source_end) = operator_bytes.first()
&& matches!(source_end, b'<' | b'o' | b'x')
{
operator_length = 1;
has_source_arrow = *source_end == b'<';
}
let run_start = operator_length;
while matches!(
operator_bytes.get(operator_length),
Some(b'-' | b'=' | b'.' | b'~')
) {
operator_length += 1;
}
if operator_length - run_start < 2 {
return None;
}
let is_visible = operator_bytes[run_start..operator_length]
.iter()
.any(|character| *character != b'~');
match operator_bytes.get(operator_length) {
Some(b'>') => {
has_target_arrow = true;
operator_length += 1;
}
Some(b'o' | b'x') => {
operator_length += 1;
}
_ => {}
}
self.rest = &self.rest[operator_length..];
Some((has_source_arrow, has_target_arrow, is_visible))
}
fn parse_edge_label_suffix(&mut self) -> Option<EdgeLabel> {
let Some(after_pipe) = self.rest.strip_prefix('|') else {
return Some(EdgeLabel::Absent);
};
let close_index = after_pipe.find('|')?;
let label = parsed_edge_label(&after_pipe[..close_index])?;
self.rest = &after_pipe[close_index + 1..];
Some(label)
}
fn parse_inline_label_operator(&mut self) -> Option<InlineLabelParse> {
let label_operators: [(&str, &str, &str); 3] = [
("--", "-->", "---"),
("-.", ".->", ".-"),
("==", "==>", "==="),
];
for (open_operator, arrow_ending, line_ending) in label_operators {
let Some(after_open) = self.rest.strip_prefix(open_operator) else {
continue;
};
if matches!(
after_open.as_bytes().first(),
Some(b'-' | b'=' | b'.' | b'~' | b'>')
) {
continue;
}
let (label_text, has_arrow, after_operator) =
if let Some(arrow_index) = after_open.find(arrow_ending) {
(
&after_open[..arrow_index],
true,
&after_open[arrow_index + arrow_ending.len()..],
)
} else if let Some(line_index) = after_open.find(line_ending) {
(
&after_open[..line_index],
false,
&after_open[line_index + line_ending.len()..],
)
} else {
continue;
};
self.rest = after_operator;
let label = parsed_edge_label(label_text)?.into_label();
return Some(InlineLabelParse::Present { has_arrow, label });
}
Some(InlineLabelParse::Absent)
}
fn skip_class_annotation(&mut self) {
let Some(after_marker) = self.rest.strip_prefix(":::") else {
return;
};
let class_length = after_marker
.chars()
.take_while(|character| {
character.is_alphanumeric() || *character == '_' || *character == '-'
})
.map(char::len_utf8)
.sum::<usize>();
self.rest = &after_marker[class_length..];
}
fn skip_whitespace(&mut self) {
self.rest = self.rest.trim_start();
}
}
fn normalized_mermaid_label(label_text: &str) -> &str {
first_mermaid_label_line(label_text)
.trim()
.trim_matches('"')
.trim()
}
fn first_mermaid_label_line(label_text: &str) -> &str {
for delimiter in ["<br/>", "<br />", "<br>"] {
if let Some((first_line, _)) = label_text.split_once(delimiter) {
return first_line;
}
}
label_text
}
fn is_renderable_identifier(identifier: &str) -> bool {
!identifier.is_empty()
&& identifier
.chars()
.all(|character| character.is_alphanumeric() || character == '_' || character == '-')
}
struct GraphLayout {
layer_members: Vec<Vec<usize>>,
node_layers: Vec<usize>,
}
fn draw_left_right_feedback_graph(graph: &MermaidGraph) -> Option<MermaidDiagram> {
if !is_left_right_feedback_graph(graph) {
return None;
}
let node_widths = left_right_node_widths(graph);
let label_width = graph
.edges
.iter()
.filter_map(|edge| edge.label.as_deref())
.map(UnicodeWidthStr::width)
.max()
.unwrap_or(0);
let gap_width = label_width.max(LAYER_GAP_COLUMNS * 2 + 4);
let right_column = node_widths[0] + gap_width;
let canvas_width = right_column + node_widths[1];
let canvas_height = NODE_BOX_HEIGHT + graph.edges.len() * 2;
let left_center = node_widths[0] / 2;
let right_center = right_column + node_widths[1] / 2;
let mut canvas = Canvas::new(canvas_width, canvas_height);
draw_node_box(&mut canvas, &graph.nodes[0], 0, 0, node_widths[0]);
draw_node_box(
&mut canvas,
&graph.nodes[1],
right_column,
0,
node_widths[1],
);
for (edge_index, edge) in graph.edges.iter().enumerate() {
if !edge.is_visible {
continue;
}
let label_row = NODE_BOX_HEIGHT + edge_index * 2;
let arrow_row = label_row + 1;
draw_feedback_edge_label(&mut canvas, edge, left_center, right_center, label_row);
draw_feedback_edge_arrow(&mut canvas, edge, left_center, right_center, arrow_row);
}
Some(canvas.into_diagram())
}
fn is_left_right_feedback_graph(graph: &MermaidGraph) -> bool {
if !matches!(graph.direction, FlowDirection::LeftRight)
|| graph.nodes.len() != 2
|| graph.edges.len() != 2
{
return false;
}
graph.edges.iter().all(|edge| {
(edge.from_index == 0 && edge.to_index == 1) || (edge.from_index == 1 && edge.to_index == 0)
}) && graph
.edges
.iter()
.any(|edge| edge.from_index == 0 && edge.to_index == 1)
&& graph
.edges
.iter()
.any(|edge| edge.from_index == 1 && edge.to_index == 0)
}
fn draw_feedback_edge_label(
canvas: &mut Canvas,
edge: &MermaidEdge,
left_center: usize,
right_center: usize,
row: usize,
) {
let Some(label) = &edge.label else {
return;
};
let label_width = UnicodeWidthStr::width(label.as_str());
let run_width = right_center.saturating_sub(left_center + 1);
if label_width > run_width {
return;
}
let start_column = left_center + 1 + (run_width - label_width) / 2;
canvas.try_write_label(start_column, row, label);
}
fn draw_feedback_edge_arrow(
canvas: &mut Canvas,
edge: &MermaidEdge,
left_center: usize,
right_center: usize,
row: usize,
) {
for column in left_center..=right_center {
canvas.merge_connector(column, row, CONNECT_LEFT | CONNECT_RIGHT);
}
if edge.has_source_arrow {
if edge.from_index == 1 {
canvas.put_arrow(right_center, row, '▶');
} else {
canvas.put_arrow(left_center, row, '◀');
}
}
if edge.has_arrow {
if edge.to_index == 1 {
canvas.put_arrow(right_center, row, '▶');
} else {
canvas.put_arrow(left_center, row, '◀');
}
}
}
fn draw_sequence_diagram(diagram: &SequenceDiagram) -> MermaidDiagram {
let gap_columns = sequence_gap_columns(diagram);
let participant_widths: Vec<usize> = diagram
.participants
.iter()
.map(|participant| UnicodeWidthStr::width(participant.label.as_str()) + 4)
.collect();
let mut participant_columns = Vec::with_capacity(diagram.participants.len());
let mut next_column = 0;
for participant_width in &participant_widths {
participant_columns.push(next_column);
next_column += *participant_width + gap_columns;
}
let lifeline_columns: Vec<usize> = participant_columns
.iter()
.zip(&participant_widths)
.map(|(left_column, width)| left_column + width / 2)
.collect();
let participant_width = next_column.saturating_sub(gap_columns);
let self_message_width = sequence_self_message_width(diagram, &lifeline_columns);
let canvas_width = participant_width.max(self_message_width).max(1);
let canvas_height = NODE_BOX_HEIGHT + 1 + diagram.messages.len() * 2;
let mut canvas = Canvas::new(canvas_width, canvas_height);
for column in &lifeline_columns {
for row in NODE_BOX_HEIGHT..canvas_height {
canvas.merge_connector(*column, row, CONNECT_UP | CONNECT_DOWN);
}
}
for (participant_index, participant) in diagram.participants.iter().enumerate() {
draw_node_box(
&mut canvas,
&MermaidNode {
is_hidden: false,
is_visible: true,
label: participant.label.clone(),
shape: NodeShape::Rectangle,
},
participant_columns[participant_index],
0,
participant_widths[participant_index],
);
}
for (message_index, message) in diagram.messages.iter().enumerate() {
let row = NODE_BOX_HEIGHT + 1 + message_index * 2;
draw_sequence_message(&mut canvas, &lifeline_columns, message, row);
}
canvas.into_diagram()
}
fn sequence_gap_columns(diagram: &SequenceDiagram) -> usize {
let widest_label = diagram
.messages
.iter()
.map(|message| UnicodeWidthStr::width(message.label.as_str()))
.max()
.unwrap_or(0);
(widest_label + 2).clamp(SEQUENCE_MIN_GAP_COLUMNS, SEQUENCE_MAX_GAP_COLUMNS)
}
fn sequence_self_message_width(diagram: &SequenceDiagram, lifeline_columns: &[usize]) -> usize {
diagram
.messages
.iter()
.filter(|message| message.from_index == message.to_index)
.map(|message| {
let lifeline_column = lifeline_columns[message.from_index];
let label_width = UnicodeWidthStr::width(message.label.as_str());
let label_end = sequence_self_label_column(lifeline_column, label_width) + label_width;
label_end.max(lifeline_column + SEQUENCE_SELF_LOOP_COLUMNS + 1)
})
.max()
.unwrap_or(0)
}
fn draw_sequence_message(
canvas: &mut Canvas,
lifeline_columns: &[usize],
message: &SequenceMessage,
row: usize,
) {
let source_column = lifeline_columns[message.from_index];
let target_column = lifeline_columns[message.to_index];
if source_column == target_column {
draw_sequence_self_message(canvas, source_column, &message.label, row);
return;
}
let left_column = source_column.min(target_column);
let right_column = source_column.max(target_column);
for column in left_column..=right_column {
canvas.merge_connector(column, row, CONNECT_LEFT | CONNECT_RIGHT);
}
if target_column >= source_column {
canvas.put_arrow(target_column, row, '▶');
} else {
canvas.put_arrow(target_column, row, '◀');
}
draw_sequence_message_label(canvas, left_column, right_column, row, &message.label);
}
fn draw_sequence_self_message(
canvas: &mut Canvas,
lifeline_column: usize,
label: &str,
arrow_row: usize,
) {
let loop_column = lifeline_column + SEQUENCE_SELF_LOOP_COLUMNS;
let label_row = arrow_row.saturating_sub(1);
canvas.merge_connector(lifeline_column, label_row, CONNECT_RIGHT);
for column in lifeline_column + 1..loop_column {
canvas.merge_connector(column, label_row, CONNECT_LEFT | CONNECT_RIGHT);
canvas.merge_connector(column, arrow_row, CONNECT_LEFT | CONNECT_RIGHT);
}
canvas.merge_connector(loop_column, label_row, CONNECT_DOWN | CONNECT_LEFT);
canvas.merge_connector(loop_column, arrow_row, CONNECT_UP | CONNECT_LEFT);
canvas.put_arrow(lifeline_column, arrow_row, '◀');
let start_column = sequence_self_label_column(lifeline_column, UnicodeWidthStr::width(label));
for (character_index, character) in label.chars().enumerate() {
canvas.put_label(start_column + character_index, label_row, character);
}
}
fn sequence_self_label_column(lifeline_column: usize, label_width: usize) -> usize {
if label_width < lifeline_column {
return lifeline_column - 1 - label_width;
}
lifeline_column + SEQUENCE_SELF_LOOP_COLUMNS + 2
}
fn draw_sequence_message_label(
canvas: &mut Canvas,
left_column: usize,
right_column: usize,
arrow_row: usize,
label: &str,
) {
let label_width = UnicodeWidthStr::width(label);
let run_width = right_column.saturating_sub(left_column + 1);
if label_width > run_width {
return;
}
let start_column = left_column + 1 + (run_width - label_width) / 2;
let label_row = arrow_row.saturating_sub(1);
for (character_index, character) in label.chars().enumerate() {
canvas.put_label(start_column + character_index, label_row, character);
}
}
fn layout_layers(graph: &MermaidGraph) -> Option<GraphLayout> {
let node_layers = assign_node_layers(graph)?;
for edge in &graph.edges {
if node_layers[edge.to_index] != node_layers[edge.from_index] + 1 {
return None;
}
}
let layer_count = node_layers
.iter()
.max()
.map_or(0, |max_layer| max_layer + 1);
let mut layer_members: Vec<Vec<usize>> = vec![Vec::new(); layer_count];
for (node_index, layer) in node_layers.iter().enumerate() {
layer_members[*layer].push(node_index);
}
Some(GraphLayout {
layer_members,
node_layers,
})
}
fn assign_node_layers(graph: &MermaidGraph) -> Option<Vec<usize>> {
let node_count = graph.nodes.len();
let mut indegrees = vec![0_usize; node_count];
let mut outgoing: Vec<Vec<usize>> = vec![Vec::new(); node_count];
for edge in &graph.edges {
if edge.from_index == edge.to_index {
return None;
}
indegrees[edge.to_index] += 1;
outgoing[edge.from_index].push(edge.to_index);
}
let mut node_layers = vec![0_usize; node_count];
let mut ready: VecDeque<usize> = (0..node_count)
.filter(|node_index| indegrees[*node_index] == 0)
.collect();
let mut processed_count = 0;
while let Some(node_index) = ready.pop_front() {
processed_count += 1;
for target_index in &outgoing[node_index] {
node_layers[*target_index] =
node_layers[*target_index].max(node_layers[node_index] + 1);
indegrees[*target_index] -= 1;
if indegrees[*target_index] == 0 {
ready.push_back(*target_index);
}
}
}
if processed_count != node_count {
return None;
}
Some(node_layers)
}
struct TopDownEdgePath {
arrow_row: usize,
has_arrow: bool,
has_source_arrow: bool,
is_visible: bool,
label: Option<String>,
region_top: usize,
source_column: usize,
source_marker: Option<char>,
target_column: usize,
target_marker: Option<char>,
track_row: usize,
}
fn draw_top_down(graph: &MermaidGraph, layout: &GraphLayout) -> MermaidDiagram {
let box_widths = top_down_node_widths(graph);
let layer_widths = top_down_layer_widths(layout, &box_widths);
let canvas_width = layer_widths.iter().copied().max().unwrap_or(1);
let region_edges = graph_region_edges(graph, layout);
let (layer_top_rows, canvas_height) = top_down_layer_top_rows(layout, ®ion_edges);
let mut canvas = Canvas::new(canvas_width, canvas_height);
let box_columns = draw_top_down_nodes(
&mut canvas,
graph,
layout,
&box_widths,
&layer_widths,
&layer_top_rows,
canvas_width,
);
let edge_paths = top_down_edge_paths(
graph,
®ion_edges,
&layer_top_rows,
&box_columns,
&box_widths,
);
draw_top_down_edge_paths(&mut canvas, &edge_paths);
canvas.into_diagram()
}
fn top_down_node_widths(graph: &MermaidGraph) -> Vec<usize> {
graph.nodes.iter().map(top_down_node_width).collect()
}
fn top_down_node_width(node: &MermaidNode) -> usize {
if node.is_hidden {
return 1;
}
UnicodeWidthStr::width(node.label.as_str()) + 4
}
fn top_down_layer_widths(layout: &GraphLayout, box_widths: &[usize]) -> Vec<usize> {
layout
.layer_members
.iter()
.map(|members| {
members
.iter()
.map(|node_index| box_widths[*node_index])
.sum::<usize>()
+ LAYER_GAP_COLUMNS * members.len().saturating_sub(1)
})
.collect()
}
fn graph_region_edges<'graph>(
graph: &'graph MermaidGraph,
layout: &GraphLayout,
) -> Vec<Vec<&'graph MermaidEdge>> {
let mut region_edges = vec![Vec::new(); layout.layer_members.len().saturating_sub(1)];
for edge in &graph.edges {
region_edges[layout.node_layers[edge.from_index]].push(edge);
}
region_edges
}
fn top_down_layer_top_rows(
layout: &GraphLayout,
region_edges: &[Vec<&MermaidEdge>],
) -> (Vec<usize>, usize) {
let layer_count = layout.layer_members.len();
let mut layer_top_rows = Vec::with_capacity(layer_count);
let mut next_row = 0;
for region_edges_after_layer in region_edges.iter().take(layer_count.saturating_sub(1)) {
layer_top_rows.push(next_row);
next_row += NODE_BOX_HEIGHT;
next_row += region_edges_after_layer.len() + 2;
}
if layer_count > 0 {
layer_top_rows.push(next_row);
next_row += NODE_BOX_HEIGHT;
}
(layer_top_rows, next_row)
}
fn draw_top_down_nodes(
canvas: &mut Canvas,
graph: &MermaidGraph,
layout: &GraphLayout,
box_widths: &[usize],
layer_widths: &[usize],
layer_top_rows: &[usize],
canvas_width: usize,
) -> Vec<usize> {
let mut box_columns = vec![0_usize; graph.nodes.len()];
for (layer_index, members) in layout.layer_members.iter().enumerate() {
let mut cursor_column = (canvas_width - layer_widths[layer_index]) / 2;
for node_index in members {
box_columns[*node_index] = cursor_column;
if graph.nodes[*node_index].is_hidden && graph.nodes[*node_index].is_visible {
draw_top_down_hidden_node(canvas, cursor_column, layer_top_rows[layer_index]);
} else if graph.nodes[*node_index].is_visible {
draw_node_box(
canvas,
&graph.nodes[*node_index],
cursor_column,
layer_top_rows[layer_index],
box_widths[*node_index],
);
}
cursor_column += box_widths[*node_index] + LAYER_GAP_COLUMNS;
}
}
box_columns
}
fn top_down_edge_paths(
graph: &MermaidGraph,
region_edges: &[Vec<&MermaidEdge>],
layer_top_rows: &[usize],
box_columns: &[usize],
box_widths: &[usize],
) -> Vec<TopDownEdgePath> {
let mut edge_paths = Vec::with_capacity(graph.edges.len());
for (layer_index, edges) in region_edges.iter().enumerate() {
let region_top = layer_top_rows[layer_index] + NODE_BOX_HEIGHT;
for (track_index, edge) in edges.iter().enumerate() {
edge_paths.push(TopDownEdgePath {
arrow_row: region_top + edges.len() + 1,
has_arrow: edge.has_arrow,
has_source_arrow: edge.has_source_arrow,
is_visible: edge.is_visible,
label: edge.label.clone(),
region_top,
source_column: box_columns[edge.from_index] + box_widths[edge.from_index] / 2,
source_marker: edge.source_marker,
target_column: box_columns[edge.to_index] + box_widths[edge.to_index] / 2,
target_marker: edge.target_marker,
track_row: region_top + 1 + track_index,
});
}
}
edge_paths
}
fn draw_top_down_edge_paths(canvas: &mut Canvas, edge_paths: &[TopDownEdgePath]) {
for edge_path in edge_paths.iter().filter(|edge_path| edge_path.is_visible) {
draw_top_down_edge_connectors(canvas, edge_path);
}
for edge_path in edge_paths.iter().filter(|edge_path| edge_path.is_visible) {
draw_top_down_edge_label(canvas, edge_path);
}
for edge_path in edge_paths.iter().filter(|edge_path| edge_path.is_visible) {
canvas.try_put_marker(
edge_path.source_column,
edge_path.region_top,
edge_path.source_marker,
);
canvas.try_put_marker(
edge_path.target_column,
edge_path.arrow_row,
edge_path.target_marker,
);
}
}
fn draw_top_down_hidden_node(canvas: &mut Canvas, column: usize, top_row: usize) {
for row in top_row..top_row + NODE_BOX_HEIGHT {
canvas.merge_connector(column, row, CONNECT_UP | CONNECT_DOWN);
}
}
fn draw_top_down_edge_connectors(canvas: &mut Canvas, edge_path: &TopDownEdgePath) {
for row in edge_path.region_top..edge_path.track_row {
canvas.merge_connector(edge_path.source_column, row, CONNECT_UP | CONNECT_DOWN);
}
if edge_path.source_column == edge_path.target_column {
for row in edge_path.track_row..edge_path.arrow_row {
canvas.merge_connector(edge_path.source_column, row, CONNECT_UP | CONNECT_DOWN);
}
} else {
let left_column = edge_path.source_column.min(edge_path.target_column);
let right_column = edge_path.source_column.max(edge_path.target_column);
let (source_mask, target_mask) = if edge_path.target_column > edge_path.source_column {
(CONNECT_UP | CONNECT_RIGHT, CONNECT_DOWN | CONNECT_LEFT)
} else {
(CONNECT_UP | CONNECT_LEFT, CONNECT_DOWN | CONNECT_RIGHT)
};
canvas.merge_connector(edge_path.source_column, edge_path.track_row, source_mask);
canvas.merge_connector(edge_path.target_column, edge_path.track_row, target_mask);
for column in left_column + 1..right_column {
canvas.merge_connector(column, edge_path.track_row, CONNECT_LEFT | CONNECT_RIGHT);
}
for row in edge_path.track_row + 1..edge_path.arrow_row {
canvas.merge_connector(edge_path.target_column, row, CONNECT_UP | CONNECT_DOWN);
}
}
if edge_path.has_arrow {
canvas.put_arrow(edge_path.target_column, edge_path.arrow_row, '▼');
} else {
canvas.merge_connector(
edge_path.target_column,
edge_path.arrow_row,
CONNECT_UP | CONNECT_DOWN,
);
}
if edge_path.has_source_arrow {
canvas.put_arrow(edge_path.source_column, edge_path.region_top, '▲');
}
}
fn draw_top_down_edge_label(canvas: &mut Canvas, edge_path: &TopDownEdgePath) {
let Some(label) = &edge_path.label else {
return;
};
let label_width = UnicodeWidthStr::width(label.as_str());
if edge_path.source_column == edge_path.target_column {
canvas.try_write_label(edge_path.source_column + 2, edge_path.track_row, label);
return;
}
let left_column = edge_path.source_column.min(edge_path.target_column);
let right_column = edge_path.source_column.max(edge_path.target_column);
let run_width = right_column.saturating_sub(left_column + 1);
if label_width > run_width {
return;
}
let start_column = left_column + 1 + (run_width - label_width) / 2;
canvas.try_write_label(start_column, edge_path.track_row, label);
}
struct LeftRightEdgePath {
arrow_column: usize,
has_arrow: bool,
has_source_arrow: bool,
is_visible: bool,
label: Option<String>,
region_left: usize,
source_marker: Option<char>,
source_row: usize,
target_marker: Option<char>,
target_row: usize,
track_column: usize,
}
fn draw_left_right(graph: &MermaidGraph, layout: &GraphLayout) -> MermaidDiagram {
let node_widths = left_right_node_widths(graph);
let layer_widths = left_right_layer_widths(layout, &node_widths);
let layer_heights = left_right_layer_heights(layout);
let canvas_height = layer_heights.iter().copied().max().unwrap_or(1);
let region_edges = graph_region_edges(graph, layout);
let (layer_left_columns, canvas_width) =
left_right_layer_left_columns(layout, ®ion_edges, &layer_widths);
let mut canvas = Canvas::new(canvas_width, canvas_height);
let box_rows = draw_left_right_nodes(
&mut canvas,
graph,
layout,
&layer_widths,
&layer_heights,
&layer_left_columns,
canvas_height,
);
let edge_paths = left_right_edge_paths(
graph,
®ion_edges,
&layer_left_columns,
&layer_widths,
&box_rows,
);
draw_left_right_edge_paths(&mut canvas, &edge_paths);
canvas.into_diagram()
}
fn left_right_node_widths(graph: &MermaidGraph) -> Vec<usize> {
graph.nodes.iter().map(left_right_node_width).collect()
}
fn left_right_node_width(node: &MermaidNode) -> usize {
if node.is_hidden {
return 1;
}
UnicodeWidthStr::width(node.label.as_str()) + 4
}
fn left_right_layer_widths(layout: &GraphLayout, node_widths: &[usize]) -> Vec<usize> {
layout
.layer_members
.iter()
.map(|members| {
members
.iter()
.map(|node_index| node_widths[*node_index])
.max()
.unwrap_or(1)
})
.collect()
}
fn left_right_layer_heights(layout: &GraphLayout) -> Vec<usize> {
layout
.layer_members
.iter()
.map(|members| {
members.len() * NODE_BOX_HEIGHT + LAYER_GAP_ROWS * members.len().saturating_sub(1)
})
.collect()
}
fn left_right_layer_left_columns(
layout: &GraphLayout,
region_edges: &[Vec<&MermaidEdge>],
layer_widths: &[usize],
) -> (Vec<usize>, usize) {
let layer_count = layout.layer_members.len();
let mut layer_left_columns = Vec::with_capacity(layer_count);
let mut next_column = 0;
for layer_index in 0..layer_count {
layer_left_columns.push(next_column);
next_column += layer_widths[layer_index];
if layer_index + 1 < layer_count {
next_column += region_edges[layer_index].len() + 2;
}
}
(layer_left_columns, next_column)
}
fn draw_left_right_nodes(
canvas: &mut Canvas,
graph: &MermaidGraph,
layout: &GraphLayout,
layer_widths: &[usize],
layer_heights: &[usize],
layer_left_columns: &[usize],
canvas_height: usize,
) -> Vec<usize> {
let mut box_rows = vec![0_usize; graph.nodes.len()];
for (layer_index, members) in layout.layer_members.iter().enumerate() {
let mut cursor_row = (canvas_height - layer_heights[layer_index]) / 2;
for node_index in members {
box_rows[*node_index] = cursor_row;
if graph.nodes[*node_index].is_hidden && graph.nodes[*node_index].is_visible {
draw_left_right_hidden_node(
canvas,
layer_left_columns[layer_index],
cursor_row + 1,
layer_widths[layer_index],
);
} else if graph.nodes[*node_index].is_visible {
draw_node_box(
canvas,
&graph.nodes[*node_index],
layer_left_columns[layer_index],
cursor_row,
layer_widths[layer_index],
);
}
cursor_row += NODE_BOX_HEIGHT + LAYER_GAP_ROWS;
}
}
box_rows
}
fn left_right_edge_paths(
graph: &MermaidGraph,
region_edges: &[Vec<&MermaidEdge>],
layer_left_columns: &[usize],
layer_widths: &[usize],
box_rows: &[usize],
) -> Vec<LeftRightEdgePath> {
let mut edge_paths = Vec::with_capacity(graph.edges.len());
for (layer_index, edges) in region_edges.iter().enumerate() {
let region_left = layer_left_columns[layer_index] + layer_widths[layer_index];
for (track_index, edge) in edges.iter().enumerate() {
edge_paths.push(LeftRightEdgePath {
arrow_column: region_left + edges.len() + 1,
has_arrow: edge.has_arrow,
has_source_arrow: edge.has_source_arrow,
is_visible: edge.is_visible,
label: edge.label.clone(),
region_left,
source_marker: edge.source_marker,
source_row: box_rows[edge.from_index] + 1,
target_marker: edge.target_marker,
target_row: box_rows[edge.to_index] + 1,
track_column: region_left + 1 + track_index,
});
}
}
edge_paths
}
fn draw_left_right_edge_paths(canvas: &mut Canvas, edge_paths: &[LeftRightEdgePath]) {
for edge_path in edge_paths.iter().filter(|edge_path| edge_path.is_visible) {
draw_left_right_edge_connectors(canvas, edge_path);
}
for edge_path in edge_paths.iter().filter(|edge_path| edge_path.is_visible) {
draw_left_right_edge_label(canvas, edge_path);
}
for edge_path in edge_paths.iter().filter(|edge_path| edge_path.is_visible) {
canvas.try_put_marker(
edge_path.region_left,
edge_path.source_row,
edge_path.source_marker,
);
canvas.try_put_marker(
edge_path.arrow_column,
edge_path.target_row,
edge_path.target_marker,
);
}
}
fn draw_left_right_hidden_node(canvas: &mut Canvas, left_column: usize, row: usize, width: usize) {
for column in left_column..left_column + width {
canvas.merge_connector(column, row, CONNECT_LEFT | CONNECT_RIGHT);
}
}
fn draw_left_right_edge_connectors(canvas: &mut Canvas, edge_path: &LeftRightEdgePath) {
for column in edge_path.region_left..edge_path.track_column {
canvas.merge_connector(column, edge_path.source_row, CONNECT_LEFT | CONNECT_RIGHT);
}
if edge_path.source_row == edge_path.target_row {
for column in edge_path.track_column..edge_path.arrow_column {
canvas.merge_connector(column, edge_path.source_row, CONNECT_LEFT | CONNECT_RIGHT);
}
} else {
let top_row = edge_path.source_row.min(edge_path.target_row);
let bottom_row = edge_path.source_row.max(edge_path.target_row);
let (source_mask, target_mask) = if edge_path.target_row > edge_path.source_row {
(CONNECT_LEFT | CONNECT_DOWN, CONNECT_UP | CONNECT_RIGHT)
} else {
(CONNECT_LEFT | CONNECT_UP, CONNECT_DOWN | CONNECT_RIGHT)
};
canvas.merge_connector(edge_path.track_column, edge_path.source_row, source_mask);
canvas.merge_connector(edge_path.track_column, edge_path.target_row, target_mask);
for row in top_row + 1..bottom_row {
canvas.merge_connector(edge_path.track_column, row, CONNECT_UP | CONNECT_DOWN);
}
for column in edge_path.track_column + 1..edge_path.arrow_column {
canvas.merge_connector(column, edge_path.target_row, CONNECT_LEFT | CONNECT_RIGHT);
}
}
if edge_path.has_arrow {
canvas.put_arrow(edge_path.arrow_column, edge_path.target_row, '▶');
} else {
canvas.merge_connector(
edge_path.arrow_column,
edge_path.target_row,
CONNECT_LEFT | CONNECT_RIGHT,
);
}
if edge_path.has_source_arrow {
canvas.put_arrow(edge_path.region_left, edge_path.source_row, '◀');
}
}
fn draw_left_right_edge_label(canvas: &mut Canvas, edge_path: &LeftRightEdgePath) {
let Some(label) = &edge_path.label else {
return;
};
let label_width = UnicodeWidthStr::width(label.as_str());
let run_end = if edge_path.source_row == edge_path.target_row {
edge_path.arrow_column
} else {
edge_path.track_column
};
let run_width = run_end.saturating_sub(edge_path.region_left);
if label_width > run_width {
return;
}
let start_column = edge_path.region_left + (run_width - label_width) / 2;
canvas.try_write_label(start_column, edge_path.source_row, label);
}
fn draw_node_box(
canvas: &mut Canvas,
node: &MermaidNode,
left_column: usize,
top_row: usize,
box_width: usize,
) {
let (top_left, top_right, bottom_left, bottom_right) = match node.shape {
NodeShape::Rectangle => ('┌', '┐', '└', '┘'),
NodeShape::Rounded => ('╭', '╮', '╰', '╯'),
};
let right_column = left_column + box_width - 1;
let middle_row = top_row + 1;
let bottom_row = top_row + 2;
canvas.put_border(left_column, top_row, top_left);
canvas.put_border(right_column, top_row, top_right);
canvas.put_border(left_column, bottom_row, bottom_left);
canvas.put_border(right_column, bottom_row, bottom_right);
for column in left_column + 1..right_column {
canvas.put_border(column, top_row, '─');
canvas.put_border(column, bottom_row, '─');
}
canvas.put_border(left_column, middle_row, '│');
canvas.put_border(right_column, middle_row, '│');
let label_width = UnicodeWidthStr::width(node.label.as_str());
let inner_width = box_width - 2;
let label_column = left_column + 1 + (inner_width - label_width) / 2;
for (character_index, character) in node.label.chars().enumerate() {
canvas.put_label(label_column + character_index, middle_row, character);
}
}
#[derive(Clone, Copy, PartialEq)]
enum CanvasCell {
Arrow(char),
Border(char),
Connector(u8),
Empty,
Label(char),
}
struct Canvas {
cells: Vec<Vec<CanvasCell>>,
}
impl Canvas {
fn new(width: usize, height: usize) -> Self {
Self {
cells: vec![vec![CanvasCell::Empty; width]; height],
}
}
fn put_border(&mut self, column: usize, row: usize, character: char) {
self.put(column, row, CanvasCell::Border(character));
}
fn put_label(&mut self, column: usize, row: usize, character: char) {
self.put(column, row, CanvasCell::Label(character));
}
fn put_arrow(&mut self, column: usize, row: usize, character: char) {
self.put(column, row, CanvasCell::Arrow(character));
}
fn merge_connector(&mut self, column: usize, row: usize, mask: u8) {
let Some(cell) = self.cell_mut(column, row) else {
return;
};
match cell {
CanvasCell::Empty => *cell = CanvasCell::Connector(mask),
CanvasCell::Connector(existing_mask) => *existing_mask |= mask,
CanvasCell::Arrow(_) | CanvasCell::Border(_) | CanvasCell::Label(_) => {}
}
}
fn try_write_label(&mut self, start_column: usize, row: usize, label: &str) {
let character_count = label.chars().count();
let Some(row_cells) = self.cells.get(row) else {
return;
};
if start_column + character_count > row_cells.len() {
return;
}
let is_writable = row_cells[start_column..start_column + character_count].iter().all(|cell| {
matches!(cell, CanvasCell::Empty)
|| matches!(cell, CanvasCell::Connector(mask) if *mask == CONNECT_LEFT | CONNECT_RIGHT)
});
if !is_writable {
return;
}
for (character_index, character) in label.chars().enumerate() {
self.put_label(start_column + character_index, row, character);
}
}
fn try_put_marker(&mut self, column: usize, row: usize, marker: Option<char>) {
let Some(marker) = marker else {
return;
};
let Some(cell) = self.cell_mut(column, row) else {
return;
};
let is_straight_connector = matches!(
cell,
CanvasCell::Connector(mask)
if *mask == CONNECT_UP | CONNECT_DOWN || *mask == CONNECT_LEFT | CONNECT_RIGHT
);
if is_straight_connector {
*cell = CanvasCell::Label(marker);
}
}
fn into_diagram(self) -> MermaidDiagram {
let mut lines = Vec::with_capacity(self.cells.len());
let mut width = 0;
for row_cells in &self.cells {
let trimmed_length = row_cells
.iter()
.rposition(|cell| *cell != CanvasCell::Empty)
.map_or(0, |last_index| last_index + 1);
width = width.max(trimmed_length);
let mut spans: Vec<Span<'static>> = Vec::new();
for cell in &row_cells[..trimmed_length] {
let (character, cell_style) = Self::cell_presentation(*cell);
match spans.last_mut() {
Some(last_span) if last_span.style == cell_style => {
last_span.content.to_mut().push(character);
}
_ => spans.push(Span::styled(character.to_string(), cell_style)),
}
}
lines.push(Line::from(spans));
}
MermaidDiagram { lines, width }
}
fn cell_presentation(cell: CanvasCell) -> (char, Style) {
match cell {
CanvasCell::Empty => (' ', Style::default()),
CanvasCell::Connector(mask) => (connector_character(mask), structure_style()),
CanvasCell::Arrow(character) | CanvasCell::Border(character) => {
(character, structure_style())
}
CanvasCell::Label(character) => (character, label_style()),
}
}
fn cell_mut(&mut self, column: usize, row: usize) -> Option<&mut CanvasCell> {
self.cells.get_mut(row)?.get_mut(column)
}
fn put(&mut self, column: usize, row: usize, cell: CanvasCell) {
if let Some(existing_cell) = self.cell_mut(column, row) {
*existing_cell = cell;
}
}
}
fn connector_character(mask: u8) -> char {
const UP: u8 = CONNECT_UP;
const DOWN: u8 = CONNECT_DOWN;
const LEFT: u8 = CONNECT_LEFT;
const RIGHT: u8 = CONNECT_RIGHT;
match mask {
mask if mask == UP | DOWN | LEFT | RIGHT => '┼',
mask if mask == UP | DOWN | LEFT => '┤',
mask if mask == UP | DOWN | RIGHT => '├',
mask if mask == UP | LEFT | RIGHT => '┴',
mask if mask == DOWN | LEFT | RIGHT => '┬',
mask if mask == UP | LEFT => '┘',
mask if mask == UP | RIGHT => '└',
mask if mask == DOWN | LEFT => '┐',
mask if mask == DOWN | RIGHT => '┌',
mask if mask & (LEFT | RIGHT) != 0 && mask & (UP | DOWN) == 0 => '─',
_ => '│',
}
}
fn structure_style() -> Style {
Style::default().fg(style::palette::text())
}
fn label_style() -> Style {
Style::default().fg(style::palette::text())
}
#[cfg(test)]
mod tests {
use std::fmt::Write;
use super::*;
fn diagram_text(diagram: &MermaidDiagram) -> String {
diagram
.lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn test_render_mermaid_uses_text_color_for_structure() {
let source = "graph TD\n A[Start] --> B[Finish]";
let diagram = render_mermaid(source).expect("chain should render");
let structure_span = diagram
.lines
.iter()
.flat_map(|line| line.spans.iter())
.find(|span| span.content.contains('┌'))
.expect("structure span should render");
assert_eq!(structure_span.style.fg, Some(style::palette::text()));
}
#[test]
fn test_render_mermaid_draws_top_down_chain() {
let source = "graph TD\n A[Start] --> B[Finish]";
let diagram = render_mermaid(source).expect("chain should render");
let text = diagram_text(&diagram);
assert!(text.contains("Start"));
assert!(text.contains("Finish"));
assert!(text.contains('┌'));
assert!(text.contains('▼'));
assert!(diagram.width > 0);
}
#[test]
fn test_render_mermaid_draws_branching_diamond() {
let source = "graph TD\n A --> B\n A --> C\n B --> D\n C --> D";
let diagram = render_mermaid(source).expect("diamond should render");
let text = diagram_text(&diagram);
assert!(text.contains('B'));
assert!(text.contains('C'));
assert_eq!(text.matches('▼').count(), 3);
}
#[test]
fn test_render_mermaid_draws_top_down_long_edge() {
let source = concat!(
"flowchart TD\n",
" A[User starts session] --> B{Choose action}\n",
" B -->|Ask agent| C[Send prompt]\n",
" B -->|Review changes| D[Open diff view]\n",
" C --> E[Agent works in worktree]\n",
" E --> F[Run checks]\n",
" F --> G[Report result]\n",
" D --> G\n",
);
let diagram = render_mermaid(source).expect("long-edge flowchart should render");
let text = diagram_text(&diagram);
assert!(text.contains("User starts session"));
assert!(text.contains("Open diff view"));
assert!(text.contains("Report result"));
assert!(text.contains('▼'));
}
#[test]
fn test_render_mermaid_draws_left_right_direction() {
let source = "flowchart LR\n A[In] --> B[Out]";
let diagram = render_mermaid(source).expect("LR chain should render");
let text = diagram_text(&diagram);
assert!(text.contains('▶'));
let first_box_line = text
.lines()
.find(|line| line.contains("In"))
.expect("label row");
assert!(first_box_line.contains("Out"));
}
#[test]
fn test_render_mermaid_draws_left_right_feedback_cycle() {
let source = concat!(
"flowchart LR\n",
" A[\"App\"] -- \"commands:<br/>prompt · interrupt · permission answer\" --> \
H[\"ag-harness\"]\n",
" H -- \"typed events:<br/>deltas · tool calls · diffs · usage\" --> A\n",
);
let diagram = render_mermaid(source).expect("two-node feedback graph should render");
let text = diagram_text(&diagram);
assert!(text.contains("App"));
assert!(text.contains("ag-harness"));
assert!(text.contains("commands:"));
assert!(text.contains("typed events:"));
assert!(text.contains('▶'));
assert!(text.contains('◀'));
assert!(!text.contains("flowchart LR"));
assert!(!text.contains("<br/>"));
}
#[test]
fn test_render_mermaid_writes_edge_label_on_track() {
let source = "graph TD\n A --> B\n A -->|yes| C";
let diagram = render_mermaid(source).expect("labeled edge should render");
let text = diagram_text(&diagram);
assert!(text.contains("yes"));
}
#[test]
fn test_render_mermaid_supports_rounded_and_chained_statements() {
let source = "graph TD; A(Begin) --> B{Choice}; B --> C((End))";
let diagram = render_mermaid(source).expect("chained statements should render");
let text = diagram_text(&diagram);
assert!(text.contains('╭'));
assert!(text.contains("Begin"));
assert!(text.contains("Choice"));
assert!(text.contains("End"));
}
#[test]
fn test_render_mermaid_maps_extended_node_shapes() {
let source = concat!(
"flowchart TD\n",
" A([Stadium]) --> B[[Subroutine]]\n",
" B --> C[(Cylinder)]\n",
" C --> D{{Hexagon}}\n",
" D --> E(((Core)))\n",
" E --> F>Flag]",
);
let diagram = render_mermaid(source).expect("extended shapes should render");
let text = diagram_text(&diagram);
assert!(text.contains("Stadium"));
assert!(text.contains("Subroutine"));
assert!(text.contains("Cylinder"));
assert!(text.contains("Hexagon"));
assert!(text.contains("Core"));
assert!(text.contains("Flag"));
assert!(!text.contains('['));
}
#[test]
fn test_render_mermaid_expands_ampersand_groups() {
let source = "flowchart TD\n A --> B & C\n B & C --> D";
let diagram = render_mermaid(source).expect("ampersand groups should render");
let text = diagram_text(&diagram);
assert!(text.contains('B'));
assert!(text.contains('C'));
assert_eq!(text.matches('▼').count(), 3);
}
#[test]
fn test_render_mermaid_accepts_extended_arrow_variants() {
let long_arrow = "flowchart TD\n A ----> B";
let source_arrow = "flowchart TD\n A <-- B";
let bidirectional = "flowchart TD\n A <--> B";
let circle_ends = "flowchart TD\n A o--o B";
let cross_ends = "flowchart TD\n A x--x B";
let long_arrow_label = "flowchart TD\n A[Alpha stage] ---->|later| B[Beta stage]";
let long_arrow_diagram = render_mermaid(long_arrow).expect("long arrow should render");
let source_arrow_diagram =
render_mermaid(source_arrow).expect("source arrow should render");
let bidirectional_diagram =
render_mermaid(bidirectional).expect("bidirectional arrow should render");
let labeled_diagram =
render_mermaid(long_arrow_label).expect("labeled long arrow should render");
assert!(diagram_text(&long_arrow_diagram).contains('▼'));
let source_arrow_text = diagram_text(&source_arrow_diagram);
assert!(source_arrow_text.contains('▼'));
assert!(!source_arrow_text.contains('▲'));
let source_position = source_arrow_text.find('B').expect("B should render");
let target_position = source_arrow_text.find('A').expect("A should render");
assert!(source_position < target_position);
let bidirectional_text = diagram_text(&bidirectional_diagram);
assert!(bidirectional_text.contains('▲'));
assert!(bidirectional_text.contains('▼'));
assert!(render_mermaid(circle_ends).is_some());
assert!(render_mermaid(cross_ends).is_some());
assert!(diagram_text(&labeled_diagram).contains("later"));
}
#[test]
fn test_render_mermaid_fans_source_arrow_chain_out_of_shared_source() {
let source = "flowchart TD\n A <-- B --> C";
let diagram = render_mermaid(source).expect("source arrow chain should render");
let text = diagram_text(&diagram);
let source_position = text.find('B').expect("B should render");
let first_target_position = text.find('A').expect("A should render");
let second_target_position = text.find('C').expect("C should render");
assert!(source_position < first_target_position);
assert!(source_position < second_target_position);
assert_eq!(text.matches('▼').count(), 2);
assert!(!text.contains('▲'));
}
#[test]
fn test_render_mermaid_treats_reciprocal_source_arrow_as_cycle() {
let top_down = "flowchart TD\n A --> B\n A <-- B";
let left_right = "flowchart LR\n A --> B\n A <-- B";
let feedback_diagram =
render_mermaid(left_right).expect("two-node feedback loop should render");
let feedback_text = diagram_text(&feedback_diagram);
assert!(render_mermaid(top_down).is_none());
assert!(feedback_text.contains('▶'));
assert!(feedback_text.contains('◀'));
}
#[test]
fn test_render_mermaid_hides_invisible_layout_link() {
let source = "flowchart TD\n A[Source] ~~~ B[Target]";
let diagram = render_mermaid(source).expect("invisible layout link should render");
let lines: Vec<String> = diagram.lines.iter().map(ToString::to_string).collect();
let source_row = lines
.iter()
.position(|line| line.contains("Source"))
.expect("source node should render");
let target_row = lines
.iter()
.position(|line| line.contains("Target"))
.expect("target node should render");
assert!(source_row + 2 < target_row - 1);
assert!(
lines[source_row + 2..target_row - 1]
.iter()
.all(|line| line.trim().is_empty())
);
}
#[test]
fn test_render_mermaid_keeps_line_operator_before_labeled_arrow_chain() {
let source = "flowchart TD\n A --- B --> C";
let diagram = render_mermaid(source).expect("mixed chain should render");
let text = diagram_text(&diagram);
assert!(text.contains('A'));
assert!(text.contains('B'));
assert!(text.contains('C'));
assert_eq!(text.matches('▼').count(), 1);
}
#[test]
fn test_render_mermaid_renders_unspaced_inline_edge_label() {
let source = "flowchart TD\n A[Alpha stage]--send-->B[Beta stage]";
let diagram = render_mermaid(source).expect("unspaced inline label should render");
let text = diagram_text(&diagram);
assert!(text.contains("send"));
assert!(text.contains('▼'));
}
#[test]
fn test_render_mermaid_draws_er_diagram_with_cardinality_markers() {
let source = concat!(
"erDiagram\n",
" CUSTOMER ||--o{ ORDER : places\n",
" CUSTOMER ||--|| ACCOUNT : owns\n",
);
let diagram = render_mermaid(source).expect("er diagram should render");
let text = diagram_text(&diagram);
assert!(text.contains("CUSTOMER"));
assert!(text.contains("ORDER"));
assert!(text.contains("ACCOUNT"));
assert!(text.contains("places"));
assert!(text.contains('1'));
assert!(text.contains('*'));
assert!(!text.contains('▼'));
}
#[test]
fn test_render_mermaid_er_omits_attribute_blocks() {
let source = concat!(
"erDiagram\n",
" CUSTOMER {\n",
" string name\n",
" }\n",
" CUSTOMER ||--o{ ORDER : places\n",
);
let diagram = render_mermaid(source).expect("er diagram should render");
let text = diagram_text(&diagram);
assert!(text.contains("CUSTOMER"));
assert!(text.contains("ORDER"));
assert!(!text.contains("string"));
}
#[test]
fn test_render_mermaid_er_supports_hyphenated_entities_and_bare_links() {
let source = concat!(
"erDiagram\n",
" ORDER ||--|{ LINE-ITEM : contains\n",
" LINE-ITEM }o..o| DISCOUNT\n",
);
let diagram = render_mermaid(source).expect("er diagram should render");
let text = diagram_text(&diagram);
assert!(text.contains("LINE-ITEM"));
assert!(text.contains("DISCOUNT"));
assert!(text.contains('+'));
assert!(text.contains('?'));
}
#[test]
fn test_render_mermaid_er_rejects_unknown_relationship_operators() {
assert!(render_mermaid("erDiagram\n A |x--o{ B : bad").is_none());
assert!(render_mermaid("erDiagram\n A ||==o{ B : bad").is_none());
assert!(render_mermaid("erDiagram").is_none());
}
#[test]
fn test_render_mermaid_draws_sequence_diagram() {
let source = concat!(
"sequenceDiagram\n",
" participant User\n",
" participant Agentty\n",
" participant Agent\n",
" User->>Agentty: Start new session\n",
" Agentty->>Agent: Send prompt\n",
" Agent-->>Agentty: Stream result\n",
);
let diagram = render_mermaid(source).expect("sequence diagram should render");
let text = diagram_text(&diagram);
assert!(text.contains("User"));
assert!(text.contains("Agentty"));
assert!(text.contains("Start new session"));
assert!(text.contains('▶'));
assert!(!text.contains("sequenceDiagram"));
}
#[test]
fn test_render_mermaid_truncates_long_sequence_labels() {
let source = concat!(
"sequenceDiagram\n",
" participant A as agentty (client)\n",
" participant S as ag-harness (service)\n",
" A->>S: connect (WebSocket, JSON-RPC)\n",
" S-->>A: events seq 1..40 (deltas, diffs, usage)\n",
);
let diagram = render_mermaid(source).expect("long labels should truncate, not reject");
let text = diagram_text(&diagram);
assert!(text.contains("agentty (client)"));
assert!(text.contains("connect (WebSocket, JSON-RPC)"));
assert!(text.contains("events seq 1..40 (deltas, diffs…"));
assert!(!text.contains("diffs, usage)"));
}
#[test]
fn test_render_mermaid_draws_sequence_self_message() {
let source = concat!(
"sequenceDiagram\n",
" participant A as agentty (client)\n",
" participant S as ag-harness (service)\n",
" A->>S: disconnect (app closes)\n",
" S->>S: session keeps running, events journaled\n",
" S-->>A: replay 41..n, then live events\n",
);
let diagram = render_mermaid(source).expect("self message should render");
let text = diagram_text(&diagram);
assert!(text.contains("session keeps running, events j…"));
assert!(text.contains('┐'));
assert!(text.contains('┘'));
assert!(text.contains('◀'));
}
#[test]
fn test_render_mermaid_skips_sequence_notes_blocks_and_activations() {
let source = concat!(
"sequenceDiagram\n",
" autonumber\n",
" actor User\n",
" User->>+Agentty: Start\n",
" activate Agentty\n",
" Note over Agentty: thinking\n",
" alt success\n",
" Agentty-->>-User: Done\n",
" else failure\n",
" Agentty--xUser: Abort\n",
" end\n",
" deactivate Agentty\n",
" Agentty-)User: Async ping",
);
let diagram = render_mermaid(source).expect("tolerant sequence should render");
let text = diagram_text(&diagram);
assert!(text.contains("User"));
assert!(text.contains("Agentty"));
assert!(text.contains("Start"));
assert!(text.contains("Done"));
assert!(text.contains("Abort"));
assert!(text.contains("Async ping"));
assert!(!text.contains("thinking"));
assert!(!text.contains("success"));
}
#[test]
fn test_render_mermaid_skips_sequence_critical_option_branches() {
let source = concat!(
"sequenceDiagram\n",
" participant Agentty\n",
" participant Forge\n",
" critical Open review request\n",
" Agentty->>Forge: Push branch\n",
" option Network timeout\n",
" Agentty->>Agentty: Retry push\n",
" option Auth rejected\n",
" Agentty->>Agentty: Report failure\n",
" end\n",
" Forge-->>Agentty: Review URL",
);
let diagram = render_mermaid(source).expect("critical block should render");
let text = diagram_text(&diagram);
assert!(text.contains("Agentty"));
assert!(text.contains("Forge"));
assert!(text.contains("Push branch"));
assert!(text.contains("Retry push"));
assert!(text.contains("Report failure"));
assert!(text.contains("Review URL"));
assert!(!text.contains("Network timeout"));
assert!(!text.contains("Auth rejected"));
}
#[test]
fn test_render_mermaid_narrows_sequence_gap_for_short_labels() {
let source = concat!(
"sequenceDiagram\n",
" participant User\n",
" participant Agentty\n",
" participant Git\n",
" User->>Agentty: Start\n",
" Agentty->>Git: Commit\n",
" Git-->>Agentty: Ok\n",
" Agentty-->>User: Done",
);
let diagram = render_mermaid(source).expect("sequence should render");
let text = diagram_text(&diagram);
assert!(diagram.width <= 50);
assert!(text.contains("Commit"));
}
#[test]
fn test_render_mermaid_rejects_double_width_edge_labels() {
let pipe_label = "flowchart TD\n A -->|你好| B";
let inline_label = "flowchart TD\n A -- 你好 --> B";
let er_label = concat!(
"erDiagram\n",
" PROJECT ||--o{ SESSION : 你好\n",
" SESSION ||--|| WORKTREE : owns",
);
assert!(render_mermaid(pipe_label).is_none());
assert!(render_mermaid(inline_label).is_none());
assert!(render_mermaid(er_label).is_none());
}
#[test]
fn test_render_mermaid_rejects_unsupported_diagram_types() {
assert!(render_mermaid("graph RL\n A --> B").is_none());
assert!(render_mermaid("").is_none());
}
#[test]
fn test_render_mermaid_rejects_source_over_preview_limits() {
let mut long_source = String::from("graph TD");
for node_index in 0..MAX_SOURCE_LINE_COUNT {
write!(&mut long_source, "\n N{node_index}")
.expect("writing to String should succeed");
}
let wide_source = format!("graph TD\n A[{}]", "x".repeat(MAX_SOURCE_BYTE_COUNT));
assert!(render_mermaid(&long_source).is_none());
assert!(render_mermaid(&wide_source).is_none());
}
#[test]
fn test_render_mermaid_rejects_node_and_edge_over_preview_limits() {
let mut too_many_nodes = String::from("graph TD");
for node_index in 0..=MAX_NODE_COUNT {
write!(&mut too_many_nodes, "\n N{node_index}")
.expect("writing to String should succeed");
}
let mut too_many_edges = String::from("graph TD");
for _ in 0..=MAX_EDGE_COUNT {
too_many_edges.push_str("\n A --> B");
}
assert!(render_mermaid(&too_many_nodes).is_none());
assert!(render_mermaid(&too_many_edges).is_none());
}
#[test]
fn test_render_mermaid_rejects_unsupported_cycles() {
let cyclic = "graph TD\n A --> B\n B --> A";
let three_node_cycle = "graph LR\n A --> B\n B --> C\n C --> A";
assert!(render_mermaid(cyclic).is_none());
assert!(render_mermaid(three_node_cycle).is_none());
}
#[test]
fn test_render_mermaid_flattens_subgraph_statements() {
let source = concat!(
"graph TD\n",
" subgraph Group\n",
" direction LR\n",
" A --> B\n",
" end\n",
" B --> C",
);
let diagram = render_mermaid(source).expect("flattened subgraph should render");
let text = diagram_text(&diagram);
assert!(text.contains('A'));
assert!(text.contains('C'));
assert!(!text.contains("Group"));
}
#[test]
fn test_render_mermaid_skips_styling_statements() {
let source = concat!(
"flowchart TD\n",
" classDef terminal stroke-width: 1.5px;\n",
" A:::terminal --> B\n",
" style A fill:#f9f\n",
" linkStyle 0 stroke:#f00\n",
" class B terminal\n",
" click A href \"https://example.com\"",
);
let diagram = render_mermaid(source).expect("styled flowchart should render");
let text = diagram_text(&diagram);
assert!(text.contains('A'));
assert!(text.contains('B'));
assert!(!text.contains("terminal"));
}
#[test]
fn test_render_mermaid_rejects_wide_character_labels() {
let source = "graph TD\n A[你好] --> B";
assert!(render_mermaid(source).is_none());
}
#[test]
fn test_render_mermaid_truncates_over_long_node_labels() {
let long_identifier = "N".repeat(MAX_LABEL_WIDTH + 1);
let long_bare = format!("graph TD\n {long_identifier} --> B");
let long_labeled =
"graph TD\n A[This label is much longer than thirty-two characters] --> B";
let wide_bare = "graph TD\n 你好 --> B";
let bare_diagram = render_mermaid(&long_bare).expect("long bare id should render");
let labeled_diagram = render_mermaid(long_labeled).expect("long label should render");
assert!(diagram_text(&bare_diagram).contains('…'));
assert!(diagram_text(&labeled_diagram).contains("This label is much longer than …"));
assert!(render_mermaid(wide_bare).is_none());
}
#[test]
fn test_render_mermaid_accepts_long_bare_identifier_with_short_label() {
let long_identifier = "N".repeat(MAX_LABEL_WIDTH + 1);
let source = format!("graph TD\n {long_identifier}[Short] --> B");
let diagram = render_mermaid(&source).expect("labeled node should render");
let text = diagram_text(&diagram);
assert!(text.contains("Short"));
assert!(!text.contains(&long_identifier));
}
#[test]
fn test_render_mermaid_uses_first_node_label_line() {
let source = concat!(
"flowchart TB\n",
" APP[\"App - owns orchestration:<br/>spawning, coordination, aggregation\"]\n",
" S1[\"session 1\"]\n",
" S2[\"session 2\"]\n",
" S3[\"session 3\"]\n",
" APP --> S1\n",
" APP --> S2\n",
" APP --> S3\n",
);
let diagram = render_mermaid(source).expect("node label with line break should render");
let text = diagram_text(&diagram);
assert!(text.contains("App - owns orchestration:"));
assert!(text.contains("session 1"));
assert!(text.contains("session 2"));
assert!(text.contains("session 3"));
assert!(text.contains('▼'));
assert!(!text.contains("<br/>"));
assert!(!text.contains("spawning, coordination, aggregation"));
}
#[test]
fn test_render_mermaid_skips_comments_and_inline_label_form() {
let source = "graph LR\n %% comment line\n A -- ok --> B";
let diagram = render_mermaid(source).expect("inline label form should render");
let text = diagram_text(&diagram);
assert!(text.contains('▶'));
assert!(!text.contains("comment"));
}
#[test]
fn test_render_mermaid_renders_dotted_edge_with_embedded_label() {
let source = "graph TD\n A --> B\n A -.yes.-> C";
let diagram = render_mermaid(source).expect("dotted labeled edge should render");
let text = diagram_text(&diagram);
assert!(text.contains("yes"));
assert_eq!(text.matches('▼').count(), 2);
}
#[test]
fn test_render_mermaid_renders_spaced_dotted_edge_label_without_arrow() {
let source = "graph TD\n A --> B\n A -. off .- C";
let diagram = render_mermaid(source).expect("dotted open labeled edge should render");
let text = diagram_text(&diagram);
assert!(text.contains("off"));
assert_eq!(text.matches('▼').count(), 1);
}
#[test]
fn test_render_mermaid_renders_thick_edge_with_embedded_label() {
let source = "graph TD\n A --> B\n A ==big==> C";
let diagram = render_mermaid(source).expect("thick labeled edge should render");
let text = diagram_text(&diagram);
assert!(text.contains("big"));
assert_eq!(text.matches('▼').count(), 2);
}
#[test]
fn test_render_mermaid_keeps_plain_dotted_and_thick_arrows() {
let source = "graph TD\n A -.-> B\n A ==>|yes| C";
let diagram = render_mermaid(source).expect("plain dotted and thick arrows should render");
let text = diagram_text(&diagram);
assert!(text.contains("yes"));
assert_eq!(text.matches('▼').count(), 2);
}
#[test]
fn test_render_mermaid_renders_graph_mixing_solid_and_dotted_labeled_edges() {
let source = concat!(
"graph TD\n",
" T[Turn command] --> C[Auto-commit]\n",
" C --> P[Auto-push]\n",
" C --> R[Rebase]\n",
" P -.race.-> R\n",
);
let diagram = render_mermaid(source).expect("mixed edge graph should render");
let text = diagram_text(&diagram);
assert!(text.contains("Turn command"));
assert!(text.contains("Auto-push"));
assert!(text.contains("Rebase"));
assert!(text.contains('▼'));
}
#[test]
fn test_render_mermaid_renders_plain_dotted_and_thick_open_links() {
let source = "graph TD\n A -.- B\n B === C";
let diagram = render_mermaid(source).expect("open dotted and thick links should render");
let text = diagram_text(&diagram);
assert!(text.contains('A'));
assert!(text.contains('C'));
assert!(!text.contains('▼'));
}
}