Skip to content

Commit 2a1bc52

Browse files
Fix Tailwind prettier parity (#145)
* Fix Tailwind comparison parity Recognize parenthesized ring colors and order typed container-query breakpoints by direction and size. Treat nonconvergent Prettier output separately so it cannot create false ordering mismatches. * Order arbitrary container queries Treat arbitrary query lengths as container breakpoints so minimum and maximum variants follow Tailwind's resolved size ordering. * Parse comparison attributes from syntax Use the pinned TSX, Svelte, and Astro parsers so corpus ranking and comparison ignore class-like text in comments and strings. * Parse typed ring custom properties Keep colons inside balanced parenthesized utility values so typed ring custom properties remain one class segment. Map length hints to ring width properties while preserving Tailwind's color behavior for untyped values.
1 parent fa26c70 commit 2a1bc52

17 files changed

Lines changed: 1605 additions & 234 deletions

rustywind-core/src/class_name.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#[derive(Debug, Clone, PartialEq, Eq)]
2+
pub(crate) struct ClassSegments<'a> {
3+
parts: Vec<&'a str>,
4+
utility_start: usize,
5+
}
6+
7+
impl<'a> ClassSegments<'a> {
8+
pub(crate) fn parse(class: &'a str) -> Option<Self> {
9+
if class.is_empty() {
10+
return None;
11+
}
12+
13+
let mut parts = Vec::new();
14+
let mut start = 0;
15+
let mut delimiters = Vec::new();
16+
let mut quote = None;
17+
let mut escaped = false;
18+
19+
for (index, character) in class.char_indices() {
20+
if escaped {
21+
escaped = false;
22+
continue;
23+
}
24+
25+
if character == '\\' {
26+
escaped = true;
27+
continue;
28+
}
29+
30+
if let Some(active_quote) = quote {
31+
if character == active_quote {
32+
quote = None;
33+
}
34+
continue;
35+
}
36+
37+
if !delimiters.is_empty() && matches!(character, '\'' | '"' | '`') {
38+
quote = Some(character);
39+
continue;
40+
}
41+
42+
match character {
43+
'[' => delimiters.push(']'),
44+
'(' => delimiters.push(')'),
45+
']' | ')' if delimiters.pop() != Some(character) => return None,
46+
':' if delimiters.is_empty() => {
47+
if start == index {
48+
return None;
49+
}
50+
parts.push(&class[start..index]);
51+
start = index + character.len_utf8();
52+
}
53+
_ => {}
54+
}
55+
}
56+
57+
if escaped || quote.is_some() || !delimiters.is_empty() || start == class.len() {
58+
return None;
59+
}
60+
61+
let utility_start = start;
62+
parts.push(&class[start..]);
63+
64+
Some(Self {
65+
parts,
66+
utility_start,
67+
})
68+
}
69+
70+
pub(crate) fn variants(&self) -> &[&'a str] {
71+
&self.parts[..self.parts.len() - 1]
72+
}
73+
74+
pub(crate) fn utility(&self) -> &'a str {
75+
self.parts[self.parts.len() - 1]
76+
}
77+
78+
pub(crate) fn utility_start(&self) -> usize {
79+
self.utility_start
80+
}
81+
}
82+
83+
#[cfg(test)]
84+
mod tests {
85+
use super::ClassSegments;
86+
87+
#[test]
88+
fn splits_only_top_level_variant_separators() {
89+
let segments =
90+
ClassSegments::parse("supports-[selector(:has(*))]:hover:ring-(length:--ring-width)")
91+
.unwrap();
92+
93+
assert_eq!(
94+
segments.variants(),
95+
&["supports-[selector(:has(*))]", "hover"]
96+
);
97+
assert_eq!(segments.utility(), "ring-(length:--ring-width)");
98+
}
99+
100+
#[test]
101+
fn ignores_delimiters_inside_quoted_arbitrary_values() {
102+
let segments = ClassSegments::parse("before:content-[':)']").unwrap();
103+
104+
assert_eq!(segments.variants(), &["before"]);
105+
assert_eq!(segments.utility(), "content-[':)']");
106+
}
107+
108+
#[test]
109+
fn rejects_unbalanced_or_empty_segments() {
110+
assert!(ClassSegments::parse("hover::flex").is_none());
111+
assert!(ClassSegments::parse("hover:ring-(length:--width").is_none());
112+
assert!(ClassSegments::parse("hover:ring-length:--width)").is_none());
113+
}
114+
}

rustywind-core/src/class_parser.rs

Lines changed: 17 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
//! assert!(parsed.important);
2929
//! ```
3030
31-
use crate::utility_map::UTILITY_MAP;
31+
use crate::{class_name::ClassSegments, utility_map::UTILITY_MAP};
3232

3333
/// A parsed Tailwind CSS class name with all its components.
3434
///
@@ -174,24 +174,17 @@ pub fn parse_class(class: &str) -> Option<ParsedClass<'_>> {
174174
working = &working[..working.len() - 1];
175175
}
176176

177-
// split by ':' but respect brackets - ':' inside [] should not be a separator
178-
// e.g., "[&>*:last-child]:rounded-b-lg" -> ["[&>*:last-child]", "rounded-b-lg"]
179-
let parts = split_respecting_brackets(working);
177+
let segments = ClassSegments::parse(working)?;
180178

181-
if parts.is_empty() {
182-
return None;
183-
}
184-
185-
if parts.len() > 1
186-
&& parts[..parts.len() - 1]
187-
.iter()
188-
.any(|part| part.starts_with('!'))
179+
if segments
180+
.variants()
181+
.iter()
182+
.any(|variant| variant.starts_with('!'))
189183
{
190184
return None;
191185
}
192186

193-
// last part is the utility (with value)
194-
let mut utility_part = parts[parts.len() - 1];
187+
let mut utility_part = segments.utility();
195188
if let Some(stripped) = utility_part.strip_prefix('!') {
196189
important = true;
197190
utility_part = stripped;
@@ -200,11 +193,7 @@ pub fn parse_class(class: &str) -> Option<ParsedClass<'_>> {
200193
// everything before is variants
201194
// Tailwind parses variants RIGHT-TO-LEFT, so we need to reverse them
202195
// for dark:hover:utility, Tailwind stores [hover, dark], not [dark, hover]
203-
let mut variants = if parts.len() > 1 {
204-
parts[..parts.len() - 1].to_vec()
205-
} else {
206-
vec![]
207-
};
196+
let mut variants = segments.variants().to_vec();
208197
variants.reverse(); // Match Tailwind's right-to-left parsing order
209198

210199
// parse utility into base + value
@@ -219,38 +208,6 @@ pub fn parse_class(class: &str) -> Option<ParsedClass<'_>> {
219208
})
220209
}
221210

222-
/// Split a class string by ':' while respecting bracket nesting.
223-
/// Colons inside square brackets `[]` are NOT treated as separators.
224-
///
225-
/// # Examples
226-
/// - `"hover:p-4"` -> `["hover", "p-4"]`
227-
/// - `"[&>*:last-child]:rounded-b-lg"` -> `["[&>*:last-child]", "rounded-b-lg"]`
228-
/// - `"dark:[&.active]:bg-red-500"` -> `["dark", "[&.active]", "bg-red-500"]`
229-
fn split_respecting_brackets(s: &str) -> Vec<&str> {
230-
let mut parts = Vec::new();
231-
let mut start = 0;
232-
let mut bracket_depth: u32 = 0;
233-
234-
for (i, c) in s.char_indices() {
235-
match c {
236-
'[' => bracket_depth += 1,
237-
']' => bracket_depth = bracket_depth.saturating_sub(1),
238-
':' if bracket_depth == 0 => {
239-
parts.push(&s[start..i]);
240-
start = i + 1;
241-
}
242-
_ => {}
243-
}
244-
}
245-
246-
// don't forget the last part
247-
if start < s.len() {
248-
parts.push(&s[start..]);
249-
}
250-
251-
parts
252-
}
253-
254211
/// Parse a utility string into base and value parts.
255212
///
256213
/// This reuses the logic from utility_map but is adapted for class parsing.
@@ -539,6 +496,15 @@ mod tests {
539496
assert_eq!(parsed.value, "[100px]");
540497
}
541498

499+
#[test]
500+
fn test_parse_typed_parenthesized_value() {
501+
let parsed = parse_class("hover:ring-(length:--ring-width)").unwrap();
502+
503+
assert_eq!(parsed.variants, vec!["hover"]);
504+
assert_eq!(parsed.utility, "ring");
505+
assert_eq!(parsed.value, "(length:--ring-width)");
506+
}
507+
542508
#[test]
543509
fn test_parse_multi_part_utility() {
544510
let parsed = parse_class("min-w-0").unwrap();

rustywind-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
//! Use [`RustyWind::sort_document`] with a [`SourceDocument`] so RustyWind can
44
//! distinguish static class text from embedded template code
55
pub mod app;
6+
mod class_name;
67
pub mod class_wrapping;
78
pub mod consts;
89
pub mod defaults;

rustywind-core/src/pattern_sorter.rs

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
2929
use std::cmp::Ordering;
3030

31+
use crate::class_name::ClassSegments;
3132
use crate::class_parser::parse_class;
3233
use crate::property_order::get_property_index;
3334
use crate::tailwind_prefix::{normalize_tailwind_prefix, normalize_tailwind_prefix_value};
@@ -327,19 +328,7 @@ fn compare_alphanumeric(a: &str, z: &str) -> Ordering {
327328
}
328329

329330
fn utility_part(class: &str) -> &str {
330-
let mut start = 0;
331-
let mut bracket_depth: u32 = 0;
332-
333-
for (index, character) in class.char_indices() {
334-
match character {
335-
'[' => bracket_depth += 1,
336-
']' => bracket_depth = bracket_depth.saturating_sub(1),
337-
':' if bracket_depth == 0 => start = index + 1,
338-
_ => {}
339-
}
340-
}
341-
342-
let utility = &class[start..];
331+
let utility = ClassSegments::parse(class).map_or(class, |segments| segments.utility());
343332
let utility = utility.strip_prefix('!').unwrap_or(utility);
344333
utility.strip_suffix('!').unwrap_or(utility)
345334
}

rustywind-core/src/tailwind_prefix.rs

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use std::borrow::Cow;
22

3+
use crate::class_name::ClassSegments;
4+
35
/// Normalize a Tailwind-prefixed class into the class name used for sorting.
46
///
57
/// Tailwind v3 places the prefix on the utility (`md:tw-text-lg`), while v4
@@ -19,7 +21,10 @@ pub fn normalize_tailwind_prefix<'a>(class: &'a str, prefix: Option<&str>) -> Co
1921
return Cow::Borrowed(rest);
2022
}
2123

22-
let utility_start = utility_start(class);
24+
let Some(utility_start) = ClassSegments::parse(class).map(|segments| segments.utility_start())
25+
else {
26+
return Cow::Borrowed(class);
27+
};
2328
let (variants, utility) = class.split_at(utility_start);
2429

2530
match normalize_v3_utility(utility, prefix) {
@@ -34,22 +39,6 @@ pub(crate) fn normalize_tailwind_prefix_value(prefix: &str) -> Option<&str> {
3439
(!prefix.is_empty()).then_some(prefix)
3540
}
3641

37-
fn utility_start(class: &str) -> usize {
38-
let mut start = 0;
39-
let mut bracket_depth: u32 = 0;
40-
41-
for (index, character) in class.char_indices() {
42-
match character {
43-
'[' => bracket_depth += 1,
44-
']' => bracket_depth = bracket_depth.saturating_sub(1),
45-
':' if bracket_depth == 0 => start = index + 1,
46-
_ => {}
47-
}
48-
}
49-
50-
start
51-
}
52-
5342
fn normalize_v3_utility<'a>(utility: &'a str, prefix: &str) -> Option<Cow<'a, str>> {
5443
let (important, utility) = utility
5544
.strip_prefix('!')
@@ -146,6 +135,14 @@ mod tests {
146135
);
147136
}
148137

138+
#[test]
139+
fn ignores_colons_inside_parenthesized_utility_values() {
140+
assert_eq!(
141+
normalize_tailwind_prefix("hover:tw-ring-(length:--ring-width)", Some("tw")),
142+
"hover:ring-(length:--ring-width)"
143+
);
144+
}
145+
149146
#[test]
150147
fn leaves_unprefixed_classes_unchanged() {
151148
assert_eq!(

0 commit comments

Comments
 (0)