use std::collections::{HashMap, VecDeque};
use super::canvas::{Canvas, Shape, Side};
use super::{Dir, Rendered, Role};
use crate::md::str_width;
const GUTTER_LR: usize = 6;
const GUTTER_TD: usize = 2;
const LABEL_PAD: usize = 4;
const STACK_LR: usize = 1;
const STACK_TD: usize = 3;
const MIN_LABEL: usize = 12;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Stroke {
Solid,
Dotted,
Thick,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Node {
pub id: String,
pub label: Vec<String>,
pub shape: Shape,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Edge {
pub from: usize,
pub to: usize,
pub label: Option<String>,
pub stroke: Stroke,
pub head: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Graph {
pub nodes: Vec<Node>,
pub edges: Vec<Edge>,
}
pub fn render(src: &str, dir: Dir, width: usize) -> Option<Rendered> {
let g = parse(src);
if g.nodes.is_empty() {
return None;
}
let ranks = rank(&g);
Some(Rendered::new(draw(&g, &ranks, dir, width).rows()))
}
const IGNORED: [&str; 8] = [
"subgraph",
"end",
"direction",
"click",
"style",
"classdef",
"class",
"linkstyle",
];
pub fn parse(src: &str) -> Graph {
let mut g = Graph::default();
let mut index: HashMap<String, usize> = HashMap::new();
let mut declared: Vec<bool> = Vec::new();
let mut first = true;
for stmt in statements(src) {
let word = stmt
.split(|c: char| c.is_whitespace())
.next()
.unwrap_or("")
.to_ascii_lowercase();
if first {
first = false;
if word == "flowchart" || word == "graph" {
continue;
}
}
if IGNORED.contains(&word.as_str()) {
continue;
}
chain(&stmt, &mut g, &mut index, &mut declared);
}
g
}
fn statements(src: &str) -> Vec<String> {
let mut out = Vec::new();
for line in src.lines() {
for part in split_top(uncomment(line), ';') {
let part = part.trim();
if !part.is_empty() {
out.push(part.to_string());
}
}
}
out
}
fn uncomment(line: &str) -> &str {
let bytes = line.as_bytes();
let mut quoted = false;
for i in 0..bytes.len() {
match bytes[i] {
b'"' => quoted = !quoted,
b'%' if !quoted && bytes.get(i + 1) == Some(&b'%') => return &line[..i],
_ => {}
}
}
line
}
fn split_top(s: &str, sep: char) -> Vec<&str> {
let mut out = Vec::new();
let mut depth = 0i32;
let mut quoted = false;
let mut start = 0;
for (i, ch) in s.char_indices() {
match ch {
'"' => quoted = !quoted,
_ if quoted => {}
'[' | '(' | '{' => depth += 1,
']' | ')' | '}' => depth = (depth - 1).max(0),
c if c == sep && depth == 0 => {
out.push(&s[start..i]);
start = i + c.len_utf8();
}
_ => {}
}
}
out.push(&s[start..]);
out
}
struct Link {
stroke: Stroke,
head: bool,
label: Option<String>,
next: usize,
}
fn chain(stmt: &str, g: &mut Graph, index: &mut HashMap<String, usize>, declared: &mut Vec<bool>) {
let chars: Vec<char> = stmt.chars().collect();
let mut groups: Vec<Vec<usize>> = Vec::new();
let mut links: Vec<Link> = Vec::new();
let (mut i, mut start) = (0, 0);
let mut depth = 0i32;
let mut quoted = false;
while i < chars.len() {
match chars[i] {
'"' => quoted = !quoted,
_ if quoted => {}
'[' | '(' | '{' => depth += 1,
']' | ')' | '}' => depth = (depth - 1).max(0),
_ if depth == 0 => {
if let Some(link) = link_at(&chars, i) {
let text: String = chars[start..i].iter().collect();
groups.push(group(&text, g, index, declared));
let link = piped(&chars, link);
i = link.next;
start = i;
links.push(link);
continue;
}
}
_ => {}
}
i += 1;
}
let text: String = chars[start..].iter().collect();
groups.push(group(&text, g, index, declared));
for (i, link) in links.iter().enumerate() {
for &from in &groups[i] {
for &to in &groups[i + 1] {
g.edges.push(Edge {
from,
to,
label: link.label.clone(),
stroke: link.stroke,
head: link.head,
});
}
}
}
}
fn piped(chars: &[char], mut link: Link) -> Link {
let mut i = link.next;
while chars.get(i) == Some(&' ') {
i += 1;
}
if chars.get(i) != Some(&'|') {
return link;
}
let Some(end) = chars[i + 1..].iter().position(|&c| c == '|') else {
return link;
};
link.label = label_text(&chars[i + 1..i + 1 + end].iter().collect::<String>());
link.next = i + end + 2;
link
}
fn link_at(c: &[char], i: usize) -> Option<Link> {
let start = usize::from(c[i] == '<' && matches!(c.get(i + 1), Some('-' | '=')));
let j = i + start;
let ch = *c.get(j)?;
if ch == '-' && c.get(j + 1) == Some(&'.') {
return dotted(c, j);
}
if ch != '-' && ch != '=' {
return None;
}
let stroke = if ch == '=' {
Stroke::Thick
} else {
Stroke::Solid
};
let mut k = j;
while c.get(k) == Some(&ch) {
k += 1;
}
if k - j < 2 {
return None;
}
if head_at(c, k) {
return Some(Link {
stroke,
head: true,
label: None,
next: k + 1,
});
}
if k - j == 2 {
if let Some((label, end)) = closing_run(c, k, ch) {
let head = head_at(c, end);
return Some(Link {
stroke,
head,
label: label_text(&label),
next: end + usize::from(head),
});
}
}
Some(Link {
stroke,
head: false,
label: None,
next: k,
})
}
fn dotted(c: &[char], j: usize) -> Option<Link> {
let mut k = j + 1;
while c.get(k) == Some(&'.') {
k += 1;
}
if c.get(k) == Some(&'-') {
let head = head_at(c, k + 1);
return Some(Link {
stroke: Stroke::Dotted,
head,
label: None,
next: k + 1 + usize::from(head),
});
}
let (label, end) = closing_dots(c, k)?;
let head = head_at(c, end);
Some(Link {
stroke: Stroke::Dotted,
head,
label: label_text(&label),
next: end + usize::from(head),
})
}
fn head_at(c: &[char], k: usize) -> bool {
match c.get(k) {
Some('>') => true,
Some('x' | 'o') => !c.get(k + 1).copied().is_some_and(is_id),
_ => false,
}
}
fn closing_run(c: &[char], from: usize, ch: char) -> Option<(String, usize)> {
let mut k = from;
while k < c.len() {
if c[k] == ch && c.get(k + 1) == Some(&ch) {
let mut end = k;
while c.get(end) == Some(&ch) {
end += 1;
}
return Some((c[from..k].iter().collect(), end));
}
k += 1;
}
None
}
fn closing_dots(c: &[char], from: usize) -> Option<(String, usize)> {
let mut k = from;
while k < c.len() {
if c[k] == '.' {
let mut end = k;
while c.get(end) == Some(&'.') {
end += 1;
}
if c.get(end) == Some(&'-') {
return Some((c[from..k].iter().collect(), end + 1));
}
}
k += 1;
}
None
}
fn group(
text: &str,
g: &mut Graph,
index: &mut HashMap<String, usize>,
declared: &mut Vec<bool>,
) -> Vec<usize> {
split_top(text, '&')
.into_iter()
.filter_map(spec)
.map(|s| touch(g, index, declared, s))
.collect()
}
struct Spec {
id: String,
label: Option<Vec<String>>,
shape: Shape,
}
fn spec(s: &str) -> Option<Spec> {
let s = s.trim();
let end = s.find(|c: char| !is_id(c)).unwrap_or(s.len());
if end == 0 {
return None;
}
let id = s[..end].to_string();
let rest = s[end..].trim_start();
let Some((open, close, shape)) = bracket(rest) else {
return Some(Spec {
id,
label: None,
shape: Shape::Rect,
});
};
let body = &rest[open.len()..];
let inner = match find_close(body, open, close) {
Some(at) => &body[..at],
None => body,
};
let label = lines_of(inner);
Some(Spec {
id,
label: Some(label),
shape,
})
}
fn bracket(rest: &str) -> Option<(&'static str, &'static str, Shape)> {
const FORMS: [(&str, &str, Shape); 13] = [
("((", "))", Shape::Circle),
("[/", "/]", Shape::Rect),
("[\\", "\\]", Shape::Rect),
("[/", "\\]", Shape::Rect),
("[\\", "/]", Shape::Rect),
("([", "])", Shape::Round),
("[[", "]]", Shape::Rect),
("[(", ")]", Shape::Round),
("{{", "}}", Shape::Diamond),
("[", "]", Shape::Rect),
("(", ")", Shape::Round),
("{", "}", Shape::Diamond),
(">", "]", Shape::Rect),
];
FORMS
.iter()
.find(|(open, ..)| rest.starts_with(open))
.map(|&(open, close, shape)| (open, close, shape))
}
fn find_close(body: &str, open: &str, close: &str) -> Option<usize> {
let mut quoted = false;
let mut depth = 0usize;
let mut i = 0;
while i < body.len() {
if !body.is_char_boundary(i) {
i += 1;
continue;
}
let rest = &body[i..];
if rest.starts_with('"') {
quoted = !quoted;
i += 1;
} else if quoted {
i += 1;
} else if rest.starts_with(close) {
if depth == 0 {
return Some(i);
}
depth -= 1;
i += close.len();
} else if rest.starts_with(open) {
depth += 1;
i += open.len();
} else {
i += 1;
}
}
None
}
fn lines_of(inner: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = inner;
loop {
match find_break(rest) {
Some((at, end)) => {
out.push(unquote(&rest[..at]));
rest = &rest[end..];
}
None => {
out.push(unquote(rest));
break;
}
}
}
out.retain(|l| !l.is_empty());
out
}
fn find_break(s: &str) -> Option<(usize, usize)> {
let lower = s.to_ascii_lowercase();
let at = lower.find("<br")?;
let end = at + lower[at..].find('>')? + 1;
let between = &lower[at + 3..end - 1];
between
.chars()
.all(|c| c.is_whitespace() || c == '/')
.then_some((at, end))
}
fn unquote(s: &str) -> String {
let s = s.trim();
let inner = s
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(s);
inner.trim().to_string()
}
fn label_text(s: &str) -> Option<String> {
let s = unquote(s);
(!s.is_empty()).then_some(s)
}
fn is_id(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '-'
}
fn touch(
g: &mut Graph,
index: &mut HashMap<String, usize>,
declared: &mut Vec<bool>,
spec: Spec,
) -> usize {
let Spec { id, label, shape } = spec;
let label = label.filter(|l| !l.is_empty());
if let Some(&i) = index.get(&id) {
if let Some(label) = label {
if !declared[i] {
g.nodes[i].label = label;
g.nodes[i].shape = shape;
declared[i] = true;
}
}
return i;
}
let i = g.nodes.len();
index.insert(id.clone(), i);
declared.push(label.is_some());
g.nodes.push(Node {
label: label.unwrap_or_else(|| vec![id.clone()]),
id,
shape,
});
i
}
pub fn rank(g: &Graph) -> Vec<usize> {
let n = g.nodes.len();
let back = back_edges(g);
let mut adjacent: Vec<Vec<usize>> = vec![Vec::new(); n];
let mut incoming = vec![0usize; n];
for (i, e) in g.edges.iter().enumerate() {
if back[i] {
continue;
}
adjacent[e.from].push(e.to);
incoming[e.to] += 1;
}
let mut rank = vec![0usize; n];
let mut queued = vec![false; n];
let mut queue: VecDeque<usize> = VecDeque::new();
for i in 0..n {
if incoming[i] == 0 {
queued[i] = true;
queue.push_back(i);
}
}
let mut done = 0;
while done < n {
let Some(v) = queue.pop_front() else {
let Some(v) = (0..n).find(|&i| !queued[i]) else {
break;
};
queued[v] = true;
queue.push_back(v);
continue;
};
done += 1;
for &t in &adjacent[v] {
rank[t] = rank[t].max(rank[v] + 1);
incoming[t] -= 1;
if incoming[t] == 0 && !queued[t] {
queued[t] = true;
queue.push_back(t);
}
}
}
rank
}
fn back_edges(g: &Graph) -> Vec<bool> {
const UNSEEN: u8 = 0;
const OPEN: u8 = 1;
const CLOSED: u8 = 2;
let n = g.nodes.len();
let mut out = vec![false; g.edges.len()];
let mut leaving: Vec<Vec<usize>> = vec![Vec::new(); n];
for (i, e) in g.edges.iter().enumerate() {
leaving[e.from].push(i);
}
let mut state = vec![UNSEEN; n];
let mut stack: Vec<(usize, usize)> = Vec::new();
for root in 0..n {
if state[root] != UNSEEN {
continue;
}
state[root] = OPEN;
stack.push((root, 0));
while let Some((v, i)) = stack.pop() {
let Some(&edge) = leaving[v].get(i) else {
state[v] = CLOSED;
continue;
};
stack.push((v, i + 1));
let t = g.edges[edge].to;
match state[t] {
OPEN => out[edge] = true,
UNSEEN => {
state[t] = OPEN;
stack.push((t, 0));
}
_ => {}
}
}
}
out
}
#[derive(Clone, Copy)]
struct Frame {
dir: Dir,
span: usize,
}
impl Frame {
fn td(&self) -> bool {
self.dir == Dir::Td
}
fn at(&self, major: usize, len: usize) -> usize {
match self.dir {
Dir::Rl => self.span.saturating_sub(major + len),
_ => major,
}
}
fn point(&self, major: usize, minor: usize) -> (usize, usize) {
match self.dir {
Dir::Td => (minor, major),
_ => (self.at(major, 1), minor),
}
}
fn rect(
&self,
major: usize,
minor: usize,
ms: usize,
mn: usize,
) -> (usize, usize, usize, usize) {
match self.dir {
Dir::Td => (minor, major, mn, ms),
_ => (self.at(major, ms), minor, ms, mn),
}
}
fn along(&self, c: &mut Canvas, major: usize, minor: usize, len: usize, stroke: Stroke) {
match self.dir {
Dir::Td => line(c, minor, major, len, false, stroke),
_ => line(c, self.at(major, len), minor, len, true, stroke),
}
}
fn across(&self, c: &mut Canvas, major: usize, minor: usize, len: usize, stroke: Stroke) {
match self.dir {
Dir::Td => line(c, minor, major, len, true, stroke),
_ => line(c, self.at(major, 1), minor, len, false, stroke),
}
}
fn arrow(&self, c: &mut Canvas, major: usize, minor: usize) {
let side = match self.dir {
Dir::Lr => Side::Right,
Dir::Rl => Side::Left,
Dir::Td => Side::Down,
};
let (x, y) = self.point(major, minor);
c.arrow(x, y, side, Role::Line);
}
fn label_along(&self, c: &mut Canvas, lo: usize, hi: usize, minor: usize, text: &str) {
let len = hi + 1 - lo;
let pad = len.saturating_sub(str_width(text)) / 2;
c.text(self.at(lo, len) + pad, minor, text, Role::Label);
}
fn label_across(&self, c: &mut Canvas, major: usize, lo: usize, hi: usize, text: &str) {
let len = hi + 1 - lo;
let pad = len.saturating_sub(str_width(text)) / 2;
c.text(lo + pad, major, text, Role::Label);
}
}
fn line(c: &mut Canvas, x: usize, y: usize, len: usize, horizontal: bool, stroke: Stroke) {
if len == 0 {
return;
}
if horizontal {
c.hline(x, y, len, Role::Line);
} else {
c.vline(x, y, len, Role::Line);
}
if stroke == Stroke::Solid {
return;
}
let (plain, thick) = if horizontal {
('─', '━')
} else {
('│', '┃')
};
for i in 0..len {
let (x, y) = if horizontal { (x + i, y) } else { (x, y + i) };
if c.get(x, y) != plain {
continue;
}
let odd = (if horizontal { x } else { y }) % 2 == 1;
match stroke {
Stroke::Dotted if odd => c.set(x, y, ' ', Role::Line),
Stroke::Thick => c.set(x, y, thick, Role::Line),
_ => {}
}
}
}
struct Place {
major: usize,
minor: usize,
ms: usize,
mn: usize,
label: Vec<String>,
shape: Shape,
}
struct Plan {
frame: Frame,
ranks: Vec<usize>,
places: Vec<Place>,
gaps: Vec<(usize, usize)>,
from: usize,
to: usize,
}
fn wrap(label: &[String], width: usize) -> Vec<String> {
let mut out = Vec::new();
for line in label {
if str_width(line) <= width {
out.push(line.clone());
continue;
}
let mut row = String::new();
for word in line.split_whitespace() {
if row.is_empty() {
row = word.to_string();
} else if str_width(&row) + 1 + str_width(word) <= width {
row.push(' ');
row.push_str(word);
} else {
out.push(std::mem::take(&mut row));
row = word.to_string();
}
}
if !row.is_empty() {
out.push(row);
}
}
out
}
fn plan(g: &Graph, ranks: &[usize], dir: Dir, width: usize) -> Plan {
let td = dir == Dir::Td;
let gutter = if td { GUTTER_TD } else { GUTTER_LR };
let stack = if td { STACK_TD } else { STACK_LR };
let labels: Vec<Vec<String>> = g
.nodes
.iter()
.map(|n| wrap(&n.label, (width / 3).max(MIN_LABEL)))
.collect();
let sizes: Vec<(usize, usize)> = g
.nodes
.iter()
.zip(&labels)
.map(|(n, label)| Canvas::node_size(n.shape, label))
.collect();
let major: Vec<usize> = sizes.iter().map(|&(w, h)| if td { h } else { w }).collect();
let minor: Vec<usize> = sizes.iter().map(|&(w, h)| if td { w } else { h }).collect();
let count = ranks.iter().max().map_or(1, |r| r + 1);
let mut by_rank: Vec<Vec<usize>> = vec![Vec::new(); count];
for (i, &r) in ranks.iter().enumerate() {
by_rank[r].push(i);
}
let mut gaps = vec![gutter; count];
gaps[0] = if g.edges.iter().any(|e| ranks[e.to] == 0) {
gutter
} else {
0
};
if !td {
for e in &g.edges {
let (from, to) = (ranks[e.from], ranks[e.to]);
match &e.label {
Some(label) if to == from + 1 => {
gaps[to] = gaps[to].max(str_width(label) + LABEL_PAD);
}
_ => {}
}
}
}
let depth: Vec<usize> = by_rank
.iter()
.map(|rank| rank.iter().map(|&i| major[i]).max().unwrap_or(0))
.collect();
let mut starts = Vec::with_capacity(count);
let mut at = 0;
for r in 0..count {
at += gaps[r];
starts.push(at);
at += depth[r];
}
let span = at;
let gaps: Vec<(usize, usize)> = (0..count)
.map(|r| (starts[r] - gaps[r], starts[r]))
.collect();
let widths: Vec<usize> = by_rank
.iter()
.map(|rank| {
rank.iter().map(|&i| minor[i]).sum::<usize>() + stack * rank.len().saturating_sub(1)
})
.collect();
let widest = widths.iter().copied().max().unwrap_or(0);
let from = g
.edges
.iter()
.filter(|e| ranks[e.to] <= ranks[e.from])
.count();
let mut places: Vec<Place> = Vec::with_capacity(g.nodes.len());
for (i, node) in g.nodes.iter().enumerate() {
places.push(Place {
major: starts[ranks[i]],
minor: 0,
ms: depth[ranks[i]],
mn: minor[i],
label: pad(&labels[i], td, depth[ranks[i]] - major[i]),
shape: node.shape,
});
}
for (r, rank) in by_rank.iter().enumerate() {
let mut at = from + (widest - widths[r]) / 2;
for &i in rank {
places[i].minor = at;
at += places[i].mn + stack;
}
}
Plan {
frame: Frame { dir, span },
ranks: ranks.to_vec(),
places,
gaps,
from,
to: from + widest,
}
}
fn pad(label: &[String], td: bool, room: usize) -> Vec<String> {
if !td || room == 0 {
return label.to_vec();
}
let mut out = vec![String::new(); room / 2];
out.extend_from_slice(label);
out
}
impl Plan {
fn mid(&self, r: usize) -> usize {
let (from, to) = self.gaps[r];
(from + to) / 2
}
fn ends(&self, e: &Edge) -> (usize, usize) {
let (s, t) = (&self.places[e.from], &self.places[e.to]);
(s.minor + s.mn / 2, t.minor + t.mn / 2)
}
fn arrive(&self, c: &mut Canvas, e: &Edge, from: usize, minor: usize) {
let edge = self.places[e.to].major;
let end = if e.head { edge - 1 } else { edge + 1 };
self.frame
.along(c, from, minor, end.saturating_sub(from), e.stroke);
if e.head {
self.frame.arrow(c, edge - 1, minor);
}
}
fn step(&self, c: &mut Canvas, e: &Edge) {
let (s, t) = self.ends(e);
let source = &self.places[e.from];
let out = source.major + source.ms;
let mid = self.mid(self.ranks[e.to]);
self.frame
.along(c, out, s, (mid + 1).saturating_sub(out), e.stroke);
if s != t {
self.frame
.across(c, mid, s.min(t), s.abs_diff(t) + 1, e.stroke);
}
self.arrive(c, e, mid, t);
if let Some(label) = &e.label {
let (from, to) = self.gaps[self.ranks[e.to]];
if self.frame.td() {
self.frame.label_across(c, from, s.min(t), s.max(t), label);
} else {
self.frame.label_along(c, from, to - 1, t, label);
}
}
}
fn detour(&self, c: &mut Canvas, e: &Edge, lane: usize, back: bool) {
let (s, t) = self.ends(e);
let source = &self.places[e.from];
let (a, b) = if back {
(self.mid(self.ranks[e.from]), self.mid(self.ranks[e.to]))
} else {
(self.mid(self.ranks[e.from] + 1), self.mid(self.ranks[e.to]))
};
if back {
let edge = source.major;
self.frame.along(c, a, s, edge.saturating_sub(a), e.stroke);
} else {
let out = source.major + source.ms;
self.frame
.along(c, out, s, (a + 1).saturating_sub(out), e.stroke);
}
self.frame
.across(c, a, lane.min(s), lane.abs_diff(s) + 1, e.stroke);
self.frame
.along(c, a.min(b), lane, a.abs_diff(b) + 1, e.stroke);
self.frame
.across(c, b, lane.min(t), lane.abs_diff(t) + 1, e.stroke);
self.arrive(c, e, b, t);
if let Some(label) = &e.label {
if self.frame.td() {
self.frame
.label_across(c, b, lane.min(t), lane.max(t), label);
} else {
self.frame.label_along(c, a.min(b), a.max(b), lane, label);
}
}
}
}
fn draw(g: &Graph, ranks: &[usize], dir: Dir, width: usize) -> Canvas {
let plan = plan(g, ranks, dir, width);
let skips = g
.edges
.iter()
.filter(|e| ranks[e.to] > ranks[e.from] + 1)
.count();
let (_, _, w, h) = plan.frame.rect(0, 0, plan.frame.span, plan.to + skips);
let mut c = Canvas::new(w.max(1), h.max(1));
for p in &plan.places {
let (x, y, w, h) = plan.frame.rect(p.major, p.minor, p.ms, p.mn);
c.node(x, y, w, h, p.shape, &p.label);
}
let (mut back, mut skip) = (0, 0);
for e in &g.edges {
let (s, t) = (ranks[e.from], ranks[e.to]);
if t == s + 1 {
plan.step(&mut c, e);
} else if t > s {
plan.detour(&mut c, e, plan.to + skip, false);
skip += 1;
} else {
plan.detour(&mut c, e, plan.from - 1 - back, true);
back += 1;
}
}
c
}
#[cfg(test)]
mod tests {
use super::*;
fn drawn(src: &str, dir: Dir) -> Vec<String> {
render(src, dir, 80).expect("a diagram").text()
}
fn row_of(rows: &[String], text: &str) -> usize {
rows.iter()
.position(|r| r.contains(text))
.unwrap_or_else(|| panic!("{text:?} is not in {rows:#?}"))
}
#[test]
fn a_bare_arrow_makes_two_boxes_and_a_line() {
assert_eq!(
drawn("flowchart LR\nA --> B", Dir::Lr),
vec!["╭───╮ ╭───╮", "│ A │─────▶│ B │", "╰───╯ ╰───╯",]
);
}
#[test]
fn node_brackets_pick_the_shape_and_the_label() {
let g = parse("flowchart LR\nA[Start] --> B(Go) --> C{Ok} --> D((End))");
let shapes: Vec<Shape> = g.nodes.iter().map(|n| n.shape).collect();
assert_eq!(
shapes,
vec![Shape::Rect, Shape::Round, Shape::Diamond, Shape::Circle]
);
let labels: Vec<&str> = g.nodes.iter().map(|n| n.label[0].as_str()).collect();
assert_eq!(labels, vec!["Start", "Go", "Ok", "End"]);
let g = parse("flowchart LR\nA([a]) --> B[[b]] --> C[(c)] --> D{{d}} --> E>e]");
let shapes: Vec<Shape> = g.nodes.iter().map(|n| n.shape).collect();
assert_eq!(
shapes,
vec![
Shape::Round,
Shape::Rect,
Shape::Round,
Shape::Diamond,
Shape::Rect
]
);
let g = parse("flowchart LR\nA[\"a] b\"]");
assert_eq!(g.nodes[0].label, vec!["a] b".to_string()]);
}
#[test]
fn a_node_declared_once_keeps_its_label_when_named_again() {
let g = parse("flowchart LR\nA[Start] --> B\nA --> C");
assert_eq!(g.nodes[0].label, vec!["Start".to_string()]);
assert_eq!(g.nodes.len(), 3);
assert_eq!(g.edges.len(), 2);
}
#[test]
fn a_node_named_before_it_is_declared_still_gets_its_label() {
let g = parse("flowchart LR\nA --> B\nB[Finish]");
assert_eq!(g.nodes[1].label, vec!["Finish".to_string()]);
assert_eq!(g.nodes.len(), 2);
}
#[test]
fn a_chain_becomes_one_edge_per_arrow() {
let g = parse("flowchart LR\nA --> B --> C");
assert_eq!(g.nodes.len(), 3);
let pairs: Vec<(usize, usize)> = g.edges.iter().map(|e| (e.from, e.to)).collect();
assert_eq!(pairs, vec![(0, 1), (1, 2)]);
}
#[test]
fn an_ampersand_list_fans_out_to_every_pair() {
let g = parse("flowchart LR\nA[a] & B --> C & D");
let pairs: Vec<(usize, usize)> = g.edges.iter().map(|e| (e.from, e.to)).collect();
assert_eq!(pairs, vec![(0, 2), (0, 3), (1, 2), (1, 3)]);
assert_eq!(g.nodes[0].label, vec!["a".to_string()]);
}
#[test]
fn an_edge_label_is_drawn_on_the_line_between_the_boxes() {
assert_eq!(
drawn("flowchart LR\nA -->|yes| B", Dir::Lr)[1],
"│ A │──yes─▶│ B │"
);
let g = parse("flowchart LR\nA -- yes --> B");
assert_eq!(g.edges[0].label.as_deref(), Some("yes"));
assert!(g.edges[0].head);
}
#[test]
fn a_dotted_and_a_thick_arrow_are_told_apart() {
let g = parse("flowchart LR\nA -.-> B\nC ==> D\nE -. no .-> F\nG == go ==> H");
let strokes: Vec<Stroke> = g.edges.iter().map(|e| e.stroke).collect();
assert_eq!(
strokes,
vec![Stroke::Dotted, Stroke::Thick, Stroke::Dotted, Stroke::Thick]
);
assert_eq!(g.edges[2].label.as_deref(), Some("no"));
assert_eq!(g.edges[3].label.as_deref(), Some("go"));
let g = parse("flowchart LR\nA -....-> B\nC =====> D\nE ----> F");
let strokes: Vec<Stroke> = g.edges.iter().map(|e| e.stroke).collect();
assert_eq!(strokes, vec![Stroke::Dotted, Stroke::Thick, Stroke::Solid]);
let solid = drawn("flowchart LR\nA --> B", Dir::Lr)[1].clone();
let dotted = drawn("flowchart LR\nA -.-> B", Dir::Lr)[1].clone();
let thick = drawn("flowchart LR\nA ==> B", Dir::Lr)[1].clone();
assert_ne!(solid, dotted);
assert_ne!(solid, thick);
assert_ne!(dotted, thick);
assert!(thick.contains('━'));
}
#[test]
fn an_arrow_without_a_head_draws_no_arrowhead() {
let g = parse("flowchart LR\nA --- B");
assert!(!g.edges[0].head);
let rows = drawn("flowchart LR\nA --- B", Dir::Lr);
assert!(!rows.iter().any(|r| r.contains('▶')));
assert_eq!(rows[1], "│ A │──────┤ B │");
}
#[test]
fn ranks_are_the_longest_path_from_a_root() {
let g = parse("flowchart LR\nA --> B\nB --> C\nA --> C");
assert_eq!(rank(&g), vec![0, 1, 2]);
let g = parse("flowchart LR\nA --> C\nB --> C");
assert_eq!(rank(&g), vec![0, 1, 0]);
}
#[test]
fn a_cycle_still_ranks_every_node() {
let g = parse("flowchart LR\nA --> B\nB --> A");
assert_eq!(rank(&g), vec![0, 1]);
let g = parse("flowchart LR\nA --> B --> C --> A");
assert_eq!(rank(&g), vec![0, 1, 2]);
}
#[test]
fn a_back_edge_is_routed_around_the_layout_rather_than_through_it() {
let rows = drawn("flowchart LR\nA --> B --> C --> A", Dir::Lr);
assert!(rows[0].contains('─'));
for name in ["│ A │", "│ B │", "│ C │"] {
assert!(
rows.iter().any(|r| r.contains(name)),
"{name} is missing from {rows:#?}"
);
}
let boxes = row_of(&rows, "│ A │");
assert!(boxes > 0, "the lane should be above the boxes");
assert!(rows[boxes].contains('▶'));
}
#[test]
fn left_to_right_makes_ranks_columns_and_top_down_makes_them_rows() {
let rows = drawn("flowchart LR\nA --> B", Dir::Lr);
assert_eq!(row_of(&rows, "A"), row_of(&rows, "B"));
let rows = drawn("graph TD\nA --> B", Dir::Td);
assert!(row_of(&rows, "A") < row_of(&rows, "B"));
assert_eq!(
rows,
vec![
"╭───╮",
"│ A │",
"╰───╯",
" │",
" ▼",
"╭───╮",
"│ B │",
"╰───╯",
]
);
}
#[test]
fn right_to_left_puts_the_first_rank_on_the_right() {
assert_eq!(
drawn("flowchart RL\nA --> B", Dir::Rl)[1],
"│ B │◀─────│ A │"
);
}
#[test]
fn a_subgraph_is_ignored_and_its_nodes_are_still_drawn() {
let g =
parse("flowchart TD\nsubgraph one [Group]\n direction LR\n A --> B\nend\nB --> C");
let ids: Vec<&str> = g.nodes.iter().map(|n| n.id.as_str()).collect();
assert_eq!(ids, vec!["A", "B", "C"]);
assert_eq!(g.edges.len(), 2);
}
#[test]
fn comments_and_style_lines_are_skipped() {
let g = parse(
"flowchart LR\n\
%% the whole line\n\
A --> B %% and the tail of one\n\
style A fill:#f00\n\
classDef big font-size:20px\n\
class A big\n\
click A \"https://example.com\"\n\
linkStyle 0 stroke:#333\n\
C:::big --> D",
);
let ids: Vec<&str> = g.nodes.iter().map(|n| n.id.as_str()).collect();
assert_eq!(ids, vec!["A", "B", "C", "D"]);
assert_eq!(g.edges.len(), 2);
}
#[test]
fn a_br_tag_splits_a_label_over_two_lines() {
let g = parse("flowchart LR\nA[read<br/>the file] --> B[one<br />two<BR>three]");
assert_eq!(
g.nodes[0].label,
vec!["read".to_string(), "the file".to_string()]
);
assert_eq!(g.nodes[1].label.len(), 3);
let rows = drawn("flowchart LR\nA[read<br/>the file]", Dir::Lr);
assert_eq!(
rows,
vec![
"╭──────────╮",
"│ read │",
"│ the file │",
"╰──────────╯",
]
);
}
#[test]
fn a_long_label_is_wrapped_rather_than_pushing_the_page_sideways() {
let src = "flowchart LR\nA[the quick brown fox jumps over the lazy dog] --> B";
let wide = render(src, Dir::Lr, 80).expect("a diagram");
assert!(wide.width <= 60, "{} columns is too many", wide.width);
assert!(wide.height() > 3, "a wrapped label makes a taller box");
let narrow = render(src, Dir::Lr, 20).expect("a diagram");
assert!(narrow.height() > wide.height());
assert!(narrow.width < wide.width);
}
#[test]
fn a_flowchart_with_nothing_in_it_is_not_drawn() {
assert_eq!(render("flowchart LR", Dir::Lr, 80), None);
assert_eq!(render("graph TD\n%% nothing yet\n\n", Dir::Td, 80), None);
assert_eq!(render("flowchart LR\nstyle A fill:#f00", Dir::Lr, 80), None);
}
pub(super) const CROWDED: &str = "flowchart TD\n\
Start[Start here] --> Check{ok?}\n\
Check -->|yes| Work[Do the work]\n\
Check -->|no| Fix[Fix it]\n\
Fix --> Work\n\
Work --> Log & Notify\n\
Log --> Done((Done))\n\
Notify --> Done\n\
Start --> Done\n\
Done --> Check\n\
Work -.-> Audit\n\
Audit ==> Done";
#[test]
fn a_crowded_diagram_never_puts_one_box_on_another() {
for dir in [Dir::Lr, Dir::Rl, Dir::Td] {
let g = parse(CROWDED);
let ranks = rank(&g);
let plan = plan(&g, &ranks, dir, 80);
for (i, a) in plan.places.iter().enumerate() {
for b in plan.places.iter().skip(i + 1) {
let apart = a.major + a.ms <= b.major
|| b.major + b.ms <= a.major
|| a.minor + a.mn <= b.minor
|| b.minor + b.mn <= a.minor;
assert!(apart, "two boxes share a cell in {dir:?}");
}
}
let rows = drawn(CROWDED, dir);
for node in &g.nodes {
for line in &node.label {
assert!(
rows.iter().any(|r| r.contains(line.as_str())),
"{line:?} was drawn over in {dir:?}: {rows:#?}"
);
}
}
}
}
#[test]
fn no_row_is_wider_than_the_diagram_says_it_is() {
for dir in [Dir::Lr, Dir::Rl, Dir::Td] {
let rendered = render(CROWDED, dir, 80).expect("a diagram");
for row in rendered.text() {
assert!(str_width(&row) <= rendered.width);
}
}
}
}
#[cfg(test)]
mod eyeball {
use super::*;
#[test]
fn look() {
for src in [
"flowchart LR\nA[Start] --> B{ok?}\nB -->|yes| C[Ship it]\nB -->|no| D[Fix it]\nD --> B",
super::tests::CROWDED,
"graph TD\nA[Start] --> B{ok?}\nB -->|yes| C[Ship it]\nB -->|no| D[Fix it]\nD --> B",
"flowchart LR\nA --> B --> C --> D\nA --> D",
"graph TD\nA --> B --> C --> D\nA --> D",
] {
for dir in [Dir::Lr, Dir::Td] {
if (dir == Dir::Td) != src.starts_with("graph") { continue; }
println!("──── {dir:?} ────");
for row in render(src, dir, 80).unwrap().text() { println!("{row}"); }
}
}
}
}