use serde::{Deserialize, Serialize};
use unicode_segmentation::UnicodeSegmentation;
use crate::parser_tools::content_parser::djot_to_plain_text;
use crate::parser_tools::djot_options::DjotImportOptions;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum CountMethod {
WhitespaceSplit,
#[default]
UnicodeWords,
CjkHybrid,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct WordCharCounts {
pub words: usize,
pub chars_with_spaces: usize,
pub chars_without_spaces: usize,
}
pub fn count(text: &str, method: CountMethod) -> WordCharCounts {
let chars_with_spaces = text.chars().count();
let chars_without_spaces = text.chars().filter(|c| !c.is_whitespace()).count();
let words = match method {
CountMethod::WhitespaceSplit => text.split_whitespace().count(),
CountMethod::UnicodeWords => text.unicode_words().count(),
CountMethod::CjkHybrid => {
let cjk = text.chars().filter(|&c| is_han_or_kana(c)).count();
let non_cjk_words = text
.unicode_words()
.filter(|w| !w.chars().any(is_han_or_kana))
.count();
cjk + non_cjk_words
}
};
WordCharCounts {
words,
chars_with_spaces,
chars_without_spaces,
}
}
pub fn count_djot(djot: &str, method: CountMethod) -> WordCharCounts {
count(
&djot_to_plain_text(djot, &DjotImportOptions::default()),
method,
)
}
fn is_han_or_kana(c: char) -> bool {
matches!(c as u32,
0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x20000..=0x3FFFF | 0x3040..=0x309F | 0x30A0..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9D )
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_and_whitespace_only() {
for m in [
CountMethod::WhitespaceSplit,
CountMethod::UnicodeWords,
CountMethod::CjkHybrid,
] {
assert_eq!(count("", m).words, 0);
assert_eq!(count(" \n\t ", m).words, 0);
}
let c = count(" \n\t ", CountMethod::UnicodeWords);
assert_eq!(c.chars_with_spaces, 6);
assert_eq!(c.chars_without_spaces, 0);
}
#[test]
fn punctuation_only_is_zero_words_for_unicode_but_not_whitespace() {
assert_eq!(count("-- ... !!", CountMethod::UnicodeWords).words, 0);
assert_eq!(count("-- ... !!", CountMethod::WhitespaceSplit).words, 3);
}
#[test]
fn apostrophes_glue_and_hyphens_split_under_unicode() {
assert_eq!(count("Elena's", CountMethod::UnicodeWords).words, 1);
assert_eq!(count("Jean-Luc", CountMethod::UnicodeWords).words, 2);
assert_eq!(count("Elena's", CountMethod::WhitespaceSplit).words, 1);
assert_eq!(count("Jean-Luc", CountMethod::WhitespaceSplit).words, 1);
}
#[test]
fn char_counts_ignore_method() {
let text = "one two";
for m in [
CountMethod::WhitespaceSplit,
CountMethod::UnicodeWords,
CountMethod::CjkHybrid,
] {
let c = count(text, m);
assert_eq!(c.chars_with_spaces, 7);
assert_eq!(c.chars_without_spaces, 6);
}
}
#[test]
fn cjk_hybrid_counts_han_per_character() {
assert_eq!(count("春眠不覺", CountMethod::CjkHybrid).words, 4);
assert!(count("春眠不覺", CountMethod::UnicodeWords).words <= 4);
}
#[test]
fn cjk_hybrid_counts_katakana_run_per_character() {
assert_eq!(count("カタカナ", CountMethod::CjkHybrid).words, 4);
}
#[test]
fn cjk_hybrid_mixes_latin_words_and_cjk_chars() {
assert_eq!(count("Hello 世界", CountMethod::CjkHybrid).words, 3);
}
#[test]
fn cjk_hybrid_hiragana_per_character_hangul_by_word() {
assert_eq!(count("ひらがな", CountMethod::CjkHybrid).words, 4);
assert_eq!(count("한국어 낱말", CountMethod::CjkHybrid).words, 2);
}
#[test]
fn count_djot_matches_count_over_extracted_plain_text() {
let djot = "# Title\n\nA *bold* word and some prose.";
let plain = djot_to_plain_text(djot, &DjotImportOptions::default());
for m in [
CountMethod::WhitespaceSplit,
CountMethod::UnicodeWords,
CountMethod::CjkHybrid,
] {
assert_eq!(count_djot(djot, m), count(&plain, m));
}
}
}