termrender 4.7.0__tar.gz → 4.9.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.7.0 → termrender-4.9.0}/CHANGELOG.md +53 -0
- {termrender-4.7.0 → termrender-4.9.0}/PKG-INFO +1 -1
- termrender-4.9.0/src/termrender/renderers/mermaid_er.py +388 -0
- termrender-4.9.0/src/termrender/renderers/mermaid_state.py +519 -0
- termrender-4.9.0/tests/test_mermaid_er.py +302 -0
- termrender-4.9.0/tests/test_mermaid_state.py +486 -0
- {termrender-4.7.0 → termrender-4.9.0}/.github/workflows/publish.yml +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/.gitignore +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/CLAUDE.md +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/LICENSE +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/README.md +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/design.json +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/pyproject.toml +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/requirements.json +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/scripts/build-mermaid-ascii.sh +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/scripts/mermaid_flow_parity.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/CLAUDE.md +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/__init__.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/__main__.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/_mermaid_bin.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/blocks.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/emit.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/layout.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/parser.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/py.typed +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/CLAUDE.md +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/__init__.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/borders.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/charts.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/code.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/columns.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/diff.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/divider.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_class.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_flow.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_flow_layout.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_flow_model.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_flow_parser.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_gantt.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_journey.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_mindmap.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_pie.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_prelude.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_sequence.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_timeline.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/panel.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/quote.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/stat.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/table.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/text.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/timeline.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/tree.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/src/termrender/style.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/__init__.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_charts.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_cli_contract.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_column_alignment.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_diff.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_inline_badge.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_linebreak.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_class.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_compat.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_dispatch.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_flow.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_flow_corpus.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_flow_layout.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_flow_model.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_flow_parser.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_flow_shapes.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_gantt.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_journey.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_mindmap.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_pie.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_sequence.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_timeline.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_myst_gaps.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_stat.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_tasklist.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_timeline.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/tests/test_variable_colons.py +0 -0
- {termrender-4.7.0 → termrender-4.9.0}/uv.lock +0 -0
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v4.9.0 (2026-07-07)
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
- **mermaid-er**: Native erDiagram renderer
|
|
9
|
+
([`caae993`](https://github.com/crouton-labs/termrender/commit/caae9930b6a996e33b401dc773c5a9aae01b1529))
|
|
10
|
+
|
|
11
|
+
Parses mermaid erDiagram source (entity attribute blocks with PK/FK/UK markers and quoted comments,
|
|
12
|
+
bare entities, the full crow's-foot cardinality grammar in both writing directions, identifying/
|
|
13
|
+
non-identifying relationship lines, entity/attribute aliasing and quoted names) into the shared
|
|
14
|
+
FlowGraph model and hands it to the existing layout_flowgraph engine (compartmented boxes, edge
|
|
15
|
+
labels). Never crashes: unparseable input degrades to a raw echo with no box-drawing glyphs,
|
|
16
|
+
matching mermaid_class.py's contract.
|
|
17
|
+
|
|
18
|
+
Not wired into mermaid.py's dispatcher (later phase). Read-only against mermaid_flow*.py.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
## v4.8.0 (2026-07-07)
|
|
22
|
+
|
|
23
|
+
### Features
|
|
24
|
+
|
|
25
|
+
- Native mermaid stateDiagram renderer on the flowchart engine
|
|
26
|
+
([`ff4fa87`](https://github.com/crouton-labs/termrender/commit/ff4fa87d275d0e18f64e386c16e13e237ad99923))
|
|
27
|
+
|
|
28
|
+
Adds src/termrender/renderers/mermaid_state.py exposing render_state(source, width) -> list[str] as
|
|
29
|
+
a thin adapter: parses stateDiagram/stateDiagram-v2 grammar into the existing flowchart engine's
|
|
30
|
+
own FlowGraph/FlowNode/FlowEdge/Subgraph model and renders through layout_flowgraph directly
|
|
31
|
+
(grandalf layout, box rasterizer, orthogonal router, subgraph frames), reusing that engine's
|
|
32
|
+
degradation contract unchanged.
|
|
33
|
+
|
|
34
|
+
Covers: A --> B / A --> B : event transitions; [*] as per-scope start/end pseudo-states (distinct
|
|
35
|
+
compact glyph markers, shared across every [*] reference in one scope); state "Long name" as s1
|
|
36
|
+
aliases; state X { ... } composite states as nested subgraph frames (a composite referenced
|
|
37
|
+
directly by an external transition gets its own proxy box, since the engine has no edge-to-frame
|
|
38
|
+
anchor); <<choice>> as a diamond, <<fork>>/<<join>> degrading to the engine's plain small rect;
|
|
39
|
+
direction LR/TD/RL/BT; note left/right of X (inline and multi-line note/end note forms) attached
|
|
40
|
+
via a headless dotted edge so text is never dropped; %% comments skipped anywhere including inside
|
|
41
|
+
note blocks; -- / --- concurrency separators flattened.
|
|
42
|
+
|
|
43
|
+
Unwired: not imported by mermaid.py's dispatcher (later phase). Only touches new files; the
|
|
44
|
+
flowchart engine files remain untouched (read-only, per a concurrent sibling's ownership).
|
|
45
|
+
|
|
46
|
+
tests/test_mermaid_state.py: 24 tests asserting real topology/geometry (row/column ordering, frame
|
|
47
|
+
containment, distinct start/end glyphs, diamond-shape glyphs, label text) across a simple
|
|
48
|
+
start/end machine, labeled transitions, aliases, choice/fork/join, composite + nested composite
|
|
49
|
+
states, inline/multi-line notes, direction LR, comments, and the degradation contract
|
|
50
|
+
(header-missing, zero-node, and crash-guard echo paths, each pinned to the same three-condition
|
|
51
|
+
rule the flowchart engine's own tests already establish — best-effort recoverable body-level
|
|
52
|
+
glitches, like a stray '}' or an unterminated composite/note, render real content rather than
|
|
53
|
+
forcing an echo, matching mermaid_flow_parser.py's documented 'auto-closed rather than dropped' /
|
|
54
|
+
'consumed silently, best-effort' policy).
|
|
55
|
+
|
|
56
|
+
|
|
4
57
|
## v4.7.0 (2026-07-07)
|
|
5
58
|
|
|
6
59
|
### Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: termrender
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.9.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,388 @@
|
|
|
1
|
+
"""Native renderer for mermaid ``erDiagram`` sources.
|
|
2
|
+
|
|
3
|
+
Standalone module: exposes a single pure function, :func:`render_er`. Not
|
|
4
|
+
wired into ``mermaid.py``'s dispatcher (a later, orchestrator-owned phase)
|
|
5
|
+
— nothing in this package imports it. Own parser, own tests, no dependency
|
|
6
|
+
on ``Block`` or the render pipeline, matching the shape of
|
|
7
|
+
``mermaid_class.py`` and ``mermaid_flow.py``.
|
|
8
|
+
|
|
9
|
+
This module does no layout or rasterization of its own. It parses mermaid
|
|
10
|
+
``erDiagram`` source into the *same* :class:`~termrender.renderers.
|
|
11
|
+
mermaid_flow_model.FlowGraph` the native flowchart engine consumes — each
|
|
12
|
+
ER entity becomes a :class:`~termrender.renderers.mermaid_flow_model.
|
|
13
|
+
FlowNode` (a compartmented box when the entity has an attribute block, a
|
|
14
|
+
plain single-label box otherwise) and each ER relationship becomes a
|
|
15
|
+
:class:`~termrender.renderers.mermaid_flow_model.FlowEdge` (a plain,
|
|
16
|
+
headless line — ER cardinality is drawn as edge-label text, not an
|
|
17
|
+
arrowhead glyph family) — then hands the graph to
|
|
18
|
+
:func:`~termrender.renderers.mermaid_flow_layout.layout_flowgraph`, which
|
|
19
|
+
owns grandalf layout, box rasterization, and orthogonal edge routing. See
|
|
20
|
+
that module's docstring for how compartments render.
|
|
21
|
+
|
|
22
|
+
Grammar supported
|
|
23
|
+
------------------
|
|
24
|
+
- Header: ``erDiagram``. Diagram direction is always top-to-bottom
|
|
25
|
+
(mermaid's erDiagram has no ``direction`` statement of its own, unlike
|
|
26
|
+
flowcharts/class diagrams).
|
|
27
|
+
- Entity blocks: ``ENTITY { ... }``, either as a single line
|
|
28
|
+
(``CUSTOMER { string name PK }``) or spanning multiple lines up to a
|
|
29
|
+
standalone ``}``. Each attribute line is ``type name [PK|FK|UK[, ...]]
|
|
30
|
+
["comment"]`` — type and name are required, the key marker(s) and the
|
|
31
|
+
trailing quoted comment are both optional. The comment is parsed (so it
|
|
32
|
+
doesn't corrupt the match) but dropped from the rendered row — see
|
|
33
|
+
*Known degradations*.
|
|
34
|
+
- Bare entity declarations: ``ENTITY`` on its own line, and entities
|
|
35
|
+
referenced only from a relationship line (never given a block) — both
|
|
36
|
+
render as a plain single-label box (no compartments), matching
|
|
37
|
+
``mermaid_class.py``'s bare-class handling.
|
|
38
|
+
- Entity/attribute aliases: ``p[Person]`` (id ``p``, displayed as
|
|
39
|
+
``Person``) and quoted names/ids containing spaces or punctuation
|
|
40
|
+
(``"driver's license"``, ``"Order Item"[OI]``) are recognized wherever
|
|
41
|
+
an entity token or an attribute name appears; the quotes are stripped
|
|
42
|
+
for display, the unquoted text is the graph key.
|
|
43
|
+
- Relationships: ``ENTITY1 <left-card><line><right-card> ENTITY2 :
|
|
44
|
+
label``. ``<line>`` is ``--`` (identifying) or ``..`` (non-identifying);
|
|
45
|
+
identifying relationships render as a solid line, non-identifying as a
|
|
46
|
+
dashed line (:class:`EdgeStyle.DOTTED`, same glyphs the class-diagram
|
|
47
|
+
renderer uses for realization/dependency). ``<left-card>``/
|
|
48
|
+
``<right-card>`` are mermaid's crow's-foot pairs read next to their
|
|
49
|
+
adjacent entity — ``||`` exactly one, ``o|``/``|o`` zero-or-one,
|
|
50
|
+
``}|``/``|{`` one-or-more, ``}o``/``o{`` zero-or-more (both writing
|
|
51
|
+
directions accepted, per mermaid's own grammar) — translated to text
|
|
52
|
+
markers ``1``, ``0..1``, ``1..*``, ``0..*`` and combined with the
|
|
53
|
+
optional ``: label`` into one string (``"1 places 0..*"``), centered on
|
|
54
|
+
the edge by the engine. Relationship lines never draw an arrowhead
|
|
55
|
+
(``dst_arrow``/``src_arrow`` both ``False``) — ER cardinality is
|
|
56
|
+
conveyed entirely by the label text, not by a glyph family, since crow's
|
|
57
|
+
foot notation has no equivalent among the engine's existing arrow-kind
|
|
58
|
+
glyphs (see *Known degradations*).
|
|
59
|
+
- ``%%`` comments are dropped.
|
|
60
|
+
|
|
61
|
+
Never crashes: any unparseable input (missing header, or a parse/layout
|
|
62
|
+
exception) degrades to a **raw echo** of the source lines with no
|
|
63
|
+
box-drawing/geometric glyphs (same contract, same glyph ranges, as
|
|
64
|
+
``mermaid_flow.py`` and ``mermaid_class.py`` — see those modules'
|
|
65
|
+
docstrings for why this exact degradation matters to the crouter attach
|
|
66
|
+
viewer).
|
|
67
|
+
|
|
68
|
+
Known degradations (by design, not bugs)
|
|
69
|
+
-----------------------------------------
|
|
70
|
+
- Cardinality and the relation label are combined into a single string
|
|
71
|
+
centered on the edge (``"1 places 0..*"``), not three independently
|
|
72
|
+
placed annotations (one marker pinned at each endpoint plus a centered
|
|
73
|
+
label, the dedicated-ER-tool convention) — the underlying engine places
|
|
74
|
+
one label per edge, same limitation ``mermaid_class.py`` documents for
|
|
75
|
+
UML cardinalities.
|
|
76
|
+
- No crow's-foot glyphs are drawn at the endpoints; cardinality is text
|
|
77
|
+
only. The engine's arrow-kind extension point supports UML triangle/
|
|
78
|
+
diamond families, not a crow's-foot family, and adding one is out of
|
|
79
|
+
scope for this renderer (an engine change, not a parser change).
|
|
80
|
+
- Attribute comments (the trailing quoted string in an attribute line)
|
|
81
|
+
are parsed — so they don't break the match or leak into the name/key
|
|
82
|
+
columns — but dropped entirely from the rendered row rather than
|
|
83
|
+
wrapped or truncated onto a second line.
|
|
84
|
+
- An entity declared with an empty block (``ENTITY { }``) still gets a
|
|
85
|
+
two-compartment box (title + a single blank attribute row) rather than
|
|
86
|
+
collapsing to a plain box — mirrors ``mermaid_class.py``'s
|
|
87
|
+
always-show-the-band-if-a-block-was-declared behavior for classes with
|
|
88
|
+
an empty fields/methods section.
|
|
89
|
+
- See ``mermaid_flow_layout.py``'s docstring for the inherited engine
|
|
90
|
+
degradations (dense-graph crossings, CJK wrap width, minimum-box-size
|
|
91
|
+
cosmetics) — this renderer's output goes through the same rasterizer
|
|
92
|
+
and router as flowcharts and class diagrams.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
from __future__ import annotations
|
|
96
|
+
|
|
97
|
+
import re
|
|
98
|
+
|
|
99
|
+
from termrender.renderers.mermaid_flow_layout import layout_flowgraph
|
|
100
|
+
from termrender.renderers.mermaid_flow_model import (
|
|
101
|
+
Direction,
|
|
102
|
+
EdgeStyle,
|
|
103
|
+
FlowEdge,
|
|
104
|
+
FlowGraph,
|
|
105
|
+
FlowNode,
|
|
106
|
+
NodeShape,
|
|
107
|
+
)
|
|
108
|
+
from termrender.renderers.mermaid_prelude import strip_prelude_lines
|
|
109
|
+
|
|
110
|
+
__all__ = ["render_er", "ERDiagramError"]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ERDiagramError(Exception):
|
|
114
|
+
"""Raised when source cannot be parsed as a mermaid ER diagram at all."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --------------------------------------------------------------------------
|
|
118
|
+
# Grammar
|
|
119
|
+
# --------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
_HEADER_RE = re.compile(r"^erDiagram\b", re.IGNORECASE)
|
|
122
|
+
_COMMENT_RE = re.compile(r"^%%")
|
|
123
|
+
|
|
124
|
+
# An entity token: a quoted name (optionally aliased, `"Order Item"[OI]`),
|
|
125
|
+
# or a bare run of non-space/non-brace characters (`CUSTOMER`, `p[Person]`,
|
|
126
|
+
# `LINE-ITEM`). Used for bare declarations, block openers, and relationship
|
|
127
|
+
# endpoints alike (see `_parse_entity_token`).
|
|
128
|
+
_ENTITY_TOKEN = r'"[^"]*"(?:\[[^\]]*\])?|[^\s{]+'
|
|
129
|
+
|
|
130
|
+
_ENTITY_TOKEN_RE = re.compile(r'^(?P<id>"[^"]*"|[^\[\s]+)(?:\[(?P<alias>[^\]]*)\])?$')
|
|
131
|
+
_BLOCK_OPEN_RE = re.compile(rf"^(?P<idtok>{_ENTITY_TOKEN})\s*\{{(?P<inline>.*)$")
|
|
132
|
+
_BARE_ENTITY_RE = re.compile(rf"^(?P<idtok>{_ENTITY_TOKEN})\s*$")
|
|
133
|
+
|
|
134
|
+
# Attribute line: `type name [PK|FK|UK[, ...]] ["comment"]`. The comment is
|
|
135
|
+
# matched (so it can't corrupt the name/keys columns) but never rendered.
|
|
136
|
+
_ATTR_RE = re.compile(
|
|
137
|
+
r'^(?P<type>\S+)\s+(?P<name>"[^"]*"|\S+)'
|
|
138
|
+
r"(?:\s+(?P<keys>(?:PK|FK|UK)(?:\s*,\s*(?:PK|FK|UK))*))?"
|
|
139
|
+
r'(?:\s+"[^"]*")?\s*$',
|
|
140
|
+
re.IGNORECASE,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Relationship operator: a left crow's-foot pair, a >=2-run dash/dot core
|
|
144
|
+
# (dash = identifying/solid, dot = non-identifying/dashed), a right
|
|
145
|
+
# crow's-foot pair. Searched against a quote-masked copy of the line (see
|
|
146
|
+
# `_mask_quotes`) so a quoted entity/attribute name can never be mistaken
|
|
147
|
+
# for the operator.
|
|
148
|
+
_LEFT_CARD = r"\|o|\|\||\}o|\}\|"
|
|
149
|
+
_RIGHT_CARD = r"o\||\|\||o\{|\|\{"
|
|
150
|
+
_REL_OP_RE = re.compile(rf"(?P<left>{_LEFT_CARD})(?P<mid>--|\.\.)(?P<right>{_RIGHT_CARD})")
|
|
151
|
+
_REL_LEFT_RE = re.compile(rf"^\s*(?P<idtok>{_ENTITY_TOKEN})\s*$")
|
|
152
|
+
_REL_RIGHT_RE = re.compile(rf"^\s*(?P<idtok>{_ENTITY_TOKEN})\s*(?::\s*(?P<label>.*))?$")
|
|
153
|
+
|
|
154
|
+
_GLYPH_RANGE_RE = re.compile("[\u2500-\u259f\u25a0-\u25ff]")
|
|
155
|
+
|
|
156
|
+
# Crow's-foot pair -> cardinality text, read from mermaid's own vocabulary
|
|
157
|
+
# (both writing directions map to the same text on the side they sit next
|
|
158
|
+
# to).
|
|
159
|
+
_CARD_LEFT = {"|o": "0..1", "||": "1", "}o": "0..*", "}|": "1..*"}
|
|
160
|
+
_CARD_RIGHT = {"o|": "0..1", "||": "1", "o{": "0..*", "|{": "1..*"}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class _Entity:
|
|
164
|
+
"""Mutable parse-time record for one ER entity — converted to a
|
|
165
|
+
:class:`FlowNode` at the end of parsing (see :func:`_build_graph`)."""
|
|
166
|
+
|
|
167
|
+
__slots__ = ("id", "display_name", "attrs", "has_block")
|
|
168
|
+
|
|
169
|
+
def __init__(self, id_: str, display_name: str) -> None:
|
|
170
|
+
self.id = id_
|
|
171
|
+
self.display_name = display_name
|
|
172
|
+
self.attrs: list[str] = []
|
|
173
|
+
self.has_block = False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _unquote(s: str) -> str:
|
|
177
|
+
if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
|
|
178
|
+
return s[1:-1]
|
|
179
|
+
return s
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _mask_quotes(line: str) -> str:
|
|
183
|
+
"""Replace every quoted span with same-length filler so a quoted entity
|
|
184
|
+
or attribute name can never be mistaken for the relation operator when
|
|
185
|
+
searching for it (see `_REL_OP_RE`); positions in the masked string
|
|
186
|
+
stay valid indices into the original line."""
|
|
187
|
+
return re.sub(r'"[^"]*"', lambda m: "Q" * len(m.group(0)), line)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _parse_entity_token(token: str) -> tuple[str, str | None]:
|
|
191
|
+
"""Split an entity token into its graph-key id and optional display
|
|
192
|
+
alias: ``CUSTOMER`` -> (``CUSTOMER``, None); ``p[Person]`` -> (``p``,
|
|
193
|
+
``Person``); ``"Order Item"[OI]`` -> (``Order Item``, ``OI``)."""
|
|
194
|
+
m = _ENTITY_TOKEN_RE.match(token.strip())
|
|
195
|
+
if not m:
|
|
196
|
+
return _unquote(token.strip()), None
|
|
197
|
+
alias = m.group("alias")
|
|
198
|
+
return _unquote(m.group("id")), (alias.strip() if alias else None)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _get_or_create(entities: dict[str, _Entity], token: str) -> _Entity:
|
|
202
|
+
eid, alias = _parse_entity_token(token)
|
|
203
|
+
ent = entities.get(eid)
|
|
204
|
+
if ent is None:
|
|
205
|
+
ent = _Entity(eid, alias or eid)
|
|
206
|
+
entities[eid] = ent
|
|
207
|
+
elif alias:
|
|
208
|
+
ent.display_name = alias
|
|
209
|
+
return ent
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _format_attr_row(m: re.Match[str]) -> str:
|
|
213
|
+
row = f"{m.group('type')} {_unquote(m.group('name'))}"
|
|
214
|
+
keys = m.group("keys")
|
|
215
|
+
if keys:
|
|
216
|
+
norm = re.sub(r"\s*,\s*", ", ", keys.strip()).upper()
|
|
217
|
+
row += f" {norm}"
|
|
218
|
+
return row
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _consume_attrs(entity: _Entity, text: str) -> None:
|
|
222
|
+
"""Split ``text`` (one physical line, or the inline body of an
|
|
223
|
+
``ENTITY { ... }`` opener/closer) on ``;`` into individual attribute
|
|
224
|
+
statements and file each into ``entity``. An attribute that doesn't
|
|
225
|
+
match the ``type name [keys] [comment]`` grammar is dropped silently
|
|
226
|
+
(best-effort, never raised)."""
|
|
227
|
+
for part in text.split(";"):
|
|
228
|
+
part = part.strip()
|
|
229
|
+
if not part:
|
|
230
|
+
continue
|
|
231
|
+
m = _ATTR_RE.match(part)
|
|
232
|
+
if m:
|
|
233
|
+
entity.attrs.append(_format_attr_row(m))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _try_relation(
|
|
237
|
+
line: str, entities: dict[str, _Entity], edges: list[FlowEdge]
|
|
238
|
+
) -> bool:
|
|
239
|
+
masked = _mask_quotes(line)
|
|
240
|
+
m = _REL_OP_RE.search(masked)
|
|
241
|
+
if not m:
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
left_text, right_text = line[: m.start()], line[m.end() :]
|
|
245
|
+
lm = _REL_LEFT_RE.match(left_text)
|
|
246
|
+
rm = _REL_RIGHT_RE.match(right_text)
|
|
247
|
+
if not lm or not rm:
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
src = _get_or_create(entities, lm.group("idtok"))
|
|
251
|
+
dst = _get_or_create(entities, rm.group("idtok"))
|
|
252
|
+
|
|
253
|
+
style = EdgeStyle.DOTTED if m.group("mid") == ".." else EdgeStyle.SOLID
|
|
254
|
+
src_card = _CARD_LEFT.get(m.group("left"))
|
|
255
|
+
dst_card = _CARD_RIGHT.get(m.group("right"))
|
|
256
|
+
label_text = rm.group("label")
|
|
257
|
+
parts = [p for p in (src_card, label_text, dst_card) if p]
|
|
258
|
+
label = " ".join(parts) if parts else None
|
|
259
|
+
|
|
260
|
+
edges.append(
|
|
261
|
+
FlowEdge(
|
|
262
|
+
src=src.id,
|
|
263
|
+
dst=dst.id,
|
|
264
|
+
style=style,
|
|
265
|
+
label=label,
|
|
266
|
+
dst_arrow=False,
|
|
267
|
+
src_arrow=False,
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
return True
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _build_graph(source: str) -> FlowGraph:
|
|
274
|
+
lines = source.splitlines()
|
|
275
|
+
sniff_lines = strip_prelude_lines(lines)
|
|
276
|
+
first = next((line.strip() for line in sniff_lines if line.strip()), "")
|
|
277
|
+
if not _HEADER_RE.match(first):
|
|
278
|
+
raise ERDiagramError(
|
|
279
|
+
"not a mermaid ER diagram: source must start with 'erDiagram'"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
entities: dict[str, _Entity] = {}
|
|
283
|
+
edges: list[FlowEdge] = []
|
|
284
|
+
seen_header = False
|
|
285
|
+
block_entity: _Entity | None = None
|
|
286
|
+
|
|
287
|
+
for raw_line in lines:
|
|
288
|
+
line = raw_line.strip()
|
|
289
|
+
if not line:
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
if block_entity is not None:
|
|
293
|
+
if line.endswith("}"):
|
|
294
|
+
_consume_attrs(block_entity, line[:-1])
|
|
295
|
+
block_entity = None
|
|
296
|
+
else:
|
|
297
|
+
_consume_attrs(block_entity, line)
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
if not seen_header:
|
|
301
|
+
if _HEADER_RE.match(line):
|
|
302
|
+
seen_header = True
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
if _COMMENT_RE.match(line):
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
om = _BLOCK_OPEN_RE.match(line)
|
|
309
|
+
if om:
|
|
310
|
+
ent = _get_or_create(entities, om.group("idtok"))
|
|
311
|
+
ent.has_block = True
|
|
312
|
+
body = om.group("inline").rstrip()
|
|
313
|
+
if body.endswith("}"):
|
|
314
|
+
_consume_attrs(ent, body[:-1])
|
|
315
|
+
else:
|
|
316
|
+
_consume_attrs(ent, body)
|
|
317
|
+
block_entity = ent
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
if _try_relation(line, entities, edges):
|
|
321
|
+
continue
|
|
322
|
+
|
|
323
|
+
bm = _BARE_ENTITY_RE.match(line)
|
|
324
|
+
if bm:
|
|
325
|
+
_get_or_create(entities, bm.group("idtok"))
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
# Unrecognized line: consumed silently, best-effort.
|
|
329
|
+
|
|
330
|
+
if not seen_header:
|
|
331
|
+
raise ERDiagramError(
|
|
332
|
+
"not a mermaid ER diagram: source must start with 'erDiagram'"
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
nodes: list[FlowNode] = []
|
|
336
|
+
for ent in entities.values():
|
|
337
|
+
compartments = [[ent.display_name], ent.attrs or [""]] if ent.has_block else None
|
|
338
|
+
nodes.append(
|
|
339
|
+
FlowNode(
|
|
340
|
+
id=ent.id,
|
|
341
|
+
label=ent.display_name,
|
|
342
|
+
shape=NodeShape.RECT,
|
|
343
|
+
compartments=compartments,
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
return FlowGraph(direction=Direction.TB, nodes=nodes, edges=edges, subgraphs=[])
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _raw_echo(source: str) -> list[str]:
|
|
351
|
+
return [_GLYPH_RANGE_RE.sub("?", line.rstrip()) for line in source.splitlines()]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def render_er(source: str, width: int) -> list[str]:
|
|
355
|
+
"""Render a mermaid ``erDiagram`` source to unicode lines.
|
|
356
|
+
|
|
357
|
+
Pure and total: never raises. Degrades to a raw echo of ``source``
|
|
358
|
+
(see the module docstring's "Never crashes" note) when the source
|
|
359
|
+
isn't an ER diagram at all, parses to zero entities, or an unexpected
|
|
360
|
+
exception escapes layout.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
source: The mermaid fence body (with or without surrounding fence
|
|
364
|
+
markers — only the text between them).
|
|
365
|
+
width: Advisory terminal width — unused, like the flowchart and
|
|
366
|
+
class-diagram renderers; this function sizes to content.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
Rendered lines on success (guaranteed to contain box-drawing
|
|
370
|
+
glyphs), or a raw echo of ``source`` on any degradation path.
|
|
371
|
+
"""
|
|
372
|
+
try:
|
|
373
|
+
graph = _build_graph(source)
|
|
374
|
+
except ERDiagramError:
|
|
375
|
+
return _raw_echo(source)
|
|
376
|
+
|
|
377
|
+
if not graph.nodes:
|
|
378
|
+
return _raw_echo(source)
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
lines = layout_flowgraph(graph, width)
|
|
382
|
+
except Exception:
|
|
383
|
+
return _raw_echo(source)
|
|
384
|
+
|
|
385
|
+
if not lines:
|
|
386
|
+
return _raw_echo(source)
|
|
387
|
+
|
|
388
|
+
return lines
|