|
1
|
+ |
//! Self-contained terminal renderer for Mermaid diagrams.
|
|
2
|
+ |
//!
|
|
3
|
+ |
//! Renders `graph`/`flowchart`, `sequenceDiagram`, and `stateDiagram` blocks
|
|
4
|
+ |
//! as Unicode box-drawing art; unsupported diagram types fall back to the raw
|
|
5
|
+ |
//! source in a framed box.
|
|
6
|
+ |
|
|
7
|
+ |
use std::collections::HashMap;
|
|
8
|
+ |
|
|
9
|
+ |
use ratatui::style::{Modifier, Style};
|
|
10
|
+ |
use ratatui::text::{Line, Span};
|
|
11
|
+ |
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
|
12
|
+ |
|
|
13
|
+ |
/// Theme-derived styles used when painting a diagram.
|
|
14
|
+ |
#[derive(Clone, Copy)]
|
|
15
|
+ |
pub(crate) struct MermaidStyles {
|
|
16
|
+ |
pub border: Style,
|
|
17
|
+ |
pub node_text: Style,
|
|
18
|
+ |
pub edge: Style,
|
|
19
|
+ |
pub edge_label: Style,
|
|
20
|
+ |
pub title: Style,
|
|
21
|
+ |
}
|
|
22
|
+ |
|
|
23
|
+ |
/// Rendered diagram: styled lines for the TUI and plain lines for ANSI output.
|
|
24
|
+ |
pub(crate) struct MermaidArt {
|
|
25
|
+ |
pub styled_lines: Vec<Line<'static>>,
|
|
26
|
+ |
pub plain_lines: Vec<String>,
|
|
27
|
+ |
}
|
|
28
|
+ |
|
|
29
|
+ |
const MAX_LABEL: usize = 28;
|
|
30
|
+ |
const PAD: usize = 1;
|
|
31
|
+ |
const GAP_X: usize = 3;
|
|
32
|
+ |
const GAP_Y: usize = 2;
|
|
33
|
+ |
/// Node labels wrap to at most this many display columns per line, and at most
|
|
34
|
+ |
/// this many lines (overflow is truncated with an ellipsis).
|
|
35
|
+ |
const WRAP_WIDTH: usize = 24;
|
|
36
|
+ |
const MAX_LINES: usize = 4;
|
|
37
|
+ |
/// Identifier-boundary characters preferred as break points when a single word
|
|
38
|
+ |
/// is too wide to fit, so it is not sliced mid-segment.
|
|
39
|
+ |
/// Mirrors `TOKEN_BREAK_CHARS` in `third_party/mermaid-to-svg/src/text_wrap.rs`;
|
|
40
|
+ |
/// the two renderers are deliberately independent, so keep these two in sync.
|
|
41
|
+ |
const LABEL_BREAK_CHARS: [char; 4] = ['_', '-', '.', '/'];
|
|
42
|
+ |
/// Sentinel marking the trailing column of a wide glyph (never emitted).
|
|
43
|
+ |
const CONT: char = '\u{0}';
|
|
44
|
+ |
const MAX_NODES: usize = 128;
|
|
45
|
+ |
const MAX_EDGES: usize = 512;
|
|
46
|
+ |
const MAX_GROUPS: usize = 24;
|
|
47
|
+ |
const MAX_GROUP_DEPTH: usize = 6;
|
|
48
|
+ |
const MAX_CANVAS_CELLS: usize = 1 << 21;
|
|
49
|
+ |
|
|
50
|
+ |
fn char_width(c: char) -> usize {
|
|
51
|
+ |
UnicodeWidthChar::width(c).unwrap_or(0)
|
|
52
|
+ |
}
|
|
53
|
+ |
|
|
54
|
+ |
#[derive(Clone, Copy)]
|
|
55
|
+ |
enum Oversize {
|
|
56
|
+ |
Width,
|
|
57
|
+ |
Cells,
|
|
58
|
+ |
}
|
|
59
|
+ |
|
|
60
|
+ |
/// Render a mermaid source block, or `None` for blank input.
|
|
61
|
+ |
pub(crate) fn render(
|
|
62
|
+ |
src: &str,
|
|
63
|
+ |
styles: &MermaidStyles,
|
|
64
|
+ |
max_width: Option<usize>,
|
|
65
|
+ |
) -> Option<MermaidArt> {
|
|
66
|
+ |
if src.trim().is_empty() {
|
|
67
|
+ |
return None;
|
|
68
|
+ |
}
|
|
69
|
+ |
|
|
70
|
+ |
let outcome: Option<Result<MermaidArt, Oversize>> = parse_graph(src)
|
|
71
|
+ |
.map(|graph| {
|
|
72
|
+ |
if graph.groups.is_empty() {
|
|
73
|
+ |
layout_flowchart(&graph, styles, max_width)
|
|
74
|
+ |
} else {
|
|
75
|
+ |
render_grouped(&graph, styles, max_width)
|
|
76
|
+ |
}
|
|
77
|
+ |
})
|
|
78
|
+ |
.or_else(|| parse_state(src).map(|graph| layout_flowchart(&graph, styles, max_width)))
|
|
79
|
+ |
.or_else(|| {
|
|
80
|
+ |
parse_class(src).map(|(graph, infos)| render_class(&graph, &infos, styles, max_width))
|
|
81
|
+ |
})
|
|
82
|
+ |
.or_else(|| {
|
|
83
|
+ |
parse_er(src).map(|(graph, infos)| render_class(&graph, &infos, styles, max_width))
|
|
84
|
+ |
})
|
|
85
|
+ |
.or_else(|| parse_sequence(src).map(|seq| layout_sequence(&seq, styles, max_width)));
|
|
86
|
+ |
|
|
87
|
+ |
let too_wide = match outcome {
|
|
88
|
+ |
Some(Ok(art)) => return Some(art),
|
|
89
|
+ |
Some(Err(Oversize::Width)) => true,
|
|
90
|
+ |
Some(Err(Oversize::Cells)) | None => false,
|
|
91
|
+ |
};
|
|
92
|
+ |
Some(fallback(src, styles, max_width, too_wide))
|
|
93
|
+ |
}
|
|
94
|
+ |
|
|
95
|
+ |
#[derive(Clone, Copy, PartialEq)]
|
|
96
|
+ |
enum Shape {
|
|
97
|
+ |
Rect,
|
|
98
|
+ |
Round,
|
|
99
|
+ |
Diamond,
|
|
100
|
+ |
}
|
|
101
|
+ |
|
|
102
|
+ |
struct Node {
|
|
103
|
+ |
label: String,
|
|
104
|
+ |
shape: Shape,
|
|
105
|
+ |
}
|
|
106
|
+ |
|
|
107
|
+ |
#[derive(Clone, Copy, PartialEq, Debug)]
|
|
108
|
+ |
enum Head {
|
|
109
|
+ |
None,
|
|
110
|
+ |
Arrow,
|
|
111
|
+ |
Circle,
|
|
112
|
+ |
Cross,
|
|
113
|
+ |
Triangle,
|
|
114
|
+ |
DiamondFill,
|
|
115
|
+ |
DiamondOpen,
|
|
116
|
+ |
}
|
|
117
|
+ |
|
|
118
|
+ |
#[derive(Clone, Copy, PartialEq)]
|
|
119
|
+ |
enum LineKind {
|
|
120
|
+ |
Solid,
|
|
121
|
+ |
Dotted,
|
|
122
|
+ |
Thick,
|
|
123
|
+ |
}
|
|
124
|
+ |
|
|
125
|
+ |
struct Edge {
|
|
126
|
+ |
from: usize,
|
|
127
|
+ |
to: usize,
|
|
128
|
+ |
label: Option<String>,
|
|
129
|
+ |
head_to: Head,
|
|
130
|
+ |
head_from: Head,
|
|
131
|
+ |
line: LineKind,
|
|
132
|
+ |
}
|
|
133
|
+ |
|
|
134
|
+ |
#[derive(Clone, Copy, PartialEq)]
|
|
135
|
+ |
enum Dir {
|
|
136
|
+ |
Down,
|
|
137
|
+ |
Up,
|
|
138
|
+ |
Right,
|
|
139
|
+ |
Left,
|
|
140
|
+ |
}
|
|
141
|
+ |
|
|
142
|
+ |
struct Group {
|
|
143
|
+ |
id: String,
|
|
144
|
+ |
label: String,
|
|
145
|
+ |
parent: Option<usize>,
|
|
146
|
+ |
}
|
|
147
|
+ |
|
|
148
|
+ |
struct Graph {
|
|
149
|
+ |
nodes: Vec<Node>,
|
|
150
|
+ |
edges: Vec<Edge>,
|
|
151
|
+ |
index: HashMap<String, usize>,
|
|
152
|
+ |
groups: Vec<Group>,
|
|
153
|
+ |
node_group: Vec<Option<usize>>,
|
|
154
|
+ |
cur_group: Option<usize>,
|
|
155
|
+ |
over_cap: bool,
|
|
156
|
+ |
dir: Dir,
|
|
157
|
+ |
}
|
|
158
|
+ |
|
|
159
|
+ |
impl Graph {
|
|
160
|
+ |
fn node_index(&mut self, id: &str, label: Option<&str>, shape: Shape) -> Option<usize> {
|
|
161
|
+ |
if let Some(&i) = self.index.get(id) {
|
|
162
|
+ |
if let Some(label) = label {
|
|
163
|
+ |
self.nodes[i].label = label.to_string();
|
|
164
|
+ |
self.nodes[i].shape = shape;
|
|
165
|
+ |
}
|
|
166
|
+ |
return Some(i);
|
|
167
|
+ |
}
|
|
168
|
+ |
if self.nodes.len() >= MAX_NODES {
|
|
169
|
+ |
self.over_cap = true;
|
|
170
|
+ |
return None;
|
|
171
|
+ |
}
|
|
172
|
+ |
let label = label.unwrap_or(id).to_string();
|
|
173
|
+ |
self.index.insert(id.to_string(), self.nodes.len());
|
|
174
|
+ |
self.nodes.push(Node { label, shape });
|
|
175
|
+ |
self.node_group.push(self.cur_group);
|
|
176
|
+ |
Some(self.nodes.len() - 1)
|
|
177
|
+ |
}
|
|
178
|
+ |
|
|
179
|
+ |
fn node_label(&mut self, id: &str, label: &str) -> Option<usize> {
|
|
180
|
+ |
if let Some(&i) = self.index.get(id) {
|
|
181
|
+ |
self.nodes[i].label = label.to_string();
|
|
182
|
+ |
return Some(i);
|
|
183
|
+ |
}
|
|
184
|
+ |
self.node_index(id, Some(label), Shape::Round)
|
|
185
|
+ |
}
|
|
186
|
+ |
}
|
|
187
|
+ |
|
|
188
|
+ |
fn parse_graph(src: &str) -> Option<Graph> {
|
|
189
|
+ |
let mut statements: Vec<String> = Vec::new();
|
|
190
|
+ |
for raw_line in src.lines() {
|
|
191
|
+ |
split_statements(raw_line, &mut statements);
|
|
192
|
+ |
}
|
|
193
|
+ |
|
|
194
|
+ |
let header = statements.first()?;
|
|
195
|
+ |
let mut header_tokens = header.split_whitespace();
|
|
196
|
+ |
let kind = header_tokens.next()?.to_ascii_lowercase();
|
|
197
|
+ |
if kind != "graph" && kind != "flowchart" {
|
|
198
|
+ |
return None;
|
|
199
|
+ |
}
|
|
200
|
+ |
let dir = match header_tokens
|
|
201
|
+ |
.next()
|
|
202
|
+ |
.unwrap_or("TB")
|
|
203
|
+ |
.to_ascii_uppercase()
|
|
204
|
+ |
.as_str()
|
|
205
|
+ |
{
|
|
206
|
+ |
"LR" => Dir::Right,
|
|
207
|
+ |
"RL" => Dir::Left,
|
|
208
|
+ |
"BT" => Dir::Up,
|
|
209
|
+ |
_ => Dir::Down,
|
|
210
|
+ |
};
|
|
211
|
+ |
|
|
212
|
+ |
let mut graph = Graph {
|
|
213
|
+ |
nodes: Vec::new(),
|
|
214
|
+ |
edges: Vec::new(),
|
|
215
|
+ |
index: HashMap::new(),
|
|
216
|
+ |
groups: Vec::new(),
|
|
217
|
+ |
node_group: Vec::new(),
|
|
218
|
+ |
cur_group: None,
|
|
219
|
+ |
over_cap: false,
|
|
220
|
+ |
dir,
|
|
221
|
+ |
};
|
|
222
|
+ |
|
|
223
|
+ |
let mut stack: Vec<usize> = Vec::new();
|
|
224
|
+ |
for st in &statements[1..] {
|
|
225
|
+ |
let first_word = st.split_whitespace().next().unwrap_or("");
|
|
226
|
+ |
match first_word.to_ascii_lowercase().as_str() {
|
|
227
|
+ |
"subgraph" => {
|
|
228
|
+ |
if graph.groups.len() >= MAX_GROUPS || stack.len() >= MAX_GROUP_DEPTH {
|
|
229
|
+ |
return None;
|
|
230
|
+ |
}
|
|
231
|
+ |
let (id, label) = parse_subgraph_decl(st["subgraph".len()..].trim());
|
|
232
|
+ |
graph.groups.push(Group {
|
|
233
|
+ |
id,
|
|
234
|
+ |
label,
|
|
235
|
+ |
parent: stack.last().copied(),
|
|
236
|
+ |
});
|
|
237
|
+ |
stack.push(graph.groups.len() - 1);
|
|
238
|
+ |
graph.cur_group = stack.last().copied();
|
|
239
|
+ |
continue;
|
|
240
|
+ |
}
|
|
241
|
+ |
"end" => {
|
|
242
|
+ |
stack.pop();
|
|
243
|
+ |
graph.cur_group = stack.last().copied();
|
|
244
|
+ |
continue;
|
|
245
|
+ |
}
|
|
246
|
+ |
"classdef" | "class" | "style" | "linkstyle" | "click" | "direction" => continue,
|
|
247
|
+ |
_ => {}
|
|
248
|
+ |
}
|
|
249
|
+ |
parse_statement(st, &mut graph);
|
|
250
|
+ |
if graph.over_cap {
|
|
251
|
+ |
return None;
|
|
252
|
+ |
}
|
|
253
|
+ |
}
|
|
254
|
+ |
|
|
255
|
+ |
if graph.nodes.is_empty() {
|
|
256
|
+ |
return None;
|
|
257
|
+ |
}
|
|
258
|
+ |
Some(graph)
|
|
259
|
+ |
}
|
|
260
|
+ |
|
|
261
|
+ |
fn parse_subgraph_decl(rest: &str) -> (String, String) {
|
|
262
|
+ |
if let Some(q) = rest.strip_prefix('"')
|
|
263
|
+ |
&& let Some((label, _)) = q.split_once('"')
|
|
264
|
+ |
{
|
|
265
|
+ |
return (label.to_string(), decode_html_entities(label));
|
|
266
|
+ |
}
|
|
267
|
+ |
if let Some(open) = rest.find('[') {
|
|
268
|
+ |
let id = rest[..open].trim();
|
|
269
|
+ |
let label = rest[open + 1..].trim_end_matches(']').trim();
|
|
270
|
+ |
let label = clean_label(label);
|
|
271
|
+ |
if !id.is_empty() && !label.is_empty() {
|
|
272
|
+ |
return (id.to_string(), label);
|
|
273
|
+ |
}
|
|
274
|
+ |
}
|
|
275
|
+ |
(rest.to_string(), rest.to_string())
|
|
276
|
+ |
}
|
|
277
|
+ |
|
|
278
|
+ |
fn split_statements(line: &str, out: &mut Vec<String>) {
|
|
279
|
+ |
let mut cur = String::new();
|
|
280
|
+ |
let mut in_quotes = false;
|
|
281
|
+ |
let mut chars = line.chars().peekable();
|
|
282
|
+ |
while let Some(c) = chars.next() {
|
|
283
|
+ |
if in_quotes {
|
|
284
|
+ |
if c == '"' {
|
|
285
|
+ |
in_quotes = false;
|
|
286
|
+ |
}
|
|
287
|
+ |
cur.push(c);
|
|
288
|
+ |
} else {
|
|
289
|
+ |
match c {
|
|
290
|
+ |
'"' => {
|
|
291
|
+ |
in_quotes = true;
|
|
292
|
+ |
cur.push(c);
|
|
293
|
+ |
}
|
|
294
|
+ |
'%' if chars.peek() == Some(&'%') => break,
|
|
295
|
+ |
';' => flush_statement(&mut cur, out),
|
|
296
|
+ |
_ => cur.push(c),
|
|
297
|
+ |
}
|
|
298
|
+ |
}
|
|
299
|
+ |
}
|
|
300
|
+ |
flush_statement(&mut cur, out);
|
|
301
|
+ |
}
|
|
302
|
+ |
|
|
303
|
+ |
fn flush_statement(cur: &mut String, out: &mut Vec<String>) {
|
|
304
|
+ |
let trimmed = cur.trim();
|
|
305
|
+ |
if !trimmed.is_empty() {
|
|
306
|
+ |
out.push(trimmed.to_string());
|
|
307
|
+ |
}
|
|
308
|
+ |
cur.clear();
|
|
309
|
+ |
}
|
|
310
|
+ |
|
|
311
|
+ |
fn parse_statement(st: &str, graph: &mut Graph) {
|
|
312
|
+ |
let chars: Vec<char> = st.chars().collect();
|
|
313
|
+ |
let mut i = 0;
|
|
314
|
+ |
|
|
315
|
+ |
let Some((mut prev, ni)) = parse_node_group(&chars, i, graph) else {
|
|
316
|
+ |
return;
|
|
317
|
+ |
};
|
|
318
|
+ |
i = ni;
|
|
319
|
+ |
|
|
320
|
+ |
loop {
|
|
321
|
+ |
i = skip_spaces(&chars, i);
|
|
322
|
+ |
if i >= chars.len() {
|
|
323
|
+ |
break;
|
|
324
|
+ |
}
|
|
325
|
+ |
let Some((left, right, line, label, ni)) = parse_link(&chars, i) else {
|
|
326
|
+ |
break;
|
|
327
|
+ |
};
|
|
328
|
+ |
i = skip_spaces(&chars, ni);
|
|
329
|
+ |
let Some((next, ni)) = parse_node_group(&chars, i, graph) else {
|
|
330
|
+ |
break;
|
|
331
|
+ |
};
|
|
332
|
+ |
i = ni;
|
|
333
|
+ |
for &f in &prev {
|
|
334
|
+ |
for &t in &next {
|
|
335
|
+ |
if graph.edges.len() >= MAX_EDGES {
|
|
336
|
+ |
graph.over_cap = true;
|
|
337
|
+ |
return;
|
|
338
|
+ |
}
|
|
339
|
+ |
let (from, to, head_to, head_from) = if left == Head::Arrow && right != Head::Arrow
|
|
340
|
+ |
{
|
|
341
|
+ |
(t, f, Head::Arrow, right)
|
|
342
|
+ |
} else {
|
|
343
|
+ |
(f, t, right, left)
|
|
344
|
+ |
};
|
|
345
|
+ |
graph.edges.push(Edge {
|
|
346
|
+ |
from,
|
|
347
|
+ |
to,
|
|
348
|
+ |
label: label.clone(),
|
|
349
|
+ |
head_to,
|
|
350
|
+ |
head_from,
|
|
351
|
+ |
line,
|
|
352
|
+ |
});
|
|
353
|
+ |
}
|
|
354
|
+ |
}
|
|
355
|
+ |
prev = next;
|
|
356
|
+ |
}
|
|
357
|
+ |
}
|
|
358
|
+ |
|
|
359
|
+ |
fn parse_node_group(
|
|
360
|
+ |
chars: &[char],
|
|
361
|
+ |
start: usize,
|
|
362
|
+ |
graph: &mut Graph,
|
|
363
|
+ |
) -> Option<(Vec<usize>, usize)> {
|
|
364
|
+ |
let (first, mut i) = parse_node(chars, start, graph)?;
|
|
365
|
+ |
let mut group = vec![first];
|
|
366
|
+ |
loop {
|
|
367
|
+ |
let j = skip_spaces(chars, i);
|
|
368
|
+ |
if chars.get(j) != Some(&'&') {
|
|
369
|
+ |
break;
|
|
370
|
+ |
}
|
|
371
|
+ |
let (next, k) = parse_node(chars, j + 1, graph)?;
|
|
372
|
+ |
group.push(next);
|
|
373
|
+ |
i = k;
|
|
374
|
+ |
}
|
|
375
|
+ |
Some((group, i))
|
|
376
|
+ |
}
|
|
377
|
+ |
|
|
378
|
+ |
fn skip_spaces(chars: &[char], mut i: usize) -> usize {
|
|
379
|
+ |
while i < chars.len() && (chars[i] == ' ' || chars[i] == '\t') {
|
|
380
|
+ |
i += 1;
|
|
381
|
+ |
}
|
|
382
|
+ |
i
|
|
383
|
+ |
}
|
|
384
|
+ |
|
|
385
|
+ |
fn is_id_char(c: char) -> bool {
|
|
386
|
+ |
c.is_alphanumeric() || c == '_'
|
|
387
|
+ |
}
|
|
388
|
+ |
|
|
389
|
+ |
fn parse_node(chars: &[char], start: usize, graph: &mut Graph) -> Option<(usize, usize)> {
|
|
390
|
+ |
let mut i = skip_spaces(chars, start);
|
|
391
|
+ |
let id_start = i;
|
|
392
|
+ |
while i < chars.len() && is_id_char(chars[i]) {
|
|
393
|
+ |
i += 1;
|
|
394
|
+ |
}
|
|
395
|
+ |
if i == id_start {
|
|
396
|
+ |
return None;
|
|
397
|
+ |
}
|
|
398
|
+ |
let id: String = chars[id_start..i].iter().collect();
|
|
399
|
+ |
|
|
400
|
+ |
let (shape, label, after) = match chars.get(i) {
|
|
401
|
+ |
Some('[') => {
|
|
402
|
+ |
if chars.get(i + 1) == Some(&'[') {
|
|
403
|
+ |
read_shape(chars, i + 2, "]]", Shape::Rect)
|
|
404
|
+ |
} else if chars.get(i + 1) == Some(&'(') {
|
|
405
|
+ |
read_shape(chars, i + 2, ")]", Shape::Round)
|
|
406
|
+ |
} else {
|
|
407
|
+ |
read_shape(chars, i + 1, "]", Shape::Rect)
|
|
408
|
+ |
}
|
|
409
|
+ |
}
|
|
410
|
+ |
Some('(') => {
|
|
411
|
+ |
if chars.get(i + 1) == Some(&'(') {
|
|
412
|
+ |
read_shape(chars, i + 2, "))", Shape::Round)
|
|
413
|
+ |
} else if chars.get(i + 1) == Some(&'[') {
|
|
414
|
+ |
read_shape(chars, i + 2, "])", Shape::Round)
|
|
415
|
+ |
} else {
|
|
416
|
+ |
read_shape(chars, i + 1, ")", Shape::Round)
|
|
417
|
+ |
}
|
|
418
|
+ |
}
|
|
419
|
+ |
Some('{') => {
|
|
420
|
+ |
if chars.get(i + 1) == Some(&'{') {
|
|
421
|
+ |
read_shape(chars, i + 2, "}}", Shape::Diamond)
|
|
422
|
+ |
} else {
|
|
423
|
+ |
read_shape(chars, i + 1, "}", Shape::Diamond)
|
|
424
|
+ |
}
|
|
425
|
+ |
}
|
|
426
|
+ |
Some('>') => read_shape(chars, i + 1, "]", Shape::Rect),
|
|
427
|
+ |
_ => (None, None, i),
|
|
428
|
+ |
};
|
|
429
|
+ |
|
|
430
|
+ |
let shape = shape.unwrap_or(Shape::Rect);
|
|
431
|
+ |
let label = label.as_deref();
|
|
432
|
+ |
let idx = graph.node_index(&id, label, shape)?;
|
|
433
|
+ |
Some((idx, after))
|
|
434
|
+ |
}
|
|
435
|
+ |
|
|
436
|
+ |
fn read_shape(
|
|
437
|
+ |
chars: &[char],
|
|
438
|
+ |
start: usize,
|
|
439
|
+ |
closer: &str,
|
|
440
|
+ |
shape: Shape,
|
|
441
|
+ |
) -> (Option<Shape>, Option<String>, usize) {
|
|
442
|
+ |
let closer: Vec<char> = closer.chars().collect();
|
|
443
|
+ |
let mut i = start;
|
|
444
|
+ |
let mut text = String::new();
|
|
445
|
+ |
let quoted = {
|
|
446
|
+ |
let mut j = start;
|
|
447
|
+ |
while matches!(chars.get(j), Some(' ') | Some('\t')) {
|
|
448
|
+ |
j += 1;
|
|
449
|
+ |
}
|
|
450
|
+ |
chars.get(j) == Some(&'"')
|
|
451
|
+ |
};
|
|
452
|
+ |
let mut in_quotes = false;
|
|
453
|
+ |
while i < chars.len() {
|
|
454
|
+ |
let c = chars[i];
|
|
455
|
+ |
if quoted && c == '"' {
|
|
456
|
+ |
in_quotes = !in_quotes;
|
|
457
|
+ |
text.push(c);
|
|
458
|
+ |
i += 1;
|
|
459
|
+ |
continue;
|
|
460
|
+ |
}
|
|
461
|
+ |
if !in_quotes && chars[i..].starts_with(closer.as_slice()) {
|
|
462
|
+ |
let label = clean_label(&text);
|
|
463
|
+ |
return (Some(shape), Some(label), i + closer.len());
|
|
464
|
+ |
}
|
|
465
|
+ |
text.push(c);
|
|
466
|
+ |
i += 1;
|
|
467
|
+ |
}
|
|
468
|
+ |
(Some(shape), Some(clean_label(&text)), chars.len())
|
|
469
|
+ |
}
|
|
470
|
+ |
|
|
471
|
+ |
fn clean_label(raw: &str) -> String {
|
|
472
|
+ |
let stripped = strip_html_tags(raw.trim());
|
|
473
|
+ |
let trimmed = stripped.trim();
|
|
474
|
+ |
let unquoted = trimmed
|
|
475
|
+ |
.strip_prefix('"')
|
|
476
|
+ |
.and_then(|t| t.strip_suffix('"'))
|
|
477
|
+ |
.or_else(|| {
|
|
478
|
+ |
trimmed
|
|
479
|
+ |
.strip_prefix('\'')
|
|
480
|
+ |
.and_then(|t| t.strip_suffix('\''))
|
|
481
|
+ |
})
|
|
482
|
+ |
.unwrap_or(trimmed)
|
|
483
|
+ |
.trim();
|
|
484
|
+ |
let text = if let Some(md) = unquoted.strip_prefix('`').and_then(|t| t.strip_suffix('`')) {
|
|
485
|
+ |
strip_markdown(md.trim())
|
|
486
|
+ |
} else {
|
|
487
|
+ |
unquoted.to_string()
|
|
488
|
+ |
};
|
|
489
|
+ |
// Decode after tag-stripping so `<b>` is removed as markup while `<b>`
|
|
490
|
+ |
// survives as a literal `<b>`; one decode at the single return covers both paths.
|
|
491
|
+ |
decode_html_entities(&text)
|
|
492
|
+ |
}
|
|
493
|
+ |
|
|
494
|
+ |
const ENTITY_LOOKAHEAD: usize = 10;
|
|
495
|
+ |
|
|
496
|
+ |
// Label text decodes HTML entities once: via clean_label for bracketed labels, or explicitly at each direct-push sink.
|
|
497
|
+ |
fn decode_html_entities(s: &str) -> String {
|
|
498
|
+ |
if !s.contains('&') {
|
|
499
|
+ |
return s.to_string();
|
|
500
|
+ |
}
|
|
501
|
+ |
let chars: Vec<char> = s.chars().collect();
|
|
502
|
+ |
let mut out = String::with_capacity(s.len());
|
|
503
|
+ |
let mut i = 0;
|
|
504
|
+ |
while i < chars.len() {
|
|
505
|
+ |
if chars[i] != '&' {
|
|
506
|
+ |
out.push(chars[i]);
|
|
507
|
+ |
i += 1;
|
|
508
|
+ |
continue;
|
|
509
|
+ |
}
|
|
510
|
+ |
// Scan window (includes the terminating `;`) so a stray `&` or over-long run stays literal.
|
|
511
|
+ |
let hi = (i + 1 + ENTITY_LOOKAHEAD).min(chars.len());
|
|
512
|
+ |
let semi = (i + 1..hi).find(|&j| chars[j] == ';');
|
|
513
|
+ |
let decoded = semi.and_then(|j| {
|
|
514
|
+ |
let body: String = chars[i + 1..j].iter().collect();
|
|
515
|
+ |
decode_entity_body(&body).map(|c| (c, j))
|
|
516
|
+ |
});
|
|
517
|
+ |
match decoded {
|
|
518
|
+ |
// Resume past the `;`; the single pass never re-scans emitted text, so
|
|
519
|
+ |
// `&lt;` decodes to the literal `<` rather than to `<`.
|
|
520
|
+ |
Some((c, j)) => {
|
|
521
|
+ |
out.push(c);
|
|
522
|
+ |
i = j + 1;
|
|
523
|
+ |
}
|
|
524
|
+ |
None => {
|
|
525
|
+ |
out.push('&');
|
|
526
|
+ |
i += 1;
|
|
527
|
+ |
}
|
|
528
|
+ |
}
|
|
529
|
+ |
}
|
|
530
|
+ |
out
|
|
531
|
+ |
}
|
|
532
|
+ |
|
|
533
|
+ |
fn decode_entity_body(body: &str) -> Option<char> {
|
|
534
|
+ |
match body {
|
|
535
|
+ |
"lt" => Some('<'),
|
|
536
|
+ |
"gt" => Some('>'),
|
|
537
|
+ |
"amp" => Some('&'),
|
|
538
|
+ |
"quot" => Some('"'),
|
|
539
|
+ |
"apos" => Some('\''),
|
|
540
|
+ |
_ => {
|
|
541
|
+ |
let num = body.strip_prefix('#')?;
|
|
542
|
+ |
let code = match num.strip_prefix(['x', 'X']) {
|
|
543
|
+ |
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
|
|
544
|
+ |
None => num.parse::<u32>().ok()?,
|
|
545
|
+ |
};
|
|
546
|
+ |
// Reject control chars: NUL collides with the CONT sentinel and ESC would inject ANSI into scrollback.
|
|
547
|
+ |
char::from_u32(code).filter(|c| !c.is_control())
|
|
548
|
+ |
}
|
|
549
|
+ |
}
|
|
550
|
+ |
}
|
|
551
|
+ |
|
|
552
|
+ |
fn strip_markdown(s: &str) -> String {
|
|
553
|
+ |
let no_code: String = s.chars().filter(|&c| c != '`').collect();
|
|
554
|
+ |
let no_strong = no_code.replace("**", "").replace("__", "");
|
|
555
|
+ |
let chars: Vec<char> = no_strong.chars().collect();
|
|
556
|
+ |
let mut out = String::with_capacity(no_strong.len());
|
|
557
|
+ |
for (i, &c) in chars.iter().enumerate() {
|
|
558
|
+ |
if (c == '*' || c == '_')
|
|
559
|
+ |
&& !(i > 0
|
|
560
|
+ |
&& chars[i - 1].is_alphanumeric()
|
|
561
|
+ |
&& chars.get(i + 1).is_some_and(|n| n.is_alphanumeric()))
|
|
562
|
+ |
{
|
|
563
|
+ |
continue;
|
|
564
|
+ |
}
|
|
565
|
+ |
out.push(c);
|
|
566
|
+ |
}
|
|
567
|
+ |
out.trim().to_string()
|
|
568
|
+ |
}
|
|
569
|
+ |
|
|
570
|
+ |
const HTML_FORMAT_TAGS: &[&str] = &[
|
|
571
|
+ |
"b", "strong", "i", "em", "u", "s", "strike", "del", "ins", "mark", "small", "big", "sub",
|
|
572
|
+ |
"sup", "code", "kbd", "samp", "var", "tt", "span", "font", "q", "abbr", "cite", "pre",
|
|
573
|
+ |
];
|
|
574
|
+ |
|
|
575
|
+ |
fn strip_html_tags(s: &str) -> String {
|
|
576
|
+ |
let chars: Vec<char> = s.chars().collect();
|
|
577
|
+ |
let mut out = String::with_capacity(s.len());
|
|
578
|
+ |
let mut i = 0;
|
|
579
|
+ |
while i < chars.len() {
|
|
580
|
+ |
if chars[i] == '<'
|
|
581
|
+ |
&& let Some((name, end)) = html_tag_at(&chars, i)
|
|
582
|
+ |
{
|
|
583
|
+ |
let lower = name.to_ascii_lowercase();
|
|
584
|
+ |
if lower == "br" {
|
|
585
|
+ |
out.push(' ');
|
|
586
|
+ |
i = end;
|
|
587
|
+ |
continue;
|
|
588
|
+ |
}
|
|
589
|
+ |
if HTML_FORMAT_TAGS.contains(&lower.as_str()) {
|
|
590
|
+ |
i = end;
|
|
591
|
+ |
continue;
|
|
592
|
+ |
}
|
|
593
|
+ |
}
|
|
594
|
+ |
out.push(chars[i]);
|
|
595
|
+ |
i += 1;
|
|
596
|
+ |
}
|
|
597
|
+ |
out
|
|
598
|
+ |
}
|
|
599
|
+ |
|
|
600
|
+ |
fn html_tag_at(chars: &[char], start: usize) -> Option<(String, usize)> {
|
|
601
|
+ |
let mut i = start + 1;
|
|
602
|
+ |
if chars.get(i) == Some(&'/') {
|
|
603
|
+ |
i += 1;
|
|
604
|
+ |
}
|
|
605
|
+ |
let name_start = i;
|
|
606
|
+ |
while i < chars.len() && chars[i].is_ascii_alphanumeric() {
|
|
607
|
+ |
i += 1;
|
|
608
|
+ |
}
|
|
609
|
+ |
if i == name_start {
|
|
610
|
+ |
return None;
|
|
611
|
+ |
}
|
|
612
|
+ |
let name: String = chars[name_start..i].iter().collect();
|
|
613
|
+ |
while i < chars.len() && chars[i] != '>' {
|
|
614
|
+ |
if chars[i] == '<' {
|
|
615
|
+ |
return None;
|
|
616
|
+ |
}
|
|
617
|
+ |
i += 1;
|
|
618
|
+ |
}
|
|
619
|
+ |
if chars.get(i) == Some(&'>') {
|
|
620
|
+ |
Some((name, i + 1))
|
|
621
|
+ |
} else {
|
|
622
|
+ |
None
|
|
623
|
+ |
}
|
|
624
|
+ |
}
|
|
625
|
+ |
|
|
626
|
+ |
fn is_link_char(c: char) -> bool {
|
|
627
|
+ |
matches!(c, '-' | '.' | '=' | '<' | '>')
|
|
628
|
+ |
}
|
|
629
|
+ |
|
|
630
|
+ |
fn parse_link(
|
|
631
|
+ |
chars: &[char],
|
|
632
|
+ |
start: usize,
|
|
633
|
+ |
) -> Option<(Head, Head, LineKind, Option<String>, usize)> {
|
|
634
|
+ |
let mut i = skip_spaces(chars, start);
|
|
635
|
+ |
let mut left = Head::None;
|
|
636
|
+ |
if let Some(&c) = chars.get(i)
|
|
637
|
+ |
&& matches!(c, 'o' | 'x')
|
|
638
|
+ |
&& matches!(chars.get(i + 1), Some('-' | '.' | '='))
|
|
639
|
+ |
{
|
|
640
|
+ |
left = if c == 'o' { Head::Circle } else { Head::Cross };
|
|
641
|
+ |
i += 1;
|
|
642
|
+ |
}
|
|
643
|
+ |
let op_start = i;
|
|
644
|
+ |
while i < chars.len() && matches!(chars[i], '-' | '.' | '=' | '<' | '>') {
|
|
645
|
+ |
i += 1;
|
|
646
|
+ |
}
|
|
647
|
+ |
if i == op_start {
|
|
648
|
+ |
return None;
|
|
649
|
+ |
}
|
|
650
|
+ |
let op1: String = chars[op_start..i].iter().collect();
|
|
651
|
+ |
if left == Head::None && op1.starts_with('<') {
|
|
652
|
+ |
left = Head::Arrow;
|
|
653
|
+ |
}
|
|
654
|
+ |
let mut line = line_kind(&op1);
|
|
655
|
+ |
let mut right = if op1.contains('>') {
|
|
656
|
+ |
Head::Arrow
|
|
657
|
+ |
} else {
|
|
658
|
+ |
Head::None
|
|
659
|
+ |
};
|
|
660
|
+ |
if right == Head::None
|
|
661
|
+ |
&& let Some((head, ni)) = trailing_head(chars, i)
|
|
662
|
+ |
{
|
|
663
|
+ |
right = head;
|
|
664
|
+ |
i = ni;
|
|
665
|
+ |
}
|
|
666
|
+ |
|
|
667
|
+ |
if chars.get(i) == Some(&'|') {
|
|
668
|
+ |
i += 1;
|
|
669
|
+ |
let l_start = i;
|
|
670
|
+ |
while i < chars.len() && chars[i] != '|' {
|
|
671
|
+ |
i += 1;
|
|
672
|
+ |
}
|
|
673
|
+ |
let label = clean_label(&chars[l_start..i].iter().collect::<String>());
|
|
674
|
+ |
if chars.get(i) == Some(&'|') {
|
|
675
|
+ |
i += 1;
|
|
676
|
+ |
}
|
|
677
|
+ |
return Some((left, right, line, non_empty(label), i));
|
|
678
|
+ |
}
|
|
679
|
+ |
|
|
680
|
+ |
if right == Head::None {
|
|
681
|
+ |
let text_start = skip_spaces(chars, i);
|
|
682
|
+ |
let mut j = text_start;
|
|
683
|
+ |
while j < chars.len() && !is_link_char(chars[j]) {
|
|
684
|
+ |
j += 1;
|
|
685
|
+ |
}
|
|
686
|
+ |
if j < chars.len() && j > text_start && matches!(chars[j], '-' | '.' | '=' | '>') {
|
|
687
|
+ |
let text: String = chars[text_start..j].iter().collect();
|
|
688
|
+ |
let op2_start = j;
|
|
689
|
+ |
while j < chars.len() && is_link_char(chars[j]) {
|
|
690
|
+ |
j += 1;
|
|
691
|
+ |
}
|
|
692
|
+ |
let op2: String = chars[op2_start..j].iter().collect();
|
|
693
|
+ |
right = if op2.contains('>') {
|
|
694
|
+ |
Head::Arrow
|
|
695
|
+ |
} else if let Some((head, nj)) = trailing_head(chars, j) {
|
|
696
|
+ |
j = nj;
|
|
697
|
+ |
head
|
|
698
|
+ |
} else {
|
|
699
|
+ |
Head::None
|
|
700
|
+ |
};
|
|
701
|
+ |
if line == LineKind::Solid {
|
|
702
|
+ |
line = line_kind(&op2);
|
|
703
|
+ |
}
|
|
704
|
+ |
return Some((left, right, line, non_empty(clean_label(&text)), j));
|
|
705
|
+ |
}
|
|
706
|
+ |
}
|
|
707
|
+ |
|
|
708
|
+ |
Some((left, right, line, None, i))
|
|
709
|
+ |
}
|
|
710
|
+ |
|
|
711
|
+ |
fn line_kind(op: &str) -> LineKind {
|
|
712
|
+ |
if op.contains('=') {
|
|
713
|
+ |
LineKind::Thick
|
|
714
|
+ |
} else if op.contains('.') {
|
|
715
|
+ |
LineKind::Dotted
|
|
716
|
+ |
} else {
|
|
717
|
+ |
LineKind::Solid
|
|
718
|
+ |
}
|
|
719
|
+ |
}
|
|
720
|
+ |
|
|
721
|
+ |
fn trailing_head(chars: &[char], i: usize) -> Option<(Head, usize)> {
|
|
722
|
+ |
let head = match chars.get(i) {
|
|
723
|
+ |
Some('o') => Head::Circle,
|
|
724
|
+ |
Some('x') => Head::Cross,
|
|
725
|
+ |
_ => return None,
|
|
726
|
+ |
};
|
|
727
|
+ |
match chars.get(i + 1) {
|
|
728
|
+ |
None | Some(' ') | Some('\t') | Some('|') | Some('&') | Some(';') => Some((head, i + 1)),
|
|
729
|
+ |
_ => None,
|
|
730
|
+ |
}
|
|
731
|
+ |
}
|
|
732
|
+ |
|
|
733
|
+ |
fn non_empty(s: String) -> Option<String> {
|
|
734
|
+ |
if s.is_empty() { None } else { Some(s) }
|
|
735
|
+ |
}
|
|
736
|
+ |
|
|
737
|
+ |
fn parse_state(src: &str) -> Option<Graph> {
|
|
738
|
+ |
let mut statements: Vec<String> = Vec::new();
|
|
739
|
+ |
for raw_line in src.lines() {
|
|
740
|
+ |
split_statements(raw_line, &mut statements);
|
|
741
|
+ |
}
|
|
742
|
+ |
let header = statements.first()?;
|
|
743
|
+ |
if !header
|
|
744
|
+ |
.split_whitespace()
|
|
745
|
+ |
.next()?
|
|
746
|
+ |
.to_ascii_lowercase()
|
|
747
|
+ |
.starts_with("statediagram")
|
|
748
|
+ |
{
|
|
749
|
+ |
return None;
|
|
750
|
+ |
}
|
|
751
|
+ |
|
|
752
|
+ |
let mut graph = Graph {
|
|
753
|
+ |
nodes: Vec::new(),
|
|
754
|
+ |
edges: Vec::new(),
|
|
755
|
+ |
index: HashMap::new(),
|
|
756
|
+ |
groups: Vec::new(),
|
|
757
|
+ |
node_group: Vec::new(),
|
|
758
|
+ |
cur_group: None,
|
|
759
|
+ |
over_cap: false,
|
|
760
|
+ |
dir: Dir::Down,
|
|
761
|
+ |
};
|
|
762
|
+ |
|
|
763
|
+ |
let mut in_note = false;
|
|
764
|
+ |
for st in &statements[1..] {
|
|
765
|
+ |
if in_note {
|
|
766
|
+ |
if st.eq_ignore_ascii_case("end note") {
|
|
767
|
+ |
in_note = false;
|
|
768
|
+ |
}
|
|
769
|
+ |
continue;
|
|
770
|
+ |
}
|
|
771
|
+ |
let first = st.split_whitespace().next().unwrap_or("");
|
|
772
|
+ |
match first.to_ascii_lowercase().as_str() {
|
|
773
|
+ |
"direction" => {
|
|
774
|
+ |
graph.dir = match st
|
|
775
|
+ |
.split_whitespace()
|
|
776
|
+ |
.nth(1)
|
|
777
|
+ |
.unwrap_or("")
|
|
778
|
+ |
.to_ascii_uppercase()
|
|
779
|
+ |
.as_str()
|
|
780
|
+ |
{
|
|
781
|
+ |
"LR" => Dir::Right,
|
|
782
|
+ |
"RL" => Dir::Left,
|
|
783
|
+ |
"BT" => Dir::Up,
|
|
784
|
+ |
_ => Dir::Down,
|
|
785
|
+ |
};
|
|
786
|
+ |
}
|
|
787
|
+ |
"note" => {
|
|
788
|
+ |
if !st.contains(':') {
|
|
789
|
+ |
in_note = true;
|
|
790
|
+ |
}
|
|
791
|
+ |
}
|
|
792
|
+ |
"state" => parse_state_decl(st, &mut graph)?,
|
|
793
|
+ |
"classdef" | "class" | "hide" | "scale" | "}" | "--" => {}
|
|
794
|
+ |
_ => {
|
|
795
|
+ |
if st.contains("-->") {
|
|
796
|
+ |
parse_transition(st, &mut graph)?;
|
|
797
|
+ |
} else {
|
|
798
|
+ |
parse_state_desc(st, &mut graph)?;
|
|
799
|
+ |
}
|
|
800
|
+ |
}
|
|
801
|
+ |
}
|
|
802
|
+ |
if graph.over_cap {
|
|
803
|
+ |
return None;
|
|
804
|
+ |
}
|
|
805
|
+ |
}
|
|
806
|
+ |
|
|
807
|
+ |
if graph.nodes.is_empty() {
|
|
808
|
+ |
return None;
|
|
809
|
+ |
}
|
|
810
|
+ |
Some(graph)
|
|
811
|
+ |
}
|
|
812
|
+ |
|
|
813
|
+ |
fn parse_state_decl(st: &str, graph: &mut Graph) -> Option<()> {
|
|
814
|
+ |
let rest = st["state".len()..].trim().trim_end_matches('{').trim();
|
|
815
|
+ |
if rest.is_empty() {
|
|
816
|
+ |
return Some(());
|
|
817
|
+ |
}
|
|
818
|
+ |
if let Some(q) = rest.strip_prefix('"') {
|
|
819
|
+ |
let (label, after) = q.split_once('"')?;
|
|
820
|
+ |
let id = after
|
|
821
|
+ |
.trim()
|
|
822
|
+ |
.strip_prefix("as")
|
|
823
|
+ |
.map(str::trim)
|
|
824
|
+ |
.unwrap_or(label);
|
|
825
|
+ |
graph.node_label(id, &decode_html_entities(label))?;
|
|
826
|
+ |
return Some(());
|
|
827
|
+ |
}
|
|
828
|
+ |
let mut shape = Shape::Round;
|
|
829
|
+ |
let mut id = rest;
|
|
830
|
+ |
let mut stereotyped = false;
|
|
831
|
+ |
if let Some(pos) = rest.find("<<") {
|
|
832
|
+ |
let stereo = rest[pos + 2..].trim_end_matches(">>").trim();
|
|
833
|
+ |
if stereo == "choice" {
|
|
834
|
+ |
shape = Shape::Diamond;
|
|
835
|
+ |
}
|
|
836
|
+ |
id = rest[..pos].trim();
|
|
837
|
+ |
stereotyped = true;
|
|
838
|
+ |
}
|
|
839
|
+ |
if id.is_empty() || id.contains(char::is_whitespace) {
|
|
840
|
+ |
return None;
|
|
841
|
+ |
}
|
|
842
|
+ |
let label = if stereotyped { Some(id) } else { None };
|
|
843
|
+ |
graph.node_index(id, label, shape)?;
|
|
844
|
+ |
Some(())
|
|
845
|
+ |
}
|
|
846
|
+ |
|
|
847
|
+ |
fn parse_transition(st: &str, graph: &mut Graph) -> Option<()> {
|
|
848
|
+ |
let mut rest = st;
|
|
849
|
+ |
let mut prev: Option<usize> = None;
|
|
850
|
+ |
while let Some((lhs, rhs)) = rest.split_once("-->") {
|
|
851
|
+ |
let from_id = lhs.trim_end().trim_end_matches('-').trim();
|
|
852
|
+ |
let from = match prev {
|
|
853
|
+ |
Some(p) => {
|
|
854
|
+ |
if !from_id.is_empty() {
|
|
855
|
+ |
return None;
|
|
856
|
+ |
}
|
|
857
|
+ |
p
|
|
858
|
+ |
}
|
|
859
|
+ |
None => {
|
|
860
|
+ |
if from_id.is_empty() {
|
|
861
|
+ |
return None;
|
|
862
|
+ |
}
|
|
863
|
+ |
state_endpoint(graph, from_id, true)?
|
|
864
|
+ |
}
|
|
865
|
+ |
};
|
|
866
|
+ |
let (to_part, tail) = match rhs.split_once("-->") {
|
|
867
|
+ |
Some((t, _)) => (t, &rhs[t.len()..]),
|
|
868
|
+ |
None => (rhs, ""),
|
|
869
|
+ |
};
|
|
870
|
+ |
let (to_part, label) = match to_part.split_once(':') {
|
|
871
|
+ |
Some((t, l)) => (t, non_empty(decode_html_entities(l.trim()))),
|
|
872
|
+ |
None => (to_part, None),
|
|
873
|
+ |
};
|
|
874
|
+ |
let to_id = to_part
|
|
875
|
+ |
.trim_start()
|
|
876
|
+ |
.trim_start_matches('>')
|
|
877
|
+ |
.trim_end()
|
|
878
|
+ |
.trim_end_matches('-')
|
|
879
|
+ |
.trim();
|
|
880
|
+ |
if to_id.is_empty() {
|
|
881
|
+ |
return None;
|
|
882
|
+ |
}
|
|
883
|
+ |
let to = state_endpoint(graph, to_id, false)?;
|
|
884
|
+ |
if graph.edges.len() >= MAX_EDGES {
|
|
885
|
+ |
graph.over_cap = true;
|
|
886
|
+ |
return Some(());
|
|
887
|
+ |
}
|
|
888
|
+ |
graph.edges.push(Edge {
|
|
889
|
+ |
from,
|
|
890
|
+ |
to,
|
|
891
|
+ |
label,
|
|
892
|
+ |
head_to: Head::Arrow,
|
|
893
|
+ |
head_from: Head::None,
|
|
894
|
+ |
line: LineKind::Solid,
|
|
895
|
+ |
});
|
|
896
|
+ |
prev = Some(to);
|
|
897
|
+ |
rest = tail;
|
|
898
|
+ |
}
|
|
899
|
+ |
Some(())
|
|
900
|
+ |
}
|
|
901
|
+ |
|
|
902
|
+ |
fn state_endpoint(graph: &mut Graph, id: &str, is_source: bool) -> Option<usize> {
|
|
903
|
+ |
if id == "[*]" {
|
|
904
|
+ |
let key = if is_source { "[*]start" } else { "[*]end" };
|
|
905
|
+ |
return graph.node_index(key, Some("●"), Shape::Round);
|
|
906
|
+ |
}
|
|
907
|
+ |
graph.node_index(id, None, Shape::Round)
|
|
908
|
+ |
}
|
|
909
|
+ |
|
|
910
|
+ |
fn parse_state_desc(st: &str, graph: &mut Graph) -> Option<()> {
|
|
911
|
+ |
if let Some((id, desc)) = st.split_once(':') {
|
|
912
|
+ |
let id = id.trim();
|
|
913
|
+ |
let desc = desc.trim();
|
|
914
|
+ |
if id.is_empty() || id.contains(char::is_whitespace) || desc.is_empty() {
|
|
915
|
+ |
return None;
|
|
916
|
+ |
}
|
|
917
|
+ |
graph.node_label(id, &decode_html_entities(desc))?;
|
|
918
|
+ |
} else if !st.contains(char::is_whitespace) {
|
|
919
|
+ |
graph.node_index(st, None, Shape::Round)?;
|
|
920
|
+ |
} else {
|
|
921
|
+ |
return None;
|
|
922
|
+ |
}
|
|
923
|
+ |
Some(())
|
|
924
|
+ |
}
|
|
925
|
+ |
|
|
926
|
+ |
const MAX_MEMBERS: usize = 8;
|
|
927
|
+ |
const CLASS_OPS: &[(&str, Head, Head, LineKind)] = &[
|
|
928
|
+ |
("<|--", Head::Triangle, Head::None, LineKind::Solid),
|
|
929
|
+ |
("--|>", Head::None, Head::Triangle, LineKind::Solid),
|
|
930
|
+ |
("<|..", Head::Triangle, Head::None, LineKind::Dotted),
|
|
931
|
+ |
("..|>", Head::None, Head::Triangle, LineKind::Dotted),
|
|
932
|
+ |
("*--", Head::DiamondFill, Head::None, LineKind::Solid),
|
|
933
|
+ |
("--*", Head::None, Head::DiamondFill, LineKind::Solid),
|
|
934
|
+ |
("o--", Head::DiamondOpen, Head::None, LineKind::Solid),
|
|
935
|
+ |
("--o", Head::None, Head::DiamondOpen, LineKind::Solid),
|
|
936
|
+ |
("<--", Head::Arrow, Head::None, LineKind::Solid),
|
|
937
|
+ |
("-->", Head::None, Head::Arrow, LineKind::Solid),
|
|
938
|
+ |
("<..", Head::Arrow, Head::None, LineKind::Dotted),
|
|
939
|
+ |
("..>", Head::None, Head::Arrow, LineKind::Dotted),
|
|
940
|
+ |
("--", Head::None, Head::None, LineKind::Solid),
|
|
941
|
+ |
("..", Head::None, Head::None, LineKind::Dotted),
|
|
942
|
+ |
];
|
|
943
|
+ |
|
|
944
|
+ |
#[derive(Default, Clone)]
|
|
945
|
+ |
struct ClassInfo {
|
|
946
|
+ |
annotation: Option<String>,
|
|
947
|
+ |
attrs: Vec<String>,
|
|
948
|
+ |
methods: Vec<String>,
|
|
949
|
+ |
}
|
|
950
|
+ |
|
|
951
|
+ |
fn parse_class(src: &str) -> Option<(Graph, Vec<ClassInfo>)> {
|
|
952
|
+ |
let mut statements: Vec<String> = Vec::new();
|
|
953
|
+ |
for raw_line in src.lines() {
|
|
954
|
+ |
split_statements(raw_line, &mut statements);
|
|
955
|
+ |
}
|
|
956
|
+ |
let header = statements.first()?;
|
|
957
|
+ |
if !header
|
|
958
|
+ |
.split_whitespace()
|
|
959
|
+ |
.next()?
|
|
960
|
+ |
.to_ascii_lowercase()
|
|
961
|
+ |
.starts_with("classdiagram")
|
|
962
|
+ |
{
|
|
963
|
+ |
return None;
|
|
964
|
+ |
}
|
|
965
|
+ |
|
|
966
|
+ |
let mut graph = Graph {
|
|
967
|
+ |
nodes: Vec::new(),
|
|
968
|
+ |
edges: Vec::new(),
|
|
969
|
+ |
index: HashMap::new(),
|
|
970
|
+ |
groups: Vec::new(),
|
|
971
|
+ |
node_group: Vec::new(),
|
|
972
|
+ |
cur_group: None,
|
|
973
|
+ |
over_cap: false,
|
|
974
|
+ |
dir: Dir::Down,
|
|
975
|
+ |
};
|
|
976
|
+ |
let mut infos: Vec<ClassInfo> = Vec::new();
|
|
977
|
+ |
let mut cur_class: Option<usize> = None;
|
|
978
|
+ |
|
|
979
|
+ |
for st in &statements[1..] {
|
|
980
|
+ |
if let Some(ci) = cur_class {
|
|
981
|
+ |
if st == "}" {
|
|
982
|
+ |
cur_class = None;
|
|
983
|
+ |
} else {
|
|
984
|
+ |
push_member(&mut infos[ci], st);
|
|
985
|
+ |
}
|
|
986
|
+ |
continue;
|
|
987
|
+ |
}
|
|
988
|
+ |
let first = st.split_whitespace().next().unwrap_or("");
|
|
989
|
+ |
match first.to_ascii_lowercase().as_str() {
|
|
990
|
+ |
"direction" => {
|
|
991
|
+ |
graph.dir = match st
|
|
992
|
+ |
.split_whitespace()
|
|
993
|
+ |
.nth(1)
|
|
994
|
+ |
.unwrap_or("")
|
|
995
|
+ |
.to_ascii_uppercase()
|
|
996
|
+ |
.as_str()
|
|
997
|
+ |
{
|
|
998
|
+ |
"LR" => Dir::Right,
|
|
999
|
+ |
"RL" => Dir::Left,
|
|
1000
|
+ |
"BT" => Dir::Up,
|
|
1001
|
+ |
_ => Dir::Down,
|
|
1002
|
+ |
};
|
|
1003
|
+ |
continue;
|
|
1004
|
+ |
}
|
|
1005
|
+ |
"note" | "callback" | "click" | "link" | "style" | "cssclass" | "classdef"
|
|
1006
|
+ |
| "namespace" | "}" => continue,
|
|
1007
|
+ |
"class" => {
|
|
1008
|
+ |
let rest = st["class".len()..].trim();
|
|
1009
|
+ |
let (name, open) = match rest.strip_suffix('{') {
|
|
1010
|
+ |
Some(n) => (n.trim(), true),
|
|
1011
|
+ |
None => (rest, false),
|
|
1012
|
+ |
};
|
|
1013
|
+ |
if name.is_empty() || name.contains(char::is_whitespace) {
|
|
1014
|
+ |
return None;
|
|
1015
|
+ |
}
|
|
1016
|
+ |
let idx = graph.node_index(name, None, Shape::Rect)?;
|
|
1017
|
+ |
sync_infos(&graph, &mut infos);
|
|
1018
|
+ |
if open {
|
|
1019
|
+ |
cur_class = Some(idx);
|
|
1020
|
+ |
}
|
|
1021
|
+ |
continue;
|
|
1022
|
+ |
}
|
|
1023
|
+ |
_ => {}
|
|
1024
|
+ |
}
|
|
1025
|
+ |
if let Some(ann) = st.strip_prefix("<<") {
|
|
1026
|
+ |
let (ann, rest) = ann.split_once(">>")?;
|
|
1027
|
+ |
let name = rest.trim();
|
|
1028
|
+ |
if name.is_empty() || name.contains(char::is_whitespace) {
|
|
1029
|
+ |
return None;
|
|
1030
|
+ |
}
|
|
1031
|
+ |
let idx = graph.node_index(name, None, Shape::Rect)?;
|
|
1032
|
+ |
sync_infos(&graph, &mut infos);
|
|
1033
|
+ |
infos[idx].annotation = Some(ann.trim().to_string());
|
|
1034
|
+ |
continue;
|
|
1035
|
+ |
}
|
|
1036
|
+ |
if let Some((from, to, head_from, head_to, line, label)) = parse_class_relation(st) {
|
|
1037
|
+ |
let f = graph.node_index(&from, None, Shape::Rect)?;
|
|
1038
|
+ |
sync_infos(&graph, &mut infos);
|
|
1039
|
+ |
let t = graph.node_index(&to, None, Shape::Rect)?;
|
|
1040
|
+ |
sync_infos(&graph, &mut infos);
|
|
1041
|
+ |
if graph.edges.len() >= MAX_EDGES {
|
|
1042
|
+ |
return None;
|
|
1043
|
+ |
}
|
|
1044
|
+ |
graph.edges.push(Edge {
|
|
1045
|
+ |
from: f,
|
|
1046
|
+ |
to: t,
|
|
1047
|
+ |
label,
|
|
1048
|
+ |
head_to,
|
|
1049
|
+ |
head_from,
|
|
1050
|
+ |
line,
|
|
1051
|
+ |
});
|
|
1052
|
+ |
continue;
|
|
1053
|
+ |
}
|
|
1054
|
+ |
if let Some((id, member)) = st.split_once(':') {
|
|
1055
|
+ |
let id = id.trim();
|
|
1056
|
+ |
let member = member.trim();
|
|
1057
|
+ |
if id.is_empty() || id.contains(char::is_whitespace) || member.is_empty() {
|
|
1058
|
+ |
return None;
|
|
1059
|
+ |
}
|
|
1060
|
+ |
let idx = graph.node_index(id, None, Shape::Rect)?;
|
|
1061
|
+ |
sync_infos(&graph, &mut infos);
|
|
1062
|
+ |
push_member(&mut infos[idx], member);
|
|
1063
|
+ |
continue;
|
|
1064
|
+ |
}
|
|
1065
|
+ |
return None;
|
|
1066
|
+ |
}
|
|
1067
|
+ |
|
|
1068
|
+ |
if graph.nodes.is_empty() {
|
|
1069
|
+ |
return None;
|
|
1070
|
+ |
}
|
|
1071
|
+ |
sync_infos(&graph, &mut infos);
|
|
1072
|
+ |
Some((graph, infos))
|
|
1073
|
+ |
}
|
|
1074
|
+ |
|
|
1075
|
+ |
fn sync_infos(graph: &Graph, infos: &mut Vec<ClassInfo>) {
|
|
1076
|
+ |
while infos.len() < graph.nodes.len() {
|
|
1077
|
+ |
infos.push(ClassInfo::default());
|
|
1078
|
+ |
}
|
|
1079
|
+ |
}
|
|
1080
|
+ |
|
|
1081
|
+ |
fn push_member(info: &mut ClassInfo, raw: &str) {
|
|
1082
|
+ |
if let Some(ann) = raw.strip_prefix("<<") {
|
|
1083
|
+ |
if let Some((ann, _)) = ann.split_once(">>") {
|
|
1084
|
+ |
info.annotation = Some(ann.trim().to_string());
|
|
1085
|
+ |
}
|
|
1086
|
+ |
return;
|
|
1087
|
+ |
}
|
|
1088
|
+ |
let member = decode_html_entities(&display_generics(raw.trim()));
|
|
1089
|
+ |
let list = if member.contains('(') {
|
|
1090
|
+ |
&mut info.methods
|
|
1091
|
+ |
} else {
|
|
1092
|
+ |
&mut info.attrs
|
|
1093
|
+ |
};
|
|
1094
|
+ |
if list.len() < MAX_MEMBERS {
|
|
1095
|
+ |
list.push(member);
|
|
1096
|
+ |
} else if list.len() == MAX_MEMBERS {
|
|
1097
|
+ |
list.push("…".to_string());
|
|
1098
|
+ |
}
|
|
1099
|
+ |
}
|
|
1100
|
+ |
|
|
1101
|
+ |
fn parse_class_relation(
|
|
1102
|
+ |
st: &str,
|
|
1103
|
+ |
) -> Option<(String, String, Head, Head, LineKind, Option<String>)> {
|
|
1104
|
+ |
let chars: Vec<char> = st.chars().collect();
|
|
1105
|
+ |
let mut found: Option<(usize, &str, Head, Head, LineKind)> = None;
|
|
1106
|
+ |
'outer: for pos in 0..chars.len() {
|
|
1107
|
+ |
for &(op, hf, ht, line) in CLASS_OPS {
|
|
1108
|
+ |
if st[char_byte(st, pos)..].starts_with(op) {
|
|
1109
|
+ |
if op.starts_with('o') && pos > 0 && is_id_char(chars[pos - 1]) {
|
|
1110
|
+ |
continue;
|
|
1111
|
+ |
}
|
|
1112
|
+ |
if op.ends_with('o')
|
|
1113
|
+ |
&& chars
|
|
1114
|
+ |
.get(pos + op.chars().count())
|
|
1115
|
+ |
.is_some_and(|&c| is_id_char(c))
|
|
1116
|
+ |
{
|
|
1117
|
+ |
continue;
|
|
1118
|
+ |
}
|
|
1119
|
+ |
found = Some((pos, op, hf, ht, line));
|
|
1120
|
+ |
break 'outer;
|
|
1121
|
+ |
}
|
|
1122
|
+ |
}
|
|
1123
|
+ |
}
|
|
1124
|
+ |
let (pos, op, head_from, head_to, line) = found?;
|
|
1125
|
+ |
let lhs = st[..char_byte(st, pos)].trim();
|
|
1126
|
+ |
let rhs = st[char_byte(st, pos) + op.len()..].trim();
|
|
1127
|
+ |
|
|
1128
|
+ |
let (lhs, card_from) = strip_cardinality_suffix(lhs);
|
|
1129
|
+ |
let (rhs, card_to) = strip_cardinality_prefix(rhs);
|
|
1130
|
+ |
let (to_id, rel_label) = match rhs.split_once(':') {
|
|
1131
|
+ |
Some((t, l)) => (t.trim(), non_empty(decode_html_entities(l.trim()))),
|
|
1132
|
+ |
None => (rhs.trim(), None),
|
|
1133
|
+ |
};
|
|
1134
|
+ |
if lhs.is_empty()
|
|
1135
|
+ |
|| to_id.is_empty()
|
|
1136
|
+ |
|| lhs.contains(char::is_whitespace)
|
|
1137
|
+ |
|| to_id.contains(char::is_whitespace)
|
|
1138
|
+ |
{
|
|
1139
|
+ |
return None;
|
|
1140
|
+ |
}
|
|
1141
|
+ |
let label = non_empty(
|
|
1142
|
+ |
[card_from, rel_label.unwrap_or_default(), card_to]
|
|
1143
|
+ |
.iter()
|
|
1144
|
+ |
.filter(|s| !s.is_empty())
|
|
1145
|
+ |
.cloned()
|
|
1146
|
+ |
.collect::<Vec<_>>()
|
|
1147
|
+ |
.join(" "),
|
|
1148
|
+ |
);
|
|
1149
|
+ |
Some((
|
|
1150
|
+ |
lhs.to_string(),
|
|
1151
|
+ |
to_id.to_string(),
|
|
1152
|
+ |
head_from,
|
|
1153
|
+ |
head_to,
|
|
1154
|
+ |
line,
|
|
1155
|
+ |
label,
|
|
1156
|
+ |
))
|
|
1157
|
+ |
}
|
|
1158
|
+ |
|
|
1159
|
+ |
fn char_byte(s: &str, char_pos: usize) -> usize {
|
|
1160
|
+ |
s.char_indices()
|
|
1161
|
+ |
.nth(char_pos)
|
|
1162
|
+ |
.map(|(b, _)| b)
|
|
1163
|
+ |
.unwrap_or(s.len())
|
|
1164
|
+ |
}
|
|
1165
|
+ |
|
|
1166
|
+ |
fn strip_cardinality_suffix(s: &str) -> (&str, String) {
|
|
1167
|
+ |
let t = s.trim_end();
|
|
1168
|
+ |
if let Some(rest) = t.strip_suffix('"')
|
|
1169
|
+ |
&& let Some(q) = rest.rfind('"')
|
|
1170
|
+ |
{
|
|
1171
|
+ |
return (rest[..q].trim_end(), rest[q + 1..].to_string());
|
|
1172
|
+ |
}
|
|
1173
|
+ |
(t, String::new())
|
|
1174
|
+ |
}
|
|
1175
|
+ |
|
|
1176
|
+ |
fn strip_cardinality_prefix(s: &str) -> (&str, String) {
|
|
1177
|
+ |
let t = s.trim_start();
|
|
1178
|
+ |
if let Some(rest) = t.strip_prefix('"')
|
|
1179
|
+ |
&& let Some(q) = rest.find('"')
|
|
1180
|
+ |
{
|
|
1181
|
+ |
return (rest[q + 1..].trim_start(), rest[..q].to_string());
|
|
1182
|
+ |
}
|
|
1183
|
+ |
(t, String::new())
|
|
1184
|
+ |
}
|
|
1185
|
+ |
|
|
1186
|
+ |
fn display_generics(s: &str) -> String {
|
|
1187
|
+ |
let mut out = String::with_capacity(s.len());
|
|
1188
|
+ |
let mut open = false;
|
|
1189
|
+ |
for c in s.chars() {
|
|
1190
|
+ |
if c == '~' {
|
|
1191
|
+ |
out.push(if open { '>' } else { '<' });
|
|
1192
|
+ |
open = !open;
|
|
1193
|
+ |
} else {
|
|
1194
|
+ |
out.push(c);
|
|
1195
|
+ |
}
|
|
1196
|
+ |
}
|
|
1197
|
+ |
out
|
|
1198
|
+ |
}
|
|
1199
|
+ |
|
|
1200
|
+ |
fn parse_er(src: &str) -> Option<(Graph, Vec<ClassInfo>)> {
|
|
1201
|
+ |
let mut statements: Vec<String> = Vec::new();
|
|
1202
|
+ |
for raw_line in src.lines() {
|
|
1203
|
+ |
split_statements(raw_line, &mut statements);
|
|
1204
|
+ |
}
|
|
1205
|
+ |
let header = statements.first()?;
|
|
1206
|
+ |
if !header
|
|
1207
|
+ |
.split_whitespace()
|
|
1208
|
+ |
.next()?
|
|
1209
|
+ |
.eq_ignore_ascii_case("erdiagram")
|
|
1210
|
+ |
{
|
|
1211
|
+ |
return None;
|
|
1212
|
+ |
}
|
|
1213
|
+ |
|
|
1214
|
+ |
let mut graph = Graph {
|
|
1215
|
+ |
nodes: Vec::new(),
|
|
1216
|
+ |
edges: Vec::new(),
|
|
1217
|
+ |
index: HashMap::new(),
|
|
1218
|
+ |
groups: Vec::new(),
|
|
1219
|
+ |
node_group: Vec::new(),
|
|
1220
|
+ |
cur_group: None,
|
|
1221
|
+ |
over_cap: false,
|
|
1222
|
+ |
dir: Dir::Down,
|
|
1223
|
+ |
};
|
|
1224
|
+ |
let mut infos: Vec<ClassInfo> = Vec::new();
|
|
1225
|
+ |
let mut cur_entity: Option<usize> = None;
|
|
1226
|
+ |
|
|
1227
|
+ |
for st in &statements[1..] {
|
|
1228
|
+ |
if let Some(ei) = cur_entity {
|
|
1229
|
+ |
if st == "}" {
|
|
1230
|
+ |
cur_entity = None;
|
|
1231
|
+ |
} else {
|
|
1232
|
+ |
push_er_attribute(&mut infos[ei], st);
|
|
1233
|
+ |
}
|
|
1234
|
+ |
continue;
|
|
1235
|
+ |
}
|
|
1236
|
+ |
if let Some((rel, label_part)) = split_er_relationship(st) {
|
|
1237
|
+ |
let tokens: Vec<&str> = rel.split_whitespace().collect();
|
|
1238
|
+ |
let [lhs, op, rhs] = tokens.as_slice() else {
|
|
1239
|
+ |
return None;
|
|
1240
|
+ |
};
|
|
1241
|
+ |
let (card_l, card_r, line) = parse_er_op(op)?;
|
|
1242
|
+ |
let f = er_entity(&mut graph, &mut infos, lhs)?;
|
|
1243
|
+ |
let t = er_entity(&mut graph, &mut infos, rhs)?;
|
|
1244
|
+ |
if graph.edges.len() >= MAX_EDGES {
|
|
1245
|
+ |
return None;
|
|
1246
|
+ |
}
|
|
1247
|
+ |
let rel_label = label_part.map(clean_label).unwrap_or_default();
|
|
1248
|
+ |
let label = non_empty(
|
|
1249
|
+ |
[card_l.to_string(), rel_label, card_r.to_string()]
|
|
1250
|
+ |
.iter()
|
|
1251
|
+ |
.filter(|s| !s.is_empty())
|
|
1252
|
+ |
.cloned()
|
|
1253
|
+ |
.collect::<Vec<_>>()
|
|
1254
|
+ |
.join(" "),
|
|
1255
|
+ |
);
|
|
1256
|
+ |
graph.edges.push(Edge {
|
|
1257
|
+ |
from: f,
|
|
1258
|
+ |
to: t,
|
|
1259
|
+ |
label,
|
|
1260
|
+ |
head_to: Head::None,
|
|
1261
|
+ |
head_from: Head::None,
|
|
1262
|
+ |
line,
|
|
1263
|
+ |
});
|
|
1264
|
+ |
continue;
|
|
1265
|
+ |
}
|
|
1266
|
+ |
let (decl, open) = match st.strip_suffix('{') {
|
|
1267
|
+ |
Some(d) => (d.trim(), true),
|
|
1268
|
+ |
None => (st.as_str(), false),
|
|
1269
|
+ |
};
|
|
1270
|
+ |
if decl.is_empty() || decl.split_whitespace().count() != 1 {
|
|
1271
|
+ |
return None;
|
|
1272
|
+ |
}
|
|
1273
|
+ |
let idx = er_entity(&mut graph, &mut infos, decl)?;
|
|
1274
|
+ |
if open {
|
|
1275
|
+ |
cur_entity = Some(idx);
|
|
1276
|
+ |
}
|
|
1277
|
+ |
}
|
|
1278
|
+ |
|
|
1279
|
+ |
if graph.nodes.is_empty() {
|
|
1280
|
+ |
return None;
|
|
1281
|
+ |
}
|
|
1282
|
+ |
sync_infos(&graph, &mut infos);
|
|
1283
|
+ |
Some((graph, infos))
|
|
1284
|
+ |
}
|
|
1285
|
+ |
|
|
1286
|
+ |
fn er_entity(graph: &mut Graph, infos: &mut Vec<ClassInfo>, token: &str) -> Option<usize> {
|
|
1287
|
+ |
let idx = if let Some(open) = token.find('[') {
|
|
1288
|
+ |
let id = &token[..open];
|
|
1289
|
+ |
let label = clean_label(token[open + 1..].trim_end_matches(']'));
|
|
1290
|
+ |
if id.is_empty() || label.is_empty() {
|
|
1291
|
+ |
return None;
|
|
1292
|
+ |
}
|
|
1293
|
+ |
graph.node_label(id, &label)?
|
|
1294
|
+ |
} else {
|
|
1295
|
+ |
graph.node_index(token, None, Shape::Rect)?
|
|
1296
|
+ |
};
|
|
1297
|
+ |
sync_infos(graph, infos);
|
|
1298
|
+ |
Some(idx)
|
|
1299
|
+ |
}
|
|
1300
|
+ |
|
|
1301
|
+ |
fn split_er_relationship(st: &str) -> Option<(&str, Option<&str>)> {
|
|
1302
|
+ |
let (rel, label) = match st.split_once(':') {
|
|
1303
|
+ |
Some((r, l)) => (r, Some(l.trim())),
|
|
1304
|
+ |
None => (st, None),
|
|
1305
|
+ |
};
|
|
1306
|
+ |
let has_op = rel.split_whitespace().any(|t| parse_er_op(t).is_some());
|
|
1307
|
+ |
if has_op { Some((rel, label)) } else { None }
|
|
1308
|
+ |
}
|
|
1309
|
+ |
|
|
1310
|
+ |
fn parse_er_op(tok: &str) -> Option<(&'static str, &'static str, LineKind)> {
|
|
1311
|
+ |
if !tok.is_ascii() || tok.len() != 6 {
|
|
1312
|
+ |
return None;
|
|
1313
|
+ |
}
|
|
1314
|
+ |
let line = match &tok[2..4] {
|
|
1315
|
+ |
"--" => LineKind::Solid,
|
|
1316
|
+ |
".." => LineKind::Dotted,
|
|
1317
|
+ |
_ => return None,
|
|
1318
|
+ |
};
|
|
1319
|
+ |
Some((er_card(&tok[..2])?, er_card(&tok[4..6])?, line))
|
|
1320
|
+ |
}
|
|
1321
|
+ |
|
|
1322
|
+ |
fn er_card(tok: &str) -> Option<&'static str> {
|
|
1323
|
+ |
match tok {
|
|
1324
|
+ |
"|o" | "o|" => Some("0..1"),
|
|
1325
|
+ |
"||" => Some("1"),
|
|
1326
|
+ |
"}o" | "o{" => Some("0..*"),
|
|
1327
|
+ |
"}|" | "|{" => Some("1..*"),
|
|
1328
|
+ |
_ => None,
|
|
1329
|
+ |
}
|
|
1330
|
+ |
}
|
|
1331
|
+ |
|
|
1332
|
+ |
fn push_er_attribute(info: &mut ClassInfo, raw: &str) {
|
|
1333
|
+ |
let mut parts: Vec<&str> = Vec::new();
|
|
1334
|
+ |
for tok in raw.split_whitespace() {
|
|
1335
|
+ |
if tok.starts_with('"') {
|
|
1336
|
+ |
break;
|
|
1337
|
+ |
}
|
|
1338
|
+ |
parts.push(tok);
|
|
1339
|
+ |
}
|
|
1340
|
+ |
if parts.is_empty() {
|
|
1341
|
+ |
return;
|
|
1342
|
+ |
}
|
|
1343
|
+ |
let line = decode_html_entities(&parts.join(" "));
|
|
1344
|
+ |
if info.attrs.len() < MAX_MEMBERS {
|
|
1345
|
+ |
info.attrs.push(line);
|
|
1346
|
+ |
} else if info.attrs.len() == MAX_MEMBERS {
|
|
1347
|
+ |
info.attrs.push("…".to_string());
|
|
1348
|
+ |
}
|
|
1349
|
+ |
}
|
|
1350
|
+ |
|
|
1351
|
+ |
fn render_class(
|
|
1352
|
+ |
graph: &Graph,
|
|
1353
|
+ |
infos: &[ClassInfo],
|
|
1354
|
+ |
styles: &MermaidStyles,
|
|
1355
|
+ |
max_width: Option<usize>,
|
|
1356
|
+ |
) -> Result<MermaidArt, Oversize> {
|
|
1357
|
+ |
let extras: Vec<NodeExtra> = graph
|
|
1358
|
+ |
.nodes
|
|
1359
|
+ |
.iter()
|
|
1360
|
+ |
.zip(infos)
|
|
1361
|
+ |
.map(|(node, info)| {
|
|
1362
|
+ |
let mut title = Vec::new();
|
|
1363
|
+ |
if let Some(a) = &info.annotation {
|
|
1364
|
+ |
title.push(format!("«{a}»"));
|
|
1365
|
+ |
}
|
|
1366
|
+ |
title.push(display_generics(&node.label));
|
|
1367
|
+ |
NodeExtra::Compartments(vec![title, info.attrs.clone(), info.methods.clone()])
|
|
1368
|
+ |
})
|
|
1369
|
+ |
.collect();
|
|
1370
|
+ |
let mut canvas = layout_canvas(graph, &extras, max_width)?;
|
|
1371
|
+ |
match graph.dir {
|
|
1372
|
+ |
Dir::Up => canvas.flip_vertical(),
|
|
1373
|
+ |
Dir::Left => canvas.flip_horizontal(),
|
|
1374
|
+ |
_ => {}
|
|
1375
|
+ |
}
|
|
1376
|
+ |
let (styled_lines, plain_lines) = canvas.to_lines(styles);
|
|
1377
|
+ |
Ok(MermaidArt {
|
|
1378
|
+ |
styled_lines,
|
|
1379
|
+ |
plain_lines,
|
|
1380
|
+ |
})
|
|
1381
|
+ |
}
|
|
1382
|
+ |
|
|
1383
|
+ |
const U: u8 = 1;
|
|
1384
|
+ |
const D: u8 = 2;
|
|
1385
|
+ |
const L: u8 = 4;
|
|
1386
|
+ |
const R: u8 = 8;
|
|
1387
|
+ |
|
|
1388
|
+ |
#[derive(Clone, Copy, PartialEq)]
|
|
1389
|
+ |
enum Cls {
|
|
1390
|
+ |
Empty,
|
|
1391
|
+ |
Border,
|
|
1392
|
+ |
Text,
|
|
1393
|
+ |
Edge,
|
|
1394
|
+ |
EdgeLabel,
|
|
1395
|
+ |
}
|
|
1396
|
+ |
|
|
1397
|
+ |
const STY_DOT: u8 = 1;
|
|
1398
|
+ |
const STY_THICK: u8 = 2;
|
|
1399
|
+ |
const STY_SOLID: u8 = 4;
|
|
1400
|
+ |
|
|
1401
|
+ |
struct Canvas {
|
|
1402
|
+ |
w: usize,
|
|
1403
|
+ |
h: usize,
|
|
1404
|
+ |
ch: Vec<char>,
|
|
1405
|
+ |
cls: Vec<Cls>,
|
|
1406
|
+ |
mask: Vec<u8>,
|
|
1407
|
+ |
style: Vec<u8>,
|
|
1408
|
+ |
occupied: Vec<bool>,
|
|
1409
|
+ |
cur_style: u8,
|
|
1410
|
+ |
}
|
|
1411
|
+ |
|
|
1412
|
+ |
impl Canvas {
|
|
1413
|
+ |
fn new(w: usize, h: usize) -> Self {
|
|
1414
|
+ |
let n = w * h;
|
|
1415
|
+ |
Self {
|
|
1416
|
+ |
w,
|
|
1417
|
+ |
h,
|
|
1418
|
+ |
ch: vec![' '; n],
|
|
1419
|
+ |
cls: vec![Cls::Empty; n],
|
|
1420
|
+ |
mask: vec![0; n],
|
|
1421
|
+ |
style: vec![0; n],
|
|
1422
|
+ |
occupied: vec![false; n],
|
|
1423
|
+ |
cur_style: STY_SOLID,
|
|
1424
|
+ |
}
|
|
1425
|
+ |
}
|
|
1426
|
+ |
|
|
1427
|
+ |
fn idx(&self, x: usize, y: usize) -> usize {
|
|
1428
|
+ |
y * self.w + x
|
|
1429
|
+ |
}
|
|
1430
|
+ |
|
|
1431
|
+ |
fn set(&mut self, x: usize, y: usize, c: char, cls: Cls) {
|
|
1432
|
+ |
if x >= self.w || y >= self.h {
|
|
1433
|
+ |
return;
|
|
1434
|
+ |
}
|
|
1435
|
+ |
let i = self.idx(x, y);
|
|
1436
|
+ |
self.ch[i] = c;
|
|
1437
|
+ |
self.cls[i] = cls;
|
|
1438
|
+ |
}
|
|
1439
|
+ |
|
|
1440
|
+ |
fn add_bits(&mut self, x: usize, y: usize, bits: u8) {
|
|
1441
|
+ |
if x >= self.w || y >= self.h {
|
|
1442
|
+ |
return;
|
|
1443
|
+ |
}
|
|
1444
|
+ |
let i = self.idx(x, y);
|
|
1445
|
+ |
if self.occupied[i] {
|
|
1446
|
+ |
return;
|
|
1447
|
+ |
}
|
|
1448
|
+ |
self.mask[i] |= bits;
|
|
1449
|
+ |
self.style[i] |= self.cur_style;
|
|
1450
|
+ |
if self.cls[i] != Cls::Border {
|
|
1451
|
+ |
self.cls[i] = Cls::Edge;
|
|
1452
|
+ |
}
|
|
1453
|
+ |
}
|
|
1454
|
+ |
|
|
1455
|
+ |
fn blit(&mut self, sub: &Canvas, ox: usize, oy: usize) {
|
|
1456
|
+ |
for sy in 0..sub.h {
|
|
1457
|
+ |
for sx in 0..sub.w {
|
|
1458
|
+ |
let (x, y) = (ox + sx, oy + sy);
|
|
1459
|
+ |
if x >= self.w || y >= self.h {
|
|
1460
|
+ |
continue;
|
|
1461
|
+ |
}
|
|
1462
|
+ |
let si = sub.idx(sx, sy);
|
|
1463
|
+ |
let di = self.idx(x, y);
|
|
1464
|
+ |
self.ch[di] = sub.ch[si];
|
|
1465
|
+ |
self.cls[di] = sub.cls[si];
|
|
1466
|
+ |
self.style[di] = sub.style[si];
|
|
1467
|
+ |
self.occupied[di] = true;
|
|
1468
|
+ |
}
|
|
1469
|
+ |
}
|
|
1470
|
+ |
}
|
|
1471
|
+ |
|
|
1472
|
+ |
fn junction(&mut self, x: usize, y: usize, bits: u8) {
|
|
1473
|
+ |
if x >= self.w || y >= self.h {
|
|
1474
|
+ |
return;
|
|
1475
|
+ |
}
|
|
1476
|
+ |
let i = self.idx(x, y);
|
|
1477
|
+ |
self.mask[i] |= bits;
|
|
1478
|
+ |
if self.cls[i] != Cls::Border {
|
|
1479
|
+ |
self.cls[i] = Cls::Edge;
|
|
1480
|
+ |
}
|
|
1481
|
+ |
}
|
|
1482
|
+ |
|
|
1483
|
+ |
fn seg_v(&mut self, x: usize, y0: usize, y1: usize) {
|
|
1484
|
+ |
let (a, b) = (y0.min(y1), y0.max(y1));
|
|
1485
|
+ |
for y in a..=b {
|
|
1486
|
+ |
let mut bits = 0;
|
|
1487
|
+ |
if y > a {
|
|
1488
|
+ |
bits |= U;
|
|
1489
|
+ |
}
|
|
1490
|
+ |
if y < b {
|
|
1491
|
+ |
bits |= D;
|
|
1492
|
+ |
}
|
|
1493
|
+ |
self.add_bits(x, y, bits);
|
|
1494
|
+ |
}
|
|
1495
|
+ |
}
|
|
1496
|
+ |
|
|
1497
|
+ |
fn seg_h(&mut self, y: usize, x0: usize, x1: usize) {
|
|
1498
|
+ |
let (a, b) = (x0.min(x1), x0.max(x1));
|
|
1499
|
+ |
for x in a..=b {
|
|
1500
|
+ |
let mut bits = 0;
|
|
1501
|
+ |
if x > a {
|
|
1502
|
+ |
bits |= L;
|
|
1503
|
+ |
}
|
|
1504
|
+ |
if x < b {
|
|
1505
|
+ |
bits |= R;
|
|
1506
|
+ |
}
|
|
1507
|
+ |
self.add_bits(x, y, bits);
|
|
1508
|
+ |
}
|
|
1509
|
+ |
}
|
|
1510
|
+ |
|
|
1511
|
+ |
fn finalize_mask(&mut self) {
|
|
1512
|
+ |
for i in 0..self.ch.len() {
|
|
1513
|
+ |
if self.mask[i] != 0 && self.ch[i] == ' ' {
|
|
1514
|
+ |
let c = mask_char(self.mask[i]);
|
|
1515
|
+ |
self.ch[i] = match self.style[i] {
|
|
1516
|
+ |
STY_DOT => dotted_char(c),
|
|
1517
|
+ |
STY_THICK => thick_char(c),
|
|
1518
|
+ |
_ => c,
|
|
1519
|
+ |
};
|
|
1520
|
+ |
}
|
|
1521
|
+ |
}
|
|
1522
|
+ |
}
|
|
1523
|
+ |
|
|
1524
|
+ |
/// Mirror top-to-bottom for `BT` (rows reorder; within-row text is
|
|
1525
|
+ |
/// unaffected, so labels stay readable). Box-drawing glyphs flip too.
|
|
1526
|
+ |
fn flip_vertical(&mut self) {
|
|
1527
|
+ |
for y in 0..self.h / 2 {
|
|
1528
|
+ |
let y2 = self.h - 1 - y;
|
|
1529
|
+ |
for x in 0..self.w {
|
|
1530
|
+ |
let (i, j) = (self.idx(x, y), self.idx(x, y2));
|
|
1531
|
+ |
self.ch.swap(i, j);
|
|
1532
|
+ |
self.cls.swap(i, j);
|
|
1533
|
+ |
}
|
|
1534
|
+ |
}
|
|
1535
|
+ |
for c in self.ch.iter_mut() {
|
|
1536
|
+ |
*c = flip_glyph_v(*c);
|
|
1537
|
+ |
}
|
|
1538
|
+ |
}
|
|
1539
|
+ |
|
|
1540
|
+ |
/// Mirror left-to-right for `RL`. Mirroring reverses each row, so after
|
|
1541
|
+ |
/// flipping glyphs we reverse each text/label run back to reading order.
|
|
1542
|
+ |
fn flip_horizontal(&mut self) {
|
|
1543
|
+ |
for y in 0..self.h {
|
|
1544
|
+ |
for x in 0..self.w / 2 {
|
|
1545
|
+ |
let x2 = self.w - 1 - x;
|
|
1546
|
+ |
let (i, j) = (self.idx(x, y), self.idx(x2, y));
|
|
1547
|
+ |
self.ch.swap(i, j);
|
|
1548
|
+ |
self.cls.swap(i, j);
|
|
1549
|
+ |
}
|
|
1550
|
+ |
}
|
|
1551
|
+ |
for c in self.ch.iter_mut() {
|
|
1552
|
+ |
*c = flip_glyph_h(*c);
|
|
1553
|
+ |
}
|
|
1554
|
+ |
for y in 0..self.h {
|
|
1555
|
+ |
let mut x = 0;
|
|
1556
|
+ |
while x < self.w {
|
|
1557
|
+ |
let cls = self.cls[self.idx(x, y)];
|
|
1558
|
+ |
if cls == Cls::Text || cls == Cls::EdgeLabel {
|
|
1559
|
+ |
let start = self.idx(x, y);
|
|
1560
|
+ |
while x < self.w && self.cls[self.idx(x, y)] == cls {
|
|
1561
|
+ |
x += 1;
|
|
1562
|
+ |
}
|
|
1563
|
+ |
let end = self.idx(x, y);
|
|
1564
|
+ |
self.ch[start..end].reverse();
|
|
1565
|
+ |
} else {
|
|
1566
|
+ |
x += 1;
|
|
1567
|
+ |
}
|
|
1568
|
+ |
}
|
|
1569
|
+ |
}
|
|
1570
|
+ |
}
|
|
1571
|
+ |
|
|
1572
|
+ |
fn to_lines(&self, styles: &MermaidStyles) -> (Vec<Line<'static>>, Vec<String>) {
|
|
1573
|
+ |
let mut styled = Vec::with_capacity(self.h);
|
|
1574
|
+ |
let mut plain = Vec::with_capacity(self.h);
|
|
1575
|
+ |
for y in 0..self.h {
|
|
1576
|
+ |
let mut last = self.w;
|
|
1577
|
+ |
for x in (0..self.w).rev() {
|
|
1578
|
+ |
let c = self.ch[self.idx(x, y)];
|
|
1579
|
+ |
if c != ' ' && c != CONT {
|
|
1580
|
+ |
last = x + 1;
|
|
1581
|
+ |
break;
|
|
1582
|
+ |
}
|
|
1583
|
+ |
}
|
|
1584
|
+ |
let mut spans: Vec<Span<'static>> = Vec::new();
|
|
1585
|
+ |
let mut plain_row = String::new();
|
|
1586
|
+ |
let mut run = String::new();
|
|
1587
|
+ |
let mut run_cls = Cls::Empty;
|
|
1588
|
+ |
for x in 0..last {
|
|
1589
|
+ |
let i = self.idx(x, y);
|
|
1590
|
+ |
let c = self.ch[i];
|
|
1591
|
+ |
if c == CONT {
|
|
1592
|
+ |
continue;
|
|
1593
|
+ |
}
|
|
1594
|
+ |
let cls = self.cls[i];
|
|
1595
|
+ |
plain_row.push(c);
|
|
1596
|
+ |
if cls != run_cls && !run.is_empty() {
|
|
1597
|
+ |
spans.push(Span::styled(
|
|
1598
|
+ |
std::mem::take(&mut run),
|
|
1599
|
+ |
style_for(run_cls, styles),
|
|
1600
|
+ |
));
|
|
1601
|
+ |
}
|
|
1602
|
+ |
run_cls = cls;
|
|
1603
|
+ |
run.push(c);
|
|
1604
|
+ |
}
|
|
1605
|
+ |
if !run.is_empty() {
|
|
1606
|
+ |
spans.push(Span::styled(run, style_for(run_cls, styles)));
|
|
1607
|
+ |
}
|
|
1608
|
+ |
styled.push(Line::from(spans));
|
|
1609
|
+ |
plain.push(plain_row.trim_end().to_string());
|
|
1610
|
+ |
}
|
|
1611
|
+ |
(styled, plain)
|
|
1612
|
+ |
}
|
|
1613
|
+ |
}
|
|
1614
|
+ |
|
|
1615
|
+ |
fn style_for(cls: Cls, styles: &MermaidStyles) -> Style {
|
|
1616
|
+ |
match cls {
|
|
1617
|
+ |
Cls::Empty => Style::default(),
|
|
1618
|
+ |
Cls::Border => styles.border,
|
|
1619
|
+ |
Cls::Text => styles.node_text,
|
|
1620
|
+ |
Cls::Edge => styles.edge,
|
|
1621
|
+ |
Cls::EdgeLabel => styles.edge_label,
|
|
1622
|
+ |
}
|
|
1623
|
+ |
}
|
|
1624
|
+ |
|
|
1625
|
+ |
fn mask_char(mask: u8) -> char {
|
|
1626
|
+ |
match mask {
|
|
1627
|
+ |
0 => ' ',
|
|
1628
|
+ |
m if m == U || m == D || m == U | D => '│',
|
|
1629
|
+ |
m if m == L || m == R || m == L | R => '─',
|
|
1630
|
+ |
m if m == D | R => '┌',
|
|
1631
|
+ |
m if m == D | L => '┐',
|
|
1632
|
+ |
m if m == U | R => '└',
|
|
1633
|
+ |
m if m == U | L => '┘',
|
|
1634
|
+ |
m if m == U | D | R => '├',
|
|
1635
|
+ |
m if m == U | D | L => '┤',
|
|
1636
|
+ |
m if m == D | L | R => '┬',
|
|
1637
|
+ |
m if m == U | L | R => '┴',
|
|
1638
|
+ |
_ => '┼',
|
|
1639
|
+ |
}
|
|
1640
|
+ |
}
|
|
1641
|
+ |
|
|
1642
|
+ |
fn dotted_char(c: char) -> char {
|
|
1643
|
+ |
match c {
|
|
1644
|
+ |
'─' => '╌',
|
|
1645
|
+ |
'│' => '╎',
|
|
1646
|
+ |
other => other,
|
|
1647
|
+ |
}
|
|
1648
|
+ |
}
|
|
1649
|
+ |
|
|
1650
|
+ |
fn thick_char(c: char) -> char {
|
|
1651
|
+ |
match c {
|
|
1652
|
+ |
'─' => '━',
|
|
1653
|
+ |
'│' => '┃',
|
|
1654
|
+ |
'┌' => '┏',
|
|
1655
|
+ |
'┐' => '┓',
|
|
1656
|
+ |
'└' => '┗',
|
|
1657
|
+ |
'┘' => '┛',
|
|
1658
|
+ |
'├' => '┣',
|
|
1659
|
+ |
'┤' => '┫',
|
|
1660
|
+ |
'┬' => '┳',
|
|
1661
|
+ |
'┴' => '┻',
|
|
1662
|
+ |
'┼' => '╋',
|
|
1663
|
+ |
other => other,
|
|
1664
|
+ |
}
|
|
1665
|
+ |
}
|
|
1666
|
+ |
|
|
1667
|
+ |
fn flip_glyph_v(c: char) -> char {
|
|
1668
|
+ |
match c {
|
|
1669
|
+ |
'┌' => '└',
|
|
1670
|
+ |
'└' => '┌',
|
|
1671
|
+ |
'┐' => '┘',
|
|
1672
|
+ |
'┘' => '┐',
|
|
1673
|
+ |
'┏' => '┗',
|
|
1674
|
+ |
'┗' => '┏',
|
|
1675
|
+ |
'┓' => '┛',
|
|
1676
|
+ |
'┛' => '┓',
|
|
1677
|
+ |
'╭' => '╰',
|
|
1678
|
+ |
'╰' => '╭',
|
|
1679
|
+ |
'╮' => '╯',
|
|
1680
|
+ |
'╯' => '╮',
|
|
1681
|
+ |
'┬' => '┴',
|
|
1682
|
+ |
'┴' => '┬',
|
|
1683
|
+ |
'┳' => '┻',
|
|
1684
|
+ |
'┻' => '┳',
|
|
1685
|
+ |
'▼' => '▲',
|
|
1686
|
+ |
'▲' => '▼',
|
|
1687
|
+ |
'▽' => '△',
|
|
1688
|
+ |
'△' => '▽',
|
|
1689
|
+ |
other => other,
|
|
1690
|
+ |
}
|
|
1691
|
+ |
}
|
|
1692
|
+ |
|
|
1693
|
+ |
fn flip_glyph_h(c: char) -> char {
|
|
1694
|
+ |
match c {
|
|
1695
|
+ |
'┌' => '┐',
|
|
1696
|
+ |
'┐' => '┌',
|
|
1697
|
+ |
'└' => '┘',
|
|
1698
|
+ |
'┘' => '└',
|
|
1699
|
+ |
'┏' => '┓',
|
|
1700
|
+ |
'┓' => '┏',
|
|
1701
|
+ |
'┗' => '┛',
|
|
1702
|
+ |
'┛' => '┗',
|
|
1703
|
+ |
'╭' => '╮',
|
|
1704
|
+ |
'╮' => '╭',
|
|
1705
|
+ |
'╰' => '╯',
|
|
1706
|
+ |
'╯' => '╰',
|
|
1707
|
+ |
'├' => '┤',
|
|
1708
|
+ |
'┤' => '├',
|
|
1709
|
+ |
'┣' => '┫',
|
|
1710
|
+ |
'┫' => '┣',
|
|
1711
|
+ |
'▶' => '◄',
|
|
1712
|
+ |
'◄' => '▶',
|
|
1713
|
+ |
'▷' => '◁',
|
|
1714
|
+ |
'◁' => '▷',
|
|
1715
|
+ |
other => other,
|
|
1716
|
+ |
}
|
|
1717
|
+ |
}
|
|
1718
|
+ |
|
|
1719
|
+ |
struct Placed {
|
|
1720
|
+ |
x: usize,
|
|
1721
|
+ |
y: usize,
|
|
1722
|
+ |
w: usize,
|
|
1723
|
+ |
h: usize,
|
|
1724
|
+ |
cx: usize,
|
|
1725
|
+ |
cy: usize,
|
|
1726
|
+ |
rank: usize,
|
|
1727
|
+ |
}
|
|
1728
|
+ |
|
|
1729
|
+ |
struct NodeSizes {
|
|
1730
|
+ |
box_w: Vec<usize>,
|
|
1731
|
+ |
box_h: Vec<usize>,
|
|
1732
|
+ |
lay_w: Vec<usize>,
|
|
1733
|
+ |
lay_h: Vec<usize>,
|
|
1734
|
+ |
extra_h: Vec<usize>,
|
|
1735
|
+ |
self_label_w: Vec<usize>,
|
|
1736
|
+ |
}
|
|
1737
|
+ |
|
|
1738
|
+ |
fn layout_flowchart(
|
|
1739
|
+ |
graph: &Graph,
|
|
1740
|
+ |
styles: &MermaidStyles,
|
|
1741
|
+ |
max_width: Option<usize>,
|
|
1742
|
+ |
) -> Result<MermaidArt, Oversize> {
|
|
1743
|
+ |
let extras: Vec<NodeExtra> = (0..graph.nodes.len()).map(|_| NodeExtra::Plain).collect();
|
|
1744
|
+ |
let mut canvas = layout_canvas(graph, &extras, max_width)?;
|
|
1745
|
+ |
match graph.dir {
|
|
1746
|
+ |
Dir::Up => canvas.flip_vertical(),
|
|
1747
|
+ |
Dir::Left => canvas.flip_horizontal(),
|
|
1748
|
+ |
_ => {}
|
|
1749
|
+ |
}
|
|
1750
|
+ |
let (styled_lines, plain_lines) = canvas.to_lines(styles);
|
|
1751
|
+ |
Ok(MermaidArt {
|
|
1752
|
+ |
styled_lines,
|
|
1753
|
+ |
plain_lines,
|
|
1754
|
+ |
})
|
|
1755
|
+ |
}
|
|
1756
|
+ |
|
|
1757
|
+ |
enum NodeExtra {
|
|
1758
|
+ |
Plain,
|
|
1759
|
+ |
Frame(Canvas),
|
|
1760
|
+ |
Compartments(Vec<Vec<String>>),
|
|
1761
|
+ |
}
|
|
1762
|
+ |
|
|
1763
|
+ |
fn layout_canvas(
|
|
1764
|
+ |
graph: &Graph,
|
|
1765
|
+ |
extras: &[NodeExtra],
|
|
1766
|
+ |
max_width: Option<usize>,
|
|
1767
|
+ |
) -> Result<Canvas, Oversize> {
|
|
1768
|
+ |
let n = graph.nodes.len();
|
|
1769
|
+ |
if n == 0 {
|
|
1770
|
+ |
return Err(Oversize::Cells);
|
|
1771
|
+ |
}
|
|
1772
|
+ |
|
|
1773
|
+ |
let ranks = compute_ranks(graph);
|
|
1774
|
+ |
let max_rank = *ranks.iter().max().unwrap_or(&0);
|
|
1775
|
+ |
|
|
1776
|
+ |
let mut by_rank: Vec<Vec<usize>> = vec![Vec::new(); max_rank + 1];
|
|
1777
|
+ |
for (idx, &r) in ranks.iter().enumerate() {
|
|
1778
|
+ |
by_rank[r].push(idx);
|
|
1779
|
+ |
}
|
|
1780
|
+ |
order_ranks(&mut by_rank, &graph.edges, &ranks);
|
|
1781
|
+ |
|
|
1782
|
+ |
let wrapped: Vec<Vec<String>> = graph
|
|
1783
|
+ |
.nodes
|
|
1784
|
+ |
.iter()
|
|
1785
|
+ |
.map(|node| wrap_label(&node.label, WRAP_WIDTH, MAX_LINES))
|
|
1786
|
+ |
.collect();
|
|
1787
|
+ |
let mut box_w: Vec<usize> = (0..n)
|
|
1788
|
+ |
.map(|i| match &extras[i] {
|
|
1789
|
+ |
NodeExtra::Frame(sub) => {
|
|
1790
|
+ |
let title_w = fit_label(&graph.nodes[i].label, WRAP_WIDTH).width();
|
|
1791
|
+ |
(sub.w + 2).max(title_w + 4)
|
|
1792
|
+ |
}
|
|
1793
|
+ |
NodeExtra::Compartments(sections) => {
|
|
1794
|
+ |
sections
|
|
1795
|
+ |
.iter()
|
|
1796
|
+ |
.flatten()
|
|
1797
|
+ |
.map(|l| l.width())
|
|
1798
|
+ |
.max()
|
|
1799
|
+ |
.unwrap_or(1)
|
|
1800
|
+ |
.max(1)
|
|
1801
|
+ |
+ 2 * PAD
|
|
1802
|
+ |
+ 2
|
|
1803
|
+ |
}
|
|
1804
|
+ |
NodeExtra::Plain => {
|
|
1805
|
+ |
wrapped[i]
|
|
1806
|
+ |
.iter()
|
|
1807
|
+ |
.map(|l| l.width())
|
|
1808
|
+ |
.max()
|
|
1809
|
+ |
.unwrap_or(1)
|
|
1810
|
+ |
.max(1)
|
|
1811
|
+ |
+ 2 * PAD
|
|
1812
|
+ |
+ 2
|
|
1813
|
+ |
}
|
|
1814
|
+ |
})
|
|
1815
|
+ |
.collect();
|
|
1816
|
+ |
let box_h: Vec<usize> = (0..n)
|
|
1817
|
+ |
.map(|i| match &extras[i] {
|
|
1818
|
+ |
NodeExtra::Frame(sub) => sub.h + 2,
|
|
1819
|
+ |
NodeExtra::Compartments(sections) => {
|
|
1820
|
+ |
let filled = sections.iter().filter(|s| !s.is_empty()).count();
|
|
1821
|
+ |
sections.iter().map(|s| s.len()).sum::<usize>() + filled.saturating_sub(1) + 2
|
|
1822
|
+ |
}
|
|
1823
|
+ |
NodeExtra::Plain => wrapped[i].len() + 2,
|
|
1824
|
+ |
})
|
|
1825
|
+ |
.collect();
|
|
1826
|
+ |
|
|
1827
|
+ |
let mut extra_h = vec![0usize; n];
|
|
1828
|
+ |
let mut self_label_w = vec![0usize; n];
|
|
1829
|
+ |
for e in &graph.edges {
|
|
1830
|
+ |
if e.from == e.to {
|
|
1831
|
+ |
extra_h[e.from] = 2;
|
|
1832
|
+ |
if let Some(l) = &e.label {
|
|
1833
|
+ |
self_label_w[e.from] = self_label_w[e.from].max(l.width().min(MAX_LABEL));
|
|
1834
|
+ |
}
|
|
1835
|
+ |
}
|
|
1836
|
+ |
}
|
|
1837
|
+ |
for i in 0..n {
|
|
1838
|
+ |
if extra_h[i] > 0 {
|
|
1839
|
+ |
box_w[i] = box_w[i].max(7);
|
|
1840
|
+ |
}
|
|
1841
|
+ |
}
|
|
1842
|
+ |
let lay_w: Vec<usize> = (0..n)
|
|
1843
|
+ |
.map(|i| {
|
|
1844
|
+ |
box_w[i]
|
|
1845
|
+ |
+ if self_label_w[i] > 0 {
|
|
1846
|
+ |
2 * (self_label_w[i] + 3)
|
|
1847
|
+ |
} else {
|
|
1848
|
+ |
0
|
|
1849
|
+ |
}
|
|
1850
|
+ |
})
|
|
1851
|
+ |
.collect();
|
|
1852
|
+ |
let lay_h: Vec<usize> = (0..n).map(|i| box_h[i] + extra_h[i]).collect();
|
|
1853
|
+ |
let sizes = NodeSizes {
|
|
1854
|
+ |
box_w,
|
|
1855
|
+ |
box_h,
|
|
1856
|
+ |
lay_w,
|
|
1857
|
+ |
lay_h,
|
|
1858
|
+ |
extra_h,
|
|
1859
|
+ |
self_label_w,
|
|
1860
|
+ |
};
|
|
1861
|
+ |
|
|
1862
|
+ |
let mut placed: Vec<Placed> = (0..n)
|
|
1863
|
+ |
.map(|_| Placed {
|
|
1864
|
+ |
x: 0,
|
|
1865
|
+ |
y: 0,
|
|
1866
|
+ |
w: 0,
|
|
1867
|
+ |
h: 0,
|
|
1868
|
+ |
cx: 0,
|
|
1869
|
+ |
cy: 0,
|
|
1870
|
+ |
rank: 0,
|
|
1871
|
+ |
})
|
|
1872
|
+ |
.collect();
|
|
1873
|
+ |
|
|
1874
|
+ |
// BT/RL reuse the TD/LR layout, then flip the finished canvas (so text
|
|
1875
|
+ |
// stays readable) into the bottom-up / right-to-left orientation.
|
|
1876
|
+ |
let vertical = matches!(graph.dir, Dir::Down | Dir::Up);
|
|
1877
|
+ |
let plan = if vertical {
|
|
1878
|
+ |
place_td(&ranks, max_rank, &by_rank, &sizes, graph, &mut placed)
|
|
1879
|
+ |
} else {
|
|
1880
|
+ |
place_lr(&ranks, max_rank, &by_rank, &sizes, graph, &mut placed)
|
|
1881
|
+ |
};
|
|
1882
|
+ |
let (canvas_w, canvas_h) = plan.canvas;
|
|
1883
|
+ |
|
|
1884
|
+ |
if let Some(mw) = max_width
|
|
1885
|
+ |
&& canvas_w > mw
|
|
1886
|
+ |
{
|
|
1887
|
+ |
return Err(Oversize::Width);
|
|
1888
|
+ |
}
|
|
1889
|
+ |
if canvas_w.saturating_mul(canvas_h) > MAX_CANVAS_CELLS {
|
|
1890
|
+ |
return Err(Oversize::Cells);
|
|
1891
|
+ |
}
|
|
1892
|
+ |
|
|
1893
|
+ |
let mut canvas = Canvas::new(canvas_w, canvas_h);
|
|
1894
|
+ |
for idx in 0..n {
|
|
1895
|
+ |
match &extras[idx] {
|
|
1896
|
+ |
NodeExtra::Frame(sub) => {
|
|
1897
|
+ |
draw_frame(&mut canvas, &placed[idx], &graph.nodes[idx].label, sub)
|
|
1898
|
+ |
}
|
|
1899
|
+ |
NodeExtra::Compartments(sections) => {
|
|
1900
|
+ |
draw_class_box(&mut canvas, &placed[idx], sections)
|
|
1901
|
+ |
}
|
|
1902
|
+ |
NodeExtra::Plain => draw_box(
|
|
1903
|
+ |
&mut canvas,
|
|
1904
|
+ |
&placed[idx],
|
|
1905
|
+ |
&wrapped[idx],
|
|
1906
|
+ |
graph.nodes[idx].shape,
|
|
1907
|
+ |
),
|
|
1908
|
+ |
}
|
|
1909
|
+ |
}
|
|
1910
|
+ |
for (i, edge) in graph.edges.iter().enumerate() {
|
|
1911
|
+ |
canvas.cur_style = match edge.line {
|
|
1912
|
+ |
LineKind::Solid => STY_SOLID,
|
|
1913
|
+ |
LineKind::Dotted => STY_DOT,
|
|
1914
|
+ |
LineKind::Thick => STY_THICK,
|
|
1915
|
+ |
};
|
|
1916
|
+ |
if edge.from == edge.to {
|
|
1917
|
+ |
route_self(&mut canvas, &placed[edge.from], edge);
|
|
1918
|
+ |
continue;
|
|
1919
|
+ |
}
|
|
1920
|
+ |
let (from, to) = (&placed[edge.from], &placed[edge.to]);
|
|
1921
|
+ |
let adjacent = to.rank == from.rank + 1;
|
|
1922
|
+ |
let bus = plan.band_end[from.rank] + plan.edge_bus[i];
|
|
1923
|
+ |
let lane = plan.lane_base + plan.edge_lane[i];
|
|
1924
|
+ |
match (vertical, adjacent) {
|
|
1925
|
+ |
(true, true) => route_forward(&mut canvas, from, to, edge, bus),
|
|
1926
|
+ |
(true, false) => route_back(&mut canvas, from, to, edge, lane),
|
|
1927
|
+ |
(false, true) => route_forward_lr(&mut canvas, from, to, edge, bus),
|
|
1928
|
+ |
(false, false) => route_back_lr(&mut canvas, from, to, edge, lane),
|
|
1929
|
+ |
}
|
|
1930
|
+ |
}
|
|
1931
|
+ |
|
|
1932
|
+ |
canvas.finalize_mask();
|
|
1933
|
+ |
Ok(canvas)
|
|
1934
|
+ |
}
|
|
1935
|
+ |
|
|
1936
|
+ |
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
|
1937
|
+ |
enum Item {
|
|
1938
|
+ |
Node(usize),
|
|
1939
|
+ |
Group(usize),
|
|
1940
|
+ |
}
|
|
1941
|
+ |
|
|
1942
|
+ |
fn render_grouped(
|
|
1943
|
+ |
graph: &Graph,
|
|
1944
|
+ |
styles: &MermaidStyles,
|
|
1945
|
+ |
max_width: Option<usize>,
|
|
1946
|
+ |
) -> Result<MermaidArt, Oversize> {
|
|
1947
|
+ |
let mut proxy: HashMap<usize, usize> = HashMap::new();
|
|
1948
|
+ |
for (gi, g) in graph.groups.iter().enumerate() {
|
|
1949
|
+ |
if let Some(&ni) = graph.index.get(&g.id) {
|
|
1950
|
+ |
proxy.insert(ni, gi);
|
|
1951
|
+ |
}
|
|
1952
|
+ |
}
|
|
1953
|
+ |
|
|
1954
|
+ |
let group_chain = |g: Option<usize>| -> Vec<usize> {
|
|
1955
|
+ |
let mut chain = Vec::new();
|
|
1956
|
+ |
let mut cur = g;
|
|
1957
|
+ |
while let Some(gi) = cur {
|
|
1958
|
+ |
chain.push(gi);
|
|
1959
|
+ |
cur = graph.groups[gi].parent;
|
|
1960
|
+ |
}
|
|
1961
|
+ |
chain.reverse();
|
|
1962
|
+ |
chain
|
|
1963
|
+ |
};
|
|
1964
|
+ |
let endpoint = |n: usize| -> (Item, Vec<usize>) {
|
|
1965
|
+ |
match proxy.get(&n) {
|
|
1966
|
+ |
Some(&gi) => (Item::Group(gi), group_chain(graph.groups[gi].parent)),
|
|
1967
|
+ |
None => (Item::Node(n), group_chain(graph.node_group[n])),
|
|
1968
|
+ |
}
|
|
1969
|
+ |
};
|
|
1970
|
+ |
|
|
1971
|
+ |
let mut scope_edges: HashMap<Option<usize>, Vec<(Item, Item, usize)>> = HashMap::new();
|
|
1972
|
+ |
let mut referenced: Vec<bool> = vec![false; graph.groups.len()];
|
|
1973
|
+ |
for (ei, e) in graph.edges.iter().enumerate() {
|
|
1974
|
+ |
let (item_f, chain_f) = endpoint(e.from);
|
|
1975
|
+ |
let (item_t, chain_t) = endpoint(e.to);
|
|
1976
|
+ |
let k = chain_f
|
|
1977
|
+ |
.iter()
|
|
1978
|
+ |
.zip(&chain_t)
|
|
1979
|
+ |
.take_while(|(a, b)| a == b)
|
|
1980
|
+ |
.count();
|
|
1981
|
+ |
let scope = if k == 0 { None } else { Some(chain_f[k - 1]) };
|
|
1982
|
+ |
let f = if chain_f.len() > k {
|
|
1983
|
+ |
Item::Group(chain_f[k])
|
|
1984
|
+ |
} else {
|
|
1985
|
+ |
item_f
|
|
1986
|
+ |
};
|
|
1987
|
+ |
let t = if chain_t.len() > k {
|
|
1988
|
+ |
Item::Group(chain_t[k])
|
|
1989
|
+ |
} else {
|
|
1990
|
+ |
item_t
|
|
1991
|
+ |
};
|
|
1992
|
+ |
if let Item::Group(gi) = f {
|
|
1993
|
+ |
referenced[gi] = true;
|
|
1994
|
+ |
}
|
|
1995
|
+ |
if let Item::Group(gi) = t {
|
|
1996
|
+ |
referenced[gi] = true;
|
|
1997
|
+ |
}
|
|
1998
|
+ |
scope_edges.entry(scope).or_default().push((f, t, ei));
|
|
1999
|
+ |
}
|
|
2000
|
+ |
|
|
2001
|
+ |
let mut direct_nodes: HashMap<Option<usize>, Vec<usize>> = HashMap::new();
|
|
2002
|
+ |
for (ni, g) in graph.node_group.iter().enumerate() {
|
|
2003
|
+ |
if !proxy.contains_key(&ni) {
|
|
2004
|
+ |
direct_nodes.entry(*g).or_default().push(ni);
|
|
2005
|
+ |
}
|
|
2006
|
+ |
}
|
|
2007
|
+ |
let mut keep = vec![false; graph.groups.len()];
|
|
2008
|
+ |
for gi in (0..graph.groups.len()).rev() {
|
|
2009
|
+ |
let has_nodes = direct_nodes.get(&Some(gi)).is_some_and(|v| !v.is_empty());
|
|
2010
|
+ |
let has_children =
|
|
2011
|
+ |
(0..graph.groups.len()).any(|c| graph.groups[c].parent == Some(gi) && keep[c]);
|
|
2012
|
+ |
keep[gi] = has_nodes || has_children || referenced[gi];
|
|
2013
|
+ |
}
|
|
2014
|
+ |
|
|
2015
|
+ |
let mut canvas = build_scope(graph, None, &scope_edges, &direct_nodes, &keep, max_width)?;
|
|
2016
|
+ |
match graph.dir {
|
|
2017
|
+ |
Dir::Up => canvas.flip_vertical(),
|
|
2018
|
+ |
Dir::Left => canvas.flip_horizontal(),
|
|
2019
|
+ |
_ => {}
|
|
2020
|
+ |
}
|
|
2021
|
+ |
let (styled_lines, plain_lines) = canvas.to_lines(styles);
|
|
2022
|
+ |
Ok(MermaidArt {
|
|
2023
|
+ |
styled_lines,
|
|
2024
|
+ |
plain_lines,
|
|
2025
|
+ |
})
|
|
2026
|
+ |
}
|
|
2027
|
+ |
|
|
2028
|
+ |
fn build_scope(
|
|
2029
|
+ |
graph: &Graph,
|
|
2030
|
+ |
scope: Option<usize>,
|
|
2031
|
+ |
scope_edges: &HashMap<Option<usize>, Vec<(Item, Item, usize)>>,
|
|
2032
|
+ |
direct_nodes: &HashMap<Option<usize>, Vec<usize>>,
|
|
2033
|
+ |
keep: &[bool],
|
|
2034
|
+ |
max_width: Option<usize>,
|
|
2035
|
+ |
) -> Result<Canvas, Oversize> {
|
|
2036
|
+ |
let mut items: Vec<Item> = Vec::new();
|
|
2037
|
+ |
if let Some(nodes) = direct_nodes.get(&scope) {
|
|
2038
|
+ |
items.extend(nodes.iter().map(|&n| Item::Node(n)));
|
|
2039
|
+ |
}
|
|
2040
|
+ |
let child_groups: Vec<usize> = (0..graph.groups.len())
|
|
2041
|
+ |
.filter(|&gi| graph.groups[gi].parent == scope && keep[gi])
|
|
2042
|
+ |
.collect();
|
|
2043
|
+ |
items.extend(child_groups.iter().map(|&gi| Item::Group(gi)));
|
|
2044
|
+ |
|
|
2045
|
+ |
if items.is_empty() {
|
|
2046
|
+ |
return Ok(Canvas::new(1, 1));
|
|
2047
|
+ |
}
|
|
2048
|
+ |
|
|
2049
|
+ |
let mut index_of: HashMap<Item, usize> = HashMap::new();
|
|
2050
|
+ |
let mut nodes: Vec<Node> = Vec::new();
|
|
2051
|
+ |
let mut extras: Vec<NodeExtra> = Vec::new();
|
|
2052
|
+ |
for item in &items {
|
|
2053
|
+ |
index_of.insert(*item, nodes.len());
|
|
2054
|
+ |
match item {
|
|
2055
|
+ |
Item::Node(ni) => {
|
|
2056
|
+ |
nodes.push(Node {
|
|
2057
|
+ |
label: graph.nodes[*ni].label.clone(),
|
|
2058
|
+ |
shape: graph.nodes[*ni].shape,
|
|
2059
|
+ |
});
|
|
2060
|
+ |
extras.push(NodeExtra::Plain);
|
|
2061
|
+ |
}
|
|
2062
|
+ |
Item::Group(gi) => {
|
|
2063
|
+ |
let sub = build_scope(graph, Some(*gi), scope_edges, direct_nodes, keep, None)?;
|
|
2064
|
+ |
nodes.push(Node {
|
|
2065
|
+ |
label: graph.groups[*gi].label.clone(),
|
|
2066
|
+ |
shape: Shape::Rect,
|
|
2067
|
+ |
});
|
|
2068
|
+ |
extras.push(NodeExtra::Frame(sub));
|
|
2069
|
+ |
}
|
|
2070
|
+ |
}
|
|
2071
|
+ |
}
|
|
2072
|
+ |
|
|
2073
|
+ |
let mut edges: Vec<Edge> = Vec::new();
|
|
2074
|
+ |
if let Some(list) = scope_edges.get(&scope) {
|
|
2075
|
+ |
for (f, t, ei) in list {
|
|
2076
|
+ |
let (Some(&fi), Some(&ti)) = (index_of.get(f), index_of.get(t)) else {
|
|
2077
|
+ |
continue;
|
|
2078
|
+ |
};
|
|
2079
|
+ |
let e = &graph.edges[*ei];
|
|
2080
|
+ |
edges.push(Edge {
|
|
2081
|
+ |
from: fi,
|
|
2082
|
+ |
to: ti,
|
|
2083
|
+ |
label: e.label.clone(),
|
|
2084
|
+ |
head_to: e.head_to,
|
|
2085
|
+ |
head_from: e.head_from,
|
|
2086
|
+ |
line: e.line,
|
|
2087
|
+ |
});
|
|
2088
|
+ |
}
|
|
2089
|
+ |
}
|
|
2090
|
+ |
|
|
2091
|
+ |
let synth = Graph {
|
|
2092
|
+ |
nodes,
|
|
2093
|
+ |
edges,
|
|
2094
|
+ |
index: HashMap::new(),
|
|
2095
|
+ |
groups: Vec::new(),
|
|
2096
|
+ |
node_group: Vec::new(),
|
|
2097
|
+ |
cur_group: None,
|
|
2098
|
+ |
over_cap: false,
|
|
2099
|
+ |
dir: graph.dir,
|
|
2100
|
+ |
};
|
|
2101
|
+ |
layout_canvas(&synth, &extras, max_width)
|
|
2102
|
+ |
}
|
|
2103
|
+ |
|
|
2104
|
+ |
fn draw_class_box(canvas: &mut Canvas, p: &Placed, sections: &[Vec<String>]) {
|
|
2105
|
+ |
draw_box(canvas, p, &[], Shape::Rect);
|
|
2106
|
+ |
let inner = p.w.saturating_sub(2 * PAD + 2).max(1);
|
|
2107
|
+ |
let mut row = p.y + 1;
|
|
2108
|
+ |
let mut first = true;
|
|
2109
|
+ |
for (si, section) in sections.iter().enumerate() {
|
|
2110
|
+ |
if section.is_empty() {
|
|
2111
|
+ |
continue;
|
|
2112
|
+ |
}
|
|
2113
|
+ |
if !first {
|
|
2114
|
+ |
canvas.set(p.x, row, '├', Cls::Border);
|
|
2115
|
+ |
for x in (p.x + 1)..(p.x + p.w - 1) {
|
|
2116
|
+ |
canvas.set(x, row, '─', Cls::Border);
|
|
2117
|
+ |
}
|
|
2118
|
+ |
canvas.set(p.x + p.w - 1, row, '┤', Cls::Border);
|
|
2119
|
+ |
row += 1;
|
|
2120
|
+ |
}
|
|
2121
|
+ |
first = false;
|
|
2122
|
+ |
for line in section {
|
|
2123
|
+ |
let text = fit_label(line, inner);
|
|
2124
|
+ |
let tx = if si == 0 {
|
|
2125
|
+ |
p.x + 1 + PAD + inner.saturating_sub(text.width()) / 2
|
|
2126
|
+ |
} else {
|
|
2127
|
+ |
p.x + 1 + PAD
|
|
2128
|
+ |
};
|
|
2129
|
+ |
draw_seq_text(canvas, &text, tx, row, Cls::Text);
|
|
2130
|
+ |
row += 1;
|
|
2131
|
+ |
}
|
|
2132
|
+ |
}
|
|
2133
|
+ |
}
|
|
2134
|
+ |
|
|
2135
|
+ |
fn draw_frame(canvas: &mut Canvas, p: &Placed, title: &str, sub: &Canvas) {
|
|
2136
|
+ |
draw_box(canvas, p, &[], Shape::Rect);
|
|
2137
|
+ |
let t = fit_label(title, p.w.saturating_sub(4));
|
|
2138
|
+ |
draw_seq_text(canvas, &format!(" {t} "), p.x + 1, p.y, Cls::Text);
|
|
2139
|
+ |
let ox = p.x + 1 + (p.w - 2 - sub.w) / 2;
|
|
2140
|
+ |
let oy = p.y + 1 + (p.h - 2 - sub.h) / 2;
|
|
2141
|
+ |
canvas.blit(sub, ox, oy);
|
|
2142
|
+ |
}
|
|
2143
|
+ |
|
|
2144
|
+ |
fn bus_spans_td(
|
|
2145
|
+ |
graph: &Graph,
|
|
2146
|
+ |
ranks: &[usize],
|
|
2147
|
+ |
centers: &[usize],
|
|
2148
|
+ |
r: usize,
|
|
2149
|
+ |
exact: bool,
|
|
2150
|
+ |
) -> Vec<(usize, usize, usize, usize, usize)> {
|
|
2151
|
+ |
graph
|
|
2152
|
+ |
.edges
|
|
2153
|
+ |
.iter()
|
|
2154
|
+ |
.enumerate()
|
|
2155
|
+ |
.filter(|(_, e)| {
|
|
2156
|
+ |
let jogs = if exact {
|
|
2157
|
+ |
centers[e.from] != centers[e.to]
|
|
2158
|
+ |
} else {
|
|
2159
|
+ |
centers[e.from].abs_diff(centers[e.to]) > 1
|
|
2160
|
+ |
};
|
|
2161
|
+ |
e.from != e.to && ranks[e.from] == r && ranks[e.to] == r + 1 && jogs
|
|
2162
|
+ |
})
|
|
2163
|
+ |
.map(|(i, e)| {
|
|
2164
|
+ |
let a = centers[e.from].min(centers[e.to]);
|
|
2165
|
+ |
let b = centers[e.from].max(centers[e.to]);
|
|
2166
|
+ |
(a, b, e.from, e.to, i)
|
|
2167
|
+ |
})
|
|
2168
|
+ |
.collect()
|
|
2169
|
+ |
}
|
|
2170
|
+ |
|
|
2171
|
+ |
fn lane_spans(
|
|
2172
|
+ |
graph: &Graph,
|
|
2173
|
+ |
ranks: &[usize],
|
|
2174
|
+ |
placed: &[Placed],
|
|
2175
|
+ |
vertical: bool,
|
|
2176
|
+ |
) -> Vec<(usize, usize, usize, usize, usize)> {
|
|
2177
|
+ |
graph
|
|
2178
|
+ |
.edges
|
|
2179
|
+ |
.iter()
|
|
2180
|
+ |
.enumerate()
|
|
2181
|
+ |
.filter(|(_, e)| e.from != e.to && ranks[e.to] != ranks[e.from] + 1)
|
|
2182
|
+ |
.map(|(i, e)| {
|
|
2183
|
+ |
let (pf, pt) = (&placed[e.from], &placed[e.to]);
|
|
2184
|
+ |
let (a, b) = if vertical {
|
|
2185
|
+ |
(pf.cy.min(pt.cy), pf.cy.max(pt.cy))
|
|
2186
|
+ |
} else {
|
|
2187
|
+ |
(pf.cx.min(pt.cx), pf.cx.max(pt.cx))
|
|
2188
|
+ |
};
|
|
2189
|
+ |
(a, b, e.from, e.to, i)
|
|
2190
|
+ |
})
|
|
2191
|
+ |
.collect()
|
|
2192
|
+ |
}
|
|
2193
|
+ |
|
|
2194
|
+ |
fn place_td(
|
|
2195
|
+ |
ranks: &[usize],
|
|
2196
|
+ |
max_rank: usize,
|
|
2197
|
+ |
by_rank: &[Vec<usize>],
|
|
2198
|
+ |
sizes: &NodeSizes,
|
|
2199
|
+ |
graph: &Graph,
|
|
2200
|
+ |
placed: &mut [Placed],
|
|
2201
|
+ |
) -> RoutePlan {
|
|
2202
|
+ |
let centers = assign_positions(by_rank, &sizes.lay_w, GAP_X, &graph.edges, ranks);
|
|
2203
|
+ |
|
|
2204
|
+ |
let mut edge_bus = vec![0usize; graph.edges.len()];
|
|
2205
|
+ |
let mut bus_tracks = vec![0usize; max_rank + 1];
|
|
2206
|
+ |
for (r, tracks) in bus_tracks.iter_mut().enumerate().take(max_rank) {
|
|
2207
|
+ |
let spans = bus_spans_td(graph, ranks, ¢ers, r, false);
|
|
2208
|
+ |
if spans.is_empty() {
|
|
2209
|
+ |
continue;
|
|
2210
|
+ |
}
|
|
2211
|
+ |
let (assigned, count) = assign_tracks(&spans);
|
|
2212
|
+ |
for (idx, slot) in assigned {
|
|
2213
|
+ |
edge_bus[idx] = slot;
|
|
2214
|
+ |
}
|
|
2215
|
+ |
*tracks = count;
|
|
2216
|
+ |
}
|
|
2217
|
+ |
|
|
2218
|
+ |
let rank_h: Vec<usize> = by_rank
|
|
2219
|
+ |
.iter()
|
|
2220
|
+ |
.map(|row| {
|
|
2221
|
+ |
row.iter()
|
|
2222
|
+ |
.map(|&i| sizes.box_h[i] + sizes.extra_h[i])
|
|
2223
|
+ |
.max()
|
|
2224
|
+ |
.unwrap_or(3)
|
|
2225
|
+ |
})
|
|
2226
|
+ |
.collect();
|
|
2227
|
+ |
let mut rank_y = vec![0usize; max_rank + 1];
|
|
2228
|
+ |
for r in 1..=max_rank {
|
|
2229
|
+ |
let gap = GAP_Y.max(bus_tracks[r - 1] + 1);
|
|
2230
|
+ |
rank_y[r] = rank_y[r - 1] + rank_h[r - 1] + gap;
|
|
2231
|
+ |
}
|
|
2232
|
+ |
let canvas_h = rank_y[max_rank] + rank_h[max_rank];
|
|
2233
|
+ |
let band_end: Vec<usize> = (0..=max_rank).map(|r| rank_y[r] + rank_h[r]).collect();
|
|
2234
|
+ |
|
|
2235
|
+ |
let mut diagram_w = 1;
|
|
2236
|
+ |
for (r, row) in by_rank.iter().enumerate() {
|
|
2237
|
+ |
for &idx in row {
|
|
2238
|
+ |
let w = sizes.box_w[idx];
|
|
2239
|
+ |
let h = sizes.box_h[idx];
|
|
2240
|
+ |
let cx = centers[idx];
|
|
2241
|
+ |
let x = cx.saturating_sub(w / 2);
|
|
2242
|
+ |
let y = rank_y[r] + (rank_h[r] - h - sizes.extra_h[idx]) / 2;
|
|
2243
|
+ |
placed[idx] = Placed {
|
|
2244
|
+ |
x,
|
|
2245
|
+ |
y,
|
|
2246
|
+ |
w,
|
|
2247
|
+ |
h,
|
|
2248
|
+ |
cx,
|
|
2249
|
+ |
cy: y + h / 2,
|
|
2250
|
+ |
rank: r,
|
|
2251
|
+ |
};
|
|
2252
|
+ |
diagram_w = diagram_w.max(x + w);
|
|
2253
|
+ |
if sizes.extra_h[idx] > 0 && sizes.self_label_w[idx] > 0 {
|
|
2254
|
+ |
diagram_w = diagram_w.max(x + w + 2 + sizes.self_label_w[idx]);
|
|
2255
|
+ |
}
|
|
2256
|
+ |
}
|
|
2257
|
+ |
}
|
|
2258
|
+ |
|
|
2259
|
+ |
let mut content_w = diagram_w;
|
|
2260
|
+ |
for e in &graph.edges {
|
|
2261
|
+ |
if e.from == e.to {
|
|
2262
|
+ |
continue;
|
|
2263
|
+ |
}
|
|
2264
|
+ |
if let Some(label) = &e.label {
|
|
2265
|
+ |
let lw = label.width().min(MAX_LABEL);
|
|
2266
|
+ |
if ranks[e.to] == ranks[e.from] + 1 {
|
|
2267
|
+ |
content_w = content_w.max(placed[e.to].cx + 2 + lw);
|
|
2268
|
+ |
} else {
|
|
2269
|
+ |
content_w = content_w.max(diagram_w + lw + 1);
|
|
2270
|
+ |
}
|
|
2271
|
+ |
}
|
|
2272
|
+ |
}
|
|
2273
|
+ |
|
|
2274
|
+ |
let mut edge_lane = vec![0usize; graph.edges.len()];
|
|
2275
|
+ |
let lanes = lane_spans(graph, ranks, placed, true);
|
|
2276
|
+ |
let (canvas_w, lane_base) = if lanes.is_empty() {
|
|
2277
|
+ |
(content_w, 0)
|
|
2278
|
+ |
} else {
|
|
2279
|
+ |
let (assigned, count) = assign_tracks(&lanes);
|
|
2280
|
+ |
for (idx, slot) in assigned {
|
|
2281
|
+ |
edge_lane[idx] = slot;
|
|
2282
|
+ |
}
|
|
2283
|
+ |
(content_w + 1 + count, content_w + 1)
|
|
2284
|
+ |
};
|
|
2285
|
+ |
|
|
2286
|
+ |
RoutePlan {
|
|
2287
|
+ |
canvas: (canvas_w, canvas_h),
|
|
2288
|
+ |
band_end,
|
|
2289
|
+ |
edge_bus,
|
|
2290
|
+ |
lane_base,
|
|
2291
|
+ |
edge_lane,
|
|
2292
|
+ |
}
|
|
2293
|
+ |
}
|
|
2294
|
+ |
|
|
2295
|
+ |
fn place_lr(
|
|
2296
|
+ |
ranks: &[usize],
|
|
2297
|
+ |
max_rank: usize,
|
|
2298
|
+ |
by_rank: &[Vec<usize>],
|
|
2299
|
+ |
sizes: &NodeSizes,
|
|
2300
|
+ |
graph: &Graph,
|
|
2301
|
+ |
placed: &mut [Placed],
|
|
2302
|
+ |
) -> RoutePlan {
|
|
2303
|
+ |
let col_w: Vec<usize> = by_rank
|
|
2304
|
+ |
.iter()
|
|
2305
|
+ |
.map(|row| row.iter().map(|&i| sizes.box_w[i]).max().unwrap_or(0))
|
|
2306
|
+ |
.collect();
|
|
2307
|
+ |
|
|
2308
|
+ |
let max_label = graph
|
|
2309
|
+ |
.edges
|
|
2310
|
+ |
.iter()
|
|
2311
|
+ |
.filter(|e| e.from == e.to || ranks[e.to] == ranks[e.from] + 1)
|
|
2312
|
+ |
.filter_map(|e| e.label.as_ref().map(|l| l.width().min(MAX_LABEL)))
|
|
2313
|
+ |
.max()
|
|
2314
|
+ |
.unwrap_or(0);
|
|
2315
|
+ |
let base_gap = (GAP_X + 1).max(max_label + 3);
|
|
2316
|
+ |
|
|
2317
|
+ |
let centers = assign_positions(by_rank, &sizes.lay_h, 1, &graph.edges, ranks);
|
|
2318
|
+ |
|
|
2319
|
+ |
let mut edge_bus = vec![0usize; graph.edges.len()];
|
|
2320
|
+ |
let mut bus_tracks = vec![0usize; max_rank + 1];
|
|
2321
|
+ |
for (r, tracks) in bus_tracks.iter_mut().enumerate().take(max_rank) {
|
|
2322
|
+ |
let spans = bus_spans_td(graph, ranks, ¢ers, r, true);
|
|
2323
|
+ |
if spans.is_empty() {
|
|
2324
|
+ |
continue;
|
|
2325
|
+ |
}
|
|
2326
|
+ |
let (assigned, count) = assign_tracks(&spans);
|
|
2327
|
+ |
for (idx, slot) in assigned {
|
|
2328
|
+ |
edge_bus[idx] = slot;
|
|
2329
|
+ |
}
|
|
2330
|
+ |
*tracks = count;
|
|
2331
|
+ |
}
|
|
2332
|
+ |
|
|
2333
|
+ |
let mut rank_x = vec![0usize; max_rank + 1];
|
|
2334
|
+ |
for r in 1..=max_rank {
|
|
2335
|
+ |
let gap = base_gap.max(bus_tracks[r - 1] + 1);
|
|
2336
|
+ |
rank_x[r] = rank_x[r - 1] + col_w[r - 1] + gap;
|
|
2337
|
+ |
}
|
|
2338
|
+ |
let canvas_w = rank_x[max_rank]
|
|
2339
|
+ |
+ col_w[max_rank]
|
|
2340
|
+ |
+ by_rank[max_rank]
|
|
2341
|
+ |
.iter()
|
|
2342
|
+ |
.filter(|&&i| sizes.extra_h[i] > 0 && sizes.self_label_w[i] > 0)
|
|
2343
|
+ |
.map(|&i| 2 + sizes.self_label_w[i])
|
|
2344
|
+ |
.max()
|
|
2345
|
+ |
.unwrap_or(0);
|
|
2346
|
+ |
let band_end: Vec<usize> = (0..=max_rank).map(|r| rank_x[r] + col_w[r]).collect();
|
|
2347
|
+ |
|
|
2348
|
+ |
let mut diagram_h = 1;
|
|
2349
|
+ |
for (r, row) in by_rank.iter().enumerate() {
|
|
2350
|
+ |
let x = rank_x[r];
|
|
2351
|
+ |
for &idx in row {
|
|
2352
|
+ |
let w = sizes.box_w[idx];
|
|
2353
|
+ |
let h = sizes.box_h[idx];
|
|
2354
|
+ |
let cy = centers[idx];
|
|
2355
|
+ |
let y = cy.saturating_sub((h + sizes.extra_h[idx]) / 2);
|
|
2356
|
+ |
placed[idx] = Placed {
|
|
2357
|
+ |
x,
|
|
2358
|
+ |
y,
|
|
2359
|
+ |
w,
|
|
2360
|
+ |
h,
|
|
2361
|
+ |
cx: x + w / 2,
|
|
2362
|
+ |
cy: y + h / 2,
|
|
2363
|
+ |
rank: r,
|
|
2364
|
+ |
};
|
|
2365
|
+ |
diagram_h = diagram_h.max(y + h + sizes.extra_h[idx]);
|
|
2366
|
+ |
}
|
|
2367
|
+ |
}
|
|
2368
|
+ |
|
|
2369
|
+ |
let mut edge_lane = vec![0usize; graph.edges.len()];
|
|
2370
|
+ |
let lanes = lane_spans(graph, ranks, placed, false);
|
|
2371
|
+ |
let (canvas_h, lane_base) = if lanes.is_empty() {
|
|
2372
|
+ |
(diagram_h, 0)
|
|
2373
|
+ |
} else {
|
|
2374
|
+ |
let (assigned, count) = assign_tracks(&lanes);
|
|
2375
|
+ |
for (idx, slot) in assigned {
|
|
2376
|
+ |
edge_lane[idx] = slot;
|
|
2377
|
+ |
}
|
|
2378
|
+ |
(diagram_h + 1 + count, diagram_h + 1)
|
|
2379
|
+ |
};
|
|
2380
|
+ |
|
|
2381
|
+ |
RoutePlan {
|
|
2382
|
+ |
canvas: (canvas_w, canvas_h),
|
|
2383
|
+ |
band_end,
|
|
2384
|
+ |
edge_bus,
|
|
2385
|
+ |
lane_base,
|
|
2386
|
+ |
edge_lane,
|
|
2387
|
+ |
}
|
|
2388
|
+ |
}
|
|
2389
|
+ |
|
|
2390
|
+ |
struct RoutePlan {
|
|
2391
|
+ |
canvas: (usize, usize),
|
|
2392
|
+ |
band_end: Vec<usize>,
|
|
2393
|
+ |
edge_bus: Vec<usize>,
|
|
2394
|
+ |
lane_base: usize,
|
|
2395
|
+ |
edge_lane: Vec<usize>,
|
|
2396
|
+ |
}
|
|
2397
|
+ |
|
|
2398
|
+ |
fn assign_tracks(spans: &[(usize, usize, usize, usize, usize)]) -> (Vec<(usize, usize)>, usize) {
|
|
2399
|
+ |
let mut sorted = spans.to_vec();
|
|
2400
|
+ |
sorted.sort_unstable();
|
|
2401
|
+ |
let mut tracks: Vec<Vec<(usize, usize, usize, usize)>> = Vec::new();
|
|
2402
|
+ |
let mut out = Vec::with_capacity(sorted.len());
|
|
2403
|
+ |
for &(s, e, f, t, idx) in &sorted {
|
|
2404
|
+ |
let compatible = |members: &Vec<(usize, usize, usize, usize)>| {
|
|
2405
|
+ |
members
|
|
2406
|
+ |
.iter()
|
|
2407
|
+ |
.all(|&(s2, e2, f2, t2)| e2 + 2 <= s || e + 2 <= s2 || f2 == f || t2 == t)
|
|
2408
|
+ |
};
|
|
2409
|
+ |
let slot = match tracks.iter().position(compatible) {
|
|
2410
|
+ |
Some(x) => x,
|
|
2411
|
+ |
None => {
|
|
2412
|
+ |
tracks.push(Vec::new());
|
|
2413
|
+ |
tracks.len() - 1
|
|
2414
|
+ |
}
|
|
2415
|
+ |
};
|
|
2416
|
+ |
tracks[slot].push((s, e, f, t));
|
|
2417
|
+ |
out.push((idx, slot));
|
|
2418
|
+ |
}
|
|
2419
|
+ |
(out, tracks.len())
|
|
2420
|
+ |
}
|
|
2421
|
+ |
|
|
2422
|
+ |
/// Reorder nodes within each rank to minimize edge crossings (Sugiyama-style
|
|
2423
|
+ |
/// barycenter sweeps): alternate down/up passes sort each rank by the mean
|
|
2424
|
+ |
/// position of its forward neighbours, keeping the ordering with the fewest
|
|
2425
|
+ |
/// crossings between adjacent ranks.
|
|
2426
|
+ |
fn order_ranks(by_rank: &mut [Vec<usize>], edges: &[Edge], ranks: &[usize]) {
|
|
2427
|
+ |
let n = ranks.len();
|
|
2428
|
+ |
if by_rank.len() < 2 || n < 3 {
|
|
2429
|
+ |
return;
|
|
2430
|
+ |
}
|
|
2431
|
+ |
let mut parents: Vec<Vec<usize>> = vec![Vec::new(); n];
|
|
2432
|
+ |
let mut children: Vec<Vec<usize>> = vec![Vec::new(); n];
|
|
2433
|
+ |
for e in edges {
|
|
2434
|
+ |
if e.from != e.to && ranks[e.to] > ranks[e.from] {
|
|
2435
|
+ |
parents[e.to].push(e.from);
|
|
2436
|
+ |
children[e.from].push(e.to);
|
|
2437
|
+ |
}
|
|
2438
|
+ |
}
|
|
2439
|
+ |
|
|
2440
|
+ |
let mut pos = vec![0usize; n];
|
|
2441
|
+ |
let set_pos = |by_rank: &[Vec<usize>], pos: &mut Vec<usize>| {
|
|
2442
|
+ |
for row in by_rank {
|
|
2443
|
+ |
for (i, &v) in row.iter().enumerate() {
|
|
2444
|
+ |
pos[v] = i;
|
|
2445
|
+ |
}
|
|
2446
|
+ |
}
|
|
2447
|
+ |
};
|
|
2448
|
+ |
set_pos(by_rank, &mut pos);
|
|
2449
|
+ |
|
|
2450
|
+ |
let mut best: Vec<Vec<usize>> = by_rank.to_vec();
|
|
2451
|
+ |
let mut best_crossings = count_crossings(edges, ranks, &pos);
|
|
2452
|
+ |
if best_crossings == 0 {
|
|
2453
|
+ |
return;
|
|
2454
|
+ |
}
|
|
2455
|
+ |
|
|
2456
|
+ |
for it in 0..8 {
|
|
2457
|
+ |
if it % 2 == 0 {
|
|
2458
|
+ |
for row in by_rank.iter_mut().skip(1) {
|
|
2459
|
+ |
sort_by_barycenter(row, &parents, &pos);
|
|
2460
|
+ |
for (i, &v) in row.iter().enumerate() {
|
|
2461
|
+ |
pos[v] = i;
|
|
2462
|
+ |
}
|
|
2463
|
+ |
}
|
|
2464
|
+ |
} else {
|
|
2465
|
+ |
let last = by_rank.len() - 1;
|
|
2466
|
+ |
for row in by_rank[..last].iter_mut().rev() {
|
|
2467
|
+ |
sort_by_barycenter(row, &children, &pos);
|
|
2468
|
+ |
for (i, &v) in row.iter().enumerate() {
|
|
2469
|
+ |
pos[v] = i;
|
|
2470
|
+ |
}
|
|
2471
|
+ |
}
|
|
2472
|
+ |
}
|
|
2473
|
+ |
let crossings = count_crossings(edges, ranks, &pos);
|
|
2474
|
+ |
if crossings < best_crossings {
|
|
2475
|
+ |
best_crossings = crossings;
|
|
2476
|
+ |
best = by_rank.to_vec();
|
|
2477
|
+ |
}
|
|
2478
|
+ |
if best_crossings == 0 {
|
|
2479
|
+ |
break;
|
|
2480
|
+ |
}
|
|
2481
|
+ |
}
|
|
2482
|
+ |
|
|
2483
|
+ |
for (row, b) in by_rank.iter_mut().zip(best) {
|
|
2484
|
+ |
*row = b;
|
|
2485
|
+ |
}
|
|
2486
|
+ |
}
|
|
2487
|
+ |
|
|
2488
|
+ |
fn sort_by_barycenter(row: &mut [usize], neigh: &[Vec<usize>], pos: &[usize]) {
|
|
2489
|
+ |
let mut keyed: Vec<(f64, usize)> = row
|
|
2490
|
+ |
.iter()
|
|
2491
|
+ |
.map(|&v| {
|
|
2492
|
+ |
let key = if neigh[v].is_empty() {
|
|
2493
|
+ |
pos[v] as f64
|
|
2494
|
+ |
} else {
|
|
2495
|
+ |
neigh[v].iter().map(|&u| pos[u] as f64).sum::<f64>() / neigh[v].len() as f64
|
|
2496
|
+ |
};
|
|
2497
|
+ |
(key, v)
|
|
2498
|
+ |
})
|
|
2499
|
+ |
.collect();
|
|
2500
|
+ |
keyed.sort_by(|a, b| a.0.total_cmp(&b.0));
|
|
2501
|
+ |
for (slot, (_, v)) in row.iter_mut().zip(keyed) {
|
|
2502
|
+ |
*slot = v;
|
|
2503
|
+ |
}
|
|
2504
|
+ |
}
|
|
2505
|
+ |
|
|
2506
|
+ |
fn count_crossings(edges: &[Edge], ranks: &[usize], pos: &[usize]) -> usize {
|
|
2507
|
+ |
let adjacent: Vec<(usize, usize, usize)> = edges
|
|
2508
|
+ |
.iter()
|
|
2509
|
+ |
.filter(|e| e.from != e.to && ranks[e.to] == ranks[e.from] + 1)
|
|
2510
|
+ |
.map(|e| (ranks[e.from], pos[e.from], pos[e.to]))
|
|
2511
|
+ |
.collect();
|
|
2512
|
+ |
let mut crossings = 0;
|
|
2513
|
+ |
for (i, a) in adjacent.iter().enumerate() {
|
|
2514
|
+ |
for b in &adjacent[i + 1..] {
|
|
2515
|
+ |
if a.0 == b.0 && ((a.1 < b.1 && a.2 > b.2) || (a.1 > b.1 && a.2 < b.2)) {
|
|
2516
|
+ |
crossings += 1;
|
|
2517
|
+ |
}
|
|
2518
|
+ |
}
|
|
2519
|
+ |
}
|
|
2520
|
+ |
crossings
|
|
2521
|
+ |
}
|
|
2522
|
+ |
|
|
2523
|
+ |
/// Assign a center coordinate (along the cross-axis) to every node so nodes line
|
|
2524
|
+ |
/// up under their neighbours. Iterative barycenter relaxation: each node drifts
|
|
2525
|
+ |
/// toward the average of its forward neighbours while ranks keep order and a
|
|
2526
|
+ |
/// minimum `sep` between boxes, which straightens chains and centers branches.
|
|
2527
|
+ |
fn assign_positions(
|
|
2528
|
+ |
by_rank: &[Vec<usize>],
|
|
2529
|
+ |
size: &[usize],
|
|
2530
|
+ |
sep: usize,
|
|
2531
|
+ |
edges: &[Edge],
|
|
2532
|
+ |
ranks: &[usize],
|
|
2533
|
+ |
) -> Vec<usize> {
|
|
2534
|
+ |
let n = size.len();
|
|
2535
|
+ |
let mut parents: Vec<Vec<usize>> = vec![Vec::new(); n];
|
|
2536
|
+ |
let mut children: Vec<Vec<usize>> = vec![Vec::new(); n];
|
|
2537
|
+ |
for e in edges {
|
|
2538
|
+ |
if e.from != e.to && ranks[e.to] > ranks[e.from] {
|
|
2539
|
+ |
parents[e.to].push(e.from);
|
|
2540
|
+ |
children[e.from].push(e.to);
|
|
2541
|
+ |
}
|
|
2542
|
+ |
}
|
|
2543
|
+ |
|
|
2544
|
+ |
let mut pos = vec![0f64; n];
|
|
2545
|
+ |
for row in by_rank {
|
|
2546
|
+ |
let mut x = 0f64;
|
|
2547
|
+ |
for &v in row {
|
|
2548
|
+ |
let half = size[v] as f64 / 2.0;
|
|
2549
|
+ |
x += half;
|
|
2550
|
+ |
pos[v] = x;
|
|
2551
|
+ |
x += half + sep as f64;
|
|
2552
|
+ |
}
|
|
2553
|
+ |
}
|
|
2554
|
+ |
|
|
2555
|
+ |
for it in 0..10 {
|
|
2556
|
+ |
if it % 2 == 0 {
|
|
2557
|
+ |
for row in by_rank.iter() {
|
|
2558
|
+ |
relax_rank(row, &parents, &mut pos, size, sep);
|
|
2559
|
+ |
}
|
|
2560
|
+ |
} else {
|
|
2561
|
+ |
for row in by_rank.iter().rev() {
|
|
2562
|
+ |
relax_rank(row, &children, &mut pos, size, sep);
|
|
2563
|
+ |
}
|
|
2564
|
+ |
}
|
|
2565
|
+ |
}
|
|
2566
|
+ |
|
|
2567
|
+ |
let min_left = (0..n)
|
|
2568
|
+ |
.map(|v| pos[v] - size[v] as f64 / 2.0)
|
|
2569
|
+ |
.fold(f64::INFINITY, f64::min);
|
|
2570
|
+ |
let min_left = if min_left.is_finite() { min_left } else { 0.0 };
|
|
2571
|
+ |
(0..n)
|
|
2572
|
+ |
.map(|v| (pos[v] - min_left).round().max(0.0) as usize)
|
|
2573
|
+ |
.collect()
|
|
2574
|
+ |
}
|
|
2575
|
+ |
|
|
2576
|
+ |
fn relax_rank(nodes: &[usize], neigh: &[Vec<usize>], pos: &mut [f64], size: &[usize], sep: usize) {
|
|
2577
|
+ |
let n = nodes.len();
|
|
2578
|
+ |
if n == 0 {
|
|
2579
|
+ |
return;
|
|
2580
|
+ |
}
|
|
2581
|
+ |
let desired: Vec<f64> = nodes
|
|
2582
|
+ |
.iter()
|
|
2583
|
+ |
.map(|&v| {
|
|
2584
|
+ |
if neigh[v].is_empty() {
|
|
2585
|
+ |
pos[v]
|
|
2586
|
+ |
} else {
|
|
2587
|
+ |
neigh[v].iter().map(|&u| pos[u]).sum::<f64>() / neigh[v].len() as f64
|
|
2588
|
+ |
}
|
|
2589
|
+ |
})
|
|
2590
|
+ |
.collect();
|
|
2591
|
+ |
|
|
2592
|
+ |
let half = |i: usize| size[nodes[i]] as f64 / 2.0;
|
|
2593
|
+ |
let mut left = vec![0f64; n];
|
|
2594
|
+ |
let mut right = vec![0f64; n];
|
|
2595
|
+ |
for i in 0..n {
|
|
2596
|
+ |
left[i] = if i == 0 {
|
|
2597
|
+ |
desired[i]
|
|
2598
|
+ |
} else {
|
|
2599
|
+ |
desired[i].max(left[i - 1] + half(i - 1) + sep as f64 + half(i))
|
|
2600
|
+ |
};
|
|
2601
|
+ |
}
|
|
2602
|
+ |
for i in (0..n).rev() {
|
|
2603
|
+ |
right[i] = if i == n - 1 {
|
|
2604
|
+ |
desired[i]
|
|
2605
|
+ |
} else {
|
|
2606
|
+ |
desired[i].min(right[i + 1] - half(i + 1) - sep as f64 - half(i))
|
|
2607
|
+ |
};
|
|
2608
|
+ |
}
|
|
2609
|
+ |
for i in 0..n {
|
|
2610
|
+ |
pos[nodes[i]] = (left[i] + right[i]) / 2.0;
|
|
2611
|
+ |
}
|
|
2612
|
+ |
for i in 1..n {
|
|
2613
|
+ |
let min_p = pos[nodes[i - 1]] + half(i - 1) + sep as f64 + half(i);
|
|
2614
|
+ |
if pos[nodes[i]] < min_p {
|
|
2615
|
+ |
pos[nodes[i]] = min_p;
|
|
2616
|
+ |
}
|
|
2617
|
+ |
}
|
|
2618
|
+ |
}
|
|
2619
|
+ |
|
|
2620
|
+ |
fn wrap_label(label: &str, width: usize, max_lines: usize) -> Vec<String> {
|
|
2621
|
+ |
let width = width.max(1);
|
|
2622
|
+ |
let char_w = |c: char| char_width(c).max(1);
|
|
2623
|
+ |
let mut lines: Vec<String> = Vec::new();
|
|
2624
|
+ |
let mut cur = String::new();
|
|
2625
|
+ |
let mut cur_w = 0usize;
|
|
2626
|
+ |
for word in label.split_whitespace() {
|
|
2627
|
+ |
let ww = word.width();
|
|
2628
|
+ |
if ww > width {
|
|
2629
|
+ |
if !cur.is_empty() {
|
|
2630
|
+ |
lines.push(std::mem::take(&mut cur));
|
|
2631
|
+ |
}
|
|
2632
|
+ |
let mut chunk = String::new();
|
|
2633
|
+ |
let mut chunk_w = 0usize;
|
|
2634
|
+ |
for ch in word.chars() {
|
|
2635
|
+ |
let cw = char_w(ch);
|
|
2636
|
+ |
if chunk_w + cw > width && !chunk.is_empty() {
|
|
2637
|
+ |
// Prefer breaking after the last identifier boundary so a long
|
|
2638
|
+ |
// token is not sliced mid-segment; fall back to a per-char break.
|
|
2639
|
+ |
let carry = match chunk.rfind(LABEL_BREAK_CHARS) {
|
|
2640
|
+ |
Some(p) => chunk.split_off(p + 1),
|
|
2641
|
+ |
None => String::new(),
|
|
2642
|
+ |
};
|
|
2643
|
+ |
lines.push(std::mem::take(&mut chunk));
|
|
2644
|
+ |
chunk_w = carry.chars().map(char_w).sum();
|
|
2645
|
+ |
chunk = carry;
|
|
2646
|
+ |
}
|
|
2647
|
+ |
chunk.push(ch);
|
|
2648
|
+ |
chunk_w += cw;
|
|
2649
|
+ |
}
|
|
2650
|
+ |
cur = chunk;
|
|
2651
|
+ |
cur_w = chunk_w;
|
|
2652
|
+ |
} else if cur.is_empty() {
|
|
2653
|
+ |
cur.push_str(word);
|
|
2654
|
+ |
cur_w = ww;
|
|
2655
|
+ |
} else if cur_w + 1 + ww <= width {
|
|
2656
|
+ |
cur.push(' ');
|
|
2657
|
+ |
cur.push_str(word);
|
|
2658
|
+ |
cur_w += 1 + ww;
|
|
2659
|
+ |
} else {
|
|
2660
|
+ |
lines.push(std::mem::take(&mut cur));
|
|
2661
|
+ |
cur.push_str(word);
|
|
2662
|
+ |
cur_w = ww;
|
|
2663
|
+ |
}
|
|
2664
|
+ |
}
|
|
2665
|
+ |
if !cur.is_empty() {
|
|
2666
|
+ |
lines.push(cur);
|
|
2667
|
+ |
}
|
|
2668
|
+ |
if lines.is_empty() {
|
|
2669
|
+ |
lines.push(String::new());
|
|
2670
|
+ |
}
|
|
2671
|
+ |
if lines.len() > max_lines {
|
|
2672
|
+ |
lines.truncate(max_lines);
|
|
2673
|
+ |
if let Some(last) = lines.last_mut() {
|
|
2674
|
+ |
let target = width.saturating_sub(1).max(1);
|
|
2675
|
+ |
let mut s = String::new();
|
|
2676
|
+ |
let mut sw = 0usize;
|
|
2677
|
+ |
for ch in last.chars() {
|
|
2678
|
+ |
let cw = char_w(ch);
|
|
2679
|
+ |
if sw + cw > target {
|
|
2680
|
+ |
break;
|
|
2681
|
+ |
}
|
|
2682
|
+ |
s.push(ch);
|
|
2683
|
+ |
sw += cw;
|
|
2684
|
+ |
}
|
|
2685
|
+ |
s.push('…');
|
|
2686
|
+ |
*last = s;
|
|
2687
|
+ |
}
|
|
2688
|
+ |
}
|
|
2689
|
+ |
lines
|
|
2690
|
+ |
}
|
|
2691
|
+ |
|
|
2692
|
+ |
fn fit_label(label: &str, inner: usize) -> String {
|
|
2693
|
+ |
if label.width() <= inner {
|
|
2694
|
+ |
return label.to_string();
|
|
2695
|
+ |
}
|
|
2696
|
+ |
let mut out = String::new();
|
|
2697
|
+ |
let mut used = 0usize;
|
|
2698
|
+ |
for c in label.chars() {
|
|
2699
|
+ |
let cw = char_width(c);
|
|
2700
|
+ |
if used + cw + 1 > inner {
|
|
2701
|
+ |
break;
|
|
2702
|
+ |
}
|
|
2703
|
+ |
out.push(c);
|
|
2704
|
+ |
used += cw;
|
|
2705
|
+ |
}
|
|
2706
|
+ |
out.push('…');
|
|
2707
|
+ |
out
|
|
2708
|
+ |
}
|
|
2709
|
+ |
|
|
2710
|
+ |
fn draw_box(canvas: &mut Canvas, p: &Placed, lines: &[String], shape: Shape) {
|
|
2711
|
+ |
let (x, y, w, h) = (p.x, p.y, p.w, p.h);
|
|
2712
|
+ |
let right = x + w - 1;
|
|
2713
|
+ |
let bottom = y + h - 1;
|
|
2714
|
+ |
|
|
2715
|
+ |
let (tl, tr, bl, br) = match shape {
|
|
2716
|
+ |
Shape::Round | Shape::Diamond => ('╭', '╮', '╰', '╯'),
|
|
2717
|
+ |
Shape::Rect => ('┌', '┐', '└', '┘'),
|
|
2718
|
+ |
};
|
|
2719
|
+ |
canvas.set(x, y, tl, Cls::Border);
|
|
2720
|
+ |
canvas.set(right, y, tr, Cls::Border);
|
|
2721
|
+ |
canvas.set(x, bottom, bl, Cls::Border);
|
|
2722
|
+ |
canvas.set(right, bottom, br, Cls::Border);
|
|
2723
|
+ |
|
|
2724
|
+ |
for cx in (x + 1)..right {
|
|
2725
|
+ |
canvas.add_bits(cx, y, L | R);
|
|
2726
|
+ |
canvas.add_bits(cx, bottom, L | R);
|
|
2727
|
+ |
}
|
|
2728
|
+ |
for cy in (y + 1)..bottom {
|
|
2729
|
+ |
canvas.add_bits(x, cy, U | D);
|
|
2730
|
+ |
canvas.add_bits(right, cy, U | D);
|
|
2731
|
+ |
}
|
|
2732
|
+ |
|
|
2733
|
+ |
for cy in y..=bottom {
|
|
2734
|
+ |
for cx in x..=right {
|
|
2735
|
+ |
let i = canvas.idx(cx, cy);
|
|
2736
|
+ |
canvas.occupied[i] = true;
|
|
2737
|
+ |
}
|
|
2738
|
+ |
}
|
|
2739
|
+ |
|
|
2740
|
+ |
let inner = w.saturating_sub(2 * PAD + 2).max(1);
|
|
2741
|
+ |
for (li, line) in lines.iter().enumerate() {
|
|
2742
|
+ |
let row = y + 1 + li;
|
|
2743
|
+ |
let text = fit_label(line, inner);
|
|
2744
|
+ |
let tw = text.width();
|
|
2745
|
+ |
let text_x = x + 1 + PAD + inner.saturating_sub(tw) / 2;
|
|
2746
|
+ |
let mut cur = text_x;
|
|
2747
|
+ |
for c in text.chars() {
|
|
2748
|
+ |
let cw = char_width(c).max(1);
|
|
2749
|
+ |
canvas.set(cur, row, c, Cls::Text);
|
|
2750
|
+ |
// Wide glyphs (CJK, emoji) own a second column; mark it as a
|
|
2751
|
+ |
// continuation so the line builder doesn't emit a stray space.
|
|
2752
|
+ |
for k in 1..cw {
|
|
2753
|
+ |
canvas.set(cur + k, row, CONT, Cls::Text);
|
|
2754
|
+ |
}
|
|
2755
|
+ |
cur += cw;
|
|
2756
|
+ |
}
|
|
2757
|
+ |
}
|
|
2758
|
+ |
}
|
|
2759
|
+ |
|
|
2760
|
+ |
fn route_forward(canvas: &mut Canvas, from: &Placed, to: &Placed, edge: &Edge, bus: usize) {
|
|
2761
|
+ |
let tx = to.cx;
|
|
2762
|
+ |
let bx = if from.cx.abs_diff(tx) <= 1 {
|
|
2763
|
+ |
tx
|
|
2764
|
+ |
} else {
|
|
2765
|
+ |
from.cx
|
|
2766
|
+ |
};
|
|
2767
|
+ |
let by = from.y + from.h - 1;
|
|
2768
|
+ |
let head_row = to.y - 1;
|
|
2769
|
+ |
|
|
2770
|
+ |
canvas.junction(bx, by, D);
|
|
2771
|
+ |
canvas.seg_v(bx, by, bus);
|
|
2772
|
+ |
if bx == tx {
|
|
2773
|
+ |
canvas.seg_v(bx, bus, head_row);
|
|
2774
|
+ |
} else {
|
|
2775
|
+ |
canvas.seg_h(bus, bx, tx);
|
|
2776
|
+ |
canvas.seg_v(tx, bus, head_row);
|
|
2777
|
+ |
}
|
|
2778
|
+ |
|
|
2779
|
+ |
if edge.head_to == Head::None {
|
|
2780
|
+ |
canvas.add_bits(tx, head_row, U);
|
|
2781
|
+ |
} else {
|
|
2782
|
+ |
canvas.set(tx, head_row, head_glyph(edge.head_to, '▼'), Cls::Edge);
|
|
2783
|
+ |
}
|
|
2784
|
+ |
if edge.head_from != Head::None {
|
|
2785
|
+ |
canvas.set(bx, by, head_glyph(edge.head_from, '▲'), Cls::Edge);
|
|
2786
|
+ |
}
|
|
2787
|
+ |
|
|
2788
|
+ |
if let Some(label) = &edge.label {
|
|
2789
|
+ |
place_label(canvas, label, head_row, tx + 1);
|
|
2790
|
+ |
}
|
|
2791
|
+ |
}
|
|
2792
|
+ |
|
|
2793
|
+ |
fn head_glyph(head: Head, arrow: char) -> char {
|
|
2794
|
+ |
match head {
|
|
2795
|
+ |
Head::Circle => 'o',
|
|
2796
|
+ |
Head::Cross => '×',
|
|
2797
|
+ |
Head::DiamondFill => '◆',
|
|
2798
|
+ |
Head::DiamondOpen => '◇',
|
|
2799
|
+ |
Head::Triangle => match arrow {
|
|
2800
|
+ |
'▼' => '▽',
|
|
2801
|
+ |
'▲' => '△',
|
|
2802
|
+ |
'◄' => '◁',
|
|
2803
|
+ |
'▶' => '▷',
|
|
2804
|
+ |
other => other,
|
|
2805
|
+ |
},
|
|
2806
|
+ |
_ => arrow,
|
|
2807
|
+ |
}
|
|
2808
|
+ |
}
|
|
2809
|
+ |
|
|
2810
|
+ |
fn route_self(canvas: &mut Canvas, p: &Placed, edge: &Edge) {
|
|
2811
|
+ |
let bottom = p.y + p.h - 1;
|
|
2812
|
+ |
let exit_x = p.cx + 1;
|
|
2813
|
+ |
let ret_x = p.x + p.w - 2;
|
|
2814
|
+ |
if ret_x <= exit_x || bottom + 2 >= canvas.h {
|
|
2815
|
+ |
return;
|
|
2816
|
+ |
}
|
|
2817
|
+ |
let (v, h, bl, br) = match edge.line {
|
|
2818
|
+ |
LineKind::Dotted => ('╎', '╌', '╰', '╯'),
|
|
2819
|
+ |
LineKind::Thick => ('┃', '━', '┗', '┛'),
|
|
2820
|
+ |
LineKind::Solid => ('│', '─', '╰', '╯'),
|
|
2821
|
+ |
};
|
|
2822
|
+ |
canvas.junction(exit_x, bottom, D);
|
|
2823
|
+ |
canvas.set(exit_x, bottom + 1, v, Cls::Edge);
|
|
2824
|
+ |
canvas.set(exit_x, bottom + 2, bl, Cls::Edge);
|
|
2825
|
+ |
for x in (exit_x + 1)..ret_x {
|
|
2826
|
+ |
canvas.set(x, bottom + 2, h, Cls::Edge);
|
|
2827
|
+ |
}
|
|
2828
|
+ |
canvas.set(ret_x, bottom + 2, br, Cls::Edge);
|
|
2829
|
+ |
canvas.set(ret_x, bottom + 1, head_glyph(edge.head_to, '▲'), Cls::Edge);
|
|
2830
|
+ |
if let Some(label) = &edge.label {
|
|
2831
|
+ |
place_label(canvas, label, bottom + 1, p.x + p.w + 1);
|
|
2832
|
+ |
}
|
|
2833
|
+ |
}
|
|
2834
|
+ |
|
|
2835
|
+ |
fn route_back(canvas: &mut Canvas, from: &Placed, to: &Placed, edge: &Edge, lane_x: usize) {
|
|
2836
|
+ |
let sx = from.x + from.w - 1;
|
|
2837
|
+ |
let sy = from.cy;
|
|
2838
|
+ |
let tx = to.x + to.w - 1;
|
|
2839
|
+ |
let tyc = to.cy;
|
|
2840
|
+ |
|
|
2841
|
+ |
canvas.junction(sx, sy, R);
|
|
2842
|
+ |
canvas.seg_h(sy, sx, lane_x);
|
|
2843
|
+ |
canvas.seg_v(lane_x, sy, tyc);
|
|
2844
|
+ |
canvas.seg_h(tyc, tx + 1, lane_x);
|
|
2845
|
+ |
|
|
2846
|
+ |
if edge.head_to == Head::None {
|
|
2847
|
+ |
canvas.add_bits(tx + 1, tyc, R);
|
|
2848
|
+ |
} else {
|
|
2849
|
+ |
canvas.set(tx + 1, tyc, head_glyph(edge.head_to, '◄'), Cls::Edge);
|
|
2850
|
+ |
}
|
|
2851
|
+ |
if edge.head_from != Head::None {
|
|
2852
|
+ |
canvas.set(sx, sy, head_glyph(edge.head_from, '◄'), Cls::Edge);
|
|
2853
|
+ |
}
|
|
2854
|
+ |
|
|
2855
|
+ |
if let Some(label) = &edge.label {
|
|
2856
|
+ |
place_label(
|
|
2857
|
+ |
canvas,
|
|
2858
|
+ |
label,
|
|
2859
|
+ |
tyc.saturating_sub(1),
|
|
2860
|
+ |
lane_x.saturating_sub(label.width() + 1),
|
|
2861
|
+ |
);
|
|
2862
|
+ |
}
|
|
2863
|
+ |
}
|
|
2864
|
+ |
|
|
2865
|
+ |
fn route_forward_lr(canvas: &mut Canvas, from: &Placed, to: &Placed, edge: &Edge, bus: usize) {
|
|
2866
|
+ |
let rx = from.x + from.w - 1;
|
|
2867
|
+ |
let ry = from.cy;
|
|
2868
|
+ |
let ly = to.cy;
|
|
2869
|
+ |
let head_col = to.x - 1;
|
|
2870
|
+ |
|
|
2871
|
+ |
canvas.junction(rx, ry, R);
|
|
2872
|
+ |
canvas.seg_h(ry, rx, bus);
|
|
2873
|
+ |
if ry == ly {
|
|
2874
|
+ |
canvas.seg_h(ry, bus, head_col);
|
|
2875
|
+ |
} else {
|
|
2876
|
+ |
canvas.seg_v(bus, ry, ly);
|
|
2877
|
+ |
canvas.seg_h(ly, bus, head_col);
|
|
2878
|
+ |
}
|
|
2879
|
+ |
|
|
2880
|
+ |
if edge.head_to == Head::None {
|
|
2881
|
+ |
canvas.add_bits(head_col, ly, R);
|
|
2882
|
+ |
} else {
|
|
2883
|
+ |
canvas.set(head_col, ly, head_glyph(edge.head_to, '▶'), Cls::Edge);
|
|
2884
|
+ |
}
|
|
2885
|
+ |
if edge.head_from != Head::None {
|
|
2886
|
+ |
canvas.set(rx, ry, head_glyph(edge.head_from, '◄'), Cls::Edge);
|
|
2887
|
+ |
}
|
|
2888
|
+ |
|
|
2889
|
+ |
if let Some(label) = &edge.label {
|
|
2890
|
+ |
place_label(canvas, label, ly.saturating_sub(1), bus + 1);
|
|
2891
|
+ |
}
|
|
2892
|
+ |
}
|
|
2893
|
+ |
|
|
2894
|
+ |
fn route_back_lr(canvas: &mut Canvas, from: &Placed, to: &Placed, edge: &Edge, lane_y: usize) {
|
|
2895
|
+ |
let sx = from.cx;
|
|
2896
|
+ |
let sy = from.y + from.h - 1;
|
|
2897
|
+ |
let tx = to.cx;
|
|
2898
|
+ |
let ty = to.y + to.h - 1;
|
|
2899
|
+ |
|
|
2900
|
+ |
canvas.junction(sx, sy, D);
|
|
2901
|
+ |
canvas.seg_v(sx, sy, lane_y);
|
|
2902
|
+ |
canvas.seg_h(lane_y, sx, tx);
|
|
2903
|
+ |
canvas.seg_v(tx, lane_y, ty + 1);
|
|
2904
|
+ |
|
|
2905
|
+ |
if edge.head_to == Head::None {
|
|
2906
|
+ |
canvas.add_bits(tx, ty + 1, D);
|
|
2907
|
+ |
} else {
|
|
2908
|
+ |
canvas.set(tx, ty + 1, head_glyph(edge.head_to, '▲'), Cls::Edge);
|
|
2909
|
+ |
}
|
|
2910
|
+ |
if edge.head_from != Head::None {
|
|
2911
|
+ |
canvas.set(sx, sy, head_glyph(edge.head_from, '▲'), Cls::Edge);
|
|
2912
|
+ |
}
|
|
2913
|
+ |
|
|
2914
|
+ |
if let Some(label) = &edge.label {
|
|
2915
|
+ |
place_label(canvas, label, lane_y.saturating_sub(1), (sx + tx) / 2);
|
|
2916
|
+ |
}
|
|
2917
|
+ |
}
|
|
2918
|
+ |
|
|
2919
|
+ |
fn place_label(canvas: &mut Canvas, label: &str, row: usize, start_x: usize) {
|
|
2920
|
+ |
if row >= canvas.h {
|
|
2921
|
+ |
return;
|
|
2922
|
+ |
}
|
|
2923
|
+ |
let text = fit_label(label, MAX_LABEL);
|
|
2924
|
+ |
let mut x = start_x;
|
|
2925
|
+ |
for c in text.chars() {
|
|
2926
|
+ |
let cw = char_width(c).max(1);
|
|
2927
|
+ |
if x + cw > canvas.w {
|
|
2928
|
+ |
break;
|
|
2929
|
+ |
}
|
|
2930
|
+ |
let blocked = (0..cw).any(|k| {
|
|
2931
|
+ |
let i = canvas.idx(x + k, row);
|
|
2932
|
+ |
canvas.ch[i] != ' ' || canvas.mask[i] != 0 || canvas.occupied[i]
|
|
2933
|
+ |
});
|
|
2934
|
+ |
if blocked {
|
|
2935
|
+ |
break;
|
|
2936
|
+ |
}
|
|
2937
|
+ |
canvas.set(x, row, c, Cls::EdgeLabel);
|
|
2938
|
+ |
for k in 1..cw {
|
|
2939
|
+ |
canvas.set(x + k, row, CONT, Cls::EdgeLabel);
|
|
2940
|
+ |
}
|
|
2941
|
+ |
x += cw;
|
|
2942
|
+ |
}
|
|
2943
|
+ |
}
|
|
2944
|
+ |
|
|
2945
|
+ |
fn compute_ranks(graph: &Graph) -> Vec<usize> {
|
|
2946
|
+ |
let n = graph.nodes.len();
|
|
2947
|
+ |
let mut children: Vec<Vec<usize>> = vec![Vec::new(); n];
|
|
2948
|
+ |
let mut indeg = vec![0usize; n];
|
|
2949
|
+ |
for e in &graph.edges {
|
|
2950
|
+ |
if e.from != e.to {
|
|
2951
|
+ |
children[e.from].push(e.to);
|
|
2952
|
+ |
indeg[e.to] += 1;
|
|
2953
|
+ |
}
|
|
2954
|
+ |
}
|
|
2955
|
+ |
|
|
2956
|
+ |
let mut color = vec![0u8; n];
|
|
2957
|
+ |
let mut dag: Vec<Vec<usize>> = vec![Vec::new(); n];
|
|
2958
|
+ |
let mut order: Vec<usize> = Vec::with_capacity(n);
|
|
2959
|
+ |
|
|
2960
|
+ |
let roots: Vec<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
|
|
2961
|
+ |
for start in roots.iter().copied().chain(0..n) {
|
|
2962
|
+ |
if color[start] == 0 {
|
|
2963
|
+ |
dfs_dag(start, &children, &mut color, &mut dag, &mut order);
|
|
2964
|
+ |
}
|
|
2965
|
+ |
}
|
|
2966
|
+ |
|
|
2967
|
+ |
let mut rank = vec![0usize; n];
|
|
2968
|
+ |
for &u in order.iter().rev() {
|
|
2969
|
+ |
for &v in &dag[u] {
|
|
2970
|
+ |
rank[v] = rank[v].max(rank[u] + 1);
|
|
2971
|
+ |
}
|
|
2972
|
+ |
}
|
|
2973
|
+ |
rank
|
|
2974
|
+ |
}
|
|
2975
|
+ |
|
|
2976
|
+ |
fn dfs_dag(
|
|
2977
|
+ |
start: usize,
|
|
2978
|
+ |
children: &[Vec<usize>],
|
|
2979
|
+ |
color: &mut [u8],
|
|
2980
|
+ |
dag: &mut [Vec<usize>],
|
|
2981
|
+ |
order: &mut Vec<usize>,
|
|
2982
|
+ |
) {
|
|
2983
|
+ |
let mut stack: Vec<(usize, usize)> = vec![(start, 0)];
|
|
2984
|
+ |
color[start] = 1;
|
|
2985
|
+ |
while let Some(frame) = stack.last_mut() {
|
|
2986
|
+ |
let u = frame.0;
|
|
2987
|
+ |
if frame.1 < children[u].len() {
|
|
2988
|
+ |
let v = children[u][frame.1];
|
|
2989
|
+ |
frame.1 += 1;
|
|
2990
|
+ |
if color[v] == 1 {
|
|
2991
|
+ |
continue;
|
|
2992
|
+ |
}
|
|
2993
|
+ |
dag[u].push(v);
|
|
2994
|
+ |
if color[v] == 0 {
|
|
2995
|
+ |
color[v] = 1;
|
|
2996
|
+ |
stack.push((v, 0));
|
|
2997
|
+ |
}
|
|
2998
|
+ |
} else {
|
|
2999
|
+ |
color[u] = 2;
|
|
3000
|
+ |
order.push(u);
|
|
3001
|
+ |
stack.pop();
|
|
3002
|
+ |
}
|
|
3003
|
+ |
}
|
|
3004
|
+ |
}
|
|
3005
|
+ |
|
|
3006
|
+ |
const SEQ_GAP: usize = 5;
|
|
3007
|
+ |
const SEQ_OPS: &[(&str, bool, SeqHead)] = &[
|
|
3008
|
+ |
("-->>", true, SeqHead::Arrow),
|
|
3009
|
+ |
("->>", false, SeqHead::Arrow),
|
|
3010
|
+ |
("--x", true, SeqHead::Cross),
|
|
3011
|
+ |
("-x", false, SeqHead::Cross),
|
|
3012
|
+ |
("--)", true, SeqHead::Arrow),
|
|
3013
|
+ |
("-)", false, SeqHead::Arrow),
|
|
3014
|
+ |
("-->", true, SeqHead::Arrow),
|
|
3015
|
+ |
("->", false, SeqHead::Arrow),
|
|
3016
|
+ |
];
|
|
3017
|
+ |
|
|
3018
|
+ |
#[derive(Clone, Copy, PartialEq)]
|
|
3019
|
+ |
enum SeqHead {
|
|
3020
|
+ |
Arrow,
|
|
3021
|
+ |
Cross,
|
|
3022
|
+ |
}
|
|
3023
|
+ |
|
|
3024
|
+ |
enum NoteAnchor {
|
|
3025
|
+ |
Over(usize, usize),
|
|
3026
|
+ |
Left(usize),
|
|
3027
|
+ |
Right(usize),
|
|
3028
|
+ |
}
|
|
3029
|
+ |
|
|
3030
|
+ |
enum SeqItem {
|
|
3031
|
+ |
Message {
|
|
3032
|
+ |
from: usize,
|
|
3033
|
+ |
to: usize,
|
|
3034
|
+ |
text: Option<String>,
|
|
3035
|
+ |
dashed: bool,
|
|
3036
|
+ |
head: SeqHead,
|
|
3037
|
+ |
},
|
|
3038
|
+ |
Note {
|
|
3039
|
+ |
anchor: NoteAnchor,
|
|
3040
|
+ |
text: String,
|
|
3041
|
+ |
},
|
|
3042
|
+ |
Divider {
|
|
3043
|
+ |
text: String,
|
|
3044
|
+ |
},
|
|
3045
|
+ |
}
|
|
3046
|
+ |
|
|
3047
|
+ |
struct Sequence {
|
|
3048
|
+ |
labels: Vec<String>,
|
|
3049
|
+ |
index: HashMap<String, usize>,
|
|
3050
|
+ |
items: Vec<SeqItem>,
|
|
3051
|
+ |
}
|
|
3052
|
+ |
|
|
3053
|
+ |
impl Sequence {
|
|
3054
|
+ |
fn participant(&mut self, id: &str, label: Option<&str>) -> Option<usize> {
|
|
3055
|
+ |
if let Some(&i) = self.index.get(id) {
|
|
3056
|
+ |
if let Some(label) = label {
|
|
3057
|
+ |
self.labels[i] = label.to_string();
|
|
3058
|
+ |
}
|
|
3059
|
+ |
return Some(i);
|
|
3060
|
+ |
}
|
|
3061
|
+ |
if self.labels.len() >= MAX_NODES {
|
|
3062
|
+ |
return None;
|
|
3063
|
+ |
}
|
|
3064
|
+ |
self.index.insert(id.to_string(), self.labels.len());
|
|
3065
|
+ |
self.labels.push(label.unwrap_or(id).to_string());
|
|
3066
|
+ |
Some(self.labels.len() - 1)
|
|
3067
|
+ |
}
|
|
3068
|
+ |
}
|
|
3069
|
+ |
|
|
3070
|
+ |
fn parse_sequence(src: &str) -> Option<Sequence> {
|
|
3071
|
+ |
let mut statements: Vec<String> = Vec::new();
|
|
3072
|
+ |
for raw_line in src.lines() {
|
|
3073
|
+ |
split_statements(raw_line, &mut statements);
|
|
3074
|
+ |
}
|
|
3075
|
+ |
let header = statements.first()?;
|
|
3076
|
+ |
if !header
|
|
3077
|
+ |
.split_whitespace()
|
|
3078
|
+ |
.next()?
|
|
3079
|
+ |
.eq_ignore_ascii_case("sequencediagram")
|
|
3080
|
+ |
{
|
|
3081
|
+ |
return None;
|
|
3082
|
+ |
}
|
|
3083
|
+ |
|
|
3084
|
+ |
let mut seq = Sequence {
|
|
3085
|
+ |
labels: Vec::new(),
|
|
3086
|
+ |
index: HashMap::new(),
|
|
3087
|
+ |
items: Vec::new(),
|
|
3088
|
+ |
};
|
|
3089
|
+ |
let mut autonumber = false;
|
|
3090
|
+ |
let mut msg_count = 0usize;
|
|
3091
|
+ |
let mut blocks: Vec<bool> = Vec::new();
|
|
3092
|
+ |
|
|
3093
|
+ |
for st in &statements[1..] {
|
|
3094
|
+ |
let first = st.split_whitespace().next().unwrap_or("");
|
|
3095
|
+ |
match first.to_ascii_lowercase().as_str() {
|
|
3096
|
+ |
"participant" | "actor" => {
|
|
3097
|
+ |
let rest = st[first.len()..].trim();
|
|
3098
|
+ |
if rest.is_empty() {
|
|
3099
|
+ |
return None;
|
|
3100
|
+ |
}
|
|
3101
|
+ |
let (id, label) = match rest.split_once(" as ") {
|
|
3102
|
+ |
Some((id, label)) => (id.trim(), Some(clean_label(label))),
|
|
3103
|
+ |
None => (rest, None),
|
|
3104
|
+ |
};
|
|
3105
|
+ |
seq.participant(id, label.as_deref())?;
|
|
3106
|
+ |
}
|
|
3107
|
+ |
"autonumber" => autonumber = true,
|
|
3108
|
+ |
"activate" | "deactivate" | "create" | "destroy" | "title" | "acctitle"
|
|
3109
|
+ |
| "accdescr" | "links" | "link" | "properties" => {}
|
|
3110
|
+ |
"note" => {
|
|
3111
|
+ |
let rest = st[first.len()..].trim();
|
|
3112
|
+ |
let (text_part, anchor) = parse_note_anchor(rest, &mut seq)?;
|
|
3113
|
+ |
if seq.items.len() >= MAX_EDGES {
|
|
3114
|
+ |
return None;
|
|
3115
|
+ |
}
|
|
3116
|
+ |
seq.items.push(SeqItem::Note {
|
|
3117
|
+ |
anchor,
|
|
3118
|
+ |
text: text_part,
|
|
3119
|
+ |
});
|
|
3120
|
+ |
}
|
|
3121
|
+ |
"loop" | "alt" | "opt" | "par" | "critical" | "break" | "else" | "and" | "option" => {
|
|
3122
|
+ |
if matches!(
|
|
3123
|
+ |
first.to_ascii_lowercase().as_str(),
|
|
3124
|
+ |
"else" | "and" | "option"
|
|
3125
|
+ |
) {
|
|
3126
|
+ |
if blocks.last() != Some(&true) {
|
|
3127
|
+ |
continue;
|
|
3128
|
+ |
}
|
|
3129
|
+ |
} else {
|
|
3130
|
+ |
blocks.push(true);
|
|
3131
|
+ |
}
|
|
3132
|
+ |
if seq.items.len() >= MAX_EDGES {
|
|
3133
|
+ |
return None;
|
|
3134
|
+ |
}
|
|
3135
|
+ |
seq.items.push(SeqItem::Divider {
|
|
3136
|
+ |
text: decode_html_entities(st),
|
|
3137
|
+ |
});
|
|
3138
|
+ |
}
|
|
3139
|
+ |
"rect" | "box" => blocks.push(false),
|
|
3140
|
+ |
"end" => {
|
|
3141
|
+ |
if blocks.pop() == Some(true) {
|
|
3142
|
+ |
if seq.items.len() >= MAX_EDGES {
|
|
3143
|
+ |
return None;
|
|
3144
|
+ |
}
|
|
3145
|
+ |
seq.items.push(SeqItem::Divider {
|
|
3146
|
+ |
text: "end".to_string(),
|
|
3147
|
+ |
});
|
|
3148
|
+ |
}
|
|
3149
|
+ |
}
|
|
3150
|
+ |
_ => {
|
|
3151
|
+ |
let (from, to, mut text, dashed, head) = parse_seq_message(st, &mut seq)?;
|
|
3152
|
+ |
if autonumber {
|
|
3153
|
+ |
msg_count += 1;
|
|
3154
|
+ |
text = Some(match text {
|
|
3155
|
+ |
Some(t) => format!("{msg_count}. {t}"),
|
|
3156
|
+ |
None => format!("{msg_count}."),
|
|
3157
|
+ |
});
|
|
3158
|
+ |
}
|
|
3159
|
+ |
if seq.items.len() >= MAX_EDGES {
|
|
3160
|
+ |
return None;
|
|
3161
|
+ |
}
|
|
3162
|
+ |
seq.items.push(SeqItem::Message {
|
|
3163
|
+ |
from,
|
|
3164
|
+ |
to,
|
|
3165
|
+ |
text,
|
|
3166
|
+ |
dashed,
|
|
3167
|
+ |
head,
|
|
3168
|
+ |
});
|
|
3169
|
+ |
}
|
|
3170
|
+ |
}
|
|
3171
|
+ |
}
|
|
3172
|
+ |
|
|
3173
|
+ |
if seq.labels.is_empty() {
|
|
3174
|
+ |
return None;
|
|
3175
|
+ |
}
|
|
3176
|
+ |
Some(seq)
|
|
3177
|
+ |
}
|
|
3178
|
+ |
|
|
3179
|
+ |
fn parse_note_anchor(rest: &str, seq: &mut Sequence) -> Option<(String, NoteAnchor)> {
|
|
3180
|
+ |
let lower = rest.to_ascii_lowercase();
|
|
3181
|
+ |
let (ids_and_text, kind) = if let Some(r) = lower.strip_prefix("over ") {
|
|
3182
|
+ |
(&rest[rest.len() - r.len()..], 0u8)
|
|
3183
|
+ |
} else if let Some(r) = lower.strip_prefix("left of ") {
|
|
3184
|
+ |
(&rest[rest.len() - r.len()..], 1)
|
|
3185
|
+ |
} else if let Some(r) = lower.strip_prefix("right of ") {
|
|
3186
|
+ |
(&rest[rest.len() - r.len()..], 2)
|
|
3187
|
+ |
} else {
|
|
3188
|
+ |
return None;
|
|
3189
|
+ |
};
|
|
3190
|
+ |
let (ids, text) = ids_and_text.split_once(':')?;
|
|
3191
|
+ |
let text = decode_html_entities(text.trim());
|
|
3192
|
+ |
let mut parts = ids.split(',').map(str::trim).filter(|s| !s.is_empty());
|
|
3193
|
+ |
let a = seq.participant(parts.next()?, None)?;
|
|
3194
|
+ |
let anchor = match kind {
|
|
3195
|
+ |
0 => {
|
|
3196
|
+ |
let b = match parts.next() {
|
|
3197
|
+ |
Some(id) => seq.participant(id, None)?,
|
|
3198
|
+ |
None => a,
|
|
3199
|
+ |
};
|
|
3200
|
+ |
NoteAnchor::Over(a.min(b), a.max(b))
|
|
3201
|
+ |
}
|
|
3202
|
+ |
1 => NoteAnchor::Left(a),
|
|
3203
|
+ |
_ => NoteAnchor::Right(a),
|
|
3204
|
+ |
};
|
|
3205
|
+ |
Some((text, anchor))
|
|
3206
|
+ |
}
|
|
3207
|
+ |
|
|
3208
|
+ |
fn parse_seq_message(
|
|
3209
|
+ |
st: &str,
|
|
3210
|
+ |
seq: &mut Sequence,
|
|
3211
|
+ |
) -> Option<(usize, usize, Option<String>, bool, SeqHead)> {
|
|
3212
|
+ |
let mut found: Option<(usize, &str, bool, SeqHead)> = None;
|
|
3213
|
+ |
for (pos, _) in st.char_indices() {
|
|
3214
|
+ |
for &(op, dashed, head) in SEQ_OPS {
|
|
3215
|
+ |
if st[pos..].starts_with(op) {
|
|
3216
|
+ |
found = Some((pos, op, dashed, head));
|
|
3217
|
+ |
break;
|
|
3218
|
+ |
}
|
|
3219
|
+ |
}
|
|
3220
|
+ |
if found.is_some() {
|
|
3221
|
+ |
break;
|
|
3222
|
+ |
}
|
|
3223
|
+ |
}
|
|
3224
|
+ |
let (pos, op, dashed, head) = found?;
|
|
3225
|
+ |
let from_id = st[..pos].trim();
|
|
3226
|
+ |
if from_id.is_empty() {
|
|
3227
|
+ |
return None;
|
|
3228
|
+ |
}
|
|
3229
|
+ |
let rest = st[pos + op.len()..]
|
|
3230
|
+ |
.trim_start()
|
|
3231
|
+ |
.trim_start_matches(['+', '-']);
|
|
3232
|
+ |
let (to_id, text) = match rest.split_once(':') {
|
|
3233
|
+ |
Some((to, text)) => (to.trim(), non_empty(decode_html_entities(text.trim()))),
|
|
3234
|
+ |
None => (rest.trim(), None),
|
|
3235
|
+ |
};
|
|
3236
|
+ |
if to_id.is_empty() {
|
|
3237
|
+ |
return None;
|
|
3238
|
+ |
}
|
|
3239
|
+ |
let from = seq.participant(from_id, None)?;
|
|
3240
|
+ |
let to = seq.participant(to_id, None)?;
|
|
3241
|
+ |
Some((from, to, text, dashed, head))
|
|
3242
|
+ |
}
|
|
3243
|
+ |
|
|
3244
|
+ |
fn note_geometry(xs: &[usize], anchor: &NoteAnchor, text_w: usize) -> (usize, usize) {
|
|
3245
|
+ |
match *anchor {
|
|
3246
|
+ |
NoteAnchor::Over(l, r) => {
|
|
3247
|
+ |
let center = (xs[l] + xs[r]) / 2;
|
|
3248
|
+ |
let w = (xs[r] - xs[l] + 5).max(text_w + 2 * PAD + 2);
|
|
3249
|
+ |
(center.saturating_sub(w / 2), w)
|
|
3250
|
+ |
}
|
|
3251
|
+ |
NoteAnchor::Left(i) => {
|
|
3252
|
+ |
let w = text_w + 2 * PAD + 2;
|
|
3253
|
+ |
(xs[i].saturating_sub(2 + w - 1), w)
|
|
3254
|
+ |
}
|
|
3255
|
+ |
NoteAnchor::Right(i) => (xs[i] + 2, text_w + 2 * PAD + 2),
|
|
3256
|
+ |
}
|
|
3257
|
+ |
}
|
|
3258
|
+ |
|
|
3259
|
+ |
fn layout_sequence(
|
|
3260
|
+ |
seq: &Sequence,
|
|
3261
|
+ |
styles: &MermaidStyles,
|
|
3262
|
+ |
max_width: Option<usize>,
|
|
3263
|
+ |
) -> Result<MermaidArt, Oversize> {
|
|
3264
|
+ |
let n = seq.labels.len();
|
|
3265
|
+ |
let labels: Vec<String> = seq
|
|
3266
|
+ |
.labels
|
|
3267
|
+ |
.iter()
|
|
3268
|
+ |
.map(|l| fit_label(l, WRAP_WIDTH))
|
|
3269
|
+ |
.collect();
|
|
3270
|
+ |
let box_w: Vec<usize> = labels
|
|
3271
|
+ |
.iter()
|
|
3272
|
+ |
.map(|l| l.width().max(1) + 2 * PAD + 2)
|
|
3273
|
+ |
.collect();
|
|
3274
|
+ |
let box_h = 3usize;
|
|
3275
|
+ |
|
|
3276
|
+ |
let item_text_w = |text: &Option<String>| text.as_deref().map(|t| t.width()).unwrap_or(0);
|
|
3277
|
+ |
|
|
3278
|
+ |
let mut gaps: Vec<usize> = (0..n.saturating_sub(1))
|
|
3279
|
+ |
.map(|i| SEQ_GAP.max(box_w[i].div_ceil(2) + box_w[i + 1].div_ceil(2) + 1))
|
|
3280
|
+ |
.collect();
|
|
3281
|
+ |
|
|
3282
|
+ |
let mut reqs: Vec<(usize, usize, usize)> = Vec::new();
|
|
3283
|
+ |
for item in &seq.items {
|
|
3284
|
+ |
match item {
|
|
3285
|
+ |
SeqItem::Message { from, to, text, .. } => {
|
|
3286
|
+ |
let tw = item_text_w(text);
|
|
3287
|
+ |
if from != to {
|
|
3288
|
+ |
let (l, r) = (*from.min(to), *from.max(to));
|
|
3289
|
+ |
reqs.push((l, r, (tw + 2).max(4)));
|
|
3290
|
+ |
} else if *from + 1 < n {
|
|
3291
|
+ |
reqs.push((*from, *from + 1, 5 + tw + 2));
|
|
3292
|
+ |
}
|
|
3293
|
+ |
}
|
|
3294
|
+ |
SeqItem::Note { anchor, text } => {
|
|
3295
|
+ |
let tw = text.width();
|
|
3296
|
+ |
match *anchor {
|
|
3297
|
+ |
NoteAnchor::Over(l, r) if l < r => reqs.push((l, r, tw.saturating_sub(1))),
|
|
3298
|
+ |
NoteAnchor::Over(i, _) => {
|
|
3299
|
+ |
let half = (tw + 4).div_ceil(2) + 2;
|
|
3300
|
+ |
if i > 0 {
|
|
3301
|
+ |
reqs.push((i - 1, i, half));
|
|
3302
|
+ |
}
|
|
3303
|
+ |
if i + 1 < n {
|
|
3304
|
+ |
reqs.push((i, i + 1, half));
|
|
3305
|
+ |
}
|
|
3306
|
+ |
}
|
|
3307
|
+ |
NoteAnchor::Left(i) if i > 0 => reqs.push((i - 1, i, tw + 7)),
|
|
3308
|
+ |
NoteAnchor::Right(i) if i + 1 < n => reqs.push((i, i + 1, tw + 7)),
|
|
3309
|
+ |
_ => {}
|
|
3310
|
+ |
}
|
|
3311
|
+ |
}
|
|
3312
|
+ |
SeqItem::Divider { .. } => {}
|
|
3313
|
+ |
}
|
|
3314
|
+ |
}
|
|
3315
|
+ |
reqs.sort_by_key(|&(l, r, _)| r - l);
|
|
3316
|
+ |
for (l, r, need) in reqs {
|
|
3317
|
+ |
let cur: usize = gaps[l..r].iter().sum();
|
|
3318
|
+ |
if cur < need {
|
|
3319
|
+ |
gaps[r - 1] += need - cur;
|
|
3320
|
+ |
}
|
|
3321
|
+ |
}
|
|
3322
|
+ |
|
|
3323
|
+ |
let mut xs = vec![0usize; n];
|
|
3324
|
+ |
xs[0] = box_w[0] / 2;
|
|
3325
|
+ |
for i in 1..n {
|
|
3326
|
+ |
xs[i] = xs[i - 1] + gaps[i - 1];
|
|
3327
|
+ |
}
|
|
3328
|
+ |
|
|
3329
|
+ |
let mut canvas_w = xs[n - 1] + box_w[n - 1].div_ceil(2) + 1;
|
|
3330
|
+ |
for item in &seq.items {
|
|
3331
|
+ |
match item {
|
|
3332
|
+ |
SeqItem::Message { from, to, text, .. } if from == to => {
|
|
3333
|
+ |
canvas_w = canvas_w.max(xs[*from] + 5 + item_text_w(text) + 1);
|
|
3334
|
+ |
}
|
|
3335
|
+ |
SeqItem::Note { anchor, text } => {
|
|
3336
|
+ |
let (x, w) = note_geometry(&xs, anchor, text.width());
|
|
3337
|
+ |
canvas_w = canvas_w.max(x + w + 1);
|
|
3338
|
+ |
}
|
|
3339
|
+ |
SeqItem::Divider { text } => {
|
|
3340
|
+ |
canvas_w = canvas_w.max(text.width() + 4);
|
|
3341
|
+ |
}
|
|
3342
|
+ |
_ => {}
|
|
3343
|
+ |
}
|
|
3344
|
+ |
}
|
|
3345
|
+ |
|
|
3346
|
+ |
let mut rows: Vec<usize> = Vec::with_capacity(seq.items.len());
|
|
3347
|
+ |
let mut y = box_h + 1;
|
|
3348
|
+ |
for item in &seq.items {
|
|
3349
|
+ |
rows.push(y);
|
|
3350
|
+ |
y += match item {
|
|
3351
|
+ |
SeqItem::Message { from, to, text, .. } => {
|
|
3352
|
+ |
if from == to {
|
|
3353
|
+ |
4
|
|
3354
|
+ |
} else if text.is_some() {
|
|
3355
|
+ |
3
|
|
3356
|
+ |
} else {
|
|
3357
|
+ |
2
|
|
3358
|
+ |
}
|
|
3359
|
+ |
}
|
|
3360
|
+ |
SeqItem::Note { .. } => 4,
|
|
3361
|
+ |
SeqItem::Divider { .. } => 2,
|
|
3362
|
+ |
};
|
|
3363
|
+ |
}
|
|
3364
|
+ |
let bottom_top = y;
|
|
3365
|
+ |
let canvas_h = bottom_top + box_h;
|
|
3366
|
+ |
|
|
3367
|
+ |
if let Some(mw) = max_width
|
|
3368
|
+ |
&& canvas_w > mw
|
|
3369
|
+ |
{
|
|
3370
|
+ |
return Err(Oversize::Width);
|
|
3371
|
+ |
}
|
|
3372
|
+ |
if canvas_w.saturating_mul(canvas_h) > MAX_CANVAS_CELLS {
|
|
3373
|
+ |
return Err(Oversize::Cells);
|
|
3374
|
+ |
}
|
|
3375
|
+ |
|
|
3376
|
+ |
let mut canvas = Canvas::new(canvas_w, canvas_h);
|
|
3377
|
+ |
for i in 0..n {
|
|
3378
|
+ |
for by in [0, bottom_top] {
|
|
3379
|
+ |
let p = Placed {
|
|
3380
|
+ |
x: xs[i].saturating_sub(box_w[i] / 2),
|
|
3381
|
+ |
y: by,
|
|
3382
|
+ |
w: box_w[i],
|
|
3383
|
+ |
h: box_h,
|
|
3384
|
+ |
cx: xs[i],
|
|
3385
|
+ |
cy: by + 1,
|
|
3386
|
+ |
rank: 0,
|
|
3387
|
+ |
};
|
|
3388
|
+ |
draw_box(
|
|
3389
|
+ |
&mut canvas,
|
|
3390
|
+ |
&p,
|
|
3391
|
+ |
std::slice::from_ref(&labels[i]),
|
|
3392
|
+ |
Shape::Rect,
|
|
3393
|
+ |
);
|
|
3394
|
+ |
}
|
|
3395
|
+ |
}
|
|
3396
|
+ |
for (item, &r) in seq.items.iter().zip(&rows) {
|
|
3397
|
+ |
if let SeqItem::Note { anchor, text } = item {
|
|
3398
|
+ |
let (x, w) = note_geometry(&xs, anchor, text.width());
|
|
3399
|
+ |
let p = Placed {
|
|
3400
|
+ |
x,
|
|
3401
|
+ |
y: r,
|
|
3402
|
+ |
w,
|
|
3403
|
+ |
h: 3,
|
|
3404
|
+ |
cx: x + w / 2,
|
|
3405
|
+ |
cy: r + 1,
|
|
3406
|
+ |
rank: 0,
|
|
3407
|
+ |
};
|
|
3408
|
+ |
draw_box(&mut canvas, &p, std::slice::from_ref(text), Shape::Rect);
|
|
3409
|
+ |
}
|
|
3410
|
+ |
}
|
|
3411
|
+ |
for &x in &xs {
|
|
3412
|
+ |
canvas.junction(x, box_h - 1, D);
|
|
3413
|
+ |
canvas.seg_v(x, box_h, bottom_top - 1);
|
|
3414
|
+ |
canvas.junction(x, bottom_top, U);
|
|
3415
|
+ |
}
|
|
3416
|
+ |
|
|
3417
|
+ |
for (item, &r) in seq.items.iter().zip(&rows) {
|
|
3418
|
+ |
match item {
|
|
3419
|
+ |
SeqItem::Message {
|
|
3420
|
+ |
from,
|
|
3421
|
+ |
to,
|
|
3422
|
+ |
text,
|
|
3423
|
+ |
dashed,
|
|
3424
|
+ |
head,
|
|
3425
|
+ |
} => {
|
|
3426
|
+ |
let line_ch = if *dashed { '╌' } else { '─' };
|
|
3427
|
+ |
if from == to {
|
|
3428
|
+ |
let x = xs[*from];
|
|
3429
|
+ |
canvas.junction(x, r, R);
|
|
3430
|
+ |
canvas.set(x + 1, r, line_ch, Cls::Edge);
|
|
3431
|
+ |
canvas.set(x + 2, r, line_ch, Cls::Edge);
|
|
3432
|
+ |
canvas.set(x + 3, r, '╮', Cls::Edge);
|
|
3433
|
+ |
canvas.set(x + 3, r + 1, '│', Cls::Edge);
|
|
3434
|
+ |
canvas.set(
|
|
3435
|
+ |
x + 1,
|
|
3436
|
+ |
r + 2,
|
|
3437
|
+ |
if *head == SeqHead::Cross { '×' } else { '◄' },
|
|
3438
|
+ |
Cls::Edge,
|
|
3439
|
+ |
);
|
|
3440
|
+ |
canvas.set(x + 2, r + 2, line_ch, Cls::Edge);
|
|
3441
|
+ |
canvas.set(x + 3, r + 2, '╯', Cls::Edge);
|
|
3442
|
+ |
if let Some(t) = text {
|
|
3443
|
+ |
draw_seq_text(&mut canvas, t, x + 5, r + 1, Cls::Text);
|
|
3444
|
+ |
}
|
|
3445
|
+ |
} else {
|
|
3446
|
+ |
let (x0, x1) = (xs[*from], xs[*to]);
|
|
3447
|
+ |
let rightward = x1 > x0;
|
|
3448
|
+ |
let arrow_row = if text.is_some() { r + 1 } else { r };
|
|
3449
|
+ |
let (lo, hi) = (x0.min(x1), x0.max(x1));
|
|
3450
|
+ |
canvas.junction(x0, arrow_row, if rightward { R } else { L });
|
|
3451
|
+ |
for x in (lo + 1)..hi {
|
|
3452
|
+ |
canvas.set(x, arrow_row, line_ch, Cls::Edge);
|
|
3453
|
+ |
}
|
|
3454
|
+ |
let head_ch = match (head, rightward) {
|
|
3455
|
+ |
(SeqHead::Cross, _) => '×',
|
|
3456
|
+ |
(SeqHead::Arrow, true) => '▶',
|
|
3457
|
+ |
(SeqHead::Arrow, false) => '◄',
|
|
3458
|
+ |
};
|
|
3459
|
+ |
let head_x = if rightward { x1 - 1 } else { x1 + 1 };
|
|
3460
|
+ |
canvas.set(head_x, arrow_row, head_ch, Cls::Edge);
|
|
3461
|
+ |
if let Some(t) = text {
|
|
3462
|
+ |
let span = hi - lo - 1;
|
|
3463
|
+ |
let t = fit_label(t, span.max(1));
|
|
3464
|
+ |
let tx = lo + 1 + span.saturating_sub(t.width()) / 2;
|
|
3465
|
+ |
draw_seq_text(&mut canvas, &t, tx, r, Cls::Text);
|
|
3466
|
+ |
}
|
|
3467
|
+ |
}
|
|
3468
|
+ |
}
|
|
3469
|
+ |
SeqItem::Note { .. } => {}
|
|
3470
|
+ |
SeqItem::Divider { text } => {
|
|
3471
|
+ |
for x in 0..canvas_w {
|
|
3472
|
+ |
canvas.set(x, r, '─', Cls::Edge);
|
|
3473
|
+ |
}
|
|
3474
|
+ |
let t = fit_label(text, canvas_w.saturating_sub(4));
|
|
3475
|
+ |
draw_seq_text(&mut canvas, &format!(" {t} "), 2, r, Cls::EdgeLabel);
|
|
3476
|
+ |
}
|
|
3477
|
+ |
}
|
|
3478
|
+ |
}
|
|
3479
|
+ |
|
|
3480
|
+ |
canvas.finalize_mask();
|
|
3481
|
+ |
let (styled_lines, plain_lines) = canvas.to_lines(styles);
|
|
3482
|
+ |
Ok(MermaidArt {
|
|
3483
|
+ |
styled_lines,
|
|
3484
|
+ |
plain_lines,
|
|
3485
|
+ |
})
|
|
3486
|
+ |
}
|
|
3487
|
+ |
|
|
3488
|
+ |
fn draw_seq_text(canvas: &mut Canvas, text: &str, x: usize, y: usize, cls: Cls) {
|
|
3489
|
+ |
let mut cur = x;
|
|
3490
|
+ |
for c in text.chars() {
|
|
3491
|
+ |
let cw = char_width(c).max(1);
|
|
3492
|
+ |
for k in 0..cw {
|
|
3493
|
+ |
if cur + k < canvas.w && y < canvas.h {
|
|
3494
|
+ |
let i = canvas.idx(cur + k, y);
|
|
3495
|
+ |
canvas.mask[i] = 0;
|
|
3496
|
+ |
}
|
|
3497
|
+ |
canvas.set(cur + k, y, if k == 0 { c } else { CONT }, cls);
|
|
3498
|
+ |
}
|
|
3499
|
+ |
cur += cw;
|
|
3500
|
+ |
}
|
|
3501
|
+ |
}
|
|
3502
|
+ |
|
|
3503
|
+ |
const TOO_WIDE_HINT: &str =
|
|
3504
|
+ |
"This diagram is too wide to display here \u{2014} open the image to view it in full.";
|
|
3505
|
+ |
|
|
3506
|
+ |
fn fallback(
|
|
3507
|
+ |
src: &str,
|
|
3508
|
+ |
styles: &MermaidStyles,
|
|
3509
|
+ |
max_width: Option<usize>,
|
|
3510
|
+ |
too_wide: bool,
|
|
3511
|
+ |
) -> MermaidArt {
|
|
3512
|
+ |
let header = first_word(src);
|
|
3513
|
+ |
let title = format!(" mermaid: {header} ");
|
|
3514
|
+ |
let limit = max_width.map(|m| m.saturating_sub(4).max(8));
|
|
3515
|
+ |
let body: Vec<String> = src
|
|
3516
|
+ |
.lines()
|
|
3517
|
+ |
.map(|l| l.trim_end())
|
|
3518
|
+ |
.skip_while(|l| l.is_empty())
|
|
3519
|
+ |
.flat_map(|l| chunk_line(l, limit))
|
|
3520
|
+ |
.collect();
|
|
3521
|
+ |
let content_w = body
|
|
3522
|
+ |
.iter()
|
|
3523
|
+ |
.map(|l| l.width())
|
|
3524
|
+ |
.chain(std::iter::once(title.width()))
|
|
3525
|
+ |
.max()
|
|
3526
|
+ |
.unwrap_or(0);
|
|
3527
|
+ |
let inner = content_w + 2;
|
|
3528
|
+ |
|
|
3529
|
+ |
let mut styled = Vec::new();
|
|
3530
|
+ |
let mut plain = Vec::new();
|
|
3531
|
+ |
|
|
3532
|
+ |
let mut top = String::from("╭");
|
|
3533
|
+ |
top.push_str(&title);
|
|
3534
|
+ |
for _ in 0..inner.saturating_sub(title.width()) {
|
|
3535
|
+ |
top.push('─');
|
|
3536
|
+ |
}
|
|
3537
|
+ |
top.push('╮');
|
|
3538
|
+ |
styled.push(Line::from(vec![
|
|
3539
|
+ |
Span::styled("╭".to_string(), styles.border),
|
|
3540
|
+ |
Span::styled(title.clone(), styles.title),
|
|
3541
|
+ |
Span::styled(
|
|
3542
|
+ |
format!("{}╮", "─".repeat(inner.saturating_sub(title.width()))),
|
|
3543
|
+ |
styles.border,
|
|
3544
|
+ |
),
|
|
3545
|
+ |
]));
|
|
3546
|
+ |
plain.push(top);
|
|
3547
|
+ |
|
|
3548
|
+ |
for line in &body {
|
|
3549
|
+ |
let pad = content_w.saturating_sub(line.width());
|
|
3550
|
+ |
styled.push(Line::from(vec![
|
|
3551
|
+ |
Span::styled("│ ".to_string(), styles.border),
|
|
3552
|
+ |
Span::styled(line.clone(), styles.node_text),
|
|
3553
|
+ |
Span::styled(format!("{} │", " ".repeat(pad)), styles.border),
|
|
3554
|
+ |
]));
|
|
3555
|
+ |
plain.push(format!("│ {}{} │", line, " ".repeat(pad)));
|
|
3556
|
+ |
}
|
|
3557
|
+ |
|
|
3558
|
+ |
let bottom = format!("╰{}╯", "─".repeat(inner));
|
|
3559
|
+ |
styled.push(Line::from(Span::styled(bottom.clone(), styles.border)));
|
|
3560
|
+ |
plain.push(bottom);
|
|
3561
|
+ |
|
|
3562
|
+ |
if too_wide {
|
|
3563
|
+ |
let hint_style = styles.border.add_modifier(Modifier::ITALIC);
|
|
3564
|
+ |
for chunk in wrap_words(TOO_WIDE_HINT, max_width) {
|
|
3565
|
+ |
styled.push(Line::from(Span::styled(chunk.clone(), hint_style)));
|
|
3566
|
+ |
plain.push(chunk);
|
|
3567
|
+ |
}
|
|
3568
|
+ |
}
|
|
3569
|
+ |
|
|
3570
|
+ |
MermaidArt {
|
|
3571
|
+ |
styled_lines: styled,
|
|
3572
|
+ |
plain_lines: plain,
|
|
3573
|
+ |
}
|
|
3574
|
+ |
}
|
|
3575
|
+ |
|
|
3576
|
+ |
fn chunk_line(line: &str, limit: Option<usize>) -> Vec<String> {
|
|
3577
|
+ |
let Some(limit) = limit else {
|
|
3578
|
+ |
return vec![line.to_string()];
|
|
3579
|
+ |
};
|
|
3580
|
+ |
if line.width() <= limit {
|
|
3581
|
+ |
return vec![line.to_string()];
|
|
3582
|
+ |
}
|
|
3583
|
+ |
let mut out = Vec::new();
|
|
3584
|
+ |
let mut cur = String::new();
|
|
3585
|
+ |
let mut cur_w = 0usize;
|
|
3586
|
+ |
for c in line.chars() {
|
|
3587
|
+ |
let cw = char_width(c).max(1);
|
|
3588
|
+ |
if cur_w + cw > limit && !cur.is_empty() {
|
|
3589
|
+ |
out.push(std::mem::take(&mut cur));
|
|
3590
|
+ |
cur_w = 0;
|
|
3591
|
+ |
}
|
|
3592
|
+ |
cur.push(c);
|
|
3593
|
+ |
cur_w += cw;
|
|
3594
|
+ |
}
|
|
3595
|
+ |
if !cur.is_empty() {
|
|
3596
|
+ |
out.push(cur);
|
|
3597
|
+ |
}
|
|
3598
|
+ |
out
|
|
3599
|
+ |
}
|
|
3600
|
+ |
|
|
3601
|
+ |
fn wrap_words(text: &str, limit: Option<usize>) -> Vec<String> {
|
|
3602
|
+ |
let Some(limit) = limit else {
|
|
3603
|
+ |
return vec![text.to_string()];
|
|
3604
|
+ |
};
|
|
3605
|
+ |
let mut lines: Vec<String> = Vec::new();
|
|
3606
|
+ |
let mut cur = String::new();
|
|
3607
|
+ |
for word in text.split(' ').filter(|w| !w.is_empty()) {
|
|
3608
|
+ |
if cur.is_empty() {
|
|
3609
|
+ |
cur.push_str(word);
|
|
3610
|
+ |
} else if cur.width() + 1 + word.width() <= limit {
|
|
3611
|
+ |
cur.push(' ');
|
|
3612
|
+ |
cur.push_str(word);
|
|
3613
|
+ |
} else {
|
|
3614
|
+ |
lines.push(std::mem::take(&mut cur));
|
|
3615
|
+ |
cur.push_str(word);
|
|
3616
|
+ |
}
|
|
3617
|
+ |
}
|
|
3618
|
+ |
if !cur.is_empty() {
|
|
3619
|
+ |
lines.push(cur);
|
|
3620
|
+ |
}
|
|
3621
|
+ |
lines
|
|
3622
|
+ |
.into_iter()
|
|
3623
|
+ |
.flat_map(|l| chunk_line(&l, Some(limit)))
|
|
3624
|
+ |
.collect()
|
|
3625
|
+ |
}
|
|
3626
|
+ |
|
|
3627
|
+ |
fn first_word(src: &str) -> String {
|
|
3628
|
+ |
src.split_whitespace()
|
|
3629
|
+ |
.next()
|
|
3630
|
+ |
.unwrap_or("diagram")
|
|
3631
|
+ |
.to_string()
|
|
3632
|
+ |
}
|
|
3633
|
+ |
|
|
3634
|
+ |
#[cfg(test)]
|
|
3635
|
+ |
mod tests {
|
|
3636
|
+ |
use super::*;
|
|
3637
|
+ |
|
|
3638
|
+ |
fn styles() -> MermaidStyles {
|
|
3639
|
+ |
let s = Style::default();
|
|
3640
|
+ |
MermaidStyles {
|
|
3641
|
+ |
border: s,
|
|
3642
|
+ |
node_text: s,
|
|
3643
|
+ |
edge: s,
|
|
3644
|
+ |
edge_label: s,
|
|
3645
|
+ |
title: s,
|
|
3646
|
+ |
}
|
|
3647
|
+ |
}
|
|
3648
|
+ |
|
|
3649
|
+ |
fn plain(src: &str) -> String {
|
|
3650
|
+ |
render(src, &styles(), Some(120))
|
|
3651
|
+ |
.unwrap()
|
|
3652
|
+ |
.plain_lines
|
|
3653
|
+ |
.join("\n")
|
|
3654
|
+ |
}
|
|
3655
|
+ |
|
|
3656
|
+ |
#[test]
|
|
3657
|
+ |
fn parses_nodes_edges_and_direction() {
|
|
3658
|
+ |
let g = parse_graph("flowchart LR\n A[Start] --> B[End]").unwrap();
|
|
3659
|
+ |
assert_eq!(g.nodes.len(), 2);
|
|
3660
|
+ |
assert_eq!(g.edges.len(), 1);
|
|
3661
|
+ |
assert_eq!(g.nodes[0].label, "Start");
|
|
3662
|
+ |
assert_eq!(g.nodes[1].label, "End");
|
|
3663
|
+ |
assert!(g.dir == Dir::Right);
|
|
3664
|
+ |
}
|
|
3665
|
+ |
|
|
3666
|
+ |
#[test]
|
|
3667
|
+ |
fn non_flowchart_returns_none_from_parse() {
|
|
3668
|
+ |
assert!(parse_graph("sequenceDiagram\n A->>B: hi").is_none());
|
|
3669
|
+ |
}
|
|
3670
|
+ |
|
|
3671
|
+ |
#[test]
|
|
3672
|
+ |
fn html_tags_are_stripped_from_labels() {
|
|
3673
|
+ |
let g = parse_graph("flowchart TD\n A[\"<b>Bold</b> and <i>italic</i>\"] --> B").unwrap();
|
|
3674
|
+ |
assert_eq!(g.nodes[0].label, "Bold and italic");
|
|
3675
|
+ |
}
|
|
3676
|
+ |
|
|
3677
|
+ |
#[test]
|
|
3678
|
+ |
fn br_tag_becomes_a_space() {
|
|
3679
|
+ |
let g = parse_graph("flowchart TD\n A[\"Line1<br/>Line2<br>Line3\"]").unwrap();
|
|
3680
|
+ |
assert_eq!(g.nodes[0].label, "Line1 Line2 Line3");
|
|
3681
|
+ |
}
|
|
3682
|
+ |
|
|
3683
|
+ |
#[test]
|
|
3684
|
+ |
fn markdown_string_strips_bold_italic_and_code() {
|
|
3685
|
+ |
let g = parse_graph(
|
|
3686
|
+ |
"flowchart TD\n A[\"`**Start** here`\"] --> B[\"`Save to **database**`\"]\n B --> C[\"`**Done!**`\"]",
|
|
3687
|
+ |
)
|
|
3688
|
+ |
.unwrap();
|
|
3689
|
+ |
assert_eq!(g.nodes[0].label, "Start here");
|
|
3690
|
+ |
assert_eq!(g.nodes[1].label, "Save to database");
|
|
3691
|
+ |
assert_eq!(g.nodes[2].label, "Done!");
|
|
3692
|
+ |
}
|
|
3693
|
+ |
|
|
3694
|
+ |
#[test]
|
|
3695
|
+ |
fn markdown_string_preserves_snake_case_and_strips_inline_code() {
|
|
3696
|
+ |
let g = parse_graph("flowchart TD\n A[\"`_italic_ uses `vocab_size` with __all__`\"]")
|
|
3697
|
+ |
.unwrap();
|
|
3698
|
+ |
assert_eq!(g.nodes[0].label, "italic uses vocab_size with all");
|
|
3699
|
+ |
}
|
|
3700
|
+ |
|
|
3701
|
+ |
#[test]
|
|
3702
|
+ |
fn markdown_string_edge_label_is_stripped() {
|
|
3703
|
+ |
let g =
|
|
3704
|
+ |
parse_graph("flowchart TD\n A -->|\"`**yes**`\"| B\n A -->|\"`__no__`\"| C").unwrap();
|
|
3705
|
+ |
assert_eq!(g.edges[0].label.as_deref(), Some("yes"));
|
|
3706
|
+ |
assert_eq!(g.edges[1].label.as_deref(), Some("no"));
|
|
3707
|
+ |
}
|
|
3708
|
+ |
|
|
3709
|
+ |
#[test]
|
|
3710
|
+ |
fn plain_label_keeps_literal_text_and_underscores() {
|
|
3711
|
+ |
// Not a markdown string (no backtick wrapper): Mermaid renders it
|
|
3712
|
+ |
// literally, so brackets, snake_case, and any `*`/`_` must survive.
|
|
3713
|
+ |
let g = parse_graph("flowchart TD\n A[\"[ 464, 3797 ] seq_len d_model\"]").unwrap();
|
|
3714
|
+ |
assert_eq!(g.nodes[0].label, "[ 464, 3797 ] seq_len d_model");
|
|
3715
|
+ |
}
|
|
3716
|
+ |
|
|
3717
|
+ |
#[test]
|
|
3718
|
+ |
fn code_and_span_tags_are_stripped() {
|
|
3719
|
+ |
let g = parse_graph(
|
|
3720
|
+ |
"flowchart TD\n A[\"<code>vocab_size</code> <span style=\\\"color:red\\\">x</span>\"]",
|
|
3721
|
+ |
)
|
|
3722
|
+ |
.unwrap();
|
|
3723
|
+ |
assert_eq!(g.nodes[0].label, "vocab_size x");
|
|
3724
|
+ |
}
|
|
3725
|
+ |
|
|
3726
|
+ |
#[test]
|
|
3727
|
+ |
fn bare_angle_brackets_are_kept() {
|
|
3728
|
+ |
let g = parse_graph("flowchart TD\n A[\"a < b and c > d\"]").unwrap();
|
|
3729
|
+ |
assert_eq!(g.nodes[0].label, "a < b and c > d");
|
|
3730
|
+ |
}
|
|
3731
|
+ |
|
|
3732
|
+ |
#[test]
|
|
3733
|
+ |
fn generic_types_are_not_stripped_as_html() {
|
|
3734
|
+ |
// `<String>` / `<i32>` / `<id>` look like tags but are not HTML
|
|
3735
|
+ |
// formatting tags, so they must survive (only b/i/code/span/… etc. and
|
|
3736
|
+ |
// <br> are stripped).
|
|
3737
|
+ |
let g = parse_graph(
|
|
3738
|
+ |
"flowchart TD\n A[\"Returns Vec<String>\"] --> B[\"Option<i32> for <id>\"]",
|
|
3739
|
+ |
)
|
|
3740
|
+ |
.unwrap();
|
|
3741
|
+ |
assert_eq!(g.nodes[0].label, "Returns Vec<String>");
|
|
3742
|
+ |
assert_eq!(g.nodes[1].label, "Option<i32> for <id>");
|
|
3743
|
+ |
}
|
|
3744
|
+ |
|
|
3745
|
+ |
#[test]
|
|
3746
|
+ |
fn decode_html_entities_covers_named_numeric_and_double_escape() {
|
|
3747
|
+ |
assert_eq!(
|
|
3748
|
+ |
decode_html_entities("<a> & "x" 'y'"),
|
|
3749
|
+ |
"<a> & \"x\" 'y'"
|
|
3750
|
+ |
);
|
|
3751
|
+ |
assert_eq!(decode_html_entities("it's <ok>"), "it's <ok>");
|
|
3752
|
+ |
assert_eq!(
|
|
3753
|
+ |
decode_html_entities("<tag> 'q'"),
|
|
3754
|
+ |
"<tag> 'q'"
|
|
3755
|
+ |
);
|
|
3756
|
+ |
// `&lt;` must yield the literal `<`, never `<`.
|
|
3757
|
+ |
assert_eq!(decode_html_entities("&lt;"), "<");
|
|
3758
|
+ |
assert_eq!(decode_html_entities("a &foo; b & c"), "a &foo; b & c");
|
|
3759
|
+ |
// Control chars (NUL collides with CONT, ESC injects ANSI) never decode.
|
|
3760
|
+ |
assert_eq!(decode_html_entities("ab�c"), "ab�c");
|
|
3761
|
+ |
assert_eq!(decode_html_entities("xy"), "xy");
|
|
3762
|
+ |
}
|
|
3763
|
+ |
|
|
3764
|
+ |
#[test]
|
|
3765
|
+ |
fn entity_escaped_flowchart_label_decodes_in_box_art() {
|
|
3766
|
+ |
let src = "flowchart LR\n YAML[\"models-config/<model>/<env>.yaml\\nenterprise_api_config:\"]\n PY[\"model_config_map.py\\nlanguage_model_dict_to_proto()\"]\n YAML --> PY";
|
|
3767
|
+ |
let g = parse_graph(src).unwrap();
|
|
3768
|
+ |
assert!(
|
|
3769
|
+ |
g.nodes[0]
|
|
3770
|
+ |
.label
|
|
3771
|
+ |
.contains("models-config/<model>/<env>.yaml"),
|
|
3772
|
+ |
"{}",
|
|
3773
|
+ |
g.nodes[0].label
|
|
3774
|
+ |
);
|
|
3775
|
+ |
let art = plain(src);
|
|
3776
|
+ |
assert!(art.contains("<model>") && art.contains("<env>"), "{art}");
|
|
3777
|
+ |
assert!(!art.contains("<") && !art.contains(">"), "{art}");
|
|
3778
|
+ |
}
|
|
3779
|
+ |
|
|
3780
|
+ |
#[test]
|
|
3781
|
+ |
fn direct_push_sinks_decode_entities() {
|
|
3782
|
+ |
// Entities contain `;`, which split_statements treats as a separator, so
|
|
3783
|
+ |
// they reach a sink intact only inside quotes; assert through the real
|
|
3784
|
+ |
// parsers where such quoting works.
|
|
3785
|
+ |
let g = parse_state(
|
|
3786
|
+ |
"stateDiagram-v2\n state \"work <job>\" as J\n Idle --> Run: \"on <go>\"\n Run: \"d <e>\"",
|
|
3787
|
+ |
)
|
|
3788
|
+ |
.unwrap();
|
|
3789
|
+ |
let node = |s: &str| g.nodes.iter().any(|n| n.label.contains(s));
|
|
3790
|
+ |
let edge = |s: &str| {
|
|
3791
|
+ |
g.edges
|
|
3792
|
+ |
.iter()
|
|
3793
|
+ |
.any(|e| e.label.as_deref().is_some_and(|l| l.contains(s)))
|
|
3794
|
+ |
};
|
|
3795
|
+ |
assert!(node("work <job>") && node("d <e>") && edge("on <go>"));
|
|
3796
|
+ |
assert!(!node("<") && !edge("<"));
|
|
3797
|
+ |
|
|
3798
|
+ |
let (cg, _) = parse_class("classDiagram\n A --> B : \"uses <X>\"").unwrap();
|
|
3799
|
+ |
assert!(cg.edges.iter().any(|e| {
|
|
3800
|
+ |
e.label
|
|
3801
|
+ |
.as_deref()
|
|
3802
|
+ |
.is_some_and(|l| l.contains("uses <X>") && !l.contains("<"))
|
|
3803
|
+ |
}));
|
|
3804
|
+ |
|
|
3805
|
+ |
let s = parse_sequence(
|
|
3806
|
+ |
"sequenceDiagram\n A->>B: \"call <svc>\"\n Note over A,B: \"memo <o>\"\n alt \"c <x>\"\n A->>B: ok\n end",
|
|
3807
|
+ |
)
|
|
3808
|
+ |
.unwrap();
|
|
3809
|
+ |
assert!(s.items.iter().any(|it| matches!(it,
|
|
3810
|
+ |
SeqItem::Message { text: Some(t), .. } if t.contains("call <svc>") && !t.contains("<"))));
|
|
3811
|
+ |
assert!(s.items.iter().any(|it| matches!(it,
|
|
3812
|
+ |
SeqItem::Note { text, .. } if text.contains("memo <o>") && !text.contains("<"))));
|
|
3813
|
+ |
assert!(s.items.iter().any(|it| matches!(it,
|
|
3814
|
+ |
SeqItem::Divider { text } if text.contains("c <x>") && !text.contains("<"))));
|
|
3815
|
+ |
|
|
3816
|
+ |
// Class members and ER attributes have no clean quoted form (splitter
|
|
3817
|
+ |
// fragments unquoted `;`; ER drops quoted text as a comment), so exercise
|
|
3818
|
+ |
// those decodes at the finalizer directly.
|
|
3819
|
+ |
let mut member = ClassInfo::default();
|
|
3820
|
+ |
push_member(&mut member, "+run <R>");
|
|
3821
|
+ |
assert_eq!(member.attrs, vec!["+run <R>".to_string()]);
|
|
3822
|
+ |
let mut attr = ClassInfo::default();
|
|
3823
|
+ |
push_er_attribute(&mut attr, "string <pk>");
|
|
3824
|
+ |
assert_eq!(attr.attrs, vec!["string <pk>".to_string()]);
|
|
3825
|
+ |
}
|
|
3826
|
+ |
|
|
3827
|
+ |
#[test]
|
|
3828
|
+ |
fn quoted_label_with_inner_brackets_is_one_node() {
|
|
3829
|
+ |
let g = parse_graph(
|
|
3830
|
+ |
"flowchart TD\n IDs[\"<b>Token IDs</b><br/>[ 464, 3797 ]<br/><i>indices</i>\"]",
|
|
3831
|
+ |
)
|
|
3832
|
+ |
.unwrap();
|
|
3833
|
+ |
assert_eq!(g.nodes.len(), 1, "inner brackets must not split the node");
|
|
3834
|
+ |
assert_eq!(g.edges.len(), 0, "no phantom edges from <br/> + brackets");
|
|
3835
|
+ |
assert_eq!(g.nodes[0].label, "Token IDs [ 464, 3797 ] indices");
|
|
3836
|
+ |
}
|
|
3837
|
+ |
|
|
3838
|
+ |
#[test]
|
|
3839
|
+ |
fn unquoted_label_with_embedded_quote_closes_at_bracket() {
|
|
3840
|
+ |
let g = parse_graph("flowchart TD\n A[5\" pipe] --> B[24\" display]").unwrap();
|
|
3841
|
+ |
assert_eq!(g.nodes.len(), 2);
|
|
3842
|
+ |
assert_eq!(g.edges.len(), 1);
|
|
3843
|
+ |
assert_eq!(g.nodes[0].label, "5\" pipe");
|
|
3844
|
+ |
assert_eq!(g.nodes[1].label, "24\" display");
|
|
3845
|
+ |
}
|
|
3846
|
+ |
|
|
3847
|
+ |
#[test]
|
|
3848
|
+ |
fn quoted_label_with_inner_parens_is_one_node() {
|
|
3849
|
+ |
let g =
|
|
3850
|
+ |
parse_graph("flowchart TD\n A[\"Tokenizer (BPE / WordPiece)\"] --> B[Done]").unwrap();
|
|
3851
|
+ |
assert_eq!(g.nodes.len(), 2);
|
|
3852
|
+ |
assert_eq!(g.edges.len(), 1);
|
|
3853
|
+ |
assert_eq!(g.nodes[0].label, "Tokenizer (BPE / WordPiece)");
|
|
3854
|
+ |
}
|
|
3855
|
+ |
|
|
3856
|
+ |
#[test]
|
|
3857
|
+ |
fn diagram_with_html_labels_renders_without_tag_artifacts() {
|
|
3858
|
+ |
let src = "flowchart TD\n IDs[\"<b>3. Token IDs</b><br/>[ 464, 3797 ]<br/><i>indices</i>\"] --> Out[\"<b>done</b>\"]";
|
|
3859
|
+ |
let out = plain(src);
|
|
3860
|
+ |
assert!(!out.contains("<b>"), "raw HTML tag leaked:\n{out}");
|
|
3861
|
+ |
assert!(!out.contains("</"), "raw closing tag leaked:\n{out}");
|
|
3862
|
+ |
assert!(!out.contains("br/"), "phantom br artifact leaked:\n{out}");
|
|
3863
|
+ |
assert!(out.contains("Token IDs"), "label text missing:\n{out}");
|
|
3864
|
+ |
}
|
|
3865
|
+ |
|
|
3866
|
+ |
#[test]
|
|
3867
|
+ |
fn ranks_ignore_back_edges() {
|
|
3868
|
+ |
let g = parse_graph("graph TD\n A-->B\n B-->C\n C-->A").unwrap();
|
|
3869
|
+ |
let r = compute_ranks(&g);
|
|
3870
|
+ |
let idx = |id: &str| g.index[id];
|
|
3871
|
+ |
assert_eq!(r[idx("A")], 0);
|
|
3872
|
+ |
assert_eq!(r[idx("B")], 1);
|
|
3873
|
+ |
assert_eq!(r[idx("C")], 2);
|
|
3874
|
+ |
}
|
|
3875
|
+ |
|
|
3876
|
+ |
#[test]
|
|
3877
|
+ |
fn td_render_has_boxes_labels_and_arrow() {
|
|
3878
|
+ |
let out = plain("graph TD\n A[Start] --> B[End]");
|
|
3879
|
+ |
assert!(out.contains("Start"), "{out}");
|
|
3880
|
+ |
assert!(out.contains("End"), "{out}");
|
|
3881
|
+ |
assert!(out.contains('┌') || out.contains('╭'), "{out}");
|
|
3882
|
+ |
assert!(out.contains('▼'), "{out}");
|
|
3883
|
+ |
}
|
|
3884
|
+ |
|
|
3885
|
+ |
#[test]
|
|
3886
|
+ |
fn edge_label_is_rendered() {
|
|
3887
|
+ |
let out = plain("graph TD\n A-->|yes| B");
|
|
3888
|
+ |
assert!(out.contains("yes"), "{out}");
|
|
3889
|
+ |
}
|
|
3890
|
+ |
|
|
3891
|
+ |
#[test]
|
|
3892
|
+ |
fn lr_is_shorter_than_td_for_a_chain() {
|
|
3893
|
+ |
let chain = "A --> B --> C --> D";
|
|
3894
|
+ |
let td = render(&format!("graph TD\n {chain}"), &styles(), Some(120))
|
|
3895
|
+ |
.unwrap()
|
|
3896
|
+ |
.plain_lines
|
|
3897
|
+ |
.len();
|
|
3898
|
+ |
let lr = render(&format!("flowchart LR\n {chain}"), &styles(), Some(120))
|
|
3899
|
+ |
.unwrap()
|
|
3900
|
+ |
.plain_lines
|
|
3901
|
+ |
.len();
|
|
3902
|
+ |
assert!(lr < td, "expected LR ({lr}) shorter than TD ({td})");
|
|
3903
|
+ |
}
|
|
3904
|
+ |
|
|
3905
|
+ |
#[test]
|
|
3906
|
+ |
fn unsupported_diagram_uses_fallback_box() {
|
|
3907
|
+ |
let out = plain("gantt\n title Plan\n section A\n task :a1, 2024-01-01, 30d");
|
|
3908
|
+ |
assert!(out.contains("mermaid: gantt"), "{out}");
|
|
3909
|
+ |
assert!(out.contains("Plan"), "{out}");
|
|
3910
|
+ |
}
|
|
3911
|
+ |
|
|
3912
|
+ |
#[test]
|
|
3913
|
+ |
fn blank_source_returns_none() {
|
|
3914
|
+ |
assert!(render(" \n ", &styles(), Some(80)).is_none());
|
|
3915
|
+ |
}
|
|
3916
|
+ |
|
|
3917
|
+ |
#[test]
|
|
3918
|
+ |
fn inline_label_with_x_or_o_letters() {
|
|
3919
|
+ |
let g = parse_graph("graph TD\n A -- no exit --> B").unwrap();
|
|
3920
|
+ |
assert_eq!(g.nodes.len(), 2);
|
|
3921
|
+ |
assert_eq!(g.edges.len(), 1);
|
|
3922
|
+ |
assert_eq!(g.edges[0].label.as_deref(), Some("no exit"));
|
|
3923
|
+ |
}
|
|
3924
|
+ |
|
|
3925
|
+ |
#[test]
|
|
3926
|
+ |
fn wide_glyph_box_stays_aligned() {
|
|
3927
|
+ |
let lines = render("graph TD\n A[日本語ab]", &styles(), Some(120))
|
|
3928
|
+ |
.unwrap()
|
|
3929
|
+ |
.plain_lines;
|
|
3930
|
+ |
let widths: Vec<usize> = lines
|
|
3931
|
+ |
.iter()
|
|
3932
|
+ |
.filter(|l| !l.trim().is_empty())
|
|
3933
|
+ |
.map(|l| l.width())
|
|
3934
|
+ |
.collect();
|
|
3935
|
+ |
assert!(
|
|
3936
|
+ |
widths.windows(2).all(|w| w[0] == w[1]),
|
|
3937
|
+ |
"box rows must share one width: {widths:?}\n{lines:?}"
|
|
3938
|
+ |
);
|
|
3939
|
+ |
assert!(!lines.iter().any(|l| l.contains(CONT)), "sentinel leaked");
|
|
3940
|
+ |
}
|
|
3941
|
+ |
|
|
3942
|
+ |
#[test]
|
|
3943
|
+ |
fn merge_has_single_arrowhead() {
|
|
3944
|
+ |
let out = plain("graph TD\n A[aaa] --> D[ddddddd]\n B[bb] --> D\n C[ccccc] --> D");
|
|
3945
|
+ |
let arrows = out.chars().filter(|&c| c == '▼').count();
|
|
3946
|
+ |
assert_eq!(arrows, 1, "merge edges share one arrowhead:\n{out}");
|
|
3947
|
+ |
assert!(!out.contains("▼▼"), "must not stack arrowheads:\n{out}");
|
|
3948
|
+ |
}
|
|
3949
|
+ |
|
|
3950
|
+ |
#[test]
|
|
3951
|
+ |
fn long_label_wraps_without_truncation() {
|
|
3952
|
+ |
let out =
|
|
3953
|
+ |
plain("graph TD\n A[Check if the user has permission to access resource] --> B[Done]");
|
|
3954
|
+ |
assert!(out.contains("permission"), "{out}");
|
|
3955
|
+ |
assert!(out.contains("resource"), "{out}");
|
|
3956
|
+ |
assert!(!out.contains('…'), "should wrap, not truncate:\n{out}");
|
|
3957
|
+ |
}
|
|
3958
|
+ |
|
|
3959
|
+ |
#[test]
|
|
3960
|
+ |
fn very_long_label_truncates_after_max_lines() {
|
|
3961
|
+ |
let long = "alpha ".repeat(40);
|
|
3962
|
+ |
let out = plain(&format!("graph TD\n A[{}] --> B[x]", long.trim()));
|
|
3963
|
+ |
assert!(out.contains('…'), "should truncate past max lines:\n{out}");
|
|
3964
|
+ |
}
|
|
3965
|
+ |
|
|
3966
|
+ |
#[test]
|
|
3967
|
+ |
fn wrap_label_breaks_long_identifier_on_boundary() {
|
|
3968
|
+ |
let lines = wrap_label("mark_filter_restore_context", WRAP_WIDTH, MAX_LINES);
|
|
3969
|
+ |
// The first line ends on an identifier boundary, not a mid-segment slice.
|
|
3970
|
+ |
assert!(
|
|
3971
|
+ |
lines[0].ends_with('_'),
|
|
3972
|
+ |
"first line must end on a boundary: {lines:?}"
|
|
3973
|
+ |
);
|
|
3974
|
+ |
// Every break (all but the last line) lands on a boundary char.
|
|
3975
|
+ |
for line in &lines[..lines.len() - 1] {
|
|
3976
|
+ |
assert!(
|
|
3977
|
+ |
line.ends_with(LABEL_BREAK_CHARS),
|
|
3978
|
+ |
"line must break on a boundary: {line:?}"
|
|
3979
|
+ |
);
|
|
3980
|
+ |
}
|
|
3981
|
+ |
// Nothing is lost: the wrapped lines reconstruct the original word.
|
|
3982
|
+ |
assert_eq!(lines.concat(), "mark_filter_restore_context");
|
|
3983
|
+ |
}
|
|
3984
|
+ |
|
|
3985
|
+ |
#[test]
|
|
3986
|
+ |
fn wrap_label_token_without_break_char_falls_back_per_char() {
|
|
3987
|
+ |
let token = "a".repeat(40);
|
|
3988
|
+ |
let lines = wrap_label(&token, WRAP_WIDTH, MAX_LINES);
|
|
3989
|
+ |
// No boundary char -> per-char hard break across multiple lines.
|
|
3990
|
+ |
assert!(lines.len() >= 2, "must hard-break: {lines:?}");
|
|
3991
|
+ |
// 40 narrow chars fit in <= MAX_LINES, so nothing is truncated or lost.
|
|
3992
|
+ |
assert_eq!(lines.concat(), token);
|
|
3993
|
+ |
}
|
|
3994
|
+ |
|
|
3995
|
+ |
#[test]
|
|
3996
|
+ |
fn flowchart_long_identifier_breaks_on_boundary_not_mid_segment() {
|
|
3997
|
+ |
let out = plain("graph TD\n A[mark_filter_restore_context] --> B[Done]");
|
|
3998
|
+ |
// The boundary-respecting pieces are present in the rendered art; the
|
|
3999
|
+ |
// `wrap_label_breaks_long_identifier_on_boundary` unit test proves there
|
|
4000
|
+ |
// is no mid-segment slice (losslessly), so no offset-coupled guard here.
|
|
4001
|
+ |
assert!(out.contains("mark_filter_restore_"), "{out}");
|
|
4002
|
+ |
assert!(out.contains("context"), "{out}");
|
|
4003
|
+ |
}
|
|
4004
|
+ |
|
|
4005
|
+ |
#[test]
|
|
4006
|
+ |
fn wrap_label_mixed_boundary_then_no_boundary_tail() {
|
|
4007
|
+ |
let token = String::from("ab_") + &"c".repeat(40);
|
|
4008
|
+ |
let lines = wrap_label(&token, WRAP_WIDTH, MAX_LINES);
|
|
4009
|
+ |
// The boundary is taken first ...
|
|
4010
|
+ |
assert!(
|
|
4011
|
+ |
lines[0].ends_with('_'),
|
|
4012
|
+ |
"first break on boundary: {lines:?}"
|
|
4013
|
+ |
);
|
|
4014
|
+ |
// ... then the long no-boundary tail falls back to a per-char break.
|
|
4015
|
+ |
assert!(
|
|
4016
|
+ |
lines[1..].iter().any(|l| !l.contains(LABEL_BREAK_CHARS)),
|
|
4017
|
+ |
"a later line must be a per-char break: {lines:?}"
|
|
4018
|
+ |
);
|
|
4019
|
+ |
// 43 cols < MAX_LINES*WRAP_WIDTH, so it must not truncate; fully lossless.
|
|
4020
|
+ |
assert_eq!(lines.concat(), token);
|
|
4021
|
+ |
}
|
|
4022
|
+ |
|
|
4023
|
+ |
#[test]
|
|
4024
|
+ |
fn wrap_label_boundary_breaking_still_truncates_at_max_lines() {
|
|
4025
|
+ |
let id = ["segment"; 20].join("_");
|
|
4026
|
+ |
let lines = wrap_label(&id, WRAP_WIDTH, MAX_LINES);
|
|
4027
|
+ |
// The identifier far exceeds MAX_LINES*WRAP_WIDTH, so it truncates ...
|
|
4028
|
+ |
assert_eq!(lines.len(), MAX_LINES);
|
|
4029
|
+ |
// ... with the ellipsis still on the final line.
|
|
4030
|
+ |
assert!(
|
|
4031
|
+ |
lines.last().unwrap().ends_with('…'),
|
|
4032
|
+ |
"truncation must keep the ellipsis: {lines:?}"
|
|
4033
|
+ |
);
|
|
4034
|
+ |
}
|
|
4035
|
+ |
|
|
4036
|
+ |
#[test]
|
|
4037
|
+ |
fn bt_flips_orientation() {
|
|
4038
|
+ |
let out = plain("flowchart BT\n A[first] --> B[second] --> C[third]");
|
|
4039
|
+ |
let lines: Vec<&str> = out.lines().collect();
|
|
4040
|
+ |
let row = |needle: &str| lines.iter().position(|l| l.contains(needle)).unwrap();
|
|
4041
|
+ |
assert!(
|
|
4042
|
+ |
row("third") < row("first"),
|
|
4043
|
+ |
"BT: 'third' should sit above 'first':\n{out}"
|
|
4044
|
+ |
);
|
|
4045
|
+ |
}
|
|
4046
|
+ |
|
|
4047
|
+ |
#[test]
|
|
4048
|
+ |
fn rl_flips_orientation() {
|
|
4049
|
+ |
let out = plain("flowchart RL\n A[first] --> B[second] --> C[third]");
|
|
4050
|
+ |
let line = out.lines().find(|l| l.contains("first")).unwrap();
|
|
4051
|
+ |
assert!(
|
|
4052
|
+ |
line.find("third") < line.find("first"),
|
|
4053
|
+ |
"RL: 'third' should sit left of 'first':\n{out}"
|
|
4054
|
+ |
);
|
|
4055
|
+ |
}
|
|
4056
|
+ |
|
|
4057
|
+ |
#[test]
|
|
4058
|
+ |
fn undirected_piped_label_has_no_arrowhead() {
|
|
4059
|
+ |
let out = plain("graph TD\n A ---|maybe| B");
|
|
4060
|
+ |
assert!(out.contains("maybe"), "{out}");
|
|
4061
|
+ |
assert!(
|
|
4062
|
+ |
!out.contains('▼'),
|
|
4063
|
+ |
"undirected link should not draw an arrow:\n{out}"
|
|
4064
|
+ |
);
|
|
4065
|
+ |
}
|
|
4066
|
+ |
|
|
4067
|
+ |
#[test]
|
|
4068
|
+ |
fn chain_edges_are_straight() {
|
|
4069
|
+ |
let out = plain("graph TD\n A[aaaa] --> B[b] --> C[cccccccc]");
|
|
4070
|
+ |
for line in out.lines() {
|
|
4071
|
+ |
assert!(
|
|
4072
|
+ |
!line.contains('└') || !line.contains('┐'),
|
|
4073
|
+ |
"chain should not jog: {line:?}"
|
|
4074
|
+ |
);
|
|
4075
|
+ |
}
|
|
4076
|
+ |
}
|
|
4077
|
+ |
|
|
4078
|
+ |
#[test]
|
|
4079
|
+ |
fn adversarial_chain_falls_back() {
|
|
4080
|
+ |
let mut src = String::from("graph TD\n");
|
|
4081
|
+ |
for i in 0..10_000 {
|
|
4082
|
+ |
src.push_str(&format!(" N{i} --> N{}\n", i + 1));
|
|
4083
|
+ |
}
|
|
4084
|
+ |
let out = plain(&src);
|
|
4085
|
+ |
assert!(out.contains("mermaid: graph"), "expected fallback:\n{out}");
|
|
4086
|
+ |
}
|
|
4087
|
+ |
|
|
4088
|
+ |
#[test]
|
|
4089
|
+ |
fn single_statement_chain_over_cap_falls_back() {
|
|
4090
|
+ |
let mut src = String::from("graph LR\n ");
|
|
4091
|
+ |
for i in 0..10_000 {
|
|
4092
|
+ |
src.push_str(&format!("N{i}-->"));
|
|
4093
|
+ |
}
|
|
4094
|
+ |
src.push_str("N10000");
|
|
4095
|
+ |
let out = plain(&src);
|
|
4096
|
+ |
assert!(out.contains("mermaid: graph"), "expected fallback");
|
|
4097
|
+ |
}
|
|
4098
|
+ |
|
|
4099
|
+ |
#[test]
|
|
4100
|
+ |
fn deep_chain_within_caps_renders() {
|
|
4101
|
+ |
let mut src = String::from("graph TD\n");
|
|
4102
|
+ |
for i in 0..100 {
|
|
4103
|
+ |
src.push_str(&format!(" N{i} --> N{}\n", i + 1));
|
|
4104
|
+ |
}
|
|
4105
|
+ |
let out = render(&src, &styles(), Some(200)).unwrap().plain_lines;
|
|
4106
|
+ |
let joined = out.join("\n");
|
|
4107
|
+ |
assert!(joined.contains("N0"), "{joined}");
|
|
4108
|
+ |
assert!(joined.contains("N100"), "{joined}");
|
|
4109
|
+ |
assert!(joined.contains('▼'), "{joined}");
|
|
4110
|
+ |
}
|
|
4111
|
+ |
|
|
4112
|
+ |
#[test]
|
|
4113
|
+ |
fn fallback_styled_and_plain_widths_match() {
|
|
4114
|
+ |
let art = render("gantt\n title Plan\n a\n", &styles(), Some(120)).unwrap();
|
|
4115
|
+ |
assert_eq!(art.styled_lines.len(), art.plain_lines.len());
|
|
4116
|
+ |
let frame_w = art.plain_lines[0].width();
|
|
4117
|
+ |
for (styled, plain) in art.styled_lines.iter().zip(&art.plain_lines) {
|
|
4118
|
+ |
let styled_w: usize = styled
|
|
4119
|
+ |
.spans
|
|
4120
|
+ |
.iter()
|
|
4121
|
+ |
.map(|s| s.content.as_ref().width())
|
|
4122
|
+ |
.sum();
|
|
4123
|
+ |
assert_eq!(styled_w, plain.width(), "styled/plain widths diverge");
|
|
4124
|
+ |
assert_eq!(plain.width(), frame_w, "fallback box must be rectangular");
|
|
4125
|
+ |
}
|
|
4126
|
+ |
}
|
|
4127
|
+ |
|
|
4128
|
+ |
#[test]
|
|
4129
|
+ |
fn over_wide_diagram_falls_back() {
|
|
4130
|
+ |
let src = "flowchart LR\n A[aaaaaaaaaaaaaaaaaaaa] --> B[bbbbbbbbbbbbbbbbbbbb] --> C[cccccccccccccccccccc]";
|
|
4131
|
+ |
let out = render(src, &styles(), Some(40)).unwrap().plain_lines;
|
|
4132
|
+ |
let joined = out.join("\n");
|
|
4133
|
+ |
assert!(
|
|
4134
|
+ |
joined.contains("mermaid: flowchart"),
|
|
4135
|
+ |
"expected fallback for over-wide diagram:\n{joined}"
|
|
4136
|
+ |
);
|
|
4137
|
+ |
let max_w = out.iter().map(|l| l.width()).max().unwrap_or(0);
|
|
4138
|
+ |
let fits = render(src, &styles(), Some(120)).unwrap().plain_lines;
|
|
4139
|
+ |
assert!(
|
|
4140
|
+ |
fits.iter().any(|l| l.contains('▶')),
|
|
4141
|
+ |
"same diagram should render when it fits"
|
|
4142
|
+ |
);
|
|
4143
|
+ |
assert!(max_w <= src.len(), "fallback width bounded by source");
|
|
4144
|
+ |
}
|
|
4145
|
+ |
|
|
4146
|
+ |
#[test]
|
|
4147
|
+ |
fn too_wide_fallback_appends_hint_below_box() {
|
|
4148
|
+ |
let src = "flowchart LR\n A[aaaaaaaaaaaaaaaaaaaa] --> B[bbbbbbbbbbbbbbbbbbbb] --> C[cccccccccccccccccccc]";
|
|
4149
|
+ |
let out = render(src, &styles(), Some(40)).unwrap().plain_lines;
|
|
4150
|
+ |
let joined = out.join("\n");
|
|
4151
|
+ |
|
|
4152
|
+ |
assert!(
|
|
4153
|
+ |
joined.contains("mermaid: flowchart"),
|
|
4154
|
+ |
"plain header:\n{joined}"
|
|
4155
|
+ |
);
|
|
4156
|
+ |
assert!(
|
|
4157
|
+ |
!joined.contains("(too wide)"),
|
|
4158
|
+ |
"header stays plain:\n{joined}"
|
|
4159
|
+ |
);
|
|
4160
|
+ |
assert!(
|
|
4161
|
+ |
joined.contains("flowchart LR"),
|
|
4162
|
+ |
"raw source kept:\n{joined}"
|
|
4163
|
+ |
);
|
|
4164
|
+ |
|
|
4165
|
+ |
let bottom = out
|
|
4166
|
+ |
.iter()
|
|
4167
|
+ |
.position(|l| l.contains('╰'))
|
|
4168
|
+ |
.expect("box bottom");
|
|
4169
|
+ |
let note = out
|
|
4170
|
+ |
.iter()
|
|
4171
|
+ |
.position(|l| l.contains("too wide"))
|
|
4172
|
+ |
.expect("note row");
|
|
4173
|
+ |
assert!(note > bottom, "note must be below the box:\n{joined}");
|
|
4174
|
+ |
assert!(
|
|
4175
|
+ |
joined.contains("open the image"),
|
|
4176
|
+ |
"note points at the image:\n{joined}"
|
|
4177
|
+ |
);
|
|
4178
|
+ |
|
|
4179
|
+ |
assert!(
|
|
4180
|
+ |
out.iter().all(|l| l.width() <= 40),
|
|
4181
|
+ |
"fits 40 cols:\n{joined}"
|
|
4182
|
+ |
);
|
|
4183
|
+ |
}
|
|
4184
|
+ |
|
|
4185
|
+ |
#[test]
|
|
4186
|
+ |
fn unsupported_diagram_fallback_not_flagged_too_wide() {
|
|
4187
|
+ |
let out = plain("gantt\n title Plan\n section A\n task :a1, 2024-01-01, 30d");
|
|
4188
|
+ |
assert!(out.contains("mermaid: gantt"), "{out}");
|
|
4189
|
+ |
assert!(
|
|
4190
|
+ |
!out.contains("too wide"),
|
|
4191
|
+ |
"unsupported type is not a width problem:\n{out}"
|
|
4192
|
+ |
);
|
|
4193
|
+ |
}
|
|
4194
|
+ |
|
|
4195
|
+ |
#[test]
|
|
4196
|
+ |
fn fitting_diagram_has_no_width_warning() {
|
|
4197
|
+ |
let out = plain("flowchart LR\n A[Start] --> B[End]");
|
|
4198
|
+ |
assert!(
|
|
4199
|
+ |
!out.contains("too wide"),
|
|
4200
|
+ |
"fitting diagram must not warn:\n{out}"
|
|
4201
|
+ |
);
|
|
4202
|
+ |
assert!(
|
|
4203
|
+ |
!out.contains("mermaid: flowchart"),
|
|
4204
|
+ |
"should draw art, not box:\n{out}"
|
|
4205
|
+ |
);
|
|
4206
|
+ |
assert!(out.contains('▶'), "should draw edges:\n{out}");
|
|
4207
|
+ |
}
|
|
4208
|
+ |
|
|
4209
|
+ |
#[test]
|
|
4210
|
+ |
fn bidirectional_link_draws_both_arrowheads() {
|
|
4211
|
+ |
let lr = plain("flowchart LR\n A <--> B");
|
|
4212
|
+ |
assert!(lr.contains('◄') && lr.contains('▶'), "{lr}");
|
|
4213
|
+ |
let td = plain("graph TD\n A <--> B");
|
|
4214
|
+ |
assert!(td.contains('▲') && td.contains('▼'), "{td}");
|
|
4215
|
+ |
}
|
|
4216
|
+ |
|
|
4217
|
+ |
#[test]
|
|
4218
|
+ |
fn reversed_arrow_swaps_edge_direction() {
|
|
4219
|
+ |
let g = parse_graph("graph TD\n A <-- B").unwrap();
|
|
4220
|
+ |
let idx = |id: &str| g.index[id];
|
|
4221
|
+ |
assert_eq!(g.edges.len(), 1);
|
|
4222
|
+ |
assert_eq!(g.edges[0].from, idx("B"));
|
|
4223
|
+ |
assert_eq!(g.edges[0].to, idx("A"));
|
|
4224
|
+ |
assert_eq!(g.edges[0].head_to, Head::Arrow);
|
|
4225
|
+ |
assert_eq!(g.edges[0].head_from, Head::None);
|
|
4226
|
+ |
let out = plain("graph TD\n A <-- B");
|
|
4227
|
+ |
let lines: Vec<&str> = out.lines().collect();
|
|
4228
|
+ |
let row = |needle: &str| lines.iter().position(|l| l.contains(needle)).unwrap();
|
|
4229
|
+ |
assert!(row("B") < row("A"), "B should rank above A:\n{out}");
|
|
4230
|
+ |
}
|
|
4231
|
+ |
|
|
4232
|
+ |
#[test]
|
|
4233
|
+ |
fn semicolon_and_comment_survive_inside_quoted_label() {
|
|
4234
|
+ |
let g = parse_graph("graph TD\n A[\"wait; 50%% done\"] --> B").unwrap();
|
|
4235
|
+ |
assert_eq!(g.nodes.len(), 2);
|
|
4236
|
+ |
assert_eq!(g.nodes[0].label, "wait; 50%% done");
|
|
4237
|
+ |
}
|
|
4238
|
+ |
|
|
4239
|
+ |
#[test]
|
|
4240
|
+ |
fn comment_outside_quotes_is_stripped() {
|
|
4241
|
+ |
let g =
|
|
4242
|
+ |
parse_graph("graph TD %% main flow\n A --> B %% trailing\n %% full line\n").unwrap();
|
|
4243
|
+ |
assert_eq!(g.nodes.len(), 2);
|
|
4244
|
+ |
assert_eq!(g.edges.len(), 1);
|
|
4245
|
+ |
}
|
|
4246
|
+ |
|
|
4247
|
+ |
#[test]
|
|
4248
|
+ |
fn skip_edge_routes_around_intermediate_boxes() {
|
|
4249
|
+ |
let out = plain("graph TD\n A --> B\n B --> C\n A --> C");
|
|
4250
|
+ |
assert!(!out.contains('┼'), "no border corruption:\n{out}");
|
|
4251
|
+ |
assert!(
|
|
4252
|
+ |
out.contains('◄'),
|
|
4253
|
+ |
"skip edge enters target from lane:\n{out}"
|
|
4254
|
+ |
);
|
|
4255
|
+ |
}
|
|
4256
|
+ |
|
|
4257
|
+ |
fn ordered_ranks(src: &str) -> (Graph, Vec<usize>, Vec<Vec<usize>>) {
|
|
4258
|
+ |
let g = parse_graph(src).unwrap();
|
|
4259
|
+ |
let ranks = compute_ranks(&g);
|
|
4260
|
+ |
let max_rank = *ranks.iter().max().unwrap();
|
|
4261
|
+ |
let mut by_rank: Vec<Vec<usize>> = vec![Vec::new(); max_rank + 1];
|
|
4262
|
+ |
for (idx, &r) in ranks.iter().enumerate() {
|
|
4263
|
+ |
by_rank[r].push(idx);
|
|
4264
|
+ |
}
|
|
4265
|
+ |
order_ranks(&mut by_rank, &g.edges, &ranks);
|
|
4266
|
+ |
(g, ranks, by_rank)
|
|
4267
|
+ |
}
|
|
4268
|
+ |
|
|
4269
|
+ |
#[test]
|
|
4270
|
+ |
fn order_ranks_removes_avoidable_crossing() {
|
|
4271
|
+ |
let (g, ranks, by_rank) = ordered_ranks("graph TD\n C[ccc]\n D[ddd]\n A --> D\n B --> C");
|
|
4272
|
+ |
let mut pos = vec![0usize; g.nodes.len()];
|
|
4273
|
+ |
for row in &by_rank {
|
|
4274
|
+ |
for (i, &v) in row.iter().enumerate() {
|
|
4275
|
+ |
pos[v] = i;
|
|
4276
|
+ |
}
|
|
4277
|
+ |
}
|
|
4278
|
+ |
assert_eq!(count_crossings(&g.edges, &ranks, &pos), 0);
|
|
4279
|
+ |
let idx = |id: &str| g.index[id];
|
|
4280
|
+ |
assert!(pos[idx("D")] < pos[idx("C")], "D follows parent A leftward");
|
|
4281
|
+ |
}
|
|
4282
|
+ |
|
|
4283
|
+ |
#[test]
|
|
4284
|
+ |
fn order_ranks_keeps_crossing_free_order() {
|
|
4285
|
+ |
let (g, ranks, by_rank) = ordered_ranks("graph TD\n A --> C\n B --> D");
|
|
4286
|
+ |
let idx = |id: &str| g.index[id];
|
|
4287
|
+ |
assert_eq!(by_rank[0], vec![idx("A"), idx("B")]);
|
|
4288
|
+ |
assert_eq!(by_rank[1], vec![idx("C"), idx("D")]);
|
|
4289
|
+ |
let mut pos = vec![0usize; g.nodes.len()];
|
|
4290
|
+ |
for row in &by_rank {
|
|
4291
|
+ |
for (i, &v) in row.iter().enumerate() {
|
|
4292
|
+ |
pos[v] = i;
|
|
4293
|
+ |
}
|
|
4294
|
+ |
}
|
|
4295
|
+ |
assert_eq!(count_crossings(&g.edges, &ranks, &pos), 0);
|
|
4296
|
+ |
}
|
|
4297
|
+ |
|
|
4298
|
+ |
#[test]
|
|
4299
|
+ |
fn crossing_edges_render_untangled() {
|
|
4300
|
+ |
let out = plain("graph TD\n C[ccc]\n D[ddd]\n A --> D\n B --> C");
|
|
4301
|
+ |
let row = out
|
|
4302
|
+ |
.lines()
|
|
4303
|
+ |
.find(|l| l.contains("ccc") && l.contains("ddd"))
|
|
4304
|
+ |
.unwrap();
|
|
4305
|
+ |
assert!(
|
|
4306
|
+ |
row.find("ddd") < row.find("ccc"),
|
|
4307
|
+ |
"children reorder under their parents:\n{out}"
|
|
4308
|
+ |
);
|
|
4309
|
+ |
assert!(!out.contains('┼'), "{out}");
|
|
4310
|
+ |
}
|
|
4311
|
+ |
|
|
4312
|
+ |
#[test]
|
|
4313
|
+ |
fn three_layer_weave_untangles() {
|
|
4314
|
+ |
let (g, ranks, by_rank) = ordered_ranks(
|
|
4315
|
+ |
"graph TD\n X[x]\n Y[y]\n A --> Y\n B --> X\n X --> Q\n Y --> P\n P[p]\n Q[q]",
|
|
4316
|
+ |
);
|
|
4317
|
+ |
let mut pos = vec![0usize; g.nodes.len()];
|
|
4318
|
+ |
for row in &by_rank {
|
|
4319
|
+ |
for (i, &v) in row.iter().enumerate() {
|
|
4320
|
+ |
pos[v] = i;
|
|
4321
|
+ |
}
|
|
4322
|
+ |
}
|
|
4323
|
+ |
assert_eq!(
|
|
4324
|
+ |
count_crossings(&g.edges, &ranks, &pos),
|
|
4325
|
+ |
0,
|
|
4326
|
+ |
"both layers untangle"
|
|
4327
|
+ |
);
|
|
4328
|
+ |
}
|
|
4329
|
+ |
|
|
4330
|
+ |
#[test]
|
|
4331
|
+ |
fn unavoidable_crossing_gets_separate_bus_rows() {
|
|
4332
|
+ |
let crossing = plain("graph TD\n A --> D[ddd]\n A --> C[ccc]\n B --> C\n B --> D");
|
|
4333
|
+ |
let parallel = plain("graph TD\n A --> C[ccc]\n B --> D[ddd]");
|
|
4334
|
+ |
assert!(crossing.contains('┼'), "wire crossing renders:\n{crossing}");
|
|
4335
|
+ |
assert_eq!(
|
|
4336
|
+ |
crossing.lines().count(),
|
|
4337
|
+ |
parallel.lines().count() + 1,
|
|
4338
|
+ |
"crossing pair claims one extra bus row:\n{crossing}"
|
|
4339
|
+ |
);
|
|
4340
|
+ |
assert_eq!(
|
|
4341
|
+ |
crossing.chars().filter(|&c| c == '▼').count(),
|
|
4342
|
+ |
2,
|
|
4343
|
+ |
"{crossing}"
|
|
4344
|
+ |
);
|
|
4345
|
+ |
}
|
|
4346
|
+ |
|
|
4347
|
+ |
#[test]
|
|
4348
|
+ |
fn fan_out_keeps_single_bus_row() {
|
|
4349
|
+ |
let out = plain("graph TD\n A --> C[ccc]\n A --> D[ddd]");
|
|
4350
|
+ |
let baseline = plain("graph TD\n A --> C[ccc]");
|
|
4351
|
+ |
assert_eq!(
|
|
4352
|
+ |
out.lines().count(),
|
|
4353
|
+ |
baseline.lines().count(),
|
|
4354
|
+ |
"shared-source jogs share one bus row:\n{out}"
|
|
4355
|
+ |
);
|
|
4356
|
+ |
assert!(!out.contains('┼'), "{out}");
|
|
4357
|
+ |
}
|
|
4358
|
+ |
|
|
4359
|
+ |
#[test]
|
|
4360
|
+ |
fn shared_target_back_edges_share_one_lane() {
|
|
4361
|
+ |
let two = plain("graph TD\n A --> B\n B --> C\n B --> A\n C --> A");
|
|
4362
|
+ |
let one = plain("graph TD\n A --> B\n B --> C\n C --> A");
|
|
4363
|
+ |
assert_eq!(
|
|
4364
|
+ |
two.lines().map(|l| l.width()).max(),
|
|
4365
|
+ |
one.lines().map(|l| l.width()).max(),
|
|
4366
|
+ |
"shared-target back edges merge into one lane:\n{two}"
|
|
4367
|
+ |
);
|
|
4368
|
+ |
assert_eq!(two.matches('◄').count(), 1, "{two}");
|
|
4369
|
+ |
}
|
|
4370
|
+ |
|
|
4371
|
+ |
#[test]
|
|
4372
|
+ |
fn distinct_back_edges_get_separate_lanes() {
|
|
4373
|
+ |
let split = plain("graph TD\n A --> B\n B --> C\n B --> A\n C --> B");
|
|
4374
|
+ |
let single = plain("graph TD\n A --> B\n B --> C\n C --> B");
|
|
4375
|
+ |
assert_eq!(split.matches('◄').count(), 2, "{split}");
|
|
4376
|
+ |
assert!(
|
|
4377
|
+ |
split.lines().map(|l| l.width()).max() > single.lines().map(|l| l.width()).max(),
|
|
4378
|
+ |
"overlapping unrelated back edges claim a second lane:\n{split}"
|
|
4379
|
+ |
);
|
|
4380
|
+ |
}
|
|
4381
|
+ |
|
|
4382
|
+ |
#[test]
|
|
4383
|
+ |
fn fallback_wraps_long_lines_to_max_width() {
|
|
4384
|
+ |
let out = render(
|
|
4385
|
+ |
"gantt\n title a very long line that should wrap inside the fallback box nicely",
|
|
4386
|
+ |
&styles(),
|
|
4387
|
+ |
Some(40),
|
|
4388
|
+ |
)
|
|
4389
|
+ |
.unwrap()
|
|
4390
|
+ |
.plain_lines;
|
|
4391
|
+ |
assert!(out.iter().all(|l| l.width() <= 40), "{}", out.join("\n"));
|
|
4392
|
+ |
for line in &out[1..out.len() - 1] {
|
|
4393
|
+ |
assert!(
|
|
4394
|
+ |
line.starts_with('│') && line.ends_with('│'),
|
|
4395
|
+ |
"body rows keep both borders: {line:?}"
|
|
4396
|
+ |
);
|
|
4397
|
+ |
}
|
|
4398
|
+ |
assert!(out.join("\n").contains("nicely"), "{}", out.join("\n"));
|
|
4399
|
+ |
}
|
|
4400
|
+ |
|
|
4401
|
+ |
#[test]
|
|
4402
|
+ |
fn class_renders_compartments() {
|
|
4403
|
+ |
let out = plain(
|
|
4404
|
+ |
"classDiagram\n class Animal {\n +int age\n +isMammal() bool\n }\n Animal <|-- Duck",
|
|
4405
|
+ |
);
|
|
4406
|
+ |
assert!(out.contains("Animal"), "{out}");
|
|
4407
|
+ |
assert!(out.contains("+int age"), "{out}");
|
|
4408
|
+ |
assert!(out.contains("+isMammal() bool"), "{out}");
|
|
4409
|
+ |
assert!(
|
|
4410
|
+ |
out.contains('├') && out.contains('┤'),
|
|
4411
|
+ |
"section rules:\n{out}"
|
|
4412
|
+ |
);
|
|
4413
|
+ |
let lines: Vec<&str> = out.lines().collect();
|
|
4414
|
+ |
let name = lines.iter().position(|l| l.contains("Animal")).unwrap();
|
|
4415
|
+ |
let attr = lines.iter().position(|l| l.contains("+int age")).unwrap();
|
|
4416
|
+ |
let method = lines
|
|
4417
|
+ |
.iter()
|
|
4418
|
+ |
.position(|l| l.contains("+isMammal() bool"))
|
|
4419
|
+ |
.unwrap();
|
|
4420
|
+ |
assert!(name < attr && attr < method, "{out}");
|
|
4421
|
+ |
}
|
|
4422
|
+ |
|
|
4423
|
+ |
#[test]
|
|
4424
|
+ |
fn class_inheritance_triangle_at_parent() {
|
|
4425
|
+ |
let out = plain("classDiagram\n Animal <|-- Duck\n Animal <|-- Fish");
|
|
4426
|
+ |
assert!(out.contains('△'), "hollow triangle:\n{out}");
|
|
4427
|
+ |
let lines: Vec<&str> = out.lines().collect();
|
|
4428
|
+ |
let animal = lines.iter().position(|l| l.contains("Animal")).unwrap();
|
|
4429
|
+ |
let duck = lines.iter().position(|l| l.contains("Duck")).unwrap();
|
|
4430
|
+ |
assert!(animal < duck, "parent above child:\n{out}");
|
|
4431
|
+ |
let tri = lines.iter().position(|l| l.contains('△')).unwrap();
|
|
4432
|
+ |
assert!(
|
|
4433
|
+ |
tri >= animal && tri < duck,
|
|
4434
|
+ |
"triangle at parent end:\n{out}"
|
|
4435
|
+ |
);
|
|
4436
|
+ |
}
|
|
4437
|
+ |
|
|
4438
|
+ |
#[test]
|
|
4439
|
+ |
fn class_realization_is_dotted_triangle() {
|
|
4440
|
+ |
let g = parse_class("classDiagram\n IShape <|.. Circle").unwrap().0;
|
|
4441
|
+ |
assert_eq!(g.edges[0].head_from, Head::Triangle);
|
|
4442
|
+ |
assert!(g.edges[0].line == LineKind::Dotted);
|
|
4443
|
+ |
let out = plain("classDiagram\n IShape <|.. Circle");
|
|
4444
|
+ |
assert!(out.contains('╎') || out.contains('╌'), "{out}");
|
|
4445
|
+ |
}
|
|
4446
|
+ |
|
|
4447
|
+ |
#[test]
|
|
4448
|
+ |
fn class_composition_and_aggregation_diamonds() {
|
|
4449
|
+ |
let out = plain("classDiagram\n Car *-- Engine\n Pond o-- Duck");
|
|
4450
|
+ |
assert!(out.contains('◆'), "filled diamond:\n{out}");
|
|
4451
|
+ |
assert!(out.contains('◇'), "open diamond:\n{out}");
|
|
4452
|
+ |
}
|
|
4453
|
+ |
|
|
4454
|
+ |
#[test]
|
|
4455
|
+ |
fn class_dependency_dotted_arrow() {
|
|
4456
|
+ |
let g = parse_class("classDiagram\n A ..> B").unwrap().0;
|
|
4457
|
+ |
assert_eq!(g.edges[0].head_to, Head::Arrow);
|
|
4458
|
+ |
assert!(g.edges[0].line == LineKind::Dotted);
|
|
4459
|
+ |
}
|
|
4460
|
+ |
|
|
4461
|
+ |
#[test]
|
|
4462
|
+ |
fn class_colon_members_merge_with_block() {
|
|
4463
|
+ |
let out = plain(
|
|
4464
|
+ |
"classDiagram\n class Duck {\n +swim()\n }\n Duck : +String beakColor\n S --> Duck",
|
|
4465
|
+ |
);
|
|
4466
|
+ |
assert!(out.contains("+swim()"), "{out}");
|
|
4467
|
+ |
assert!(out.contains("+String beakColor"), "{out}");
|
|
4468
|
+ |
}
|
|
4469
|
+ |
|
|
4470
|
+ |
#[test]
|
|
4471
|
+ |
fn class_annotation_renders_guillemets() {
|
|
4472
|
+ |
let out = plain("classDiagram\n <<interface>> Shape\n Shape <|.. Circle");
|
|
4473
|
+ |
assert!(out.contains("«interface»"), "{out}");
|
|
4474
|
+ |
}
|
|
4475
|
+ |
|
|
4476
|
+ |
#[test]
|
|
4477
|
+ |
fn class_generics_display_as_angle_brackets() {
|
|
4478
|
+ |
let out = plain("classDiagram\n Shape~T~ : +area() T\n S --> Shape~T~");
|
|
4479
|
+ |
assert!(out.contains("Shape<T>"), "{out}");
|
|
4480
|
+ |
assert!(!out.contains('~'), "{out}");
|
|
4481
|
+ |
}
|
|
4482
|
+ |
|
|
4483
|
+ |
#[test]
|
|
4484
|
+ |
fn class_cardinalities_fold_into_label() {
|
|
4485
|
+ |
let out = plain("classDiagram\n Student \"many\" --> \"1\" School : attends");
|
|
4486
|
+ |
assert!(out.contains("many attends 1"), "{out}");
|
|
4487
|
+ |
}
|
|
4488
|
+ |
|
|
4489
|
+ |
#[test]
|
|
4490
|
+ |
fn class_from_end_head_survives_fan_out_jog() {
|
|
4491
|
+ |
let out = plain("classDiagram\n Animal <|-- Duck\n Animal <|-- Fish\n Animal <|-- Cow");
|
|
4492
|
+ |
assert_eq!(
|
|
4493
|
+ |
out.matches('△').count() + out.matches('▽').count(),
|
|
4494
|
+ |
1,
|
|
4495
|
+ |
"merged from-end glyph on the parent border:\n{out}"
|
|
4496
|
+ |
);
|
|
4497
|
+ |
}
|
|
4498
|
+ |
|
|
4499
|
+ |
#[test]
|
|
4500
|
+ |
fn class_empty_class_is_plain_titled_box() {
|
|
4501
|
+ |
let out = plain("classDiagram\n class Loner\n A --> Loner");
|
|
4502
|
+ |
assert!(out.contains("Loner"), "{out}");
|
|
4503
|
+ |
}
|
|
4504
|
+ |
|
|
4505
|
+ |
#[test]
|
|
4506
|
+ |
fn class_unknown_statement_falls_back() {
|
|
4507
|
+ |
let out = plain("classDiagram\n A --> B\n total garbage here");
|
|
4508
|
+ |
assert!(out.contains("mermaid: classDiagram"), "{out}");
|
|
4509
|
+ |
}
|
|
4510
|
+ |
|
|
4511
|
+ |
#[test]
|
|
4512
|
+ |
fn class_member_cap_ellipsis() {
|
|
4513
|
+ |
let mut src = String::from("classDiagram\n class Big {\n");
|
|
4514
|
+ |
for i in 0..12 {
|
|
4515
|
+ |
src.push_str(&format!(" +field{i}\n"));
|
|
4516
|
+ |
}
|
|
4517
|
+ |
src.push_str(" }\n A --> Big");
|
|
4518
|
+ |
let out = plain(&src);
|
|
4519
|
+ |
assert!(out.contains("+field7"), "{out}");
|
|
4520
|
+ |
assert!(!out.contains("+field9"), "{out}");
|
|
4521
|
+ |
assert!(out.contains('…'), "{out}");
|
|
4522
|
+ |
}
|
|
4523
|
+ |
|
|
4524
|
+ |
#[test]
|
|
4525
|
+ |
fn class_direction_lr() {
|
|
4526
|
+ |
let out = plain("classDiagram\n direction LR\n A --> B");
|
|
4527
|
+ |
let line = out.lines().find(|l| l.contains('A')).unwrap();
|
|
4528
|
+ |
assert!(line.contains('B'), "{out}");
|
|
4529
|
+ |
}
|
|
4530
|
+ |
|
|
4531
|
+ |
#[test]
|
|
4532
|
+ |
fn er_renders_entities_and_relationship_labels() {
|
|
4533
|
+ |
let out = plain(
|
|
4534
|
+ |
"erDiagram\n CUSTOMER ||--o{ ORDER : places\n CUSTOMER {\n string name PK \"full name\"\n int custNumber\n }",
|
|
4535
|
+ |
);
|
|
4536
|
+ |
assert!(out.contains("CUSTOMER"), "{out}");
|
|
4537
|
+ |
assert!(out.contains("ORDER"), "{out}");
|
|
4538
|
+ |
assert!(out.contains("string name PK"), "{out}");
|
|
4539
|
+ |
assert!(
|
|
4540
|
+ |
!out.contains("full name"),
|
|
4541
|
+ |
"attribute comments dropped:\n{out}"
|
|
4542
|
+ |
);
|
|
4543
|
+ |
assert!(out.contains("1 places 0..*"), "{out}");
|
|
4544
|
+ |
assert!(out.contains('├'), "attribute compartment rule:\n{out}");
|
|
4545
|
+ |
}
|
|
4546
|
+ |
|
|
4547
|
+ |
#[test]
|
|
4548
|
+ |
fn er_cardinality_map() {
|
|
4549
|
+ |
let cases = [
|
|
4550
|
+ |
("||--||", "1", "1"),
|
|
4551
|
+ |
("|o--o|", "0..1", "0..1"),
|
|
4552
|
+ |
("}o--o{", "0..*", "0..*"),
|
|
4553
|
+ |
("}|--|{", "1..*", "1..*"),
|
|
4554
|
+ |
("||--o{", "1", "0..*"),
|
|
4555
|
+ |
];
|
|
4556
|
+ |
for (op, l, r) in cases {
|
|
4557
|
+ |
let (cl, cr, line) = parse_er_op(op).unwrap();
|
|
4558
|
+ |
assert_eq!((cl, cr), (l, r), "{op}");
|
|
4559
|
+ |
assert!(line == LineKind::Solid);
|
|
4560
|
+ |
}
|
|
4561
|
+ |
assert!(parse_er_op("||..o{").unwrap().2 == LineKind::Dotted);
|
|
4562
|
+ |
assert!(parse_er_op("||==o{").is_none());
|
|
4563
|
+ |
assert!(parse_er_op("garbage").is_none());
|
|
4564
|
+ |
}
|
|
4565
|
+ |
|
|
4566
|
+ |
#[test]
|
|
4567
|
+ |
fn er_non_identifying_renders_dotted() {
|
|
4568
|
+ |
let out = plain("erDiagram\n A ||..o{ B : uses");
|
|
4569
|
+ |
assert!(out.contains('╎') || out.contains('╌'), "{out}");
|
|
4570
|
+ |
}
|
|
4571
|
+ |
|
|
4572
|
+ |
#[test]
|
|
4573
|
+ |
fn er_relationships_have_no_arrowheads() {
|
|
4574
|
+ |
let out = plain("erDiagram\n A ||--o{ B : has");
|
|
4575
|
+ |
for head in ['▼', '▲', '◄', '▶', '△', '◆', '◇'] {
|
|
4576
|
+ |
assert!(!out.contains(head), "{head} in:\n{out}");
|
|
4577
|
+ |
}
|
|
4578
|
+ |
}
|
|
4579
|
+ |
|
|
4580
|
+ |
#[test]
|
|
4581
|
+ |
fn er_entity_alias_label() {
|
|
4582
|
+ |
let out = plain("erDiagram\n p[Person] ||--o{ a[\"Bank Account\"] : owns");
|
|
4583
|
+ |
assert!(out.contains("Person"), "{out}");
|
|
4584
|
+ |
assert!(out.contains("Bank Account"), "{out}");
|
|
4585
|
+ |
}
|
|
4586
|
+ |
|
|
4587
|
+ |
#[test]
|
|
4588
|
+ |
fn er_unquoted_label_and_bare_entity_decl() {
|
|
4589
|
+ |
let g = parse_er("erDiagram\n LONER\n A ||--|| B : linked")
|
|
4590
|
+ |
.unwrap()
|
|
4591
|
+ |
.0;
|
|
4592
|
+ |
assert_eq!(g.nodes.len(), 3);
|
|
4593
|
+ |
let out = plain("erDiagram\n LONER\n A ||--|| B : linked");
|
|
4594
|
+ |
assert!(out.contains("LONER"), "{out}");
|
|
4595
|
+ |
assert!(out.contains("1 linked 1"), "{out}");
|
|
4596
|
+ |
}
|
|
4597
|
+ |
|
|
4598
|
+ |
#[test]
|
|
4599
|
+ |
fn er_attribute_cap_ellipsis() {
|
|
4600
|
+ |
let mut src = String::from("erDiagram\n BIG {\n");
|
|
4601
|
+ |
for i in 0..12 {
|
|
4602
|
+ |
src.push_str(&format!(" int f{i}\n"));
|
|
4603
|
+ |
}
|
|
4604
|
+ |
src.push_str(" }\n BIG ||--|| OTHER : x");
|
|
4605
|
+ |
let out = plain(&src);
|
|
4606
|
+ |
assert!(out.contains("int f7"), "{out}");
|
|
4607
|
+ |
assert!(!out.contains("int f9"), "{out}");
|
|
4608
|
+ |
assert!(out.contains('…'), "{out}");
|
|
4609
|
+ |
}
|
|
4610
|
+ |
|
|
4611
|
+ |
#[test]
|
|
4612
|
+ |
fn er_unknown_statement_falls_back() {
|
|
4613
|
+ |
let out = plain("erDiagram\n A ||--|| B : ok\n utter nonsense statement");
|
|
4614
|
+ |
assert!(out.contains("mermaid: erDiagram"), "{out}");
|
|
4615
|
+ |
}
|
|
4616
|
+ |
|
|
4617
|
+ |
#[test]
|
|
4618
|
+ |
fn subgraph_renders_titled_frame() {
|
|
4619
|
+ |
let out = plain(
|
|
4620
|
+ |
"graph TD\n S[Start] --> one\n subgraph one [Group One]\n A --> B\n end\n one --> E[End]",
|
|
4621
|
+ |
);
|
|
4622
|
+ |
assert!(out.contains(" Group One "), "{out}");
|
|
4623
|
+ |
let lines: Vec<&str> = out.lines().collect();
|
|
4624
|
+ |
let title = lines.iter().position(|l| l.contains("Group One")).unwrap();
|
|
4625
|
+ |
let a = lines.iter().position(|l| l.contains("│ A │")).unwrap();
|
|
4626
|
+ |
let b = lines.iter().position(|l| l.contains("│ B │")).unwrap();
|
|
4627
|
+ |
let frame_close = lines
|
|
4628
|
+ |
.iter()
|
|
4629
|
+ |
.rposition(|l| l.trim_start().starts_with('└'))
|
|
4630
|
+ |
.unwrap();
|
|
4631
|
+ |
assert!(title < a && a < b && b <= frame_close, "{out}");
|
|
4632
|
+ |
assert!(out.contains("Start") && out.contains("End"), "{out}");
|
|
4633
|
+ |
assert_eq!(out.matches('▼').count(), 3, "{out}");
|
|
4634
|
+ |
}
|
|
4635
|
+ |
|
|
4636
|
+ |
#[test]
|
|
4637
|
+ |
fn subgraph_edge_between_groups() {
|
|
4638
|
+ |
let out = plain(
|
|
4639
|
+ |
"graph TD\n subgraph api [API]\n A1 --> A2\n end\n subgraph db [Storage]\n B1\n end\n api --> db",
|
|
4640
|
+ |
);
|
|
4641
|
+ |
assert!(out.contains(" API "), "{out}");
|
|
4642
|
+ |
assert!(out.contains(" Storage "), "{out}");
|
|
4643
|
+ |
let lines: Vec<&str> = out.lines().collect();
|
|
4644
|
+ |
let api = lines.iter().position(|l| l.contains("API")).unwrap();
|
|
4645
|
+ |
let db = lines.iter().position(|l| l.contains("Storage")).unwrap();
|
|
4646
|
+ |
assert!(api < db, "API frame ranks above Storage:\n{out}");
|
|
4647
|
+ |
}
|
|
4648
|
+ |
|
|
4649
|
+ |
#[test]
|
|
4650
|
+ |
fn subgraph_nested_frames() {
|
|
4651
|
+ |
let out = plain(
|
|
4652
|
+ |
"graph TD\n subgraph outer [Outer]\n subgraph inner [Inner]\n X --> Y\n end\n W --> X\n end\n S --> outer",
|
|
4653
|
+ |
);
|
|
4654
|
+ |
assert!(out.contains(" Outer "), "{out}");
|
|
4655
|
+ |
assert!(out.contains(" Inner "), "{out}");
|
|
4656
|
+ |
let lines: Vec<&str> = out.lines().collect();
|
|
4657
|
+ |
let outer = lines.iter().position(|l| l.contains("Outer")).unwrap();
|
|
4658
|
+ |
let inner = lines.iter().position(|l| l.contains("Inner")).unwrap();
|
|
4659
|
+ |
assert!(outer < inner, "{out}");
|
|
4660
|
+ |
}
|
|
4661
|
+ |
|
|
4662
|
+ |
#[test]
|
|
4663
|
+ |
fn subgraph_cross_member_edge_attaches_to_frame() {
|
|
4664
|
+ |
let out = plain("graph LR\n S --> A\n subgraph g [Workers]\n A --> B\n end\n B --> T");
|
|
4665
|
+ |
assert!(out.contains(" Workers "), "{out}");
|
|
4666
|
+ |
assert!(out.contains('S') && out.contains('T'), "{out}");
|
|
4667
|
+ |
assert_eq!(out.matches('▶').count(), 3, "{out}");
|
|
4668
|
+ |
let row = out.lines().find(|l| l.contains("│ A ├")).unwrap();
|
|
4669
|
+ |
assert!(
|
|
4670
|
+ |
row.find('S') < row.find('A'),
|
|
4671
|
+ |
"A stays outside the group (first definition wins):\n{out}"
|
|
4672
|
+ |
);
|
|
4673
|
+ |
}
|
|
4674
|
+ |
|
|
4675
|
+ |
#[test]
|
|
4676
|
+ |
fn subgraph_id_referenced_before_declaration() {
|
|
4677
|
+ |
let g = parse_graph("graph TD\n X --> two\n subgraph two\n C --> D\n end").unwrap();
|
|
4678
|
+ |
assert_eq!(g.groups.len(), 1);
|
|
4679
|
+ |
let out = plain("graph TD\n X --> two\n subgraph two\n C --> D\n end");
|
|
4680
|
+ |
assert!(out.contains(" two "), "frame titled by id:\n{out}");
|
|
4681
|
+ |
assert!(out.contains("│ C │"), "{out}");
|
|
4682
|
+ |
}
|
|
4683
|
+ |
|
|
4684
|
+ |
#[test]
|
|
4685
|
+ |
fn subgraph_quoted_and_plain_titles() {
|
|
4686
|
+ |
let out = plain("graph TD\n subgraph \"My Stuff\"\n A\n end\n S --> A");
|
|
4687
|
+ |
assert!(out.contains(" My Stuff "), "{out}");
|
|
4688
|
+ |
let out2 = plain("graph TD\n subgraph batch jobs\n B\n end\n S --> B");
|
|
4689
|
+ |
assert!(out2.contains(" batch jobs "), "{out2}");
|
|
4690
|
+ |
let out3 = plain("graph TD\n subgraph \"a <b>\"\n C\n end\n S --> C");
|
|
4691
|
+ |
assert!(out3.contains("a <b>") && !out3.contains("<"), "{out3}");
|
|
4692
|
+ |
}
|
|
4693
|
+ |
|
|
4694
|
+ |
#[test]
|
|
4695
|
+ |
fn subgraph_empty_is_dropped() {
|
|
4696
|
+ |
let out = plain("graph TD\n subgraph ghost\n end\n A --> B");
|
|
4697
|
+ |
assert!(!out.contains("ghost"), "{out}");
|
|
4698
|
+ |
assert!(out.contains('▼'), "{out}");
|
|
4699
|
+ |
}
|
|
4700
|
+ |
|
|
4701
|
+ |
#[test]
|
|
4702
|
+ |
fn subgraph_bt_flips_frame_and_contents() {
|
|
4703
|
+ |
let out = plain("flowchart BT\n S --> one\n subgraph one [Up]\n A --> B\n end");
|
|
4704
|
+ |
assert!(out.contains(" Up "), "{out}");
|
|
4705
|
+ |
let lines: Vec<&str> = out.lines().collect();
|
|
4706
|
+ |
let row = |needle: &str| lines.iter().position(|l| l.contains(needle)).unwrap();
|
|
4707
|
+ |
assert!(row("│ B │") < row("│ A │"), "contents flip with BT:\n{out}");
|
|
4708
|
+ |
assert!(row(" Up ") < row("S"), "frame above source in BT:\n{out}");
|
|
4709
|
+ |
assert!(out.contains('▲'), "{out}");
|
|
4710
|
+ |
}
|
|
4711
|
+ |
|
|
4712
|
+ |
#[test]
|
|
4713
|
+ |
fn subgraph_depth_over_cap_falls_back() {
|
|
4714
|
+ |
let mut src = String::from("graph TD\n");
|
|
4715
|
+ |
for i in 0..8 {
|
|
4716
|
+ |
src.push_str(&format!(" subgraph g{i}\n"));
|
|
4717
|
+ |
}
|
|
4718
|
+ |
src.push_str(" A --> B\n");
|
|
4719
|
+ |
for _ in 0..8 {
|
|
4720
|
+ |
src.push_str(" end\n");
|
|
4721
|
+ |
}
|
|
4722
|
+ |
let out = plain(&src);
|
|
4723
|
+ |
assert!(out.contains("mermaid: graph"), "{out}");
|
|
4724
|
+ |
}
|
|
4725
|
+ |
|
|
4726
|
+ |
#[test]
|
|
4727
|
+ |
fn subgraph_groupless_path_unchanged() {
|
|
4728
|
+ |
let g = parse_graph("graph TD\n A --> B").unwrap();
|
|
4729
|
+ |
assert!(g.groups.is_empty());
|
|
4730
|
+ |
}
|
|
4731
|
+ |
|
|
4732
|
+ |
#[test]
|
|
4733
|
+ |
fn fan_out_creates_cross_product_edges() {
|
|
4734
|
+ |
let g = parse_graph("graph TD\n A & B --> C & D").unwrap();
|
|
4735
|
+ |
assert_eq!(g.nodes.len(), 4);
|
|
4736
|
+ |
assert_eq!(g.edges.len(), 4);
|
|
4737
|
+ |
let idx = |id: &str| g.index[id];
|
|
4738
|
+ |
let has = |f: &str, t: &str| g.edges.iter().any(|e| e.from == idx(f) && e.to == idx(t));
|
|
4739
|
+ |
assert!(has("A", "C") && has("A", "D") && has("B", "C") && has("B", "D"));
|
|
4740
|
+ |
let out = plain("graph TD\n A & B --> C & D");
|
|
4741
|
+ |
assert_eq!(out.chars().filter(|&c| c == '▼').count(), 2, "{out}");
|
|
4742
|
+ |
}
|
|
4743
|
+ |
|
|
4744
|
+ |
#[test]
|
|
4745
|
+ |
fn fan_out_in_chain() {
|
|
4746
|
+ |
let g = parse_graph("graph LR\n A & B --> C --> D").unwrap();
|
|
4747
|
+ |
assert_eq!(g.edges.len(), 3);
|
|
4748
|
+ |
}
|
|
4749
|
+ |
|
|
4750
|
+ |
#[test]
|
|
4751
|
+ |
fn fan_out_with_reversed_arrow() {
|
|
4752
|
+ |
let g = parse_graph("graph TD\n A & B <-- C").unwrap();
|
|
4753
|
+ |
let idx = |id: &str| g.index[id];
|
|
4754
|
+ |
assert_eq!(g.edges.len(), 2);
|
|
4755
|
+ |
assert!(g.edges.iter().all(|e| e.from == idx("C")));
|
|
4756
|
+ |
assert!(g.edges.iter().all(|e| e.head_to == Head::Arrow));
|
|
4757
|
+ |
}
|
|
4758
|
+ |
|
|
4759
|
+ |
#[test]
|
|
4760
|
+ |
fn circle_and_cross_endings_create_no_phantom_nodes() {
|
|
4761
|
+ |
let g = parse_graph("graph TD\n A --o B\n C --x D").unwrap();
|
|
4762
|
+ |
assert_eq!(g.nodes.len(), 4, "no phantom o/x nodes");
|
|
4763
|
+ |
assert!(!g.index.contains_key("o"));
|
|
4764
|
+ |
assert!(!g.index.contains_key("x"));
|
|
4765
|
+ |
assert_eq!(g.edges[0].head_to, Head::Circle);
|
|
4766
|
+ |
assert_eq!(g.edges[1].head_to, Head::Cross);
|
|
4767
|
+ |
let out = plain("graph TD\n A --o B");
|
|
4768
|
+ |
assert!(out.contains('o'), "circle head rendered:\n{out}");
|
|
4769
|
+ |
}
|
|
4770
|
+ |
|
|
4771
|
+ |
#[test]
|
|
4772
|
+ |
fn left_endings_decorate_without_reversing() {
|
|
4773
|
+ |
let g = parse_graph("graph TD\n A o-- B\n C x-- D").unwrap();
|
|
4774
|
+ |
let idx = |id: &str| g.index[id];
|
|
4775
|
+ |
assert_eq!(g.edges[0].from, idx("A"));
|
|
4776
|
+ |
assert_eq!(g.edges[0].to, idx("B"));
|
|
4777
|
+ |
assert_eq!(g.edges[0].head_from, Head::Circle);
|
|
4778
|
+ |
assert_eq!(g.edges[1].head_from, Head::Cross);
|
|
4779
|
+ |
assert_eq!(g.edges[0].head_to, Head::None);
|
|
4780
|
+ |
}
|
|
4781
|
+ |
|
|
4782
|
+ |
#[test]
|
|
4783
|
+ |
fn reversed_arrow_with_end_marker_swaps_direction() {
|
|
4784
|
+ |
let g = parse_graph("graph TD\n A <--o B\n C <--x D").unwrap();
|
|
4785
|
+ |
let idx = |id: &str| g.index[id];
|
|
4786
|
+ |
assert_eq!(g.edges[0].from, idx("B"));
|
|
4787
|
+ |
assert_eq!(g.edges[0].to, idx("A"));
|
|
4788
|
+ |
assert_eq!(g.edges[0].head_to, Head::Arrow);
|
|
4789
|
+ |
assert_eq!(g.edges[0].head_from, Head::Circle);
|
|
4790
|
+ |
assert_eq!(g.edges[1].from, idx("D"));
|
|
4791
|
+ |
assert_eq!(g.edges[1].to, idx("C"));
|
|
4792
|
+ |
assert_eq!(g.edges[1].head_from, Head::Cross);
|
|
4793
|
+ |
let plain_rev = plain("graph TD\n A <--o B");
|
|
4794
|
+ |
let lines: Vec<&str> = plain_rev.lines().collect();
|
|
4795
|
+ |
let row = |needle: &str| lines.iter().position(|l| l.contains(needle)).unwrap();
|
|
4796
|
+ |
assert!(
|
|
4797
|
+ |
row("B") < row("A"),
|
|
4798
|
+ |
"ranks match plain <-- reversal:\n{plain_rev}"
|
|
4799
|
+ |
);
|
|
4800
|
+ |
}
|
|
4801
|
+ |
|
|
4802
|
+ |
#[test]
|
|
4803
|
+ |
fn both_end_markers_parse() {
|
|
4804
|
+ |
let g = parse_graph("graph TD\n A o--o B\n C x--x D").unwrap();
|
|
4805
|
+ |
assert_eq!(g.edges[0].head_from, Head::Circle);
|
|
4806
|
+ |
assert_eq!(g.edges[0].head_to, Head::Circle);
|
|
4807
|
+ |
assert_eq!(g.edges[1].head_from, Head::Cross);
|
|
4808
|
+ |
assert_eq!(g.edges[1].head_to, Head::Cross);
|
|
4809
|
+ |
assert_eq!(g.nodes.len(), 4);
|
|
4810
|
+ |
}
|
|
4811
|
+ |
|
|
4812
|
+ |
#[test]
|
|
4813
|
+ |
fn dotted_and_thick_lines_render_distinctly() {
|
|
4814
|
+ |
let dotted = plain("graph TD\n A -.-> B");
|
|
4815
|
+ |
assert!(dotted.contains('╎'), "dotted vertical:\n{dotted}");
|
|
4816
|
+ |
let thick = plain("graph TD\n A ==> B");
|
|
4817
|
+ |
assert!(thick.contains('┃'), "thick vertical:\n{thick}");
|
|
4818
|
+ |
let solid = plain("graph TD\n A --> B");
|
|
4819
|
+ |
assert!(
|
|
4820
|
+ |
!solid.contains('╎') && !solid.contains('┃'),
|
|
4821
|
+ |
"solid unchanged:\n{solid}"
|
|
4822
|
+ |
);
|
|
4823
|
+ |
}
|
|
4824
|
+ |
|
|
4825
|
+ |
#[test]
|
|
4826
|
+ |
fn dotted_label_form_renders_dashed() {
|
|
4827
|
+ |
let out = plain("graph LR\n A -. maybe .-> B");
|
|
4828
|
+ |
assert!(out.contains('╌'), "{out}");
|
|
4829
|
+ |
assert!(out.contains("maybe"), "{out}");
|
|
4830
|
+ |
}
|
|
4831
|
+ |
|
|
4832
|
+ |
#[test]
|
|
4833
|
+ |
fn thick_jog_uses_thick_corners() {
|
|
4834
|
+ |
let out = plain("graph TD\n A[aaaaaaa] ==> B\n A ==> C[ccccccc]");
|
|
4835
|
+ |
assert!(
|
|
4836
|
+ |
out.contains('┏') || out.contains('┓') || out.contains('┳'),
|
|
4837
|
+ |
"thick corners on jog:\n{out}"
|
|
4838
|
+ |
);
|
|
4839
|
+ |
}
|
|
4840
|
+ |
|
|
4841
|
+ |
#[test]
|
|
4842
|
+ |
fn state_diagram_renders_states_and_transitions() {
|
|
4843
|
+ |
let out =
|
|
4844
|
+ |
plain("stateDiagram-v2\n [*] --> Idle\n Idle --> Running: start\n Running --> [*]");
|
|
4845
|
+ |
assert!(out.contains("Idle"), "{out}");
|
|
4846
|
+ |
assert!(out.contains("Running"), "{out}");
|
|
4847
|
+ |
assert!(out.contains("start"), "{out}");
|
|
4848
|
+ |
assert!(out.contains('▼'), "{out}");
|
|
4849
|
+ |
assert_eq!(
|
|
4850
|
+ |
out.matches('●').count(),
|
|
4851
|
+ |
2,
|
|
4852
|
+ |
"distinct start and end markers:\n{out}"
|
|
4853
|
+ |
);
|
|
4854
|
+ |
let lines: Vec<&str> = out.lines().collect();
|
|
4855
|
+ |
let first_dot = lines.iter().position(|l| l.contains('●')).unwrap();
|
|
4856
|
+ |
let last_dot = lines.iter().rposition(|l| l.contains('●')).unwrap();
|
|
4857
|
+ |
let idle = lines.iter().position(|l| l.contains("Idle")).unwrap();
|
|
4858
|
+ |
assert!(first_dot < idle && idle < last_dot, "{out}");
|
|
4859
|
+ |
}
|
|
4860
|
+ |
|
|
4861
|
+ |
#[test]
|
|
4862
|
+ |
fn state_v1_header_renders() {
|
|
4863
|
+ |
let out = plain("stateDiagram\n A --> B");
|
|
4864
|
+ |
assert!(out.contains('▼'), "{out}");
|
|
4865
|
+ |
}
|
|
4866
|
+ |
|
|
4867
|
+ |
#[test]
|
|
4868
|
+ |
fn state_boxes_are_rounded() {
|
|
4869
|
+ |
let out = plain("stateDiagram-v2\n A --> B");
|
|
4870
|
+ |
assert!(out.contains('╭'), "{out}");
|
|
4871
|
+ |
assert!(!out.contains('┌'), "states render rounded:\n{out}");
|
|
4872
|
+ |
}
|
|
4873
|
+ |
|
|
4874
|
+ |
#[test]
|
|
4875
|
+ |
fn state_alias_label_renders() {
|
|
4876
|
+ |
let out = plain("stateDiagram-v2\n state \"Waiting for input\" as W\n W --> Done");
|
|
4877
|
+ |
assert!(out.contains("Waiting for input"), "{out}");
|
|
4878
|
+ |
}
|
|
4879
|
+ |
|
|
4880
|
+ |
#[test]
|
|
4881
|
+ |
fn state_choice_parses_as_diamond() {
|
|
4882
|
+ |
let g = parse_state(
|
|
4883
|
+ |
"stateDiagram-v2\n state c <<choice>>\n A --> c\n c --> B: yes\n c --> D: no",
|
|
4884
|
+ |
)
|
|
4885
|
+ |
.unwrap();
|
|
4886
|
+ |
assert!(g.nodes[g.index["c"]].shape == Shape::Diamond);
|
|
4887
|
+ |
assert_eq!(g.edges.len(), 3);
|
|
4888
|
+ |
}
|
|
4889
|
+ |
|
|
4890
|
+ |
#[test]
|
|
4891
|
+ |
fn state_description_sets_label() {
|
|
4892
|
+ |
let out = plain("stateDiagram-v2\n s2 : waits patiently\n A --> s2");
|
|
4893
|
+ |
assert!(out.contains("waits patiently"), "{out}");
|
|
4894
|
+ |
}
|
|
4895
|
+ |
|
|
4896
|
+ |
#[test]
|
|
4897
|
+ |
fn state_direction_lr() {
|
|
4898
|
+ |
let out = plain("stateDiagram-v2\n direction LR\n A --> B --> C");
|
|
4899
|
+ |
let td = plain("stateDiagram-v2\n A --> B");
|
|
4900
|
+ |
assert!(
|
|
4901
|
+ |
out.lines().count() <= td.lines().count() + 2,
|
|
4902
|
+ |
"LR stays flat:\n{out}"
|
|
4903
|
+ |
);
|
|
4904
|
+ |
let line = out.lines().find(|l| l.contains('A')).unwrap();
|
|
4905
|
+ |
assert!(line.contains('B'), "A and B share a row in LR:\n{out}");
|
|
4906
|
+ |
}
|
|
4907
|
+ |
|
|
4908
|
+ |
#[test]
|
|
4909
|
+ |
fn state_composite_contents_render_flat() {
|
|
4910
|
+ |
let out = plain("stateDiagram-v2\n state Active {\n A --> B\n }\n Active --> Done");
|
|
4911
|
+ |
assert!(out.contains("Active"), "{out}");
|
|
4912
|
+ |
assert!(out.contains('A') && out.contains('B'), "{out}");
|
|
4913
|
+ |
assert!(out.contains("Done"), "{out}");
|
|
4914
|
+ |
}
|
|
4915
|
+ |
|
|
4916
|
+ |
#[test]
|
|
4917
|
+ |
fn state_notes_are_skipped() {
|
|
4918
|
+ |
let out = plain(
|
|
4919
|
+ |
"stateDiagram-v2\n A --> B\n note right of A: inline note\n note left of B\n block text\n end note",
|
|
4920
|
+ |
);
|
|
4921
|
+ |
assert!(out.contains('▼'), "{out}");
|
|
4922
|
+ |
assert!(!out.contains("note"), "{out}");
|
|
4923
|
+ |
assert!(!out.contains("block text"), "{out}");
|
|
4924
|
+ |
}
|
|
4925
|
+ |
|
|
4926
|
+ |
#[test]
|
|
4927
|
+ |
fn state_back_transition_uses_lane() {
|
|
4928
|
+ |
let out = plain("stateDiagram-v2\n A --> B\n B --> C\n C --> B: retry");
|
|
4929
|
+ |
assert!(out.contains('◄'), "{out}");
|
|
4930
|
+ |
assert!(out.contains("retry"), "{out}");
|
|
4931
|
+ |
}
|
|
4932
|
+ |
|
|
4933
|
+ |
#[test]
|
|
4934
|
+ |
fn state_unknown_statement_falls_back() {
|
|
4935
|
+ |
let out = plain("stateDiagram-v2\n A --> B\n some garbage line");
|
|
4936
|
+ |
assert!(out.contains("mermaid: stateDiagram-v2"), "{out}");
|
|
4937
|
+ |
}
|
|
4938
|
+ |
|
|
4939
|
+ |
#[test]
|
|
4940
|
+ |
fn state_over_cap_falls_back() {
|
|
4941
|
+ |
let mut src = String::from("stateDiagram-v2\n");
|
|
4942
|
+ |
for i in 0..600 {
|
|
4943
|
+ |
src.push_str(&format!(" S{i} --> S{}\n", i + 1));
|
|
4944
|
+ |
}
|
|
4945
|
+ |
let out = plain(&src);
|
|
4946
|
+ |
assert!(out.contains("mermaid: stateDiagram-v2"), "{out}");
|
|
4947
|
+ |
}
|
|
4948
|
+ |
|
|
4949
|
+ |
#[test]
|
|
4950
|
+ |
|