///|
pub struct Conversion {
flowchart : @model.Flowchart?
diagnostics : ReadOnlyArray[@diagrams.Diagnostic]
}
///|
priv struct Pending {
id : String
label : String
role : @model.ConnectionRole
}
///|
priv struct Control {
kind : String
entry : String
exit : String
region : String
parent : String?
tails : Array[Pending]
branches : Array[@model.Branch]
members : Array[String]
mut condition : String
mut branch_condition : String
mut branch_at : Int32
mut branch_label : String
mut last_decision : String
mut has_else : Bool
mut backward : String?
origin : @model.SourceBinding
}
///|
fn arrow_hint(arrow : String) -> @model.Direction? {
let word = StringBuilder()
let mut bracket = false
for ch in arrow {
if ch == '[' {
bracket = true
} else if ch == ']' {
bracket = false
} else if !bracket && ch >= 'a' && ch <= 'z' {
word.write_char(ch)
}
}
match word.to_string() {
"l" | "le" | "left" => Some(RL)
"r" | "ri" | "right" => Some(LR)
"u" | "up" => Some(BT)
"d" | "do" | "down" => Some(TB)
_ => if arrow == "->" { Some(LR) } else { None }
}
}
///|
/// Convert native syntax to explicit structural topology. Expressions remain
/// descriptive; pending continuation sets never execute source conditions.
pub fn to_flowchart(
document : Document,
id~ : String = "activity",
limits~ : @envelope.Limits = @envelope.Limits::new(),
) -> Conversion {
let nodes : Array[@model.Node] = []
let edges : Array[@model.Connection] = []
let groups : Array[@model.Group] = []
let lanes : Array[@model.Lane] = []
let notes : Array[@model.Note] = []
let regions : Array[@model.Region] = []
let diagnostics : Array[@diagrams.Diagnostic] = []
let pending : Array[Pending] = []
let controls : Array[Control] = []
let group_path : Array[String] = []
let lane_ids : Map[String, Bool] = Map([])
let used : Map[String, Bool] = Map([])
let named : Map[String, String] = Map([])
let legacy_actions : Map[String, String] = Map([])
let unresolved : Array[(String, String, @model.SourceBinding)] = []
let mut lane : String? = None
let mut work = 0
let mut failed = false
let mut serial = 0
let mut last : String? = None
let mut span : @diagrams.Span? = None
let mut origin = @model.SourceBinding::new("plantuml", "0")
let mut next_label = ""
let mut next_style : @model.LineStyle = Solid
let mut next_direction : @model.Direction? = None
fn error(code : String, message : String) {
failed = true
if diagnostics.length() < limits.diagnostics.max(1) {
diagnostics.push(@diagrams.Diagnostic::error(code, message, span?))
}
}
fn charge(n : Int32) -> Bool {
if n < 0 || n > limits.work - work {
error("activity/limit", "Activity conversion work limit exceeded")
false
} else {
work += n
true
}
}
fn fresh(prefix : String) -> String {
let mut value = "__activity_" + prefix + serial.to_string()
serial += 1
while used.contains(value) {
value = "__activity_" + prefix + serial.to_string()
serial += 1
}
used[value] = true
value
}
// Reserve every authored name before generating IDs, including forward labels.
for statement in document.items {
match statement.kind {
Label(name) | Goto(name) => used[name] = true
Edge(from, _, target, _) => {
fn reserve(value : Endpoint) {
match value {
Alias(name) | Synchronization(name) => used[name] = true
Action(_, Some(name), _) => used[name] = true
_ => ()
}
}
if from is Some(value) {
reserve(value)
}
reserve(target)
}
_ => ()
}
}
fn connect(
source : String,
target : String,
label : String,
role : @model.ConnectionRole,
) {
if edges.length() >= limits.edges ||
!charge(source.length() + target.length() + label.length() + 1) {
error("activity/limit", "Activity connection limit exceeded")
return
}
edges.push(
@model.Connection::new(
fresh("edge"),
source,
target,
label~,
style=next_style,
role~,
direction_hint?=next_direction,
origin~,
),
)
}
fn link_to(target : String) {
for value in pending {
connect(
value.id,
target,
if next_label.is_empty() {
value.label
} else {
next_label
},
value.role,
)
}
pending.clear()
next_label = ""
next_style = Solid
}
fn current(value : String) {
pending.clear()
pending.push({ id: value, label: "", role: Flow, })
last = Some(value)
}
fn node(
label : String,
role : @model.Role,
shape : String,
connect_in : Bool,
) -> String {
if label.contains("=
limits.nodes ||
!charge(label.length() + 1) {
error("activity/limit", "Activity node limit exceeded")
return key
}
let region_ids = controls.iter().map(c => c.region).collect()
nodes.push(
@model.Node::new(
key,
label=label.replace_all(old="\\n", new="\n"),
role~,
shape~,
lane?,
groups=group_path[:],
regions=region_ids[:],
origin~,
),
)
for control in controls {
control.members.push(key)
}
if connect_in {
link_to(key)
current(key)
}
key
}
fn branch(control : Control) {
let members = control.members[control.branch_at:]
control.branches.push(
@model.Branch::new(
fresh("branch"),
label=control.branch_label,
members~,
condition?=if control.branch_condition.is_empty() {
None
} else {
Some(control.branch_condition)
},
origin=control.origin,
),
)
control.branch_at = control.members.length()
}
fn open(
kind : String,
entry : String,
exit : String,
condition : String,
label : String,
) {
if controls.length() >= limits.depth {
error("activity/limit", "Activity control nesting limit exceeded")
return
}
let parent = controls.last().map(c => c.region)
controls.push({
kind,
entry,
exit,
region: fresh("region"),
parent,
tails: [],
branches: [],
members: [],
condition,
branch_condition: condition,
branch_at: 0,
branch_label: label,
last_decision: entry,
has_else: false,
backward: None,
origin,
})
}
fn close_region(
control : Control,
kind : @model.RegionKind,
join_specification : String?,
) {
regions.push(
@model.Region::new(
control.region,
kind,
parent?=control.parent,
entry=control.entry,
exit=control.exit,
members=control.members[:],
branches=control.branches[:],
condition=control.condition,
join_specification?,
origin=control.origin,
),
)
}
fn save_tails(control : Control) {
for value in pending {
control.tails.push(value)
}
branch(control)
pending.clear()
}
fn close_conditional() {
guard controls.pop() is Some(control) else {
error("activity/invalid-ast", "Conditional close without open")
return
}
if control.kind != "if" &&
control.kind != "switch" &&
control.kind != "legacy-if" {
error("activity/invalid-ast", "Mismatched conditional close")
return
}
branch(control)
if control.kind == "legacy-if" {
close_region(control, Conditional, None)
return
}
for value in pending {
control.tails.push(value)
}
pending.clear()
if !control.has_else {
control.tails.push({ id: control.last_decision, label: "", role: Branch, })
}
for value in control.tails {
connect(value.id, control.exit, value.label, value.role)
}
current(control.exit)
close_region(control, Conditional, None)
}
fn legacy_endpoint(value : Endpoint, target : Bool) -> String {
match value {
Start(top) => {
let code = if target && !nodes.is_empty() {
"__legacy_end"
} else {
"__legacy_start"
}
if named.get(code) is Some(key) {
key
} else {
let key = node(
"",
if target && !nodes.is_empty() {
ActivityFinal
} else {
Start
},
if target && !nodes.is_empty() {
"fr-circ"
} else {
"sm-circ"
},
false,
)
named[code] = key
ignore(top)
key
}
}
Final => {
let key = node("", ActivityFinal, "fr-circ", false)
key
}
Action(label, code, decoration) => {
if !decoration.is_empty() {
diagnostics.push(
@diagrams.Diagnostic::warning(
"activity/unapplied-presentation",
"Legacy action presentation is retained as source data",
span?,
),
)
}
let identity = code.unwrap_or(label)
match legacy_actions.get(identity) {
Some(key) => key
None => {
let key = node(label, Action, "rounded", false)
legacy_actions[identity] = key
if code is Some(code) {
named[code] = key
}
key
}
}
}
Alias(code) =>
match
(if named.contains(code) {
named.get(code)
} else {
legacy_actions.get(code)
}) {
Some(key) => key
None => {
let key = node(code, Action, "rounded", false)
named[code] = key
key
}
}
Synchronization(code) =>
match named.get("bar:" + code) {
Some(key) => key
None => {
let key = node("", Join, "fork", false)
named["bar:" + code] = key
key
}
}
}
}
if !limits.valid() {
error("plantuml/invalid-limits", "Activity limits must be positive")
}
for index, statement in document.items {
if failed || !charge(1) {
break
}
span = statement.span
origin = @model.SourceBinding::new("plantuml", index.to_string(), span?)
match statement.kind {
ActionList(label, depth) =>
ignore(
node(
(if depth > 1 { "*".repeat(depth - 1) + " " } else { "" }) + label,
Action,
"rounded",
true,
),
)
LegacyDecision(from, arrow, condition, label) => {
let source = match from {
Some(value) => Some(legacy_endpoint(value, false))
None => last
}
let entry = node(condition, Decision, "diamond", false)
if source is Some(source) {
connect(source, entry, label, Branch)
}
ignore(arrow)
open("legacy-if", entry, entry, condition, "")
current(entry)
}
Action(label, terminator, decoration) => {
let shape = match terminator {
"]" => "rect"
"/" => "lean-r"
"<" | ">" => "odd"
"|" => "fork"
"}" => "diamond"
_ => "rounded"
}
let key = node(label, Action, shape, true)
if decoration.find("[[") is Some(start) &&
decoration.find("]]") is Some(end) &&
end > start + 2 {
let link = part(decoration, start + 2, end)
.split(" ")
.next()
.unwrap_or("")
.to_owned()
for i, value in nodes {
if value.id == key {
nodes[i] = @model.Node::new(
value.id,
label=value.label,
label_kind=value.label_kind,
shape=value.shape,
metadata=value.metadata[:],
lane?=value.lane,
groups=value.groups[:],
properties=@model.RenderProperties::new(link~),
role=value.role,
regions=value.regions[:],
origin?=value.origin,
)
}
}
}
if !decoration.is_empty() {
diagnostics.push(
@diagrams.Diagnostic::warning(
"activity/unapplied-presentation",
"Action decoration is retained as source data",
span?,
),
)
}
}
Start => ignore(node("", Start, "sm-circ", true))
Stop | End => {
ignore(
node(
"",
if statement.kind == Stop {
ActivityFinal
} else {
FlowFinal
},
if statement.kind == Stop {
"fr-circ"
} else {
"cross-circ"
},
true,
),
)
pending.clear()
}
If(condition, label) => {
let entry = node(condition, Decision, "diamond", true)
let exit = node("", Merge, "diamond", false)
open("if", entry, exit, condition, label)
pending.clear()
pending.push({ id: entry, label, role: Branch, })
}
ElseIf(condition, label) | ElseIfIncoming(_, condition, label) => {
guard controls.last() is Some(control) else {
error("activity/invalid-ast", "elseif without if")
continue
}
save_tails(control)
let incoming = match statement.kind {
ElseIfIncoming(value, _, _) => value
_ => ""
}
pending.push({
id: control.last_decision,
label: incoming,
role: Branch,
})
let decision = node(condition, Decision, "diamond", true)
control.last_decision = decision
control.branch_label = label
control.branch_condition = condition
pending.clear()
pending.push({ id: decision, label, role: Branch, })
}
Else(label) => {
guard controls.last() is Some(control) else {
error("activity/invalid-ast", "else without if")
continue
}
save_tails(control)
control.branch_label = label
control.branch_condition = ""
control.has_else = true
pending.push({ id: control.last_decision, label, role: Branch, })
if control.kind == "legacy-if" {
last = Some(control.last_decision)
}
}
EndIf | EndSwitch => close_conditional()
Switch(condition) => {
let entry = node(condition, Decision, "diamond", true)
let exit = node("", Merge, "diamond", false)
open("switch", entry, exit, condition, "")
if controls.last() is Some(control) {
control.has_else = true
}
pending.clear()
}
Case(label) => {
guard controls.last() is Some(control) else {
error("activity/invalid-ast", "case without switch")
continue
}
if !control.members.is_empty() || !pending.is_empty() {
save_tails(control)
}
control.branch_label = label
control.branch_condition = label
pending.push({ id: control.entry, label, role: Branch, })
}
While(condition, label) => {
let entry = node(condition, Decision, "diamond", true)
let exit = node("", Merge, "anchor", false)
open("while", entry, exit, condition, label)
pending.clear()
pending.push({ id: entry, label, role: Branch, })
}
EndWhile(label) => {
guard controls.last() is Some(control) else {
error("activity/invalid-ast", "endwhile without while")
continue
}
if control.kind != "while" {
error("activity/invalid-ast", "Mismatched loop end")
continue
}
if control.backward is Some(text) {
ignore(node(text, Action, "rounded", true))
}
for value in pending {
connect(value.id, control.entry, value.label, LoopBack)
}
connect(control.entry, control.exit, label, LoopExit)
branch(control)
ignore(controls.pop())
current(control.exit)
close_region(control, Loop, None)
}
Repeat(action) => {
let label = if action.has_prefix(":") {
after(action, ":").trim_end(chars=";").to_owned()
} else {
action
}
let entry = node(
label,
if label.is_empty() {
Connector
} else {
Action
},
if label.is_empty() {
"anchor"
} else {
"rounded"
},
true,
)
let exit = node("", Merge, "anchor", false)
open("repeat", entry, exit, "", "")
}
RepeatWhile(condition, yes, no) => {
guard controls.last() is Some(control) else {
error("activity/invalid-ast", "repeat while without repeat")
continue
}
if control.kind != "repeat" {
error("activity/invalid-ast", "Mismatched repeat end")
continue
}
control.condition = condition
control.branch_condition = condition
let decision = node(condition, Decision, "diamond", true)
if control.backward is Some(text) {
pending.clear()
pending.push({ id: decision, label: yes, role: LoopBack, })
let back = node(text, Action, "rounded", true)
connect(back, control.entry, "", LoopBack)
} else {
connect(decision, control.entry, yes, LoopBack)
}
connect(decision, control.exit, no, LoopExit)
branch(control)
ignore(controls.pop())
current(control.exit)
close_region(control, Loop, None)
}
Backward(text) => {
guard controls.last() is Some(control) else {
error("activity/invalid-ast", "backward outside a loop")
continue
}
control.backward = Some(text)
}
Break => {
let mut found = false
for i = controls.length() - 1; i >= 0; i = i - 1 {
let control = controls[i]
if control.kind == "repeat" || control.kind == "while" {
for value in pending {
connect(value.id, control.exit, value.label, LoopExit)
}
pending.clear()
found = true
break
}
}
if !found {
error("activity/invalid-ast", "break outside a loop")
}
}
Fork | Split => {
let entry = node(
"",
Fork,
if statement.kind == Fork {
"fork"
} else {
"anchor"
},
true,
)
let exit = node("", Join, "fork", false)
open(
if statement.kind == Fork {
"fork"
} else {
"split"
},
entry,
exit,
"",
"",
)
current(entry)
}
ForkAgain | SplitAgain => {
guard controls.last() is Some(control) else {
error("activity/invalid-ast", "Parallel branch outside a region")
continue
}
save_tails(control)
current(control.entry)
}
EndFork(merge, label) => {
guard controls.pop() is Some(control) else {
error("activity/invalid-ast", "end fork without fork")
continue
}
if control.kind != "fork" {
error("activity/invalid-ast", "Mismatched fork end")
continue
}
save_tails(control)
for value in control.tails {
connect(value.id, control.exit, value.label, Parallel)
}
if merge {
for i, node in nodes {
if node.id == control.exit {
nodes[i] = @model.Node::new(
node.id,
label=node.label,
label_kind=node.label_kind,
shape="diamond",
metadata=node.metadata[:],
lane?=node.lane,
groups=node.groups[:],
properties?=node.properties,
role=Merge,
regions=node.regions[:],
origin?=node.origin,
)
}
}
}
current(control.exit)
close_region(
control,
Parallel,
if label.is_empty() {
None
} else {
Some(label)
},
)
}
EndSplit => {
guard controls.pop() is Some(control) else {
error("activity/invalid-ast", "end split without split")
continue
}
if control.kind != "split" {
error("activity/invalid-ast", "Mismatched split end")
continue
}
save_tails(control)
for value in control.tails {
connect(value.id, control.exit, value.label, Parallel)
}
current(control.exit)
close_region(control, Split, None)
}
Kill | Detach => {
if last is Some(key) {
for i, value in nodes {
if value.id == key {
let metadata = value.metadata.iter().to_array()
metadata.push(
(
"plantuml/termination",
if statement.kind == Kill {
"kill"
} else {
"detach"
},
),
)
nodes[i] = @model.Node::new(
value.id,
label=value.label,
label_kind=value.label_kind,
shape=value.shape,
metadata=metadata[:],
lane?=value.lane,
groups=value.groups[:],
properties?=value.properties,
role=value.role,
regions=value.regions[:],
origin?=value.origin,
)
}
}
}
pending.clear()
}
Connector(label) => {
let key = match named.get("connector:" + label) {
Some(key) => {
link_to(key)
current(key)
key
}
None => {
let key = node(label, Connector, "circle", true)
named["connector:" + label] = key
key
}
}
ignore(key)
}
Label(name) =>
if named.contains("label:" + name) {
error("activity/invalid-ast", "Duplicate label " + name)
} else {
let key = node("", Connector, "anchor", true)
named["label:" + name] = key
}
Goto(name) => {
for value in pending {
unresolved.push((value.id, name, origin))
}
pending.clear()
}
Group(_, label, before, decoration) => {
if !before.is_empty() || !decoration.is_empty() {
diagnostics.push(
@diagrams.Diagnostic::warning(
"activity/unapplied-presentation",
"Group presentation is retained as source data",
span?,
),
)
}
let key = fresh("group")
groups.push(@model.Group::new(key, label~, parent?=group_path.last()))
group_path.push(key)
}
EndGroup =>
if group_path.pop() == None {
error("activity/invalid-ast", "Group end without group")
}
Lane(code, label, color) => {
if !lane_ids.contains(code) {
if used.contains(code) {
error("activity/invalid-ast", "Lane identity collision " + code)
}
used[code] = true
lane_ids[code] = true
lanes.push(@model.Lane::new(code, label~))
}
lane = Some(code)
if !color.is_empty() {
diagnostics.push(
@diagrams.Diagnostic::warning(
"activity/unapplied-presentation",
"Lane color is retained as source data",
span?,
),
)
}
}
Note(placement, text, code, _) => {
if text.contains(" fresh("note"))
let targets = last.iter().collect()
notes.push(
@model.Note::new(
key,
text,
targets=targets[:],
placement~,
groups=group_path[:],
lane?,
origin~,
),
)
}
NoteOnLink(text, _) => {
let targets = edges.last().map(e => e.id).iter().to_array()
if targets.is_empty() {
error(
"activity/invalid-ast", "A note on link needs a preceding connection",
)
}
notes.push(
@model.Note::new(
fresh("note"),
text,
connection_targets=targets[:],
groups=group_path[:],
lane?,
origin~,
),
)
}
Arrow(style, text) => {
if text.contains("#") {
diagnostics.push(
@diagrams.Diagnostic::warning(
"activity/unapplied-presentation",
"Arrow color is retained as source data",
span?,
),
)
}
next_style = match style {
"invisible" => Invisible
"dotted" => Dotted
"thick" => Thick
_ => Solid
}
let cleaned = text.trim_end(chars=";").to_owned()
if cleaned.find(">") is Some(at) {
next_label = trim(part(cleaned, at + 1, cleaned.length()))
}
}
Edge(from, arrow, target, label) => {
if arrow.contains("#") {
diagnostics.push(
@diagrams.Diagnostic::warning(
"activity/unapplied-presentation",
"Arrow color is retained as source data",
span?,
),
)
}
let source = match from {
Some(value) => Some(legacy_endpoint(value, false))
None => last
}
let target = legacy_endpoint(target, true)
next_direction = arrow_hint(arrow)
if !arrow.is_empty() && source is Some(source) {
next_style = if arrow.contains("hidden") {
Invisible
} else if arrow.contains(".") {
Dotted
} else {
Solid
}
connect(source, target, label, Flow)
next_style = Solid
next_direction = None
} else if !arrow.is_empty() {
error("activity/invalid-ast", "Legacy arrow has no previous activity")
}
current(target)
}
LegacyIf(condition) => {
let entry = node(condition, Decision, "diamond", true)
open("legacy-if", entry, entry, condition, "")
current(entry)
}
Presentation(_) =>
diagnostics.push(
@diagrams.Diagnostic::warning(
"activity/unapplied-presentation",
"Presentation data is retained but not applied",
span?,
),
)
}
}
for (source, name, binding) in unresolved {
origin = binding
match named.get("label:" + name) {
Some(target) => connect(source, target, "", Flow)
None => error("activity/unresolved-label", "Unknown label " + name)
}
}
if !controls.is_empty() || !group_path.is_empty() {
error("activity/unclosed-control", "Unclosed activity control or group")
}
let title = document.statements
.iter()
.filter_map(s => {
match s {
@envelope.Title(value) => Some(value)
_ => None
}
})
.next()
let model = @model.Flowchart::new(
id,
nodes[:],
edges[:],
groups=groups[:],
lanes=lanes[:],
notes=notes[:],
regions=regions[:],
title?,
)
if !failed {
let findings = @model.validate(
model,
nodes_limit=limits.nodes,
edges_limit=limits.edges,
depth_limit=limits.depth,
work_limit=(limits.work - work).max(0),
)
for finding in findings {
diagnostics.push(finding)
failed = true
}
}
{
flowchart: if failed {
None
} else {
Some(model)
},
diagnostics: ReadOnlyArray::from_array(diagnostics),
}
}