Skip to main content

khmer_tokenizer_core/
kcc.rs

1//! Khmer Character Cluster (KCC) splitting.
2//!
3//! Written Khmer has no spaces between words and uses an abugida script: a base
4//! consonant can carry stacked subscript consonants (each introduced by COENG,
5//! `U+17D2`) plus dependent vowels and signs. The smallest meaningful unit for
6//! word segmentation is therefore the *orthographic cluster*, not the Unicode
7//! scalar value.
8//!
9//! Splitting into clusters first is what keeps the segmenter from ever cutting a
10//! base character away from its subscripts or vowels — the core correctness bug
11//! in naive char-by-char tokenizers.
12
13/// `U+17D2` KHMER SIGN COENG — joins a following consonant as a subscript.
14pub(crate) const COENG: char = '\u{17D2}';
15
16/// `U+17CC` KHMER SIGN ROBAT — the one combining mark that legitimately
17/// precedes a `COENG`+consonant subscript pair (see `crate::normalize`).
18pub(crate) const ROBAT: char = '\u{17CC}';
19
20/// A Khmer *base*: consonants (`U+1780..=U+17A2`) and independent vowels
21/// (`U+17A3..=U+17B3`). A cluster always begins with one of these.
22pub(crate) fn is_khmer_base(c: char) -> bool {
23    matches!(c as u32, 0x1780..=0x17B3)
24}
25
26/// Dependent vowels, signs and diacritics that attach to a base, plus the
27/// zero-width joiners that may appear inside a cluster.
28pub(crate) fn is_khmer_combining(c: char) -> bool {
29    let o = c as u32;
30    matches!(o, 0x17B4..=0x17D1)   // dependent vowels & most signs
31        || o == 0x17D3             // bathamasat
32        || o == 0x17DD             // atthacan
33        || matches!(o, 0x200C..=0x200D) // ZWNJ / ZWJ
34}
35
36/// True if `c` falls anywhere in the Khmer Unicode block (`U+1780..=U+17FF`).
37pub fn is_khmer(c: char) -> bool {
38    matches!(c as u32, 0x1780..=0x17FF)
39}
40
41/// Split `text` into Khmer Character Clusters.
42///
43/// Each Khmer cluster is a base character followed by any number of
44/// COENG+consonant subscripts and combining marks. Non-Khmer scalars (spaces,
45/// Latin letters, digits, punctuation) are returned as individual single-char
46/// clusters, so downstream code can group or separate them as needed.
47///
48/// # Example
49/// ```
50/// use khmer_tokenizer_core::split_kcc;
51/// assert_eq!(split_kcc("ខ្មែរ"), vec!["ខ្មែ", "រ"]);
52/// assert_eq!(split_kcc("ស្ត្រី"), vec!["ស្ត្រី"]); // stacked subscripts stay whole
53/// ```
54pub fn split_kcc(text: &str) -> Vec<String> {
55    let chars: Vec<char> = text.chars().collect();
56    let n = chars.len();
57    let mut clusters: Vec<String> = Vec::new();
58    let mut i = 0;
59
60    while i < n {
61        let c = chars[i];
62        if is_khmer_base(c) {
63            let start = i;
64            i += 1;
65            while i < n {
66                let d = chars[i];
67                if d == COENG && i + 1 < n {
68                    // COENG plus the consonant it subscripts.
69                    i += 2;
70                } else if is_khmer_combining(d) {
71                    i += 1;
72                } else {
73                    break;
74                }
75            }
76            clusters.push(chars[start..i].iter().collect());
77        } else {
78            // Non-base scalar (space, Latin, digit, punctuation, stray mark).
79            clusters.push(c.to_string());
80            i += 1;
81        }
82    }
83
84    clusters
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn keeps_base_with_subscripts_and_vowels() {
93        assert_eq!(split_kcc("ខ្មែរ"), vec!["ខ្មែ", "រ"]);
94        assert_eq!(split_kcc("ស្ត្រី"), vec!["ស្ត្រី"]);
95    }
96
97    #[test]
98    fn separates_non_khmer_scalars() {
99        assert_eq!(split_kcc("ab 1"), vec!["a", "b", " ", "1"]);
100    }
101
102    #[test]
103    fn classifies_khmer_block() {
104        assert!(is_khmer('ខ'));
105        assert!(!is_khmer('a'));
106        assert!(!is_khmer('1'));
107    }
108}