termrender 4.6.0__tar.gz → 4.6.1__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 (77) hide show
  1. {termrender-4.6.0 → termrender-4.6.1}/CHANGELOG.md +42 -0
  2. {termrender-4.6.0 → termrender-4.6.1}/PKG-INFO +1 -1
  3. termrender-4.6.1/scripts/mermaid_flow_parity.py +168 -0
  4. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_flow.py +16 -10
  5. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_flow_parser.py +128 -59
  6. termrender-4.6.1/tests/test_mermaid_flow_corpus.py +436 -0
  7. {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_flow_parser.py +91 -0
  8. {termrender-4.6.0 → termrender-4.6.1}/.github/workflows/publish.yml +0 -0
  9. {termrender-4.6.0 → termrender-4.6.1}/.gitignore +0 -0
  10. {termrender-4.6.0 → termrender-4.6.1}/CLAUDE.md +0 -0
  11. {termrender-4.6.0 → termrender-4.6.1}/LICENSE +0 -0
  12. {termrender-4.6.0 → termrender-4.6.1}/README.md +0 -0
  13. {termrender-4.6.0 → termrender-4.6.1}/design.json +0 -0
  14. {termrender-4.6.0 → termrender-4.6.1}/pyproject.toml +0 -0
  15. {termrender-4.6.0 → termrender-4.6.1}/requirements.json +0 -0
  16. {termrender-4.6.0 → termrender-4.6.1}/scripts/build-mermaid-ascii.sh +0 -0
  17. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/CLAUDE.md +0 -0
  18. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/__init__.py +0 -0
  19. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/__main__.py +0 -0
  20. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
  21. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/_mermaid_bin.py +0 -0
  22. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/blocks.py +0 -0
  23. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/emit.py +0 -0
  24. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/layout.py +0 -0
  25. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/parser.py +0 -0
  26. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/py.typed +0 -0
  27. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/CLAUDE.md +0 -0
  28. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/__init__.py +0 -0
  29. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/borders.py +0 -0
  30. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/charts.py +0 -0
  31. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/code.py +0 -0
  32. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/columns.py +0 -0
  33. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/diff.py +0 -0
  34. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/divider.py +0 -0
  35. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid.py +0 -0
  36. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_flow_layout.py +0 -0
  37. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_flow_model.py +0 -0
  38. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_gantt.py +0 -0
  39. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_journey.py +0 -0
  40. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_mindmap.py +0 -0
  41. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_pie.py +0 -0
  42. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_prelude.py +0 -0
  43. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_sequence.py +0 -0
  44. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_timeline.py +0 -0
  45. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/panel.py +0 -0
  46. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/quote.py +0 -0
  47. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/stat.py +0 -0
  48. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/table.py +0 -0
  49. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/text.py +0 -0
  50. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/timeline.py +0 -0
  51. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/tree.py +0 -0
  52. {termrender-4.6.0 → termrender-4.6.1}/src/termrender/style.py +0 -0
  53. {termrender-4.6.0 → termrender-4.6.1}/tests/__init__.py +0 -0
  54. {termrender-4.6.0 → termrender-4.6.1}/tests/test_charts.py +0 -0
  55. {termrender-4.6.0 → termrender-4.6.1}/tests/test_cli_contract.py +0 -0
  56. {termrender-4.6.0 → termrender-4.6.1}/tests/test_column_alignment.py +0 -0
  57. {termrender-4.6.0 → termrender-4.6.1}/tests/test_diff.py +0 -0
  58. {termrender-4.6.0 → termrender-4.6.1}/tests/test_inline_badge.py +0 -0
  59. {termrender-4.6.0 → termrender-4.6.1}/tests/test_linebreak.py +0 -0
  60. {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_compat.py +0 -0
  61. {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_dispatch.py +0 -0
  62. {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_flow.py +0 -0
  63. {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_flow_layout.py +0 -0
  64. {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_flow_model.py +0 -0
  65. {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_flow_shapes.py +0 -0
  66. {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_gantt.py +0 -0
  67. {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_journey.py +0 -0
  68. {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_mindmap.py +0 -0
  69. {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_pie.py +0 -0
  70. {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_sequence.py +0 -0
  71. {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_timeline.py +0 -0
  72. {termrender-4.6.0 → termrender-4.6.1}/tests/test_myst_gaps.py +0 -0
  73. {termrender-4.6.0 → termrender-4.6.1}/tests/test_stat.py +0 -0
  74. {termrender-4.6.0 → termrender-4.6.1}/tests/test_tasklist.py +0 -0
  75. {termrender-4.6.0 → termrender-4.6.1}/tests/test_timeline.py +0 -0
  76. {termrender-4.6.0 → termrender-4.6.1}/tests/test_variable_colons.py +0 -0
  77. {termrender-4.6.0 → termrender-4.6.1}/uv.lock +0 -0
@@ -1,6 +1,48 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v4.6.1 (2026-07-07)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **mermaid-flow**: Parse chained edges and bracket-aware tokenization
9
+ ([`5040b96`](https://github.com/crouton-labs/termrender/commit/5040b967bbe75c70c6d03d57930742ba020e3333))
10
+
11
+ - Unify edge parsing behind one bracket-depth-aware connector scanner (_scan_connectors) shared with
12
+ & fan-out splitting and ; statement splitting, replacing the single-connector _EDGE_RE. -
13
+ CRITICAL: chained edge statements (A-->B-->C) now parse as a sequence of node groups separated by
14
+ connectors, emitting one edge per adjacent triple instead of dropping the tail after the first
15
+ connector. - MAJOR: connector-looking text inside [], (), {} labels (A[Go --> Fast]) is no longer
16
+ mistaken for an edge connector. - MINOR: semicolons inside labels (A[Check; validate]) no longer
17
+ split the statement.
18
+
19
+ Adds structural tests for 2/3-link chains, mixed-style chains, chain with inline label,
20
+ connector-in-label for all three connector families, and semicolon-in-label. Adds a chained-form
21
+ golden for the labeled back-edge cycle proving identical render to the separate-line form.
22
+
23
+ ### Testing
24
+
25
+ - Add flowchart golden corpus and go-binary parity harness
26
+ ([`2a5a00a`](https://github.com/crouton-labs/termrender/commit/2a5a00aba18195eac987bc8c202a80724d6e43c9))
27
+
28
+ - tests/test_mermaid_flow_corpus.py: 26 golden-output tests pinning exact render_flowchart() lines
29
+ across all 9 node shapes, every edge style (solid/dotted/thick/headless/bidirectional), both label
30
+ forms, & fan-out, a multi-parent DAG, a labeled-back-edge cycle (the case the vendored Go binary
31
+ panics/mis-parses on), LR vs TD, subgraph + nested subgraph, a long label vs a tight width budget,
32
+ and the two degradation paths. - scripts/mermaid_flow_parity.py: standalone parity harness (not a
33
+ pytest dependency) that invokes the vendored Go binary the way termrender's real dispatch path
34
+ does (preprocess_mermaid_for_ascii + fix_mermaid_encoding) and the native renderer side by side
35
+ across 14 representative inputs, writing the comparison to the orchestrator's shared context dir.
36
+ Confirms parity-or-better plus two Go failure modes the native renderer avoids: a self-loop panic
37
+ (index out of range) and labeled-back-edge mis-parsing (label text swallowed into a phantom node
38
+ instead of a back-edge). - fix: mermaid_flow.py's module docstring described a stale prior phase
39
+ ("every shape renders as a plain rectangle", "subgraphs parse but aren't drawn as frames") — both
40
+ are false now; corrected to describe actual current behavior (distinct shape borders, framed
41
+ subgraphs with contiguous-membership flattening).
42
+
43
+ 463 tests green (437 pre-existing + 26 new corpus).
44
+
45
+
4
46
  ## v4.6.0 (2026-07-06)
5
47
 
6
48
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 4.6.0
3
+ Version: 4.6.1
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,168 @@
1
+ #!/usr/bin/env python3
2
+ """Standalone parity harness: native flowchart renderer vs. the vendored Go
3
+ binary, for a curated set of representative mermaid flowchart sources.
4
+
5
+ Not a pytest test and not imported by the test suite — the corpus tests must
6
+ not depend on the vendored ``mermaid-ascii`` binary being present on disk.
7
+ Run this once (locally, wherever the vendored binary for the host platform
8
+ exists) to (re)generate the side-by-side comparison artifact:
9
+
10
+ .venv/bin/python scripts/mermaid_flow_parity.py
11
+
12
+ Invokes the Go binary exactly the way termrender's real dispatch path does
13
+ (``mermaid.py::_render_via_binary``): run ``preprocess_mermaid_for_ascii``
14
+ (flowchart shape normalization) on the source, shell out to
15
+ ``mermaid_ascii_bin()`` with ``-f - -y 1``, and undo its Latin-1
16
+ double-encoding via ``fix_mermaid_encoding`` — so the comparison is fair
17
+ (same preprocessing termrender actually applies before falling back to the
18
+ binary today).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import subprocess
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
28
+
29
+ from termrender._mermaid_bin import mermaid_ascii_bin
30
+ from termrender.renderers.mermaid import fix_mermaid_encoding, preprocess_mermaid_for_ascii
31
+ from termrender.renderers.mermaid_flow import render_flowchart
32
+
33
+ OUTPUT_PATH = Path(
34
+ "/Users/silasrhyneer/.crouter/canvas/nodes/mr9swads-0f62d05c/context/flow-parity-sample.md"
35
+ )
36
+
37
+ WIDTH = 60
38
+
39
+ CASES: list[tuple[str, str]] = [
40
+ (
41
+ "Simple forward DAG",
42
+ "graph TD\nA-->B\nA-->C\n",
43
+ ),
44
+ (
45
+ "Multi-parent DAG",
46
+ "graph TD\nA-->C\nB-->C\n",
47
+ ),
48
+ (
49
+ "Headless edge (`---`)",
50
+ "graph TD\nA---B\n",
51
+ ),
52
+ (
53
+ "Pipe-labeled edge",
54
+ "graph TD\nA-->|hi|B\n",
55
+ ),
56
+ (
57
+ "Inline-labeled edge",
58
+ "graph TD\nA -- go now --> B\n",
59
+ ),
60
+ (
61
+ "`&` fan-out",
62
+ "graph TD\nA & B --> C & D\n",
63
+ ),
64
+ (
65
+ "Non-rect shapes (diamond, circle, cylinder)",
66
+ "graph TD\nA{Decision}-->B((Circle))\nB-->C[(DB)]\n",
67
+ ),
68
+ (
69
+ "LR direction",
70
+ "graph LR\nA-->B\nA-->C\n",
71
+ ),
72
+ (
73
+ "Subgraph frame",
74
+ "graph TD\nsubgraph zone[Zone]\nA-->B\nend\n",
75
+ ),
76
+ (
77
+ "Plain 3-cycle (no label)",
78
+ "graph TD\nA-->B\nB-->C\nC-->A\n",
79
+ ),
80
+ (
81
+ "Self-loop (Go binary PANICS: index out of range in drawLine)",
82
+ "graph TD\nA-->A\n",
83
+ ),
84
+ (
85
+ "Cycle with a LABELED back-edge (Go binary mis-parses the label as "
86
+ "a phantom node — wrong topology; this is the case the requirements "
87
+ "doc calls out as the one the Go binary cannot handle)",
88
+ "graph TD\nA-->B\nB-->C\nC-->|retry|A\n",
89
+ ),
90
+ (
91
+ "Same labeled-back-edge cycle, spaced arrows (Go binary still "
92
+ "fabricates a phantom 4th node '|retry| A' instead of a back-edge "
93
+ "into A — the label-parsing bug is not a whitespace quirk)",
94
+ "graph TD\nA --> B\nB --> C\nC --> |retry| A\n",
95
+ ),
96
+ (
97
+ "Simple forward DAG, spaced arrows (Go binary's own preferred "
98
+ "form — included so the unspaced cases above are judged fairly "
99
+ "against Go's best case, not just its worst case)",
100
+ "graph TD\nA --> B\nA --> C\n",
101
+ ),
102
+ ]
103
+
104
+
105
+ def render_via_go_binary(source: str) -> tuple[str, bool]:
106
+ """Return (rendered_text, panicked). Mirrors mermaid.py::_render_via_binary
107
+ but surfaces panic/failure explicitly instead of silently echoing source
108
+ (the harness wants to *show* the failure, not hide it)."""
109
+ try:
110
+ result = subprocess.run(
111
+ [mermaid_ascii_bin(), "-f", "-", "-y", "1"],
112
+ input=preprocess_mermaid_for_ascii(source),
113
+ capture_output=True,
114
+ text=True,
115
+ timeout=30,
116
+ )
117
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
118
+ return f"[harness error invoking binary: {exc}]", True
119
+
120
+ if result.returncode != 0:
121
+ stderr_tail = "\n".join(result.stderr.strip().splitlines()[:4])
122
+ return f"PANIC (exit {result.returncode}):\n{stderr_tail}", True
123
+
124
+ return fix_mermaid_encoding(result.stdout), False
125
+
126
+
127
+ def main() -> None:
128
+ sections: list[str] = [
129
+ "# Native flowchart renderer vs. Go `mermaid-ascii` — parity sample",
130
+ "",
131
+ "Generated by `scripts/mermaid_flow_parity.py`. The Go binary is",
132
+ "invoked exactly the way termrender's real dispatch path does today",
133
+ "(`mermaid.py::_render_via_binary`): flowchart shape normalization via",
134
+ "`preprocess_mermaid_for_ascii`, then `mermaid-ascii -f - -y 1`, then",
135
+ "`fix_mermaid_encoding` on the output.",
136
+ "",
137
+ ]
138
+
139
+ for title, source in CASES:
140
+ go_text, panicked = render_via_go_binary(source)
141
+ our_lines = render_flowchart(source, WIDTH)
142
+ our_text = "\n".join(our_lines)
143
+
144
+ sections.append(f"## {title}")
145
+ sections.append("")
146
+ sections.append("Mermaid source:")
147
+ sections.append("```")
148
+ sections.append(source.rstrip("\n"))
149
+ sections.append("```")
150
+ sections.append("")
151
+ sections.append(f"Go binary output{' (PANIC/failure)' if panicked else ''}:")
152
+ sections.append("```")
153
+ sections.append(go_text if go_text.strip() else "(empty)")
154
+ sections.append("```")
155
+ sections.append("")
156
+ sections.append("Native `render_flowchart` output:")
157
+ sections.append("```")
158
+ sections.append(our_text if our_text.strip() else "(empty)")
159
+ sections.append("```")
160
+ sections.append("")
161
+
162
+ OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
163
+ OUTPUT_PATH.write_text("\n".join(sections) + "\n")
164
+ print(f"wrote {OUTPUT_PATH}")
165
+
166
+
167
+ if __name__ == "__main__":
168
+ main()
@@ -25,19 +25,25 @@ Grammar supported
25
25
  Header: ``graph``/``flowchart`` + direction (``TD``/``TB``/``LR``/``RL``/
26
26
  ``BT``). Node shapes: ``[rect]``, ``(round)``, ``([stadium])``,
27
27
  ``[(cylinder)]``, ``((circle))``, ``{diamond}``, ``{{hexagon}}``,
28
- ``[[subroutine]]``, ``[/parallelogram/]`` — this phase renders every shape
29
- as a plain rectangle (distinct shape borders are a later phase; only the
30
- label text is load-bearing here). Edges: ``-->``, ``---``, ``-.->``, ``==>``
31
- (and bidirectional forms), with ``|label|`` and inline ``A -- text --> B``
32
- labels, and ``&`` fan-out. ``subgraph ... end`` blocks parse but are not yet
33
- rendered as frames (nodes inside them still place and render normally).
34
- ``class``/``classDef``/``style``/``click``/``linkStyle`` lines are ignored.
28
+ ``[[subroutine]]``, ``[/parallelogram/]`` — each renders with a distinct
29
+ shape-specific border (diamond/parallelogram slant tapers, round/stadium/
30
+ circle rounded corners, a cylinder's rounded cap over a square base, a
31
+ hexagon's cut corners, a subroutine's double-bar sides); only a shape whose
32
+ box is too small for its own drawer falls back to the plain rectangle
33
+ border. Edges: ``-->``, ``---``, ``-.->``, ``==>`` (and bidirectional
34
+ forms), with ``|label|`` and inline ``A -- text --> B`` labels, and ``&``
35
+ fan-out. ``subgraph ... end`` blocks (including nested subgraphs) render as
36
+ enclosing frames with a left-anchored title when their members place
37
+ contiguously enough for a clean rect; otherwise a block flattens (no frame,
38
+ members still placed and drawn) rather than draw a frame that would
39
+ visually claim a non-member node. ``class``/``classDef``/``style``/
40
+ ``click``/``linkStyle`` lines are ignored.
35
41
 
36
42
  Known degradations (by design, not bugs)
37
43
  -----------------------------------------
38
- - Node shapes other than a plain rectangle border are not yet visually
39
- distinct (Phase 4 of this renderer's build).
40
- - Subgraph blocks parse but are not yet drawn as enclosing frames (Phase 4).
44
+ - A subgraph whose members aren't placed contiguously enough for a clean
45
+ enclosing rect flattens (drops its frame and title) rather than draw a
46
+ frame that visually claims a non-member node.
41
47
  - See ``mermaid_flow_layout.py``'s module docstring for the edge-routing
42
48
  degradations (dense-graph crossings, label-lane overlap, CJK wrapping,
43
49
  minimum-box-size self-loop cosmetics).
@@ -27,9 +27,17 @@ Grammar covered
27
27
  pipe form and the inline ``A -- text --> B`` / ``A -. text .-> B`` /
28
28
  ``A == text ==> B`` form. ``&`` fan-out (``A & B --> C & D``) expands to
29
29
  the full cartesian product of edges, each carrying the shared style/
30
- label/arrow flags; the ``&`` splitter is bracket-depth-aware so a literal
31
- ``&`` inside a node label (``A[Foo & Bar]``) is not mistaken for fan-out.
32
- Empty-string labels (``A -->|| B``) are stored as ``None``.
30
+ label/arrow flags. A statement may chain any number of connectors
31
+ (``A-->B-->C``) this is parsed as a sequence of node groups separated
32
+ by connectors, emitting one edge per adjacent (group, connector, group)
33
+ triple, with each connector's own style/label/arrows applied only to the
34
+ edge it introduces. Connector scanning, ``&`` fan-out splitting, and
35
+ ``;`` statement splitting are all bracket-depth-aware: a token that looks
36
+ like a connector, ``&``, or ``;`` but sits inside ``[...]``/``(...)``/
37
+ ``{...}`` (part of a node label, e.g. ``A[Go --> Fast]`` or
38
+ ``A[Check; validate]``) is left untouched rather than mistaken for
39
+ statement structure. Empty-string labels (``A -->|| B``) are stored as
40
+ ``None``.
33
41
  - ``subgraph <id>[ title] ... end``: ``id[title]`` splits into graph key and
34
42
  display title; a bare ``subgraph Sub1`` (no brackets) uses the token as
35
43
  both id and title. Nesting is a stack: a node declared while a subgraph is
@@ -112,20 +120,19 @@ _SHAPE_PATTERNS: list[tuple[NodeShape, re.Pattern[str]]] = [
112
120
  (NodeShape.DIAMOND, re.compile(r"^\{(.*)\}$")),
113
121
  ]
114
122
 
115
- # One statement is `left <connector> right`, where <connector> is either the
116
- # inline dash-label form (odelim ... label ... cdelim) or a bare/pipe-label
117
- # connector token. `left` is non-greedy so the connector alternation is tried
118
- # at the earliest position that yields an overall match.
119
- _EDGE_RE = re.compile(
120
- r"^(?P<left>.+?)\s*"
121
- r"(?:"
123
+ # A single connector token: either the inline dash-label form
124
+ # (odelim ... label ... cdelim) or a bare/pipe-label connector. This is
125
+ # matched repeatedly (via `_scan_connectors`) against a whole statement to
126
+ # find every TOP-LEVEL occurrence an edge statement is a sequence of node
127
+ # groups separated by these connectors, so `A-->B-->C` yields two matches
128
+ # and three groups (`A`, `B`, `C`), not one match with a malformed right
129
+ # side.
130
+ _CONNECTOR_RE = re.compile(
122
131
  r"(?P<lhead1><)?(?P<odelim>--|-\.|==)\s+(?P<label>.+?)\s+"
123
132
  r"(?P<cdelim>-->|---|\.->|\.-|==>|===)"
124
133
  r"|"
125
134
  r"(?P<lhead2><)?(?P<conn>-\.+->|-\.+-|={2,}>|={3,}|-{2,}>|-{2,})"
126
135
  r"(?:\s*\|(?P<plabel>[^|]*)\|)?"
127
- r")"
128
- r"\s*(?P<right>.+)$"
129
136
  )
130
137
 
131
138
  _OPENERS = set("([{")
@@ -144,9 +151,29 @@ def _norm_label(text: str | None) -> str | None:
144
151
  return stripped or None
145
152
 
146
153
 
147
- def _split_amp(text: str) -> list[str]:
148
- """Split on top-level ``&`` only a ``&`` nested inside ``[...]``/
149
- ``(...)``/``{...}`` (part of a node label) is left untouched."""
154
+ def _bracket_depths(text: str) -> list[int]:
155
+ """``depths[i]`` = bracket depth immediately before ``text[i]``
156
+ (``depths[len(text)]`` = depth at end of string). Shared by every
157
+ top-level scanner below (connector matching, ``&`` splitting, ``;``
158
+ splitting) to decide whether a candidate token sits inside a node
159
+ label's ``[...]``/``(...)``/``{...}`` delimiters and should therefore
160
+ be ignored rather than treated as statement structure."""
161
+ depths = [0] * (len(text) + 1)
162
+ depth = 0
163
+ for i, ch in enumerate(text):
164
+ depths[i] = depth
165
+ if ch in _OPENERS:
166
+ depth += 1
167
+ elif ch in _CLOSERS:
168
+ depth = max(0, depth - 1)
169
+ depths[len(text)] = depth
170
+ return depths
171
+
172
+
173
+ def _split_top_level(text: str, sep: str) -> list[str]:
174
+ """Split ``text`` on top-level occurrences of the single-character
175
+ ``sep`` only — a ``sep`` nested inside ``[...]``/``(...)``/``{...}``
176
+ (part of a node label) is left untouched."""
150
177
  parts: list[str] = []
151
178
  buf: list[str] = []
152
179
  depth = 0
@@ -157,20 +184,49 @@ def _split_amp(text: str) -> list[str]:
157
184
  elif ch in _CLOSERS:
158
185
  depth = max(0, depth - 1)
159
186
  buf.append(ch)
160
- elif ch == "&" and depth == 0:
161
- parts.append("".join(buf).strip())
187
+ elif ch == sep and depth == 0:
188
+ parts.append("".join(buf))
162
189
  buf = []
163
190
  else:
164
191
  buf.append(ch)
165
- if buf:
166
- parts.append("".join(buf).strip())
167
- return [p for p in parts if p]
192
+ parts.append("".join(buf))
193
+ return parts
194
+
195
+
196
+ def _split_amp(text: str) -> list[str]:
197
+ """Split on top-level ``&`` only — a ``&`` nested inside ``[...]``/
198
+ ``(...)``/``{...}`` (part of a node label) is left untouched."""
199
+ return [p.strip() for p in _split_top_level(text, "&") if p.strip()]
200
+
201
+
202
+ def _scan_connectors(text: str) -> list[re.Match[str]]:
203
+ """Find every top-level (bracket-depth-0) connector match in ``text``,
204
+ left to right. A candidate match that starts inside ``[...]``/
205
+ ``(...)``/``{...}`` (i.e. part of a node label such as
206
+ ``A[Go --> Fast]``) is skipped rather than accepted, and the scan
207
+ resumes one character past the skipped match's start so a real
208
+ top-level connector later in the same text is still found."""
209
+ depths = _bracket_depths(text)
210
+ matches: list[re.Match[str]] = []
211
+ pos = 0
212
+ while pos <= len(text):
213
+ m = _CONNECTOR_RE.search(text, pos)
214
+ if not m:
215
+ break
216
+ if depths[m.start()] == 0:
217
+ matches.append(m)
218
+ pos = m.end()
219
+ else:
220
+ pos = m.start() + 1
221
+ return matches
168
222
 
169
223
 
170
224
  def _iter_statements(lines: list[str]) -> Iterator[str]:
171
- """Flatten source lines into ``;``-and-newline-separated statements."""
225
+ """Flatten source lines into ``;``-and-newline-separated statements,
226
+ bracket-depth-aware so a ``;`` inside a node label (``A[Check; x]``)
227
+ does not split the statement in two."""
172
228
  for raw in lines:
173
- for part in raw.split(";"):
229
+ for part in _split_top_level(raw, ";"):
174
230
  part = part.strip()
175
231
  if part:
176
232
  yield part
@@ -263,46 +319,59 @@ def _try_edge(
263
319
  edges: list[FlowEdge],
264
320
  stack: list[Subgraph],
265
321
  ) -> bool:
266
- m = _EDGE_RE.match(stmt)
267
- if not m:
322
+ """Parse ``stmt`` as an edge statement: a sequence of node groups
323
+ separated by top-level connectors (one group for a plain edge, three
324
+ or more for a chain like ``A-->B-->C``). Emits one (fan-out-expanded)
325
+ edge per adjacent (group, connector, group) triple, each carrying its
326
+ own connector's style/label/arrows."""
327
+ connectors = _scan_connectors(stmt)
328
+ if not connectors:
268
329
  return False
269
- gd = m.groupdict()
270
-
271
- if gd.get("odelim") is not None:
272
- style = {
273
- "--": EdgeStyle.SOLID,
274
- "-.": EdgeStyle.DOTTED,
275
- "==": EdgeStyle.THICK,
276
- }[gd["odelim"]]
277
- dst_arrow = gd["cdelim"].endswith(">")
278
- src_arrow = gd.get("lhead1") is not None
279
- label = _norm_label(gd.get("label"))
280
- else:
281
- conn = gd["conn"] or ""
282
- if "." in conn:
283
- style = EdgeStyle.DOTTED
284
- elif conn.startswith("="):
285
- style = EdgeStyle.THICK
330
+
331
+ groups: list[str] = []
332
+ prev_end = 0
333
+ for m in connectors:
334
+ groups.append(stmt[prev_end : m.start()])
335
+ prev_end = m.end()
336
+ groups.append(stmt[prev_end:])
337
+
338
+ resolved = [_resolve_nodes(group, nodes, stack) for group in groups]
339
+
340
+ for i, m in enumerate(connectors):
341
+ gd = m.groupdict()
342
+ if gd.get("odelim") is not None:
343
+ style = {
344
+ "--": EdgeStyle.SOLID,
345
+ "-.": EdgeStyle.DOTTED,
346
+ "==": EdgeStyle.THICK,
347
+ }[gd["odelim"]]
348
+ dst_arrow = gd["cdelim"].endswith(">")
349
+ src_arrow = gd.get("lhead1") is not None
350
+ label = _norm_label(gd.get("label"))
286
351
  else:
287
- style = EdgeStyle.SOLID
288
- dst_arrow = conn.endswith(">")
289
- src_arrow = gd.get("lhead2") is not None
290
- label = _norm_label(gd.get("plabel"))
291
-
292
- left_ids = _resolve_nodes(gd["left"], nodes, stack)
293
- right_ids = _resolve_nodes(gd["right"], nodes, stack)
294
- for src in left_ids:
295
- for dst in right_ids:
296
- edges.append(
297
- FlowEdge(
298
- src=src,
299
- dst=dst,
300
- style=style,
301
- label=label,
302
- dst_arrow=dst_arrow,
303
- src_arrow=src_arrow,
352
+ conn = gd["conn"] or ""
353
+ if "." in conn:
354
+ style = EdgeStyle.DOTTED
355
+ elif conn.startswith("="):
356
+ style = EdgeStyle.THICK
357
+ else:
358
+ style = EdgeStyle.SOLID
359
+ dst_arrow = conn.endswith(">")
360
+ src_arrow = gd.get("lhead2") is not None
361
+ label = _norm_label(gd.get("plabel"))
362
+
363
+ for src in resolved[i]:
364
+ for dst in resolved[i + 1]:
365
+ edges.append(
366
+ FlowEdge(
367
+ src=src,
368
+ dst=dst,
369
+ style=style,
370
+ label=label,
371
+ dst_arrow=dst_arrow,
372
+ src_arrow=src_arrow,
373
+ )
304
374
  )
305
- )
306
375
  return True
307
376
 
308
377