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}