termrender 4.6.2__tar.gz → 4.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. {termrender-4.6.2 → termrender-4.7.0}/CHANGELOG.md +48 -0
  2. {termrender-4.6.2 → termrender-4.7.0}/PKG-INFO +1 -1
  3. termrender-4.7.0/src/termrender/renderers/mermaid_class.py +410 -0
  4. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_flow_layout.py +155 -11
  5. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_flow_model.py +25 -2
  6. termrender-4.7.0/tests/test_mermaid_class.py +307 -0
  7. {termrender-4.6.2 → termrender-4.7.0}/uv.lock +23 -0
  8. {termrender-4.6.2 → termrender-4.7.0}/.github/workflows/publish.yml +0 -0
  9. {termrender-4.6.2 → termrender-4.7.0}/.gitignore +0 -0
  10. {termrender-4.6.2 → termrender-4.7.0}/CLAUDE.md +0 -0
  11. {termrender-4.6.2 → termrender-4.7.0}/LICENSE +0 -0
  12. {termrender-4.6.2 → termrender-4.7.0}/README.md +0 -0
  13. {termrender-4.6.2 → termrender-4.7.0}/design.json +0 -0
  14. {termrender-4.6.2 → termrender-4.7.0}/pyproject.toml +0 -0
  15. {termrender-4.6.2 → termrender-4.7.0}/requirements.json +0 -0
  16. {termrender-4.6.2 → termrender-4.7.0}/scripts/build-mermaid-ascii.sh +0 -0
  17. {termrender-4.6.2 → termrender-4.7.0}/scripts/mermaid_flow_parity.py +0 -0
  18. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/CLAUDE.md +0 -0
  19. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/__init__.py +0 -0
  20. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/__main__.py +0 -0
  21. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
  22. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/_mermaid_bin.py +0 -0
  23. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/blocks.py +0 -0
  24. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/emit.py +0 -0
  25. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/layout.py +0 -0
  26. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/parser.py +0 -0
  27. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/py.typed +0 -0
  28. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/CLAUDE.md +0 -0
  29. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/__init__.py +0 -0
  30. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/borders.py +0 -0
  31. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/charts.py +0 -0
  32. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/code.py +0 -0
  33. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/columns.py +0 -0
  34. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/diff.py +0 -0
  35. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/divider.py +0 -0
  36. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid.py +0 -0
  37. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_flow.py +0 -0
  38. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_flow_parser.py +0 -0
  39. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_gantt.py +0 -0
  40. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_journey.py +0 -0
  41. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_mindmap.py +0 -0
  42. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_pie.py +0 -0
  43. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_prelude.py +0 -0
  44. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_sequence.py +0 -0
  45. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_timeline.py +0 -0
  46. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/panel.py +0 -0
  47. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/quote.py +0 -0
  48. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/stat.py +0 -0
  49. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/table.py +0 -0
  50. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/text.py +0 -0
  51. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/timeline.py +0 -0
  52. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/tree.py +0 -0
  53. {termrender-4.6.2 → termrender-4.7.0}/src/termrender/style.py +0 -0
  54. {termrender-4.6.2 → termrender-4.7.0}/tests/__init__.py +0 -0
  55. {termrender-4.6.2 → termrender-4.7.0}/tests/test_charts.py +0 -0
  56. {termrender-4.6.2 → termrender-4.7.0}/tests/test_cli_contract.py +0 -0
  57. {termrender-4.6.2 → termrender-4.7.0}/tests/test_column_alignment.py +0 -0
  58. {termrender-4.6.2 → termrender-4.7.0}/tests/test_diff.py +0 -0
  59. {termrender-4.6.2 → termrender-4.7.0}/tests/test_inline_badge.py +0 -0
  60. {termrender-4.6.2 → termrender-4.7.0}/tests/test_linebreak.py +0 -0
  61. {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_compat.py +0 -0
  62. {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_dispatch.py +0 -0
  63. {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_flow.py +0 -0
  64. {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_flow_corpus.py +0 -0
  65. {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_flow_layout.py +0 -0
  66. {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_flow_model.py +0 -0
  67. {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_flow_parser.py +0 -0
  68. {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_flow_shapes.py +0 -0
  69. {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_gantt.py +0 -0
  70. {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_journey.py +0 -0
  71. {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_mindmap.py +0 -0
  72. {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_pie.py +0 -0
  73. {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_sequence.py +0 -0
  74. {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_timeline.py +0 -0
  75. {termrender-4.6.2 → termrender-4.7.0}/tests/test_myst_gaps.py +0 -0
  76. {termrender-4.6.2 → termrender-4.7.0}/tests/test_stat.py +0 -0
  77. {termrender-4.6.2 → termrender-4.7.0}/tests/test_tasklist.py +0 -0
  78. {termrender-4.6.2 → termrender-4.7.0}/tests/test_timeline.py +0 -0
  79. {termrender-4.6.2 → termrender-4.7.0}/tests/test_variable_colons.py +0 -0
@@ -1,6 +1,54 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v4.7.0 (2026-07-07)
5
+
6
+ ### Features
7
+
8
+ - **mermaid-class**: Native classDiagram renderer
9
+ ([`32d6c8c`](https://github.com/crouton-labs/termrender/commit/32d6c8cda0e654120ac2647fe2613f2b06fbb07d))
10
+
11
+ render_class(source, width) -> list[str] in mermaid_class.py, unwired (not imported by mermaid.py's
12
+ dispatcher). Standalone module matching mermaid_sequence.py/mermaid_flow.py's shape: own parser,
13
+ own tests, no Block/pipeline dependency.
14
+
15
+ Parses classDiagram source (class blocks with member lines, member-association form, bare
16
+ declarations, <<stereotype>> annotations, ~T~ generics, direction) into the shared
17
+ FlowGraph/FlowNode/FlowEdge model and hands it to the flowchart engine's layout_flowgraph, using
18
+ the new compartments and arrow-kind extensions. Covers all six UML relationship kinds
19
+ (inheritance, composition, aggregation, association, dependency, realization) plus the two
20
+ headless link forms, both marker-writing directions (<|-- / --|>), quoted cardinalities combined
21
+ with edge labels, and never-crash raw-echo degradation (missing header, empty body, malformed
22
+ input, literal box-glyph sanitization).
23
+
24
+ 24 new golden/topology tests in tests/test_mermaid_class.py; full suite (503 tests) green.
25
+
26
+ - **mermaid-flow**: Support compartmented nodes and UML arrow-kind glyphs
27
+ ([`2b1441c`](https://github.com/crouton-labs/termrender/commit/2b1441cd707621870651c0c391f603c60f8d3529))
28
+
29
+ Extend the flowchart engine with two opt-in, backward-compatible hooks so UML-flavored renderers
30
+ (mermaid classDiagram) can reuse grandalf layout + rasterization + orthogonal routing without
31
+ duplicating it:
32
+
33
+ - FlowNode.compartments: list[list[str]] | None draws a multi-line box with a horizontal separator
34
+ row between compartments (UML name/ fields/methods bands) instead of the single wrapped label.
35
+ None (the default) is byte-for-byte the original single-label path. -
36
+ FlowEdge.dst_arrow_kind/src_arrow_kind (default "default") select an arrowhead glyph family
37
+ independent of style/line weight: triangle_hollow (inheritance/realization), diamond_filled
38
+ (composition), diamond_hollow (aggregation) — layered on top of the existing direction-computed
39
+ \u25bc\u25b2\u25b6\u25c0 selection.
40
+
41
+ All 479 pre-existing tests pass unmodified; every new field defaults to the prior behavior.
42
+
43
+
44
+ ## v4.6.3 (2026-07-07)
45
+
46
+ ### Bug Fixes
47
+
48
+ - Commit uv.lock resolution for grandalf runtime dependency
49
+ ([`0b2a39e`](https://github.com/crouton-labs/termrender/commit/0b2a39ee4440a907c309328920062e543fd5e47e))
50
+
51
+
4
52
  ## v4.6.2 (2026-07-07)
5
53
 
6
54
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 4.6.2
3
+ Version: 4.7.0
4
4
  Summary: Rich terminal rendering of directive-flavored markdown
5
5
  Project-URL: Homepage, https://github.com/CaptainCrouton89/termrender
6
6
  Project-URL: Repository, https://github.com/CaptainCrouton89/termrender
@@ -0,0 +1,410 @@
1
+ """Native renderer for mermaid ``classDiagram`` sources.
2
+
3
+ Standalone module: exposes a single pure function, :func:`render_class`.
4
+ Not wired into ``mermaid.py``'s dispatcher (a later, orchestrator-owned
5
+ phase) — nothing in this package imports it. Own parser, own tests, no
6
+ dependency on ``Block`` or the render pipeline, matching the shape of
7
+ ``mermaid_sequence.py`` and ``mermaid_flow.py``.
8
+
9
+ This module does no layout or rasterization of its own. It parses mermaid
10
+ ``classDiagram`` source into the *same* :class:`~termrender.renderers.
11
+ mermaid_flow_model.FlowGraph` the native flowchart engine consumes — each
12
+ UML class becomes a :class:`~termrender.renderers.mermaid_flow_model.
13
+ FlowNode` (a compartmented box when the class has a body/annotation, a
14
+ plain single-label box otherwise) and each UML relationship becomes a
15
+ :class:`~termrender.renderers.mermaid_flow_model.FlowEdge` (arrow-kind
16
+ glyphs picked per the UML relation vocabulary) — then hands the graph to
17
+ :func:`~termrender.renderers.mermaid_flow_layout.layout_flowgraph`, which
18
+ owns grandalf layout, box rasterization, and orthogonal edge routing. See
19
+ that module's docstring, and its "UML extension points" section
20
+ specifically, for how compartments and arrow kinds render.
21
+
22
+ Grammar supported
23
+ ------------------
24
+ - Header: ``classDiagram`` / ``classDiagram-v2``. Optional ``direction
25
+ TB``/``TD``/``LR``/``RL``/``BT`` statement (``TD`` normalizes to ``TB``);
26
+ defaults to ``TB``.
27
+ - Class blocks: ``class Name { ... }``, either as a single line
28
+ (``class Name { +field type; +method() }``, ``;``-separated members) or
29
+ spanning multiple lines up to a standalone ``}``. A member line with a
30
+ literal ``(`` is a method, otherwise a field — mermaid's own convention
31
+ (``+String owner`` is a field, ``+deposit(amount) bool`` is a method).
32
+ Visibility markers (``+ - # ~``) are kept verbatim as typed; no semantic
33
+ meaning is attached to them beyond display. A ``<<Stereotype>>`` line
34
+ inside the block is an annotation (rendered ``«Stereotype»`` above the
35
+ class name), not a member.
36
+ - Member association: ``Name : +method()`` — appends one member to a class
37
+ without an explicit block (same field/method classification as inside a
38
+ block).
39
+ - Bare class declaration: ``class Name`` (no body) and standalone
40
+ annotation: ``<<Interface>> Name``.
41
+ - Generics: ``List~T~`` (anywhere — class name or member text) renders as
42
+ ``List<T>``.
43
+ - Relationships, both writing directions accepted (``<|--`` / ``--|>`` mean
44
+ the same thing, marker on whichever side): inheritance (``<|--``/``--|>``,
45
+ hollow triangle, solid line), realization (``<|..``/``..|>``, hollow
46
+ triangle, dashed line), composition (``*--``/``--*``, filled diamond),
47
+ aggregation (``o--``/``--o``, hollow diamond), association (``-->``/
48
+ ``<--``, filled arrow), dependency (``..>``/``<..``, dashed filled
49
+ arrow), plus the two headless forms (``--`` solid link, ``..`` dashed
50
+ link). Optional quoted cardinalities on either side
51
+ (``A "1" --> "many" B``) and a trailing ``: label`` combine into one
52
+ edge label (``"1 label many"``, extra-empty parts dropped) — the engine
53
+ centers this single combined label on the edge's longest straight run;
54
+ the UML convention of a cardinality glyph pinned at each endpoint
55
+ separately isn't supported (see *Known degradations*).
56
+ - ``%%`` comments are dropped.
57
+
58
+ Never crashes: any unparseable input (missing header, or a parse/layout
59
+ exception) degrades to a **raw echo** of the source lines with no
60
+ box-drawing/geometric glyphs (same contract, same glyph ranges, as
61
+ ``mermaid_flow.py`` — see that module's docstring for why this exact
62
+ degradation matters to the crouter attach viewer).
63
+
64
+ Known degradations (by design, not bugs)
65
+ -----------------------------------------
66
+ - Cardinalities and a relation label are combined into a single label
67
+ string centered on the edge, not three independently-placed annotations
68
+ (per-endpoint cardinality glyphs, common in dedicated UML tools) — the
69
+ underlying engine places one label per edge.
70
+ - ``note`` statements, `<<...>>` generic constraints beyond the ``~T~``
71
+ substitution, and class-diagram namespaces are not recognized — lines
72
+ using them are silently dropped (best-effort parsing, never raised).
73
+ - A relation's *source*-side marker (inheritance/composition/aggregation's
74
+ glyph on the "whole"/parent class) is drawn at that class's single
75
+ shared exit anchor for its rank-flow direction. When one class has
76
+ several outgoing relations that each carry a source-side marker (e.g. a
77
+ class that both composes one collaborator and aggregates another), all
78
+ of them exit through that same anchor cell and only the last-drawn
79
+ marker glyph survives there — topology and line routing stay correct
80
+ for every edge, but the specific marker glyph shown at that shared point
81
+ reflects only one of the colliding relations. Rare in small diagrams
82
+ (most classes have at most one source-marked outgoing relation);
83
+ accepted the same way the engine already accepts dense-graph line
84
+ crossings, rather than reworking shared anchor allocation across the
85
+ underlying flowchart engine.
86
+ - See ``mermaid_flow_layout.py``'s docstring for the inherited engine
87
+ degradations (dense-graph crossings, CJK wrap width, minimum-box-size
88
+ cosmetics) — this renderer's output goes through the same rasterizer and
89
+ router as flowcharts.
90
+ """
91
+
92
+ from __future__ import annotations
93
+
94
+ import re
95
+
96
+ from termrender.renderers.mermaid_flow_layout import layout_flowgraph
97
+ from termrender.renderers.mermaid_flow_model import (
98
+ Direction,
99
+ EdgeStyle,
100
+ FlowEdge,
101
+ FlowGraph,
102
+ FlowNode,
103
+ NodeShape,
104
+ )
105
+ from termrender.renderers.mermaid_prelude import strip_prelude_lines
106
+
107
+ __all__ = ["render_class", "ClassDiagramError"]
108
+
109
+
110
+ class ClassDiagramError(Exception):
111
+ """Raised when source cannot be parsed as a mermaid class diagram at all."""
112
+
113
+
114
+ # --------------------------------------------------------------------------
115
+ # Grammar
116
+ # --------------------------------------------------------------------------
117
+
118
+ _HEADER_RE = re.compile(r"^classDiagram(?:-v2)?\b", re.IGNORECASE)
119
+ _DIRECTION_RE = re.compile(r"^direction\s+(TB|TD|LR|RL|BT)\b", re.IGNORECASE)
120
+ _COMMENT_RE = re.compile(r"^%%")
121
+
122
+ _CLASS_OPEN_RE = re.compile(r"^class\s+(\S+)\s*\{(.*)$")
123
+ _CLASS_BARE_RE = re.compile(r"^class\s+(\S+)\s*$")
124
+ _ANNOTATION_STANDALONE_RE = re.compile(r"^<<(.+?)>>\s+(\S+)\s*$")
125
+ _ANNOTATION_ONLY_RE = re.compile(r"^<<(.+?)>>\s*$")
126
+ _MEMBER_ASSOC_RE = re.compile(r'^(?P<id>[^\s"]+)\s*:\s*(?P<member>.+)$')
127
+
128
+ _GENERIC_RE = re.compile(r"~([^~]*)~")
129
+
130
+ # Relation operator: an optional left head marker, a >=2-run dash/dot core
131
+ # (dash = solid, dot = dashed), an optional right head marker. Searched
132
+ # against a quote-masked copy of the line (see _mask_quotes) so a quoted
133
+ # range cardinality like "0..1" is never mistaken for the operator itself.
134
+ _OP_RE = re.compile(r"(?P<lhead><\||\*|o|<)?(?P<core>-{2,}|\.{2,})(?P<rhead>\|>|\*|o|>)?")
135
+ _REL_LEFT_RE = re.compile(r'^\s*(?P<id>[^\s"]+)\s*(?:"(?P<card>[^"]*)")?\s*$')
136
+ _REL_RIGHT_RE = re.compile(
137
+ r'^\s*(?:"(?P<card>[^"]*)")?\s*(?P<id>[^\s":]+)\s*(?::\s*(?P<label>.*))?$'
138
+ )
139
+
140
+ _GLYPH_RANGE_RE = re.compile("[\u2500-\u259f\u25a0-\u25ff]")
141
+
142
+
143
+ class _ClassDef:
144
+ """Mutable parse-time record for one UML class — converted to a
145
+ :class:`FlowNode` at the end of parsing (see :func:`_build_graph`)."""
146
+
147
+ __slots__ = ("id", "display_name", "annotations", "fields", "methods", "has_block")
148
+
149
+ def __init__(self, id_: str, display_name: str) -> None:
150
+ self.id = id_
151
+ self.display_name = display_name
152
+ self.annotations: list[str] = []
153
+ self.fields: list[str] = []
154
+ self.methods: list[str] = []
155
+ self.has_block = False
156
+
157
+
158
+ def _format_generics(text: str) -> str:
159
+ """``List~T~`` -> ``List<T>`` (also handles multi-param ``Map~K, V~``)."""
160
+ return _GENERIC_RE.sub(r"<\1>", text)
161
+
162
+
163
+ def _strip_generics_id(token: str) -> str:
164
+ """The graph-key id for a (possibly generic) class token: everything
165
+ before the first ``~``, so ``Stack~T~`` and a later bare ``Stack``
166
+ reference resolve to the same node."""
167
+ return token.split("~", 1)[0]
168
+
169
+
170
+ def _mask_quotes(line: str) -> str:
171
+ """Replace every quoted span with same-length filler so a quoted range
172
+ cardinality (``"0..1"``) can never be mistaken for the relation
173
+ operator when searching for it (see ``_OP_RE``); positions in the
174
+ masked string stay valid indices into the original line."""
175
+ return re.sub(r'"[^"]*"', lambda m: "Q" * len(m.group(0)), line)
176
+
177
+
178
+ def _get_or_create(classes: dict[str, _ClassDef], token: str) -> _ClassDef:
179
+ cid = _strip_generics_id(token)
180
+ cls = classes.get(cid)
181
+ if cls is None:
182
+ cls = _ClassDef(cid, _format_generics(token))
183
+ classes[cid] = cls
184
+ elif "~" in token:
185
+ cls.display_name = _format_generics(token)
186
+ return cls
187
+
188
+
189
+ def _consume_members(cls: _ClassDef, text: str) -> None:
190
+ """Split ``text`` (one physical line, or the inline body of a
191
+ ``class Name { ... }`` opener/closer) on ``;`` into individual member/
192
+ annotation statements and file each into ``cls``."""
193
+ for part in text.split(";"):
194
+ part = part.strip()
195
+ if not part:
196
+ continue
197
+ am = _ANNOTATION_ONLY_RE.match(part)
198
+ if am:
199
+ cls.annotations.append(am.group(1).strip())
200
+ continue
201
+ formatted = _format_generics(part)
202
+ if "(" in formatted:
203
+ cls.methods.append(formatted)
204
+ else:
205
+ cls.fields.append(formatted)
206
+
207
+
208
+ _ARROW_KIND = {
209
+ "<|": "triangle_hollow",
210
+ "|>": "triangle_hollow",
211
+ "*": "diamond_filled",
212
+ "o": "diamond_hollow",
213
+ "<": "default",
214
+ ">": "default",
215
+ }
216
+
217
+
218
+ def _try_relation(
219
+ line: str, classes: dict[str, _ClassDef], edges: list[FlowEdge]
220
+ ) -> bool:
221
+ masked = _mask_quotes(line)
222
+ m = _OP_RE.search(masked)
223
+ if not m or not (m.group("lhead") or m.group("rhead") or m.group("core")):
224
+ return False
225
+
226
+ left_text, right_text = line[: m.start()], line[m.end() :]
227
+ lm = _REL_LEFT_RE.match(left_text)
228
+ rm = _REL_RIGHT_RE.match(right_text)
229
+ if not lm or not rm:
230
+ return False
231
+
232
+ src_token, dst_token = lm.group("id"), rm.group("id")
233
+ src = _get_or_create(classes, src_token)
234
+ dst = _get_or_create(classes, dst_token)
235
+
236
+ style = EdgeStyle.DOTTED if m.group("core").startswith(".") else EdgeStyle.SOLID
237
+ lhead, rhead = m.group("lhead"), m.group("rhead")
238
+ src_arrow, dst_arrow = lhead is not None, rhead is not None
239
+ src_kind = _ARROW_KIND.get(lhead, "default")
240
+ dst_kind = _ARROW_KIND.get(rhead, "default")
241
+
242
+ # Cardinalities read src-side then dst-side, with the relation label
243
+ # (if any) sitting between them — combined into one string so a
244
+ # labeled+cardinalitied edge reads "1 contains many", not
245
+ # "1 many contains" (the underlying engine centers one label per edge).
246
+ src_card, dst_card = lm.group("card"), rm.group("card")
247
+ label_text = rm.group("label")
248
+ ordered = [p for p in (src_card, label_text, dst_card) if p]
249
+ label = " ".join(ordered) if ordered else None
250
+
251
+ edges.append(
252
+ FlowEdge(
253
+ src=src.id,
254
+ dst=dst.id,
255
+ style=style,
256
+ label=_format_generics(label) if label else None,
257
+ dst_arrow=dst_arrow,
258
+ src_arrow=src_arrow,
259
+ dst_arrow_kind=dst_kind,
260
+ src_arrow_kind=src_kind,
261
+ )
262
+ )
263
+ return True
264
+
265
+
266
+ def _build_graph(source: str) -> FlowGraph:
267
+ lines = source.splitlines()
268
+ sniff_lines = strip_prelude_lines(lines)
269
+ first = next((line.strip() for line in sniff_lines if line.strip()), "")
270
+ if not _HEADER_RE.match(first):
271
+ raise ClassDiagramError(
272
+ "not a mermaid class diagram: source must start with 'classDiagram'"
273
+ )
274
+
275
+ direction = Direction.TB
276
+ classes: dict[str, _ClassDef] = {}
277
+ edges: list[FlowEdge] = []
278
+ seen_header = False
279
+ block_cls: _ClassDef | None = None
280
+
281
+ for raw_line in lines:
282
+ line = raw_line.strip()
283
+ if not line:
284
+ continue
285
+
286
+ if block_cls is not None:
287
+ if line.endswith("}"):
288
+ _consume_members(block_cls, line[:-1])
289
+ block_cls = None
290
+ else:
291
+ _consume_members(block_cls, line)
292
+ continue
293
+
294
+ if not seen_header:
295
+ if _HEADER_RE.match(line):
296
+ seen_header = True
297
+ continue
298
+
299
+ if _COMMENT_RE.match(line):
300
+ continue
301
+
302
+ dm = _DIRECTION_RE.match(line)
303
+ if dm:
304
+ token = dm.group(1).upper()
305
+ direction = Direction.TB if token == "TD" else Direction(token)
306
+ continue
307
+
308
+ om = _CLASS_OPEN_RE.match(line)
309
+ if om:
310
+ name_token, inline_body = om.group(1), om.group(2)
311
+ cls = _get_or_create(classes, name_token)
312
+ cls.has_block = True
313
+ body = inline_body.rstrip()
314
+ if body.endswith("}"):
315
+ _consume_members(cls, body[:-1])
316
+ else:
317
+ _consume_members(cls, body)
318
+ block_cls = cls
319
+ continue
320
+
321
+ bm = _CLASS_BARE_RE.match(line)
322
+ if bm:
323
+ _get_or_create(classes, bm.group(1))
324
+ continue
325
+
326
+ am = _ANNOTATION_STANDALONE_RE.match(line)
327
+ if am:
328
+ cls = _get_or_create(classes, am.group(2))
329
+ cls.annotations.append(am.group(1).strip())
330
+ continue
331
+
332
+ if _try_relation(line, classes, edges):
333
+ continue
334
+
335
+ mm = _MEMBER_ASSOC_RE.match(line)
336
+ if mm:
337
+ cls = _get_or_create(classes, mm.group("id"))
338
+ cls.has_block = True
339
+ _consume_members(cls, mm.group("member"))
340
+ continue
341
+
342
+ # Unrecognized line: consumed silently, best-effort.
343
+
344
+ if not seen_header:
345
+ raise ClassDiagramError(
346
+ "not a mermaid class diagram: source must start with 'classDiagram'"
347
+ )
348
+
349
+ nodes: list[FlowNode] = []
350
+ for cls in classes.values():
351
+ name_lines = [f"\u00ab{a}\u00bb" for a in cls.annotations] + [cls.display_name]
352
+ if cls.has_block:
353
+ compartments = [name_lines, cls.fields or [""], cls.methods or [""]]
354
+ elif cls.annotations:
355
+ compartments = [name_lines]
356
+ else:
357
+ compartments = None
358
+ nodes.append(
359
+ FlowNode(
360
+ id=cls.id,
361
+ label=cls.display_name,
362
+ shape=NodeShape.RECT,
363
+ compartments=compartments,
364
+ )
365
+ )
366
+
367
+ return FlowGraph(direction=direction, nodes=nodes, edges=edges, subgraphs=[])
368
+
369
+
370
+ def _raw_echo(source: str) -> list[str]:
371
+ return [
372
+ _GLYPH_RANGE_RE.sub("?", line.rstrip()) for line in source.splitlines()
373
+ ]
374
+
375
+
376
+ def render_class(source: str, width: int) -> list[str]:
377
+ """Render a mermaid ``classDiagram`` source to unicode lines.
378
+
379
+ Pure and total: never raises. Degrades to a raw echo of ``source``
380
+ (see the module docstring's "Never crashes" note) when the source
381
+ isn't a class diagram at all, parses to zero classes, or an
382
+ unexpected exception escapes layout.
383
+
384
+ Args:
385
+ source: The mermaid fence body (with or without surrounding fence
386
+ markers — only the text between them).
387
+ width: Advisory terminal width — unused, like the flowchart and
388
+ sequence renderers; this function sizes to content.
389
+
390
+ Returns:
391
+ Rendered lines on success (guaranteed to contain box-drawing
392
+ glyphs), or a raw echo of ``source`` on any degradation path.
393
+ """
394
+ try:
395
+ graph = _build_graph(source)
396
+ except ClassDiagramError:
397
+ return _raw_echo(source)
398
+
399
+ if not graph.nodes:
400
+ return _raw_echo(source)
401
+
402
+ try:
403
+ lines = layout_flowgraph(graph, width)
404
+ except Exception:
405
+ return _raw_echo(source)
406
+
407
+ if not lines:
408
+ return _raw_echo(source)
409
+
410
+ return lines