Skip to main content

yuru_ja/
lib.rs

1//! Japanese phonetic matching backend for Yuru.
2//!
3//! The backend adds kana and romaji reading keys for Japanese candidates and
4//! keeps source spans so matches in generated readings can highlight the
5//! original display text.
6
7mod numeric;
8/// Kanji reading helpers backed by Lindera.
9pub mod reading;
10/// Romaji and kana conversion helpers.
11pub mod romaji;
12
13use yuru_core::{
14    base_query_variants, normalize,
15    normalize::{contains_kana, katakana_to_hiragana},
16    KeyBudget, LangMode, LanguageBackend, QueryBudget, QueryVariant, SearchKey, SourceSpan,
17};
18
19const ROMAJI_TO_KANA_FANOUT_LIMIT: usize = 16;
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22/// Controls whether Japanese kanji readings are generated.
23pub enum JapaneseReadingMode {
24    /// Do not generate kanji reading keys.
25    None,
26    /// Generate kanji reading keys with Lindera.
27    Lindera,
28}
29
30#[derive(Clone, Debug)]
31/// Japanese language backend for kana, romaji, and optional kanji readings.
32pub struct JapaneseBackend {
33    reading: JapaneseReadingMode,
34}
35
36impl JapaneseBackend {
37    /// Creates a Japanese backend with the selected reading mode.
38    pub fn new(reading: JapaneseReadingMode) -> Self {
39        Self { reading }
40    }
41}
42
43impl Default for JapaneseBackend {
44    fn default() -> Self {
45        Self {
46            reading: JapaneseReadingMode::Lindera,
47        }
48    }
49}
50
51impl LanguageBackend for JapaneseBackend {
52    fn mode(&self) -> LangMode {
53        LangMode::Japanese
54    }
55
56    fn build_candidate_keys(&self, text: &str, budget: KeyBudget) -> Vec<SearchKey> {
57        if budget.max_keys == 0 || text.len() > budget.max_total_bytes {
58            return Vec::new();
59        }
60        let mut keys = Vec::new();
61
62        if contains_kana(text) {
63            let (hira, source_map) = hiragana_with_source_map(text);
64            push_reading_keys_with_map(&mut keys, &hira, &source_map);
65        }
66        if self.reading != JapaneseReadingMode::None {
67            for reading in reading::kanji_reading_candidates_with_sources_with_budget(
68                text,
69                budget.max_keys,
70                budget.max_total_bytes,
71            ) {
72                let (hira, source_map) =
73                    katakana_to_hiragana_with_source_map(&reading.text, &reading.source_map);
74                push_reading_keys_with_map(&mut keys, &hira, &source_map);
75                if keys.len() >= budget.max_keys {
76                    break;
77                }
78            }
79        }
80
81        keys
82    }
83
84    fn expand_query(&self, query: &str, budget: QueryBudget) -> Vec<QueryVariant> {
85        let mut variants = base_query_variants(query);
86        let romaji_limit = budget.max_variants.max(ROMAJI_TO_KANA_FANOUT_LIMIT);
87        let normalized = normalize::normalize(query);
88        if contains_kana(&normalized) {
89            variants.push(QueryVariant::kana(katakana_to_hiragana(&normalized)));
90        }
91        if let Some(numeric_romaji) = numeric::numeric_romaji_query(query) {
92            for kana in romaji::romaji_to_kana_candidates(&numeric_romaji, romaji_limit) {
93                variants.push(QueryVariant::romaji_to_kana(kana));
94            }
95        }
96        for kana in romaji::romaji_to_kana_candidates(query, romaji_limit) {
97            variants.push(QueryVariant::romaji_to_kana(kana));
98        }
99        variants
100    }
101}
102
103fn hiragana_with_source_map(text: &str) -> (String, Vec<Option<SourceSpan>>) {
104    let mut out = String::new();
105    let mut source_map = Vec::new();
106
107    for (char_index, ch) in text.chars().enumerate() {
108        let normalized = normalize::normalize(&ch.to_string());
109        let hira = katakana_to_hiragana(&normalized);
110        let source = Some(SourceSpan {
111            start_char: char_index,
112            end_char: char_index + 1,
113        });
114        out.push_str(&hira);
115        source_map.extend(hira.chars().map(|_| source));
116    }
117
118    (out, source_map)
119}
120
121fn katakana_to_hiragana_with_source_map(
122    text: &str,
123    source_map: &[Option<SourceSpan>],
124) -> (String, Vec<Option<SourceSpan>>) {
125    let mut out = String::new();
126    let mut out_map = Vec::new();
127
128    for (index, ch) in text.chars().enumerate() {
129        let normalized = normalize::normalize(&ch.to_string());
130        let hira = katakana_to_hiragana(&normalized);
131        let source = source_map.get(index).copied().flatten();
132        out.push_str(&hira);
133        out_map.extend(hira.chars().map(|_| source));
134    }
135
136    (out, out_map)
137}
138
139fn push_reading_keys_with_map(
140    keys: &mut Vec<SearchKey>,
141    hira: &str,
142    source_map: &[Option<SourceSpan>],
143) {
144    keys.push(SearchKey::kana_reading(hira.to_string()).with_source_map(source_map.to_vec()));
145    let (romaji, romaji_map) = romaji::kana_to_romaji_with_source_map(hira, source_map);
146    if romaji != hira {
147        keys.push(SearchKey::romaji_reading(romaji).with_source_map(romaji_map));
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use yuru_core::{build_candidate, KeyKind, SearchConfig};
154
155    use super::*;
156
157    #[test]
158    fn japanese_mode_does_not_build_pinyin_keys() {
159        let backend = JapaneseBackend::default();
160        let cand = build_candidate(0, "東京駅", &backend, &SearchConfig::default());
161        assert!(!cand
162            .keys
163            .iter()
164            .any(|k| matches!(k.kind, KeyKind::PinyinFull | KeyKind::PinyinJoined)));
165    }
166
167    #[test]
168    fn japanese_mode_builds_kana_keys_for_katakana() {
169        let backend = JapaneseBackend::default();
170        let cand = build_candidate(0, "カメラ.txt", &backend, &SearchConfig::default());
171        assert!(cand
172            .keys
173            .iter()
174            .any(|k| k.kind == KeyKind::KanaReading && k.text.contains("かめら")));
175        let key = cand
176            .keys
177            .iter()
178            .find(|k| k.kind == KeyKind::RomajiReading && k.text.contains("kamera"))
179            .unwrap();
180        let map = key.source_map.as_ref().unwrap();
181        assert_eq!(
182            map[0],
183            Some(SourceSpan {
184                start_char: 0,
185                end_char: 1
186            })
187        );
188        assert_eq!(
189            map[2],
190            Some(SourceSpan {
191                start_char: 1,
192                end_char: 2
193            })
194        );
195    }
196
197    #[test]
198    fn japanese_mode_builds_lindera_reading_keys_for_common_kanji() {
199        let cand = build_candidate(
200            0,
201            "tests/日本語.txt",
202            &JapaneseBackend::default(),
203            &SearchConfig::default(),
204        );
205
206        assert!(cand
207            .keys
208            .iter()
209            .any(|k| k.kind == KeyKind::KanaReading && k.text.contains("にほんご")));
210        assert!(cand
211            .keys
212            .iter()
213            .any(|k| k.kind == KeyKind::RomajiReading && k.text.contains("nihongo")));
214    }
215
216    #[test]
217    fn japanese_mode_folds_prolonged_sound_in_lindera_reading_keys() {
218        let cand = build_candidate(
219            0,
220            "2025年8月 ハッピースマイル写真展示室コード.pdf",
221            &JapaneseBackend::default(),
222            &SearchConfig::default(),
223        );
224
225        assert!(cand.keys.iter().any(|key| {
226            key.kind == KeyKind::KanaReading && key.text.contains("はっぴ-すまいるしゃしんてんじ")
227        }));
228    }
229
230    #[test]
231    fn japanese_mode_uses_numeric_context_for_date_reading_keys() {
232        let cand = build_candidate(
233            0,
234            "2025年8月 ハッピースマイル写真展示室コード.pdf",
235            &JapaneseBackend::default(),
236            &SearchConfig::default(),
237        );
238
239        assert!(cand.keys.iter().any(|key| {
240            key.kind == KeyKind::RomajiReading
241                && key.text.contains("nisennijuugonenhachigatsu")
242                && key.text.contains("happi-sumairu")
243        }));
244        assert!(cand.keys.iter().any(|key| {
245            key.kind == KeyKind::RomajiReading
246                && key.text.contains("2025nen8gatsu")
247                && key.text.contains("happi-sumairu")
248        }));
249    }
250
251    #[test]
252    fn japanese_mode_maps_kanji_reading_to_source_span() {
253        let cand = build_candidate(
254            0,
255            "tests/日本人の.txt",
256            &JapaneseBackend::default(),
257            &SearchConfig::default(),
258        );
259        let key = cand
260            .keys
261            .iter()
262            .find(|key| {
263                key.kind == KeyKind::RomajiReading
264                    && (key.text.contains("nihonjinno") || key.text.contains("nipponjinno"))
265            })
266            .unwrap();
267        let ni_index = key.text.chars().position(|ch| ch == 'n').unwrap();
268        let no_index = key.text.rfind("no").unwrap();
269        let no_char_index = key.text[..no_index].chars().count();
270        let source_map = key.source_map.as_ref().unwrap();
271
272        assert_eq!(
273            source_map[ni_index],
274            Some(SourceSpan {
275                start_char: 6,
276                end_char: 9
277            })
278        );
279        assert_eq!(
280            source_map[no_char_index],
281            Some(SourceSpan {
282                start_char: 9,
283                end_char: 10
284            })
285        );
286    }
287
288    #[test]
289    fn japanese_reading_none_skips_lindera_kanji_readings() {
290        let backend = JapaneseBackend::new(JapaneseReadingMode::None);
291        let cand = build_candidate(0, "tests/日本語.txt", &backend, &SearchConfig::default());
292
293        assert!(!cand
294            .keys
295            .iter()
296            .any(|k| k.kind == KeyKind::RomajiReading && k.text.contains("nihongo")));
297    }
298}