use crate::{LoadError, LoadResult, Options, Plugin, SourceMap};
use rustledger_core::{BookingMethod, Directive, DisplayContext};
use rustledger_parser::Spanned;
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Clone)]
pub struct LoadOptions {
pub booking_method: BookingMethod,
pub run_plugins: bool,
pub auto_accounts: bool,
pub extra_plugins: Vec<String>,
pub extra_plugin_configs: Vec<Option<String>>,
pub validate: bool,
pub path_security: bool,
}
impl Default for LoadOptions {
fn default() -> Self {
Self {
booking_method: BookingMethod::Strict,
run_plugins: true,
auto_accounts: false,
extra_plugins: Vec::new(),
extra_plugin_configs: Vec::new(),
validate: true,
path_security: false,
}
}
}
impl LoadOptions {
#[must_use]
pub const fn raw() -> Self {
Self {
booking_method: BookingMethod::Strict,
run_plugins: false,
auto_accounts: false,
extra_plugins: Vec::new(),
extra_plugin_configs: Vec::new(),
validate: false,
path_security: false,
}
}
}
#[derive(Debug, Error)]
pub enum ProcessError {
#[error("loading failed: {0}")]
Load(#[from] LoadError),
#[cfg(feature = "booking")]
#[error("booking error: {message}")]
Booking {
message: String,
date: chrono::NaiveDate,
narration: String,
},
#[cfg(feature = "plugins")]
#[error("plugin error: {0}")]
Plugin(String),
#[cfg(feature = "validation")]
#[error("validation error: {0}")]
Validation(String),
#[cfg(feature = "plugins")]
#[error("failed to convert plugin output: {0}")]
PluginConversion(String),
}
#[derive(Debug)]
pub struct Ledger {
pub directives: Vec<Spanned<Directive>>,
pub options: Options,
pub plugins: Vec<Plugin>,
pub source_map: SourceMap,
pub errors: Vec<LedgerError>,
pub display_context: DisplayContext,
}
#[derive(Debug)]
pub struct LedgerError {
pub severity: ErrorSeverity,
pub code: String,
pub message: String,
pub location: Option<ErrorLocation>,
pub phase: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorSeverity {
Error,
Warning,
}
#[derive(Debug, Clone)]
pub struct ErrorLocation {
pub file: std::path::PathBuf,
pub line: usize,
pub column: usize,
}
impl LedgerError {
pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
severity: ErrorSeverity::Error,
code: code.into(),
message: message.into(),
location: None,
phase: "validate".to_string(),
}
}
pub fn warning(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
severity: ErrorSeverity::Warning,
code: code.into(),
message: message.into(),
location: None,
phase: "validate".to_string(),
}
}
#[must_use]
pub fn with_phase(mut self, phase: impl Into<String>) -> Self {
self.phase = phase.into();
self
}
#[must_use]
pub fn with_location(mut self, location: ErrorLocation) -> Self {
self.location = Some(location);
self
}
}
pub fn process(raw: LoadResult, options: &LoadOptions) -> Result<Ledger, ProcessError> {
let mut directives = raw.directives;
let mut errors: Vec<LedgerError> = Vec::new();
for load_err in raw.errors {
errors.push(LedgerError::error("LOAD", load_err.to_string()).with_phase("parse"));
}
directives.sort_by(|a, b| {
a.value
.date()
.cmp(&b.value.date())
.then_with(|| a.value.priority().cmp(&b.value.priority()))
});
#[cfg(feature = "booking")]
{
let file_set_booking = raw.options.set_options.contains("booking_method");
let effective_method = if file_set_booking {
raw.options
.booking_method
.parse()
.unwrap_or(options.booking_method)
} else {
options.booking_method
};
run_booking(&mut directives, effective_method, &mut errors);
}
#[cfg(feature = "plugins")]
if options.run_plugins || !options.extra_plugins.is_empty() || options.auto_accounts {
run_plugins(
&mut directives,
&raw.plugins,
&raw.options,
options,
&raw.source_map,
&mut errors,
)?;
}
#[cfg(feature = "validation")]
if options.validate {
run_validation(&directives, &raw.options, &mut errors);
}
Ok(Ledger {
directives,
options: raw.options,
plugins: raw.plugins,
source_map: raw.source_map,
errors,
display_context: raw.display_context,
})
}
#[cfg(feature = "booking")]
fn run_booking(
directives: &mut Vec<Spanned<Directive>>,
booking_method: BookingMethod,
errors: &mut Vec<LedgerError>,
) {
use rustledger_booking::BookingEngine;
let mut engine = BookingEngine::with_method(booking_method);
engine.register_account_methods(directives.iter().map(|s| &s.value));
for spanned in directives.iter_mut() {
if let Directive::Transaction(txn) = &mut spanned.value {
match engine.book_and_interpolate(txn) {
Ok(result) => {
engine.apply(&result.transaction);
*txn = result.transaction;
}
Err(e) => {
errors.push(LedgerError::error(
"BOOK",
format!("{} ({}, \"{}\")", e, txn.date, txn.narration),
));
}
}
}
}
}
#[cfg(feature = "plugins")]
pub fn run_plugins(
directives: &mut Vec<Spanned<Directive>>,
file_plugins: &[Plugin],
file_options: &Options,
options: &LoadOptions,
source_map: &SourceMap,
errors: &mut Vec<LedgerError>,
) -> Result<(), ProcessError> {
use rustledger_plugin::{
DocumentDiscoveryPlugin, NativePlugin, NativePluginRegistry, PluginInput, PluginOptions,
directive_to_wrapper_with_location, wrapper_to_directive,
};
let base_dir = source_map
.files()
.first()
.and_then(|f| f.path.parent())
.unwrap_or_else(|| std::path::Path::new("."));
let has_document_dirs = options.run_plugins && !file_options.documents.is_empty();
let resolved_documents: Vec<String> = if has_document_dirs {
file_options
.documents
.iter()
.map(|d| {
let path = std::path::Path::new(d);
if path.is_absolute() {
d.clone()
} else {
base_dir.join(path).to_string_lossy().to_string()
}
})
.collect()
} else {
Vec::new()
};
let mut raw_plugins: Vec<(String, Option<String>)> = Vec::new();
if options.auto_accounts {
raw_plugins.push(("auto_accounts".to_string(), None));
}
if options.run_plugins {
for plugin in file_plugins {
raw_plugins.push((plugin.name.clone(), plugin.config.clone()));
}
}
for (i, plugin_name) in options.extra_plugins.iter().enumerate() {
let config = options.extra_plugin_configs.get(i).cloned().flatten();
raw_plugins.push((plugin_name.clone(), config));
}
if raw_plugins.is_empty() && !has_document_dirs {
return Ok(());
}
let mut wrappers: Vec<_> = directives
.iter()
.map(|spanned| {
let (filename, lineno) = if let Some(file) = source_map.get(spanned.file_id as usize) {
let (line, _col) = file.line_col(spanned.span.start);
(Some(file.path.display().to_string()), Some(line as u32))
} else {
(None, None)
};
directive_to_wrapper_with_location(&spanned.value, filename, lineno)
})
.collect();
let plugin_options = PluginOptions {
operating_currencies: file_options.operating_currency.clone(),
title: file_options.title.clone(),
};
if has_document_dirs {
let doc_plugin = DocumentDiscoveryPlugin::new(resolved_documents, base_dir.to_path_buf());
let input = PluginInput {
directives: wrappers.clone(),
options: plugin_options.clone(),
config: None,
};
let output = doc_plugin.process(input);
for err in output.errors {
let ledger_err = match err.severity {
rustledger_plugin::PluginErrorSeverity::Error => {
LedgerError::error("PLUGIN", err.message)
}
rustledger_plugin::PluginErrorSeverity::Warning => {
LedgerError::warning("PLUGIN", err.message)
}
};
errors.push(ledger_err);
}
wrappers = output.directives;
}
if !raw_plugins.is_empty() {
let registry = NativePluginRegistry::new();
for (raw_name, plugin_config) in &raw_plugins {
let resolved_name = if registry.find(raw_name).is_some() {
Some(raw_name.as_str())
} else if let Some(short_name) = raw_name.strip_prefix("beancount.plugins.") {
registry.find(short_name).is_some().then_some(short_name)
} else if let Some(short_name) = raw_name.strip_prefix("beancount_reds_plugins.") {
registry.find(short_name).is_some().then_some(short_name)
} else if let Some(short_name) = raw_name.strip_prefix("beancount_lazy_plugins.") {
registry.find(short_name).is_some().then_some(short_name)
} else {
None
};
if let Some(name) = resolved_name
&& let Some(plugin) = registry.find(name)
{
let input = PluginInput {
directives: wrappers.clone(),
options: plugin_options.clone(),
config: plugin_config.clone(),
};
let output = plugin.process(input);
for err in output.errors {
let ledger_err = match err.severity {
rustledger_plugin::PluginErrorSeverity::Error => {
LedgerError::error("PLUGIN", err.message).with_phase("plugin")
}
rustledger_plugin::PluginErrorSeverity::Warning => {
LedgerError::warning("PLUGIN", err.message).with_phase("plugin")
}
};
errors.push(ledger_err);
}
wrappers = output.directives;
}
}
}
let filename_to_file_id: std::collections::HashMap<String, u16> = source_map
.files()
.iter()
.map(|f| (f.path.display().to_string(), f.id as u16))
.collect();
let mut new_directives = Vec::with_capacity(wrappers.len());
for wrapper in &wrappers {
let directive = wrapper_to_directive(wrapper)
.map_err(|e| ProcessError::PluginConversion(e.to_string()))?;
let (span, file_id) =
if let (Some(filename), Some(lineno)) = (&wrapper.filename, wrapper.lineno) {
if let Some(&fid) = filename_to_file_id.get(filename) {
if let Some(file) = source_map.get(fid as usize) {
let span_start = file.line_start(lineno as usize).unwrap_or(0);
(rustledger_parser::Span::new(span_start, span_start), fid)
} else {
(rustledger_parser::Span::new(0, 0), 0)
}
} else {
(rustledger_parser::Span::new(0, 0), 0)
}
} else {
(rustledger_parser::Span::new(0, 0), 0)
};
new_directives.push(Spanned::new(directive, span).with_file_id(file_id as usize));
}
*directives = new_directives;
Ok(())
}
#[cfg(feature = "validation")]
fn run_validation(
directives: &[Spanned<Directive>],
file_options: &Options,
errors: &mut Vec<LedgerError>,
) {
use rustledger_validate::{ValidationOptions, validate_spanned_with_options};
let account_types: Vec<String> = file_options
.account_types()
.iter()
.map(|s| (*s).to_string())
.collect();
let validation_options = ValidationOptions {
account_types,
infer_tolerance_from_cost: file_options.infer_tolerance_from_cost,
tolerance_multiplier: file_options.inferred_tolerance_multiplier,
inferred_tolerance_default: file_options.inferred_tolerance_default.clone(),
..Default::default()
};
let validation_errors = validate_spanned_with_options(directives, validation_options);
for err in validation_errors {
let phase = if err.code.is_parse_phase() {
"parse"
} else {
"validate"
};
let severity_level = if err.code.is_warning() {
ErrorSeverity::Warning
} else {
ErrorSeverity::Error
};
errors.push(LedgerError {
severity: severity_level,
code: err.code.code().to_string(),
message: err.to_string(),
location: None,
phase: phase.to_string(),
});
}
}
pub fn load(path: &Path, options: &LoadOptions) -> Result<Ledger, ProcessError> {
let mut loader = crate::Loader::new();
if options.path_security {
loader = loader.with_path_security(true);
}
let raw = loader.load(path)?;
process(raw, options)
}
pub fn load_raw(path: &Path) -> Result<LoadResult, LoadError> {
crate::Loader::new().load(path)
}