use std::panic::AssertUnwindSafe;
use ratatui::text::{Line, Span};
use super::super::{
render::{display_width, truncate_to_display_width},
theme::Theme,
};
use super::panel::ClosedPanel;
use crate::tui::terminal_graph::{GraphStyles, Oversize};
mod flow;
mod gantt;
mod gitgraph;
mod mindmap;
mod model;
mod policy;
mod security;
mod sequence;
pub(super) struct MermaidArt {
pub(super) styled_lines: Vec<Line<'static>>,
pub(super) plain_lines: Vec<String>,
}
const MAX_SOURCE_BYTES: usize = 64 * 1024;
const MAX_SOURCE_LINES: usize = 2_048;
const MAX_PRIMARY_ENTITIES: usize = 128;
const MAX_RELATIONSHIPS: usize = 512;
const MAX_GROUPS: usize = 24;
const MAX_DETAILS: usize = 1_024;
const MAX_RENDERED_LINES: usize = 4_096;
const MAX_RENDERED_CELLS: usize = 2_000_000;
const MIN_CLIP_WIDTH: usize = 24;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum MermaidFallback {
Blank,
SourceBytes,
SourceLines,
UnsafeContent,
Unsupported,
Malformed,
StructuralLimit,
Panic,
OutputLines,
OutputCells,
TooWide,
AnsiOutput,
}
impl MermaidFallback {
pub(super) fn panel_title(self) -> &'static str {
match self {
Self::TooWide => "MERMAID · PANE TOO NARROW",
Self::Unsupported => "MERMAID · UNSUPPORTED",
Self::Malformed => "MERMAID · INVALID",
Self::SourceBytes
| Self::SourceLines
| Self::StructuralLimit
| Self::OutputLines
| Self::OutputCells => "MERMAID · TOO LARGE",
Self::Blank | Self::UnsafeContent | Self::Panic | Self::AnsiOutput => {
"MERMAID · NOT RENDERED"
}
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum MermaidRender {
Rendered(Vec<Line<'static>>),
Clipped {
lines: Vec<Line<'static>>,
hidden_columns: usize,
},
Fallback(MermaidFallback),
}
pub(super) fn render_closed_fence(source: String, inner_width: usize) -> ClosedPanel {
match mermaid_art(&source, inner_width) {
Ok((title, lines)) => ClosedPanel::Art {
title,
lines,
source,
},
Err(reason) => ClosedPanel::SourceFallback {
title: reason.panel_title(),
source,
},
}
}
pub(super) fn render_open_prefix(
complete_body: &str,
copy_source: &str,
inner_width: usize,
) -> Option<ClosedPanel> {
let mut body = complete_body;
loop {
match mermaid_art(body, inner_width) {
Ok((title, lines)) => {
return Some(ClosedPanel::Art {
title,
lines,
source: copy_source.to_string(),
});
}
Err(MermaidFallback::Blank | MermaidFallback::Malformed) => {
let (shorter, _) = body.rsplit_once('\n')?;
body = shorter;
}
Err(_) => return None,
}
}
}
fn mermaid_art(
source: &str,
inner_width: usize,
) -> Result<(&'static str, Vec<Line<'static>>), MermaidFallback> {
match render_mermaid(source, inner_width) {
MermaidRender::Rendered(lines) => Ok(("MERMAID", lines)),
MermaidRender::Clipped {
mut lines,
hidden_columns,
} => {
lines.push(Line::from(Span::styled(
format!("▶ {hidden_columns} cols clipped"),
Theme::dim(),
)));
Ok(("MERMAID · CLIPPED", lines))
}
MermaidRender::Fallback(reason) => Err(reason),
}
}
pub(super) fn render_mermaid(source: &str, inner_width: usize) -> MermaidRender {
match std::panic::catch_unwind(AssertUnwindSafe(|| render_inner(source, inner_width))) {
Ok(result) => result,
Err(_) => MermaidRender::Fallback(MermaidFallback::Panic),
}
}
fn render_inner(source: &str, inner_width: usize) -> MermaidRender {
if source.trim().is_empty() {
return MermaidRender::Fallback(MermaidFallback::Blank);
}
if source.len() > MAX_SOURCE_BYTES {
return MermaidRender::Fallback(MermaidFallback::SourceBytes);
}
if source.lines().count() > MAX_SOURCE_LINES {
return MermaidRender::Fallback(MermaidFallback::SourceLines);
}
if inner_width == 0 || security::contains_unsafe_content(source) {
return MermaidRender::Fallback(MermaidFallback::UnsafeContent);
}
if !is_supported_header(source) {
return MermaidRender::Fallback(MermaidFallback::Unsupported);
}
let parsed = match mermaid_rs_renderer::parse_mermaid_strict(source) {
Ok(parsed) => parsed,
Err(_) => return MermaidRender::Fallback(MermaidFallback::Malformed),
};
if !policy::paints(parsed.graph.kind) {
return MermaidRender::Fallback(MermaidFallback::Unsupported);
}
if !parsed.graph.node_links.is_empty() {
return MermaidRender::Fallback(MermaidFallback::UnsafeContent);
}
let (primary, relationships, groups, details) = model::complexity(&parsed.graph);
if primary > MAX_PRIMARY_ENTITIES
|| relationships > MAX_RELATIONSHIPS
|| groups > MAX_GROUPS
|| details > MAX_DETAILS
{
return MermaidRender::Fallback(MermaidFallback::StructuralLimit);
}
let Some(model) = model::from_ir(&parsed.graph) else {
return MermaidRender::Fallback(MermaidFallback::Unsupported);
};
let style = Theme::code_text();
let styles = GraphStyles {
border: style,
node_text: style,
edge: style,
edge_label: style,
node_styles: Vec::new(),
};
let result = layout_model(&model, &styles, Some(inner_width));
let art = match result {
Ok(art) => art,
Err(Oversize::Width) => {
return render_clipped(&model, &styles, inner_width);
}
Err(Oversize::Cells) => {
return MermaidRender::Fallback(MermaidFallback::OutputCells);
}
};
if let Err(reason) = validate_output(&art.plain_lines, Some(inner_width)) {
return MermaidRender::Fallback(reason);
}
MermaidRender::Rendered(art.styled_lines)
}
fn render_clipped(
model: &model::TerminalModel,
styles: &GraphStyles,
inner_width: usize,
) -> MermaidRender {
if inner_width < MIN_CLIP_WIDTH {
return MermaidRender::Fallback(MermaidFallback::TooWide);
}
let art = match layout_model(model, styles, None) {
Ok(art) => art,
Err(Oversize::Width) => return MermaidRender::Fallback(MermaidFallback::TooWide),
Err(Oversize::Cells) => return MermaidRender::Fallback(MermaidFallback::OutputCells),
};
let full_width = art
.plain_lines
.iter()
.map(|line| display_width(line))
.max()
.unwrap_or(0);
let hidden_columns = full_width.saturating_sub(inner_width);
if hidden_columns == 0 {
return MermaidRender::Rendered(art.styled_lines);
}
let lines: Vec<Line<'static>> = art
.styled_lines
.into_iter()
.map(|line| clip_line_to_width(line, inner_width))
.collect();
let plain_clipped: Vec<String> = lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
})
.collect();
if let Err(reason) = validate_output(&plain_clipped, None) {
return MermaidRender::Fallback(reason);
}
MermaidRender::Clipped {
lines,
hidden_columns,
}
}
fn clip_line_to_width(line: Line<'static>, width: usize) -> Line<'static> {
let mut used = 0usize;
let mut spans = Vec::new();
for span in line.spans {
if used >= width {
break;
}
let span_width = display_width(span.content.as_ref());
if used + span_width <= width {
used += span_width;
spans.push(span);
} else {
let cut = truncate_to_display_width(span.content.as_ref(), width - used).into_owned();
spans.push(Span::styled(cut, span.style));
break;
}
}
Line::from(spans)
}
fn layout_model(
model: &model::TerminalModel,
styles: &GraphStyles,
max_width: Option<usize>,
) -> Result<MermaidArt, Oversize> {
match model {
model::TerminalModel::Sequence(sequence) => {
sequence::layout_sequence(sequence, styles, max_width)
}
model::TerminalModel::Class { graph, info } => {
flow::render_class(graph, info, styles, max_width)
}
model::TerminalModel::Flow(graph) => flow::layout_flow(graph, styles, max_width),
model::TerminalModel::GitGraph(graph) => {
gitgraph::layout_gitgraph(graph, styles, max_width)
}
model::TerminalModel::Gantt(chart) => gantt::layout_gantt(chart, styles, max_width),
model::TerminalModel::Mindmap(map) => mindmap::layout_mindmap(map, styles, max_width),
}
}
pub(super) fn art_from_plain(plain_lines: Vec<String>, styles: &GraphStyles) -> MermaidArt {
let styled_lines = plain_lines
.iter()
.map(|line| Line::from(Span::styled(line.clone(), styles.node_text)))
.collect();
MermaidArt {
styled_lines,
plain_lines,
}
}
fn validate_output(lines: &[String], max_width: Option<usize>) -> Result<(), MermaidFallback> {
if lines.len() > MAX_RENDERED_LINES {
return Err(MermaidFallback::OutputLines);
}
let mut cells = 0usize;
for line in lines {
if line.contains('\x1b') {
return Err(MermaidFallback::AnsiOutput);
}
let width = display_width(line);
if max_width.is_some_and(|max| width > max) {
return Err(MermaidFallback::TooWide);
}
cells = cells.saturating_add(width);
if cells > MAX_RENDERED_CELLS {
return Err(MermaidFallback::OutputCells);
}
}
Ok(())
}
fn is_supported_header(source: &str) -> bool {
let Some(header) = source
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with("%%"))
.and_then(|line| line.split_whitespace().next())
else {
return false;
};
let header = header.trim_end_matches(':').to_ascii_lowercase();
matches!(
header.as_str(),
"flowchart"
| "graph"
| "statediagram"
| "statediagram-v2"
| "sequencediagram"
| "classdiagram"
| "erdiagram"
| "gitgraph"
| "gantt"
| "mindmap"
)
}
#[cfg(test)]
pub(crate) const PHASE_CHAIN_FLOWCHART: &str = concat!(
"flowchart LR\n",
" P1[\"Phase 1: retention sweep\"] --> P2[\"Phase 2: parent link on disk\"]\n",
" P2 --> P3[\"Phase 3: session delete API + CLI\"]\n",
" P3 --> P4[\"Phase 4: TUI delete in resume picker\"]\n",
" P3 --> P5[\"Phase 5: nest runs under session\"]"
);
#[cfg(test)]
#[path = "mermaid_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "mermaid_width_receipts.rs"]
mod width_receipts;