use rustledger_core::Directive;
use rustledger_parser::ParseResult;
use crate::types::EditorRange;
pub const ACCOUNT_TYPES: &[&str] = &["Assets", "Liabilities", "Equity", "Income", "Expenses"];
pub const DIRECTIVES: &[(&str, &str)] = &[
("open", "Open an account"),
("close", "Close an account"),
("commodity", "Define a commodity/currency"),
("balance", "Assert account balance"),
("pad", "Pad account to target"),
("event", "Record an event"),
("query", "Define a named query"),
("note", "Add a note to an account"),
("document", "Link a document"),
("custom", "Custom directive"),
("price", "Record a price"),
("txn", "Transaction (complete)"),
("*", "Transaction (complete)"),
("!", "Transaction (incomplete)"),
];
pub fn get_line(source: &str, line_num: usize) -> &str {
source.lines().nth(line_num).unwrap_or("")
}
pub fn is_date_like(s: &str) -> bool {
if s.len() != 10 {
return false;
}
let chars: Vec<char> = s.chars().collect();
chars[4] == '-'
&& chars[7] == '-'
&& chars.iter().enumerate().all(|(i, c)| {
if i == 4 || i == 7 {
*c == '-'
} else {
c.is_ascii_digit()
}
})
}
pub fn get_word_at_position(source: &str, line: u32, character: u32) -> Option<String> {
let line_text = source.lines().nth(line as usize)?;
let col = character as usize;
if col > line_text.len() {
return None;
}
let chars: Vec<char> = line_text.chars().collect();
let mut start = col;
while start > 0 && is_word_char(chars.get(start - 1).copied()?) {
start -= 1;
}
let mut end = col;
while end < chars.len() && is_word_char(chars[end]) {
end += 1;
}
if start == end {
return None;
}
Some(chars[start..end].iter().collect())
}
pub fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == ':' || c == '_' || c == '-'
}
pub fn is_account_type(s: &str) -> bool {
matches!(
s,
"Assets" | "Liabilities" | "Equity" | "Income" | "Expenses"
)
}
pub fn is_currency_like(s: &str) -> bool {
s.len() >= 2
&& s.len() <= 5
&& s.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
}
pub fn extract_accounts(parse_result: &ParseResult) -> Vec<String> {
let mut accounts = Vec::new();
for spanned_directive in &parse_result.directives {
match &spanned_directive.value {
Directive::Open(open) => accounts.push(open.account.to_string()),
Directive::Close(close) => accounts.push(close.account.to_string()),
Directive::Balance(bal) => accounts.push(bal.account.to_string()),
Directive::Pad(pad) => {
accounts.push(pad.account.to_string());
accounts.push(pad.source_account.to_string());
}
Directive::Transaction(txn) => {
for posting in &txn.postings {
accounts.push(posting.account.to_string());
}
}
_ => {}
}
}
accounts.sort();
accounts.dedup();
accounts
}
pub fn extract_currencies(parse_result: &ParseResult) -> Vec<String> {
let mut currencies = Vec::new();
for spanned_directive in &parse_result.directives {
match &spanned_directive.value {
Directive::Open(open) => {
for currency in &open.currencies {
currencies.push(currency.to_string());
}
}
Directive::Commodity(comm) => currencies.push(comm.currency.to_string()),
Directive::Balance(bal) => currencies.push(bal.amount.currency.to_string()),
Directive::Transaction(txn) => {
for posting in &txn.postings {
if let Some(ref units) = posting.units {
if let Some(currency) = units.currency() {
currencies.push(currency.to_string());
}
}
}
}
_ => {}
}
}
currencies.push("USD".to_string());
currencies.push("EUR".to_string());
currencies.push("GBP".to_string());
currencies.sort();
currencies.dedup();
currencies
}
pub fn extract_payees(parse_result: &ParseResult) -> Vec<String> {
let mut payees = Vec::new();
for spanned_directive in &parse_result.directives {
if let Directive::Transaction(txn) = &spanned_directive.value {
if let Some(ref payee) = txn.payee {
payees.push(payee.to_string());
}
}
}
payees.sort();
payees.dedup();
payees
}
pub fn count_account_usages(account: &str, parse_result: &ParseResult) -> usize {
let mut count = 0;
for spanned_directive in &parse_result.directives {
if let Directive::Transaction(txn) = &spanned_directive.value {
for posting in &txn.postings {
if posting.account.as_ref() == account {
count += 1;
}
}
}
}
count
}
#[allow(clippy::cmp_owned)]
pub fn count_currency_usages(currency: &str, parse_result: &ParseResult) -> usize {
let mut count = 0;
for spanned_directive in &parse_result.directives {
match &spanned_directive.value {
Directive::Transaction(txn) => {
for posting in &txn.postings {
if let Some(ref units) = posting.units {
if let Some(c) = units.currency() {
if c.to_string() == currency {
count += 1;
}
}
}
}
}
Directive::Balance(bal) => {
if bal.amount.currency.as_ref() == currency {
count += 1;
}
}
_ => {}
}
}
count
}
pub fn find_quoted_string_in_line(line: &str, text: &str, line_num: u32) -> Option<EditorRange> {
let quoted = format!("\"{text}\"");
if let Some(pos) = line.find("ed) {
return Some(EditorRange {
start_line: line_num,
start_character: pos as u32,
end_line: line_num,
end_character: (pos + quoted.len()) as u32,
});
}
None
}
pub fn find_word_in_line(line: &str, word: &str, line_num: u32) -> Option<EditorRange> {
find_nth_word_in_line(line, word, line_num, 0)
}
pub fn find_nth_word_in_line(
line: &str,
word: &str,
line_num: u32,
n: usize,
) -> Option<EditorRange> {
let mut count = 0;
let mut start = 0;
while let Some(pos) = line[start..].find(word) {
let abs_pos = start + pos;
let before_ok = abs_pos == 0 || !is_word_char(line.chars().nth(abs_pos - 1)?);
let after_ok = abs_pos + word.len() >= line.len()
|| !is_word_char(line.chars().nth(abs_pos + word.len())?);
if before_ok && after_ok {
if count == n {
return Some(EditorRange {
start_line: line_num,
start_character: abs_pos as u32,
end_line: line_num,
end_character: (abs_pos + word.len()) as u32,
});
}
count += 1;
}
start = abs_pos + 1;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use rustledger_parser::parse;
#[test]
fn test_get_word_at_position() {
let source = "2024-01-01 open Assets:Bank USD";
let word = get_word_at_position(source, 0, 11);
assert_eq!(word, Some("open".to_string()));
let word = get_word_at_position(source, 0, 20);
assert_eq!(word, Some("Assets:Bank".to_string()));
let word = get_word_at_position(source, 0, 28);
assert_eq!(word, Some("USD".to_string()));
}
#[test]
fn test_get_word_at_position_out_of_bounds() {
let source = "hello";
let word = get_word_at_position(source, 0, 100);
assert!(word.is_none());
}
#[test]
fn test_get_word_at_position_at_space() {
let source = "hello world";
let word = get_word_at_position(source, 0, 5);
assert_eq!(word, Some("hello".to_string()));
}
#[test]
fn test_is_date_like() {
assert!(is_date_like("2024-01-15"));
assert!(is_date_like("1999-12-31"));
assert!(!is_date_like("2024-1-15")); assert!(!is_date_like("not-a-date"));
assert!(is_date_like("2024-13-99")); }
#[test]
fn test_is_currency_like() {
assert!(is_currency_like("USD"));
assert!(is_currency_like("EUR"));
assert!(is_currency_like("BTC"));
assert!(is_currency_like("AAPL"));
assert!(!is_currency_like("U")); assert!(!is_currency_like("VERYLONGCURRENCY")); assert!(!is_currency_like("usd")); }
#[test]
fn test_is_account_type() {
assert!(is_account_type("Assets"));
assert!(is_account_type("Liabilities"));
assert!(is_account_type("Equity"));
assert!(is_account_type("Income"));
assert!(is_account_type("Expenses"));
assert!(!is_account_type("Other"));
assert!(!is_account_type("assets")); }
#[test]
fn test_extract_accounts() {
let source = r#"2024-01-01 open Assets:Bank USD
2024-01-01 open Expenses:Food USD
2024-01-15 * "Coffee"
Assets:Bank -5.00 USD
Expenses:Food 5.00 USD
"#;
let result = parse(source);
let accounts = extract_accounts(&result);
assert!(accounts.contains(&"Assets:Bank".to_string()));
assert!(accounts.contains(&"Expenses:Food".to_string()));
}
#[test]
fn test_extract_currencies() {
let source = r#"2024-01-01 open Assets:Bank USD
2024-01-01 commodity EUR
2024-01-15 balance Assets:Bank 100.00 GBP
"#;
let result = parse(source);
let currencies = extract_currencies(&result);
assert!(currencies.contains(&"USD".to_string()));
assert!(currencies.contains(&"EUR".to_string()));
assert!(currencies.contains(&"GBP".to_string()));
}
#[test]
fn test_extract_payees() {
let source = r#"2024-01-15 * "Coffee Shop" "Morning coffee"
Assets:Bank -5.00 USD
Expenses:Food
2024-01-16 * "Restaurant" "Lunch"
Assets:Bank -20.00 USD
Expenses:Food
"#;
let result = parse(source);
let payees = extract_payees(&result);
assert!(payees.contains(&"Coffee Shop".to_string()));
assert!(payees.contains(&"Restaurant".to_string()));
}
#[test]
fn test_find_word_in_line() {
let line = "2024-01-01 open Assets:Bank USD";
let range = find_word_in_line(line, "open", 5);
assert!(range.is_some());
let r = range.unwrap();
assert_eq!(r.start_line, 5);
assert_eq!(r.start_character, 11);
assert_eq!(r.end_character, 15);
}
#[test]
fn test_find_nth_word_in_line() {
let line = "USD EUR USD GBP";
let first = find_nth_word_in_line(line, "USD", 0, 0);
assert!(first.is_some());
assert_eq!(first.unwrap().start_character, 0);
let second = find_nth_word_in_line(line, "USD", 0, 1);
assert!(second.is_some());
assert_eq!(second.unwrap().start_character, 8);
}
#[test]
fn test_find_quoted_string_in_line() {
let line = r#"2024-01-15 * "Coffee Shop" "Morning coffee""#;
let range = find_quoted_string_in_line(line, "Coffee Shop", 0);
assert!(range.is_some());
let r = range.unwrap();
assert_eq!(r.start_character, 13);
assert_eq!(r.end_character, 26);
}
}