///|
/// Scan an OFX document and return its first statement plus every diagnostic.
///
/// This is the entry point for the common case: one export, one account. A file
/// that holds several statements is reported one statement at a time — the
/// first declaration of a statement-level field wins and later different values
/// raise `conflicting-value` — so use [`parse_document`] when the caller needs
/// every statement in the file, and this function when it needs the one a
/// single-account import expects.
///
/// The scanner never fails as a whole: a transaction is dropped only when one of
/// its own fields is missing or malformed, and the reason is always reported.
pub fn parse_statement(source : String) -> Statement {
let text = strip_leading_bom(source)
let diagnostics : Array[Diagnostic] = []
let header = parse_header(text, diagnostics)
let tokens = lex(text, diagnostics)
let signon = parse_signon(tokens, diagnostics)
statement_from_tokens(tokens, header, signon, diagnostics, true)
}
///|
/// Every statement in an OFX file, in the order the file states them.
///
/// `header` and `signon` belong to the file and are shared by every statement,
/// while `diagnostics` holds what could be said about the file itself: the
/// lexer's complaints, the header, and the sign-on. The diagnostics that belong
/// to one account's statement live on that statement, so a caller can report
/// "statement 3 of 5 failed" instead of one undifferentiated list.
pub struct Document {
header : Header
signon : Signon
statements : Array[Statement]
diagnostics : Array[Diagnostic]
}
///|
/// How many statements the file holds.
pub fn Document::statement_count(self : Document) -> Int {
self.statements.length()
}
///|
/// How many distinct accounts the file holds.
///
/// A file that states the same account twice is still one account to a ledger,
/// so the count is of names, not of statements.
pub fn Document::account_count(self : Document) -> Int {
let found : Array[String] = []
for label in self.account_labels() {
if !found.contains(label) {
found.push(label)
}
}
found.length()
}
///|
/// The distinct currencies the file uses, in the order they first appear.
///
/// A caller that sums the whole file needs one scale, and this is how it finds
/// out whether the file has one.
pub fn Document::currencies(self : Document) -> Array[String] {
let found : Array[String] = []
for statement in self.statements {
if statement.currency != "" && !found.contains(statement.currency) {
found.push(statement.currency)
}
}
found
}
///|
/// The statement that belongs to an account, or `None` when the file has none.
///
/// The account is matched against `ACCTID` exactly; matching a bank id as well
/// is left to the caller, because two institutions can use the same account
/// number.
pub fn Document::statement_for_account(
self : Document,
account_id : String,
) -> Statement? {
for statement in self.statements {
if statement.account_id == account_id {
return Some(statement)
}
}
None
}
///|
/// The period the whole file covers, as `(start, end)`.
///
/// `None` means no statement stated a period at all. A statement that states
/// only one end is included from that end, so the answer is never narrower than
/// the file.
pub fn Document::period(self : Document) -> (String, String)? {
let mut start = ""
let mut end = ""
for statement in self.statements {
if statement.start_date != "" &&
(start == "" || statement.start_date < start) {
start = statement.start_date
}
if statement.end_date != "" && (end == "" || statement.end_date > end) {
end = statement.end_date
}
}
if start == "" && end == "" {
None
} else {
Some(
(
if start == "" {
end
} else {
start
},
if end == "" {
start
} else {
end
},
),
)
}
}
///|
/// Whether the file held no statement at all.
pub fn Document::is_empty(self : Document) -> Bool {
self.statements.length() == 0
}
///|
/// The statement at an index, or `None` when the index is out of range.
pub fn Document::statement(self : Document, index : Int) -> Statement? {
if index < 0 || index >= self.statements.length() {
None
} else {
Some(self.statements[index])
}
}
///|
/// Every diagnostic in the file: the file-level ones first, then each
/// statement's in order.
pub fn Document::diagnostics(self : Document) -> Array[Diagnostic] {
let all : Array[Diagnostic] = self.diagnostics.copy()
for statement in self.statements {
all.push_iter(statement.diagnostics.iter())
}
all
}
///|
/// Whether any diagnostic, at either level, has severity `Error`.
pub fn Document::has_errors(self : Document) -> Bool {
self.diagnostics().any(d => d.severity is Error)
}
///|
/// Whether every statement can be imported: no `Error` at either level.
pub fn Document::ok(self : Document) -> Bool {
!self.has_errors()
}
///|
/// Number of `Error` diagnostics across the file and its statements.
pub fn Document::error_count(self : Document) -> Int {
self.diagnostics().filter(d => d.severity is Error).length()
}
///|
/// Number of `Warning` diagnostics across the file and its statements.
pub fn Document::warning_count(self : Document) -> Int {
self.diagnostics().filter(d => d.severity is Warning).length()
}
///|
/// Every diagnostic as a stable `severity:code[:location]` string.
pub fn Document::render_diagnostics(self : Document) -> Array[String] {
self.diagnostics().map(d => d.to_string())
}
///|
/// Every transaction in the file, statement by statement.
pub fn Document::transactions(self : Document) -> Array[Transaction] {
let all : Array[Transaction] = []
for statement in self.statements {
all.push_iter(statement.transactions.iter())
}
all
}
///|
/// Every investment record in the file, statement by statement.
pub fn Document::investments(self : Document) -> Array[Investment] {
let all : Array[Investment] = []
for statement in self.statements {
all.push_iter(statement.investments.iter())
}
all
}
///|
/// The account labels of the file, in statement order.
///
/// A label is `BANKID/ACCTID` when both are known, the account id alone when
/// there is no bank id, and `UNKNOWN` when the statement named no account, so a
/// report can always print something.
pub fn Document::account_labels(self : Document) -> Array[String] {
let labels : Array[String] = []
for statement in self.statements {
labels.push(account_label(statement))
}
labels
}
///|
/// Sum every transaction in the file at one scale, or `None` when an amount will
/// not convert or the total overflows.
pub fn Document::total_minor_units(self : Document, scale : Int) -> Int64? {
let amounts : Array[String] = []
for statement in self.statements {
for transaction in statement.transactions {
amounts.push(transaction.amount)
}
}
sum_amounts(amounts, scale, 0L)
}
///|
/// How a statement's account is printed in a report.
fn account_label(statement : Statement) -> String {
if statement.bank_id != "" && statement.account_id != "" {
statement.bank_id + "/" + statement.account_id
} else if statement.account_id != "" {
statement.account_id
} else {
"UNKNOWN"
}
}
///|
/// Whether a tag starts a statement: the aggregate that owns an account, a
/// period, balances and a transaction list.
fn is_statement_root(tag : String) -> Bool {
tag == "STMTRS" || tag == "CCSTMTRS"
}
///|
/// Whether a tag can only appear outside a statement, which makes it a sign that
/// an unclosed statement has ended.
///
/// OFX 1.x closes its aggregates, but a truncated or hand-edited file may not,
/// and a group that swallowed the rest of the document would report the outer
/// closing tags as unexpected.
fn is_statement_container(tag : String) -> Bool {
match tag {
"OFX"
| "BANKMSGSRSV1"
| "STMTTRNRS"
| "CCSTMTTRNRS"
| "SIGNONMSGSRSV1"
| "SONRS"
| "CREDITCARDMSGSRSV1" => true
_ => false
}
}
///|
/// Cut a token stream into one group per statement.
///
/// A group starts at `STMTRS` or `CCSTMTRS` and ends at its closing tag, at the
/// next statement that starts, or at a closing tag that can only belong to an
/// enclosing aggregate. When the slice boundary is not the statement's own
/// closing tag, a closing token is synthesized for the group, because a slice
/// that stops mid-aggregate would otherwise be reported as an unclosed
/// statement: the file is fine, the cut is ours. A file with no statement
/// wrapper produces no groups at all, which the caller treats as a single
/// fragment.
fn split_statements(tokens : Array[TagToken]) -> Array[Array[TagToken]] {
let groups : Array[Array[TagToken]] = []
let mut current : Array[TagToken] = []
let mut root = ""
let mut open = false
for token in tokens {
let tag = token.name
let line = token.line
match close_target(tag) {
Some(target) =>
if open {
if is_statement_root(target) {
current.push(token)
groups.push(current)
current = []
open = false
} else if is_statement_container(target) {
current.push({
name: "/" + root,
value: "",
line,
self_closing: false,
})
groups.push(current)
current = []
open = false
} else {
current.push(token)
}
}
None =>
if is_statement_root(tag) {
if open {
current.push({
name: "/" + root,
value: "",
line,
self_closing: false,
})
groups.push(current)
current = []
}
open = true
root = tag
current.push(token)
} else if open {
current.push(token)
}
}
}
if open && current.length() > 0 {
current.push({
name: "/" + root,
value: "",
line: tokens[tokens.length() - 1].line,
self_closing: false,
})
groups.push(current)
}
groups
}
///|
/// Scan an OFX document and return every statement it holds.
///
/// A file that holds one statement and a file that holds thirty are read by the
/// same rules, and each statement is diagnosed on its own: a bad record in the
/// third account does not make the first two unimportable, and the caller can
/// see which statement failed. A document with no statement wrapper at all is
/// still read, as a single statement, so fragments keep working exactly as they
/// do with [`parse_statement`].
pub fn parse_document(source : String) -> Document {
let text = strip_leading_bom(source)
let diagnostics : Array[Diagnostic] = []
let header = parse_header(text, diagnostics)
let tokens = lex(text, diagnostics)
let signon = parse_signon(tokens, diagnostics)
let statements : Array[Statement] = []
let groups = split_statements(tokens)
if groups.length() == 0 {
let statement_diagnostics : Array[Diagnostic] = []
statements.push(
statement_from_tokens(tokens, header, signon, statement_diagnostics, true),
)
} else {
for group in groups {
let statement_diagnostics : Array[Diagnostic] = []
statements.push(
statement_from_tokens(
group, header, signon, statement_diagnostics, false,
),
)
}
}
{ header, signon, statements, diagnostics, }
}