Skip to main content

idet_core/
completion.rs

1//! Word collection and prefix matching for autocomplete.
2
3use std::collections::HashMap;
4
5const MIN_WORD_LENGTH: usize = 3;
6
7/// Returns whether `character` counts as part of a word (alphanumeric or `_`).
8#[must_use]
9pub fn is_word_char(character: char) -> bool {
10    character.is_alphanumeric() || character == '_'
11}
12
13/// Collects the unique words of `text` (length ≥ 3) with their occurrence
14/// count, sorted by count descending and then alphabetically.
15#[must_use]
16pub fn collect_words(text: &str) -> Vec<(String, usize)> {
17    let mut counts: HashMap<&str, usize> = HashMap::new();
18    for word in text
19        .split(|c: char| !is_word_char(c))
20        .filter(|word| word.chars().count() >= MIN_WORD_LENGTH)
21    {
22        *counts.entry(word).or_insert(0) += 1;
23    }
24    let mut words: Vec<(String, usize)> = counts
25        .into_iter()
26        .map(|(word, count)| (word.to_owned(), count))
27        .collect();
28    words.sort_unstable_by(|(left_word, left_count), (right_word, right_count)| {
29        right_count
30            .cmp(left_count)
31            .then_with(|| left_word.cmp(right_word))
32    });
33    words
34}
35
36/// Returns the run of word characters ending immediately before `cursor`,
37/// where `cursor` is a character index into `text`.
38#[must_use]
39pub fn current_prefix(text: &str, cursor: usize) -> String {
40    let before: String = text.chars().take(cursor).collect();
41    let reversed: String = before
42        .chars()
43        .rev()
44        .take_while(|c| is_word_char(*c))
45        .collect();
46    reversed.chars().rev().collect()
47}
48
49/// Returns the character-index range of the word containing `cursor`,
50/// scanning both backward and forward from it with [`is_word_char`].
51#[must_use]
52pub fn current_word(text: &str, cursor: usize) -> (usize, usize) {
53    let chars: Vec<char> = text.chars().collect();
54    let cursor = cursor.min(chars.len());
55    let mut start = cursor;
56    while start > 0 && is_word_char(chars[start - 1]) {
57        start -= 1;
58    }
59    let mut end = cursor;
60    while end < chars.len() && is_word_char(chars[end]) {
61        end += 1;
62    }
63    (start, end)
64}
65
66/// Returns the words starting with `prefix` and their occurrence count.
67///
68/// The order of `words` is preserved and `prefix` itself is kept as a
69/// candidate, but a result holding nothing else comes back empty: there is
70/// then nothing left to complete.
71#[must_use]
72pub fn matches<'a>(words: &'a [(String, usize)], prefix: &str) -> Vec<(&'a String, usize)> {
73    let found: Vec<(&'a String, usize)> = words
74        .iter()
75        .filter(|(word, _)| word.starts_with(prefix))
76        .map(|(word, count)| (word, *count))
77        .collect();
78    if found.iter().all(|(word, _)| word.as_str() == prefix) {
79        return Vec::new();
80    }
81    found
82}
83
84/// Converts the character index `char_index` into a byte offset into `text`,
85/// returning the text length when the index is past the end.
86#[must_use]
87pub fn char_to_byte(text: &str, char_index: usize) -> usize {
88    text.char_indices()
89        .nth(char_index)
90        .map_or(text.len(), |(byte, _)| byte)
91}