tsv_css 0.8.0

CSS formatter and parser compatible with Svelte's parseCss — part of tsv
Documentation
// Top-level separator classification for CSS values.
//
// Old list parsing functions removed - replaced by ValueParser with same-source recursion
// - parse_comma_separated_values() → handled by ValueParser::parse_comma_separated()
// - parse_space_separated_values() → handled by ValueParser::parse_space_separated()
// - split_values_at_delimiter() → replaced by ValueCursor usage in ValueParser
// See: crates/tsv_css/src/parser/value/parser.rs for the new implementation

use crate::parser::value::scan::{comment_end, is_comment_start};

/// Top-level separator found in a CSS value string.
///
/// "Top level" means not inside parentheses, quotes, or block comments. Commas
/// take priority over whitespace (a value with both is comma-separated), so the
/// variants are ordered by that precedence.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ValueSeparator {
    /// At least one top-level comma — parse as a comma-separated list.
    Comma,
    /// No top-level comma, but at least one top-level whitespace run — parse as
    /// a space-separated list.
    Whitespace,
    /// Neither — a single leaf value.
    None,
}

/// Classify a CSS value's top-level separators in a single pass over `s`. Comma
/// detection short-circuits on the first top-level comma (commas win even when
/// whitespace appears earlier); whitespace is merely recorded while scanning, so
/// a later comma still takes priority. Block-comment bodies are skipped, so a `,`
/// or space inside `/* ... */` is not treated as a separator. Callers pass the
/// already-trimmed value text — leading/trailing whitespace must not classify as
/// a separator.
///
/// Scans raw bytes: every CSS separator — `,`, ASCII whitespace, and the `(` `)`
/// quote `/*` structure that gates them — is ASCII, so a non-ASCII byte (including
/// a multibyte char's continuation byte, e.g. the `0xA0` in U+4E20 that a careless
/// `as char` cast would alias to NBSP) is never a separator and falls through as
/// content. This reaches the same verdict as the real-`char` splitting in
/// [`crate::parser::value::cursor::ValueCursor`] (which tests `is_css_whitespace`),
/// without decoding.
///
/// [`ValueCursor`](crate::parser::value::cursor::ValueCursor) steps over a comment
/// whole for the same reason, so the classification and the split it feeds agree on
/// where a comment's interior ends — including when that interior holds an
/// unbalanced paren or quote. `ValueParser::split_top_level`'s progress guard remains
/// for the comment-free disagreement it was always about (a value opening with a
/// delimiter, e.g. `,a b`).
pub(crate) fn classify_separators(s: &str) -> ValueSeparator {
    let mut in_parens: u32 = 0;
    let mut in_quote = false;
    let mut quote_char = 0u8;
    let mut whitespace_seen = false;
    let bytes = s.as_bytes();

    let mut i = 0;
    while i < bytes.len() {
        let b = bytes[i];

        // A block comment is OPAQUE — step over it whole, so a `,` or space inside
        // `/* … */` is comment text rather than a separator — and at the top level it is
        // also a BOUNDARY. A comment is its own node, so `a/* c */b` holds three of them
        // exactly as `a /* c */ b` does, and reporting that here is what routes such a
        // value to the space-separated arm, where `split_top_level`'s `comment_is_element`
        // performs the split. Keying the boundary on the comment rather than on the
        // whitespace that usually accompanies it is what makes every glued position —
        // leading, interior, trailing — reach the split; a value whose *only* separator is
        // a glued comment (`0.3s/* c */5px`) has no whitespace to report.
        if !in_quote && is_comment_start(bytes, i) {
            if in_parens == 0 {
                whitespace_seen = true;
            }
            i = comment_end(bytes, i);
            continue;
        }

        // An escape is OPAQUE: step over it whole (`crate::escapes::escape_len`), so nothing
        // inside it — an escaped paren, comma, or space, or a hex escape's whitespace
        // terminator — reads as nesting or as a separator. Kept identical in the twin
        // `fast_scan` / `ValueCursor` trackers (see `fast_scan` for the full rationale).
        if b == b'\\'
            && let Some(len) = crate::escapes::escape_len(s, i)
        {
            i += len;
            continue;
        }

        match b {
            b'\'' | b'"' if !in_quote => {
                in_quote = true;
                quote_char = b;
            }
            _ if in_quote && b == quote_char => in_quote = false,
            b'(' if !in_quote => in_parens += 1,
            b')' if !in_quote => in_parens = in_parens.saturating_sub(1),
            // Comma wins immediately, before whitespace is ever consulted.
            b',' if in_parens == 0 && !in_quote => return ValueSeparator::Comma,
            // CSS whitespace is ASCII-only; a non-ASCII byte is never a separator.
            _ if in_parens == 0 && !in_quote && b.is_ascii_whitespace() => whitespace_seen = true,
            _ => {}
        }
        i += 1;
    }

    if whitespace_seen {
        ValueSeparator::Whitespace
    } else {
        ValueSeparator::None
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn single_value_has_no_separators() {
        assert_eq!(classify_separators("auto"), ValueSeparator::None);
        assert_eq!(classify_separators("10px"), ValueSeparator::None);
        assert_eq!(classify_separators("#fff"), ValueSeparator::None);
    }

    #[test]
    fn comma_is_detected() {
        assert_eq!(classify_separators("red, blue"), ValueSeparator::Comma);
        assert_eq!(classify_separators("a,b,c"), ValueSeparator::Comma);
    }

    #[test]
    fn whitespace_is_detected() {
        assert_eq!(
            classify_separators("1px solid red"),
            ValueSeparator::Whitespace
        );
        assert_eq!(classify_separators("a\tb"), ValueSeparator::Whitespace);
    }

    #[test]
    fn comma_takes_priority_over_earlier_whitespace() {
        // whitespace appears first, but a later top-level comma still wins
        assert_eq!(
            classify_separators("red blue, green"),
            ValueSeparator::Comma
        );
    }

    #[test]
    fn separators_inside_parens_are_ignored() {
        assert_eq!(classify_separators("rgba(1, 2, 3)"), ValueSeparator::None);
        assert_eq!(classify_separators("calc(1px + 2px)"), ValueSeparator::None);
        assert_eq!(
            classify_separators("rgba(1, 2, 3), blue"),
            ValueSeparator::Comma
        );
    }

    #[test]
    fn separators_inside_quotes_are_ignored() {
        assert_eq!(classify_separators(r#""foo, bar""#), ValueSeparator::None);
        assert_eq!(classify_separators(r#""foo bar""#), ValueSeparator::None);
        assert_eq!(
            classify_separators(r#""foo, bar", baz"#),
            ValueSeparator::Comma
        );
    }

    #[test]
    fn separators_inside_comments_are_ignored() {
        // A comma inside a comment must not classify as a COMMA separator — that is the
        // opacity rule, and it is what keeps the value off the comma-split path where the
        // element would be torn in half mid-comment.
        assert_eq!(
            classify_separators("red/*,*/blue"),
            ValueSeparator::Whitespace
        );
        // The comment's own boundary, though, IS a separator: `red/*,*/blue` holds three
        // nodes exactly as `red /* x */ blue` does, so both classify as whitespace-
        // separated and reach `split_top_level`'s comment-aware split.
        assert_eq!(
            classify_separators("red/* x */blue"),
            ValueSeparator::Whitespace
        );
    }

    #[test]
    fn a_top_level_comment_is_a_separator_in_every_position() {
        // Leading, interior and trailing all reach the split. The interior case is the one
        // with no whitespace to fall back on, so keying the boundary on the comment rather
        // than on accompanying whitespace is what makes it work.
        for s in ["/* c */a", "a/* c */b", "a/* c */", "0.3s/* c */5px"] {
            assert_eq!(
                classify_separators(s),
                ValueSeparator::Whitespace,
                "top-level comment boundary in {s:?}"
            );
        }
        // Inside parens it is interior text, not a top-level boundary.
        assert_eq!(classify_separators("f(a/* c */b)"), ValueSeparator::None);
        // A comma still outranks it.
        assert_eq!(classify_separators("a/* c */b, c"), ValueSeparator::Comma);
    }

    #[test]
    fn whitespace_outside_comment_still_counts() {
        // the space between `*/` and `red` is a real top-level separator
        assert_eq!(
            classify_separators("/* x */ red"),
            ValueSeparator::Whitespace
        );
    }

    #[test]
    fn multibyte_token_is_not_a_separator() {
        // U+4E20 encodes as `E4 B8 A0`; its `0xA0` continuation byte must not be
        // mistaken for U+00A0/NBSP whitespace, so a lone non-ASCII token is a
        // single value, not a separator.
        assert_eq!(classify_separators("丠"), ValueSeparator::None);
        // genuine separators around such tokens are still detected
        assert_eq!(classify_separators("丠 中"), ValueSeparator::Whitespace);
        assert_eq!(classify_separators("丠, 中"), ValueSeparator::Comma);
        // the aliasing byte inside parens stays contained
        assert_eq!(classify_separators("calc(丠)"), ValueSeparator::None);
    }
}