rustledger-lsp 0.21.0

Language Server Protocol implementation for Beancount
Documentation
//! Completion resolve handler for lazy-loading completion details.
//!
//! Provides additional information when a completion item is selected:
//! - Account completions: show current balance and transaction count
//! - Currency completions: show price history

use lsp_types::{CompletionItem, Documentation, MarkupContent, MarkupKind};
use rustledger_core::Decimal;
use rustledger_core::Directive;
use rustledger_parser::Spanned;
use std::collections::HashMap;

use super::utils::{is_account_like, is_currency_like_simple};

/// Handle a completion item resolve request.
/// This adds detailed documentation to completion items.
///
/// `directives` is the set of directives to aggregate over. The caller
/// passes the *full ledger* (all `include`d files, resolved via the
/// `journalFile` setting) when one is configured, falling back to the
/// directives of the currently edited file otherwise. Resolving against
/// the full ledger keeps the detail popup (balances, transaction counts,
/// price history) consistent with `hover`, instead of reflecting only
/// the month/file the cursor happens to be in (issue #1297).
pub fn handle_completion_resolve(
    item: CompletionItem,
    directives: &[Spanned<Directive>],
) -> CompletionItem {
    let mut resolved = item.clone();

    // The detail is inferred from the label shape. `handle_completion`
    // attaches only `{uri}` to each item's `data` (never a structured
    // "kind"), so there is nothing else to dispatch on here: an
    // account-like label gets balance/transaction detail, a
    // currency-like label gets price history. Labels that match neither
    // (payees, tags, links) have no detail popup — resolving those
    // would require the producer to tag items with a kind, which it
    // does not currently do.
    //
    // Only fill documentation the item doesn't already carry: a resolve
    // handler adds missing detail, it doesn't clobber what a producer
    // (or a client round-trip) may have set eagerly.
    if resolved.documentation.is_none() {
        let label = &item.label;
        if is_account_like(label) {
            let (doc, detail) = resolve_account_documentation(label, directives);
            resolved.documentation = Some(doc);
            // Surface the ledger-wide summary in `detail` too — clients that
            // show `detail` inline (and not the documentation popup) otherwise
            // only see the generic kind hint set by the producer (issue #1408).
            if let Some(detail) = detail {
                resolved.detail = Some(detail);
            }
        } else if is_currency_like_simple(label) {
            let (doc, detail) = resolve_currency_documentation(label, directives);
            resolved.documentation = Some(doc);
            if let Some(detail) = detail {
                resolved.detail = Some(detail);
            }
        }
    }

    resolved
}

/// Resolve documentation (and a concise `detail` summary) for an account
/// completion. Returns the markdown popup plus, when the account has activity,
/// a one-line `detail` like `"3500 USD · 2 txns"`.
fn resolve_account_documentation(
    account: &str,
    directives: &[Spanned<Directive>],
) -> (Documentation, Option<String>) {
    let mut balances: HashMap<String, Decimal> = HashMap::new();
    let mut transaction_count = 0;
    let mut first_date: Option<rustledger_core::NaiveDate> = None;
    let mut last_date: Option<rustledger_core::NaiveDate> = None;

    for spanned in directives {
        if let Directive::Transaction(txn) = &spanned.value {
            for posting in &txn.postings {
                if posting.account.as_ref() == account {
                    transaction_count += 1;

                    // Track dates
                    if first_date.is_none() || Some(txn.date) < first_date {
                        first_date = Some(txn.date);
                    }
                    if last_date.is_none() || Some(txn.date) > last_date {
                        last_date = Some(txn.date);
                    }

                    // Track balance
                    if let Some(units) = &posting.units
                        && let Some(number) = units.number()
                    {
                        let currency = units.currency().unwrap_or("???").to_string();
                        *balances.entry(currency).or_default() += number;
                    }
                }
            }
        }
    }

    let mut doc = format!("**{}**\n\n", account);

    if transaction_count > 0 {
        doc.push_str(&format!("📊 **{} transactions**\n\n", transaction_count));

        if let (Some(first), Some(last)) = (first_date, last_date) {
            doc.push_str(&format!("📅 {}{}\n\n", first, last));
        }

        if !balances.is_empty() {
            doc.push_str("**Current Balance:**\n");
            for (currency, amount) in &balances {
                doc.push_str(&format!("- {} {}\n", amount, currency));
            }
        }
    } else {
        doc.push_str("_No transactions found_");
    }

    // One-line summary for the completion `detail` field: balances
    // (currency-sorted for stable output) and the transaction count.
    let detail = if transaction_count > 0 {
        let mut parts: Vec<String> = balances
            .iter()
            .map(|(currency, amount)| format!("{amount} {currency}"))
            .collect();
        parts.sort();
        let balance_str = parts.join(", ");
        Some(if balance_str.is_empty() {
            format!("{transaction_count} txns")
        } else {
            format!("{balance_str} · {transaction_count} txns")
        })
    } else {
        None
    };

    let documentation = Documentation::MarkupContent(MarkupContent {
        kind: MarkupKind::Markdown,
        value: doc,
    });
    (documentation, detail)
}

/// Resolve documentation (and a concise `detail` summary) for a currency
/// completion. Returns the markdown popup plus, when the currency is used, a
/// one-line `detail` like `"12 postings · 155 USD"` (latest price).
fn resolve_currency_documentation(
    currency: &str,
    directives: &[Spanned<Directive>],
) -> (Documentation, Option<String>) {
    let mut prices: Vec<(rustledger_core::NaiveDate, Decimal, String)> = Vec::new();
    let mut usage_count = 0;

    for spanned in directives {
        match &spanned.value {
            Directive::Price(price) if price.currency.as_ref() == currency => {
                prices.push((
                    price.date,
                    price.amount.number,
                    price.amount.currency.to_string(),
                ));
            }
            Directive::Transaction(txn) => {
                for posting in &txn.postings {
                    if let Some(units) = &posting.units
                        && units.currency() == Some(currency)
                    {
                        usage_count += 1;
                    }
                }
            }
            _ => {}
        }
    }

    let mut doc = format!("**{}**\n\n", currency);

    doc.push_str(&format!("📊 Used in **{} postings**\n\n", usage_count));

    if !prices.is_empty() {
        // Sort by date descending
        prices.sort_by_key(|b| std::cmp::Reverse(b.0));

        doc.push_str("**Recent Prices:**\n");
        for (date, amount, quote_currency) in prices.iter().take(5) {
            doc.push_str(&format!("- {}: {} {}\n", date, amount, quote_currency));
        }

        if prices.len() > 5 {
            doc.push_str(&format!("- _...and {} more_\n", prices.len() - 5));
        }
    }

    // One-line `detail`: usage count plus the latest price, when known.
    let detail = if usage_count > 0 || !prices.is_empty() {
        let mut summary = format!("{usage_count} postings");
        if let Some((_, amount, quote)) = prices.first() {
            summary.push_str(&format!(" · {amount} {quote}"));
        }
        Some(summary)
    } else {
        None
    };

    let documentation = Documentation::MarkupContent(MarkupContent {
        kind: MarkupKind::Markdown,
        value: doc,
    });
    (documentation, detail)
}

#[cfg(test)]
mod tests {
    use super::super::utils::{is_account_like, is_currency_like_simple};
    use super::*;
    use rustledger_parser::parse;

    #[test]
    fn test_resolve_account_completion() {
        let source = r#"2024-01-01 open Assets:Bank USD
2024-01-15 * "Deposit"
  Assets:Bank  100.00 USD
  Income:Salary
2024-01-20 * "Coffee"
  Assets:Bank  -5.00 USD
  Expenses:Food
"#;
        let result = parse(source);

        let item = CompletionItem {
            label: "Assets:Bank".to_string(),
            ..Default::default()
        };

        let resolved = handle_completion_resolve(item, &result.directives);
        assert!(resolved.documentation.is_some());

        // `detail` now carries the ledger summary too (issue #1408).
        let detail = resolved.detail.clone().expect("detail summary set");
        assert!(detail.contains("95"), "detail balance; got: {detail}");
        assert!(detail.contains("2 txns"), "detail count; got: {detail}");

        if let Some(Documentation::MarkupContent(content)) = resolved.documentation {
            assert!(content.value.contains("Assets:Bank"));
            assert!(content.value.contains("2 transactions"));
            assert!(content.value.contains("95")); // 100 - 5
        }
    }

    /// Regression for #1297: the resolve detail must aggregate over
    /// every directive it's handed, not just the current file. When a
    /// `journalFile` is configured the caller passes the full ledger's
    /// directives (all `include`d files merged), so a completion
    /// resolved while editing one monthly file still reports the
    /// whole-ledger balance and transaction count — matching `hover`.
    ///
    /// This simulates the multi-file case by concatenating the
    /// directives from two "files" (two parses) into one slice, the
    /// same shape `LedgerState::directives()` hands the call site.
    #[test]
    fn resolve_account_aggregates_across_full_ledger() {
        // "Current file" the cursor is in: only January's activity.
        let jan = parse(
            r#"2024-01-15 * "Deposit"
  Assets:Bank  100.00 USD
  Income:Salary
"#,
        );
        // Another included file: February's activity.
        let feb = parse(
            r#"2024-02-10 * "Coffee"
  Assets:Bank  -5.00 USD
  Expenses:Food
2024-02-20 * "Lunch"
  Assets:Bank  -10.00 USD
  Expenses:Food
"#,
        );

        // The full ledger = every file's directives merged. This is
        // what the resolve handler receives when journalFile is set.
        let mut full_ledger = jan.directives.clone();
        full_ledger.extend(feb.directives.iter().cloned());

        let item = CompletionItem {
            label: "Assets:Bank".to_string(),
            ..Default::default()
        };

        // Resolving against only the current file would report 1
        // transaction and a 100.00 balance. Against the full ledger it
        // must see all three postings.
        let resolved = handle_completion_resolve(item, &full_ledger);
        let Some(Documentation::MarkupContent(content)) = resolved.documentation else {
            panic!("expected markdown documentation");
        };
        assert!(
            content.value.contains("3 transactions"),
            "should count postings across all files; got:\n{}",
            content.value
        );
        assert!(
            content.value.contains("85"), // 100 - 5 - 10
            "balance should aggregate across all files; got:\n{}",
            content.value
        );
        // Date range should span both files (Jan → Feb).
        assert!(
            content.value.contains("2024-01-15") && content.value.contains("2024-02-20"),
            "date range should span the full ledger; got:\n{}",
            content.value
        );
    }

    #[test]
    fn test_resolve_currency_completion() {
        let source = r#"2024-01-01 price AAPL 150 USD
2024-01-15 price AAPL 155 USD
2024-01-15 * "Buy stock"
  Assets:Brokerage  10 AAPL
  Assets:Bank  -1500 USD
"#;
        let result = parse(source);

        let item = CompletionItem {
            label: "AAPL".to_string(),
            ..Default::default()
        };

        let resolved = handle_completion_resolve(item, &result.directives);
        assert!(resolved.documentation.is_some());

        // `detail` carries usage + latest price (issue #1408).
        let detail = resolved.detail.clone().expect("detail summary set");
        assert!(detail.contains("postings"), "detail usage; got: {detail}");
        assert!(detail.contains("155"), "detail latest price; got: {detail}");

        if let Some(Documentation::MarkupContent(content)) = resolved.documentation {
            assert!(content.value.contains("AAPL"));
            assert!(content.value.contains("Recent Prices"));
        }
    }

    #[test]
    fn test_is_account_like() {
        assert!(is_account_like("Assets:Bank"));
        assert!(is_account_like("Expenses:Food:Coffee"));
        assert!(!is_account_like("USD"));
        assert!(!is_account_like("hello"));
    }

    #[test]
    fn test_is_currency_like() {
        assert!(is_currency_like_simple("USD"));
        assert!(is_currency_like_simple("AAPL"));
        assert!(is_currency_like_simple("BTC"));
        assert!(!is_currency_like_simple("Assets:Bank"));
        assert!(!is_currency_like_simple("hello world"));
    }
}