///|
/// One segment of a `#highlight` path.
pub(all) enum Segment {
/// A member name or an array position. Both compare as text, so `0`
/// selects array position 0 and also an object member named `0`.
Name(String)
/// The `n`-th member, from 1, that has this name in its container. The
/// source spelling is a quoted name followed by `#n`, as in `"item"#2`.
Occurrence(String, Int32)
/// `*`: one member with any name or position.
Any
/// `**`: zero or more member levels. At the end of a path, every member
/// below the current container.
Deep
} derive(Eq, Debug)
///|
pub extend Segment with Eq::{equal, not_equal}
///|
pub extend Segment with @debug.Debug::{to_repr}
///|
/// A typed `#highlight` selector. The stereotype is inert author metadata;
/// library themes paint every highlight.
pub struct Selector {
segments : ReadOnlyArray[Segment]
/// The text between `<<` and `>>`, without the outer markers.
stereotype : String?
span : @diagrams.Span?
} derive(Eq, Debug)
///|
pub extend Selector with Eq::{equal, not_equal}
///|
pub extend Selector with @debug.Debug::{to_repr}
///|
/// A selector. The constructor copies `segments`.
pub fn Selector::new(
segments : ArrayView[Segment],
stereotype? : String,
span? : @diagrams.Span,
) -> Selector {
{ segments: ReadOnlyArray::from_array(segments), stereotype, span, }
}
///|
/// Equality without the source span.
pub fn Selector::logical_equal(self : Selector, other : Selector) -> Bool {
self.segments == other.segments && self.stereotype == other.stereotype
}
///|
/// True when the selector has no wildcard and no occurrence number.
pub fn Selector::is_plain(self : Selector) -> Bool {
self.segments.iter().all(segment => segment is Name(_))
}
///|
let highlight_word = "#highlight "
///|
/// True when the trimmed line is a `#highlight` statement.
pub fn is_highlight_line(text : String) -> Bool {
@envelope.trim(text).has_prefix(highlight_word)
}
///|
fn quoted(text : String) -> String {
let out = StringBuilder()
out.write_char('"')
for c in text {
if c == '"' || c == '\\' || ((text == "*" || text == "**") && c == '*') {
out.write_char('\\')
}
out.write_char(c)
}
out.write_char('"')
out.to_string()
}
///|
/// The canonical source line of the selector, without a line break. Names
/// are quoted, so `/`, `*` and `<` in a name stay literal.
pub fn Selector::to_line(self : Selector) -> String {
let out = StringBuilder()
out.write_string(highlight_word)
for i, segment in self.segments {
if i > 0 {
out.write_string(" / ")
}
match segment {
Name(name) => out.write_string(quoted(name))
Occurrence(name, n) => {
out.write_string(quoted(name))
out.write_string("#\{n}")
}
Any => out.write_string("*")
Deep => out.write_string("**")
}
}
if self.stereotype is Some(text) {
out.write_string(" <<\{text}>>")
}
out.to_string()
}
///|
fn is_blank_unit(c : Int32) -> Bool {
c == 32 || c == 9
}
///|
/// Parse the text of a `#highlight` line. A quoted segment keeps `/`, `<`
/// and `>` inside the segment and accepts `\"`, `\\` and `\*` escapes. A
/// quoted `*` or `**` without escapes is a wildcard, as upstream. Upstream
/// PlantUML splits on every `/` before it removes quotes; this library does
/// not, so a quoted name that contains `/` selects one member.
pub fn parse_selector(line : String) -> Result[Selector, String] {
let t = @envelope.trim(line)
guard t.has_prefix(highlight_word) else {
return Err("A highlight starts with '#highlight '")
}
let text = @envelope.trim(@cst.slice(t, highlight_word.length(), t.length()))
let n = text.length()
let segments : Array[Segment] = []
let mut stereotype : String? = None
let mut i = 0
fn unit(at : Int32) -> Int32 {
text.code_unit_at(at).to_int()
}
while true {
while i < n && is_blank_unit(unit(i)) {
i += 1
}
if i + 1 < n && unit(i) == 60 && unit(i + 1) == 60 {
// `<<` starts the stereotype; it runs to the end of the line.
let rest = @cst.slice(text, i, n)
let rest = @envelope.trim(rest)
if rest.length() < 4 || !rest.has_suffix(">>") {
return Err("A highlight stereotype must end with '>>'")
}
if segments.is_empty() {
return Err("A highlight needs a path before its stereotype")
}
stereotype = Some(@cst.slice(rest, 2, rest.length() - 2))
break
}
if i >= n {
return Err(
if segments.is_empty() {
"A highlight needs a path"
} else {
"A highlight path has an empty segment"
},
)
}
if unit(i) == 34 {
let name = StringBuilder()
let mut j = i + 1
let mut run = j
let mut closed = false
let mut escaped = false
while j < n {
let c = unit(j)
if c == 92 &&
j + 1 < n &&
(unit(j + 1) == 34 || unit(j + 1) == 92 || unit(j + 1) == 42) {
name.write_string(@cst.slice(text, run, j))
escaped = true
run = j + 1
j += 2
} else if c == 34 {
name.write_string(@cst.slice(text, run, j))
closed = true
j += 1
break
} else {
j += 1
}
}
if !closed {
return Err("A quoted highlight segment has no closing quote")
}
let mut occurrence : Int32? = None
if j < n && unit(j) == 35 {
let mut k = j + 1
let mut value = 0
while k < n && unit(k) >= 48 && unit(k) <= 57 && k - j <= 9 {
value = value * 10 + (unit(k) - 48)
k += 1
}
if k == j + 1 || value < 1 {
return Err("An occurrence number after '#' must be 1 or more")
}
occurrence = Some(value)
j = k
}
while j < n && is_blank_unit(unit(j)) {
j += 1
}
// As upstream, a quoted `*` or `**` is a wildcard. Write `\*` for a
// literal star.
let segment : Segment = match (occurrence, name.to_string()) {
(Some(value), text) => Occurrence(text, value)
(None, "*") if !escaped => Any
(None, "**") if !escaped => Deep
(None, text) => Name(text)
}
segments.push(segment)
i = j
} else {
let start = i
while i < n &&
unit(i) != 47 &&
!(i + 1 < n && unit(i) == 60 && unit(i + 1) == 60) {
if unit(i) == 60 || unit(i) == 62 {
return Err(
"Quote a highlight name that contains '<' or '>'; '<<' starts a stereotype",
)
}
i += 1
}
let raw = @envelope.trim(@cst.slice(text, start, i))
if raw.is_empty() {
return Err("A highlight path has an empty segment")
}
segments.push(
match raw {
"*" => Any
"**" => Deep
_ => Name(raw)
},
)
}
while i < n && is_blank_unit(unit(i)) {
i += 1
}
if i >= n {
break
}
if unit(i) == 47 {
i += 1
continue
}
if i + 1 < n && unit(i) == 60 && unit(i + 1) == 60 {
continue
}
return Err("Expected '/' or '<<' after a highlight segment")
}
Ok(Selector::new(segments, stereotype?))
}
///|
/// Read the selectors of the `#highlight` statement lines of a scan. A line
/// that does not parse gives an error with the code `/invalid-highlight`.
pub fn read_selectors(
scan : @envelope.Scan,
prefix : String,
) -> (Array[Selector], Array[@diagrams.Diagnostic]) {
let selectors = []
let diagnostics = []
for line in scan.lines {
guard line.role is Statement(node) && node.kind == Highlight else {
continue
}
let span = scan.span(line.offset, line.offset + line.text.length())
match parse_selector(line.text) {
Ok(selector) => selectors.push({ ..selector, span: Some(span), })
Err(message) =>
diagnostics.push(
@diagrams.Diagnostic::error(
"\{prefix}/invalid-highlight",
message,
span~,
),
)
}
}
(selectors, diagnostics)
}