Skip to content

Commit 67225d7

Browse files
authored
Merge pull request #99 from jongalloway/copilot/add-mermaid-class-diagram-parser
feat: Mermaid class diagram parser (declarations, members, relationships)
2 parents 540d65a + 0a25fd4 commit 67225d7

6 files changed

Lines changed: 1004 additions & 3 deletions

File tree

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
using DiagramForge.Abstractions;
2+
using DiagramForge.Models;
3+
4+
namespace DiagramForge.Parsers.Mermaid;
5+
6+
internal sealed class MermaidClassDiagramParser : IMermaidDiagramParser
7+
{
8+
// Relationship operators sorted so that longer tokens are tried first when positions tie.
9+
// Each entry: (token, relationshipType, isReversed).
10+
// isReversed=true means the written textual order is source-on-right / target-on-left,
11+
// so SourceId and TargetId must be swapped relative to the left/right parse order.
12+
// For example: "Animal <|-- Dog" → left=Animal, right=Dog, but Dog IS the child
13+
// (semantic source), so isReversed=true causes Edge(Dog, Animal).
14+
private static readonly (string Token, string RelType, bool IsReversed)[] RelOperators =
15+
[
16+
("<|--", "inheritance", true),
17+
("--|>", "inheritance", false),
18+
("<|..", "realization", true),
19+
("..|>", "realization", false),
20+
("*--", "composition", false),
21+
("--*", "composition", true),
22+
("o--", "aggregation", false),
23+
("--o", "aggregation", true),
24+
("<..", "dependency", true),
25+
("..>", "dependency", false),
26+
("<--", "association", true),
27+
("-->", "association", false),
28+
("..", "link", false),
29+
("--", "link", false),
30+
];
31+
32+
public bool CanParse(MermaidDiagramKind kind) => kind == MermaidDiagramKind.ClassDiagram;
33+
34+
public Diagram Parse(MermaidDocument document)
35+
{
36+
var hints = new LayoutHints { Direction = LayoutDirection.TopToBottom };
37+
38+
var builder = new DiagramSemanticModelBuilder()
39+
.WithSourceSyntax("mermaid")
40+
.WithDiagramType("classdiagram")
41+
.WithLayoutHints(hints);
42+
43+
var nodesSeen = new Dictionary<string, Node>(StringComparer.Ordinal);
44+
45+
Node GetOrCreateNode(string id)
46+
{
47+
if (!nodesSeen.TryGetValue(id, out var node))
48+
{
49+
node = new Node(id, id) { Shape = Shape.Rectangle };
50+
node.Metadata["class:isClass"] = true;
51+
nodesSeen[id] = node;
52+
builder.AddNode(node);
53+
}
54+
55+
return node;
56+
}
57+
58+
// Current class whose {} block is being parsed (null = top-level).
59+
Node? currentBlockNode = null;
60+
61+
for (int i = 1; i < document.Lines.Length; i++)
62+
{
63+
var line = document.Lines[i];
64+
65+
// ── Close brace ends a member block ─────────────────────────────
66+
if (line == "}")
67+
{
68+
currentBlockNode = null;
69+
continue;
70+
}
71+
72+
// ── Lines inside a member block ──────────────────────────────────
73+
if (currentBlockNode is not null)
74+
{
75+
AddMemberToNode(currentBlockNode, line);
76+
continue;
77+
}
78+
79+
// ── direction ────────────────────────────────────────────────────
80+
if (line.StartsWith("direction ", StringComparison.OrdinalIgnoreCase))
81+
{
82+
hints.Direction = ParseDirection(line);
83+
continue;
84+
}
85+
86+
// ── Explicit class declaration: "class ClassName" or
87+
// "class ClassName[\"label\"]" or "class ClassName {" ─────────
88+
if (line.StartsWith("class ", StringComparison.OrdinalIgnoreCase))
89+
{
90+
var rest = line[6..].Trim();
91+
bool opensBrace = rest.EndsWith('{');
92+
if (opensBrace)
93+
rest = rest[..^1].TrimEnd();
94+
95+
var (id, label) = ParseClassDeclaration(rest);
96+
if (!string.IsNullOrEmpty(id))
97+
{
98+
var node = GetOrCreateNode(id);
99+
if (label is not null)
100+
node.Label = new Label(label);
101+
102+
if (opensBrace)
103+
currentBlockNode = node;
104+
}
105+
106+
continue;
107+
}
108+
109+
// ── ClassName { member block (no "class" keyword) ────────────────
110+
if (line.EndsWith('{'))
111+
{
112+
var candidate = line[..^1].TrimEnd();
113+
if (IsValidClassId(candidate) && FindRelationshipOp(candidate) is null)
114+
{
115+
currentBlockNode = GetOrCreateNode(candidate);
116+
continue;
117+
}
118+
}
119+
120+
// ── Relationship line (try before colon-member to avoid false ────
121+
// positives on "ClassName : member" that looks like a target) ─
122+
if (TryParseRelationship(line, builder, GetOrCreateNode))
123+
continue;
124+
125+
// ── Colon-member: "ClassName : memberDefinition" ─────────────────
126+
if (TryParseColonMember(line, GetOrCreateNode))
127+
continue;
128+
}
129+
130+
return builder.Build();
131+
}
132+
133+
// ── Direction ─────────────────────────────────────────────────────────────
134+
135+
private static LayoutDirection ParseDirection(string line)
136+
{
137+
var lower = line.ToLowerInvariant();
138+
if (lower.Contains(" lr", StringComparison.Ordinal)) return LayoutDirection.LeftToRight;
139+
if (lower.Contains(" rl", StringComparison.Ordinal)) return LayoutDirection.RightToLeft;
140+
if (lower.Contains(" bt", StringComparison.Ordinal)) return LayoutDirection.BottomToTop;
141+
return LayoutDirection.TopToBottom;
142+
}
143+
144+
// ── Class declaration parsing ─────────────────────────────────────────────
145+
146+
/// <summary>
147+
/// Parses the part after "class ": returns (id, optionalLabel).
148+
/// Handles both plain IDs and <c>ClassName["A label"]</c> form.
149+
/// </summary>
150+
private static (string Id, string? Label) ParseClassDeclaration(string token)
151+
{
152+
token = token.Trim();
153+
int bracketStart = token.IndexOf('[');
154+
if (bracketStart > 0 && token.EndsWith(']'))
155+
{
156+
var id = token[..bracketStart].Trim();
157+
var labelRaw = token[(bracketStart + 1)..^1].Trim();
158+
// Strip surrounding quotes that Mermaid allows: ["My Label"]
159+
if (labelRaw.Length >= 2 && labelRaw[0] == '"' && labelRaw[^1] == '"')
160+
labelRaw = labelRaw[1..^1];
161+
return (id, labelRaw.Length > 0 ? labelRaw : null);
162+
}
163+
164+
return (token, null);
165+
}
166+
167+
// ── Member handling ───────────────────────────────────────────────────────
168+
169+
/// <summary>
170+
/// Adds a single member line to the appropriate compartment of <paramref name="node"/>.
171+
/// Methods (those containing <c>()</c>) go to the "methods" compartment;
172+
/// everything else goes to the "attributes" compartment.
173+
/// </summary>
174+
private static void AddMemberToNode(Node node, string memberText)
175+
{
176+
if (string.IsNullOrWhiteSpace(memberText))
177+
return;
178+
179+
memberText = memberText.Trim();
180+
bool isMethod = memberText.Contains('(');
181+
var kind = isMethod ? "methods" : "attributes";
182+
183+
var compartment = node.Compartments.FirstOrDefault(c => c.Kind == kind);
184+
if (compartment is null)
185+
{
186+
compartment = new NodeCompartment(kind);
187+
node.Compartments.Add(compartment);
188+
}
189+
190+
compartment.Lines.Add(new Label(memberText));
191+
}
192+
193+
// ── Colon-member syntax: "ClassName : member" ────────────────────────────
194+
195+
private static bool TryParseColonMember(string line, Func<string, Node> getOrCreate)
196+
{
197+
int colonIdx = line.IndexOf(':');
198+
if (colonIdx <= 0)
199+
return false;
200+
201+
var id = line[..colonIdx].Trim();
202+
var member = line[(colonIdx + 1)..].Trim();
203+
204+
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(member) || !IsValidClassId(id))
205+
return false;
206+
207+
AddMemberToNode(getOrCreate(id), member);
208+
return true;
209+
}
210+
211+
// ── Relationship parsing ──────────────────────────────────────────────────
212+
213+
private static bool TryParseRelationship(
214+
string line,
215+
IDiagramSemanticModelBuilder builder,
216+
Func<string, Node> getOrCreate)
217+
{
218+
var found = FindRelationshipOp(line);
219+
if (found is null)
220+
return false;
221+
222+
var (token, relType, isReversed, opIdx) = found.Value;
223+
224+
var leftRaw = line[..opIdx].Trim();
225+
var rightRaw = line[(opIdx + token.Length)..].Trim();
226+
227+
// Extract relationship label: "ClassName : label" after the right class
228+
string? label = null;
229+
int labelColon = rightRaw.LastIndexOf(':');
230+
if (labelColon >= 0)
231+
{
232+
var candidate = rightRaw[(labelColon + 1)..].Trim();
233+
if (!string.IsNullOrEmpty(candidate))
234+
{
235+
label = candidate;
236+
rightRaw = rightRaw[..labelColon].Trim();
237+
}
238+
}
239+
240+
// Strip cardinality tokens (quoted strings adjacent to class names)
241+
var leftId = ExtractClassId(leftRaw);
242+
var rightId = ExtractClassId(rightRaw);
243+
244+
if (string.IsNullOrEmpty(leftId) || string.IsNullOrEmpty(rightId))
245+
return false;
246+
247+
getOrCreate(leftId);
248+
getOrCreate(rightId);
249+
250+
// When isReversed=true the semantic "from" end is on the right side of the operator
251+
// (e.g. "Animal <|-- Dog": Dog IS the child/source, Animal is the parent/target).
252+
// Swap so that SourceId always represents the logical origin of the relationship.
253+
string sourceId = isReversed ? rightId : leftId;
254+
string targetId = isReversed ? leftId : rightId;
255+
256+
var edge = new Edge(sourceId, targetId);
257+
if (label is not null)
258+
edge.Label = new Label(label);
259+
260+
bool isDashed = token.Contains('.');
261+
edge.LineStyle = isDashed ? EdgeLineStyle.Dashed : EdgeLineStyle.Solid;
262+
edge.ArrowHead = MapArrowHead(relType);
263+
edge.Metadata["class:relationshipType"] = relType;
264+
edge.Metadata["class:operatorReversed"] = isReversed;
265+
266+
builder.AddEdge(edge);
267+
return true;
268+
}
269+
270+
/// <summary>
271+
/// Extracts the class name from an endpoint token, stripping any adjacent
272+
/// quoted cardinality strings (e.g., <c>Animal "1"</c> → <c>Animal</c>;
273+
/// <c>"0..*" Zoo</c> → <c>Zoo</c>).
274+
/// </summary>
275+
private static string ExtractClassId(string part)
276+
{
277+
part = part.Trim();
278+
279+
// Leading quoted cardinality: "0..*" Zoo
280+
if (part.StartsWith('"'))
281+
{
282+
int end = part.IndexOf('"', 1);
283+
if (end >= 0)
284+
part = part[(end + 1)..].Trim();
285+
}
286+
287+
// Trailing quoted cardinality: Animal "1"
288+
if (part.EndsWith('"'))
289+
{
290+
int start = part.LastIndexOf('"', part.Length - 2);
291+
if (start >= 0)
292+
part = part[..start].Trim();
293+
}
294+
295+
return part;
296+
}
297+
298+
private static ArrowHeadStyle MapArrowHead(string relType) =>
299+
relType switch
300+
{
301+
"association" or "dependency" => ArrowHeadStyle.Arrow,
302+
// Composition and aggregation use a diamond marker shape in UML, but the
303+
// current renderer has no distinct diamond marker. Use None here and rely on
304+
// class:relationshipType metadata for future renderer support.
305+
_ => ArrowHeadStyle.None,
306+
};
307+
308+
// ── Operator lookup ───────────────────────────────────────────────────────
309+
310+
private static (string Token, string RelType, bool IsReversed, int Index)?
311+
FindRelationshipOp(string line)
312+
{
313+
(string Token, string RelType, bool IsReversed, int Index)? best = null;
314+
315+
foreach (var (token, relType, isReversed) in RelOperators)
316+
{
317+
int idx = line.IndexOf(token, StringComparison.Ordinal);
318+
if (idx < 0)
319+
continue;
320+
321+
if (best is null
322+
|| idx < best.Value.Index
323+
|| (idx == best.Value.Index && token.Length > best.Value.Token.Length))
324+
{
325+
best = (token, relType, isReversed, idx);
326+
}
327+
}
328+
329+
return best;
330+
}
331+
332+
// ── Helpers ───────────────────────────────────────────────────────────────
333+
334+
/// <summary>
335+
/// Returns <see langword="true"/> when <paramref name="s"/> looks like a valid
336+
/// Mermaid class identifier (starts with a letter or underscore, contains only
337+
/// letters, digits, or underscores).
338+
/// </summary>
339+
private static bool IsValidClassId(string s) =>
340+
s.Length > 0
341+
&& (char.IsLetter(s[0]) || s[0] == '_')
342+
&& s.All(c => char.IsLetterOrDigit(c) || c == '_');
343+
}

src/DiagramForge/Parsers/Mermaid/MermaidDiagramKind.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ internal enum MermaidDiagramKind
1212
Timeline,
1313
ArchitectureDiagram,
1414
XyChart,
15+
ClassDiagram,
1516
}

src/DiagramForge/Parsers/Mermaid/MermaidDocument.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ public static bool TryParse(string diagramText, [System.Diagnostics.CodeAnalysis
8282
// MermaidParser emit a specific "unsupported type" error instead of a generic one.
8383
private static readonly FrozenSet<string> KnownUnsupportedMermaidKeywords = new[]
8484
{
85-
"classdiagram",
8685
"erdiagram",
8786
"journey",
8887
"gantt",
@@ -198,6 +197,11 @@ private static bool TryDetectKind(string headerLine, out MermaidDiagramKind kind
198197
kind = MermaidDiagramKind.XyChart;
199198
return true;
200199
}
200+
if (normalizedHeader.Equals("classdiagram", StringComparison.OrdinalIgnoreCase))
201+
{
202+
kind = MermaidDiagramKind.ClassDiagram;
203+
return true;
204+
}
201205
var spaceIndex = normalizedHeader.IndexOf(' ');
202206
var keyword = spaceIndex >= 0 ? normalizedHeader[..spaceIndex] : normalizedHeader;
203207
if (KnownUnsupportedMermaidKeywords.Contains(keyword))

src/DiagramForge/Parsers/Mermaid/MermaidParser.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ namespace DiagramForge.Parsers.Mermaid;
1717
/// <item>Sequence diagram (sequenceDiagram)</item>
1818
/// <item>Timeline</item>
1919
/// <item>Architecture diagram (architecture-beta)</item>
20+
/// <item>Class diagram (classDiagram)</item>
2021
/// </list>
2122
/// </remarks>
2223
public sealed class MermaidParser : IDiagramParser
@@ -32,9 +33,10 @@ public sealed class MermaidParser : IDiagramParser
3233
new MermaidTimelineParser(),
3334
new MermaidArchitectureParser(),
3435
new MermaidXyChartParser(),
36+
new MermaidClassDiagramParser(),
3537
];
3638

37-
private static readonly string[] SupportedDiagramTypes = ["flowchart", "mindmap", "venn-beta", "statediagram", "block", "sequencediagram", "timeline", "architecture-beta", "xychart", "xychart-beta"];
39+
private static readonly string[] SupportedDiagramTypes = ["flowchart", "mindmap", "venn-beta", "statediagram", "block", "sequencediagram", "timeline", "architecture-beta", "xychart", "xychart-beta", "classdiagram"];
3840

3941
public string SyntaxId => "mermaid";
4042

0 commit comments

Comments
 (0)