Skip to content

Commit 6cd3263

Browse files
feat(useSortedClasses): sort @container and other bare utilities with modifiers (#11356)
1 parent b51d8b1 commit 6cd3263

43 files changed

Lines changed: 2131 additions & 141 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
The Tailwind parser now understands modifiers on bare utilities (`@container/sidebar`, `shadow/50`).

crates/biome_js_analyze/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ name = "js_analyzer"
2020
harness = false
2121
name = "use_sorted_classes_parser"
2222

23+
[[bench]]
24+
harness = false
25+
name = "use_sorted_classes_v4"
26+
2327
[dependencies]
2428
biome_analyze = { workspace = true }
2529
biome_analyze_macros = { workspace = true }
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
group/menu peer/toggle group-hover/menu:flex group-hover/menu:opacity-100 group-focus/menu:visible peer-checked/toggle:bg-blue-500 peer-checked/toggle:text-white peer-hover/tab:underline peer-focus/tab:ring-2 group-has-[:checked]/row:bg-slate-50 group-data-[state=open]/item:rotate-180 dark:group-hover/menu:bg-slate-800 sm:peer-checked/toggle:ring-4 md:group-hover/menu:flex @container @container/sidebar @sm:grid @sm:grid-cols-2 @md:grid-cols-3 @lg/sidebar:flex @sm/main:text-sm @max-md:hidden @max-lg/main:flex-col @min-[400px]:p-4 @[400px]:gap-2 @[24rem]/card:rounded-lg @7xl:max-w-none min-[600px]:columns-2 max-[900px]:hidden min-sm:text-xs [&_p]:text-slate-600 [&_svg]:size-4 [&\_literal]:underline [&>li]:mt-2 [&.active]:font-bold hover/broken:flex w-1/2 bg-red-500/50 bg-red-500/[.35] group-hover/[.5]:opacity-75
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
use biome_js_analyze::lint::nursery::use_sorted_classes::sort_v4::sort_class_list;
2+
use biome_tailwind_parser::parse_tailwind;
3+
use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main};
4+
5+
#[cfg(target_os = "windows")]
6+
#[global_allocator]
7+
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
8+
9+
#[cfg(all(
10+
any(target_os = "macos", target_os = "linux"),
11+
not(target_env = "musl")
12+
))]
13+
#[global_allocator]
14+
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
15+
16+
#[cfg(all(target_env = "musl", target_os = "linux", target_arch = "aarch64"))]
17+
#[global_allocator]
18+
static GLOBAL: std::alloc::System = std::alloc::System;
19+
20+
// The full work a wired rule does per class string: parse the Tailwind
21+
// candidate list and sort it with the v4 engine.
22+
const CLASS_STRING_FIXTURES: &[(&str, &str)] = &[
23+
(
24+
"simple_classes",
25+
include_str!("fixtures/simple_classes.txt"),
26+
),
27+
(
28+
"variant_classes",
29+
include_str!("fixtures/variant_classes.txt"),
30+
),
31+
(
32+
"arbitrary_classes",
33+
include_str!("fixtures/arbitrary_classes.txt"),
34+
),
35+
(
36+
"modifier_classes",
37+
include_str!("fixtures/modifier_classes.txt"),
38+
),
39+
("stress", include_str!("fixtures/stress.txt")),
40+
(
41+
"extreme_stress",
42+
include_str!("fixtures/extreme_stress.txt"),
43+
),
44+
];
45+
46+
fn bench_use_sorted_classes_v4(c: &mut Criterion) {
47+
let mut group = c.benchmark_group("use_sorted_classes_v4");
48+
49+
for (name, content) in CLASS_STRING_FIXTURES {
50+
let content = content.trim();
51+
group.throughput(Throughput::Bytes(content.len() as u64));
52+
group.bench_with_input(
53+
BenchmarkId::new("parse_and_sort", name),
54+
content,
55+
|b, input| {
56+
b.iter(|| black_box(sort_class_list(&parse_tailwind(black_box(input)).tree())));
57+
},
58+
);
59+
}
60+
61+
group.finish();
62+
}
63+
64+
criterion_group!(use_sorted_classes_v4, bench_use_sorted_classes_v4);
65+
criterion_main!(use_sorted_classes_v4);

crates/biome_js_analyze/src/lint/nursery/use_sorted_classes/sort_v4.rs

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,18 @@ pub fn sort_class_list(root: &TwRoot) -> String {
5353
.collect();
5454

5555
// `Vec::sort_by` is stable, so Unknown-vs-Unknown comparisons returning
56-
// `Equal` keep input order, and Known entries with identical keys
57-
// also keep input order.
58-
keyed.sort_by(|a, b| compare(&a.0, &b.0));
56+
// `Equal` keep input order. Known entries whose keys tie — possible
57+
// when variants share a rank (`@sm/main:flex` with `@sm:flex`) —
58+
// break the tie on full candidate text, mirroring Tailwind's final
59+
// candidate-string comparison.
60+
keyed.sort_by(|a, b| {
61+
compare(&a.0, &b.0).then_with(|| match (&a.0, &b.0) {
62+
(SortKey::Known { .. }, SortKey::Known { .. }) => {
63+
TwNameCollator.cmp(a.1.chars(), b.1.chars())
64+
}
65+
_ => Ordering::Equal,
66+
})
67+
});
5968

6069
// Sort is in-place; total text length is unchanged. Pre-size the output
6170
// so chunked emission never re-allocates.
@@ -286,7 +295,26 @@ impl PendingSortKey {
286295
return Self::Unknown;
287296
};
288297
let name = name.text_trimmed();
289-
if let Some(entry) = STATIC_UTILITIES
298+
if let Some(modifier) = s.modifier() {
299+
// Static registrations take no modifier; the few
300+
// valid bare-with-modifier forms (`@container/main`
301+
// names the container, `shadow/50` sets the shadow
302+
// color opacity) compile through a functional root's
303+
// bare placement, picked by modifier shape.
304+
if is_negative {
305+
None
306+
} else {
307+
FUNCTIONAL_UTILITIES.get(name).and_then(|entry| {
308+
let placement = if modifier_accepted(ModifierKind::Opacity, &modifier)
309+
{
310+
entry.bare_opacity
311+
} else {
312+
entry.bare_name
313+
};
314+
placement.map(|(sig, count)| (pool_signature(sig), count))
315+
})
316+
}
317+
} else if let Some(entry) = STATIC_UTILITIES
290318
.get(name)
291319
// Tailwind registers negative statics individually
292320
// (`-m-px` exists, `-flex` does not).
@@ -1190,6 +1218,56 @@ mod tests {
11901218
assert_eq!(compare(&keys[0], &keys[1]), Ordering::Less);
11911219
}
11921220

1221+
#[test]
1222+
fn group_and_peer_scopes_sort_bare_first_then_by_modifier_text() {
1223+
let keys = classify_all("group-hover:flex group-hover/a:flex group-hover/b:flex");
1224+
assert_eq!(compare(&keys[0], &keys[1]), Ordering::Less);
1225+
assert_eq!(compare(&keys[1], &keys[2]), Ordering::Less);
1226+
// A bracketed modifier compares by its inner value (`.5` < `menu`).
1227+
let keys = classify_all("group-hover/[.5]:flex group-hover/menu:flex");
1228+
assert_eq!(compare(&keys[0], &keys[1]), Ordering::Less);
1229+
}
1230+
1231+
#[test]
1232+
fn a_modifier_outside_group_peer_and_container_variants_is_unknown() {
1233+
assert_eq!(classify("hover/foo:flex"), SortKey::Unknown);
1234+
assert_eq!(classify("has-hover/5:flex"), SortKey::Unknown);
1235+
assert_eq!(classify("min-[600px]/5:flex"), SortKey::Unknown);
1236+
}
1237+
1238+
#[test]
1239+
fn an_unresolvable_breakpoint_or_container_size_is_unknown() {
1240+
assert_eq!(classify("min-abc:flex"), SortKey::Unknown);
1241+
assert_eq!(classify("@max-abc:flex"), SortKey::Unknown);
1242+
assert_eq!(classify("min-[var(--w)]:flex"), SortKey::Unknown);
1243+
}
1244+
1245+
#[test]
1246+
fn variants_resolving_equal_lengths_share_a_rank() {
1247+
// `min-[40rem]` and `sm` both resolve 40rem, so their keys tie;
1248+
// `sort_class_list` breaks the tie on candidate text.
1249+
let keys = classify_all("min-[40rem]:flex sm:flex");
1250+
assert_eq!(compare(&keys[0], &keys[1]), Ordering::Equal);
1251+
// A container modifier does not participate in ordering.
1252+
let keys = classify_all("@sm/main:flex @sm:flex");
1253+
assert_eq!(compare(&keys[0], &keys[1]), Ordering::Equal);
1254+
}
1255+
1256+
#[test]
1257+
fn container_sizes_compare_by_unit_text_before_magnitude() {
1258+
// 400px > 384px (= 24rem), but `px` < `rem` textually, so the
1259+
// arbitrary pixel size groups before every named (rem) size.
1260+
let keys = classify_all("@min-[400px]:flex @sm:flex");
1261+
assert_eq!(compare(&keys[0], &keys[1]), Ordering::Less);
1262+
}
1263+
1264+
#[test]
1265+
fn arbitrary_selectors_compare_with_underscores_decoded() {
1266+
// `_` decodes to a space, which sorts below `>`.
1267+
let keys = classify_all("[&_p]:flex [&>p]:flex");
1268+
assert_eq!(compare(&keys[0], &keys[1]), Ordering::Less);
1269+
}
1270+
11931271
#[test]
11941272
fn unparseable_arbitrary_breakpoints_keep_the_comparator_total() {
11951273
// Parseable and unparseable arbitrary breakpoint values sharing one

0 commit comments

Comments
 (0)