///|
/// The longest `&...;` reference this decoder is willing to look for. Anything
/// longer is treated as a literal ampersand in bank text rather than as a
/// reference, so a stray `&` never swallows the rest of a memo.
let max_reference_length : Int = 12
///|
/// The named character references that are understood.
///
/// OFX 2.x documents are XML, so the five predefined XML entities always
/// decode; `nbsp` is included because bank text uses it constantly. Any other
/// name is reported as `unknown-entity` and left exactly as written instead of
/// being dropped silently.
fn named_entity(name : String) -> Char? {
match name {
"lt" => Some('<')
"gt" => Some('>')
"amp" => Some('&')
"quot" => Some('"')
"apos" => Some('\'')
"nbsp" => Some('\u{00A0}')
_ => None
}
}
///|
/// The value of one digit in `radix`, or `-1` when the character is not a digit
/// of that radix.
fn digit_value(c : Char, radix : Int) -> Int {
let value = if c >= '0' && c <= '9' {
c.to_int() - 48
} else if c >= 'a' && c <= 'f' {
c.to_int() - 87
} else if c >= 'A' && c <= 'F' {
c.to_int() - 55
} else {
-1
}
if value >= 0 && value < radix {
value
} else {
-1
}
}
///|
/// Decode the body of a character reference, without its `&` and `;`.
///
/// Returns `None` after reporting either `malformed-entity` (a numeric
/// reference that is empty, non-numeric, zero, above `U+10FFFF` or a surrogate)
/// or `unknown-entity` (a name this decoder does not know). The caller keeps
/// the reference exactly as written in both cases.
fn decode_reference(
body : String,
line : Int,
diagnostics : Array[Diagnostic],
) -> Char? {
let chars = body.iter().to_array()
if chars.length() == 0 {
diagnostics.push(warning_at("malformed-entity", at_text("&;", line)))
return None
}
if chars[0] != '#' {
match named_entity(body) {
Some(c) => return Some(c)
None => {
diagnostics.push(
warning_at("unknown-entity", at_text("&" + body + ";", line)),
)
return None
}
}
}
let mut radix = 10
let mut index = 1
if index < chars.length() && (chars[index] == 'x' || chars[index] == 'X') {
radix = 16
index += 1
}
let mut code = 0
let mut valid = index < chars.length()
while index < chars.length() && valid {
let digit = digit_value(chars[index], radix)
if digit < 0 {
valid = false
} else {
code = code * radix + digit
if code > 1114111 {
code = 1114112
}
index += 1
}
}
// `body` still carries the leading `#`, so the literal is `&` + body + `;`.
let literal = at_text("&" + body + ";", line)
if !valid || code <= 0 || code > 1114111 {
diagnostics.push(warning_at("malformed-entity", literal))
return None
}
match code.to_char() {
Some(c) => Some(c)
None => {
diagnostics.push(warning_at("malformed-entity", literal))
None
}
}
}
///|
/// Append `text` to `out` with character references decoded.
///
/// CDATA sections never reach this function: the lexer appends their text
/// literally, because a CDATA section is by definition not entity-encoded. Only
/// the document text between tags is decoded here, which is where a bank can
/// write `&`, `é` or ` `.
fn decode_into(
out : StringBuilder,
text : String,
line : Int,
diagnostics : Array[Diagnostic],
) -> Unit {
if !text.contains_char('&') {
out.write_string(text)
return
}
let chars = text.iter().to_array()
let mut index = 0
while index < chars.length() {
if chars[index] != '&' {
out.write_char(chars[index])
index += 1
continue
}
let mut stop = index + 1
while stop < chars.length() &&
stop - index <= max_reference_length &&
chars[stop] != ';' {
stop += 1
}
if stop >= chars.length() || stop - index > max_reference_length {
diagnostics.push(warning_at("malformed-entity", at_text("&", line)))
out.write_char('&')
index += 1
continue
}
let body = StringBuilder()
for position in (index + 1).. out.write_char(c)
None =>
for position in index..<(stop + 1) {
out.write_char(chars[position])
}
}
index = stop + 1
}
}