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}