Skip to main content

skey_engine/
lib.rs

1//! skey-engine — Stateless Vietnamese input method engine.
2//!
3//! Letter-based accumulator model for Vietnamese input.
4//! Key features:
5//!   - Stateless: input string → output string
6//!   - Letter struct with variant + tone tracking
7//!   - Modifier keys mutate the last vowel in place
8//!   - Precomputed UTF-8 tables for output
9//!   - Correct tone placement for all diphthong/triphthong cases
10
11pub mod tables;
12pub mod engine;
13pub mod spelling;
14pub mod charset;
15pub mod vseq;
16
17use std::collections::HashSet;
18use std::ffi::{CStr, CString};
19use std::os::raw::c_char;
20use std::sync::OnceLock;
21
22/// Input method.
23#[derive(Clone, Copy, PartialEq, Eq)]
24pub enum Method { Telex, Vni, TeipVni, Viqr }
25
26/// Embedded Vietnamese word list (data/vietnamese.cm.dict).
27///
28/// Compiled into the binary — no runtime file loading.  The file is kept
29/// sorted by byte order (LC_ALL=C sort) in the repo so lookups are binary
30/// searches on a parsed-once `Vec<&str>`.  Data source: ibus-bamboo's
31/// vietnamese.cm.dict (GPLv3), see data/LICENSE.vietnamese.cm.dict.
32static DICT: OnceLock<Vec<&'static str>> = OnceLock::new();
33
34/// Tone-stripped forms of the dictionary words, sorted and deduplicated.
35/// Vietnamese tone marks modify vowels in place (ơ→ớ), so a mid-word
36/// composition like "phươc" never appears as a string prefix of the final
37/// word "phước" — it matches the tone-stripped base form instead.
38static BASES: OnceLock<Vec<String>> = OnceLock::new();
39
40/// Access the embedded dictionary, parsing it on first use.
41fn dict() -> &'static Vec<&'static str> {
42    DICT.get_or_init(|| {
43        include_str!("../data/vietnamese.cm.dict")
44            .lines()
45            .collect()
46    })
47}
48
49/// Tone-stripped base forms of the dictionary, sorted and deduplicated.
50fn bases() -> &'static Vec<String> {
51    BASES.get_or_init(|| {
52        let mut v: Vec<String> =
53            dict().iter().map(|w| charset::remove_tone(w)).collect();
54        v.sort();
55        v.dedup();
56        v
57    })
58}
59
60/// Opaque engine handle — holds only configuration.
61pub struct SkeyEngine {
62    method: Method,
63    modern: bool,
64    short_w: bool,
65    auto_restore: bool,
66    dict: bool,
67    user_words: HashSet<String>,
68}
69
70impl SkeyEngine {
71    pub fn new(method: Method) -> Self {
72        SkeyEngine {
73            method,
74            modern: true,
75            short_w: false,
76            auto_restore: false,
77            dict: false,
78            user_words: HashSet::new(),
79        }
80    }
81
82    pub fn set_method(&mut self, method: Method) { self.method = method; }
83    pub fn set_modern(&mut self, v: bool) { self.modern = v; }
84    pub fn set_short_w(&mut self, v: bool) { self.short_w = v; }
85    pub fn set_auto_restore(&mut self, v: bool) { self.auto_restore = v; }
86    /// Dictionary mode: auto-restore validates the composed output against
87    /// the embedded Vietnamese word list instead of syllable rules.  Words
88    /// the list lacks (user vocabulary) can be added with `add_word`.
89    pub fn set_dict(&mut self, v: bool) { self.dict = v; }
90    /// Add a user word (kept lowercase).  User words are checked before the
91    /// embedded list, so they override a missing entry.
92    pub fn add_word(&mut self, word: &str) {
93        self.user_words.insert(word.to_lowercase());
94    }
95
96    /// Remove all user words (reload of the user dictionary file).
97    pub fn clear_words(&mut self) {
98        self.user_words.clear();
99    }
100
101    /// Dictionary mode validity: `word` (lowercase) is a real Vietnamese
102    /// word or a viable mid-word intermediate, so composition stays visible
103    /// in real time while typing.
104    ///
105    /// Two tiers:
106    /// - Tone-marked output must match a dictionary word exactly, or be an
107    ///   intermediate whose base form is a strict prefix of some dictionary
108    ///   word's base ("lượ" → base "lươ" ⊂ "lươc" of "lược").  An exact
109    ///   base match alone is NOT enough — that would accept wrong-tone
110    ///   words like "lước" just because "lược" shares its base form.
111    /// - Tone-less output is an intermediate: keep it when it is (or could
112    ///   become) the tone-stripped base form of a dictionary word, e.g.
113    ///   "phươc" (typing "phuowc") is the base of "phước".
114    fn dict_known(&self, word: &str) -> bool {
115        let base = charset::remove_tone(word);
116        if base != word {
117            // Tone marks present — exact word, or strict base-prefix.
118            if self.user_words.contains(word)
119                || dict().binary_search(&word).is_ok()
120            {
121                return true;
122            }
123            for w in &self.user_words {
124                let wb = charset::remove_tone(w);
125                if wb.starts_with(&base) && wb != base {
126                    return true;
127                }
128            }
129            let bs = bases();
130            match bs.binary_search(&base) {
131                Ok(_) => false, // tone-variant of a real word — reject
132                Err(i) => i < bs.len() && bs[i].starts_with(&base),
133            }
134        } else {
135            // No tone marks — base-form exact or prefix match.
136            for w in &self.user_words {
137                if charset::remove_tone(w).starts_with(&base) {
138                    return true;
139                }
140            }
141            let bs = bases();
142            match bs.binary_search(&base) {
143                Ok(_) => true,
144                Err(i) => i < bs.len() && bs[i].starts_with(&base),
145            }
146        }
147    }
148
149    pub fn transform(&self, input: &str) -> String {
150        let composed = match self.method {
151            Method::Telex => engine::convert_telex(input, self.modern, self.short_w),
152            Method::Vni => engine::convert_vni(input, self.modern, self.short_w),
153            Method::TeipVni => engine::convert_teip_vni(input, self.modern, self.short_w),
154            Method::Viqr => engine::convert_viqr(input),
155        };
156        // Auto-restore: if the composed output is not valid Vietnamese and
157        // differs from the raw input, return the raw input instead.
158        // Skip if the output is all ASCII (e.g. "cow" from toggle-off,
159        // English words) — those are intentional, not corrupted.
160        // Also skip if the input contains Vietnamese composition markers
161        // (dd → đ, vowel+w, double vowels) — the engine intentionally
162        // transformed them and the result should be kept.
163        let all_ascii = composed.chars().all(|c| c.is_ascii());
164        let known = if self.dict {
165            // Dictionary mode: valid = in the embedded word list (or a
166            // prefix of one) or added by the user.  Lookup is lowercase —
167            // the dict is stored so.
168            self.dict_known(&composed.to_lowercase())
169        } else {
170            Self::is_valid(&composed)
171        };
172        if self.auto_restore && !all_ascii && composed != input
173            && !known && !has_vn_markers(input)
174        {
175            input.to_string()
176        } else {
177            composed
178        }
179    }
180
181    pub fn is_valid(s: &str) -> bool {
182        spelling::is_valid_cvc(s)
183    }
184}
185
186/// Check whether the input string contains Vietnamese composition markers
187/// that signal intentional IME transformation. When these are present,
188/// auto-restore should not revert the engine output even if the result
189/// isn't a complete valid syllable (e.g. abbreviations like "đc").
190pub(crate) fn has_vn_markers(input: &str) -> bool {
191    // Vietnamese composition markers that signal intentional IME transforms.
192    //   dd → đ                  (d-stroke — intentional when word-initial)
193    //   aaa/eee/ooo             (3+ same vowel = circumflex toggle in progress)
194    //   aww/oww/uww             (vowel+w+w = horn/breve toggle chain)
195    //
196    // We deliberately do NOT match "aw"/"ow"/"uw" (single w) — those appear
197    // in English words (cow, how, law) whose output (cơ, hơ, lă) is valid
198    // Vietnamese anyway, so auto-restore wouldn't touch them.
199    // The toggle chains (vowww → vơw) produce invalid output that needs
200    // protection from auto-restore.
201    //
202    // "dd" counts as an intentional marker when the resulting đ would be
203    // word-initial or follow a consonant — Vietnamese abbreviations place
204    // đ right after a consonant (đc, vcđ, kđ).  A dd after a VOWEL
205    // (address, add, odd, ladder) is English — English dd always follows
206    // a vowel — and must NOT block auto-restore.
207    let lower = input.to_lowercase();
208    let dd_marker = lower.starts_with("dd")
209        || lower.match_indices("dd").any(|(i, _)| {
210            i > 0
211                && !matches!(
212                    lower[..i].chars().next_back(),
213                    Some('a' | 'e' | 'i' | 'o' | 'u' | 'y')
214                )
215        });
216    dd_marker
217        || lower.contains("aaa")
218        || lower.contains("eee")
219        || lower.contains("ooo")
220        || lower.contains("aww")
221        || lower.contains("oww")
222        || lower.contains("uww")
223}
224
225// ── C FFI ──────────────────────────────────────────────────────────
226
227#[unsafe(no_mangle)]
228pub unsafe extern "C" fn skey_engine_new(method: i32) -> *mut SkeyEngine {
229    let m = match method {
230        0 => Method::Telex,    // SKEY_METHOD_TELEX
231        1 => Method::Vni,      // SKEY_METHOD_VNI
232        2 => Method::Viqr,     // SKEY_METHOD_VIQR
233        3 => Method::TeipVni,  // SKEY_METHOD_TEIP_VNI
234        _ => Method::Telex,
235    };
236    Box::into_raw(Box::new(SkeyEngine::new(m)))
237}
238
239#[unsafe(no_mangle)]
240pub unsafe extern "C" fn skey_engine_free(engine: *mut SkeyEngine) {
241    if !engine.is_null() { unsafe { drop(Box::from_raw(engine)); } }
242}
243
244#[unsafe(no_mangle)]
245pub unsafe extern "C" fn skey_engine_set_method(engine: *mut SkeyEngine, method: i32) {
246    if engine.is_null() { return; }
247    let m = match method {
248        0 => Method::Telex,
249        1 => Method::Vni,
250        2 => Method::Viqr,
251        3 => Method::TeipVni,
252        _ => Method::Telex,
253    };
254    unsafe { (*engine).set_method(m); }
255}
256
257/// tone_style: 0=traditional (hoà, thuỷ), 1=modern (hòa, thủy). Default modern.
258#[unsafe(no_mangle)]
259pub unsafe extern "C" fn skey_engine_set_tone_style(e: *mut SkeyEngine, v: i32) {
260    if !e.is_null() { unsafe { (*e).set_modern(v != 0); } }
261}
262#[unsafe(no_mangle)]
263pub unsafe extern "C" fn skey_engine_set_free_marking(e: *mut SkeyEngine, v: i32) {
264    // Free marking ON → modern style (tone on 2nd vowel for oa/oe/uy)
265    // Free marking OFF → traditional (tone on 1st vowel for oa/oe/uy)
266    if !e.is_null() { unsafe { (*e).set_modern(v != 0); } }
267}
268#[unsafe(no_mangle)]
269pub unsafe extern "C" fn skey_engine_set_short_w(e: *mut SkeyEngine, v: i32) {
270    if !e.is_null() { unsafe { (*e).set_short_w(v != 0); } }
271}
272#[unsafe(no_mangle)]
273pub unsafe extern "C" fn skey_engine_set_auto_restore(e: *mut SkeyEngine, v: i32) {
274    if !e.is_null() { unsafe { (*e).set_auto_restore(v != 0); } }
275}
276#[unsafe(no_mangle)]
277pub unsafe extern "C" fn skey_engine_set_dict(e: *mut SkeyEngine, v: i32) {
278    if !e.is_null() { unsafe { (*e).set_dict(v != 0); } }
279}
280#[unsafe(no_mangle)]
281pub unsafe extern "C" fn skey_engine_add_word(e: *mut SkeyEngine, word: *const c_char) {
282    if e.is_null() || word.is_null() { return; }
283    let s = match unsafe { CStr::from_ptr(word) }.to_str() {
284        Ok(s) => s, Err(_) => return,
285    };
286    unsafe { (*e).add_word(s); }
287}
288#[unsafe(no_mangle)]
289pub unsafe extern "C" fn skey_engine_clear_words(e: *mut SkeyEngine) {
290    if !e.is_null() { unsafe { (*e).clear_words(); } }
291}
292
293/// All embedded dictionary words joined by '\n' (sorted).  Caller frees
294/// with skey_free_string().
295#[unsafe(no_mangle)]
296pub unsafe extern "C" fn skey_engine_dict_words() -> *mut c_char {
297    CString::new(dict().join("\n")).unwrap_or_default().into_raw()
298}
299#[unsafe(no_mangle)]
300pub unsafe extern "C" fn skey_engine_set_bracket_uo(_e: *mut SkeyEngine, _v: i32) {}
301
302#[unsafe(no_mangle)]
303pub unsafe extern "C" fn skey_engine_transform(
304    engine: *const SkeyEngine,
305    input: *const c_char,
306) -> *mut c_char {
307    if engine.is_null() || input.is_null() { return std::ptr::null_mut(); }
308    let e = unsafe { &*engine };
309    let s = match unsafe { CStr::from_ptr(input) }.to_str() {
310        Ok(s) => s, Err(_) => return std::ptr::null_mut(),
311    };
312    CString::new(e.transform(s)).unwrap_or_default().into_raw()
313}
314
315// ── Charset FFI ──────────────────────────────────────────────────────
316
317#[unsafe(no_mangle)]
318pub unsafe extern "C" fn skey_charset_encode(
319    input: *const c_char,
320    charset: i32,
321    out_len: *mut usize,
322) -> *mut u8 {
323    if input.is_null() || out_len.is_null() { return std::ptr::null_mut(); }
324    let s = match unsafe { CStr::from_ptr(input) }.to_str() {
325        Ok(s) => s, Err(_) => return std::ptr::null_mut(),
326    };
327    let cs = match charset {
328        0 => charset::VietCharset::Unicode,
329        1 => charset::VietCharset::TCVN3,
330        2 => charset::VietCharset::VNIWin,
331        3 => charset::VietCharset::WinCP1258,
332        4 => charset::VietCharset::VIQR,
333        _ => charset::VietCharset::Unicode,
334    };
335    let encoded = charset::encode(s, cs);
336    let len = encoded.len();
337    let ptr = libc::malloc(len).cast::<u8>();
338    if ptr.is_null() { return std::ptr::null_mut(); }
339    unsafe { std::ptr::copy_nonoverlapping(encoded.as_ptr(), ptr, len); }
340    unsafe { *out_len = len; }
341    ptr
342}
343
344#[unsafe(no_mangle)]
345pub unsafe extern "C" fn skey_charset_decode(
346    input: *const u8,
347    len: usize,
348    charset: i32,
349) -> *mut c_char {
350    if input.is_null() { return std::ptr::null_mut(); }
351    let bytes = unsafe { std::slice::from_raw_parts(input, len) };
352    let cs = match charset {
353        0 => charset::VietCharset::Unicode,
354        1 => charset::VietCharset::TCVN3,
355        2 => charset::VietCharset::VNIWin,
356        3 => charset::VietCharset::WinCP1258,
357        4 => charset::VietCharset::VIQR,
358        _ => charset::VietCharset::Unicode,
359    };
360    let decoded = charset::decode(bytes, cs);
361    CString::new(decoded).unwrap_or_default().into_raw()
362}
363
364#[unsafe(no_mangle)]
365pub unsafe extern "C" fn skey_charset_remove_tone(input: *const c_char) -> *mut c_char {
366    if input.is_null() { return std::ptr::null_mut(); }
367    let s = match unsafe { CStr::from_ptr(input) }.to_str() {
368        Ok(s) => s, Err(_) => return std::ptr::null_mut(),
369    };
370    CString::new(charset::remove_tone(s)).unwrap_or_default().into_raw()
371}
372
373#[unsafe(no_mangle)]
374pub unsafe extern "C" fn skey_charset_free_buf(ptr: *mut u8) {
375    if !ptr.is_null() { unsafe { libc::free(ptr.cast()); } }
376}
377
378// ── C FFI (continued) ────────────────────────────────────────────────
379
380#[unsafe(no_mangle)]
381pub unsafe extern "C" fn skey_engine_is_valid(s: *const c_char) -> i32 {
382    if s.is_null() { return 0; }
383    let s = match unsafe { CStr::from_ptr(s) }.to_str() {
384        Ok(s) => s, Err(_) => return 0,
385    };
386    if SkeyEngine::is_valid(s) { 1 } else { 0 }
387}
388
389#[unsafe(no_mangle)]
390pub unsafe extern "C" fn skey_free_string(s: *mut c_char) {
391    if !s.is_null() { unsafe { drop(CString::from_raw(s)); } }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    fn eng(auto_restore: bool, dict: bool) -> SkeyEngine {
399        let mut e = SkeyEngine::new(Method::Telex);
400        e.set_auto_restore(auto_restore);
401        e.set_dict(dict);
402        e
403    }
404
405    #[test]
406    fn dict_restores_nonwords() {
407        let e = eng(true, true);
408        // "lước" is a structurally valid syllable but not a real word —
409        // rule-based check keeps it, dictionary check restores it.
410        assert_eq!(e.transform("luwocs"), "luwocs");
411        // English "need" → "nêd" is not in the dictionary either.
412        assert_eq!(e.transform("need"), "need");
413        // "force" → "fỏce" is not a word → restored.
414        assert_eq!(e.transform("force"), "force");
415    }
416
417    #[test]
418    fn dict_keeps_prefixes_realtime() {
419        // Mid-word prefixes of real words must not be restored, so the
420        // composition stays visible while typing.
421        let e = eng(true, true);
422        assert_eq!(e.transform("phuowc"), "phươc"); // prefix of "phước"
423        assert_eq!(e.transform("phuowcs"), "phước"); // complete word
424        assert_eq!(e.transform("truwoc"), "trươc"); // prefix of "trước"
425        // Tone-marked intermediates whose base is a strict prefix of a
426        // dictionary word's base ("lượ" → "lươ" ⊂ "lươc" of "lược").
427        assert_eq!(e.transform("luow"), "lươ");
428        assert_eq!(e.transform("luowj"), "lượ");
429        assert_eq!(e.transform("luowjc"), "lược");
430        // User words' prefixes are kept too.
431        let mut e2 = eng(true, true);
432        e2.add_word("lước");
433        assert_eq!(e2.transform("luwoc"), "lươc");
434    }
435
436    #[test]
437    fn dict_keeps_real_words() {
438        let e = eng(true, true);
439        assert_eq!(e.transform("truwocs"), "trước");
440        assert_eq!(e.transform("khoong"), "không");
441        assert_eq!(e.transform("Vieetj"), "Việt");
442        assert_eq!(e.transform("luoojcw"), "lược"); // luộc + w → lược
443    }
444
445    #[test]
446    fn dict_keeps_abbreviations() {
447        let e = eng(true, true);
448        assert_eq!(e.transform("ddc"), "đc"); // has_vn_markers protection
449    }
450
451    #[test]
452    fn auto_restore_english_dd_words() {
453        // Mid-word "dd" is English, not an intentional đ — auto-restore
454        // must return the raw input at every intermediate stage.
455        let e = eng(true, false);
456        assert_eq!(e.transform("add"), "add");
457        assert_eq!(e.transform("addr"), "addr");
458        assert_eq!(e.transform("addre"), "addre");
459        assert_eq!(e.transform("addres"), "addres");
460        assert_eq!(e.transform("address"), "address");
461        assert_eq!(e.transform("ladder"), "ladder");
462        assert_eq!(e.transform("odd"), "odd"); // English dd after a vowel
463                                               // Dict mode restores them too (no word starts with "ad").
464        let e = eng(true, true);
465        assert_eq!(e.transform("address"), "address");
466        // Word-initial dd stays an intentional đ abbreviation.
467        let e = eng(true, false);
468        assert_eq!(e.transform("ddc"), "đc");
469        assert_eq!(e.transform("ddi"), "đi");
470        // dd after a CONSONANT is an intentional abbreviation too —
471        // đ follows a consonant in Vietnamese chat shorthand (vcđ, kđ).
472        assert_eq!(e.transform("vcdd"), "vcđ");
473        assert_eq!(e.transform("kdd"), "kđ");
474        assert_eq!(e.transform("cmdd"), "cmđ");
475    }
476
477    #[test]
478    fn double_vowel_swap_survives_auto_restore() {
479        // lắm + a → lấm (hook↔circumflex swap) is a valid word, so
480        // auto-restore must NOT revert it to raw keystrokes.
481        let e = eng(true, false);
482        assert_eq!(e.transform("lawsma"), "lấm");
483        let e = eng(true, true);
484        assert_eq!(e.transform("lawsma"), "lấm"); // dict mode keeps it too
485        assert_eq!(e.transform("luowjco"), "luộc"); // lược + o → luộc (ươ→uô)
486    }
487
488    #[test]
489    fn dict_off_keeps_rule_based_behavior() {
490        let e = eng(true, false);
491        assert_eq!(e.transform("luwocs"), "lước"); // rule-valid → kept
492        assert_eq!(e.transform("need"), "need"); // rule-invalid → restored
493    }
494
495    #[test]
496    fn dict_user_words_override() {
497        let mut e = eng(true, true);
498        assert_eq!(e.transform("luwocs"), "luwocs");
499        e.add_word("Lước"); // stored lowercase — matched case-insensitively
500        assert_eq!(e.transform("luwocs"), "lước");
501        assert_eq!(e.transform("LUWOCS"), "LƯỚC");
502    }
503
504    #[test]
505    fn dict_embedded_is_sorted() {
506        // binary_search requires byte-order sorting of the embedded file.
507        let dict = DICT.get_or_init(|| {
508            include_str!("../data/vietnamese.cm.dict")
509                .lines()
510                .collect()
511        });
512        assert!(dict.len() > 7000, "dict unexpectedly small: {}", dict.len());
513        for w in dict.windows(2) {
514            assert!(w[0] < w[1], "dict not sorted at {:?}", w);
515        }
516    }
517}