Files
rust_browser/crates/selectors/src/tests/lenient_parsing_tests.rs
Zachary D. Rowitsch 4866432a2a Add lenient CSS identifier parsing for Tailwind compatibility
Tailwind CSS generates selectors with special characters (|, ;, =, /)
that aren't always properly escaped in class/ID names. This adds a
two-tier parsing approach:

- is_ident_char(): Strict version for attribute names where =, *, |
  have special meaning as operators
- is_ident_char_lenient(): Lenient version for class/ID names that
  accepts |, ;, =, / which Tailwind often leaves unescaped

Note: * is intentionally NOT included in lenient chars since it has
special meaning as the universal selector. Proper CSS escapes it as \*.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 21:47:22 -05:00

481 lines
17 KiB
Rust

//! Additional tests for lenient parsing to ensure comprehensive coverage
//! and verify no regressions between strict and lenient parsing modes.
use crate::{Combinator, PseudoClass, Selector, SimpleSelector};
// =============================================================================
// PRIORITY 1: Strict vs Lenient Boundary Tests (CRITICAL)
// =============================================================================
#[test]
fn test_parse_lenient_class_then_strict_attribute() {
// Class with = should work (lenient), but attribute selector should still parse correctly (strict)
let selector = Selector::parse(".data-state=open[attr=value]").unwrap();
assert_eq!(selector.components().len(), 2);
assert!(
matches!(
&selector.components()[0],
SimpleSelector::Class(c) if c == "data-state=open"
),
"Expected class 'data-state=open', got {:?}",
selector.components()[0]
);
assert!(
matches!(
&selector.components()[1],
SimpleSelector::AttributeEquals(name, value) if name == "attr" && value == "value"
),
"Expected attribute [attr=value], got {:?}",
selector.components()[1]
);
}
#[test]
fn test_parse_class_with_pipe_then_attribute_hyphen_match() {
// Class with | should work, but [attr|=val] hyphen-match should still work
// This tests that | in class name doesn't interfere with |= operator in attributes
let selector = Selector::parse(r".content-\['|'\][lang|=en]").unwrap();
assert_eq!(selector.components().len(), 2);
assert!(matches!(
&selector.components()[0],
SimpleSelector::Class(_)
));
assert!(
matches!(
&selector.components()[1],
SimpleSelector::AttributeHyphen(name, value) if name == "lang" && value == "en"
),
"Expected [lang|=en], got {:?}",
selector.components()[1]
);
}
#[test]
fn test_parse_class_with_escaped_star_then_attribute_substring() {
// Class with escaped \* should work, and [attr*=val] substring-match should still work
// Note: * must be escaped in class names since it's the universal selector
let selector = Selector::parse(r".\*\:fill-red[data*=test]").unwrap();
assert_eq!(selector.components().len(), 2);
assert!(
matches!(&selector.components()[0], SimpleSelector::Class(c) if c.starts_with('*')),
"Expected class starting with *, got {:?}",
selector.components()[0]
);
assert!(
matches!(
&selector.components()[1],
SimpleSelector::AttributeSubstring(name, value) if name == "data" && value == "test"
),
"Expected [data*=test], got {:?}",
selector.components()[1]
);
}
#[test]
fn test_attribute_name_does_not_use_lenient_parsing() {
// Attribute names should NOT accept unescaped special chars
// [data|test=value] should parse as AttributeHyphen(data, test=value) or fail
// It should NOT parse as AttributeEquals("data|test", "value")
let selector = Selector::parse("[data|test=value]");
// This might parse as [data|=test=value] which would fail, or skip the attribute entirely
if let Some(s) = selector {
// Verify no attribute has a name containing unescaped special chars
for comp in s.components() {
match comp {
SimpleSelector::AttributeEquals(name, _) => {
assert!(
!name.contains('|'),
"Attribute name should not contain unescaped |, got: {}",
name
);
}
SimpleSelector::AttributeHyphen(name, value) => {
// This is the expected parse: [data|="test=value"]
assert_eq!(name, "data");
assert_eq!(value, "test");
}
_ => {}
}
}
}
}
// =============================================================================
// PRIORITY 1: ID Selector Lenient Tests
// =============================================================================
#[test]
fn test_parse_id_with_slash() {
let selector = Selector::parse("#w-1/2").unwrap();
assert_eq!(selector.components().len(), 1);
assert!(
matches!(
&selector.components()[0],
SimpleSelector::Id(id) if id == "w-1/2"
),
"Expected id 'w-1/2', got {:?}",
selector.components()[0]
);
}
#[test]
fn test_parse_id_with_equals() {
let selector = Selector::parse("#data-state=open").unwrap();
assert_eq!(selector.components().len(), 1);
assert!(
matches!(
&selector.components()[0],
SimpleSelector::Id(id) if id == "data-state=open"
),
"Expected id 'data-state=open', got {:?}",
selector.components()[0]
);
}
#[test]
fn test_parse_id_with_pipe() {
// Use pipe directly without brackets since [] are not lenient chars
let selector = Selector::parse(r"#content|pipe").unwrap();
assert_eq!(selector.components().len(), 1);
if let SimpleSelector::Id(id) = &selector.components()[0] {
assert!(id.contains('|'), "ID should contain |, got {}", id);
assert_eq!(id, "content|pipe");
} else {
panic!("Expected ID selector, got {:?}", selector.components()[0]);
}
}
#[test]
fn test_parse_id_with_semicolon() {
let selector = Selector::parse("#shadow-box;").unwrap();
assert_eq!(selector.components().len(), 1);
if let SimpleSelector::Id(id) = &selector.components()[0] {
assert!(id.contains(';'), "ID should contain ;, got {}", id);
} else {
panic!("Expected ID selector, got {:?}", selector.components()[0]);
}
}
#[test]
fn test_parse_id_with_star() {
let selector = Selector::parse(r"#\*fill-red").unwrap();
assert_eq!(selector.components().len(), 1);
if let SimpleSelector::Id(id) = &selector.components()[0] {
assert!(id.contains('*'), "ID should contain *, got {}", id);
} else {
panic!("Expected ID selector, got {:?}", selector.components()[0]);
}
}
// =============================================================================
// PRIORITY 2: Multiple Lenient Characters in Single Selector
// =============================================================================
#[test]
fn test_parse_class_with_all_lenient_chars() {
// Combine all lenient characters in one class name
// Note: * is NOT a lenient char (it's the universal selector) - must be escaped
let selector = Selector::parse(r".test|pipe\*star/slash;semi=equals").unwrap();
assert_eq!(selector.components().len(), 1);
if let SimpleSelector::Class(c) = &selector.components()[0] {
assert_eq!(c, "test|pipe*star/slash;semi=equals");
} else {
panic!("Expected class selector");
}
}
#[test]
fn test_parse_id_with_all_lenient_chars() {
// Note: * is NOT a lenient char - must be escaped as \*
let selector = Selector::parse(r"#test|pipe\*star/slash;semi=equals").unwrap();
assert_eq!(selector.components().len(), 1);
if let SimpleSelector::Id(id) = &selector.components()[0] {
assert_eq!(id, "test|pipe*star/slash;semi=equals");
} else {
panic!("Expected ID selector");
}
}
// =============================================================================
// PRIORITY 2: Lenient Characters Adjacent to CSS Syntax
// =============================================================================
#[test]
fn test_parse_class_ending_with_slash_before_pseudo() {
// Class ending in / followed by pseudo-class
let selector = Selector::parse(".w-1/2:hover").unwrap();
assert_eq!(selector.components().len(), 2);
assert!(
matches!(
&selector.components()[0],
SimpleSelector::Class(c) if c == "w-1/2"
),
"Expected class 'w-1/2', got {:?}",
selector.components()[0]
);
assert!(
matches!(
&selector.components()[1],
SimpleSelector::PseudoClass(PseudoClass::Hover)
),
"Expected :hover pseudo-class, got {:?}",
selector.components()[1]
);
}
#[test]
fn test_parse_class_with_equals_before_child_combinator() {
// Class with = before child combinator
let selector = Selector::parse(".state=open > .child").unwrap();
assert_eq!(selector.parts.len(), 2);
assert!(
matches!(
&selector.parts[0].simple_selectors[0],
SimpleSelector::Class(c) if c == "state=open"
),
"Expected class 'state=open', got {:?}",
selector.parts[0].simple_selectors[0]
);
assert_eq!(selector.parts[1].combinator, Some(Combinator::Child));
}
#[test]
fn test_parse_class_with_star_at_start_and_end() {
// * at boundaries of class name - must be escaped since * is the universal selector
let selector = Selector::parse(r".\*fill\*").unwrap();
if let SimpleSelector::Class(c) = &selector.components()[0] {
assert_eq!(c, "*fill*");
} else {
panic!(
"Expected class selector, got {:?}",
selector.components()[0]
);
}
}
#[test]
fn test_parse_class_with_pipe_before_attribute() {
// Ensure | in class doesn't interfere with following [attr]
let selector = Selector::parse(".has|pipe[disabled]").unwrap();
assert_eq!(selector.components().len(), 2);
assert!(matches!(
&selector.components()[0],
SimpleSelector::Class(c) if c == "has|pipe"
));
assert!(matches!(
&selector.components()[1],
SimpleSelector::AttributeExists(name) if name == "disabled"
));
}
#[test]
fn test_parse_class_with_slash_before_descendant() {
let selector = Selector::parse(".w-1/2 .child").unwrap();
assert_eq!(selector.parts.len(), 2);
assert_eq!(selector.parts[1].combinator, Some(Combinator::Descendant));
assert!(matches!(
&selector.parts[0].simple_selectors[0],
SimpleSelector::Class(c) if c == "w-1/2"
));
}
#[test]
fn test_parse_class_with_semicolon_at_end() {
// Semicolon at the very end of selector
let selector = Selector::parse(".shadow-box;").unwrap();
assert_eq!(selector.components().len(), 1);
if let SimpleSelector::Class(c) = &selector.components()[0] {
assert!(c.contains(';'));
}
}
// =============================================================================
// PRIORITY 2: Combinator Boundary Tests
// =============================================================================
#[test]
fn test_parse_universal_vs_class_with_escaped_star() {
// * as universal selector should be separate from .class\* (escaped star in class)
let selector = Selector::parse(r"* .class\*").unwrap();
assert_eq!(selector.parts.len(), 2);
assert!(matches!(
&selector.parts[0].simple_selectors[0],
SimpleSelector::Universal
));
assert!(matches!(
&selector.parts[1].simple_selectors[0],
SimpleSelector::Class(c) if c == "class*"
));
}
#[test]
fn test_parse_class_escaped_star_before_child_combinator() {
// .\*class > child - * must be escaped to be part of class name
let selector = Selector::parse(r".\*class > .child").unwrap();
assert_eq!(selector.parts.len(), 2);
assert!(matches!(
&selector.parts[0].simple_selectors[0],
SimpleSelector::Class(c) if c == "*class"
));
assert_eq!(selector.parts[1].combinator, Some(Combinator::Child));
}
// =============================================================================
// PRIORITY 3: Escaped Lenient Characters
// =============================================================================
#[test]
fn test_parse_class_with_escaped_and_unescaped_slash() {
// Mix of escaped and unescaped /
let selector = Selector::parse(r".w-1\/2/3").unwrap();
if let SimpleSelector::Class(c) = &selector.components()[0] {
assert_eq!(c, "w-1/2/3");
} else {
panic!("Expected class selector");
}
}
#[test]
fn test_parse_class_with_escaped_pipe() {
// Escaped | vs unescaped | - both should work and produce same result
let selector = Selector::parse(r".a\|b|c").unwrap();
if let SimpleSelector::Class(c) = &selector.components()[0] {
assert_eq!(c, "a|b|c");
} else {
panic!("Expected class selector");
}
}
#[test]
fn test_parse_class_with_escaped_equals() {
let selector = Selector::parse(r".a\=b=c").unwrap();
if let SimpleSelector::Class(c) = &selector.components()[0] {
assert_eq!(c, "a=b=c");
} else {
panic!("Expected class selector");
}
}
#[test]
fn test_parse_class_with_escaped_star() {
// All * must be escaped - unescaped * is not a valid ident char
let selector = Selector::parse(r".\*a\*b").unwrap();
if let SimpleSelector::Class(c) = &selector.components()[0] {
assert_eq!(c, "*a*b");
} else {
panic!("Expected class selector");
}
}
// =============================================================================
// PRIORITY 3: Compound Selectors with Multiple Lenient Classes
// =============================================================================
#[test]
fn test_parse_multiple_lenient_classes() {
// [ and ] are not lenient chars, must escape them
let selector = Selector::parse(r".w-1/2.h-1/2.aspect-\[16/9\]").unwrap();
assert_eq!(selector.components().len(), 3);
assert!(matches!(&selector.components()[0], SimpleSelector::Class(c) if c == "w-1/2"));
assert!(matches!(&selector.components()[1], SimpleSelector::Class(c) if c == "h-1/2"));
assert!(matches!(&selector.components()[2], SimpleSelector::Class(c) if c.contains("16/9")));
}
#[test]
fn test_parse_multiple_ids_with_lenient_chars() {
let selector = Selector::parse("#id1/test#id2=test").unwrap();
assert_eq!(selector.components().len(), 2);
assert!(matches!(&selector.components()[0], SimpleSelector::Id(id) if id == "id1/test"));
assert!(matches!(&selector.components()[1], SimpleSelector::Id(id) if id == "id2=test"));
}
#[test]
fn test_parse_lenient_class_with_type_and_pseudo() {
let selector = Selector::parse("div.w-1/2:hover").unwrap();
assert_eq!(selector.components().len(), 3);
assert!(matches!(&selector.components()[0], SimpleSelector::Type(t) if t == "div"));
assert!(matches!(&selector.components()[1], SimpleSelector::Class(c) if c == "w-1/2"));
assert!(matches!(
&selector.components()[2],
SimpleSelector::PseudoClass(PseudoClass::Hover)
));
}
// =============================================================================
// PRIORITY 3: Specificity with Lenient Characters
// =============================================================================
#[test]
fn test_specificity_with_lenient_chars() {
let s1 = Selector::parse(".normal").unwrap();
let s2 = Selector::parse(".w-1/2").unwrap();
let s3 = Selector::parse(".data-state=open").unwrap();
// All should have same specificity (0, 0, 1, 0) - one class
assert_eq!(s1.specificity(), s2.specificity());
assert_eq!(s1.specificity(), s3.specificity());
}
#[test]
fn test_specificity_id_with_lenient_chars() {
let s1 = Selector::parse("#normal").unwrap();
let s2 = Selector::parse("#w-1/2").unwrap();
// Both should have same specificity (0, 1, 0, 0) - one ID
assert_eq!(s1.specificity(), s2.specificity());
}
// =============================================================================
// Edge Cases and Regression Tests
// =============================================================================
#[test]
fn test_parse_empty_class_after_dot_still_fails() {
// . followed by a character that stops identifier parsing
let selector = Selector::parse(". ");
// Should fail or produce empty selector
if let Some(s) = selector {
assert!(s.components().is_empty() || s.parts.is_empty());
}
}
#[test]
fn test_parse_attribute_with_lenient_chars_in_value() {
// Lenient chars in attribute VALUE should work (they're unrelated to identifier parsing)
let selector = Selector::parse(r#"[data-value="has/pipe|and=stuff"]"#).unwrap();
assert_eq!(selector.components().len(), 1);
assert!(matches!(
&selector.components()[0],
SimpleSelector::AttributeEquals(name, value)
if name == "data-value" && value == "has/pipe|and=stuff"
));
}
#[test]
fn test_parse_real_world_tailwind_complex() {
// Real-world complex Tailwind selector
let selector = Selector::parse(
r".dark\:bg-gray-900.hover\:shadow-\[0px_0px_1px_rgba\(0\,0\,0\,0\.1\);\]:hover",
)
.unwrap();
assert!(selector.components().len() >= 2);
// Should end with :hover pseudo-class
let last = selector.components().last().unwrap();
assert!(matches!(
last,
SimpleSelector::PseudoClass(PseudoClass::Hover)
));
}
#[test]
fn test_parse_tailwind_with_arbitrary_and_descendant() {
// Tailwind with arbitrary value and descendant combinator
// Must escape [ and ] since they're not lenient chars
let selector = Selector::parse(r".parent-\[state=open\] > .child").unwrap();
assert_eq!(selector.parts.len(), 2);
assert!(matches!(
&selector.parts[0].simple_selectors[0],
SimpleSelector::Class(c) if c.contains("state=open")
));
assert_eq!(selector.parts[1].combinator, Some(Combinator::Child));
}