use crate::ledger_state::LedgerState;
use lsp_types::{
CompletionItem, CompletionItemKind, CompletionParams, CompletionResponse, Position,
};
use rustledger_completion::{CompletionCandidate, CompletionKind};
use rustledger_parser::ParseResult;
pub use rustledger_completion::CompletionContext;
pub fn handle_completion(
params: &CompletionParams,
source: &str,
parse_result: &ParseResult,
ledger_state: Option<&LedgerState>,
encoding: super::utils::PositionEncoding,
) -> Option<CompletionResponse> {
let position = params.text_document_position.position;
let uri = ¶ms.text_document_position.text_document.uri;
let context = detect_context(source, position, encoding);
tracing::debug!("Completion context: {:?} at {:?}", context, position);
let mut items = match context {
CompletionContext::LineStart => complete_line_start(),
CompletionContext::AfterDate => complete_after_date(),
CompletionContext::ExpectingAccount => complete_account_start(parse_result, ledger_state),
CompletionContext::AccountSegment { prefix } => {
complete_account_segment(&prefix, parse_result, ledger_state)
}
CompletionContext::ExpectingCurrency => complete_currency(parse_result, ledger_state),
CompletionContext::InsideString => complete_payee(parse_result, ledger_state),
CompletionContext::Tag => complete_tag(parse_result, ledger_state),
CompletionContext::Link => complete_link(parse_result, ledger_state),
CompletionContext::Unknown => return None,
};
let uri_data = serde_json::json!({ "uri": uri.as_str() });
for item in &mut items {
item.data = Some(uri_data.clone());
}
if items.is_empty() {
None
} else {
tracing::debug!("Completion response: {} items", items.len());
Some(CompletionResponse::Array(items))
}
}
fn detect_context(
source: &str,
position: Position,
encoding: super::utils::PositionEncoding,
) -> CompletionContext {
let line = get_line(source, position.line as usize);
let byte_col = rustledger_completion::offset_to_byte(
line,
position.character as usize,
shared_encoding(encoding),
);
rustledger_completion::classify_context(&line[..byte_col])
}
fn shared_encoding(
encoding: super::utils::PositionEncoding,
) -> rustledger_completion::PositionEncoding {
match encoding {
super::utils::PositionEncoding::Utf8 => rustledger_completion::PositionEncoding::Utf8,
super::utils::PositionEncoding::Utf16 => rustledger_completion::PositionEncoding::Utf16,
}
}
fn get_line(source: &str, line_num: usize) -> &str {
source.lines().nth(line_num).unwrap_or("")
}
fn lsp_kind(kind: CompletionKind) -> CompletionItemKind {
match kind {
CompletionKind::Date => CompletionItemKind::VALUE,
CompletionKind::Directive => CompletionItemKind::KEYWORD,
CompletionKind::AccountType | CompletionKind::AccountSegmentFolder => {
CompletionItemKind::FOLDER
}
CompletionKind::Account => CompletionItemKind::VARIABLE,
CompletionKind::Currency => CompletionItemKind::UNIT,
CompletionKind::Payee => CompletionItemKind::TEXT,
CompletionKind::Tag => CompletionItemKind::CONSTANT,
CompletionKind::Link => CompletionItemKind::REFERENCE,
}
}
fn to_item(candidate: CompletionCandidate) -> CompletionItem {
let CompletionCandidate {
label,
insert_text,
kind,
detail,
} = candidate;
let mut item = CompletionItem {
label,
kind: Some(lsp_kind(kind)),
detail,
..Default::default()
};
match kind {
CompletionKind::AccountType
| CompletionKind::Account
| CompletionKind::Currency
| CompletionKind::Payee => {}
CompletionKind::Date | CompletionKind::Directive | CompletionKind::AccountSegmentFolder => {
item.insert_text = Some(insert_text);
}
CompletionKind::Tag | CompletionKind::Link => {
item.filter_text = Some(insert_text.clone());
item.insert_text = Some(insert_text);
}
}
item
}
fn complete_line_start() -> Vec<CompletionItem> {
let today = jiff::Zoned::now().date().to_string();
rustledger_completion::line_start_candidates(&today)
.into_iter()
.map(to_item)
.collect()
}
fn complete_after_date() -> Vec<CompletionItem> {
rustledger_completion::after_date_candidates()
.into_iter()
.map(to_item)
.collect()
}
fn complete_account_start(
parse_result: &ParseResult,
ledger_state: Option<&LedgerState>,
) -> Vec<CompletionItem> {
let known_accounts = get_all_accounts(parse_result, ledger_state);
rustledger_completion::account_start_candidates(&known_accounts)
.into_iter()
.map(to_item)
.collect()
}
fn complete_account_segment(
prefix: &str,
parse_result: &ParseResult,
ledger_state: Option<&LedgerState>,
) -> Vec<CompletionItem> {
let known_accounts = get_all_accounts(parse_result, ledger_state);
rustledger_completion::account_segment_candidates(prefix, &known_accounts)
.into_iter()
.map(to_item)
.collect()
}
fn complete_currency(
parse_result: &ParseResult,
ledger_state: Option<&LedgerState>,
) -> Vec<CompletionItem> {
let currencies = get_all_currencies(parse_result, ledger_state);
rustledger_completion::currency_candidates(¤cies)
.into_iter()
.map(to_item)
.collect()
}
fn complete_payee(
parse_result: &ParseResult,
ledger_state: Option<&LedgerState>,
) -> Vec<CompletionItem> {
let payees = get_all_payees(parse_result, ledger_state);
rustledger_completion::payee_candidates(&payees)
.into_iter()
.map(to_item)
.collect()
}
fn complete_tag(
parse_result: &ParseResult,
ledger_state: Option<&LedgerState>,
) -> Vec<CompletionItem> {
let tags = get_all_tags(parse_result, ledger_state);
rustledger_completion::tag_candidates(&tags)
.into_iter()
.map(to_item)
.collect()
}
fn complete_link(
parse_result: &ParseResult,
ledger_state: Option<&LedgerState>,
) -> Vec<CompletionItem> {
let links = get_all_links(parse_result, ledger_state);
rustledger_completion::link_candidates(&links)
.into_iter()
.map(to_item)
.collect()
}
fn get_all_accounts(parse_result: &ParseResult, ledger_state: Option<&LedgerState>) -> Vec<String> {
let mut accounts = extract_accounts(parse_result);
if let Some(state) = ledger_state {
accounts.extend(state.accounts().iter().cloned());
}
accounts.sort();
accounts.dedup();
accounts
}
fn get_all_currencies(
parse_result: &ParseResult,
ledger_state: Option<&LedgerState>,
) -> Vec<String> {
let mut currencies = extract_currencies(parse_result);
if let Some(state) = ledger_state {
currencies.extend(state.currencies().iter().cloned());
}
currencies.sort();
currencies.dedup();
currencies
}
fn get_all_payees(parse_result: &ParseResult, ledger_state: Option<&LedgerState>) -> Vec<String> {
let mut payees = extract_payees(parse_result);
if let Some(state) = ledger_state {
payees.extend(state.payees().iter().cloned());
}
payees.sort();
payees.dedup();
payees
}
fn get_all_tags(parse_result: &ParseResult, ledger_state: Option<&LedgerState>) -> Vec<String> {
let mut tags = extract_tags(parse_result);
if let Some(state) = ledger_state {
tags.extend(state.tags().iter().cloned());
}
tags.sort();
tags.dedup();
tags
}
fn get_all_links(parse_result: &ParseResult, ledger_state: Option<&LedgerState>) -> Vec<String> {
let mut links = extract_links(parse_result);
if let Some(state) = ledger_state {
links.extend(state.links().iter().cloned());
}
links.sort();
links.dedup();
links
}
fn extract_accounts(parse_result: &ParseResult) -> Vec<String> {
rustledger_core::extract_accounts_iter(parse_result.directives.iter().map(|s| &s.value))
}
fn extract_currencies(parse_result: &ParseResult) -> Vec<String> {
rustledger_core::extract_currencies_iter(parse_result.directives.iter().map(|s| &s.value))
}
fn extract_payees(parse_result: &ParseResult) -> Vec<String> {
rustledger_core::extract_payees_iter(parse_result.directives.iter().map(|s| &s.value))
}
fn extract_tags(parse_result: &ParseResult) -> Vec<String> {
rustledger_core::extract_tags_iter(parse_result.directives.iter().map(|s| &s.value))
}
fn extract_links(parse_result: &ParseResult) -> Vec<String> {
rustledger_core::extract_links_iter(parse_result.directives.iter().map(|s| &s.value))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_context_line_start() {
let source = "\n";
let ctx = detect_context(
source,
Position::new(0, 0),
crate::handlers::utils::PositionEncoding::Utf16,
);
assert_eq!(ctx, CompletionContext::LineStart);
}
#[test]
fn test_detect_context_after_date() {
let source = "2024-01-15 ";
let ctx = detect_context(
source,
Position::new(0, 11),
crate::handlers::utils::PositionEncoding::Utf16,
);
assert_eq!(ctx, CompletionContext::AfterDate);
}
#[test]
fn test_detect_context_expecting_account() {
let source = " ";
let ctx = detect_context(
source,
Position::new(0, 2),
crate::handlers::utils::PositionEncoding::Utf16,
);
assert_eq!(ctx, CompletionContext::ExpectingAccount);
}
#[test]
fn test_detect_context_account_segment() {
let source = " Assets:";
let ctx = detect_context(
source,
Position::new(0, 9),
crate::handlers::utils::PositionEncoding::Utf16,
);
assert_eq!(
ctx,
CompletionContext::AccountSegment {
prefix: "Assets:".to_string()
}
);
}
#[test]
fn test_detect_context_multibyte_inline_comment_no_panic() {
let source = "1970-01-01 open Assets:Cash:PettyCash KRW ; 소\n";
let pos = Position::new(0, 45);
let _ctx = detect_context(source, pos, crate::handlers::utils::PositionEncoding::Utf16);
}
#[test]
fn test_detect_context_cjk_narration() {
let source = "2024-01-15 * \"午餐\" \"中華料理\"\n Expenses:Food 100 CNY\n";
let pos = Position::new(0, 20);
let _ctx = detect_context(source, pos, crate::handlers::utils::PositionEncoding::Utf16);
}
#[test]
fn test_detect_context_emoji_narration_utf16_offset() {
let source = "2024-01-15 * \"🍣\"\n";
let pos = Position::new(0, 17);
let _ctx = detect_context(source, pos, crate::handlers::utils::PositionEncoding::Utf16);
}
#[test]
fn complete_account_start_returns_all_known_accounts_above_legacy_cap() {
let source = "\
2024-01-01 open Assets:Bank:Checking USD
2024-01-01 open Income:Salary
2024-01-01 open Income:SomethingElse
2024-01-01 open Expenses:ExpenseType01
2024-01-01 open Expenses:ExpenseType02
2024-01-01 open Expenses:ExpenseType03
2024-01-01 open Expenses:ExpenseType04
2024-01-01 open Expenses:ExpenseType05
2024-01-01 open Expenses:ExpenseType06
2024-01-01 open Expenses:ExpenseType07
2024-01-01 open Expenses:ExpenseType08
2024-01-01 open Expenses:ExpenseType09
2024-01-01 open Expenses:ExpenseType10
2024-01-01 open Expenses:ExpenseType11
2024-01-01 open Expenses:ExpenseType12
2024-01-01 open Expenses:ExpenseType13
2024-01-01 open Expenses:ExpenseType14
2024-01-01 open Expenses:ExpenseType15
2024-01-01 open Expenses:ExpenseType16
2024-01-01 open Expenses:ExpenseType17
2024-01-01 open Expenses:ExpenseType18
2024-01-01 open Expenses:ExpenseType19
2024-01-01 open Expenses:ExpenseType20
2024-01-01 open Expenses:ExpenseType21
2024-01-01 open Expenses:ExpenseType22
2024-01-01 open Expenses:ExpenseType23
2024-01-01 open Expenses:ExpenseType24
2024-01-01 open Expenses:ExpenseType25
2024-01-01 open Expenses:ExpenseType26
2024-01-01 open Expenses:ExpenseType27
2024-01-01 open Expenses:ExpenseType28
2024-01-01 open Expenses:ExpenseType29
2024-01-01 open Expenses:ExpenseType30
";
let parsed = rustledger_parser::parse(source);
assert!(
parsed.errors.is_empty(),
"fixture must parse cleanly: {:?}",
parsed.errors,
);
let items = complete_account_start(&parsed, None);
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.contains(&"Expenses:ExpenseType19"),
"ExpenseType19 must be reachable (pre-fix this was the last that worked); \
labels = {labels:?}"
);
assert!(
labels.contains(&"Expenses:ExpenseType20"),
"ExpenseType20 must be reachable (pre-fix this was the first that failed); \
labels = {labels:?}"
);
assert!(
labels.contains(&"Expenses:ExpenseType30"),
"ExpenseType30 must be reachable (pre-fix all 20+ accounts were dropped); \
labels = {labels:?}"
);
}
#[test]
fn complete_payee_returns_all_known_payees_above_legacy_cap() {
use std::fmt::Write as _;
let mut source = String::from("2024-01-01 open Assets:Cash USD\n");
for n in 1..=30 {
writeln!(
source,
"2024-02-01 * \"Buy{n:02}\" \"\"\n Assets:Cash 1 USD\n Assets:Cash -1 USD",
)
.unwrap();
}
let parsed = rustledger_parser::parse(&source);
assert!(
parsed.errors.is_empty(),
"fixture must parse cleanly: {:?}",
parsed.errors,
);
let items = complete_payee(&parsed, None);
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.contains(&"Buy19"),
"Buy19 must be reachable (pre-fix last that worked); labels = {labels:?}"
);
assert!(
labels.contains(&"Buy20"),
"Buy20 must be reachable (pre-fix first that failed); labels = {labels:?}"
);
assert!(
labels.contains(&"Buy30"),
"Buy30 must be reachable (pre-fix all 20+ payees were dropped); labels = {labels:?}"
);
}
fn ctx_at_end(before: &str) -> CompletionContext {
let char_len = before.chars().map(char::len_utf16).sum::<usize>() as u32;
detect_context(
before,
Position::new(0, char_len),
crate::handlers::utils::PositionEncoding::Utf16,
)
}
#[test]
fn test_detect_context_tag_on_transaction_header() {
assert_eq!(
ctx_at_end("2024-01-15 * \"Central Perk\" #cof"),
CompletionContext::Tag
);
assert_eq!(
ctx_at_end("2024-01-15 * \"Central Perk\" #"),
CompletionContext::Tag
);
}
#[test]
fn test_detect_context_link_on_transaction_header() {
assert_eq!(
ctx_at_end("2024-01-15 * \"Central Perk\" ^trip"),
CompletionContext::Link
);
}
#[test]
fn test_detect_context_tag_on_pushtag() {
assert_eq!(ctx_at_end("pushtag #tr"), CompletionContext::Tag);
assert_eq!(ctx_at_end("poptag #tr"), CompletionContext::Tag);
}
#[test]
fn test_detect_context_hash_inside_string_is_not_tag() {
let ctx = ctx_at_end("2024-01-15 * \"paid #5 invoice");
assert_ne!(ctx, CompletionContext::Tag);
assert_ne!(ctx, CompletionContext::Link);
}
#[test]
fn test_detect_context_hash_in_comment_is_not_tag() {
let ctx = ctx_at_end("2024-01-15 * \"Lunch\" ; see #123");
assert_ne!(ctx, CompletionContext::Tag);
assert_ne!(ctx, CompletionContext::Link);
}
#[test]
fn test_detect_context_after_completed_tag_is_not_tag() {
assert_eq!(
ctx_at_end("2024-01-15 * \"Central Perk\" #coffee "),
CompletionContext::AfterDate
);
}
#[test]
fn complete_tag_returns_known_tags_without_sigil_in_insert() {
let source = "\
2024-01-01 open Assets:Bank:Checking USD
2024-01-01 open Expenses:Stuff USD
2024-01-15 * \"Central Perk\" #coffee #morning
Assets:Bank:Checking -5 USD
Expenses:Stuff
";
let parsed = rustledger_parser::parse(source);
assert!(
parsed.errors.is_empty(),
"fixture must parse: {:?}",
parsed.errors
);
let items = complete_tag(&parsed, None);
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"#coffee"), "labels = {labels:?}");
assert!(labels.contains(&"#morning"), "labels = {labels:?}");
let coffee = items.iter().find(|i| i.label == "#coffee").unwrap();
assert_eq!(coffee.insert_text.as_deref(), Some("coffee"));
assert_eq!(coffee.filter_text.as_deref(), Some("coffee"));
}
#[test]
fn test_detect_context_tag_after_semicolon_inside_string() {
assert_eq!(
ctx_at_end("2024-01-15 * \"a;b\" #tr"),
CompletionContext::Tag
);
}
#[test]
fn test_detect_context_escaped_quote_keeps_string_open() {
let ctx = ctx_at_end("2024-01-15 * \"a\\\"b #tag");
assert_ne!(ctx, CompletionContext::Tag);
assert_ne!(ctx, CompletionContext::Link);
}
#[test]
fn complete_link_returns_known_links() {
let source = "\
2024-01-01 open Assets:Bank:Checking USD
2024-01-01 open Expenses:Stuff USD
2024-01-15 * \"Flight\" ^trip-2024
Assets:Bank:Checking -5 USD
Expenses:Stuff
";
let parsed = rustledger_parser::parse(source);
assert!(
parsed.errors.is_empty(),
"fixture must parse: {:?}",
parsed.errors
);
let items = complete_link(&parsed, None);
let coffee = items.iter().find(|i| i.label == "^trip-2024");
assert!(
coffee.is_some(),
"labels = {:?}",
items.iter().map(|i| &i.label).collect::<Vec<_>>()
);
assert_eq!(coffee.unwrap().insert_text.as_deref(), Some("trip-2024"));
}
}