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.
- {termrender-4.6.0 → termrender-4.6.1}/CHANGELOG.md +42 -0
- {termrender-4.6.0 → termrender-4.6.1}/PKG-INFO +1 -1
- termrender-4.6.1/scripts/mermaid_flow_parity.py +168 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_flow.py +16 -10
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_flow_parser.py +128 -59
- termrender-4.6.1/tests/test_mermaid_flow_corpus.py +436 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_flow_parser.py +91 -0
- {termrender-4.6.0 → termrender-4.6.1}/.github/workflows/publish.yml +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/.gitignore +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/CLAUDE.md +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/LICENSE +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/README.md +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/design.json +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/pyproject.toml +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/requirements.json +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/scripts/build-mermaid-ascii.sh +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/CLAUDE.md +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/__init__.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/__main__.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/_mermaid_bin.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/blocks.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/emit.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/layout.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/parser.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/py.typed +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/CLAUDE.md +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/__init__.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/borders.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/charts.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/code.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/columns.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/diff.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/divider.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_flow_layout.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_flow_model.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_gantt.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_journey.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_mindmap.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_pie.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_prelude.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_sequence.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/mermaid_timeline.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/panel.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/quote.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/stat.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/table.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/text.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/timeline.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/renderers/tree.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/src/termrender/style.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/__init__.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_charts.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_cli_contract.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_column_alignment.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_diff.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_inline_badge.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_linebreak.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_compat.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_dispatch.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_flow.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_flow_layout.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_flow_model.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_flow_shapes.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_gantt.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_journey.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_mindmap.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_pie.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_sequence.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_mermaid_timeline.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_myst_gaps.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_stat.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_tasklist.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_timeline.py +0 -0
- {termrender-4.6.0 → termrender-4.6.1}/tests/test_variable_colons.py +0 -0
- {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.
|
|
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/]`` —
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
``
|
|
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
|
-
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
148
|
-
"""
|
|
149
|
-
``(
|
|
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 ==
|
|
161
|
-
parts.append("".join(buf)
|
|
187
|
+
elif ch == sep and depth == 0:
|
|
188
|
+
parts.append("".join(buf))
|
|
162
189
|
buf = []
|
|
163
190
|
else:
|
|
164
191
|
buf.append(ch)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|