Skip to main content

bamboo_core/
engine.rs

1//! The core engine that processes keypresses and maintains the IME state.
2
3use crate::config::Config;
4use crate::input_method::{InputMethod, Rule};
5use crate::mode::{Mode, OutputOptions};
6use crate::utils::{is_upper, lower};
7
8/// Maximum number of active transformations in a single syllable.
9pub const MAX_ACTIVE_TRANS: usize = 16;
10
11/// A lightweight snapshot of the engine state before a keystroke, used for O(1) backspace.
12#[derive(Clone, Copy)]
13struct Snapshot {
14    active_buffer: [Transformation; MAX_ACTIVE_TRANS],
15    active_len: usize,
16    current_state_id: u32,
17}
18
19/// Represents a single keypress or a transformation derived from it (e.g., adding a mark or tone).
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Hash)]
21pub struct Transformation {
22    /// The rule that was applied to create this transformation.
23    pub rule: Rule,
24    /// The index of the transformation in the composition that this transformation targets (if any).
25    /// For example, a tone mark transformation targets an earlier vowel.
26    /// Uses u8 since MAX_ACTIVE_TRANS = 16, saving 14 bytes vs `Option<usize>`.
27    pub target: Option<u8>,
28    /// Whether the resulting character should be rendered as uppercase.
29    pub is_upper_case: bool,
30}
31
32/// A stack-allocated buffer for transformations to avoid heap allocations in the hot path.
33///
34/// This structure uses a fixed-size array and is extremely fast for frequent updates.
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Hash)]
36pub struct TransformationStack {
37    data: [Transformation; MAX_ACTIVE_TRANS],
38    len: usize,
39}
40
41impl TransformationStack {
42    /// Creates a new, empty transformation stack.
43    pub fn new() -> Self {
44        Self { data: [Transformation::default(); MAX_ACTIVE_TRANS], len: 0 }
45    }
46
47    /// Pushes a new transformation onto the stack.
48    /// Does nothing if the stack is full.
49    pub fn push(&mut self, t: Transformation) {
50        debug_assert!(
51            self.len < MAX_ACTIVE_TRANS,
52            "TransformationStack overflow: max {MAX_ACTIVE_TRANS} reached"
53        );
54        if self.len < MAX_ACTIVE_TRANS {
55            self.data[self.len] = t;
56            self.len += 1;
57        }
58    }
59
60    /// Removes and returns the last transformation from the stack.
61    #[allow(dead_code)]
62    pub fn pop(&mut self) -> Option<Transformation> {
63        if self.len > 0 {
64            self.len -= 1;
65            Some(self.data[self.len])
66        } else {
67            None
68        }
69    }
70
71    /// Clears all transformations from the stack.
72    pub fn clear(&mut self) {
73        self.len = 0;
74    }
75
76    /// Returns the number of transformations currently in the stack.
77    pub fn len(&self) -> usize {
78        self.len
79    }
80
81    /// Returns true if the stack contains no transformations.
82    pub fn is_empty(&self) -> bool {
83        self.len == 0
84    }
85
86    /// Returns a slice containing all transformations in the stack.
87    pub fn as_slice(&self) -> &[Transformation] {
88        &self.data[..self.len]
89    }
90
91    /// Returns a mutable slice containing all transformations in the stack.
92    pub fn as_mut_slice(&mut self) -> &mut [Transformation] {
93        &mut self.data[..self.len]
94    }
95
96    /// Appends a slice of transformations to the stack.
97    pub fn extend_from_slice(&mut self, other: &[Transformation]) {
98        let to_copy = other.len().min(MAX_ACTIVE_TRANS - self.len);
99        if to_copy > 0 {
100            self.data[self.len..self.len + to_copy].copy_from_slice(&other[..to_copy]);
101            self.len += to_copy;
102        }
103    }
104
105    /// Drains transformations from a starting index into another stack.
106    pub fn drain_to(&mut self, start: usize, target: &mut TransformationStack) {
107        target.clear();
108        if start < self.len {
109            target.extend_from_slice(&self.data[start..self.len]);
110            self.len = start;
111        }
112    }
113}
114
115#[inline]
116fn uoh_tail_match(s: &str) -> bool {
117    for pat in ["uơ", "ưo"] {
118        if let Some(idx) = s.find(pat) {
119            let after = &s[idx + pat.len()..];
120            if after.chars().next().is_some_and(|c| c.is_alphabetic()) {
121                return true;
122            }
123        }
124    }
125    false
126}
127
128/// The main stateful processor of the Vietnamese Input Method Engine.
129///
130/// It maintains an internal buffer of transformations and produces the correctly marked Vietnamese text.
131/// The engine uses a hybrid approach combining a Rule Engine with a Lazy JIT DFA for peak performance.
132pub struct Engine {
133    committed_text: String,
134    /// Stack-allocated buffer for the active composition to avoid heap allocations.
135    active_buffer: [Transformation; MAX_ACTIVE_TRANS],
136    active_len: usize,
137
138    input_method: InputMethod,
139    all_rules: Box<[Rule]>,
140    ascii_rule_indices: [(u16, u16); 128],
141    non_ascii_rule_indices: Box<[(char, (u16, u16))]>,
142    ascii_effect_keys: [bool; 128],
143    non_ascii_effect_keys: Vec<char>,
144    config: Config,
145
146    // Stack buffers to avoid per-keystroke heap allocations.
147    work_comp: TransformationStack,
148    scratch_comp: TransformationStack,
149
150    prev_preedit: String,
151    delta_buf: String,
152
153    dfa: crate::dfa::Dfa,
154    current_state_id: u32,
155
156    // Snapshot stack for O(1) backspace — all stack-allocated, zero heap.
157    snapshots: [Snapshot; MAX_ACTIVE_TRANS],
158    snapshot_len: usize,
159
160    // Lazily-initialized scratch engine for restore_last_word to avoid repeated with_config.
161    scratch_engine: Option<Box<Engine>>,
162}
163
164impl Engine {
165    /// Creates a new engine with the specified input method and default configuration.
166    pub fn new(input_method: InputMethod) -> Self {
167        Self::with_config(input_method, Config::default())
168    }
169
170    /// Creates a new engine with a specific input method and configuration.
171    pub fn with_config(input_method: InputMethod, config: Config) -> Self {
172        let mut rules_by_key: std::collections::BTreeMap<char, Vec<Rule>> =
173            std::collections::BTreeMap::new();
174        for rule in &input_method.rules {
175            let key = lower(rule.key);
176            rules_by_key.entry(key).or_default().push(*rule);
177        }
178
179        let total_rules: usize = rules_by_key.values().map(|v| v.len()).sum();
180        let mut all_rules_vec = Vec::with_capacity(total_rules);
181        let mut ascii_rule_indices = [(0u16, 0u16); 128];
182        let mut non_ascii_indices_vec = Vec::new();
183
184        for (key, rules) in rules_by_key {
185            let start = all_rules_vec.len() as u16;
186            all_rules_vec.extend(rules);
187            let end = all_rules_vec.len() as u16;
188            if key.is_ascii() {
189                ascii_rule_indices[key as usize] = (start, end);
190            } else {
191                non_ascii_indices_vec.push((key, (start, end)));
192            }
193        }
194
195        let mut ascii_effect_keys = [false; 128];
196        let mut non_ascii_effect_keys: Vec<char> = Vec::new();
197        for key in &input_method.keys {
198            if key.is_ascii() {
199                ascii_effect_keys[*key as usize] = true;
200            } else {
201                non_ascii_effect_keys.push(*key);
202            }
203        }
204        non_ascii_effect_keys.sort_unstable();
205        non_ascii_effect_keys.dedup();
206
207        Self {
208            committed_text: String::with_capacity(256),
209            active_buffer: [Transformation::default(); MAX_ACTIVE_TRANS],
210            active_len: 0,
211            input_method,
212            all_rules: all_rules_vec.into_boxed_slice(),
213            ascii_rule_indices,
214            non_ascii_rule_indices: non_ascii_indices_vec.into_boxed_slice(),
215            ascii_effect_keys,
216            non_ascii_effect_keys,
217            config,
218
219            work_comp: TransformationStack::new(),
220            scratch_comp: TransformationStack::new(),
221
222            prev_preedit: String::with_capacity(64),
223            delta_buf: String::with_capacity(64),
224            dfa: crate::dfa::Dfa::new(),
225            current_state_id: 0,
226
227            snapshots: [Snapshot {
228                active_buffer: [Transformation::default(); MAX_ACTIVE_TRANS],
229                active_len: 0,
230                current_state_id: 0,
231            }; MAX_ACTIVE_TRANS],
232            snapshot_len: 0,
233            scratch_engine: None,
234        }
235    }
236
237    #[inline]
238    pub(crate) fn active_slice(&self) -> &[Transformation] {
239        &self.active_buffer[..self.active_len]
240    }
241
242    fn take_active_into(&mut self, out: &mut TransformationStack) {
243        out.clear();
244        out.extend_from_slice(self.active_slice());
245        self.active_len = 0;
246    }
247
248    fn set_active_from_stack(&mut self, src: &mut TransformationStack) {
249        self.active_len = src.len().min(MAX_ACTIVE_TRANS);
250        self.active_buffer[..self.active_len].copy_from_slice(src.as_slice());
251        src.clear();
252    }
253
254    #[inline]
255    fn push_snapshot(&mut self) {
256        if self.snapshot_len < MAX_ACTIVE_TRANS {
257            let snap = &mut self.snapshots[self.snapshot_len];
258            snap.active_buffer[..self.active_len]
259                .copy_from_slice(&self.active_buffer[..self.active_len]);
260            snap.active_len = self.active_len;
261            snap.current_state_id = self.current_state_id;
262            self.snapshot_len += 1;
263        }
264    }
265
266    #[inline]
267    fn pop_snapshot(&mut self) -> Option<()> {
268        if self.snapshot_len == 0 {
269            return None;
270        }
271        self.snapshot_len -= 1;
272        let snap = &self.snapshots[self.snapshot_len];
273        self.active_len = snap.active_len;
274        self.active_buffer[..self.active_len]
275            .copy_from_slice(&snap.active_buffer[..self.active_len]);
276        self.current_state_id = snap.current_state_id;
277        Some(())
278    }
279
280    /// Returns the current configuration of the engine.
281    pub fn config(&self) -> Config {
282        self.config
283    }
284
285    /// Updates the engine configuration.
286    pub fn set_config(&mut self, config: Config) {
287        self.config = config;
288    }
289
290    /// Returns a copy of the current input method.
291    pub fn input_method(&self) -> InputMethod {
292        self.input_method.clone()
293    }
294
295    /// Warms up the DFA by pre-compiling common Vietnamese syllables.
296    ///
297    /// This API is intentionally unstable and currently uses a Telex-biased
298    /// heuristic corpus. It can help long-lived Telex sessions, but may hurt
299    /// cold-start latency or non-Telex/custom input methods.
300    ///
301    /// Prefer relying on the default lazy JIT behavior unless you have benchmark
302    /// data for your production workload.
303    #[deprecated(
304        since = "0.3.4",
305        note = "Engine::warm_up() is unstable and may be removed. It uses a Telex-biased heuristic and may regress cold-start or non-Telex workloads."
306    )]
307    pub fn warm_up(&mut self) {
308        let mut compiler = crate::dfa::DfaCompiler::new(&self.input_method, self.config.to_flags());
309        compiler.compile_common();
310        self.dfa = compiler.dfa;
311        self.current_state_id = 0;
312    }
313
314    fn get_applicable_rules(&self, key: char) -> &[Rule] {
315        let key = lower(key);
316        if key.is_ascii() {
317            let (start, end) = self.ascii_rule_indices[key as usize];
318            &self.all_rules[start as usize..end as usize]
319        } else {
320            self.non_ascii_rule_indices
321                .binary_search_by_key(&key, |(k, _)| *k)
322                .map(|idx| {
323                    let (start, end) = self.non_ascii_rule_indices[idx].1;
324                    &self.all_rules[start as usize..end as usize]
325                })
326                .unwrap_or(&[])
327        }
328    }
329
330    fn can_process_key_raw(&self, lower_key: char) -> bool {
331        if crate::utils::is_alpha(lower_key)
332            || (lower_key.is_ascii() && self.ascii_effect_keys[lower_key as usize])
333            || self.non_ascii_effect_keys.binary_search(&lower_key).is_ok()
334        {
335            return true;
336        }
337        if crate::utils::is_word_break_symbol(lower_key) {
338            return false;
339        }
340        crate::utils::is_vietnamese_rune(lower_key)
341    }
342
343    fn generate_transformations(
344        &self,
345        composition: &mut TransformationStack,
346        key: char,
347        is_upper_case: bool,
348    ) {
349        let lower_key = lower(key);
350        let mut trans_buf = TransformationStack::new();
351
352        crate::bamboo_util::generate_transformations(
353            composition.as_slice(),
354            self.get_applicable_rules(lower_key),
355            self.config.to_flags(),
356            lower_key,
357            is_upper_case,
358            &mut trans_buf,
359        );
360
361        if trans_buf.is_empty() {
362            crate::bamboo_util::generate_fallback_transformations(
363                self.get_applicable_rules(lower_key),
364                lower_key,
365                is_upper_case,
366                &mut trans_buf,
367            );
368
369            // Temporary combined data to avoid full struct copy
370            let combined_len = composition.len() + trans_buf.len();
371            if combined_len <= MAX_ACTIVE_TRANS {
372                let mut tmp_data = [Transformation::default(); MAX_ACTIVE_TRANS];
373                tmp_data[..composition.len()].copy_from_slice(composition.as_slice());
374                tmp_data[composition.len()..combined_len].copy_from_slice(trans_buf.as_slice());
375
376                if !self.input_method.super_keys.is_empty() {
377                    let current_str = crate::flattener::flatten_slice(
378                        &tmp_data[..combined_len],
379                        OutputOptions::TONE_LESS | OutputOptions::LOWER_CASE,
380                    );
381                    if uoh_tail_match(&current_str) {
382                        let (target, rule) = crate::bamboo_util::find_target(
383                            &tmp_data[..combined_len],
384                            self.get_applicable_rules(self.input_method.super_keys[0]),
385                            self.config.to_flags(),
386                        );
387                        if let (Some(target), Some(mut rule)) = (target, rule) {
388                            rule.key = '\0';
389                            trans_buf.push(Transformation {
390                                rule,
391                                target: Some(target),
392                                is_upper_case: false,
393                            });
394                        }
395                    }
396                }
397            }
398        }
399        composition.extend_from_slice(trans_buf.as_slice());
400        if self.config.to_flags() & crate::bamboo_util::EFREE_TONE_MARKING != 0
401            && self.is_valid_internal(composition.as_slice(), false)
402        {
403            let mut extra = TransformationStack::new();
404            crate::bamboo_util::refresh_last_tone_target_into(
405                composition.as_mut_slice(),
406                self.config.to_flags() & crate::bamboo_util::ESTD_TONE_STYLE != 0,
407                &mut extra,
408            );
409            composition.extend_from_slice(extra.as_slice());
410        }
411    }
412
413    fn last_syllable_start(composition: &[Transformation]) -> usize {
414        let mut idx = composition.len();
415        let mut last_is_vowel = false;
416        let mut found_vowel = false;
417
418        while idx > 0 {
419            let tmp = &composition[idx - 1];
420            if tmp.target.is_none() {
421                let is_v = crate::utils::is_vowel(tmp.rule.result);
422                if found_vowel && !is_v && !last_is_vowel {
423                    break;
424                }
425                if is_v {
426                    found_vowel = true;
427                }
428                last_is_vowel = is_v;
429            }
430            idx -= 1;
431        }
432
433        idx
434    }
435
436    fn new_composition_in_place(
437        &self,
438        composition: &mut TransformationStack,
439        scratch: &mut TransformationStack,
440        key: char,
441        is_upper_case: bool,
442    ) {
443        let syllable_abs_start = Self::last_syllable_start(composition.as_slice());
444
445        composition.drain_to(syllable_abs_start, scratch);
446
447        let offset = syllable_abs_start;
448        if offset != 0 {
449            for t in scratch.as_mut_slice().iter_mut() {
450                if let Some(target) = t.target {
451                    t.target = Some(target.saturating_sub(offset as u8));
452                }
453            }
454        }
455
456        self.generate_transformations(scratch, key, is_upper_case);
457
458        if offset != 0 {
459            for t in scratch.as_mut_slice().iter_mut() {
460                if let Some(target) = t.target {
461                    t.target = Some(target + offset as u8);
462                }
463            }
464        }
465
466        composition.extend_from_slice(scratch.as_slice());
467    }
468
469    /// Processes a string of characters and returns the resulting active word.
470    ///
471    /// This is a convenience wrapper around [`Self::process_str`] and [`Self::output`].
472    pub fn process(&mut self, s: &str, mode: Mode) -> String {
473        self.process_str(s, mode).output()
474    }
475
476    /// Processes a string of characters and returns a reference to the engine.
477    pub fn process_str(&mut self, s: &str, mode: Mode) -> &Self {
478        for key in s.chars() {
479            self.process_key(key, mode);
480        }
481        self
482    }
483
484    fn lcp_chars_and_bytes(a: &str, b: &str) -> (usize, usize) {
485        let mut lcp_chars = 0usize;
486        let mut lcp_bytes = 0usize;
487        for (ac, bc) in a.chars().zip(b.chars()) {
488            if ac == bc {
489                lcp_chars += 1;
490                lcp_bytes += ac.len_utf8();
491            } else {
492                break;
493            }
494        }
495        (lcp_chars, lcp_bytes)
496    }
497
498    /// Processes a single key and returns a **3-way diff** for efficient text editor updates.
499    ///
500    /// Instead of rewriting the entire preedit, the frontend only needs to apply:
501    /// 1. Keep the common prefix unchanged.
502    /// 2. Delete `backspace_count` characters from the end of the previous preedit.
503    /// 3. Append `inserted_suffix`.
504    ///
505    /// ```text
506    /// previous_preedit = [common_prefix] + [backspace_count chars to delete]
507    /// new_preedit      = [common_prefix] + [inserted_suffix]
508    /// ```
509    ///
510    /// The common prefix length is implicit: `previous_preedit.len() - backspace_count`
511    /// (in characters). The frontend does not need to compute LCP/LCS — the engine does it.
512    ///
513    /// # Returns
514    ///
515    /// `(backspace_count, backspaces_bytes, inserted_suffix)`:
516    /// - `backspace_count`: Number of **characters** to delete from the end of the previous preedit.
517    /// - `backspaces_bytes`: Number of **UTF-8 bytes** to delete (for byte-oriented editors).
518    /// - `inserted_suffix`: The new string to append after deletion.
519    ///
520    /// # Example
521    ///
522    /// ```rust
523    /// use bamboo_core::{Engine, Mode, InputMethod};
524    ///
525    /// let mut engine = Engine::new(InputMethod::telex());
526    ///
527    /// let (bs, _, ins) = engine.process_key_delta('a', Mode::Vietnamese);
528    /// assert_eq!(bs, 0);
529    /// assert_eq!(ins, "a");
530    ///
531    /// let (bs, _, ins) = engine.process_key_delta('s', Mode::Vietnamese);
532    /// // previous = "a", new = "á"
533    /// // keep prefix = 1 - 1 = 0, delete = 1 ("a"), insert = "á"
534    /// assert_eq!(bs, 1);
535    /// assert_eq!(ins, "á");
536    /// ```
537    pub fn process_key_delta(&mut self, key: char, mode: Mode) -> (usize, usize, &str) {
538        self.process_key(key, mode);
539
540        let active_len = self.active_len;
541        let active = &self.active_buffer[..active_len];
542        crate::flattener::flatten_slice_into(active, OutputOptions::NONE, &mut self.delta_buf);
543
544        let (_prefix_len, lcp_bytes) =
545            Self::lcp_chars_and_bytes(&self.prev_preedit, &self.delta_buf);
546
547        let prev_bytes = self.prev_preedit.len();
548
549        // Count only the suffix chars after the common prefix — O(suffix_len) instead of O(total).
550        let backspace_count = self.prev_preedit[lcp_bytes..].chars().count();
551        let backspaces_bytes = prev_bytes.saturating_sub(lcp_bytes);
552
553        std::mem::swap(&mut self.prev_preedit, &mut self.delta_buf);
554        let inserted_suffix = &self.prev_preedit[lcp_bytes..];
555        (backspace_count, backspaces_bytes, inserted_suffix)
556    }
557
558    /// Similar to [`Self::process_key_delta`], but writes the inserted string into a provided buffer.
559    ///
560    /// # Returns
561    /// `backspace_count` — number of characters to delete from the end of the previous preedit.
562    pub fn process_key_delta_into(
563        &mut self,
564        key: char,
565        mode: Mode,
566        inserted: &mut String,
567    ) -> usize {
568        let (backspace_count, _backspaces_bytes, ins) = self.process_key_delta(key, mode);
569        inserted.clear();
570        inserted.push_str(ins);
571        backspace_count
572    }
573
574    /// Processes a single character.
575    ///
576    /// The `mode` determines whether to apply Vietnamese transformation rules.
577    pub fn process_key(&mut self, key: char, mode: Mode) {
578        let lower_key = lower(key);
579        let is_upper_case = is_upper(key);
580
581        // English mode: skip all Vietnamese processing.
582        // Direct buffer append — no DFA lookup, no snapshot.
583        if mode == Mode::English {
584            if crate::utils::is_word_break_symbol(lower_key) && self.active_len > 0 {
585                self.commit();
586            }
587            if self.active_len >= MAX_ACTIVE_TRANS {
588                self.commit();
589            }
590            self.active_buffer[self.active_len] =
591                crate::bamboo_util::new_appending_trans(lower_key, is_upper_case);
592            self.active_len += 1;
593            if crate::utils::is_word_break_symbol(lower_key) {
594                self.commit();
595            }
596            self.current_state_id = 0;
597            return;
598        }
599
600        // DFA Fast Path: if DFA has a cached transition, key is valid.
601        // Skip can_process_key_raw entirely.
602        if lower_key.is_ascii() && !is_upper_case {
603            let next_state_id =
604                self.dfa.get_state(self.current_state_id).transitions[lower_key as usize];
605            if next_state_id != 0 {
606                // Snapshot before overwriting active buffer (skip if empty — nothing to restore).
607                if self.active_len > 0 {
608                    self.push_snapshot();
609                }
610                self.current_state_id = next_state_id;
611                let comp = self.dfa.get_composition(next_state_id);
612                self.active_len = comp.len().min(MAX_ACTIVE_TRANS);
613                self.active_buffer[..self.active_len].copy_from_slice(comp);
614                return;
615            }
616        }
617
618        // Slow path: validate key and handle word breaks
619        if !self.can_process_key_raw(lower_key) {
620            if crate::utils::is_word_break_symbol(lower_key) {
621                self.commit();
622            }
623            // Snapshot before push_active so backspace can restore previous state.
624            // Word breaks trigger commit() which clears snapshots — that's correct
625            // (committed text can't be undone via backspace).
626            if self.active_len > 0 {
627                self.push_snapshot();
628            }
629            let trans = crate::bamboo_util::new_appending_trans(lower_key, is_upper_case);
630            self.push_active(trans);
631            if crate::utils::is_word_break_symbol(lower_key) {
632                self.commit();
633            }
634            self.current_state_id = 0;
635            return;
636        }
637
638        // Snapshot only needed before slow-path mutations (new_composition_in_place).
639        self.push_snapshot();
640
641        let mut work = self.work_comp;
642        let mut scratch = self.scratch_comp;
643
644        self.take_active_into(&mut work);
645        self.new_composition_in_place(&mut work, &mut scratch, lower_key, is_upper_case);
646
647        // Try to update DFA (Lazy JIT)
648        if lower_key.is_ascii() && !is_upper_case && work.len() <= MAX_ACTIVE_TRANS {
649            let next_id = self.dfa.add_state(work.as_slice());
650            self.dfa.states[self.current_state_id as usize].transitions[lower_key as usize] =
651                next_id;
652            self.current_state_id = next_id;
653        } else {
654            self.current_state_id = self.dfa.find_state(work.as_slice()).unwrap_or(0);
655        }
656
657        self.set_active_from_stack(&mut work);
658
659        self.work_comp = work;
660        self.scratch_comp = scratch;
661    }
662
663    fn push_active(&mut self, trans: Transformation) {
664        if self.active_len >= MAX_ACTIVE_TRANS {
665            // Buffer full, auto-commit to make room
666            self.commit();
667        }
668        self.active_buffer[self.active_len] = trans;
669        self.active_len += 1;
670        self.current_state_id = self.dfa.find_state(self.active_slice()).unwrap_or(0);
671    }
672
673    /// Clears the active syllable buffer and appends it to the committed text.
674    pub fn commit(&mut self) {
675        if self.active_len == 0 {
676            return;
677        }
678        let word = self.output();
679        self.committed_text.push_str(&word);
680        self.active_len = 0;
681        self.current_state_id = 0;
682        self.snapshot_len = 0;
683    }
684
685    /// Returns the currently active syllable as a string.
686    pub fn output(&self) -> String {
687        crate::flattener::flatten_slice(self.active_slice(), OutputOptions::NONE)
688    }
689
690    /// Returns the processed string according to the specified options.
691    ///
692    /// This can be used to get the full text (committed + active) or variations like toneless text.
693    pub fn get_processed_str(&self, options: OutputOptions) -> String {
694        let active = self.active_slice();
695        if options.contains(OutputOptions::FULL_TEXT) {
696            let mut result = self.committed_text.clone();
697            result.push_str(&crate::flattener::flatten_slice(active, options));
698            return result;
699        }
700        if options.contains(OutputOptions::PUNCTUATION_MODE) {
701            if active.is_empty() {
702                return String::new();
703            }
704            let (_, tail) = crate::bamboo_util::extract_last_word_with_punctuation_marks(
705                active,
706                &self.input_method.keys,
707            );
708            return crate::flattener::flatten_slice(tail, OutputOptions::NONE);
709        }
710        crate::flattener::flatten_slice(active, options)
711    }
712
713    /// Checks if the current composition forms a valid Vietnamese syllable.
714    pub fn is_valid(&self, input_is_full_complete: bool) -> bool {
715        self.is_valid_internal(self.active_slice(), input_is_full_complete)
716    }
717
718    fn is_valid_internal(
719        &self,
720        composition: &[Transformation],
721        input_is_full_complete: bool,
722    ) -> bool {
723        crate::bamboo_util::is_valid(composition, input_is_full_complete)
724    }
725
726    /// Restores the last word in the composition to its un-transformed state.
727    ///
728    /// If `to_vietnamese` is true, it attempts to re-apply Vietnamese transformations.
729    pub fn restore_last_word(&mut self, to_vietnamese: bool) {
730        let mut work = self.work_comp;
731
732        self.take_active_into(&mut work);
733        if work.is_empty() {
734            self.set_active_from_stack(&mut work);
735            self.current_state_id = 0;
736            return;
737        }
738
739        let (prev_slice, last) =
740            crate::bamboo_util::extract_last_word(work.as_slice(), Some(&self.input_method.keys));
741
742        let mut previous = TransformationStack::new();
743        previous.extend_from_slice(prev_slice);
744
745        if last.is_empty() {
746            self.set_active_from_stack(&mut work);
747            self.current_state_id = 0;
748            return;
749        }
750        if !to_vietnamese {
751            previous.extend_from_slice(&crate::bamboo_util::break_composition_slice(last));
752            self.set_active_from_stack(&mut previous);
753            self.current_state_id = 0;
754            return;
755        }
756
757        let mut new_comp = TransformationStack::new();
758        if self.scratch_engine.is_none() {
759            self.scratch_engine =
760                Some(Box::new(Self::with_config(self.input_method.clone(), self.config)));
761        }
762        let temp_engine = self.scratch_engine.as_mut().unwrap();
763        temp_engine.reset();
764
765        for t in last {
766            if t.rule.key == '\0' {
767                continue;
768            }
769            temp_engine.process_key(t.rule.key, Mode::Vietnamese);
770        }
771        new_comp.extend_from_slice(temp_engine.active_slice());
772
773        previous.extend_from_slice(new_comp.as_slice());
774
775        self.set_active_from_stack(&mut previous);
776        self.current_state_id = 0;
777    }
778
779    /// Removes the last character from the active composition.
780    pub fn remove_last_char(&mut self, refresh_last_tone_target: bool) {
781        if self.pop_snapshot().is_none() {
782            return;
783        }
784
785        if refresh_last_tone_target && self.active_len > 0 {
786            let mut extra = TransformationStack::new();
787            crate::bamboo_util::refresh_last_tone_target_into(
788                &mut self.active_buffer[..self.active_len],
789                self.config.to_flags() & crate::bamboo_util::ESTD_TONE_STYLE != 0,
790                &mut extra,
791            );
792            for t in extra.as_slice() {
793                if self.active_len < MAX_ACTIVE_TRANS {
794                    self.active_buffer[self.active_len] = *t;
795                    self.active_len += 1;
796                }
797            }
798        }
799    }
800
801    /// Resets the engine state, clearing committed and active text.
802    pub fn reset(&mut self) {
803        self.committed_text.clear();
804        self.active_len = 0;
805        self.prev_preedit.clear();
806        self.delta_buf.clear();
807        self.current_state_id = 0;
808        self.snapshot_len = 0;
809    }
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815
816    #[test]
817    fn delta_backspaces_and_inserted() {
818        let telex = InputMethod::telex();
819        let mut e = Engine::new(telex);
820
821        let (bs1, _bb1, ins1) = e.process_key_delta('a', Mode::Vietnamese);
822        assert_eq!(bs1, 0, "First 'a' should have 0 backspaces");
823        assert_eq!(ins1, "a");
824
825        let (bs2, _bb2, ins2) = e.process_key_delta('s', Mode::Vietnamese);
826        assert_eq!(bs2, 1, "Adding 's' to 'a' should have 1 backspace for 'á'");
827        assert_eq!(ins2, "á");
828
829        let (bs3, _bb3, ins3) = e.process_key_delta(' ', Mode::Vietnamese);
830        assert_eq!(bs3, 1, "Space should clear the preedit 'á'");
831        assert_eq!(ins3, "");
832    }
833}