iriencoding 1.0.4

Encoding and decoding of IRI (RFC 3987) segments
Documentation
// SPDX-FileCopyrightText: 2025 2025 Dominik George <nik@naturalnet.de>
//
// SPDX-License-Identifier: Apache-2.0 OR MIT

//! Encoding and decoding of IRI (RFC 3987) segments

const HEX_CHARS: &[u8; 16] = b"0123456789ABCDEF";

#[inline(always)]
fn is_ucschar(c: &char) -> bool {
    ('\u{d0}'..='\u{d7ff}').contains(c)
        || ('\u{f900}'..='\u{fdcf}').contains(c)
        || ('\u{fdf0}'..='\u{ffef}').contains(c)
        || ('\u{10000}'..='\u{1fffd}').contains(c)
        || ('\u{20000}'..='\u{2fffd}').contains(c)
        || ('\u{30000}'..='\u{3fffd}').contains(c)
        || ('\u{40000}'..='\u{4fffd}').contains(c)
        || ('\u{50000}'..='\u{5fffd}').contains(c)
        || ('\u{60000}'..='\u{6fffd}').contains(c)
        || ('\u{70000}'..='\u{7fffd}').contains(c)
        || ('\u{80000}'..='\u{8fffd}').contains(c)
        || ('\u{90000}'..='\u{9fffd}').contains(c)
        || ('\u{a0000}'..='\u{afffd}').contains(c)
        || ('\u{b0000}'..='\u{bfffd}').contains(c)
        || ('\u{c0000}'..='\u{cfffd}').contains(c)
        || ('\u{d0000}'..='\u{dfffd}').contains(c)
        || ('\u{e0000}'..='\u{efffd}').contains(c)
}

fn pct_encode(c: &char, buf: &mut [u8; 12]) -> usize {
    let mut temp_buf: [u8; 4] = [0; 4];
    c.encode_utf8(&mut temp_buf);

    let len = c.len_utf8();
    let mut bnum = 0usize;
    let mut offset = 0usize;
    while bnum < c.len_utf8() {
        buf[offset] = b'%';
        buf[offset + 1] = HEX_CHARS[(temp_buf[bnum] >> 4) as usize];
        buf[offset + 2] = HEX_CHARS[(temp_buf[bnum] & 15) as usize];

        offset += 3;
        bnum += 1;
    }

    len * 3
}

fn hex_decode(c: char) -> Option<u8> {
    if !('0'..='f').contains(&c) {
        return None;
    }

    let mut buf = [0u8; 1];
    c.encode_utf8(&mut buf);
    let c = buf[0];

    Some(if c.is_ascii_digit() {
        c - b'0'
    } else if (b'a'..=b'f').contains(&c) {
        c - b'a' + 10
    } else if (b'A'..=b'F').contains(&c) {
        c - b'A' + 10
    } else {
        return None;
    })
}

fn pct_decode(a: char, b: char) -> Option<u8> {
    Some((hex_decode(a)? << 4) + hex_decode(b)?)
}

fn encode_ipchar(c: &char, buf: &mut [u8; 12]) -> usize {
    if c.is_alphanumeric()
        || *c == '.'
        || *c == '-'
        || *c == '_'
        || *c == '~'
        || *c == ':'
        || *c == '@'
        || *c == '!'
        || *c == '$'
        || *c == '&'
        || *c == '\''
        || *c == '('
        || *c == ')'
        || *c == '*'
        || *c == '+'
        || *c == ','
        || *c == ';'
        || *c == '='
        || is_ucschar(c)
    {
        c.encode_utf8(buf);
        c.len_utf8()
    } else {
        pct_encode(c, buf)
    }
}

/// Encode a string to be a valid IRI segment
///
/// # Examples
///
/// ```
/// use iriencoding::iriencode;
///
/// assert_eq!(iriencode("Schildkröte"), "Schildkröte");
/// assert_eq!(iriencode("Schildkröten sind grün"), "Schildkröten%20sind%20grün");
/// assert_eq!(iriencode("🦷🪥🐢"), "🦷🪥🐢");
/// ```
pub fn iriencode(source: &str) -> String {
    let mut buf = [0u8; 12];
    let mut target = String::with_capacity(source.len() * 3);
    let mut len;

    for c in source.chars() {
        len = encode_ipchar(&c, &mut buf);
        target.push_str(str::from_utf8(&buf[0..len]).expect("input was valid UTF-8"));
    }

    target.shrink_to_fit();
    target
}

/// Decode a string if it has valid IRI encoding
///
/// Returns [None] if the encoding is invalid.
///
/// # Examples
///
/// ```
/// use iriencoding::iridecode;
///
/// assert_eq!(iridecode("Schildkröte"), Some("Schildkröte".into()));
/// assert_eq!(iridecode("Schildkröten%20sind%20grün"), Some("Schildkröten sind grün".into()));
/// assert_eq!(iridecode("%F0%9F%A6%B7%F0%9F%AA%A5%F0%9F%90%A2"), Some("🦷🪥🐢".into()));
///
/// // Invalid percent-encoding
/// assert!(iridecode("Schildkr%öte").is_none());
/// assert!(iridecode("Schildkr%2öte").is_none());
/// // Invalid UTF-8
/// assert!(iridecode("%F0%9F%A6").is_none())
/// ```
pub fn iridecode(source: &str) -> Option<String> {
    let mut target = String::with_capacity(source.len());
    let mut buf = [0u8; 4];

    let mut chars = source.chars();
    while let Some(c) = chars.next() {
        if c == '%' {
            if let (Some(a), Some(b)) = (chars.next(), chars.next()) {
                let byte = pct_decode(a, b)?;
                buf[0] = byte;

                let len = if (b'\xc2'..=b'\xdf').contains(&byte) {
                    2usize
                } else if (b'\xe0'..=b'\xef').contains(&byte) {
                    3usize
                } else if (b'\xf0'..=b'\xf4').contains(&byte) {
                    4usize
                } else {
                    1usize
                };

                for byte in buf.iter_mut().take(len).skip(1) {
                    if let (Some('%'), Some(a), Some(b)) =
                        (chars.next(), chars.next(), chars.next())
                    {
                        *byte = pct_decode(a, b)?;
                    } else {
                        return None;
                    }
                }

                target.push_str(str::from_utf8(&buf[0..len]).ok().unwrap());
            } else {
                return None;
            }
        } else {
            target.push(c);
        }
    }

    Some(target)
}