Skip to main content

skey_engine/
spelling.rs

1//! Vietnamese CVC (Consonant-Vowel-Consonant) spelling validation.
2
3// Static token: (chars, length)
4type Token = ([char; 4], u8);
5
6static FC_0: &[Token] = &[
7    (['b', '\0', '\0', '\0'], 1),
8    (['d', '\0', '\0', '\0'], 1),
9    (['đ', '\0', '\0', '\0'], 1),
10    (['g', '\0', '\0', '\0'], 1),
11    (['g', 'h', '\0', '\0'], 2),
12    (['m', '\0', '\0', '\0'], 1),
13    (['n', '\0', '\0', '\0'], 1),
14    (['n', 'h', '\0', '\0'], 2),
15    (['p', '\0', '\0', '\0'], 1),
16    (['p', 'h', '\0', '\0'], 2),
17    (['r', '\0', '\0', '\0'], 1),
18    (['s', '\0', '\0', '\0'], 1),
19    (['t', '\0', '\0', '\0'], 1),
20    (['t', 'r', '\0', '\0'], 2),
21    (['v', '\0', '\0', '\0'], 1),
22    (['z', '\0', '\0', '\0'], 1),
23];
24static FC_1: &[Token] = &[
25    (['c', '\0', '\0', '\0'], 1),
26    (['h', '\0', '\0', '\0'], 1),
27    (['k', '\0', '\0', '\0'], 1),
28    (['k', 'h', '\0', '\0'], 2),
29    (['q', 'u', '\0', '\0'], 2),
30    (['t', 'h', '\0', '\0'], 2),
31];
32static FC_2: &[Token] = &[
33    (['c', 'h', '\0', '\0'], 2),
34    (['g', 'i', '\0', '\0'], 2),
35    (['l', '\0', '\0', '\0'], 1),
36    (['n', 'g', '\0', '\0'], 2),
37    (['n', 'g', 'h', '\0'], 3),
38    (['x', '\0', '\0', '\0'], 1),
39];
40static FC_3: &[Token] = &[(['đ', '\0', '\0', '\0'], 1), (['l', '\0', '\0', '\0'], 1)];
41static FC_4: &[Token] = &[(['h', '\0', '\0', '\0'], 1)];
42static FC_ROWS: &[&[Token]] = &[FC_0, FC_1, FC_2, FC_3, FC_4];
43
44static VO_0: &[Token] = &[
45    (['ê', '\0', '\0', '\0'], 1),
46    (['i', '\0', '\0', '\0'], 1),
47    (['u', 'a', '\0', '\0'], 2),
48    (['u', 'ê', '\0', '\0'], 2),
49    (['u', 'y', '\0', '\0'], 2),
50    (['y', '\0', '\0', '\0'], 1),
51];
52static VO_1: &[Token] = &[
53    (['a', '\0', '\0', '\0'], 1),
54    (['i', 'ê', '\0', '\0'], 2),
55    (['o', 'a', '\0', '\0'], 2),
56    (['u', 'e', '\0', '\0'], 2),
57    (['u', 'y', 'ê', '\0'], 3),
58    (['y', 'ê', '\0', '\0'], 2),
59];
60static VO_2: &[Token] = &[
61    (['â', '\0', '\0', '\0'], 1),
62    (['ă', '\0', '\0', '\0'], 1),
63    (['e', '\0', '\0', '\0'], 1),
64    (['o', '\0', '\0', '\0'], 1),
65    (['o', 'o', '\0', '\0'], 2),
66    (['ô', '\0', '\0', '\0'], 1),
67    (['ơ', '\0', '\0', '\0'], 1),
68    (['o', 'e', '\0', '\0'], 2),
69    (['u', '\0', '\0', '\0'], 1),
70    (['ư', '\0', '\0', '\0'], 1),
71    (['u', 'â', '\0', '\0'], 2),
72    (['u', 'ô', '\0', '\0'], 2),
73    (['ư', 'ơ', '\0', '\0'], 2),
74];
75static VO_3: &[Token] = &[(['o', 'ă', '\0', '\0'], 2)];
76static VO_4: &[Token] = &[(['u', 'ơ', '\0', '\0'], 2)];
77static VO_5: &[Token] = &[
78    (['a', 'i', '\0', '\0'], 2),
79    (['a', 'o', '\0', '\0'], 2),
80    (['a', 'u', '\0', '\0'], 2),
81    (['â', 'u', '\0', '\0'], 2),
82    (['a', 'y', '\0', '\0'], 2),
83    (['â', 'y', '\0', '\0'], 2),
84    (['e', 'o', '\0', '\0'], 2),
85    (['ê', 'u', '\0', '\0'], 2),
86    (['i', 'a', '\0', '\0'], 2),
87    (['i', 'ê', 'u', '\0'], 3),
88    (['i', 'u', '\0', '\0'], 2),
89    (['o', 'a', 'i', '\0'], 3),
90    (['o', 'a', 'o', '\0'], 3),
91    (['o', 'a', 'y', '\0'], 3),
92    (['o', 'e', 'o', '\0'], 3),
93    (['o', 'i', '\0', '\0'], 2),
94    (['ô', 'i', '\0', '\0'], 2),
95    (['ơ', 'i', '\0', '\0'], 2),
96    (['ư', 'a', '\0', '\0'], 2),
97    (['u', 'â', 'y', '\0'], 3),
98    (['u', 'i', '\0', '\0'], 2),
99    (['ư', 'i', '\0', '\0'], 2),
100    (['u', 'ô', 'i', '\0'], 3),
101    (['ư', 'ơ', 'i', '\0'], 3),
102    (['ư', 'ơ', 'u', '\0'], 3),
103    (['ư', 'u', '\0', '\0'], 2),
104    (['u', 'y', 'a', '\0'], 3),
105    (['u', 'y', 'u', '\0'], 3),
106    (['y', 'ê', 'u', '\0'], 3),
107];
108static VO_6: &[Token] = &[(['ă', '\0', '\0', '\0'], 1)];
109static VO_7: &[Token] = &[(['i', '\0', '\0', '\0'], 1)];
110static VO_ROWS: &[&[Token]] = &[VO_0, VO_1, VO_2, VO_3, VO_4, VO_5, VO_6, VO_7];
111
112static LC_0: &[Token] = &[(['c', 'h', '\0', '\0'], 2), (['n', 'h', '\0', '\0'], 2)];
113static LC_1: &[Token] = &[(['c', '\0', '\0', '\0'], 1), (['n', 'g', '\0', '\0'], 2)];
114static LC_2: &[Token] = &[
115    (['m', '\0', '\0', '\0'], 1),
116    (['n', '\0', '\0', '\0'], 1),
117    (['p', '\0', '\0', '\0'], 1),
118    (['t', '\0', '\0', '\0'], 1),
119];
120static LC_3: &[Token] = &[(['k', '\0', '\0', '\0'], 1)];
121static LC_4: &[Token] = &[(['c', '\0', '\0', '\0'], 1)];
122static LC_ROWS: &[&[Token]] = &[LC_0, LC_1, LC_2, LC_3, LC_4];
123
124const CV_ALLOWED_MASKS: [u16; 5] = [
125    (1 << 0) | (1 << 1) | (1 << 2) | (1 << 3) | (1 << 5),
126    (1 << 0) | (1 << 1) | (1 << 2) | (1 << 3) | (1 << 4) | (1 << 5),
127    (1 << 0) | (1 << 1) | (1 << 2) | (1 << 3) | (1 << 5),
128    1 << 6,
129    1 << 7,
130];
131
132const VC_ALLOWED_MASKS: [u16; 8] = [
133    (1 << 0) | (1 << 1) | (1 << 2), // ê,i,ua,uê,uy,y + {ch,nh, c,ng, m,n,p,t}
134    (1 << 0) | (1 << 1) | (1 << 2), // a,iê,oa,ue,uyê,yê + {ch,nh, c,ng, m,n,p,t}
135    (1 << 0) | (1 << 1) | (1 << 2), // â,ă,e,o,oo,ô,ơ,oe,u,ư,uâ,uô,ươ + {ch,nh, c,ng, m,n,p,t}
136    (1 << 1) | (1 << 2),            // oă + {c,ng, m,n,p,t}
137    0,                              // uơ — no suffix
138    0,                              // diphthongs/triphthongs — no suffix
139    1 << 3,                         // ă + {k}
140    1 << 4,                         // i + {c}
141];
142
143// ── Utility ────────────────────────────────────────────────────────
144
145pub fn lower(c: char) -> char {
146    c.to_lowercase().next().unwrap_or(c)
147}
148
149/// Returns the toneless base vowel for a character.
150/// e.g. 'ấ' → 'â', 'ạ' → 'a', 'ê' → 'ê'
151pub fn toneless_base(c: char) -> char {
152    // Map of all Vietnamese vowel characters → their toneless base
153    match lower(c) {
154        'a' | 'à' | 'á' | 'ả' | 'ã' | 'ạ' => 'a',
155        'ă' | 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ' => 'ă',
156        'â' | 'ầ' | 'ấ' | 'ẩ' | 'ẫ' | 'ậ' => 'â',
157        'e' | 'è' | 'é' | 'ẻ' | 'ẽ' | 'ẹ' => 'e',
158        'ê' | 'ề' | 'ế' | 'ể' | 'ễ' | 'ệ' => 'ê',
159        'i' | 'ì' | 'í' | 'ỉ' | 'ĩ' | 'ị' => 'i',
160        'o' | 'ò' | 'ó' | 'ỏ' | 'õ' | 'ọ' => 'o',
161        'ô' | 'ồ' | 'ố' | 'ổ' | 'ỗ' | 'ộ' => 'ô',
162        'ơ' | 'ờ' | 'ớ' | 'ở' | 'ỡ' | 'ợ' => 'ơ',
163        'u' | 'ù' | 'ú' | 'ủ' | 'ũ' | 'ụ' => 'u',
164        'ư' | 'ừ' | 'ứ' | 'ử' | 'ữ' | 'ự' => 'ư',
165        'y' | 'ỳ' | 'ý' | 'ỷ' | 'ỹ' | 'ỵ' => 'y',
166        other => other,
167    }
168}
169
170/// Returns the tone number (0=none, 1=grave, 2=acute, 3=hook, 4=tilde, 5=dot)
171pub fn tone_of(c: char) -> u8 {
172    match lower(c) {
173        'à' | 'ằ' | 'ầ' | 'è' | 'ề' | 'ì' | 'ò' | 'ồ' | 'ờ' | 'ù' | 'ừ' | 'ỳ' => {
174            1
175        }
176        'á' | 'ắ' | 'ấ' | 'é' | 'ế' | 'í' | 'ó' | 'ố' | 'ớ' | 'ú' | 'ứ' | 'ý' => {
177            2
178        }
179        'ả' | 'ẳ' | 'ẩ' | 'ẻ' | 'ể' | 'ỉ' | 'ỏ' | 'ổ' | 'ở' | 'ủ' | 'ử' | 'ỷ' => {
180            3
181        }
182        'ã' | 'ẵ' | 'ẫ' | 'ẽ' | 'ễ' | 'ĩ' | 'õ' | 'ỗ' | 'ỡ' | 'ũ' | 'ữ' | 'ỹ' => {
183            4
184        }
185        'ạ' | 'ặ' | 'ậ' | 'ẹ' | 'ệ' | 'ị' | 'ọ' | 'ộ' | 'ợ' | 'ụ' | 'ự' | 'ỵ' => {
186            5
187        }
188        _ => 0,
189    }
190}
191
192/// Applies tone `t` to the toneless base character `c`.
193pub fn apply_tone(c: char, t: u8) -> char {
194    let base = toneless_base(c);
195    let table: &[(char, [char; 6])] = &[
196        ('a', ['a', 'à', 'á', 'ả', 'ã', 'ạ']),
197        ('ă', ['ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ']),
198        ('â', ['â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ']),
199        ('e', ['e', 'è', 'é', 'ẻ', 'ẽ', 'ẹ']),
200        ('ê', ['ê', 'ề', 'ế', 'ể', 'ễ', 'ệ']),
201        ('i', ['i', 'ì', 'í', 'ỉ', 'ĩ', 'ị']),
202        ('o', ['o', 'ò', 'ó', 'ỏ', 'õ', 'ọ']),
203        ('ô', ['ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ']),
204        ('ơ', ['ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ']),
205        ('u', ['u', 'ù', 'ú', 'ủ', 'ũ', 'ụ']),
206        ('ư', ['ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự']),
207        ('y', ['y', 'ỳ', 'ý', 'ỷ', 'ỹ', 'ỵ']),
208    ];
209    for &(b, ref tones) in table {
210        if base == b {
211            let ti = (t as usize).min(5);
212            let ch = tones[ti];
213            return if c.is_uppercase() {
214                ch.to_uppercase().next().unwrap_or(ch)
215            } else {
216                ch
217            };
218        }
219    }
220    c
221}
222
223/// Applies mark `m` to character `c`.  Marks: 1=hat, 2=breve, 3=horn, 4=dash.
224pub fn apply_mark(c: char, m: u8) -> char {
225    let base = toneless_base(lower(c));
226    let result = match (base, m) {
227        ('a', 1) => 'â',
228        ('a', 2) => 'ă',
229        ('e', 1) => 'ê',
230        ('o', 1) => 'ô',
231        ('o', 3) => 'ơ',
232        ('u', 3) => 'ư',
233        ('u', 1) => 'ư', // uw→ư handled by pair transform
234        ('d', 4) => 'đ',
235        _ => return c,
236    };
237    let tone = tone_of(c);
238    let result = if tone > 0 {
239        apply_tone(result, tone)
240    } else {
241        result
242    };
243    if c.is_uppercase() {
244        result.to_uppercase().next().unwrap_or(result)
245    } else {
246        result
247    }
248}
249
250pub fn is_vowel(c: char) -> bool {
251    matches!(
252        lower(c),
253        'a' | 'à'
254            | 'á'
255            | 'ả'
256            | 'ã'
257            | 'ạ'
258            | 'ă'
259            | 'ằ'
260            | 'ắ'
261            | 'ẳ'
262            | 'ẵ'
263            | 'ặ'
264            | 'â'
265            | 'ầ'
266            | 'ấ'
267            | 'ẩ'
268            | 'ẫ'
269            | 'ậ'
270            | 'e'
271            | 'è'
272            | 'é'
273            | 'ẻ'
274            | 'ẽ'
275            | 'ẹ'
276            | 'ê'
277            | 'ề'
278            | 'ế'
279            | 'ể'
280            | 'ễ'
281            | 'ệ'
282            | 'i'
283            | 'ì'
284            | 'í'
285            | 'ỉ'
286            | 'ĩ'
287            | 'ị'
288            | 'o'
289            | 'ò'
290            | 'ó'
291            | 'ỏ'
292            | 'õ'
293            | 'ọ'
294            | 'ô'
295            | 'ồ'
296            | 'ố'
297            | 'ổ'
298            | 'ỗ'
299            | 'ộ'
300            | 'ơ'
301            | 'ờ'
302            | 'ớ'
303            | 'ở'
304            | 'ỡ'
305            | 'ợ'
306            | 'u'
307            | 'ù'
308            | 'ú'
309            | 'ủ'
310            | 'ũ'
311            | 'ụ'
312            | 'ư'
313            | 'ừ'
314            | 'ứ'
315            | 'ử'
316            | 'ữ'
317            | 'ự'
318            | 'y'
319            | 'ỳ'
320            | 'ý'
321            | 'ỷ'
322            | 'ỹ'
323            | 'ỵ'
324    )
325}
326
327// ── Bitmask lookup ─────────────────────────────────────────────────
328
329fn lookup_mask(rows: &[&[Token]], input: &[char], full: bool, complete: bool) -> u16 {
330    let input_len = input.len() as u8;
331    let mut ret = 0u16;
332    for (index, tokens) in rows.iter().enumerate() {
333        for (t_chars, t_len) in *tokens {
334            if *t_len < input_len {
335                continue;
336            }
337            if full && *t_len > input_len {
338                continue;
339            }
340            let mut is_match = true;
341            for i in 0..input.len() {
342                let ic = input[i];
343                let tc = t_chars[i];
344                if ic != tc && (complete || toneless_base(tc) != toneless_base(ic)) {
345                    is_match = false;
346                    break;
347                }
348            }
349            if is_match {
350                ret |= 1u16 << index;
351                break;
352            }
353        }
354    }
355    ret
356}
357
358fn is_valid_cv(fc_mask: u16, vo_mask: u16) -> bool {
359    let mut mask = fc_mask;
360    while mask != 0 {
361        let idx = mask.trailing_zeros() as usize;
362        if idx < CV_ALLOWED_MASKS.len() && (CV_ALLOWED_MASKS[idx] & vo_mask) != 0 {
363            return true;
364        }
365        mask &= mask - 1;
366    }
367    false
368}
369
370fn is_valid_vc(vo_mask: u16, lc_mask: u16) -> bool {
371    let mut mask = vo_mask;
372    while mask != 0 {
373        let idx = mask.trailing_zeros() as usize;
374        if idx < VC_ALLOWED_MASKS.len() && (VC_ALLOWED_MASKS[idx] & lc_mask) != 0 {
375            return true;
376        }
377        mask &= mask - 1;
378    }
379    false
380}
381
382// ── CVC split ──────────────────────────────────────────────────────
383
384/// Splits a Vietnamese syllable string into first consonant, vowel, last consonant.
385fn split_cvc(s: &str) -> (Vec<char>, Vec<char>, Vec<char>) {
386    let chars: Vec<char> = s.chars().map(|c| toneless_base(lower(c))).collect();
387    if chars.is_empty() {
388        return (vec![], vec![], vec![]);
389    }
390
391    // Scan from end to find vowel → lc boundary
392    let mut lc_start = chars.len();
393    while lc_start > 0 {
394        if is_vowel(chars[lc_start - 1]) {
395            break;
396        }
397        lc_start -= 1;
398    }
399
400    // Scan from vowel boundary back to find fc start
401    let mut vo_start = lc_start;
402    while vo_start > 0 {
403        if !is_vowel(chars[vo_start - 1]) {
404            break;
405        }
406        vo_start -= 1;
407    }
408
409    let mut fc: Vec<char> = chars[..vo_start].to_vec();
410    let mut vo: Vec<char> = chars[vo_start..lc_start].to_vec();
411    let lc: Vec<char> = chars[lc_start..].to_vec();
412
413    // "gi" + vowel: move 'i' from vo to fc (gi is a consonant digraph FC_2)
414    // gi + i (without a separate following vowel) is not valid Vietnamese:
415    //   "gì"  — ok, no consonant suffix
416    //   "gim" — invalid, gi + i + consonant
417    if !vo.is_empty() && fc.len() == 1 && fc[0] == 'g' && vo[0] == 'i' {
418        fc.push(vo.remove(0)); // move 'i' from vowel to first consonant
419        if vo.is_empty() && !lc.is_empty() {
420            return (fc, vo, lc); // invalid: gi+i+consonant, caught below
421        }
422    }
423
424    // "qu" + vowel: move 'u' from vo to fc (qu is a consonant digraph FC_1)
425    // qu + u (without a separate following vowel) is not valid Vietnamese
426    if !vo.is_empty() && fc.len() == 1 && fc[0] == 'q' && vo[0] == 'u' {
427        fc.push(vo.remove(0)); // move 'u' from vowel to first consonant
428        if vo.is_empty() && !lc.is_empty() {
429            return (fc, vo, lc); // invalid: qu+u+consonant, caught below
430        }
431    }
432
433    (fc, vo, lc)
434}
435
436/// Public CVC validation entry point.
437///
438/// Returns `true` if `s` can be parsed as a valid Vietnamese syllable.
439/// `s` should already be in composed form (with diacritics applied).
440pub fn is_valid_cvc(s: &str) -> bool {
441    if s.is_empty() || s.len() <= 1 {
442        return s.len() <= 1; // empty or single char is always "valid" (partial input)
443    }
444
445    let (fc, vo, lc) = split_cvc(s);
446
447    // gi + i (+ consonant) → invalid: gi digraph requires a different vowel
448    // qu + u (+ consonant) → invalid: qu digraph requires a different vowel
449    if vo.is_empty()
450        && !lc.is_empty()
451        && ((fc.len() == 2 && fc[0] == 'g' && fc[1] == 'i')
452            || (fc.len() == 2 && fc[0] == 'q' && fc[1] == 'u'))
453    {
454        return false;
455    }
456
457    // k only combines with e, ê, i, y (and their extended sequences: eo, êu, ia,
458    // iê, iêu).  Pattern from x-unikey's isValidCV.
459    if fc.len() == 1 && fc[0] == 'k' && !vo.is_empty() {
460        let first_vowel = vo[0];
461        if !matches!(first_vowel, 'e' | 'ê' | 'i' | 'y') {
462            return false;
463        }
464    }
465
466    // Debug: uncomment to trace CVC split
467    // eprintln!("is_valid_cvc({s:?}): fc={fc:?} vo={vo:?} lc={lc:?}");
468
469    let fc_mask = if !fc.is_empty() {
470        let m = lookup_mask(FC_ROWS, &fc, !vo.is_empty(), true);
471        if m == 0 {
472            return false;
473        }
474        m
475    } else {
476        0
477    };
478
479    let vo_mask = if !vo.is_empty() {
480        let m = lookup_mask(VO_ROWS, &vo, !lc.is_empty(), false);
481        if m == 0 {
482            return false;
483        }
484        m
485    } else {
486        0
487    };
488
489    let lc_mask = if !lc.is_empty() {
490        let m = lookup_mask(LC_ROWS, &lc, false, true);
491        if m == 0 {
492            return false;
493        }
494        m
495    } else {
496        0
497    };
498
499    if vo_mask == 0 {
500        return fc_mask != 0;
501    }
502    if fc_mask != 0 {
503        if !is_valid_cv(fc_mask, vo_mask) {
504            return false;
505        }
506        if lc_mask == 0 {
507            return true;
508        }
509    }
510    if lc_mask != 0 {
511        return is_valid_vc(vo_mask, lc_mask);
512    }
513    true
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    #[test]
521    fn valid_syllables() {
522        assert!(is_valid_cvc("ba"));
523        assert!(is_valid_cvc("con"));
524        assert!(is_valid_cvc("không"));
525        assert!(is_valid_cvc("người"));
526        assert!(is_valid_cvc("được"));
527        assert!(is_valid_cvc("trường"));
528        assert!(is_valid_cvc("buồn"));
529        assert!(is_valid_cvc("đẹp"));
530        assert!(is_valid_cvc("bâ"));
531        assert!(is_valid_cvc("mê"));
532        assert!(is_valid_cvc("că"));
533        assert!(is_valid_cvc("mơ"));
534        assert!(is_valid_cvc("ngoăn"));
535    }
536
537    #[test]
538    fn invalid_english_words() {
539        assert!(!is_valid_cvc("wôd")); // wood
540        assert!(!is_valid_cvc("rebôt")); // reboot
541        assert!(!is_valid_cvc("ôk")); // ook
542        assert!(!is_valid_cvc("vâi")); // vaai
543        assert!(!is_valid_cvc("alô")); // aloo
544        assert!(!is_valid_cvc("world")); // world
545        assert!(!is_valid_cvc("wỏld")); // world (with tone)
546    }
547
548    #[test]
549    fn đ_abbreviations() {
550        assert!(!is_valid_cvc("vcđ"));
551        assert!(!is_valid_cvc("đc"));
552        assert!(!is_valid_cvc("nđm"));
553        assert!(!is_valid_cvc("avcđ"));
554        assert!(!is_valid_cvc("bcđ"));
555    }
556}
557
558#[test]
559fn debug_gi_split() {
560    let (fc, vo, lc) = split_cvc("giờ");
561    eprintln!("giờ: fc={fc:?} vo={vo:?} lc={lc:?}");
562    eprintln!("valid: {}", is_valid_cvc("giờ"));
563}