vize_l1 0.481.0

L1 - the lossless Vue-template surface tree (codename Sinopia, Davinci P2-7)
Documentation
//! Decode at most one HTML entity from the start of a byte slice (`&name;`, `&#...;`, …).
//! Rules align with `htmlize::unescape_bytes_in` (WHATWG), using the same `ENTITIES` map.

use core::cmp::min;
use core::num::IntErrorKind;

use htmlize::{Context, ENTITIES, ENTITY_MAX_LENGTH, ENTITY_MIN_LENGTH};

use super::DecodedEntity;

/// If `input` starts with a valid entity, returns its decoded scalars and the number of
/// bytes consumed (including `&` and an optional `;`). Otherwise `None` so the tokenizer can
/// emit `&` as literal text.
pub(crate) fn try_decode_entity(input: &[u8], context: Context) -> Option<(DecodedEntity, usize)> {
    if input.first() != Some(&b'&') {
        return None;
    }
    if input.get(1) == Some(&b'#') {
        decode_numeric_entity(input)
    } else {
        decode_named_entity(input, context)
    }
}

fn named_expansion(expansion: &'static [u8]) -> Option<DecodedEntity> {
    Some(DecodedEntity::Named(core::str::from_utf8(expansion).ok()?))
}

fn decode_named_entity(input: &[u8], context: Context) -> Option<(DecodedEntity, usize)> {
    let mut j = 1usize;
    let mut steps = 0usize;
    while steps < ENTITY_MAX_LENGTH - 1 {
        if input.get(j).is_some_and(u8::is_ascii_alphanumeric) {
            j += 1;
            steps += 1;
        } else {
            break;
        }
    }

    let mut consumed_end = j;
    match input.get(j).copied() {
        Some(b';') => consumed_end = j + 1,
        Some(b'=') if context == Context::Attribute => return None,
        _ => {}
    }

    if context == Context::Attribute {
        let candidate = input.get(..consumed_end)?;
        if candidate.len() < ENTITY_MIN_LENGTH {
            return None;
        }
        let expansion = ENTITIES.get(candidate)?;
        return Some((named_expansion(expansion)?, consumed_end));
    }

    let max_len = min(consumed_end, ENTITY_MAX_LENGTH);
    for check_len in (ENTITY_MIN_LENGTH..=max_len).rev() {
        if let Some(expansion) = input.get(..check_len).and_then(|name| ENTITIES.get(name)) {
            return Some((named_expansion(expansion)?, check_len));
        }
    }
    None
}

fn decode_numeric_entity(input: &[u8]) -> Option<(DecodedEntity, usize)> {
    if input.len() < 3 || !input.starts_with(b"&#") {
        return None;
    }

    let mut pos = 2usize;
    let number = match input.get(pos).copied() {
        Some(b'x' | b'X') => {
            pos += 1;
            let start = pos;
            while input.get(pos).is_some_and(u8::is_ascii_hexdigit) {
                pos += 1;
            }
            let hex = input.get(start..pos)?;
            if hex.is_empty() {
                return None;
            }
            u32::from_str_radix(core::str::from_utf8(hex).ok()?, 16)
        }
        Some(c) if c.is_ascii_digit() => {
            let start = pos;
            while input.get(pos).is_some_and(u8::is_ascii_digit) {
                pos += 1;
            }
            let dec = input.get(start..pos)?;
            if dec.is_empty() {
                return None;
            }
            core::str::from_utf8(dec).ok()?.parse::<u32>()
        }
        _ => return None,
    };

    let mut end = pos;
    if input.get(pos) == Some(&b';') {
        end = pos + 1;
    }

    let ch = match number {
        Ok(n) => correct_numeric_entity(n),
        Err(e) if *e.kind() == IntErrorKind::PosOverflow => '\u{FFFD}',
        Err(_) => return None,
    };
    Some((DecodedEntity::Numeric(ch), end))
}

/// <https://html.spec.whatwg.org/multipage/parsing.html#numeric-character-reference-end-state>
fn correct_numeric_entity(number: u32) -> char {
    match number {
        0x00 => '\u{FFFD}',
        0x11_0000.. => '\u{FFFD}',
        0xD800..=0xDFFF => '\u{FFFD}',
        0x80 => '\u{20AC}',
        0x82 => '\u{201A}',
        0x83 => '\u{0192}',
        0x84 => '\u{201E}',
        0x85 => '\u{2026}',
        0x86 => '\u{2020}',
        0x87 => '\u{2021}',
        0x88 => '\u{02C6}',
        0x89 => '\u{2030}',
        0x8A => '\u{0160}',
        0x8B => '\u{2039}',
        0x8C => '\u{0152}',
        0x8E => '\u{017D}',
        0x91 => '\u{2018}',
        0x92 => '\u{2019}',
        0x93 => '\u{201C}',
        0x94 => '\u{201D}',
        0x95 => '\u{2022}',
        0x96 => '\u{2013}',
        0x97 => '\u{2014}',
        0x98 => '\u{02DC}',
        0x99 => '\u{2122}',
        0x9A => '\u{0161}',
        0x9B => '\u{203A}',
        0x9C => '\u{0153}',
        0x9E => '\u{017E}',
        0x9F => '\u{0178}',
        c => char::from_u32(c).unwrap_or('\u{FFFD}'),
    }
}

#[cfg(test)]
mod tests {
    use vize_l0::String;

    use super::*;

    fn text(value: DecodedEntity) -> String {
        let mut output = String::default();
        value.for_each(|ch| output.push(ch));
        output
    }

    #[test]
    fn general_named_semicolon() {
        let s = b"&amp;rest";
        let (c, n) = try_decode_entity(s, Context::General).unwrap();
        assert_eq!(text(c), "&");
        assert_eq!(n, 5);
        assert_eq!(&s[n..], b"rest");
    }

    #[test]
    fn general_named_no_semicolon_longest() {
        let s = b"&timesX";
        let (c, n) = try_decode_entity(s, Context::General).unwrap();
        assert_eq!(text(c), "×");
        assert_eq!(n, 6);
    }

    #[test]
    fn attribute_times_x_not_entity() {
        assert!(try_decode_entity(b"&timesX", Context::Attribute).is_none());
    }

    #[test]
    fn numeric_dec() {
        let s = b"&#38;z";
        let (c, n) = try_decode_entity(s, Context::General).unwrap();
        assert_eq!(text(c), "&");
        assert_eq!(n, 5);
    }

    /// `&` + `&amp;`: leading `&` alone is not a valid entity (next byte is `&`).
    #[test]
    fn double_ampersand_amp_from_start_is_none() {
        assert!(try_decode_entity(b"&&amp;", Context::General).is_none());
        assert!(try_decode_entity(b"&&amp;", Context::Attribute).is_none());
    }

    /// Same bytes as tokenizer would pass after consuming the first literal `&`.
    #[test]
    fn double_ampersand_decode_second_reference() {
        let s = b"&&amp;";
        let (c, n) = try_decode_entity(&s[1..], Context::General).unwrap();
        assert_eq!(text(c), "&");
        assert_eq!(n, 5);
        assert_eq!(1 + n, s.len());
    }

    #[test]
    fn hex_numeric() {
        let s = b"&#x26;y";
        let (c, n) = try_decode_entity(s, Context::General).unwrap();
        assert_eq!(text(c), "&");
        assert_eq!(n, 6);
        assert_eq!(&s[n..], b"y");
    }

    #[test]
    fn attribute_named_with_semicolon() {
        let s = b"&lt;";
        let (c, n) = try_decode_entity(s, Context::Attribute).unwrap();
        assert_eq!(text(c), "<");
        assert_eq!(n, 4);
    }

    #[test]
    fn numeric_surrogate_replaced() {
        let s = b"&#55296;";
        let (c, n) = try_decode_entity(s, Context::General).unwrap();
        assert_eq!(text(c), "�");
        assert_eq!(n, 8);
    }

    #[test]
    fn numeric_windows_1252_mapping() {
        let s = b"&#128;";
        let (c, n) = try_decode_entity(s, Context::General).unwrap();
        assert_eq!(text(c), "€");
        assert_eq!(n, 6);
    }

    #[test]
    fn named_reference_keeps_both_scalars_in_text_and_attributes() {
        for context in [Context::General, Context::Attribute] {
            let (value, consumed) = try_decode_entity(b"&fjlig;tail", context).unwrap();
            assert_eq!(text(value), "fj");
            assert_eq!(consumed, 7);
        }
    }

    #[test]
    fn bare_ampersand_is_none() {
        assert!(try_decode_entity(b"&", Context::General).is_none());
        assert!(try_decode_entity(b"&@", Context::General).is_none());
    }
}