diff --git a/.github/workflows/compare-tailwind.yml b/.github/workflows/compare-tailwind.yml new file mode 100644 index 0000000..8460921 --- /dev/null +++ b/.github/workflows/compare-tailwind.yml @@ -0,0 +1,42 @@ +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 + 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index c55a48b..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 @@ -13,9 +16,17 @@ ### 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 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 @@ -24,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 67b5d19..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 @@ -24,6 +32,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/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/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..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: @@ -342,6 +352,100 @@ 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_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(); + + 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/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 2dfbdfe..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" @@ -1168,10 +1207,14 @@ 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 + // 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"][..]), @@ -1192,19 +1235,23 @@ 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) "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( &[ @@ -1215,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"][..]), @@ -1291,7 +1339,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 @@ -1300,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( &[ @@ -1353,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_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_value(value) || value == "auto" || value == "current" => { + "accent" if is_color_like_value(value, infer_named_colors) || value == "auto" => { Some(&["accent-color"][..]) } // caret color - "caret" if is_color_value(value) || value == "current" => 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 @@ -1386,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_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) @@ -1409,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_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_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"][..]), @@ -1420,8 +1491,10 @@ impl UtilityMap { "aspect" => Some(&["aspect-ratio"][..]), // text decoration - "decoration" if is_color_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 @@ -1477,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"][..]); } @@ -1485,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 { @@ -1562,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-") { @@ -1594,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"][..], @@ -1628,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", @@ -2050,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 @@ -2075,16 +2243,55 @@ 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 color-kind parenthesized custom properties — no theme-key denylist +/// +/// 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 { + match ParenthesizedValueKind::parse(value) { + Some(ParenthesizedValueKind::Color) => true, + Some(ParenthesizedValueKind::Length) => false, + None => !value.starts_with('(') && (is_color_value(value) || value == "current"), } } -fn is_color_like_value(value: &str) -> bool { - is_color_value(value) || value == "current" || value.starts_with('(') +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, 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 +/// +/// 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 bytes = value.as_bytes(); + + bytes.first().is_some_and(u8::is_ascii_alphabetic) + && bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-') + && bytes.last() != Some(&b'-') + && !matches!( + value, + "auto" | "none" | "initial" | "unset" | "revert" | "revert-layer" | "inherit" + ) } fn is_flex_value(value: &str) -> bool { @@ -2421,6 +2628,118 @@ 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"]), + ("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"]), + ("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"]), + ("divide-y-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_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(); + 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-x-2", &["border-inline-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,9 +2780,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); assert_eq!(map.get_properties("max-w-[min(100%, 500px)]"), None); @@ -2492,6 +2808,109 @@ 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("Primary")); + + 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("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] + 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", 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")); + 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")); 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/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 24e6fa7..69c433f 100644 --- a/tests/tailwind-compare/README.md +++ b/tests/tailwind-compare/README.md @@ -63,8 +63,22 @@ 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`. + +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. 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 }));