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 ExtraPlugin {
pub name: String,
pub config: Option<String>,
}
#[derive(Debug, Clone)]
pub struct LoadOptions {
pub booking_method: BookingMethod,
pub run_plugins: bool,
pub auto_accounts: bool,
pub extra_plugins: Vec<ExtraPlugin>,
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(),
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(),
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,
}
impl Ledger {
#[cfg(feature = "booking")]
#[must_use]
pub fn balance_view(&self) -> Vec<Directive> {
let mut booked: Vec<Directive> = self.directives.iter().map(|s| s.value.clone()).collect();
debug_assert!(
!booked.iter().any(|d| matches!(d, Directive::Transaction(t) if rustledger_booking::is_synthesized_pad(t))),
"balance_view called on a Ledger whose directives already contain synth pad transactions",
);
let pad_result = rustledger_booking::process_pads(&booked);
let mut merged: Vec<Directive> =
Vec::with_capacity(booked.len() + pad_result.padding_transactions.len());
for txn in pad_result.padding_transactions {
merged.push(Directive::Transaction(txn));
}
merged.append(&mut booked);
merged.sort_by_key(rustledger_core::Directive::date);
merged
}
}
#[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 errors: Vec<LedgerError> = Vec::new();
for load_err in &raw.errors {
errors.push(LedgerError::error("LOAD", load_err.to_string()).with_phase("parse"));
}
#[cfg(any(feature = "validation", feature = "booking"))]
let effective_booking_method = resolve_effective_booking_method(&raw, options);
#[cfg(feature = "validation")]
let validation_session = if options.validate {
Some(rustledger_validate::ValidationSession::new(
build_validation_options(&raw.options, &raw.source_map, effective_booking_method),
))
} else {
None
};
#[cfg(feature = "validation")]
let today = jiff::Zoned::now().date();
let synthed = crate::Directives::<crate::Raw>::from_parser(raw.directives)
.sort()
.apply_synth_plugins(
&raw.plugins,
&raw.options,
options,
&raw.source_map,
&mut errors,
)?;
#[cfg(feature = "validation")]
let (directives, validation_session) =
synthed.early_validate(validation_session, today, &raw.source_map, &mut errors);
#[cfg(not(feature = "validation"))]
let directives = synthed.early_validate(&raw.source_map, &mut errors);
let (booked, failed) = directives.book(
#[cfg(feature = "booking")]
effective_booking_method,
#[cfg(feature = "booking")]
&mut errors,
);
let regular_applied = booked.apply_regular_plugins(
&raw.plugins,
&raw.options,
options,
&raw.source_map,
&mut errors,
)?;
#[cfg(feature = "validation")]
let late_validated =
regular_applied.late_validate(validation_session, today, &raw.source_map, &mut errors);
#[cfg(not(feature = "validation"))]
let late_validated = regular_applied.late_validate(&raw.source_map, &mut errors);
let finalized = late_validated.finalize(failed);
Ok(Ledger {
directives: finalized.into_inner(),
options: raw.options,
plugins: raw.plugins,
source_map: raw.source_map,
errors,
display_context: raw.display_context,
})
}
#[cfg(any(feature = "validation", feature = "booking"))]
fn resolve_effective_booking_method(
raw: &LoadResult,
options: &LoadOptions,
) -> rustledger_core::BookingMethod {
let file_set = raw.options.set_options.contains("booking_method");
if file_set {
raw.options
.booking_method
.parse()
.unwrap_or(options.booking_method)
} else {
options.booking_method
}
}
type CanonicalSortKey = (
rustledger_core::NaiveDate,
rustledger_core::DirectivePriority,
u16,
usize,
);
#[inline]
const fn canonical_sort_key(d: &Spanned<Directive>) -> CanonicalSortKey {
(d.value.date(), d.value.priority(), d.file_id, d.span.start)
}
impl crate::Directives<crate::Raw> {
#[must_use]
pub(crate) fn sort(mut self) -> crate::Directives<crate::Sorted> {
self.as_vec_mut().sort_by_key(canonical_sort_key);
crate::Directives::new_unchecked(std::mem::take(self.as_vec_mut()))
}
}
impl crate::Directives<crate::Sorted> {
pub(crate) fn apply_synth_plugins(
mut self,
plugins: &[crate::Plugin],
file_options: &crate::Options,
options: &LoadOptions,
source_map: &SourceMap,
errors: &mut Vec<LedgerError>,
) -> Result<crate::Directives<crate::Synthed>, ProcessError> {
#[cfg(feature = "plugins")]
run_plugins(
self.as_vec_mut(),
plugins,
file_options,
options,
source_map,
errors,
PluginPass::PreBookingSynth,
)?;
#[cfg(not(feature = "plugins"))]
{
let _ = (plugins, file_options, options, source_map, errors);
}
Ok(crate::Directives::new_unchecked(std::mem::take(
self.as_vec_mut(),
)))
}
}
impl crate::Directives<crate::Synthed> {
#[cfg(feature = "validation")]
pub(crate) fn early_validate(
mut self,
validation_session: Option<
rustledger_validate::ValidationSession<rustledger_validate::Pending>,
>,
today: rustledger_core::NaiveDate,
source_map: &SourceMap,
errors: &mut Vec<LedgerError>,
) -> (
crate::Directives<crate::EarlyValidated>,
Option<rustledger_validate::ValidationSession<rustledger_validate::EarlyDone>>,
) {
let session_out = validation_session.map(|session| {
let (session, phase_errors) = session.run_early_spanned(self.as_slice(), today);
ledger_errors_extend(errors, phase_errors, source_map);
session
});
(
crate::Directives::new_unchecked(std::mem::take(self.as_vec_mut())),
session_out,
)
}
#[cfg(not(feature = "validation"))]
pub(crate) fn early_validate(
mut self,
source_map: &SourceMap,
errors: &mut Vec<LedgerError>,
) -> crate::Directives<crate::EarlyValidated> {
let _ = (source_map, errors);
crate::Directives::new_unchecked(std::mem::take(self.as_vec_mut()))
}
}
impl crate::Directives<crate::EarlyValidated> {
pub(crate) fn book(
mut self,
#[cfg(feature = "booking")] effective_method: rustledger_core::BookingMethod,
#[cfg(feature = "booking")] errors: &mut Vec<LedgerError>,
) -> (
crate::Directives<crate::Booked>,
crate::phase::FailedBookings,
) {
#[cfg(feature = "booking")]
let (booked, failed) =
run_booking(std::mem::take(self.as_vec_mut()), effective_method, errors);
#[cfg(not(feature = "booking"))]
let (booked, failed): (Vec<Spanned<Directive>>, Vec<Spanned<Directive>>) =
(std::mem::take(self.as_vec_mut()), Vec::new());
(
crate::Directives::new_unchecked(booked),
crate::phase::FailedBookings::new(failed),
)
}
}
impl crate::Directives<crate::Booked> {
pub(crate) fn apply_regular_plugins(
mut self,
plugins: &[crate::Plugin],
file_options: &crate::Options,
options: &LoadOptions,
source_map: &SourceMap,
errors: &mut Vec<LedgerError>,
) -> Result<crate::Directives<crate::RegularPluginsApplied>, ProcessError> {
#[cfg(feature = "plugins")]
run_plugins(
self.as_vec_mut(),
plugins,
file_options,
options,
source_map,
errors,
PluginPass::PostBooking,
)?;
#[cfg(not(feature = "plugins"))]
{
let _ = (plugins, file_options, options, source_map, errors);
}
Ok(crate::Directives::new_unchecked(std::mem::take(
self.as_vec_mut(),
)))
}
}
impl crate::Directives<crate::RegularPluginsApplied> {
#[cfg(feature = "validation")]
pub(crate) fn late_validate(
mut self,
validation_session: Option<
rustledger_validate::ValidationSession<rustledger_validate::EarlyDone>,
>,
today: rustledger_core::NaiveDate,
source_map: &SourceMap,
errors: &mut Vec<LedgerError>,
) -> crate::Directives<crate::LateValidated> {
if let Some(session) = validation_session {
let (session, phase_errors) = session.run_late_spanned(self.as_slice(), today);
ledger_errors_extend(errors, phase_errors, source_map);
let finalize_errors = session.finalize();
ledger_errors_extend(errors, finalize_errors, source_map);
}
crate::Directives::new_unchecked(std::mem::take(self.as_vec_mut()))
}
#[cfg(not(feature = "validation"))]
pub(crate) fn late_validate(
mut self,
source_map: &SourceMap,
errors: &mut Vec<LedgerError>,
) -> crate::Directives<crate::LateValidated> {
let _ = (source_map, errors);
crate::Directives::new_unchecked(std::mem::take(self.as_vec_mut()))
}
}
impl crate::Directives<crate::LateValidated> {
pub(crate) fn finalize(
mut self,
failed: crate::phase::FailedBookings,
) -> crate::Directives<crate::Finalized> {
let mut v = std::mem::take(self.as_vec_mut());
v.extend(failed.into_inner());
v.sort_by_key(canonical_sort_key);
crate::Directives::new_unchecked(v)
}
}
#[cfg(feature = "booking")]
fn run_booking(
mut directives: Vec<Spanned<Directive>>,
booking_method: BookingMethod,
errors: &mut Vec<LedgerError>,
) -> (Vec<Spanned<Directive>>, Vec<Spanned<Directive>>) {
use rustledger_booking::BookingEngine;
let mut engine = BookingEngine::with_method(booking_method);
engine.register_account_methods(directives.iter().map(|s| &s.value));
let mut order: Vec<usize> = (0..directives.len()).collect();
order.sort_by_key(|&i| {
let d = &directives[i].value;
(d.date(), d.priority(), d.has_cost_reduction())
});
let mut failed_indices: Vec<usize> = Vec::new();
for &i in &order {
let spanned = &mut directives[i];
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),
));
failed_indices.push(i);
}
}
}
}
let failed_set: rustc_hash::FxHashSet<usize> = failed_indices.iter().copied().collect();
let mut booked = Vec::with_capacity(directives.len() - failed_indices.len());
let mut failed = Vec::with_capacity(failed_indices.len());
for (i, d) in directives.into_iter().enumerate() {
if failed_set.contains(&i) {
failed.push(d);
} else {
booked.push(d);
}
}
(booked, failed)
}
#[cfg(feature = "plugins")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginPass {
PreBookingSynth,
PostBooking,
}
#[cfg(feature = "plugins")]
struct PluginInvocation {
name: String,
config: Option<String>,
force_python: bool,
}
#[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>,
pass: PluginPass,
) -> Result<(), ProcessError> {
use rustledger_plugin::{NativePlugin, NativePluginRegistry, PluginInput, PluginOptions};
let base_dir = source_map
.files()
.first()
.and_then(|f| f.path.parent())
.unwrap_or_else(|| std::path::Path::new("."));
let registry = NativePluginRegistry::global();
let mut entries: Vec<PluginInvocation> = Vec::new();
if matches!(pass, PluginPass::PreBookingSynth) {
if options.auto_accounts {
entries.push(PluginInvocation {
name: rustledger_plugin::AUTO_ACCOUNTS_NAME.to_string(),
config: None,
force_python: false,
});
}
if options.run_plugins && !file_options.documents.is_empty() {
let resolved: Vec<String> = 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();
entries.push(PluginInvocation {
name: rustledger_plugin::DOCUMENT_DISCOVERY_NAME.to_string(),
config: Some(rustledger_plugin::document_discovery_config(
base_dir, &resolved,
)),
force_python: false,
});
}
}
let want_synth = matches!(pass, PluginPass::PreBookingSynth);
if options.run_plugins {
for plugin in file_plugins {
if registry.find_synth(&plugin.name).is_some() == want_synth {
entries.push(PluginInvocation {
name: plugin.name.clone(),
config: plugin.config.clone(),
force_python: plugin.force_python,
});
}
}
}
for extra in &options.extra_plugins {
if registry.find_synth(&extra.name).is_some() == want_synth {
entries.push(PluginInvocation {
name: extra.name.clone(),
config: extra.config.clone(),
force_python: false,
});
}
}
if entries.is_empty() {
return Ok(());
}
let plugin_options = PluginOptions {
operating_currencies: file_options.operating_currency.clone(),
title: file_options.title.clone(),
};
for invocation in &entries {
let PluginInvocation {
name: raw_name,
config: plugin_config,
force_python,
} = invocation;
let native_plugin: Option<&dyn NativePlugin> = if *force_python {
None
} else {
match pass {
PluginPass::PreBookingSynth => registry
.find_synth(raw_name)
.map(|p| p as &dyn NativePlugin),
PluginPass::PostBooking => registry
.find_regular(raw_name)
.map(|p| p as &dyn NativePlugin),
}
};
if let Some(plugin) = native_plugin {
let wrappers = build_wrappers(directives, source_map);
let input = PluginInput {
directives: wrappers,
options: plugin_options.clone(),
config: plugin_config.clone(),
};
let output = plugin.process(input);
record_plugin_errors(errors, output.errors, source_map);
apply_plugin_ops(directives, output.ops, errors, source_map)?;
} 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();
#[cfg_attr(
not(any(feature = "wasm-plugins", feature = "python-plugins")),
allow(unused_variables)
)]
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;
}
};
let wrappers = build_wrappers(directives, source_map);
match run_wasm_plugin(&wasm_path, &wrappers, &plugin_options, plugin_config) {
Ok((ops, plugin_errors)) => {
for err in plugin_errors {
errors.push(err);
}
apply_plugin_ops(directives, ops, errors, source_map)?;
}
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 '{raw_name}' requires the wasm-plugins feature"),
)
.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;
}
};
let wrappers = build_wrappers(directives, source_map);
match run_python_plugin(
raw_name,
&resolved,
base_dir,
&wrappers,
&plugin_options,
plugin_config,
) {
Ok((ops, plugin_errors)) => {
for err in plugin_errors {
errors.push(err);
}
apply_plugin_ops(directives, ops, errors, source_map)?;
}
Err(e) => {
errors.push(LedgerError::error("E8002", e).with_phase("plugin"));
}
}
}
#[cfg(not(feature = "python-plugins"))]
{
errors.push(
LedgerError::error(
"E8005",
format!(
"Python plugin \"{raw_name}\" requires the python-plugins feature",
),
)
.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"),
);
}
}
}
}
Ok(())
}
#[cfg(feature = "plugins")]
fn build_wrappers(
directives: &[Spanned<Directive>],
source_map: &SourceMap,
) -> Vec<rustledger_plugin::DirectiveWrapper> {
use rustledger_plugin::directive_to_wrapper_with_location;
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()
}
#[cfg(feature = "plugins")]
fn record_plugin_errors(
errors: &mut Vec<LedgerError>,
plugin_errors: Vec<rustledger_plugin::PluginError>,
source_map: &SourceMap,
) {
for err in plugin_errors {
let mut 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")
}
};
if let (Some(file), Some(line)) = (&err.source_file, err.line_number) {
let resolved_path = source_map
.get_by_path(std::path::Path::new(file))
.map_or_else(|| std::path::PathBuf::from(file), |f| f.path.clone());
ledger_err = ledger_err.with_location(ErrorLocation {
file: resolved_path,
line: line as usize,
column: 1,
});
}
errors.push(ledger_err);
}
}
#[cfg(feature = "plugins")]
fn apply_plugin_ops(
directives: &mut Vec<Spanned<Directive>>,
ops: Vec<rustledger_plugin::PluginOp>,
errors: &mut Vec<LedgerError>,
source_map: &SourceMap,
) -> Result<(), ProcessError> {
use rustledger_plugin::PluginOp;
use rustledger_plugin::wrapper_to_directive;
let n = directives.len();
let mut seen = vec![false; n];
for op in &ops {
let idx = match op {
PluginOp::Keep(i) | PluginOp::Modify(i, _) | PluginOp::Delete(i) => Some(*i),
PluginOp::Insert(_) => None,
};
if let Some(i) = idx {
if i >= n {
errors.push(
LedgerError::error(
"PLUGIN",
format!(
"plugin op references out-of-bounds input index {i} (input has {n} directives)"
),
)
.with_phase("plugin"),
);
return Ok(());
}
if seen[i] {
errors.push(
LedgerError::error(
"PLUGIN",
format!("plugin op references input index {i} more than once"),
)
.with_phase("plugin"),
);
return Ok(());
}
seen[i] = true;
}
}
for (i, was_seen) in seen.iter().enumerate() {
if !was_seen {
errors.push(
LedgerError::error(
"PLUGIN",
format!(
"plugin omitted input directive {i} (must appear in exactly one of Keep/Modify/Delete)"
),
)
.with_phase("plugin"),
);
return Ok(());
}
}
let mut new_directives = Vec::with_capacity(ops.len());
for op in ops {
match op {
PluginOp::Keep(i) => {
new_directives.push(directives[i].clone());
}
PluginOp::Modify(i, wrapper) => {
let mut directive = wrapper_to_directive(&wrapper)
.map_err(|e| ProcessError::PluginConversion(e.to_string()))?;
sanitize_inner_posting_spans(&mut directive, source_map);
new_directives.push(Spanned {
value: directive,
span: directives[i].span,
file_id: directives[i].file_id,
});
}
PluginOp::Insert(wrapper) => {
let (span, file_id) = match (&wrapper.filename, wrapper.lineno) {
(Some(filename), Some(lineno)) => {
if let Some(file) = source_map.get_by_path(std::path::Path::new(filename)) {
let span_start = file.line_start(lineno as usize).unwrap_or(0);
(
rustledger_parser::Span::new(span_start, span_start),
file.id as u16,
)
} else {
(
rustledger_parser::Span::ZERO,
rustledger_parser::SYNTHESIZED_FILE_ID,
)
}
}
_ => (
rustledger_parser::Span::ZERO,
rustledger_parser::SYNTHESIZED_FILE_ID,
),
};
let mut directive = wrapper_to_directive(&wrapper)
.map_err(|e| ProcessError::PluginConversion(e.to_string()))?;
sanitize_inner_posting_spans(&mut directive, source_map);
new_directives.push(Spanned::new(directive, span).with_file_id(file_id as usize));
}
PluginOp::Delete(_) => {}
}
}
*directives = new_directives;
Ok(())
}
#[cfg(feature = "plugins")]
fn sanitize_inner_posting_spans(directive: &mut Directive, source_map: &SourceMap) {
use rustledger_core::Span;
use rustledger_parser::SYNTHESIZED_FILE_ID;
if let Directive::Transaction(txn) = directive {
for p in &mut txn.postings {
let ok = if p.file_id == SYNTHESIZED_FILE_ID {
true
} else {
source_map
.get(p.file_id as usize)
.is_some_and(|f| p.span.start <= p.span.end && p.span.end <= f.source.len())
};
if !ok {
let inner = std::mem::replace(
&mut p.value,
rustledger_core::Posting::auto(rustledger_core::InternedStr::from("")),
);
*p = rustledger_core::Spanned::synthesized(inner);
} else if p.file_id == SYNTHESIZED_FILE_ID && p.span != Span::ZERO {
p.span = Span::ZERO;
}
}
}
}
#[cfg(feature = "validation")]
fn build_validation_options(
file_options: &Options,
source_map: &SourceMap,
default_booking_method: BookingMethod,
) -> rustledger_validate::ValidationOptions {
use rustledger_validate::ValidationOptions;
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();
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())
.with_default_booking_method(default_booking_method)
}
#[cfg(feature = "validation")]
fn ledger_errors_extend(
errors: &mut Vec<LedgerError>,
validation_errors: Vec<rustledger_validate::ValidationError>,
source_map: &SourceMap,
) {
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::DirectiveWrapper],
options: &rustledger_plugin::PluginOptions,
config: &Option<String>,
) -> Result<(Vec<rustledger_plugin::PluginOp>, 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.ops, 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::DirectiveWrapper],
options: &rustledger_plugin::PluginOptions,
config: &Option<String>,
) -> Result<(Vec<rustledger_plugin::PluginOp>, 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.ops, errors))
}
#[cfg(all(test, feature = "plugins"))]
mod sanitize_tests {
use super::sanitize_inner_posting_spans;
use crate::source_map::SourceMap;
use rust_decimal_macros::dec;
use rustledger_core::{
Amount, Directive, IncompleteAmount, Posting, SYNTHESIZED_FILE_ID, Span, Spanned,
Transaction,
};
use std::path::PathBuf;
use std::sync::Arc;
fn txn_with_postings(postings: Vec<Spanned<Posting>>) -> Directive {
let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
let mut txn = Transaction::new(date, "x");
txn.postings = postings;
Directive::Transaction(txn)
}
fn posting_at(file_id: u16, span: Span) -> Spanned<Posting> {
let p = Posting::with_incomplete(
"Assets:Cash",
IncompleteAmount::Complete(Amount::new(dec!(1), "USD")),
);
Spanned::new(p, span).with_file_id(file_id as usize)
}
fn source_map_with_one_file(source: &str) -> (SourceMap, u16) {
let mut sm = SourceMap::new();
let id = sm.add_file(PathBuf::from("test.bean"), Arc::from(source));
(sm, id as u16)
}
#[test]
fn span_within_real_file_is_preserved() {
let (sm, fid) = source_map_with_one_file("0123456789");
let mut d = txn_with_postings(vec![posting_at(fid, Span::new(2, 6))]);
sanitize_inner_posting_spans(&mut d, &sm);
let Directive::Transaction(t) = &d else {
unreachable!()
};
assert_eq!(t.postings[0].file_id, fid);
assert_eq!(t.postings[0].span, Span::new(2, 6));
}
#[test]
fn span_past_eof_is_reset_to_synthesized() {
let (sm, fid) = source_map_with_one_file("0123456789"); let mut d = txn_with_postings(vec![posting_at(fid, Span::new(0, 9999))]);
sanitize_inner_posting_spans(&mut d, &sm);
let Directive::Transaction(t) = &d else {
unreachable!()
};
assert_eq!(t.postings[0].file_id, SYNTHESIZED_FILE_ID);
assert_eq!(t.postings[0].span, Span::ZERO);
}
#[test]
fn unknown_file_id_is_reset_to_synthesized() {
let (sm, _real) = source_map_with_one_file("hello");
let mut d = txn_with_postings(vec![posting_at(123, Span::new(0, 5))]);
sanitize_inner_posting_spans(&mut d, &sm);
let Directive::Transaction(t) = &d else {
unreachable!()
};
assert_eq!(t.postings[0].file_id, SYNTHESIZED_FILE_ID);
assert_eq!(t.postings[0].span, Span::ZERO);
}
#[test]
fn start_after_end_is_reset_to_synthesized() {
let (sm, fid) = source_map_with_one_file("abcdef");
let mut d = txn_with_postings(vec![posting_at(fid, Span::new(5, 2))]);
sanitize_inner_posting_spans(&mut d, &sm);
let Directive::Transaction(t) = &d else {
unreachable!()
};
assert_eq!(t.postings[0].file_id, SYNTHESIZED_FILE_ID);
assert_eq!(t.postings[0].span, Span::ZERO);
}
#[test]
fn synthesized_file_id_is_left_alone_but_span_normalized() {
let (sm, _fid) = source_map_with_one_file("x");
let mut d = txn_with_postings(vec![posting_at(SYNTHESIZED_FILE_ID, Span::new(100, 200))]);
sanitize_inner_posting_spans(&mut d, &sm);
let Directive::Transaction(t) = &d else {
unreachable!()
};
assert_eq!(t.postings[0].file_id, SYNTHESIZED_FILE_ID);
assert_eq!(t.postings[0].span, Span::ZERO, "synth span normalized");
}
#[test]
fn boundary_span_eq_source_len_is_valid() {
let (sm, fid) = source_map_with_one_file("abcd");
let mut d = txn_with_postings(vec![posting_at(fid, Span::new(0, 4))]);
sanitize_inner_posting_spans(&mut d, &sm);
let Directive::Transaction(t) = &d else {
unreachable!()
};
assert_eq!(t.postings[0].file_id, fid);
assert_eq!(t.postings[0].span, Span::new(0, 4));
}
#[test]
fn non_transaction_directive_is_left_alone() {
let (sm, _fid) = source_map_with_one_file("x");
let mut d = Directive::Open(rustledger_core::Open {
date: rustledger_core::naive_date(2024, 1, 1).unwrap(),
account: "Assets:Bank".into(),
currencies: vec![],
booking: None,
meta: Default::default(),
});
sanitize_inner_posting_spans(&mut d, &sm); assert!(matches!(d, Directive::Open(_)));
}
}