okpyeon 0.1.0

Korean IME candidate data: hangul reading -> hanja candidates with hun-eum glosses (옥편), plus the MS-IME-style consonant -> special-symbol tables (ㅁ + 한자). Data from libhangul, single-character lookups only.
Documentation
#![cfg_attr(not(test), no_std)]
#![deny(missing_docs)]

//! # okpyeon (옥편)
//!
//! Korean IME candidate data as static tables, for the two conversions every
//! Korean input method needs when the 한자(hanja) key is pressed:
//!
//! - **Hanja lookup** ([`hanja`]): a hangul syllable reading (독음) to its
//!   hanja candidates, each with a hun-eum gloss (훈음, e.g. `"배울 학"`).
//!   Like flipping through a paper 옥편.
//! - **Special symbols** ([`symbols`], [`symbols_revised`]): the MS-IME-style
//!   consonant tables (press `ㅁ` then the 한자 key to pick `※ ☆ ★ …`).
//!
//! Word-level (multi-syllable) conversion is deliberately out of scope; every
//! lookup key is a single `char`.
//!
//! ```
//! let candidates = okpyeon::hanja('학').unwrap();
//! assert_eq!(candidates[0], ('學', "배울 학"));
//!
//! let shapes = okpyeon::symbols('ㅁ').unwrap();
//! assert!(shapes.contains(&'※'));
//! ```
//!
//! ## Data
//!
//! The tables are compiled in from two [libhangul] data files (vendored in
//! `data/`, BSD-3-Clause, see the crate README for provenance):
//!
//! - `hanja.txt`, filtered to entries whose key and value are each a single
//!   character (multi-syllable word entries, plus two anomalous entries with
//!   multi-character values, are removed). Candidate order preserves the file
//!   order, which libhangul curates common-first (`학` starts with
//!   `學 鶴 虐 …`), so it is directly usable as an IME candidate list.
//!   Glosses may be empty for rare hanja.
//! - `mssymbol.txt`, the consonant-to-symbol tables replicating the Windows
//!   MS IME behavior. 18 keys exist (`ㄱ`-`ㅎ` plus `ㄲ ㄸ ㅃ ㅆ`; `ㅉ` has no
//!   assignment, faithful to MS IME). Keys are compatibility jamo (U+3131..).
//!
//! No allocation, no `std`, no dependencies; lookups are binary searches over
//! sorted static slices.
//!
//! [libhangul]: https://github.com/libhangul/libhangul

include!(concat!(env!("OUT_DIR"), "/tables.rs"));

/// Looks up hanja candidates for a hangul syllable reading (독음).
///
/// Returns `(hanja, hun-eum gloss)` pairs in libhangul's curated
/// common-first order. The gloss is empty for many rare hanja. Returns
/// `None` when the reading has no entry.
///
/// ```
/// assert_eq!(okpyeon::hanja('국').unwrap()[0], ('國', "나라 국"));
/// assert_eq!(okpyeon::hanja('A'), None);
/// ```
pub fn hanja(reading: char) -> Option<&'static [(char, &'static str)]> {
    lookup(HANJA, reading)
}

/// Looks up the MS-IME-style special symbol candidates for a consonant.
///
/// This is the table behind the Windows habit of composing a lone consonant
/// and pressing the 한자 key: `ㄱ` punctuation, `ㄴ` brackets, `ㄷ` math,
/// `ㄹ` units, `ㅁ` general symbols (`※ ☆ ★ …`), `ㅂ` box drawing, and so
/// on. The key must be a compatibility jamo (U+3131..); 18 keys are defined
/// (`ㅉ` intentionally absent, as in MS IME).
///
/// Faithful to libhangul's `mssymbol.txt`, including its two known quirks
/// inherited from old MS IME: `ㄹ`'s 4th slot is a fullwidth `F` (a
/// duplicate of the one in the `ㅍ` fullwidth-latin table) where `°` is
/// expected, and `㉾` is missing. See [`symbols_revised`] for the corrected
/// table.
///
/// ```
/// assert!(okpyeon::symbols('ㅁ').unwrap().contains(&'★'));
/// assert_eq!(okpyeon::symbols('ㅉ'), None);
/// ```
pub fn symbols(consonant: char) -> Option<&'static [char]> {
    lookup(SYMBOLS, consonant)
}

/// Like [`symbols`], with the two known `mssymbol.txt` quirks corrected.
///
/// - `ㄹ`'s 4th candidate is `°` (degree sign) instead of a fullwidth `F`
///   duplicated from the `ㅍ` table, matching the 날개셋 input method.
/// - `ㅁ` gains `㉾` as its last candidate, matching current Windows MS IME.
///
/// Everything else is identical to [`symbols`].
///
/// ```
/// assert_eq!(okpyeon::symbols_revised('ㄹ').unwrap()[3], '°');
/// assert_eq!(okpyeon::symbols_revised('ㅁ').unwrap().last(), Some(&'㉾'));
/// ```
pub fn symbols_revised(consonant: char) -> Option<&'static [char]> {
    lookup(SYMBOLS_REVISED, consonant)
}

fn lookup<T: Copy>(table: &'static [(char, T)], key: char) -> Option<T> {
    table
        .binary_search_by_key(&key, |&(k, _)| k)
        .ok()
        .map(|i| table[i].1)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn assert_sorted_unique<T>(table: &[(char, T)]) {
        assert!(table.windows(2).all(|w| w[0].0 < w[1].0));
    }

    #[test]
    fn tables_are_sorted_and_unique() {
        assert_sorted_unique(HANJA);
        assert_sorted_unique(SYMBOLS);
        assert_sorted_unique(SYMBOLS_REVISED);
    }

    #[test]
    fn no_empty_candidate_list() {
        assert!(HANJA.iter().all(|e| !e.1.is_empty()));
        assert!(SYMBOLS.iter().all(|e| !e.1.is_empty()));
        assert!(SYMBOLS_REVISED.iter().all(|e| !e.1.is_empty()));
    }

    #[test]
    fn symbol_keys_are_the_18_msime_consonants() {
        let expected: Vec<char> = {
            let mut keys: Vec<char> = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅊㅋㅌㅍㅎ".chars().collect();
            keys.sort();
            keys
        };
        let actual: Vec<char> = SYMBOLS.iter().map(|e| e.0).collect();
        assert_eq!(actual, expected);
        let revised: Vec<char> = SYMBOLS_REVISED.iter().map(|e| e.0).collect();
        assert_eq!(revised, expected);
    }

    #[test]
    fn table_sizes_match_vendored_data() {
        assert_eq!(HANJA.iter().map(|e| e.1.len()).sum::<usize>(), 28_472);
        assert_eq!(SYMBOLS.iter().map(|e| e.1.len()).sum::<usize>(), 987);
        assert_eq!(
            SYMBOLS_REVISED.iter().map(|e| e.1.len()).sum::<usize>(),
            988
        );
    }
}