///|
/// The four supported native diagram kinds.
pub(all) enum Kind {
Regex
Ebnf
Json
Yaml
Activity
} derive(Eq, Debug)
///|
pub extend Kind with Eq::{not_equal, equal}
///|
pub extend Kind with @debug.Debug::{to_repr}
///|
/// The marker word of a kind, as in `@start`.
pub fn Kind::word(self : Kind) -> String {
match self {
Regex => "regex"
Ebnf => "ebnf"
Json => "json"
Yaml => "yaml"
Activity => "uml"
}
}
///|
pub fn kind_of_word(word : String) -> Kind? {
match word {
"regex" => Some(Regex)
"ebnf" => Some(Ebnf)
"json" => Some(Json)
"yaml" => Some(Yaml)
"uml" => Some(Activity)
_ => None
}
}
///|
/// Explicit positive bounds for reading, writing and conversion.
pub struct Limits {
source : Int32
output : Int32
depth : Int32
nodes : Int32
edges : Int32
work : Int32
diagnostics : Int32
} derive(Eq, Debug)
///|
pub extend Limits with Eq::{not_equal, equal}
///|
pub extend Limits with @debug.Debug::{to_repr}
///|
pub fn Limits::new(
source~ : Int32 = 1048576,
output~ : Int32 = 4194304,
depth~ : Int32 = 256,
nodes~ : Int32 = 100000,
edges~ : Int32 = 100000,
work~ : Int32 = 4000000,
diagnostics~ : Int32 = 64,
) -> Limits {
{ source, output, depth, nodes, edges, work, diagnostics, }
}
///|
pub fn Limits::valid(self : Limits) -> Bool {
self.source > 0 &&
self.output > 0 &&
self.depth > 0 &&
self.nodes > 0 &&
self.edges > 0 &&
self.work > 0 &&
self.diagnostics > 0
}
///|
/// One retained presentation or metadata statement. Library themes and
/// options paint the diagram; these values are inert author data.
pub(all) enum Statement {
Title(String)
Caption(String)
Header(String)
Footer(String)
Legend(ReadOnlyArray[String])
/// The text after `skinparam`, or the lines of a `skinparam {}` block.
Skinparam(ReadOnlyArray[String])
/// The lines inside a `")
out
}
Skin(text) => ["skin " + text]
Scale(text) => ["scale " + text]
Hide(text) => [text]
Pragma(text) => ["!pragma " + text]
Option(text) => ["!option " + text]
}
}
///|
/// The role of one source line inside the block.
pub(all) enum Role {
/// A line for the kind reader.
Body
/// A line that belongs to a shared statement, comment or directive node.
/// Only the first line of a multi-line statement carries the node.
Statement(@cst.Node)
/// A continuation line of a multi-line statement.
Continuation
} derive(Eq, Debug)
///|
pub extend Role with Eq::{not_equal, equal}
///|
pub extend Role with @debug.Debug::{to_repr}
///|
/// One source line between the block markers, without its line break.
pub struct Line {
number : Int32
offset : Int32
text : String
newline : String
role : Role
} derive(Eq, Debug)
///|
pub extend Line with Eq::{not_equal, equal}
///|
pub extend Line with @debug.Debug::{to_repr}
///|
/// The tokens of a line as one text token plus its line break.
pub fn Line::tokens(self : Line, kind : @cst.TokenKind) -> Array[@cst.Element] {
let out : Array[@cst.Element] = []
if !self.text.is_empty() {
out.push(Token(@cst.Token::new(kind, self.text, self.offset)))
}
if !self.newline.is_empty() {
out.push(
Token(
@cst.Token::new(Newline, self.newline, self.offset + self.text.length()),
),
)
}
out
}
///|
/// The scanned envelope of one native block.
pub struct Scan {
source : String
kind : Kind?
name : String?
/// Nodes before the body: leading trivia and the start marker.
head : ReadOnlyArray[@cst.Node]
lines : ReadOnlyArray[Line]
/// Nodes after the body: the end marker and trailing trivia or text.
tail : ReadOnlyArray[@cst.Node]
statements : ReadOnlyArray[Statement]
diagnostics : ReadOnlyArray[@diagrams.Diagnostic]
lines_index : @cst.Lines
}
///|
/// The stable diagnostic codes of envelope scanning.
pub let diagnostic_codes : ReadOnlyArray[String] = [
"plantuml/source-limit", "plantuml/invalid-limits", "plantuml/missing-start-marker",
"plantuml/unsupported-diagram", "plantuml/missing-end-marker", "plantuml/wrong-end-marker",
"plantuml/multiple-blocks", "plantuml/text-outside-block", "plantuml/unsupported-directive",
"plantuml/unsupported-preprocessing", "plantuml/unsupported-statement", "plantuml/unclosed-block",
"plantuml/unapplied-presentation", "plantuml/diagnostic-limit",
]
///|
priv struct Raw {
offset : Int32
text : String
newline : String
}
///|
fn split_lines(source : String) -> Array[Raw] {
let out = []
let mut start = 0
let mut i = 0
let n = source.length()
while i < n {
let c = source.code_unit_at(i).to_int()
if c == 10 || c == 13 {
let width = if c == 13 &&
i + 1 < n &&
source.code_unit_at(i + 1).to_int() == 10 {
2
} else {
1
}
out.push({
offset: start,
text: @cst.slice(source, start, i),
newline: @cst.slice(source, i, i + width),
})
i += width
start = i
} else {
i += 1
}
}
if start < n || n == 0 {
out.push({ offset: start, text: @cst.slice(source, start, n), newline: "", })
}
out
}
///|
fn is_space_unit(c : Int32) -> Bool {
c == 32 || c == 9 || c == 0xfeff
}
///|
/// `text` without leading and trailing code units up to U+0020, as Java
/// `String.trim` removes them.
pub fn java_trim(text : String) -> String {
let mut a = 0
let mut b = text.length()
while a < b && text.code_unit_at(a).to_int() <= 32 {
a += 1
}
while b > a && text.code_unit_at(b - 1).to_int() <= 32 {
b -= 1
}
@cst.slice(text, a, b)
}
///|
/// `text` without leading and trailing spaces, tabs and byte-order marks.
pub fn trim(text : String) -> String {
let mut a = 0
let mut b = text.length()
while a < b && is_space_unit(text.code_unit_at(a).to_int()) {
a += 1
}
while b > a && is_space_unit(text.code_unit_at(b - 1).to_int()) {
b -= 1
}
@cst.slice(text, a, b)
}
///|
fn has_word(text : String, word : String) -> Bool {
text == word ||
(
text.has_prefix(word) &&
is_space_unit(text.code_unit_at(word.length()).to_int())
)
}
///|
fn after_word(text : String, word : String) -> String {
trim(@cst.slice(text, word.length(), text.length()))
}
///|
/// True when `text` calls a preprocessor function such as `%date()`.
pub fn has_preprocessor_call(text : String) -> Bool {
let n = text.length()
let mut i = 0
while i < n {
if text.code_unit_at(i).to_int() == 37 {
let mut j = i + 1
fn name(c : Int32) -> Bool {
(c >= 97 && c <= 122) ||
(c >= 65 && c <= 90) ||
c == 95 ||
(c >= 48 && c <= 57)
}
while j < n && name(text.code_unit_at(j).to_int()) {
j += 1
}
if j > i + 1 && j < n && text.code_unit_at(j).to_int() == 40 {
let first = text.code_unit_at(i + 1).to_int()
if !(first >= 48 && first <= 57) {
return true
}
}
i = j
} else {
i += 1
}
}
false
}
///|
fn marker(text : String, prefix : String) -> (String, String?)? {
let t = trim(text)
guard t.has_prefix(prefix) else { return None }
let rest = @cst.slice(t, prefix.length(), t.length())
let mut i = 0
while i < rest.length() {
let c = rest.code_unit_at(i).to_int()
if !((c >= 97 && c <= 122) || (c >= 48 && c <= 57)) {
break
}
i += 1
}
if i == 0 {
return None
}
let word = @cst.slice(rest, 0, i)
let tail = trim(@cst.slice(rest, i, rest.length()))
if i < rest.length() && !is_space_unit(rest.code_unit_at(i).to_int()) {
return None
}
Some((word, if tail.is_empty() { None } else { Some(tail) }))
}
///|
fn is_trivia(text : String) -> Bool {
let t = trim(text)
t.is_empty() || t.has_prefix("'")
}
///|
fn trivia_node(
raw : Raw,
kind : @cst.TokenKind,
node? : @cst.NodeKind,
) -> @cst.Node {
let children : Array[@cst.Element] = []
if !raw.text.is_empty() {
children.push(Token(@cst.Token::new(kind, raw.text, raw.offset)))
}
if !raw.newline.is_empty() {
children.push(
Token(
@cst.Token::new(Newline, raw.newline, raw.offset + raw.text.length()),
),
)
}
@cst.Node::new(
match node {
Some(node) => node
None =>
if kind == Comment {
Comment
} else if kind == Whitespace {
Blank
} else {
Error
}
},
children,
)
}
///|
/// Scan one native block. Multiple blocks, wrong end markers and text
/// outside the block are errors, never a partial result.
pub fn scan(source : String, limits~ : Limits = Limits::new()) -> Scan {
let diagnostics : Array[@diagrams.Diagnostic] = []
let index = @cst.Lines::new(source)
fn error(code : String, message : String, start : Int32, end : Int32) {
if diagnostics.length() < limits.diagnostics {
diagnostics.push(
@diagrams.Diagnostic::error(code, message, span=index.span(start, end)),
)
}
}
fn warning(code : String, message : String, start : Int32, end : Int32) {
if diagnostics.length() < limits.diagnostics {
diagnostics.push(
@diagrams.Diagnostic::warning(
code,
message,
span=index.span(start, end),
),
)
}
}
let empty : Scan = {
source,
kind: None,
name: None,
head: [],
lines: [],
tail: [],
statements: [],
diagnostics: [],
lines_index: index,
}
if !limits.valid() {
return {
..empty,
diagnostics: [
@diagrams.Diagnostic::error(
"plantuml/invalid-limits", "PlantUML limits must be positive",
),
],
}
}
if source.length() > limits.source {
return {
..empty,
head: [@cst.Node::new(Error, [Token(@cst.Token::new(Error, source, 0))])],
diagnostics: [
@diagrams.Diagnostic::error(
"plantuml/source-limit", "Source limit exceeded",
),
],
}
}
let raws = split_lines(source)
let head : Array[@cst.Node] = []
let mut i = 0
let mut kind : Kind? = None
let mut name : String? = None
let mut in_block_comment = false
while i < raws.length() {
let raw = raws[i]
let t = trim(raw.text)
if in_block_comment {
head.push(trivia_node(raw, Comment))
if t.has_suffix("'/") {
in_block_comment = false
}
i += 1
continue
}
if t.has_prefix("/'") {
head.push(trivia_node(raw, Comment))
in_block_comment = !t.has_suffix("'/") || t.length() < 4
i += 1
continue
}
if t.is_empty() {
head.push(trivia_node(raw, Whitespace))
i += 1
continue
}
if marker(raw.text, "@start") is Some((word, label)) {
head.push(trivia_node(raw, Marker, node=Start))
match kind_of_word(word) {
Some(k) => kind = Some(k)
None =>
error(
"plantuml/unsupported-diagram",
"Unsupported diagram type @start\{word}; expected regex, ebnf, json or yaml",
raw.offset,
raw.offset + raw.text.length(),
)
}
name = label
i += 1
break
}
if t.has_prefix("'") {
head.push(trivia_node(raw, Comment))
} else {
head.push(trivia_node(raw, Error))
error(
"plantuml/text-outside-block",
"Text before the start marker",
raw.offset,
raw.offset + raw.text.length(),
)
}
i += 1
}
if kind is None && diagnostics.is_empty() {
error(
"plantuml/missing-start-marker", "Expected @startregex, @startebnf, @startjson or @startyaml",
0, 0,
)
}
let body_raws : Array[Raw] = []
let mut end_index : Int32? = None
while i < raws.length() {
if marker(raws[i].text, "@end") is Some(_) {
end_index = Some(i)
break
}
body_raws.push(raws[i])
i += 1
}
let tail : Array[@cst.Node] = []
match end_index {
None =>
if kind is Some(k) {
error(
"plantuml/missing-end-marker",
"Missing @end\{k.word()}",
source.length(),
source.length(),
)
}
Some(e) => {
let raw = raws[e]
tail.push(trivia_node(raw, Marker, node=End))
if marker(raw.text, "@end") is Some((word, extra)) && kind is Some(k) {
if word != k.word() {
error(
"plantuml/wrong-end-marker",
"Expected @end\{k.word()}, found @end\{word}",
raw.offset,
raw.offset + raw.text.length(),
)
} else if extra is Some(_) {
error(
"plantuml/wrong-end-marker",
"Unexpected text after @end\{word}",
raw.offset,
raw.offset + raw.text.length(),
)
}
}
let mut j = e + 1
let mut block = false
while j < raws.length() {
let raw = raws[j]
let t = trim(raw.text)
if block {
tail.push(trivia_node(raw, Comment))
if t.has_suffix("'/") {
block = false
}
} else if t.has_prefix("/'") {
tail.push(trivia_node(raw, Comment))
block = !t.has_suffix("'/") || t.length() < 4
} else if is_trivia(raw.text) {
tail.push(
trivia_node(raw, if t.is_empty() { Whitespace } else { Comment }),
)
} else {
tail.push(trivia_node(raw, Error, node=Trailing))
if marker(raw.text, "@start") is Some(_) {
error(
"plantuml/multiple-blocks",
"A source can contain only one diagram block",
raw.offset,
raw.offset + raw.text.length(),
)
} else {
error(
"plantuml/text-outside-block",
"Text after the end marker",
raw.offset,
raw.offset + raw.text.length(),
)
}
}
j += 1
}
}
}
let (lines, statements) = classify(
kind.unwrap_or(Regex),
body_raws,
i => index.span(i, i),
fn(code, message, start, end) {
if code.has_prefix("!") {
warning(@cst.slice(code, 1, code.length()), message, start, end)
} else {
error(code, message, start, end)
}
},
)
if diagnostics.length() >= limits.diagnostics {
diagnostics.push(
@diagrams.Diagnostic::error(
"plantuml/diagnostic-limit", "Diagnostic limit reached",
),
)
}
{
source,
kind,
name,
head: ReadOnlyArray::from_array(head),
lines: ReadOnlyArray::from_array(lines),
tail: ReadOnlyArray::from_array(tail),
statements: ReadOnlyArray::from_array(statements),
diagnostics: ReadOnlyArray::from_array(diagnostics),
lines_index: index,
}
}
///|
/// Classify body lines into shared statements and kind body lines. A report
/// code that starts with `!` is a warning.
fn classify(
kind : Kind,
raws : Array[Raw],
_span : (Int32) -> @diagrams.Span,
report : (String, String, Int32, Int32) -> Unit,
) -> (Array[Line], Array[Statement]) {
let lines : Array[Line] = []
let statements : Array[Statement] = []
let data = kind == Json || kind == Yaml
let mut data_started = false
let mut activity_text = 0
// The pinned JSON and YAML header rules need one space after the word.
fn word_at(t : String, word : String) -> Bool {
if data {
t.has_prefix(word + " ")
} else {
has_word(t, word)
}
}
let mut i = 0
fn line(index : Int32, role : Role) -> Line {
let raw = raws[index]
{
number: index,
offset: raw.offset,
text: raw.text,
newline: raw.newline,
role,
}
}
fn statement(
first : Int32,
last : Int32,
kind : @cst.NodeKind,
token : @cst.TokenKind,
) -> Unit {
let children : Array[@cst.Element] = []
for k in first..<=last {
let raw = raws[k]
if !raw.text.is_empty() {
children.push(Token(@cst.Token::new(token, raw.text, raw.offset)))
}
if !raw.newline.is_empty() {
children.push(
Token(
@cst.Token::new(
Newline,
raw.newline,
raw.offset + raw.text.length(),
),
),
)
}
}
lines.push(line(first, Statement(@cst.Node::new(kind, children))))
for k in (first + 1)..<=last {
lines.push(line(k, Continuation))
}
}
fn whole(index : Int32) -> (Int32, Int32) {
(raws[index].offset, raws[index].offset + raws[index].text.length())
}
fn block_end(start : Int32, close : (String) -> Bool) -> Int32? {
let mut k = start + 1
while k < raws.length() {
if close(trim(raws[k].text)) {
return Some(k)
}
k += 1
}
None
}
while i < raws.length() {
let raw = raws[i]
let t = if data { java_trim(raw.text) } else { trim(raw.text) }
let (start, end) = whole(i)
// Activity labels and notes can contain words that otherwise introduce
// envelope metadata. Keep those lines in their body construct.
if kind == Activity {
if activity_text != 0 {
lines.push(line(i, Body))
if (activity_text == 1 && t.contains(";")) ||
(activity_text == 2 && (t == "end note" || t == "endnote")) {
activity_text = 0
}
i += 1
continue
}
if (t.has_prefix("note ") || t == "note" || t.has_prefix("floating note")) &&
!t.contains(":") {
activity_text = 2
lines.push(line(i, Body))
i += 1
continue
}
if (t.has_prefix(":") || (t.has_prefix("#") && t.contains(":"))) &&
!t.contains(";") &&
!t.has_suffix(")") {
activity_text = 1
lines.push(line(i, Body))
i += 1
continue
}
}
// Comments are removed before any diagram reader runs.
if t.has_prefix("'") {
statement(i, i, Comment, Comment)
i += 1
continue
}
if t.has_prefix("/'") {
let last = if t.length() >= 4 && t.has_suffix("'/") {
Some(i)
} else {
block_end(i, s => s.has_suffix("'/"))
}
match last {
Some(last) => {
statement(i, last, Comment, Comment)
i = last + 1
}
None => {
report(
"plantuml/unclosed-block", "Unclosed block comment", start, end,
)
statement(i, raws.length() - 1, Error, Error)
i = raws.length()
}
}
continue
}
if t.has_prefix("!") {
if has_word(t, "!option") && (!data || !data_started) {
statements.push(Option(after_word(t, "!option")))
statement(i, i, Pragma, Directive)
report(
"!plantuml/unapplied-presentation", "The option is retained but not applied",
start, end,
)
} else if has_word(t, "!pragma") && (!data || !data_started) {
statements.push(Pragma(after_word(t, "!pragma")))
statement(i, i, Pragma, Directive)
report(
"!plantuml/unapplied-presentation", "The pragma is retained but not applied",
start, end,
)
} else {
statement(i, i, Directive, Directive)
report(
"plantuml/unsupported-directive", "Preprocessor directives such as includes, definitions and themes are not supported",
start, end,
)
}
i += 1
continue
}
if has_preprocessor_call(raw.text) {
statement(i, i, Directive, Directive)
report(
"plantuml/unsupported-preprocessing", "Preprocessor function calls are not evaluated",
start, end,
)
i += 1
continue
}
if t == "") {
Some(last) => {
let content = []
for k in (i + 1).. {
report(
"plantuml/unclosed-block", "Unclosed