1pub const ACCOUNT_TYPES: &[&str] = &["Assets", "Liabilities", "Equity", "Income", "Expenses"];
21
22pub const DIRECTIVES: &[&str] = &[
24 "open",
25 "close",
26 "commodity",
27 "balance",
28 "pad",
29 "event",
30 "query",
31 "note",
32 "document",
33 "custom",
34 "price",
35 "txn",
36 "*",
37 "!",
38];
39
40#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum CompletionContext {
46 LineStart,
48 AfterDate,
50 ExpectingAccount,
52 AccountSegment {
54 prefix: String,
56 },
57 ExpectingCurrency,
59 InsideString,
61 Tag,
64 Link,
66 Unknown,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum PositionEncoding {
77 Utf8,
79 Utf16,
81 Char,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum CompletionKind {
93 Date,
95 Directive,
97 AccountType,
99 Account,
101 AccountSegmentFolder,
104 Currency,
106 Payee,
108 Tag,
110 Link,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct CompletionCandidate {
117 pub label: String,
119 pub insert_text: String,
124 pub kind: CompletionKind,
126 pub detail: Option<String>,
128}
129
130#[must_use]
144pub fn offset_to_byte(line: &str, offset: usize, encoding: PositionEncoding) -> usize {
145 match encoding {
146 PositionEncoding::Char => line
147 .char_indices()
148 .nth(offset)
149 .map_or(line.len(), |(b, _)| b),
150 PositionEncoding::Utf8 | PositionEncoding::Utf16 => {
151 let mut acc = 0usize;
152 let mut byte_col = 0usize;
153 for ch in line.chars() {
154 if acc >= offset {
155 break;
156 }
157 let u = match encoding {
158 PositionEncoding::Utf8 => ch.len_utf8(),
159 PositionEncoding::Utf16 => ch.len_utf16(),
160 PositionEncoding::Char => unreachable!(),
161 };
162 if acc + u > offset {
163 break;
165 }
166 acc += u;
167 byte_col += ch.len_utf8();
168 }
169 byte_col
170 }
171 }
172}
173
174#[must_use]
183pub fn classify_context(before_cursor: &str) -> CompletionContext {
184 let trimmed = before_cursor.trim_start();
185
186 if before_cursor.starts_with(" ") || before_cursor.starts_with('\t') {
190 if trimmed.is_empty() {
192 return CompletionContext::ExpectingAccount;
193 }
194 let posting_content = trimmed;
196
197 if posting_content.contains(':') && posting_content.contains(' ') {
199 let parts: Vec<&str> = posting_content.split_whitespace().collect();
201 if parts.len() >= 2 {
202 if let Some(last) = parts.last()
204 && (last.parse::<f64>().is_ok() || last.ends_with('.'))
205 {
206 return CompletionContext::ExpectingCurrency;
207 }
208 }
209 return CompletionContext::Unknown;
210 }
211
212 if let Some(colon_pos) = posting_content.rfind(':') {
214 let prefix = &posting_content[..=colon_pos];
215 return CompletionContext::AccountSegment {
216 prefix: prefix.to_string(),
217 };
218 }
219
220 return CompletionContext::ExpectingAccount;
222 }
223
224 if in_code_position(before_cursor)
233 && !before_cursor.ends_with(char::is_whitespace)
234 && let Some(token) = before_cursor.split_whitespace().next_back()
235 {
236 if token.starts_with('#') {
237 return CompletionContext::Tag;
238 }
239 if token.starts_with('^') {
240 return CompletionContext::Link;
241 }
242 }
243
244 if trimmed.is_empty() {
246 return CompletionContext::LineStart;
247 }
248
249 if trimmed.len() >= 10 && trimmed.is_char_boundary(10) && is_date_like(&trimmed[..10]) {
254 let after_date = trimmed[10..].trim_start();
255 if after_date.is_empty() {
256 return CompletionContext::AfterDate;
257 }
258
259 for directive in DIRECTIVES {
261 if let Some(rest) = after_date.strip_prefix(directive) {
262 let after_directive = rest.trim_start();
263 if after_directive.is_empty() || !after_directive.contains(' ') {
264 match *directive {
266 "open" | "close" | "balance" | "pad" | "note" | "document" => {
267 if let Some(colon_pos) = after_directive.rfind(':') {
268 return CompletionContext::AccountSegment {
269 prefix: after_directive[..=colon_pos].to_string(),
270 };
271 }
272 return CompletionContext::ExpectingAccount;
273 }
274 _ => return CompletionContext::Unknown,
275 }
276 }
277 }
278 }
279
280 return CompletionContext::AfterDate;
282 }
283
284 let quote_count = before_cursor.chars().filter(|&c| c == '"').count();
286 if quote_count % 2 == 1 {
287 return CompletionContext::InsideString;
288 }
289
290 CompletionContext::Unknown
291}
292
293fn in_code_position(before: &str) -> bool {
300 let mut in_string = false;
301 let mut escaped = false;
302 for ch in before.chars() {
303 if in_string {
304 if escaped {
305 escaped = false;
306 } else if ch == '\\' {
307 escaped = true;
308 } else if ch == '"' {
309 in_string = false;
310 }
311 } else if ch == '"' {
312 in_string = true;
313 } else if ch == ';' {
314 return false;
316 }
317 }
318 !in_string
319}
320
321fn is_date_like(s: &str) -> bool {
323 if s.len() != 10 {
324 return false;
325 }
326 let chars: Vec<char> = s.chars().collect();
327 chars[4] == '-'
328 && chars[7] == '-'
329 && chars.iter().enumerate().all(|(i, c)| {
330 if i == 4 || i == 7 {
331 *c == '-'
332 } else {
333 c.is_ascii_digit()
334 }
335 })
336}
337
338#[must_use]
340fn directive_detail(directive: &str) -> &'static str {
341 match directive {
342 "open" => "Open an account",
343 "close" => "Close an account",
344 "commodity" => "Define a commodity/currency",
345 "balance" => "Assert account balance",
346 "pad" => "Pad account to target",
347 "event" => "Record an event",
348 "query" => "Define a named query",
349 "note" => "Add a note to an account",
350 "document" => "Link a document",
351 "custom" => "Custom directive",
352 "price" => "Record a price",
353 "txn" | "*" => "Transaction (complete)",
354 "!" => "Transaction (incomplete)",
355 _ => "",
356 }
357}
358
359#[must_use]
363pub fn line_start_candidates(today: &str) -> Vec<CompletionCandidate> {
364 vec![CompletionCandidate {
365 label: today.to_string(),
366 insert_text: format!("{today} "),
367 kind: CompletionKind::Date,
368 detail: Some("Today's date".to_string()),
369 }]
370}
371
372#[must_use]
374pub fn after_date_candidates() -> Vec<CompletionCandidate> {
375 DIRECTIVES
376 .iter()
377 .map(|&d| CompletionCandidate {
378 label: d.to_string(),
379 insert_text: format!("{d} "),
380 kind: CompletionKind::Directive,
381 detail: Some(directive_detail(d).to_string()),
382 })
383 .collect()
384}
385
386#[must_use]
394pub fn account_start_candidates(accounts: &[String]) -> Vec<CompletionCandidate> {
395 let mut items: Vec<CompletionCandidate> = ACCOUNT_TYPES
396 .iter()
397 .map(|&t| CompletionCandidate {
398 label: format!("{t}:"),
399 insert_text: format!("{t}:"),
400 kind: CompletionKind::AccountType,
401 detail: Some(format!("{t} account type")),
402 })
403 .collect();
404
405 for account in accounts {
406 items.push(CompletionCandidate {
407 label: account.clone(),
408 insert_text: account.clone(),
409 kind: CompletionKind::Account,
410 detail: Some("Known account".to_string()),
411 });
412 }
413
414 items
415}
416
417#[must_use]
424pub fn account_segment_candidates(prefix: &str, accounts: &[String]) -> Vec<CompletionCandidate> {
425 let matching: Vec<_> = accounts.iter().filter(|a| a.starts_with(prefix)).collect();
427
428 let mut segments: Vec<String> = matching
430 .iter()
431 .filter_map(|a| {
432 let after_prefix = &a[prefix.len()..];
433 let next_segment = after_prefix.split(':').next()?;
434 if next_segment.is_empty() {
435 None
436 } else {
437 Some(next_segment.to_string())
438 }
439 })
440 .collect();
441
442 segments.sort();
443 segments.dedup();
444
445 segments
446 .into_iter()
447 .map(|seg| {
448 let full = format!("{prefix}{seg}");
449 let has_more = matching.iter().any(|a| a.starts_with(&format!("{full}:")));
451 let insert_text = if has_more {
452 format!("{seg}:")
453 } else {
454 seg.clone()
455 };
456 CompletionCandidate {
457 label: seg,
458 insert_text,
459 kind: if has_more {
460 CompletionKind::AccountSegmentFolder
461 } else {
462 CompletionKind::Account
463 },
464 detail: Some(if has_more {
465 "Account segment".to_string()
466 } else {
467 "Account".to_string()
468 }),
469 }
470 })
471 .collect()
472}
473
474#[must_use]
476pub fn currency_candidates(currencies: &[String]) -> Vec<CompletionCandidate> {
477 currencies
478 .iter()
479 .map(|c| CompletionCandidate {
480 label: c.clone(),
481 insert_text: c.clone(),
482 kind: CompletionKind::Currency,
483 detail: Some("Currency".to_string()),
484 })
485 .collect()
486}
487
488#[must_use]
491pub fn payee_candidates(payees: &[String]) -> Vec<CompletionCandidate> {
492 payees
493 .iter()
494 .map(|p| CompletionCandidate {
495 label: p.clone(),
496 insert_text: p.clone(),
497 kind: CompletionKind::Payee,
498 detail: Some("Known payee".to_string()),
499 })
500 .collect()
501}
502
503#[must_use]
510pub fn tag_candidates(tags: &[String]) -> Vec<CompletionCandidate> {
511 tags.iter()
512 .map(|tag| CompletionCandidate {
513 label: format!("#{tag}"),
514 insert_text: tag.clone(),
515 kind: CompletionKind::Tag,
516 detail: Some("Tag".to_string()),
517 })
518 .collect()
519}
520
521#[must_use]
525pub fn link_candidates(links: &[String]) -> Vec<CompletionCandidate> {
526 links
527 .iter()
528 .map(|link| CompletionCandidate {
529 label: format!("^{link}"),
530 insert_text: link.clone(),
531 kind: CompletionKind::Link,
532 detail: Some("Link".to_string()),
533 })
534 .collect()
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 fn ctx(before: &str) -> CompletionContext {
544 classify_context(before)
545 }
546
547 #[test]
548 fn test_is_date_like() {
549 assert!(is_date_like("2024-01-15"));
550 assert!(is_date_like("2000-12-31"));
551 assert!(!is_date_like("2024/01/15"));
552 assert!(!is_date_like("24-01-15"));
553 assert!(!is_date_like("not-a-date"));
554 }
555
556 #[test]
557 fn classify_line_start() {
558 assert_eq!(ctx(""), CompletionContext::LineStart);
559 assert_eq!(ctx(" "), CompletionContext::LineStart);
561 }
562
563 #[test]
564 fn classify_after_date() {
565 assert_eq!(ctx("2024-01-15 "), CompletionContext::AfterDate);
566 }
567
568 #[test]
569 fn classify_expecting_account() {
570 assert_eq!(ctx(" "), CompletionContext::ExpectingAccount);
571 assert_eq!(ctx("2024-01-15 open "), CompletionContext::ExpectingAccount);
572 }
573
574 #[test]
575 fn classify_account_segment() {
576 assert_eq!(
577 ctx(" Assets:"),
578 CompletionContext::AccountSegment {
579 prefix: "Assets:".to_string()
580 }
581 );
582 assert_eq!(
583 ctx("2024-01-15 open Assets:"),
584 CompletionContext::AccountSegment {
585 prefix: "Assets:".to_string()
586 }
587 );
588 }
589
590 #[test]
591 fn classify_expecting_currency() {
592 assert_eq!(
593 ctx(" Assets:Bank 100.00 "),
594 CompletionContext::ExpectingCurrency
595 );
596 }
597
598 #[test]
599 fn classify_inside_string() {
600 assert_eq!(ctx("text \"inside"), CompletionContext::InsideString);
601 }
602
603 #[test]
604 fn classify_unknown() {
605 assert_eq!(ctx("some random text"), CompletionContext::Unknown);
606 }
607
608 #[test]
609 fn classify_tag_on_transaction_header() {
610 assert_eq!(
611 ctx("2024-01-15 * \"Central Perk\" #cof"),
612 CompletionContext::Tag
613 );
614 assert_eq!(
615 ctx("2024-01-15 * \"Central Perk\" #"),
616 CompletionContext::Tag
617 );
618 }
619
620 #[test]
621 fn classify_link_on_transaction_header() {
622 assert_eq!(
623 ctx("2024-01-15 * \"Central Perk\" ^trip"),
624 CompletionContext::Link
625 );
626 }
627
628 #[test]
629 fn classify_tag_on_pushtag() {
630 assert_eq!(ctx("pushtag #tr"), CompletionContext::Tag);
631 assert_eq!(ctx("poptag #tr"), CompletionContext::Tag);
632 }
633
634 #[test]
635 fn classify_hash_inside_string_is_not_tag() {
636 let c = ctx("2024-01-15 * \"paid #5 invoice");
637 assert_ne!(c, CompletionContext::Tag);
638 assert_ne!(c, CompletionContext::Link);
639 }
640
641 #[test]
642 fn classify_hash_in_comment_is_not_tag() {
643 let c = ctx("2024-01-15 * \"Lunch\" ; see #123");
644 assert_ne!(c, CompletionContext::Tag);
645 assert_ne!(c, CompletionContext::Link);
646 }
647
648 #[test]
649 fn classify_after_completed_tag_is_not_tag() {
650 assert_eq!(
651 ctx("2024-01-15 * \"Central Perk\" #coffee "),
652 CompletionContext::AfterDate
653 );
654 }
655
656 #[test]
657 fn classify_tag_after_semicolon_inside_string() {
658 assert_eq!(ctx("2024-01-15 * \"a;b\" #tr"), CompletionContext::Tag);
659 }
660
661 #[test]
662 fn classify_escaped_quote_keeps_string_open() {
663 let c = ctx("2024-01-15 * \"a\\\"b #tag");
664 assert_ne!(c, CompletionContext::Tag);
665 assert_ne!(c, CompletionContext::Link);
666 }
667
668 #[test]
669 fn test_in_code_position() {
670 assert!(in_code_position("2024-01-15 * \"x\" #"));
671 assert!(in_code_position("pushtag #"));
672 assert!(!in_code_position("2024-01-15 * \"x\" ; "));
673 assert!(!in_code_position("2024-01-15 * \"open"));
674 assert!(in_code_position("2024-01-15 * \"a;b\" "));
675 assert!(!in_code_position("2024-01-15 * \"a\\\"b"));
676 }
677
678 #[test]
681 fn offset_to_byte_char_korean_partial_segment() {
682 let line = " Liabilities:Card:롯";
684 let byte = offset_to_byte(line, 20, PositionEncoding::Char);
685 let before = &line[..byte];
687 assert_eq!(
688 classify_context(before),
689 CompletionContext::AccountSegment {
690 prefix: "Liabilities:Card:".to_string()
691 }
692 );
693 let byte19 = offset_to_byte(line, 19, PositionEncoding::Char);
695 assert_eq!(
696 classify_context(&line[..byte19]),
697 CompletionContext::AccountSegment {
698 prefix: "Liabilities:Card:".to_string()
699 }
700 );
701 }
702
703 #[test]
704 fn offset_to_byte_char_past_end_clamps() {
705 let line = "abc";
706 assert_eq!(offset_to_byte(line, 100, PositionEncoding::Char), 3);
707 }
708
709 #[test]
710 fn offset_to_byte_utf16_surrogate_pair() {
711 let line = "a🍣b";
713 assert_eq!(offset_to_byte(line, 3, PositionEncoding::Utf16), 5);
715 assert_eq!(offset_to_byte(line, 2, PositionEncoding::Utf16), 1);
717 }
718
719 #[test]
720 fn offset_to_byte_utf8_multibyte() {
721 let line = "x소y";
723 assert_eq!(offset_to_byte(line, 4, PositionEncoding::Utf8), 4);
725 assert_eq!(offset_to_byte(line, 2, PositionEncoding::Utf8), 1);
727 }
728
729 #[test]
732 fn line_start_candidate_uses_supplied_date() {
733 let items = line_start_candidates("2026-06-12");
734 assert_eq!(items.len(), 1);
735 assert_eq!(items[0].label, "2026-06-12");
736 assert_eq!(items[0].insert_text, "2026-06-12 ");
737 assert_eq!(items[0].kind, CompletionKind::Date);
738 }
739
740 #[test]
741 fn after_date_candidates_returns_all_directives() {
742 let items = after_date_candidates();
743 let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
744 assert!(labels.contains(&"open"));
745 assert!(labels.contains(&"close"));
746 assert!(labels.contains(&"balance"));
747 assert!(labels.contains(&"*"));
748 assert!(labels.contains(&"!"));
749 let open = items.iter().find(|i| i.label == "open").unwrap();
751 assert_eq!(open.insert_text, "open ");
752 assert_eq!(open.detail.as_deref(), Some("Open an account"));
753 }
754
755 #[test]
756 fn account_start_candidates_includes_types_and_all_accounts() {
757 let accounts: Vec<String> = (1..=30)
758 .map(|n| format!("Expenses:ExpenseType{n:02}"))
759 .collect();
760 let items = account_start_candidates(&accounts);
761 let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
762 assert!(labels.contains(&"Assets:"));
763 assert!(labels.contains(&"Expenses:ExpenseType19"));
765 assert!(labels.contains(&"Expenses:ExpenseType20"));
766 assert!(labels.contains(&"Expenses:ExpenseType30"));
767 let assets = items.iter().find(|i| i.label == "Assets:").unwrap();
769 assert_eq!(assets.kind, CompletionKind::AccountType);
770 }
771
772 #[test]
773 fn account_segment_candidates_filters_and_marks_folders() {
774 let accounts = vec![
775 "Assets:Bank:Checking".to_string(),
776 "Assets:Bank:Savings".to_string(),
777 "Assets:Crypto".to_string(),
778 "Expenses:Food".to_string(),
779 ];
780 let items = account_segment_candidates("Assets:Bank:", &accounts);
781 assert_eq!(items.len(), 2);
782 let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
783 assert!(labels.contains(&"Checking"));
784 assert!(labels.contains(&"Savings"));
785 let checking = items.iter().find(|i| i.label == "Checking").unwrap();
787 assert_eq!(checking.insert_text, "Checking");
788 assert_eq!(checking.kind, CompletionKind::Account);
789
790 let top = account_segment_candidates("Assets:", &accounts);
792 let bank = top.iter().find(|i| i.label == "Bank").unwrap();
793 assert_eq!(bank.kind, CompletionKind::AccountSegmentFolder);
794 assert_eq!(bank.insert_text, "Bank:");
795 let crypto = top.iter().find(|i| i.label == "Crypto").unwrap();
796 assert_eq!(crypto.kind, CompletionKind::Account);
797 assert_eq!(crypto.insert_text, "Crypto");
798 }
799
800 #[test]
801 fn currency_candidates_basic() {
802 let items = currency_candidates(&["USD".to_string(), "EUR".to_string()]);
803 assert_eq!(items.len(), 2);
804 assert_eq!(items[0].kind, CompletionKind::Currency);
805 assert_eq!(items[0].detail.as_deref(), Some("Currency"));
806 }
807
808 #[test]
809 fn payee_candidates_returns_all() {
810 let payees: Vec<String> = (1..=30).map(|n| format!("Buy{n:02}")).collect();
811 let items = payee_candidates(&payees);
812 let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
813 assert!(labels.contains(&"Buy19"));
814 assert!(labels.contains(&"Buy20"));
815 assert!(labels.contains(&"Buy30"));
816 assert_eq!(items[0].kind, CompletionKind::Payee);
817 }
818
819 #[test]
820 fn tag_candidates_keep_sigil_in_label_only() {
821 let items = tag_candidates(&["coffee".to_string(), "morning".to_string()]);
822 let coffee = items.iter().find(|i| i.label == "#coffee").unwrap();
823 assert_eq!(coffee.insert_text, "coffee");
824 assert_eq!(coffee.kind, CompletionKind::Tag);
825 assert_eq!(coffee.detail.as_deref(), Some("Tag"));
826 }
827
828 #[test]
829 fn link_candidates_keep_sigil_in_label_only() {
830 let items = link_candidates(&["trip-2024".to_string()]);
831 let trip = items.iter().find(|i| i.label == "^trip-2024").unwrap();
832 assert_eq!(trip.insert_text, "trip-2024");
833 assert_eq!(trip.kind, CompletionKind::Link);
834 assert_eq!(trip.detail.as_deref(), Some("Link"));
835 }
836}