termrender 4.7.0__tar.gz → 4.8.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 (81) hide show
  1. {termrender-4.7.0 → termrender-4.8.0}/CHANGELOG.md +36 -0
  2. {termrender-4.7.0 → termrender-4.8.0}/PKG-INFO +1 -1
  3. termrender-4.8.0/src/termrender/renderers/mermaid_state.py +519 -0
  4. termrender-4.8.0/tests/test_mermaid_state.py +486 -0
  5. {termrender-4.7.0 → termrender-4.8.0}/.github/workflows/publish.yml +0 -0
  6. {termrender-4.7.0 → termrender-4.8.0}/.gitignore +0 -0
  7. {termrender-4.7.0 → termrender-4.8.0}/CLAUDE.md +0 -0
  8. {termrender-4.7.0 → termrender-4.8.0}/LICENSE +0 -0
  9. {termrender-4.7.0 → termrender-4.8.0}/README.md +0 -0
  10. {termrender-4.7.0 → termrender-4.8.0}/design.json +0 -0
  11. {termrender-4.7.0 → termrender-4.8.0}/pyproject.toml +0 -0
  12. {termrender-4.7.0 → termrender-4.8.0}/requirements.json +0 -0
  13. {termrender-4.7.0 → termrender-4.8.0}/scripts/build-mermaid-ascii.sh +0 -0
  14. {termrender-4.7.0 → termrender-4.8.0}/scripts/mermaid_flow_parity.py +0 -0
  15. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/CLAUDE.md +0 -0
  16. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/__init__.py +0 -0
  17. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/__main__.py +0 -0
  18. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
  19. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/_mermaid_bin.py +0 -0
  20. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/blocks.py +0 -0
  21. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/emit.py +0 -0
  22. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/layout.py +0 -0
  23. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/parser.py +0 -0
  24. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/py.typed +0 -0
  25. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/CLAUDE.md +0 -0
  26. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/__init__.py +0 -0
  27. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/borders.py +0 -0
  28. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/charts.py +0 -0
  29. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/code.py +0 -0
  30. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/columns.py +0 -0
  31. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/diff.py +0 -0
  32. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/divider.py +0 -0
  33. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid.py +0 -0
  34. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_class.py +0 -0
  35. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_flow.py +0 -0
  36. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_flow_layout.py +0 -0
  37. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_flow_model.py +0 -0
  38. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_flow_parser.py +0 -0
  39. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_gantt.py +0 -0
  40. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_journey.py +0 -0
  41. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_mindmap.py +0 -0
  42. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_pie.py +0 -0
  43. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_prelude.py +0 -0
  44. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_sequence.py +0 -0
  45. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_timeline.py +0 -0
  46. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/panel.py +0 -0
  47. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/quote.py +0 -0
  48. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/stat.py +0 -0
  49. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/table.py +0 -0
  50. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/text.py +0 -0
  51. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/timeline.py +0 -0
  52. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/tree.py +0 -0
  53. {termrender-4.7.0 → termrender-4.8.0}/src/termrender/style.py +0 -0
  54. {termrender-4.7.0 → termrender-4.8.0}/tests/__init__.py +0 -0
  55. {termrender-4.7.0 → termrender-4.8.0}/tests/test_charts.py +0 -0
  56. {termrender-4.7.0 → termrender-4.8.0}/tests/test_cli_contract.py +0 -0
  57. {termrender-4.7.0 → termrender-4.8.0}/tests/test_column_alignment.py +0 -0
  58. {termrender-4.7.0 → termrender-4.8.0}/tests/test_diff.py +0 -0
  59. {termrender-4.7.0 → termrender-4.8.0}/tests/test_inline_badge.py +0 -0
  60. {termrender-4.7.0 → termrender-4.8.0}/tests/test_linebreak.py +0 -0
  61. {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_class.py +0 -0
  62. {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_compat.py +0 -0
  63. {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_dispatch.py +0 -0
  64. {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_flow.py +0 -0
  65. {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_flow_corpus.py +0 -0
  66. {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_flow_layout.py +0 -0
  67. {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_flow_model.py +0 -0
  68. {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_flow_parser.py +0 -0
  69. {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_flow_shapes.py +0 -0
  70. {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_gantt.py +0 -0
  71. {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_journey.py +0 -0
  72. {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_mindmap.py +0 -0
  73. {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_pie.py +0 -0
  74. {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_sequence.py +0 -0
  75. {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_timeline.py +0 -0
  76. {termrender-4.7.0 → termrender-4.8.0}/tests/test_myst_gaps.py +0 -0
  77. {termrender-4.7.0 → termrender-4.8.0}/tests/test_stat.py +0 -0
  78. {termrender-4.7.0 → termrender-4.8.0}/tests/test_tasklist.py +0 -0
  79. {termrender-4.7.0 → termrender-4.8.0}/tests/test_timeline.py +0 -0
  80. {termrender-4.7.0 → termrender-4.8.0}/tests/test_variable_colons.py +0 -0
  81. {termrender-4.7.0 → termrender-4.8.0}/uv.lock +0 -0
@@ -1,6 +1,42 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v4.8.0 (2026-07-07)
5
+
6
+ ### Features
7
+
8
+ - Native mermaid stateDiagram renderer on the flowchart engine
9
+ ([`ff4fa87`](https://github.com/crouton-labs/termrender/commit/ff4fa87d275d0e18f64e386c16e13e237ad99923))
10
+
11
+ Adds src/termrender/renderers/mermaid_state.py exposing render_state(source, width) -> list[str] as
12
+ a thin adapter: parses stateDiagram/stateDiagram-v2 grammar into the existing flowchart engine's
13
+ own FlowGraph/FlowNode/FlowEdge/Subgraph model and renders through layout_flowgraph directly
14
+ (grandalf layout, box rasterizer, orthogonal router, subgraph frames), reusing that engine's
15
+ degradation contract unchanged.
16
+
17
+ Covers: A --> B / A --> B : event transitions; [*] as per-scope start/end pseudo-states (distinct
18
+ compact glyph markers, shared across every [*] reference in one scope); state "Long name" as s1
19
+ aliases; state X { ... } composite states as nested subgraph frames (a composite referenced
20
+ directly by an external transition gets its own proxy box, since the engine has no edge-to-frame
21
+ anchor); <<choice>> as a diamond, <<fork>>/<<join>> degrading to the engine's plain small rect;
22
+ direction LR/TD/RL/BT; note left/right of X (inline and multi-line note/end note forms) attached
23
+ via a headless dotted edge so text is never dropped; %% comments skipped anywhere including inside
24
+ note blocks; -- / --- concurrency separators flattened.
25
+
26
+ Unwired: not imported by mermaid.py's dispatcher (later phase). Only touches new files; the
27
+ flowchart engine files remain untouched (read-only, per a concurrent sibling's ownership).
28
+
29
+ tests/test_mermaid_state.py: 24 tests asserting real topology/geometry (row/column ordering, frame
30
+ containment, distinct start/end glyphs, diamond-shape glyphs, label text) across a simple
31
+ start/end machine, labeled transitions, aliases, choice/fork/join, composite + nested composite
32
+ states, inline/multi-line notes, direction LR, comments, and the degradation contract
33
+ (header-missing, zero-node, and crash-guard echo paths, each pinned to the same three-condition
34
+ rule the flowchart engine's own tests already establish — best-effort recoverable body-level
35
+ glitches, like a stray '}' or an unterminated composite/note, render real content rather than
36
+ forcing an echo, matching mermaid_flow_parser.py's documented 'auto-closed rather than dropped' /
37
+ 'consumed silently, best-effort' policy).
38
+
39
+
4
40
  ## v4.7.0 (2026-07-07)
5
41
 
6
42
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 4.7.0
3
+ Version: 4.8.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,519 @@
1
+ """Native ASCII renderer for mermaid ``stateDiagram``/``stateDiagram-v2`` sources.
2
+
3
+ Standalone module: exposes a single pure function, ``render_state``. Not
4
+ wired into ``mermaid.py``'s dispatcher (a later, orchestrator-owned phase)
5
+ — nothing in this package imports it yet.
6
+
7
+ Layout model
8
+ ------------
9
+ This module owns none of the layout math. A state diagram is translated
10
+ into the flowchart engine's own model (:class:`~termrender.renderers.
11
+ mermaid_flow_model.FlowGraph`/``FlowNode``/``FlowEdge``/``Subgraph``) and
12
+ handed straight to :func:`~termrender.renderers.mermaid_flow_layout.
13
+ layout_flowgraph` — the same grandalf-based Sugiyama layout, box
14
+ rasterizer, orthogonal edge router, and subgraph-frame drawer used by the
15
+ native flowchart renderer. States become boxes, transitions become edges,
16
+ composite ``state X { ... }`` blocks become subgraph frames — this module
17
+ is purely the state-diagram-to-flowchart-model adapter (parse + translate);
18
+ :func:`render_state` is ``parse -> layout_flowgraph -> lines``, wrapped in
19
+ a degradation guard, mirroring ``mermaid_flow.py``'s own public contract
20
+ and the same box-glyph-presence success signal the crouter attach viewer
21
+ depends on.
22
+
23
+ Grammar supported
24
+ ------------------
25
+ Header: ``stateDiagram``/``stateDiagram-v2``. Transitions: ``A --> B`` and
26
+ ``A --> B : event`` (the event becomes the edge label). ``[*]`` as a
27
+ pseudo-state: as a transition source it is the (per-scope) start marker,
28
+ rendered as a compact rounded box holding a single ``\u25cf`` (filled
29
+ circle) glyph; as a transition destination it is the (per-scope) end
30
+ marker, holding a single ``\u25c9`` (fisheye) glyph instead — same shape,
31
+ distinct glyph, so start and end read differently without a bespoke
32
+ border. Each lexical scope (the top level, or one per composite state) has
33
+ its own shared start/end marker, created lazily and reused across every
34
+ ``[*]`` reference within that scope — mirrors mermaid's "one implicit
35
+ start/end per machine" semantics. ``state "Long name" as s1`` aliases a
36
+ plain state's display label; the same form immediately followed by ``{``
37
+ both aliases *and* opens a composite. ``state X { ... }`` (with or without
38
+ an alias) opens a composite state, translated to a
39
+ :class:`~termrender.renderers.mermaid_flow_model.Subgraph`: transitions and
40
+ sub-states declared inside become members of that subgraph (nesting via
41
+ ``Subgraph.children``, exactly as deep as the source nests), and are drawn
42
+ inside the engine's enclosing frame. A composite id referenced directly by
43
+ a transition *outside* its own block (entering/exiting the composite as a
44
+ whole, rather than one of its substates) gets its own proxy
45
+ :class:`FlowNode` — the engine has no notion of an edge terminating on a
46
+ frame's border, so this proxy renders as an ordinary small box near (not
47
+ inside) the frame; a composite never referenced this way gets no such
48
+ box. ``state X <<choice>>`` marks ``X`` a choice pseudo-state (rendered as
49
+ the engine's existing compact diamond); ``<<fork>>``/``<<join>>`` degrade
50
+ to the engine's plain small rectangle (no bespoke bar glyph exists in the
51
+ underlying engine — "a small box" is the documented, explicit fallback for
52
+ these two). ``direction LR``/``TD``/``TB``/``RL``/``BT`` on its own line
53
+ sets the whole diagram's rank-flow direction (last one wins if repeated).
54
+ ``note left of X : text`` and the multi-line ``note left of X`` / ... /
55
+ ``end note`` block form both attach: the note becomes its own small boxed
56
+ node holding the note text, joined to ``X`` by a plain undirected dotted
57
+ line (no arrowhead either end) — the engine has no "float this box beside
58
+ that one" primitive, so the left/right side isn't honored, but the text
59
+ itself is never dropped (see *Known degradations*). A lone ``--`` or
60
+ ``---`` line inside a composite (mermaid's concurrent-region separator) is
61
+ consumed with no effect — the regions on either side flatten into one
62
+ shared sibling scope rather than rendering as visually distinct lanes.
63
+ ``%%`` line comments are skipped anywhere, including inside a multi-line
64
+ ``note ... / end note`` block (a comment line there is dropped, not folded
65
+ into the note text).
66
+
67
+ Known degradations (by design, not bugs)
68
+ -----------------------------------------
69
+ - Note position (``left of``/``right of``) has no engine-level lever (node
70
+ placement comes from grandalf's layering, not manual coordinates) and is
71
+ not honored; the note still renders, just wherever the graph places it,
72
+ with its full text intact.
73
+ - A composite id referenced by an external transition before its own
74
+ ``state X { ... }``/``state "..." as X { ... }`` declaration is seen
75
+ (a forward reference) gets its proxy box labeled with the bare id rather
76
+ than the composite's eventual display title — declaration-order
77
+ dependency, matching the equivalent limitation already documented for
78
+ the flowchart parser's node re-declaration policy.
79
+ - Fork/join pseudo-states render as the engine's plain small rectangle,
80
+ not a bar — see *Grammar supported* above.
81
+ - Everything else the underlying flowchart engine already documents as a
82
+ degradation (dense-graph crossings, CJK label wrapping, minimum-box-size
83
+ cosmetics, a subgraph that isn't contiguous enough for a clean frame
84
+ flattening instead) applies unchanged, since this module draws through
85
+ that engine. See ``mermaid_flow_layout.py``'s and ``mermaid_flow.py``'s
86
+ module docstrings.
87
+ """
88
+
89
+ from __future__ import annotations
90
+
91
+ import re
92
+ from dataclasses import dataclass
93
+
94
+ from termrender.renderers.mermaid_flow_layout import layout_flowgraph
95
+ from termrender.renderers.mermaid_flow_model import (
96
+ Direction,
97
+ EdgeStyle,
98
+ FlowEdge,
99
+ FlowGraph,
100
+ FlowNode,
101
+ NodeShape,
102
+ Subgraph,
103
+ )
104
+ from termrender.renderers.mermaid_prelude import strip_prelude_lines
105
+
106
+ __all__ = ["render_state", "StateDiagramError"]
107
+
108
+
109
+ class StateDiagramError(Exception):
110
+ """Raised when source cannot be parsed as a mermaid state diagram at all."""
111
+
112
+
113
+ # --------------------------------------------------------------------------
114
+ # Grammar
115
+ # --------------------------------------------------------------------------
116
+
117
+ _HEADER_RE = re.compile(r"^stateDiagram(-v2)?\b", re.IGNORECASE)
118
+ _DIRECTION_RE = re.compile(r"^direction\s+(TB|TD|LR|RL|BT)\s*$", re.IGNORECASE)
119
+ _COMMENT_RE = re.compile(r"^%%")
120
+ _CONCURRENCY_SEP_RE = re.compile(r"^-{2,}\s*$")
121
+
122
+ _ALIAS_RE = re.compile(r'^state\s+"([^"]*)"\s+as\s+(\S+?)\s*(\{)?\s*$', re.IGNORECASE)
123
+ _COMPOSITE_START_RE = re.compile(r"^state\s+(\S+)\s*\{\s*$", re.IGNORECASE)
124
+ _COMPOSITE_END_RE = re.compile(r"^\}\s*$")
125
+ _ANNOTATION_RE = re.compile(
126
+ r"^state\s+(\S+)\s*<<\s*(choice|fork|join)\s*>>\s*$", re.IGNORECASE
127
+ )
128
+ _BARE_STATE_RE = re.compile(r"^state\s+(\S+)\s*$", re.IGNORECASE)
129
+
130
+ _NOTE_INLINE_RE = re.compile(
131
+ r"^note\s+(left|right)\s+of\s+(\S+)\s*:\s*(.*)$", re.IGNORECASE
132
+ )
133
+ _NOTE_BLOCK_START_RE = re.compile(
134
+ r"^note\s+(left|right)\s+of\s+(\S+)\s*$", re.IGNORECASE
135
+ )
136
+ _NOTE_END_RE = re.compile(r"^end\s+note\s*$", re.IGNORECASE)
137
+
138
+ # Non-greedy src: a plain id never legitimately contains "-->", but the
139
+ # greedy alternative would still (via backtracking) find *a* match — lazy
140
+ # quantification just makes it find the intended (leftmost) one directly.
141
+ _TRANSITION_RE = re.compile(
142
+ r"^(?P<src>\[\*\]|\S+?)\s*-->\s*(?P<dst>\[\*\]|\S+)\s*(?::\s*(?P<label>.*))?$"
143
+ )
144
+
145
+ _START_GLYPH = "\u25cf" # ● filled circle — start pseudo-state marker
146
+ _END_GLYPH = "\u25c9" # ◉ fisheye — end pseudo-state marker, distinct from start
147
+
148
+ _ANNOTATION_SHAPE: dict[str, NodeShape] = {
149
+ "choice": NodeShape.DIAMOND,
150
+ "fork": NodeShape.RECT,
151
+ "join": NodeShape.RECT,
152
+ }
153
+
154
+
155
+ @dataclass
156
+ class _Scope:
157
+ """One lexical scope's ``[*]`` bookkeeping: the root, or one composite
158
+ state's body. ``key`` namespaces the synthetic start/end marker ids so
159
+ nested composites each get their own start/end rather than sharing the
160
+ root's."""
161
+
162
+ key: str
163
+ start_id: str | None = None
164
+ end_id: str | None = None
165
+
166
+
167
+ # --------------------------------------------------------------------------
168
+ # Small helpers
169
+ # --------------------------------------------------------------------------
170
+
171
+
172
+ def _norm_label(text: str | None) -> str | None:
173
+ if text is None:
174
+ return None
175
+ stripped = text.strip()
176
+ return stripped or None
177
+
178
+
179
+ def _pseudo_id(scope_key: str, kind: str) -> str:
180
+ return f"__{kind}__" if not scope_key else f"__{kind}_{scope_key}__"
181
+
182
+
183
+ def _register(
184
+ nodes: dict[str, FlowNode],
185
+ stack: list[Subgraph],
186
+ node_id: str,
187
+ label: str | None = None,
188
+ shape: NodeShape | None = None,
189
+ ) -> None:
190
+ """Register (or update) a state node — mirrors the flowchart parser's
191
+ node re-declaration policy: an existing node's label/shape is updated
192
+ in place when a richer declaration supplies one, never downgraded by a
193
+ later bare reference. Adds ``node_id`` to the innermost currently-open
194
+ composite's member list (once), if any composite is open."""
195
+ existing = nodes.get(node_id)
196
+ if existing is None:
197
+ nodes[node_id] = FlowNode(
198
+ id=node_id,
199
+ label=label if label is not None else node_id,
200
+ shape=shape if shape is not None else NodeShape.RECT,
201
+ )
202
+ else:
203
+ if label is not None:
204
+ existing.label = label
205
+ if shape is not None:
206
+ existing.shape = shape
207
+ if stack and node_id not in stack[-1].node_ids:
208
+ stack[-1].node_ids.append(node_id)
209
+
210
+
211
+ def _resolve_endpoint(
212
+ token: str,
213
+ kind: str,
214
+ scope: _Scope,
215
+ nodes: dict[str, FlowNode],
216
+ stack: list[Subgraph],
217
+ composites: dict[str, Subgraph],
218
+ ) -> str:
219
+ """Resolve one transition endpoint token to a graph node id: ``[*]``
220
+ becomes the current scope's shared start (``kind == "start"``) or end
221
+ (``kind == "end"``) marker, created lazily on first reference; any
222
+ other token is registered as an ordinary state (using the composite's
223
+ display title if the token happens to be a known composite id, so a
224
+ transition that targets a composite directly gets a sensibly-labeled
225
+ proxy box — see the module docstring)."""
226
+ if token == "[*]":
227
+ if kind == "start":
228
+ if scope.start_id is None:
229
+ scope.start_id = _pseudo_id(scope.key, "start")
230
+ _register(
231
+ nodes, stack, scope.start_id, label=_START_GLYPH, shape=NodeShape.ROUND
232
+ )
233
+ return scope.start_id
234
+ if scope.end_id is None:
235
+ scope.end_id = _pseudo_id(scope.key, "end")
236
+ _register(
237
+ nodes, stack, scope.end_id, label=_END_GLYPH, shape=NodeShape.ROUND
238
+ )
239
+ return scope.end_id
240
+
241
+ if token not in nodes:
242
+ label = composites[token].title if token in composites else None
243
+ _register(nodes, stack, token, label=label)
244
+ return token
245
+
246
+
247
+ def _attach_note(
248
+ nodes: dict[str, FlowNode],
249
+ edges: list[FlowEdge],
250
+ stack: list[Subgraph],
251
+ composites: dict[str, Subgraph],
252
+ target: str | None,
253
+ text: str,
254
+ counters: dict[str, int],
255
+ ) -> None:
256
+ """Attach a note as its own small boxed node, joined to ``target`` by a
257
+ plain (headless) dotted line. Never drops non-empty note text (see the
258
+ module docstring's degradation notes on *why* the box floats wherever
259
+ the graph places it rather than strictly left/right of ``target``)."""
260
+ stripped = text.strip()
261
+ if not target or not stripped:
262
+ return
263
+ if target not in nodes:
264
+ label = composites[target].title if target in composites else None
265
+ _register(nodes, stack, target, label=label)
266
+ counters["note"] += 1
267
+ note_id = f"__note_{counters['note']}__"
268
+ _register(nodes, stack, note_id, label=stripped, shape=NodeShape.RECT)
269
+ edges.append(
270
+ FlowEdge(
271
+ src=target,
272
+ dst=note_id,
273
+ style=EdgeStyle.DOTTED,
274
+ label=None,
275
+ dst_arrow=False,
276
+ src_arrow=False,
277
+ )
278
+ )
279
+
280
+
281
+ def _close_composite(stack: list[Subgraph], roots: list[Subgraph]) -> None:
282
+ done = stack.pop()
283
+ if stack:
284
+ stack[-1].children.append(done)
285
+ else:
286
+ roots.append(done)
287
+
288
+
289
+ # --------------------------------------------------------------------------
290
+ # Public entry point
291
+ # --------------------------------------------------------------------------
292
+
293
+
294
+ def parse(source: str) -> FlowGraph:
295
+ """Parse a mermaid ``stateDiagram``/``stateDiagram-v2`` source into a
296
+ :class:`~termrender.renderers.mermaid_flow_model.FlowGraph` — the
297
+ flowchart engine's own model, so :func:`~termrender.renderers.
298
+ mermaid_flow_layout.layout_flowgraph` can render it directly.
299
+
300
+ Args:
301
+ source: The mermaid fence body; its first non-blank line (after
302
+ skipping the standard mermaid prelude) must start with
303
+ ``stateDiagram``.
304
+
305
+ Returns:
306
+ A populated :class:`FlowGraph`. A structurally-empty but headed
307
+ diagram (blank/comment-only body) returns a graph with no nodes.
308
+
309
+ Raises:
310
+ StateDiagramError: If ``source`` is not a mermaid state diagram at
311
+ all (missing the ``stateDiagram`` header).
312
+ """
313
+ lines = source.splitlines()
314
+ sniff_lines = strip_prelude_lines(lines)
315
+ first = next((line.strip() for line in sniff_lines if line.strip()), "")
316
+ if not _HEADER_RE.match(first):
317
+ raise StateDiagramError(
318
+ "not a mermaid state diagram: source must start with 'stateDiagram'"
319
+ )
320
+
321
+ direction = Direction.TB
322
+ nodes: dict[str, FlowNode] = {}
323
+ edges: list[FlowEdge] = []
324
+ composites: dict[str, Subgraph] = {}
325
+ roots: list[Subgraph] = []
326
+ stack: list[Subgraph] = []
327
+ scopes: list[_Scope] = [_Scope(key="")]
328
+ counters = {"note": 0}
329
+ seen_header = False
330
+ note_target: str | None = None
331
+ note_lines: list[str] | None = None
332
+
333
+ for raw in lines:
334
+ line = raw.strip()
335
+ if not line:
336
+ continue
337
+
338
+ if note_lines is not None:
339
+ if _NOTE_END_RE.match(line):
340
+ _attach_note(
341
+ nodes, edges, stack, composites, note_target,
342
+ " ".join(note_lines), counters,
343
+ )
344
+ note_target = None
345
+ note_lines = None
346
+ elif not _COMMENT_RE.match(line):
347
+ note_lines.append(line)
348
+ continue
349
+
350
+ if not seen_header:
351
+ if _HEADER_RE.match(line):
352
+ seen_header = True
353
+ continue
354
+
355
+ if _COMMENT_RE.match(line):
356
+ continue
357
+
358
+ if _CONCURRENCY_SEP_RE.match(line):
359
+ continue
360
+
361
+ m = _DIRECTION_RE.match(line)
362
+ if m:
363
+ token = m.group(1).upper()
364
+ direction = Direction.TB if token == "TD" else Direction(token)
365
+ continue
366
+
367
+ m = _NOTE_INLINE_RE.match(line)
368
+ if m:
369
+ _attach_note(
370
+ nodes, edges, stack, composites, m.group(2), m.group(3), counters
371
+ )
372
+ continue
373
+
374
+ m = _NOTE_BLOCK_START_RE.match(line)
375
+ if m:
376
+ note_target = m.group(2)
377
+ note_lines = []
378
+ continue
379
+
380
+ m = _ALIAS_RE.match(line)
381
+ if m:
382
+ title, node_id, opens_brace = m.groups()
383
+ display = title.strip() or node_id
384
+ if opens_brace:
385
+ sub = Subgraph(id=node_id, title=display)
386
+ composites[node_id] = sub
387
+ stack.append(sub)
388
+ scopes.append(_Scope(key=node_id))
389
+ else:
390
+ _register(nodes, stack, node_id, label=display)
391
+ continue
392
+
393
+ m = _COMPOSITE_START_RE.match(line)
394
+ if m:
395
+ node_id = m.group(1)
396
+ sub = Subgraph(id=node_id, title=node_id)
397
+ composites[node_id] = sub
398
+ stack.append(sub)
399
+ scopes.append(_Scope(key=node_id))
400
+ continue
401
+
402
+ if _COMPOSITE_END_RE.match(line):
403
+ if stack:
404
+ _close_composite(stack, roots)
405
+ if len(scopes) > 1:
406
+ scopes.pop()
407
+ continue
408
+
409
+ m = _ANNOTATION_RE.match(line)
410
+ if m:
411
+ node_id, kind = m.groups()
412
+ _register(nodes, stack, node_id, shape=_ANNOTATION_SHAPE[kind.lower()])
413
+ continue
414
+
415
+ m = _TRANSITION_RE.match(line)
416
+ if m:
417
+ scope = scopes[-1]
418
+ src_id = _resolve_endpoint(
419
+ m.group("src"), "start", scope, nodes, stack, composites
420
+ )
421
+ dst_id = _resolve_endpoint(
422
+ m.group("dst"), "end", scope, nodes, stack, composites
423
+ )
424
+ edges.append(
425
+ FlowEdge(
426
+ src=src_id,
427
+ dst=dst_id,
428
+ style=EdgeStyle.SOLID,
429
+ label=_norm_label(m.group("label")),
430
+ dst_arrow=True,
431
+ src_arrow=False,
432
+ )
433
+ )
434
+ continue
435
+
436
+ m = _BARE_STATE_RE.match(line)
437
+ if m:
438
+ _register(nodes, stack, m.group(1))
439
+ continue
440
+
441
+ # Unrecognized body line: consumed silently, best-effort.
442
+
443
+ if note_lines is not None:
444
+ _attach_note(
445
+ nodes, edges, stack, composites, note_target,
446
+ " ".join(note_lines), counters,
447
+ )
448
+
449
+ while stack:
450
+ _close_composite(stack, roots)
451
+
452
+ return FlowGraph(
453
+ direction=direction,
454
+ nodes=list(nodes.values()),
455
+ edges=edges,
456
+ subgraphs=roots,
457
+ )
458
+
459
+
460
+ # Same ranges the downstream attach viewer uses to detect render *success*
461
+ # (see mermaid_flow.py's identical guard). The raw-echo path must never
462
+ # contain one of these, even when malformed/degenerate source itself
463
+ # happens to contain a literal box-drawing or geometric glyph.
464
+ _GLYPH_RANGE_RE = re.compile("[\u2500-\u259f\u25a0-\u25ff]")
465
+
466
+
467
+ def render_state(source: str, width: int) -> list[str]:
468
+ """Render a mermaid ``stateDiagram``/``stateDiagram-v2`` source to
469
+ unicode lines.
470
+
471
+ Pure and total: never raises. Degrades to a raw echo of ``source``
472
+ (each line ``\\n``-split and ``.rstrip()``-ed, containing no
473
+ box-drawing glyphs) exactly when:
474
+
475
+ 1. The source isn't a state diagram at all (no ``stateDiagram`` header
476
+ on its first non-blank, post-prelude line) — :func:`parse` raises
477
+ :class:`StateDiagramError`.
478
+ 2. Parsing succeeds but yields zero states (an empty or comment-only
479
+ diagram body) — nothing to draw.
480
+ 3. Any unexpected exception escapes :func:`~termrender.renderers.
481
+ mermaid_flow_layout.layout_flowgraph` — a defensive catch-all; this
482
+ should never happen for well-typed input, but the guarantee is
483
+ "never crash, degrade" regardless.
484
+
485
+ Otherwise, returns the rendered diagram: guaranteed to contain unicode
486
+ box-drawing glyphs (every state is a bordered box), never ANSI escapes.
487
+
488
+ Args:
489
+ source: The mermaid fence body (with or without the surrounding
490
+ fence markers — only the text between them).
491
+ width: Advisory terminal width; sized to content, may overflow
492
+ (see the module docstring's "Known degradations", inherited
493
+ from the underlying flowchart engine).
494
+
495
+ Returns:
496
+ Rendered lines on success, or a raw-echo of ``source`` on any of
497
+ the three degradation conditions above.
498
+ """
499
+ try:
500
+ graph = parse(source)
501
+ except StateDiagramError:
502
+ return _raw_echo(source)
503
+
504
+ if not graph.nodes:
505
+ return _raw_echo(source)
506
+
507
+ try:
508
+ lines = layout_flowgraph(graph, width)
509
+ except Exception:
510
+ return _raw_echo(source)
511
+
512
+ if not lines:
513
+ return _raw_echo(source)
514
+
515
+ return lines
516
+
517
+
518
+ def _raw_echo(source: str) -> list[str]:
519
+ return [_GLYPH_RANGE_RE.sub("?", line.rstrip()) for line in source.splitlines()]