September 10, 2026
1 pull request merged across 1 repo
bahdotsh/mdterm
Mermaid feedback (back) edges were routed straight through sibling nodes, so a
cycle rendered as edges that were never declared. Self-loops came out as a
dangling stub. Cycle classification recursed and blew the stack on a long
chain. Along the way this turned up a handful of related faults in the
renderer, listed below.
Refs #56. Supersedes #57.
What changed
Feedback edge routing
A back edge leaves its source through the bottom border, runs along a gap row
(top-down) or gap column (left-right) into a gutter lane, and enters its
destination through the opposite border. Gap rows and columns never contain
nodes, so a route cannot pass through a sibling. Crossings with forward edges
render as junctions.
The gaps are sized for what they carry rather than picked from a fixed table.
Each gap holds one row or column per feedback endpoint in the layers it
separates, on top of what the forward edges and their labels need, so two
routes never merge into one ambiguous line and a label never lands on a route.
Lanes are assigned by span, shortest innermost, so a longer back edge wraps
around a shorter one instead of crossing it. Self-loops close. Extra rows
outside the diagram are added only when a route needs them.
Left-right drops and rises use the edges of a node's *column*, not of its box.
Boxes are centred in a column as wide as its widest node, so a narrow box's own
margin can sit inside a taller neighbour.
Arrowheads
An arrowhead is a single cell, and the only cell of an edge that says which box
is the destination. Anything drawn across it used to repaint it as a junction,
which is a legitimate character in a legitimate place, so nothing complained
and the edge quietly stopped pointing anywhere. 926 of the 2000 graphs the
property test generates lost at least one head; main loses them too.
A head now records the axis its own edge runs through it and keeps its glyph
against any crossing. On top of that, both renderers keep a spanning forward
edge off an arrowhead row (top-down) or column (left-right) to begin with,
since a bus that has to break over a row of heads belongs elsewhere and every
gap is budgeted for somewhere to put it.
Cycle detection
classify_feedback_edges uses an explicit stack. A linear chain of ~80k nodes
overflowed the main thread stack in release on macOS, and far fewer on
Windows' 1 MB main-thread stack. It classifies a 200k-node chain in the test
suite now.
edge_layer_feedback_indices is gone. After longest-path layering over the
pruned DAG every non-feedback edge is strictly descending, so its extra check
could never fire. Both renderers share one layout() helper and one feedback
set.
Left-right forward edges
The right border of an even-width node was computed one column too far right,
leaving a gap between the box and its outgoing edge (│ Loop │ ────▶). Edges
start flush with the border now, and every edge crossing a gap bends at the
same column so junctions still converge for mixed-width columns.
Each gap is sized for the labels it carries, and the bend moves right with the
label rather than sitting at the gap's midpoint — a centred bend costs two
columns of gap per column of label, and a diagram wider than the terminal used
to be word-wrapped into fragments.
Wrapping
wrap_lines treated a rendered diagram row as prose, so an over-wide row's
overflow landed on a row of its own, interleaved with the rows below it.
Diagram rows are clipped to the display width instead. This is not only
cosmetic: the viewer paints a row in full whatever its width, so an over-wide
row ran past the border and shifted every row after it.
Only diagrams are clipped. Code blocks are text and wrap as before, and a
diagram is still a code block for every other purpose, so clicking one still
copies the mermaid source. Clipping applies to the HTML export too, where the
page could have scrolled instead; truncating still beats shredding, and
--width controls it.
Clippy
manual_filter in viewer.rs fails cargo clippy -- -D warnings on current
stable and would block CI for any PR. Also a useless_borrows_in_formatting
in an image.rs test.
Examples
graph TD with A --> B; A --> C; B --> D; C --> D; D --> B. Before, the back
edge into B ran through C and read as C --> B:
┌─────┐ ┌─────┐
│ B │◀───│ C │────┐
└─────┘ └─────┘ │
After:
┌─────┐
│ A │
└─────┘
│
│
┌─────┴────┐
│ ┌────────┼───────┐
▼ ▼ ▼ │
┌─────┐ ┌─────┐ │
│ B │ │ C │ │
└─────┘ └─────┘ │
│ │ │
│ │ │
└─────┬────┘ │
▼ │
┌─────┐ │
│ D │ │
└─────┘ │
│ │
└──────────────┘
Two labelled back edges, graph LR:
┌───────┐ ┌───────┐ ┌──────┐ ┌──────┐
│ Start │─────▶│ Check │─────▶│ Work │─────▶│ Done │
└───────┘ └───────┘ └──────┘ └──────┘
▲ ▲ └─┐ └─┐
┌──┘ ┌──┘ │ │
│ └─────────again──────────┘ │
│ │
└────────────────────────retry────────────────────────┘
Difference from main
Acyclic top-down renders keep their layout exactly: over 400 random acyclic
graphs, line count and every row width are identical to main. They differ on
146 rows, all of one of two kinds — a head main had painted away, and a label
that overruns the canvas now ending in an ellipsis instead of being cut
mid-word. Both are pinned by tests.
Left-right renders differ by the even-width arrow fix, by labels no longer
being painted through the destination box, and by gap widths.
Known and not addressed
- In a dense diagram a label can land on a *different* forward edge's line.
Instrumented, that is 653 of 2000 randomly generated graphs, in both
directions. It predates this branch and main has it too.
- The same class reaches arrowheads in left-right, where each column is centred
in the canvas on its own: a forward edge running in to its own destination
can meet a feedback head belonging to a box in another column. A finished
top-down render asserts that nothing crosses a head at all; left-right cannot
yet.
- A forward edge spanning more than one layer and drawn straight down the
centre line runs its vertical through the box in between. main does the
same.
- Top-down canvases are sized for their boxes, not their labels, so they
truncate where left-right diagrams now grow.
Each of these wants a placement pass that draws every line before deciding
where the rest goes, which is its own change rather than a special case bolted
onto this one.
Tests
196 pass. Every render checks two invariants of its own, so rendering a shape
is itself an assertion: no feedback route may lie over a node cell, and no
label may cover a route. A seeded property test pushes 2000 assorted graphs
through both, varying node count, node width, edge count, direction and
labelling, and now also counts arrowheads against the heads each graph's shape
calls for — that count went from 926 failures to none. Cycle renders run on a
detached helper thread with a timeout, so a regression of the original hang
fails cargo test instead of hanging it, and a panicking render is told apart
from a hanging one.
Snapshots cover the acyclic examples, top-down and left-right cycles,
self-loops, sibling and stacked-node avoidance, several endpoints sharing a
layer, gaps carrying both an exit and an entry, truncated and inline labels,
every forward-label collision, and the two spanning-edge shapes above.
cargo fmt --check, cargo clippy -- -D warnings,
cargo clippy --all-targets -- -D warnings and cargo test are clean.