diff --git a/Cargo.toml b/Cargo.toml index fd06591..991c76e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Claudio Noguera "] edition = "2018" readme = "README.md" description = "A command line ledger tool" -keywords = ["ledger"] +keywords = ["ledger", "plaintext-accounting"] license = "MIT" homepage = "https://github.com/frosklis/dinero-rs" repository = "https://github.com/frosklis/dinero-rs" @@ -31,3 +31,5 @@ shellexpand = "2.1.0" two_timer = "2.2.0" assert_cmd = "1.0.3" terminal_size = "0.1.16" +pest = "2.0" +pest_derive = "2.0" diff --git a/README.md b/README.md index 52b08f3..4b4b242 100644 --- a/README.md +++ b/README.md @@ -13,4 +13,4 @@ Currently supported are: Report filtering by account name and by date. -Run ```dinero --help``` for a list of available commands and options. +Run ```dinero --help``` for a list of available commands and options. \ No newline at end of file diff --git a/examples/automated.ledger b/examples/automated.ledger new file mode 100644 index 0000000..ba35512 --- /dev/null +++ b/examples/automated.ledger @@ -0,0 +1,19 @@ +; This should fail because the added posting makes the transaction unbalanced +; Different formats for currencies used, different operations += Income:Salary + (Savings) 600 EUR + (Free spending) (abs(amount) - EUR 600) += Savings + (Savings:Risky investments) (amount * 0.10) + (Savings:Deposits) 0.40 + (Savings:Shares) (amount / 2) += Expenses:Rent + Expenses:Rent EUR 553.12 + Expenses:Utilities (amount - 553.12 EUR) + Expenses:Rent (-amount) +2021-01-01 * Flights + Income:Salary -1000 EUR + Assets:Checking account +2021-01-05 * Rent + Expenses:Rent 705.43 EUR + Assets:Checking account diff --git a/examples/automated_fail.ledger b/examples/automated_fail.ledger new file mode 100644 index 0000000..ad98088 --- /dev/null +++ b/examples/automated_fail.ledger @@ -0,0 +1,11 @@ +; This should fail because the added posting makes the transaction unbalanced + += flights + [Bugdet:Holiday] -1 + +2021-01-01 * Flights + ; :holiday: + Expenses:Flights 200 USD + Assets:Checking account + + diff --git a/src/grammar/value_expression.pest b/src/grammar/value_expression.pest new file mode 100644 index 0000000..e380f4f --- /dev/null +++ b/src/grammar/value_expression.pest @@ -0,0 +1,65 @@ +// 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 index 23604a0..593b1f2 100644 --- a/src/lib/filter.rs +++ b/src/lib/filter.rs @@ -5,7 +5,6 @@ pub fn filter(options: &CommonOpts, transaction: &Transaction, posting: // Get what's needed let predicate = &options.query; let real = options.real; - let name = posting.account.get_name().to_lowercase(); // Check for real postings if real { @@ -27,11 +26,15 @@ pub fn filter(options: &CommonOpts, transaction: &Transaction, posting: 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 p in predicate { + for pred in predicate { + let p = pred.trim(); if p.starts_with("%") { // look in the posting tags for tag in posting.tags.iter() { diff --git a/src/lib/mod.rs b/src/lib/mod.rs index 43ea2a5..ea5a94f 100644 --- a/src/lib/mod.rs +++ b/src/lib/mod.rs @@ -1,3 +1,7 @@ +extern crate pest; +#[macro_use] +extern crate pest_derive; + pub mod commands; mod error; mod filter; diff --git a/src/lib/models/mod.rs b/src/lib/models/mod.rs index e17150b..f010ee5 100644 --- a/src/lib/models/mod.rs +++ b/src/lib/models/mod.rs @@ -14,8 +14,9 @@ pub use transaction::{ Cleared, Posting, PostingType, Transaction, TransactionStatus, TransactionType, }; -use crate::models::transaction::Cost; use crate::parser::ParsedLedger; +use crate::parser::{tokenizers, value_expr}; +use crate::{filter::filter_predicate, models::transaction::Cost}; use crate::{Error, List}; use num::BigInt; use std::rc::Rc; @@ -36,6 +37,7 @@ pub struct Ledger { pub(crate) commodities: List, pub(crate) transactions: Vec>, pub(crate) prices: Vec, + payees: List, } impl Ledger { @@ -45,6 +47,7 @@ impl Ledger { prices: vec![], transactions: vec![], commodities: List::::new(), + payees: List::::new(), } } } @@ -54,6 +57,7 @@ impl ParsedLedger { pub fn to_ledger(mut self, no_checks: bool) -> Result { let mut commodity_strs = HashSet::::new(); let mut account_strs = HashSet::::new(); + let mut payee_strs = HashSet::::new(); // // 1. Populate the directive lists @@ -123,96 +127,12 @@ impl ParsedLedger { // 4. Get the right postings // let mut transactions = Vec::new(); + let mut automated_transactions = Vec::new(); for parsed in self.transactions.iter() { - match parsed.transaction_type { - TransactionType::Real => {} - TransactionType::Automated => { - eprintln!("Found automated transaction. Skipping."); - continue; - } - TransactionType::Periodic => { - eprintln!("Found periodic transaction. Skipping."); - continue; - } - } - let mut transaction = Transaction::::new(TransactionType::Real); - transaction.description = parsed.description.clone(); - transaction.code = parsed.code.clone(); - transaction.note = parsed.note.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); - 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(( - self.commodities.get(&c.as_str()).unwrap().clone(), - p.money_amount.clone().unwrap(), - ))); - } - if let Some(c) = &p.cost_currency { - let posting_currency = self - .commodities - .get(&p.money_currency.as_ref().unwrap().as_str()) - .unwrap(); - let amount = Money::from(( - self.commodities.get(c.as_str()).unwrap().clone(), - p.cost_amount.clone().unwrap(), - )); - posting.cost = match p.cost_type.as_ref().unwrap() { - PriceType::Total => Some(Cost::Total { - amount: amount.clone(), - }), - PriceType::PerUnit => Some(Cost::PerUnit { - amount: amount.clone(), - }), - }; - prices.push(Price { - date: transaction.date.unwrap(), - commodity: posting_currency.clone(), - price: Money::Money { - amount: p.cost_amount.clone().unwrap() - / match p.cost_type.as_ref().unwrap() { - PriceType::Total => { - posting.amount.as_ref().unwrap().get_amount() - } - PriceType::PerUnit => BigRational::from(BigInt::from(1)), - }, - currency: amount.get_commodity().unwrap().clone(), - }, - }) - } - if let Some(c) = &p.balance_currency { - posting.balance = Some(Money::from(( - self.commodities.get(c.as_str()).unwrap().clone(), - p.balance_amount.clone().unwrap(), - ))); - } - match posting.kind { - PostingType::Real => transaction.postings.push(posting.to_owned()), - PostingType::Virtual => transaction.virtual_postings.push(posting.to_owned()), - PostingType::VirtualMustBalance => transaction - .virtual_postings_balance - .push(posting.to_owned()), - } - } - match transaction.clone().is_balanced() { - true => { - transaction.status = TransactionStatus::InternallyBalanced; - } - false => {} - } - transactions.push(transaction); + let (mut t, mut auto, mut new_prices) = self._transaction_to_ledger(parsed)?; + transactions.append(&mut t); + automated_transactions.append(&mut auto); + prices.append(&mut new_prices); } // Now sort the transactions vector by date @@ -252,13 +172,232 @@ 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() { + 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) { + for comment in t.comments.iter() { + p.to_owned().tags.append(&mut comment.get_tags()); + } + for auto_posting in automated.postings_iter() { + let account_alias = auto_posting.account.clone(); + match self.accounts.get(&account_alias) { + Ok(_) => {} // do nothing + Err(_) => { + self.accounts.insert(Account::from(account_alias.as_str())) + } + } + let account = self.accounts.get(&account_alias).unwrap(); + let money = match &auto_posting.money_currency { + None => Some(value_expr::eval_value_expression( + auto_posting.amount_expr.clone().unwrap().as_str(), + p, + t, + &mut self.commodities, + )), + Some(alias) => { + if alias == "" { + Some(Money::from(( + p.amount.clone().unwrap().get_commodity().unwrap(), + p.amount.clone().unwrap().get_amount() + * auto_posting.money_amount.clone().unwrap(), + ))) + } else { + match self.commodities.get(&alias) { + Ok(_) => {} // do nothing + Err(_) => self + .commodities + .insert(Currency::from(alias.as_str())), + } + Some(Money::from(( + self.commodities.get(alias).unwrap().clone(), + auto_posting.money_amount.clone().unwrap(), + ))) + } + } + }; + let posting = Posting { + account: account.clone(), + amount: money, + balance: None, + cost: None, + kind: auto_posting.kind, + tags: vec![], + }; + // println!("{:?}", posting); + match auto_posting.kind { + PostingType::Real => extra_postings.push(posting), + PostingType::Virtual => extra_virtual_postings.push(posting), + PostingType::VirtualMustBalance => { + extra_virtual_postings_balance.push(posting) + } + } + } + // todo!("Need to work on transaction automation"); + } + } + t.postings.append(&mut extra_postings); + t.virtual_postings.append(&mut extra_virtual_postings); + t.virtual_postings_balance + .append(&mut extra_virtual_postings_balance); + } + } + // Populate balances + let mut balances: HashMap, Balance> = HashMap::new(); + for account in self.accounts.values() { + balances.insert(account.clone(), Balance::new()); + } + + // Balance the transactions + for t in transactions.iter_mut() { + let date = t.date.unwrap().clone(); + // output_balances(&balances); + let balance = match t.balance(&mut balances, no_checks) { + Ok(balance) => balance, + Err(e) => { + eprintln!("{}", t); + return Err(e.into()); + } + }; + if balance.len() == 2 { + let vec = balance.iter().map(|(_, x)| x.abs()).collect::>(); + + let commodity = vec[0].get_commodity().unwrap().clone(); + let price = Money::Money { + amount: vec[1].get_amount() / vec[0].get_amount(), + currency: vec[1].get_commodity().unwrap().clone(), + }; + + prices.push(Price { + date, + commodity, + price, + }); + } + } + } Ok(Ledger { accounts: self.accounts, commodities: self.commodities, transactions, prices, + payees: self.payees, }) } + + fn _transaction_to_ledger( + &self, + parsed: &Transaction, + ) -> Result< + ( + Vec>, + Vec>, + Vec, + ), + Error, + > { + let mut automated_transactions = vec![]; + let mut prices = vec![]; + let mut transactions = vec![]; + match parsed.transaction_type { + TransactionType::Real => { + let mut transaction = Transaction::::new(TransactionType::Real); + transaction.description = parsed.description.clone(); + transaction.code = parsed.code.clone(); + transaction.note = parsed.note.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); + 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(( + self.commodities.get(&c.as_str()).unwrap().clone(), + p.money_amount.clone().unwrap(), + ))); + } + if let Some(c) = &p.cost_currency { + let posting_currency = self + .commodities + .get(&p.money_currency.as_ref().unwrap().as_str()) + .unwrap(); + let amount = Money::from(( + self.commodities.get(c.as_str()).unwrap().clone(), + p.cost_amount.clone().unwrap(), + )); + posting.cost = match p.cost_type.as_ref().unwrap() { + PriceType::Total => Some(Cost::Total { + amount: amount.clone(), + }), + PriceType::PerUnit => Some(Cost::PerUnit { + amount: amount.clone(), + }), + }; + prices.push(Price { + date: transaction.date.unwrap(), + commodity: posting_currency.clone(), + price: Money::Money { + amount: p.cost_amount.clone().unwrap() + / match p.cost_type.as_ref().unwrap() { + PriceType::Total => { + posting.amount.as_ref().unwrap().get_amount() + } + PriceType::PerUnit => BigRational::from(BigInt::from(1)), + }, + currency: amount.get_commodity().unwrap().clone(), + }, + }) + } + if let Some(c) = &p.balance_currency { + posting.balance = Some(Money::from(( + self.commodities.get(c.as_str()).unwrap().clone(), + p.balance_amount.clone().unwrap(), + ))); + } + match posting.kind { + PostingType::Real => transaction.postings.push(posting.to_owned()), + PostingType::Virtual => { + transaction.virtual_postings.push(posting.to_owned()) + } + PostingType::VirtualMustBalance => transaction + .virtual_postings_balance + .push(posting.to_owned()), + } + } + match transaction.clone().is_balanced() { + true => { + transaction.status = TransactionStatus::InternallyBalanced; + } + false => {} + } + transactions.push(transaction); + } + TransactionType::Automated => { + // Add transaction to the automated transactions queue, we'll process them + // later. + automated_transactions.push(parsed.clone()); + } + TransactionType::Periodic => { + eprintln!("Found periodic transaction. Skipping."); + } + } + Ok((transactions, automated_transactions, prices)) + } } #[derive(Copy, Clone, Debug)] diff --git a/src/lib/models/money.rs b/src/lib/models/money.rs index 8d97360..27e8082 100644 --- a/src/lib/models/money.rs +++ b/src/lib/models/money.rs @@ -1,6 +1,6 @@ use std::fmt; use std::fmt::{Display, Formatter}; -use std::ops::{Add, Mul, Neg}; +use std::ops::{Add, Div, Mul, Neg, Sub}; use std::rc::Rc; use num; @@ -9,6 +9,7 @@ use num::{BigInt, Signed, Zero}; use crate::models::balance::Balance; use crate::models::{Currency, HasName}; +use num::traits::Inv; use std::str::FromStr; /// Money representation: an amount and a currency @@ -169,6 +170,14 @@ impl Mul for Money { } } +impl Div for Money { + type Output = Money; + + fn div(self, rhs: BigRational) -> Self::Output { + self * rhs.inv() + } +} + impl From<(Rc, BigRational)> for Money { fn from(cur_amount: (Rc, BigRational)) -> Self { let (currency, amount) = cur_amount; @@ -195,6 +204,15 @@ impl Add for Money { b1 + b2 } } +impl Sub for Money { + type Output = Balance; + + fn sub(self, rhs: Self) -> Self::Output { + let b1 = Balance::from(self); + let b2 = Balance::from(rhs); + b1 - b2 + } +} impl<'a> Neg for Money { type Output = Money; diff --git a/src/lib/parser/chars.rs b/src/lib/parser/chars.rs index 4f3d34e..2c44973 100644 --- a/src/lib/parser/chars.rs +++ b/src/lib/parser/chars.rs @@ -62,6 +62,31 @@ pub(super) fn consume_str<'a>( Ok(()) } +pub(super) fn get_value_expression(tokenizer: &mut Tokenizer) -> String { + let mut retval: Vec = Vec::new(); + let mut open = 0; + let mut close = 0; + + while let Some(c) = tokenizer.content.get(tokenizer.position) { + if *c == '(' { + open += 1; + } else if *c == ')' { + close += 1; + } else if *c == '\n' { + if open == close { + break; + } + tokenizer.line_index += 1; + tokenizer.line_position = 0; + } else if *c == ';' { + break; + } + retval.push(*c); + tokenizer.position += 1; + } + retval.iter().collect() +} + pub(super) fn get_line(tokenizer: &mut Tokenizer) -> String { let mut retval: Vec = Vec::new(); while let Some(c) = tokenizer.content.get(tokenizer.position) { diff --git a/src/lib/parser/mod.rs b/src/lib/parser/mod.rs index 5805daf..cce4c0f 100644 --- a/src/lib/parser/mod.rs +++ b/src/lib/parser/mod.rs @@ -19,7 +19,8 @@ use crate::{models, Error, List, ParserError}; mod chars; mod include; -mod tokenizers; +pub mod tokenizers; +pub mod value_expr; use tokenizers::{account, comment, commodity, payee, price, tag, transaction}; diff --git a/src/lib/parser/tokenizers/transaction.rs b/src/lib/parser/tokenizers/transaction.rs index 1161898..04a3e9e 100644 --- a/src/lib/parser/tokenizers/transaction.rs +++ b/src/lib/parser/tokenizers/transaction.rs @@ -10,21 +10,18 @@ use num::BigInt; use regex::Regex; use std::str::FromStr; -pub(crate) fn parse<'a>(tokenizer: &'a mut Tokenizer) -> Result, Error> { +pub(crate) fn parse(tokenizer: &mut Tokenizer) -> Result, Error> { parse_generic(tokenizer, true) } -pub(crate) fn parse_automated_transaction<'a>( - tokenizer: &'a mut Tokenizer, +pub(crate) fn parse_automated_transaction( + tokenizer: &mut Tokenizer, ) -> Result, Error> { parse_generic(tokenizer, false) } /// Parses a transaction -pub(crate) fn parse_generic<'a>( - tokenizer: &'a 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!("{}{}{}{}{}{}", r"(\d{4}[/-]\d{2}[/-]\d{2})" , // date @@ -34,7 +31,7 @@ pub(crate) fn parse_generic<'a>( r"(.*)" , // description r"( ;.*)?" , // note ).as_str()).unwrap(); - static ref RE_AUTOMATED: Regex = Regex::new(format!("{}",r"(.*)" ).as_str()).unwrap(); + static ref RE_AUTOMATED: Regex = Regex::new(format!("{}",r"=(.*)" ).as_str()).unwrap(); } let mystr = chars::get_line(tokenizer); let caps = match real { @@ -212,6 +209,7 @@ fn parse_posting( // println!("{} is a virtual account {:?}", account, posting_type) } } + let mut posting = Posting { account, money_amount: None, @@ -232,6 +230,10 @@ fn parse_posting( // Amounts loop { match tokenizer.get_char() { + Some('(') => { + // This is a value expression + posting.amount_expr = Some(chars::get_value_expression(tokenizer)); + } Some('\n') => break, None => break, Some(';') => { @@ -284,18 +286,15 @@ fn parse_posting( posting.money_amount = Some(money.0); posting.money_currency = Some(money.1); } - Err(e) => { - // eprintln!("I fail here 260"); - match transaction_type { - TransactionType::Real | TransactionType::Periodic => return Err(e), - TransactionType::Automated => { - posting.amount_expr = Some(chars::get_line(tokenizer)); + Err(e) => match transaction_type { + TransactionType::Real | TransactionType::Periodic => return Err(e), + TransactionType::Automated => { + posting.amount_expr = Some(chars::get_line(tokenizer)); - tokenizer.line_index -= 1; - tokenizer.position -= 1; - } + tokenizer.line_index -= 1; + tokenizer.position -= 1; } - } + }, }, } chars::consume_whitespaces(tokenizer); @@ -368,7 +367,7 @@ fn parse_amount(tokenizer: &mut Tokenizer) -> Result { match BigInt::from_str(num.as_str()) { Ok(n) => n, Err(_) => { - // eprintln!("I fail here 341."); + // 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/lib/parser/value_expr.rs new file mode 100644 index 0000000..a6ac4d1 --- /dev/null +++ b/src/lib/parser/value_expr.rs @@ -0,0 +1,298 @@ +use crate::models::{Currency, Money, Posting, Transaction}; +use crate::pest::Parser; +use crate::List; +use num::{abs, BigInt, BigRational}; +use std::rc::Rc; +use std::str::FromStr; + +#[derive(Parser)] +#[grammar = "grammar/value_expression.pest"] +pub struct ValueExpressionParser; + +pub fn eval_value_expression( + expression: &str, + posting: &Posting, + transaction: &Transaction, + commodities: &mut List, +) -> Money { + let parsed = ValueExpressionParser::parse(Rule::value_expr, expression) + .expect("unsuccessful parse") // unwrap the parse result + .next() + .unwrap() + .into_inner() + .next() + .unwrap(); + + // Build the abstract syntax tree + let root = build_ast_from_expr(parsed); + + match eval(&root, posting, transaction, commodities) { + EvalResult::Number(n) => posting.amount.clone().unwrap() * n, + EvalResult::Money(m) => m, + _ => panic!("Should be money"), + } +} + +#[derive(Clone)] +enum Node { + Amount, + Number(BigRational), + Money { + currency: String, + amount: BigRational, + }, + UnaryExpr { + op: Unary, + child: Box, + }, + BinaryExpr { + op: Binary, + lhs: Box, + rhs: Box, + }, +} + +enum EvalResult { + Number(BigRational), + Money(Money), + Boolean(bool), +} + +fn eval( + node: &Node, + posting: &Posting, + transaction: &Transaction, + commodities: &mut List, +) -> EvalResult { + match node { + Node::Amount => EvalResult::Money(posting.amount.clone().unwrap()), + Node::Number(n) => EvalResult::Number(n.clone()), + Node::Money { currency, amount } => { + let cur = match commodities.get(¤cy) { + Ok(c) => c.clone(), + Err(_) => { + let c = Currency::from(currency.as_str()); + commodities.insert(c.clone()); + Rc::new(c) + } + }; + EvalResult::Money(Money::from((cur.clone(), amount.clone()))) + } + Node::UnaryExpr { op, child } => { + let res = eval(child, posting, transaction, commodities); + match op { + Unary::Not => EvalResult::Boolean(false), + Unary::Neg => match res { + EvalResult::Number(n) => EvalResult::Number(-n), + EvalResult::Money(money) => EvalResult::Money(-money), + EvalResult::Boolean(b) => EvalResult::Boolean(!b), + }, + Unary::Abs => match res { + EvalResult::Number(n) => EvalResult::Number(abs(n)), + EvalResult::Money(money) => EvalResult::Money(match money { + Money::Zero => Money::Zero, + Money::Money { amount, currency } => Money::from((currency, abs(amount))), + }), + EvalResult::Boolean(_b) => panic!("Can't do abs of boolean"), + }, + } + } + Node::BinaryExpr { op, lhs, rhs } => { + let left = eval(lhs, posting, transaction, commodities); + let right = eval(rhs, posting, transaction, commodities); + match op { + Binary::Add | Binary::Subtract => { + if let EvalResult::Number(lhs) = left { + if let EvalResult::Number(rhs) = right { + EvalResult::Number(match op { + Binary::Add => lhs + rhs, + Binary::Subtract => lhs - rhs, + _ => unreachable!(), + }) + } else { + panic!("Should be numbers") + } + } else if let EvalResult::Money(lhs) = left { + if let EvalResult::Money(rhs) = right { + EvalResult::Money(match op { + Binary::Add => (lhs + rhs).to_money().unwrap(), + Binary::Subtract => (lhs - rhs).to_money().unwrap(), + _ => unreachable!(), + }) + } else { + panic!("Should be money") + } + } else { + panic!("Should be money") + } + } + Binary::Mult | Binary::Div => { + if let EvalResult::Number(lhs) = left { + if let EvalResult::Number(rhs) = right { + EvalResult::Number(match op { + Binary::Mult => lhs * rhs, + Binary::Div => lhs / rhs, + _ => unreachable!(), + }) + } else if let EvalResult::Money(rhs) = right { + EvalResult::Money(match op { + Binary::Mult => rhs * lhs, // the other way around is not implemented + Binary::Div => panic!("Can't divide number by money"), + _ => unreachable!(), + }) + } else { + panic!("Should be numbers") + } + } else if let EvalResult::Money(lhs) = left { + if let EvalResult::Number(rhs) = right { + EvalResult::Money(match op { + Binary::Mult => lhs * rhs, + Binary::Div => lhs / rhs, + _ => unreachable!(), + }) + } else { + panic!("rhs should be a number") + } + } else { + panic!("Should be numbers") + } + } + Binary::Or | Binary::And => { + if let EvalResult::Boolean(lhs) = left { + if let EvalResult::Boolean(rhs) = right { + EvalResult::Boolean(match op { + Binary::Or => lhs | rhs, + Binary::And => lhs & rhs, + _ => unreachable!(), + }) + } else { + panic!("Should be booleans") + } + } else { + panic!("Should be booleans") + } + } + } + } + } +} + +#[derive(Clone)] +enum Unary { + Not, + Neg, + Abs, +} + +#[derive(Clone)] +enum Binary { + Add, + Subtract, + Mult, + Div, + Or, + And, +} + +#[derive(Clone)] +enum Ternary {} + +fn build_ast_from_expr(pair: pest::iterators::Pair) -> 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 => { + let mut pair = pair.into_inner(); + let lhspair = pair.next().unwrap(); + let lhs = build_ast_from_expr(lhspair); + match pair.next() { + None => lhs, + Some(x) => { + let op = match rule { + Rule::or_expr => Binary::Or, + Rule::and_expr => Binary::And, + _ => match x.as_str() { + "+" => Binary::Add, + "-" => Binary::Subtract, + "*" => Binary::Mult, + "/" => Binary::Div, + x => unreachable!("{}", x), + }, + }; + let rhspair = pair.next().unwrap(); + let rhs = build_ast_from_expr(rhspair); + parse_binary_expr(op, lhs, rhs) + } + } + } + Rule::primary => { + let mut inner = pair.into_inner(); + let first = inner.next().unwrap(); + match first.as_rule() { + Rule::unary_function | Rule::unary => { + let op = match first.as_str() { + "abs" => Unary::Abs, + "-" => Unary::Neg, + unknown => panic!("Unknown expr: {:?}", unknown), + }; + parse_unary_expr(op, build_ast_from_expr(inner.next().unwrap())) + } + Rule::amount => Node::Amount, + Rule::money => { + let mut money = first.into_inner(); + let child = money.next().unwrap(); + match child.as_rule() { + Rule::number => Node::Money { + currency: money.next().unwrap().as_str().to_string(), + amount: parse_big_rational(child.as_str()), + }, + Rule::currency => Node::Money { + currency: child.as_str().to_string(), + amount: parse_big_rational(money.next().unwrap().as_str()), + }, + unknown => panic!("Unknown rule: {:?}", unknown), + } + } + Rule::number => Node::Number(parse_big_rational(first.as_str())), + + unknown => panic!("Unknown rule: {:?}", unknown), + } + } + unknown => panic!("Unknown expr: {:?}", unknown), + } +} + +fn parse_binary_expr(operation: Binary, lhs: Node, rhs: Node) -> Node { + Node::BinaryExpr { + op: operation, + lhs: Box::new(lhs), + rhs: Box::new(rhs), + } +} + +fn parse_unary_expr(operation: Unary, child: Node) -> Node { + Node::UnaryExpr { + op: operation, + child: Box::new(child), + } +} + +fn parse_big_rational(input: &str) -> BigRational { + let mut num = String::new(); + let mut den = "1".to_string(); + let mut decimal = false; + for c in input.chars() { + if c == '.' { + decimal = true + } else { + num.push(c); + if decimal { + den.push('0') + }; + } + } + BigRational::new( + BigInt::from_str(num.as_str()).unwrap(), + BigInt::from_str(den.as_str()).unwrap(), + ) +} diff --git a/tests/common.rs b/tests/common.rs index 01cfba3..0529d57 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -8,3 +8,11 @@ pub fn test_args(args: &[&str]) { let res = run_app(function_args.iter().map(|x| x.to_string()).collect()); assert!(res.is_ok()); } +pub fn test_err(args: &[&str]) { + let mut function_args: Vec<&str> = vec!["testing"]; + for arg in args { + function_args.push(arg); + } + let res = run_app(function_args.iter().map(|x| x.to_string()).collect()); + assert!(res.is_err()); +} diff --git a/tests/test_commands.rs b/tests/test_commands.rs index b3fe916..3ad6a57 100644 --- a/tests/test_commands.rs +++ b/tests/test_commands.rs @@ -1,5 +1,5 @@ use assert_cmd::Command; -use common::test_args; +use common::{test_args, test_err}; mod common; #[test] fn date_filters() { @@ -167,3 +167,25 @@ fn commodities_command() { test_args(args); } + +#[test] +/// If this fails it means that it created an extra posting +fn automated_fail() { + let args = &["reg", "-f", "examples/automated_fail.ledger"]; + let assert_1 = Command::cargo_bin("dinero").unwrap().args(args).assert(); + let output_err = String::from_utf8(assert_1.get_output().to_owned().stderr).unwrap(); + assert_eq!(output_err.lines().into_iter().count(), 5); + + test_err(args); +} + +#[test] +/// If this fails it means that it created an extra posting +fn automated_value_expression() { + let args = &["reg", "-f", "examples/automated.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(), 12); + + test_args(args); +}