Skip to content

refactor: move tailwind sorter to its own crate#9348

Closed
ematipico wants to merge 1 commit intomainfrom
refactor/tailwind-sorter
Closed

refactor: move tailwind sorter to its own crate#9348
ematipico wants to merge 1 commit intomainfrom
refactor/tailwind-sorter

Conversation

@ematipico
Copy link
Copy Markdown
Member

Summary

This PR moves the sorting algorithm we have in use_sorted_classes.rs into its own crate.

I used an agent to do the move.

This is a refactor, so no changeset needed

Test Plan

Green CI

Docs

@ematipico ematipico requested review from a team March 5, 2026 17:42
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Mar 5, 2026

⚠️ No Changeset found

Latest commit: 8d441d3

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 5, 2026

Walkthrough

This PR extracts Tailwind CSS class sorting logic from biome_js_analyze into a new, language-agnostic biome_tailwind_sort crate. Core sorting algorithms, class lexing, configuration, and preset management are moved to shared modules. The JavaScript linter is refactored to depend on and use this extracted library, updating imports and function signatures accordingly whilst preserving existing behaviour.

Possibly related PRs

  • #7976: Adds benchmarking for use_sorted_classes and makes class_lexer public; directly related to this crate extraction effort
  • #8623: Modifies the same use_sorted_classes module with similar import path updates to align with the shared sorting architecture

Suggested labels

A-Linter, A-Tooling, L-JavaScript, L-Tailwind, A-Project

Suggested reviewers

  • dyc3
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'refactor: move tailwind sorter to its own crate' accurately describes the primary change—extracting tailwind sorting logic into a separate crate.
Description check ✅ Passed The description explains the refactoring work, acknowledges AI assistance, and notes this is a non-user-facing change requiring no changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/tailwind-sorter

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/biome_js_analyze/src/lint/nursery/use_sorted_classes/sort.rs`:
- Around line 108-133: get_sort_class_name_range currently allows zero-width
ranges (end == start) to be returned; change the empty-range guard to reject
zero-length ranges by returning None when end <= start instead of only when end
< start so it matches the Tailwind sorter behavior; update the condition in
get_sort_class_name_range (the end/start check before Some(TextRange::new(start,
end))) to use <= and keep the rest of the logic (offset_prefix/offset_suffix and
TextRange::new) unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fb4ffe8e-5e48-4ffb-9dd1-97d6b0c48041

📥 Commits

Reviewing files that changed from the base of the PR and between 70c2d4e and 8d441d3.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock and included by **
📒 Files selected for processing (12)
  • crates/biome_js_analyze/Cargo.toml
  • crates/biome_js_analyze/benches/use_sorted_classes_parser.rs
  • crates/biome_js_analyze/src/lint/nursery/use_sorted_classes.rs
  • crates/biome_js_analyze/src/lint/nursery/use_sorted_classes/sort.rs
  • crates/biome_tailwind_sort/Cargo.toml
  • crates/biome_tailwind_sort/src/class_info.rs
  • crates/biome_tailwind_sort/src/class_lexer.rs
  • crates/biome_tailwind_sort/src/lib.rs
  • crates/biome_tailwind_sort/src/presets.rs
  • crates/biome_tailwind_sort/src/sort.rs
  • crates/biome_tailwind_sort/src/sort_config.rs
  • crates/biome_tailwind_sort/src/tailwind_preset.rs

Comment on lines +108 to +133
pub(crate) fn get_sort_class_name_range(
class_name: &TokenText,
range: &TextRange,
template_ctx: &Option<TemplateLiteralSpaceContext>,
) -> Option<TextRange> {
let sort_context = template_ctx.as_ref().map(|ctx| ctx.to_sort_context());

let mut class_iter = class_name.split_whitespace();
let first_class_len = class_iter.next().map_or(0, |s| s.len()) as u32;
let last_class_len = class_iter.next_back().map_or(0, |s| s.len()) as u32;

let (ignore_prefix, ignore_suffix) = sort_context
.as_ref()
.map_or((false, false), |ctx| (ctx.ignore_prefix, ctx.ignore_suffix));
let offset_prefix = if ignore_prefix { first_class_len } else { 0 };
let offset_suffix = if ignore_suffix { last_class_len } else { 0 };

let start = range.start() + TextSize::from(offset_prefix);
let end = range.end() - TextSize::from(offset_suffix);

if end < start {
return None;
}

Some(TextRange::new(start, end))
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the guard comparison in both files
echo "=== JS Analyzer ==="
rg -n -C2 'if end' crates/biome_js_analyze/src/lint/nursery/use_sorted_classes/sort.rs

echo ""
echo "=== Tailwind Sorter ==="
rg -n -C2 'if end' crates/biome_tailwind_sort/src/sort.rs

Repository: biomejs/biome

Length of output: 361


Align empty-range handling with the shared sorter.

The JS analyser permits zero-width ranges (end == start) to pass through, whilst the Tailwind sorter rejects them. Bring them into alignment to prevent fragile empty highlights.

-    if end < start {
+    if end <= start {
         return None;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub(crate) fn get_sort_class_name_range(
class_name: &TokenText,
range: &TextRange,
template_ctx: &Option<TemplateLiteralSpaceContext>,
) -> Option<TextRange> {
let sort_context = template_ctx.as_ref().map(|ctx| ctx.to_sort_context());
let mut class_iter = class_name.split_whitespace();
let first_class_len = class_iter.next().map_or(0, |s| s.len()) as u32;
let last_class_len = class_iter.next_back().map_or(0, |s| s.len()) as u32;
let (ignore_prefix, ignore_suffix) = sort_context
.as_ref()
.map_or((false, false), |ctx| (ctx.ignore_prefix, ctx.ignore_suffix));
let offset_prefix = if ignore_prefix { first_class_len } else { 0 };
let offset_suffix = if ignore_suffix { last_class_len } else { 0 };
let start = range.start() + TextSize::from(offset_prefix);
let end = range.end() - TextSize::from(offset_suffix);
if end < start {
return None;
}
Some(TextRange::new(start, end))
}
pub(crate) fn get_sort_class_name_range(
class_name: &TokenText,
range: &TextRange,
template_ctx: &Option<TemplateLiteralSpaceContext>,
) -> Option<TextRange> {
let sort_context = template_ctx.as_ref().map(|ctx| ctx.to_sort_context());
let mut class_iter = class_name.split_whitespace();
let first_class_len = class_iter.next().map_or(0, |s| s.len()) as u32;
let last_class_len = class_iter.next_back().map_or(0, |s| s.len()) as u32;
let (ignore_prefix, ignore_suffix) = sort_context
.as_ref()
.map_or((false, false), |ctx| (ctx.ignore_prefix, ctx.ignore_suffix));
let offset_prefix = if ignore_prefix { first_class_len } else { 0 };
let offset_suffix = if ignore_suffix { last_class_len } else { 0 };
let start = range.start() + TextSize::from(offset_prefix);
let end = range.end() - TextSize::from(offset_suffix);
if end <= start {
return None;
}
Some(TextRange::new(start, end))
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/biome_js_analyze/src/lint/nursery/use_sorted_classes/sort.rs` around
lines 108 - 133, get_sort_class_name_range currently allows zero-width ranges
(end == start) to be returned; change the empty-range guard to reject
zero-length ranges by returning None when end <= start instead of only when end
< start so it matches the Tailwind sorter behavior; update the condition in
get_sort_class_name_range (the end/start check before Some(TextRange::new(start,
end))) to use <= and keep the rest of the logic (offset_prefix/offset_suffix and
TextRange::new) unchanged.

@github-actions github-actions Bot added A-Linter Area: linter L-JavaScript Language: JavaScript and super languages L-Tailwind Language: Tailwind CSS labels Mar 5, 2026
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Mar 5, 2026

Merging this PR will not alter performance

✅ 58 untouched benchmarks
⏩ 156 skipped benchmarks1


Comparing refactor/tailwind-sorter (8d441d3) with main (70c2d4e)

Open in CodSpeed

Footnotes

  1. 156 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@dyc3
Copy link
Copy Markdown
Contributor

dyc3 commented Mar 5, 2026

I was planning on redoing that rule's logic so that we would have it in a biome_tailwind_analyze crate instead. #8503 has the impl for adding tailwind as an embedded language and adds the biome_tailwind_analyze crate.

@ematipico
Copy link
Copy Markdown
Member Author

My work doesn't void yours.

The reason why I extracted this in a crate is because I plan to implement it in other languages (CSS, HTML), so a generic crate sounds a good solution

@dyc3
Copy link
Copy Markdown
Contributor

dyc3 commented Mar 5, 2026

Right, but the existing tailwind class sorting rule effectively uses it's own parser. I'm more concerned about my work voiding yours 😅

We would treat tailwind as an embedded language, and reimplement this rule in biome_tailwind_analyze. So that would effectively become the shared logic. So this old logic would get thrown out anyway, though it would be an ok reference for porting the logic, I suppose.

@ematipico
Copy link
Copy Markdown
Member Author

Ah, no, that was my plan all along. But since you're already on top of it, I leave that to you :)

@ematipico ematipico closed this Mar 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Linter Area: linter L-JavaScript Language: JavaScript and super languages L-Tailwind Language: Tailwind CSS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants