Skip to content

Commit 1bb6257

Browse files
jjbaumertclaude
andauthored
Mermaid polish: silent-misparse fixes + flowchart doc-parity (closes #32 highlights) (#44)
* docs(mermaid): polish-slice design spec (issue #32 highlights) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(mermaid): polish-slice implementation plan (9 tasks) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(mermaid): unified flowchart edge-op scanner — circle/cross heads, multi-length, bidirectional, invisible links Kills the phantom-node silent misparse for terminator-after-run spellings (A--oB, A---xB): the terminator now binds to the edge, matching mermaid's documented rule. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(mermaid): accept the docs' no-space inline edge-label spellings Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(mermaid): render circle/cross/bidirectional edge heads; invisible edges draw nothing Also fixes -.- and === wrongly rendering an arrowhead (mermaid treats them as open links). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(mermaid): edges to subgraph ids error loudly instead of creating a phantom node Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(mermaid): classDef default auto-applies to unclassed flowchart nodes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(mermaid): subroutine shape A[[text]] — the 14th legacy flowchart shape Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(mermaid): skip YAML front matter before kind detection, preserving error line numbers Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(mermaid): run the MAX_SOURCE_LEN gate before front-matter stripping An oversized source starting with `---` no longer gets an O(n) scan and a source-sized allocation before rejection; the cap is again the first real work done on untrusted input. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(mermaid): flowchart statement-soup property tests over the new operator vocabulary Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(mermaid): statement splitting on `;` now respects double quotes The docs' own entity-code example A["This is a #35; test"] was truncated mid-quote into an `unclosed "[" bracket` error (found by the doc gallery). Quoted labels may contain semicolons. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(mermaid): commit the flowchart doc-example gallery with post-polish expectations Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * polish(mermaid): final-review hardening — full-operator error text, quoted-`;` prop coverage, thick-open marker test Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(mermaid): pin the reverse-head error-text regression with the case that actually strips Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 89a084b commit 1bb6257

9 files changed

Lines changed: 2474 additions & 74 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//! Renders the example snippets from the official mermaid flowchart
2+
//! docs (https://mermaid.ai/open-source/syntax/flowchart.html) through
3+
//! our renderer, writing one .svg (success) or .err.txt (error) per
4+
//! example into target/doc-gallery/ for side-by-side visual comparison
5+
//! with the doc's reference images.
6+
//!
7+
//! Run: cargo run -p ogrenotes-mermaid --example doc_gallery
8+
//!
9+
//! Expectation notes reflect the post-polish-slice behavior (issue #32;
10+
//! docs/superpowers/specs/2026-07-11-mermaid-polish-design.md).
11+
12+
use std::fs;
13+
use std::path::Path;
14+
15+
fn main() {
16+
// (name, source, expectation-note)
17+
let cases: &[(&str, &str, &str)] = &[
18+
// ── Basic ────────────────────────────────────────────────
19+
("basic_bare_node", "flowchart TD\n A", "match"),
20+
("basic_text_node", "flowchart TD\n A[This is the text in the box]", "match"),
21+
("basic_unicode_quoted", "flowchart TD\n A[\"This is the text in the box\"]", "match"),
22+
("basic_markdown_backticks", "flowchart TD\n A[\"`This is the text in the box`\"]", "diverge: backticks render literally (no markdown)"),
23+
("front_matter", "---\ntitle: Node\n---\nflowchart LR\n id", "match: front matter skipped (title not rendered in v1)"),
24+
("direction_td", "flowchart TD\n A --> B", "match"),
25+
("direction_lr", "flowchart LR\n A --> B", "match"),
26+
// ── Legacy shapes ────────────────────────────────────────
27+
("shape_round", "flowchart TD\n A(This is the text in the box)", "match"),
28+
("shape_stadium", "flowchart TD\n A([This is the text in the box])", "match"),
29+
("shape_subroutine", "flowchart TD\n A[[This is the text in the box]]", "match (added in polish slice)"),
30+
("shape_cylinder", "flowchart TD\n A[(Database)]", "match"),
31+
("shape_circle", "flowchart TD\n A((This is the text in the box))", "match"),
32+
("shape_asymmetric", "flowchart TD\n A>This is the text in the box]", "match"),
33+
("shape_rhombus", "flowchart TD\n A{This is the text in the box}", "match"),
34+
("shape_hexagon", "flowchart TD\n A{{This is the text in the box}}", "match"),
35+
("shape_parallelogram", "flowchart TD\n A[/This is the text in the box/]", "match"),
36+
("shape_parallelogram_alt", "flowchart TD\n A[\\This is the text in the box\\]", "match"),
37+
("shape_trapezoid", "flowchart TD\n A[/This is the text in the box\\]", "match"),
38+
("shape_trapezoid_alt", "flowchart TD\n A[\\This is the text in the box/]", "match"),
39+
("shape_double_circle", "flowchart TD\n A(((This is the text in the box)))", "match"),
40+
// ── v11.3+ @-syntax (expected to error loudly) ───────────
41+
("at_syntax_multi", "flowchart TD\n A@{ shape: rect, label: \"Rectangle\" }\n B@{ shape: circle, label: \"Circle\" }\n A --> B", "error (out of scope: @-syntax)"),
42+
("at_syntax_icon", "flowchart TD\n A@{ shape: icon, icon: \"fa:fa-heart\", form: \"circle\", label: \"Heart\" }", "error (out of scope)"),
43+
// ── Links ────────────────────────────────────────────────
44+
("link_arrow", "flowchart TD\n A-->B", "match"),
45+
("link_open", "flowchart TD\n A---B", "match"),
46+
("link_pipe_text", "flowchart TD\n A-->|text|B", "match"),
47+
("link_chain_text_node", "flowchart TD\n A-->text-->B", "match (creates a node named 'text' — same as mermaid)"),
48+
("link_inline_text_spaced", "flowchart TD\n A-- text -->B", "match"),
49+
("link_dotted", "flowchart TD\n A-.->B", "match"),
50+
("link_dotted_open", "flowchart TD\n A-.-B", "match: dotted open, no arrowhead (fixed in polish slice)"),
51+
("link_dotted_text_nospace", "flowchart TD\n A-.text.-B", "match (no-space spelling added in polish slice)"),
52+
("link_dotted_text_spaced", "flowchart TD\n A-. text .-> B", "match"),
53+
("link_thick", "flowchart TD\n A==>B", "match"),
54+
("link_thick_text_nospace", "flowchart TD\n A==text==>B", "match (no-space spelling added in polish slice)"),
55+
("link_thick_text_spaced", "flowchart TD\n A== text ==>B", "match"),
56+
("link_invisible", "flowchart TD\n A ~~~ B", "match: renders both nodes, no visible edge (layout-only)"),
57+
// ── Chaining ─────────────────────────────────────────────
58+
("chain_simple", "flowchart TD\n A-->B-->C", "match"),
59+
("chain_fanout_mid", "flowchart TD\n A-->B & C-->D", "match"),
60+
("chain_fanout_both", "flowchart TD\n A & B-->C & D", "match"),
61+
("chain_multiline", "flowchart TD\n A-->B-->C\n A-->D-->C\n B-->E", "match"),
62+
// ── Edge ids / animation (v11.10+) ───────────────────────
63+
("edge_id", "flowchart TD\n e1@-->A & B", "error (out of scope: edge ids)"),
64+
// ── New arrow types (fixed silent divergences) ───────────
65+
("circle_edge", "flowchart LR\n A --o B", "match: circle-ended edge (was a loud error pre-polish)"),
66+
("cross_edge", "flowchart LR\n A --x B", "match: cross-ended edge"),
67+
("circle_edge_nospace", "flowchart TD\n A---oB", "match: circle edge to B — mermaid's documented terminator binding (was a phantom node 'oB')"),
68+
("cross_edge_nospace", "flowchart TD\n A---xB", "match: cross edge to B (was a phantom node 'xB')"),
69+
("arrow_then_o_node", "flowchart TD\n A-->oB", "match: arrow to a node named 'oB' — the '>' already terminated the link, in mermaid too"),
70+
// ── Multi-directional / lengths ──────────────────────────
71+
("multidir", "flowchart LR\n A o--o B\n B <--> C\n C x--x D", "match: per-end heads via marker-start/marker-end"),
72+
("min_length", "flowchart TD\n A[Start] --> B{Rhombus}\n B --> C[rect_1]\n B --> D[rect_2]\n B --> E[rect_3]\n C --> F[A]\n D --> E\n E --> F[B]\n F --> G[End]\n A -----> E", "match: parses; extra length's rank-span hint not honored (quiet cosmetic divergence)"),
73+
("length_with_text", "flowchart TD\n A --text---- E", "match: long closer run with inline label"),
74+
// ── Special characters ───────────────────────────────────
75+
("special_html", "flowchart TD\n A[\"This is a <strong>test</strong>\"]", "diverge-by-design: we escape (literal text); mermaid renders the HTML"),
76+
("entity_codes", "flowchart TD\n A[\"This is a #35; test\"]", "diverge: entity code renders literally, not decoded to #"),
77+
// ── Subgraphs ────────────────────────────────────────────
78+
("subgraph_titled", "flowchart TD\n subgraph sgID[A Subgraph]\n A-->B\n end", "match"),
79+
("subgraph_edge_to_id", "flowchart TD\n subgraph sgID[A Subgraph]\n A-->B\n end\n sgID-->C\n C-->D", "LOUD ERROR: edge-to-subgraph routing deferred (was a silent phantom node 'sgID')"),
80+
("subgraph_direction", "flowchart TD\n subgraph sgID[A Subgraph]\n direction LR\n A-->B\n end", "error (direction-in-subgraph out of scope)"),
81+
// ── Interaction / styling ────────────────────────────────
82+
("click_callback", "flowchart TD\n A-->B\n click A callback \"Tooltip text\"", "error (out of scope: click)"),
83+
("comments", "flowchart TD\n A[Auslan]\n %%this is a comment\n A-->B[\"Christmas\"]", "match"),
84+
("link_style", "flowchart LR\n A-->B-->C-->D\n linkStyle 3 stroke:#ff3,stroke-width:4px,color:red;", "error (out of scope: linkStyle)"),
85+
("node_style", "flowchart TD\n A-->B\n style A fill:#f9f,stroke:#333,stroke-width:4px;", "error (out of scope: style)"),
86+
("classes_inline", "flowchart TD\n A:::someclass --> B\n classDef someclass fill:#f9f,stroke:#333,stroke-width:4px;", "match (allowlisted props apply)"),
87+
("classes_two", "flowchart TD\n A:::first --> B:::second\n classDef first fill:#f9f,stroke:#333,stroke-width:4px;\n classDef second fill:#bbf,stroke:#f66,stroke-width:2px,color:#fff;", "match"),
88+
("class_default", "flowchart TD\n A --> B\n classDef default fill:#f9f,stroke:#333,stroke-width:4px;", "match: default auto-applied to unclassed nodes (added in polish slice)"),
89+
("fontawesome", "flowchart TD\n B[fa:fa-twitter]", "diverge-by-design: renders literal text, no icon"),
90+
("spaced_no_semicolons", "flowchart LR\n A --> B --> C\n A --> D --> C\n B --> E", "match"),
91+
];
92+
93+
let out_dir = Path::new("target/doc-gallery");
94+
fs::create_dir_all(out_dir).expect("create out dir");
95+
96+
let (mut ok, mut err) = (0usize, 0usize);
97+
for (name, source, note) in cases {
98+
let result = ogrenotes_mermaid::render(source);
99+
match (result.svg, result.error) {
100+
(Some(svg), None) => {
101+
fs::write(out_dir.join(format!("{name}.svg")), svg).expect("write svg");
102+
ok += 1;
103+
println!("RENDERED {name:<28} — {note}");
104+
}
105+
(None, Some(e)) => {
106+
let msg = format!(
107+
"line {:?}: {}\n\nsource:\n{}\n\nexpectation: {}\n",
108+
e.line, e.message, source, note
109+
);
110+
fs::write(out_dir.join(format!("{name}.err.txt")), msg).expect("write err");
111+
err += 1;
112+
println!("ERRORED {name:<28} — {note}");
113+
}
114+
other => {
115+
// XOR invariant means this is unreachable; keep loud anyway.
116+
println!("INVARIANT VIOLATION {name}: {other:?}");
117+
}
118+
}
119+
}
120+
println!("\n{ok} rendered, {err} errored → target/doc-gallery/");
121+
println!("Compare SVGs against the doc images; .err.txt files list the loud-error cases.");
122+
}

‎crates/mermaid/src/flowchart/mod.rs‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
pub(crate) mod parse;
22
pub(crate) mod shapes;
33
pub(crate) mod svg;
4+
#[cfg(test)]
5+
mod props;
46

57
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68
pub(crate) enum ShapeKind {
79
Rect,
10+
Subroutine,
811
Rounded,
912
Stadium,
1013
Circle,
@@ -25,16 +28,26 @@ pub(crate) enum EdgeKind {
2528
Open,
2629
Dotted,
2730
Thick,
31+
/// `~~~` — participates in layout, draws nothing.
32+
Invisible,
33+
}
34+
35+
/// Per-end edge decoration. `from_head` renders as `marker-start`,
36+
/// `to_head` as `marker-end` (edge paths run from→to after the layout
37+
/// engine restores true direction on reversed edges).
38+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39+
pub(crate) enum Head {
40+
None,
41+
Arrow,
42+
Circle,
43+
Cross,
2844
}
2945

3046
#[derive(Debug, Clone)]
3147
pub(crate) struct FlowNode {
32-
/// Mermaid source identifier. Not read by the render pipeline
33-
/// (downstream stages address nodes by index); kept because parser
34-
/// tests assert on it to verify id-extraction is correct (bare ids,
35-
/// subgraph-membership lookup, class-assignment lookup). See
36-
/// task-14-report.md for why this isn't deleted outright.
37-
#[allow(dead_code)]
48+
/// Mermaid source identifier. Read by the post-parse
49+
/// edge-to-subgraph check (parse.rs) and asserted on by parser
50+
/// tests; downstream render stages address nodes by index.
3851
pub id: String,
3952
pub label: String, // raw; escaped only at SVG emission
4053
pub shape: ShapeKind,
@@ -46,7 +59,12 @@ pub(crate) struct FlowNode {
4659
pub(crate) struct FlowEdge {
4760
pub from: usize,
4861
pub to: usize,
62+
/// Line-style family. `Arrow` is retained (rather than folding into
63+
/// `Open`) because the parser's pre-polish tests assert it for `-->`;
64+
/// solid edges map to `Arrow` iff either head is `Head::Arrow`.
4965
pub kind: EdgeKind,
66+
pub from_head: Head,
67+
pub to_head: Head,
5068
pub label: Option<String>,
5169
}
5270

0 commit comments

Comments
 (0)