///|
pub struct Limits {
source : Int32
output : Int32
states : Int32
regions : Int32
transitions : Int32
depth : Int32
diagnostics : Int32
work : Int32
} derive(Eq, Debug)
///|
pub fn Limits::new(
source~ : Int32 = 16777216,
output~ : Int32 = 16777216,
states~ : Int32 = 10000,
regions~ : Int32 = 10000,
transitions~ : Int32 = 50000,
depth~ : Int32 = 256,
diagnostics~ : Int32 = 256,
work~ : Int32 = 1000000,
) -> Limits {
{ source, output, states, regions, transitions, depth, diagnostics, work, }
}
///|
pub struct ReadResult {
tree : @cst.Node
ast : @ast.Diagram?
diagnostics : ReadOnlyArray[@diagrams.Diagnostic]
} derive(Eq, Debug)
///|
pub fn trim(s : String) -> String {
let mut a = 0
let mut b = s.length()
while a < b && [9, 10, 13, 32].contains(s.code_unit_at(a).to_int()) {
a += 1
}
while b > a && [9, 10, 13, 32].contains(s.code_unit_at(b - 1).to_int()) {
b -= 1
}
s[a:b].to_owned()
}
///|
/// Mermaid state identifiers are opaque tokens, not executable names.
pub fn valid_id(s : String) -> Bool {
s != "" &&
s != "[*]" &&
!s.iter().any(c => [9, 10, 13, 32, 34, 58, 123, 125, 45].contains(c.to_int())) &&
!s.contains("%%")
}
///|
fn endpoint(s : String) -> (String, ReadOnlyArray[String])? {
let parts = s.split(":::").map(p => trim(p.to_owned())).to_array()
if parts.length() > 2 || (parts[0] != "[*]" && !valid_id(parts[0])) {
return None
}
if parts.length() == 2 && !valid_id(parts[1]) {
return None
}
Some(
(
parts[0],
if parts.length() == 2 {
ReadOnlyArray::from_array([parts[1]])
} else {
[]
},
),
)
}
///|
fn outside(s : String, needle : String) -> Int32? {
let mut i = 0
let mut quoted = false
while i < s.length() {
if s.code_unit_at(i).to_int() == 34 {
quoted = !quoted
}
if !quoted && s[i:].has_prefix(needle) {
return Some(i)
}
i += 1
}
None
}
///|
fn description_colon(s : String) -> Int32? {
let mut i = 0
while i < s.length() {
if s[i:].has_prefix(":::") {
i += 3
continue
}
if s.code_unit_at(i).to_int() == 58 {
return Some(i)
}
i += 1
}
None
}
///|
fn declaration(s : String, composite : Bool) -> @ast.StatementKind? {
let mut text = trim(s)
let mut label : String? = None
if text.has_prefix("\"") {
guard text[1:].find("\"") is Some(close) else { return None }
label = Some(text[1:close + 1].to_owned())
let rest = trim(text[close + 2:].to_owned())
guard rest.to_lower().has_prefix("as ") ||
rest.to_lower().has_prefix("as\t") else {
return None
}
text = trim(rest[2:].to_owned())
}
let mut description : String? = None
if label != None && description_colon(text) is Some(colon) {
description = Some(text[colon + 1:].to_owned())
text = trim(text[:colon].to_owned())
}
let mut role : String? = None
for r in ["choice", "fork", "join"] {
for suffix in ["<<" + r + ">>", "[[" + r + "]]"] {
if text.to_lower().has_suffix(suffix) {
role = Some(r)
text = trim(text[:text.length() - suffix.length()].to_owned())
}
}
}
guard endpoint(text) is Some((id, classes)) else { return None }
if id == "[*]" || (composite && role != None) {
return None
}
if !composite && label == None && role == None {
return Some(Presentation("state " + s))
}
if composite {
Some(@ast.StatementKind::Composite(id~, label~))
} else {
Some(@ast.StatementKind::State(id~, label~, role~, description~, classes~))
}
}
///|
fn metadata_prefix(text : String, word : String) -> Bool {
let lower = text.to_lower()
if !lower.has_prefix(word) {
return false
}
let suffix = trim(text[word.length():].to_owned())
suffix.has_prefix(":") || suffix.has_prefix("{")
}
///|
fn statement(s : String) -> @ast.StatementKind? {
let text = trim(s)
let lower = text.to_lower()
if text == "}" {
return Some(EndComposite)
}
if text == "--" {
return Some(Concurrent)
}
if lower.has_prefix("direction ") || lower.has_prefix("direction\t") {
let d = trim(text[9:].to_owned()).to_upper()
return if ["TB", "BT", "LR", "RL"].contains(d) {
Some(Direction(d))
} else {
None
}
}
if lower.has_prefix("state ") || lower.has_prefix("state\t") {
let composite = text.has_suffix("{")
let end = if composite { text.length() - 1 } else { text.length() }
return declaration(text[6:end].to_owned(), composite)
}
if metadata_prefix(text, "acctitle") || metadata_prefix(text, "accdescr") {
guard text.find(":") is Some(colon) else { return None }
return if metadata_prefix(text, "acctitle") {
Some(AccTitle(trim(text[colon + 1:].to_owned())))
} else {
Some(AccDescription(trim(text[colon + 1:].to_owned())))
}
}
if [
"classDef ", "classDef\t", "class ", "class\t", "style ", "style\t", "click ",
"click\t", "scale ", "hide empty description",
]
.iter()
.any(p => lower.has_prefix(p.to_lower())) {
return Some(Presentation(text))
}
if lower.has_prefix("note ") || lower.has_prefix("note\t") {
let rest = trim(text[4:].to_owned())
if rest.has_prefix("\"") {
guard rest[1:].find("\"") is Some(close) else { return None }
let suffix = trim(rest[close + 2:].to_owned())
guard suffix.to_lower().has_prefix("as ") else { return None }
let id = trim(suffix[3:].to_owned())
return if valid_id(id) {
Some(FloatingNote(id~, text=rest[1:close + 1].to_owned()))
} else {
None
}
}
let placement = if rest.to_lower().has_prefix("left of ") {
"left"
} else if rest.to_lower().has_prefix("right of ") {
"right"
} else {
return None
}
let suffix = trim(rest[if placement == "left" { 8 } else { 9 }:].to_owned())
guard suffix.find(":") is Some(colon) else { return None }
let target = trim(suffix[:colon].to_owned())
return if valid_id(target) {
Some(Note(target~, placement~, text=trim(suffix[colon + 1:].to_owned())))
} else {
None
}
}
if outside(text, "-->") is Some(arrow) {
let left = trim(text[:arrow].to_owned())
let right = trim(text[arrow + 3:].to_owned())
let colon = description_colon(right)
let target = trim(right[:colon.unwrap_or(right.length())].to_owned())
let label = colon.map(i => trim(right[i + 1:].to_owned()))
match (endpoint(left), endpoint(target)) {
(Some((a, ac)), Some((b, bc))) =>
return Some(
Transition(
source=a,
target=b,
label~,
source_classes=ac,
target_classes=bc,
),
)
_ => return None
}
}
if description_colon(text) is Some(colon) {
let id = trim(text[:colon].to_owned())
return if valid_id(id) {
Some(Description(id~, text=trim(text[colon + 1:].to_owned())))
} else {
None
}
}
endpoint(text).map(pair => {
@ast.StatementKind::State(
id=pair.0,
label=None,
role=None,
description=None,
classes=pair.1,
)
})
}
///|
pub fn read(source : String, limits~ : Limits = Limits::new()) -> ReadResult {
let tokens : Array[@cst.Token] = []
let statements : Array[@ast.Statement] = []
let diagnostics : Array[@diagrams.Diagnostic] = []
let directives = []
let mut front_matter : String? = None
let mut header : String? = None
let mut offset = 0
let mut depth = 0
let mut regions = 1
let mut states = 0
let mut transitions = 0
let mut fatal = false
let mut work = 0
let line_starts : Array[Int32] = [0]
fn position(offset : Int32) -> @diagrams.Position {
let mut low = 0
let mut high = line_starts.length()
while low + 1 < high {
work += 1
let middle = (low + high) / 2
if line_starts[middle] <= offset {
low = middle
} else {
high = middle
}
}
{ offset, line: low + 1, column: offset - line_starts[low] + 1, }
}
fn span(start : Int32, end : Int32) -> @diagrams.Span {
{ start: position(start), end: position(end), }
}
fn error(code : String, message : String, start : Int32, end : Int32) {
let d = @diagrams.Diagnostic::error(
"state/" + code,
message,
span=span(start, end),
)
if diagnostics.length() < limits.diagnostics.max(1) {
diagnostics.push(d)
} else {
diagnostics[diagnostics.length() - 1] = @diagrams.Diagnostic::error(
"state/diagnostic-limit", "Diagnostic budget exhausted",
)
fatal = true
}
}
if limits.source < 0 || source.length() > limits.source {
error("source-limit", "Source budget exhausted", 0, 0)
return {
tree: @cst.Node::new([{ kind: Error, text: source, offset: 0, }]),
ast: None,
diagnostics: ReadOnlyArray::from_array(diagnostics),
}
}
if limits.depth < 1 ||
limits.states < 1 ||
limits.regions < 1 ||
limits.transitions < 1 ||
limits.work < 1 ||
limits.diagnostics < 1 {
error("invalid-limits", "All processing limits must be positive", 0, 0)
return {
tree: @cst.Node::new([{ kind: Error, text: source, offset: 0, }]),
ast: None,
diagnostics: ReadOnlyArray::from_array(diagnostics),
}
}
if source.length() > limits.work {
error("work-limit", "Reader work budget exhausted", 0, 0)
return {
tree: @cst.Node::new([{ kind: Error, text: source, offset: 0, }]),
ast: None,
diagnostics: ReadOnlyArray::from_array(diagnostics),
}
}
work = source.length()
for i in 0.. tail.has_prefix(p.to_lower()))
while end < source.length() {
work += 1
if work > limits.work {
error("work-limit", "Reader work budget exhausted", start, end)
fatal = true
break
}
let n = source.code_unit_at(end).to_int()
if n == 10 {
if first_newline == None {
first_newline = Some(end)
}
if !quote {
break
}
}
if !plain && n == 34 {
quote = !quote
}
if !plain &&
!quote &&
n == 58 &&
!source[end:].has_prefix(":::") &&
(end == 0 || source.code_unit_at(end - 1).to_int() != 58) {
descriptive = true
}
if !plain && !descriptive && !quote && n == 123 {
end += 1
break
}
if !plain && !descriptive && !quote && n == 125 {
if end == begin {
end += 1
}
break
}
end += 1
}
if fatal {
break
}
if quote && end == source.length() {
if first_newline is Some(line) {
end = line
}
}
let mut text = trim(source[start:end].to_owned())
let lower = text.to_lower()
let mut kind : @cst.TokenKind = Statement
if text == "---" && header == None && front_matter == None {
let mut close = if end < source.length() { end + 1 } else { end }
let mut found = false
while close < source.length() {
let mut e = close
while e < source.length() && source.code_unit_at(e).to_int() != 10 {
e += 1
}
if trim(source[close:e].to_owned()) == "---" {
end = e
found = true
break
}
close = e + 1
}
if found {
front_matter = Some(source[start:end].to_owned())
kind = FrontMatter
} else {
error("syntax", "Unterminated front matter", start, end)
kind = Error
}
} else if text.has_prefix("%%{") {
match source[start:].find("}%%") {
Some(i) => {
end = start + i + 3
directives.push(source[start:end].to_owned())
kind = Directive
}
None => {
error("syntax", "Unterminated directive", start, end)
kind = Error
}
}
} else if text == "" {
kind = Whitespace
} else if text.has_prefix("%%") || text.has_prefix("#") {
kind = Comment
statements.push({ kind: Comment(text), span: span(start, end), })
} else if header == None {
let candidate = ["stateDiagram-v2", "stateDiagram"]
.iter()
.find_first(h => {
text == h || text.has_prefix(h + " ") || text.has_prefix(h + "\t")
})
if candidate is Some(h) {
header = Some(h)
kind = Header
let suffix = trim(text[h.length():].to_owned())
if suffix != "" {
// Return the suffix to the statement scanner without dropping bytes.
end = start + source[start:end].find(h).unwrap() + h.length()
}
} else {
error("header", "Expected stateDiagram or stateDiagram-v2", start, end)
kind = Error
}
} else {
if !lower.has_prefix("note ") && outside(text, "%%") is Some(comment) {
text = trim(text[:comment].to_owned())
}
if lower.has_prefix("note ") &&
!text.contains(":") &&
!lower.has_prefix("note \"") {
let mut close = if end < source.length() { end + 1 } else { end }
let body_start = close
let mut found = false
while close < source.length() {
let mut e = close
while e < source.length() && source.code_unit_at(e).to_int() != 10 {
e += 1
}
if trim(source[close:e].to_owned()).to_lower() == "end note" {
text += ": " + trim(source[body_start:close].to_owned())
end = e
found = true
break
}
close = e + 1
}
if !found {
error("syntax", "Unterminated note", start, end)
kind = Error
}
}
if metadata_prefix(text, "accdescr") && text.contains("{") {
let open = start + source[start:end].find("{").unwrap()
match source[open + 1:].find("}") {
Some(i) => {
end = open + 1 + i + 1
text = "accDescr: " + trim(source[open + 1:end - 1].to_owned())
}
None => {
error(
"syntax", "Unterminated accessibility description", start, end,
)
kind = Error
}
}
}
if kind != Error {
match statement(text) {
Some(value) => {
match value {
Composite(..) => {
depth += 1
regions += 1
states += 1
}
EndComposite =>
if depth == 0 {
error("syntax", "Unexpected closing composite", start, end)
kind = Error
} else {
depth -= 1
}
Concurrent => {
if depth == 0 {
error(
"syntax", "Concurrent separators require a composite", start,
end,
)
kind = Error
}
regions += 1
}
State(..) | Description(..) => states += 1
Transition(..) => transitions += 1
_ => ()
}
if kind != Error {
statements.push({ kind: value, span: span(start, end), })
}
}
None => {
error("syntax", "Invalid state statement", start, end)
kind = Error
}
}
}
}
if kind == Error {
statements.push({ kind: Invalid(text), span: span(start, end), })
}
if depth > limits.depth {
error("depth-limit", "Composite nesting budget exhausted", start, end)
fatal = true
}
if states > limits.states {
error("state-limit", "State statement budget exhausted", start, end)
fatal = true
}
if regions > limits.regions {
error("region-limit", "Region budget exhausted", start, end)
fatal = true
}
if transitions > limits.transitions {
error("transition-limit", "Transition budget exhausted", start, end)
fatal = true
}
work += (end - start).max(0)
if work > limits.work {
error("work-limit", "Reader work budget exhausted", start, end)
fatal = true
}
offset = if end < source.length() && source.code_unit_at(end).to_int() == 10 {
end + 1
} else {
end
}
tokens.push({ kind, text: source[start:offset].to_owned(), offset: start, })
}
if offset < source.length() {
tokens.push({ kind: Error, text: source[offset:].to_owned(), offset, })
}
if header == None {
error("header", "Missing state header", 0, 0)
}
if depth != 0 {
error("syntax", "Unclosed composite", source.length(), source.length())
statements.push({
kind: Invalid("unclosed composite"),
span: span(source.length(), source.length()),
})
}
{
tree: @cst.Node::new(tokens),
ast: if fatal || header == None {
None
} else {
Some(
@ast.Diagram::new(
statements,
header=header.unwrap(),
front_matter?,
directives~,
),
)
},
diagnostics: ReadOnlyArray::from_array(diagnostics),
}
}
///|
pub let diagnostic_codes : ReadOnlyArray[String] = [
"state/syntax", "state/header", "state/source-limit", "state/output-limit", "state/state-limit",
"state/region-limit", "state/transition-limit", "state/depth-limit", "state/work-limit",
"state/diagnostic-limit", "state/invalid-limits", "state/invalid-ast",
]
///|
pub extend Limits with Eq::{equal, not_equal}
///|
pub extend Limits with @debug.Debug::{to_repr}
///|
pub extend ReadResult with Eq::{equal, not_equal}
///|
pub extend ReadResult with @debug.Debug::{to_repr}