Skip to content

Commit c07901a

Browse files
committed
Fix arbitrary variant class sorting (Issue #115)
1 parent c5979cd commit c07901a

File tree

6 files changed

+364
-47
lines changed

6 files changed

+364
-47
lines changed

rustywind-core/src/app.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,41 @@ mod tests {
475475
assert_eq!(output, r#"<div class="m-4 flex p-4"></div>"#);
476476
}
477477

478+
/// Test that arbitrary variant classes are matched by the regex (Issue #115)
479+
#[test]
480+
fn test_regex_matches_arbitrary_variants() {
481+
let app = RUSTYWIND_DEFAULT;
482+
483+
// Test element state selectors
484+
let input = r#"<div class="[&.htmx-request]:h-0 flex p-4"></div>"#;
485+
assert!(app.has_classes(input), "Should match [&.class] syntax");
486+
487+
let sorted = app.sort_file_contents(input);
488+
assert!(
489+
sorted.contains("[&.htmx-request]:h-0"),
490+
"Arbitrary variant should be preserved in output"
491+
);
492+
493+
// Test child/sibling selectors
494+
let input2 = r#"<div class="[&>*]:p-4 [&+*]:mt-4 block"></div>"#;
495+
assert!(app.has_classes(input2), "Should match combinator syntax");
496+
497+
// Test attribute selectors
498+
let input3 = r#"<div class="[&[data-state=open]]:bg-gray-100 flex"></div>"#;
499+
assert!(
500+
app.has_classes(input3),
501+
"Should match attribute selector syntax"
502+
);
503+
504+
// Test at-rule variants
505+
let input4 = r#"<div class="[@supports(display:grid)]:grid flex"></div>"#;
506+
assert!(app.has_classes(input4), "Should match @-rule syntax");
507+
508+
// Test calc with percentage
509+
let input5 = r#"<div class="w-[calc(100%+20px)] flex"></div>"#;
510+
assert!(app.has_classes(input5), "Should match calc with percentage");
511+
}
512+
478513
#[test_case(
479514
None,
480515
ClassWrapping::NoWrapping,

rustywind-core/src/class_parser.rs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,9 @@ pub fn parse_class(class: &str) -> Option<ParsedClass<'_>> {
172172
working = &working[..working.len() - 1];
173173
}
174174

175-
// Split by ':' to separate variants from utility
176-
let parts: Vec<&str> = working.split(':').collect();
175+
// Split by ':' but respect brackets - ':' inside [] should not be a separator
176+
// e.g., "[&>*:last-child]:rounded-b-lg" -> ["[&>*:last-child]", "rounded-b-lg"]
177+
let parts = split_respecting_brackets(working);
177178

178179
if parts.is_empty() {
179180
return None;
@@ -204,6 +205,38 @@ pub fn parse_class(class: &str) -> Option<ParsedClass<'_>> {
204205
})
205206
}
206207

208+
/// Split a class string by ':' while respecting bracket nesting.
209+
/// Colons inside square brackets `[]` are NOT treated as separators.
210+
///
211+
/// # Examples
212+
/// - `"hover:p-4"` -> `["hover", "p-4"]`
213+
/// - `"[&>*:last-child]:rounded-b-lg"` -> `["[&>*:last-child]", "rounded-b-lg"]`
214+
/// - `"dark:[&.active]:bg-red-500"` -> `["dark", "[&.active]", "bg-red-500"]`
215+
fn split_respecting_brackets(s: &str) -> Vec<&str> {
216+
let mut parts = Vec::new();
217+
let mut start = 0;
218+
let mut bracket_depth: u32 = 0;
219+
220+
for (i, c) in s.char_indices() {
221+
match c {
222+
'[' => bracket_depth += 1,
223+
']' => bracket_depth = bracket_depth.saturating_sub(1),
224+
':' if bracket_depth == 0 => {
225+
parts.push(&s[start..i]);
226+
start = i + 1;
227+
}
228+
_ => {}
229+
}
230+
}
231+
232+
// Don't forget the last part
233+
if start < s.len() {
234+
parts.push(&s[start..]);
235+
}
236+
237+
parts
238+
}
239+
207240
/// Parse a utility string into base and value parts.
208241
///
209242
/// This reuses the logic from utility_map but is adapted for class parsing.

rustywind-core/src/defaults.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,12 @@ use regex::Regex;
33
use std::sync::LazyLock;
44

55
pub static RE: LazyLock<Regex> = LazyLock::new(|| {
6-
Regex::new(r#"\b(?:class(?:Name)?\s*=\s*["'])([_a-zA-Z0-9\.,\s\-:\[\]()/#]+)["']"#).unwrap()
6+
// Character class includes:
7+
// - Basic: _a-zA-Z0-9 (alphanumeric, underscore)
8+
// - Syntax: .,\s\-: (dot, comma, whitespace, hyphen, colon)
9+
// - Brackets: \[\]() (square brackets, parentheses)
10+
// - Values: /# (slash for opacity, hash for colors)
11+
// - Arbitrary variants: &>+~=*@% (selectors, combinators, at-rules, calc)
12+
Regex::new(r#"\b(?:class(?:Name)?\s*=\s*["'])([_a-zA-Z0-9\.,\s\-:\[\]()/#&>+~=*@%]+)["']"#)
13+
.unwrap()
714
});

rustywind-core/src/pattern_sorter.rs

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,11 @@ pub struct SortKey {
297297
/// This is used to properly sort compound variants like peer-hover vs peer-focus
298298
pub variant_chain: Vec<VariantInfo>,
299299

300+
/// Arbitrary variant selectors for tiebreaking when variant_order is equal
301+
/// e.g., for `[&.x]:block` this would be `["[&.x]"]`
302+
/// Used to sort different arbitrary variants lexicographically (with `_` decoded as space)
303+
pub arbitrary_variants: Vec<compact_str::CompactString>,
304+
300305
/// Property indices from PROPERTY_ORDER (lower = earlier)
301306
/// When utilities have multiple properties (e.g., rounded-t), ALL property indices
302307
/// are stored and compared in order for proper tiebreaking.
@@ -619,19 +624,53 @@ impl Ord for SortKey {
619624
(false, true) => return Ordering::Greater, // Variant after base class
620625
(true, true) => {} // Both base classes, continue to property comparison
621626
(false, false) => {
622-
// Both have variants - compare by variant_order
627+
// Both have variants - continue with comparison below
628+
}
629+
}
630+
631+
// Bit 63 indicates presence of arbitrary variants
632+
const ARBITRARY_BIT: u128 = 1u128 << 63;
633+
let self_has_arbitrary = self.variant_order & ARBITRARY_BIT != 0;
634+
let other_has_arbitrary = other.variant_order & ARBITRARY_BIT != 0;
635+
636+
// 2. Compare by arbitrary variant presence and selectors
637+
// Classes without arbitrary variants sort BEFORE classes with arbitrary variants
638+
// When both have arbitrary, compare selectors FIRST, then known variant bits
639+
match (self_has_arbitrary, other_has_arbitrary) {
640+
(false, true) => return Ordering::Less, // No arbitrary before arbitrary
641+
(true, false) => return Ordering::Greater,
642+
(true, true) => {
643+
// Both have arbitrary variants - compare selectors FIRST
644+
let decode = |s: &str| s.replace('_', " ");
645+
let a: Vec<_> = self.arbitrary_variants.iter().map(|s| decode(s)).collect();
646+
let b: Vec<_> = other.arbitrary_variants.iter().map(|s| decode(s)).collect();
647+
match a.cmp(&b) {
648+
Ordering::Equal => {
649+
// Same arbitrary selectors - compare known variant bits
650+
// (mask out the arbitrary bit for comparison)
651+
let self_known = self.variant_order & !ARBITRARY_BIT;
652+
let other_known = other.variant_order & !ARBITRARY_BIT;
653+
if self_known != other_known {
654+
return self_known.cmp(&other_known);
655+
}
656+
// Fall through to fine-grained comparison
657+
}
658+
other => return other,
659+
}
660+
}
661+
(false, false) => {
662+
// Neither has arbitrary - compare by known variant bits
663+
if self.variant_order != other.variant_order {
664+
return self.variant_order.cmp(&other.variant_order);
665+
}
666+
// Fall through to fine-grained comparison
623667
}
624668
}
625669

626-
// 2. Compare by variant order (bitwise OR of all variant indices)
627-
// This matches Tailwind's algorithm exactly - variant_order comes FIRST
628-
// When variant_order is equal, fall through to fine-grained variant chain comparison
629-
self.variant_order
630-
.cmp(&other.variant_order)
631-
// 3. Fine-grained recursive variant chain comparison
632-
// When coarse variant_order ties, compare the actual variant chains
633-
// This handles multi-level variants like focus:dark: vs dark:focus:
634-
.then_with(|| compare_variant_lists(&self.variant_chain, &other.variant_chain))
670+
// 3. Fine-grained recursive variant chain comparison
671+
// When coarse variant_order ties, compare the actual variant chains
672+
// This handles multi-level variants like focus:dark: vs dark:focus:
673+
compare_variant_lists(&self.variant_chain, &other.variant_chain)
635674
// Then compare by property indices - compare ALL properties in order
636675
// This is crucial for utilities like rounded-t vs rounded-l that tie on first property
637676
.then_with(|| {
@@ -903,6 +942,15 @@ impl PatternSorter {
903942
// Parse variants into structured form for recursive comparison
904943
let variant_chain = parse_variants(&parsed.variants);
905944

945+
// Extract arbitrary variants for lexicographic tiebreaking
946+
// These are variants that start with '[' (e.g., [&.htmx-request], [&>*])
947+
let arbitrary_variants: Vec<compact_str::CompactString> = parsed
948+
.variants
949+
.iter()
950+
.filter(|v| v.starts_with('['))
951+
.map(|v| compact_str::CompactString::new(*v))
952+
.collect();
953+
906954
// Get the CSS properties this utility generates
907955
let properties = parsed.get_properties()?;
908956

@@ -940,6 +988,7 @@ impl PatternSorter {
940988
Some(SortKey {
941989
variant_order,
942990
variant_chain,
991+
arbitrary_variants,
943992
property_indices,
944993
numeric_value,
945994
is_negative,
@@ -1131,6 +1180,7 @@ mod tests {
11311180
let key1 = SortKey {
11321181
variant_order: 0,
11331182
variant_chain: vec![],
1183+
arbitrary_variants: vec![],
11341184
property_indices: vec![100],
11351185
numeric_value: None,
11361186
is_negative: false,
@@ -1142,6 +1192,7 @@ mod tests {
11421192
let key2 = SortKey {
11431193
variant_order: 1,
11441194
variant_chain: parse_variants(&["md"]),
1195+
arbitrary_variants: vec![],
11451196
property_indices: vec![100],
11461197
numeric_value: None,
11471198
is_negative: false,
@@ -1159,6 +1210,7 @@ mod tests {
11591210
let key1 = SortKey {
11601211
variant_order: 0,
11611212
variant_chain: vec![],
1213+
arbitrary_variants: vec![],
11621214
property_indices: vec![50],
11631215
numeric_value: None,
11641216
is_negative: false,
@@ -1170,6 +1222,7 @@ mod tests {
11701222
let key2 = SortKey {
11711223
variant_order: 0,
11721224
variant_chain: vec![],
1225+
arbitrary_variants: vec![],
11731226
property_indices: vec![100],
11741227
numeric_value: None,
11751228
is_negative: false,
@@ -1187,6 +1240,7 @@ mod tests {
11871240
let key1 = SortKey {
11881241
variant_order: 0,
11891242
variant_chain: vec![],
1243+
arbitrary_variants: vec![],
11901244
property_indices: vec![100],
11911245
numeric_value: None,
11921246
is_negative: false,
@@ -1198,6 +1252,7 @@ mod tests {
11981252
let key2 = SortKey {
11991253
variant_order: 0,
12001254
variant_chain: vec![],
1255+
arbitrary_variants: vec![],
12011256
property_indices: vec![100],
12021257
numeric_value: None,
12031258
is_negative: false,
@@ -1215,6 +1270,7 @@ mod tests {
12151270
let key1 = SortKey {
12161271
variant_order: 0,
12171272
variant_chain: vec![],
1273+
arbitrary_variants: vec![],
12181274
property_indices: vec![100],
12191275
numeric_value: None,
12201276
is_negative: false,
@@ -1226,6 +1282,7 @@ mod tests {
12261282
let key2 = SortKey {
12271283
variant_order: 0,
12281284
variant_chain: vec![],
1285+
arbitrary_variants: vec![],
12291286
property_indices: vec![100],
12301287
numeric_value: None,
12311288
is_negative: false,
@@ -1358,6 +1415,7 @@ mod tests {
13581415
let key1 = SortKey {
13591416
variant_order: 0,
13601417
variant_chain: vec![],
1418+
arbitrary_variants: vec![],
13611419
property_indices: vec![100],
13621420
numeric_value: Some(4.0),
13631421
is_negative: false,
@@ -1368,6 +1426,7 @@ mod tests {
13681426
let key2 = SortKey {
13691427
variant_order: 0,
13701428
variant_chain: vec![],
1429+
arbitrary_variants: vec![],
13711430
property_indices: vec![100],
13721431
numeric_value: Some(8.0),
13731432
is_negative: false,
@@ -1381,6 +1440,7 @@ mod tests {
13811440
let key3 = SortKey {
13821441
variant_order: 0,
13831442
variant_chain: vec![],
1443+
arbitrary_variants: vec![],
13841444
property_indices: vec![100],
13851445
numeric_value: Some(50.0),
13861446
is_negative: false,
@@ -1391,6 +1451,7 @@ mod tests {
13911451
let key4 = SortKey {
13921452
variant_order: 0,
13931453
variant_chain: vec![],
1454+
arbitrary_variants: vec![],
13941455
property_indices: vec![100],
13951456
numeric_value: Some(110.0),
13961457
is_negative: false,
@@ -1404,6 +1465,7 @@ mod tests {
14041465
let key5 = SortKey {
14051466
variant_order: 0,
14061467
variant_chain: vec![],
1468+
arbitrary_variants: vec![],
14071469
property_indices: vec![100],
14081470
numeric_value: Some(4.0),
14091471
is_negative: false,
@@ -1414,6 +1476,7 @@ mod tests {
14141476
let key6 = SortKey {
14151477
variant_order: 0,
14161478
variant_chain: vec![],
1479+
arbitrary_variants: vec![],
14171480
property_indices: vec![100],
14181481
numeric_value: None,
14191482
is_negative: false,

0 commit comments

Comments
 (0)