Skip to main content

golia_pinyin/
session.rs

1//! Per-input mutable state — accumulates the user's typing buffer and exposes
2//! candidates / commit semantics.
3//!
4//! Mirrors the [`inputx-wubi`](https://crates.io/crates/inputx-wubi) session
5//! surface so a composite engine (e.g. the Inputx IME's `inputx-core`) can
6//! dispatch between both engines uniformly.
7
8use crate::dict::PinyinDict;
9use crate::engine::PinyinEngine;
10use crate::fuzzy::FuzzyConfig;
11
12/// One typing session: holds the partial pinyin string the user has typed
13/// so far, exposes candidates, and commits on selection. Borrows the
14/// [`PinyinEngine`] for the dict + fuzzy config.
15pub struct Session<'e> {
16    engine: &'e PinyinEngine,
17    input: String,
18    /// Reused candidate buffer to avoid per-keystroke allocs.
19    cand_buf: Vec<String>,
20}
21
22impl<'e> Session<'e> {
23    /// Open a session against `engine`. The session holds a borrow for its
24    /// lifetime; the engine itself is `Send + Sync`-compatible (FST is
25    /// `'static`, fuzzy is `Copy`).
26    pub fn new(engine: &'e PinyinEngine) -> Self {
27        Self {
28            engine,
29            input: String::new(),
30            cand_buf: Vec::with_capacity(16),
31        }
32    }
33
34    /// Append one ASCII character to the input buffer. Non-ASCII or
35    /// non-letter characters are silently ignored — the IME shell is
36    /// responsible for filtering at the keyboard layer.
37    pub fn input_char(&mut self, c: char) {
38        if c.is_ascii_alphabetic() {
39            self.input.push(c.to_ascii_lowercase());
40        }
41    }
42
43    /// Drop the last input character, if any. Returns whether anything was
44    /// removed.
45    pub fn backspace(&mut self) -> bool {
46        self.input.pop().is_some()
47    }
48
49    /// Clear the entire input buffer (e.g., on Esc).
50    pub fn reset(&mut self) {
51        self.input.clear();
52    }
53
54    /// The raw input string typed so far.
55    pub fn input(&self) -> &str {
56        &self.input
57    }
58
59    /// Candidates for the current input, considering fuzzy expansion. Result
60    /// is borrowed from the session's reused buffer; subsequent calls
61    /// invalidate the previous slice.
62    ///
63    /// Empty input yields an empty slice.
64    pub fn candidates(&mut self) -> &[String] {
65        if self.input.is_empty() {
66            self.cand_buf.clear();
67            return &self.cand_buf;
68        }
69        let input = self.input.clone();
70        Self::lookup_with_fuzzy(self.engine, &input, &mut self.cand_buf);
71        &self.cand_buf
72    }
73
74    /// Same as [`Self::candidates`] but writes into a caller-owned buffer.
75    /// Useful for FFI callers that own the buffer's lifetime independently
76    /// of the session.
77    pub fn lookup_into(&self, out: &mut Vec<String>) {
78        if self.input.is_empty() {
79            out.clear();
80            return;
81        }
82        Self::lookup_with_fuzzy(self.engine, &self.input, out);
83    }
84
85    fn lookup_with_fuzzy(engine: &PinyinEngine, input: &str, out: &mut Vec<String>) {
86        out.clear();
87        let dict = engine.dict();
88        let fuzzy = engine.fuzzy();
89        // For v0.1: fuzzy applies to the WHOLE input (treating it as one
90        // syllable). v0.2 will integrate the segmenter so fuzzy works
91        // per-syllable in multi-syllable inputs. The current behavior is
92        // correct for short/single-syllable inputs and a no-op when fuzzy
93        // is strict — which is the default.
94        let alternates = expand_full_input(fuzzy, input);
95        let mut local = Vec::with_capacity(8);
96        for variant in alternates {
97            dict.lookup_into(&variant, &mut local);
98            for w in local.drain(..) {
99                if !out.contains(&w) {
100                    out.push(w);
101                }
102            }
103        }
104    }
105
106    /// Commit the candidate at index `idx`, returning the committed word and
107    /// resetting the input buffer. Out-of-range indices yield `None` and
108    /// leave the session untouched.
109    ///
110    /// Side effect: records the pick in the engine's L0 layer (item 28).
111    /// Three consecutive picks of the same `(input, word)` auto-pin it to
112    /// position 0 for that input. Pinyin v0.2 ignores the fuzzy expansion
113    /// for L0 attribution — record uses the literal user input string,
114    /// matching what `dict.exists_in_l1` will accept.
115    pub fn commit(&mut self, idx: usize) -> Option<String> {
116        let cands = self.candidates();
117        let word = cands.get(idx).cloned()?;
118        // Record before clearing so we still have the input string.
119        self.engine.dict().record_pick(&self.input, &word);
120        self.input.clear();
121        self.cand_buf.clear();
122        Some(word)
123    }
124}
125
126fn expand_full_input(fuzzy: FuzzyConfig, input: &str) -> Vec<String> {
127    // Skip the wrapper Vec when fuzzy is fully strict — the common case.
128    if matches!(
129        fuzzy,
130        FuzzyConfig {
131            z_zh: false,
132            c_ch: false,
133            s_sh: false,
134            n_l: false,
135            f_h: false,
136            r_l: false,
137            in_ing: false,
138            en_eng: false,
139            an_ang: false
140        }
141    ) {
142        return vec![input.to_string()];
143    }
144    fuzzy.expand(input)
145}
146
147// Internal helper for tests that want a quick `lookup` without managing the
148// session lifecycle.
149#[allow(dead_code)]
150fn quick_lookup(dict: &PinyinDict, pinyin: &str) -> Vec<String> {
151    dict.lookup(pinyin)
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn input_and_lookup_zhongguo() {
160        let engine = PinyinEngine::new();
161        let mut session = Session::new(&engine);
162        for c in "zhongguo".chars() {
163            session.input_char(c);
164        }
165        let cands = session.candidates();
166        assert_eq!(cands.first().map(String::as_str), Some("中国"));
167    }
168
169    #[test]
170    fn backspace_shrinks_input() {
171        let engine = PinyinEngine::new();
172        let mut session = Session::new(&engine);
173        for c in "abc".chars() {
174            session.input_char(c);
175        }
176        assert_eq!(session.input(), "abc");
177        assert!(session.backspace());
178        assert_eq!(session.input(), "ab");
179        assert!(session.backspace());
180        assert!(session.backspace());
181        assert!(!session.backspace()); // empty now
182    }
183
184    #[test]
185    fn commit_returns_word_and_resets() {
186        let engine = PinyinEngine::new();
187        let mut session = Session::new(&engine);
188        for c in "wo".chars() {
189            session.input_char(c);
190        }
191        let committed = session.commit(0);
192        assert_eq!(committed.as_deref(), Some("我"));
193        assert_eq!(session.input(), "");
194        assert!(session.candidates().is_empty());
195    }
196
197    #[test]
198    fn commit_out_of_range_is_noop() {
199        let engine = PinyinEngine::new();
200        let mut session = Session::new(&engine);
201        for c in "wo".chars() {
202            session.input_char(c);
203        }
204        // Use clearly-out-of-range index — full v0.2 dict has 100+ candidates
205        // for some single-syllable inputs, so the bootstrap-era `99` was no
206        // longer "obviously past the end".
207        assert!(session.commit(999_999).is_none());
208        assert_eq!(session.input(), "wo");
209    }
210
211    #[test]
212    fn input_char_filters_non_ascii() {
213        let engine = PinyinEngine::new();
214        let mut session = Session::new(&engine);
215        session.input_char('z');
216        session.input_char('中'); // ignored
217        session.input_char('h');
218        assert_eq!(session.input(), "zh");
219    }
220
221    #[test]
222    fn fuzzy_expands_lookup() {
223        // With z↔zh fuzzy on, typing `zong` should also match `zhong`.
224        let engine = PinyinEngine::with_fuzzy(FuzzyConfig {
225            z_zh: true,
226            ..FuzzyConfig::default()
227        });
228        let mut session = Session::new(&engine);
229        for c in "zong".chars() {
230            session.input_char(c);
231        }
232        // `zong` itself isn't in bootstrap; expansion picks up `zhong` instead.
233        let cands = session.candidates();
234        assert!(
235            cands.iter().any(|w| w == "中"),
236            "expected fuzzy z→zh to find 中, got {cands:?}"
237        );
238    }
239
240    #[test]
241    fn empty_input_no_candidates() {
242        let engine = PinyinEngine::new();
243        let mut session = Session::new(&engine);
244        assert!(session.candidates().is_empty());
245    }
246
247    #[test]
248    fn reset_clears_input() {
249        let engine = PinyinEngine::new();
250        let mut session = Session::new(&engine);
251        for c in "abc".chars() {
252            session.input_char(c);
253        }
254        session.reset();
255        assert_eq!(session.input(), "");
256    }
257
258    /// Item 28 — committing through Session feeds the engine's L0. After
259    /// PROMOTE_THRESHOLD repeats, the picked candidate is auto-pinned.
260    /// Gated to default features: bootstrap dict has too few candidates
261    /// per input to make pin-vs-default observable.
262    #[cfg(not(feature = "bootstrap_only"))]
263    #[test]
264    fn commit_feeds_l0_pin_promotion() {
265        use crate::ranking::PROMOTE_THRESHOLD;
266        let engine = PinyinEngine::new();
267
268        // Pick a non-default candidate for "shi" via a fresh session each time.
269        // 时 isn't first in v0.2 (是 dominates), so promoting it via picks
270        // visibly changes the lookup ordering.
271        let target = "时";
272        for _ in 0..PROMOTE_THRESHOLD {
273            let mut s = Session::new(&engine);
274            for c in "shi".chars() {
275                s.input_char(c);
276            }
277            let cands = s.candidates();
278            let idx = cands
279                .iter()
280                .position(|w| w == target)
281                .expect("时 should be a 'shi' candidate");
282            assert_eq!(s.commit(idx).as_deref(), Some(target));
283        }
284
285        // After threshold picks, 时 should now be at position 0 for "shi".
286        let mut probe = Session::new(&engine);
287        for c in "shi".chars() {
288            probe.input_char(c);
289        }
290        assert_eq!(
291            probe.candidates().first().map(String::as_str),
292            Some(target),
293            "expected L0 to pin 时 after {PROMOTE_THRESHOLD} picks via Session::commit"
294        );
295    }
296}