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