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: rustledger_core::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)]
#[non_exhaustive]
pub struct LedgerError {
pub severity: ErrorSeverity,
pub code: String,
pub message: String,
pub location: Option<ErrorLocation>,
pub source_span: Option<(usize, usize)>,
pub file_id: Option<u16>,
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,
source_span: None,
file_id: 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,
source_span: None,
file_id: None,
phase: "validate".to_string(),
}
}
#[must_use]
pub const fn with_source_span(mut self, span: (usize, usize), file_id: u16) -> Self {
self.source_span = Some(span);
self.file_id = Some(file_id);
self
}
#[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_cached_key(|d| {
(
d.value.date(),
d.value.priority(),
d.value.has_cost_reduction(),
)
});
#[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, &raw.source_map, &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>, bool)> = Vec::new();
if options.auto_accounts {
raw_plugins.push(("auto_accounts".to_string(), None, false));
}
if options.run_plugins {
for plugin in file_plugins {
raw_plugins.push((
plugin.name.clone(),
plugin.config.clone(),
plugin.force_python,
));
}
}
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, false));
}
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: std::mem::take(&mut wrappers),
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, force_python) in &raw_plugins {
let resolved_name = if *force_python {
None
} else 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: std::mem::take(&mut wrappers),
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;
} else {
let plugin_path = std::path::Path::new(raw_name);
let ext = plugin_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let resolve_path = |name: &str| -> Result<std::path::PathBuf, String> {
let p = std::path::Path::new(name);
let resolved = if p.is_absolute() {
p.to_path_buf()
} else {
base_dir.join(name)
};
if options.path_security
&& let (Ok(canon_base), Ok(canon_plugin)) =
(base_dir.canonicalize(), resolved.canonicalize())
&& !canon_plugin.starts_with(&canon_base)
{
return Err(format!(
"plugin path '{name}' is outside the ledger directory"
));
}
Ok(resolved)
};
if ext == "wasm" {
#[cfg(feature = "wasm-plugins")]
{
let wasm_path = match resolve_path(raw_name) {
Ok(p) => p,
Err(e) => {
errors.push(LedgerError::error("PLUGIN", e).with_phase("plugin"));
continue;
}
};
match run_wasm_plugin(&wasm_path, &wrappers, &plugin_options, plugin_config)
{
Ok((output_directives, plugin_errors)) => {
for err in plugin_errors {
errors.push(err);
}
wrappers = output_directives;
}
Err(e) => {
errors.push(
LedgerError::error(
"PLUGIN",
format!("WASM plugin {} failed: {e}", wasm_path.display()),
)
.with_phase("plugin"),
);
}
}
}
#[cfg(not(feature = "wasm-plugins"))]
{
errors.push(
LedgerError::error(
"PLUGIN",
format!(
"WASM plugin '{}' requires the wasm-plugins feature",
raw_name
),
)
.with_phase("plugin"),
);
}
} else if *force_python
|| ext == "py"
|| raw_name.contains(std::path::MAIN_SEPARATOR)
|| raw_name.contains('.')
{
#[cfg(feature = "python-plugins")]
{
let resolved = match resolve_path(raw_name) {
Ok(p) => p,
Err(e) => {
errors.push(LedgerError::error("PLUGIN", e).with_phase("plugin"));
continue;
}
};
match run_python_plugin(
raw_name,
&resolved,
base_dir,
&wrappers,
&plugin_options,
plugin_config,
) {
Ok((output_directives, plugin_errors)) => {
for err in plugin_errors {
errors.push(err);
}
wrappers = output_directives;
}
Err(e) => {
errors.push(LedgerError::error("E8002", e).with_phase("plugin"));
}
}
}
#[cfg(not(feature = "python-plugins"))]
{
errors.push(
LedgerError::error(
"E8005",
format!(
"Python plugin \"{}\" requires python-plugin-wasm feature",
raw_name
),
)
.with_phase("plugin"),
);
}
} else {
#[cfg(feature = "python-plugins")]
{
use rustledger_plugin::python::{is_python_available, suggest_module_path};
let suggestion = if is_python_available() {
suggest_module_path(raw_name)
} else {
None
};
if let Some(module_path) = suggestion {
errors.push(
LedgerError::error(
"E8004",
format!(
"Cannot resolve Python module '{raw_name}'. Replace with: plugin \"{module_path}\""
),
)
.with_phase("plugin"),
);
} else {
errors.push(
LedgerError::error(
"E8001",
format!("Plugin not found: \"{raw_name}\""),
)
.with_phase("plugin"),
);
}
}
#[cfg(not(feature = "python-plugins"))]
{
errors.push(
LedgerError::error(
"E8001",
format!("Plugin not found: \"{raw_name}\""),
)
.with_phase("plugin"),
);
}
}
}
}
}
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),
rustledger_parser::SYNTHESIZED_FILE_ID,
)
}
} else {
(
rustledger_parser::Span::new(0, 0),
rustledger_parser::SYNTHESIZED_FILE_ID,
)
}
} else {
(
rustledger_parser::Span::new(0, 0),
rustledger_parser::SYNTHESIZED_FILE_ID,
)
};
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,
source_map: &SourceMap,
errors: &mut Vec<LedgerError>,
) {
use rustledger_validate::{ValidationOptions, validate_spanned_with_options};
let base_dir = source_map
.files()
.first()
.and_then(|f| f.path.parent())
.unwrap_or_else(|| std::path::Path::new("."));
let resolved_document_dirs: Vec<std::path::PathBuf> = file_options
.documents
.iter()
.map(|d| {
let path = std::path::Path::new(d);
if path.is_absolute() {
path.to_path_buf()
} else {
base_dir.join(path)
}
})
.collect();
let account_types: Vec<String> = file_options
.account_types()
.iter()
.map(|s| (*s).to_string())
.collect();
let validation_options = ValidationOptions::default()
.with_account_types(account_types)
.with_document_dirs(resolved_document_dirs)
.with_infer_tolerance_from_cost(file_options.infer_tolerance_from_cost)
.with_tolerance_multiplier(file_options.inferred_tolerance_multiplier)
.with_inferred_tolerance_default(file_options.inferred_tolerance_default.clone());
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
};
let message = match &err.note {
Some(note) => format!("{err}\n note: {note}"),
None => err.to_string(),
};
let location = err.span.and_then(|span| {
let fid = err.file_id? as usize;
let file = source_map.get(fid)?;
let (line, column) = file.line_col(span.start);
Some(ErrorLocation {
file: file.path.clone(),
line,
column,
})
});
errors.push(LedgerError {
severity: severity_level,
code: err.code.code().to_string(),
message,
location,
source_span: err.span.map(|s| (s.start, s.end)),
file_id: err.file_id,
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)
}
#[cfg(feature = "wasm-plugins")]
fn run_wasm_plugin(
wasm_path: &std::path::Path,
directives: &[rustledger_plugin_types::DirectiveWrapper],
options: &rustledger_plugin::PluginOptions,
config: &Option<String>,
) -> Result<
(
Vec<rustledger_plugin_types::DirectiveWrapper>,
Vec<LedgerError>,
),
String,
> {
use rustledger_plugin::{PluginInput, PluginManager};
let mut mgr = PluginManager::new();
let plugin_idx = mgr
.load(wasm_path)
.map_err(|e| format!("failed to load: {e}"))?;
let input = PluginInput {
directives: directives.to_vec(),
options: options.clone(),
config: config.clone(),
};
let output = mgr
.execute(plugin_idx, &input)
.map_err(|e| format!("execution failed: {e}"))?;
let mut errors = Vec::new();
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);
}
Ok((output.directives, errors))
}
#[cfg(feature = "python-plugins")]
fn run_python_plugin(
module_name: &str,
resolved_path: &std::path::Path,
base_dir: &std::path::Path,
directives: &[rustledger_plugin_types::DirectiveWrapper],
options: &rustledger_plugin::PluginOptions,
config: &Option<String>,
) -> Result<
(
Vec<rustledger_plugin_types::DirectiveWrapper>,
Vec<LedgerError>,
),
String,
> {
use rustledger_plugin::{PluginInput, python::PythonRuntime};
let runtime = PythonRuntime::new().map_err(|e| format!("Python runtime unavailable: {e}"))?;
let input = PluginInput {
directives: directives.to_vec(),
options: options.clone(),
config: config.clone(),
};
let is_file = resolved_path.exists()
|| std::path::Path::new(module_name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("py"))
|| module_name.contains(std::path::MAIN_SEPARATOR);
let output = if is_file {
runtime
.execute_module(module_name, &input, Some(base_dir))
.map_err(|e| format!("Python plugin execution failed: {e}"))?
} else {
runtime
.execute_module(module_name, &input, Some(base_dir))
.map_err(|e| format!("Python plugin '{module_name}' execution failed: {e}"))?
};
let mut errors = Vec::new();
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);
}
Ok((output.directives, errors))
}