Skip to main content

inputx_nihongo/
romaji.rs

1//! Romaji → kana conversion. Hepburn primary; kunrei alternates accepted
2//! so users can type `si`/`ti`/`tu`/`hu`/`zi` and get the canonical kana.
3//!
4//! The table is sorted by pattern-length DESC so the greedy parser can
5//! iterate in declaration order and take the first match. Adding new
6//! entries: insert in the right length bucket; same-length order is
7//! significant only when two patterns share a prefix (longer table
8//! pattern always wins regardless of order within bucket).
9
10/// One romaji pattern with its hiragana + katakana renderings.
11///
12/// `kana_h` and `kana_k` are full UTF-8 strings (not single chars) so
13/// 拗音 digraphs like `kya` → `きゃ` (two codepoints) fit in the table
14/// shape as easily as the basic 五十音.
15#[derive(Copy, Clone, Debug)]
16struct Entry {
17    rom: &'static str,
18    kana_h: &'static str,
19    kana_k: &'static str,
20}
21
22// Convenience helper to keep the const table readable.
23const fn e(rom: &'static str, h: &'static str, k: &'static str) -> Entry {
24    Entry { rom, kana_h: h, kana_k: k }
25}
26
27/// Greedy-match table. **Length-descending order.** First match wins.
28#[rustfmt::skip]
29const TABLE: &[Entry] = &[
30    // ---- 4-letter patterns (kunrei small-tsu variants) -------------
31    e("xtsu", "っ", "ッ"),
32    e("ltsu", "っ", "ッ"),
33
34    // ---- 3-letter patterns: 拗音 + xtu / ltu --------------------------
35    e("kya", "きゃ", "キャ"), e("kyu", "きゅ", "キュ"), e("kyo", "きょ", "キョ"),
36    e("gya", "ぎゃ", "ギャ"), e("gyu", "ぎゅ", "ギュ"), e("gyo", "ぎょ", "ギョ"),
37    e("sha", "しゃ", "シャ"), e("shu", "しゅ", "シュ"), e("sho", "しょ", "ショ"),
38    e("sya", "しゃ", "シャ"), e("syu", "しゅ", "シュ"), e("syo", "しょ", "ショ"),
39    e("shi", "し",   "シ"),
40    e("cha", "ちゃ", "チャ"), e("chu", "ちゅ", "チュ"), e("cho", "ちょ", "チョ"),
41    e("tya", "ちゃ", "チャ"), e("tyu", "ちゅ", "チュ"), e("tyo", "ちょ", "チョ"),
42    e("chi", "ち",   "チ"),
43    e("tsu", "つ",   "ツ"),
44    e("jya", "じゃ", "ジャ"), e("jyu", "じゅ", "ジュ"), e("jyo", "じょ", "ジョ"),
45    e("nya", "にゃ", "ニャ"), e("nyu", "にゅ", "ニュ"), e("nyo", "にょ", "ニョ"),
46    e("hya", "ひゃ", "ヒャ"), e("hyu", "ひゅ", "ヒュ"), e("hyo", "ひょ", "ヒョ"),
47    e("bya", "びゃ", "ビャ"), e("byu", "びゅ", "ビュ"), e("byo", "びょ", "ビョ"),
48    e("pya", "ぴゃ", "ピャ"), e("pyu", "ぴゅ", "ピュ"), e("pyo", "ぴょ", "ピョ"),
49    e("mya", "みゃ", "ミャ"), e("myu", "みゅ", "ミュ"), e("myo", "みょ", "ミョ"),
50    e("rya", "りゃ", "リャ"), e("ryu", "りゅ", "リュ"), e("ryo", "りょ", "リョ"),
51    e("xtu", "っ",   "ッ"),
52    e("ltu", "っ",   "ッ"),
53    e("xya", "ゃ",   "ャ"),  e("xyu", "ゅ",   "ュ"),  e("xyo", "ょ",   "ョ"),
54
55    // ---- 3-letter foreign-loanword extensions (mozc/Google IME standard) ----
56    // Without these, romaji like `famiriaare` (ファミリアアレ) renders as
57    // `fあみりああれ` — the leading `f` has no `fa` map, falls through as
58    // ASCII passthrough, and is_jp_clean rejects the whole candidate.
59    // fa-row yoon (foreign): ファ/ヴァ/ etc.
60    e("fya", "ふゃ", "フャ"), e("fyu", "ふゅ", "フュ"), e("fyo", "ふょ", "フョ"),
61    e("vya", "ゔゃ", "ヴャ"), e("vyu", "ゔゅ", "ヴュ"), e("vyo", "ゔょ", "ヴョ"),
62    // ts/ch/sh/j single-yoon foreign borrowings
63    e("tsa", "つぁ", "ツァ"), e("tsi", "つぃ", "ツィ"),
64    e("tse", "つぇ", "ツェ"), e("tso", "つぉ", "ツォ"),
65    e("che", "ちぇ", "チェ"),
66    e("she", "しぇ", "シェ"),
67    // kw/gw row (クァ/グァ etc. — foreign-borrowed labialized k/g)
68    e("kwa", "くぁ", "クァ"), e("kwi", "くぃ", "クィ"),
69    e("kwe", "くぇ", "クェ"), e("kwo", "くぉ", "クォ"),
70    e("gwa", "ぐぁ", "グァ"), e("gwi", "ぐぃ", "グィ"),
71    e("gwe", "ぐぇ", "グェ"), e("gwo", "ぐぉ", "グォ"),
72    // w-row foreign extensions (ウァ/ウィ/ウェ/ウォ) — 'wh*' explicit form.
73    // Also exposes plain `wi/we` 2-letter below as the more common entry.
74    e("wha", "うぁ", "ウァ"), e("whi", "うぃ", "ウィ"),
75    e("whe", "うぇ", "ウェ"), e("who", "うぉ", "ウォ"),
76    // Explicit ティ/ディ via `th*/dh*` — sidesteps kunrei ti=ち / di=ぢ
77    // conflict. `tho/dho` map to て+small-yo / で+small-yo per mozc.
78    e("tha", "てぁ", "テァ"), e("thi", "てぃ", "ティ"),
79    e("the", "てぇ", "テェ"), e("tho", "てょ", "テョ"),
80    e("dha", "でぁ", "デァ"), e("dhi", "でぃ", "ディ"),
81    e("dhe", "でぇ", "デェ"), e("dho", "でょ", "デョ"),
82    // Explicit トゥ/ドゥ via `twu/dwu` — sidesteps kunrei tu=つ / du=づ.
83    e("twu", "とぅ", "トゥ"),
84    e("dwu", "どぅ", "ドゥ"),
85
86    // ---- 2-letter patterns: basic 五十音 + 浊音 + 半浊音 + 'nn' ----------
87    e("ka", "か", "カ"), e("ki", "き", "キ"), e("ku", "く", "ク"), e("ke", "け", "ケ"), e("ko", "こ", "コ"),
88    e("ga", "が", "ガ"), e("gi", "ぎ", "ギ"), e("gu", "ぐ", "グ"), e("ge", "げ", "ゲ"), e("go", "ご", "ゴ"),
89    e("sa", "さ", "サ"),                       e("su", "す", "ス"), e("se", "せ", "セ"), e("so", "そ", "ソ"),
90    e("si", "し", "シ"),  // kunrei
91    e("za", "ざ", "ザ"), e("zi", "じ", "ジ"), e("zu", "ず", "ズ"), e("ze", "ぜ", "ゼ"), e("zo", "ぞ", "ゾ"),
92    e("ji", "じ", "ジ"),  // hepburn
93    e("ta", "た", "タ"),                       e("tu", "つ", "ツ"), e("te", "て", "テ"), e("to", "と", "ト"),
94    e("ti", "ち", "チ"),  // kunrei
95    e("da", "だ", "ダ"), e("di", "ぢ", "ヂ"), e("du", "づ", "ヅ"), e("de", "で", "デ"), e("do", "ど", "ド"),
96    e("na", "な", "ナ"), e("ni", "に", "ニ"), e("nu", "ぬ", "ヌ"), e("ne", "ね", "ネ"), e("no", "の", "ノ"),
97    e("ha", "は", "ハ"), e("hi", "ひ", "ヒ"), e("hu", "ふ", "フ"), e("he", "へ", "ヘ"), e("ho", "ほ", "ホ"),
98    e("fu", "ふ", "フ"),  // hepburn
99    e("ba", "ば", "バ"), e("bi", "び", "ビ"), e("bu", "ぶ", "ブ"), e("be", "べ", "ベ"), e("bo", "ぼ", "ボ"),
100    e("pa", "ぱ", "パ"), e("pi", "ぴ", "ピ"), e("pu", "ぷ", "プ"), e("pe", "ぺ", "ペ"), e("po", "ぽ", "ポ"),
101    e("ma", "ま", "マ"), e("mi", "み", "ミ"), e("mu", "む", "ム"), e("me", "め", "メ"), e("mo", "も", "モ"),
102    e("ya", "や", "ヤ"),                       e("yu", "ゆ", "ユ"),                       e("yo", "よ", "ヨ"),
103    e("ra", "ら", "ラ"), e("ri", "り", "リ"), e("ru", "る", "ル"), e("re", "れ", "レ"), e("ro", "ろ", "ロ"),
104    e("wa", "わ", "ワ"), e("wo", "を", "ヲ"),
105    e("ja", "じゃ", "ジャ"), e("ju", "じゅ", "ジュ"), e("jo", "じょ", "ジョ"),  // hepburn 2-letter
106
107    // ---- 2-letter foreign-loanword extensions ------------------------
108    // fa-row (ファ): native JP has only fu = ふ/フ; foreign borrowings need
109    // fa/fi/fe/fo for words like ファミリア (familia) / フィルム (film) /
110    // フェルト (felt) / フォーマット (format).
111    e("fa", "ふぁ", "ファ"), e("fi", "ふぃ", "フィ"),
112    e("fe", "ふぇ", "フェ"), e("fo", "ふぉ", "フォ"),
113    // va-row (ヴァ): foreign 'v' — ヴァイオリン (violin), ヴィデオ etc.
114    // Hiragana ゔ (U+3094) is rare but standard for paired small-vowels.
115    e("va", "ゔぁ", "ヴァ"), e("vi", "ゔぃ", "ヴィ"),
116    e("vu", "ゔ",   "ヴ"),  e("ve", "ゔぇ", "ヴェ"),
117    e("vo", "ゔぉ", "ヴォ"),
118    // w-row foreign 2-letter aliases (ウィ/ウェ): wedding=ウェディング etc.
119    e("wi", "うぃ", "ウィ"), e("we", "うぇ", "ウェ"),
120    // je: foreign borrowings (ジェット = jet) — covers gap between ja/ju/jo.
121    e("je", "じぇ", "ジェ"),
122    // Small explicit kana: type ぁ/ぃ/ぅ/ぇ/ぉ directly via `xa-xo` (mozc/
123    // Hepburn convention) or `la-lo` (alternative IME convention; some
124    // users default to `l-`). Both aliases supported.
125    e("xa", "ぁ", "ァ"), e("xi", "ぃ", "ィ"), e("xu", "ぅ", "ゥ"),
126    e("xe", "ぇ", "ェ"), e("xo", "ぉ", "ォ"),
127    e("la", "ぁ", "ァ"), e("li", "ぃ", "ィ"), e("lu", "ぅ", "ゥ"),
128    e("le", "ぇ", "ェ"), e("lo", "ぉ", "ォ"),
129    // 'nn' and single 'n' are NOT table entries — they're handled by the
130    // moraic-n state machine in `render` so that `annai` → あんない and
131    // `nna` → んな work the way mozc/Google JP IME do them. Adding them
132    // back to the table would break that (greedy "nn" would consume both
133    // n's, so `annai` would mis-parse as a+nn+ai = あんあい).
134
135    // ---- 1-letter patterns ------------------------------------------
136    e("a", "あ", "ア"), e("i", "い", "イ"), e("u", "う", "ウ"), e("e", "え", "エ"), e("o", "お", "オ"),
137    e("-", "ー", "ー"),  // chōonpu (long-vowel mark)
138];
139
140/// Render `s` (a romaji buffer) as hiragana. Unmappable bytes pass
141/// through as ASCII so the caller can show partial input to the user
142/// while they keep typing. Double-consonant gemination (`kk*` → `っk*`)
143/// is recognized; double-vowel does not produce 促音.
144pub fn to_hiragana(s: &str) -> String {
145    render(s, /* katakana = */ false)
146}
147
148/// Render `s` as katakana. Same parser as [`to_hiragana`].
149pub fn to_katakana(s: &str) -> String {
150    render(s, /* katakana = */ true)
151}
152
153fn render(s: &str, katakana: bool) -> String {
154    let lower = s.to_ascii_lowercase();
155    let bytes = lower.as_bytes();
156    let sokuon = if katakana { "ッ" } else { "っ" };
157    let n_str = if katakana { "ン" } else { "ん" };
158    let mut out = String::with_capacity(s.len() * 3);
159    let mut i = 0;
160    while i < bytes.len() {
161        // Gemination: doubled consonant (not `n`, not vowel) emits 促音
162        // and consumes only one byte, leaving the doubled consonant to
163        // be matched as part of the following syllable on the next iter.
164        // `n` doubling is excluded here — it's handled by the moraic-n
165        // state machine below (`nn` is the explicit-ん shortcut, not 促音).
166        if i + 1 < bytes.len()
167            && bytes[i] == bytes[i + 1]
168            && bytes[i] != b'n'
169            && is_gemini_consonant(bytes[i])
170        {
171            out.push_str(sokuon);
172            i += 1;
173            continue;
174        }
175        // Moraic-n state machine. Drives correct parsing of `annai` →
176        // あんない and `nna` → んな, matching mozc / Google JP IME
177        // behavior:
178        //   `n` at buffer end                       → ん (consume 1)
179        //   `nn` at buffer end (no further bytes)   → ん (consume 2,
180        //       single-ん shortcut so users typing `nn` to commit ん
181        //       don't get んん)
182        //   `n` followed by another consonant       → ん (consume 1),
183        //       letting the next consonant start a fresh syllable.
184        //       Critical for `annai`: a + n[→ん] + n + a + i parses as
185        //       a + ん + na + i.
186        //   `n` followed by vowel or `y`            → fall through to
187        //       the greedy table (which matches `na`, `nya`, etc.).
188        if bytes[i] == b'n' {
189            if i + 1 >= bytes.len() {
190                out.push_str(n_str);
191                i += 1;
192                continue;
193            }
194            let next = bytes[i + 1];
195            if next == b'n' && i + 2 >= bytes.len() {
196                out.push_str(n_str);
197                i += 2;
198                continue;
199            }
200            if !matches!(next, b'a' | b'i' | b'u' | b'e' | b'o' | b'y') {
201                out.push_str(n_str);
202                i += 1;
203                continue;
204            }
205            // else: fall through to table match for `n+vowel` / `n+y+vowel`.
206        }
207        // Longest-prefix table match.
208        let mut matched_len = 0;
209        for entry in TABLE.iter() {
210            let pat = entry.rom.as_bytes();
211            if pat.len() <= bytes.len() - i && &bytes[i..i + pat.len()] == pat {
212                out.push_str(if katakana { entry.kana_k } else { entry.kana_h });
213                matched_len = pat.len();
214                break;
215            }
216        }
217        if matched_len == 0 {
218            // Unmappable byte (e.g. user typed a digit / punct, or an
219            // incomplete romaji prefix like 'k' / 'sh'). Pass through
220            // as ASCII so the candidate stays visible during typing.
221            out.push(bytes[i] as char);
222            i += 1;
223        } else {
224            i += matched_len;
225        }
226    }
227    out
228}
229
230const fn is_gemini_consonant(b: u8) -> bool {
231    matches!(
232        b,
233        b'k' | b'g' | b's' | b'z' | b'j' | b't' | b'd' | b'c'
234        | b'h' | b'f' | b'b' | b'p' | b'm' | b'r' | b'y' | b'w' | b'v'
235    )
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn basic_vowels() {
244        assert_eq!(to_hiragana("a"), "あ");
245        assert_eq!(to_hiragana("aiueo"), "あいうえお");
246        assert_eq!(to_katakana("aiueo"), "アイウエオ");
247    }
248
249    #[test]
250    fn basic_ka_row() {
251        assert_eq!(to_hiragana("ka"), "か");
252        assert_eq!(to_hiragana("kakikukeko"), "かきくけこ");
253        assert_eq!(to_katakana("ka"), "カ");
254    }
255
256    #[test]
257    fn voiced_and_semi_voiced() {
258        assert_eq!(to_hiragana("ga"), "が");
259        assert_eq!(to_hiragana("za"), "ざ");
260        assert_eq!(to_hiragana("ba"), "ば");
261        assert_eq!(to_hiragana("pa"), "ぱ");
262    }
263
264    #[test]
265    fn hepburn_and_kunrei_aliases() {
266        // shi/si, chi/ti, tsu/tu, fu/hu, ji/zi all map to canonical kana.
267        assert_eq!(to_hiragana("shi"), "し");
268        assert_eq!(to_hiragana("si"), "し");
269        assert_eq!(to_hiragana("chi"), "ち");
270        assert_eq!(to_hiragana("ti"), "ち");
271        assert_eq!(to_hiragana("tsu"), "つ");
272        assert_eq!(to_hiragana("tu"), "つ");
273        assert_eq!(to_hiragana("fu"), "ふ");
274        assert_eq!(to_hiragana("hu"), "ふ");
275        assert_eq!(to_hiragana("ji"), "じ");
276        assert_eq!(to_hiragana("zi"), "じ");
277    }
278
279    #[test]
280    fn youon_digraphs() {
281        assert_eq!(to_hiragana("kya"), "きゃ");
282        assert_eq!(to_hiragana("kyu"), "きゅ");
283        assert_eq!(to_hiragana("kyo"), "きょ");
284        assert_eq!(to_hiragana("sha"), "しゃ");
285        assert_eq!(to_hiragana("cha"), "ちゃ");
286        assert_eq!(to_hiragana("ja"), "じゃ");
287        assert_eq!(to_hiragana("nyo"), "にょ");
288        assert_eq!(to_katakana("rya"), "リャ");
289    }
290
291    #[test]
292    fn sokuon_gemination() {
293        // `kka` → っ + か
294        assert_eq!(to_hiragana("kka"), "っか");
295        // `ssa` → っ + さ
296        assert_eq!(to_hiragana("ssa"), "っさ");
297        // explicit small-tsu spellings
298        assert_eq!(to_hiragana("xtsu"), "っ");
299        assert_eq!(to_hiragana("xtu"), "っ");
300        // `nn` is *not* a sokuon — it's ん (the moraic n)
301        assert_eq!(to_hiragana("nn"), "ん");
302    }
303
304    #[test]
305    fn moraic_n_basic() {
306        // Single `n` at buffer end commits as ん.
307        assert_eq!(to_hiragana("n"), "ん");
308        // `n` followed by vowel forms the n-row syllable.
309        assert_eq!(to_hiragana("na"), "な");
310        // `nn` at buffer end is the explicit-ん shortcut — single ん.
311        assert_eq!(to_hiragana("nn"), "ん");
312    }
313
314    #[test]
315    fn moraic_n_annai_pattern() {
316        // The mozc-style golden case: `n` followed by another consonant
317        // commits as ん even though it's mid-buffer. Otherwise `annai`
318        // parses to あんあい (wrong) instead of あんない.
319        assert_eq!(to_hiragana("nna"), "んな");
320        assert_eq!(to_hiragana("annai"), "あんない");
321        assert_eq!(to_hiragana("ganbaru"), "がんばる");
322        // 'wa' deliberately maps to わ — users typing the historical
323        // は-particle pronunciation will get こんにちわ from `konnichiwa`,
324        // which is the standard romaji-mapping outcome (mozc behavior).
325        assert_eq!(to_hiragana("konnichiwa"), "こんにちわ");
326    }
327
328    #[test]
329    fn chouonpu_long_mark() {
330        assert_eq!(to_hiragana("ka-"), "かー");
331        assert_eq!(to_katakana("ko-hi-"), "コーヒー");
332    }
333
334    #[test]
335    fn incomplete_romaji_passes_through() {
336        // Lone consonant left in the buffer renders as ASCII so the
337        // candidate is "still typing" not "fully consumed garbage".
338        assert_eq!(to_hiragana("k"), "k");
339        assert_eq!(to_hiragana("sh"), "sh");
340    }
341
342    #[test]
343    fn multi_syllable_word() {
344        assert_eq!(to_hiragana("nihon"), "にほん");
345        assert_eq!(to_hiragana("tokyo"), "ときょ");
346        assert_eq!(to_hiragana("nippon"), "にっぽん");
347        assert_eq!(to_katakana("kohi-"), "コヒー");
348    }
349
350    #[test]
351    fn case_insensitive() {
352        assert_eq!(to_hiragana("KA"), "か");
353        assert_eq!(to_hiragana("Nihon"), "にほん");
354    }
355
356    #[test]
357    fn foreign_fa_row() {
358        // 2-letter ファ row — the famiriaare regression. Pre-fix `fa` had
359        // no entry, leading `f` fell through as ASCII, is_jp_clean rejected
360        // every hiragana/katakana variant containing it.
361        assert_eq!(to_katakana("fa"), "ファ");
362        assert_eq!(to_katakana("famiriaare"), "ファミリアアレ");
363        assert_eq!(to_katakana("fi"), "フィ");
364        assert_eq!(to_katakana("fe"), "フェ");
365        assert_eq!(to_katakana("fo"), "フォ");
366        // hiragana variants render through the same path.
367        assert_eq!(to_hiragana("fa"), "ふぁ");
368        assert_eq!(to_hiragana("famiriaare"), "ふぁみりああれ");
369    }
370
371    #[test]
372    fn foreign_va_row() {
373        assert_eq!(to_katakana("va"), "ヴァ");
374        assert_eq!(to_katakana("vi"), "ヴィ");
375        assert_eq!(to_katakana("vu"), "ヴ");
376        assert_eq!(to_katakana("ve"), "ヴェ");
377        assert_eq!(to_katakana("vo"), "ヴォ");
378        // Real foreign-loanword: violin = vaiorin
379        assert_eq!(to_katakana("vaiorin"), "ヴァイオリン");
380    }
381
382    #[test]
383    fn foreign_w_row() {
384        // 2-letter `wi/we` (also accepts 3-letter `whi/whe` aliases)
385        assert_eq!(to_katakana("wi"), "ウィ");
386        assert_eq!(to_katakana("we"), "ウェ");
387        assert_eq!(to_katakana("whi"), "ウィ");
388        assert_eq!(to_katakana("whe"), "ウェ");
389        assert_eq!(to_katakana("wha"), "ウァ");
390        assert_eq!(to_katakana("who"), "ウォ");
391        // wedding = wedhingu (kunrei `di`=ぢ, so ディ requires explicit dhi)
392        assert_eq!(to_katakana("wedhingu"), "ウェディング");
393        // weak point: wedingu still renders 'wedi' as ウェ+ぢ → ウェヂ
394        // (kunrei mapping) which is expected and matches mozc.
395        assert_eq!(to_katakana("wedingu"), "ウェヂング");
396    }
397
398    #[test]
399    fn foreign_je_che_she() {
400        assert_eq!(to_katakana("je"), "ジェ");
401        assert_eq!(to_katakana("che"), "チェ");
402        assert_eq!(to_katakana("she"), "シェ");
403        // jet = jetto
404        assert_eq!(to_katakana("jetto"), "ジェット");
405        // shake = sheiku
406        assert_eq!(to_katakana("sheiku"), "シェイク");
407    }
408
409    #[test]
410    fn foreign_ts_row() {
411        assert_eq!(to_katakana("tsa"), "ツァ");
412        assert_eq!(to_katakana("tsi"), "ツィ");
413        assert_eq!(to_katakana("tse"), "ツェ");
414        assert_eq!(to_katakana("tso"), "ツォ");
415    }
416
417    #[test]
418    fn foreign_thi_dhi_twu_dwu() {
419        // Explicit ティ/ディ/トゥ/ドゥ — sidesteps kunrei conflict.
420        assert_eq!(to_katakana("thi"), "ティ");
421        assert_eq!(to_katakana("dhi"), "ディ");
422        assert_eq!(to_katakana("twu"), "トゥ");
423        assert_eq!(to_katakana("dwu"), "ドゥ");
424        // party = pa-thi-
425        assert_eq!(to_katakana("pa-thi-"), "パーティー");
426    }
427
428    #[test]
429    fn foreign_kwa_gwa_rows() {
430        assert_eq!(to_katakana("kwa"), "クァ");
431        assert_eq!(to_katakana("kwi"), "クィ");
432        assert_eq!(to_katakana("kwe"), "クェ");
433        assert_eq!(to_katakana("kwo"), "クォ");
434        assert_eq!(to_katakana("gwa"), "グァ");
435    }
436
437    #[test]
438    fn foreign_fy_vy_yoon() {
439        assert_eq!(to_katakana("fya"), "フャ");
440        assert_eq!(to_katakana("fyu"), "フュ");
441        assert_eq!(to_katakana("vya"), "ヴャ");
442        assert_eq!(to_katakana("vyu"), "ヴュ");
443    }
444
445    #[test]
446    fn small_kana_explicit() {
447        // xa-xo and la-lo aliases — type small ぁぃぅぇぉ directly.
448        assert_eq!(to_hiragana("xa"), "ぁ");
449        assert_eq!(to_hiragana("xi"), "ぃ");
450        assert_eq!(to_hiragana("xu"), "ぅ");
451        assert_eq!(to_hiragana("xe"), "ぇ");
452        assert_eq!(to_hiragana("xo"), "ぉ");
453        assert_eq!(to_hiragana("la"), "ぁ");
454        assert_eq!(to_katakana("xa"), "ァ");
455        assert_eq!(to_katakana("la"), "ァ");
456    }
457
458    #[test]
459    fn pre_existing_kunrei_di_du_unchanged() {
460        // Sanity guard against the new 'dh*/dw*/d*' entries clobbering
461        // the kunrei `di`=ぢ / `du`=づ entries. mozc convention: bare
462        // `di` is still kunrei → ぢ; explicit ディ uses `dhi`.
463        assert_eq!(to_hiragana("di"), "ぢ");
464        assert_eq!(to_hiragana("du"), "づ");
465        assert_eq!(to_hiragana("ti"), "ち");
466        assert_eq!(to_hiragana("tu"), "つ");
467    }
468}