use lsp_types::{CodeLens, CodeLensParams, Command, Position, Range};
use rustledger_booking::BookingEngine;
use rustledger_core::NaiveDate;
use rustledger_core::{BookingMethod, Decimal, Directive};
use rustledger_parser::{ParseResult, Spanned};
use std::collections::HashMap;
use super::utils::LineIndex;
pub fn handle_code_lens(
params: &CodeLensParams,
source: &str,
parse_result: &ParseResult,
) -> Option<Vec<CodeLens>> {
let line_index = LineIndex::new(source);
let mut lenses = Vec::new();
let uri = params.text_document.uri.as_str();
let account_stats = collect_account_stats(parse_result);
for spanned in &parse_result.directives {
let (line, _) = line_index.offset_to_position(spanned.span.start);
match &spanned.value {
Directive::Open(open) => {
let account = open.account.to_string();
let stats = account_stats.get(&account);
let txn_count = stats.map(|s| s.transaction_count).unwrap_or(0);
let currencies: Vec<String> =
open.currencies.iter().map(|c| c.to_string()).collect();
let title = if txn_count > 0 {
if currencies.is_empty() {
format!("{} transactions", txn_count)
} else {
format!("{} transactions | {}", txn_count, currencies.join(", "))
}
} else if !currencies.is_empty() {
currencies.join(", ")
} else {
"No transactions".to_string()
};
lenses.push(CodeLens {
range: Range {
start: Position::new(line, 0),
end: Position::new(line, 0),
},
command: Some(Command {
title,
command: "rledger.showAccountDetails".to_string(),
arguments: Some(vec![serde_json::json!(account)]),
}),
data: Some(serde_json::json!({ "uri": uri })),
});
}
Directive::Transaction(txn) => {
let posting_count = txn.postings.len();
let currencies: Vec<String> = txn
.postings
.iter()
.filter_map(|p| {
p.units
.as_ref()
.and_then(|u| u.currency().map(String::from))
})
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
let title = if currencies.is_empty() {
format!("{} postings", posting_count)
} else {
format!("{} postings | {}", posting_count, currencies.join(", "))
};
lenses.push(CodeLens {
range: Range {
start: Position::new(line, 0),
end: Position::new(line, 0),
},
command: Some(Command {
title,
command: "rledger.showTransactionDetails".to_string(),
arguments: None,
}),
data: Some(serde_json::json!({ "uri": uri })),
});
}
Directive::Balance(bal) => {
let data = serde_json::json!({
"uri": uri,
"kind": "balance",
"account": bal.account.to_string(),
"date": bal.date.to_string(),
"expected_amount": bal.amount.number.to_string(),
"expected_currency": bal.amount.currency.to_string(),
});
lenses.push(CodeLens {
range: Range {
start: Position::new(line, 0),
end: Position::new(line, 0),
},
command: None, data: Some(data),
});
}
_ => {}
}
}
lenses.extend(super::import::import_code_lens(
&parse_result.directives,
source,
));
if lenses.is_empty() {
None
} else {
Some(lenses)
}
}
pub fn handle_code_lens_resolve(
lens: CodeLens,
parse_result: &ParseResult,
ledger_directives: Option<&[Spanned<Directive>]>,
) -> CodeLens {
let mut resolved = lens.clone();
let mut processed_balance = false;
if let Some(data) = &lens.data
&& data.get("kind").and_then(|v| v.as_str()) == Some("balance")
{
processed_balance = true;
let account = data.get("account").and_then(|v| v.as_str()).unwrap_or("");
let date_str = data.get("date").and_then(|v| v.as_str()).unwrap_or("");
let expected_amount = data
.get("expected_amount")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<Decimal>().ok())
.unwrap_or_default();
let expected_currency = data
.get("expected_currency")
.and_then(|v| v.as_str())
.unwrap_or("");
let date = date_str.parse::<NaiveDate>().ok();
let actual_balance = calculate_balance_at_date(
ledger_directives.unwrap_or(&parse_result.directives),
account,
date,
);
let actual_amount = actual_balance
.get(expected_currency)
.copied()
.unwrap_or_default();
if actual_amount == expected_amount {
resolved.command = Some(Command {
title: format!("✓ Balance: {} {}", expected_amount, expected_currency),
command: "rledger.showBalanceDetails".to_string(),
arguments: Some(vec![serde_json::json!({
"account": account,
"status": "verified",
"expected": format!("{} {}", expected_amount, expected_currency),
"actual": format!("{} {}", actual_amount, expected_currency),
})]),
});
}
}
if !processed_balance && resolved.command.is_none() {
resolved.command = Some(Command {
title: "Balance assertion".to_string(),
command: "rledger.noop".to_string(),
arguments: None,
});
}
resolved
}
fn calculate_balance_at_date(
directives_in: &[Spanned<Directive>],
account: &str,
date: Option<rustledger_core::NaiveDate>,
) -> HashMap<String, Decimal> {
let mut directives: Vec<Spanned<Directive>> = directives_in.to_vec();
directives.sort_by_cached_key(|d| {
(
d.value.date(),
d.value.priority(),
d.value.has_cost_reduction(),
)
});
let mut booking_engine = BookingEngine::with_method(BookingMethod::Strict);
booking_engine.register_account_methods(directives.iter().map(|s| &s.value));
for spanned in &mut directives {
if let Directive::Transaction(txn) = &mut spanned.value
&& let Ok(result) = booking_engine.book_and_interpolate(txn)
{
booking_engine.apply(&result.transaction);
*txn = result.transaction;
}
}
let mut balances: HashMap<String, Decimal> = HashMap::new();
for spanned in &directives {
if let Directive::Transaction(txn) = &spanned.value {
if let Some(d) = date
&& txn.date >= d
{
continue;
}
for posting in &txn.postings {
if posting.account.as_ref() == account
&& let Some(units) = &posting.units
&& let Some(number) = units.number()
{
let currency = units.currency().unwrap_or("???").to_string();
*balances.entry(currency).or_default() += number;
}
}
}
}
balances
}
#[derive(Default)]
struct AccountStats {
transaction_count: usize,
}
fn collect_account_stats(parse_result: &ParseResult) -> HashMap<String, AccountStats> {
let mut stats: HashMap<String, AccountStats> = HashMap::new();
for spanned in &parse_result.directives {
if let Directive::Transaction(txn) = &spanned.value {
for posting in &txn.postings {
let account = posting.account.to_string();
stats.entry(account).or_default().transaction_count += 1;
}
}
}
stats
}
#[cfg(test)]
mod tests {
use super::*;
use rustledger_parser::parse;
#[test]
fn test_code_lens_accounts() {
let source = r#"2024-01-01 open Assets:Bank USD
2024-01-15 * "Coffee"
Assets:Bank -5.00 USD
Expenses:Food
2024-01-16 * "Lunch"
Assets:Bank -10.00 USD
Expenses:Food
"#;
let result = parse(source);
let params = CodeLensParams {
text_document: lsp_types::TextDocumentIdentifier {
uri: "file:///test.beancount".parse().unwrap(),
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let lenses = handle_code_lens(¶ms, source, &result);
assert!(lenses.is_some());
let lenses = lenses.unwrap();
assert_eq!(lenses.len(), 3);
assert!(
lenses[0]
.command
.as_ref()
.unwrap()
.title
.contains("2 transactions")
);
}
#[test]
fn test_code_lens_balance() {
let source = r#"2024-01-01 open Assets:Bank USD
2024-01-31 balance Assets:Bank 100 USD
"#;
let result = parse(source);
let params = CodeLensParams {
text_document: lsp_types::TextDocumentIdentifier {
uri: "file:///test.beancount".parse().unwrap(),
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let lenses = handle_code_lens(¶ms, source, &result);
assert!(lenses.is_some());
let lenses = lenses.unwrap();
let balance_lens = lenses.iter().find(|l| {
l.data
.as_ref()
.and_then(|d| d.get("kind"))
.and_then(|v| v.as_str())
== Some("balance")
});
assert!(balance_lens.is_some());
assert!(balance_lens.unwrap().command.is_none());
}
#[test]
fn test_code_lens_resolve_balance_match() {
let source = r#"2024-01-01 open Assets:Bank USD
2024-01-15 * "Deposit"
Assets:Bank 100.00 USD
Income:Salary
2024-01-31 balance Assets:Bank 100 USD
"#;
let result = parse(source);
let lens = CodeLens {
range: Range {
start: Position::new(4, 0),
end: Position::new(4, 0),
},
command: None,
data: Some(serde_json::json!({
"kind": "balance",
"account": "Assets:Bank",
"date": "2024-01-31",
"expected_amount": "100",
"expected_currency": "USD",
})),
};
let resolved = handle_code_lens_resolve(lens, &result, None);
assert!(resolved.command.is_some());
let cmd = resolved.command.unwrap();
assert!(cmd.title.contains("✓")); assert!(cmd.title.contains("100"));
}
#[test]
fn test_code_lens_resolve_balance_mismatch() {
let source = r#"2024-01-01 open Assets:Bank USD
2024-01-15 * "Deposit"
Assets:Bank 50.00 USD
Income:Salary
2024-01-31 balance Assets:Bank 100 USD
"#;
let result = parse(source);
let lens = CodeLens {
range: Range {
start: Position::new(4, 0),
end: Position::new(4, 0),
},
command: None,
data: Some(serde_json::json!({
"kind": "balance",
"account": "Assets:Bank",
"date": "2024-01-31",
"expected_amount": "100",
"expected_currency": "USD",
})),
};
let resolved = handle_code_lens_resolve(lens, &result, None);
assert!(
resolved.command.is_none(),
"Mismatched balance should not show codelens (diagnostic handles it)"
);
}
#[test]
fn test_code_lens_resolve_with_auto_filled_posting() {
let source = r#"2024-01-01 open Assets:Bank USD
2024-01-01 open Income:Salary USD
2024-01-15 * "Deposit"
Assets:Bank 100.00 USD
Income:Salary
2024-01-31 balance Assets:Bank 100 USD
"#;
let result = parse(source);
let lens = CodeLens {
range: Range {
start: Position::new(5, 0),
end: Position::new(5, 0),
},
command: None,
data: Some(serde_json::json!({
"kind": "balance",
"account": "Assets:Bank",
"date": "2024-01-31",
"expected_amount": "100",
"expected_currency": "USD",
})),
};
let resolved = handle_code_lens_resolve(lens, &result, None);
assert!(
resolved.command.is_some(),
"Balance with auto-filled posting should pass after booking"
);
let cmd = resolved.command.unwrap();
assert!(
cmd.title.contains("✓"),
"Should show checkmark for passing balance. Got: {}",
cmd.title
);
}
#[test]
fn test_code_lens_resolve_missing_data() {
let source = r#"2024-01-01 open Assets:Bank USD"#;
let result = parse(source);
let lens = CodeLens {
range: Range {
start: Position::new(0, 0),
end: Position::new(0, 0),
},
command: None,
data: None,
};
let resolved = handle_code_lens_resolve(lens, &result, None);
assert!(
resolved.command.is_some(),
"Lens must always have a command after resolve"
);
}
#[test]
fn test_code_lens_resolve_multifile_balance() {
let bank_source = r#"2024-01-01 open Assets:Bank:Checking USD
2024-01-15 * "Paycheck"
Assets:Bank:Checking 5000 USD
Income:Salary
2024-01-21 balance Assets:Bank:Checking 4950 USD
"#;
let bank_result = parse(bank_source);
let credit_card_source = r#"2024-01-01 open Liabilities:Credit-Card
2024-01-20 * "Pay off credit card"
Assets:Bank:Checking -50 USD
Liabilities:Credit-Card
"#;
let credit_card_result = parse(credit_card_source);
let mut full_directives = bank_result.directives.clone();
full_directives.extend(credit_card_result.directives.clone());
let lens = CodeLens {
range: Range {
start: Position::new(4, 0),
end: Position::new(4, 0),
},
command: None,
data: Some(serde_json::json!({
"kind": "balance",
"account": "Assets:Bank:Checking",
"date": "2024-01-21",
"expected_amount": "4950",
"expected_currency": "USD",
})),
};
let resolved_single = handle_code_lens_resolve(lens.clone(), &bank_result, None);
assert!(
resolved_single.command.is_none(),
"Single-file mode should see mismatch (5000 != 4950)"
);
let resolved_multi = handle_code_lens_resolve(lens, &bank_result, Some(&full_directives));
assert!(
resolved_multi.command.is_some(),
"Multi-file mode should see match (5000 - 50 = 4950)"
);
let cmd = resolved_multi.command.unwrap();
assert!(
cmd.title.contains("✓"),
"Should show checkmark. Got: {}",
cmd.title
);
assert!(
cmd.title.contains("4950"),
"Should show correct balance. Got: {}",
cmd.title
);
}
#[test]
fn test_code_lens_resolve_wrong_kind() {
let source = r#"2024-01-01 open Assets:Bank USD"#;
let result = parse(source);
let lens = CodeLens {
range: Range {
start: Position::new(0, 0),
end: Position::new(0, 0),
},
command: None,
data: Some(serde_json::json!({
"kind": "unknown",
"account": "Assets:Bank",
})),
};
let resolved = handle_code_lens_resolve(lens, &result, None);
assert!(
resolved.command.is_some(),
"Lens must always have a command after resolve"
);
}
}