use crate::jukugo;
use crate::kanji;
use crate::romaji;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Candidate {
pub word: String,
pub kind: KanaKind,
pub freq: u32,
pub composed: bool,
pub proximity_milli: u16,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum KanaKind {
Hiragana,
Katakana,
Kanji,
}
pub struct JapaneseEngine {
buffer: Vec<u8>,
candidates: Vec<Candidate>,
}
impl Default for JapaneseEngine {
fn default() -> Self {
Self::new()
}
}
impl JapaneseEngine {
pub fn new() -> Self {
Self {
buffer: Vec::with_capacity(8),
candidates: Vec::with_capacity(8),
}
}
pub fn handle_letter(&mut self, c: u8) -> bool {
if c == b'-' {
if self.buffer.is_empty() {
return false;
}
self.buffer.push(b'-');
self.refresh_candidates();
return true;
}
if !c.is_ascii_alphabetic() {
return false;
}
self.buffer.push(c.to_ascii_lowercase());
self.refresh_candidates();
true
}
pub fn backspace(&mut self) -> bool {
if self.buffer.is_empty() {
return false;
}
self.buffer.pop();
self.refresh_candidates();
true
}
pub fn escape(&mut self) -> bool {
if self.buffer.is_empty() {
return false;
}
self.buffer.clear();
self.candidates.clear();
true
}
pub fn preedit(&self) -> &str {
std::str::from_utf8(&self.buffer).unwrap_or("")
}
pub fn is_composing(&self) -> bool {
!self.buffer.is_empty()
}
pub fn candidates(&self) -> &[Candidate] {
&self.candidates
}
pub fn commit_index(&mut self, index: usize) -> Option<String> {
let text = self.candidates.get(index)?.word.clone();
self.buffer.clear();
self.candidates.clear();
Some(text)
}
fn refresh_candidates(&mut self) {
self.candidates.clear();
let s = std::str::from_utf8(&self.buffer).unwrap_or("");
for composed in compose_sentence(s) {
self.candidates.push(composed);
}
let mut jukugo_hits: Vec<(&str, u32)> = jukugo::lookup_by_reading(s).collect();
jukugo_hits.sort_by(|a, b| b.1.cmp(&a.1));
let had_exact_jukugo = !jukugo_hits.is_empty();
for (compound, freq) in jukugo_hits {
self.candidates.push(Candidate {
word: compound.to_string(),
kind: KanaKind::Kanji,
freq,
composed: false,
proximity_milli: 1000,
});
}
if !had_exact_jukugo && s.len() >= 3 {
let mut pred: Vec<(&str, u32, usize)> =
jukugo::lookup_by_reading_prefix(s).collect();
pred.sort_by(|a, b| b.1.cmp(&a.1));
for (kanji, freq, reading_len) in pred.into_iter().take(8) {
let proximity_milli = ((s.len() * 1000) / reading_len.max(1)) as u16;
self.candidates.push(Candidate {
word: kanji.to_string(),
kind: KanaKind::Kanji,
freq,
composed: false,
proximity_milli,
});
}
}
let mut kanji_hits: Vec<(char, u32)> = kanji::lookup_by_reading(s).collect();
kanji_hits.sort_by(|a, b| b.1.cmp(&a.1));
for (kanji_char, freq) in kanji_hits {
self.candidates.push(Candidate {
word: kanji_char.to_string(),
kind: KanaKind::Kanji,
freq,
composed: false,
proximity_milli: 1000,
});
}
let kana_freq: u32 = if s.len() <= 2 { 100 } else { 30 };
let h = romaji::to_hiragana(s);
if !h.is_empty() && h != s {
self.candidates.push(Candidate {
word: h.clone(),
kind: KanaKind::Hiragana,
freq: kana_freq,
composed: false,
proximity_milli: 1000,
});
}
let k = romaji::to_katakana(s);
if !k.is_empty() && k != s && k != h {
self.candidates.push(Candidate {
word: k,
kind: KanaKind::Katakana,
freq: kana_freq,
composed: false,
proximity_milli: 1000,
});
}
}
}
const SENTENCE_SUFFIXES: &[(&str, &str)] = &[
("dewanaikatta", "ではなかった"),
("dewaarimasen", "ではありません"),
("dewanaiyou", "ではないよう"),
("dewanakatta", "ではなかった"),
("dewanai", "ではない"),
("deshita", "でした"),
("dewashita", "ではした"),
("deshou", "でしょう"),
("darou", "だろう"),
("datta", "だった"),
("desu", "です"),
("dewa", "では"),
("kara", "から"),
("made", "まで"),
("yori", "より"),
("nado", "など"),
("toka", "とか"),
("nimo", "にも"),
("demo", "でも"),
("masu", "ます"),
("masen", "ません"),
("mashita", "ました"),
("mashou", "ましょう"),
("wa", "は"),
("ga", "が"),
("wo", "を"),
("ni", "に"),
("de", "で"),
("to", "と"),
("mo", "も"),
("no", "の"),
("ka", "か"),
("e", "へ"),
("ya", "や"),
];
const KANJI_SUFFIXES: &[(&str, &str)] = &[
("to", "都"), ("fu", "府"), ("ken", "県"), ("shi", "市"),
("ku", "区"), ("chou", "町"), ("son", "村"), ("mura", "村"),
("shima", "島"), ("gun", "郡"), ("jin", "人"), ("go", "語"),
];
fn compose_one_segment(buffer: &str) -> Vec<(String, u32)> {
let mut out: Vec<(String, u32)> = Vec::new();
for (s_reading, s_kana) in SENTENCE_SUFFIXES {
if let Some(prefix) = buffer.strip_suffix(s_reading) {
if prefix.is_empty() {
continue;
}
for (compound, freq) in jukugo::lookup_by_reading(prefix) {
out.push((format!("{compound}{s_kana}"), freq));
}
for (ch, freq) in kanji::lookup_by_reading(prefix) {
out.push((format!("{ch}{s_kana}"), freq));
}
}
}
out
}
fn compose_sentence(buffer: &str) -> Vec<Candidate> {
let mut hits: Vec<(String, u32)> = Vec::new();
for (word, freq) in compose_one_segment(buffer) {
hits.push((word, freq));
}
for (sfx_read, sfx_kanji) in KANJI_SUFFIXES {
if let Some(prefix) = buffer.strip_suffix(sfx_read) {
if prefix.is_empty() {
continue;
}
for (compound, freq) in jukugo::lookup_by_reading(prefix) {
hits.push((format!("{compound}{sfx_kanji}"), freq));
}
}
}
for split in 2..buffer.len() {
let left = &buffer[..split];
let right = &buffer[split..];
let lefts = compose_one_segment(left);
if lefts.is_empty() {
continue;
}
let mut right_hits: Vec<(String, u32)> = Vec::new();
for (compound, freq) in jukugo::lookup_by_reading(right) {
right_hits.push((compound.to_string(), freq));
}
for (ch, freq) in kanji::lookup_by_reading(right) {
right_hits.push((ch.to_string(), freq));
}
for (lw, lf) in &lefts {
for (rw, rf) in &right_hits {
let combined = format!("{lw}{rw}");
let combined_freq = ((*lf.min(rf) as f64) * 0.65) as u32;
hits.push((combined, combined_freq));
}
}
}
if buffer.len() >= 4 {
for split in 2..buffer.len() - 1 {
let left = &buffer[..split];
let right = &buffer[split..];
let lefts = compose_one_segment(left);
if lefts.is_empty() {
continue;
}
let rights = compose_one_segment(right);
if rights.is_empty() {
continue;
}
for (lw, lf) in &lefts {
for (rw, rf) in &rights {
let combined = format!("{lw}{rw}");
let combined_freq =
((*lf.min(rf) as f64) * 0.7) as u32;
hits.push((combined, combined_freq));
}
}
}
}
let mut best: std::collections::HashMap<String, u32> =
std::collections::HashMap::new();
for (w, f) in hits {
let entry = best.entry(w).or_insert(0);
if f > *entry {
*entry = f;
}
}
let mut sorted: Vec<(String, u32)> = best.into_iter().collect();
sorted.sort_by(|a, b| b.1.cmp(&a.1));
sorted.truncate(30);
sorted
.into_iter()
.map(|(word, freq)| Candidate {
word,
kind: KanaKind::Kanji,
freq: ((freq as f64) * 0.85) as u32,
composed: true,
proximity_milli: 1000,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chouonpu_hyphen_extends_buffer_when_composing() {
let mut e = JapaneseEngine::new();
for b in b"ko" { assert!(e.handle_letter(*b)); }
assert!(e.handle_letter(b'-'), "`-` must be accepted as chouonpu mid-composition");
for b in b"hi" { assert!(e.handle_letter(*b)); }
assert!(e.handle_letter(b'-'), "trailing `-` also accepted");
let cands = e.candidates();
assert!(cands.iter().any(|c| c.word == "コーヒー"),
"expected コーヒー (katakana with chouonpu) among candidates, got {:?}",
cands.iter().map(|c| &c.word).collect::<Vec<_>>());
assert!(cands.iter().any(|c| c.word == "こーひー"),
"expected こーひー (hiragana with chouonpu) among candidates");
}
#[test]
fn chouonpu_hyphen_literal_oo_no_auto_chouonpu() {
let mut e = JapaneseEngine::new();
for b in b"koo" { assert!(e.handle_letter(*b)); }
let cands = e.candidates();
assert!(cands.iter().any(|c| c.word == "コオ"),
"expected コオ for `koo`; got {:?}", cands.iter().map(|c| &c.word).collect::<Vec<_>>());
assert!(!cands.iter().any(|c| c.word.contains("コー") && c.word.chars().count() <= 2),
"double-o must not auto-convert to chōonpu");
}
#[test]
fn chouonpu_hyphen_rejected_on_empty_buffer() {
let mut e = JapaneseEngine::new();
assert!(!e.handle_letter(b'-'), "empty-buffer `-` must reject");
assert_eq!(e.preedit(), "", "rejected `-` must leave buffer empty");
}
#[test]
fn single_letter_a_gives_hiragana_katakana() {
let mut e = JapaneseEngine::new();
assert!(e.handle_letter(b'a'));
let cands = e.candidates();
assert!(cands.iter().any(|c| c.word == "あ" && c.kind == KanaKind::Hiragana));
assert!(cands.iter().any(|c| c.word == "ア" && c.kind == KanaKind::Katakana));
}
#[test]
fn kanji_match_on_full_buffer_on_yomi() {
let mut e = JapaneseEngine::new();
for b in b"kou" {
e.handle_letter(*b);
}
let cands = e.candidates();
assert!(
cands.iter().any(|c| c.word == "高" && c.kind == KanaKind::Kanji),
"expected 高 in candidates for 'kou', got {:?}",
cands
);
assert!(
cands.iter().any(|c| c.word == "こう" && c.kind == KanaKind::Hiragana),
"expected こう (hiragana) in candidates for 'kou', got {:?}",
cands
);
}
#[test]
fn multi_syllable_finds_jukugo() {
let mut e = JapaneseEngine::new();
for b in b"nihon" {
e.handle_letter(*b);
}
let cands = e.candidates();
assert!(
cands.iter().any(|c| c.word == "日本" && c.kind == KanaKind::Kanji),
"expected 日本 (kanji jukugo) for 'nihon', got {:?}",
cands
);
assert!(cands.iter().any(|c| c.word == "にほん"));
assert!(cands.iter().any(|c| c.word == "ニホン"));
}
#[test]
fn backspace_pops_and_re_renders() {
let mut e = JapaneseEngine::new();
for b in b"kou" {
e.handle_letter(*b);
}
assert!(e.backspace());
assert_eq!(e.preedit(), "ko");
let cands = e.candidates();
assert!(cands.iter().any(|c| c.word == "こ"));
}
#[test]
fn backspace_on_empty_is_false() {
let mut e = JapaneseEngine::new();
assert!(!e.backspace());
}
#[test]
fn escape_drops_buffer() {
let mut e = JapaneseEngine::new();
e.handle_letter(b'k');
assert!(e.is_composing());
assert!(e.escape());
assert!(!e.is_composing());
assert_eq!(e.preedit(), "");
}
#[test]
fn commit_clears_state() {
let mut e = JapaneseEngine::new();
for b in b"kou" {
e.handle_letter(*b);
}
let committed = e.commit_index(0).expect("commit");
assert_eq!(committed, "こう");
assert!(!e.is_composing());
assert_eq!(e.candidates().len(), 0);
}
#[test]
fn commit_out_of_range_returns_none() {
let mut e = JapaneseEngine::new();
e.handle_letter(b'a');
assert!(e.commit_index(999).is_none());
assert!(e.is_composing());
}
#[test]
fn non_letter_rejected_without_state_change() {
let mut e = JapaneseEngine::new();
assert!(!e.handle_letter(b'5'));
assert!(!e.handle_letter(b' '));
assert_eq!(e.preedit(), "");
}
#[test]
fn counter_words_round8() {
let cases: &[(&[u8], &str)] = &[
(b"ikko", "一個"),
(b"hitori", "一人"),
(b"futari", "二人"),
(b"sannin", "三人"),
(b"yonin", "四人"),
(b"mikka", "三日"),
(b"yokka", "四日"),
(b"tsuitachi", "一日"),
(b"futsuka", "二日"),
(b"ippon", "一本"),
(b"sanbon", "三本"),
(b"ichimai", "一枚"),
(b"sanbiki", "三匹"),
(b"ittou", "一頭"),
(b"ikkai", "一回"),
(b"isshuukan", "一週間"),
(b"ikkagetsu", "ヶ月"), (b"yoji", "四時"),
(b"kuji", "九時"),
(b"ippun", "一分"),
(b"juppun", "十分"),
(b"ippai", "一杯"),
(b"issatsu", "一冊"),
(b"hitotsu", "一つ"),
(b"mittsu", "三つ"),
(b"daiichi", "第一"),
(b"hatachi", "二十歳"),
];
for (input, expected_substr) in cases {
let mut e = JapaneseEngine::new();
for b in *input {
e.handle_letter(*b);
}
let cands = e.candidates();
assert!(
cands.iter().any(|c| c.word.contains(expected_substr)),
"expected a candidate containing `{}` for input `{}`, got {:?}",
expected_substr,
std::str::from_utf8(input).unwrap(),
cands.iter().map(|c| &c.word).collect::<Vec<_>>()
);
}
}
#[test]
fn ikkagetsu_renders_full_kanji() {
let mut e = JapaneseEngine::new();
for b in b"ikkagetsu" {
e.handle_letter(*b);
}
let cands = e.candidates();
assert!(
cands.iter().any(|c| c.word == "一ヶ月"),
"expected `一ヶ月` for `ikkagetsu`, got {:?}",
cands.iter().map(|c| &c.word).collect::<Vec<_>>()
);
}
#[test]
fn brands_round9() {
let cases: &[(&[u8], &str)] = &[
(b"sutaba", "スタバ"),
(b"yunikuro", "ユニクロ"),
(b"amazon", "アマゾン"),
(b"sebun", "セブン"),
(b"famima", "ファミマ"),
(b"rouson", "ローソン"),
(b"donki", "ドンキ"),
(b"toyota", "トヨタ"),
(b"sonii", "ソニー"),
(b"nintendou", "任天堂"),
(b"yuuchuubu", "ユーチューブ"),
(b"insuta", "インスタ"),
(b"tikkutokku", "ティックトック"),
(b"merukari", "メルカリ"),
(b"peipei", "ペイペイ"),
(b"rain", "ライン"),
(b"netofuri", "ネトフリ"),
(b"shinkansen", "新幹線"),
(b"makku", "マック"),
(b"yoshinoya", "吉野家"),
(b"sukiya", "すき家"),
(b"ichiran", "一蘭"),
(b"jiburi", "ジブリ"),
(b"pokemon", "ポケモン"),
(b"suika", "スイカ"),
];
for (input, expected) in cases {
let mut e = JapaneseEngine::new();
for b in *input {
e.handle_letter(*b);
}
let cands = e.candidates();
assert!(
cands.iter().any(|c| c.word == *expected),
"expected `{}` for `{}`, got {:?}",
expected,
std::str::from_utf8(input).unwrap(),
cands.iter().map(|c| &c.word).collect::<Vec<_>>()
);
}
}
#[test]
fn counter_word_outranks_kana() {
let mut e = JapaneseEngine::new();
for b in b"hitori" {
e.handle_letter(*b);
}
let cands = e.candidates();
let one_person_idx = cands.iter().position(|c| c.word == "一人");
let hiragana_idx = cands.iter().position(|c| c.word == "ひとり");
assert!(
one_person_idx.is_some(),
"no `一人` candidate for `hitori`, got {:?}",
cands.iter().map(|c| &c.word).collect::<Vec<_>>()
);
if let (Some(o), Some(h)) = (one_person_idx, hiragana_idx) {
assert!(
o < h,
"`一人` (#{o}) should rank above `ひとり` (#{h}), got {:?}",
cands.iter().map(|c| &c.word).collect::<Vec<_>>()
);
}
}
}