use unicode_width::UnicodeWidthStr;
use crate::class::{Class, ClassDiagram, Member, RelKind, Relation};
use crate::layout::layered::{LayoutConfig, LayoutResult, layout as layered_layout};
use crate::render::box_table::{NAME_PAD, grid_to_string, put, put_str};
use crate::types::{Direction, Edge, EdgeEndpoint, EdgeStyle, Graph, Node, NodeShape};
const INTERIOR_PAD: usize = NAME_PAD;
const BOX_GAP: usize = 2;
pub fn render(chart: &ClassDiagram, _max_width: Option<usize>) -> String {
if chart.classes.is_empty() {
return String::new();
}
let boxes: Vec<BoxGeometry> = chart.classes.iter().map(compute_box_geometry).collect();
let graph = synthesise_graph(chart, &boxes);
let config = LayoutConfig {
layer_gap: 4,
node_gap: 2,
..LayoutConfig::default()
};
let LayoutResult { positions } = layered_layout(&graph, &config);
if positions.is_empty() {
return String::new();
}
let canvas_cols = positions
.iter()
.map(|(name, &(col, _row))| {
let w = boxes
.iter()
.find(|b| b.class_name == *name)
.map_or(0, |b| b.box_width);
col + w
})
.max()
.unwrap_or(1)
+ BOX_GAP;
let canvas_rows = positions
.iter()
.map(|(name, &(_col, row))| {
let h = boxes
.iter()
.find(|b| b.class_name == *name)
.map_or(0, |b| b.box_height);
row + h
})
.max()
.unwrap_or(1)
+ BOX_GAP;
let mut grid: Vec<Vec<char>> = vec![vec![' '; canvas_cols]; canvas_rows];
for (class, geo) in chart.classes.iter().zip(boxes.iter()) {
let Some(&(col, row)) = positions.get(&class.name) else {
continue;
};
paint_class_box(&mut grid, row, col, class, geo);
}
for rel in &chart.relations {
let Some(&(from_col, from_row)) = positions.get(&rel.from) else {
continue;
};
let Some(&(to_col, to_row)) = positions.get(&rel.to) else {
continue;
};
let from_geo = boxes.iter().find(|b| b.class_name == rel.from);
let to_geo = boxes.iter().find(|b| b.class_name == rel.to);
let (Some(fg), Some(tg)) = (from_geo, to_geo) else {
continue;
};
paint_relation(&mut grid, from_col, from_row, fg, to_col, to_row, tg, rel);
}
grid_to_string(&grid)
}
pub(crate) struct BoxGeometry {
pub class_name: String,
pub box_width: usize,
pub box_height: usize,
}
fn compute_box_geometry(class: &Class) -> BoxGeometry {
let has_stereotype = class.stereotype.is_some();
let name_w = class.name.width();
let stereo_w = class
.stereotype
.as_ref()
.map(|s| format!("<<{}>>", s.label()).width())
.unwrap_or(0);
let header_content_w = name_w.max(stereo_w);
let header_box_w = header_content_w + 2 * INTERIOR_PAD + 2;
let member_content_w = class
.members
.iter()
.map(member_display_width)
.max()
.unwrap_or(0);
let member_box_w = if member_content_w > 0 {
member_content_w + 2 * INTERIOR_PAD + 2
} else {
0
};
let box_width = header_box_w.max(member_box_w).max(6);
let body_rows = if !class.members.is_empty() {
1 + class.members.len() } else {
0
};
let box_height = 1 + if has_stereotype { 1 } else { 0 }
+ 1 + body_rows
+ 1;
BoxGeometry {
class_name: class.name.clone(),
box_width,
box_height,
}
}
fn member_display_width(m: &Member) -> usize {
format_member(m).width()
}
fn synthesise_graph(chart: &ClassDiagram, boxes: &[BoxGeometry]) -> Graph {
let mut graph = Graph::new(Direction::TopToBottom);
for (class, geo) in chart.classes.iter().zip(boxes.iter()) {
let interior_w = geo.box_width.saturating_sub(2);
let interior_h = geo.box_height.saturating_sub(2);
let filler_line = " ".repeat(interior_w.max(1));
let label = std::iter::repeat_n(filler_line.as_str(), interior_h.max(1))
.collect::<Vec<_>>()
.join("\n");
graph
.nodes
.push(Node::new(&class.name, label, NodeShape::Rectangle));
}
for rel in &chart.relations {
graph.edges.push(Edge {
from: rel.from.clone(),
to: rel.to.clone(),
label: None,
style: if rel.kind.is_dashed() {
EdgeStyle::Dotted
} else {
EdgeStyle::Solid
},
end: EdgeEndpoint::None,
start: EdgeEndpoint::None,
});
}
graph
}
fn paint_class_box(
grid: &mut [Vec<char>],
row: usize,
col: usize,
class: &Class,
geo: &BoxGeometry,
) {
let left = col;
let right = col + geo.box_width - 1;
let interior_w = geo.box_width - 2;
put(grid, row, left, '┌');
for c in (left + 1)..right {
put(grid, row, c, '─');
}
put(grid, row, right, '┐');
let mut cur_row = row + 1;
if let Some(stereo) = &class.stereotype {
let label = format!("<<{}>>", stereo.label());
let lw = label.width();
let offset = (interior_w.saturating_sub(lw)) / 2;
put(grid, cur_row, left, '│');
put_str(grid, cur_row, left + 1 + offset, &label);
put(grid, cur_row, right, '│');
cur_row += 1;
}
{
let name_w = class.name.width();
let offset = (interior_w.saturating_sub(name_w)) / 2;
put(grid, cur_row, left, '│');
put_str(grid, cur_row, left + 1 + offset, &class.name);
put(grid, cur_row, right, '│');
cur_row += 1;
}
if class.members.is_empty() {
put(grid, cur_row, left, '└');
for c in (left + 1)..right {
put(grid, cur_row, c, '─');
}
put(grid, cur_row, right, '┘');
return;
}
put(grid, cur_row, left, '├');
for c in (left + 1)..right {
put(grid, cur_row, c, '─');
}
put(grid, cur_row, right, '┤');
cur_row += 1;
for member in &class.members {
let text = format_member(member);
put(grid, cur_row, left, '│');
put_str(grid, cur_row, left + 1 + INTERIOR_PAD, &text);
put(grid, cur_row, right, '│');
cur_row += 1;
}
put(grid, cur_row, left, '└');
for c in (left + 1)..right {
put(grid, cur_row, c, '─');
}
put(grid, cur_row, right, '┘');
}
fn format_member(m: &Member) -> String {
match m {
Member::Attribute(a) => {
let vis = a
.visibility
.map(|v| v.as_char().to_string())
.unwrap_or_else(|| " ".to_string());
let suffix = if a.is_static { "$" } else { "" };
if a.type_name.is_empty() {
format!("{vis}{}{suffix}", a.name)
} else {
format!("{vis}{} {}{suffix}", a.name, a.type_name)
}
}
Member::Method(mt) => {
let vis = mt
.visibility
.map(|v| v.as_char().to_string())
.unwrap_or_else(|| " ".to_string());
let ret = mt
.return_type
.as_ref()
.map(|r| format!(" {r}"))
.unwrap_or_default();
let static_s = if mt.is_static { "$" } else { "" };
let abs_s = if mt.is_abstract { "*" } else { "" };
format!("{vis}{}({}){ret}{static_s}{abs_s}", mt.name, mt.params)
}
}
}
#[allow(clippy::too_many_arguments)]
fn paint_relation(
grid: &mut [Vec<char>],
from_col: usize,
from_row: usize,
from_geo: &BoxGeometry,
to_col: usize,
to_row: usize,
to_geo: &BoxGeometry,
rel: &Relation,
) {
let from_cx = from_col + from_geo.box_width / 2;
let from_cy = from_row + from_geo.box_height / 2;
let to_cx = to_col + to_geo.box_width / 2;
let to_cy = to_row + to_geo.box_height / 2;
let dy = to_cy as isize - from_cy as isize;
let dx = to_cx as isize - from_cx as isize;
let line_ch = if rel.kind.is_dashed() { '┄' } else { '─' };
let vert_ch = if rel.kind.is_dashed() { '┆' } else { '│' };
if dy.abs() >= dx.abs() {
let (from_ac, from_ar, to_ac, to_ar) = if dy > 0 {
let far = from_row + from_geo.box_height - 1;
(from_cx, far, to_cx, to_row)
} else {
(from_cx, from_row, to_cx, to_row + to_geo.box_height - 1)
};
draw_manhattan(grid, from_ac, from_ar, to_ac, to_ar, line_ch, vert_ch);
paint_endpoints(grid, from_ac, from_ar, to_ac, to_ar, rel, false);
} else {
let (from_ac, from_ar, to_ac, to_ar) = if dx > 0 {
let far = from_col + from_geo.box_width - 1;
(far, from_cy, to_col, to_cy)
} else {
(from_col, from_cy, to_col + to_geo.box_width - 1, to_cy)
};
draw_manhattan(grid, from_ac, from_ar, to_ac, to_ar, line_ch, vert_ch);
paint_endpoints(grid, from_ac, from_ar, to_ac, to_ar, rel, true);
}
if let Some(label) = &rel.label
&& !label.is_empty()
{
let mid_col = (from_cx + to_cx) / 2;
let mid_row = (from_row + from_geo.box_height / 2 + to_row + to_geo.box_height / 2) / 2;
let label_row = mid_row.saturating_sub(1);
put_str(grid, label_row, mid_col, label);
}
}
fn draw_manhattan(
grid: &mut [Vec<char>],
c0: usize,
r0: usize,
c1: usize,
r1: usize,
h_ch: char,
v_ch: char,
) {
if r0 != r1 {
let (r_lo, r_hi) = if r0 < r1 { (r0, r1) } else { (r1, r0) };
for r in r_lo..=r_hi {
let cur = grid
.get(r)
.and_then(|row| row.get(c0))
.copied()
.unwrap_or(' ');
let ch = junction(cur, v_ch, false);
put(grid, r, c0, ch);
}
}
if c0 != c1 {
let (c_lo, c_hi) = if c0 < c1 { (c0, c1) } else { (c1, c0) };
for c in c_lo..=c_hi {
let cur = grid
.get(r1)
.and_then(|row| row.get(c))
.copied()
.unwrap_or(' ');
let ch = junction(cur, h_ch, true);
put(grid, r1, c, ch);
}
}
}
fn junction(existing: char, new_ch: char, is_horizontal: bool) -> char {
let h_chars = ['─', '┄', '━', '┼', '├', '┤', '┬', '┴'];
let v_chars = ['│', '┆', '┃', '┼', '├', '┤', '┬', '┴'];
let existing_is_h = h_chars.contains(&existing);
let existing_is_v = v_chars.contains(&existing);
if is_horizontal && existing_is_v {
'┼'
} else if !is_horizontal && existing_is_h {
'┼'
} else {
new_ch
}
}
fn paint_endpoints(
grid: &mut [Vec<char>],
from_ac: usize,
from_ar: usize,
to_ac: usize,
to_ar: usize,
rel: &Relation,
horizontal: bool,
) {
let from_dir = if horizontal {
if to_ac > from_ac {
Dir::Right
} else {
Dir::Left
}
} else if to_ar > from_ar {
Dir::Down
} else {
Dir::Up
};
let to_dir = from_dir.reverse();
match rel.kind {
RelKind::Inheritance | RelKind::Realization => {
put(grid, from_ar, from_ac, '△');
}
RelKind::Composition => {
put(grid, from_ar, from_ac, '◆');
}
RelKind::Aggregation => {
put(grid, from_ar, from_ac, '◇');
}
RelKind::AssociationDirected => {
put(grid, to_ar, to_ac, dir_arrow(to_dir));
}
RelKind::Dependency => {
put(grid, to_ar, to_ac, dir_arrow(to_dir));
}
RelKind::AssociationPlain => {
}
}
}
#[derive(Clone, Copy)]
enum Dir {
Up,
Down,
Left,
Right,
}
impl Dir {
fn reverse(self) -> Self {
match self {
Self::Up => Self::Down,
Self::Down => Self::Up,
Self::Left => Self::Right,
Self::Right => Self::Left,
}
}
}
fn dir_arrow(dir: Dir) -> char {
match dir {
Dir::Up => '▴',
Dir::Down => '▾',
Dir::Left => '◂',
Dir::Right => '▸',
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::class::parse;
#[test]
fn render_empty_diagram_returns_empty_string() {
let diag = ClassDiagram::default();
assert_eq!(render(&diag, None), "");
}
#[test]
fn render_single_bare_class_contains_name() {
let diag = parse("classDiagram\nclass Animal").unwrap();
let out = render(&diag, None);
assert!(out.contains("Animal"), "missing name in:\n{out}");
assert!(out.contains('┌'), "missing top border in:\n{out}");
assert!(out.contains('└'), "missing bottom border in:\n{out}");
}
#[test]
fn render_class_with_members_contains_members() {
let src = "classDiagram\nclass Animal {\n +String name\n +speak() void\n}";
let diag = parse(src).unwrap();
let out = render(&diag, None);
assert!(out.contains("Animal"), "missing class name in:\n{out}");
assert!(out.contains("name"), "missing attribute in:\n{out}");
assert!(out.contains("speak"), "missing method in:\n{out}");
}
#[test]
fn render_stereotype_appears_in_box() {
let src = "classDiagram\nclass IShape {\n <<interface>>\n +draw()\n}";
let diag = parse(src).unwrap();
let out = render(&diag, None);
assert!(
out.contains("<<interface>>"),
"missing stereotype in:\n{out}"
);
}
#[test]
fn render_two_classes_with_relation_produces_both_names() {
let src = "classDiagram\nAnimal <|-- Dog";
let diag = parse(src).unwrap();
let out = render(&diag, None);
assert!(out.contains("Animal"), "missing Animal in:\n{out}");
assert!(out.contains("Dog"), "missing Dog in:\n{out}");
}
#[test]
fn render_no_panic_on_all_relation_kinds() {
let kinds = [
"A <|-- B", "A --|> B", "A *-- B", "A o-- B", "A --> B", "A -- B", "A <|.. B",
"A ..|> B", "A ..> B", "A <.. B",
];
for src in kinds {
let full = format!("classDiagram\n{src}");
let diag = parse(&full).unwrap();
let out = render(&diag, None);
assert!(!out.is_empty(), "empty render for {src:?}");
}
}
#[test]
fn format_member_attribute_with_type() {
let m = Member::Attribute(crate::class::Attribute {
visibility: Some(crate::class::Visibility::Public),
name: "count".to_string(),
type_name: "int".to_string(),
is_static: false,
});
assert_eq!(format_member(&m), "+count int");
}
#[test]
fn format_member_method_with_params_and_return() {
let m = Member::Method(crate::class::Method {
visibility: Some(crate::class::Visibility::Public),
name: "add".to_string(),
params: "x: int".to_string(),
return_type: Some("int".to_string()),
is_static: false,
is_abstract: false,
});
assert_eq!(format_member(&m), "+add(x: int) int");
}
#[test]
fn format_member_static_adds_dollar() {
let m = Member::Attribute(crate::class::Attribute {
visibility: Some(crate::class::Visibility::Public),
name: "INSTANCE".to_string(),
type_name: "Singleton".to_string(),
is_static: true,
});
let s = format_member(&m);
assert!(s.ends_with('$'), "expected '$' suffix in {s:?}");
}
#[test]
fn render_class_with_relation_and_label() {
let src = "classDiagram\nAnimal <|-- Dog : inherits";
let diag = parse(src).unwrap();
let out = render(&diag, None);
assert!(out.contains("inherits"), "missing label in:\n{out}");
}
#[test]
fn render_output_has_no_trailing_whitespace_on_any_line() {
let src = "classDiagram\nclass Animal {\n +String name\n}\nclass Dog\nAnimal <|-- Dog";
let diag = parse(src).unwrap();
let out = render(&diag, None);
for (i, line) in out.lines().enumerate() {
assert_eq!(
line,
line.trim_end(),
"trailing whitespace on line {i}: {line:?}"
);
}
}
}