spellabet/
lib.rs

1#![deny(clippy::all)]
2#![warn(clippy::nursery, clippy::pedantic)]
3
4//! # Spelling Alphabet
5//!
6//! A Rust library for transforming text strings into corresponding code words
7//! based on predefined [spelling alphabets][], like the NATO phonetic alphabet.
8//! These alphabets are designed to enhance verbal clarity, especially when
9//! spelling out words over low-fidelity voice channels. This library supports
10//! several standard alphabets and allows for customization to suit specific
11//! communication needs.
12//!
13//! In operation, spellabet preserves the original capitalization of letters by
14//! returning either lowercase or uppercase code words. It similarly converts
15//! known digits and other symbols into code words, while unrecognized
16//! characters are returned unconverted.
17//!
18//! This library powers the command line utility `spellout`, which provides a
19//! handy interface for phonetic conversions. Check out [spellout on GitHub][]
20//! for more information.
21//!
22//! [spelling alphabets]: https://en.wikipedia.org/wiki/Spelling_alphabet
23//! [spellout on GitHub]: https://github.com/EarthmanMuons/spellout/
24//!
25//! # Example
26//!
27//! ```
28//! use spellabet::{PhoneticConverter, SpellingAlphabet};
29//!
30//! let converter = PhoneticConverter::new(&SpellingAlphabet::Nato);
31//! println!("{}", converter.convert("Example123!"));
32//! ```
33//!
34//! ```text
35//! ECHO x-ray alfa mike papa lima echo One Two Tree Exclamation
36//! ```
37
38use std::char;
39use std::cmp::Ordering;
40use std::collections::HashMap;
41
42use code_words::{
43    DEFAULT_DIGITS_AND_SYMBOLS, JAN_ALPHABET, LAPD_ALPHABET, NATO_ALPHABET, ROYAL_NAVY_ALPHABET,
44    US_FINANCIAL_ALPHABET, WESTERN_UNION_ALPHABET,
45};
46use convert_case::{Case, Casing};
47
48mod code_words;
49
50/// A phonetic converter.
51pub struct PhoneticConverter {
52    /// The map of characters to code words.
53    conversion_map: HashMap<char, String>,
54    /// Is set when the code word output will be in "nonce form".
55    nonce_form: bool,
56}
57
58/// A spelling alphabet.
59#[derive(Default)]
60pub enum SpellingAlphabet {
61    /// The JAN (Joint Army/Navy) spelling alphabet.
62    Jan,
63    /// The LAPD (Los Angeles Police Department) spelling alphabet.
64    Lapd,
65    /// The NATO (North Atlantic Treaty Organization) spelling alphabet.
66    /// This is the default.
67    #[default]
68    Nato,
69    /// The Royal Navy spelling alphabet.
70    RoyalNavy,
71    /// The United States Financial Industry spelling alphabet.
72    UsFinancial,
73    /// The Western Union spelling alphabet.
74    WesternUnion,
75}
76
77impl PhoneticConverter {
78    /// Creates and returns a new instance of `PhoneticConverter` using the
79    /// desired spelling alphabet character mappings.
80    ///
81    /// # Arguments
82    ///
83    /// * `alphabet` - The [`SpellingAlphabet`] to use for character
84    ///   conversions.
85    ///
86    /// # Examples
87    ///
88    ///
89    /// ```
90    /// # use spellabet::{PhoneticConverter, SpellingAlphabet};
91    /// let converter = PhoneticConverter::new(&SpellingAlphabet::default());
92    /// ```
93    #[must_use]
94    pub fn new(alphabet: &SpellingAlphabet) -> Self {
95        let conversion_map = alphabet.initialize();
96
97        Self {
98            conversion_map,
99            nonce_form: false,
100        }
101    }
102
103    /// Get the current character mappings of the `PhoneticConverter` instance.
104    #[must_use]
105    pub const fn mappings(&self) -> &HashMap<char, String> {
106        &self.conversion_map
107    }
108
109    /// Configures the current `PhoneticConverter` instance to either output
110    /// code words in "nonce form" or not, based on the given boolean value.
111    ///
112    /// Nonce form means each letter character is expanded into the form "'A' as
113    /// in ALFA". Digits and symbols are always returned using the normal output
114    /// format.
115    ///
116    /// # Arguments
117    ///
118    /// * `nonce_form` - If true, enables nonce form output. Otherwise, the
119    ///   normal output format is used.
120    ///
121    /// # Examples
122    ///
123    /// ```
124    /// # use spellabet::{PhoneticConverter, SpellingAlphabet};
125    /// let converter = PhoneticConverter::new(&SpellingAlphabet::default()).nonce_form(true);
126    /// println!("{}", converter.convert("Hello"));
127    /// ```
128    ///
129    /// ```text
130    /// 'H' as in HOTEL, 'e' as in echo, 'l' as in lima, 'l' as in lima, 'o' as in oscar
131    /// ```
132    #[must_use]
133    pub const fn nonce_form(mut self, nonce_form: bool) -> Self {
134        self.nonce_form = nonce_form;
135        self
136    }
137
138    /// Modifies the conversion map of the current `PhoneticConverter` instance
139    /// by adding or replacing mappings based on the given overrides map.
140    ///
141    /// # Arguments
142    ///
143    /// * `overrides_map` - The desired character to code word mappings to
144    ///   override. The capitalization of the keys and values will be
145    ///   automatically normalized.
146    ///
147    /// # Examples
148    ///
149    /// ```
150    /// use std::collections::HashMap;
151    /// # use spellabet::{PhoneticConverter, SpellingAlphabet};
152    ///
153    /// let mut converter = PhoneticConverter::new(&SpellingAlphabet::default());
154    ///
155    /// let mut overrides_map = HashMap::new();
156    /// overrides_map.insert('a', "Apple".to_string());
157    /// overrides_map.insert('b', "Banana".to_string());
158    ///
159    /// println!("BEFORE: {}", converter.convert("abcd"));
160    /// ```
161    ///
162    /// ```text
163    /// BEFORE: alfa bravo charlie delta
164    /// ```
165    ///
166    /// ```
167    /// # use std::collections::HashMap;
168    /// # use spellabet::{PhoneticConverter, SpellingAlphabet};
169    /// # let mut converter = PhoneticConverter::new(&SpellingAlphabet::default());
170    /// # let mut overrides_map = HashMap::new();
171    /// # overrides_map.insert('a', "Apple".to_string());
172    /// # overrides_map.insert('b', "Banana".to_string());
173    /// converter = converter.with_overrides(overrides_map);
174    /// println!("AFTER: {}", converter.convert("abcd"));
175    /// ```
176    ///
177    /// ```text
178    /// AFTER: apple banana charlie delta
179    /// ```
180    #[must_use]
181    pub fn with_overrides(mut self, overrides_map: HashMap<char, String>) -> Self {
182        let normalized_overrides: HashMap<char, String> = overrides_map
183            .into_iter()
184            .map(|(k, v)| (k.to_ascii_lowercase(), v.to_case(Case::Pascal)))
185            .collect();
186
187        self.conversion_map.extend(normalized_overrides);
188        self
189    }
190
191    /// Converts the given text into a string of code words using the current
192    /// character mappings of the `PhoneticConverter` instance.
193    ///
194    /// # Arguments
195    ///
196    /// * `text` - The text to convert into code words.
197    ///
198    /// # Examples
199    ///
200    /// ```
201    /// # use spellabet::{PhoneticConverter, SpellingAlphabet};
202    /// let converter = PhoneticConverter::new(&SpellingAlphabet::default());
203    /// assert_eq!(converter.convert("Hello"), "HOTEL echo lima lima oscar");
204    /// ```
205    #[must_use]
206    pub fn convert(&self, text: &str) -> String {
207        let mut result = String::new();
208
209        for (i, c) in text.chars().enumerate() {
210            // add separator between converted characters
211            if i != 0 {
212                if self.nonce_form {
213                    result.push_str(", ");
214                } else {
215                    result.push(' ');
216                }
217            }
218            self.convert_char(c, &mut result);
219        }
220        result
221    }
222
223    fn convert_char(&self, character: char, result: &mut String) {
224        match self.conversion_map.get(&character.to_ascii_lowercase()) {
225            Some(word) => {
226                let code_word = match character {
227                    _ if character.is_lowercase() => word.to_lowercase(),
228                    _ if character.is_uppercase() => word.to_uppercase(),
229                    _ => word.clone(),
230                };
231
232                if self.nonce_form && character.is_alphabetic() {
233                    result.push_str(&format!("'{character}' as in {code_word}"));
234                } else {
235                    result.push_str(&code_word);
236                }
237            }
238            None => result.push(character),
239        }
240    }
241
242    /// Writes the current character mappings of the `PhoneticConverter`
243    /// instance to the given writer.
244    ///
245    /// # Arguments
246    ///
247    /// * `writer` - The output destination.
248    /// * `verbose` - If true, dumps all characters. Otherwise, dumps only
249    ///   letter characters.
250    ///
251    /// # Errors
252    ///
253    /// This function will return an error if writing to the provided writer
254    /// fails. The specific conditions under which this may occur depend on the
255    /// nature of the writer.
256    ///
257    /// # Examples
258    ///
259    /// ```
260    /// # use spellabet::{PhoneticConverter, SpellingAlphabet};
261    /// let converter = PhoneticConverter::new(&SpellingAlphabet::default());
262    ///
263    /// let mut buf = Vec::new();
264    /// let verbose = false;
265    /// converter.dump_alphabet(&mut buf, verbose)?;
266    /// let output = String::from_utf8(buf)?;
267    /// println!("{output}");
268    /// # Ok::<(), Box<dyn std::error::Error>>(())
269    /// ```
270    ///
271    /// ```text
272    /// a -> Alfa
273    /// b -> Bravo
274    /// c -> Charlie
275    /// ...
276    /// ```
277    pub fn dump_alphabet(
278        &self,
279        mut writer: impl std::io::Write,
280        verbose: bool,
281    ) -> std::io::Result<()> {
282        let mut entries: Vec<_> = self.conversion_map.iter().collect();
283        entries.sort_by(|a, b| custom_char_ordering(*a.0, *b.0));
284        for (character, code_word) in entries {
285            if verbose || character.is_alphabetic() {
286                writeln!(writer, "{character} -> {code_word}")?;
287            }
288        }
289        Ok(())
290    }
291}
292
293// Sort characters in the order of letters before digits before symbols.
294// Within each group, characters will be sorted in their natural order.
295fn custom_char_ordering(a: char, b: char) -> Ordering {
296    match (
297        a.is_alphabetic(),
298        b.is_alphabetic(),
299        a.is_numeric(),
300        b.is_numeric(),
301    ) {
302        (true, false, _, _) | (false, false, true, false) => Ordering::Less,
303        (false, true, _, _) | (false, false, false, true) => Ordering::Greater,
304        _ => a.cmp(&b),
305    }
306}
307
308impl SpellingAlphabet {
309    /// Generates and returns a character to code word map based on the current
310    /// `SpellingAlphabet`.
311    #[must_use]
312    pub fn initialize(&self) -> HashMap<char, String> {
313        let mut map: HashMap<char, String> = HashMap::new();
314
315        let extend_map = |map: &mut HashMap<char, String>, source_map: &[(char, &str)]| {
316            for (k, v) in source_map {
317                map.insert(*k, (*v).to_string());
318            }
319        };
320
321        extend_map(&mut map, &DEFAULT_DIGITS_AND_SYMBOLS);
322
323        match self {
324            Self::Jan => extend_map(&mut map, &JAN_ALPHABET),
325            Self::Lapd => extend_map(&mut map, &LAPD_ALPHABET),
326            Self::Nato => extend_map(&mut map, &NATO_ALPHABET),
327            Self::RoyalNavy => extend_map(&mut map, &ROYAL_NAVY_ALPHABET),
328            Self::UsFinancial => extend_map(&mut map, &US_FINANCIAL_ALPHABET),
329            Self::WesternUnion => extend_map(&mut map, &WESTERN_UNION_ALPHABET),
330        };
331
332        map
333    }
334}