use std::collections::{HashMap, HashSet};
use rustledger_core::Directive;
use rustledger_parser::{Spanned, parse as parse_beancount};
use crate::types::{Error, Include, LedgerOptions, Plugin};
pub struct LineLookup {
line_starts: Vec<usize>,
}
impl LineLookup {
pub fn new(source: &str) -> Self {
let mut line_starts = vec![0];
for (i, c) in source.char_indices() {
if c == '\n' {
line_starts.push(i + 1);
}
}
Self { line_starts }
}
pub fn byte_to_line(&self, byte_offset: usize) -> u32 {
match self.line_starts.binary_search(&byte_offset) {
Ok(line) => line as u32 + 1,
Err(line) => line as u32,
}
}
}
pub struct PrecisionTracker {
counts: HashMap<String, HashMap<u32, u32>>,
}
impl PrecisionTracker {
pub fn new() -> Self {
Self {
counts: HashMap::new(),
}
}
pub fn observe(&mut self, currency: &str, number: rustledger_core::Decimal) {
let precision = number.scale();
let currency_counts = self.counts.entry(currency.to_string()).or_default();
*currency_counts.entry(precision).or_insert(0) += 1;
}
pub fn most_common_precision(&self) -> HashMap<String, u32> {
self.counts
.iter()
.map(|(currency, counts)| {
let precision = counts
.iter()
.max_by_key(|(_, count)| *count)
.map_or(2, |(prec, _)| *prec);
(currency.clone(), precision)
})
.collect()
}
}
impl Default for PrecisionTracker {
fn default() -> Self {
Self::new()
}
}
pub struct LoadResult {
pub directives: Vec<Directive>,
pub spanned_directives: Vec<Spanned<Directive>>,
pub directive_lines: Vec<u32>,
pub line_lookup: LineLookup,
pub errors: Vec<Error>,
pub options: LedgerOptions,
pub plugins: Vec<Plugin>,
pub includes: Vec<Include>,
}
pub fn load_source(source: &str) -> LoadResult {
let lookup = LineLookup::new(source);
let parse_result = parse_beancount(source);
let includes: Vec<Include> = parse_result
.includes
.iter()
.map(|(path, span)| Include {
path: path.clone(),
lineno: lookup.byte_to_line(span.start),
})
.collect();
let mut vfs = rustledger_loader::VirtualFileSystem::new();
vfs.add_file("<source>", source);
let load_opts = rustledger_loader::LoadOptions {
validate: false,
..Default::default()
};
let ledger = match rustledger_loader::Loader::new()
.with_filesystem(Box::new(vfs))
.load(std::path::Path::new("<source>"))
.map_err(|e| e.to_string())
.and_then(|raw| rustledger_loader::process(raw, &load_opts).map_err(|e| e.to_string()))
{
Ok(ledger) => ledger,
Err(e) => {
return LoadResult {
directives: Vec::new(),
spanned_directives: Vec::new(),
directive_lines: Vec::new(),
line_lookup: lookup,
errors: vec![Error::new(e).validate_phase()],
options: LedgerOptions::default(),
plugins: Vec::new(),
includes,
};
}
};
let mut directives: Vec<Directive> = Vec::new();
let mut directive_lines: Vec<u32> = Vec::new();
let mut commodities: HashSet<String> = HashSet::new();
let mut precision_tracker = PrecisionTracker::new();
for spanned in &ledger.directives {
let line = if ledger.source_map.get(spanned.file_id as usize).is_some() {
lookup.byte_to_line(spanned.span.start)
} else {
0
};
directive_lines.push(line);
match &spanned.value {
Directive::Open(o) => {
for c in &o.currencies {
commodities.insert(c.to_string());
}
}
Directive::Commodity(c) => {
commodities.insert(c.currency.to_string());
}
Directive::Transaction(t) => {
for p in &t.postings {
if let Some(units) = &p.units
&& let Some(amt) = units.as_amount()
{
commodities.insert(amt.currency.to_string());
precision_tracker.observe(amt.currency.as_ref(), amt.number);
}
if let Some(price) = &p.price
&& let Some(amt) = price.amount()
{
commodities.insert(amt.currency.to_string());
precision_tracker.observe(amt.currency.as_ref(), amt.number);
}
}
}
Directive::Balance(b) => {
commodities.insert(b.amount.currency.to_string());
precision_tracker.observe(b.amount.currency.as_ref(), b.amount.number);
}
Directive::Price(p) => {
commodities.insert(p.currency.to_string());
commodities.insert(p.amount.currency.to_string());
precision_tracker.observe(p.amount.currency.as_ref(), p.amount.number);
}
_ => {}
}
directives.push(spanned.value.clone());
}
let normalized_includes: Vec<String> =
includes.iter().map(|i| i.path.replace('\\', "/")).collect();
let errors: Vec<Error> = ledger
.errors
.iter()
.filter(|e| {
if e.phase != "parse"
|| !(e.message.contains("not found") || e.message.contains("does not match"))
{
return true;
}
let msg = e.message.replace('\\', "/");
!normalized_includes.iter().any(|p| msg.contains(p))
})
.map(ledger_error_to_ffi)
.collect();
let mut options = build_ledger_options(&ledger.options);
let mut commodity_list: Vec<_> = commodities.into_iter().collect();
commodity_list.sort();
options.commodities = commodity_list;
options.display_precision = precision_tracker.most_common_precision();
let plugins: Vec<Plugin> = ledger
.plugins
.iter()
.map(|p| Plugin {
name: p.name.clone(),
config: p.config.clone(),
})
.collect();
LoadResult {
directives,
spanned_directives: ledger.directives,
directive_lines,
line_lookup: lookup,
errors,
options,
plugins,
includes,
}
}
#[must_use]
pub fn build_ledger_options(options: &rustledger_loader::Options) -> LedgerOptions {
LedgerOptions {
title: options.title.clone(),
operating_currency: options.operating_currency.clone(),
name_assets: options.name_assets.clone(),
name_liabilities: options.name_liabilities.clone(),
name_equity: options.name_equity.clone(),
name_income: options.name_income.clone(),
name_expenses: options.name_expenses.clone(),
documents: options.documents.clone(),
commodities: Vec::new(),
booking_method: options.booking_method.clone(),
display_precision: options
.display_precision
.iter()
.map(|(k, v)| (k.clone(), *v))
.collect(),
render_commas: options.render_commas,
inferred_tolerance_default: options
.inferred_tolerance_default
.iter()
.map(|(k, v)| (k.clone(), v.to_string()))
.collect(),
inferred_tolerance_multiplier: options.inferred_tolerance_multiplier.to_string(),
infer_tolerance_from_cost: options.infer_tolerance_from_cost,
account_rounding: options.account_rounding.clone(),
account_previous_balances: options.account_previous_balances.clone(),
account_previous_earnings: options.account_previous_earnings.clone(),
account_previous_conversions: options.account_previous_conversions.clone(),
account_current_earnings: options.account_current_earnings.clone(),
account_current_conversions: options.account_current_conversions.clone(),
account_unrealized_gains: options.account_unrealized_gains.clone(),
conversion_currency: options.conversion_currency.clone(),
}
}
pub struct FileLoad {
pub directives: Vec<Directive>,
pub directive_lines: Vec<u32>,
pub directive_files: Vec<String>,
pub errors: Vec<Error>,
pub options: LedgerOptions,
pub plugins: Vec<Plugin>,
pub loaded_files: Vec<String>,
}
#[must_use]
pub fn expand_pads<T: Clone>(
directives: Vec<Directive>,
tags: Vec<T>,
synth_tag: &T,
) -> (Vec<Directive>, Vec<T>) {
let pads = rustledger_booking::process_pads(&directives).padding_transactions;
let mut pairs: Vec<(Directive, T)> = Vec::with_capacity(directives.len() + pads.len());
for txn in pads {
pairs.push((Directive::Transaction(txn), synth_tag.clone()));
}
pairs.extend(directives.into_iter().zip(tags));
pairs.sort_by_key(|(d, _)| d.date());
pairs.into_iter().unzip()
}
pub fn load_file(path: &std::path::Path, path_security: bool) -> Result<FileLoad, String> {
load_file_with_fs(path, path_security, None)
}
pub fn load_file_with_fs(
path: &std::path::Path,
path_security: bool,
fs: Option<Box<dyn rustledger_loader::FileSystem>>,
) -> Result<FileLoad, String> {
let options = rustledger_loader::LoadOptions {
path_security,
validate: false,
..Default::default()
};
let ledger = match fs {
Some(fs) => rustledger_loader::load_with_fs(path, &options, fs),
None => rustledger_loader::load(path, &options),
}
.map_err(|e| format!("Failed to load file: {e}"))?;
let mut directives: Vec<Directive> = Vec::new();
let mut directive_lines: Vec<u32> = Vec::new();
let mut directive_files: Vec<String> = Vec::new();
for spanned in &ledger.directives {
directives.push(spanned.value.clone());
let file_id = spanned.file_id as usize;
if let Some(sf) = ledger.source_map.get(file_id) {
let (line, _col) = sf.line_col(spanned.span.start);
directive_lines.push(line as u32);
directive_files.push(sf.path.display().to_string());
} else {
directive_lines.push(0);
directive_files.push("<unknown>".to_string());
}
}
let errors: Vec<Error> = ledger.errors.iter().map(ledger_error_to_ffi).collect();
let options = build_ledger_options(&ledger.options);
let plugins: Vec<Plugin> = ledger
.plugins
.iter()
.map(|p| Plugin {
name: p.name.clone(),
config: p.config.clone(),
})
.collect();
let loaded_files: Vec<String> = ledger
.source_map
.files()
.iter()
.map(|sf| sf.path.display().to_string())
.collect();
Ok(FileLoad {
directives,
directive_lines,
directive_files,
errors,
options,
plugins,
loaded_files,
})
}
fn ledger_error_to_ffi(e: &rustledger_loader::LedgerError) -> Error {
let mut err = Error::new(e.message.clone());
if let Some(loc) = &e.location {
err = err.with_line(loc.line as u32);
}
if e.phase != "parse" {
err = err.validate_phase();
}
if matches!(e.severity, rustledger_loader::ErrorSeverity::Warning) {
err.severity = "warning".to_string();
}
err
}
#[must_use]
pub fn apply_plugins(
plugin_names: &[&str],
mut directives: Vec<Directive>,
mut directive_lines: Vec<u32>,
mut directive_files: Vec<String>,
errors: &mut Vec<Error>,
options: &LedgerOptions,
) -> (Vec<Directive>, Vec<u32>, Vec<String>) {
use rustledger_plugin::{
NativePluginRegistry, PluginInput, PluginOptions, directive_to_wrapper,
wrapper_to_directive,
};
if plugin_names.is_empty() || !errors.is_empty() {
return (directives, directive_lines, directive_files);
}
let registry = NativePluginRegistry::global();
for plugin_name in plugin_names {
let Some(plugin) = registry.find_regular(plugin_name) else {
errors.push(Error::new(format!("Unknown plugin: {plugin_name}")));
continue;
};
let wrappers: Vec<_> = directives
.iter()
.enumerate()
.map(|(i, d)| {
let mut wrapper = directive_to_wrapper(d);
wrapper.filename = Some(
directive_files
.get(i)
.cloned()
.unwrap_or_else(|| "<unknown>".to_string()),
);
wrapper.lineno = Some(directive_lines.get(i).copied().unwrap_or(0));
wrapper
})
.collect();
let input = PluginInput {
directives: wrappers,
options: PluginOptions {
operating_currencies: options.operating_currency.clone(),
title: options.title.clone(),
},
config: None,
};
let input_dirs = input.directives.clone();
let output = plugin.process(input);
for err in output.errors {
errors.push(Error::new(err.message));
}
if let Err(msg) = rustledger_plugin::validate_op_coverage(directives.len(), &output.ops) {
errors.push(Error::new(format!("plugin '{plugin_name}': {msg}")));
continue;
}
let mut new_directives = Vec::new();
let mut new_lines = Vec::new();
let mut new_files = Vec::new();
for op in &output.ops {
let wrapper = match op {
rustledger_plugin::PluginOp::Keep(i) => input_dirs.get(*i).cloned(),
rustledger_plugin::PluginOp::Modify(_, w)
| rustledger_plugin::PluginOp::Insert(w) => Some(w.clone()),
rustledger_plugin::PluginOp::Delete(_) => None,
};
if let Some(wrapper) = wrapper
&& let Ok(directive) = wrapper_to_directive(&wrapper)
{
new_directives.push(directive);
new_lines.push(wrapper.lineno.unwrap_or(0));
new_files.push(wrapper.filename.unwrap_or_else(|| "<plugin>".to_string()));
}
}
directives = new_directives;
directive_lines = new_lines;
directive_files = new_files;
}
(directives, directive_lines, directive_files)
}
pub use rustledger_core::{ACCOUNT_TYPES, account_type};
#[cfg(test)]
mod tests {
use super::*;
const PAD_LEDGER: &str = "\
option \"operating_currency\" \"USD\"
2020-01-01 open Assets:SomeName USD
2020-01-01 open Equity:Opening-balances
2024-01-20 pad Assets:SomeName Equity:Opening-balances
2024-01-21 balance Assets:SomeName 42 USD
";
#[test]
fn expand_pads_materializes_padding_transaction() {
let load = load_source(PAD_LEDGER);
assert!(!load.directives.iter().any(|d| matches!(
d,
Directive::Transaction(t) if rustledger_booking::is_synthesized_pad(t)
)));
let raw_len = load.directives.len();
let (expanded, lines) = expand_pads(load.directives, load.directive_lines, &0u32);
assert_eq!(expanded.len(), raw_len + 1);
assert_eq!(expanded.len(), lines.len());
let synth = expanded
.iter()
.filter(|d| matches!(d, Directive::Transaction(t) if rustledger_booking::is_synthesized_pad(t)))
.count();
assert_eq!(synth, 1, "expected exactly one synthesized Padding txn");
}
}