logs.gokuls.in

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.