///|
/// The declared header of an OFX document.
///
/// OFX 1.x files open with `KEY:VALUE` lines (`OFXHEADER:100`, `DATA:OFXSGML`,
/// `VERSION:102`, `SECURITY:NONE`, `ENCODING:USASCII`, `CHARSET:1252`,
/// `COMPRESSION:NONE`, `OLDFILEUID`, `NEWFILEUID`); OFX 2.x files carry the
/// same facts in the XML declaration and an `` processing
/// instruction. Both shapes fill this struct. A field the document does not
/// declare stays empty: the importer never invents a default, so a caller can
/// tell "the file says 1252" from "the file says nothing".
pub struct Header {
ofx_header : String
data : String
version : String
security : String
encoding : String
charset : String
compression : String
old_file_uid : String
new_file_uid : String
}
///|
/// Whether the document declares itself as OFX 2.x XML rather than OFX 1.x
/// SGML. Only the header decides this; the body is scanned the same way either
/// way.
pub fn Header::is_xml(self : Header) -> Bool {
self.ofx_header == "200" || self.data == "OFXXML"
}
///|
/// Whether the document declared no header field at all.
pub fn Header::is_empty(self : Header) -> Bool {
self.ofx_header == "" &&
self.data == "" &&
self.version == "" &&
self.security == "" &&
self.encoding == "" &&
self.charset == "" &&
self.compression == "" &&
self.old_file_uid == "" &&
self.new_file_uid == ""
}
///|
/// The declared fields as `KEY:VALUE` pairs separated by a single space, in
/// header order, omitting everything the document left out.
pub fn Header::render(self : Header) -> String {
let pairs : Array[(String, String)] = [
("OFXHEADER", self.ofx_header),
("DATA", self.data),
("VERSION", self.version),
("SECURITY", self.security),
("ENCODING", self.encoding),
("CHARSET", self.charset),
("COMPRESSION", self.compression),
("OLDFILEUID", self.old_file_uid),
("NEWFILEUID", self.new_file_uid),
]
let parts : Array[String] = []
for pair in pairs {
let (key, value) = pair
if value != "" {
parts.push(key + ":" + value)
}
}
parts.join(" ")
}
///|
/// The header keys this importer understands. Any other key is reported as
/// `unknown-header-line` instead of being stored under a guessed meaning.
fn is_known_header_key(key : String) -> Bool {
match key {
"OFXHEADER"
| "DATA"
| "VERSION"
| "SECURITY"
| "ENCODING"
| "CHARSET"
| "COMPRESSION"
| "OLDFILEUID"
| "NEWFILEUID" => true
_ => false
}
}
///|
/// The text before the first `<`, which is where an OFX 1.x header block lives.
fn prologue_of(source : String) -> String {
let chars = source.iter().to_array()
let out = StringBuilder()
let mut index = 0
while index < chars.length() && chars[index] != '<' {
out.write_char(chars[index])
index += 1
}
out.to_string()
}
///|
fn is_name_start(c : Char) -> Bool {
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'
}
///|
fn is_name_char(c : Char) -> Bool {
is_name_start(c) || (c >= '0' && c <= '9') || c == '-' || c == '.'
}
///|
fn is_three_digits(value : String) -> Bool {
is_digits(value, 3)
}
///|
/// One declared field of the header being collected, or the reason it could not
/// be stored.
///
/// The header is collected into a `Map` because a document may declare the same
/// key twice, in the prologue and in an `` instruction; the later
/// declaration wins, exactly as it would if the file were read top to bottom.
fn record_header_field(
key : String,
value : String,
line : Int,
fields : Map[String, String],
diagnostics : Array[Diagnostic],
) -> Unit {
if value == "" {
diagnostics.push(warning_at("empty-header-value", at_text(key, line)))
} else if is_known_header_key(key) {
fields[key] = value
} else {
diagnostics.push(warning_at("unknown-header-line", at_text(key, line)))
}
}
///|
/// Record one `KEY:VALUE` line of an OFX 1.x header block.
fn apply_header_line(
line : String,
line_number : Int,
fields : Map[String, String],
diagnostics : Array[Diagnostic],
) -> Unit {
let chars = line.iter().to_array()
let mut stop = 0
while stop < chars.length() && chars[stop] != ':' {
stop += 1
}
if stop >= chars.length() {
diagnostics.push(
warning_at("unknown-header-line", "@" + line_number.to_string()),
)
return
}
let key = StringBuilder()
for position in 0..` processing instruction.
///
/// The XML declaration only contributes `encoding`, because its `version` is the
/// XML version (`1.0`) and not the OFX version the caller cares about. Every
/// other processing instruction is treated as an OFX header declaration, which
/// is how OFX 2.x states `OFXHEADER`, `VERSION` and the file UIDs.
fn apply_processing_instruction(
body : String,
line : Int,
fields : Map[String, String],
diagnostics : Array[Diagnostic],
) -> Unit {
let chars = body.iter().to_array()
let mut index = 0
let mut xml_declaration = false
while index < chars.length() {
if !is_name_start(chars[index]) {
index += 1
continue
}
let name = StringBuilder()
let start = index
while index < chars.length() && is_name_char(chars[index]) {
name.write_char(chars[index])
index += 1
}
let key = name.to_string()
if start == 0 && (key == "xml" || key == "XML") {
xml_declaration = true
}
let mut cursor = index
while cursor < chars.length() && is_space(chars[cursor]) {
cursor += 1
}
if cursor >= chars.length() || chars[cursor] != '=' {
// The PI target itself, or a valueless flag: nothing to record.
continue
}
cursor += 1
while cursor < chars.length() && is_space(chars[cursor]) {
cursor += 1
}
let value = StringBuilder()
if cursor < chars.length() &&
(chars[cursor] == '"' || chars[cursor] == '\'') {
let quote = chars[cursor]
cursor += 1
while cursor < chars.length() && chars[cursor] != quote {
value.write_char(chars[cursor])
cursor += 1
}
cursor += 1
} else {
// An unquoted value, as an OFX 1.x prologue line would write it. Read up
// to the next whitespace instead of discarding the declaration.
while cursor < chars.length() && !is_space(chars[cursor]) {
value.write_char(chars[cursor])
cursor += 1
}
}
if xml_declaration {
if key == "encoding" {
record_header_field(
"ENCODING",
value.to_string(),
line,
fields,
diagnostics,
)
}
} else {
record_header_field(
key.to_upper(),
value.to_string(),
line,
fields,
diagnostics,
)
}
index = cursor
}
}
///|
/// Find every `...?>` processing instruction in the source and record its
/// attributes.
fn apply_processing_instructions(
source : String,
fields : Map[String, String],
diagnostics : Array[Diagnostic],
) -> Unit {
let chars = source.iter().to_array()
let mut index = 0
let mut line = 1
while index < chars.length() {
if chars[index] == '\n' {
line += 1
index += 1
continue
}
let is_pi = chars[index] == '<' &&
index + 1 < chars.length() &&
chars[index + 1] == '?'
if is_pi {
let open_line = line
index += 2
let body = StringBuilder()
while index < chars.length() {
if chars[index] == '?' &&
index + 1 < chars.length() &&
chars[index + 1] == '>' {
index += 2
break
}
if chars[index] == '\n' {
line += 1
}
body.write_char(chars[index])
index += 1
}
apply_processing_instruction(
body.to_string(),
open_line,
fields,
diagnostics,
)
continue
}
index += 1
}
}
///|
fn field_or(fields : Map[String, String], key : String) -> String {
match fields.get(key) {
Some(value) => value
None => ""
}
}
///|
fn header_from_fields(fields : Map[String, String]) -> Header {
{
ofx_header: field_or(fields, "OFXHEADER"),
data: field_or(fields, "DATA"),
version: field_or(fields, "VERSION"),
security: field_or(fields, "SECURITY"),
encoding: field_or(fields, "ENCODING"),
charset: field_or(fields, "CHARSET"),
compression: field_or(fields, "COMPRESSION"),
old_file_uid: field_or(fields, "OLDFILEUID"),
new_file_uid: field_or(fields, "NEWFILEUID"),
}
}
///|
/// Check the few header facts whose value changes how the body must be read.
///
/// A compressed body is an error: the importer cannot inflate it, so whatever
/// it extracts would be noise. An unrecognised `OFXHEADER` or a `VERSION` that
/// is not the documented three digits is a warning, because the body is still
/// worth attempting.
fn validate_header(header : Header, diagnostics : Array[Diagnostic]) -> Unit {
if header.compression != "" && header.compression.to_upper() != "NONE" {
diagnostics.push(error_at("unsupported-compression", header.compression))
}
if header.ofx_header != "" &&
header.ofx_header != "100" &&
header.ofx_header != "200" &&
header.ofx_header != "220" {
diagnostics.push(
warning_at("invalid-header-version", "OFXHEADER:" + header.ofx_header),
)
}
if header.version != "" && !is_three_digits(header.version) {
diagnostics.push(
warning_at("invalid-header-version", "VERSION:" + header.version),
)
}
}
///|
/// Read the declared header of an OFX document.
///
/// Both the OFX 1.x `KEY:VALUE` prologue and OFX 2.x processing instructions
/// are read from the same source. Nothing here is required: a document without a
/// header parses exactly like one with a header, it just declares nothing.
fn parse_header(source : String, diagnostics : Array[Diagnostic]) -> Header {
let fields : Map[String, String] = Map([])
let mut line_number = 1
for raw_line in prologue_of(source).split("\n") {
let line = raw_line.trim().to_owned()
if line != "" {
apply_header_line(line, line_number, fields, diagnostics)
}
line_number += 1
}
apply_processing_instructions(source, fields, diagnostics)
let header = header_from_fields(fields)
validate_header(header, diagnostics)
header
}