Skip to main content

rustledger_completion/
lib.rs

1//! Editor-agnostic completion logic for Beancount sources.
2//!
3//! This crate is the single source of truth for the completion logic
4//! shared between the LSP (`rustledger-lsp`) and the WASM editor
5//! (`rustledger-wasm`). It is deliberately pure: no clock access, no
6//! `lsp-types`, no `wasm-bindgen`. Callers supply the live data
7//! (account/currency/payee/tag/link string lists and "today's" date)
8//! and map the neutral [`CompletionCandidate`] results into their own
9//! editor-specific item types.
10//!
11//! The two responsibilities are:
12//! 1. **Context detection** — [`offset_to_byte`] maps a position
13//!    (under a [`PositionEncoding`]) to a byte offset, then
14//!    [`classify_context`] classifies the text before the cursor into a
15//!    [`CompletionContext`].
16//! 2. **Candidate generation** — the `*_candidates` functions produce
17//!    neutral [`CompletionCandidate`] lists for each context.
18
19/// Standard Beancount account types.
20pub const ACCOUNT_TYPES: &[&str] = &["Assets", "Liabilities", "Equity", "Income", "Expenses"];
21
22/// Standard Beancount directives.
23pub 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/// Completion context detected from cursor position.
41///
42/// This is the LSP superset (the WASM editor previously lacked the
43/// `Tag`/`Link` variants; it now gains them through this crate).
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum CompletionContext {
46    /// At the start of a line (expecting date or directive).
47    LineStart,
48    /// After a date (expecting directive keyword or flag).
49    AfterDate,
50    /// After directive keyword (expecting account).
51    ExpectingAccount,
52    /// Inside an account name (after colon).
53    AccountSegment {
54        /// The prefix typed so far (e.g., "Assets:").
55        prefix: String,
56    },
57    /// After an amount (expecting currency).
58    ExpectingCurrency,
59    /// Inside a string (payee/narration).
60    InsideString,
61    /// Typing a tag (after `#`) on a transaction header or in
62    /// `pushtag`/`poptag`.
63    Tag,
64    /// Typing a link (after `^`) on a transaction header.
65    Link,
66    /// Unknown context.
67    Unknown,
68}
69
70/// How a position offset is encoded by the caller.
71///
72/// The LSP negotiates UTF-8 byte offsets or UTF-16 code units; the WASM
73/// editor passes character (Unicode scalar value) offsets. All three map
74/// to a byte offset via [`offset_to_byte`].
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum PositionEncoding {
77    /// UTF-8 byte offsets.
78    Utf8,
79    /// UTF-16 code units (the LSP 3.17 default).
80    Utf16,
81    /// Unicode scalar value (character) offsets — the WASM editor's
82    /// convention (see issue #1289).
83    Char,
84}
85
86/// The kind of a completion candidate.
87///
88/// One variant per distinct item kind emitted by either adapter, so the
89/// neutral candidate can be mapped back to the exact LSP
90/// `CompletionItemKind` / WASM `CompletionKind` it replaces.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum CompletionKind {
93    /// Today's date template (line start).
94    Date,
95    /// A directive keyword (after a date).
96    Directive,
97    /// A standard account type (`Assets:`, `Expenses:`, …).
98    AccountType,
99    /// A fully-qualified known account name.
100    Account,
101    /// An intermediate account segment that has further sub-segments
102    /// (rendered as a folder).
103    AccountSegmentFolder,
104    /// A currency/commodity.
105    Currency,
106    /// A known payee name.
107    Payee,
108    /// A tag (after `#`).
109    Tag,
110    /// A link (after `^`).
111    Link,
112}
113
114/// A neutral, editor-agnostic completion candidate.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct CompletionCandidate {
117    /// The label shown in the completion popup.
118    pub label: String,
119    /// The text to insert. Distinguished from `label` (e.g. tags keep
120    /// the `#` in `label` but drop it in `insert_text`). When equal to
121    /// `label` (no special insert behavior) adapters may treat it as
122    /// "no explicit insert text".
123    pub insert_text: String,
124    /// The kind of candidate.
125    pub kind: CompletionKind,
126    /// Human-readable detail string, if any.
127    pub detail: Option<String>,
128}
129
130/// Map a position `offset` (in the given `encoding`) into a byte offset
131/// into `line`, clamped to a char boundary.
132///
133/// - [`PositionEncoding::Utf8`] / [`PositionEncoding::Utf16`] walk the
134///   line's chars once accumulating the encoded length, bailing at the
135///   start of the char a mid-char offset would land in (the LSP
136///   behavior).
137/// - [`PositionEncoding::Char`] treats `offset` as a character index and
138///   maps to the byte offset of that char, clamping past end-of-line to
139///   the line length (the #1289 WASM behavior).
140///
141/// The result is always a valid char boundary in `line`, so slicing
142/// `&line[..offset_to_byte(...)]` never panics.
143#[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                    // Position lands mid-char — bail at the start of this char.
164                    break;
165                }
166                acc += u;
167                byte_col += ch.len_utf8();
168            }
169            byte_col
170        }
171    }
172}
173
174/// Classify the completion context from the text before the cursor.
175///
176/// `before_cursor` is the slice of the current line up to (and not
177/// including) the cursor, already mapped to a byte boundary via
178/// [`offset_to_byte`].
179///
180/// This is the LSP classification body, ported verbatim, including the
181/// `in_code_position`-gated Tag/Link detection.
182#[must_use]
183pub fn classify_context(before_cursor: &str) -> CompletionContext {
184    let trimmed = before_cursor.trim_start();
185
186    // Check if we're at the start of a posting (indented line)
187    // This must come before the empty check since an indented line
188    // with just spaces should be expecting an account.
189    if before_cursor.starts_with("  ") || before_cursor.starts_with('\t') {
190        // Empty indented line means expecting an account
191        if trimmed.is_empty() {
192            return CompletionContext::ExpectingAccount;
193        }
194        // Inside a posting - could be account or amount
195        let posting_content = trimmed;
196
197        // Check if there's already an account (contains colon and space after)
198        if posting_content.contains(':') && posting_content.contains(' ') {
199            // After account, might be expecting amount or currency
200            let parts: Vec<&str> = posting_content.split_whitespace().collect();
201            if parts.len() >= 2 {
202                // Check if last part looks like a number
203                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        // Check if typing an account segment
213        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        // Starting an account name
221        return CompletionContext::ExpectingAccount;
222    }
223
224    // Tag (`#tag`) / link (`^link`) completion. Tags and links appear
225    // on transaction header lines (after the date/flag/strings) and in
226    // `pushtag`/`poptag` directives. We trigger when the token directly
227    // under the cursor begins with the sigil, but only when the cursor
228    // is in *code* position: not inside a string literal (a `#` in a
229    // narration is just text) and not after a comment marker. The
230    // cursor must also sit at the end of the token (no trailing
231    // whitespace), i.e. the user is still typing it.
232    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    // Empty or whitespace only at line start (not indented)
245    if trimmed.is_empty() {
246        return CompletionContext::LineStart;
247    }
248
249    // Check for date at line start (YYYY-MM-DD pattern). Guard the
250    // 10-byte split on a char boundary: a `YYYY-MM-DD` prefix is all
251    // ASCII, so if byte 10 lands mid-character the line can't be a
252    // date, and slicing there would panic on multi-byte input.
253    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        // Check for directive keywords
260        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                    // After directive, expecting account for most directives
265                    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        // After date but no recognized directive yet
281        return CompletionContext::AfterDate;
282    }
283
284    // Check if inside a quoted string
285    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
293/// Whether the end of `before` is in "code" position: not inside a
294/// string literal and not past a comment marker. A single forward scan
295/// tracks string state with backslash-escape handling, matching the
296/// lexer's string rule (`"([^"\\]|\\.)*"`), so a `"` or `;` that lives
297/// *inside* a narration does not flip the classification. An unescaped,
298/// unquoted `;` starts a comment, after which nothing is code.
299fn 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            // Comment marker outside any string: rest of line is comment.
315            return false;
316        }
317    }
318    !in_string
319}
320
321/// Check if a string looks like a date (YYYY-MM-DD).
322fn 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/// The detail string shown for a directive keyword.
339#[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/// Candidates at line start: a single date template using the supplied
360/// `today` string (the crate is clock-free; each adapter passes its own
361/// date).
362#[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/// Candidates after a date: the directive keywords.
373#[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/// Candidates when starting an account name: the standard account types
387/// followed by every known account.
388///
389/// `accounts` must be the full, sorted, deduplicated list of known
390/// accounts — the adapters gather it the same way (file + ledger state).
391/// We return every known account: the client filters by the typed
392/// prefix, and capping server-side defeats that filtering (issue #1183).
393#[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/// Candidates for the next account segment after a `prefix`.
418///
419/// For a `prefix` like `Assets:`, emits the unique next segments of
420/// every account that starts with `prefix`; a segment that has further
421/// sub-segments is a folder (inserts `segment:`), otherwise a leaf
422/// account (inserts `segment`).
423#[must_use]
424pub fn account_segment_candidates(prefix: &str, accounts: &[String]) -> Vec<CompletionCandidate> {
425    // Find accounts that start with this prefix
426    let matching: Vec<_> = accounts.iter().filter(|a| a.starts_with(prefix)).collect();
427
428    // Extract unique next segments
429    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            // Check if this is a complete account or has more segments
450            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/// Candidates for a currency after an amount.
475#[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/// Candidates for a payee/narration inside a string. Returns all known
489/// payees — the client filters (issue #1183, the `.take(20)` trap).
490#[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/// Candidates for a tag after `#` (issue #1268).
504///
505/// The `#` is a trigger character the user has already typed, so
506/// `insert_text` carries the tag name *without* the `#`; `label` keeps
507/// the `#` for a readable popup. `tags` come back without the leading
508/// `#` from the core extractor.
509#[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/// Candidates for a link after `^` (issue #1268). Mirrors
522/// [`tag_candidates`]; the sigil is kept in `label` and dropped in
523/// `insert_text`.
524#[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    /// Classify the context at the end of `before` (the whole string is
542    /// treated as the text before the cursor).
543    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        // A single leading space is not the 2-space posting indent.
560        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    // ---- offset_to_byte ----
679
680    #[test]
681    fn offset_to_byte_char_korean_partial_segment() {
682        // #1289: "  Liabilities:Card:롯", char offset 20 is after "롯".
683        let line = "  Liabilities:Card:롯";
684        let byte = offset_to_byte(line, 20, PositionEncoding::Char);
685        // The slice must not panic and must contain the colon prefix.
686        let before = &line[..byte];
687        assert_eq!(
688            classify_context(before),
689            CompletionContext::AccountSegment {
690                prefix: "Liabilities:Card:".to_string()
691            }
692        );
693        // Offset 19 (at the colon, before "롯") is the other boundary.
694        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        // "🍣" is 2 UTF-16 units, 4 UTF-8 bytes.
712        let line = "a🍣b";
713        // After 'a' (1 unit) + "🍣" (2 units) = 3 units -> byte 5.
714        assert_eq!(offset_to_byte(line, 3, PositionEncoding::Utf16), 5);
715        // Mid-surrogate (offset 2) bails at the start of "🍣" -> byte 1.
716        assert_eq!(offset_to_byte(line, 2, PositionEncoding::Utf16), 1);
717    }
718
719    #[test]
720    fn offset_to_byte_utf8_multibyte() {
721        // "소" is 3 UTF-8 bytes.
722        let line = "x소y";
723        // After 'x' (1 byte) + "소" (3 bytes) = byte 4.
724        assert_eq!(offset_to_byte(line, 4, PositionEncoding::Utf8), 4);
725        // Mid-char (offset 2) bails at the start of "소" -> byte 1.
726        assert_eq!(offset_to_byte(line, 2, PositionEncoding::Utf8), 1);
727    }
728
729    // ---- candidate algorithms ----
730
731    #[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        // insert_text appends a space.
750        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        // No cap (#1183): all 30 accounts present.
764        assert!(labels.contains(&"Expenses:ExpenseType19"));
765        assert!(labels.contains(&"Expenses:ExpenseType20"));
766        assert!(labels.contains(&"Expenses:ExpenseType30"));
767        // Account-type entry is a folder kind.
768        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        // Leaf segments insert without a trailing colon.
786        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        // Top-level prefix: "Bank" has more, "Crypto" does not.
791        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}