Skip to main content

lindera_binding_core/
tokenizer.rs

1//! Shared tokenizer build-flow orchestration for the bindings.
2//!
3//! Each binding's tokenizer wrapper reimplements the same flow: configure a
4//! [`lindera_analysis::tokenizer::TokenizerBuilder`], build a
5//! [`lindera_analysis::tokenizer::Tokenizer`], and convert the resulting tokens. This
6//! module collects that orchestration into [`CoreTokenizerBuilder`] and
7//! [`CoreTokenizer`], leaving each binding to do only its FFI-value conversion
8//! (`serde_json::Value` ⇔ the host language's argument type) and a thin wrapper.
9
10use std::path::Path;
11use std::str::FromStr;
12use std::sync::{Mutex, MutexGuard};
13
14use serde_json::Value;
15
16use lindera::dictionary::{Dictionary, UserDictionary};
17use lindera::mode::Mode;
18use lindera::segmenter::Segmenter;
19use lindera_analysis::tokenizer::{Tokenizer, TokenizerBuilder};
20use lindera_analysis::worker::AnalysisWorker;
21
22use crate::error::CoreResult;
23use crate::token::{SurfaceView, TokenView};
24
25/// Builder that orchestrates tokenizer configuration on behalf of the bindings.
26///
27/// Wraps [`lindera_analysis::tokenizer::TokenizerBuilder`]; filter arguments are passed
28/// as [`serde_json::Value`] so the FFI-specific value conversion stays in each
29/// binding.
30pub struct CoreTokenizerBuilder {
31    /// The backing lindera builder.
32    inner: TokenizerBuilder,
33}
34
35impl CoreTokenizerBuilder {
36    /// Creates a builder with the default (empty) configuration.
37    pub fn new() -> CoreResult<Self> {
38        Ok(Self {
39            inner: TokenizerBuilder::new()?,
40        })
41    }
42
43    /// Creates a builder from a YAML configuration file.
44    pub fn from_file(file_path: &Path) -> CoreResult<Self> {
45        Ok(Self {
46            inner: TokenizerBuilder::from_file(file_path)?,
47        })
48    }
49
50    /// Sets the segmenter mode from a string (`"normal"` or `"decompose"`).
51    pub fn set_mode(&mut self, mode: &str) -> CoreResult<&mut Self> {
52        let mode = Mode::from_str(mode)?;
53        self.inner.set_segmenter_mode(&mode);
54        Ok(self)
55    }
56
57    /// Sets the segmenter dictionary URI / path.
58    pub fn set_dictionary(&mut self, uri: &str) -> &mut Self {
59        self.inner.set_segmenter_dictionary(uri);
60        self
61    }
62
63    /// Sets the segmenter user-dictionary URI / path.
64    pub fn set_user_dictionary(&mut self, uri: &str) -> &mut Self {
65        self.inner.set_segmenter_user_dictionary(uri);
66        self
67    }
68
69    /// Sets whether whitespace tokens are kept in the output.
70    pub fn set_keep_whitespace(&mut self, keep_whitespace: bool) -> &mut Self {
71        self.inner.set_segmenter_keep_whitespace(keep_whitespace);
72        self
73    }
74
75    /// Appends a character filter identified by `kind` with JSON `args`.
76    pub fn append_character_filter(&mut self, kind: &str, args: &Value) -> &mut Self {
77        self.inner.append_character_filter(kind, args);
78        self
79    }
80
81    /// Appends a token filter identified by `kind` with JSON `args`.
82    pub fn append_token_filter(&mut self, kind: &str, args: &Value) -> &mut Self {
83        self.inner.append_token_filter(kind, args);
84        self
85    }
86
87    /// Builds a [`CoreTokenizer`] from the current configuration.
88    pub fn build(&self) -> CoreResult<CoreTokenizer> {
89        Ok(CoreTokenizer::from_tokenizer(self.inner.build()?))
90    }
91}
92
93/// Tokenizer that orchestrates tokenization on behalf of the bindings.
94///
95/// Internally holds a reusable [`AnalysisWorker`] behind a [`Mutex`], so
96/// every call reuses the Viterbi lattice and scratch buffers instead of
97/// reallocating them (bindings keep one long-lived `CoreTokenizer`
98/// instance, which makes this the natural reuse point). `Mutex` — rather
99/// than `RefCell` — keeps `CoreTokenizer: Send + Sync`, which the Python
100/// (pyo3) and Node.js (napi) class wrappers require. All binding runtimes
101/// call tokenize while effectively single-threaded (GIL / one JS thread /
102/// request scope), so the lock is uncontended in practice.
103///
104/// Returns owned [`TokenView`]s so the bindings never handle borrowed
105/// `lindera` tokens directly.
106pub struct CoreTokenizer {
107    /// The reusable analysis session (lattice + normalization buffer +
108    /// scratch), locked per call.
109    worker: Mutex<AnalysisWorker>,
110}
111
112/// Locks the worker mutex, recovering from poisoning.
113///
114/// A panic in a previous call may have left the worker's internal buffers
115/// in an inconsistent intermediate state, so recovery resets them (the
116/// dictionary and filter configuration are unaffected). Capacity built up
117/// so far is lost, which is acceptable on this exceptional path.
118///
119/// # 引数
120///
121/// * `mutex` - The worker mutex to lock.
122///
123/// # 戻り値
124///
125/// A guard for the (possibly reset) worker.
126fn lock_worker(mutex: &Mutex<AnalysisWorker>) -> MutexGuard<'_, AnalysisWorker> {
127    match mutex.lock() {
128        Ok(guard) => guard,
129        Err(poisoned) => {
130            let mut guard = poisoned.into_inner();
131            guard.reset();
132            guard
133        }
134    }
135}
136
137impl CoreTokenizer {
138    /// Builds a tokenizer from segmenter parts, parsing `mode` from a string.
139    pub fn from_segmenter(
140        mode: &str,
141        dictionary: Dictionary,
142        user_dictionary: Option<UserDictionary>,
143    ) -> CoreResult<Self> {
144        let mode = Mode::from_str(mode)?;
145        let segmenter = Segmenter::new(mode, dictionary, user_dictionary);
146        Ok(Self::from_tokenizer(Tokenizer::new(segmenter)))
147    }
148
149    /// Wraps an already-built lindera [`Tokenizer`], consuming it into the
150    /// internal reusable worker (no user-dictionary copy).
151    pub fn from_tokenizer(tokenizer: Tokenizer) -> Self {
152        Self {
153            worker: Mutex::new(tokenizer.into_worker()),
154        }
155    }
156
157    /// Tokenizes `text`, returning owned [`TokenView`]s.
158    ///
159    /// Reuses the internal worker's buffers across calls; output is
160    /// identical to tokenizing with a fresh lattice.
161    pub fn tokenize(&self, text: &str) -> CoreResult<Vec<TokenView>> {
162        let mut worker = lock_worker(&self.worker);
163        let tokens = worker.tokenize(text)?;
164        Ok(tokens.into_iter().map(TokenView::from_token).collect())
165    }
166
167    /// Tokenizes `text`, returning surface-only views ([`SurfaceView`])
168    /// without materializing any morphological details.
169    ///
170    /// This is the fast path for wakati-style callers: it skips the
171    /// per-token details loading and string materialization that
172    /// [`CoreTokenizer::tokenize`] pays (~14 allocations per IPADIC token
173    /// down to ~1), while reusing the internal worker's buffers like the
174    /// other methods. Token filters configured on the tokenizer still run
175    /// (some may load details internally); the saving on this path is the
176    /// FFI-side materialization.
177    pub fn tokenize_surfaces(&self, text: &str) -> CoreResult<Vec<SurfaceView>> {
178        let mut worker = lock_worker(&self.worker);
179        let tokens = worker.tokenize(text)?;
180        Ok(tokens.into_iter().map(SurfaceView::from_token).collect())
181    }
182
183    /// Tokenizes `text` and returns the N-best results as `(tokens, cost)` pairs.
184    ///
185    /// Reuses the internal worker's buffers across calls, like
186    /// [`CoreTokenizer::tokenize`].
187    pub fn tokenize_nbest(
188        &self,
189        text: &str,
190        n: usize,
191        unique: bool,
192        cost_threshold: Option<i64>,
193    ) -> CoreResult<Vec<(Vec<TokenView>, i64)>> {
194        let mut worker = lock_worker(&self.worker);
195        let results = worker.tokenize_nbest(text, n, unique, cost_threshold)?;
196        Ok(results
197            .into_iter()
198            .map(|(tokens, cost)| {
199                (
200                    tokens.into_iter().map(TokenView::from_token).collect(),
201                    cost,
202                )
203            })
204            .collect())
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn builder_new_succeeds() {
214        assert!(CoreTokenizerBuilder::new().is_ok());
215    }
216
217    #[test]
218    fn set_mode_accepts_known_modes() {
219        let mut builder = CoreTokenizerBuilder::new().expect("builder");
220        assert!(builder.set_mode("normal").is_ok());
221        assert!(builder.set_mode("decompose").is_ok());
222    }
223
224    #[test]
225    fn set_mode_rejects_unknown_mode() {
226        let mut builder = CoreTokenizerBuilder::new().expect("builder");
227        assert!(builder.set_mode("definitely-not-a-mode").is_err());
228    }
229
230    #[test]
231    fn infallible_setters_chain() {
232        let mut builder = CoreTokenizerBuilder::new().expect("builder");
233        builder
234            .set_dictionary("embedded://ipadic")
235            .set_keep_whitespace(true)
236            .append_token_filter("japanese_compound_word", &Value::Object(Default::default()));
237        // Reaching here means the borrow-returning setters compose.
238    }
239
240    /// The pyo3 (Python) and napi (Node.js) class wrappers require the
241    /// wrapped type to be `Send + Sync`; losing either auto-trait would be
242    /// a de-facto breaking change for every binding. The `Mutex` (rather
243    /// than `RefCell`) around the internal worker exists exactly to
244    /// preserve this.
245    #[test]
246    fn core_tokenizer_is_send_and_sync() {
247        fn assert_send_sync<T: Send + Sync>() {}
248        assert_send_sync::<CoreTokenizer>();
249        assert_send_sync::<CoreTokenizerBuilder>();
250    }
251
252    #[cfg(feature = "embed-ipadic")]
253    mod with_ipadic {
254        use super::super::*;
255
256        fn ipadic_tokenizer() -> CoreTokenizer {
257            let dictionary = match lindera::dictionary::load_dictionary("embedded://ipadic") {
258                Ok(dictionary) => dictionary,
259                Err(err) => panic!("failed to load embedded IPADIC: {err}"),
260            };
261            match CoreTokenizer::from_segmenter("normal", dictionary, None) {
262                Ok(tokenizer) => tokenizer,
263                Err(err) => panic!("failed to build CoreTokenizer: {err}"),
264            }
265        }
266
267        /// Repeated calls through the internal reused worker must keep
268        /// producing identical output (lattice-reuse regression gate).
269        #[test]
270        fn tokenize_repeated_calls_are_stable() {
271            let tokenizer = ipadic_tokenizer();
272            let first = match tokenizer.tokenize("すもももももももものうち") {
273                Ok(tokens) => tokens,
274                Err(err) => panic!("tokenize failed: {err}"),
275            };
276            assert!(!first.is_empty());
277            for _ in 0..100 {
278                let again = match tokenizer.tokenize("すもももももももものうち") {
279                    Ok(tokens) => tokens,
280                    Err(err) => panic!("tokenize failed: {err}"),
281                };
282                assert_eq!(first.len(), again.len());
283                for (a, b) in first.iter().zip(again.iter()) {
284                    assert_eq!(a.surface, b.surface);
285                    assert_eq!(a.byte_start, b.byte_start);
286                    assert_eq!(a.byte_end, b.byte_end);
287                    assert_eq!(a.details, b.details);
288                }
289            }
290        }
291
292        /// N-best calls reuse the same worker and must stay stable too.
293        #[test]
294        fn tokenize_nbest_repeated_calls_are_stable() {
295            let tokenizer = ipadic_tokenizer();
296            let first = match tokenizer.tokenize_nbest("すもももももももものうち", 3, false, None)
297            {
298                Ok(results) => results,
299                Err(err) => panic!("tokenize_nbest failed: {err}"),
300            };
301            assert!(!first.is_empty());
302            for _ in 0..10 {
303                let again =
304                    match tokenizer.tokenize_nbest("すもももももももものうち", 3, false, None)
305                    {
306                        Ok(results) => results,
307                        Err(err) => panic!("tokenize_nbest failed: {err}"),
308                    };
309                assert_eq!(first.len(), again.len());
310                for ((a_tokens, a_cost), (b_tokens, b_cost)) in first.iter().zip(again.iter()) {
311                    assert_eq!(a_cost, b_cost);
312                    assert_eq!(a_tokens.len(), b_tokens.len());
313                }
314            }
315        }
316
317        /// The surface-only fast path must yield exactly the surfaces and
318        /// byte offsets of the full tokenize path, for repeated calls.
319        #[test]
320        fn tokenize_surfaces_matches_tokenize() {
321            let tokenizer = ipadic_tokenizer();
322            let texts = [
323                "すもももももももものうち",
324                "関西国際空港限定トートバッグ",
325                "",
326            ];
327            for _ in 0..3 {
328                for text in texts {
329                    let full = match tokenizer.tokenize(text) {
330                        Ok(tokens) => tokens,
331                        Err(err) => panic!("tokenize failed: {err}"),
332                    };
333                    let surfaces = match tokenizer.tokenize_surfaces(text) {
334                        Ok(views) => views,
335                        Err(err) => panic!("tokenize_surfaces failed: {err}"),
336                    };
337                    assert_eq!(full.len(), surfaces.len(), "count for {text:?}");
338                    for (a, b) in full.iter().zip(surfaces.iter()) {
339                        assert_eq!(a.surface, b.surface);
340                        assert_eq!(a.byte_start, b.byte_start);
341                        assert_eq!(a.byte_end, b.byte_end);
342                    }
343                }
344            }
345        }
346
347        /// A panic while the worker lock is held must not wedge the
348        /// tokenizer: the next call recovers from the poisoned mutex with a
349        /// reset worker and produces correct output.
350        #[test]
351        fn tokenize_recovers_from_poisoned_lock() {
352            use std::sync::Arc;
353
354            let tokenizer = Arc::new(ipadic_tokenizer());
355            let expected = match tokenizer.tokenize("すもももももももものうち") {
356                Ok(tokens) => tokens,
357                Err(err) => panic!("tokenize failed: {err}"),
358            };
359
360            // Poison the mutex by panicking while holding the guard.
361            let poisoner = Arc::clone(&tokenizer);
362            let result = std::thread::spawn(move || {
363                let _guard = lock_worker(&poisoner.worker);
364                panic!("intentional panic to poison the worker lock");
365            })
366            .join();
367            assert!(result.is_err(), "poisoning thread must have panicked");
368            assert!(tokenizer.worker.is_poisoned(), "lock must be poisoned");
369
370            // The next call must recover and produce identical output.
371            let after = match tokenizer.tokenize("すもももももももものうち") {
372                Ok(tokens) => tokens,
373                Err(err) => panic!("tokenize after poison failed: {err}"),
374            };
375            assert_eq!(expected.len(), after.len());
376            for (a, b) in expected.iter().zip(after.iter()) {
377                assert_eq!(a.surface, b.surface);
378            }
379        }
380    }
381}