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.
- {termrender-4.6.2 → termrender-4.7.0}/CHANGELOG.md +48 -0
- {termrender-4.6.2 → termrender-4.7.0}/PKG-INFO +1 -1
- termrender-4.7.0/src/termrender/renderers/mermaid_class.py +410 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_flow_layout.py +155 -11
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_flow_model.py +25 -2
- termrender-4.7.0/tests/test_mermaid_class.py +307 -0
- {termrender-4.6.2 → termrender-4.7.0}/uv.lock +23 -0
- {termrender-4.6.2 → termrender-4.7.0}/.github/workflows/publish.yml +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/.gitignore +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/CLAUDE.md +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/LICENSE +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/README.md +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/design.json +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/pyproject.toml +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/requirements.json +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/scripts/build-mermaid-ascii.sh +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/scripts/mermaid_flow_parity.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/CLAUDE.md +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/__init__.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/__main__.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/_mermaid_bin.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/blocks.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/emit.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/layout.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/parser.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/py.typed +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/CLAUDE.md +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/__init__.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/borders.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/charts.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/code.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/columns.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/diff.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/divider.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_flow.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_flow_parser.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_gantt.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_journey.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_mindmap.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_pie.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_prelude.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_sequence.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/mermaid_timeline.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/panel.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/quote.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/stat.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/table.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/text.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/timeline.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/renderers/tree.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/src/termrender/style.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/__init__.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_charts.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_cli_contract.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_column_alignment.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_diff.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_inline_badge.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_linebreak.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_compat.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_dispatch.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_flow.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_flow_corpus.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_flow_layout.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_flow_model.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_flow_parser.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_flow_shapes.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_gantt.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_journey.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_mindmap.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_pie.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_sequence.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_mermaid_timeline.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_myst_gaps.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_stat.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_tasklist.py +0 -0
- {termrender-4.6.2 → termrender-4.7.0}/tests/test_timeline.py +0 -0
- {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.
|
|
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
|