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>
481 lines
17 KiB
Rust
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));
|
|
}
|