use super::pronunciation::cmudict;
fn is_vowel(ch: char) -> bool {
matches!(ch, 'a' | 'e' | 'i' | 'o' | 'u')
}
fn is_vowel_at(lower: &[char], index: usize) -> bool {
is_vowel(lower[index]) || (index > 0 && lower[index] == 'y')
}
fn is_valid_onset(first: char, second: char) -> bool {
matches!(
(first, second),
('b', 'l')
| ('b', 'r')
| ('c', 'h')
| ('c', 'l')
| ('c', 'r')
| ('d', 'r')
| ('f', 'l')
| ('f', 'r')
| ('g', 'l')
| ('g', 'r')
| ('k', 'l')
| ('k', 'n')
| ('k', 'r')
| ('p', 'h')
| ('p', 'l')
| ('p', 'r')
| ('q', 'u')
| ('s', 'c')
| ('s', 'h')
| ('s', 'k')
| ('s', 'l')
| ('s', 'm')
| ('s', 'n')
| ('s', 'p')
| ('s', 't')
| ('s', 'w')
| ('t', 'h')
| ('t', 'r')
| ('t', 'w')
| ('w', 'h')
| ('w', 'r')
)
}
fn is_digraph(first: char, second: char) -> bool {
matches!(
(first, second),
('c', 'h') | ('s', 'h') | ('t', 'h') | ('p', 'h') | ('g', 'h') | ('c', 'k') | ('n', 'g')
)
}
fn longest_consonant_cluster(lower: &[char]) -> usize {
let mut longest = 0usize;
let mut run = 0usize;
let mut index = 0usize;
while index < lower.len() {
if is_vowel_at(lower, index) {
run = 0;
index += 1;
continue;
}
run += 1;
longest = longest.max(run);
if lower
.get(index + 1)
.is_some_and(|next| is_digraph(lower[index], *next))
{
index += 2;
} else {
index += 1;
}
}
longest
}
pub(crate) fn is_whole_word_contraction(lower: &str) -> bool {
super::rule_10_2::wordsign(lower).is_some()
|| super::rule_10_3::is_strong_contraction_word(lower)
|| super::rule_10_7::is_initial_letter_contraction_word(lower)
}
fn reads_as_word(uppercase: &[char]) -> bool {
let lower: Vec<char> = uppercase.iter().map(|ch| ch.to_ascii_lowercase()).collect();
if is_whole_word_contraction(&lower.iter().collect::<String>()) {
return true;
}
if lower.len() < 4 || !(0..lower.len()).any(|index| is_vowel_at(&lower, index)) {
return false;
}
if lower.len() >= 5 {
return longest_consonant_cluster(&lower) <= 3;
}
if cmudict::has_word_pronunciation(&lower) {
return true;
}
let starts_with_two_vowels = is_vowel(lower[0]) && is_vowel(lower[1]);
let onset_allowed =
is_vowel(lower[0]) || is_vowel(lower[1]) || is_valid_onset(lower[0], lower[1]);
!starts_with_two_vowels && onset_allowed && longest_consonant_cluster(&lower) <= 2
}
pub(crate) fn is_word_like_roman_token(token: &str) -> bool {
let chars: Vec<char> = token
.chars()
.skip_while(|ch| {
matches!(
ch,
'(' | '[' | '{' | '\u{2018}' | '\u{201c}' | '"' | '\'' | '#'
)
})
.collect();
let run: Vec<char> = chars
.iter()
.take_while(|ch| ch.is_ascii_alphabetic())
.copied()
.collect();
if run.len() < 2
|| chars[..run.len()]
.iter()
.any(|ch| !ch.is_ascii_alphabetic())
{
return false;
}
if run.iter().any(|ch| ch.is_ascii_lowercase()) {
return true;
}
if run.len() <= 3 {
let lower: Vec<char> = run.iter().map(|ch| ch.to_ascii_lowercase()).collect();
return cmudict::has_word_pronunciation(&lower);
}
reads_as_word(&run)
}
pub(crate) fn preceding_token_is_word_like(token: &str) -> bool {
if token.chars().any(crate::utils::is_korean_char) {
return false;
}
let trimmed: String = token
.chars()
.filter(|ch| {
!matches!(
ch,
')' | ']'
| '}'
| '\u{2019}'
| '\u{201d}'
| '"'
| '\''
| ','
| '.'
| ':'
| ';'
| '!'
| '?'
)
})
.collect();
is_word_like_roman_token(&trimmed)
}
pub(crate) fn korean_context_run_is_initialism(run: &[char], in_english_phrase: bool) -> bool {
run.len() >= 2
&& run.iter().all(|ch| ch.is_ascii_uppercase())
&& !in_english_phrase
&& !reads_as_word(run)
}
pub(crate) struct RomanRunPosition<'a> {
pub word_chars: &'a [char],
pub run_start: usize,
pub run_end: usize,
pub prev_word: &'a str,
pub next_word: Option<&'a str>,
}
pub(crate) fn is_letter_initialism_in_korean_text(
state: &crate::rules::context::EncoderState,
position: &RomanRunPosition<'_>,
) -> bool {
let RomanRunPosition {
word_chars,
run_start,
run_end,
prev_word,
next_word,
} = *position;
let korean_document = state.english_indicator && !state.english_dominant_no_indicator;
let run = &word_chars[run_start..run_end];
if !korean_document || !run.iter().all(|ch| ch.is_ascii_uppercase()) {
return false;
}
let is_apostrophe = |ch: &char| matches!(ch, '\'' | '\u{2019}');
let neighbour = |offset: usize| word_chars.get(offset);
let digit_adjacent = run_start
.checked_sub(1)
.and_then(neighbour)
.is_some_and(char::is_ascii_digit)
|| neighbour(run_end).is_some_and(char::is_ascii_digit);
let apostrophe_joined = run_start.checked_sub(2).is_some_and(|before| {
is_apostrophe(&word_chars[before + 1]) && word_chars[before].is_ascii_alphabetic()
}) || (neighbour(run_end).is_some_and(is_apostrophe)
&& neighbour(run_end + 1).is_some_and(char::is_ascii_alphabetic));
if digit_adjacent || apostrophe_joined {
return false;
}
let run_opens_word = !word_chars[..run_start]
.iter()
.any(char::is_ascii_alphanumeric);
let run_closes_word = !word_chars[run_end..]
.iter()
.any(char::is_ascii_alphanumeric);
let in_english_phrase = (run_opens_word && preceding_token_is_word_like(prev_word))
|| (run_closes_word && next_word.is_some_and(is_word_like_roman_token));
korean_context_run_is_initialism(run, in_english_phrase)
}
#[cfg(test)]
mod tests {
use super::*;
fn chars(text: &str) -> Vec<char> {
text.chars().collect()
}
#[rstest::rstest]
#[case::who("WHO")]
#[case::oed("OED")]
#[case::mou("MOU")]
#[case::led("LED")]
#[case::est("EST")]
#[case::gh("GH")]
#[case::no_vowel("MLCC")]
#[case::invalid_onset_qled("QLED")]
#[case::invalid_onset_ccus("CCUS")]
#[case::three_consonants_ustr("USTR")]
#[case::two_leading_vowels("EAFF")]
#[case::long_cluster("EDSCG")]
#[case::unfccc("UNFCCC")]
fn initialisms_are_spelled(#[case] run: &str) {
assert!(korean_context_run_is_initialism(&chars(run), false));
}
#[rstest::rstest]
#[case::fortran("FORTRAN")]
#[case::candu("CANDU")]
#[case::oled("OLED")]
#[case::kaist("KAIST")]
#[case::haccp("HACCP")]
#[case::dictionary_word_gist("GIST")]
#[case::digraph_cluster_lights("LIGHTS")]
#[case::keri("KERI")]
#[case::valid_onset_star("STAR")]
#[case::y_as_vowel_enhypen("ENHYPEN")]
#[case::strong_contraction_the("THE")]
#[case::strong_wordsign_out("OUT")]
#[case::initial_letter_day("DAY")]
fn word_acronyms_keep_contractions(#[case] run: &str) {
assert!(!korean_context_run_is_initialism(&chars(run), false));
}
#[rstest::rstest]
#[case::the("THE")]
#[case::out("OUT")]
fn phrase_members_are_text(#[case] run: &str) {
assert!(!korean_context_run_is_initialism(&chars(run), true));
}
#[rstest::rstest]
#[case::title_case("Fix", true)]
#[case::quoted_lowercase("‘GeForce", true)]
#[case::caps_dictionary_word("DAY’를", true)]
#[case::caps_long_word("WORLD", true)]
#[case::initialism_neighbour("TV는", false)]
#[case::initialism_pair("CJ", false)]
#[case::korean_gloss("네오(Neo)", false)]
#[case::single_letter("A", false)]
fn neighbour_word_detection(#[case] token: &str, #[case] expected: bool) {
assert_eq!(is_word_like_roman_token(token), expected);
}
#[rstest::rstest]
#[case::plain_word("Fix", true)]
#[case::closing_quote("GeForce’", true)]
#[case::korean_gloss_before("네오(Neo)", false)]
#[case::initialism("KBS", false)]
fn preceding_neighbour_detection(#[case] token: &str, #[case] expected: bool) {
assert_eq!(preceding_token_is_word_like(token), expected);
}
#[rstest::rstest]
#[case::lights("lights", 3)]
#[case::kbstar("kbstar", 4)]
#[case::haccp("haccp", 3)]
#[case::oled("oled", 1)]
fn consonant_clusters_are_digraph_aware(#[case] word: &str, #[case] expected: usize) {
assert_eq!(longest_consonant_cluster(&chars(word)), expected);
}
}