use crate::ledger_state::LedgerState;
use lsp_types::{
CompletionItem, CompletionItemKind, CompletionParams, CompletionResponse, Position,
};
use rustledger_parser::ParseResult;
const ACCOUNT_TYPES: &[&str] = &["Assets", "Liabilities", "Equity", "Income", "Expenses"];
const DIRECTIVES: &[&str] = &[
"open",
"close",
"commodity",
"balance",
"pad",
"event",
"query",
"note",
"document",
"custom",
"price",
"txn",
"*",
"!",
];
#[derive(Debug, Clone, PartialEq)]
pub enum CompletionContext {
LineStart,
AfterDate,
ExpectingAccount,
AccountSegment {
prefix: String,
},
ExpectingCurrency,
InsideString,
Unknown,
}
pub fn handle_completion(
params: &CompletionParams,
source: &str,
parse_result: &ParseResult,
ledger_state: Option<&LedgerState>,
) -> Option<CompletionResponse> {
let position = params.text_document_position.position;
let uri = ¶ms.text_document_position.text_document.uri;
let context = detect_context(source, position);
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::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 {
Some(CompletionResponse::Array(items))
}
}
fn detect_context(source: &str, position: Position) -> CompletionContext {
let line = get_line(source, position.line as usize);
let byte_col = super::utils::char_offset_to_byte(line, position.character as usize);
let before_cursor = &line[..byte_col];
let trimmed = before_cursor.trim_start();
if before_cursor.starts_with(" ") || before_cursor.starts_with('\t') {
if trimmed.is_empty() {
return CompletionContext::ExpectingAccount;
}
let posting_content = trimmed;
if posting_content.contains(':') && posting_content.contains(' ') {
let parts: Vec<&str> = posting_content.split_whitespace().collect();
if parts.len() >= 2 {
if let Some(last) = parts.last()
&& (last.parse::<f64>().is_ok() || last.ends_with('.'))
{
return CompletionContext::ExpectingCurrency;
}
}
return CompletionContext::Unknown;
}
if let Some(colon_pos) = posting_content.rfind(':') {
let prefix = &posting_content[..colon_pos + 1];
return CompletionContext::AccountSegment {
prefix: prefix.to_string(),
};
}
return CompletionContext::ExpectingAccount;
}
if trimmed.is_empty() {
return CompletionContext::LineStart;
}
if trimmed.len() >= 10 && is_date_like(&trimmed[..10]) {
let after_date = trimmed[10..].trim_start();
if after_date.is_empty() {
return CompletionContext::AfterDate;
}
for directive in DIRECTIVES {
if let Some(rest) = after_date.strip_prefix(directive) {
let after_directive = rest.trim_start();
if after_directive.is_empty() || !after_directive.contains(' ') {
match *directive {
"open" | "close" | "balance" | "pad" | "note" | "document" => {
if let Some(colon_pos) = after_directive.rfind(':') {
return CompletionContext::AccountSegment {
prefix: after_directive[..colon_pos + 1].to_string(),
};
}
return CompletionContext::ExpectingAccount;
}
_ => return CompletionContext::Unknown,
}
}
}
}
return CompletionContext::AfterDate;
}
let quote_count = before_cursor.chars().filter(|&c| c == '"').count();
if quote_count % 2 == 1 {
return CompletionContext::InsideString;
}
CompletionContext::Unknown
}
fn get_line(source: &str, line_num: usize) -> &str {
source.lines().nth(line_num).unwrap_or("")
}
fn is_date_like(s: &str) -> bool {
if s.len() != 10 {
return false;
}
let chars: Vec<char> = s.chars().collect();
chars[4] == '-'
&& chars[7] == '-'
&& chars.iter().enumerate().all(|(i, c)| {
if i == 4 || i == 7 {
*c == '-'
} else {
c.is_ascii_digit()
}
})
}
fn complete_line_start() -> Vec<CompletionItem> {
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
vec![CompletionItem {
label: today.clone(),
kind: Some(CompletionItemKind::VALUE),
detail: Some("Today's date".to_string()),
insert_text: Some(format!("{} ", today)),
..Default::default()
}]
}
fn complete_after_date() -> Vec<CompletionItem> {
DIRECTIVES
.iter()
.map(|&d| {
let detail = match d {
"open" => "Open an account",
"close" => "Close an account",
"commodity" => "Define a commodity/currency",
"balance" => "Assert account balance",
"pad" => "Pad account to target",
"event" => "Record an event",
"query" => "Define a named query",
"note" => "Add a note to an account",
"document" => "Link a document",
"custom" => "Custom directive",
"price" => "Record a price",
"txn" | "*" => "Transaction (complete)",
"!" => "Transaction (incomplete)",
_ => "",
};
CompletionItem {
label: d.to_string(),
kind: Some(CompletionItemKind::KEYWORD),
detail: Some(detail.to_string()),
insert_text: Some(format!("{} ", d)),
..Default::default()
}
})
.collect()
}
fn complete_account_start(
parse_result: &ParseResult,
ledger_state: Option<&LedgerState>,
) -> Vec<CompletionItem> {
let mut items: Vec<CompletionItem> = ACCOUNT_TYPES
.iter()
.map(|&t| CompletionItem {
label: format!("{}:", t),
kind: Some(CompletionItemKind::FOLDER),
detail: Some(format!("{} account type", t)),
..Default::default()
})
.collect();
let known_accounts = get_all_accounts(parse_result, ledger_state);
for account in known_accounts.iter().take(20) {
items.push(CompletionItem {
label: account.clone(),
kind: Some(CompletionItemKind::VARIABLE),
detail: Some("Known account".to_string()),
..Default::default()
});
}
items
}
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);
let matching: Vec<_> = known_accounts
.iter()
.filter(|a| a.starts_with(prefix))
.collect();
let mut segments: Vec<String> = matching
.iter()
.filter_map(|a| {
let after_prefix = &a[prefix.len()..];
let next_segment = after_prefix.split(':').next()?;
if next_segment.is_empty() {
None
} else {
Some(next_segment.to_string())
}
})
.collect();
segments.sort();
segments.dedup();
segments
.into_iter()
.map(|seg| {
let full = format!("{}{}", prefix, seg);
let has_more = matching
.iter()
.any(|a| a.starts_with(&format!("{}:", full)));
CompletionItem {
label: seg.clone(),
kind: Some(if has_more {
CompletionItemKind::FOLDER
} else {
CompletionItemKind::VARIABLE
}),
detail: Some(if has_more {
"Account segment".to_string()
} else {
"Account".to_string()
}),
insert_text: Some(if has_more { format!("{}:", seg) } else { seg }),
..Default::default()
}
})
.collect()
}
fn complete_currency(
parse_result: &ParseResult,
ledger_state: Option<&LedgerState>,
) -> Vec<CompletionItem> {
let currencies = get_all_currencies(parse_result, ledger_state);
currencies
.into_iter()
.map(|c| CompletionItem {
label: c.clone(),
kind: Some(CompletionItemKind::UNIT),
detail: Some("Currency".to_string()),
..Default::default()
})
.collect()
}
fn complete_payee(
parse_result: &ParseResult,
ledger_state: Option<&LedgerState>,
) -> Vec<CompletionItem> {
let payees = get_all_payees(parse_result, ledger_state);
payees
.into_iter()
.take(20)
.map(|p| CompletionItem {
label: p.clone(),
kind: Some(CompletionItemKind::TEXT),
detail: Some("Known payee".to_string()),
..Default::default()
})
.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 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))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_date_like() {
assert!(is_date_like("2024-01-15"));
assert!(is_date_like("2000-12-31"));
assert!(!is_date_like("2024/01/15"));
assert!(!is_date_like("24-01-15"));
assert!(!is_date_like("not-a-date"));
}
#[test]
fn test_detect_context_line_start() {
let source = "\n";
let ctx = detect_context(source, Position::new(0, 0));
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));
assert_eq!(ctx, CompletionContext::AfterDate);
}
#[test]
fn test_detect_context_expecting_account() {
let source = " ";
let ctx = detect_context(source, Position::new(0, 2));
assert_eq!(ctx, CompletionContext::ExpectingAccount);
}
#[test]
fn test_detect_context_account_segment() {
let source = " Assets:";
let ctx = detect_context(source, Position::new(0, 9));
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);
}
#[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);
}
#[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);
}
}