Sign in

    diagrams-mermaid

    Mermaid for diagrams.mbt: lossless CST, parser, AST, printer and conversion to diagram models

    Download zip
    Author
    Version
    0.2.0
    License
    Apache-2.0
    Last updated
    11 hours ago
    Downloads
    21

    #moonrockz/diagrams-mermaid

    Mermaid for diagrams.mbt. This module reads Mermaid sequence and native swimlane diagrams into lossless syntax and typed AST values, prints canonical Mermaid, and converts them to format-independent models in moonrockz/diagrams. render_svg dispatches both supported kinds to moonrockz/diagrams-svg. Existing read, print, ReadResult and to_sequence remain sequence-specific.

    The reader follows the sequence diagram grammar of Mermaid 12.2.0. The syntax tree has every statement, every arrow, comments, directives and front matter. It reads each sequence example of the Mermaid 12.2.0 documentation without a diagnostic. The typed AST has every statement, with frames and boxes as nested values. Conversion keeps all sequence events, nested frames and sections, participant kinds and boxes, title and accessibility fields, and numbering changes. It also adds links and reports semantic problems with source spans.

    These rules of Mermaid apply:

    • A ; ends a statement, as a line break does. One exception comes from the grammar: an autonumber number must have a space or a line break after it, so autonumber 10; is an error in Mermaid and here, but autonumber 10 ; is not.
    • # starts a comment, also in a message text, but an entity code such as #9829; is text.
    • %% at the start of a statement starts a comment, and %%{...}%% is a directive.
    • Keywords do not depend on letter case.

    #Read, print and convert

    The text of the syntax tree is always equal to the source, also when the source has errors. The AST does not keep spaces or comments, so the printer writes canonical text.

    ///|
    test {
    let source = "sequenceDiagram\n%% the client calls the API\nparticipant Client\nClient ->> API :GET /forecast\n"
    let r = @mermaid.read(source)
    assert_eq(r.tree.text(), source)
    assert_eq(r.diagnostics.length(), 0)
    let ast = r.ast.unwrap()
    assert_eq(
    @mermaid.print(ast),
    "sequenceDiagram\n participant Client\n Client->>API: GET /forecast\n",
    )
    let model = @mermaid.to_sequence(ast).sequence
    assert_eq(model.participants.length(), 2)
    }

    #Canonical printing

    print writes canonical Mermaid from the AST. Reading the printed text gives an equal AST, and printing that AST again gives the same text. AST equality does not compare source positions. The rules are:

    • The front matter comes first, then each %%{...}%% directive on its own line, then sequenceDiagram.
    • Each statement is on its own line. Statements have two spaces of indentation for each level: one under the header, and one more in each frame and box.
    • Keywords are in lower case, except sequenceDiagram, accTitle and accDescr. title: text becomes title text.
    • A message is the sender, () if it has one before the arrow, the arrow as written, + or -, () if it has one after the arrow, the receiver, :, and a space and the text if the text is not empty. Notes and link, links, properties and details statements use the same : text form.
    • Two note participants are joined by , with no spaces.
    • A participant configuration is written @{ and its text and }.
    • A - after an arrow that ends with -, such as /|-, has a space before it, because /|-- is another arrow.
    • A description with a line break (\n or \r) uses accDescr { ... } with its lines as written. Any other description uses accDescr: text.
    • Comments are not printed, because the AST does not keep them.

    ///|
    test {
    let source = "sequenceDiagram\nALT Is sick\n B ->>- A : Not so good\nELSE is well\nNote Over A , B: fine\nEND\n"
    let printed = @mermaid.print(@mermaid.read(source).ast.unwrap())
    assert_eq(
    printed, "sequenceDiagram\n alt Is sick\n B->>-A: Not so good\n else is well\n note over A,B: fine\n end\n",
    )
    assert_eq(@mermaid.read(printed).ast, @mermaid.read(source).ast)
    }

    #Diagnostics

    A statement that does not parse gives a diagnostic with a stable code and a span. The reader continues at the next statement, so one read reports every problem. Lines and columns start at 1, and columns count UTF-16 code units.

    ///|
    test {
    let r = @mermaid.read("sequenceDiagram\nA->>B: hi\nfrobnicate A\n")
    let d = r.diagnostics[0]
    assert_eq(d.code, "mermaid/unknown-statement")
    assert_eq(d.span.unwrap().start.line, 3)
    assert_eq(r.ast.unwrap().statements.length(), 1)
    }

    CodeMeaning
    mermaid/missing-headerThe source does not start with sequenceDiagram. There is no AST.
    mermaid/unexpected-headerThe source has a second sequenceDiagram.
    mermaid/unknown-statementThe statement is not one that the grammar knows.
    mermaid/missing-message-textA message has no : and text.
    mermaid/invalid-messageA message has no receiver.
    mermaid/invalid-participantA participant, actor, create, destroy, activate or deactivate statement has no valid name, or its @{...} configuration is not closed.
    mermaid/invalid-noteA note has no placement, no participant or no text, or left of or right of has two participants.
    mermaid/invalid-autonumberautonumber has something other than a start, a step or off.
    mermaid/invalid-linkA link, links, properties or details statement has no participant or no text.
    mermaid/invalid-titleA title has no text.
    mermaid/missing-endA frame, such as loop or box, has no end. The span is the frame keyword.
    mermaid/unexpected-endAn end has no open frame.
    mermaid/misplaced-sectionelse is not in alt, and is not in par, or option is not in critical.
    mermaid/invalid-in-boxA box has a statement that is not a participant declaration.
    mermaid/unclosed-directiveA %%{ directive has no closing }%%.
    mermaid/unclosed-descriptionAn accDescr { has no closing }.

    The table lists every code. This check keeps it current:

    ///|
    test {
    assert_eq(@mermaid.diagnostic_codes.iter().to_array(), [
    "mermaid/missing-header", "mermaid/unexpected-header", "mermaid/unknown-statement",
    "mermaid/missing-message-text", "mermaid/invalid-message", "mermaid/invalid-participant",
    "mermaid/invalid-note", "mermaid/invalid-autonumber", "mermaid/invalid-link",
    "mermaid/invalid-title", "mermaid/missing-end", "mermaid/unexpected-end", "mermaid/misplaced-section",
    "mermaid/invalid-in-box", "mermaid/unclosed-directive", "mermaid/unclosed-description",
    ])
    }

    #Sequence model and semantics

    Conversion adds undeclared participants at their first use. Participant metadata accepts JSON and YAML scalar type and alias fields. An explicit as label wins over the metadata alias. The types are participant, actor, boundary, control, entity, database, collections, queue, service, resource and client. A box has a stable ID within one conversion, its label and its optional leading CSS colour. Boxes with equal labels remain distinct groups.

    Each arrow has a solid or dotted line and independent heads at its sender and receiver. Heads include filled, open, cross, async, filled and unfilled half arrows, and central connections. par_over remains a distinct frame kind. A rect frame stores its colour separately from section labels.

    A->>+B starts an activation on B after the message; B-->>-A ends an activation on B after the reply. Activations can nest. Creation is an event before its message. A destroy B statement waits for the next message, which must involve B, then adds the destruction event after that message. Events that use B after its destruction give a diagnostic.

    Autonumber changes remain in event order, including inside frames. Each message has its visible number or None. The counter advances even while numbers are hidden. Turning numbering on without a start or step resumes the counter and step. Nonzero explicit values replace them. The counter rounds to two decimal places after each message, as Mermaid does.

    Conversion has a separate code list, conversion_diagnostic_codes:

    CodeMeaning
    mermaid/inactive-deactivationThe participant has no active activation.
    mermaid/destroyed-participantA statement uses a destroyed participant.
    mermaid/conflicting-participant-kindA declaration changes an already declared kind.
    mermaid/invalid-participant-configA configuration has invalid fields or an unknown kind.
    mermaid/missing-destroying-messageA destroy statement has no associated next message involving its participant.
    mermaid/invalid-linksA links value is not a JSON object.
    mermaid/conflicting-box-groupA participant is declared in two different boxes.
    mermaid/duplicate-creationA create statement names an existing participant.

    A known URL scheme in link or links attaches a model link to the participant. The schemes are smithy: and openrpc:. Other URLs stay in the AST. A links object selects its first URL with a known scheme in source order; later link statements can replace that selection.

    Smithy IDs use / in Mermaid, because # starts a comment. A participant link such as smithy:example.weather/Weather becomes the canonical target example.weather#Weather. A whole message label such as example.weather/GetForecast also becomes a Smithy link. The label stays as written. Message labels require at least two namespace identifiers, so read/write stays plain text. Names follow the Smithy 2.0 shape ID grammar, including an optional $member. An openrpc: URL stays an opaque target; message labels do not infer OpenRPC links.

    ///|
    test {
    let source = "sequenceDiagram\nparticipant W\nlink W: shape @ smithy:example.weather/Weather\nC->>W: example.weather/GetForecast\n"
    let converted = @mermaid.to_sequence(@mermaid.read(source).ast.unwrap())
    assert_eq(converted.diagnostics.length(), 0)
    assert_eq(
    converted.sequence.participant("W").unwrap().link.unwrap().target,
    "example.weather#Weather",
    )
    }

    The independent fixtures for the 38 frozen examples come from moonbit-community/mermaid@0.2.0. Refresh them with mise run corpus:reference. Normal tests read the committed JSON and compare participant order, labels, kinds, messages, arrow forms, central connections and nested frame boundaries.

    #Render as SVG

    The SVG renderer draws every sequence element, including nested frames, activations, notes, self-messages, creation and destruction. Its adaptive theme follows the viewer's light preference. Use moonrockz/diagrams-svg directly for fixed dark or light colours and public layout and legibility checks. Run mise run gallery to compare all 38 frozen corpus diagrams in both themes.

    ///|
    test {
    let r = @mermaid.render_svg("sequenceDiagram\nClient->>API: GET /forecast\n")
    assert_eq(r.media_type, "image/svg+xml")
    assert_true(
    r.output.unwrap().has_prefix("<svg xmlns=\"http://www.w3.org/2000/svg\""),
    )
    }

    #Native swimlane API

    The native header is swimlane-beta. The compatibility profile is Mermaid 12.2.0, with global TB by default, TD as its alias, and BT, LR and RL. Top-level subgraphs become ordered responsibility lanes; nested groups keep their own ownership. Unowned nodes remain outside lanes. Ordinary flowchart and graph inputs return mermaid/unsupported-diagram from the new dispatch API.

    read_diagram returns a ParsedDiagram::Sequence or ParsedDiagram::Swimlane AST with the corresponding DiagramTree and diagnostics. tree.text() always reproduces the complete source. print_diagram returns optional canonical text and diagnostics. to_swimlane converts the swimlane AST to immutable model values. Syntax locations use half-open UTF-16 offsets and one-based line/column values. Canonical AST equality ignores positions and incidental source spelling.

    The following tracer covers two lanes and one cross-lane connection. The full corpus separately covers all grammar productions, shape forms and metadata.

    ///|
    test "read print and render a native two-lane swimlane" {
    let source = "swimlane-beta LR\nsubgraph Customer\nA([Request])\nend\nsubgraph Support\nB{Review}\nend\nA handoff@--> B\n"
    let result = @mermaid.read_diagram(source)
    assert_eq(result.tree.text(), source)
    assert_eq(result.diagnostics, [])
    guard result.ast is Some(parsed) else { fail("missing AST") }
    let printed = @mermaid.print_diagram(parsed)
    assert_eq(@mermaid.read_diagram(printed.output.unwrap()).ast, Some(parsed))
    guard parsed is Swimlane(ast) else { fail("wrong kind") }
    let converted = @mermaid.to_swimlane(ast)
    assert_eq(converted.diagnostics, [])
    let view = converted.swimlane.unwrap()
    assert_eq(view.lanes.length(), 2)
    assert_eq(view.nodes[0].lane, Some("Customer"))
    assert_eq(view.nodes[1].lane, Some("Support"))
    assert_eq(view.connections[0].id, "handoff")
    let rendered = @mermaid.render_svg(source)
    assert_eq(rendered.media_type, "image/svg+xml")
    assert_true(rendered.output is Some(_))
    }

    #Normalization and retained data

    Implicit endpoints appear in reference-defined order. Repeated subgraph IDs merge members while retaining the first completed database registration's title, direction and position. A nested registration can complete before its outer one. Group membership follows the database's accepted closed members rather than last textual mention. A style declaration can create an implicit unowned node.

    Explicit and generated edge IDs follow the pinned database, including collision counters and the one explicit-ID slot in grouped expansion. Parallel edges keep distinct IDs. Endpoint markers determine structural traversal independently of layout direction: one marked end points toward that end, two marked ends permit both directions, and no marked end requires undirected route selection.

    Node and edge labels retain Text, Markdown or Html kind. YAML shape data uses pinned portable moonbit-community/yaml@0.0.8 events to check expanded alias weight and logical depth before moonbitstack/moonyaml@0.1.1 resolves values. Both obey explicit budgets with Mermaid-compatible JSON-schema behavior. Raw syntax remains in the CST; typed values drive canonical printing. Nested metadata stays data.

    Classes, style, click callbacks, links and renderer configuration read and print without execution or resource loads. Conversion reports unapplied presentation behavior. SVG uses explicit library options and safe label projection. Asset and unknown shapes retain their identities, then fail rendering explicitly.

    ///|
    test "grouped expansion retains one native explicit ID and every occurrence" {
    let read = @mermaid.read_diagram("swimlane-beta\nA & B e@--> C & D\n")
    guard read.ast is Some(Swimlane(ast)) else { fail("missing AST") }
    let view = @mermaid.to_swimlane(ast).swimlane.unwrap()
    assert_eq(view.nodes.length(), 4)
    assert_eq(view.connections.length(), 4)
    assert_eq(view.connections.iter().filter(edge => edge.id == "e").count(), 1)
    }

    #Swimlane diagnostics and limits

    The existing sequence inventories retain their meanings. New swimlane_diagnostic_codes and swimlane_conversion_diagnostic_codes expose the native reader and conversion inventories. Constructed-model validation has a separate diagrams/swimlane.diagnostic_codes inventory. The low-level swimlane-printer.diagnostic_codes inventory contains swimlane/print-limit and swimlane/invalid-ast.

    CodeMeaning
    swimlane/source-limitSource exceeds its configured UTF-16 limit.
    swimlane/work-limitReading or conversion exhausts its work budget.
    swimlane/depth-limitNesting exceeds the configured bound.
    swimlane/unsupported-headerThe swimlane reader receives another header.
    swimlane/directiveA source directive is unclosed.
    swimlane/unexpected-endNo group is open for end.
    swimlane/syntaxA statement or its shape data is malformed.
    swimlane/missing-endA group has no closing end.
    swimlane/node-limitNormalized node count exceeds its bound.
    swimlane/edge-limitExpanded connections exceed their bound.
    swimlane/directionA declared direction has no supported model value.
    swimlane/ownership-cycleNormalized ownership is cyclic.
    swimlane/ownershipOwnership references a missing group.
    swimlane/invalid-astTyped values are inconsistent or cannot print as an equivalent native AST.
    swimlane/presentation-metadataA renderer-only feature remains unapplied source data.
    swimlane/print-limitCanonical printing exceeds a configured bound.
    mermaid/unsupported-diagramKind dispatch receives an unsupported header.
    mermaid/print-limitSequence output exceeds the new dispatch printer's bound.

    ///|
    test "documented native diagnostic inventories match exported code" {
    assert_eq(@mermaid.swimlane_diagnostic_codes, [
    "swimlane/source-limit", "swimlane/work-limit", "swimlane/depth-limit", "swimlane/unsupported-header",
    "swimlane/directive", "swimlane/unexpected-end", "swimlane/syntax", "swimlane/missing-end",
    ])
    assert_eq(@mermaid.swimlane_conversion_diagnostic_codes, [
    "swimlane/work-limit", "swimlane/node-limit", "swimlane/edge-limit", "swimlane/depth-limit",
    "swimlane/direction", "swimlane/ownership-cycle", "swimlane/ownership", "swimlane/invalid-ast",
    "swimlane/presentation-metadata",
    ])
    }

    swimlane-syntax.Limits::new supplies explicit limits to reading, printing and conversion. Defaults are 16 Mi UTF-16 source/output units, 10,000 nodes, 50,000 expanded connections, nesting 256, one million work units and 256 diagnostics. Metadata has separate metadata_depth and metadata_work settings. Metadata depth supports 1 through 256 and defaults to 256; unsupported settings fail with a depth-limit finding before processing. The positive metadata work budget defaults to one million units and also obeys the overall work budget. Diagram group depth remains independently configurable. Nested metadata prints in block YAML where the pinned native lexer requires it.

    Limit exhaustion returns a distinct finding without apparently complete output. The renderer propagates failed-stage diagnostics without returning empty text as successful SVG.

    The test-only reference harness pins Mermaid 12.2.0 and its lockfile. Run mise run swimlane-corpus:reference -- --check to check normalized nodes, labels, shapes, connections, IDs, styles, markers, lengths, directions and ownership against the real parser/database. Omit --check when intentionally refreshing reference fixtures. mise run validate:swimlane-corpus verifies source digests and portable generated fixtures; swimlane-corpus:generate refreshes embeddings. The frozen grammar-production and shape manifests describe coverage. Mermaid and its JavaScript dependencies are used only by the test oracle.

    DiagramPrintResult

    pub struct DiagramPrintResult {
    output : String?
    diagnostics : ReadOnlyArray[
    Diagnostic
    ]
    } derive(Eq,
    Debug
    )

    DiagramPrintResult::equal

    DiagramPrintResult::not_equal

    DiagramReadResult

    pub struct DiagramReadResult {
    tree : DiagramTree
    ast : ParsedDiagram?
    diagnostics : ReadOnlyArray[
    Diagnostic
    ]
    } derive(Eq,
    Debug
    )

    DiagramReadResult::equal

    DiagramReadResult::not_equal

    fn DiagramReadResult::not_equal(x : DiagramReadResult, y : DiagramReadResult) -> Bool

    DiagramTree

    DiagramTree::equal

    fn DiagramTree::equal(DiagramTree, DiagramTree) -> Bool

    DiagramTree::not_equal

    fn DiagramTree::not_equal(x : DiagramTree, y : DiagramTree) -> Bool

    DiagramTree::text

    fn DiagramTree::text(self : DiagramTree) -> String

    ParsedDiagram

    Multi-kind entry points preserve the sequence-only API.

    ParsedDiagram::equal

    ParsedDiagram::not_equal

    fn ParsedDiagram::not_equal(x : ParsedDiagram, y : ParsedDiagram) -> Bool

    ReadResult

    The result of reading a Mermaid source: the lossless syntax tree, the typed AST when the source has the sequenceDiagram header, and all diagnostics.

    conversion_diagnostic_codes

    let conversion_diagnostic_codes : ReadOnlyArray[String]

    Every diagnostic code that conversion can give. Reading uses diagnostic_codes; link resolution uses sequence/unresolved-link.

    diagnostic_codes

    let diagnostic_codes : ReadOnlyArray[String]

    Every diagnostic code that reading can give.

    print

    Canonical Mermaid text of diagram.

    read

    fn read(source : String) -> ReadResult

    Read a Mermaid sequence diagram. The text of tree is always equal to source.

    read_diagram

    render_svg

    fn render_svg(source : String) ->
    Rendered

    Read source and render it with archify's classic theme as adaptive SVG. The result has the diagnostics of reading, converting and rendering. A source without a diagram gives no output.

    swimlane_conversion_diagnostic_codes

    let swimlane_conversion_diagnostic_codes : ReadOnlyArray[String]

    swimlane_diagnostic_codes

    let swimlane_diagnostic_codes : ReadOnlyArray[String]

    Existing sequence-only inventories retain their original meaning.

    to_sequence

    The sequence model of diagram, with the conversion diagnostics.

    Powered by MoonBit

    Site sourceReport issuePackagesBuild queueSkillsStatistics

    © 2026 mooncakes.io