diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f267c..8d28446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog Changelog file for dinero-rs project, a command line application for managing finances. +## [0.12.0] - 2021-02-xx (planned) +### Added +- support for (some of the) automated transaction syntax, what Claudio uses in his personal ledger +### Fixed +- speed bump (from 44 seconds to 7 seconds) in a big personal ledger ## [0.11.1] - 2021-02-22 ### Fixed - Fixed bug in balance report diff --git a/Cargo.toml b/Cargo.toml index 3c64795..4d0ee50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,10 +12,10 @@ repository = "https://github.com/frosklis/dinero-rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] name = "dinero" -path = "src/lib/mod.rs" +path = "src/mod.rs" [[bin]] name = "dinero" -test = false +test = false bench = false path = "src/main.rs" diff --git a/examples/demo.ledger b/examples/demo.ledger index a08cb34..402e4ee 100644 --- a/examples/demo.ledger +++ b/examples/demo.ledger @@ -14,7 +14,7 @@ 2021-01-15 * Flights ; destination: spain - Expenses:Travel 200 EUR + Expenses:Unknown 200 EUR ; this will get translated to Expenses:Flights Assets:Bank:Checking account $-210.12 2021-01-16 * Alphabet Inc. @@ -34,3 +34,7 @@ commodity € commodity USD alias $ P 2021-01-23 AAPL 139.07 USD +payee ACME, inc. + alias (?i)(.*acme.*) +account Expenses:Travel + payee Flights diff --git a/src/lib/main.rs b/src/app.rs similarity index 96% rename from src/lib/main.rs rename to src/app.rs index 0311be4..3bff06c 100644 --- a/src/lib/main.rs +++ b/src/app.rs @@ -9,7 +9,7 @@ use two_timer; use lazy_static::lazy_static; use regex::Regex; -use crate::commands::{accounts, balance, check, commodities, prices, register}; +use crate::commands::{accounts, balance, check, commodities, payees, prices, register}; use crate::Error; use chrono::NaiveDate; use colored::Colorize; @@ -35,7 +35,8 @@ enum Command { /// List the accounts Accounts(CommonOpts), // Codes, - // Payees, + /// List the payees + Payees(CommonOpts), /// Show the exchange rates Prices(CommonOpts), /// List commodities @@ -203,6 +204,13 @@ pub fn run_app(mut args: Vec) -> Result<(), ()> { commodities::execute(options.input_file, options.no_balance_check) } + Command::Payees(options) => { + if options.force_color { + env::set_var("CLICOLOR_FORCE", "1"); + } + + payees::execute(options.input_file, options.no_balance_check) + } Command::Prices(options) => prices::execute(options.input_file, options.no_balance_check), Command::Accounts(options) => { if options.force_color { @@ -223,7 +231,7 @@ pub fn run_app(mut args: Vec) -> Result<(), ()> { } /// A parser for date expressions -fn date_parser(date: &str) -> Result { +pub fn date_parser(date: &str) -> Result { lazy_static! { static ref RE_MONTH: Regex = Regex::new(r"(\d{4})[/-](\d\d?)$").unwrap(); static ref RE_DATE: Regex = Regex::new(r"(\d{4})[/-](\d\d?)[/-](\d\d?)$").unwrap(); diff --git a/src/lib/commands/accounts.rs b/src/commands/accounts.rs similarity index 100% rename from src/lib/commands/accounts.rs rename to src/commands/accounts.rs diff --git a/src/lib/commands/balance.rs b/src/commands/balance.rs similarity index 97% rename from src/lib/commands/balance.rs rename to src/commands/balance.rs index 192c45d..5eeb17d 100644 --- a/src/lib/commands/balance.rs +++ b/src/commands/balance.rs @@ -20,13 +20,13 @@ pub fn execute(options: &CommonOpts, flat: bool, show_total: bool) -> Result<(), let no_balance_check = options.no_balance_check; let mut tokenizer: Tokenizer = Tokenizer::from(&path); let items = tokenizer.tokenize()?; - let ledger = items.to_ledger(no_balance_check)?; + let mut ledger = items.to_ledger(no_balance_check)?; let mut balances: HashMap, Balance> = HashMap::new(); for t in ledger.transactions.iter() { for p in t.postings_iter() { - if !filter::filter(&options, t, p) { + if !filter::filter(&options, t, p, &mut ledger.commodities)? { continue; } let mut cur_bal = balances @@ -140,7 +140,7 @@ pub fn execute(options: &CommonOpts, flat: bool, show_total: bool) -> Result<(), let mut text = account.split(":").last().unwrap().to_string(); // This is where it gets tricky, we need to collapse while we can let mut collapse = true; - 'outer: loop { + loop { if (index + 1) >= num_bal { break; } @@ -153,7 +153,7 @@ pub fn execute(options: &CommonOpts, flat: bool, show_total: bool) -> Result<(), break; } let this_depth = name.split(":").count(); - if (this_depth == n + 1) { + if this_depth == n + 1 { collapse = false; break; } diff --git a/src/lib/commands/check.rs b/src/commands/check.rs similarity index 100% rename from src/lib/commands/check.rs rename to src/commands/check.rs diff --git a/src/lib/commands/commodities.rs b/src/commands/commodities.rs similarity index 100% rename from src/lib/commands/commodities.rs rename to src/commands/commodities.rs diff --git a/src/lib/commands/mod.rs b/src/commands/mod.rs similarity index 86% rename from src/lib/commands/mod.rs rename to src/commands/mod.rs index 68c50f2..2310ee2 100644 --- a/src/lib/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,5 +2,6 @@ pub mod accounts; pub mod balance; pub mod check; pub mod commodities; +pub mod payees; pub mod prices; pub mod register; diff --git a/src/commands/payees.rs b/src/commands/payees.rs new file mode 100644 index 0000000..273bdf6 --- /dev/null +++ b/src/commands/payees.rs @@ -0,0 +1,21 @@ +use crate::models::{HasName, Payee}; +use crate::parser::Tokenizer; +use crate::Error; +use std::ops::Deref; +use std::path::PathBuf; + +pub fn execute(path: PathBuf, no_balance_check: bool) -> Result<(), Error> { + let mut tokenizer: Tokenizer = Tokenizer::from(&path); + let items = tokenizer.tokenize()?; + let ledger = items.to_ledger(no_balance_check)?; + let mut payees = ledger + .payees + .iter() + .map(|x| x.1.deref().to_owned()) + .collect::>(); + payees.sort_by(|a, b| a.get_name().cmp(b.get_name())); + for payee in payees.iter() { + println!("{}", payee); + } + Ok(()) +} diff --git a/src/lib/commands/prices.rs b/src/commands/prices.rs similarity index 100% rename from src/lib/commands/prices.rs rename to src/commands/prices.rs diff --git a/src/lib/commands/register.rs b/src/commands/register.rs similarity index 92% rename from src/lib/commands/register.rs rename to src/commands/register.rs index ee66e9d..e9bb350 100644 --- a/src/lib/commands/register.rs +++ b/src/commands/register.rs @@ -13,7 +13,7 @@ pub fn execute(options: &CommonOpts) -> Result<(), Error> { // Now work let mut tokenizer: Tokenizer = Tokenizer::from(&path); let items = tokenizer.tokenize()?; - let ledger = items.to_ledger(no_balance_check)?; + let mut ledger = items.to_ledger(no_balance_check)?; let mut balance = Balance::new(); @@ -36,7 +36,7 @@ pub fn execute(options: &CommonOpts) -> Result<(), Error> { for t in ledger.transactions.iter() { let mut counter = 0; for p in t.postings_iter() { - if !filter::filter(&options, t, p) { + if !filter::filter(&options, t, p, &mut ledger.commodities)? { continue; } counter += 1; @@ -44,7 +44,10 @@ pub fn execute(options: &CommonOpts) -> Result<(), Error> { print!( "{:w1$}{:width$}", format!("{}", t.date.unwrap()), - clip(&t.description, w_description), + clip( + &format!("{} ", t.get_payee(&mut ledger.payees)), + w_description + ), width = w_description, w1 = w_date ); diff --git a/src/lib/error.rs b/src/error.rs similarity index 100% rename from src/lib/error.rs rename to src/error.rs diff --git a/src/filter.rs b/src/filter.rs new file mode 100644 index 0000000..c80b5c3 --- /dev/null +++ b/src/filter.rs @@ -0,0 +1,140 @@ +use crate::models::{Currency, Posting, PostingType, Transaction}; +use crate::parser::value_expr::{eval_expression, EvalResult}; +use crate::{CommonOpts, Error, List}; +use colored::Colorize; +use regex::Regex; +use std::collections::HashMap; + +pub fn filter( + options: &CommonOpts, + transaction: &Transaction, + posting: &Posting, + commodities: &mut List, +) -> Result { + // Get what's needed + let predicate = preprocess_query(&options.query); + let real = options.real; + + // Check for real postings + if real { + if let PostingType::Real = posting.kind { + } else { + return Ok(false); + } + } + + // Check for dates at the transaction level + // todo should do this at the posting level + if let Some(date) = options.end { + if transaction.date.unwrap() >= date { + return Ok(false); + } + } + if let Some(date) = options.begin { + if transaction.date.unwrap() < date { + return Ok(false); + } + } + + filter_predicate( + predicate.as_str(), + posting, + transaction, + commodities, + &mut HashMap::new(), + ) +} + +pub fn filter_predicate( + predicate: &str, + posting: &Posting, + transaction: &Transaction, + commodities: &mut List, + regexes: &mut HashMap, +) -> Result { + if (predicate.len() == 0) | (predicate == "()") { + return Ok(true); + } + let result = eval_expression(predicate, posting, transaction, commodities, regexes); + match result { + EvalResult::Boolean(b) => Ok(b), + _ => Err(Error { + message: vec![predicate.red().bold(), "should return a boolean".normal()], + }), + } +} + +/// Create search expression from Strings +/// +/// The command line arguments provide syntactic sugar which save time when querying the journal. +/// This expands it to an actual query +/// +/// # Examples +/// ```rust +/// # use dinero::filter::preprocess_query; +/// let params:Vec = vec!["@payee", "savings" , "and", "checking", "and", "expr", "/aeiou/"].iter().map(|x| x.to_string()).collect(); +/// let processed = preprocess_query(¶ms); +/// assert_eq!(processed, "((payee =~ /(?i)payee/) or (account =~ /(?i)savings/) and (account =~ /(?i)checking/) and (/aeiou/))") +/// ``` +pub fn preprocess_query(query: &Vec) -> String { + let mut expression = String::new(); + let mut and = false; + let mut first = true; + let mut expr = false; + for raw_term in query.iter() { + let term = raw_term.trim(); + if term.len() == 0 { + continue; + } + if term == "and" { + and = true; + continue; + } else if term == "or" { + and = false; + continue; + } else if term == "expr" { + expr = true; + continue; + } + let join_term = if !first { + if and { + " and (" + } else { + " or (" + } + } else { + "(" + }; + expression.push_str(join_term); + if expr { + expression.push_str(term); + } else if let Some(c) = term.chars().next() { + match c { + '@' => { + expression.push_str("payee =~ /(?i)"); // case insensitive + expression.push_str(&term.to_string()[1..]); + expression.push_str("/") + } + '%' => { + expression.push_str("has_tag(/(?i)"); // case insensitive + expression.push_str(&term.to_string()[1..]); + expression.push_str("/)") + } + '/' => { + expression.push_str("account =~ "); // case insensitive + expression.push_str(term); + } + _ => { + expression.push_str("account =~ /(?i)"); // case insensitive + expression.push_str(term); + expression.push_str("/") + } + } + } + expression.push_str(")"); + and = false; + expr = false; + first = false; + } + format!("({})", expression) +} diff --git a/src/grammar/expressions.pest b/src/grammar/expressions.pest new file mode 100644 index 0000000..0c5ee12 --- /dev/null +++ b/src/grammar/expressions.pest @@ -0,0 +1,68 @@ +// Grammar specification for value expressions + +// A value expression is an expression between parenthesis +value_expr = {"(" ~ ws* ~ expr ~ ws* ~ ")"} + +// Then the expression builds up in terms of increasing preference +expr = { or_expr } +or_expr = { and_expr ~ ws* ~ ( or ~ ws* ~ and_expr ) * } +and_expr = { comparison_expr ~ ws* ~ ( and ~ ws* ~ comparison_expr ) * } +comparison_expr = { additive_expr ~ ws* ~ ( comparison ~ ws* ~ additive_expr ) * } +additive_expr = { multiplicative_expr ~ ws* ~ ( add ~ ws* ~ multiplicative_expr ) * } +multiplicative_expr = { primary ~ ws* ~ ( mult ~ ws* ~ primary )* } +primary = { + ("(" ~ ws* ~ expr ~ ws* ~ ")") | + (unary ~ ws* ~ expr) | + term | + (function ~ ws* ~ "(" ~ ws* ~ expr ~ ws* ~ ("," ~ ws* ~ expr ~ ws*)* ~ ")") + } + + +term = _{ variable | money | number | regex | string } +money = { (number ~ ws* ~ currency) | (currency ~ ws* ~ number) } +currency = { LETTER+ | ("\"" ~ (!"\"" ~ ANY)+ ~ "\"")} +regex = { "/" ~ (!"/" ~ ANY)* ~ "/"} +string = { "'" ~ (!"'" ~ ANY)* ~ "'"} +variable = { + "account" | + "payee" | + "date" | + "note" | + "amount" | + "total_amount" | + "cost" | + "value" | + "gain" | + "depth" | + "posting_number" | + "posting_count" | + "cleared" | + "real" | + "not_automated" | + "running_total" | + "note" | + // Abbreviations go later + "T" | "N" | "O" | "Z" | "R" | "X" | + "n" | "l" | "g" | "v" | "b" +} + + +// helpers +number = { "-"? ~ bigint ~ ("." ~ bigint)? } +bigint = _{ ASCII_DIGIT+ } +ws = _{" "} + + +add = { "+" | "-" } +mult = { "*" | "/" } +and = {"&" | "and"} +or = {"|" | "or" } +unary = { "-" | "!" | "not" } +function = { "abs" | "has_tag" | "to_date" | "any" | "tag" } +comparison = { eq | ne | ge | gt | le | lt } +eq = { "=~" | "=="} +ne = { "!=" } +gt = { ">" } +ge = { ">=" } +le = { "<=" } +lt = { "<" } \ No newline at end of file diff --git a/src/grammar/value_expression.pest b/src/grammar/value_expression.pest deleted file mode 100644 index e380f4f..0000000 --- a/src/grammar/value_expression.pest +++ /dev/null @@ -1,65 +0,0 @@ -// Grammar specification for value expressions - -value_expr = {"(" ~ ws* ~ expr ~ ws* ~ ")"} -term = _{ variable | money | number } -money = { (number ~ ws* ~ currency) | (currency ~ ws* ~ number) } -currency = { LETTER+ | ("\"" ~ (!"\"" ~ ANY)+ ~ "\"")} -variable = _{ date | amount | total_amount | cost | value | gain | depth | posting_number | posting_count | cleared | real | not_automated | running_total } - -number = { "-"? ~ bigint ~ ("." ~ bigint)? } -bigint = _{ ASCII_DIGIT+ } -ws = _{" "} -/* Not yet implemented -ternary = { expr ~ ws* ~ "?" ~ ws* ~ expr ~ ws* ~ ":" ~ ws* ~ expr} -binary = { expr ~ ws* ~ - ("<=" | ">=" | "<" | ">" | "&" | "|" ) - ~ ws* ~ expr} - -*/ - -expr = { or_expr } -or_expr = { and_expr ~ ws* ~ ( or ~ ws* ~ and_expr ) * } -and_expr = { additive_expr ~ ws* ~ ( or ~ ws* ~ additive_expr ) * } -additive_expr = { multiplicative_expr ~ ws* ~ ( add ~ ws* ~ multiplicative_expr ) * } -multiplicative_expr = { primary ~ ws* ~ ( mult ~ ws* ~ primary )* } -primary = { - ("(" ~ ws* ~ expr ~ ws* ~ ")") | - term | - (unary ~ ws* ~ primary) | - (unary_function ~ ws* ~ "(" ~ expr ~ ws* ~ ")") - } - -add = { "+" | "-" } -mult = { "*" | "/" } -and = {"&"} -or = {"|"} -unary = { "-" | "!"} -unary_function = { "abs" } - -total_amount = {"T"} -// A posting’s date, as the number of seconds past the epoch. This is always “today” for an account. -date = {"d"} -// The posting’s amount; the balance of an account, without considering children. -amount = {"amount" | "t"} -// The cost of a posting; the cost of an account, without its children. -cost = {"b"} -// The market value of a posting or an account, without its children. -value = {"v"} -// The net gain (market value minus cost basis), for a posting or an account, without its children. It is the same as ‘v-b’. -gain = {"g"} -// The depth (“level”) of an account. If an account has one parent, its depth is one. -depth = {"l"} -// The index of a posting, or the count of postings affecting an account. -posting_number = {"n"} -// ‘1’ if a posting’s transaction has been cleared, ‘0’ otherwise. -cleared = {"X"} -// ‘1’ if a posting is not virtual, ‘0’ otherwise. -real = {"R"} -// ‘1’ if a posting is not automated, ‘0’ otherwise. -not_automated = {"Z"} - -// The total of all postings seen so far, or the total of an account and all its children. -running_total = {"O"} - -// The total count of postings affecting an account and all its children. -posting_count = {"N"} \ No newline at end of file diff --git a/src/lib/filter.rs b/src/lib/filter.rs deleted file mode 100644 index 593b1f2..0000000 --- a/src/lib/filter.rs +++ /dev/null @@ -1,54 +0,0 @@ -use crate::models::{HasName, Posting, PostingType, Transaction}; -use crate::CommonOpts; - -pub fn filter(options: &CommonOpts, transaction: &Transaction, posting: &Posting) -> bool { - // Get what's needed - let predicate = &options.query; - let real = options.real; - - // Check for real postings - if real { - if let PostingType::Real = posting.kind { - } else { - return false; - } - } - - // Check for dates at the transaction level - // todo should do this at the posting level - if let Some(date) = options.end { - if transaction.date.unwrap() >= date { - return false; - } - } - if let Some(date) = options.begin { - if transaction.date.unwrap() < date { - return false; - } - } - return filter_predicate(predicate, posting); -} -pub fn filter_predicate(predicate: &Vec, posting: &Posting) -> bool { - let name = posting.account.get_name().to_lowercase(); - if predicate.len() == 0 { - return true; - } - for pred in predicate { - let p = pred.trim(); - if p.starts_with("%") { - // look in the posting tags - for tag in posting.tags.iter() { - match tag.name.to_lowercase().find(&p.to_lowercase()[1..]) { - None => continue, - Some(_) => return true, - } - } - } else { - match name.find(&p.to_lowercase()) { - None => continue, - Some(_) => return true, - } - } - } - false -} diff --git a/src/lib/list.rs b/src/list.rs similarity index 61% rename from src/lib/list.rs rename to src/list.rs index b9166f4..b970345 100644 --- a/src/lib/list.rs +++ b/src/list.rs @@ -1,4 +1,5 @@ -use std::collections::hash_map::{Iter, RandomState, Values}; +use regex::Regex; +use std::collections::hash_map::{Iter, Values}; use std::collections::HashMap; use std::fmt::Debug; use std::hash::Hash; @@ -15,18 +16,13 @@ use crate::LedgerError; /// - Adding new elements to the list /// - Adding new aliases to existing elements /// - Retrieving elements +/// - Retrieving elements with a regular expression #[derive(Debug, Clone)] pub struct List { aliases: HashMap, list: HashMap>, } -impl Into>> for List { - fn into(self) -> HashMap, RandomState> { - self.list - } -} - impl<'a, T: Eq + Hash + HasName + Clone + FromDirective + HasAliases + Debug> List { pub fn new() -> Self { let aliases: HashMap = HashMap::new(); @@ -38,7 +34,7 @@ impl<'a, T: Eq + Hash + HasName + Clone + FromDirective + HasAliases + Debug> Li pub fn insert(&mut self, element: T) { let found = self.list.get(&element.get_name().to_lowercase()); match found { - Some(_) => (), // do nothing + Some(_) => eprintln!("Duplicate element: {:?}", element), // do nothing None => { // Change the name which will be used as key to lowercase let name = element.get_name().to_string().to_lowercase(); @@ -67,12 +63,6 @@ impl<'a, T: Eq + Hash + HasName + Clone + FromDirective + HasAliases + Debug> Li () } - pub fn element_in_list(&self, element: &T) -> bool { - match self.aliases.get(&element.get_name().to_lowercase()) { - None => false, - Some(_) => true, - } - } pub fn get(&self, index: &str) -> Result<&Rc, LedgerError> { match self.list.get(&index.to_lowercase()) { None => match self.aliases.get(&index.to_lowercase()) { @@ -86,6 +76,22 @@ impl<'a, T: Eq + Hash + HasName + Clone + FromDirective + HasAliases + Debug> Li Some(x) => Ok(x), } } + /// Gets an element from the regex + pub fn get_regex(&self, regex: Regex) -> Option<&Rc> { + // Try the list + for (_alias, value) in self.list.iter() { + if regex.is_match(value.get_name()) { + return Some(value); + } + } + for (alias, value) in self.aliases.iter() { + if regex.is_match(alias) { + return self.list.get(value); + } + } + + None + } pub fn iter(&self) -> Iter<'_, String, Rc> { self.list.iter() @@ -107,3 +113,44 @@ impl List { self.aliases.extend(other.to_owned().aliases.into_iter()); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::Payee; + use regex::Regex; + #[test] + fn list() { + let name = "ACME Inc."; + let payee = Payee::from(name); + let mut list: List = List::new(); + list.insert(payee.clone()); + + // Get ACME from the list, using a regex + let pattern = Regex::new("ACME").unwrap(); + let retrieved = list.get_regex(pattern); + + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().get_name(), "ACME Inc."); + assert_eq!(list.len_alias(), 1); + + // Now add and alias + list.add_alias("ACME is awesome".to_string(), &payee); + assert_eq!(list.len_alias(), 2); + + // Retrieve an element that is not in the list + assert!(list.get_regex(Regex::new("Warner").unwrap()).is_none()); + assert!(list.get("Warner").is_err()); + assert!(list.get_regex(Regex::new("awesome").unwrap()).is_some()); + } + #[test] + #[should_panic] + fn list_repeated_alias() { + let mut list: List = List::new(); + list.insert(Payee::from("ACME")); + for _ in 0..2 { + let retrieved = list.get("ACME").unwrap(); + list.add_alias("ACME, Inc.".to_string(), &retrieved.clone()) + } + } +} diff --git a/src/lib/mod.rs b/src/mod.rs similarity index 77% rename from src/lib/mod.rs rename to src/mod.rs index ea5a94f..5233a0c 100644 --- a/src/lib/mod.rs +++ b/src/mod.rs @@ -2,14 +2,14 @@ extern crate pest; #[macro_use] extern crate pest_derive; +mod app; pub mod commands; mod error; -mod filter; +pub mod filter; mod list; -mod main; pub mod models; pub mod parser; +pub use app::{run_app, CommonOpts}; pub(crate) use error::{Error, LedgerError, ParserError}; pub use list::List; -pub use main::{run_app, CommonOpts}; diff --git a/src/lib/models/account.rs b/src/models/account.rs similarity index 95% rename from src/lib/models/account.rs rename to src/models/account.rs index 8cb4687..67118b9 100644 --- a/src/lib/models/account.rs +++ b/src/models/account.rs @@ -1,4 +1,5 @@ use crate::models::{FromDirective, HasAliases, HasName, Origin}; +use regex::Regex; use std::collections::hash_map::RandomState; use std::collections::HashSet; use std::fmt; @@ -14,7 +15,7 @@ pub struct Account { pub(crate) aliases: HashSet, pub(crate) check: Vec, pub(crate) assert: Vec, - pub(crate) payee: Vec, + pub(crate) payee: Vec, pub(crate) default: bool, } @@ -121,4 +122,8 @@ impl Account { } } } + + pub fn is_match(&self, regex: Regex) -> bool { + regex.is_match(self.get_name()) + } } diff --git a/src/lib/models/balance.rs b/src/models/balance.rs similarity index 100% rename from src/lib/models/balance.rs rename to src/models/balance.rs diff --git a/src/lib/models/comment.rs b/src/models/comment.rs similarity index 100% rename from src/lib/models/comment.rs rename to src/models/comment.rs diff --git a/src/lib/models/currency.rs b/src/models/currency.rs similarity index 100% rename from src/lib/models/currency.rs rename to src/models/currency.rs diff --git a/src/lib/models/mod.rs b/src/models/mod.rs similarity index 80% rename from src/lib/models/mod.rs rename to src/models/mod.rs index f010ee5..19cc9bd 100644 --- a/src/lib/models/mod.rs +++ b/src/models/mod.rs @@ -37,19 +37,7 @@ pub struct Ledger { pub(crate) commodities: List, pub(crate) transactions: Vec>, pub(crate) prices: Vec, - payees: List, -} - -impl Ledger { - pub fn new() -> Self { - Ledger { - accounts: List::::new(), - prices: vec![], - transactions: vec![], - commodities: List::::new(), - payees: List::::new(), - } - } + pub(crate) payees: List, } impl ParsedLedger { @@ -59,13 +47,14 @@ impl ParsedLedger { let mut account_strs = HashSet::::new(); let mut payee_strs = HashSet::::new(); - // // 1. Populate the directive lists - // + for transaction in self.transactions.iter() { for p in transaction.postings.iter() { account_strs.insert(p.account.clone()); - + if let Some(payee) = p.payee.clone() { + payee_strs.insert(payee); + } // Currencies if let Some(c) = &p.money_currency { commodity_strs.insert(c.clone()); @@ -100,7 +89,37 @@ impl ParsedLedger { Err(_) => self.accounts.insert(Account::from(alias.as_str())), } } - // TODO payees + + // Payees + let payees_copy = self.payees.clone(); + for alias in payee_strs { + match self.payees.get(&alias) { + Ok(_) => {} // do nothing + Err(_) => { + // Payees are actually matched by regex + let mut matched = false; + let mut alias_to_add = "".to_string(); + let mut payee_to_add = None; + 'outer: for (_, p) in payees_copy.iter() { + for p_alias in p.alias_regex.iter() { + // println!("{:?}", p_alias); // todo delete + if p_alias.is_match(alias.as_str()) { + // self.payees.add_alias(alias.to_string(), p); + payee_to_add = Some(p); + alias_to_add = alias.to_string(); + matched = true; + break 'outer; + } + } + } + if !matched { + self.payees.insert(Payee::from(alias.as_str())) + } else { + self.payees.add_alias(alias_to_add, payee_to_add.unwrap()); + } + } + } + } // 3. Prices from price statements let mut prices: Vec = Vec::new(); @@ -128,6 +147,7 @@ impl ParsedLedger { // let mut transactions = Vec::new(); let mut automated_transactions = Vec::new(); + for parsed in self.transactions.iter() { let (mut t, mut auto, mut new_prices) = self._transaction_to_ledger(parsed)?; transactions.append(&mut t); @@ -174,13 +194,20 @@ impl ParsedLedger { // 5. Go over the transactions again and see if there is something we need to do with them if automated_transactions.len() > 0 { - for automated in automated_transactions.iter() { + let mut regexes = HashMap::new(); + for automated in automated_transactions.iter_mut() { for t in transactions.iter_mut() { let mut extra_postings = vec![]; let mut extra_virtual_postings = vec![]; let mut extra_virtual_postings_balance = vec![]; for p in t.postings_iter() { - if filter_predicate(&vec![automated.description.clone()], p) { + if filter_predicate( + automated.get_filter_query().as_str(), + p, + t, + &mut self.commodities, + &mut regexes, + )? { for comment in t.comments.iter() { p.to_owned().tags.append(&mut comment.get_tags()); } @@ -192,6 +219,17 @@ impl ParsedLedger { self.accounts.insert(Account::from(account_alias.as_str())) } } + let payee = if let Some(payee_alias) = &auto_posting.payee { + match self.payees.get(&payee_alias) { + Ok(_) => {} // do nothing + Err(_) => { + self.payees.insert(Payee::from(payee_alias.as_str())) + } + } + Some(self.payees.get(&payee_alias).unwrap().clone()) + } else { + p.payee.clone() + }; let account = self.accounts.get(&account_alias).unwrap(); let money = match &auto_posting.money_currency { None => Some(value_expr::eval_value_expression( @@ -199,6 +237,7 @@ impl ParsedLedger { p, t, &mut self.commodities, + &mut regexes, )), Some(alias) => { if alias == "" { @@ -221,6 +260,7 @@ impl ParsedLedger { } } }; + let posting = Posting { account: account.clone(), amount: money, @@ -228,8 +268,9 @@ impl ParsedLedger { cost: None, kind: auto_posting.kind, tags: vec![], + payee, }; - // println!("{:?}", posting); + match auto_posting.kind { PostingType::Real => extra_postings.push(posting), PostingType::Virtual => extra_virtual_postings.push(posting), @@ -292,11 +333,11 @@ impl ParsedLedger { fn _transaction_to_ledger( &self, - parsed: &Transaction, + parsed: &Transaction, ) -> Result< ( Vec>, - Vec>, + Vec>, Vec, ), Error, @@ -309,21 +350,42 @@ impl ParsedLedger { let mut transaction = Transaction::::new(TransactionType::Real); transaction.description = parsed.description.clone(); transaction.code = parsed.code.clone(); - transaction.note = parsed.note.clone(); + transaction.comments = parsed.comments.clone(); transaction.date = parsed.date; transaction.effective_date = parsed.effective_date; + for comment in parsed.comments.iter() { transaction.tags.append(&mut comment.get_tags()); } // Go posting by posting for p in parsed.postings.iter() { - let account = self.accounts.get(&p.account)?; - - let mut posting: Posting = Posting::new(account, p.kind); + let payee = match &p.payee { + None => transaction.get_payee_inmutable(&self.payees), + Some(x) => self.payees.get(x).unwrap().clone(), + }; + let account = if p.account.to_lowercase().ends_with("unknown") { + let mut account = None; + for (_, acc) in self.accounts.iter() { + for alias in acc.payee.iter() { + if alias.is_match(payee.get_name()) { + account = Some(acc.clone()); + break; + } + } + } + match account { + Some(x) => x, + None => self.accounts.get(&p.account)?.clone(), + } + } else { + self.accounts.get(&p.account)?.clone() + }; + let mut posting: Posting = Posting::new(&account, p.kind, &payee); posting.tags = transaction.tags.clone(); for comment in p.comments.iter() { posting.tags.append(&mut comment.get_tags()); } + // Modify posting with amounts if let Some(c) = &p.money_currency { posting.amount = Some(Money::from(( @@ -418,14 +480,3 @@ pub trait HasAliases { pub trait FromDirective { fn is_from_directive(&self) -> bool; } - -fn _output_balances(bal: &HashMap, Balance>) { - let mut s = String::new(); - for (k, v) in bal.iter() { - if v.is_zero() { - continue; - } - s.push_str(format!("{}: {}\n", k.get_name(), v).as_str()); - } - println!("{}", s); -} diff --git a/src/lib/models/models.rs b/src/models/models.rs similarity index 100% rename from src/lib/models/models.rs rename to src/models/models.rs diff --git a/src/lib/models/money.rs b/src/models/money.rs similarity index 100% rename from src/lib/models/money.rs rename to src/models/money.rs diff --git a/src/lib/models/payee.rs b/src/models/payee.rs similarity index 60% rename from src/lib/models/payee.rs rename to src/models/payee.rs index 12754b2..cad17a1 100644 --- a/src/lib/models/payee.rs +++ b/src/models/payee.rs @@ -1,6 +1,9 @@ use crate::models::{FromDirective, HasAliases, HasName, Origin}; +use regex::Regex; use std::collections::hash_map::RandomState; use std::collections::HashSet; +use std::fmt; +use std::fmt::{Display, Formatter}; use std::hash::{Hash, Hasher}; #[derive(Debug, Clone)] @@ -8,6 +11,7 @@ pub struct Payee { pub name: String, pub note: Option, pub alias: HashSet, + pub alias_regex: Vec, pub(crate) origin: Origin, } @@ -19,6 +23,12 @@ impl PartialEq for Payee { } } +impl Display for Payee { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.name) + } +} + impl HasName for Payee { fn get_name(&self) -> &str { self.name.as_str() @@ -45,3 +55,20 @@ impl Hash for Payee { self.name.hash(state); } } +impl Payee { + pub fn is_match(&self, regex: Regex) -> bool { + regex.is_match(self.get_name()) + } +} + +impl From<&str> for Payee { + fn from(name: &str) -> Self { + Payee { + name: String::from(name), + note: None, + alias: Default::default(), + alias_regex: Default::default(), + origin: Origin::FromTransaction, + } + } +} diff --git a/src/lib/models/price.rs b/src/models/price.rs similarity index 100% rename from src/lib/models/price.rs rename to src/models/price.rs diff --git a/src/lib/models/transaction.rs b/src/models/transaction.rs similarity index 69% rename from src/lib/models/transaction.rs rename to src/models/transaction.rs index 9dda1e1..31d0d28 100644 --- a/src/lib/models/transaction.rs +++ b/src/models/transaction.rs @@ -8,13 +8,15 @@ use chrono::NaiveDate; use num::rational::BigRational; use crate::models::balance::Balance; -use crate::models::{Account, Comment, HasName, Money}; -use crate::LedgerError; +use crate::models::{Account, Comment, HasName, Money, Origin, Payee}; +use crate::{LedgerError, List}; use num::BigInt; use std::fmt; use std::fmt::{Display, Formatter}; use super::Tag; +use crate::filter::preprocess_query; +use regex::Regex; #[derive(Debug, Clone)] pub struct Transaction { @@ -24,13 +26,69 @@ pub struct Transaction { pub cleared: Cleared, pub code: Option, pub description: String, - pub note: Option, + pub payee: Option, pub postings: Vec, pub virtual_postings: Vec, pub virtual_postings_balance: Vec, pub comments: Vec, pub transaction_type: TransactionType, pub tags: Vec, + filter_query: Option, +} + +impl Transaction { + pub fn get_filter_query(&mut self) -> String { + match self.filter_query.clone() { + None => { + let mut parts: Vec = vec![]; + let mut current = String::new(); + let mut in_regex = false; + let mut in_string = false; + for c in self.description.chars() { + if (c == ' ') & !in_string & !in_regex { + parts.push(current.clone()); + current = String::new(); + } + if c == '"' { + in_string = !in_string; + } else if c == '/' { + in_regex = !in_regex; + current.push(c); + } else { + current.push(c) + } + } + parts.push(current.clone()); + //self.description.split(" ").map(|x| x.to_string()).collect(); + let res = preprocess_query(&parts); + self.filter_query = Some(res.clone()); + res + } + Some(x) => x, + } + } + pub fn get_payee(&self, payees: &mut List) -> Rc { + match payees.get(&self.description) { + Ok(x) => x.clone(), + Err(_) => { + let payee = Payee { + name: self.description.clone(), + note: None, + alias: Default::default(), + alias_regex: vec![], + origin: Origin::FromTransaction, + }; + payees.insert(payee); + self.get_payee(payees) + } + } + } + pub fn get_payee_inmutable(&self, payees: &List) -> Rc { + match payees.get(&self.description) { + Ok(x) => x.clone(), + Err(_) => panic!("Payee not found"), + } + } } #[derive(Debug, Copy, Clone)] @@ -69,10 +127,11 @@ pub struct Posting { pub cost: Option, pub kind: PostingType, pub tags: Vec, + pub payee: Option>, } impl Posting { - pub fn new(account: &Account, kind: PostingType) -> Posting { + pub fn new(account: &Account, kind: PostingType, payee: &Payee) -> Posting { Posting { account: Rc::new(account.clone()), amount: None, @@ -80,11 +139,36 @@ impl Posting { cost: None, kind: kind, tags: vec![], + payee: Some(Rc::new(payee.clone())), } } pub fn set_amount(&mut self, money: Money) { self.amount = Some(money) } + pub fn has_tag(&self, regex: Regex) -> bool { + for t in self.tags.iter() { + if regex.is_match(t.get_name()) { + return true; + } + } + false + } + pub fn get_tag(&self, regex: Regex) -> Option { + for t in self.tags.iter() { + if regex.is_match(t.get_name()) { + return t.value.clone(); + } + } + None + } + pub fn get_exact_tag(&self, regex: String) -> Option { + for t in self.tags.iter() { + if regex.as_str() == t.get_name() { + return t.value.clone(); + } + } + None + } } #[derive(Debug, Clone)] @@ -102,13 +186,14 @@ impl Transaction { cleared: Cleared::Unknown, code: None, description: "".to_string(), - note: None, + payee: None, postings: vec![], virtual_postings: vec![], virtual_postings_balance: vec![], comments: vec![], transaction_type: t_type, tags: vec![], + filter_query: None, } } /// Iterator over all the postings, including the virtual ones @@ -203,12 +288,13 @@ impl Transaction { // 1. Iterate over postings let mut fill_account = &Rc::new(Account::from("this will never be used")); + let mut fill_payee = None; let mut postings: Vec = Vec::new(); for p in self.postings.iter() { // If it has money, update the balance if let Some(money) = &p.amount { let expected_balance = balances.get(p.account.deref()).unwrap().clone() // What we had - + Balance::from(money.clone()); // What we add + + Balance::from(money.clone()); // What we add if !skip_balance_check { if let Some(balance) = &p.balance { if Balance::from(balance.clone()) != expected_balance { @@ -229,36 +315,36 @@ impl Transaction { // Update the balance of the transaction transaction_balance = transaction_balance // What we had + match &p.cost { - None => Balance::from(money.clone()), + None => Balance::from(money.clone()), // If it has a cost, the secondary currency is added for the balance - Some(cost) => match cost { - Cost::Total { amount } => { - if p.amount.as_ref().unwrap().is_negative() { - Balance::from(-amount.clone()) - } else { - Balance::from(amount.clone()) - } - } - Cost::PerUnit { amount } => { - let currency = match amount { - Money::Zero => panic!("Cost has no currency"), - Money::Money { currency, .. } => currency, - }; - let units = match amount { - Money::Zero => BigRational::from(BigInt::from(0)), - Money::Money { amount, .. } => amount.clone(), - } * match p.amount.as_ref().unwrap() { - Money::Zero => BigRational::from(BigInt::from(0)), - Money::Money { amount, .. } => amount.clone(), - }; - let money = Money::Money { - amount: units, - currency: currency.clone(), - }; - Balance::from(money) + Some(cost) => match cost { + Cost::Total { amount } => { + if p.amount.as_ref().unwrap().is_negative() { + Balance::from(-amount.clone()) + } else { + Balance::from(amount.clone()) } - }, - }; + } + Cost::PerUnit { amount } => { + let currency = match amount { + Money::Zero => panic!("Cost has no currency"), + Money::Money { currency, .. } => currency, + }; + let units = match amount { + Money::Zero => BigRational::from(BigInt::from(0)), + Money::Money { amount, .. } => amount.clone(), + } * match p.amount.as_ref().unwrap() { + Money::Zero => BigRational::from(BigInt::from(0)), + Money::Money { amount, .. } => amount.clone(), + }; + let money = Money::Money { + amount: units, + currency: currency.clone(), + }; + Balance::from(money) + } + }, + }; // Add the posting postings.push(Posting { @@ -268,6 +354,7 @@ impl Transaction { cost: p.cost.clone(), kind: PostingType::Real, tags: self.tags.clone(), + payee: p.payee.clone(), }); } else if &p.balance.is_some() & !skip_balance_check { // There is a balance @@ -287,10 +374,12 @@ impl Transaction { cost: p.cost.clone(), kind: PostingType::Real, tags: p.tags.clone(), + payee: p.payee.clone(), }); } else { // We do nothing, but this is the account for the empty post fill_account = &p.account; + fill_payee = p.payee.clone(); } } @@ -321,6 +410,7 @@ impl Transaction { cost: None, kind: PostingType::Real, tags: self.tags.clone(), + payee: fill_payee.clone(), }); } self.postings = postings; diff --git a/src/lib/parser/chars.rs b/src/parser/chars.rs similarity index 100% rename from src/lib/parser/chars.rs rename to src/parser/chars.rs diff --git a/src/lib/parser/include.rs b/src/parser/include.rs similarity index 100% rename from src/lib/parser/include.rs rename to src/parser/include.rs diff --git a/src/lib/parser/mod.rs b/src/parser/mod.rs similarity index 99% rename from src/lib/parser/mod.rs rename to src/parser/mod.rs index cce4c0f..c72d623 100644 --- a/src/lib/parser/mod.rs +++ b/src/parser/mod.rs @@ -29,7 +29,7 @@ pub struct ParsedLedger { pub accounts: List, pub payees: List, pub commodities: List, - pub transactions: Vec>, + pub transactions: Vec>, pub prices: Vec, pub comments: Vec, pub tags: Vec, diff --git a/src/lib/parser/tokenizers/account.rs b/src/parser/tokenizers/account.rs similarity index 97% rename from src/lib/parser/tokenizers/account.rs rename to src/parser/tokenizers/account.rs index 1105fe5..a6f0ec1 100644 --- a/src/lib/parser/tokenizers/account.rs +++ b/src/parser/tokenizers/account.rs @@ -22,7 +22,7 @@ pub(crate) fn parse(tokenizer: &mut Tokenizer) -> Result { let mut aliases = HashSet::new(); let mut check = Vec::new(); let mut assert = Vec::new(); - let mut payee = Vec::new(); + let mut payee: Vec = Vec::new(); let mut note = None; let mut isin = None; @@ -68,7 +68,7 @@ pub(crate) fn parse(tokenizer: &mut Tokenizer) -> Result { assert.push(chars::get_line(tokenizer).trim().to_string()); } "payee" => { - payee.push(chars::get_line(tokenizer).trim().to_string()); + payee.push(Regex::new(chars::get_line(tokenizer).trim()).unwrap()); } "default" => default = true, found => { diff --git a/src/lib/parser/tokenizers/comment.rs b/src/parser/tokenizers/comment.rs similarity index 100% rename from src/lib/parser/tokenizers/comment.rs rename to src/parser/tokenizers/comment.rs diff --git a/src/lib/parser/tokenizers/commodity.rs b/src/parser/tokenizers/commodity.rs similarity index 100% rename from src/lib/parser/tokenizers/commodity.rs rename to src/parser/tokenizers/commodity.rs diff --git a/src/lib/parser/tokenizers/mod.rs b/src/parser/tokenizers/mod.rs similarity index 100% rename from src/lib/parser/tokenizers/mod.rs rename to src/parser/tokenizers/mod.rs diff --git a/src/lib/parser/tokenizers/payee.rs b/src/parser/tokenizers/payee.rs similarity index 71% rename from src/lib/parser/tokenizers/payee.rs rename to src/parser/tokenizers/payee.rs index 34e2cfc..863f9f2 100644 --- a/src/lib/parser/tokenizers/payee.rs +++ b/src/parser/tokenizers/payee.rs @@ -12,7 +12,7 @@ use crate::ParserError; pub(crate) fn parse(tokenizer: &mut Tokenizer) -> Result { lazy_static! { static ref RE: Regex = Regex::new(format!("{}{}{}", - r"(payee) +" , // directive commodity + r"(payee) +" , // payee directive r"(.*)" , // description r"( ;.*)?" , // note ).as_str()).unwrap(); @@ -31,7 +31,7 @@ pub(crate) fn parse(tokenizer: &mut Tokenizer) -> Result { Some(m) => { match i { 1 => - // commodity + // payee { detected = true; } @@ -70,10 +70,37 @@ pub(crate) fn parse(tokenizer: &mut Tokenizer) -> Result { } } + let alias_regex: Vec = alias + .iter() + .map(|x| Regex::new(x.clone().as_str()).unwrap()) + .collect(); Ok(Payee { name, note, alias, + alias_regex, origin: Origin::FromDirective, }) } + +mod tests { + use super::*; + use crate::models::HasName; + #[test] + fn parse_ko() { + let input = "payee ACME ; From the Looney Tunes\n\tWrong Acme, Inc.\n".to_string(); + let mut tokenizer = Tokenizer::from(input); + let payee_raw = parse(&mut tokenizer); + assert!(payee_raw.is_err()); + } + + #[test] + fn parse_ok() { + let input = "payee ACME\n\talias Acme, Inc.\n".to_string(); + let mut tokenizer = Tokenizer::from(input); + let payee_raw = parse(&mut tokenizer); + assert!(payee_raw.is_ok()); + let payee = payee_raw.unwrap(); + assert_eq!(payee.get_name(), "ACME"); + } +} diff --git a/src/lib/parser/tokenizers/price.rs b/src/parser/tokenizers/price.rs similarity index 100% rename from src/lib/parser/tokenizers/price.rs rename to src/parser/tokenizers/price.rs diff --git a/src/lib/parser/tokenizers/tag.rs b/src/parser/tokenizers/tag.rs similarity index 100% rename from src/lib/parser/tokenizers/tag.rs rename to src/parser/tokenizers/tag.rs diff --git a/src/lib/parser/tokenizers/transaction.rs b/src/parser/tokenizers/transaction.rs similarity index 87% rename from src/lib/parser/tokenizers/transaction.rs rename to src/parser/tokenizers/transaction.rs index 04a3e9e..3442ec7 100644 --- a/src/lib/parser/tokenizers/transaction.rs +++ b/src/parser/tokenizers/transaction.rs @@ -1,4 +1,6 @@ -use crate::models::{Cleared, Comment, PostingType, PriceType, Transaction, TransactionType}; +use crate::models::{ + Cleared, Comment, HasName, PostingType, PriceType, Transaction, TransactionType, +}; use crate::parser::chars::LineType; use crate::parser::tokenizers::comment; use crate::parser::{chars, Tokenizer}; @@ -10,26 +12,27 @@ use num::BigInt; use regex::Regex; use std::str::FromStr; -pub(crate) fn parse(tokenizer: &mut Tokenizer) -> Result, Error> { +pub(crate) fn parse(tokenizer: &mut Tokenizer) -> Result, Error> { parse_generic(tokenizer, true) } pub(crate) fn parse_automated_transaction( tokenizer: &mut Tokenizer, -) -> Result, Error> { +) -> Result, Error> { parse_generic(tokenizer, false) } /// Parses a transaction -fn parse_generic(tokenizer: &mut Tokenizer, real: bool) -> Result, Error> { +fn parse_generic(tokenizer: &mut Tokenizer, real: bool) -> Result, Error> { lazy_static! { - static ref RE_REAL: Regex = Regex::new(format!("{}{}{}{}{}{}", + static ref RE_REAL: Regex = Regex::new(format!("{}{}{}{}{}{}{}", r"(\d{4}[/-]\d{2}[/-]\d{2})" , // date r"(= ?\d{4}[/-]\d{2}[/-]\d{2})? +" , // effective_date r"([\*!])? +" , // cleared r"(\(.*\) )?" , // code r"(.*)" , // description - r"( ;.*)?" , // note + r"( |.*)" , // payee + r"( ;.*)?" , // note ).as_str()).unwrap(); static ref RE_AUTOMATED: Regex = Regex::new(format!("{}",r"=(.*)" ).as_str()).unwrap(); } @@ -42,7 +45,7 @@ fn parse_generic(tokenizer: &mut Tokenizer, real: bool) -> Result RE_AUTOMATED.captures(mystr.as_str()).unwrap(), }; - let mut transaction = Transaction::::new(match real { + let mut transaction = Transaction::::new(match real { true => TransactionType::Real, false => TransactionType::Automated, }); @@ -56,7 +59,9 @@ fn parse_generic(tokenizer: &mut Tokenizer, real: bool) -> Result transaction.date = Some(parse_date(m.as_str())), - false => transaction.description = m.as_str().to_string(), + false => { + transaction.description = m.as_str().to_string(); + } } } 2 => @@ -81,9 +86,19 @@ fn parse_generic(tokenizer: &mut Tokenizer, real: bool) -> Result // description { - transaction.description = m.as_str().to_string() + transaction.description = m.as_str().to_string(); } 6 => + // payee + { + if real { + match m.as_str() { + "" => (), + x => transaction.payee = Some(x.to_string()), + } + } + } + 7 => // note { transaction.code = Some(m.as_str().to_string()) @@ -94,7 +109,9 @@ fn parse_generic(tokenizer: &mut Tokenizer, real: bool) -> Result (), } } - + if real & transaction.payee.is_none() { + transaction.payee = Some(transaction.description.clone()); + } // Have a flag so that it can be known whether a comment belongs to the transaction or to the // posting let mut parsed_posting = false; @@ -105,9 +122,20 @@ fn parse_generic(tokenizer: &mut Tokenizer, real: bool) -> Result { let len = transaction.postings.len(); + + for tag in comment.get_tags().iter() { + if tag.get_name().to_lowercase() == "payee" { + if let Some(payee) = &tag.value { + transaction.postings[len - 1].payee = Some(payee.clone()); + } + break; + } + } transaction.postings[len - 1].comments.push(comment); } - false => transaction.comments.push(comment), + false => { + transaction.comments.push(comment); + } } } c if c.is_numeric() => { @@ -116,7 +144,7 @@ fn parse_generic(tokenizer: &mut Tokenizer, real: bool) -> Result { - match parse_posting(tokenizer, transaction.transaction_type) { + match parse_posting(tokenizer, transaction.transaction_type, &transaction.payee) { // Although here we already know the kind of the posting (virtual, real), // we deal with that in the next phase of parsing Ok(posting) => transaction.postings.push(posting), @@ -134,7 +162,7 @@ fn parse_generic(tokenizer: &mut Tokenizer, real: bool) -> Result, pub money_currency: Option, @@ -146,6 +174,7 @@ pub struct Posting { pub comments: Vec, pub amount_expr: Option, pub kind: PostingType, + pub payee: Option, } /// Parses a posting @@ -153,7 +182,8 @@ pub struct Posting { fn parse_posting( tokenizer: &mut Tokenizer, transaction_type: TransactionType, -) -> Result { + default_payee: &Option, +) -> Result { let mut account = String::new(); let mut posting_type = PostingType::Real; let mut finished = false; @@ -210,7 +240,7 @@ fn parse_posting( } } - let mut posting = Posting { + let mut posting = RawPosting { account, money_amount: None, money_currency: None, @@ -222,6 +252,7 @@ fn parse_posting( comments: Vec::new(), amount_expr: None, kind: posting_type, + payee: default_payee.clone(), }; if finished { return Ok(posting); @@ -367,7 +398,6 @@ fn parse_amount(tokenizer: &mut Tokenizer) -> Result { match BigInt::from_str(num.as_str()) { Ok(n) => n, Err(_) => { - // eprintln!("I fail here 372."); //todo delete return Err(ParserError::UnexpectedInput(Some( "Wrong number format".to_string(), ))); diff --git a/src/lib/parser/value_expr.rs b/src/parser/value_expr.rs similarity index 52% rename from src/lib/parser/value_expr.rs rename to src/parser/value_expr.rs index a6ac4d1..7364ce5 100644 --- a/src/lib/parser/value_expr.rs +++ b/src/parser/value_expr.rs @@ -1,20 +1,26 @@ -use crate::models::{Currency, Money, Posting, Transaction}; +use crate::app; +use crate::models::{Account, Currency, Money, Payee, Posting, Transaction}; use crate::pest::Parser; use crate::List; +use chrono::NaiveDate; use num::{abs, BigInt, BigRational}; +use regex::Regex; use std::rc::Rc; use std::str::FromStr; +use std::collections::HashMap; + #[derive(Parser)] -#[grammar = "grammar/value_expression.pest"] +#[grammar = "grammar/expressions.pest"] pub struct ValueExpressionParser; -pub fn eval_value_expression( +pub fn eval_expression( expression: &str, posting: &Posting, transaction: &Transaction, commodities: &mut List, -) -> Money { + regexes: &mut HashMap, +) -> EvalResult { let parsed = ValueExpressionParser::parse(Rule::value_expr, expression) .expect("unsuccessful parse") // unwrap the parse result .next() @@ -24,18 +30,32 @@ pub fn eval_value_expression( .unwrap(); // Build the abstract syntax tree - let root = build_ast_from_expr(parsed); + let root = build_ast_from_expr(parsed, regexes); + // println!("{:?}", expression); //todo delete + eval(&root, posting, transaction, commodities, regexes) +} - match eval(&root, posting, transaction, commodities) { +pub fn eval_value_expression( + expression: &str, + posting: &Posting, + transaction: &Transaction, + commodities: &mut List, + regexes: &mut HashMap, +) -> Money { + match eval_expression(expression, posting, transaction, commodities, regexes) { EvalResult::Number(n) => posting.amount.clone().unwrap() * n, EvalResult::Money(m) => m, _ => panic!("Should be money"), } } -#[derive(Clone)] -enum Node { +#[derive(Clone, Debug)] +pub enum Node { Amount, + Account, + Payee, + Note, + Date, Number(BigRational), Money { currency: String, @@ -50,22 +70,38 @@ enum Node { lhs: Box, rhs: Box, }, + Regex(Regex), + String(String), } -enum EvalResult { +#[derive(Debug)] +pub enum EvalResult { Number(BigRational), Money(Money), Boolean(bool), + Account(Rc), + Payee(Rc), + Regex(Regex), + String(Option), + Date(NaiveDate), + Note, } -fn eval( +pub fn eval( node: &Node, posting: &Posting, transaction: &Transaction, commodities: &mut List, + regexes: &mut HashMap, ) -> EvalResult { - match node { + let res = match node { Node::Amount => EvalResult::Money(posting.amount.clone().unwrap()), + Node::Account => EvalResult::Account(posting.account.clone()), + Node::Payee => EvalResult::Payee(posting.payee.clone().unwrap()), + Node::Note => EvalResult::Note, + Node::Date => EvalResult::Date(transaction.date.clone().unwrap()), + Node::Regex(r) => EvalResult::Regex(r.clone()), + Node::String(r) => EvalResult::String(Some(r.clone())), Node::Number(n) => EvalResult::Number(n.clone()), Node::Money { currency, amount } => { let cur = match commodities.get(¤cy) { @@ -79,13 +115,33 @@ fn eval( EvalResult::Money(Money::from((cur.clone(), amount.clone()))) } Node::UnaryExpr { op, child } => { - let res = eval(child, posting, transaction, commodities); + let res = eval(child, posting, transaction, commodities, regexes); match op { - Unary::Not => EvalResult::Boolean(false), + Unary::Not => match res { + EvalResult::Boolean(b) => EvalResult::Boolean(!b), + x => panic!("Can't do neg of {:?}", x), + }, + Unary::Any => { + let mut res = false; + for p in transaction.postings_iter() { + if let EvalResult::Boolean(b) = + eval(child, p, transaction, commodities, regexes) + { + if b { + res = true; + break; + } + } else { + panic!("Should evaluate to boolean") + } + } + EvalResult::Boolean(res) + } Unary::Neg => match res { EvalResult::Number(n) => EvalResult::Number(-n), EvalResult::Money(money) => EvalResult::Money(-money), EvalResult::Boolean(b) => EvalResult::Boolean(!b), + x => panic!("Can't do neg of {:?}", x), }, Unary::Abs => match res { EvalResult::Number(n) => EvalResult::Number(abs(n)), @@ -94,13 +150,78 @@ fn eval( Money::Money { amount, currency } => Money::from((currency, abs(amount))), }), EvalResult::Boolean(_b) => panic!("Can't do abs of boolean"), + x => panic!("Can't do abs of {:?}", x), + }, + Unary::HasTag => match res { + EvalResult::Regex(r) => EvalResult::Boolean(posting.has_tag(r)), + x => panic!("Expected regex. Found {:?}", x), + }, + Unary::Tag => match res { + EvalResult::Regex(r) => EvalResult::String(posting.get_tag(r)), + EvalResult::String(r) => EvalResult::String(posting.get_exact_tag(r.unwrap())), + x => panic!("Expected regex. Found {:?}", x), + }, + Unary::ToDate => match res { + EvalResult::String(r) => { + EvalResult::Date(app::date_parser(r.unwrap().as_str()).unwrap()) + } + x => panic!("Expected String. Found {:?}", x), }, } } Node::BinaryExpr { op, lhs, rhs } => { - let left = eval(lhs, posting, transaction, commodities); - let right = eval(rhs, posting, transaction, commodities); + let left = eval(lhs, posting, transaction, commodities, regexes); + let right = eval(rhs, posting, transaction, commodities, regexes); match op { + Binary::Eq => { + // println!("{:?} eq {:?}", left, right); // todo delete + match right { + EvalResult::Regex(rhs) => match left { + EvalResult::Account(lhs) => EvalResult::Boolean(lhs.is_match(rhs)), + EvalResult::Payee(lhs) => EvalResult::Boolean(lhs.is_match(rhs)), + EvalResult::String(lhs) => match lhs { + Some(lhs) => EvalResult::Boolean(rhs.is_match(lhs.as_str())), + None => EvalResult::Boolean(false), + }, + EvalResult::Note => { + let mut result = false; + for comment in transaction.comments.iter() { + // println!("{:?} -- {}", rhs, comment.comment); //todo delete + if rhs.is_match(comment.comment.as_str()) { + result = true; + break; + } + } + EvalResult::Boolean(result) + } + x => panic!("Found {:?}", x), + }, + EvalResult::Money(rhs) => match left { + EvalResult::Money(lhs) => EvalResult::Boolean(lhs == rhs), + EvalResult::Number(lhs) => EvalResult::Boolean(lhs == rhs.get_amount()), + + unknown => panic!("Don't know what to do with {:?}", unknown), + }, + unknown => panic!("Don't know what to do with {:?}", unknown), + } + } + Binary::Lt | Binary::Gt | Binary::Ge | Binary::Le => { + // println!("{:?} {:?} {:?}", left, op, right); // todo delete + if let EvalResult::Date(lhs) = left { + match right { + EvalResult::Date(rhs) => match op { + Binary::Lt => EvalResult::Boolean(lhs < rhs), + Binary::Gt => EvalResult::Boolean(lhs > rhs), + Binary::Ge => EvalResult::Boolean(lhs >= rhs), + Binary::Le => EvalResult::Boolean(lhs <= rhs), + x => panic!("Found {:?}", x), + }, + x => panic!("Found {:?}", x), + } + } else { + panic!("Expected Date"); + } + } Binary::Add | Binary::Subtract => { if let EvalResult::Number(lhs) = left { if let EvalResult::Number(rhs) = right { @@ -172,39 +293,58 @@ fn eval( panic!("Should be booleans") } } + unknown => panic!("Not implemented: {:?}", unknown), } } - } + }; + // println!("Result: {:?}", res); //todo delete + res } -#[derive(Clone)] -enum Unary { +#[derive(Clone, Debug)] +pub enum Unary { Not, Neg, Abs, + Any, + HasTag, + Tag, + ToDate, } -#[derive(Clone)] -enum Binary { +#[derive(Clone, Debug)] +pub enum Binary { Add, Subtract, Mult, Div, Or, And, + Eq, + Ge, + Gt, + Le, + Lt, } #[derive(Clone)] -enum Ternary {} +pub enum Ternary {} -fn build_ast_from_expr(pair: pest::iterators::Pair) -> Node { +fn build_ast_from_expr( + pair: pest::iterators::Pair, + regexes: &mut HashMap, +) -> Node { let rule = pair.as_rule(); match rule { - Rule::expr => build_ast_from_expr(pair.into_inner().next().unwrap()), - Rule::or_expr | Rule::and_expr | Rule::additive_expr | Rule::multiplicative_expr => { + Rule::expr => build_ast_from_expr(pair.into_inner().next().unwrap(), regexes), + Rule::comparison_expr + | Rule::or_expr + | Rule::and_expr + | Rule::additive_expr + | Rule::multiplicative_expr => { let mut pair = pair.into_inner(); let lhspair = pair.next().unwrap(); - let lhs = build_ast_from_expr(lhspair); + let lhs = build_ast_from_expr(lhspair, regexes); match pair.next() { None => lhs, Some(x) => { @@ -216,11 +356,16 @@ fn build_ast_from_expr(pair: pest::iterators::Pair) -> Node { "-" => Binary::Subtract, "*" => Binary::Mult, "/" => Binary::Div, + "=~" | "==" => Binary::Eq, + "<" => Binary::Lt, + ">" => Binary::Gt, + "<=" => Binary::Le, + ">=" => Binary::Ge, x => unreachable!("{}", x), }, }; let rhspair = pair.next().unwrap(); - let rhs = build_ast_from_expr(rhspair); + let rhs = build_ast_from_expr(rhspair, regexes); parse_binary_expr(op, lhs, rhs) } } @@ -229,15 +374,19 @@ fn build_ast_from_expr(pair: pest::iterators::Pair) -> Node { let mut inner = pair.into_inner(); let first = inner.next().unwrap(); match first.as_rule() { - Rule::unary_function | Rule::unary => { + Rule::function | Rule::unary => { let op = match first.as_str() { "abs" => Unary::Abs, "-" => Unary::Neg, + "has_tag" => Unary::HasTag, + "tag" => Unary::Tag, + "to_date" => Unary::ToDate, + "not" => Unary::Not, + "any" => Unary::Any, unknown => panic!("Unknown expr: {:?}", unknown), }; - parse_unary_expr(op, build_ast_from_expr(inner.next().unwrap())) + parse_unary_expr(op, build_ast_from_expr(inner.next().unwrap(), regexes)) } - Rule::amount => Node::Amount, Rule::money => { let mut money = first.into_inner(); let child = money.next().unwrap(); @@ -254,6 +403,32 @@ fn build_ast_from_expr(pair: pest::iterators::Pair) -> Node { } } Rule::number => Node::Number(parse_big_rational(first.as_str())), + Rule::regex | Rule::string => { + let full = first.as_str().to_string(); + let n = full.len() - 1; + let slice = &full[1..n]; + match first.as_rule() { + Rule::regex => match regexes.get(slice) { + None => { + let regex = Regex::new(slice).unwrap(); + regexes.insert(slice.to_string(), regex.clone()); + Node::Regex(regex) + } + Some(regex) => Node::Regex(regex.clone()), + }, + Rule::string => Node::String(slice.to_string()), + unknown => unreachable!("This cannot happen {:?}", unknown), + } + } + Rule::variable => match first.as_str() { + "account" => Node::Account, + "amount" => Node::Amount, + "payee" => Node::Payee, + "note" => Node::Note, + "date" => Node::Date, + unknown => panic!("Unknown variable: {:?}", unknown), + }, + Rule::expr => build_ast_from_expr(first, regexes), unknown => panic!("Unknown rule: {:?}", unknown), } diff --git a/tests/test_commands.rs b/tests/test_commands.rs index 3ad6a57..6b2de8e 100644 --- a/tests/test_commands.rs +++ b/tests/test_commands.rs @@ -111,7 +111,7 @@ fn accounts_command() { let args = &["accounts", "-f", "examples/demo.ledger"]; let assert_1 = Command::cargo_bin("dinero").unwrap().args(args).assert(); let output = String::from_utf8(assert_1.get_output().to_owned().stdout).unwrap(); - assert_eq!(output.lines().into_iter().count(), 6); + assert_eq!(output.lines().into_iter().count(), 7); test_args(args); } @@ -157,14 +157,29 @@ fn prices_command() { test_args(args); } +#[test] +/// Check the payees command +fn payees_command() { + let args = &["payees", "-f", "examples/demo.ledger"]; + let assert_1 = Command::cargo_bin("dinero").unwrap().args(args).assert(); + let output = String::from_utf8(assert_1.get_output().to_owned().stdout).unwrap(); + assert_eq!( + output.lines().into_iter().count(), + 5, + "Because of the aliases, there should be only 5 payees, not 6." + ); + + test_args(args); +} + #[test] /// Check the commodities command fn commodities_command() { let args = &["commodities", "-f", "examples/demo.ledger"]; let assert_1 = Command::cargo_bin("dinero").unwrap().args(args).assert(); let output = String::from_utf8(assert_1.get_output().to_owned().stdout).unwrap(); - assert_eq!(output.lines().into_iter().count(), 5); + assert_eq!(output.lines().into_iter().count(), 5); test_args(args); }