1mod numeric;
8pub mod reading;
10pub 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)]
22pub enum JapaneseReadingMode {
24 None,
26 Lindera,
28}
29
30#[derive(Clone, Debug)]
31pub struct JapaneseBackend {
33 reading: JapaneseReadingMode,
34}
35
36impl JapaneseBackend {
37 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}