termrender 4.8.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.
Files changed (83) hide show
  1. {termrender-4.8.0 → termrender-4.9.0}/CHANGELOG.md +17 -0
  2. {termrender-4.8.0 → termrender-4.9.0}/PKG-INFO +1 -1
  3. termrender-4.9.0/src/termrender/renderers/mermaid_er.py +388 -0
  4. termrender-4.9.0/tests/test_mermaid_er.py +302 -0
  5. {termrender-4.8.0 → termrender-4.9.0}/.github/workflows/publish.yml +0 -0
  6. {termrender-4.8.0 → termrender-4.9.0}/.gitignore +0 -0
  7. {termrender-4.8.0 → termrender-4.9.0}/CLAUDE.md +0 -0
  8. {termrender-4.8.0 → termrender-4.9.0}/LICENSE +0 -0
  9. {termrender-4.8.0 → termrender-4.9.0}/README.md +0 -0
  10. {termrender-4.8.0 → termrender-4.9.0}/design.json +0 -0
  11. {termrender-4.8.0 → termrender-4.9.0}/pyproject.toml +0 -0
  12. {termrender-4.8.0 → termrender-4.9.0}/requirements.json +0 -0
  13. {termrender-4.8.0 → termrender-4.9.0}/scripts/build-mermaid-ascii.sh +0 -0
  14. {termrender-4.8.0 → termrender-4.9.0}/scripts/mermaid_flow_parity.py +0 -0
  15. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/CLAUDE.md +0 -0
  16. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/__init__.py +0 -0
  17. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/__main__.py +0 -0
  18. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
  19. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/_mermaid_bin.py +0 -0
  20. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/blocks.py +0 -0
  21. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/emit.py +0 -0
  22. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/layout.py +0 -0
  23. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/parser.py +0 -0
  24. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/py.typed +0 -0
  25. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/CLAUDE.md +0 -0
  26. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/__init__.py +0 -0
  27. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/borders.py +0 -0
  28. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/charts.py +0 -0
  29. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/code.py +0 -0
  30. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/columns.py +0 -0
  31. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/diff.py +0 -0
  32. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/divider.py +0 -0
  33. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid.py +0 -0
  34. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_class.py +0 -0
  35. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_flow.py +0 -0
  36. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_flow_layout.py +0 -0
  37. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_flow_model.py +0 -0
  38. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_flow_parser.py +0 -0
  39. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_gantt.py +0 -0
  40. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_journey.py +0 -0
  41. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_mindmap.py +0 -0
  42. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_pie.py +0 -0
  43. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_prelude.py +0 -0
  44. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_sequence.py +0 -0
  45. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_state.py +0 -0
  46. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_timeline.py +0 -0
  47. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/panel.py +0 -0
  48. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/quote.py +0 -0
  49. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/stat.py +0 -0
  50. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/table.py +0 -0
  51. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/text.py +0 -0
  52. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/timeline.py +0 -0
  53. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/tree.py +0 -0
  54. {termrender-4.8.0 → termrender-4.9.0}/src/termrender/style.py +0 -0
  55. {termrender-4.8.0 → termrender-4.9.0}/tests/__init__.py +0 -0
  56. {termrender-4.8.0 → termrender-4.9.0}/tests/test_charts.py +0 -0
  57. {termrender-4.8.0 → termrender-4.9.0}/tests/test_cli_contract.py +0 -0
  58. {termrender-4.8.0 → termrender-4.9.0}/tests/test_column_alignment.py +0 -0
  59. {termrender-4.8.0 → termrender-4.9.0}/tests/test_diff.py +0 -0
  60. {termrender-4.8.0 → termrender-4.9.0}/tests/test_inline_badge.py +0 -0
  61. {termrender-4.8.0 → termrender-4.9.0}/tests/test_linebreak.py +0 -0
  62. {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_class.py +0 -0
  63. {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_compat.py +0 -0
  64. {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_dispatch.py +0 -0
  65. {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_flow.py +0 -0
  66. {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_flow_corpus.py +0 -0
  67. {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_flow_layout.py +0 -0
  68. {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_flow_model.py +0 -0
  69. {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_flow_parser.py +0 -0
  70. {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_flow_shapes.py +0 -0
  71. {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_gantt.py +0 -0
  72. {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_journey.py +0 -0
  73. {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_mindmap.py +0 -0
  74. {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_pie.py +0 -0
  75. {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_sequence.py +0 -0
  76. {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_state.py +0 -0
  77. {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_timeline.py +0 -0
  78. {termrender-4.8.0 → termrender-4.9.0}/tests/test_myst_gaps.py +0 -0
  79. {termrender-4.8.0 → termrender-4.9.0}/tests/test_stat.py +0 -0
  80. {termrender-4.8.0 → termrender-4.9.0}/tests/test_tasklist.py +0 -0
  81. {termrender-4.8.0 → termrender-4.9.0}/tests/test_timeline.py +0 -0
  82. {termrender-4.8.0 → termrender-4.9.0}/tests/test_variable_colons.py +0 -0
  83. {termrender-4.8.0 → termrender-4.9.0}/uv.lock +0 -0
@@ -1,6 +1,23 @@
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
+
4
21
  ## v4.8.0 (2026-07-07)
5
22
 
6
23
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 4.8.0
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
@@ -0,0 +1,302 @@
1
+ """Golden-output tests for the native mermaid erDiagram renderer.
2
+
3
+ Mirrors ``test_mermaid_class.py``'s methodology: real rendered geometry
4
+ (exact line lists, or topology assertions on a full relation chain), not
5
+ merely "no exception raised". ``render_er`` reuses the flowchart engine's
6
+ grandalf layout + rasterizer + router (via the shared ``FlowGraph``/
7
+ ``FlowNode``/``FlowEdge`` model — see ``mermaid_er.py``'s module
8
+ docstring), so these tests exercise the ER-specific parsing (attribute
9
+ blocks, cardinality-to-text mapping, aliasing) end to end rather than
10
+ re-testing the underlying engine (already covered by
11
+ ``test_mermaid_flow_corpus.py``/``test_mermaid_flow_shapes.py``).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+
18
+ from termrender.renderers.mermaid_er import render_er
19
+
20
+ _BOX_GLYPH_RE = re.compile(r"[\u2500-\u259F\u25A0-\u25FF]")
21
+
22
+
23
+ def _lines(source: str, width: int = 80) -> list[str]:
24
+ return render_er(source, width)
25
+
26
+
27
+ def _has_box_glyphs(lines: list[str]) -> bool:
28
+ return any(_BOX_GLYPH_RE.search(line) for line in lines)
29
+
30
+
31
+ def _row_col(lines: list[str], glyph: str) -> tuple[int, int]:
32
+ for r, line in enumerate(lines):
33
+ c = line.find(glyph)
34
+ if c != -1:
35
+ return r, c
36
+ raise AssertionError(f"glyph {glyph!r} not found in {lines!r}")
37
+
38
+
39
+ # --------------------------------------------------------------------------
40
+ # Compartmented entities
41
+ # --------------------------------------------------------------------------
42
+
43
+
44
+ def test_multiline_entity_block_compartments():
45
+ src = (
46
+ "erDiagram\n"
47
+ "CUSTOMER {\n"
48
+ " string name\n"
49
+ " string custNumber\n"
50
+ " string sector\n"
51
+ "}\n"
52
+ )
53
+ assert _lines(src) == [
54
+ "┌───────────────────┐",
55
+ "│ CUSTOMER │",
56
+ "├───────────────────┤",
57
+ "│ string name │",
58
+ "│ string custNumber │",
59
+ "│ string sector │",
60
+ "└───────────────────┘",
61
+ ]
62
+
63
+
64
+ def test_oneline_semicolon_entity_block():
65
+ src = "erDiagram\nENTITY { string name PK; int qty }\n"
66
+ lines = _lines(src)
67
+ assert lines[0].startswith("┌")
68
+ assert any("name PK" in line for line in lines)
69
+ assert any("qty" in line for line in lines)
70
+ sep_rows = [i for i, l in enumerate(lines) if set(l.strip()) <= {"├", "─", "┤"}]
71
+ assert len(sep_rows) == 1
72
+
73
+
74
+ def test_bare_entity_no_block_is_plain_box_not_compartmented():
75
+ assert _lines("erDiagram\nFOO\n") == [
76
+ "┌─────┐",
77
+ "│ FOO │",
78
+ "└─────┘",
79
+ ]
80
+
81
+
82
+ def test_entity_referenced_only_in_relationship_is_plain_box():
83
+ lines = _lines("erDiagram\nA ||--o{ B : has\n")
84
+ # Neither A nor B ever got a `{ ... }` block -> both are plain boxes
85
+ # (no separator rows anywhere in the output).
86
+ sep_rows = [i for i, l in enumerate(lines) if set(l.strip()) <= {"├", "─", "┤"}]
87
+ assert sep_rows == []
88
+
89
+
90
+ def test_entity_with_empty_block_still_shows_blank_attribute_row():
91
+ src = "erDiagram\nFOO {\n}\n"
92
+ lines = _lines(src)
93
+ sep_rows = [i for i, l in enumerate(lines) if set(l.strip()) <= {"├", "─", "┤"}]
94
+ assert len(sep_rows) == 1
95
+ assert any(l.strip("│ ") == "" for l in lines)
96
+
97
+
98
+ def test_attribute_comment_is_dropped_but_does_not_corrupt_parse():
99
+ src = 'erDiagram\nFOO {\n string code PK "a helpful comment"\n}\n'
100
+ lines = _lines(src)
101
+ assert any("code PK" in line for line in lines)
102
+ assert not any("helpful comment" in line for line in lines)
103
+
104
+
105
+ # --------------------------------------------------------------------------
106
+ # Aliases / quoted names
107
+ # --------------------------------------------------------------------------
108
+
109
+
110
+ def test_entity_alias_bracket_form_displays_alias_not_id():
111
+ lines = _lines("erDiagram\np[Person] {\n string name\n}\n")
112
+ assert any("Person" in line for line in lines)
113
+ assert not any("p[Person]" in line for line in lines)
114
+
115
+
116
+ def test_alias_declared_once_resolves_same_node_from_relation():
117
+ src = "erDiagram\np[Person] {\n string name\n}\np ||--o{ CAR : owns\n"
118
+ lines = _lines(src)
119
+ assert sum(1 for line in lines if "Person" in line) == 1
120
+ assert any("CAR" in line for line in lines)
121
+
122
+
123
+ def test_quoted_attribute_name_with_apostrophe():
124
+ src = 'erDiagram\nPERSON {\n string "driver\'s license" UK\n}\n'
125
+ lines = _lines(src)
126
+ assert any("driver's license" in line for line in lines)
127
+ assert not any('"' in line for line in lines)
128
+
129
+
130
+ def test_quoted_entity_name_with_alias():
131
+ src = 'erDiagram\n"Order Item"[OI] {\n int qty\n}\n'
132
+ lines = _lines(src)
133
+ assert any("OI" in line for line in lines)
134
+ assert not any("Order Item" in line for line in lines)
135
+
136
+
137
+ # --------------------------------------------------------------------------
138
+ # Cardinality grammar (all four kinds, both writing directions) + line style
139
+ # --------------------------------------------------------------------------
140
+
141
+
142
+ def test_exactly_one_cardinality_both_sides():
143
+ lines = _lines("erDiagram\nA ||--|| B : rel\n")
144
+ assert any("1 rel 1" in line for line in lines)
145
+
146
+
147
+ def test_zero_or_one_left_and_right_forms():
148
+ left = _lines("erDiagram\nA |o--|| B : rel\n")
149
+ right = _lines("erDiagram\nA ||--o| B : rel\n")
150
+ assert any("0..1 rel 1" in line for line in left)
151
+ assert any("1 rel 0..1" in line for line in right)
152
+
153
+
154
+ def test_one_or_more_left_and_right_forms():
155
+ left = _lines("erDiagram\nA }|--|| B : rel\n")
156
+ right = _lines("erDiagram\nA ||--|{ B : rel\n")
157
+ assert any("1..* rel 1" in line for line in left)
158
+ assert any("1 rel 1..*" in line for line in right)
159
+
160
+
161
+ def test_zero_or_more_left_and_right_forms():
162
+ left = _lines("erDiagram\nA }o--|| B : rel\n")
163
+ right = _lines("erDiagram\nA ||--o{ B : rel\n")
164
+ assert any("0..* rel 1" in line for line in left)
165
+ assert any("1 rel 0..*" in line for line in right)
166
+
167
+
168
+ def test_cardinality_without_label():
169
+ lines = _lines("erDiagram\nCUSTOMER ||--o{ ORDER\n")
170
+ assert any("1 0..*" in line for line in lines)
171
+
172
+
173
+ def test_identifying_relation_is_solid_line():
174
+ lines = _lines("erDiagram\nA ||--o{ B : has\n")
175
+ joined = "\n".join(lines)
176
+ assert "│" in joined
177
+ assert "╎" not in joined
178
+
179
+
180
+ def test_non_identifying_relation_is_dashed_line():
181
+ lines = _lines("erDiagram\nA ||..o{ B : has\n")
182
+ joined = "\n".join(lines)
183
+ assert "╎" in joined
184
+
185
+
186
+ def test_relationship_never_draws_an_arrowhead():
187
+ lines = _lines("erDiagram\nA ||--o{ B : has\n")
188
+ joined = "\n".join(lines)
189
+ for glyph in ("▲", "▼", "◀", "▶"):
190
+ assert glyph not in joined
191
+
192
+
193
+ # --------------------------------------------------------------------------
194
+ # Full topology: a small multi-entity chain
195
+ # --------------------------------------------------------------------------
196
+
197
+
198
+ def test_multi_entity_chain_topology():
199
+ src = "erDiagram\nA ||--o{ B : ab\nB ||--o{ C : bc\n"
200
+ lines = _lines(src)
201
+ assert _has_box_glyphs(lines)
202
+ for name in "ABC":
203
+ assert any(name == line.strip("│ ") for line in lines)
204
+ a_row, _ = _row_col(lines, "A")
205
+ b_row, _ = _row_col(lines, "B")
206
+ c_row, _ = _row_col(lines, "C")
207
+ assert a_row < b_row < c_row # linear TB chain, declaration order
208
+
209
+
210
+ # --------------------------------------------------------------------------
211
+ # Golden corpus: mermaid docs' classic order example
212
+ # --------------------------------------------------------------------------
213
+
214
+
215
+ def test_order_example_golden_topology():
216
+ src = (
217
+ "erDiagram\n"
218
+ " CUSTOMER ||--o{ ORDER : places\n"
219
+ " ORDER ||--|{ LINE-ITEM : contains\n"
220
+ " CUSTOMER }|..|{ DELIVERY-ADDRESS : uses\n"
221
+ "\n"
222
+ " CUSTOMER {\n"
223
+ " string name\n"
224
+ " string custNumber\n"
225
+ " string sector\n"
226
+ " }\n"
227
+ " ORDER {\n"
228
+ " int orderNumber\n"
229
+ " string deliveryAddress\n"
230
+ " }\n"
231
+ " LINE-ITEM {\n"
232
+ " string productCode\n"
233
+ " int quantity\n"
234
+ " float pricePerUnit\n"
235
+ " }\n"
236
+ )
237
+ lines = _lines(src)
238
+ assert _has_box_glyphs(lines)
239
+ joined = "\n".join(lines)
240
+
241
+ for name in ("CUSTOMER", "ORDER", "LINE-ITEM", "DELIVERY-ADDRESS"):
242
+ assert name in joined
243
+
244
+ # DELIVERY-ADDRESS never got a block -> plain box; the other three did.
245
+ da_row, da_col = _row_col(lines, "DELIVERY-ADDRESS")
246
+ assert lines[da_row][da_col - 2] == "│" # ` DELIVERY-ADDRESS ` inside a border
247
+
248
+ for attr in ("string name", "string custNumber", "string sector"):
249
+ assert attr in joined
250
+ for attr in ("int orderNumber", "string deliveryAddress"):
251
+ assert attr in joined
252
+ for attr in ("string productCode", "int quantity", "float pricePerUnit"):
253
+ assert attr in joined
254
+
255
+ assert "places" in joined
256
+ assert "contains" in joined
257
+ assert "uses" in joined
258
+
259
+ # Non-identifying relation (CUSTOMER }|..|{ DELIVERY-ADDRESS) is dashed.
260
+ assert "╎" in joined
261
+
262
+ cust_row, _ = _row_col(lines, "CUSTOMER")
263
+ order_row, _ = _row_col(lines, "ORDER")
264
+ line_item_row, _ = _row_col(lines, "LINE-ITEM")
265
+ assert cust_row < order_row < line_item_row
266
+
267
+
268
+ # --------------------------------------------------------------------------
269
+ # Degradation: malformed input, empty body, literal-glyph sanitization
270
+ # --------------------------------------------------------------------------
271
+
272
+
273
+ def test_non_er_diagram_source_degrades_to_raw_echo():
274
+ src = "graph TD\nA-->B\n"
275
+ lines = _lines(src)
276
+ assert lines == ["graph TD", "A-->B"]
277
+ assert not _has_box_glyphs(lines)
278
+
279
+
280
+ def test_header_only_empty_body_degrades_to_raw_echo():
281
+ lines = _lines("erDiagram\n")
282
+ assert lines == ["erDiagram"]
283
+ assert not _has_box_glyphs(lines)
284
+
285
+
286
+ def test_malformed_input_with_literal_box_glyph_is_sanitized():
287
+ src = "not an er diagram\nhas a \u2500 literal glyph\n"
288
+ lines = _lines(src)
289
+ assert lines == ["not an er diagram", "has a ? literal glyph"]
290
+ assert not _has_box_glyphs(lines)
291
+
292
+
293
+ def test_render_er_never_raises_on_garbage():
294
+ for garbage in (
295
+ "",
296
+ "\n\n\n",
297
+ "erDiagram\nFOO {{{\n",
298
+ "erDiagram\n}}}}\n",
299
+ "erDiagram\nA ||-- B\n", # missing right cardinality pair
300
+ 'erDiagram\nA { string "unterminated PK\n',
301
+ ):
302
+ render_er(garbage, 80) # must not raise
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes