tsv_css 0.8.0

CSS formatter and parser compatible with Svelte's parseCss โ€” part of tsv
Documentation
use super::token::{Token, TokenKind};
use crate::escapes::escape_len;
use crate::number::sign_starts_number;
use tsv_lang::Error;

/// Whether `ch` is a non-ASCII CSS identifier code point.
///
/// Mirrors Svelte's `parseCss` (`read_identifier`: `codePointAt(0) >= 160`): any
/// code point at or above U+00A0 is a valid identifier code point, so symbols and
/// emoji (`โ™ฅ`, `๐Ÿ’ฉ`) are accepted while the C1 control range U+0080โ€“U+009F stays
/// excluded. Single source for the threshold shared by `is_identifier_start` and
/// the continuation guard in `read_identifier`.
#[inline]
pub(crate) fn is_non_ascii_identifier_codepoint(ch: char) -> bool {
    ch as u32 >= 0xA0
}

/// 256-entry lookup table for the ASCII identifier-continuation fast path
/// ([`ascii_identifier_run_end`], run by `read_identifier` and by the lexer front's common
/// identifier). Each entry is computed from the same byte predicate the
/// per-char continuation arm expands to for ASCII (`[a-zA-Z0-9_-]`), so a lookup
/// replaces the alnum/eq OR-chain plus a full UTF-8 decode with one L1 load on the
/// hot identifier-body loop. Non-ASCII bytes are all `false`, so the fast path stops
/// at the first non-ASCII byte and the char loop decodes it โ€” byte-identical.
///
/// Shared with `read_number_into`'s dimension-unit body loop: a unit (`px`, `rem`) is an
/// identifier, so it continues on exactly this predicate โ€” and with the declaration
/// value's boundary scan, which decides whether a `url` is a token start (and so opens an
/// opaque url-token) from the single byte before it.
pub(crate) const IDENT_CONTINUE_LUT: [bool; 256] = {
    let mut t = [false; 256];
    let mut i = 0;
    while i < 256 {
        let b = i as u8;
        t[i] = b.is_ascii_alphanumeric() || b == b'-' || b == b'_';
        i += 1;
    }
    t
};

/// How many bytes an identifier run's walk tests behind one bound check
/// ([`ascii_identifier_run_end`], and the lexer front's first chunk).
pub(crate) const IDENT_CHUNK: usize = 8;

/// The end of the run of ASCII identifier-continuation bytes (`[a-zA-Z0-9_-]`,
/// [`IDENT_CONTINUE_LUT`]) that starts at `from` โ€” `from` itself when none does.
///
/// [`IDENT_CHUNK`] bytes at a time while that many are in bounds, each tested against the
/// table in turn with no bound check of its own ([`ascii_identifier_chunk_stop`]), and byte by
/// byte within a chunk of the end of input. Every byte is still the table's to classify; only
/// the bound check is shared across the chunk.
#[inline]
pub(crate) fn ascii_identifier_run_end(bytes: &[u8], from: usize) -> usize {
    let mut p = from;
    while let Some(chunk) = bytes.get(p..p + IDENT_CHUNK) {
        if let Some(i) = ascii_identifier_chunk_stop(chunk) {
            return p + i;
        }
        p += IDENT_CHUNK;
    }
    while p < bytes.len() && IDENT_CONTINUE_LUT[bytes[p] as usize] {
        p += 1;
    }
    p
}

/// The offset of the first byte of `chunk` that does not continue an ASCII identifier
/// ([`IDENT_CONTINUE_LUT`]), or `None` when every byte does.
#[inline]
pub(crate) fn ascii_identifier_chunk_stop(chunk: &[u8]) -> Option<usize> {
    chunk.iter().position(|&b| !IDENT_CONTINUE_LUT[b as usize])
}

/// Whether the byte an ASCII continuation run stopped on (`None` at end of input) ends the
/// identifier outright: any ASCII byte but the `\` escape introducer. The run already took
/// every ASCII continuation byte, so only a non-ASCII code point or an escape can extend it.
#[inline]
pub(crate) fn run_stop_ends_identifier(stop: Option<u8>) -> bool {
    stop.is_none_or(|b| b.is_ascii() && b != b'\\')
}

/// Whether `ch` can begin a CSS identifier token in the lexer dispatch.
///
/// Covers ASCII letters, non-ASCII identifier code points (see
/// `is_non_ascii_identifier_codepoint`), `-`, `_`, and the `\` escape introducer.
/// Digits and a leading `$` have their own dispatch arms (numbers, and `$`-prefixed
/// identifiers), so they're intentionally excluded here โ€” but a `$` arm uses this to
/// confirm the *next* char begins an identifier.
pub(crate) fn is_identifier_start(ch: char) -> bool {
    ch.is_ascii_alphabetic()
        || is_non_ascii_identifier_codepoint(ch)
        || ch == '-'
        || ch == '_'
        || ch == '\\'
}

/// Does a `-` followed by `after` open an IDENT or a NUMBER of its own โ€” so that the two
/// lex as one token, rather than as a `<delim-token>` and whatever follows it?
///
/// css-syntax-3 ยง4.3.9 "Would start an identifier", read at the `-`: its second bullet
/// admits an ident-start code point, a second `-`, or a valid escape. ยง4.3.10's number is
/// asked over the sign by [`sign_starts_number`] (`-1.5`, `-.5`; a lone `-.` is left
/// alone).
///
/// โš ๏ธ The ident-start set is **this lexer's** โ€” an ASCII letter, `_`, or any code point at
/// U+00A0 and above ([`is_non_ascii_identifier_codepoint`]) โ€” not ยง4.2's enumerated
/// ranges, which leave holes at U+00D7 and U+00F7. The question is "would the glued text
/// lex as one token", and the lexings that answer it are tsv's own and that of the parser
/// tsv replaces (`parseCss` reads an identifier at `codePointAt(0) >= 160` too), so `-ร—a`
/// is one ident here. The `\` bullet is the spec's rather than [`is_identifier_start`]'s:
/// a `\` before a newline or at end of input is no escape at all (ยง4.3.8), which
/// [`escape_len`] answers.
///
/// One reading for its two askers, which must agree or one pass splits what the next
/// glues: the value-run splitter (`parser::value::operators`, deciding whether a `-` at
/// an operand position is the operator) and the value printer (`printer::values`,
/// refusing a head glue that would merge the operator and its member into one token).
pub(crate) fn hyphen_starts_own_token(after: &str) -> bool {
    if sign_starts_number(b'-', after) {
        return true;
    }
    match after.chars().next() {
        None => false,
        Some('-') => true,
        Some('\\') => escape_len(after, 0).is_some(),
        Some(ch) => ch.is_ascii_alphabetic() || ch == '_' || is_non_ascii_identifier_codepoint(ch),
    }
}

/// The ASCII byte form of `is_identifier_start`, for the lexer's byte-first dispatch.
///
/// Equal to `is_identifier_start(b as char)` for every ASCII byte (`b < 0x80`), and
/// `false` for every non-ASCII byte โ€” the non-ASCII identifier code points (`>= 0xA0`)
/// are `>= 0x80`, so they're handled by the dispatch's non-ASCII arm, not here. Covers the
/// ASCII letters, `-`, `_`, and the `\` escape introducer (digits and a leading `$`
/// have their own dispatch arms).
#[inline]
pub(crate) fn is_ascii_identifier_start(b: u8) -> bool {
    b.is_ascii_alphabetic() || b == b'-' || b == b'_' || b == b'\\'
}

/// Read a CSS identifier โ€” the general reader, which the lexer reaches only for an
/// identifier whose ASCII run stops on a non-ASCII byte or a `\`, or that opens with a `\`
/// (the common identifier is read by the lexer's front, `Lexer::next_token_into_local`).
/// CSS identifiers can contain unicode escapes; the characters a-z, A-Z, 0-9, -, _;
/// any non-ASCII code point at or above U+00A0 (symbols and emoji, matching Svelte's
/// `>= 160` rule); plus an optional leading `$` (SCSS-style; the lexer dispatch only
/// routes `$` here when it begins an identifier).
/// Per CSS Syntax Level 3 spec, escape sequences are decoded to their actual characters.
///
/// Returns the token plus the **decoded value only when an escape was present**:
/// the common no-escape identifier returns `None` (no allocation โ€” its text is the
/// verbatim source slice `source[start..end]`). The decoded buffer is materialized
/// lazily from the verbatim run scanned so far the first time a `\` escape is seen;
/// the caller (`Lexer::read_decoded_identifier_into`) funnels it into the lexer's reused
/// `decode_scratch`, so this owned `String` is the only allocation on the escape path.
pub(crate) fn read_identifier(
    source: &str,
    pos: &mut usize,
) -> Result<(Token, Option<String>), Error> {
    let start = *pos;
    // `None` until an escape forces a decoded buffer; then materialized from the
    // verbatim run `source[start..pos]` scanned so far and appended to per escape.
    let mut decoded: Option<String> = None;

    // Optional leading `$` (SCSS-style variable / property identifiers). Svelte's
    // `parseCss` treats `$foo` as a single identifier; a bare `$` (e.g. the `$=`
    // attribute selector) is kept as a Dollar token by the lexer dispatch, so this
    // arm is only reached when `$` begins an identifier. No push: the `$` is part of
    // the verbatim run captured if/when an escape later materializes the buffer.
    //
    // Byte view for the `$` test and the ASCII continuation fast path (coexists with the
    // `source` `&str` slices the escape arms take โ€” both are immutable borrows).
    let bytes = source.as_bytes();
    if bytes.get(*pos) == Some(&b'$') {
        *pos += 1;
    }

    // CSS identifiers can contain escape sequences that must be decoded
    loop {
        // ASCII fast path: while no `\` escape has yet forced a decoded buffer, scan a
        // run of ASCII identifier-continuation bytes (`[a-zA-Z0-9_-]`) via the lookup
        // table instead of decoding a UTF-8 char per byte. The table is `true` for
        // exactly the ASCII bytes the char match arm below accepts and `false` for every
        // non-ASCII byte, `\`, and terminator, so this advances to precisely the first
        // byte that arm would not consume โ€” byte-identical, skipping the per-char decode.
        // Once an escape materializes the decoded buffer, each following char must be
        // pushed, so the fast path yields to the char loop.
        //
        // The run's stop byte settles the common identifier on the spot: at end of input,
        // or on an ASCII byte other than `\`, the char match below can only take its
        // `None` / `_` arm and end the identifier (the table already rejected every ASCII
        // continuation byte), so the fast path ends it without decoding the byte again.
        // Only a non-ASCII byte or an escape goes on to the char loop.
        if decoded.is_none() {
            *pos = ascii_identifier_run_end(bytes, *pos);
            if run_stop_ends_identifier(bytes.get(*pos).copied()) {
                break;
            }
        }

        let current_char = source[*pos..].chars().next();
        match current_char {
            // Continuation char: ASCII alphanumeric, a non-ASCII identifier code point,
            // `-`, or `_` โ€” the same predicate Svelte's `read_identifier` applies per
            // char (`codePointAt(0) >= 160 || [a-zA-Z0-9_-]`). The ASCII cases are
            // handled by the fast path above; this arm now fires for non-ASCII code
            // points (and for the first char after an escape materialized the buffer).
            Some(ch)
                if ch.is_ascii_alphanumeric()
                    || is_non_ascii_identifier_codepoint(ch)
                    || ch == '-'
                    || ch == '_' =>
            {
                if let Some(buf) = decoded.as_mut() {
                    buf.push(ch);
                }
                *pos += ch.len_utf8();
            }
            Some('\\') => {
                // Check if this is a valid escape sequence
                let Some(next_ch) = source[*pos + 1..].chars().next() else {
                    // Backslash at end of input. If the identifier already has
                    // content, end it before the backslash; but when the `\` is
                    // the FIRST char scanned, breaking would return a zero-width
                    // token โ€” the caller's token loop would re-dispatch at the
                    // same position forever (and accumulate tokens unboundedly
                    // on the token-tree paths). A lone `\` at EOF is a parse
                    // error per css-syntax ยง4.3.7 (consume an escaped code
                    // point); the strict-throw model rejects it.
                    if *pos == start {
                        return Err(Error::lex("Unexpected end of input after backslash", *pos));
                    }
                    break;
                };

                if next_ch.is_ascii_hexdigit() {
                    // Unicode escape: \XXXXXX (1-6 hex digits)
                    let buf = decoded.get_or_insert_with(|| source[start..*pos].to_string());
                    let ch = decode_unicode_escape(source, pos)?;
                    buf.push(ch);
                } else if next_ch == '\n' || next_ch == '\r' || next_ch == '\x0C' {
                    // Newline after backslash - invalid escape. Same zero-width
                    // hazard as the EOF branch above: error rather than spin
                    // when the `\` is the first char scanned. (Svelte's parseCss
                    // is lenient here and reads the `\` into the value; tsv
                    // follows the css-syntax parse-error posture โ€” see
                    // conformance_svelte.md ยงCSS Parser Scope & Error Model.)
                    if *pos == start {
                        return Err(Error::lex("Invalid escape: backslash before newline", *pos));
                    }
                    break;
                } else {
                    // Single character escape: backslash followed by any character
                    // The character itself is the escaped value
                    let buf = decoded.get_or_insert_with(|| source[start..*pos].to_string());
                    *pos += 1; // skip backslash
                    buf.push(next_ch);
                    *pos += next_ch.len_utf8();
                }
            }
            _ => {
                break;
            }
        }
    }

    let token = Token {
        kind: TokenKind::Identifier,
        start: start as u32,
        end: *pos as u32,
    };
    Ok((token, decoded))
}

/// Decode a CSS unicode escape sequence: \XXXXXX (1-6 hex digits)
/// Advances position past the escape sequence.
///
/// Per CSS Syntax 3 ยง4.3.7 (consume an escaped code point), a value that is
/// zero, is for a surrogate, or is greater than the maximum allowed code point
/// (U+10FFFF) decodes to U+FFFD REPLACEMENT CHARACTER โ€” it is not a parse error.
pub(crate) fn decode_unicode_escape(source: &str, pos: &mut usize) -> Result<char, Error> {
    let start = *pos;
    *pos += 1; // skip \

    // Read 1-6 hex digits, accumulating their value directly into a `u32` โ€” no
    // intermediate `String` + `from_str_radix` allocation. Six hex digits max
    // (0xFFFFFF), so `code_point` never overflows. A hex digit is always one ASCII
    // byte, so advancing by one byte per digit matches `ch.len_utf8()` exactly.
    let mut code_point: u32 = 0;
    let mut digits: usize = 0;
    for _ in 0..6 {
        let Some(ch) = source[*pos..].chars().next() else {
            break;
        };
        let Some(d) = ch.to_digit(16) else {
            break;
        };
        code_point = code_point * 16 + d;
        *pos += 1;
        digits += 1;
    }

    if digits == 0 {
        return Err(Error::lex("Invalid unicode escape sequence", start));
    }

    // Skip the hex escape's optional whitespace terminator.
    //
    // **JS `\s`, deliberately** โ€” and NOT `whitespace::is_css_whitespace`. The oracle for the
    // LEXER is Svelte's `parseCss`, whose `read_identifier` matches the terminator with
    // `/\\[0-9a-fA-F]{1,6}(\r\n|\s)?/` (`1-parse/read/style.js`) โ€” a JS regex, so a VT
    // (U+000B) or NBSP after a hex escape terminates it there. ASCII-only would leave the VT
    // and NBSP inside the identifier, moving the **token boundary** and re-parsing
    // `.a\41<VT>b` as `aA` + descendant + `b` (and over-rejecting `[a=b\41<VT>c]`).
    //
    // โš ๏ธ And NOT `char::is_whitespace` either โ€” that is not what JS `\s` is.
    // The two differ at exactly two code points and a site reading it gets both wrong: `.a\41<ZWNBSP>b`
    // is `aAb` to `parseCss` (U+FEFF is `\s`, so it is eaten as the terminator) but would be
    // `aA<ZWNBSP>b` โ€” a WIRE divergence, and a different selector โ€” while `.a\41<NEL>b`
    // is a `css_expected_identifier` REJECTION there (U+0085 is not `\s`) but would be
    // accepted as `aAb`.
    //
    // This is a different rule from `escapes::escape_len`, and deliberately so: that one is
    // a *printer-side value scanner* whose job is "how far does this escape reach when I
    // rewrite a value", answering to css-syntax-3; this one is *tokenization*, answering to
    // `parseCss`, and its boundaries become wire spans the AST oracle compares. The wire
    // writer's `raw_selector_name` is the SECOND reader of this same rule and asks this same
    // class, which is what keeps the wire agreeing with the span it was cut from.
    //
    // The lexer's two other non-ASCII whitespace reads are NOT this rule and are still on
    // `char::is_whitespace`: the whitespace token's non-ASCII half
    // (`non_ascii_whitespace_run_end`), which only `<NEL>` can reach (every code point at or
    // above U+00A0 is an identifier code point here) and which is the tracked gap pinned in
    // tests/css_boundary_whitespace.rs; and `url_arg_is_quoted`, which
    // forks a url-token per css-syntax-3 ยง4.3.6 โ€” a grammar `parseCss` has no counterpart for,
    // so its class answers to nothing named here.
    if let Some(ch) = source[*pos..].chars().next()
        && tsv_lang::is_js_whitespace(ch)
    {
        *pos += ch.len_utf8();
    }

    // Per CSS Syntax 3 ยง4.3.7, zero / a surrogate / an above-maximum code point decodes to
    // U+FFFD REPLACEMENT CHARACTER rather than being a parse error: `char::from_u32` already
    // returns `None` for the surrogate / above-maximum cases, and zero is a valid `char`
    // (NUL) so it needs the explicit guard.
    let decoded = if code_point == 0 {
        '\u{FFFD}'
    } else {
        char::from_u32(code_point).unwrap_or('\u{FFFD}')
    };
    Ok(decoded)
}