diff --git a/crates/biome_js_analyze/src/lint/nursery/use_sorted_classes.rs b/crates/biome_js_analyze/src/lint/nursery/use_sorted_classes.rs index 09a31bf8fd93..22f4fc23075e 100644 --- a/crates/biome_js_analyze/src/lint/nursery/use_sorted_classes.rs +++ b/crates/biome_js_analyze/src/lint/nursery/use_sorted_classes.rs @@ -4,6 +4,7 @@ mod presets; mod sort; mod sort_config; pub mod sort_v4; +mod sort_v4_variants; mod tailwind_preset; mod tailwind_preset_v4; mod tailwind_preset_v4_types; diff --git a/crates/biome_js_analyze/src/lint/nursery/use_sorted_classes/sort_v4.rs b/crates/biome_js_analyze/src/lint/nursery/use_sorted_classes/sort_v4.rs index 8c1358af7d8b..9e76f1c67857 100644 --- a/crates/biome_js_analyze/src/lint/nursery/use_sorted_classes/sort_v4.rs +++ b/crates/biome_js_analyze/src/lint/nursery/use_sorted_classes/sort_v4.rs @@ -1,6 +1,6 @@ use std::cmp::Ordering; -use biome_rowan::{AstNode, AstNodeList, AstSeparatedList, SyntaxNodeText, TokenText}; +use biome_rowan::{AstNode, AstNodeList, SyntaxNodeText, TokenText}; use biome_string_case::Collator; use biome_tailwind_syntax::{ AnyTwCandidate, AnyTwFullCandidate, AnyTwModifier, AnyTwValue, CssGenericComponentValueList, @@ -14,6 +14,9 @@ use super::tailwind_preset_v4_types::{ ArbitraryBranch, NamedBranch, NamedValueType, Negative, UtilityEntry, }; use super::arbitrary_value_match::value_matches_type; +use super::sort_v4_variants::{ + VariantWeight, VariantGroups, VariantKey, variant_keys_from_candidate, +}; #[cfg(test)] use super::tailwind_preset_v4_types::{CssDataType, ThemeNamespace}; @@ -22,13 +25,32 @@ use super::tailwind_preset_v4_types::{CssDataType, ThemeNamespace}; /// space-separated result. pub fn sort_class_list(root: &TwRoot) -> String { let candidates = root.candidates(); - let mut keyed: Vec<(SortKey, SyntaxNodeText)> = Vec::with_capacity(candidates.len()); + + // A variant's weight depends on the whole list, so classify first and + // weight in a second pass. + let mut pending: Vec<(PendingSortKey, SyntaxNodeText)> = + Vec::with_capacity(candidates.len()); for candidate in candidates { let text = candidate.syntax().text_trimmed(); - let key = SortKey::from_candidate(&candidate); - keyed.push((key, text)); + let key = PendingSortKey::from_candidate(&candidate); + pending.push((key, text)); } + // Group the variants across the list, then weight each pending key. + let variant_groups = VariantGroups::new( + pending + .iter() + .filter_map(|(key, _)| match key { + PendingSortKey::Known { variants, .. } => Some(variants.as_slice()), + PendingSortKey::Unknown => None, + }) + .flatten(), + ); + let mut keyed: Vec<(SortKey, SyntaxNodeText)> = pending + .into_iter() + .map(|(key, text)| (key.into_sort_key(&variant_groups), text)) + .collect(); + // `Vec::sort_by` is stable, so Unknown-vs-Unknown comparisons returning // `Equal` keep input order, and Known entries with identical keys // also keep input order. @@ -52,6 +74,10 @@ pub fn sort_class_list(root: &TwRoot) -> String { enum SortKey { Unknown, Known { + /// Variant weight (`hover:`, `sm:`, …), empty for a plain + /// utility. The outermost sort key, so variantless utilities + /// sort first. + variant_weight: VariantWeight, signature: Signature, /// Total declaration count — Tailwind's tie-break after the /// signature (wider utilities sort first). @@ -61,6 +87,22 @@ enum SortKey { }, } +/// A classified candidate whose variants are resolved but not yet +/// weighted — weighting needs the whole list. +/// [PendingSortKey::into_sort_key] finishes the [SortKey] once the +/// [VariantGroups] are built. +#[derive(Clone, Debug, Eq, PartialEq)] +enum PendingSortKey { + Unknown, + Known { + signature: Signature, + count: u8, + name: NameKey, + important: bool, + variants: Vec, + }, +} + /// The set of CSS properties a candidate's declarations set, encoded as /// ascending indices into Tailwind's canonical property order — the /// order in which properties first appear in Tailwind's generated @@ -181,19 +223,18 @@ impl Collator for TwNameCollator { } } -impl SortKey { - /// Build a sort key from a parsed candidate. Returns `Unknown` for - /// shapes we cannot yet place; each `// TODO:` below tags an input - /// class awaiting follow-up implementation. +impl PendingSortKey { + /// Classify a candidate into its utility placement and variants, or + /// `Unknown` for a shape we can't place. fn from_candidate(candidate: &AnyTwFullCandidate) -> Self { let AnyTwFullCandidate::TwFullCandidate(node) = candidate else { return Self::Unknown; }; - // TODO: variant weight (`hover:`, `sm:`, `[&:hover]:`). - if !node.variants().is_empty() { + // An unrecognized variant leaves the candidate unplaced. + let Some(variants) = variant_keys_from_candidate(node) else { return Self::Unknown; - } + }; let is_negative = node.negative_token().is_some(); // An important candidate (`flex!`) sorts exactly where its plain @@ -301,9 +342,36 @@ impl SortKey { text: Some(inner.syntax().text_trimmed()), }, important: is_important, + variants, }, } } + + /// Finish a [SortKey] by weighting the variants against + /// `variant_groups`. + fn into_sort_key(self, variant_groups: &VariantGroups) -> SortKey { + match self { + Self::Unknown => SortKey::Unknown, + Self::Known { + signature, + count, + name, + important, + variants, + } => { + let Some(variant_weight) = variant_groups.weight_for(&variants) else { + return SortKey::Unknown; + }; + SortKey::Known { + variant_weight, + signature, + count, + name, + important, + } + } + } + } } fn pool_signature(idx: u16) -> Signature { @@ -319,19 +387,24 @@ fn compare(a: &SortKey, b: &SortKey) -> Ordering { (SortKey::Known { .. }, SortKey::Unknown) => Ordering::Greater, ( SortKey::Known { + variant_weight: v1, signature: s1, count: c1, name: n1, important: i1, }, SortKey::Known { + variant_weight: v2, signature: s2, count: c2, name: n2, important: i2, }, - ) => s1 - .cmp(s2) + // Variants sort outermost — a plain utility before any + // variant (`flex hover:flex sm:flex`). + ) => v1 + .cmp(v2) + .then_with(|| s1.cmp(s2)) // Wider utilities (e.g. `sr-only` setting 9 properties) win // a signature tie so they sort before narrower utilities. .then_with(|| c2.cmp(c1)) @@ -511,6 +584,7 @@ mod tests { fn known(property_idx: u16, property_count: u8) -> SortKey { SortKey::Known { + variant_weight: VariantWeight::default(), signature: Signature::Property(property_idx), count: property_count, name: NameKey::default(), @@ -541,7 +615,42 @@ mod tests { fn classify(input: &str) -> SortKey { let parsed = parse_tailwind(input); let full = parsed.tree().candidates().iter().next().unwrap(); - SortKey::from_candidate(&full) + let pending = PendingSortKey::from_candidate(&full); + // Groups from this one candidate; a plain utility gets empty + // `variant_weight`. + let variants: &[VariantKey] = match &pending { + PendingSortKey::Known { variants, .. } => variants, + PendingSortKey::Unknown => &[], + }; + let groups = VariantGroups::new(variants); + pending.into_sort_key(&groups) + } + + /// Classify a whole class list against one shared [VariantGroups], the + /// way `sort_class_list` does. Needed to exercise variant-against-variant + /// ordering: [classify] builds groups per candidate, so every lone + /// variant lands in group 0 and compares equal to any other. + fn classify_all(input: &str) -> Vec { + let parsed = parse_tailwind(input); + let pending: Vec = parsed + .tree() + .candidates() + .iter() + .map(|candidate| PendingSortKey::from_candidate(&candidate)) + .collect(); + let groups = VariantGroups::new( + pending + .iter() + .filter_map(|key| match key { + PendingSortKey::Known { variants, .. } => Some(variants.as_slice()), + PendingSortKey::Unknown => None, + }) + .flatten(), + ); + pending + .into_iter() + .map(|key| key.into_sort_key(&groups)) + .collect() } fn functional_parts(input: &str) -> (AnyTwValue, Option) { @@ -604,6 +713,7 @@ mod tests { fn compare_breaks_exact_key_tie_plain_before_important() { let plain = known(5, 1); let important = SortKey::Known { + variant_weight: VariantWeight::default(), signature: Signature::Property(5), count: 1, name: NameKey::default(), @@ -813,10 +923,8 @@ mod tests { #[test] fn arbitrary_candidate_takes_signature_from_property_index() { - let parsed = parse_tailwind("[display:block]"); - let full = parsed.tree().candidates().iter().next().unwrap(); let display_idx = *PROPERTY_INDEX.get("display").unwrap(); - let key = SortKey::from_candidate(&full); + let key = classify("[display:block]"); let SortKey::Known { signature, count, @@ -834,6 +942,7 @@ mod tests { #[test] fn important_suffix_is_position_neutral_in_the_key() { let SortKey::Known { + variant_weight, signature, count, name, @@ -845,6 +954,7 @@ mod tests { assert_eq!( classify("flex!"), SortKey::Known { + variant_weight, signature, count, name, @@ -872,9 +982,39 @@ mod tests { } #[test] - fn important_with_variants_is_still_unknown() { - // Variant weight is the remaining TODO; `!` must not bypass it. - assert_eq!(classify("hover:flex!"), SortKey::Unknown); + fn variants_classify_and_keep_importance() { + // A recognized variant places the candidate; `!` still rides + // through as the final tiebreak. + assert!(matches!( + classify("hover:flex!"), + SortKey::Known { important: true, .. } + )); + // A variant sorts after its variantless twin. + assert_eq!( + compare(&classify("flex"), &classify("hover:flex")), + Ordering::Less + ); + } + + #[test] + fn variants_order_against_each_other_by_breakpoint() { + // Shared groups (via `classify_all`) are what make one variant + // comparable to another. Ascending breakpoints: `sm` before `md`. + let keys = classify_all("sm:flex md:flex"); + assert_eq!(compare(&keys[0], &keys[1]), Ordering::Less); + // `max-*` is descending, so the larger breakpoint sorts first. + let keys = classify_all("max-lg:flex max-sm:flex"); + assert_eq!(compare(&keys[0], &keys[1]), Ordering::Less); + } + + #[test] + fn unparseable_arbitrary_breakpoints_keep_the_comparator_total() { + // Parseable and unparseable arbitrary breakpoint values sharing one + // order bucket used to compare non-transitively (unparseable values + // compared equal to everything), which can panic the sort. The + // unparseable value now ranks last, so grouping the list completes. + let keys = classify_all("min-[2rem]:flex min-[10rem]:flex min-[15xyz]:flex sm:flex flex"); + assert_eq!(keys.len(), 5); } #[test] diff --git a/crates/biome_js_analyze/src/lint/nursery/use_sorted_classes/sort_v4_variants.rs b/crates/biome_js_analyze/src/lint/nursery/use_sorted_classes/sort_v4_variants.rs new file mode 100644 index 000000000000..a0ce8ed6496f --- /dev/null +++ b/crates/biome_js_analyze/src/lint/nursery/use_sorted_classes/sort_v4_variants.rs @@ -0,0 +1,459 @@ +//! Variant ordering for the Tailwind v4 class sorter. +//! +//! A candidate's variants (`hover:`, `sm:`, `group-has-[…]:`) form the +//! *outermost* part of its sort key: a plain utility sorts before any +//! variant of it, so `flex hover:flex sm:flex` keeps that order. This +//! module turns each candidate's variants into a [VariantWeight] that +//! [`sort_v4`](super::sort_v4) compares ahead of the utility signature. +//! +//! # Weighting takes two passes +//! +//! A variant's weight depends on the *whole* class list, not the +//! candidate alone, so the list is classified first and weighted second +//! (see [`sort_class_list`](super::sort_v4::sort_class_list)): +//! +//! 1. Parse each candidate's variants into [VariantKey]s +//! ([variant_keys_from_candidate]). +//! 2. Collect every distinct key across the list into [VariantGroups]: +//! sort them by [VariantKey]'s `Ord` (Tailwind's variant order) and +//! give each an ascending group index. +//! 3. Each candidate's [VariantWeight] is the set of group indices its +//! variants land in — a bitset compared as one big number. No +//! variants means zero, which sorts first; a higher-ordered variant +//! sets a higher bit, so the weight is larger and sorts later. This +//! mirrors how Tailwind ranks variant combinations. +//! +//! # Where the order comes from +//! +//! [VARIANTS] is generated from Tailwind's own design system, so each +//! variant's `order` is Tailwind's. Breakpoints and containers instead +//! compare by resolved length — ascending for `min-*`/`sm`/`md`, +//! descending for `max-*` ([compare_variant_values]). An arbitrary value +//! that does not parse (`min-[15xyz]`) ranks after every parseable one +//! rather than comparing equal to all of them, which would make the +//! comparator non-transitive and can panic the sort. + +use std::cmp::Ordering; + +use biome_rowan::{AstNode, SyntaxNodeText, TokenText}; +use biome_tailwind_syntax::{ + AnyTwVariant, AnyTwVariantSegment, TwFullCandidate, TwVariantSegmentList, +}; +use smallvec::SmallVec; + +use super::tailwind_preset_v4::{BREAKPOINT_VALUES, CONTAINER_VALUES, VARIANTS}; +use super::tailwind_preset_v4_types::{VariantCompare, VariantEntry, VariantKind}; + +/// The variants a candidate carries, as the set of [VariantGroups] +/// indices they occupy — a bitset compared as one big number. +/// +/// This is the outermost field of the utility sort key. Empty (a plain +/// utility) is zero and sorts first; a higher-ordered variant sets a +/// higher bit, so the weight is larger and sorts later — the way +/// Tailwind ranks variant combinations. Backed by a `SmallVec<[u64; 1]>`: +/// inline (no heap) for the ≤64 distinct-variant case that covers +/// essentially every class list, spilling to the heap only in the +/// unbounded pathological case. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(super) struct VariantWeight(SmallVec<[u64; 1]>); + +impl VariantWeight { + fn set(&mut self, index: usize) { + let word = index / 64; + if self.0.len() <= word { + self.0.resize(word + 1, 0); + } + self.0[word] |= 1u64 << (index % 64); + } +} + +impl Ord for VariantWeight { + fn cmp(&self, other: &Self) -> Ordering { + // Compare as a big-endian integer: longer weights first, then + // most-significant word first. `set` only grows the vec to hold + // a new bit and never clears one, so the top word is always + // non-zero — equal weights have equal vecs, which keeps this + // `Ord` consistent with the derived `Eq`. + let self_len = trimmed_word_len(&self.0); + let other_len = trimmed_word_len(&other.0); + match self_len.cmp(&other_len) { + Ordering::Equal => {} + ordering => return ordering, + } + for index in (0..self_len).rev() { + match self.0[index].cmp(&other.0[index]) { + Ordering::Equal => {} + ordering => return ordering, + } + } + Ordering::Equal + } +} + +impl PartialOrd for VariantWeight { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +fn trimmed_word_len(words: &[u64]) -> usize { + words + .iter() + .rposition(|word| *word != 0) + .map_or(0, |index| index + 1) +} + +/// A parsed variant. Registered roots borrow their name straight from +/// the [VARIANTS] registry (`&'static str`); source-derived text is a +/// [TokenText] (a cheap ref-counted slice, no heap copy). Only +/// `Arbitrary` owns a `Box`: its selector is a +/// `CssGenericComponentValueList` that can span several tokens, so no +/// single slice covers it. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(super) enum VariantKey { + Static(&'static str), + Functional { + root: &'static str, + value: Option, + }, + Compound { + root: &'static str, + variant: Box, + }, + Arbitrary(Box), +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(super) enum VariantValue { + Named(TokenText), + Arbitrary(Box), +} + +#[derive(Clone, Debug)] +enum VariantSegment { + Named(TokenText), + Arbitrary(Box), + CssVariable, +} + +pub(super) struct VariantGroups { + /// Distinct variant keys in ascending order; a key's rank — its + /// weight-bit index — is its position here. + ranked: Vec, +} + +impl VariantGroups { + pub(super) fn new<'a>(variants: impl IntoIterator) -> Self { + // Each distinct key's index is its rank. The comparator agrees + // with `Eq`, so equal keys are adjacent after sorting and `dedup` + // collapses them. + let mut ranked: Vec = variants.into_iter().cloned().collect(); + ranked.sort(); + ranked.dedup(); + Self { ranked } + } + + /// Returns `None` if a variant was not part of the list the ranks + /// were built from. Unreachable when the ranks come from these same + /// candidates, so callers fold it into `Unknown`. + pub(super) fn weight_for(&self, variants: &[VariantKey]) -> Option { + let mut weight = VariantWeight::default(); + for variant in variants { + weight.set(self.ranked.binary_search(variant).ok()?); + } + Some(weight) + } +} + +pub(super) fn variant_keys_from_candidate(candidate: &TwFullCandidate) -> Option> { + let mut variants = Vec::new(); + for variant in candidate.variants() { + variants.push(variant_key_from_variant(&variant.ok()?)?); + } + Some(variants) +} + +fn variant_key_from_variant(variant: &AnyTwVariant) -> Option { + match variant { + AnyTwVariant::TwArbitraryVariant(variant) => Some(VariantKey::Arbitrary( + variant.selector_token().ok()?.text_trimmed().into(), + )), + AnyTwVariant::TwVariantExpression(expression) => { + // `raw` is only a lookup buffer; the stored key borrows the + // registry's own `&'static str`, never this string. + let raw = syntax_text_to_box(&expression.syntax().text_trimmed()); + if let Some((&name, entry)) = VARIANTS.get_entry(raw.as_ref()) + && entry.kind == VariantKind::Static + { + return Some(VariantKey::Static(name)); + } + + let segments = variant_segments(expression.segments())?; + variant_key_from_segments(&segments) + } + AnyTwVariant::TwBogusVariant(_) => None, + } +} + +fn variant_segments(segments: TwVariantSegmentList) -> Option> { + let mut result = Vec::new(); + for segment in segments { + let segment = segment.ok()?; + result.push(match segment { + AnyTwVariantSegment::TwNamedVariantSegment(segment) => { + VariantSegment::Named(segment.value_token().ok()?.token_text_trimmed()) + } + AnyTwVariantSegment::TwArbitraryVariantSegment(segment) => VariantSegment::Arbitrary( + syntax_text_to_box(&segment.value().syntax().text_trimmed()), + ), + AnyTwVariantSegment::TwCssVariableVariantSegment(_) => VariantSegment::CssVariable, + AnyTwVariantSegment::TwBogusVariantSegment(_) => return None, + }); + } + Some(result) +} + +fn variant_key_from_segments(segments: &[VariantSegment]) -> Option { + match segments.first()? { + VariantSegment::Arbitrary(selector) if segments.len() == 1 => { + Some(VariantKey::Arbitrary(selector.clone())) + } + VariantSegment::Named(_) => { + let (root, entry, value_segments) = variant_root_from_segments(segments)?; + match entry.kind { + VariantKind::Static if value_segments.is_empty() => Some(VariantKey::Static(root)), + VariantKind::Functional => Some(VariantKey::Functional { + root, + value: Some(variant_value_from_segments(value_segments)?), + }), + VariantKind::Compound => Some(VariantKey::Compound { + root, + variant: Box::new(variant_key_from_segments(value_segments)?), + }), + VariantKind::Static => None, + } + } + VariantSegment::Arbitrary(_) | VariantSegment::CssVariable => None, + } +} + +fn variant_root_from_segments( + segments: &[VariantSegment], +) -> Option<(&'static str, &'static VariantEntry, &[VariantSegment])> { + // `root` is a scratch buffer that grows one segment at a time to probe + // the registry for the longest matching prefix; the matched key is + // stored, not this string. + let mut root = String::new(); + let mut best = None; + + for (index, segment) in segments.iter().enumerate() { + let VariantSegment::Named(segment) = segment else { + break; + }; + if !root.is_empty() { + root.push('-'); + } + root.push_str(segment); + + if let Some((&name, entry)) = VARIANTS.get_entry(root.as_str()) { + best = Some((name, entry, index + 1)); + } + } + + let (name, entry, rest_index) = best?; + Some((name, entry, &segments[rest_index..])) +} + +fn variant_value_from_segments(segments: &[VariantSegment]) -> Option { + match segments { + [VariantSegment::Named(value)] => Some(VariantValue::Named(value.clone())), + [VariantSegment::Arbitrary(value)] => Some(VariantValue::Arbitrary(value.clone())), + _ => None, + } +} + +impl Ord for VariantKey { + /// Ranks variants the way Tailwind orders them, so [VariantGroups] can + /// position each distinct variant of a class list. Arbitrary selectors + /// sort after every registered variant and among themselves by text; + /// registered variants sort by Tailwind's `order`, then resolved + /// breakpoint/container length, then root and functional value. This + /// is a total order consistent with the derived `Eq`. + fn cmp(&self, other: &Self) -> Ordering { + match (self, other) { + (Self::Arbitrary(left), Self::Arbitrary(right)) => left.cmp(right), + (Self::Arbitrary(_), _) => Ordering::Greater, + (_, Self::Arbitrary(_)) => Ordering::Less, + _ => compare_registered_variant_keys(self, other), + } + } +} + +impl PartialOrd for VariantKey { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +fn compare_registered_variant_keys(left: &VariantKey, right: &VariantKey) -> Ordering { + let Some(left_entry) = variant_entry(left) else { + return Ordering::Greater; + }; + let Some(right_entry) = variant_entry(right) else { + return Ordering::Less; + }; + + left_entry + .order + .cmp(&right_entry.order) + .then_with(|| compare_same_order_variant_keys(left, left_entry, right, right_entry)) +} + +fn compare_same_order_variant_keys( + left: &VariantKey, + left_entry: &VariantEntry, + right: &VariantKey, + right_entry: &VariantEntry, +) -> Ordering { + if let ( + VariantKey::Compound { + root: left_root, + variant: left_variant, + }, + VariantKey::Compound { + root: right_root, + variant: right_variant, + }, + ) = (left, right) + && left_root == right_root + { + return left_variant.cmp(right_variant); + } + + compare_variant_values(left, left_entry.compare, right, right_entry.compare) + .then_with(|| variant_root(left).cmp(variant_root(right))) + .then_with(|| compare_functional_values(left, right)) +} + +fn compare_variant_values( + left: &VariantKey, + left_compare: VariantCompare, + right: &VariantKey, + right_compare: VariantCompare, +) -> Ordering { + if left_compare != right_compare || left_compare == VariantCompare::Default { + return Ordering::Equal; + } + + let Some(left_value) = variant_compare_value(left, left_compare) else { + return Ordering::Equal; + }; + let Some(right_value) = variant_compare_value(right, right_compare) else { + return Ordering::Equal; + }; + + let ordering = left_value + .partial_cmp(&right_value) + .unwrap_or(Ordering::Equal); + match left_compare { + VariantCompare::BreakpointAsc | VariantCompare::ContainerAsc => ordering, + VariantCompare::BreakpointDesc | VariantCompare::ContainerDesc => ordering.reverse(), + VariantCompare::Default => Ordering::Equal, + } +} + +fn variant_compare_value(key: &VariantKey, compare: VariantCompare) -> Option { + let value = match key { + VariantKey::Static(root) => *root, + VariantKey::Functional { + value: Some(value), .. + } => value.text(), + _ => return None, + }; + + let resolved = match compare { + VariantCompare::BreakpointAsc | VariantCompare::BreakpointDesc => { + BREAKPOINT_VALUES.get(value).copied().unwrap_or(value) + } + VariantCompare::ContainerAsc | VariantCompare::ContainerDesc => { + CONTAINER_VALUES.get(value).copied().unwrap_or(value) + } + VariantCompare::Default => return None, + }; + + // An arbitrary value that does not parse (e.g. `min-[foo]`) ranks + // after every parseable one rather than comparing equal to all of + // them, which would make the comparator non-transitive. + Some(parse_length_value(resolved).unwrap_or(f64::INFINITY)) +} + +fn parse_length_value(value: &str) -> Option { + if let Some(value) = value.strip_suffix("rem") { + return value.parse::().ok().map(|value| value * 16.0); + } + if let Some(value) = value.strip_suffix("px") { + return value.parse::().ok(); + } + value.parse::().ok() +} + +fn compare_functional_values(left: &VariantKey, right: &VariantKey) -> Ordering { + match (variant_value(left), variant_value(right)) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Less, + (Some(_), None) => Ordering::Greater, + (Some(left), Some(right)) => left.cmp(right), + } +} + +fn variant_entry(key: &VariantKey) -> Option<&'static VariantEntry> { + VARIANTS.get(variant_root(key)) +} + +fn variant_root(key: &VariantKey) -> &str { + match key { + VariantKey::Static(root) + | VariantKey::Functional { root, .. } + | VariantKey::Compound { root, .. } => root, + VariantKey::Arbitrary(_) => "", + } +} + +fn variant_value(key: &VariantKey) -> Option<&VariantValue> { + match key { + VariantKey::Functional { value, .. } => value.as_ref(), + _ => None, + } +} + +impl VariantValue { + fn text(&self) -> &str { + match self { + Self::Named(value) => value, + Self::Arbitrary(value) => value, + } + } +} + +impl Ord for VariantValue { + fn cmp(&self, other: &Self) -> Ordering { + match (self, other) { + (Self::Named(left), Self::Named(right)) => left.cmp(right), + (Self::Arbitrary(left), Self::Arbitrary(right)) => left.cmp(right), + (Self::Named(_), Self::Arbitrary(_)) => Ordering::Less, + (Self::Arbitrary(_), Self::Named(_)) => Ordering::Greater, + } + } +} + +impl PartialOrd for VariantValue { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +fn syntax_text_to_box(text: &SyntaxNodeText) -> Box { + let mut result = String::with_capacity(usize::from(text.len())); + text.for_each_chunk(|chunk| result.push_str(chunk)); + result.into_boxed_str() +} diff --git a/crates/biome_js_analyze/src/lint/nursery/use_sorted_classes/tailwind_preset_v4.rs b/crates/biome_js_analyze/src/lint/nursery/use_sorted_classes/tailwind_preset_v4.rs index f2f1834b4e61..bef26750426f 100644 --- a/crates/biome_js_analyze/src/lint/nursery/use_sorted_classes/tailwind_preset_v4.rs +++ b/crates/biome_js_analyze/src/lint/nursery/use_sorted_classes/tailwind_preset_v4.rs @@ -6,6 +6,7 @@ //! Source references (Tailwind v4): //! - property-order: https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/src/property-order.ts //! - utilities: https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/src/utilities.ts +//! - variants: https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/src/variants.ts //! - default theme: https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/theme.css //! - infer-data-type: https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/src/utils/infer-data-type.ts @@ -13,7 +14,7 @@ use phf::{phf_map, phf_set}; use super::tailwind_preset_v4_types::{ ArbitraryBranch, CssDataType, FunctionalEntry, NamedBranch, NamedValueType, Negative::*, - ThemeNamespace, UtilityEntry, + ThemeNamespace, UtilityEntry, VariantCompare, VariantEntry, VariantKind, }; pub static PROPERTY_INDEX: phf::Map<&'static str, u16> = phf_map! { @@ -4408,6 +4409,121 @@ pub static FUNCTIONAL_UTILITIES: phf::Map<&'static str, FunctionalEntry> = phf_m }, }; +pub(super) static VARIANTS: phf::Map<&'static str, VariantEntry> = phf_map! { + "*" => VariantEntry { kind: VariantKind::Static, order: 1, compare: VariantCompare::Default, compounds: 0, compounds_with: 0 }, + "**" => VariantEntry { kind: VariantKind::Static, order: 2, compare: VariantCompare::Default, compounds: 0, compounds_with: 0 }, + "not" => VariantEntry { kind: VariantKind::Compound, order: 3, compare: VariantCompare::Default, compounds: 2, compounds_with: 3 }, + "group" => VariantEntry { kind: VariantKind::Compound, order: 4, compare: VariantCompare::Default, compounds: 2, compounds_with: 2 }, + "peer" => VariantEntry { kind: VariantKind::Compound, order: 5, compare: VariantCompare::Default, compounds: 2, compounds_with: 2 }, + "first-letter" => VariantEntry { kind: VariantKind::Static, order: 6, compare: VariantCompare::Default, compounds: 0, compounds_with: 0 }, + "first-line" => VariantEntry { kind: VariantKind::Static, order: 7, compare: VariantCompare::Default, compounds: 0, compounds_with: 0 }, + "marker" => VariantEntry { kind: VariantKind::Static, order: 8, compare: VariantCompare::Default, compounds: 0, compounds_with: 0 }, + "selection" => VariantEntry { kind: VariantKind::Static, order: 9, compare: VariantCompare::Default, compounds: 0, compounds_with: 0 }, + "file" => VariantEntry { kind: VariantKind::Static, order: 10, compare: VariantCompare::Default, compounds: 0, compounds_with: 0 }, + "placeholder" => VariantEntry { kind: VariantKind::Static, order: 11, compare: VariantCompare::Default, compounds: 0, compounds_with: 0 }, + "backdrop" => VariantEntry { kind: VariantKind::Static, order: 12, compare: VariantCompare::Default, compounds: 0, compounds_with: 0 }, + "details-content" => VariantEntry { kind: VariantKind::Static, order: 13, compare: VariantCompare::Default, compounds: 0, compounds_with: 0 }, + "before" => VariantEntry { kind: VariantKind::Static, order: 14, compare: VariantCompare::Default, compounds: 0, compounds_with: 0 }, + "after" => VariantEntry { kind: VariantKind::Static, order: 15, compare: VariantCompare::Default, compounds: 0, compounds_with: 0 }, + "first" => VariantEntry { kind: VariantKind::Static, order: 16, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "last" => VariantEntry { kind: VariantKind::Static, order: 17, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "only" => VariantEntry { kind: VariantKind::Static, order: 18, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "odd" => VariantEntry { kind: VariantKind::Static, order: 19, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "even" => VariantEntry { kind: VariantKind::Static, order: 20, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "first-of-type" => VariantEntry { kind: VariantKind::Static, order: 21, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "last-of-type" => VariantEntry { kind: VariantKind::Static, order: 22, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "only-of-type" => VariantEntry { kind: VariantKind::Static, order: 23, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "visited" => VariantEntry { kind: VariantKind::Static, order: 24, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "target" => VariantEntry { kind: VariantKind::Static, order: 25, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "open" => VariantEntry { kind: VariantKind::Static, order: 26, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "default" => VariantEntry { kind: VariantKind::Static, order: 27, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "checked" => VariantEntry { kind: VariantKind::Static, order: 28, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "indeterminate" => VariantEntry { kind: VariantKind::Static, order: 29, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "placeholder-shown" => VariantEntry { kind: VariantKind::Static, order: 30, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "autofill" => VariantEntry { kind: VariantKind::Static, order: 31, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "optional" => VariantEntry { kind: VariantKind::Static, order: 32, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "required" => VariantEntry { kind: VariantKind::Static, order: 33, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "valid" => VariantEntry { kind: VariantKind::Static, order: 34, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "invalid" => VariantEntry { kind: VariantKind::Static, order: 35, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "user-valid" => VariantEntry { kind: VariantKind::Static, order: 36, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "user-invalid" => VariantEntry { kind: VariantKind::Static, order: 37, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "in-range" => VariantEntry { kind: VariantKind::Static, order: 38, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "out-of-range" => VariantEntry { kind: VariantKind::Static, order: 39, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "read-only" => VariantEntry { kind: VariantKind::Static, order: 40, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "empty" => VariantEntry { kind: VariantKind::Static, order: 41, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "focus-within" => VariantEntry { kind: VariantKind::Static, order: 42, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "hover" => VariantEntry { kind: VariantKind::Static, order: 43, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "focus" => VariantEntry { kind: VariantKind::Static, order: 44, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "focus-visible" => VariantEntry { kind: VariantKind::Static, order: 45, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "active" => VariantEntry { kind: VariantKind::Static, order: 46, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "enabled" => VariantEntry { kind: VariantKind::Static, order: 47, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "disabled" => VariantEntry { kind: VariantKind::Static, order: 48, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "inert" => VariantEntry { kind: VariantKind::Static, order: 49, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "in" => VariantEntry { kind: VariantKind::Compound, order: 50, compare: VariantCompare::Default, compounds: 2, compounds_with: 2 }, + "has" => VariantEntry { kind: VariantKind::Compound, order: 51, compare: VariantCompare::Default, compounds: 2, compounds_with: 2 }, + "aria" => VariantEntry { kind: VariantKind::Functional, order: 52, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "data" => VariantEntry { kind: VariantKind::Functional, order: 53, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "nth" => VariantEntry { kind: VariantKind::Functional, order: 54, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "nth-last" => VariantEntry { kind: VariantKind::Functional, order: 55, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "nth-of-type" => VariantEntry { kind: VariantKind::Functional, order: 56, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "nth-last-of-type" => VariantEntry { kind: VariantKind::Functional, order: 57, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "supports" => VariantEntry { kind: VariantKind::Functional, order: 58, compare: VariantCompare::Default, compounds: 1, compounds_with: 0 }, + "motion-safe" => VariantEntry { kind: VariantKind::Static, order: 59, compare: VariantCompare::Default, compounds: 1, compounds_with: 0 }, + "motion-reduce" => VariantEntry { kind: VariantKind::Static, order: 60, compare: VariantCompare::Default, compounds: 1, compounds_with: 0 }, + "contrast-more" => VariantEntry { kind: VariantKind::Static, order: 61, compare: VariantCompare::Default, compounds: 1, compounds_with: 0 }, + "contrast-less" => VariantEntry { kind: VariantKind::Static, order: 62, compare: VariantCompare::Default, compounds: 1, compounds_with: 0 }, + "max" => VariantEntry { kind: VariantKind::Functional, order: 63, compare: VariantCompare::BreakpointDesc, compounds: 1, compounds_with: 0 }, + "2xl" => VariantEntry { kind: VariantKind::Static, order: 64, compare: VariantCompare::BreakpointAsc, compounds: 1, compounds_with: 0 }, + "lg" => VariantEntry { kind: VariantKind::Static, order: 64, compare: VariantCompare::BreakpointAsc, compounds: 1, compounds_with: 0 }, + "md" => VariantEntry { kind: VariantKind::Static, order: 64, compare: VariantCompare::BreakpointAsc, compounds: 1, compounds_with: 0 }, + "min" => VariantEntry { kind: VariantKind::Functional, order: 64, compare: VariantCompare::BreakpointAsc, compounds: 1, compounds_with: 0 }, + "sm" => VariantEntry { kind: VariantKind::Static, order: 64, compare: VariantCompare::BreakpointAsc, compounds: 1, compounds_with: 0 }, + "xl" => VariantEntry { kind: VariantKind::Static, order: 64, compare: VariantCompare::BreakpointAsc, compounds: 1, compounds_with: 0 }, + "@max" => VariantEntry { kind: VariantKind::Functional, order: 65, compare: VariantCompare::ContainerDesc, compounds: 1, compounds_with: 0 }, + "@" => VariantEntry { kind: VariantKind::Functional, order: 66, compare: VariantCompare::ContainerAsc, compounds: 1, compounds_with: 0 }, + "@min" => VariantEntry { kind: VariantKind::Functional, order: 66, compare: VariantCompare::ContainerAsc, compounds: 1, compounds_with: 0 }, + "portrait" => VariantEntry { kind: VariantKind::Static, order: 67, compare: VariantCompare::Default, compounds: 1, compounds_with: 0 }, + "landscape" => VariantEntry { kind: VariantKind::Static, order: 68, compare: VariantCompare::Default, compounds: 1, compounds_with: 0 }, + "ltr" => VariantEntry { kind: VariantKind::Static, order: 69, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "rtl" => VariantEntry { kind: VariantKind::Static, order: 70, compare: VariantCompare::Default, compounds: 2, compounds_with: 0 }, + "dark" => VariantEntry { kind: VariantKind::Static, order: 71, compare: VariantCompare::Default, compounds: 1, compounds_with: 0 }, + "starting" => VariantEntry { kind: VariantKind::Static, order: 72, compare: VariantCompare::Default, compounds: 0, compounds_with: 0 }, + "print" => VariantEntry { kind: VariantKind::Static, order: 73, compare: VariantCompare::Default, compounds: 1, compounds_with: 0 }, + "forced-colors" => VariantEntry { kind: VariantKind::Static, order: 74, compare: VariantCompare::Default, compounds: 1, compounds_with: 0 }, + "inverted-colors" => VariantEntry { kind: VariantKind::Static, order: 75, compare: VariantCompare::Default, compounds: 1, compounds_with: 0 }, + "pointer-none" => VariantEntry { kind: VariantKind::Static, order: 76, compare: VariantCompare::Default, compounds: 1, compounds_with: 0 }, + "pointer-coarse" => VariantEntry { kind: VariantKind::Static, order: 77, compare: VariantCompare::Default, compounds: 1, compounds_with: 0 }, + "pointer-fine" => VariantEntry { kind: VariantKind::Static, order: 78, compare: VariantCompare::Default, compounds: 1, compounds_with: 0 }, + "any-pointer-none" => VariantEntry { kind: VariantKind::Static, order: 79, compare: VariantCompare::Default, compounds: 1, compounds_with: 0 }, + "any-pointer-coarse" => VariantEntry { kind: VariantKind::Static, order: 80, compare: VariantCompare::Default, compounds: 1, compounds_with: 0 }, + "any-pointer-fine" => VariantEntry { kind: VariantKind::Static, order: 81, compare: VariantCompare::Default, compounds: 1, compounds_with: 0 }, + "noscript" => VariantEntry { kind: VariantKind::Static, order: 82, compare: VariantCompare::Default, compounds: 1, compounds_with: 0 }, +}; + +pub(super) static BREAKPOINT_VALUES: phf::Map<&'static str, &'static str> = phf_map! { + "2xl" => "96rem", + "lg" => "64rem", + "md" => "48rem", + "sm" => "40rem", + "xl" => "80rem", +}; + +pub(super) static CONTAINER_VALUES: phf::Map<&'static str, &'static str> = phf_map! { + "2xl" => "42rem", + "2xs" => "18rem", + "3xl" => "48rem", + "3xs" => "16rem", + "4xl" => "56rem", + "5xl" => "64rem", + "6xl" => "72rem", + "7xl" => "80rem", + "lg" => "32rem", + "md" => "28rem", + "sm" => "24rem", + "xl" => "36rem", + "xs" => "20rem", +}; + pub(super) static THEME_KEYS_COLOR: phf::Set<&'static str> = phf_set! { "amber-100", "amber-200", diff --git a/crates/biome_js_analyze/src/lint/nursery/use_sorted_classes/tailwind_preset_v4_types.rs b/crates/biome_js_analyze/src/lint/nursery/use_sorted_classes/tailwind_preset_v4_types.rs index 130ed1a7102b..b9acc2b222ad 100644 --- a/crates/biome_js_analyze/src/lint/nursery/use_sorted_classes/tailwind_preset_v4_types.rs +++ b/crates/biome_js_analyze/src/lint/nursery/use_sorted_classes/tailwind_preset_v4_types.rs @@ -97,6 +97,38 @@ impl ThemeNamespace { } } +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum VariantKind { + Static, + Functional, + Compound, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum VariantCompare { + Default, + BreakpointAsc, + BreakpointDesc, + ContainerAsc, + ContainerDesc, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct VariantEntry { + pub kind: VariantKind, + pub order: u16, + pub compare: VariantCompare, + /// Tailwind's `Compounds` bitflags for what this variant emits: + /// `1` = at-rules (media / container queries), `2` = style rules + /// (selectors), `0` = neither. + pub compounds: u8, + /// The `Compounds` bitflags a compound variant (`group-*`, `has-*`) + /// accepts as its nested variant; `0` for non-compound variants. + pub compounds_with: u8, +} + #[derive(Copy, Clone)] pub struct UtilityEntry { /// Index into `SIGNATURE_POOL` — the ascending property-order diff --git a/crates/biome_js_analyze/tests/sort_v4/cases.jsonc b/crates/biome_js_analyze/tests/sort_v4/cases.jsonc index e021b832c738..7ee52779017b 100644 --- a/crates/biome_js_analyze/tests/sort_v4/cases.jsonc +++ b/crates/biome_js_analyze/tests/sort_v4/cases.jsonc @@ -216,6 +216,30 @@ "[display:block]! [color:red]", // arbitrary candidate + important "mt-4 -mt-2!", // negative + important "not-a-class! flex", // unknown base stays front even with `!` + // ─── variants ────────────────────────────────────────────────── + // Variant weight sorts before the existing utility dimensions; + // variants group in Tailwind's canonical order. Expected order + // cross-checked against prettier-plugin-tailwindcss 0.8.0. + + "hover:flex flex", + "focus:flex hover:flex flex", + "hover:flex [&:hover]:flex flex", + "data-[state=open]:flex aria-checked:flex flex", + "nth-last-3:flex nth-2:flex flex", + "peer-focus:flex group-hover:flex flex", + "has-[:checked]:flex not-hover:flex flex", + "dark:flex hover:flex flex", // dark mode + "md:flex sm:flex flex max-lg:flex max-sm:flex min-lg:flex", // responsive, min/max + "md:hover:flex hover:flex flex md:flex", // compound + + // ─── variants + important suffix ─────────────────────────────── + // The important suffix reuses the underlying utility key, so it + // composes with variant weight. + + "p-2! flex!", + "p-2! hover:flex! flex!", + "w-full! flex! p-4!", + "flex [color:red]!", // ─── real-world strings (shadcn/ui v4, MIT) ───────────────────── // Inputs scrambled; output captured by snapshot. diff --git a/crates/biome_js_analyze/tests/sort_v4/cases.snap b/crates/biome_js_analyze/tests/sort_v4/cases.snap index 12251187559a..b4f914424b72 100644 --- a/crates/biome_js_analyze/tests/sort_v4/cases.snap +++ b/crates/biome_js_analyze/tests/sort_v4/cases.snap @@ -1,6 +1,5 @@ --- source: crates/biome_js_analyze/tests/sort_v4_test.rs -assertion_line: 25 expression: rendered input_file: crates/biome_js_analyze/tests/sort_v4/cases.jsonc --- @@ -364,6 +363,48 @@ sorted: -mt-2! mt-4 input: not-a-class! flex sorted: not-a-class! flex --- +input: hover:flex flex +sorted: flex hover:flex +--- +input: focus:flex hover:flex flex +sorted: flex hover:flex focus:flex +--- +input: hover:flex [&:hover]:flex flex +sorted: flex hover:flex [&:hover]:flex +--- +input: data-[state=open]:flex aria-checked:flex flex +sorted: flex aria-checked:flex data-[state=open]:flex +--- +input: nth-last-3:flex nth-2:flex flex +sorted: flex nth-2:flex nth-last-3:flex +--- +input: peer-focus:flex group-hover:flex flex +sorted: flex group-hover:flex peer-focus:flex +--- +input: has-[:checked]:flex not-hover:flex flex +sorted: flex not-hover:flex has-[:checked]:flex +--- +input: dark:flex hover:flex flex +sorted: flex hover:flex dark:flex +--- +input: md:flex sm:flex flex max-lg:flex max-sm:flex min-lg:flex +sorted: flex max-lg:flex max-sm:flex sm:flex md:flex min-lg:flex +--- +input: md:hover:flex hover:flex flex md:flex +sorted: flex hover:flex md:flex md:hover:flex +--- +input: p-2! flex! +sorted: flex! p-2! +--- +input: p-2! hover:flex! flex! +sorted: flex! p-2! hover:flex! +--- +input: w-full! flex! p-4! +sorted: flex! w-full! p-4! +--- +input: flex [color:red]! +sorted: flex [color:red]! +--- input: shadow-sm text-card-foreground border bg-card gap-6 py-6 rounded-xl flex flex-col sorted: text-card-foreground bg-card flex flex-col gap-6 rounded-xl border py-6 shadow-sm --- @@ -380,4 +421,4 @@ input: hover:text-accent-foreground border bg-background shadow-xs hover:bg-acc sorted: hover:text-accent-foreground bg-background hover:bg-accent dark:border-input dark:bg-input/30 dark:hover:bg-input/50 border shadow-xs --- input: items-center [&_svg]:pointer-events-none inline-flex disabled:opacity-50 gap-2 focus-visible:border-ring justify-center rounded-md text-sm shrink-0 transition-all font-medium outline-none whitespace-nowrap focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:shrink-0 -sorted: [&_svg]:pointer-events-none disabled:opacity-50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:shrink-0 inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none +sorted: focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 diff --git a/packages/tailwindcss-config-analyzer/src/v4/extract-variants.ts b/packages/tailwindcss-config-analyzer/src/v4/extract-variants.ts new file mode 100644 index 000000000000..8aaf0bc1839f --- /dev/null +++ b/packages/tailwindcss-config-analyzer/src/v4/extract-variants.ts @@ -0,0 +1,115 @@ +// Extract built-in Tailwind v4 variant metadata used for sorting. + +import { __unstable__loadDesignSystem } from "tailwindcss"; +import { makeLoadStylesheet } from "./css-helpers.js"; + +export type VariantKind = "Static" | "Functional" | "Compound"; + +export type VariantCompare = + | "Default" + | "BreakpointAsc" + | "BreakpointDesc" + | "ContainerAsc" + | "ContainerDesc"; + +export type ExtractedVariant = { + name: string; + kind: VariantKind; + order: number; + compare: VariantCompare; + compounds: number; + compounds_with: number; +}; + +export type ThemeValue = { + name: string; + value: string; +}; + +export type ExtractedVariants = { + variants: ExtractedVariant[]; + breakpoints: ThemeValue[]; + containers: ThemeValue[]; +}; + +type RawVariant = { + kind: "static" | "functional" | "compound"; + order: number; + compounds: number; + compoundsWith: number; +}; + +type DesignSystemWithVariants = { + variants: { + variants: Map; + }; + theme: { + namespace(name: string): Iterable<[string, string]>; + }; +}; + +export async function extractVariants(): Promise { + const ds = (await __unstable__loadDesignSystem(`@import "tailwindcss";`, { + base: process.cwd(), + loadStylesheet: makeLoadStylesheet(), + })) as unknown as DesignSystemWithVariants; + + const breakpoints = themeValues(ds, "--breakpoint"); + const containers = themeValues(ds, "--container"); + const breakpointNames = new Set(breakpoints.map(({ name }) => name)); + + const variants = [...ds.variants.variants.entries()] + .map(([name, variant]) => ({ + name, + kind: variantKind(variant.kind), + order: variant.order, + compare: compareKind(name, breakpointNames), + compounds: variant.compounds, + compounds_with: variant.compoundsWith, + })) + .sort( + (a, b) => + a.order - b.order || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0), + ); + + return { variants, breakpoints, containers }; +} + +function themeValues( + ds: DesignSystemWithVariants, + namespace: string, +): ThemeValue[] { + return [...ds.theme.namespace(namespace)] + .map(([name, value]) => ({ name, value })) + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); +} + +function variantKind(kind: RawVariant["kind"]): VariantKind { + switch (kind) { + case "static": + return "Static"; + case "functional": + return "Functional"; + case "compound": + return "Compound"; + } +} + +function compareKind( + name: string, + breakpointNames: Set, +): VariantCompare { + if (name === "max") { + return "BreakpointDesc"; + } + if (name === "min" || breakpointNames.has(name)) { + return "BreakpointAsc"; + } + if (name === "@max") { + return "ContainerDesc"; + } + if (name === "@" || name === "@min") { + return "ContainerAsc"; + } + return "Default"; +} diff --git a/packages/tailwindcss-config-analyzer/src/v4/generate-tailwind-preset-v4.ts b/packages/tailwindcss-config-analyzer/src/v4/generate-tailwind-preset-v4.ts index 6f1f9035d43d..fe09565406bc 100644 --- a/packages/tailwindcss-config-analyzer/src/v4/generate-tailwind-preset-v4.ts +++ b/packages/tailwindcss-config-analyzer/src/v4/generate-tailwind-preset-v4.ts @@ -18,6 +18,7 @@ import { type ThemeKeysByPrefix, } from "./extract-theme-keys.js"; import { extractUtilities } from "./extract-utilities.js"; +import { extractVariants } from "./extract-variants.js"; import { renderRust } from "./render-rust.js"; import { THEME_NAMESPACES } from "./theme-namespaces.js"; @@ -87,17 +88,23 @@ function runRustfmt(filePath: string): Promise { } async function main() { - const [propertyOrder, themeKeys, utilities] = await Promise.all([ + const [propertyOrder, themeKeys, utilities, variants] = await Promise.all([ extractPropertyOrder(), extractThemeKeys(), extractUtilities(), + extractVariants(), ]); verifyNamespaces(themeKeys); - const rust = renderRust({ propertyOrder, themeKeys, utilities }); - const repoRoot = await findRepoRoot(); + const rust = renderRust({ + propertyOrder, + themeKeys, + utilities, + variants, + }); + const outPath = path.join(repoRoot, OUTPUT_PATH); await fs.writeFile(outPath, rust); @@ -106,7 +113,7 @@ async function main() { console.log(`wrote ${OUTPUT_PATH}`); console.log( ` property-order: ${propertyOrder.length}, namespaces with keys: ${themeKeys.size}, ` + - `static: ${utilities.static.length}, functional: ${utilities.functional.length}`, + `static: ${utilities.static.length}, functional: ${utilities.functional.length}, variants: ${variants.variants.length}`, ); } diff --git a/packages/tailwindcss-config-analyzer/src/v4/render-rust.ts b/packages/tailwindcss-config-analyzer/src/v4/render-rust.ts index 4095f33dd7ba..60f427cc6c6a 100644 --- a/packages/tailwindcss-config-analyzer/src/v4/render-rust.ts +++ b/packages/tailwindcss-config-analyzer/src/v4/render-rust.ts @@ -3,8 +3,9 @@ // Codegen scope is intentionally narrow — only the long phf maps, // sets, and arrays are emitted. Structural types (`NamedValueType`, // `CssDataType`, `ThemeNamespace`, `NamedBranch`, `ArbitraryBranch`, -// `Negative`, `UtilityEntry`, `FunctionalEntry`) live in the hand-written -// sibling `tailwind_preset_v4_types.rs` and are imported here. +// `Negative`, `UtilityEntry`, `FunctionalEntry`, `VariantKind`, +// `VariantCompare`, `VariantEntry`) live in the hand-written sibling +// `tailwind_preset_v4_types.rs` and are imported here. import type { ArbitraryBranch, @@ -13,6 +14,11 @@ import type { NamedBranch, PropertySort, } from "./extract-utilities.js"; +import type { + ExtractedVariant, + ExtractedVariants, + ThemeValue, +} from "./extract-variants.js"; import { THEME_NAMESPACES, type ThemeNamespacePrefix, @@ -26,6 +32,7 @@ const HEADER = `//! AUTO-GENERATED. DO NOT EDIT MANUALLY. //! Source references (Tailwind v4): //! - property-order: https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/src/property-order.ts //! - utilities: https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/src/utilities.ts +//! - variants: https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/src/variants.ts //! - default theme: https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/theme.css //! - infer-data-type: https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/src/utils/infer-data-type.ts @@ -33,7 +40,7 @@ use phf::{phf_map, phf_set}; use super::tailwind_preset_v4_types::{ ArbitraryBranch, CssDataType, FunctionalEntry, NamedBranch, NamedValueType, Negative::*, - ThemeNamespace, UtilityEntry, + ThemeNamespace, UtilityEntry, VariantCompare, VariantEntry, VariantKind, }; `; @@ -332,10 +339,32 @@ function renderThemeKeys(keys: Map>): string { return blocks.join(""); } +function renderVariants(variants: ExtractedVariant[]): string { + const lines = variants.map( + (v) => + ` ${rustString(v.name)} => VariantEntry { kind: VariantKind::${v.kind}, order: ${v.order}, compare: VariantCompare::${v.compare}, compounds: ${v.compounds}, compounds_with: ${v.compounds_with} },`, + ); + return `pub(super) static VARIANTS: phf::Map<&'static str, VariantEntry> = phf_map! { +${lines.join("\n")} +}; +`; +} + +function renderThemeValueMap(mapName: string, values: ThemeValue[]): string { + const lines = values.map( + ({ name, value }) => ` ${rustString(name)} => ${rustString(value)},`, + ); + return `pub(super) static ${mapName}: phf::Map<&'static str, &'static str> = phf_map! { +${lines.join("\n")} +}; +`; +} + export function renderRust(input: { propertyOrder: string[]; themeKeys: Map>; utilities: ExtractedUtilities; + variants: ExtractedVariants; }): string { const { pool: keywordPool, idxOf: keywordIdx } = collectKeywordPool( input.utilities, @@ -351,6 +380,9 @@ export function renderRust(input: { renderSignaturePool(signaturePool), renderStaticUtilities(input.utilities, sigIdx), renderFunctionalUtilities(input.utilities, sigIdx, keywordIdx), + renderVariants(input.variants.variants), + renderThemeValueMap("BREAKPOINT_VALUES", input.variants.breakpoints), + renderThemeValueMap("CONTAINER_VALUES", input.variants.containers), renderThemeKeys(input.themeKeys), ].join("\n"); }