Skip to main content

lindera_analysis/character_filter/
mapping.rs

1use std::collections::HashMap;
2
3use daachorse::DoubleArrayAhoCorasick;
4use daachorse::DoubleArrayAhoCorasickBuilder;
5use daachorse::MatchKind;
6use serde_json::Value;
7
8use crate::character_filter::{CharacterFilter, OffsetMapping, Transformation};
9use lindera::LinderaResult;
10use lindera::error::LinderaErrorKind;
11
12pub const MAPPING_CHARACTER_FILTER_NAME: &str = "mapping";
13
14pub type MappingCharacterFilterConfig = Value;
15
16#[derive(Clone)]
17pub struct MappingCharacterFilter {
18    mapping: HashMap<String, String>,
19    trie: DoubleArrayAhoCorasick<u32>,
20}
21
22impl MappingCharacterFilter {
23    /// Create a new `MappingCharacterFilter` from a surface-to-replacement mapping.
24    ///
25    /// # Arguments
26    ///
27    /// * `mapping` - A map from surface text to its replacement text. Keys must be
28    ///   non-empty; an empty key would match at every byte position under the
29    ///   leftmost-longest search strategy used by `apply`, which is never a
30    ///   meaningful mapping and is therefore rejected.
31    ///
32    /// # Returns
33    ///
34    /// A `MappingCharacterFilter`, or an error if `mapping` contains an empty key or
35    /// the underlying Aho-Corasick automaton fails to build.
36    pub fn new(mapping: HashMap<String, String>) -> LinderaResult<Self> {
37        if mapping.keys().any(|key| key.is_empty()) {
38            return Err(LinderaErrorKind::Args
39                .with_error(anyhow::anyhow!("mapping key must not be empty.")));
40        }
41
42        let mut keyset: Vec<(&[u8], u32)> = Vec::new();
43        let mut keys = mapping.keys().collect::<Vec<_>>();
44        keys.sort();
45        for (value, key) in keys.into_iter().enumerate() {
46            keyset.push((key.as_bytes(), value as u32));
47        }
48
49        let trie = DoubleArrayAhoCorasickBuilder::new()
50            .match_kind(MatchKind::LeftmostLongest)
51            .build_with_values(keyset)
52            .map_err(|err| LinderaErrorKind::Build.with_error(anyhow::anyhow!(err)))?;
53
54        Ok(Self { mapping, trie })
55    }
56
57    pub fn from_config(config: &MappingCharacterFilterConfig) -> LinderaResult<Self> {
58        let mapping = config
59            .get("mapping")
60            .and_then(Value::as_object)
61            .ok_or_else(|| {
62                LinderaErrorKind::Parse.with_error(anyhow::anyhow!("mapping must be an object."))
63            })?
64            .iter()
65            .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
66            .collect::<HashMap<String, String>>();
67
68        Self::new(mapping)
69    }
70}
71
72impl CharacterFilter for MappingCharacterFilter {
73    fn name(&self) -> &'static str {
74        MAPPING_CHARACTER_FILTER_NAME
75    }
76
77    /// Apply the filter using the `OffsetMapping` API.
78    ///
79    /// Performs a single leftmost-longest pass over `text` with the underlying
80    /// Aho-Corasick automaton: at each position the longest configured key wins,
81    /// matches never overlap, and scanning resumes immediately after each match
82    /// (mirroring `docs/src/concepts/filters.md`'s documented "longest-match
83    /// search" semantics for this filter).
84    ///
85    /// # Arguments
86    ///
87    /// * `text` - The text to filter, replaced in place with the mapped text.
88    ///
89    /// # Returns
90    ///
91    /// An `OffsetMapping` recording one `Transformation` per replacement whose
92    /// byte length differs from the original (length-preserving replacements are
93    /// not recorded), in ascending filtered-offset order.
94    fn apply(&self, text: &mut String) -> LinderaResult<OffsetMapping> {
95        let mut filtered_text = String::with_capacity(text.len());
96        let mut mapping = OffsetMapping::new();
97
98        {
99            let source = text.as_str();
100            let mut cursor = 0_usize;
101
102            for m in self.trie.leftmost_find_iter(source) {
103                // Keys are validated non-empty in `new`, and all keys are valid
104                // UTF-8, so matches are non-empty, non-overlapping, strictly
105                // ascending, and always land on char boundaries.
106                debug_assert!(m.start() >= cursor && m.end() > m.start());
107
108                // Copy the unmatched gap before this match verbatim.
109                filtered_text.push_str(&source[cursor..m.start()]);
110
111                let input_start = m.start();
112                let input_len = m.end() - m.start();
113                let replacement_text = &self.mapping[&source[m.start()..m.end()]];
114                let replacement_len = replacement_text.len();
115
116                // Record transformation if text changed
117                if input_len != replacement_len {
118                    let transformation = Transformation::new(
119                        input_start,
120                        input_start + input_len,
121                        filtered_text.len(),
122                        filtered_text.len() + replacement_len,
123                    );
124                    mapping.add_transformation(transformation);
125                }
126
127                filtered_text.push_str(replacement_text);
128                cursor = m.end();
129            }
130
131            // Copy the trailing unmatched tail.
132            filtered_text.push_str(&source[cursor..]);
133        }
134
135        *text = filtered_text;
136        Ok(mapping)
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use std::collections::HashMap;
143
144    use crate::character_filter::mapping::{MappingCharacterFilter, MappingCharacterFilterConfig};
145    use crate::character_filter::{CharacterFilter, Transformation};
146
147    #[test]
148    fn test_mapping_character_filter_config() {
149        let config_str = r#"
150        {
151            "mapping": {
152                "ア": "ア",
153                "イ": "イ",
154                "ウ": "ウ",
155                "エ": "エ",
156                "オ": "オ"
157            }
158        }
159        "#;
160        let result: Result<MappingCharacterFilterConfig, _> = serde_json::from_str(config_str);
161        assert!(result.is_ok());
162    }
163
164    #[test]
165    fn test_mapping_character_filter_from_config() {
166        let config_str = r#"
167        {
168            "mapping": {
169                "ア": "ア",
170                "イ": "イ",
171                "ウ": "ウ",
172                "エ": "エ",
173                "オ": "オ"
174            }
175        }
176        "#;
177        let config = serde_json::from_str::<MappingCharacterFilterConfig>(config_str).unwrap();
178
179        let result = MappingCharacterFilter::from_config(&config);
180        assert!(result.is_ok());
181    }
182
183    #[test]
184    fn test_mapping_character_filter_apply() {
185        {
186            let config_str = r#"
187            {
188                "mapping": {
189                    "ア": "ア",
190                    "イ": "イ",
191                    "ウ": "ウ",
192                    "エ": "エ",
193                    "オ": "オ"
194                }
195            }
196            "#;
197            let config = serde_json::from_str::<MappingCharacterFilterConfig>(config_str).unwrap();
198
199            let filter = MappingCharacterFilter::from_config(&config).unwrap();
200
201            let original_text = "アイウエオ";
202            let mut text = original_text.to_string();
203            let mapping = filter.apply(&mut text).unwrap();
204            assert_eq!("アイウエオ", text.as_str());
205            assert!(mapping.is_empty());
206
207            // Test text fragments
208            let start = 3;
209            let end = 6;
210            assert_eq!("イ", &text[start..end]);
211            let correct_start = mapping.correct_offset(start, text.len());
212            let correct_end = mapping.correct_offset(end, text.len());
213            assert_eq!(3, correct_start);
214            assert_eq!(6, correct_end);
215            assert_eq!("イ", &original_text[correct_start..correct_end]);
216        }
217
218        {
219            let config_str = r#"
220            {
221                "mapping": {
222                    "リ": "リ",
223                    "ン": "ン",
224                    "デ": "デ",
225                    "ラ": "ラ"
226                }
227            }
228            "#;
229            let config = serde_json::from_str::<MappingCharacterFilterConfig>(config_str).unwrap();
230
231            let filter = MappingCharacterFilter::from_config(&config).unwrap();
232            let original_text = "リンデラ";
233            let mut text = original_text.to_string();
234            let mapping = filter.apply(&mut text).unwrap();
235            assert_eq!("リンデラ", text.as_str());
236
237            // Verify transformation: "デ"(6-12) → "デ"(6-9)
238            assert_eq!(1, mapping.transformations.len());
239            let transform = &mapping.transformations[0];
240            assert_eq!(6, transform.original_start);
241            assert_eq!(12, transform.original_end);
242            assert_eq!(6, transform.filtered_start);
243            assert_eq!(9, transform.filtered_end);
244
245            // Test text fragments
246            let start = 6;
247            let end = 9;
248            assert_eq!("デ", &text[start..end]);
249            let correct_start = mapping.correct_offset(start, text.len());
250            let correct_end = mapping.correct_offset(end, text.len());
251            assert_eq!(6, correct_start);
252            assert_eq!(12, correct_end);
253            assert_eq!("デ", &original_text[correct_start..correct_end]);
254        }
255
256        {
257            let config_str = r#"
258            {
259                "mapping": {
260                    "リンデラ": "リンデラ"
261                }
262            }
263            "#;
264            let config = serde_json::from_str::<MappingCharacterFilterConfig>(config_str).unwrap();
265
266            let filter = MappingCharacterFilter::from_config(&config).unwrap();
267            let original_text = "リンデラ";
268            let mut text = original_text.to_string();
269            let mapping = filter.apply(&mut text).unwrap();
270            assert_eq!("リンデラ", text.as_str());
271
272            // Verify transformation: "リンデラ"(0-15) → "リンデラ"(0-12)
273            assert_eq!(1, mapping.transformations.len());
274            let transform = &mapping.transformations[0];
275            assert_eq!(0, transform.original_start);
276            assert_eq!(15, transform.original_end);
277            assert_eq!(0, transform.filtered_start);
278            assert_eq!(12, transform.filtered_end);
279
280            // Test text fragments
281            let start = 0;
282            let end = 12;
283            assert_eq!("リンデラ", &text[start..end]);
284            let correct_start = mapping.correct_offset(start, text.len());
285            let correct_end = mapping.correct_offset(end, text.len());
286            assert_eq!(0, correct_start);
287            assert_eq!(15, correct_end);
288            assert_eq!("リンデラ", &original_text[correct_start..correct_end]);
289        }
290
291        {
292            let config_str = r#"
293            {
294                "mapping": {
295                    "リンデラ": "Lindera"
296                }
297            }
298            "#;
299            let config = serde_json::from_str::<MappingCharacterFilterConfig>(config_str).unwrap();
300
301            let filter = MappingCharacterFilter::from_config(&config).unwrap();
302            let original_text = "Rust製形態素解析器リンデラで日本語を形態素解析する。";
303            let mut text = original_text.to_string();
304            let mapping = filter.apply(&mut text).unwrap();
305            assert_eq!(
306                "Rust製形態素解析器Linderaで日本語を形態素解析する。",
307                text.as_str()
308            );
309
310            // Verify transformation: "リンデラ"(25-37) → "Lindera"(25-32)
311            assert_eq!(1, mapping.transformations.len());
312            let transform = &mapping.transformations[0];
313            assert_eq!(25, transform.original_start);
314            assert_eq!(37, transform.original_end);
315            assert_eq!(25, transform.filtered_start);
316            assert_eq!(32, transform.filtered_end);
317
318            // Test text fragments
319            let start = 25;
320            let end = 32;
321            assert_eq!("Lindera", &text[start..end]);
322            let correct_start = mapping.correct_offset(start, text.len());
323            let correct_end = mapping.correct_offset(end, text.len());
324            assert_eq!(25, correct_start);
325            assert_eq!(37, correct_end);
326            assert_eq!("リンデラ", &original_text[correct_start..correct_end]);
327
328            let start = 35;
329            let end = 44;
330            assert_eq!("日本語", &text[start..end]);
331            let correct_start = mapping.correct_offset(start, text.len());
332            let correct_end = mapping.correct_offset(end, text.len());
333            assert_eq!(40, correct_start);
334            assert_eq!(49, correct_end);
335            assert_eq!("日本語", &original_text[correct_start..correct_end]);
336        }
337
338        {
339            let config_str = r#"
340            {
341                "mapping": {
342                    "1": "1",
343                    "0": "0",
344                    "㍑": "リットル"
345                }
346            }
347            "#;
348            let config = serde_json::from_str(config_str).unwrap();
349
350            let filter = MappingCharacterFilter::from_config(&config).unwrap();
351            let original_text = "10㍑";
352            let mut text = original_text.to_string();
353            let mapping = filter.apply(&mut text).unwrap();
354            assert_eq!("10リットル", text.as_str());
355
356            // All three replacements are recorded because of byte length differences
357            assert_eq!(3, mapping.transformations.len());
358
359            // Verify the last transformation: "㍑"(6-9) → "リットル"(2-14)
360            let transform = &mapping.transformations[2];
361            assert_eq!(6, transform.original_start);
362            assert_eq!(9, transform.original_end);
363            assert_eq!(2, transform.filtered_start);
364            assert_eq!(14, transform.filtered_end);
365
366            // Test text fragments
367            let start = 2;
368            let end = 14;
369            assert_eq!("リットル", &text[start..end]);
370            let correct_start = mapping.correct_offset(start, text.len());
371            let correct_end = mapping.correct_offset(end, text.len());
372            assert_eq!(6, correct_start);
373            assert_eq!(9, correct_end);
374            assert_eq!("㍑", &original_text[correct_start..correct_end]);
375        }
376    }
377
378    #[test]
379    fn test_mapping_character_filter_apply_longest_match() {
380        let mut mapping = HashMap::new();
381        mapping.insert("ab".to_string(), "X".to_string());
382        mapping.insert("abc".to_string(), "YY".to_string());
383        mapping.insert("b".to_string(), "Z".to_string());
384        let filter = MappingCharacterFilter::new(mapping).unwrap();
385
386        let mut text = "abcabx".to_string();
387        let mapping = filter.apply(&mut text).unwrap();
388        assert_eq!("YYXx", text.as_str());
389
390        // "abc" (longest match at 0) wins over "ab"/"b"; "ab" (longest at 3) wins over "b".
391        assert_eq!(2, mapping.transformations.len());
392        assert_eq!(Transformation::new(0, 3, 0, 2), mapping.transformations[0]);
393        assert_eq!(Transformation::new(3, 5, 2, 3), mapping.transformations[1]);
394    }
395
396    #[test]
397    fn test_mapping_character_filter_apply_backtrack() {
398        // "abcd" fails to match "abx", but the shorter key "b" hiding inside the
399        // failed prefix must still be found via the automaton's fail links.
400        let mut mapping = HashMap::new();
401        mapping.insert("abcd".to_string(), "1".to_string());
402        mapping.insert("b".to_string(), "22".to_string());
403        let filter = MappingCharacterFilter::new(mapping).unwrap();
404
405        let mut text = "abx".to_string();
406        let mapping = filter.apply(&mut text).unwrap();
407        assert_eq!("a22x", text.as_str());
408        assert_eq!(1, mapping.transformations.len());
409        assert_eq!(Transformation::new(1, 2, 1, 3), mapping.transformations[0]);
410    }
411
412    #[test]
413    fn test_mapping_character_filter_apply_leftmost_wins() {
414        // The long leftmost match consumes "h", so the overlapping key "hz"
415        // starting inside it must not fire.
416        let mut mapping = HashMap::new();
417        mapping.insert("abcdefgh".to_string(), "1".to_string());
418        mapping.insert("hz".to_string(), "2".to_string());
419        let filter = MappingCharacterFilter::new(mapping).unwrap();
420
421        let mut text = "abcdefghz".to_string();
422        let mapping = filter.apply(&mut text).unwrap();
423        assert_eq!("1z", text.as_str());
424        assert_eq!(1, mapping.transformations.len());
425    }
426
427    #[test]
428    fn test_mapping_character_filter_apply_shared_prefix() {
429        // "デ" and "ラ" share the "EF BE" lead byte pair; this exercises fail-link
430        // recovery mid-multibyte-character and proves the gap copy never slices
431        // across a char boundary.
432        let mut mapping = HashMap::new();
433        mapping.insert("デ".to_string(), "デ".to_string());
434        mapping.insert("ラ".to_string(), "ラ".to_string());
435        let filter = MappingCharacterFilter::new(mapping).unwrap();
436
437        let mut text = "テラ".to_string();
438        let mapping = filter.apply(&mut text).unwrap();
439        assert_eq!("テラ", text.as_str());
440        // "ラ" -> "ラ" is a same-byte-length substitution (3 -> 3 bytes).
441        assert!(mapping.is_empty());
442    }
443
444    #[test]
445    fn test_mapping_character_filter_empty_key_rejected() {
446        let mut mapping = HashMap::new();
447        mapping.insert(String::new(), "x".to_string());
448        assert!(MappingCharacterFilter::new(mapping).is_err());
449
450        let mut mapping = HashMap::new();
451        mapping.insert("a".to_string(), "b".to_string());
452        assert!(MappingCharacterFilter::new(mapping).is_ok());
453    }
454
455    #[test]
456    fn test_mapping_character_filter_apply_large_input() {
457        let mut mapping = HashMap::new();
458        mapping.insert("リンデラ".to_string(), "Lindera".to_string());
459        let filter = MappingCharacterFilter::new(mapping).unwrap();
460
461        // Large, entirely non-matching input: the worst case for the previous
462        // O(n^2) implementation. A generous absolute wall-clock ceiling is used
463        // instead of a two-point scaling ratio, since a linear implementation
464        // finishes in low single-digit milliseconds here (leaving a huge margin)
465        // while the previous quadratic implementation would take several seconds
466        // at this size, making the ceiling a reliable regression guard without
467        // being sensitive to CI timing noise.
468        let mut text = "あ".repeat(100_000);
469        let original_len = text.len();
470
471        let start = std::time::Instant::now();
472        let mapping = filter.apply(&mut text).unwrap();
473        let elapsed = start.elapsed();
474
475        assert_eq!(original_len, text.len());
476        assert!(mapping.is_empty());
477        assert!(
478            elapsed.as_secs() < 3,
479            "apply() took too long ({elapsed:?}); the quadratic-scan regression may have returned"
480        );
481    }
482}