Parser
The parser turns .mmd text into a MetroGraph.
Start here to add a node shape, a %%metro directive or a new statement form, or to understand the grammar.
The entry point is parse_metro_mermaid in src/nf_metro/parser/mermaid.py.
The data model it builds is in src/nf_metro/parser/model.py.
The parser package is split by job:
grammar.pyholds the grammar, statement types and transformer.directives.pyhandles directive parsing and dispatch.resolve.pyholds the post-parse graph rewrites.mermaid.pyis the public entry point and statement-application driver.
Parsing is the first of the three stages, Parse -> Layout -> Render.
The layout pipeline takes over from the MetroGraph this stage produces.
Scope of parsing
Section titled “Scope of parsing”The input is a subset of Mermaid graph LR syntax plus %%metro directives.
Parsing reads that text and produces a MetroGraph of sections, stations, edges, lines and ports.
It assigns no coordinates, which is the layout stage’s job.
The work splits in two:
- Front-end. Recognize each line’s shape, whether it is a node, an edge or a directive, and pull out its pieces. The grammar recognizes statement shapes and boundaries. Python handles directive payloads and graph semantics (see Directives and Parse-then-resolve flow).
- Post-parse. Rewrite the raw graph into the form layout expects: resolve inter-section edges into ports and junctions, insert bypass and convergence stations, and create the implicit section for loose nodes. These are plain functions operating on the model, covered in Parse-then-resolve flow.
Input format
Section titled “Input format”.mmd files use a subset of Mermaid graph LR syntax:
%%metro title: Pipeline Name%%metro line: line_id | Display Name | #hexcolor | style
graph LR subgraph section_id [Section Name] %%metro entry: left | line1, line2 %%metro exit: right | line1, line2 node_id[Label] node_id -->|line_id| other_node end %% Inter-section edges live outside subgraphs node_a -->|line_id| node_b- A Mermaid
subgraphbecomes aSection. - A node (
node_id[Label]) becomes aStation. - An edge (
a -->|line1,line2| b) becomes oneEdgeper line id. The pipe-delimited label lists the lines the edge carries. An endpoint may also be written with an inline shape (a[A] -->|line1| b[B]), which declares that node’s label as well as the edge. - Lines must be declared with
%%metro line:before use. An edge with no line annotation, or one naming an undeclared line, is rejected as a semantic error (see Leniency and error policy). - A primary graph direction other than
LRproduces a warning from_warn_if_non_lr_primary. Per-section flow is controlled by%%metro direction:and is independent of the header.
Why the parser uses a grammar
Section titled “Why the parser uses a grammar”One way to read a line-oriented format is to write the instructions for recognizing each line: check for a graph header, then for a subgraph, then for an arrow, then try six node-shape patterns in a fixed order.
That works, but the order of the checks becomes load-bearing, and every new shape or directive means finding the right slot in the chain.
A grammar works the other way round.
Instead of the recognizing instructions, you write down the rules of what a valid line is and a library does the recognizing.
nf-metro uses lark for this.
The grammar lives as a string in grammar.py (_GRAMMAR), and reads roughly:
node: NAME SHAPE?edge: NAME SHAPE? ARROW EDGELABEL? NAME SHAPE?
SHAPE: /\(\[...\]\)|\[\[...\]\]|\(\(...\)\)|\[...\]|\(...\)|\{...\}/ARROW: /-->|---|==>/NAME: /[a-zA-Z_][a-zA-Z0-9_]*/An edge endpoint may carry an inline shape.
x[X] -->|a| y[Y] declares node x with label “X”, node y with label “Y”, and the edge between them, all in one line.
The SHAPE terminal’s inner pattern, _SHAPE_INNER, excludes the arrow sequences.
A source shape like [X] therefore stops at the arrow rather than swallowing it.
You describe the shapes and lark works out how to match them.
Adding a node shape is one more alternative in the SHAPE rule rather than a new regex slotted into a hand-ordered chain, and the order of the rules stops mattering for correctness.
From text to MetroGraph
Section titled “From text to MetroGraph”Three steps:
- Parse.
_PARSER.parse(text)turns the whole document into a parse tree using the grammar. - Transform.
_StatementTransformerwalks that tree and flattens it into an ordered list of typed statements, one small dataclass per source line, from a fixed union of_GraphHeader,_Subgraph,_Directive,_Node,_Edge,_End,_Commentand_Junk. The transformer normalizes as it goes. Each statement carries structured fields rather than raw tokens._Subgraphalready splits the section id from its display name,_Directivesplits the body on the first colon intokeyandvalue, and_Edgecarries its line ids as a list plus any inline endpoint labels. - Drive.
parse_metro_mermaiditerates those statements in source order and dispatches each byisinstance, applying it to the graph while tracking whichsubgraphit is currently inside throughcurrent_section_id. A_Subgraphopens a section, an_Endcloses it, and the nodes, edges and directives in between are attached to it.
Before auto-layout or resolver rewrites begin, the parser freezes the accepted layout input in graph.layout_provenance.authored.
This snapshot contains the authored grid cells, section directions, entry and exit hints, connector-side choices, both possible fold-threshold inputs, and line-order inputs.
The line-order record distinguishes a directive, a caller override, and the default, and also keeps line definition order.
The effective decision records the chosen policy and source.
A caller override is captured before inference but remains applied at the established API override stage, preserving current layout behavior.
Because the snapshot is immutable, later inference cannot make an authored value look inferred or make an inferred value look authored.
The internal candidate executor applies prospective layout commitments through one typed overlay at this boundary. It rejects malformed, duplicate, unknown, or conflicting commitments before inference, then verifies the settled graph against the same commitments.
Because the appliers receive structured fields, the driver stays a thin dispatch.
A _Node registers a station, an _Edge registers one edge per line id and declares any inline-shaped endpoints, and a _Directive is routed to a handler.
The driver is an in-order loop by design. Source order matters because the dictionary insertion order of stations affects downstream layout. The grammar handles recognising lines while the driver handles applying them in sequence.
Node shapes collapse to an id and a label
Section titled “Node shapes collapse to an id and a label”The model records only a node’s id and label, never which shape it was drawn as.
All six Mermaid shapes therefore collapse to a single SHAPE terminal plus a small helper, _shape_label, that strips the delimiters, instead of six separate regexes that each had to be tried in the right order.
Directives
Section titled “Directives”The grammar does not describe %%metro directive bodies.
They keep their own handler functions, because a grammar cannot express behavior like “warn about this and ignore it”, which several directives need.
The grammar delivers the directive line as a unit, and the transformer splits its body once on the first colon into a key and a value.
A %%metro line with no colon becomes a _Comment and is ignored.
Dispatch on the key happens in _apply_directive.
Most directives are graph-wide and live in the _GLOBAL_DIRECTIVE_HANDLERS dict, keyed by exact name and mapping to a (value, graph) -> None handler.
Exact-key lookup means handler order is irrelevant, and a key that is a prefix of another, such as legend beside legend_combo or logo beside logo_scale, cannot shadow it.
Two families are dispatched separately because they need more than the value alone: entry, exit, direction and number need the enclosing section, and the icon keys file, files and dir need the key itself to choose the icon type.
A key matching none of these is ignored with a UserWarning.
The simple scalar, bool and enum settings that also have a CLI flag, such as spacing, gaps, diamond_style, the scales, width, height and animate, are not hand-written handlers.
They are declared once in nf_metro.options, as LayoutOption entries in LAYOUT_OPTIONS.
_make_layout_option_handler generates a directive handler from each entry, parsing through coerce and writing the named MetroGraph field, and nf_metro.cli generates the matching click flag from the same registry.
Adding such an option means one registry entry rather than a handler plus a flag plus a docs row.
tests/test_options_parity.py guards that every registry option exists in both planes.
The bespoke handlers in the table carry grammar, meaning fields, sections and coordinates, that the generic registry cannot express.
The directives _apply_directive recognizes:
| Directive | Effect |
|---|---|
title: / style: | graph title and theme name |
line: id | name | #color | style | declare a MetroLine (style is solid / dashed / dotted) |
line_order: | definition or span line ordering |
entry: / exit: (inside a subgraph) | stored as port hints on the section |
direction: | section flow LR / RL / TB |
number: (inside a subgraph) | pin the section’s positive-integer number badge |
grid: | manual section grid placement |
compact_offsets: / center_ports: | bundle layout toggles |
diamond_style: | fork-join layout straight / symmetric |
line_spread: | how shared lines relate vertically (bundle / centered / rails), graph-wide or per-section |
fold_threshold: | station count at which long chains wrap into serpentine rows |
x_spacing: / y_spacing: / section_x_gap: / section_y_gap: | layout spacing and section gaps |
width: / height: / animate: | render output size and animation toggle |
off_track: | mark stations to lift above the section’s top track |
label_angle: | diagonal station-label angle |
legend: / legend_min_height: / legend_combo: / legend_logo_gap: | legend block |
logo: / logo_scale: | logo path and scaling |
font_scale: | global font scaling |
stroke_scale: | global ink scaling (strokes + station pills) |
group: | annotative caption spanning stations |
marker: / marker_legend: | per-station marker shape/fill styling and its legend caption |
file: / files: / dir: | terminus file-icon designation |
entry: and exit: do not create Port objects at parse time.
_parse_port_hint records them on the Section as entry_hints and exit_hints, each a (side, [line_ids]) list.
Real inter-section edges drive port creation later.
Leniency and error policy
Section titled “Leniency and error policy”The split is deliberate. The grammar and parse layer is lenient about syntax it does not recognize and warns rather than crashing. Semantic validity is a separate, stricter phase. A typo in a node line should not abort a render of an otherwise-fine diagram, but an edge that names no metro line is a modeling error.
| Input | Outcome |
|---|---|
Blank line, %% comment, or %%metro line with no colon | ignored silently |
Unrecognized non-blank line (the grammar junk rule) | dropped, with a UserWarning (“Ignored unrecognised line: …”) |
Unknown %%metro directive key | ignored, with a UserWarning ("%%metro <key>: unknown directive; ignoring") |
Malformed directive payload (too few | fields, an unusable enum/number/bool, a section-scoped directive outside a subgraph) | warned about and ignored, uniformly across handlers (_warn_directive) |
Foreign/unsupported syntax (Mermaid flowchart) | raises ValueError with guidance, via _check_unsupported_input, before the grammar runs |
| Edge with no line annotation, or an undeclared line id | raised by _validate_edge_annotations after parsing |
| Directive naming a station, section, or line the map never declares | warned about and ignored by _warn_unresolved_references after the statement scan |
Graph-semantic checks beyond edge annotations live in the separate validate phase, nf_metro.parser.validate.validate_graph.
Both phases read one shared piece: find_undeclared_line_edges, which sits next to validate_graph and covers the undeclared-line half of the edge check.
That is what keeps nf-metro validate and nf-metro render accepting the same maps.
A map declaring no %%metro line: directive at all is no exception, since every annotated edge in it names an undeclared line.
A directive may name a node or subgraph that appears further down the file, so the ids a directive references cannot be resolved in its handler.
A handler that resolved an id itself would report a false unknown for every forward reference.
Handlers therefore check only payload shape and buffer the ids.
_warn_unresolved_references and _resolve_legend_combos resolve them in _finalize_graph, once the whole source has been applied.
The unrecognized-line case is handled in the grammar by a low-priority catch-all:
JUNK.-10: /[^\n]+/JUNK matches any line, but its negative priority means it wins only when nothing more specific does.
The transformer turns the match into a _Junk statement, and the driver warns when it applies one.
This junk fallback is why the parser is configured as Lark(..., parser="earley", lexer="dynamic") rather than the faster lalr.
A line that begins like a valid statement but then hits an unexpected token must be able to fall back to junk and be dropped.
An Earley parser can explore that fallback.
A committing lalr parser cannot backtrack a partly-matched line and would turn it into a fatal error instead.
Parse-then-resolve flow
Section titled “Parse-then-resolve flow”After the grammar parse and statement application, parse_metro_mermaid runs a post-parse sequence, but only when the graph has sections:
_validate_edge_annotationsrejects malformed edges._remove_empty_sectionsand_create_implicit_sectiondrop empty subgraphs and wrap loose, section-less stations in an implicit, invisible section.- Capture authored route facts and their parser-local edge lineage, then expand interchanges.
infer_section_layout, fromlayout/auto_layout.py, infers missing grid positions, section directions and port sides from the section DAG, preserving anything set explicitly by directives._insert_terminus_convergence_stations, propagating authored lineage through each synthetic convergence edge.resolve_section_endpointsclassifies connectors and settles their entry and exit sides once, before ports exist.- Build the immutable
RouteTopologydescribed in Authored route topology. _resolve_sectionscreates ports and junctions from that topology, using the endpoint result for current edge endpoints and compatibility order._insert_bypass_stationssplits any required exit legs and updates their connector traces.
Finally the parser applies the pending terminus icons, off_track marks and per-station markers that were buffered during the statement scan.
_resolve_sections
Section titled “_resolve_sections”_resolve_sections rewrites inter-section edges into port and junction chains.
It is split into three helpers:
_build_entry_side_mappingbuilds a per-line entry-side lookup from theentry_hints. A section gets one entry side. If all hints agree, that side is used. Otherwise they collapse to the natural entry for the section direction, which is LEFT for LR, RIGHT for RL and TOP for TB._classify_edgessplits edges into internal ones, with both endpoints in one section, and inter-section ones, and populates each section’sinternal_edges._create_ports_and_junctionscreatesPortobjects from topology endpoint groups and rewrites each inter-section edge into the chainsource -> exit_port -> entry_port -> target. The design rule is one exit port per source section and side and one entry port per target section and side. Lines sharing a boundary leave together for consistent ordering, and topology divergence groups decide where fan-out junctions are required.
After ports and fan junctions exist, topology convergence groups decide where merge junctions are required.
_assign_section_numbers then numbers any unnumbered sections.
The result is a MetroGraph whose edges all live within a section or run port-to-port, ready for the layout stage.
Authored route topology
Section titled “Authored route topology”RouteTopology captures route meaning before the resolver replaces authored cross-section connectors with port and junction chains.
Authored facts are captured before interchange expansion.
A parser-local sidecar follows each authored connector through interchange and terminus-convergence rewrites, but is never stored on MetroGraph or Edge.
After terminus convergence, resolve_section_endpoints settles every boundary side on the production graph.
It retains the current edge endpoints and their encounter order.
The topology builder uses those boundary facts to describe the semantic groups.
_resolve_sections then creates ports and junctions from the topology, while the boundary result preserves the current edge endpoints and output order.
The topology contains one connected LineNetwork per line component, exact endpoint BundleRuns, cross-section connectors, resolved endpoint groups, divergences and resolver-shaped convergences.
Connector identities derive from source, target, line and an occurrence number among exact duplicates.
Network, bundle, divergence and convergence identities derive from their defining content.
An unrelated edge or component cannot renumber existing identities.
All records are frozen and contain only scalar values, PortSide values and tuples.
They retain no MetroGraph, station, section, edge, mutable container or NetworkX object.
Top-level and nested collections have explicit canonical ordering.
The same input therefore produces the same topology under every hash seed.
The resolver also records a RouteResolutionTrace.
It maps endpoint groups to ports, fan and merge groups to junctions, and each authored connector to its ordered final edge paths.
Most connectors have one path.
A connector can have several when one exit leg needs parallel bypass helpers.
Because the records use scalar ids and tuples, duplicate authored connectors can share paths.
Bypass insertion updates the affected paths when it splits an exit leg.
RouteTopology and RouteResolutionTrace stay on MetroGraph while layout and routing run.
They identify the authored fan, merge and endpoint group behind each resolver-created port or junction.
They are excluded from RenderPlan, because that plan is created only after geometry has settled and needs no parser provenance to render SVG, HTML or a manifest.
LayoutProvenance is excluded for the same reason.
Layout decision provenance
Section titled “Layout decision provenance”LayoutProvenance keeps two views of layout intent:
authoredis the frozen pre-inference snapshot described in From text toMetroGraph.grids,directions,connector_sidesandfold_threshold_decisiondescribe the effective values the engine uses.
Each effective decision answers three separate questions: who selected the value, whether another inference pass may replace it, and why it was selected.
The compact states shown by nf-metro info --verbose and nf-metro explain are authored, inferred and inferred-then-pinned.
A value can therefore be engine-selected and locked without being misreported as authored.
Grid and direction decisions are keyed by section id.
Port-side decisions are keyed by the semantic ConnectorId plus entry or exit.
Two lines sharing one visible port can therefore retain different histories.
If resolution changes an authored side, the effective decision records the new side and retains the original authored options.
The fold threshold follows caller, directive, then default precedence. The snapshot retains the caller and directive values separately, while the effective decision records the selected source. Rendering reads this typed decision when it reports an unsafe fold threshold.
Add a shape, directive or statement form
Section titled “Add a shape, directive or statement form”- A node shape. Add one alternative to the
SHAPEterminal in_GRAMMAR. If its delimiters are two characters per side, add the opener totwo_char_opensin_shape_label. - A
%%metrodirective. Write a(value, graph) -> Nonehandler and add an entry to the_GLOBAL_DIRECTIVE_HANDLERSdict, keyed by the exact directive name. Ordering does not matter. A directive that needs the enclosing section or the key itself is dispatched in_apply_directiveinstead of the dict. On an unusable payload, call_warn_malformed, or_warn_directivefor a more specific message, and return rather than failing silently, per the leniency policy. A directive that names a station, section or line id must buffer it and add a case to_directive_references. The id then resolves against the whole file rather than only the text before the directive. - A new statement form. Add a rule and its terminal to
_GRAMMAR, a typed statement dataclass in the_Statementunion, a method to_StatementTransformerreturning that dataclass, and anisinstancebranch to the driver loop inparse_metro_mermaid.
Equivalence checks
Section titled “Equivalence checks”The grammar must produce exactly the MetroGraph the input implies.
Changes here are therefore checked by comparing parsed model objects rather than rendered SVGs.
The check parses every fixture in tests/fixtures/, examples/ and examples/topologies/ and asserts that the model matches.
Identical models render identically, which keeps the gallery byte-identical without chasing sub-pixel render drift.
The grammar’s coverage and behavior are pinned by tests/test_parser_grammar.py.