rusty_expressions 0.2.1

Oniguruma remade in pure Rust: named groups, look-around, backreferences, subexp calls, absent expressions, callouts, per-regex encodings (UTF-8/16/32, Shift_JIS, Big5, EUC-*, GB18030, ISO-8859-*) and Perl/Python/Java/POSIX/GNU/Emacs/grep syntax dialects. Match-equivalent to Oniguruma 6.9.10 and ~3x faster than libonig. no_std + alloc with default-features = false, runs on wasm32, no C toolchain.
Documentation
//! Character types and Unicode 16.0-oriented properties (doc/RE).

extern crate alloc;

use super::encoding::Encoding;
use super::syntax::Options;
use super::ucd16;

/// The POSIX character classes, as Oniguruma names them.
///
/// The class tests dispatch on `enc_ctype` bit constants rather than on this
/// enum; it is the readable vocabulary for the same set.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Ctype {
    Word,
    Digit,
    Space,
    Xdigit,
    Alnum,
    Alpha,
    Ascii,
    Blank,
    Cntrl,
    Graph,
    Lower,
    Print,
    Punct,
    Upper,
    Hiragana,
    Katakana,
    Newline,
    /// Unicode general category coarse: C L M N P S Z
    Gc(u8),
}

pub fn is_ascii_word(cp: u32) -> bool {
    matches!(cp, 0x30..=0x39 | 0x41..=0x5a | 0x61..=0x7a | 0x5f)
}

pub fn is_ascii_digit(cp: u32) -> bool {
    (0x30..=0x39).contains(&cp)
}

pub fn is_ascii_space(cp: u32) -> bool {
    matches!(cp, 0x09 | 0x0a | 0x0b | 0x0c | 0x0d | 0x20)
}

pub fn is_xdigit(cp: u32) -> bool {
    is_ascii_digit(cp) || (0x41..=0x46).contains(&cp) || (0x61..=0x66).contains(&cp)
}

fn ch(cp: u32) -> Option<char> {
    char::from_u32(cp)
}

/// Unicode word: Letter | Mark | Number | Connector_Punctuation (doc/RE).
pub fn is_unicode_word(cp: u32) -> bool {
    if is_ascii_word(cp) {
        return true;
    }
    let n = ucd16::gc_name(ucd16::gc(cp));
    matches!(n.as_bytes().first().copied(), Some(b'L' | b'M' | b'N')) || n == "Pc"
}

fn is_mark(cp: u32) -> bool {
    matches!(ucd16::gc(cp), 6 | 7 | 8)
}

pub fn is_unicode_space(cp: u32) -> bool {
    matches!(
        cp,
        0x09 | 0x0a | 0x0b | 0x0c | 0x0d | 0x20 | 0x85 | 0xa0 | 0x1680 | 0x2000..=0x200a
            | 0x2028 | 0x2029 | 0x202f | 0x205f | 0x3000
    )
}

pub fn is_decimal_number(cp: u32) -> bool {
    ucd16::gc(cp) == 9
}

pub fn is_hiragana(cp: u32) -> bool {
    matches!(cp, 0x3041..=0x3096 | 0x3099..=0x309f | 0x1b001..=0x1b11f)
}

pub fn is_katakana(cp: u32) -> bool {
    matches!(cp, 0x30a0..=0x30ff | 0x31f0..=0x31ff | 0xff65..=0xff9f | 0x32d0..=0x32fe)
}

pub fn is_gc(cp: u32, letter: u8) -> bool {
    ucd16::gc_name(ucd16::gc(cp)).as_bytes().first().copied() == Some(letter)
}

/// Character class for a byte in a non-Unicode single-byte encoding.
///
/// Those encodings decode a byte to itself, not to a Unicode codepoint, so a
/// Unicode property lookup would be answering about the wrong character: byte
/// 0xC0 is `A-grave` in Latin-1 but Cyrillic `A` in CP1251. Oniguruma carries
/// per-encoding tables and, as its remake, so do we -- see `enc_ctype.rs`,
/// generated from libonig.
///
/// `None` means the caller should use its normal path -- the byte is ASCII, so
/// the ASCII rules apply and the encoding does not come into it.
fn enc_class(enc: Encoding, cp: u32, bit: u16) -> Option<bool> {
    if enc.is_unicode() || cp <= 0x7f {
        return None;
    }
    let Some(t) = super::enc_ctype::table(enc.name()) else {
        // A multi-byte (CJK) encoding. Oniguruma's `onigenc_mb2_is_code_ctype`
        // answers these without a table: a non-ASCII character is word, graph
        // and print if it is genuinely multi-byte, and is nothing else -- not
        // space, not alpha, not digit. We previously transcoded to Unicode and
        // asked Unicode, which disagrees: byte 0xA0 in Shift_JIS became U+00A0
        // and so counted as whitespace, making `\s+` match where libonig
        // matches nothing.
        //
        // The length test is load-bearing, not decoration. Shift_JIS spells
        // halfwidth katakana in one byte, and libonig does NOT call those word
        // characters; without the test `\w+` swallowed them.
        const MB_TRUE: u16 = super::enc_ctype::WORD
            | super::enc_ctype::GRAPH
            | super::enc_ctype::PRINT;
        return Some(bit & MB_TRUE != 0 && enc.code_mbc_len(cp) > 1);
    };
    if cp > 0xff {
        // A single-byte encoding cannot produce this; treat as no match.
        return Some(false);
    }
    Some(t[(cp - 0x80) as usize] & bit != 0)
}

pub fn is_word(enc: Encoding, opt: Options, cp: u32) -> bool {
    if opt.contains(Options::WORD_IS_ASCII) || opt.contains(Options::POSIX_IS_ASCII) {
        return is_ascii_word(cp);
    }
    if let Some(hit) = enc_class(enc, cp, super::enc_ctype::WORD) {
        return hit;
    }
    if !enc.is_unicode() {
        // CJK: the decode above produced a Unicode codepoint.
        return if cp <= 0x7f {
            is_ascii_word(cp)
        } else {
            is_unicode_word(cp)
        };
    }
    is_unicode_word(cp)
}

pub fn is_digit(enc: Encoding, opt: Options, cp: u32) -> bool {
    if opt.contains(Options::DIGIT_IS_ASCII) || opt.contains(Options::POSIX_IS_ASCII) {
        return is_ascii_digit(cp);
    }
    if let Some(hit) = enc_class(enc, cp, super::enc_ctype::DIGIT) {
        return hit;
    }
    if !enc.is_unicode() {
        return if cp <= 0x7f {
            is_ascii_digit(cp)
        } else {
            is_decimal_number(cp)
        };
    }
    is_decimal_number(cp)
}

pub fn is_space(enc: Encoding, opt: Options, cp: u32) -> bool {
    if opt.contains(Options::SPACE_IS_ASCII) || opt.contains(Options::POSIX_IS_ASCII) {
        return is_ascii_space(cp);
    }
    if let Some(hit) = enc_class(enc, cp, super::enc_ctype::SPACE) {
        return hit;
    }
    if !enc.is_unicode() {
        return if cp <= 0x7f {
            is_ascii_space(cp)
        } else {
            is_unicode_space(cp)
        };
    }
    is_unicode_space(cp)
}

pub fn posix(name: &str, enc: Encoding, opt: Options, cp: u32) -> bool {
    // A non-Unicode single-byte encoding answers from its own table, for the
    // same reason `is_word` does: the byte is not a Unicode codepoint.
    if !opt.contains(Options::POSIX_IS_ASCII) {
        let bit = match name {
            "alpha" => Some(super::enc_ctype::ALPHA),
            "alnum" => Some(super::enc_ctype::ALNUM),
            "upper" => Some(super::enc_ctype::UPPER),
            "lower" => Some(super::enc_ctype::LOWER),
            "punct" => Some(super::enc_ctype::PUNCT),
            "print" => Some(super::enc_ctype::PRINT),
            "graph" => Some(super::enc_ctype::GRAPH),
            "cntrl" => Some(super::enc_ctype::CNTRL),
            "xdigit" => Some(super::enc_ctype::XDIGIT),
            "space" => Some(super::enc_ctype::SPACE),
            "digit" => Some(super::enc_ctype::DIGIT),
            "word" => Some(super::enc_ctype::WORD),
            _ => None,
        };
        if let Some(bit) = bit {
            if let Some(hit) = enc_class(enc, cp, bit) {
                return hit;
            }
        }
    }
    let ascii = opt.contains(Options::POSIX_IS_ASCII) || !enc.is_unicode();
    match name {
        "alnum" => {
            if ascii {
                is_ascii_word(cp) && cp != 0x5f
            } else {
                ch(cp).map(|c| c.is_alphabetic()).unwrap_or(false) || is_decimal_number(cp)
            }
        }
        "alpha" => {
            if ascii {
                matches!(cp, 0x41..=0x5a | 0x61..=0x7a)
            } else {
                ch(cp).map(|c| c.is_alphabetic()).unwrap_or(false)
            }
        }
        "ascii" => cp <= 0x7f,
        "blank" => cp == 0x09 || cp == 0x20 || (!ascii && matches!(cp, 0x2000..=0x200a | 0x3000 | 0xa0)),
        "cntrl" => {
            if ascii {
                cp <= 0x1f || cp == 0x7f
            } else {
                cp <= 0x1f || (0x7f..=0x9f).contains(&cp)
            }
        }
        "digit" => is_digit(enc, opt, cp),
        "graph" => {
            if ascii {
                (0x21..=0x7e).contains(&cp)
            } else {
                !is_unicode_space(cp) && !posix("cntrl", enc, opt, cp)
            }
        }
        "lower" => {
            if ascii {
                (0x61..=0x7a).contains(&cp)
            } else {
                ch(cp).map(|c| c.is_lowercase()).unwrap_or(false)
            }
        }
        "print" => posix("graph", enc, opt, cp) || posix("space", enc, opt, cp),
        "punct" => {
            if ascii {
                matches!(cp, 0x21..=0x2f | 0x3a..=0x40 | 0x5b..=0x60 | 0x7b..=0x7e)
            } else {
                is_gc(cp, b'P') || is_gc(cp, b'S')
            }
        }
        "space" => is_space(enc, opt, cp),
        "upper" => {
            if ascii {
                (0x41..=0x5a).contains(&cp)
            } else {
                ch(cp).map(|c| c.is_uppercase()).unwrap_or(false)
            }
        }
        "xdigit" => is_xdigit(cp),
        "word" => is_word(enc, opt, cp),
        "hiragana" => is_hiragana(cp),
        "katakana" => is_katakana(cp),
        _ => false,
    }
}

pub fn property(name: &str, enc: Encoding, opt: Options, cp: u32) -> bool {
    let n = name;
    if n.len() == 1 {
        return is_gc(cp, n.as_bytes()[0]);
    }
    if n.len() == 2 {
        if let Some(hit) = gc_detail(cp, n) {
            return hit;
        }
    }
    match n {
        "Alnum" | "alnum" => posix("alnum", enc, opt, cp),
        "Alpha" | "alpha" => posix("alpha", enc, opt, cp),
        "Blank" | "blank" => posix("blank", enc, opt, cp),
        "Cntrl" | "cntrl" => posix("cntrl", enc, opt, cp),
        "Digit" | "digit" => posix("digit", enc, opt, cp),
        "Graph" | "graph" => posix("graph", enc, opt, cp),
        "Lower" | "lower" => posix("lower", enc, opt, cp),
        "Print" | "print" => posix("print", enc, opt, cp),
        "Punct" | "punct" => posix("punct", enc, opt, cp),
        "Space" | "space" => posix("space", enc, opt, cp),
        "Upper" | "upper" => posix("upper", enc, opt, cp),
        "XDigit" | "xdigit" => posix("xdigit", enc, opt, cp),
        "Word" | "word" => posix("word", enc, opt, cp),
        "ASCII" | "ascii" => posix("ascii", enc, opt, cp),
        "Hiragana" | "hiragana" | "Hira" => is_hiragana(cp),
        "Katakana" | "katakana" | "Kana" => is_katakana(cp),
        "Letter" => is_gc(cp, b'L'),
        "Mark" => is_gc(cp, b'M'),
        "Number" => is_gc(cp, b'N'),
        "Punctuation" => is_gc(cp, b'P'),
        "Symbol" => is_gc(cp, b'S'),
        "Separator" => is_gc(cp, b'Z'),
        "Other" => is_gc(cp, b'C'),
        "Any" => true,
        "Assigned" => ucd16::gc(cp) != 0,
        "Latin" | "Latn" => is_latin(cp),
        "Greek" | "Grek" => matches!(cp, 0x0370..=0x03ff | 0x1f00..=0x1fff),
        "Cyrillic" | "Cyrl" => matches!(cp, 0x0400..=0x04ff | 0x0500..=0x052f | 0x2de0..=0x2dff | 0xa640..=0xa69f),
        "Han" | "Hani" => matches!(cp, 0x3400..=0x4dbf | 0x4e00..=0x9fff | 0xf900..=0xfaff | 0x20000..=0x2a6df),
        "Hangul" | "Hang" => matches!(cp, 0x1100..=0x11ff | 0x3130..=0x318f | 0xac00..=0xd7af),
        "Common" | "Zyyy" => !is_latin(cp) && !is_hiragana(cp) && !is_katakana(cp),
        _ => posix(n, enc, opt, cp),
    }
}

/// Unicode 16.0 general-category detail from committed UCD tables.
fn gc_detail(cp: u32, name: &str) -> Option<bool> {
    const GCS: &[&str] = &[
        "Lu", "Ll", "Lt", "Lm", "Lo", "Mn", "Mc", "Me", "Nd", "Nl", "No", "Pc", "Pd", "Ps",
        "Pe", "Pi", "Pf", "Po", "Sm", "Sc", "Sk", "So", "Zs", "Zl", "Zp", "Cc", "Cf", "Cs",
        "Co", "Cn",
    ];
    if GCS.contains(&name) {
        Some(ucd16::gc_eq(cp, name))
    } else {
        None
    }
}

fn is_latin(cp: u32) -> bool {
    matches!(
        cp,
        0x0041..=0x005a
            | 0x0061..=0x007a
            | 0x00c0..=0x00d6
            | 0x00d8..=0x00f6
            | 0x00f8..=0x024f
            | 0x1e00..=0x1eff
            | 0x2c60..=0x2c7f
            | 0xa720..=0xa7ff
            | 0xab30..=0xab6f
    )
}

/// Extended grapheme cluster boundary (UAX #29 subset): break except CR+LF and Hangul.
pub fn grapheme_break(prev: Option<u32>, cur: u32) -> bool {
    match prev {
        None => true,
        Some(0x0d) if cur == 0x0a => false,
        Some(_) if cur == 0x0a || cur == 0x0d => true,
        Some(_) if is_mark(cur) => false,
        Some(p) if (0x1100..=0x11ff).contains(&p) && (0x1100..=0x11ff).contains(&cur) => false,
        _ => true,
    }
}

/// User-defined Unicode property: name -> inclusive ranges.
#[derive(Clone, Debug)]
pub struct UserProperty {
    pub name: alloc::string::String,
    pub ranges: alloc::vec::Vec<(u32, u32)>,
}

impl UserProperty {
    pub fn contains(&self, cp: u32) -> bool {
        self.ranges.iter().any(|&(a, b)| (a..=b).contains(&cp))
    }
}