pub const ACCOUNT_TYPES: &[&str] = &["Assets", "Liabilities", "Equity", "Income", "Expenses"];
pub const DIRECTIVES: &[&str] = &[
"open",
"close",
"commodity",
"balance",
"pad",
"event",
"query",
"note",
"document",
"custom",
"price",
"txn",
"*",
"!",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CompletionContext {
LineStart,
AfterDate,
ExpectingAccount,
AccountSegment {
prefix: String,
},
ExpectingCurrency,
InsideString,
Tag,
Link,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PositionEncoding {
Utf8,
Utf16,
Char,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompletionKind {
Date,
Directive,
AccountType,
Account,
AccountSegmentFolder,
Currency,
Payee,
Tag,
Link,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompletionCandidate {
pub label: String,
pub insert_text: String,
pub kind: CompletionKind,
pub detail: Option<String>,
}
#[must_use]
pub fn offset_to_byte(line: &str, offset: usize, encoding: PositionEncoding) -> usize {
match encoding {
PositionEncoding::Char => line
.char_indices()
.nth(offset)
.map_or(line.len(), |(b, _)| b),
PositionEncoding::Utf8 | PositionEncoding::Utf16 => {
let mut acc = 0usize;
let mut byte_col = 0usize;
for ch in line.chars() {
if acc >= offset {
break;
}
let u = match encoding {
PositionEncoding::Utf8 => ch.len_utf8(),
PositionEncoding::Utf16 => ch.len_utf16(),
PositionEncoding::Char => unreachable!(),
};
if acc + u > offset {
break;
}
acc += u;
byte_col += ch.len_utf8();
}
byte_col
}
}
}
#[must_use]
pub fn classify_context(before_cursor: &str) -> CompletionContext {
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];
return CompletionContext::AccountSegment {
prefix: prefix.to_string(),
};
}
return CompletionContext::ExpectingAccount;
}
if in_code_position(before_cursor)
&& !before_cursor.ends_with(char::is_whitespace)
&& let Some(token) = before_cursor.split_whitespace().next_back()
{
if token.starts_with('#') {
return CompletionContext::Tag;
}
if token.starts_with('^') {
return CompletionContext::Link;
}
}
if trimmed.is_empty() {
return CompletionContext::LineStart;
}
if trimmed.len() >= 10 && trimmed.is_char_boundary(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].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 in_code_position(before: &str) -> bool {
let mut in_string = false;
let mut escaped = false;
for ch in before.chars() {
if in_string {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
} else if ch == '"' {
in_string = true;
} else if ch == ';' {
return false;
}
}
!in_string
}
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()
}
})
}
#[must_use]
fn directive_detail(directive: &str) -> &'static str {
match directive {
"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)",
_ => "",
}
}
#[must_use]
pub fn line_start_candidates(today: &str) -> Vec<CompletionCandidate> {
vec![CompletionCandidate {
label: today.to_string(),
insert_text: format!("{today} "),
kind: CompletionKind::Date,
detail: Some("Today's date".to_string()),
}]
}
#[must_use]
pub fn after_date_candidates() -> Vec<CompletionCandidate> {
DIRECTIVES
.iter()
.map(|&d| CompletionCandidate {
label: d.to_string(),
insert_text: format!("{d} "),
kind: CompletionKind::Directive,
detail: Some(directive_detail(d).to_string()),
})
.collect()
}
#[must_use]
pub fn account_start_candidates(accounts: &[String]) -> Vec<CompletionCandidate> {
let mut items: Vec<CompletionCandidate> = ACCOUNT_TYPES
.iter()
.map(|&t| CompletionCandidate {
label: format!("{t}:"),
insert_text: format!("{t}:"),
kind: CompletionKind::AccountType,
detail: Some(format!("{t} account type")),
})
.collect();
for account in accounts {
items.push(CompletionCandidate {
label: account.clone(),
insert_text: account.clone(),
kind: CompletionKind::Account,
detail: Some("Known account".to_string()),
});
}
items
}
#[must_use]
pub fn account_segment_candidates(prefix: &str, accounts: &[String]) -> Vec<CompletionCandidate> {
let matching: Vec<_> = 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}:")));
let insert_text = if has_more {
format!("{seg}:")
} else {
seg.clone()
};
CompletionCandidate {
label: seg,
insert_text,
kind: if has_more {
CompletionKind::AccountSegmentFolder
} else {
CompletionKind::Account
},
detail: Some(if has_more {
"Account segment".to_string()
} else {
"Account".to_string()
}),
}
})
.collect()
}
#[must_use]
pub fn currency_candidates(currencies: &[String]) -> Vec<CompletionCandidate> {
currencies
.iter()
.map(|c| CompletionCandidate {
label: c.clone(),
insert_text: c.clone(),
kind: CompletionKind::Currency,
detail: Some("Currency".to_string()),
})
.collect()
}
#[must_use]
pub fn payee_candidates(payees: &[String]) -> Vec<CompletionCandidate> {
payees
.iter()
.map(|p| CompletionCandidate {
label: p.clone(),
insert_text: p.clone(),
kind: CompletionKind::Payee,
detail: Some("Known payee".to_string()),
})
.collect()
}
#[must_use]
pub fn tag_candidates(tags: &[String]) -> Vec<CompletionCandidate> {
tags.iter()
.map(|tag| CompletionCandidate {
label: format!("#{tag}"),
insert_text: tag.clone(),
kind: CompletionKind::Tag,
detail: Some("Tag".to_string()),
})
.collect()
}
#[must_use]
pub fn link_candidates(links: &[String]) -> Vec<CompletionCandidate> {
links
.iter()
.map(|link| CompletionCandidate {
label: format!("^{link}"),
insert_text: link.clone(),
kind: CompletionKind::Link,
detail: Some("Link".to_string()),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx(before: &str) -> CompletionContext {
classify_context(before)
}
#[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 classify_line_start() {
assert_eq!(ctx(""), CompletionContext::LineStart);
assert_eq!(ctx(" "), CompletionContext::LineStart);
}
#[test]
fn classify_after_date() {
assert_eq!(ctx("2024-01-15 "), CompletionContext::AfterDate);
}
#[test]
fn classify_expecting_account() {
assert_eq!(ctx(" "), CompletionContext::ExpectingAccount);
assert_eq!(ctx("2024-01-15 open "), CompletionContext::ExpectingAccount);
}
#[test]
fn classify_account_segment() {
assert_eq!(
ctx(" Assets:"),
CompletionContext::AccountSegment {
prefix: "Assets:".to_string()
}
);
assert_eq!(
ctx("2024-01-15 open Assets:"),
CompletionContext::AccountSegment {
prefix: "Assets:".to_string()
}
);
}
#[test]
fn classify_expecting_currency() {
assert_eq!(
ctx(" Assets:Bank 100.00 "),
CompletionContext::ExpectingCurrency
);
}
#[test]
fn classify_inside_string() {
assert_eq!(ctx("text \"inside"), CompletionContext::InsideString);
}
#[test]
fn classify_unknown() {
assert_eq!(ctx("some random text"), CompletionContext::Unknown);
}
#[test]
fn classify_tag_on_transaction_header() {
assert_eq!(
ctx("2024-01-15 * \"Central Perk\" #cof"),
CompletionContext::Tag
);
assert_eq!(
ctx("2024-01-15 * \"Central Perk\" #"),
CompletionContext::Tag
);
}
#[test]
fn classify_link_on_transaction_header() {
assert_eq!(
ctx("2024-01-15 * \"Central Perk\" ^trip"),
CompletionContext::Link
);
}
#[test]
fn classify_tag_on_pushtag() {
assert_eq!(ctx("pushtag #tr"), CompletionContext::Tag);
assert_eq!(ctx("poptag #tr"), CompletionContext::Tag);
}
#[test]
fn classify_hash_inside_string_is_not_tag() {
let c = ctx("2024-01-15 * \"paid #5 invoice");
assert_ne!(c, CompletionContext::Tag);
assert_ne!(c, CompletionContext::Link);
}
#[test]
fn classify_hash_in_comment_is_not_tag() {
let c = ctx("2024-01-15 * \"Lunch\" ; see #123");
assert_ne!(c, CompletionContext::Tag);
assert_ne!(c, CompletionContext::Link);
}
#[test]
fn classify_after_completed_tag_is_not_tag() {
assert_eq!(
ctx("2024-01-15 * \"Central Perk\" #coffee "),
CompletionContext::AfterDate
);
}
#[test]
fn classify_tag_after_semicolon_inside_string() {
assert_eq!(ctx("2024-01-15 * \"a;b\" #tr"), CompletionContext::Tag);
}
#[test]
fn classify_escaped_quote_keeps_string_open() {
let c = ctx("2024-01-15 * \"a\\\"b #tag");
assert_ne!(c, CompletionContext::Tag);
assert_ne!(c, CompletionContext::Link);
}
#[test]
fn test_in_code_position() {
assert!(in_code_position("2024-01-15 * \"x\" #"));
assert!(in_code_position("pushtag #"));
assert!(!in_code_position("2024-01-15 * \"x\" ; "));
assert!(!in_code_position("2024-01-15 * \"open"));
assert!(in_code_position("2024-01-15 * \"a;b\" "));
assert!(!in_code_position("2024-01-15 * \"a\\\"b"));
}
#[test]
fn offset_to_byte_char_korean_partial_segment() {
let line = " Liabilities:Card:롯";
let byte = offset_to_byte(line, 20, PositionEncoding::Char);
let before = &line[..byte];
assert_eq!(
classify_context(before),
CompletionContext::AccountSegment {
prefix: "Liabilities:Card:".to_string()
}
);
let byte19 = offset_to_byte(line, 19, PositionEncoding::Char);
assert_eq!(
classify_context(&line[..byte19]),
CompletionContext::AccountSegment {
prefix: "Liabilities:Card:".to_string()
}
);
}
#[test]
fn offset_to_byte_char_past_end_clamps() {
let line = "abc";
assert_eq!(offset_to_byte(line, 100, PositionEncoding::Char), 3);
}
#[test]
fn offset_to_byte_utf16_surrogate_pair() {
let line = "a🍣b";
assert_eq!(offset_to_byte(line, 3, PositionEncoding::Utf16), 5);
assert_eq!(offset_to_byte(line, 2, PositionEncoding::Utf16), 1);
}
#[test]
fn offset_to_byte_utf8_multibyte() {
let line = "x소y";
assert_eq!(offset_to_byte(line, 4, PositionEncoding::Utf8), 4);
assert_eq!(offset_to_byte(line, 2, PositionEncoding::Utf8), 1);
}
#[test]
fn line_start_candidate_uses_supplied_date() {
let items = line_start_candidates("2026-06-12");
assert_eq!(items.len(), 1);
assert_eq!(items[0].label, "2026-06-12");
assert_eq!(items[0].insert_text, "2026-06-12 ");
assert_eq!(items[0].kind, CompletionKind::Date);
}
#[test]
fn after_date_candidates_returns_all_directives() {
let items = after_date_candidates();
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"open"));
assert!(labels.contains(&"close"));
assert!(labels.contains(&"balance"));
assert!(labels.contains(&"*"));
assert!(labels.contains(&"!"));
let open = items.iter().find(|i| i.label == "open").unwrap();
assert_eq!(open.insert_text, "open ");
assert_eq!(open.detail.as_deref(), Some("Open an account"));
}
#[test]
fn account_start_candidates_includes_types_and_all_accounts() {
let accounts: Vec<String> = (1..=30)
.map(|n| format!("Expenses:ExpenseType{n:02}"))
.collect();
let items = account_start_candidates(&accounts);
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"Assets:"));
assert!(labels.contains(&"Expenses:ExpenseType19"));
assert!(labels.contains(&"Expenses:ExpenseType20"));
assert!(labels.contains(&"Expenses:ExpenseType30"));
let assets = items.iter().find(|i| i.label == "Assets:").unwrap();
assert_eq!(assets.kind, CompletionKind::AccountType);
}
#[test]
fn account_segment_candidates_filters_and_marks_folders() {
let accounts = vec![
"Assets:Bank:Checking".to_string(),
"Assets:Bank:Savings".to_string(),
"Assets:Crypto".to_string(),
"Expenses:Food".to_string(),
];
let items = account_segment_candidates("Assets:Bank:", &accounts);
assert_eq!(items.len(), 2);
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"Checking"));
assert!(labels.contains(&"Savings"));
let checking = items.iter().find(|i| i.label == "Checking").unwrap();
assert_eq!(checking.insert_text, "Checking");
assert_eq!(checking.kind, CompletionKind::Account);
let top = account_segment_candidates("Assets:", &accounts);
let bank = top.iter().find(|i| i.label == "Bank").unwrap();
assert_eq!(bank.kind, CompletionKind::AccountSegmentFolder);
assert_eq!(bank.insert_text, "Bank:");
let crypto = top.iter().find(|i| i.label == "Crypto").unwrap();
assert_eq!(crypto.kind, CompletionKind::Account);
assert_eq!(crypto.insert_text, "Crypto");
}
#[test]
fn currency_candidates_basic() {
let items = currency_candidates(&["USD".to_string(), "EUR".to_string()]);
assert_eq!(items.len(), 2);
assert_eq!(items[0].kind, CompletionKind::Currency);
assert_eq!(items[0].detail.as_deref(), Some("Currency"));
}
#[test]
fn payee_candidates_returns_all() {
let payees: Vec<String> = (1..=30).map(|n| format!("Buy{n:02}")).collect();
let items = payee_candidates(&payees);
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"Buy19"));
assert!(labels.contains(&"Buy20"));
assert!(labels.contains(&"Buy30"));
assert_eq!(items[0].kind, CompletionKind::Payee);
}
#[test]
fn tag_candidates_keep_sigil_in_label_only() {
let items = tag_candidates(&["coffee".to_string(), "morning".to_string()]);
let coffee = items.iter().find(|i| i.label == "#coffee").unwrap();
assert_eq!(coffee.insert_text, "coffee");
assert_eq!(coffee.kind, CompletionKind::Tag);
assert_eq!(coffee.detail.as_deref(), Some("Tag"));
}
#[test]
fn link_candidates_keep_sigil_in_label_only() {
let items = link_candidates(&["trip-2024".to_string()]);
let trip = items.iter().find(|i| i.label == "^trip-2024").unwrap();
assert_eq!(trip.insert_text, "trip-2024");
assert_eq!(trip.kind, CompletionKind::Link);
assert_eq!(trip.detail.as_deref(), Some("Link"));
}
}