Skip to main content

common/parser_tools/
word_count.rs

1//! Pure word/character counting over `&str` — no document, no store, no threads.
2//!
3//! Mirrors [`djot_to_plain_text`]
4//! and the search matcher's shape: a cheap primitive over `&str` with the *policy*
5//! (which counting method) passed in as a parameter, so a host app can count a manuscript
6//! of thousands of scenes without importing each one into a document.
7//!
8//! Language is **not** a parameter: UAX #29 word segmentation is not locale-tailored the
9//! way case-folding is, and the CJK rule ([`CountMethod::CjkHybrid`]) is script-detected
10//! per character. A caller that wants "count this Chinese scene per character" selects
11//! `CjkHybrid`; it does not pass a language tag.
12
13use serde::{Deserialize, Serialize};
14use unicode_segmentation::UnicodeSegmentation;
15
16use crate::parser_tools::content_parser::djot_to_plain_text;
17use crate::parser_tools::djot_options::DjotImportOptions;
18
19/// How words are delimited. Characters are always counted the same way (Unicode scalar
20/// values), independent of this choice — see [`WordCharCounts`].
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
22pub enum CountMethod {
23    /// `str::split_whitespace` — fast, parity mode (e.g. matching another tool's count).
24    /// Miscounts scripts that are not space-delimited (CJK) and is crude around
25    /// punctuation, but it is exactly what many word processors report.
26    WhitespaceSplit,
27    /// UAX #29 word segmentation via `unicode_words` — the sound general-purpose default:
28    /// apostrophes glue (`"Elena's"` is one word), hyphens split, punctuation-only runs
29    /// are excluded. Still undercounts CJK (a run of ideographs may segment as one word).
30    #[default]
31    UnicodeWords,
32    /// UAX #29 for alphabetic scripts, but every Han / Hiragana / Katakana character counts
33    /// as one word — the East-Asian convention, where a "word count" of non-space-delimited
34    /// prose approximates a character count. Applied per character (not per detected script
35    /// run) so it is correct whether the segmenter split a run per-character (Han) or glued
36    /// it (Katakana, UAX #29 rule WB13). Korean (Hangul) is space-delimited and stays on the
37    /// [`UnicodeWords`](CountMethod::UnicodeWords) rule.
38    CjkHybrid,
39}
40
41/// The three counts a caller might display. All three are always computed — "characters
42/// with spaces" vs "without" is a display choice, not a separate counting mode.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub struct WordCharCounts {
45    pub words: usize,
46    pub chars_with_spaces: usize,
47    pub chars_without_spaces: usize,
48}
49
50/// Count words and characters in already-extracted plain text.
51pub fn count(text: &str, method: CountMethod) -> WordCharCounts {
52    let chars_with_spaces = text.chars().count();
53    let chars_without_spaces = text.chars().filter(|c| !c.is_whitespace()).count();
54    let words = match method {
55        CountMethod::WhitespaceSplit => text.split_whitespace().count(),
56        CountMethod::UnicodeWords => text.unicode_words().count(),
57        CountMethod::CjkHybrid => {
58            // Each Han/Kana character is one word; every UAX #29 word that contains no
59            // Han/Kana is one word. A mixed "Hello 世界" → "Hello" (1) + 世 + 界 = 3.
60            let cjk = text.chars().filter(|&c| is_han_or_kana(c)).count();
61            let non_cjk_words = text
62                .unicode_words()
63                .filter(|w| !w.chars().any(is_han_or_kana))
64                .count();
65            cjk + non_cjk_words
66        }
67    };
68    WordCharCounts {
69        words,
70        chars_with_spaces,
71        chars_without_spaces,
72    }
73}
74
75/// Extract prose from Djot source, then count. The two-step (strip then count) is kept
76/// deliberately simple: `djot_to_plain_text` carries a pinned contract (table-anchor
77/// sentinel, single-`\n` block joins) and fusing the parse-and-count into one AST walk is
78/// the easiest way to accidentally create a third, silently-diverging "what is the text".
79pub fn count_djot(djot: &str, method: CountMethod) -> WordCharCounts {
80    count(
81        &djot_to_plain_text(djot, &DjotImportOptions::default()),
82        method,
83    )
84}
85
86/// Han ideographs (incl. common extensions & compatibility) and Japanese kana. Excludes
87/// Hangul: Korean is space-delimited and counts by the UAX #29 word rule.
88fn is_han_or_kana(c: char) -> bool {
89    matches!(c as u32,
90        0x3400..=0x4DBF     // CJK Unified Ideographs Extension A
91        | 0x4E00..=0x9FFF   // CJK Unified Ideographs
92        | 0xF900..=0xFAFF   // CJK Compatibility Ideographs
93        | 0x20000..=0x3FFFF // Supplementary + Tertiary Ideographic Planes (all CJK Ext B–I)
94        | 0x3040..=0x309F   // Hiragana
95        | 0x30A0..=0x30FF   // Katakana
96        | 0x31F0..=0x31FF   // Katakana Phonetic Extensions
97        | 0xFF66..=0xFF9D   // Halfwidth Katakana
98    )
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn empty_and_whitespace_only() {
107        for m in [
108            CountMethod::WhitespaceSplit,
109            CountMethod::UnicodeWords,
110            CountMethod::CjkHybrid,
111        ] {
112            assert_eq!(count("", m).words, 0);
113            assert_eq!(count("   \n\t ", m).words, 0);
114        }
115        let c = count("   \n\t ", CountMethod::UnicodeWords);
116        assert_eq!(c.chars_with_spaces, 6);
117        assert_eq!(c.chars_without_spaces, 0);
118    }
119
120    #[test]
121    fn punctuation_only_is_zero_words_for_unicode_but_not_whitespace() {
122        // `unicode_words` drops punctuation-only runs; `split_whitespace` counts the token.
123        assert_eq!(count("-- ... !!", CountMethod::UnicodeWords).words, 0);
124        assert_eq!(count("-- ... !!", CountMethod::WhitespaceSplit).words, 3);
125    }
126
127    #[test]
128    fn apostrophes_glue_and_hyphens_split_under_unicode() {
129        assert_eq!(count("Elena's", CountMethod::UnicodeWords).words, 1);
130        // "Jean-Luc" is two UAX #29 words (the hyphen is a boundary).
131        assert_eq!(count("Jean-Luc", CountMethod::UnicodeWords).words, 2);
132        // Whitespace split sees each as one token.
133        assert_eq!(count("Elena's", CountMethod::WhitespaceSplit).words, 1);
134        assert_eq!(count("Jean-Luc", CountMethod::WhitespaceSplit).words, 1);
135    }
136
137    #[test]
138    fn char_counts_ignore_method() {
139        let text = "one two";
140        for m in [
141            CountMethod::WhitespaceSplit,
142            CountMethod::UnicodeWords,
143            CountMethod::CjkHybrid,
144        ] {
145            let c = count(text, m);
146            assert_eq!(c.chars_with_spaces, 7);
147            assert_eq!(c.chars_without_spaces, 6);
148        }
149    }
150
151    #[test]
152    fn cjk_hybrid_counts_han_per_character() {
153        // Four Han ideographs → four words under CjkHybrid.
154        assert_eq!(count("春眠不覺", CountMethod::CjkHybrid).words, 4);
155        // Under plain Unicode words the run may be one (or few) segments — always ≤ 4.
156        assert!(count("春眠不覺", CountMethod::UnicodeWords).words <= 4);
157    }
158
159    #[test]
160    fn cjk_hybrid_counts_katakana_run_per_character() {
161        // A glued Katakana run (UAX #29 WB13) is still counted per character.
162        assert_eq!(count("カタカナ", CountMethod::CjkHybrid).words, 4);
163    }
164
165    #[test]
166    fn cjk_hybrid_mixes_latin_words_and_cjk_chars() {
167        // "Hello" (1 word) + 世 + 界 (2 chars) = 3.
168        assert_eq!(count("Hello 世界", CountMethod::CjkHybrid).words, 3);
169    }
170
171    #[test]
172    fn cjk_hybrid_hiragana_per_character_hangul_by_word() {
173        assert_eq!(count("ひらがな", CountMethod::CjkHybrid).words, 4);
174        // Hangul is space-delimited: two space-separated Korean words stay two words.
175        assert_eq!(count("한국어 낱말", CountMethod::CjkHybrid).words, 2);
176    }
177
178    #[test]
179    fn count_djot_matches_count_over_extracted_plain_text() {
180        let djot = "# Title\n\nA *bold* word and some prose.";
181        let plain = djot_to_plain_text(djot, &DjotImportOptions::default());
182        for m in [
183            CountMethod::WhitespaceSplit,
184            CountMethod::UnicodeWords,
185            CountMethod::CjkHybrid,
186        ] {
187            assert_eq!(count_djot(djot, m), count(&plain, m));
188        }
189    }
190}