use lsp_types::{InlayHint, InlayHintKind, InlayHintLabel, InlayHintParams, Position};
use rustledger_core::{Decimal, Directive};
use rustledger_parser::ParseResult;
use std::collections::HashMap;
use super::utils::byte_offset_to_position;
pub fn handle_inlay_hints(
params: &InlayHintParams,
source: &str,
parse_result: &ParseResult,
) -> Option<Vec<InlayHint>> {
let range = params.range;
let uri = params.text_document.uri.as_str();
let mut hints = Vec::new();
let lines: Vec<&str> = source.lines().collect();
for spanned in &parse_result.directives {
if let Directive::Transaction(txn) = &spanned.value {
let (start_line, _) = byte_offset_to_position(source, spanned.span.start);
if start_line > range.end.line {
continue;
}
let inferred = calculate_inferred_amount(txn);
for (i, posting) in txn.postings.iter().enumerate() {
let posting_line = start_line + 1 + i as u32;
if posting_line < range.start.line || posting_line > range.end.line {
continue;
}
if posting.units.is_none()
&& let Some((amount, currency)) = &inferred
&& let Some(line) = lines.get(posting_line as usize)
{
let trimmed = line.trim();
let indent = line.len() - line.trim_start().len();
let end_col = indent + trimmed.len();
let data = serde_json::json!({
"uri": uri,
"kind": "inferred_amount",
"account": posting.account.to_string(),
"amount": amount.to_string(),
"currency": currency,
});
hints.push(InlayHint {
position: Position::new(posting_line, end_col as u32),
label: InlayHintLabel::String(format!(" {} {}", amount, currency)),
kind: Some(InlayHintKind::TYPE),
text_edits: None,
tooltip: None, padding_left: Some(true),
padding_right: None,
data: Some(data),
});
}
}
}
}
if hints.is_empty() { None } else { Some(hints) }
}
pub fn handle_inlay_hint_resolve(hint: InlayHint, parse_result: &ParseResult) -> InlayHint {
let mut resolved = hint.clone();
if let Some(data) = &hint.data
&& let Some(kind) = data.get("kind").and_then(|v| v.as_str())
&& kind == "inferred_amount"
{
let account = data.get("account").and_then(|v| v.as_str()).unwrap_or("");
let amount = data.get("amount").and_then(|v| v.as_str()).unwrap_or("");
let currency = data.get("currency").and_then(|v| v.as_str()).unwrap_or("");
let tooltip = build_account_tooltip(account, amount, currency, parse_result);
resolved.tooltip = Some(lsp_types::InlayHintTooltip::MarkupContent(
lsp_types::MarkupContent {
kind: lsp_types::MarkupKind::Markdown,
value: tooltip,
},
));
}
resolved
}
fn build_account_tooltip(
account: &str,
inferred_amount: &str,
currency: &str,
parse_result: &ParseResult,
) -> String {
let mut balances: HashMap<String, Decimal> = HashMap::new();
let mut transaction_count = 0;
for spanned in &parse_result.directives {
if let Directive::Transaction(txn) = &spanned.value {
for posting in &txn.postings {
if posting.account.as_ref() == account {
transaction_count += 1;
if let Some(units) = &posting.units
&& let Some(number) = units.number()
{
let curr = units.currency().unwrap_or("???").to_string();
*balances.entry(curr).or_default() += number;
}
}
}
}
}
let mut tooltip = format!("**Inferred:** {} {}\n\n", inferred_amount, currency);
tooltip.push_str(&format!("**Account:** `{}`\n\n", account));
if transaction_count > 0 {
tooltip.push_str(&format!("📊 {} transactions\n\n", transaction_count));
if !balances.is_empty() {
tooltip.push_str("**Current Balance:**\n");
for (curr, amount) in &balances {
tooltip.push_str(&format!("- {} {}\n", amount, curr));
}
}
} else {
tooltip.push_str("_First transaction for this account_");
}
tooltip
}
fn calculate_inferred_amount(txn: &rustledger_core::Transaction) -> Option<(Decimal, String)> {
let mut amounts_by_currency: HashMap<String, Decimal> = HashMap::new();
let mut empty_posting_count = 0;
for posting in &txn.postings {
if let Some(ref units) = posting.units {
if let (Some(num), Some(curr)) = (units.number(), units.currency()) {
let currency = curr.to_string();
*amounts_by_currency.entry(currency).or_insert(Decimal::ZERO) += num;
}
} else {
empty_posting_count += 1;
}
}
if empty_posting_count == 1 && amounts_by_currency.len() == 1 {
let (currency, total) = amounts_by_currency.into_iter().next()?;
Some((-total, currency))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use rustledger_parser::parse;
#[test]
fn test_inlay_hints_inferred_amount() {
let source = r#"2024-01-15 * "Coffee Shop"
Assets:Bank -5.00 USD
Expenses:Food
"#;
let result = parse(source);
let params = InlayHintParams {
text_document: lsp_types::TextDocumentIdentifier {
uri: "file:///test.beancount".parse().unwrap(),
},
range: lsp_types::Range {
start: Position::new(0, 0),
end: Position::new(3, 0),
},
work_done_progress_params: Default::default(),
};
let hints = handle_inlay_hints(¶ms, source, &result);
assert!(hints.is_some());
let hints = hints.unwrap();
assert_eq!(hints.len(), 1);
if let InlayHintLabel::String(label) = &hints[0].label {
assert!(label.contains("5.00"));
assert!(label.contains("USD"));
}
}
#[test]
fn test_calculate_inferred_amount() {
let source = r#"2024-01-15 * "Test"
Assets:Bank -10.00 USD
Expenses:Food
"#;
let result = parse(source);
if let Some(spanned) = result.directives.first()
&& let Directive::Transaction(txn) = &spanned.value
{
let inferred = calculate_inferred_amount(txn);
assert!(inferred.is_some());
let (amount, currency) = inferred.unwrap();
assert_eq!(amount, Decimal::new(1000, 2)); assert_eq!(currency, "USD");
}
}
#[test]
fn test_inlay_hint_resolve() {
let source = r#"2024-01-15 * "Coffee"
Assets:Bank -5.00 USD
Expenses:Food
2024-01-20 * "Lunch"
Assets:Bank -10.00 USD
Expenses:Food
"#;
let result = parse(source);
let hint = InlayHint {
position: Position::new(2, 15),
label: InlayHintLabel::String(" 5.00 USD".to_string()),
kind: Some(InlayHintKind::TYPE),
text_edits: None,
tooltip: None,
padding_left: Some(true),
padding_right: None,
data: Some(serde_json::json!({
"kind": "inferred_amount",
"account": "Expenses:Food",
"amount": "5.00",
"currency": "USD",
})),
};
let resolved = handle_inlay_hint_resolve(hint, &result);
assert!(resolved.tooltip.is_some());
if let Some(lsp_types::InlayHintTooltip::MarkupContent(content)) = resolved.tooltip {
assert!(content.value.contains("Expenses:Food"));
assert!(content.value.contains("2 transactions"));
}
}
#[test]
fn test_inlay_hints_disappear_when_amount_explicit() {
let source_v1 = r#"2024-01-15 * "Paycheck"
Assets:Bank 5000 USD
Income:Salary
"#;
let source_v2 = r#"2024-01-15 * "Paycheck"
Assets:Bank 5000 USD
Income:Salary -5000 USD
"#;
let params = InlayHintParams {
text_document: lsp_types::TextDocumentIdentifier {
uri: "file:///test.beancount".parse().unwrap(),
},
range: lsp_types::Range {
start: Position::new(0, 0),
end: Position::new(10, 0),
},
work_done_progress_params: Default::default(),
};
let result_v1 = parse(source_v1);
let hints_v1 = handle_inlay_hints(¶ms, source_v1, &result_v1);
let result_v2 = parse(source_v2);
let hints_v2 = handle_inlay_hints(¶ms, source_v2, &result_v2);
assert!(hints_v1.is_some(), "V1 should have hints");
assert_eq!(
hints_v1.as_ref().unwrap().len(),
1,
"V1 should have exactly 1 hint"
);
assert!(
hints_v2.is_none() || hints_v2.as_ref().unwrap().is_empty(),
"V2 should have no hints when amount is explicit"
);
}
}