#![forbid(unsafe_code)]
pub mod detect;
pub mod layout;
pub mod parser;
pub mod render;
pub mod types;
pub use types::{Direction, Edge, EdgeEndpoint, EdgeStyle, Graph, Node, NodeShape};
use detect::DiagramKind;
use layout::layered::LayoutConfig;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
EmptyInput,
UnsupportedDiagram(String),
ParseError(String),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::EmptyInput => write!(f, "empty or blank input"),
Error::UnsupportedDiagram(kind) => {
write!(f, "unsupported diagram type: '{kind}'")
}
Error::ParseError(msg) => write!(f, "parse error: {msg}"),
}
}
}
impl std::error::Error for Error {}
pub fn render(input: &str) -> Result<String, Error> {
render_with_width(input, None)
}
pub fn render_with_width(input: &str, max_width: Option<usize>) -> Result<String, Error> {
let kind = detect::detect(input)?;
let graph = match kind {
DiagramKind::Flowchart => parser::parse(input)?,
};
let default_cfg = LayoutConfig::default();
let result = render_with_config(&graph, &default_cfg);
let Some(budget) = max_width else {
return Ok(result);
};
if max_line_width(&result) <= budget {
return Ok(result);
}
const COMPACT_CONFIGS: &[LayoutConfig] = &[
LayoutConfig {
layer_gap: 4,
node_gap: 2,
},
LayoutConfig {
layer_gap: 2,
node_gap: 1,
},
LayoutConfig {
layer_gap: 1,
node_gap: 0,
},
];
let mut best = render_with_config(&graph, COMPACT_CONFIGS.last().expect("non-empty"));
for cfg in COMPACT_CONFIGS {
let candidate = render_with_config(&graph, cfg);
if max_line_width(&candidate) <= budget {
return Ok(candidate);
}
best = candidate;
}
Ok(best)
}
fn render_with_config(graph: &crate::types::Graph, config: &LayoutConfig) -> String {
let mut positions = layout::layered::layout(graph, config);
if !graph.subgraphs.is_empty() {
offset_positions_for_subgraphs(graph, &mut positions);
}
let sg_bounds = layout::subgraph::compute_subgraph_bounds(graph, &positions);
render::render(graph, &positions, &sg_bounds)
}
fn offset_positions_for_subgraphs(
graph: &crate::types::Graph,
positions: &mut std::collections::HashMap<String, (usize, usize)>,
) {
use layout::subgraph::SG_BORDER_PAD;
let node_sg_map = graph.node_to_subgraph();
let max_depth = compute_max_nesting_depth(graph);
let required_pad = SG_BORDER_PAD * (max_depth + 1);
let mut min_col = usize::MAX;
let mut min_row = usize::MAX;
for (node_id, &(col, row)) in positions.iter() {
if node_sg_map.contains_key(node_id) {
min_col = min_col.min(col);
min_row = min_row.min(row);
}
}
if min_col == usize::MAX {
return; }
let col_offset = required_pad.saturating_sub(min_col);
let row_offset = required_pad.saturating_sub(min_row);
if col_offset == 0 && row_offset == 0 {
return;
}
for (col, row) in positions.values_mut() {
*col += col_offset;
*row += row_offset;
}
}
fn compute_max_nesting_depth(graph: &crate::types::Graph) -> usize {
fn depth_of(graph: &crate::types::Graph, sg: &crate::types::Subgraph, cur: usize) -> usize {
let mut max = cur;
for child_id in &sg.subgraph_ids {
if let Some(child) = graph.find_subgraph(child_id) {
max = max.max(depth_of(graph, child, cur + 1));
}
}
max
}
graph
.subgraphs
.iter()
.map(|sg| depth_of(graph, sg, 0))
.max()
.unwrap_or(0)
}
fn max_line_width(text: &str) -> usize {
text.lines()
.map(unicode_width::UnicodeWidthStr::width)
.max()
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_simple_lr_flowchart() {
let out = render("graph LR; A-->B-->C").unwrap();
assert!(out.contains('A'), "missing A in:\n{out}");
assert!(out.contains('B'), "missing B in:\n{out}");
assert!(out.contains('C'), "missing C in:\n{out}");
assert!(
out.contains('▸') || out.contains('-'),
"no arrow found in:\n{out}"
);
}
#[test]
fn render_simple_td_flowchart() {
let out = render("graph TD; A-->B").unwrap();
let a_pos = out.find('A').unwrap_or(usize::MAX);
let b_pos = out.find('B').unwrap_or(usize::MAX);
assert!(a_pos < b_pos, "expected A before B in TD layout:\n{out}");
assert!(out.contains('▾'), "missing down arrow in:\n{out}");
}
#[test]
fn render_labeled_nodes() {
let out = render("graph LR; A[Start] --> B[End]").unwrap();
assert!(out.contains("Start"), "missing 'Start' in:\n{out}");
assert!(out.contains("End"), "missing 'End' in:\n{out}");
assert!(
out.contains('┌') || out.contains('╭'),
"no box corner:\n{out}"
);
}
#[test]
fn render_edge_labels() {
let out = render("graph LR; A -->|yes| B").unwrap();
assert!(out.contains("yes"), "missing edge label 'yes' in:\n{out}");
}
#[test]
fn render_diamond_node() {
let out = render("graph LR; A{Decision} --> B[OK]").unwrap();
assert!(out.contains("Decision"), "missing 'Decision' in:\n{out}");
assert!(out.contains('◇'), "no diamond marker in:\n{out}");
}
#[test]
fn parse_semicolons() {
let out = render("graph LR; A-->B; B-->C").unwrap();
assert!(out.contains('A'));
assert!(out.contains('B'));
assert!(out.contains('C'));
}
#[test]
fn parse_newlines() {
let src = "graph TD\nA[Alpha]\nB[Beta]\nA --> B";
let out = render(src).unwrap();
assert!(out.contains("Alpha"), "missing 'Alpha' in:\n{out}");
assert!(out.contains("Beta"), "missing 'Beta' in:\n{out}");
}
#[test]
fn unknown_diagram_type_returns_error() {
let err = render("pie title Pets").unwrap_err();
assert!(
matches!(err, Error::UnsupportedDiagram(_)),
"expected UnsupportedDiagram, got {err:?}"
);
}
#[test]
fn empty_input_returns_error() {
assert!(matches!(render(""), Err(Error::EmptyInput)));
assert!(matches!(render(" "), Err(Error::EmptyInput)));
assert!(matches!(render("\n\n"), Err(Error::EmptyInput)));
}
#[test]
fn single_node_renders() {
let out = render("graph LR; A[Alone]").unwrap();
assert!(out.contains("Alone"), "missing 'Alone' in:\n{out}");
assert!(out.contains('┌') || out.contains('╭'));
}
#[test]
fn cyclic_graph_doesnt_hang() {
let out = render("graph LR; A-->B; B-->A").unwrap();
assert!(out.contains('A'));
assert!(out.contains('B'));
}
#[test]
fn special_chars_in_labels() {
let out = render("graph LR; A[Hello World] --> B[Item (1)]").unwrap();
assert!(out.contains("Hello World"), "missing label in:\n{out}");
assert!(out.contains("Item (1)"), "missing label in:\n{out}");
}
#[test]
fn flowchart_keyword_accepted() {
let out = render("flowchart LR; A-->B").unwrap();
assert!(out.contains('A'));
}
#[test]
fn rl_direction_accepted() {
let out = render("graph RL; A-->B").unwrap();
assert!(out.contains('A'));
assert!(out.contains('B'));
}
#[test]
fn bt_direction_accepted() {
let out = render("graph BT; A-->B").unwrap();
assert!(out.contains('A'));
assert!(out.contains('B'));
}
#[test]
fn multiple_branches() {
let src = "graph LR; A[Start] --> B{Decision}; B -->|Yes| C[End]; B -->|No| D[Skip]";
let out = render(src).unwrap();
assert!(out.contains("Start"), "missing 'Start':\n{out}");
assert!(out.contains("Decision"), "missing 'Decision':\n{out}");
assert!(out.contains("End"), "missing 'End':\n{out}");
assert!(out.contains("Skip"), "missing 'Skip':\n{out}");
assert!(out.contains("Yes"), "missing 'Yes':\n{out}");
assert!(out.contains("No"), "missing 'No':\n{out}");
}
#[test]
fn dotted_arrow_parsed() {
let out = render("graph LR; A-.->B").unwrap();
assert!(out.contains('A'));
assert!(out.contains('B'));
}
#[test]
fn thick_arrow_parsed() {
let out = render("graph LR; A==>B").unwrap();
assert!(out.contains('A'));
assert!(out.contains('B'));
}
#[test]
fn rounded_node_renders() {
let out = render("graph LR; A(Rounded)").unwrap();
assert!(out.contains("Rounded"), "missing label in:\n{out}");
assert!(
out.contains('╭') || out.contains('╰'),
"no rounded corners:\n{out}"
);
}
#[test]
fn circle_node_renders() {
let out = render("graph LR; A((Circle))").unwrap();
assert!(out.contains("Circle"), "missing label in:\n{out}");
assert!(
out.contains('(') || out.contains('╭'),
"no circle markers:\n{out}"
);
}
#[test]
fn real_world_flowchart_with_subgraph() {
let src = r#"graph LR
subgraph Supervisor
direction TB
F[Factory] -->|creates| W[Worker]
W -->|panics/exits| F
end
W -->|beat| HB[Heartbeat]
HB --> WD[Watchdog]
W --> CB{Circuit Breaker}
CB -->|CLOSED| DB[(Database)]"#;
let out = render(src).expect("should parse real-world flowchart");
assert!(out.contains("Factory"), "missing Factory:\n{out}");
assert!(out.contains("Worker"), "missing Worker:\n{out}");
assert!(out.contains("Heartbeat"), "missing Heartbeat:\n{out}");
assert!(out.contains("Database"), "missing Database:\n{out}");
assert!(
!out.contains("subgraph"),
"subgraph should be skipped:\n{out}"
);
assert!(
!out.contains("direction"),
"direction should be skipped:\n{out}"
);
}
#[test]
fn multiple_edges_from_same_node_spread() {
let out = render("graph LR; A-->B; A-->C; A-->D").unwrap();
let arrow_rows: Vec<usize> = out
.lines()
.enumerate()
.filter(|(_, line)| line.contains('▸'))
.map(|(i, _)| i)
.collect();
assert!(
arrow_rows.len() >= 3,
"expected at least 3 distinct arrow rows, got {arrow_rows:?}:\n{out}"
);
let unique: std::collections::HashSet<_> = arrow_rows.iter().collect();
assert_eq!(
unique.len(),
arrow_rows.len(),
"duplicate arrow rows {arrow_rows:?} — edges not spread:\n{out}"
);
}
#[test]
fn long_edge_label_not_truncated() {
let out = render("graph LR; A-->|panics and exits cleanly| B").unwrap();
assert!(
out.contains("panics and exits cleanly"),
"label truncated:\n{out}"
);
}
#[test]
fn diverging_labels_dont_collide() {
let out = render("graph TD; B{Ok?}; B-->|Yes|C; B-->|No|D").unwrap();
assert!(out.contains("Yes"), "missing 'Yes' label:\n{out}");
assert!(out.contains("No"), "missing 'No' label:\n{out}");
assert!(
!out.contains("NoYes") && !out.contains("YesNo"),
"labels collided:\n{out}"
);
}
#[test]
fn stadium_node_renders() {
let out = render("graph LR; A([Stadium])").unwrap();
assert!(out.contains("Stadium"), "missing label:\n{out}");
assert!(
out.contains('(') || out.contains('╭'),
"no stadium markers:\n{out}"
);
}
#[test]
fn subroutine_node_renders() {
let out = render("graph LR; A[[Subroutine]]").unwrap();
assert!(out.contains("Subroutine"), "missing label:\n{out}");
assert!(out.contains('│'), "no inner vertical bars:\n{out}");
}
#[test]
fn cylinder_node_renders() {
let out = render("graph LR; A[(Database)]").unwrap();
assert!(out.contains("Database"), "missing label:\n{out}");
assert!(
out.contains('╭') || out.contains('╰'),
"no cylinder arcs:\n{out}"
);
}
#[test]
fn hexagon_node_renders() {
let out = render("graph LR; A{{Hexagon}}").unwrap();
assert!(out.contains("Hexagon"), "missing label:\n{out}");
assert!(out.contains('<') || out.contains('>'), "no hexagon markers:\n{out}");
}
#[test]
fn asymmetric_node_renders() {
let out = render("graph LR; A>Async]").unwrap();
assert!(out.contains("Async"), "missing label:\n{out}");
assert!(out.contains('⟩'), "no asymmetric marker:\n{out}");
}
#[test]
fn parallelogram_node_renders() {
let out = render("graph LR; A[/Parallel/]").unwrap();
assert!(out.contains("Parallel"), "missing label:\n{out}");
assert!(out.contains('/'), "no parallelogram slant marker:\n{out}");
}
#[test]
fn trapezoid_node_renders() {
let out = render("graph LR; A[/Trap\\]").unwrap();
assert!(out.contains("Trap"), "missing label:\n{out}");
assert!(out.contains('/'), "no trapezoid slant marker:\n{out}");
}
#[test]
fn double_circle_node_renders() {
let out = render("graph LR; A(((DblCircle)))").unwrap();
assert!(out.contains("DblCircle"), "missing label:\n{out}");
let corner_count = out.chars().filter(|&c| c == '╭').count();
assert!(
corner_count >= 2,
"expected ≥2 rounded corners for double circle, got {corner_count}:\n{out}"
);
}
#[test]
fn dotted_edge_renders_with_dotted_glyph() {
let out = render("graph LR; A-.->B").unwrap();
assert!(
out.contains('┄') || out.contains('┆'),
"no dotted glyph in:\n{out}"
);
}
#[test]
fn thick_edge_renders_with_thick_glyph() {
let out = render("graph LR; A==>B").unwrap();
assert!(
out.contains('━') || out.contains('┃'),
"no thick glyph in:\n{out}"
);
}
#[test]
fn bidirectional_edge_has_two_arrows() {
let out = render("graph LR; A<-->B").unwrap();
assert!(
out.contains('◂') && out.contains('▸'),
"missing bidirectional arrows in:\n{out}"
);
}
#[test]
fn plain_line_edge_has_no_arrow() {
let out = render("graph LR; A---B").unwrap();
assert!(
!out.contains('▸') && !out.contains('◂'),
"unexpected arrow in plain line:\n{out}"
);
}
#[test]
fn circle_endpoint_renders_circle_glyph() {
let out = render("graph LR; A--oB").unwrap();
assert!(out.contains('○'), "no circle endpoint glyph in:\n{out}");
}
#[test]
fn cross_endpoint_renders_cross_glyph() {
let out = render("graph LR; A--xB").unwrap();
assert!(out.contains('×'), "no cross endpoint glyph in:\n{out}");
}
#[test]
fn subgraph_renders_with_border_and_label() {
let src = r#"graph LR
subgraph Supervisor
F[Factory] --> W[Worker]
end"#;
let out = render(src).unwrap();
assert!(out.contains("Supervisor"), "missing label:\n{out}");
assert!(out.contains("Factory"), "missing Factory:\n{out}");
assert!(out.contains("Worker"), "missing Worker:\n{out}");
assert!(
out.contains('╭') || out.contains('╰'),
"missing rounded subgraph corner:\n{out}"
);
assert!(out.contains('│'), "missing vertical border:\n{out}");
}
#[test]
fn nested_subgraphs_render() {
let src = r#"graph TD
subgraph Outer
subgraph Inner
A[A]
end
B[B]
end"#;
let out = render(src).unwrap();
assert!(out.contains("Outer"), "missing Outer label:\n{out}");
assert!(out.contains("Inner"), "missing Inner label:\n{out}");
assert!(out.contains('A'), "missing A:\n{out}");
assert!(out.contains('B'), "missing B:\n{out}");
let corner_count = out.chars().filter(|&c| c == '╭').count();
assert!(
corner_count >= 2,
"expected at least 2 top-left rounded corners (one per subgraph), got {corner_count}:\n{out}"
);
}
#[test]
fn edge_crossing_subgraph_boundary_renders() {
let src = r#"graph LR
subgraph S
F[Factory] --> W[Worker]
end
W --> HB[Heartbeat]"#;
let out = render(src).unwrap();
assert!(out.contains("Heartbeat"), "missing Heartbeat:\n{out}");
assert!(out.contains("Factory"), "missing Factory:\n{out}");
assert!(out.contains("Worker"), "missing Worker:\n{out}");
assert!(out.contains('╭'), "missing subgraph border:\n{out}");
}
#[test]
fn subgraph_keywords_not_leaked_as_labels() {
let src = r#"graph LR
subgraph Supervisor
direction TB
F[Factory] -->|creates| W[Worker]
W -->|panics/exits| F
end
W -->|beat| HB[Heartbeat]"#;
let out = render(src).expect("should render");
assert!(out.contains("Factory"), "missing Factory:\n{out}");
assert!(out.contains("Worker"), "missing Worker:\n{out}");
assert!(out.contains("Heartbeat"), "missing Heartbeat:\n{out}");
assert!(
!out.contains("subgraph"),
"bare 'subgraph' keyword leaked into output:\n{out}"
);
assert!(
!out.contains("direction"),
"bare 'direction' keyword leaked into output:\n{out}"
);
}
#[test]
fn subgraph_perpendicular_direction_lr_in_td() {
let src = r#"graph TD
subgraph Pipeline
direction LR
A[Input] --> B[Process] --> C[Output]
end
C --> D[Finish]"#;
let out = render(src).unwrap();
assert!(out.contains("Input"), "missing Input:\n{out}");
assert!(out.contains("Process"), "missing Process:\n{out}");
assert!(out.contains("Output"), "missing Output:\n{out}");
assert!(out.contains("Finish"), "missing Finish:\n{out}");
let row_of = |needle: &str| -> usize {
out.lines().position(|l| l.contains(needle)).expect("label not found")
};
assert_eq!(
row_of("Input"),
row_of("Process"),
"Input/Process should share a row in LR subgraph:\n{out}"
);
assert_eq!(
row_of("Process"),
row_of("Output"),
"Process/Output should share a row in LR subgraph:\n{out}"
);
}
#[test]
fn subgraph_same_direction_as_parent_unchanged() {
let a = render(
r#"graph LR
subgraph S
direction LR
A-->B
end"#,
)
.unwrap();
let b = render(
r#"graph LR
subgraph S
A-->B
end"#,
)
.unwrap();
assert_eq!(
a,
b,
"direction LR inside graph LR should match default\nA:\n{a}\nB:\n{b}"
);
}
#[test]
fn subgraph_inherits_when_no_direction() {
let out = render(
r#"graph TD
subgraph S
A-->B-->C
end"#,
)
.unwrap();
let row_of = |needle: &str| -> usize {
out.lines().position(|l| l.contains(needle)).expect("label not found")
};
assert!(
row_of("A") < row_of("B"),
"A should be above B in TD:\n{out}"
);
assert!(
row_of("B") < row_of("C"),
"B should be above C in TD:\n{out}"
);
}
}