use std::path::Path;
use wasm_bindgen::prelude::*;
use rustledger_core::Directive;
use rustledger_loader::{LoadOptions, Loader, VirtualFileSystem, process};
use rustledger_parser::{ParseResult as ParserResult, parse as parse_beancount};
use crate::types::{Error, LedgerOptions, Severity};
use crate::utils::LineLookup;
pub fn parse_error_to_wasm(
e: &rustledger_parser::ParseError,
lookup: &LineLookup,
file: Option<String>,
) -> Error {
Error::new(e.to_string())
.with_code(format!("P{:04}", e.kind_code()))
.with_phase("parse")
.with_hint(e.hint.clone())
.with_file(file)
.with_span(
lookup.byte_to_line_col(e.span.start),
lookup.byte_to_line_col(e.span.end),
)
}
pub fn validation_error_to_wasm(
e: &rustledger_validate::ValidationError,
lookup: &LineLookup,
file: Option<String>,
fallback_line: Option<u32>,
) -> Error {
let base = if matches!(e.code.severity(), rustledger_validate::Severity::Error) {
Error::new(e.message.clone())
} else {
Error::warning(e.message.clone())
};
let mut out = base
.with_code(e.code.code())
.with_phase("validate")
.with_hint(e.note.clone().or_else(|| e.context.clone()))
.with_file(file);
if let Some(span) = e.span {
out = out.with_span(
lookup.byte_to_line_col(span.start),
lookup.byte_to_line_col(span.end),
);
} else {
out.line = fallback_line;
}
out
}
pub struct ProcessedLedger {
pub directives: Vec<Directive>,
pub options: LedgerOptions,
pub errors: Vec<Error>,
pub parse_result: ParserResult,
pub lookup: LineLookup,
}
pub fn load_and_book(source: &str) -> ProcessedLedger {
let parse_result = parse_beancount(source);
let lookup = LineLookup::new(source);
if !parse_result.errors.is_empty() {
let errors: Vec<Error> = parse_result
.errors
.iter()
.map(|e| parse_error_to_wasm(e, &lookup, None))
.collect();
let options = extract_options(&parse_result.options);
return ProcessedLedger {
directives: Vec::new(),
options,
errors,
parse_result,
lookup,
};
}
let mut vfs = VirtualFileSystem::new();
vfs.add_file("input.beancount", source);
let mut loader = Loader::new().with_filesystem(Box::new(vfs));
let raw = match loader.load(Path::new("input.beancount")) {
Ok(raw) => raw,
Err(e) => {
let options = extract_options(&parse_result.options);
return ProcessedLedger {
directives: Vec::new(),
options,
errors: vec![Error::new(format!("Load error: {e}"))],
parse_result,
lookup,
};
}
};
let options = extract_loader_options(&raw.options);
let load_options = LoadOptions {
validate: false,
..Default::default()
};
match process(raw, &load_options) {
Ok(ledger) => {
let directives = ledger.directives.into_iter().map(|s| s.value).collect();
let errors: Vec<Error> = ledger.errors.into_iter().map(Error::from).collect();
ProcessedLedger {
directives,
options,
errors,
parse_result,
lookup,
}
}
Err(e) => ProcessedLedger {
directives: Vec::new(),
options,
errors: vec![Error::new(format!("Processing error: {e}"))],
parse_result,
lookup,
},
}
}
#[must_use]
pub fn has_fatal(errors: &[Error]) -> bool {
errors.iter().any(|e| e.severity == Severity::Error)
}
pub fn run_validation(load: &ProcessedLedger) -> Vec<Error> {
use rustledger_validate::{ValidationOptions, ValidationSession};
if has_fatal(&load.errors) {
return Vec::new();
}
let mut date_to_line: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
for spanned in &load.parse_result.directives {
let line = load.lookup.byte_to_line(spanned.span.start);
let date = spanned.value.date().to_string();
date_to_line.entry(date).or_insert(line);
}
#[allow(clippy::unwrap_used)]
let today = rustledger_core::naive_date(2999, 12, 31).unwrap();
let session = ValidationSession::new(ValidationOptions::default());
let (session, mut errors) = session.run_early(&load.directives, today);
let (session, late_errs) = session.run_late(&load.directives, today);
errors.extend(late_errs);
errors.extend(session.finalize());
errors
.iter()
.map(|err| {
let fallback_line = date_to_line.get(&err.date.to_string()).copied();
validation_error_to_wasm(err, &load.lookup, None, fallback_line)
})
.collect()
}
pub fn to_js<T: serde::Serialize>(value: &T) -> Result<JsValue, JsError> {
let serializer = serde_wasm_bindgen::Serializer::json_compatible();
value
.serialize(&serializer)
.map_err(|e| JsError::new(&e.to_string()))
}
pub fn extract_options(options: &[(String, String, rustledger_parser::Span)]) -> LedgerOptions {
let mut ledger_options = LedgerOptions::default();
for (key, value, _span) in options {
match key.as_str() {
"title" => ledger_options.title = Some(value.clone()),
"operating_currency" => {
ledger_options.operating_currencies.push(value.clone());
}
_ => {}
}
}
ledger_options
}
fn extract_loader_options(options: &rustledger_loader::Options) -> LedgerOptions {
LedgerOptions {
title: options.title.clone(),
operating_currencies: options.operating_currency.clone(),
}
}
#[cfg(test)]
mod warning_severity_tests {
use super::*;
#[test]
fn has_fatal_ignores_warnings() {
assert!(!has_fatal(&[Error::warning("w".to_string())]));
assert!(has_fatal(&[Error::new("e")]));
assert!(has_fatal(&[
Error::warning("w".to_string()),
Error::new("e")
]));
}
#[test]
fn run_validation_not_skipped_by_warning() {
let src = "plugin \"unrealized\" \"Equity:Unrealized\"\n\
2020-01-01 open Assets:Stock\n2020-01-01 open Assets:Cash\n\
2020-01-01 open Equity:Unrealized\n\
2020-01-02 * \"buy\"\n Assets:Stock 10 AAPL {100.00 USD}\n Assets:Cash -1000.00 USD\n\
2020-06-01 price AAPL 150.00 USD\n\
2020-07-01 * \"x\"\n Assets:Cash -5.00 USD\n Expenses:NeverOpened 5.00 USD\n";
let load = load_and_book(src);
assert!(
load.errors.iter().any(|e| e.severity == Severity::Warning),
"expected an unrealized warning in load.errors"
);
assert!(!has_fatal(&load.errors), "a warning must not be fatal");
let validation = run_validation(&load);
let e = validation
.iter()
.find(|e| e.message.contains("NeverOpened"))
.expect("validation must run despite the warning and report E1001");
assert_eq!(e.phase.as_deref(), Some("validate"), "phase");
assert!(
e.code.as_deref().is_some_and(|c| c.starts_with('E')),
"expected an E#### code, got {:?}",
e.code
);
assert!(e.line.is_some(), "validation error should carry a line");
}
#[test]
fn parse_error_carries_rich_fields() {
let load = load_and_book(" 2020-01-01 open Assets:A\n");
let e = load
.errors
.iter()
.find(|e| e.phase.as_deref() == Some("parse"))
.expect("expected a parse-phase error");
assert!(
e.code.as_deref().is_some_and(|c| c.starts_with('P')),
"expected a P#### code, got {:?}",
e.code
);
assert!(
e.line.is_some() && e.column.is_some(),
"parse error should have start line+column"
);
assert!(
e.end_line.is_some() && e.end_column.is_some(),
"parse error should have an end position"
);
}
#[test]
fn validation_severity_follows_code() {
use rustledger_validate::{ErrorCode, ValidationError};
let lookup = LineLookup::new("x");
let date = rustledger_core::naive_date(2020, 1, 1).unwrap();
let mk = |code| ValidationError::new(code, "m", date);
let warn = validation_error_to_wasm(&mk(ErrorCode::DateOutOfOrder), &lookup, None, Some(1));
assert_eq!(warn.severity, Severity::Warning);
let err = validation_error_to_wasm(&mk(ErrorCode::AccountNotOpen), &lookup, None, Some(1));
assert_eq!(err.severity, Severity::Error);
}
}