1use std::any::Any;
9use std::collections::HashMap;
10
11use super::component::Component;
12use super::theme::theme;
13use super::utils::wrap_text_with_ansi;
14
15pub struct Mermaid {
16 source: String,
17 padding_x: usize,
18}
19
20impl Mermaid {
21 pub fn new(source: impl Into<String>) -> Self {
22 Self {
23 source: source.into(),
24 padding_x: 1,
25 }
26 }
27
28 fn body_lines(&self, width: usize) -> Vec<String> {
29 let source = self.source.trim();
30 let mut lines = Vec::new();
31 let first = source.lines().next().unwrap_or_default().trim();
32 let is_sequence = first.eq_ignore_ascii_case("sequencediagram");
33 let is_flow = first.eq_ignore_ascii_case("graph")
34 || first.to_ascii_lowercase().starts_with("graph ")
35 || first.eq_ignore_ascii_case("flowchart")
36 || first.to_ascii_lowercase().starts_with("flowchart ");
37
38 if is_sequence {
39 lines.extend(self.render_sequence(source));
40 } else if is_flow {
41 lines.extend(self.render_flow(source));
42 }
43
44 if lines.is_empty() {
45 lines = source
46 .lines()
47 .skip_while(|line| {
48 let trimmed = line.trim().to_ascii_lowercase();
49 trimmed == "mermaid"
50 || trimmed == "graph"
51 || trimmed.starts_with("graph ")
52 || trimmed == "flowchart"
53 || trimmed.starts_with("flowchart ")
54 || trimmed == "sequencediagram"
55 })
56 .map(|line| format!(" {line}"))
57 .collect();
58 }
59
60 let max_width = width.saturating_sub(self.padding_x * 2).max(1);
61 lines
62 .into_iter()
63 .flat_map(|line| wrap_text_with_ansi(&line, max_width))
64 .collect()
65 }
66
67 fn render_flow(&self, source: &str) -> Vec<String> {
68 let edges: Vec<_> = source
69 .lines()
70 .skip(1)
71 .map(str::trim)
72 .filter(|line| {
73 !line.is_empty()
74 && !line.starts_with("%%")
75 && !line.starts_with("class")
76 && !line.starts_with("style")
77 && !line.starts_with("linkStyle")
78 })
79 .filter_map(parse_flow_edge)
80 .collect();
81
82 let mut labels = HashMap::new();
86 for (left, _, _, right) in &edges {
87 for raw_node in [*left, *right] {
88 let id = node_id(raw_node);
89 let label = node_label(raw_node);
90 if !id.is_empty() && !label.is_empty() && id != label {
91 labels.insert(id, label);
92 }
93 }
94 }
95
96 edges
97 .into_iter()
98 .filter_map(|(left, arrow, edge_label, right)| {
99 let left = labels
100 .get(&node_id(left))
101 .cloned()
102 .unwrap_or_else(|| node_label(left));
103 let right = labels
104 .get(&node_id(right))
105 .cloned()
106 .unwrap_or_else(|| node_label(right));
107 if left.is_empty() || right.is_empty() {
108 return None;
109 }
110 let label_suffix = edge_label
111 .filter(|label| !label.is_empty())
112 .map(|label| format!(" ({label})"))
113 .unwrap_or_default();
114 Some(format!(" {left} {arrow}{label_suffix} {right}"))
115 })
116 .collect()
117 }
118
119 fn render_sequence(&self, source: &str) -> Vec<String> {
120 let mut out = Vec::new();
121 for raw in source.lines().skip(1) {
122 let line = raw.trim();
123 if line.is_empty() || line.starts_with("%%") || line.starts_with("participant ") {
124 continue;
125 }
126 let Some((left, rest)) = line.split_once("->>") else {
127 continue;
128 };
129 let Some((right, message)) = rest.split_once(':') else {
130 continue;
131 };
132 out.push(format!(
133 " {} ──▶ {}: {}",
134 left.trim(),
135 right.trim(),
136 message.trim()
137 ));
138 }
139 out
140 }
141}
142
143fn parse_flow_edge(line: &str) -> Option<(&str, &'static str, Option<String>, &str)> {
147 let (connector, arrow) = if line.contains("-.->") {
148 ("-.->", "╌╌▶")
149 } else if line.contains("==>") {
150 ("==>", "══▶")
151 } else if line.contains("-->") {
152 ("-->", "──▶")
153 } else if line.contains("---") {
154 ("---", "───")
155 } else {
156 return None;
157 };
158 let (left, raw_right) = line.split_once(connector)?;
159 let raw_right = raw_right.trim();
160 if let Some(rest) = raw_right.strip_prefix('|') {
162 let (label, right) = rest.split_once('|')?;
163 return Some((
164 left,
165 arrow,
166 Some(label.trim().trim_matches('"').to_string()),
167 right,
168 ));
169 }
170 if let Some((right, label)) = raw_right.split_once(':') {
173 if !label.trim().is_empty() {
174 return Some((
175 left,
176 arrow,
177 Some(label.trim().trim_matches('"').to_string()),
178 right,
179 ));
180 }
181 }
182 Some((left, arrow, None, raw_right))
183}
184
185fn node_id(raw: &str) -> String {
188 raw.trim()
189 .trim_matches(';')
190 .trim()
191 .split(['(', '[', '{'])
192 .next()
193 .unwrap_or_default()
194 .trim()
195 .to_string()
196}
197
198fn node_label(raw: &str) -> String {
199 let raw = raw.trim().trim_matches(';').trim();
200 let Some(open) = raw.find(['(', '[', '{']) else {
201 return raw.to_string();
202 };
203 let close = match raw.as_bytes().get(open) {
204 Some(b'(') => ')',
205 Some(b'[') => ']',
206 Some(b'{') => '}',
207 _ => return raw.to_string(),
208 };
209 raw[open + 1..]
212 .rfind(close)
213 .map(|end| {
214 raw[open + 1..open + 1 + end]
215 .trim()
216 .trim_matches(|c| matches!(c, '"' | '(' | ')' | '[' | ']' | '{' | '}'))
217 .trim()
218 .to_string()
219 })
220 .filter(|label| !label.is_empty())
221 .unwrap_or_else(|| raw.to_string())
222}
223
224impl Component for Mermaid {
225 fn render(&self, width: usize) -> Vec<String> {
226 let colors = theme().colors;
227 let panel_width = width.max(1).saturating_sub(self.padding_x * 2).max(1);
228 let mut lines = Vec::new();
229 lines.push(format!(
230 "{}{}",
231 " ".repeat(self.padding_x),
232 colors.md_code_block_border.fg("mermaid")
233 ));
234 lines.extend(
235 self.body_lines(panel_width)
236 .into_iter()
237 .map(|line| format!("{}{}", " ".repeat(self.padding_x), line)),
238 );
239 lines
240 }
241
242 fn invalidate(&self) {}
243
244 fn as_any(&self) -> &dyn Any {
245 self
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use crate::strip_ansi;
253
254 #[test]
255 fn renders_flowchart_edges_as_terminal_arrows() {
256 let output = strip_ansi(
257 &Mermaid::new("flowchart LR\nA[Start] --> B{Done?}")
258 .render(60)
259 .join("\n"),
260 );
261 assert!(output.contains("Start ──▶ Done?"));
262 }
263
264 #[test]
265 fn renders_labelled_and_styled_flowchart_edges() {
266 let output = strip_ansi(
267 &Mermaid::new(
268 "flowchart TD\nA((Start)) -->|continue| B{Ready?}\nB -.->|no| C[Retry]\nB ==> D([Done])",
269 )
270 .render(60)
271 .join("\n"),
272 );
273 assert!(output.contains("Start ──▶ (continue) Ready?"), "{output}");
274 assert!(output.contains("Ready? ╌╌▶ (no) Retry"), "{output}");
275 assert!(output.contains("Ready? ══▶ Done"), "{output}");
276 }
277
278 #[test]
279 fn long_flowchart_edges_wrap_instead_of_eliding() {
280 let output = strip_ansi(
281 &Mermaid::new(
282 "flowchart LR\nA[Very long source node] --> B[Very long destination node]",
283 )
284 .render(20)
285 .join("\n"),
286 );
287 let compact: String = output.chars().filter(|ch| !ch.is_whitespace()).collect();
288 assert!(compact.contains("Verylongsourcenode"), "{output}");
289 assert!(compact.contains("Verylongdestinationnode"), "{output}");
290 }
291
292 #[test]
293 fn renders_sequence_messages() {
294 let output = strip_ansi(
295 &Mermaid::new("sequenceDiagram\nAlice->>Bob: Hello")
296 .render(60)
297 .join("\n"),
298 );
299 assert!(output.contains("Alice ──▶ Bob: Hello"));
300 }
301
302 #[test]
303 fn falls_back_to_source_for_unknown_diagrams() {
304 let output = strip_ansi(&Mermaid::new("pie\n \"A\" : 50").render(60).join("\n"));
305 assert!(output.contains("\"A\" : 50"));
306 }
307}