use regex::Regex;
use std::sync::LazyLock;
pub fn format_codepoint(c: char) -> String {
let cp = c as u32;
if cp <= 0xFFFF {
format!("U+{cp:04X}")
} else if cp <= 0xFFFFF {
format!("U+{cp:05X}")
} else if cp <= 0x10FFFF {
format!("U+{cp:06X}")
} else {
panic!("Invalid Unicode codepoint: {cp}");
}
}
pub fn parse_single_char(input: &str) -> Option<char> {
let mut chars = input.trim().chars();
let first = chars.next()?;
if chars.next().is_some() {
return None;
}
if first.len_utf8() != input.len() {
return None;
}
Some(first)
}
pub fn normalize_codepoint(input: &str) -> Result<String, String> {
let trimmed = input.trim();
let Some(hex) = trimmed.strip_prefix("U+").or_else(|| trimmed.strip_prefix("u+")) else {
return Err(format!("Invalid codepoint '{trimmed}': expected format U+XXXX"));
};
if !(4..=6).contains(&hex.len()) {
return Err(format!("Invalid codepoint '{trimmed}': expected 4 to 6 hex digits"));
}
if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!("Invalid codepoint '{trimmed}': contains non-hex characters"));
}
let value = u32::from_str_radix(hex, 16).map_err(|_| format!("Invalid codepoint '{trimmed}': parse failed"))?;
if value > 0x10FFFF || (0xD800..=0xDFFF).contains(&value) {
return Err(format!("Invalid codepoint '{trimmed}': out of Unicode range"));
}
Ok(format_codepoint(char::from_u32(value).unwrap()))
}
pub fn parse_codepoint(token: &str) -> Option<char> {
let normalized = normalize_codepoint(token).ok()?;
let hex = normalized
.strip_prefix("U+")
.or_else(|| normalized.strip_prefix("u+"))?;
if hex.len() < 4 || hex.len() > 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let value = u32::from_str_radix(hex, 16).ok()?;
if value > 0x10FFFF || (0xD800..=0xDFFF).contains(&value) {
return None;
}
std::char::from_u32(value)
}
pub fn is_invisible_char(c: char) -> bool {
let cp = c as u32;
matches!(
cp,
0x0000..=0x0008
| 0x000A..=0x001F | 0x007F..=0x009F | 0x00AD | 0x034F | 0x061C | 0x115F | 0x1160 | 0x17B4 | 0x17B5 | 0x180B..=0x180E | 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x206F | 0x3164 | 0xFE00..=0xFE0F | 0xFEFF | 0xFFA0 | 0xFFF0..=0xFFF8 | 0x1BCA0..=0x1BCA3 | 0x1D173..=0x1D17A | 0xE0000..=0xE0FFF )
}
pub fn is_deprecated_char(c: char) -> bool {
let cp = c as u32;
matches!(
cp,
0x0149 | 0x0673 | 0x0F77 | 0x0F79 | 0x17A3..=0x17A4 | 0x206A..=0x206F | 0x2329 | 0x232A | 0xE0001 )
}
pub fn is_unsuitable_for_markup_char(c: char) -> bool {
let cp = c as u32;
matches!(
cp,
0x0340 | 0x0341 | 0xFFF9..=0xFFFC )
}
pub fn is_cjk_letter(c: char) -> bool {
let cp = c as u32;
if cp < 0x1100 {
return false;
}
if matches!(cp, 0x4E00..=0x9FFF | 0x3400..=0x4DBF | 0xAC00..=0xD7A3) {
return true;
}
static CJK_LETTER: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[\p{scx=Han}\p{scx=Hiragana}\p{scx=Katakana}\p{scx=Hangul}--\p{P}--\p{S}--\p{Mn}--\p{Me}]$")
.expect("CJK letter class is a valid regex")
});
let mut buf = [0u8; 4];
CJK_LETTER.is_match(c.encode_utf8(&mut buf))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cjk_letters_cover_han_kana_and_hangul() {
for c in ['中', '々', '〇', '\u{20000}'] {
assert!(is_cjk_letter(c), "{c:?} (U+{:04X}) is a CJK letter", c as u32);
}
for c in ['あ', 'カ', 'ー', 'ハ'] {
assert!(is_cjk_letter(c), "{c:?} (U+{:04X}) is a CJK letter", c as u32);
}
for c in ['한', '\u{1100}'] {
assert!(is_cjk_letter(c), "{c:?} (U+{:04X}) is a CJK letter", c as u32);
}
}
#[test]
fn cjk_punctuation_symbols_and_other_scripts_are_not_letters() {
for c in ['・', '。', '「', '〜', '\u{2E80}', '\u{3000}'] {
assert!(!is_cjk_letter(c), "{c:?} (U+{:04X}) is not a CJK letter", c as u32);
}
for c in ['1', 'T', 'a', '1', 'ㄅ', ' ', 'é'] {
assert!(!is_cjk_letter(c), "{c:?} (U+{:04X}) is not a CJK letter", c as u32);
}
for c in ['\u{3099}', '\u{309A}', '\u{0305}', '\u{0323}'] {
assert!(!is_cjk_letter(c), "{c:?} (U+{:04X}) is not a CJK letter", c as u32);
}
}
}