moonrockz/diagrams-plantuml
PlantUML for diagrams.mbt. This module reads native PlantUML state, activity, regex, EBNF,
JSON and YAML blocks into lossless syntax trees and typed ASTs, writes canonical
PlantUML, and converts the blocks to format-independent models in
moonrockz/diagrams. render_svg composes reading, conversion and the
moonrockz/diagrams-svg renderer.
The module follows PlantUML 1.2026.9 (commit
18d0aae311a4f1eb3723bb4b0ad939185e22f166). It has no Java, Graphviz, server
or network dependency, and it runs on wasm, wasm-gc, js and native. A
pinned JAR is test-only reference tooling; see
the corpus notice.
| Block | Model | Notes |
|---|---|---|
@startregex |
@regex.Regex |
One pattern line. Structure only; no input matching. |
@startebnf |
@ebnf.Grammar |
Ordered rules and notes. References stay symbolic. |
@startjson |
@data_tree.DataTree |
Exact number lexemes, ordered and repeated members. |
@startyaml |
@data_tree.DataTree |
The native PlantUML YAML subset. Every scalar is text. |
@startuml activity |
@flowchart.Flowchart |
Modern and legacy activity dialects. Structural data only. |
@startuml state |
@state.StateDiagram |
Hierarchy, concurrent regions, pseudostates and descriptive history. |
Read, print and convert
read accepts exactly one native block. read_body accepts a raw body when the
caller gives its kind. The tree text is always equal to the source, also when the
source has errors. A document is present only when there is no error.
///|
test {
let source = "@startregex\ntitle Year\n(?<year>\\d{4})-\\d{2}\n@endregex\n"
let read = @plantuml.read(source)
assert_eq(read.tree.text(), source)
assert_eq(read.diagnostics.length(), 0)
let document = read.document.unwrap()
assert_eq(
@plantuml.print(document).output,
Some("@startregex\ntitle Year\n(?<year>\\d{4})-\\d{2}\n@endregex\n"),
)
guard @plantuml.to_diagram(document).diagram is Some(Regex(model)) else {
fail("regex")
}
assert_eq(model.title, Some("Year"))
}
Canonical writing derives the text from typed values. Reading the output gives an equal logical AST: source locations, comments and statement position are not compared. A value that the native syntax cannot express gives a diagnostic and no text. The writer never copies stale source text.
///|
test {
let body = @plantuml.read_body(
Ebnf,
"expr = term, { (\"+\" | \"-\"), term };",
)
assert_eq(body.diagnostics.length(), 0)
let printed = @plantuml.print_body(body.document.unwrap())
assert_eq(printed.output, Some("expr = term, { (\"+\" | \"-\"), term };\n"))
}
Errors instead of partial results
Each block kind has a stable inventory of diagnostic codes in diagnostic_codes.
Diagnostics use half-open UTF-16 spans. These inputs give errors:
| Input | Code |
|---|---|
| Two blocks, or text before or after the block | plantuml/multiple-blocks, plantuml/text-outside-block |
A wrong end marker, such as @endyaml for @startjson |
plantuml/wrong-end-marker |
!include, !define, !theme and other preprocessor directives |
plantuml/unsupported-directive |
A preprocessor call such as %date() |
plantuml/unsupported-preprocessing |
| A regex with more than one pattern line | regex/multiple-patterns |
| An EBNF rule that PlantUML draws as "Syntax error!" | ebnf/syntax |
| Malformed JSON | json/... |
A YAML form that the pinned parser refuses, such as a folded > value |
yaml/... |
///|
test {
let read = @plantuml.read(
"@startregex\n!include other.puml\nabc\n@endregex\n",
)
assert_true(read.document is None)
assert_eq(read.diagnostics[0].code, "plantuml/unsupported-directive")
}
title is the diagram title. caption, header, footer, legend,
skinparam, <style>, skin, scale, hide, !pragma and !option stay in
the AST as inert statements, with an plantuml/unapplied-presentation warning.
Library themes and render options paint the diagram.
Regex profile
The reader follows the token order of the pinned PlantUML reader. It reads
literals and escaped characters, ., letter escapes such as \d and \s,
\p{...}, \x{...}, \uXXXX, \0ooo, POSIX classes [[:name:]], sets,
anchors ^ $ \A \Z \z \G \b \B, numbered backreferences, \Q...\E, capturing,
non-capturing and named groups, lookahead and lookbehind assertions,
alternatives and the quantifiers * + ? {n} {n,} {,m} {n,m}, also reluctant.
Repetition bounds stay symbolic: a{1000000} is one model node.
Adjacent characters of a sequence become one literal term, as PlantUML draws them. Capturing and non-capturing groups stay in the model and the SVG shows them as frames. Lookaround assertions are constraints, not consuming branches.
The library refuses \x without braces, \k, \P{...}, inline flags such as
(?i), atomic groups, unclosed sets, quantifiers with two commas and bounds
larger than a 32-bit integer. PlantUML 1.2026.9 refuses lazy brace quantifiers
such as {2,4}?, empty alternatives and empty groups; so does this library.
EBNF profile
Statements dispatch in the pinned order: whole-line notes (* ... *),
multi-line notes (* ... *) and (** ... **), single-line rules and
multi-line rules that end with ;. Rules keep their order and Unicode names.
Expressions have terminals in either quote, nonterminal references, special
sequences ? ... ?, concatenation, alternatives, grouping, [ ], { },
{ }-, decimal counts n * x and exclusions x - y. A rule without a body is
an empty rule. Notes on terms and the note below a rule name are kept.
An exclusion takes the rest of its group, as in the pinned reader:
a - b | c is a - (b | c). The model keeps the exclusion as a constraint on
its left term; it never becomes an alternative. A reference to a missing or
repeated rule name gives a warning and stays a visible nonterminal. A recursive
reference stays one reference and is never expanded.
JSON and YAML profiles
JSON keeps all member occurrences in order, including repeated names, and the
exact spelling of numbers such as 12345678901234567890 and -0.0. A scalar
root stays a scalar model; PlantUML draws it in a one-element array.
YAML follows the pinned PlantUML line parser, not a general YAML library. Every
scalar is text: enabled: true gives the string true. Indentation, - items,
lists of maps, flow lists [a, "b,c"], quotes, # comments, | block text and
key-only lines with following text are read. Anchors, aliases and tags such as
&a, *a and !!str stay literal text. A repeated key keeps every line in the
AST; the model takes the last value at the first position, with a
yaml/duplicate-key warning, as PlantUML does.
#highlight selectors resolve to member and value identities. A quoted segment
keeps a / inside it. * and ** are wildcards. A name that matches more than
one member highlights all of them with a warning; "name"#n selects one
occurrence. An unresolved selector gives a warning and selects nothing. A
<<stereotype>> is inert metadata and never becomes CSS.
///|
test {
let source = "@startjson\n{\"id\": 12345678901234567890, \"id\": -0.0}\n@endjson\n"
let document = @plantuml.read(source).document.unwrap()
guard @plantuml.to_diagram(document).diagram is Some(DataTree(tree)) else {
fail("json")
}
let numbers = tree.nodes
.iter()
.filter_map(n => {
match n.value {
Number(lexeme) => Some(lexeme)
_ => None
}
})
.collect()
assert_eq(numbers, ["12345678901234567890", "-0.0"])
}
Render
render_svg reads, converts and renders one block. Diagnostics from every stage
accumulate, and an error gives no output.
///|
test {
let rendered = @plantuml.render_svg(
"@startyaml\nname: test\nlist:\n - a\n - b\n@endyaml\n",
renderer=@svg.SvgRenderer::new(mode=Fixed(Light)),
)
assert_true(rendered.output.unwrap().contains("data-diagram=\"data-tree\""))
}
External trace providers
The library bundles no regex matcher, grammar parser or parser generator. An
external engine implements the public RailroadTraceProvider trait from
moonrockz/diagrams/railroad-trace. A request has a typed regex or EBNF
subject, the input, the view identity, explicit limits and, for EBNF, the start
rule. A result is Matched, NotMatched, Unsupported, Exhausted or
ProviderError. A provider that supports one subject kind returns Unsupported
for the other.
render_traced_svg is the only PlantUML entry point that calls a provider. It
validates the result and renders it with independent highlight channels.
Ordinary rendering, scenes, playback and export never call a provider.
///|
/// A scripted stub: it supports regex requests and reports a failure at
/// the first term. It is an example of the contract, not a matcher.
struct FirstTermFails {}
///|
impl @trace.RailroadTraceProvider for FirstTermFails with fn trace(_, request) {
guard request.subject is Regex(_) else { return Unsupported("regex only") }
let topology = request.topology().topology.unwrap()
let track = topology.tracks[0]
let first = topology.terms[0]
NotMatched(
@trace.Trace::new(request, "first-term-fails", [
@trace.Attempt::new(
"a0",
NotMatched,
[Traverse(topology.connector_of(track.id, Start).unwrap().id, false)],
failure=@trace.FailurePoint::new(Terminal(first.id), position=0),
),
]),
)
}
///|
test {
let result = @plantuml.render_traced_svg(
"@startregex\nab|cd\n@endregex\n",
FirstTermFails::{ },
"x",
)
assert_true(result.run.result is Some(NotMatched(_)))
assert_true(result.rendered.output.unwrap().contains("rr-trace-failure"))
let grammar = @plantuml.render_traced_svg(
"@startebnf\nr = \"x\";\n@endebnf\n",
FirstTermFails::{ },
"x",
start_rule="r",
)
assert_true(grammar.run.result is Some(Unsupported(_)))
assert_true(grammar.rendered.output is None)
}
To draw evidence that a host adapter already has, pass a TraceHighlight to
SvgRenderer::new(trace=...) and render normally. The
SVG module guide describes the channels.
Modern and legacy activity flowcharts
@startuml activity blocks use the shared flowchart model, SVG renderer and
offline player. The reader keeps the activity dialect, typed control statements
and lossless source. Other UML families remain explicit unsupported inputs.
Both modern and legacy syntax use the pinned PlantUML 1.2026.9 profile.
///|
test "activity source prints and converts to a flowchart" {
let source = "@startuml\nstart\n:Read;\n:Read;\nstop\n@enduml\n"
let result = @plantuml.read(source)
assert_eq(result.tree.text(), source)
assert_eq(result.diagnostics, [])
let document = result.document.unwrap()
guard document is Activity(activity) else { fail("Expected activity") }
assert_eq(activity.dialect, Modern)
let printed = @plantuml.print(document)
assert_eq(printed.diagnostics, [])
let model = @plantuml.to_diagram(document)
assert_eq(model.diagnostics, [])
guard model.diagram is Some(Flowchart(flowchart)) else {
fail("Expected flowchart")
}
assert_eq(flowchart.nodes.length(), 4)
assert_true(flowchart.nodes[1].id != flowchart.nodes[2].id)
}
An explicit activity body needs a dialect. This keeps generic UML block selection separate from body parsing.
///|
test "legacy activity body requires an explicit dialect" {
let result = @plantuml.read_body(
Activity,
"(*) --> \"Read\" as R\nR --> (*)\n",
dialect=Legacy,
)
assert_eq(result.diagnostics, [])
let model = @plantuml.to_diagram(result.document.unwrap())
assert_true(model.diagram is Some(Flowchart(_)))
}
See the PlantUML flowchart guide for control syntax, ownership, notes, finite structural playback and independent compatibility evidence. Conditions and join specifications remain descriptive. The library does not execute workflows or load author-requested resources.
State blocks add Document::State and envelope.Kind::State. Ambiguous UML
arrows require read_body(State, body); explicit state declarations and scoped
pseudostates select state blocks. The dedicated state reader has no renderer
or host dependency. See the state guide.