pub fn is_space(c: char) -> bool {
matches!(
c,
'\u{0009}'..='\u{000D}'
| '\u{0020}'
| '\u{0085}'
| '\u{00A0}'
| '\u{1680}'
| '\u{2000}'..='\u{200A}'
| '\u{2028}'
| '\u{2029}'
| '\u{202F}'
| '\u{205F}'
| '\u{3000}'
)
}
pub fn ngrams(text: &str, ngram_min: usize, ngram_max: usize) -> Vec<String> {
let lower = text.to_lowercase();
let mut out = Vec::new();
for word in lower.split(is_space).filter(|w| !w.is_empty()) {
let padded: Vec<char> = std::iter::once(' ')
.chain(word.chars())
.chain(std::iter::once(' '))
.collect();
for n in ngram_min.max(1)..=ngram_max {
if n > padded.len() {
break;
}
for window in padded.windows(n) {
out.push(window.iter().collect());
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn words_are_padded_and_windowed() {
assert_eq!(
ngrams("Ab", 1, 2),
vec![" ", "a", "b", " ", " a", "ab", "b "]
);
}
#[test]
fn windows_count_characters_not_bytes() {
assert_eq!(ngrams("é", 3, 3), vec![" é "]);
}
#[test]
fn short_words_stop_at_their_length() {
assert_eq!(ngrams("a", 1, 4), vec![" ", "a", " ", " a", "a ", " a "]);
}
#[test]
fn splits_on_unicode_white_space_only() {
assert_eq!(ngrams("a\u{3000}b", 2, 2), vec![" a", "a ", " b", "b "]);
assert_eq!(ngrams("a\u{1c}b", 4, 4), vec![" a\u{1c}b", "a\u{1c}b "]);
}
#[test]
fn empty_and_blank_text_have_no_ngrams() {
assert!(ngrams("", 1, 4).is_empty());
assert!(ngrams(" \t\n", 1, 4).is_empty());
}
#[test]
fn final_sigma_lowercases_like_python() {
assert_eq!(ngrams("ΟΔΟΣ", 1, 1).concat(), " οδο\u{3c2} ");
}
}