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
//! data/*.txt(libhangul 유래, `key:value:comment`)를 파싱해
//! 이진 탐색용 정렬 정적 테이블 소스(tables.rs)를 생성한다. 외부 의존성 없음.

use std::collections::HashMap;
use std::env;
use std::fmt::Write as _;
use std::fs;
use std::path::PathBuf;

type Group = (char, Vec<(char, String)>);

fn parse(path: &str) -> Vec<Group> {
    let src = fs::read_to_string(path).unwrap_or_else(|e| panic!("{path}: {e}"));
    let mut groups: HashMap<char, Vec<(char, String)>> = HashMap::new();
    let mut order: Vec<char> = Vec::new();
    for line in src.lines() {
        let line = line.trim_end_matches('\r');
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let mut fields = line.splitn(3, ':');
        let key = single_char(fields.next().unwrap_or(""), path, line);
        let value = single_char(fields.next().unwrap_or(""), path, line);
        let comment = fields.next().unwrap_or("");
        if !groups.contains_key(&key) {
            order.push(key);
        }
        groups
            .entry(key)
            .or_default()
            .push((value, comment.to_string()));
    }
    let mut entries: Vec<Group> = order
        .into_iter()
        .map(|k| {
            let g = groups.remove(&k).unwrap();
            (k, g)
        })
        .collect();
    entries.sort_by_key(|e| e.0);
    entries
}

fn single_char(field: &str, path: &str, line: &str) -> char {
    let mut chars = field.chars();
    match (chars.next(), chars.next()) {
        (Some(c), None) => c,
        _ => panic!("{path}: field {field:?} is not a single char in line {line:?}"),
    }
}

/// mssymbol.txt 원본에 날개셋/최신 Windows 보정을 적용한 판을 만든다.
/// - ㄹ 4번째 자리: 중복 배정된 전각 F를 °(도 기호)로 교체 (날개셋 동작)
/// - ㅁ 맨 끝: ㉾ 추가 (최신 Windows MS IME 동작)
fn revise(symbols: &[Group]) -> Vec<Group> {
    let mut revised = symbols.to_vec();
    let rieul = revised
        .iter_mut()
        .find(|g| g.0 == '')
        .expect("mssymbol.txt: ㄹ group missing");
    assert_eq!(
        rieul.1.get(3).map(|e| e.0),
        Some(''),
        "mssymbol.txt: expected duplicated fullwidth F at ㄹ[3]; data changed upstream?"
    );
    assert!(
        !rieul.1.iter().any(|e| e.0 == '°'),
        "mssymbol.txt: ° already present at ㄹ; data changed upstream?"
    );
    rieul.1[3].0 = '°';
    let mieum = revised
        .iter_mut()
        .find(|g| g.0 == '')
        .expect("mssymbol.txt: ㅁ group missing");
    assert!(
        !mieum.1.iter().any(|e| e.0 == ''),
        "mssymbol.txt: ㉾ already present at ㅁ; data changed upstream?"
    );
    mieum.1.push(('', String::new()));
    revised
}

fn emit_hanja(out: &mut String, entries: &[Group]) {
    out.push_str("pub(crate) static HANJA: &[(char, &[(char, &str)])] = &[\n");
    for (key, candidates) in entries {
        write!(out, "    ({key:?}, &[").unwrap();
        for (value, comment) in candidates {
            write!(out, "({value:?}, {comment:?}), ").unwrap();
        }
        out.push_str("]),\n");
    }
    out.push_str("];\n");
}

fn emit_symbols(out: &mut String, name: &str, entries: &[Group]) {
    writeln!(out, "pub(crate) static {name}: &[(char, &[char])] = &[").unwrap();
    for (key, candidates) in entries {
        write!(out, "    ({key:?}, &[").unwrap();
        for (value, _) in candidates {
            write!(out, "{value:?}, ").unwrap();
        }
        out.push_str("]),\n");
    }
    out.push_str("];\n");
}

fn main() {
    println!("cargo:rerun-if-changed=build.rs");
    println!("cargo:rerun-if-changed=data/hanja.txt");
    println!("cargo:rerun-if-changed=data/mssymbol.txt");

    let hanja = parse("data/hanja.txt");
    let symbols = parse("data/mssymbol.txt");
    let revised = revise(&symbols);

    let mut out = String::new();
    emit_hanja(&mut out, &hanja);
    emit_symbols(&mut out, "SYMBOLS", &symbols);
    emit_symbols(&mut out, "SYMBOLS_REVISED", &revised);

    let dest = PathBuf::from(env::var("OUT_DIR").unwrap()).join("tables.rs");
    fs::write(dest, out).unwrap();
}