use unicode_width::UnicodeWidthStr;
use crate::er::{AttributeKey, Cardinality, ErDiagram, Relationship};
const MIN_ENTITY_GAP: usize = 4;
const NAME_PAD: usize = 2;
pub fn render(chart: &ErDiagram, _max_width: Option<usize>) -> String {
if chart.entities.is_empty() {
return String::new();
}
let entity_widths: Vec<usize> = chart.entities.iter().map(entity_box_width).collect();
let entity_heights: Vec<usize> = chart.entities.iter().map(entity_box_height).collect();
let tallest = *entity_heights.iter().max().unwrap_or(&HEADER_ROWS);
let pair_gaps = compute_pair_gaps(chart);
let entity_left: Vec<usize> = {
let mut out = Vec::with_capacity(chart.entities.len());
let mut col = 0usize;
for (i, &w) in entity_widths.iter().enumerate() {
out.push(col);
col += w + pair_gaps.get(i).copied().unwrap_or(MIN_ENTITY_GAP);
}
out
};
let last_right = entity_left
.last()
.zip(entity_widths.last())
.map(|(&left, &w)| left + w)
.unwrap_or(0);
let has_labels = chart
.relationships
.iter()
.any(|r| r.label.as_deref().is_some_and(|s| !s.is_empty()));
let top_pad: usize = if has_labels { 1 } else { 0 };
let height = top_pad + tallest;
let width = last_right.max(1);
let mut grid: Vec<Vec<char>> = vec![vec![' '; width]; height];
for (i, entity) in chart.entities.iter().enumerate() {
let left = entity_left[i];
let right = left + entity_widths[i] - 1;
draw_entity_box(&mut grid, top_pad, left, right, entity);
}
for rel in &chart.relationships {
let (Some(from_idx), Some(to_idx)) =
(chart.entity_index(&rel.from), chart.entity_index(&rel.to))
else {
continue;
};
if from_idx == to_idx {
continue;
}
draw_relationship_line(
&mut grid,
top_pad,
entity_left[from_idx],
entity_widths[from_idx],
entity_left[to_idx],
entity_widths[to_idx],
rel,
);
}
grid_to_string(&grid)
}
fn compute_pair_gaps(chart: &ErDiagram) -> Vec<usize> {
let n = chart.entities.len();
if n < 2 {
return vec![MIN_ENTITY_GAP; n];
}
let mut gaps = vec![MIN_ENTITY_GAP; n];
for rel in &chart.relationships {
let Some(from_idx) = chart.entity_index(&rel.from) else {
continue;
};
let Some(to_idx) = chart.entity_index(&rel.to) else {
continue;
};
if from_idx == to_idx {
continue;
}
let (lo_idx, hi_idx) = if from_idx <= to_idx {
(from_idx, to_idx)
} else {
(to_idx, from_idx)
};
let label_w = rel.label.as_deref().map(|s| s.width()).unwrap_or(0);
let needed = label_w.max(2) + 4;
for gap in gaps.iter_mut().take(hi_idx).skip(lo_idx) {
*gap = (*gap).max(needed);
}
}
gaps
}
const HEADER_ROWS: usize = 3;
struct AttrColumns {
type_w: usize,
name_w: usize,
keys_w: usize,
}
fn attr_columns(entity: &crate::er::Entity) -> AttrColumns {
let mut cols = AttrColumns {
type_w: 0,
name_w: 0,
keys_w: 0,
};
for attr in &entity.attributes {
cols.type_w = cols.type_w.max(attr.type_name.width());
cols.name_w = cols.name_w.max(attr.name.width());
cols.keys_w = cols.keys_w.max(format_keys(&attr.keys).width());
}
cols
}
fn entity_box_width(entity: &crate::er::Entity) -> usize {
let header_w = entity.name.width() + 2 * NAME_PAD + 2;
if entity.attributes.is_empty() {
return header_w;
}
let cols = attr_columns(entity);
let attr_w = 2 * NAME_PAD + cols.type_w + 1 + cols.name_w + 1 + cols.keys_w + 2;
attr_w.max(header_w)
}
fn entity_box_height(entity: &crate::er::Entity) -> usize {
if entity.attributes.is_empty() {
HEADER_ROWS
} else {
HEADER_ROWS + entity.attributes.len() + 1
}
}
fn draw_entity_box(
grid: &mut [Vec<char>],
top_pad: usize,
left: usize,
right: usize,
entity: &crate::er::Entity,
) {
let interior_w = right - left - 1;
let name_w = entity.name.width();
let name_start = left + 1 + (interior_w.saturating_sub(name_w)) / 2;
put(grid, top_pad, left, '┌');
for c in (left + 1)..right {
put(grid, top_pad, c, '─');
}
put(grid, top_pad, right, '┐');
put(grid, top_pad + 1, left, '│');
put_str(grid, top_pad + 1, name_start, &entity.name);
put(grid, top_pad + 1, right, '│');
if entity.attributes.is_empty() {
put(grid, top_pad + 2, left, '└');
for c in (left + 1)..right {
put(grid, top_pad + 2, c, '─');
}
put(grid, top_pad + 2, right, '┘');
return;
}
put(grid, top_pad + 2, left, '├');
for c in (left + 1)..right {
put(grid, top_pad + 2, c, '─');
}
put(grid, top_pad + 2, right, '┤');
let cols = attr_columns(entity);
for (i, attr) in entity.attributes.iter().enumerate() {
let row = top_pad + HEADER_ROWS + i;
put(grid, row, left, '│');
let mut col = left + 1 + NAME_PAD;
put_str(grid, row, col, &pad_right(&attr.type_name, cols.type_w));
col += cols.type_w + 1;
put_str(grid, row, col, &pad_right(&attr.name, cols.name_w));
col += cols.name_w + 1;
let keys_str = format_keys(&attr.keys);
put_str(grid, row, col, &pad_right(&keys_str, cols.keys_w));
put(grid, row, right, '│');
}
let bottom = top_pad + HEADER_ROWS + entity.attributes.len();
put(grid, bottom, left, '└');
for c in (left + 1)..right {
put(grid, bottom, c, '─');
}
put(grid, bottom, right, '┘');
}
fn pad_right(s: &str, width: usize) -> String {
let current = s.width();
if current >= width {
return s.to_string();
}
let mut out = String::with_capacity(s.len() + (width - current));
out.push_str(s);
for _ in current..width {
out.push(' ');
}
out
}
fn format_keys(keys: &[AttributeKey]) -> String {
keys.iter()
.map(|k| match k {
AttributeKey::PrimaryKey => "PK",
AttributeKey::ForeignKey => "FK",
AttributeKey::UniqueKey => "UK",
})
.collect::<Vec<_>>()
.join(",")
}
#[allow(clippy::too_many_arguments)]
fn draw_relationship_line(
grid: &mut [Vec<char>],
top_pad: usize,
from_left: usize,
from_width: usize,
to_left: usize,
to_width: usize,
rel: &Relationship,
) {
let line_row = top_pad + 1;
let from_right_border = from_left + from_width - 1;
let to_left_border = to_left;
let from_left_border = from_left;
let to_right_border = to_left + to_width - 1;
let going_right = from_right_border < to_left_border;
let (left_border, right_border, source_at_left, line_lo, line_hi) = if going_right {
let lo = from_right_border + 1;
let hi = to_left_border.saturating_sub(1);
(from_right_border, to_left_border, true, lo, hi)
} else {
let lo = to_right_border + 1;
let hi = from_left_border.saturating_sub(1);
(to_right_border, from_left_border, false, lo, hi)
};
if line_hi <= line_lo {
return; }
let line_glyph = if rel.line_style.is_dashed() {
'┄'
} else {
'─'
};
if !rel.line_style.is_dashed() {
put(grid, line_row, left_border, '┤');
put(grid, line_row, right_border, '├');
}
for c in line_lo..=line_hi {
put(grid, line_row, c, line_glyph);
}
let (lo_card, hi_card) = if source_at_left {
(rel.from_cardinality, rel.to_cardinality)
} else {
(rel.to_cardinality, rel.from_cardinality)
};
put(grid, line_row, line_lo, cardinality_glyph(lo_card));
put(grid, line_row, line_hi, cardinality_glyph(hi_card));
if top_pad == 0 {
return;
}
if let Some(label) = &rel.label
&& !label.is_empty()
{
let label_w = label.width();
let gap_w = line_hi - line_lo + 1;
let label_row = top_pad - 1;
if gap_w >= label_w {
let offset = (gap_w - label_w) / 2;
put_str(grid, label_row, line_lo + offset, label);
} else {
put_str(grid, label_row, line_lo, label);
}
}
}
fn cardinality_glyph(c: Cardinality) -> char {
match c {
Cardinality::ExactlyOne => '1',
Cardinality::ZeroOrOne => '?',
Cardinality::OneOrMany => '+',
Cardinality::ZeroOrMany => '*',
}
}
fn put(grid: &mut [Vec<char>], row: usize, col: usize, ch: char) {
if let Some(line) = grid.get_mut(row)
&& let Some(cell) = line.get_mut(col)
{
*cell = ch;
}
}
fn put_str(grid: &mut [Vec<char>], row: usize, col: usize, s: &str) {
for (c, ch) in (col..).zip(s.chars()) {
put(grid, row, c, ch);
}
}
fn grid_to_string(grid: &[Vec<char>]) -> String {
let mut out = String::with_capacity(grid.iter().map(|r| r.len() + 1).sum());
for row in grid {
let line: String = row.iter().collect();
out.push_str(line.trim_end());
out.push('\n');
}
while out.ends_with('\n') {
out.pop();
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::er::parse;
#[test]
fn renders_two_entities_with_relationship() {
let chart = parse("erDiagram\nCUSTOMER ||--o{ ORDER : places").unwrap();
let out = render(&chart, None);
assert!(out.contains("CUSTOMER"));
assert!(out.contains("ORDER"));
assert!(out.contains('1'));
assert!(out.contains('*'));
assert!(out.contains("places"));
}
#[test]
fn renders_isolated_entity_with_attributes() {
let chart = parse("erDiagram\nCUSTOMER {\n string name\n string email PK\n}").unwrap();
let out = render(&chart, None);
assert!(out.contains("CUSTOMER"));
assert!(out.contains("string"));
assert!(out.contains("email"));
assert!(out.contains("PK"));
}
#[test]
fn renders_dashed_line_for_non_identifying() {
let chart = parse("erDiagram\nA ||..o{ B").unwrap();
let out = render(&chart, None);
assert!(out.contains('┄'), "expected dashed line in:\n{out}");
}
#[test]
fn cardinality_glyph_table_is_distinct() {
let glyphs = [
cardinality_glyph(Cardinality::ExactlyOne),
cardinality_glyph(Cardinality::ZeroOrOne),
cardinality_glyph(Cardinality::OneOrMany),
cardinality_glyph(Cardinality::ZeroOrMany),
];
let unique: std::collections::HashSet<_> = glyphs.iter().collect();
assert_eq!(unique.len(), 4, "cardinality glyphs must be unique");
}
#[test]
fn format_keys_handles_zero_one_and_multiple() {
assert_eq!(format_keys(&[]), "");
assert_eq!(format_keys(&[AttributeKey::PrimaryKey]), "PK");
assert_eq!(
format_keys(&[AttributeKey::ForeignKey, AttributeKey::UniqueKey]),
"FK,UK"
);
}
}