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    let text = djot_to_plain_text(djot, &DjotImportOptions::default());
81    // Drop the inline-object sentinels before counting.
82    //
83    // `djot_to_plain_text` is the **addressable** view: an image and a footnote
84    // reference each occupy one `U+FFFC` there, because anything that addresses
85    // the text by position — a caret, a search hit, a comment's anchor — has to
86    // agree with the document character for character.
87    //
88    // A word count is not addressing anything. It answers "how much has the
89    // writer written", and a picture is not a character they wrote: leaving the
90    // sentinel in makes a manuscript's character count tick up when someone
91    // inserts a photograph, and a pace target creep away from the prose it was
92    // set against. Same view, two questions — and this is the one that wants the
93    // objects gone.
94    let prose: String = text.chars().filter(|&c| c != OBJECT_REPLACEMENT).collect();
95    count(&prose, method)
96}
97
98/// U+FFFC OBJECT REPLACEMENT CHARACTER — one document character standing in for
99/// an inline object (an image, a footnote's marker).
100const OBJECT_REPLACEMENT: char = '\u{FFFC}';
101
102/// Han ideographs (incl. common extensions & compatibility) and Japanese kana. Excludes
103/// Hangul: Korean is space-delimited and counts by the UAX #29 word rule.
104fn is_han_or_kana(c: char) -> bool {
105    matches!(c as u32,
106        0x3400..=0x4DBF     // CJK Unified Ideographs Extension A
107        | 0x4E00..=0x9FFF   // CJK Unified Ideographs
108        | 0xF900..=0xFAFF   // CJK Compatibility Ideographs
109        | 0x20000..=0x3FFFF // Supplementary + Tertiary Ideographic Planes (all CJK Ext B–I)
110        | 0x3040..=0x309F   // Hiragana
111        | 0x30A0..=0x30FF   // Katakana
112        | 0x31F0..=0x31FF   // Katakana Phonetic Extensions
113        | 0xFF66..=0xFF9D   // Halfwidth Katakana
114    )
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn empty_and_whitespace_only() {
123        for m in [
124            CountMethod::WhitespaceSplit,
125            CountMethod::UnicodeWords,
126            CountMethod::CjkHybrid,
127        ] {
128            assert_eq!(count("", m).words, 0);
129            assert_eq!(count("   \n\t ", m).words, 0);
130        }
131        let c = count("   \n\t ", CountMethod::UnicodeWords);
132        assert_eq!(c.chars_with_spaces, 6);
133        assert_eq!(c.chars_without_spaces, 0);
134    }
135
136    #[test]
137    fn punctuation_only_is_zero_words_for_unicode_but_not_whitespace() {
138        // `unicode_words` drops punctuation-only runs; `split_whitespace` counts the token.
139        assert_eq!(count("-- ... !!", CountMethod::UnicodeWords).words, 0);
140        assert_eq!(count("-- ... !!", CountMethod::WhitespaceSplit).words, 3);
141    }
142
143    #[test]
144    fn apostrophes_glue_and_hyphens_split_under_unicode() {
145        assert_eq!(count("Elena's", CountMethod::UnicodeWords).words, 1);
146        // "Jean-Luc" is two UAX #29 words (the hyphen is a boundary).
147        assert_eq!(count("Jean-Luc", CountMethod::UnicodeWords).words, 2);
148        // Whitespace split sees each as one token.
149        assert_eq!(count("Elena's", CountMethod::WhitespaceSplit).words, 1);
150        assert_eq!(count("Jean-Luc", CountMethod::WhitespaceSplit).words, 1);
151    }
152
153    #[test]
154    fn char_counts_ignore_method() {
155        let text = "one two";
156        for m in [
157            CountMethod::WhitespaceSplit,
158            CountMethod::UnicodeWords,
159            CountMethod::CjkHybrid,
160        ] {
161            let c = count(text, m);
162            assert_eq!(c.chars_with_spaces, 7);
163            assert_eq!(c.chars_without_spaces, 6);
164        }
165    }
166
167    #[test]
168    fn cjk_hybrid_counts_han_per_character() {
169        // Four Han ideographs → four words under CjkHybrid.
170        assert_eq!(count("春眠不覺", CountMethod::CjkHybrid).words, 4);
171        // Under plain Unicode words the run may be one (or few) segments — always ≤ 4.
172        assert!(count("春眠不覺", CountMethod::UnicodeWords).words <= 4);
173    }
174
175    #[test]
176    fn cjk_hybrid_counts_katakana_run_per_character() {
177        // A glued Katakana run (UAX #29 WB13) is still counted per character.
178        assert_eq!(count("カタカナ", CountMethod::CjkHybrid).words, 4);
179    }
180
181    #[test]
182    fn cjk_hybrid_mixes_latin_words_and_cjk_chars() {
183        // "Hello" (1 word) + 世 + 界 (2 chars) = 3.
184        assert_eq!(count("Hello 世界", CountMethod::CjkHybrid).words, 3);
185    }
186
187    #[test]
188    fn cjk_hybrid_hiragana_per_character_hangul_by_word() {
189        assert_eq!(count("ひらがな", CountMethod::CjkHybrid).words, 4);
190        // Hangul is space-delimited: two space-separated Korean words stay two words.
191        assert_eq!(count("한국어 낱말", CountMethod::CjkHybrid).words, 2);
192    }
193
194    #[test]
195    fn count_djot_matches_count_over_extracted_plain_text() {
196        let djot = "# Title\n\nA *bold* word and some prose.";
197        let plain = djot_to_plain_text(djot, &DjotImportOptions::default());
198        for m in [
199            CountMethod::WhitespaceSplit,
200            CountMethod::UnicodeWords,
201            CountMethod::CjkHybrid,
202        ] {
203            assert_eq!(count_djot(djot, m), count(&plain, m));
204        }
205    }
206}