From 50118e903e301c6e3500928cc6dadd10d34e2a35 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 21 Jul 2026 13:49:17 -0500 Subject: [PATCH 1/6] Sort named design-system colors as known Classify arbitrary named design-system values such as bg-muted, text-foreground, and bg-card/90 as color utilities so they sort like Prettier. Side border names map to border color rather than width; shadow-family names stay unclassified because they are ambiguous with custom sizes. The parity harness now includes a shadcn-style semantic palette for grading these tokens. --- CHANGELOG.md | 7 + rustywind-core/CHANGELOG.md | 6 + rustywind-core/src/class_parser.rs | 4 +- rustywind-core/src/hybrid_sorter.rs | 71 +++++++ rustywind-core/src/utility_map.rs | 188 ++++++++++++++++-- rustywind-core/tests/fuzz_regression_tests.rs | 7 +- rustywind-core/tests/test_opacity_sorting.rs | 13 +- .../test_parenthesized_ring_utilities.rs | 12 +- tests/tailwind-compare/README.md | 13 +- tests/tailwind-compare/tailwind.css | 36 ++++ tests/tailwind-compare/test/compare.test.mjs | 19 ++ 11 files changed, 341 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c55a48b..3cd1ccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ ### Changed +- Sort arbitrary named design-system colors such as `bg-muted`, + `text-foreground`, `border-input`, and `bg-card/90` as color utilities instead + of unknown classes; `border-{t,r,b,l,x,y,s,e}-` now resolves to side + border color instead of border width, while shadow-family named values stay + unclassified because they are ambiguous with custom `--shadow-*` sizes; the + parity harness stylesheet now defines a shadcn-style semantic palette so + these tokens are graded against Prettier - Verify sorting parity against Tailwind CSS 4.3.3 and prettier-plugin-tailwindcss 0.8.1 with zero ordering divergences across all pinned comparison corpora and fuzz rounds; the comparison and fuzz harnesses diff --git a/rustywind-core/CHANGELOG.md b/rustywind-core/CHANGELOG.md index 67b5d19..95d38f9 100644 --- a/rustywind-core/CHANGELOG.md +++ b/rustywind-core/CHANGELOG.md @@ -24,6 +24,12 @@ ### Changed +- Classify arbitrary named design-system values in color-capable utility-map + families, including opacity-stripped values such as `bg-card/90`, so they + receive color properties instead of remaining unknown; + `border-{t,r,b,l,x,y,s,e}-` now maps to side border color instead of + border width, while shadow-family named values remain unclassified because + they are ambiguous with custom `--shadow-*` sizes - Parse known markup languages with Winnow and extract typed class-attribute spans directly instead of scanning every regular-expression match against every tag - Parse template expressions, arbitrary-value brackets, comments, strings, and regular expressions diff --git a/rustywind-core/src/class_parser.rs b/rustywind-core/src/class_parser.rs index c2f1076..07a8e83 100644 --- a/rustywind-core/src/class_parser.rs +++ b/rustywind-core/src/class_parser.rs @@ -651,11 +651,11 @@ mod tests { assert_eq!(parsed.value, "red-500/50"); assert_eq!(parsed.get_properties(), Some(&["background-color"][..])); - // test custom color with opacity (should be unknown) + // named design-system colors resolve through the named-color fallback let parsed = parse_class("bg-primary/20").unwrap(); assert_eq!(parsed.utility, "bg"); assert_eq!(parsed.value, "primary/20"); - assert_eq!(parsed.get_properties(), None); // Custom color = unknown + assert_eq!(parsed.get_properties(), Some(&["background-color"][..])); // test variant + opacity let parsed = parse_class("dark:text-white/90").unwrap(); diff --git a/rustywind-core/src/hybrid_sorter.rs b/rustywind-core/src/hybrid_sorter.rs index 7aca8c8..60e6155 100644 --- a/rustywind-core/src/hybrid_sorter.rs +++ b/rustywind-core/src/hybrid_sorter.rs @@ -342,6 +342,77 @@ mod tests { assert_eq!(sorted[3], "grid"); } + #[test] + fn test_named_colors_sort_as_known() { + let sorter = HybridSorter::new(); + let classes = vec!["bg-muted", "flex", "unknown-class"]; + + assert_eq!( + sorter.sort_classes(&classes), + vec!["unknown-class", "flex", "bg-muted"] + ); + } + + #[test] + fn test_named_color_ordering_matches_prettier() { + let sorter = HybridSorter::new(); + + let classes = vec![ + "bg-red-500", + "bg-muted", + "bg-primary", + "bg-primary-foreground", + "bg-card", + "bg-card/90", + "bg-chart-2", + "bg-chart-1", + "text-foreground", + "border-input", + ]; + assert_eq!( + sorter.sort_classes(&classes), + vec![ + "border-input", + "bg-card", + "bg-card/90", + "bg-chart-1", + "bg-chart-2", + "bg-muted", + "bg-primary", + "bg-primary-foreground", + "bg-red-500", + "text-foreground", + ] + ); + + let classes = vec!["bg-card/90", "bg-card"]; + assert_eq!(sorter.sort_classes(&classes), vec!["bg-card", "bg-card/90"]); + + let classes = vec!["bg-primary-foreground", "bg-primary"]; + assert_eq!( + sorter.sort_classes(&classes), + vec!["bg-primary", "bg-primary-foreground"] + ); + + let classes = vec!["bg-muted", "bg-red-500", "bg-background"]; + assert_eq!( + sorter.sort_classes(&classes), + vec!["bg-background", "bg-muted", "bg-red-500"] + ); + + let classes = vec!["hover:bg-muted", "hover:flex", "bg-muted", "flex"]; + assert_eq!( + sorter.sort_classes(&classes), + vec!["flex", "bg-muted", "hover:flex", "hover:bg-muted"] + ); + + let classes = vec!["text-muted-foreground", "text-sm", "line-clamp-2"]; + assert_eq!( + sorter.sort_classes(&classes), + vec!["line-clamp-2", "text-sm", "text-muted-foreground"] + ); + } + #[test] fn test_relative_order_preserved_for_unknown_classes() { // test that unknown classes maintain their relative order diff --git a/rustywind-core/src/utility_map.rs b/rustywind-core/src/utility_map.rs index 2dfbdfe..a20cf55 100644 --- a/rustywind-core/src/utility_map.rs +++ b/rustywind-core/src/utility_map.rs @@ -1110,6 +1110,8 @@ impl UtilityMap { "bg-size" => Some(&["background-size"][..]), "bg" if is_color_like_value(value) => Some(&["background-color"][..]), "bg" if value.starts_with('[') => Some(&["background-color"][..]), // arbitrary value + // known non-color forms win before this denylist fallback + "bg" if is_named_color_value(value) => Some(&["background-color"][..]), // border width "border-bs" | "border-be" | "border-is" | "border-ie" => None, @@ -1121,14 +1123,30 @@ impl UtilityMap { { Some(&["border-width"][..]) } - "border-x" if is_color_like_value(value) => Some(&["border-inline-color"][..]), - "border-y" if is_color_like_value(value) => Some(&["border-block-color"][..]), - "border-s" if is_color_like_value(value) => Some(&["border-inline-start-color"][..]), - "border-e" if is_color_like_value(value) => Some(&["border-inline-end-color"][..]), - "border-t" if is_color_like_value(value) => Some(&["border-top-color"][..]), - "border-r" if is_color_like_value(value) => Some(&["border-right-color"][..]), - "border-b" if is_color_like_value(value) => Some(&["border-bottom-color"][..]), - "border-l" if is_color_like_value(value) => Some(&["border-left-color"][..]), + "border-x" if is_color_like_value(value) || is_named_color_value(value) => { + Some(&["border-inline-color"][..]) + } + "border-y" if is_color_like_value(value) || is_named_color_value(value) => { + Some(&["border-block-color"][..]) + } + "border-s" if is_color_like_value(value) || is_named_color_value(value) => { + Some(&["border-inline-start-color"][..]) + } + "border-e" if is_color_like_value(value) || is_named_color_value(value) => { + Some(&["border-inline-end-color"][..]) + } + "border-t" if is_color_like_value(value) || is_named_color_value(value) => { + Some(&["border-top-color"][..]) + } + "border-r" if is_color_like_value(value) || is_named_color_value(value) => { + Some(&["border-right-color"][..]) + } + "border-b" if is_color_like_value(value) || is_named_color_value(value) => { + Some(&["border-bottom-color"][..]) + } + "border-l" if is_color_like_value(value) || is_named_color_value(value) => { + Some(&["border-left-color"][..]) + } "border-x" => Some(&["border-inline-width"][..]), // Use border-inline-width for left+right "border-y" => Some(&["border-block-width"][..]), // Use border-block-width for top+bottom "border-s" => Some(&["border-inline-start-width"][..]), @@ -1140,6 +1158,7 @@ impl UtilityMap { // border color "border" if is_color_like_value(value) => Some(&["border-color"][..]), + "border" if is_named_color_value(value) => Some(&["border-color"][..]), // border radius "rounded" @@ -1172,6 +1191,7 @@ impl UtilityMap { "text" if value.starts_with('(') => Some(&["color"][..]), "text" if is_size_keyword(value) => Some(&["font-size"][..]), "text" if value.starts_with('[') => Some(&["font-size"][..]), // arbitrary text size + "text" if is_named_color_value(value) => Some(&["color"][..]), // font "font" if is_weight_keyword(value) => Some(&["font-weight"][..]), @@ -1216,6 +1236,7 @@ impl UtilityMap { ) } "ring" if is_ring_color_value(value) => Some(&["--tw-ring-color"][..]), + "ring" if is_named_color_value(value) => Some(&["--tw-ring-color"][..]), "ring-offset" if value.parse::().is_ok() || ParenthesizedRingValueKind::parse(value) @@ -1224,6 +1245,7 @@ impl UtilityMap { Some(&["--tw-ring-offset-width"][..]) } "ring-offset" if is_ring_color_value(value) => Some(&["--tw-ring-offset-color"][..]), + "ring-offset" if is_named_color_value(value) => Some(&["--tw-ring-offset-color"][..]), "inset-ring" if value.is_empty() || value.parse::().is_ok() @@ -1233,6 +1255,7 @@ impl UtilityMap { Some(&["--tw-inset-ring-shadow"][..]) } "inset-ring" if is_ring_color_value(value) => Some(&["--tw-inset-ring-color"][..]), + "inset-ring" if is_named_color_value(value) => Some(&["--tw-inset-ring-color"][..]), // transitions "transition" => Some(&["transition-property"][..]), @@ -1357,25 +1380,40 @@ impl UtilityMap { Some(&["outline-width"][..]) } "outline" if is_color_value(value) => Some(&["outline-color"][..]), + "outline" if is_named_color_value(value) => Some(&["outline-color"][..]), "outline-offset" => Some(&["outline-offset"][..]), // accent color - "accent" if is_color_value(value) || value == "auto" || value == "current" => { + "accent" + if is_color_value(value) + || is_named_color_value(value) + || value == "auto" + || value == "current" => + { Some(&["accent-color"][..]) } // caret color - "caret" if is_color_value(value) || value == "current" => Some(&["caret-color"][..]), + "caret" + if is_color_value(value) || is_named_color_value(value) || value == "current" => + { + Some(&["caret-color"][..]) + } // placeholder color - "placeholder" if is_color_like_value(value) => Some(&["placeholder-color"][..]), + "placeholder" if is_color_like_value(value) || is_named_color_value(value) => { + Some(&["placeholder-color"][..]) + } // svg paint - "fill" if is_color_like_value(value) => Some(&["fill"][..]), + "fill" if is_color_like_value(value) || is_named_color_value(value) => { + Some(&["fill"][..]) + } "stroke" if value.parse::().is_ok() || is_arbitrary_like_value(value) => { Some(&["stroke-width"][..]) } "stroke" if is_color_like_value(value) => Some(&["stroke"][..]), + "stroke" if is_named_color_value(value) => Some(&["stroke"][..]), "object" if is_arbitrary_like_value(value) => Some(&["object-position"][..]), // space between @@ -1386,8 +1424,12 @@ impl UtilityMap { "space-y" => Some(&["column-gap"][..]), // divide - "divide-x" if is_color_like_value(value) => Some(&["--tw-divide-color-sort"][..]), - "divide-y" if is_color_like_value(value) => Some(&["--tw-divide-color-sort"][..]), + "divide-x" if is_color_like_value(value) || is_named_color_value(value) => { + Some(&["--tw-divide-color-sort"][..]) + } + "divide-y" if is_color_like_value(value) || is_named_color_value(value) => { + Some(&["--tw-divide-color-sort"][..]) + } "divide-x" if value.is_empty() || value.parse::().is_ok() => { Some(&["divide-x-width"][..]) } @@ -1395,6 +1437,7 @@ impl UtilityMap { Some(&["divide-y-width"][..]) } "divide" if is_color_value(value) => Some(&["divide-color"][..]), + "divide" if is_named_color_value(value) => Some(&["divide-color"][..]), "divide-opacity" => Some(&["border-opacity"][..]), // leading (line-height) @@ -1415,6 +1458,9 @@ impl UtilityMap { "from" if is_gradient_position(value) => Some(&["--tw-gradient-from-position"][..]), "via" if is_gradient_position(value) => Some(&["--tw-gradient-via-position"][..]), "to" if is_gradient_position(value) => Some(&["--tw-gradient-to-position"][..]), + "from" if is_named_color_value(value) => Some(&["--tw-gradient-from"][..]), + "via" if is_named_color_value(value) => Some(&["--tw-gradient-via"][..]), + "to" if is_named_color_value(value) => Some(&["--tw-gradient-to"][..]), // aspect ratio (arbitrary values) "aspect" => Some(&["aspect-ratio"][..]), @@ -1424,6 +1470,7 @@ impl UtilityMap { "decoration" if value.parse::().is_ok() => { Some(&["text-decoration-thickness"][..]) } + "decoration" if is_named_color_value(value) => Some(&["text-decoration-color"][..]), // underline offset "underline-offset" => Some(&["text-underline-offset"][..]), @@ -2087,6 +2134,28 @@ fn is_color_like_value(value: &str) -> bool { is_color_value(value) || value == "current" || value.starts_with('(') } +/// Denylist fallback for Tailwind v4 `--color-*` theme keys +/// +/// After every known non-color form of a color-capable prefix is excluded, an unknown named value +/// such as `muted`, `muted-foreground`, `chart-1`, or `lightPrimary` is assumed to be a theme key +/// Values arrive opacity-stripped because `parse_utility_parts` strips from the first `/`, so +/// `bg-card/90` is seen as `card` +fn is_named_color_value(value: &str) -> bool { + let Some((first, remaining)) = value.as_bytes().split_first() else { + return false; + }; + + first.is_ascii_alphabetic() + && remaining + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-') + && !value.ends_with('-') + && !matches!( + value, + "auto" | "none" | "initial" | "unset" | "revert" | "revert-layer" | "inherit" + ) +} + fn is_flex_value(value: &str) -> bool { matches!(value, "auto" | "initial" | "none") || is_numeric_value(value) @@ -2421,6 +2490,76 @@ mod tests { ); } + #[test] + fn test_named_design_system_colors() { + let map = UtilityMap::new(); + let cases: &[(&str, &[&str])] = &[ + ("bg-muted", &["background-color"]), + ("bg-card/90", &["background-color"]), + ("text-foreground", &["color"]), + ("text-muted-foreground", &["color"]), + ("border-input", &["border-color"]), + ("border-t-input", &["border-top-color"]), + ("border-b-border", &["border-bottom-color"]), + ("ring-ring", &["--tw-ring-color"]), + ("ring-offset-background", &["--tw-ring-offset-color"]), + ("inset-ring-primary", &["--tw-inset-ring-color"]), + ("outline-ring", &["outline-color"]), + ("accent-primary", &["accent-color"]), + ("caret-primary", &["caret-color"]), + ("placeholder-muted", &["placeholder-color"]), + ("fill-muted", &["fill"]), + ("stroke-muted", &["stroke"]), + ("divide-border", &["divide-color"]), + ("divide-x-border", &["--tw-divide-color-sort"]), + ("from-background", &["--tw-gradient-from"]), + ("via-muted", &["--tw-gradient-via"]), + ("to-primary", &["--tw-gradient-to"]), + ("decoration-primary", &["text-decoration-color"]), + ("bg-sidebar-primary-foreground", &["background-color"]), + ("bg-chart-1", &["background-color"]), + // color-capable prefixes accept project-defined palette names + ("from-alternative", &["--tw-gradient-from"]), + ("decoration-charcoal-500", &["text-decoration-color"]), + ]; + + for &(utility, properties) in cases { + assert_eq!(map.get_properties(utility), Some(properties), "{utility}"); + } + } + + #[test] + fn test_named_color_fallback_exclusions() { + let map = UtilityMap::new(); + let cases: &[(&str, &[&str])] = &[ + ("bg-clip-border", &["background-clip"]), + ("bg-gradient-to-b", &["background-image"]), + ("bg-cover", &["background-size"]), + ("bg-fixed", &["background-attachment"]), + ("border-2", &["border-width"]), + ("border-b", &["border-bottom-width"]), + ("border-t-[3px]", &["border-top-width"]), + ("border-dashed", &["border-style"]), + ("text-sm", &["font-size"]), + ("text-center", &["text-align"]), + ("font-display", &["font-family"]), + ("font-bold", &["font-weight"]), + ("inset-shadow-custom", &["--tw-inset-shadow"]), + ("stroke-2", &["stroke-width"]), + ("decoration-wavy", &["text-decoration-style"]), + ("from-10%", &["--tw-gradient-from-position"]), + ("outline-none", &["outline-style"]), + ]; + + for &(utility, properties) in cases { + assert_eq!(map.get_properties(utility), Some(properties), "{utility}"); + } + + assert_eq!(map.get_properties("shadow-card"), None); + assert_eq!(map.get_properties("text-shadow-custom"), None); + assert_eq!(map.get_properties("drop-shadow-glow"), None); + } + #[test] fn test_arbitrary_values() { let map = UtilityMap::new(); @@ -2461,8 +2600,6 @@ mod tests { assert_eq!(map.get_properties("unknown-utility"), None); assert_eq!(map.get_properties("fake-class"), None); assert_eq!(map.get_properties("flex-center"), None); - assert_eq!(map.get_properties("from-alternative"), None); - assert_eq!(map.get_properties("decoration-charcoal-500"), None); assert_eq!(map.get_properties("text-shadow-custom"), None); assert_eq!(map.get_properties("stroke-1.5"), None); assert_eq!(map.get_properties("will-change"), None); @@ -2492,6 +2629,25 @@ mod tests { assert!(!is_color_value("")); } + #[test] + fn test_is_named_color_value() { + assert!(is_named_color_value("muted")); + assert!(is_named_color_value("muted-foreground")); + assert!(is_named_color_value("chart-1")); + assert!(is_named_color_value("lightPrimary")); + + assert!(!is_named_color_value("")); + assert!(!is_named_color_value("[#fff]")); + assert!(!is_named_color_value("(--x)")); + assert!(!is_named_color_value("2")); + assert!(!is_named_color_value("1.5")); + assert!(!is_named_color_value("2xl")); + assert!(!is_named_color_value("10%")); + assert!(!is_named_color_value("auto")); + assert!(!is_named_color_value("none")); + assert!(!is_named_color_value("foo-")); + } + #[test] fn test_is_size_keyword() { assert!(is_size_keyword("xs")); diff --git a/rustywind-core/tests/fuzz_regression_tests.rs b/rustywind-core/tests/fuzz_regression_tests.rs index 372fd02..5c17a64 100644 --- a/rustywind-core/tests/fuzz_regression_tests.rs +++ b/rustywind-core/tests/fuzz_regression_tests.rs @@ -51,13 +51,14 @@ mod opacity_sorting_demo { println!(" Output: {:?}", sorted); println!(); - // Test 2: Custom colors with opacity are treated as unknown (sort first) + // Test 2: named design-system colors with opacity sort as color utilities let input = vec!["flex", "bg-primary/20", "p-4"]; let sorted = sort_classes(&input); - println!("Test 2 - Custom colors with opacity (unknown):"); + println!("Test 2 - Named design-system colors with opacity:"); println!(" Input: {:?}", input); println!(" Output: {:?}", sorted); - assert_eq!(sorted[0], "bg-primary/20"); // Unknown class sorts first + // matches prettier-plugin-tailwindcss with `primary` defined as a theme color + assert_eq!(sorted, vec!["flex", "bg-primary/20", "p-4"]); println!(); // Test 3: Variants with opacity work correctly diff --git a/rustywind-core/tests/test_opacity_sorting.rs b/rustywind-core/tests/test_opacity_sorting.rs index 6e88d45..0cb5924 100644 --- a/rustywind-core/tests/test_opacity_sorting.rs +++ b/rustywind-core/tests/test_opacity_sorting.rs @@ -5,12 +5,13 @@ use rustywind_core::pattern_sorter::sort_classes; fn test_opacity_slash_recognition() { // Test which opacity classes are recognized as known vs unknown let test_cases = vec![ - ("text-white/60", true), // Standard color + opacity → KNOWN - ("bg-black/25", true), // Standard color + opacity → KNOWN - ("bg-red-500/50", true), // Standard color shade + opacity → KNOWN - ("to-stroke/0", false), // custom gradient color + opacity → UNKNOWN - ("bg-primary/20", false), // Custom color + opacity → UNKNOWN - ("from-stroke/0", false), // custom gradient color + opacity → UNKNOWN + ("text-white/60", true), // Standard color + opacity → KNOWN + ("bg-black/25", true), // Standard color + opacity → KNOWN + ("bg-red-500/50", true), // Standard color shade + opacity → KNOWN + // named design-system values resolve through the named-color fallback + ("to-stroke/0", true), // named gradient color + opacity → KNOWN + ("bg-primary/20", true), // named color + opacity → KNOWN + ("from-stroke/0", true), // named gradient color + opacity → KNOWN ("border-gray-300/50", true), // Standard color shade + opacity → KNOWN ]; diff --git a/rustywind-core/tests/test_parenthesized_ring_utilities.rs b/rustywind-core/tests/test_parenthesized_ring_utilities.rs index 7c5a895..6e338ab 100644 --- a/rustywind-core/tests/test_parenthesized_ring_utilities.rs +++ b/rustywind-core/tests/test_parenthesized_ring_utilities.rs @@ -28,7 +28,11 @@ fn parenthesized_ring_colors_map_to_color_properties() { map.get_properties("inset-ring-(color:--color)"), Some(&["--tw-inset-ring-color"][..]) ); - assert_eq!(map.get_properties("ring-offset-background"), None); + // named design-system colors resolve through the named-color fallback + assert_eq!( + map.get_properties("ring-offset-background"), + Some(&["--tw-ring-offset-color"][..]) + ); } #[test] @@ -77,11 +81,12 @@ fn parenthesized_ring_colors_sort_with_ring_color_utilities() { assert!(sorter.get_sort_key("ring-(--color)").is_some()); assert!(sorter.get_sort_key("ring-offset-(--color)").is_some()); - assert!(sorter.get_sort_key("ring-offset-background").is_none()); + // ring-offset-background is a named design-system color, no longer unknown + assert!(sorter.get_sort_key("ring-offset-background").is_some()); + // matches prettier-plugin-tailwindcss with `background` defined as a theme color assert_eq!( sorter.sort_classes(&classes), vec![ - "group-data-[checked=true]/button:ring-offset-background", "size-5", "rounded-full", "bg-(--color)", @@ -90,6 +95,7 @@ fn parenthesized_ring_colors_sort_with_ring_color_utilities() { "ring-offset-2", "ring-offset-(--color)", "group-data-[checked=true]/button:ring-(--color)", + "group-data-[checked=true]/button:ring-offset-background", ] ); } diff --git a/tests/tailwind-compare/README.md b/tests/tailwind-compare/README.md index 24e6fa7..d3e87ae 100644 --- a/tests/tailwind-compare/README.md +++ b/tests/tailwind-compare/README.md @@ -63,8 +63,11 @@ Prettier nonconvergence and custom-only differences do not fail a corpus. Only quoted `class` and `className` literals without template delimiters are in scope. Dynamic expressions, `class:list`, helper calls such as `cn` and `cva`, -and project-specific Tailwind configuration are outside this comparison. A -token is Tailwind-known when the pinned Prettier plugin moves it past two -unknown sentinels using the supplied minimal Tailwind 4 stylesheet. Differences -that preserve the order of known tokens are reported separately as -`custom-only`. +and project-specific Tailwind configuration are outside this comparison; the +shared shadcn-style semantic palette is the one deliberate exception. A token +is Tailwind-known when the pinned Prettier plugin moves it past two unknown +sentinels using a Tailwind 4 stylesheet that layers a shadcn-style semantic +`@theme` palette (background/foreground, card, muted, `sidebar-*`, `chart-1`… +`chart-5`, and others) over the defaults so design-system color tokens are +graded as known utilities. Differences that preserve the order of known tokens +are reported separately as `custom-only`. diff --git a/tests/tailwind-compare/tailwind.css b/tests/tailwind-compare/tailwind.css index f1d8c73..8194dc0 100644 --- a/tests/tailwind-compare/tailwind.css +++ b/tests/tailwind-compare/tailwind.css @@ -1 +1,37 @@ @import "tailwindcss"; + +/* shadcn-style semantic palette so the pinned plugin grades design-system color tokens as known utilities; declaration order verified inert */ +@theme { + --color-accent: #000001; + --color-accent-foreground: #000002; + --color-background: #000003; + --color-border: #000004; + --color-card: #000005; + --color-card-foreground: #000006; + --color-chart-1: #000007; + --color-chart-2: #000008; + --color-chart-3: #000009; + --color-chart-4: #000010; + --color-chart-5: #000011; + --color-destructive: #000012; + --color-destructive-foreground: #000013; + --color-foreground: #000014; + --color-input: #000015; + --color-muted: #000016; + --color-muted-foreground: #000017; + --color-popover: #000018; + --color-popover-foreground: #000019; + --color-primary: #000020; + --color-primary-foreground: #000021; + --color-ring: #000022; + --color-secondary: #000023; + --color-secondary-foreground: #000024; + --color-sidebar: #000025; + --color-sidebar-accent: #000026; + --color-sidebar-accent-foreground: #000027; + --color-sidebar-border: #000028; + --color-sidebar-foreground: #000029; + --color-sidebar-primary: #000030; + --color-sidebar-primary-foreground: #000031; + --color-sidebar-ring: #000032; +} diff --git a/tests/tailwind-compare/test/compare.test.mjs b/tests/tailwind-compare/test/compare.test.mjs index 8e22669..89d056c 100644 --- a/tests/tailwind-compare/test/compare.test.mjs +++ b/tests/tailwind-compare/test/compare.test.mjs @@ -41,6 +41,25 @@ test("classifies known-order differences for utilities containing both quotes", ); }); +test("probes shared design-system colors as known utilities", async () => { + const known = await probeKnownClasses( + [ + "bg-muted", + "text-foreground", + "border-input", + "bg-card/90", + "bg-lightPrimary", + ], + resolve(engineDirectory, "tailwind.css"), + ); + + assert.equal(known.get("bg-muted"), true); + assert.equal(known.get("text-foreground"), true); + assert.equal(known.get("border-input"), true); + assert.equal(known.get("bg-card/90"), true); + assert.equal(known.get("bg-lightPrimary"), false); +}); + test("scrubs configured paths from invocation errors", (context) => { const directory = mkdtempSync(join(tmpdir(), "rustywind-compare-")); context.after(() => rmSync(directory, { force: true, recursive: true })); From 0a05b2a4b5b5c499f7ab8e0dac717d02ae92efc7 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 21 Jul 2026 13:57:30 -0500 Subject: [PATCH 2/6] Consolidate named-color match arms Merge duplicate color-like and named-color arms into single guards, simplify is_named_color_value, expand side-border and keyword coverage, and clarify the changelog note about shadow ambiguity vs the parity palette. --- CHANGELOG.md | 11 ++-- rustywind-core/src/utility_map.rs | 95 +++++++++++++++++++++---------- 2 files changed, 70 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cd1ccd..750ba78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,13 +16,14 @@ - Sort arbitrary named design-system colors such as `bg-muted`, `text-foreground`, `border-input`, and `bg-card/90` as color utilities instead of unknown classes; `border-{t,r,b,l,x,y,s,e}-` now resolves to side - border color instead of border width, while shadow-family named values stay - unclassified because they are ambiguous with custom `--shadow-*` sizes; the - parity harness stylesheet now defines a shadcn-style semantic palette so - these tokens are graded against Prettier + border color instead of border width, while shadow-family named values still + sort as unknown classes because they are ambiguous with custom `--shadow-*` + sizes - Verify sorting parity against Tailwind CSS 4.3.3 and prettier-plugin-tailwindcss 0.8.1 with zero ordering divergences across all - pinned comparison corpora and fuzz rounds; the comparison and fuzz harnesses + pinned comparison corpora and fuzz rounds; the comparison harness stylesheet + now defines a shadcn-style semantic palette so named design-system colors are + graded against Prettier; the comparison and fuzz harnesses now pin that toolchain, and the fuzz harness resolves Tailwind v4 ordering through a `tailwindStylesheet` entry point instead of the plugin's bundled fallback diff --git a/rustywind-core/src/utility_map.rs b/rustywind-core/src/utility_map.rs index a20cf55..59adb5d 100644 --- a/rustywind-core/src/utility_map.rs +++ b/rustywind-core/src/utility_map.rs @@ -1157,8 +1157,9 @@ impl UtilityMap { "border-l" => Some(&["border-left-width"][..]), // border color - "border" if is_color_like_value(value) => Some(&["border-color"][..]), - "border" if is_named_color_value(value) => Some(&["border-color"][..]), + "border" if is_color_like_value(value) || is_named_color_value(value) => { + Some(&["border-color"][..]) + } // border radius "rounded" @@ -1235,8 +1236,9 @@ impl UtilityMap { ][..], ) } - "ring" if is_ring_color_value(value) => Some(&["--tw-ring-color"][..]), - "ring" if is_named_color_value(value) => Some(&["--tw-ring-color"][..]), + "ring" if is_ring_color_value(value) || is_named_color_value(value) => { + Some(&["--tw-ring-color"][..]) + } "ring-offset" if value.parse::().is_ok() || ParenthesizedRingValueKind::parse(value) @@ -1244,8 +1246,9 @@ impl UtilityMap { { Some(&["--tw-ring-offset-width"][..]) } - "ring-offset" if is_ring_color_value(value) => Some(&["--tw-ring-offset-color"][..]), - "ring-offset" if is_named_color_value(value) => Some(&["--tw-ring-offset-color"][..]), + "ring-offset" if is_ring_color_value(value) || is_named_color_value(value) => { + Some(&["--tw-ring-offset-color"][..]) + } "inset-ring" if value.is_empty() || value.parse::().is_ok() @@ -1254,8 +1257,9 @@ impl UtilityMap { { Some(&["--tw-inset-ring-shadow"][..]) } - "inset-ring" if is_ring_color_value(value) => Some(&["--tw-inset-ring-color"][..]), - "inset-ring" if is_named_color_value(value) => Some(&["--tw-inset-ring-color"][..]), + "inset-ring" if is_ring_color_value(value) || is_named_color_value(value) => { + Some(&["--tw-inset-ring-color"][..]) + } // transitions "transition" => Some(&["transition-property"][..]), @@ -1379,8 +1383,9 @@ impl UtilityMap { "outline" if value.is_empty() || value == "none" || value.parse::().is_ok() => { Some(&["outline-width"][..]) } - "outline" if is_color_value(value) => Some(&["outline-color"][..]), - "outline" if is_named_color_value(value) => Some(&["outline-color"][..]), + "outline" if is_color_value(value) || is_named_color_value(value) => { + Some(&["outline-color"][..]) + } "outline-offset" => Some(&["outline-offset"][..]), // accent color @@ -1412,8 +1417,9 @@ impl UtilityMap { "stroke" if value.parse::().is_ok() || is_arbitrary_like_value(value) => { Some(&["stroke-width"][..]) } - "stroke" if is_color_like_value(value) => Some(&["stroke"][..]), - "stroke" if is_named_color_value(value) => Some(&["stroke"][..]), + "stroke" if is_color_like_value(value) || is_named_color_value(value) => { + Some(&["stroke"][..]) + } "object" if is_arbitrary_like_value(value) => Some(&["object-position"][..]), // space between @@ -1436,8 +1442,9 @@ impl UtilityMap { "divide-y" if value.is_empty() || value.parse::().is_ok() => { Some(&["divide-y-width"][..]) } - "divide" if is_color_value(value) => Some(&["divide-color"][..]), - "divide" if is_named_color_value(value) => Some(&["divide-color"][..]), + "divide" if is_color_value(value) || is_named_color_value(value) => { + Some(&["divide-color"][..]) + } "divide-opacity" => Some(&["border-opacity"][..]), // leading (line-height) @@ -1452,25 +1459,29 @@ impl UtilityMap { // background utilities "bg-opacity" => Some(&["background-opacity"][..]), "via" if value == "none" => Some(&["--tw-gradient-via-stops"][..]), - "from" if is_color_value(value) => Some(&["--tw-gradient-from"][..]), - "via" if is_color_value(value) => Some(&["--tw-gradient-via"][..]), - "to" if is_color_value(value) => Some(&["--tw-gradient-to"][..]), + "from" if is_color_value(value) || is_named_color_value(value) => { + Some(&["--tw-gradient-from"][..]) + } + "via" if is_color_value(value) || is_named_color_value(value) => { + Some(&["--tw-gradient-via"][..]) + } + "to" if is_color_value(value) || is_named_color_value(value) => { + Some(&["--tw-gradient-to"][..]) + } "from" if is_gradient_position(value) => Some(&["--tw-gradient-from-position"][..]), "via" if is_gradient_position(value) => Some(&["--tw-gradient-via-position"][..]), "to" if is_gradient_position(value) => Some(&["--tw-gradient-to-position"][..]), - "from" if is_named_color_value(value) => Some(&["--tw-gradient-from"][..]), - "via" if is_named_color_value(value) => Some(&["--tw-gradient-via"][..]), - "to" if is_named_color_value(value) => Some(&["--tw-gradient-to"][..]), // aspect ratio (arbitrary values) "aspect" => Some(&["aspect-ratio"][..]), // text decoration - "decoration" if is_color_value(value) => Some(&["text-decoration-color"][..]), + "decoration" if is_color_value(value) || is_named_color_value(value) => { + Some(&["text-decoration-color"][..]) + } "decoration" if value.parse::().is_ok() => { Some(&["text-decoration-thickness"][..]) } - "decoration" if is_named_color_value(value) => Some(&["text-decoration-color"][..]), // underline offset "underline-offset" => Some(&["text-underline-offset"][..]), @@ -2138,18 +2149,17 @@ fn is_color_like_value(value: &str) -> bool { /// /// After every known non-color form of a color-capable prefix is excluded, an unknown named value /// such as `muted`, `muted-foreground`, `chart-1`, or `lightPrimary` is assumed to be a theme key +/// /// Values arrive opacity-stripped because `parse_utility_parts` strips from the first `/`, so /// `bg-card/90` is seen as `card` fn is_named_color_value(value: &str) -> bool { - let Some((first, remaining)) = value.as_bytes().split_first() else { - return false; - }; + let bytes = value.as_bytes(); - first.is_ascii_alphabetic() - && remaining + bytes.first().is_some_and(u8::is_ascii_alphabetic) + && bytes .iter() .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-') - && !value.ends_with('-') + && bytes.last() != Some(&b'-') && !matches!( value, "auto" | "none" | "initial" | "unset" | "revert" | "revert-layer" | "inherit" @@ -2501,6 +2511,12 @@ mod tests { ("border-input", &["border-color"]), ("border-t-input", &["border-top-color"]), ("border-b-border", &["border-bottom-color"]), + ("border-r-muted", &["border-right-color"]), + ("border-l-muted", &["border-left-color"]), + ("border-x-input", &["border-inline-color"]), + ("border-y-input", &["border-block-color"]), + ("border-s-accent", &["border-inline-start-color"]), + ("border-e-accent", &["border-inline-end-color"]), ("ring-ring", &["--tw-ring-color"]), ("ring-offset-background", &["--tw-ring-offset-color"]), ("inset-ring-primary", &["--tw-inset-ring-color"]), @@ -2512,6 +2528,7 @@ mod tests { ("stroke-muted", &["stroke"]), ("divide-border", &["divide-color"]), ("divide-x-border", &["--tw-divide-color-sort"]), + ("divide-y-border", &["--tw-divide-color-sort"]), ("from-background", &["--tw-gradient-from"]), ("via-muted", &["--tw-gradient-via"]), ("to-primary", &["--tw-gradient-to"]), @@ -2539,6 +2556,7 @@ mod tests { ("border-2", &["border-width"]), ("border-b", &["border-bottom-width"]), ("border-t-[3px]", &["border-top-width"]), + ("border-x-2", &["border-inline-width"]), ("border-dashed", &["border-style"]), ("text-sm", &["font-size"]), ("text-center", &["text-align"]), @@ -2600,7 +2618,6 @@ mod tests { assert_eq!(map.get_properties("unknown-utility"), None); assert_eq!(map.get_properties("fake-class"), None); assert_eq!(map.get_properties("flex-center"), None); - assert_eq!(map.get_properties("text-shadow-custom"), None); assert_eq!(map.get_properties("stroke-1.5"), None); assert_eq!(map.get_properties("will-change"), None); assert_eq!(map.get_properties("max-w-[min(100%, 500px)]"), None); @@ -2635,6 +2652,7 @@ mod tests { assert!(is_named_color_value("muted-foreground")); assert!(is_named_color_value("chart-1")); assert!(is_named_color_value("lightPrimary")); + assert!(is_named_color_value("Primary")); assert!(!is_named_color_value("")); assert!(!is_named_color_value("[#fff]")); @@ -2643,9 +2661,24 @@ mod tests { assert!(!is_named_color_value("1.5")); assert!(!is_named_color_value("2xl")); assert!(!is_named_color_value("10%")); - assert!(!is_named_color_value("auto")); - assert!(!is_named_color_value("none")); + assert!(!is_named_color_value("muted_foreground")); + assert!(!is_named_color_value("müted")); assert!(!is_named_color_value("foo-")); + + for keyword in [ + "auto", + "none", + "initial", + "unset", + "revert", + "revert-layer", + "inherit", + ] { + assert!( + !is_named_color_value(keyword), + "{keyword} should be excluded" + ); + } } #[test] From c4e7eeebf99b04b80aba404002fc33c501c5cb71 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 21 Jul 2026 14:01:48 -0500 Subject: [PATCH 3/6] Fold named colors into is_color_like_value Include the theme-key denylist in the color-like check so call sites stop repeating OR named guards, and keep shadow-family prefixes on a known-only helper so sizes stay unambiguous. --- rustywind-core/src/utility_map.rs | 148 ++++++++++++------------------ 1 file changed, 60 insertions(+), 88 deletions(-) diff --git a/rustywind-core/src/utility_map.rs b/rustywind-core/src/utility_map.rs index 59adb5d..472160f 100644 --- a/rustywind-core/src/utility_map.rs +++ b/rustywind-core/src/utility_map.rs @@ -1110,8 +1110,6 @@ impl UtilityMap { "bg-size" => Some(&["background-size"][..]), "bg" if is_color_like_value(value) => Some(&["background-color"][..]), "bg" if value.starts_with('[') => Some(&["background-color"][..]), // arbitrary value - // known non-color forms win before this denylist fallback - "bg" if is_named_color_value(value) => Some(&["background-color"][..]), // border width "border-bs" | "border-be" | "border-is" | "border-ie" => None, @@ -1123,30 +1121,14 @@ impl UtilityMap { { Some(&["border-width"][..]) } - "border-x" if is_color_like_value(value) || is_named_color_value(value) => { - Some(&["border-inline-color"][..]) - } - "border-y" if is_color_like_value(value) || is_named_color_value(value) => { - Some(&["border-block-color"][..]) - } - "border-s" if is_color_like_value(value) || is_named_color_value(value) => { - Some(&["border-inline-start-color"][..]) - } - "border-e" if is_color_like_value(value) || is_named_color_value(value) => { - Some(&["border-inline-end-color"][..]) - } - "border-t" if is_color_like_value(value) || is_named_color_value(value) => { - Some(&["border-top-color"][..]) - } - "border-r" if is_color_like_value(value) || is_named_color_value(value) => { - Some(&["border-right-color"][..]) - } - "border-b" if is_color_like_value(value) || is_named_color_value(value) => { - Some(&["border-bottom-color"][..]) - } - "border-l" if is_color_like_value(value) || is_named_color_value(value) => { - Some(&["border-left-color"][..]) - } + "border-x" if is_color_like_value(value) => Some(&["border-inline-color"][..]), + "border-y" if is_color_like_value(value) => Some(&["border-block-color"][..]), + "border-s" if is_color_like_value(value) => Some(&["border-inline-start-color"][..]), + "border-e" if is_color_like_value(value) => Some(&["border-inline-end-color"][..]), + "border-t" if is_color_like_value(value) => Some(&["border-top-color"][..]), + "border-r" if is_color_like_value(value) => Some(&["border-right-color"][..]), + "border-b" if is_color_like_value(value) => Some(&["border-bottom-color"][..]), + "border-l" if is_color_like_value(value) => Some(&["border-left-color"][..]), "border-x" => Some(&["border-inline-width"][..]), // Use border-inline-width for left+right "border-y" => Some(&["border-block-width"][..]), // Use border-block-width for top+bottom "border-s" => Some(&["border-inline-start-width"][..]), @@ -1157,9 +1139,7 @@ impl UtilityMap { "border-l" => Some(&["border-left-width"][..]), // border color - "border" if is_color_like_value(value) || is_named_color_value(value) => { - Some(&["border-color"][..]) - } + "border" if is_color_like_value(value) => Some(&["border-color"][..]), // border radius "rounded" @@ -1188,8 +1168,8 @@ impl UtilityMap { // text "list" => Some(&["list-style-type"][..]), - "text" if is_color_value(value) => Some(&["color"][..]), - "text" if value.starts_with('(') => Some(&["color"][..]), + // size/`[` must win before the named-color denylist (e.g. text-sm, text-[14px]) + "text" if is_known_color_like_value(value) => Some(&["color"][..]), "text" if is_size_keyword(value) => Some(&["font-size"][..]), "text" if value.starts_with('[') => Some(&["font-size"][..]), // arbitrary text size "text" if is_named_color_value(value) => Some(&["color"][..]), @@ -1213,10 +1193,14 @@ impl UtilityMap { Some(&["--tw-shadow", "box-shadow"][..]) } "inset-shadow" if value.starts_with('[') => Some(&["--tw-inset-shadow"][..]), - "inset-shadow" if is_color_like_value(value) => Some(&["--tw-inset-shadow-color"][..]), + "inset-shadow" if is_known_color_like_value(value) => { + Some(&["--tw-inset-shadow-color"][..]) + } "inset-shadow" => Some(&["--tw-inset-shadow"][..]), "text-shadow" if value.starts_with('[') => Some(&["--tw-text-shadow"][..]), - "text-shadow" if is_color_like_value(value) => Some(&["--tw-text-shadow-color"][..]), + "text-shadow" if is_known_color_like_value(value) => { + Some(&["--tw-text-shadow-color"][..]) + } "text-shadow" if is_text_shadow_size_value(value) => Some(&["--tw-text-shadow"][..]), // ring (uses multiple properties) @@ -1236,9 +1220,7 @@ impl UtilityMap { ][..], ) } - "ring" if is_ring_color_value(value) || is_named_color_value(value) => { - Some(&["--tw-ring-color"][..]) - } + "ring" if is_ring_color_value(value) => Some(&["--tw-ring-color"][..]), "ring-offset" if value.parse::().is_ok() || ParenthesizedRingValueKind::parse(value) @@ -1246,9 +1228,7 @@ impl UtilityMap { { Some(&["--tw-ring-offset-width"][..]) } - "ring-offset" if is_ring_color_value(value) || is_named_color_value(value) => { - Some(&["--tw-ring-offset-color"][..]) - } + "ring-offset" if is_ring_color_value(value) => Some(&["--tw-ring-offset-color"][..]), "inset-ring" if value.is_empty() || value.parse::().is_ok() @@ -1257,9 +1237,7 @@ impl UtilityMap { { Some(&["--tw-inset-ring-shadow"][..]) } - "inset-ring" if is_ring_color_value(value) || is_named_color_value(value) => { - Some(&["--tw-inset-ring-color"][..]) - } + "inset-ring" if is_ring_color_value(value) => Some(&["--tw-inset-ring-color"][..]), // transitions "transition" => Some(&["transition-property"][..]), @@ -1318,7 +1296,9 @@ impl UtilityMap { "sepia" if value.is_empty() || value.starts_with('[') || is_numeric_value(value) => { Some(&["--tw-sepia"][..]) } - "drop-shadow" if is_color_like_value(value) => Some(&["--tw-drop-shadow-color"][..]), + "drop-shadow" if is_known_color_like_value(value) => { + Some(&["--tw-drop-shadow-color"][..]) + } "drop-shadow" if is_drop_shadow_size_value(value) => Some(&["--tw-drop-shadow"][..]), // masks @@ -1383,43 +1363,26 @@ impl UtilityMap { "outline" if value.is_empty() || value == "none" || value.parse::().is_ok() => { Some(&["outline-width"][..]) } - "outline" if is_color_value(value) || is_named_color_value(value) => { - Some(&["outline-color"][..]) - } + "outline" if is_color_like_value(value) => Some(&["outline-color"][..]), "outline-offset" => Some(&["outline-offset"][..]), // accent color - "accent" - if is_color_value(value) - || is_named_color_value(value) - || value == "auto" - || value == "current" => - { + "accent" if is_color_like_value(value) || value == "auto" => { Some(&["accent-color"][..]) } // caret color - "caret" - if is_color_value(value) || is_named_color_value(value) || value == "current" => - { - Some(&["caret-color"][..]) - } + "caret" if is_color_like_value(value) => Some(&["caret-color"][..]), // placeholder color - "placeholder" if is_color_like_value(value) || is_named_color_value(value) => { - Some(&["placeholder-color"][..]) - } + "placeholder" if is_color_like_value(value) => Some(&["placeholder-color"][..]), // svg paint - "fill" if is_color_like_value(value) || is_named_color_value(value) => { - Some(&["fill"][..]) - } + "fill" if is_color_like_value(value) => Some(&["fill"][..]), "stroke" if value.parse::().is_ok() || is_arbitrary_like_value(value) => { Some(&["stroke-width"][..]) } - "stroke" if is_color_like_value(value) || is_named_color_value(value) => { - Some(&["stroke"][..]) - } + "stroke" if is_color_like_value(value) => Some(&["stroke"][..]), "object" if is_arbitrary_like_value(value) => Some(&["object-position"][..]), // space between @@ -1430,21 +1393,15 @@ impl UtilityMap { "space-y" => Some(&["column-gap"][..]), // divide - "divide-x" if is_color_like_value(value) || is_named_color_value(value) => { - Some(&["--tw-divide-color-sort"][..]) - } - "divide-y" if is_color_like_value(value) || is_named_color_value(value) => { - Some(&["--tw-divide-color-sort"][..]) - } + "divide-x" if is_color_like_value(value) => Some(&["--tw-divide-color-sort"][..]), + "divide-y" if is_color_like_value(value) => Some(&["--tw-divide-color-sort"][..]), "divide-x" if value.is_empty() || value.parse::().is_ok() => { Some(&["divide-x-width"][..]) } "divide-y" if value.is_empty() || value.parse::().is_ok() => { Some(&["divide-y-width"][..]) } - "divide" if is_color_value(value) || is_named_color_value(value) => { - Some(&["divide-color"][..]) - } + "divide" if is_color_like_value(value) => Some(&["divide-color"][..]), "divide-opacity" => Some(&["border-opacity"][..]), // leading (line-height) @@ -1459,15 +1416,9 @@ impl UtilityMap { // background utilities "bg-opacity" => Some(&["background-opacity"][..]), "via" if value == "none" => Some(&["--tw-gradient-via-stops"][..]), - "from" if is_color_value(value) || is_named_color_value(value) => { - Some(&["--tw-gradient-from"][..]) - } - "via" if is_color_value(value) || is_named_color_value(value) => { - Some(&["--tw-gradient-via"][..]) - } - "to" if is_color_value(value) || is_named_color_value(value) => { - Some(&["--tw-gradient-to"][..]) - } + "from" if is_color_like_value(value) => Some(&["--tw-gradient-from"][..]), + "via" if is_color_like_value(value) => Some(&["--tw-gradient-via"][..]), + "to" if is_color_like_value(value) => Some(&["--tw-gradient-to"][..]), "from" if is_gradient_position(value) => Some(&["--tw-gradient-from-position"][..]), "via" if is_gradient_position(value) => Some(&["--tw-gradient-via-position"][..]), "to" if is_gradient_position(value) => Some(&["--tw-gradient-to-position"][..]), @@ -1476,9 +1427,7 @@ impl UtilityMap { "aspect" => Some(&["aspect-ratio"][..]), // text decoration - "decoration" if is_color_value(value) || is_named_color_value(value) => { - Some(&["text-decoration-color"][..]) - } + "decoration" if is_color_like_value(value) => Some(&["text-decoration-color"][..]), "decoration" if value.parse::().is_ok() => { Some(&["text-decoration-thickness"][..]) } @@ -2141,10 +2090,18 @@ fn is_ring_color_value(value: &str) -> bool { } } -fn is_color_like_value(value: &str) -> bool { +/// Known colors, `current`, and parenthesized custom properties — no theme-key denylist +/// +/// Shadow-family prefixes use this so named values stay ambiguous with custom sizes +fn is_known_color_like_value(value: &str) -> bool { is_color_value(value) || value == "current" || value.starts_with('(') } +/// Color-capable values including theme-key denylist names such as `muted` or `card` +fn is_color_like_value(value: &str) -> bool { + is_known_color_like_value(value) || is_named_color_value(value) +} + /// Denylist fallback for Tailwind v4 `--color-*` theme keys /// /// After every known non-color form of a color-capable prefix is excluded, an unknown named value @@ -2681,6 +2638,21 @@ mod tests { } } + #[test] + fn test_is_color_like_value_includes_named() { + assert!(is_color_like_value("red-500")); + assert!(is_color_like_value("current")); + assert!(is_color_like_value("(--tw-color)")); + assert!(is_color_like_value("muted")); + assert!(is_color_like_value("muted-foreground")); + + assert!(is_known_color_like_value("red-500")); + assert!(is_known_color_like_value("current")); + assert!(is_known_color_like_value("(--tw-color)")); + assert!(!is_known_color_like_value("muted")); + assert!(!is_known_color_like_value("muted-foreground")); + } + #[test] fn test_is_size_keyword() { assert!(is_size_keyword("xs")); From f40ff86f7396ba4fad129b6de91dfdc87f555f1e Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 21 Jul 2026 14:11:15 -0500 Subject: [PATCH 4/6] Add CI workflow for Tailwind comparison Run just compare-tailwind on push and pull requests so ordering regressions against Prettier are caught in CI. --- .github/workflows/compare-tailwind.yml | 37 ++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/compare-tailwind.yml diff --git a/.github/workflows/compare-tailwind.yml b/.github/workflows/compare-tailwind.yml new file mode 100644 index 0000000..745db62 --- /dev/null +++ b/.github/workflows/compare-tailwind.yml @@ -0,0 +1,37 @@ +name: Compare Tailwind + +on: [push, pull_request, workflow_dispatch] + +jobs: + compare-tailwind: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + override: true + profile: minimal + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: tests/tailwind-compare/package-lock.json + + - name: Install just + uses: extractions/setup-just@v2 + + - name: Compare RustyWind with Prettier Tailwind plugin + run: just compare-tailwind + + - name: Upload comparison report + if: always() + uses: actions/upload-artifact@v4 + with: + name: tailwind-compare-results + path: target/tailwind-compare/results/ + if-no-files-found: ignore From 80c292c2f7412a3c49f36c14a7df7da5b720cf64 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 21 Jul 2026 15:03:05 -0500 Subject: [PATCH 5/6] Add --no-named-colors opt-out Default named-color inference treats ambiguous project names such as text-display as colors. Projects that use those names for other purposes can disable inference while still recognizing palette, arbitrary, and CSS-variable colors. Also resolve typed (length:...) parentheses to width/position slots for Tailwind parity. --- CHANGELOG.md | 9 + README.md | 13 + rustywind-cli/src/main.rs | 27 ++ rustywind-cli/src/options.rs | 1 + rustywind-cli/tests/named_colors.rs | 48 ++ rustywind-core/CHANGELOG.md | 8 + rustywind-core/src/app.rs | 90 +++- rustywind-core/src/hybrid_sorter.rs | 33 ++ rustywind-core/src/pattern_sorter.rs | 15 +- rustywind-core/src/utility_map.rs | 436 +++++++++++++++---- rustywind-core/tests/test_tailwind_prefix.rs | 4 + tests/tailwind-compare/README.md | 11 + 12 files changed, 590 insertions(+), 105 deletions(-) create mode 100644 rustywind-cli/tests/named_colors.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 750ba78..33ac228 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Added +- Add `--no-named-colors` to keep ambiguous project-defined names such as + `text-display` unknown instead of inferring them as colors; the option applies + to pattern sorting and preserves named-color inference as the default - Add `--json` to the check, dry-run, bare, write, and stdin modes to emit one machine-readable JSON document with changed files, changed class-group counts, proposed or written content, and structured `{path, message}` errors for @@ -32,6 +35,12 @@ - Replace the panic on unreadable stdin with a normal error message +### Breaking changes + +- `RustyWind` now includes an `infer_named_colors` option. Code constructing + `RustyWind` with a struct literal must set `infer_named_colors: true` to keep + the default behavior, or use `RustyWind::new` or `RustyWind::default`. + ## [0.26.0] - 2026-07-21 ### Added diff --git a/README.md b/README.md index 5eb17fa..55a7c1d 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,16 @@ Use with tailwind prettier plugin - `rustywind --vite-css ` +By default, RustyWind treats project-defined names under color-capable prefixes as colors, so +classes such as `bg-muted` and `text-foreground` sort with Tailwind utilities. If a project uses an +ambiguous name for another purpose, such as `text-display` for a custom font size, keep those names +unknown with the pattern sorter: + +- `rustywind --no-named-colors .` + +Built-in palette colors, arbitrary colors, and CSS-variable colors remain recognized. For exact +project-specific ordering, use `--output-css-file` or `--vite-css` instead. + ```shell Usage: rustywind [OPTIONS] [PATH]... @@ -151,6 +161,9 @@ Options: --allow-duplicates When set, RustyWind will not delete duplicated classes + --no-named-colors + Do not infer project-defined named values as colors + --config-file When set, RustyWind will use the config file to derive configurations. The config file current only supports json with one property sortOrder, e.g. { "sortOrder": ["class1", ...] } diff --git a/rustywind-cli/src/main.rs b/rustywind-cli/src/main.rs index 9667284..46488ee 100644 --- a/rustywind-cli/src/main.rs +++ b/rustywind-cli/src/main.rs @@ -69,6 +69,12 @@ pub struct Cli { /// When set, RustyWind will not delete duplicated classes. #[arg(long)] allow_duplicates: bool, + /// Do not infer project-defined named values as colors + #[arg( + long, + conflicts_with_all = &["config_file", "output_css_file", "vite_css"] + )] + no_named_colors: bool, /// When set, RustyWind will use the config file to derive configurations. The config file /// current only supports json with one property sortOrder, e.g. /// { "sortOrder": ["class1", ...] }. @@ -429,4 +435,25 @@ mod tests { assert_eq!(cli.tailwind_prefix.as_deref(), Some("tw")); } + + #[test] + fn parses_no_named_colors() { + let cli = Cli::try_parse_from(["rustywind", "--no-named-colors", "index.html"]) + .expect("named-color opt-out should parse"); + + assert!(cli.no_named_colors); + } + + #[test] + fn no_named_colors_rejects_custom_sorters() { + for sorter_args in [ + &["--config-file", "sorter.json"][..], + &["--output-css-file", "tailwind.css"][..], + &["--vite-css", "http://localhost/main.css"][..], + ] { + let mut args = vec!["rustywind", "--no-named-colors", "index.html"]; + args.extend_from_slice(sorter_args); + assert!(Cli::try_parse_from(args).is_err()); + } + } } diff --git a/rustywind-cli/src/options.rs b/rustywind-cli/src/options.rs index 686b3ed..ceebb48 100644 --- a/rustywind-cli/src/options.rs +++ b/rustywind-cli/src/options.rs @@ -132,6 +132,7 @@ impl Options { allow_duplicates: cli.allow_duplicates, class_wrapping: get_class_wrapping_from_cli(&cli), tailwind_prefix: cli.tailwind_prefix.clone(), + infer_named_colors: !cli.no_named_colors, }; Ok(Options { diff --git a/rustywind-cli/tests/named_colors.rs b/rustywind-cli/tests/named_colors.rs new file mode 100644 index 0000000..e8eb022 --- /dev/null +++ b/rustywind-cli/tests/named_colors.rs @@ -0,0 +1,48 @@ +use std::io::Write as _; +use std::process::{Command, Stdio}; + +fn sort_stdin(args: &[&str], input: &str) -> std::process::Output { + let mut child = Command::new(assert_cmd::cargo::cargo_bin!("rustywind")) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("rustywind should start"); + child + .stdin + .as_mut() + .expect("stdin should be piped") + .write_all(input.as_bytes()) + .expect("stdin should be written"); + child.wait_with_output().expect("rustywind should finish") +} + +#[test] +fn no_named_colors_keeps_ambiguous_names_unknown() { + if std::env::var_os("CROSS_RUNNER").is_some() { + return; + } + + let input = r#"
"#; + let default = sort_stdin(&["--stdin", "--stdin-filename", "input.html"], input); + let disabled = sort_stdin( + &[ + "--stdin", + "--stdin-filename", + "input.html", + "--no-named-colors", + ], + input, + ); + + assert!(default.status.success()); + assert!(default.stderr.is_empty()); + assert_eq!( + String::from_utf8(default.stdout).unwrap(), + r#"
"# + ); + assert!(disabled.status.success()); + assert!(disabled.stderr.is_empty()); + assert_eq!(String::from_utf8(disabled.stdout).unwrap(), input); +} diff --git a/rustywind-core/CHANGELOG.md b/rustywind-core/CHANGELOG.md index 95d38f9..325e5bd 100644 --- a/rustywind-core/CHANGELOG.md +++ b/rustywind-core/CHANGELOG.md @@ -4,11 +4,19 @@ ### Added +- Add configurable named-color inference through `RustyWind`, `PatternSorter`, + `HybridSorter`, and `UtilityMap`; inference remains enabled by default - Add `SortReport` and `RustyWind::sort_document_with_report`, which return the sorted contents together with the number of class groups (attribute values or custom-regex captures) whose text changed; `sort_document` is now a thin wrapper over the report +### Breaking changes + +- `RustyWind` now includes an `infer_named_colors` option. Code constructing + `RustyWind` with a struct literal must set it explicitly or use an existing + constructor. + ## [0.5.0] - 2026-07-21 ### Added diff --git a/rustywind-core/src/app.rs b/rustywind-core/src/app.rs index 5b940bd..d3b305b 100644 --- a/rustywind-core/src/app.rs +++ b/rustywind-core/src/app.rs @@ -19,8 +19,17 @@ use std::sync::{Arc, LazyLock, RwLock}; /// Global instance of the HybridSorter for pattern-based sorting static PATTERN_SORTER: LazyLock = LazyLock::new(HybridSorter::new); -static PREFIXED_PATTERN_SORTERS: LazyLock>>> = - LazyLock::new(|| RwLock::new(HashMap::new())); +static PATTERN_SORTER_WITHOUT_NAMED_COLORS: LazyLock = + LazyLock::new(|| HybridSorter::new().with_named_color_inference(false)); +static PREFIXED_PATTERN_SORTERS: LazyLock< + RwLock>>, +> = LazyLock::new(|| RwLock::new(HashMap::new())); + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct PatternSorterCacheKey { + tailwind_prefix: String, + infer_named_colors: bool, +} struct SortCandidate<'a> { original: &'a str, @@ -164,6 +173,8 @@ pub struct RustyWind { pub class_wrapping: ClassWrapping, /// Tailwind prefix normalized while computing sort order pub tailwind_prefix: Option, + /// Whether project-defined named values are inferred to be colors + pub infer_named_colors: bool, } impl Default for RustyWind { @@ -174,6 +185,7 @@ impl Default for RustyWind { allow_duplicates: false, class_wrapping: ClassWrapping::NoWrapping, tailwind_prefix: None, + infer_named_colors: true, } } } @@ -203,9 +215,16 @@ impl RustyWind { allow_duplicates, class_wrapping, tailwind_prefix, + infer_named_colors: true, } } + /// Configure whether project-defined named values are inferred to be colors + pub fn with_named_color_inference(mut self, infer_named_colors: bool) -> Self { + self.infer_named_colors = infer_named_colors; + self + } + /// Checks whether a source document contains a sortable static class run pub fn has_classes(&self, document: SourceDocument<'_>) -> bool { if matches!(self.regex, FinderRegex::DefaultRegex) @@ -436,9 +455,13 @@ impl RustyWind { .as_deref() .and_then(normalize_tailwind_prefix_value) { - return prefixed_pattern_sorter(tailwind_prefix).sort_classes(&classes_vec); + return prefixed_pattern_sorter(tailwind_prefix, self.infer_named_colors) + .sort_classes(&classes_vec); } - return PATTERN_SORTER.sort_classes(&classes_vec); + if self.infer_named_colors { + return PATTERN_SORTER.sort_classes(&classes_vec); + } + return PATTERN_SORTER_WITHOUT_NAMED_COLORS.sort_classes(&classes_vec); } // otherwise, use the old HashMap-based approach @@ -554,11 +577,16 @@ impl RustyWind { } } -fn prefixed_pattern_sorter(tailwind_prefix: &str) -> Arc { +fn prefixed_pattern_sorter(tailwind_prefix: &str, infer_named_colors: bool) -> Arc { + let key = PatternSorterCacheKey { + tailwind_prefix: tailwind_prefix.to_string(), + infer_named_colors, + }; + if let Some(sorter) = PREFIXED_PATTERN_SORTERS .read() .expect("prefixed pattern sorter cache should not be poisoned") - .get(tailwind_prefix) + .get(&key) { return Arc::clone(sorter); } @@ -567,15 +595,12 @@ fn prefixed_pattern_sorter(tailwind_prefix: &str) -> Arc { .write() .expect("prefixed pattern sorter cache should not be poisoned"); - Arc::clone( - sorters - .entry(tailwind_prefix.to_string()) - .or_insert_with(|| { - Arc::new(HybridSorter::new_with_tailwind_prefix(Some( - tailwind_prefix, - ))) - }), - ) + Arc::clone(sorters.entry(key).or_insert_with(|| { + Arc::new( + HybridSorter::new_with_tailwind_prefix(Some(tailwind_prefix)) + .with_named_color_inference(infer_named_colors), + ) + })) } fn has_attribute_name_prefix(source: &str, match_start: usize) -> bool { @@ -709,6 +734,7 @@ mod tests { allow_duplicates: false, class_wrapping: ClassWrapping::NoWrapping, tailwind_prefix: None, + infer_named_colors: true, }; trait TestRustyWindExt { @@ -911,6 +937,37 @@ mod tests { // Note: Removed old static-list ordering tests. Pattern-based sorting follows // Tailwind v4's canonical property order, tested in integration_tests.rs + #[test] + fn named_color_inference_is_configurable() { + let classes = PlainClassList::parse("text-display flex").unwrap(); + + assert_eq!( + RUSTYWIND_DEFAULT.sort_class_list(classes), + "flex text-display" + ); + assert_eq!( + RUSTYWIND_DEFAULT + .clone() + .with_named_color_inference(false) + .sort_class_list(classes), + "text-display flex" + ); + } + + #[test] + fn prefixed_sorter_caches_are_isolated_by_named_color_inference() { + let classes = PlainClassList::parse("tw:text-display tw:flex").unwrap(); + let enabled = RustyWind { + tailwind_prefix: Some("tw".to_string()), + ..RUSTYWIND_DEFAULT + }; + let disabled = enabled.clone().with_named_color_inference(false); + + assert_eq!(enabled.sort_class_list(classes), "tw:flex tw:text-display"); + assert_eq!(disabled.sort_class_list(classes), "tw:text-display tw:flex"); + assert_eq!(enabled.sort_class_list(classes), "tw:flex tw:text-display"); + } + // SORT_FILE_CONTENTS ------------------------------------------------------------------------- // test behavioral properties, not exact ordering (which is tested in integration_tests.rs) @@ -999,6 +1056,7 @@ mod tests { regex: FinderRegex::DefaultRegex, class_wrapping: ClassWrapping::NoWrapping, tailwind_prefix: None, + infer_named_colors: true, }; let input = r#"
"#; @@ -1338,6 +1396,7 @@ mod tests { allow_duplicates: false, class_wrapping, tailwind_prefix: None, + infer_named_colors: true, }; assert_eq!(app.sort_file_contents(input), output); @@ -1351,6 +1410,7 @@ mod tests { allow_duplicates: false, class_wrapping: ClassWrapping::NoWrapping, tailwind_prefix: None, + infer_named_colors: true, }; let input = "even-columns empty-state hovercraft event.status status_color even:flex"; diff --git a/rustywind-core/src/hybrid_sorter.rs b/rustywind-core/src/hybrid_sorter.rs index 60e6155..76de281 100644 --- a/rustywind-core/src/hybrid_sorter.rs +++ b/rustywind-core/src/hybrid_sorter.rs @@ -82,6 +82,16 @@ impl HybridSorter { } } + /// Configure whether project-defined named values are inferred to be colors + /// + /// Any existing cache entries are cleared so sort keys cannot cross inference modes + pub fn with_named_color_inference(mut self, infer_named_colors: bool) -> Self { + self.pattern_sorter = + std::mem::take(&mut self.pattern_sorter).with_named_color_inference(infer_named_colors); + self.cache.clear(); + self + } + /// Get the sort key for a class string /// /// Uses two-tier lookup: @@ -353,6 +363,29 @@ mod tests { ); } + #[test] + fn test_named_color_inference_can_be_disabled() { + let sorter = HybridSorter::new().with_named_color_inference(false); + + assert_eq!(sorter.get_sort_key("text-display"), None); + assert!(sorter.get_sort_key("bg-red-500").is_some()); + assert_eq!( + sorter.sort_classes(&["flex", "text-display"]), + vec!["text-display", "flex"] + ); + } + + #[test] + fn changing_named_color_inference_clears_cached_keys() { + let sorter = HybridSorter::new(); + assert!(sorter.get_sort_key("text-display").is_some()); + assert_eq!(sorter.cache_stats().0, 1); + + let sorter = sorter.with_named_color_inference(false); + assert_eq!(sorter.cache_stats().0, 0); + assert_eq!(sorter.get_sort_key("text-display"), None); + } + #[test] fn test_named_color_ordering_matches_prettier() { let sorter = HybridSorter::new(); diff --git a/rustywind-core/src/pattern_sorter.rs b/rustywind-core/src/pattern_sorter.rs index 10d65b1..74a891c 100644 --- a/rustywind-core/src/pattern_sorter.rs +++ b/rustywind-core/src/pattern_sorter.rs @@ -32,6 +32,7 @@ use crate::class_name::ClassSegments; use crate::class_parser::parse_class; use crate::property_order::get_property_index; use crate::tailwind_prefix::{normalize_tailwind_prefix, normalize_tailwind_prefix_value}; +use crate::utility_map::UTILITY_MAP; use crate::variant_order::{ ARBITRARY_VARIANT_BIT, VariantInfo, calculate_variant_order, compare_variant_lists, parse_variants, @@ -1131,6 +1132,7 @@ impl PartialOrd for SortKey { /// collections of classes according to Tailwind's canonical ordering. pub struct PatternSorter { tailwind_prefix: Option, + infer_named_colors: bool, } impl PatternSorter { @@ -1138,6 +1140,7 @@ impl PatternSorter { pub fn new() -> Self { Self { tailwind_prefix: None, + infer_named_colors: true, } } @@ -1147,9 +1150,16 @@ impl PatternSorter { tailwind_prefix: tailwind_prefix .and_then(normalize_tailwind_prefix_value) .map(compact_str::CompactString::new), + infer_named_colors: true, } } + /// Configure whether project-defined named values are inferred to be colors + pub fn with_named_color_inference(mut self, infer_named_colors: bool) -> Self { + self.infer_named_colors = infer_named_colors; + self + } + /// Get the sort key for a class string. /// /// Returns `None` if the class cannot be parsed or its properties are unknown. @@ -1192,7 +1202,10 @@ impl PatternSorter { .collect(); // get the CSS properties this utility generates - let properties = parsed.get_properties()?; + let properties = UTILITY_MAP.get_properties_with_named_color_inference( + &parsed.full_utility(), + self.infer_named_colors, + )?; // get ALL property indices (not just minimum) for proper multi-property tiebreaking // this is crucial for utilities like rounded-t vs rounded-l that share the first property diff --git a/rustywind-core/src/utility_map.rs b/rustywind-core/src/utility_map.rs index 472160f..355538c 100644 --- a/rustywind-core/src/utility_map.rs +++ b/rustywind-core/src/utility_map.rs @@ -907,6 +907,19 @@ impl UtilityMap { /// assert!(px_props.contains(&"padding-inline")); /// ``` pub fn get_properties(&self, utility: &str) -> Option<&'static [&'static str]> { + self.get_properties_with_named_color_inference(utility, true) + } + + /// Get the CSS properties generated by a utility class with configurable named-color inference + /// + /// When `infer_named_colors` is false, project-defined names such as `bg-muted` or + /// `text-display` remain unknown while built-in, arbitrary, and CSS-variable colors remain + /// recognized + pub fn get_properties_with_named_color_inference( + &self, + utility: &str, + infer_named_colors: bool, + ) -> Option<&'static [&'static str]> { if utility.chars().any(char::is_whitespace) { return None; } @@ -921,11 +934,15 @@ impl UtilityMap { } // fall back to pattern matching - self.match_pattern(utility) + self.match_pattern(utility, infer_named_colors) } /// Match a utility against known patterns to determine its properties. - fn match_pattern(&self, utility: &str) -> Option<&'static [&'static str]> { + fn match_pattern( + &self, + utility: &str, + infer_named_colors: bool, + ) -> Option<&'static [&'static str]> { // parse utility into base and value let (base, value) = parse_utility_parts(utility)?; let is_negative = base.starts_with('-'); @@ -936,7 +953,7 @@ impl UtilityMap { } if base == "mask" - && let Some(props) = mask_properties(value) + && let Some(props) = mask_properties(value, infer_named_colors) { return Some(props); } @@ -1108,7 +1125,9 @@ impl UtilityMap { "bg" if value.starts_with("[length:") => Some(&["background-size"][..]), "bg-position" if is_arbitrary_like_value(value) => Some(&["background-position"][..]), "bg-size" => Some(&["background-size"][..]), - "bg" if is_color_like_value(value) => Some(&["background-color"][..]), + "bg" if is_color_like_value(value, infer_named_colors) => { + Some(&["background-color"][..]) + } "bg" if value.starts_with('[') => Some(&["background-color"][..]), // arbitrary value // border width @@ -1117,18 +1136,36 @@ impl UtilityMap { "border" if value.is_empty() || value.parse::().is_ok() - || (value.starts_with('[') && !is_color_like_value(value)) => + || is_parenthesized_length(value) + || (value.starts_with('[') + && !is_color_like_value(value, infer_named_colors)) => { Some(&["border-width"][..]) } - "border-x" if is_color_like_value(value) => Some(&["border-inline-color"][..]), - "border-y" if is_color_like_value(value) => Some(&["border-block-color"][..]), - "border-s" if is_color_like_value(value) => Some(&["border-inline-start-color"][..]), - "border-e" if is_color_like_value(value) => Some(&["border-inline-end-color"][..]), - "border-t" if is_color_like_value(value) => Some(&["border-top-color"][..]), - "border-r" if is_color_like_value(value) => Some(&["border-right-color"][..]), - "border-b" if is_color_like_value(value) => Some(&["border-bottom-color"][..]), - "border-l" if is_color_like_value(value) => Some(&["border-left-color"][..]), + "border-x" if is_color_like_value(value, infer_named_colors) => { + Some(&["border-inline-color"][..]) + } + "border-y" if is_color_like_value(value, infer_named_colors) => { + Some(&["border-block-color"][..]) + } + "border-s" if is_color_like_value(value, infer_named_colors) => { + Some(&["border-inline-start-color"][..]) + } + "border-e" if is_color_like_value(value, infer_named_colors) => { + Some(&["border-inline-end-color"][..]) + } + "border-t" if is_color_like_value(value, infer_named_colors) => { + Some(&["border-top-color"][..]) + } + "border-r" if is_color_like_value(value, infer_named_colors) => { + Some(&["border-right-color"][..]) + } + "border-b" if is_color_like_value(value, infer_named_colors) => { + Some(&["border-bottom-color"][..]) + } + "border-l" if is_color_like_value(value, infer_named_colors) => { + Some(&["border-left-color"][..]) + } "border-x" => Some(&["border-inline-width"][..]), // Use border-inline-width for left+right "border-y" => Some(&["border-block-width"][..]), // Use border-block-width for top+bottom "border-s" => Some(&["border-inline-start-width"][..]), @@ -1139,7 +1176,9 @@ impl UtilityMap { "border-l" => Some(&["border-left-width"][..]), // border color - "border" if is_color_like_value(value) => Some(&["border-color"][..]), + "border" if is_color_like_value(value, infer_named_colors) => { + Some(&["border-color"][..]) + } // border radius "rounded" @@ -1171,8 +1210,11 @@ impl UtilityMap { // size/`[` must win before the named-color denylist (e.g. text-sm, text-[14px]) "text" if is_known_color_like_value(value) => Some(&["color"][..]), "text" if is_size_keyword(value) => Some(&["font-size"][..]), - "text" if value.starts_with('[') => Some(&["font-size"][..]), // arbitrary text size - "text" if is_named_color_value(value) => Some(&["color"][..]), + // arbitrary and typed-length text sizes: text-[14px], text-(length:--fs) + "text" if value.starts_with('[') || is_parenthesized_length(value) => { + Some(&["font-size"][..]) + } + "text" if infer_named_colors && is_named_color_value(value) => Some(&["color"][..]), // font "font" if is_weight_keyword(value) => Some(&["font-weight"][..]), @@ -1207,9 +1249,9 @@ impl UtilityMap { "ring" if value.is_empty() || value.parse::().is_ok() - || ParenthesizedRingValueKind::parse(value) - == Some(ParenthesizedRingValueKind::Length) - || (value.starts_with('[') && !is_color_like_value(value)) => + || is_parenthesized_length(value) + || (value.starts_with('[') + && !is_color_like_value(value, infer_named_colors)) => { Some( &[ @@ -1220,24 +1262,25 @@ impl UtilityMap { ][..], ) } - "ring" if is_ring_color_value(value) => Some(&["--tw-ring-color"][..]), - "ring-offset" - if value.parse::().is_ok() - || ParenthesizedRingValueKind::parse(value) - == Some(ParenthesizedRingValueKind::Length) => - { + "ring" if is_ring_color_value(value, infer_named_colors) => { + Some(&["--tw-ring-color"][..]) + } + "ring-offset" if value.parse::().is_ok() || is_parenthesized_length(value) => { Some(&["--tw-ring-offset-width"][..]) } - "ring-offset" if is_ring_color_value(value) => Some(&["--tw-ring-offset-color"][..]), + "ring-offset" if is_ring_color_value(value, infer_named_colors) => { + Some(&["--tw-ring-offset-color"][..]) + } "inset-ring" if value.is_empty() || value.parse::().is_ok() - || ParenthesizedRingValueKind::parse(value) - == Some(ParenthesizedRingValueKind::Length) => + || is_parenthesized_length(value) => { Some(&["--tw-inset-ring-shadow"][..]) } - "inset-ring" if is_ring_color_value(value) => Some(&["--tw-inset-ring-color"][..]), + "inset-ring" if is_ring_color_value(value, infer_named_colors) => { + Some(&["--tw-inset-ring-color"][..]) + } // transitions "transition" => Some(&["transition-property"][..]), @@ -1307,7 +1350,7 @@ impl UtilityMap { "mask-size" => Some(&["mask-size"][..]), "mask-radial-at" => Some(&["--tw-mask-radial-position"][..]), "mask-linear" => Some(&["--tw-mask-linear"][..]), - "mask-radial" => Some(mask_radial_properties(value)), + "mask-radial" => Some(mask_radial_properties(value, infer_named_colors)), "mask-conic" => Some(&["--tw-mask-conic"][..]), "mask-x" if value.starts_with("from-") => Some( &[ @@ -1360,29 +1403,38 @@ impl UtilityMap { "contain" => Some(&["contain"][..]), // outline - "outline" if value.is_empty() || value == "none" || value.parse::().is_ok() => { + "outline" + if value.is_empty() + || value == "none" + || value.parse::().is_ok() + || is_parenthesized_length(value) => + { Some(&["outline-width"][..]) } - "outline" if is_color_like_value(value) => Some(&["outline-color"][..]), + "outline" if is_color_like_value(value, infer_named_colors) => { + Some(&["outline-color"][..]) + } "outline-offset" => Some(&["outline-offset"][..]), // accent color - "accent" if is_color_like_value(value) || value == "auto" => { + "accent" if is_color_like_value(value, infer_named_colors) || value == "auto" => { Some(&["accent-color"][..]) } // caret color - "caret" if is_color_like_value(value) => Some(&["caret-color"][..]), + "caret" if is_color_like_value(value, infer_named_colors) => Some(&["caret-color"][..]), // placeholder color - "placeholder" if is_color_like_value(value) => Some(&["placeholder-color"][..]), + "placeholder" if is_color_like_value(value, infer_named_colors) => { + Some(&["placeholder-color"][..]) + } // svg paint - "fill" if is_color_like_value(value) => Some(&["fill"][..]), + "fill" if is_color_like_value(value, infer_named_colors) => Some(&["fill"][..]), "stroke" if value.parse::().is_ok() || is_arbitrary_like_value(value) => { Some(&["stroke-width"][..]) } - "stroke" if is_color_like_value(value) => Some(&["stroke"][..]), + "stroke" if is_color_like_value(value, infer_named_colors) => Some(&["stroke"][..]), "object" if is_arbitrary_like_value(value) => Some(&["object-position"][..]), // space between @@ -1393,15 +1445,21 @@ impl UtilityMap { "space-y" => Some(&["column-gap"][..]), // divide - "divide-x" if is_color_like_value(value) => Some(&["--tw-divide-color-sort"][..]), - "divide-y" if is_color_like_value(value) => Some(&["--tw-divide-color-sort"][..]), + "divide-x" if is_color_like_value(value, infer_named_colors) => { + Some(&["--tw-divide-color-sort"][..]) + } + "divide-y" if is_color_like_value(value, infer_named_colors) => { + Some(&["--tw-divide-color-sort"][..]) + } "divide-x" if value.is_empty() || value.parse::().is_ok() => { Some(&["divide-x-width"][..]) } "divide-y" if value.is_empty() || value.parse::().is_ok() => { Some(&["divide-y-width"][..]) } - "divide" if is_color_like_value(value) => Some(&["divide-color"][..]), + "divide" if is_color_like_value(value, infer_named_colors) => { + Some(&["divide-color"][..]) + } "divide-opacity" => Some(&["border-opacity"][..]), // leading (line-height) @@ -1416,9 +1474,15 @@ impl UtilityMap { // background utilities "bg-opacity" => Some(&["background-opacity"][..]), "via" if value == "none" => Some(&["--tw-gradient-via-stops"][..]), - "from" if is_color_like_value(value) => Some(&["--tw-gradient-from"][..]), - "via" if is_color_like_value(value) => Some(&["--tw-gradient-via"][..]), - "to" if is_color_like_value(value) => Some(&["--tw-gradient-to"][..]), + "from" if is_color_like_value(value, infer_named_colors) => { + Some(&["--tw-gradient-from"][..]) + } + "via" if is_color_like_value(value, infer_named_colors) => { + Some(&["--tw-gradient-via"][..]) + } + "to" if is_color_like_value(value, infer_named_colors) => { + Some(&["--tw-gradient-to"][..]) + } "from" if is_gradient_position(value) => Some(&["--tw-gradient-from-position"][..]), "via" if is_gradient_position(value) => Some(&["--tw-gradient-via-position"][..]), "to" if is_gradient_position(value) => Some(&["--tw-gradient-to-position"][..]), @@ -1427,8 +1491,10 @@ impl UtilityMap { "aspect" => Some(&["aspect-ratio"][..]), // text decoration - "decoration" if is_color_like_value(value) => Some(&["text-decoration-color"][..]), - "decoration" if value.parse::().is_ok() => { + "decoration" if is_color_like_value(value, infer_named_colors) => { + Some(&["text-decoration-color"][..]) + } + "decoration" if value.parse::().is_ok() || is_parenthesized_length(value) => { Some(&["text-decoration-thickness"][..]) } // underline offset @@ -1484,7 +1550,7 @@ fn arbitrary_property_properties(utility: &str) -> Option<&'static [&'static str } } -fn mask_properties(value: &str) -> Option<&'static [&'static str]> { +fn mask_properties(value: &str, infer_named_colors: bool) -> Option<&'static [&'static str]> { if value.starts_with("[position:") { return Some(&["mask-position"][..]); } @@ -1492,61 +1558,146 @@ fn mask_properties(value: &str) -> Option<&'static [&'static str]> { return Some(&["mask-size"][..]); } if let Some(value) = value.strip_prefix("t-from-") { - return Some(mask_stop_properties("top", "from", value)); + return Some(mask_stop_properties( + "top", + "from", + value, + infer_named_colors, + )); } if let Some(value) = value.strip_prefix("t-to-") { - return Some(mask_stop_properties("top", "to", value)); + return Some(mask_stop_properties("top", "to", value, infer_named_colors)); } if let Some(value) = value.strip_prefix("r-from-") { - return Some(mask_stop_properties("right", "from", value)); + return Some(mask_stop_properties( + "right", + "from", + value, + infer_named_colors, + )); } if let Some(value) = value.strip_prefix("r-to-") { - return Some(mask_stop_properties("right", "to", value)); + return Some(mask_stop_properties( + "right", + "to", + value, + infer_named_colors, + )); } if let Some(value) = value.strip_prefix("b-from-") { - return Some(mask_stop_properties("bottom", "from", value)); + return Some(mask_stop_properties( + "bottom", + "from", + value, + infer_named_colors, + )); } if let Some(value) = value.strip_prefix("b-to-") { - return Some(mask_stop_properties("bottom", "to", value)); + return Some(mask_stop_properties( + "bottom", + "to", + value, + infer_named_colors, + )); } if let Some(value) = value.strip_prefix("l-from-") { - return Some(mask_stop_properties("left", "from", value)); + return Some(mask_stop_properties( + "left", + "from", + value, + infer_named_colors, + )); } if let Some(value) = value.strip_prefix("l-to-") { - return Some(mask_stop_properties("left", "to", value)); + return Some(mask_stop_properties( + "left", + "to", + value, + infer_named_colors, + )); } if let Some(value) = value.strip_prefix("x-from-") { - return Some(mask_axis_stop_properties("x", "from", value)); + return Some(mask_axis_stop_properties( + "x", + "from", + value, + infer_named_colors, + )); } if let Some(value) = value.strip_prefix("x-to-") { - return Some(mask_axis_stop_properties("x", "to", value)); + return Some(mask_axis_stop_properties( + "x", + "to", + value, + infer_named_colors, + )); } if let Some(value) = value.strip_prefix("y-from-") { - return Some(mask_axis_stop_properties("y", "from", value)); + return Some(mask_axis_stop_properties( + "y", + "from", + value, + infer_named_colors, + )); } if let Some(value) = value.strip_prefix("y-to-") { - return Some(mask_axis_stop_properties("y", "to", value)); + return Some(mask_axis_stop_properties( + "y", + "to", + value, + infer_named_colors, + )); } if let Some(value) = value.strip_prefix("linear-from-") { - return Some(mask_stop_properties("linear", "from", value)); + return Some(mask_stop_properties( + "linear", + "from", + value, + infer_named_colors, + )); } if let Some(value) = value.strip_prefix("linear-to-") { - return Some(mask_stop_properties("linear", "to", value)); + return Some(mask_stop_properties( + "linear", + "to", + value, + infer_named_colors, + )); } if let Some(value) = value.strip_prefix("radial-from-") { - return Some(mask_stop_properties("radial", "from", value)); + return Some(mask_stop_properties( + "radial", + "from", + value, + infer_named_colors, + )); } if let Some(value) = value.strip_prefix("radial-to-") { - return Some(mask_stop_properties("radial", "to", value)); + return Some(mask_stop_properties( + "radial", + "to", + value, + infer_named_colors, + )); } if value.starts_with("radial-at-") { return Some(&["--tw-mask-radial-position"][..]); } if let Some(value) = value.strip_prefix("conic-from-") { - return Some(mask_stop_properties("conic", "from", value)); + return Some(mask_stop_properties( + "conic", + "from", + value, + infer_named_colors, + )); } if let Some(value) = value.strip_prefix("conic-to-") { - return Some(mask_stop_properties("conic", "to", value)); + return Some(mask_stop_properties( + "conic", + "to", + value, + infer_named_colors, + )); } match value { @@ -1569,18 +1720,18 @@ fn mask_properties(value: &str) -> Option<&'static [&'static str]> { value if value.starts_with("linear-") => Some(&["--tw-mask-linear-position"][..]), value if value.starts_with("radial-") => Some(&["--tw-mask-radial"][..]), value if value.starts_with("conic-") => Some(&["--tw-mask-conic-position"][..]), - value if is_color_like_value(value) => Some(&["mask-image"][..]), + value if is_color_like_value(value, infer_named_colors) => Some(&["mask-image"][..]), _ => None, } } -fn mask_radial_properties(value: &str) -> &'static [&'static str] { +fn mask_radial_properties(value: &str, infer_named_colors: bool) -> &'static [&'static str] { if let Some(value) = value.strip_prefix("from-") { - return mask_stop_properties("radial", "from", value); + return mask_stop_properties("radial", "from", value, infer_named_colors); } if let Some(value) = value.strip_prefix("to-") { - return mask_stop_properties("radial", "to", value); + return mask_stop_properties("radial", "to", value, infer_named_colors); } if value.starts_with("at-") { @@ -1601,8 +1752,13 @@ fn mask_radial_properties(value: &str) -> &'static [&'static str] { &["--tw-mask-radial"][..] } -fn mask_stop_properties(side: &str, stop: &str, value: &str) -> &'static [&'static str] { - match (side, stop, is_color_like_value(value)) { +fn mask_stop_properties( + side: &str, + stop: &str, + value: &str, + infer_named_colors: bool, +) -> &'static [&'static str] { + match (side, stop, is_color_like_value(value, infer_named_colors)) { ("top", "from", true) => &["--tw-mask-top-from-color"][..], ("top", "from", false) => &["--tw-mask-top-from-position"][..], ("top", "to", true) => &["--tw-mask-top-to-color"][..], @@ -1635,8 +1791,13 @@ fn mask_stop_properties(side: &str, stop: &str, value: &str) -> &'static [&'stat } } -fn mask_axis_stop_properties(axis: &str, stop: &str, value: &str) -> &'static [&'static str] { - match (axis, stop, is_color_like_value(value)) { +fn mask_axis_stop_properties( + axis: &str, + stop: &str, + value: &str, + infer_named_colors: bool, +) -> &'static [&'static str] { + match (axis, stop, is_color_like_value(value, infer_named_colors)) { ("x", "from", true) => &[ "mask-image", "--tw-mask-right-from-color", @@ -2057,12 +2218,12 @@ fn is_numeric_value(value: &str) -> bool { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ParenthesizedRingValueKind { +enum ParenthesizedValueKind { Color, Length, } -impl ParenthesizedRingValueKind { +impl ParenthesizedValueKind { fn parse(value: &str) -> Option { let inner = value.strip_prefix('(')?.strip_suffix(')')?; let (data_type, custom_property) = inner @@ -2082,24 +2243,34 @@ impl ParenthesizedRingValueKind { } } -fn is_ring_color_value(value: &str) -> bool { - match ParenthesizedRingValueKind::parse(value) { - Some(ParenthesizedRingValueKind::Color) => true, - Some(ParenthesizedRingValueKind::Length) => false, - None => !value.starts_with('(') && is_color_like_value(value), +fn is_ring_color_value(value: &str, infer_named_colors: bool) -> bool { + match ParenthesizedValueKind::parse(value) { + Some(ParenthesizedValueKind::Color) => true, + Some(ParenthesizedValueKind::Length) => false, + None => !value.starts_with('(') && is_color_like_value(value, infer_named_colors), } } -/// Known colors, `current`, and parenthesized custom properties — no theme-key denylist +/// Known colors, `current`, and color-kind parenthesized custom properties — no theme-key denylist /// -/// Shadow-family prefixes use this so named values stay ambiguous with custom sizes +/// Shadow-family prefixes use this so named values stay ambiguous with custom sizes. Typed +/// non-color parentheses such as `(length:--stop)` are excluded so they can reach the +/// width/position arms that Tailwind assigns them to fn is_known_color_like_value(value: &str) -> bool { - is_color_value(value) || value == "current" || value.starts_with('(') + match ParenthesizedValueKind::parse(value) { + Some(ParenthesizedValueKind::Color) => true, + Some(ParenthesizedValueKind::Length) => false, + None => !value.starts_with('(') && (is_color_value(value) || value == "current"), + } +} + +fn is_parenthesized_length(value: &str) -> bool { + ParenthesizedValueKind::parse(value) == Some(ParenthesizedValueKind::Length) } /// Color-capable values including theme-key denylist names such as `muted` or `card` -fn is_color_like_value(value: &str) -> bool { - is_known_color_like_value(value) || is_named_color_value(value) +fn is_color_like_value(value: &str, infer_named_colors: bool) -> bool { + is_known_color_like_value(value) || infer_named_colors && is_named_color_value(value) } /// Denylist fallback for Tailwind v4 `--color-*` theme keys @@ -2502,6 +2673,40 @@ mod tests { } } + #[test] + fn test_named_color_inference_can_be_disabled() { + let map = UtilityMap::new(); + + for utility in [ + "bg-muted", + "bg-card/90", + "text-display", + "ring-muted", + "outline-muted", + ] { + assert_eq!( + map.get_properties_with_named_color_inference(utility, false), + None, + "{utility}" + ); + } + + let known_cases: &[(&str, &[&str])] = &[ + ("text-sm", &["font-size"]), + ("bg-red-500", &["background-color"]), + ("bg-[#fff]", &["background-color"]), + ("text-(--color)", &["color"]), + ("text-(length:--font-size)", &["font-size"]), + ]; + for &(utility, properties) in known_cases { + assert_eq!( + map.get_properties_with_named_color_inference(utility, false), + Some(properties), + "{utility}" + ); + } + } + #[test] fn test_named_color_fallback_exclusions() { let map = UtilityMap::new(); @@ -2638,13 +2843,66 @@ mod tests { } } + #[test] + fn test_typed_length_parens_resolve_to_length_properties() { + let map = UtilityMap::new(); + + // Tailwind assigns typed length parentheses to width/position/thickness slots + assert_eq!( + map.get_properties("from-(length:--stop)"), + Some(&["--tw-gradient-from-position"][..]) + ); + assert_eq!( + map.get_properties("via-(length:--stop)"), + Some(&["--tw-gradient-via-position"][..]) + ); + assert_eq!( + map.get_properties("to-(length:--stop)"), + Some(&["--tw-gradient-to-position"][..]) + ); + assert_eq!( + map.get_properties("border-(length:--w)"), + Some(&["border-width"][..]) + ); + assert_eq!( + map.get_properties("outline-(length:--w)"), + Some(&["outline-width"][..]) + ); + assert_eq!( + map.get_properties("decoration-(length:--t)"), + Some(&["text-decoration-thickness"][..]) + ); + assert_eq!( + map.get_properties("text-(length:--fs)"), + Some(&["font-size"][..]) + ); + assert_eq!( + map.get_properties("stroke-(length:--w)"), + Some(&["stroke-width"][..]) + ); + + // bare custom properties keep resolving as colors + assert_eq!( + map.get_properties("from-(--stop)"), + Some(&["--tw-gradient-from"][..]) + ); + assert_eq!( + map.get_properties("outline-(--c)"), + Some(&["outline-color"][..]) + ); + assert_eq!(map.get_properties("text-(--c)"), Some(&["color"][..])); + + // malformed parentheses are not colors + assert_eq!(map.get_properties("outline-(x)"), None); + } + #[test] fn test_is_color_like_value_includes_named() { - assert!(is_color_like_value("red-500")); - assert!(is_color_like_value("current")); - assert!(is_color_like_value("(--tw-color)")); - assert!(is_color_like_value("muted")); - assert!(is_color_like_value("muted-foreground")); + assert!(is_color_like_value("red-500", true)); + assert!(is_color_like_value("current", true)); + assert!(is_color_like_value("(--tw-color)", true)); + assert!(is_color_like_value("muted", true)); + assert!(is_color_like_value("muted-foreground", true)); assert!(is_known_color_like_value("red-500")); assert!(is_known_color_like_value("current")); diff --git a/rustywind-core/tests/test_tailwind_prefix.rs b/rustywind-core/tests/test_tailwind_prefix.rs index 8912e0f..af73c3c 100644 --- a/rustywind-core/tests/test_tailwind_prefix.rs +++ b/rustywind-core/tests/test_tailwind_prefix.rs @@ -93,6 +93,7 @@ fn rustywind_flag_preserves_original_prefixed_classes_in_output() { allow_duplicates: false, class_wrapping: Default::default(), tailwind_prefix: Some("tw".to_string()), + infer_named_colors: true, }; let input = r#"
"#; @@ -117,6 +118,7 @@ fn custom_sorter_uses_normalized_prefixed_fallback_after_exact_lookup() { allow_duplicates: false, class_wrapping: Default::default(), tailwind_prefix: Some("tw".to_string()), + infer_named_colors: true, }; assert_eq!( @@ -141,6 +143,7 @@ fn custom_sorter_variant_fallback_keeps_v3_prefixed_exact_order() { allow_duplicates: false, class_wrapping: Default::default(), tailwind_prefix: Some("tw".to_string()), + infer_named_colors: true, }; assert_eq!( @@ -161,6 +164,7 @@ fn custom_sorter_variant_fallback_keeps_v4_prefixed_exact_order() { allow_duplicates: false, class_wrapping: Default::default(), tailwind_prefix: Some("tw".to_string()), + infer_named_colors: true, }; assert_eq!( diff --git a/tests/tailwind-compare/README.md b/tests/tailwind-compare/README.md index d3e87ae..69c433f 100644 --- a/tests/tailwind-compare/README.md +++ b/tests/tailwind-compare/README.md @@ -71,3 +71,14 @@ sentinels using a Tailwind 4 stylesheet that layers a shadcn-style semantic `chart-5`, and others) over the defaults so design-system color tokens are graded as known utilities. Differences that preserve the order of known tokens are reported separately as `custom-only`. + +RustyWind's named-color fallback is configuration-blind, which carries two +known limitations. A project theme key in another namespace that shares a +color-capable prefix (for example `--text-display`, making `text-display` a +font-size utility) is still sorted as a color by default; `--no-named-colors` +keeps ambiguous names unknown, while deriving order from the project's real CSS +via `--output-css-file` or `--vite-css` provides exact project-specific order. +Invalid opacity modifiers such as `bg-muted/foo` are sorted as their base +color even though Tailwind rejects the candidate; modifier validation is +per-utility (`text-sm/tight` and `group/name` are valid) and is out of scope +for the comparison engine. From 76fa11b415213d01b6210dfa046bf9ab174f08ca Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 21 Jul 2026 16:19:18 -0500 Subject: [PATCH 6/6] Harden compare-tailwind checkout permissions Scope the workflow to contents: read and disable persisted checkout credentials for this PR job. --- .github/workflows/compare-tailwind.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/compare-tailwind.yml b/.github/workflows/compare-tailwind.yml index 745db62..8460921 100644 --- a/.github/workflows/compare-tailwind.yml +++ b/.github/workflows/compare-tailwind.yml @@ -2,11 +2,16 @@ name: Compare Tailwind on: [push, pull_request, workflow_dispatch] +permissions: + contents: read + jobs: compare-tailwind: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Setup Rust toolchain uses: actions-rust-lang/setup-rust-toolchain@v1