Skip to main content

harper_core/
indefinite_article.rs

1use std::borrow::Cow;
2
3use itertools::Itertools;
4
5use crate::case::Case::Upper;
6use crate::char_ext::CharExt;
7use crate::{CaseIterExt, Dialect};
8
9#[derive(Debug, PartialEq)]
10pub enum InitialSound {
11    Vowel,
12    Consonant,
13    Either,
14}
15
16/// Checks whether a provided word begins with a vowel _sound_. Returns `None` if `word` is empty.
17///
18/// It was produced through trial and error.
19/// Matches with 99.71% and 99.77% of vowels and non-vowels in the
20/// Carnegie-Mellon University word -> pronunciation dataset.
21pub fn starts_with_vowel(word: &[char], dialect: Dialect) -> Option<InitialSound> {
22    if word.is_empty() {
23        return None;
24    }
25
26    if matches!(word, ['L', 'E', 'D'] | ['S', 'Q', 'L'] | ['U', 'R', 'L']) {
27        return Some(InitialSound::Either);
28    }
29
30    // Try to get the first chunk of a word that appears to be a partial initialism.
31    // For example:
32    // - `RFL` from `RFLink`
33    // - `m` from `mDNS`
34    let word = {
35        let word_casing = word.get_casing_unfiltered();
36        match word_casing.as_slice() {
37            // Lower-upper or upper-upper, possibly a (partial) initialism.
38            [Some(first_char_case), Some(Upper), ..] => {
39                &word[0..word_casing
40                    .iter()
41                    .position(|c| *c != Some(*first_char_case))
42                    .unwrap_or(word.len())]
43            }
44            // Lower-lower or upper-lower, unlikely to be a partial initialism.
45            _ => word,
46        }
47    };
48
49    let is_likely_initialism = word.iter().all(|c| !c.is_alphabetic() || c.is_uppercase());
50
51    if word.len() == 1 || (is_likely_initialism && !is_likely_acronym(word)) {
52        return Some(
53            if matches!(
54                word[0].to_ascii_uppercase(),
55                'A' | 'E' | 'F' | 'H' | 'I' | 'L' | 'M' | 'N' | 'O' | 'R' | 'S' | 'X'
56            ) {
57                InitialSound::Vowel
58            } else {
59                InitialSound::Consonant
60            },
61        );
62    }
63
64    let word = to_lower_word(word);
65    let word = word.as_ref();
66
67    if matches!(word, ['u', 'b', 'i', ..]) {
68        return Some(InitialSound::Either);
69    }
70
71    // Treat formats like `mp3` the same as `MP3`: the leading `m` is read as
72    // the letter name “em”, so it takes `an` rather than `a`.
73    if matches!(word, ['m', 'p', digit, ..] if digit.is_ascii_digit()) {
74        return Some(InitialSound::Vowel);
75    }
76
77    // `npm` is officially an all-lowercase initialism pronounced letter-by-letter
78    if matches!(word, ['n', 'p', 'm']) {
79        return Some(InitialSound::Vowel);
80    }
81
82    if matches!(word, ['e', 'u', 'l', 'e', ..]) {
83        return Some(InitialSound::Vowel);
84    }
85
86    if matches!(
87        word,
88        ['u', 'k', ..]
89            | ['u', 'd', 'e', ..] // for 'udev'
90            | ['e', 'u', 'p', 'h', ..]
91            | ['e', 'u', 'g' | 'l' | 'c', ..]
92            | ['o', 'n', 'e', ..]
93            | ['o', 'n', 'c', 'e']
94    ) {
95        return Some(InitialSound::Consonant);
96    }
97
98    if matches!(
99        word,
100        ['h', 'o', 'u', 'r', ..]
101            | ['u', 'n', 'i', 'n' | 'm', ..]
102            | ['u', 'n', 'a' | 'u', ..]
103            | ['u', 'r', 'b', ..]
104            | ['i', 'n', 't', ..]
105    ) {
106        return Some(InitialSound::Vowel);
107    }
108
109    if matches!(word, ['h', 'e', 'r', 'b', ..] if dialect == Dialect::American || dialect == Dialect::Canadian)
110    {
111        return Some(InitialSound::Vowel);
112    }
113
114    if matches!(word, ['u', 'n' | 's', 'i' | 'a' | 'u', ..]) {
115        return Some(InitialSound::Consonant);
116    }
117
118    if matches!(word, ['u', 'n', ..]) {
119        return Some(InitialSound::Vowel);
120    }
121
122    if matches!(word, ['u', 'r', 'g', ..]) {
123        return Some(InitialSound::Vowel);
124    }
125
126    if matches!(word, ['u', 't', 't', ..]) {
127        return Some(InitialSound::Vowel);
128    }
129
130    if matches!(
131        word,
132        ['u', 't' | 'r' | 'n', ..] | ['e', 'u', 'r', ..] | ['u', 'w', ..] | ['u', 's', 'e', ..]
133    ) {
134        return Some(InitialSound::Consonant);
135    }
136
137    if matches!(word, ['o', 'n', 'e', 'a' | 'e' | 'i' | 'u', 'l' | 'd', ..]) {
138        return Some(InitialSound::Vowel);
139    }
140
141    if matches!(word, ['o', 'n', 'e', 'a' | 'e' | 'i' | 'u' | '-' | 's', ..]) {
142        return Some(InitialSound::Consonant);
143    }
144
145    if matches!(
146        word,
147        ['s', 'o', 's']
148            | ['r', 'z', ..]
149            | ['n', 'g', ..]
150            | ['n', 'v', ..]
151            | ['x', 'b', 'o', 'x']
152            | ['h', 'e', 'i', 'r', ..]
153            | ['h', 'o', 'n', 'o', 'r', ..]
154            | ['h', 'o', 'n', 'e', 's', ..]
155    ) {
156        return Some(InitialSound::Vowel);
157    }
158
159    if matches!(
160        word,
161        ['j', 'u' | 'o', 'n', ..] | ['j', 'u', 'r', 'a' | 'i' | 'o', ..]
162    ) {
163        return Some(InitialSound::Consonant);
164    }
165
166    if matches!(word, ['x', '-' | '\'' | '.' | 'o' | 's', ..]) {
167        return Some(InitialSound::Vowel);
168    }
169
170    if word[0].is_vowel() {
171        return Some(InitialSound::Vowel);
172    }
173
174    Some(InitialSound::Consonant)
175}
176
177fn to_lower_word(word: &[char]) -> Cow<'_, [char]> {
178    if word.iter().any(|c| c.is_uppercase()) {
179        Cow::Owned(
180            word.iter()
181                .flat_map(|c| c.to_lowercase())
182                .collect::<Vec<_>>(),
183        )
184    } else {
185        Cow::Borrowed(word)
186    }
187}
188
189fn is_likely_acronym(word: &[char]) -> bool {
190    /// Does the word contain any sequences that might indicate it's not an acronym?
191    fn word_contains_false_positive_sequence(word: &[char]) -> bool {
192        let likely_false_positive_sequences = [['V', 'C']];
193        for fp_sequence in likely_false_positive_sequences {
194            if word
195                .windows(fp_sequence.len())
196                .any(|subslice| subslice == fp_sequence)
197            {
198                return true;
199            }
200        }
201        false
202    }
203
204    // If the initialism is shorter than this, skip it.
205    const MIN_LEN: usize = 3;
206
207    if let Some(first_chars) = word.get(..MIN_LEN)
208        // Unlikely to be an acronym if it contains non-alphabetic characters.
209        && first_chars.iter().copied().all(char::is_alphabetic)
210        && !word_contains_false_positive_sequence(word)
211    {
212        let vowel_map = first_chars
213            .iter()
214            .map(CharExt::is_vowel)
215            .collect_array::<MIN_LEN>()
216            .unwrap();
217        matches!(vowel_map, [false, true, false] | [false, true, true])
218    } else {
219        false
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn test_npm_4139() {
229        assert_eq!(
230            starts_with_vowel(&['n', 'p', 'm'].to_vec(), Dialect::American),
231            Some(InitialSound::Vowel)
232        );
233    }
234}