use lsp_types::{Hover, HoverContents, HoverParams, MarkupContent, MarkupKind};
use rustledger_core::Directive;
use rustledger_parser::{ParseResult, Spanned};
use crate::ledger_state::LedgerState;
use super::utils::{
PositionEncoding, commodity_declaration_spans, count_noun, get_word_at_source_position,
is_account_type, is_currency_like_simple,
};
pub fn handle_hover(
params: &HoverParams,
source: &str,
parse_result: &ParseResult,
ledger_state: Option<&LedgerState>,
encoding: PositionEncoding,
) -> Option<Hover> {
let position = params.text_document_position_params.position;
let word = get_word_at_source_position(source, position, encoding)?;
tracing::debug!("Hover for word: {:?}", word);
let ledger_directives = ledger_state.and_then(LedgerState::directives);
if (word.contains(':') || is_account_type(&word))
&& let Some(info) = get_account_info(&word, parse_result, ledger_directives)
{
return Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: info,
}),
range: None,
});
}
if is_currency_like_simple(&word)
&& let Some(info) = get_currency_info(&word, parse_result)
{
return Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: info,
}),
range: None,
});
}
if let Some(info) = get_directive_info(&word) {
return Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: info,
}),
range: None,
});
}
None
}
fn get_account_info(
account: &str,
parse_result: &ParseResult,
ledger_directives: Option<&[Spanned<Directive>]>,
) -> Option<String> {
let matches_open = |open: &rustledger_core::Open| {
let oa = open.account.as_ref();
account == oa
|| account
.strip_prefix(oa)
.is_some_and(|rest| rest.starts_with(':'))
};
let find_open = |dirs: &[Spanned<Directive>]| {
dirs.iter().find_map(|sd| match &sd.value {
Directive::Open(open) if matches_open(open) => Some(open.clone()),
_ => None,
})
};
let open =
find_open(&parse_result.directives).or_else(|| ledger_directives.and_then(find_open));
let usage_count = count_account_usages(account, parse_result);
if let Some(open) = open {
let mut info = format!("## Account: `{}`\n\n", open.account);
info.push_str(&format!("**Opened:** {}\n\n", open.date));
if !open.currencies.is_empty() {
let currencies: Vec<String> = open.currencies.iter().map(|c| c.to_string()).collect();
info.push_str(&format!("**Currencies:** {}\n\n", currencies.join(", ")));
}
info.push_str(&format!(
"**Used in:** {}",
count_noun(usage_count, "posting")
));
return Some(info);
}
if usage_count > 0 {
return Some(format!(
"## Account: `{account}`\n\n**Note:** No `open` directive found\n\n**Used in:** {}",
count_noun(usage_count, "posting")
));
}
None
}
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
}
fn get_currency_info(currency: &str, parse_result: &ParseResult) -> Option<String> {
for spanned_directive in &parse_result.directives {
if let Directive::Commodity(comm) = &spanned_directive.value
&& comm.currency.as_ref() == currency
{
let mut info = format!("## Currency: `{}`\n\n", currency);
info.push_str(&format!("**Defined:** {}\n", comm.date));
let usage_count = count_currency_usages(currency, parse_result);
info.push_str(&format!(
"\n**Used in:** {}",
count_noun(usage_count, "amount")
));
return Some(info);
}
}
let usage_count = count_currency_usages(currency, parse_result);
if usage_count > 0 {
return Some(format!(
"## Currency: `{}`\n\n**Note:** No `commodity` directive found\n\n**Used in:** {}",
currency,
count_noun(usage_count, "amount")
));
}
None
}
#[allow(clippy::cmp_owned)]
fn count_currency_usages(currency: &str, parse_result: &ParseResult) -> usize {
let declaration_spans = commodity_declaration_spans(parse_result);
parse_result
.currency_occurrences
.iter()
.filter(|o| o.value == currency && !declaration_spans.contains(&o.span))
.count()
}
fn get_directive_info(keyword: &str) -> Option<String> {
let info = match keyword {
"open" => {
"## `open` Directive\n\nOpens an account for use in transactions.\n\n```beancount\n2024-01-01 open Assets:Bank USD\n```"
}
"close" => {
"## `close` Directive\n\nCloses an account. No transactions allowed after this date.\n\n```beancount\n2024-12-31 close Assets:OldBank\n```"
}
"commodity" => {
"## `commodity` Directive\n\nDefines a currency or commodity.\n\n```beancount\n2024-01-01 commodity USD\n```"
}
"balance" => {
"## `balance` Directive\n\nAsserts the balance of an account at a given date.\n\n```beancount\n2024-01-01 balance Assets:Bank 1000.00 USD\n```"
}
"pad" => {
"## `pad` Directive\n\nAutomatically pads an account to match a balance assertion.\n\n```beancount\n2024-01-01 pad Assets:Bank Equity:Opening-Balances\n```"
}
"event" => {
"## `event` Directive\n\nRecords a named event with a value.\n\n```beancount\n2024-01-01 event \"location\" \"New York\"\n```"
}
"note" => {
"## `note` Directive\n\nAttaches a note to an account.\n\n```beancount\n2024-01-01 note Assets:Bank \"Account opened\"\n```"
}
"document" => {
"## `document` Directive\n\nLinks a document to an account.\n\n```beancount\n2024-01-01 document Assets:Bank \"/path/to/statement.pdf\"\n```"
}
"query" => {
"## `query` Directive\n\nDefines a named BQL query.\n\n```beancount\n2024-01-01 query \"expenses\" \"SELECT account, sum(amount)\"\n```"
}
"custom" => {
"## `custom` Directive\n\nA custom directive for extensions.\n\n```beancount\n2024-01-01 custom \"budget\" Expenses:Food 500.00 USD\n```"
}
"price" => {
"## `price` Directive\n\nRecords a price for a commodity.\n\n```beancount\n2024-01-01 price BTC 45000.00 USD\n```"
}
"txn" | "*" => {
"## Transaction\n\nA complete (balanced) transaction.\n\n```beancount\n2024-01-01 * \"Payee\" \"Description\"\n Assets:Bank -100.00 USD\n Expenses:Food\n```"
}
"!" => {
"## Transaction (Incomplete)\n\nAn incomplete or flagged transaction.\n\n```beancount\n2024-01-01 ! \"Payee\" \"Needs review\"\n Assets:Bank -100.00 USD\n Expenses:Unknown\n```"
}
"include" => {
"## `include` Directive\n\nIncludes another Beancount file.\n\n```beancount\ninclude \"other-file.beancount\"\n```"
}
"option" => {
"## `option` Directive\n\nSets a Beancount option.\n\n```beancount\noption \"operating_currency\" \"USD\"\n```"
}
"plugin" => {
"## `plugin` Directive\n\nLoads a plugin.\n\n```beancount\nplugin \"beancount.plugins.auto_accounts\"\n```"
}
_ => return None,
};
Some(info.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use rustledger_parser::parse;
#[test]
fn test_get_account_info_resolves_included_open() {
let pr = parse("2024-02-01 * \"x\"\n Assets:Shared 1 USD\n Assets:Shared -1 USD\n");
let inc = parse("2024-01-01 open Assets:Shared USD\n");
let single = get_account_info("Assets:Shared", &pr, None).expect("usage info");
assert!(single.contains("No `open` directive found"));
let cross =
get_account_info("Assets:Shared", &pr, Some(&inc.directives)).expect("account info");
assert!(cross.contains("**Opened:** 2024-01-01"), "got: {cross}");
assert!(
!cross.contains("No `open`"),
"should not claim missing open: {cross}"
);
}
#[test]
fn test_get_directive_info() {
assert!(get_directive_info("open").is_some());
assert!(get_directive_info("close").is_some());
assert!(get_directive_info("*").is_some());
assert!(get_directive_info("unknown").is_none());
}
#[test]
fn test_count_currency_usages_exhaustive() {
use rustledger_parser::parse;
let source = r#"2024-01-01 commodity USD
2024-01-01 open Assets:Bank USD
2024-01-15 * "Buy stock"
Assets:Stock 10 AAPL {150 USD}
Assets:Bank
2024-01-20 balance Assets:Bank -1500 USD
2024-01-21 price AAPL 155 USD
"#;
let parse_result = parse(source);
assert!(
parse_result.errors.is_empty(),
"parse errors: {:?}",
parse_result.errors
);
let count = count_currency_usages("USD", &parse_result);
assert_eq!(
count, 4,
"expected 4 USD usages (Open + CostSpec + Balance + Price.amount); got {count}"
);
}
}