Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/eleven-baths-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@biomejs/biome": minor
---

Added new assist rule [`useSortedAttributes`](https://biomejs.dev/assist/actions/use-sorted-attributes/) for HTML, porting the existing JSX rule. This rule enforces sorted HTML attributes.

**Invalid**

```html
<input type="text" id="name" name="name" />
```
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/biome_analyze/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ biome_deserialize_macros = { workspace = true, optional = true }
biome_diagnostics = { workspace = true }
biome_parser = { workspace = true }
biome_rowan = { workspace = true }
biome_string_case = { workspace = true }
biome_suppression = { workspace = true }
camino = { workspace = true }
enumflags2 = { workspace = true }
Expand Down
1 change: 1 addition & 0 deletions crates/biome_analyze/src/shared/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pub mod class_dedup;
pub mod sort_attributes;
66 changes: 66 additions & 0 deletions crates/biome_analyze/src/shared/sort_attributes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
use biome_rowan::{Language, SyntaxResult, SyntaxToken};
use biome_string_case::StrLikeExtension;
use std::cmp::Ordering;

pub trait SortableAttribute {
type Language: Language;

fn name(&self) -> SyntaxResult<SyntaxToken<Self::Language>>;

fn ascii_nat_cmp(&self, other: &Self) -> Ordering {
let (Ok(self_name), Ok(other_name)) = (self.name(), other.name()) else {
return Ordering::Equal;
};

self_name
.text_trimmed()
.ascii_nat_cmp(other_name.text_trimmed())
}

fn lexicographic_cmp(&self, other: &Self) -> Ordering {
let (Ok(self_name), Ok(other_name)) = (self.name(), other.name()) else {
return Ordering::Equal;
};

self_name
.text_trimmed()
.lexicographic_cmp(other_name.text_trimmed())
}
}

#[derive(Clone)]
pub struct AttributeGroup<T: SortableAttribute + Clone> {
pub attrs: Vec<T>,
}

impl<T: SortableAttribute + Clone> Default for AttributeGroup<T> {
fn default() -> Self {
Self { attrs: Vec::new() }
}
}

impl<T: SortableAttribute + Clone> AttributeGroup<T> {
pub fn is_empty(&self) -> bool {
self.attrs.is_empty()
}

pub fn is_sorted<F>(&self, comparator: F) -> bool
where
F: Fn(&T, &T) -> bool,
{
self.attrs.is_sorted_by(comparator)
}

pub fn get_sorted_attributes<F>(&self, comparator: F) -> Vec<T>
where
F: FnMut(&T, &T) -> Ordering,
{
let mut new_attrs = self.attrs.clone();
new_attrs.sort_by(comparator);
new_attrs
}

pub fn clear(&mut self) {
self.attrs.clear();
}
}
192 changes: 192 additions & 0 deletions crates/biome_html_analyze/src/assist/source/use_sorted_attributes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
use crate::HtmlRuleAction;
use biome_analyze::shared::sort_attributes::{AttributeGroup, SortableAttribute};
use biome_analyze::{
Ast, FixKind, Rule, RuleAction, RuleDiagnostic, RuleSource, context::RuleContext,
declare_source_rule,
};
use biome_console::markup;
use biome_deserialize::TextRange;
use biome_diagnostics::{Applicability, category};
use biome_html_syntax::{
AnyHtmlAttribute, HtmlAttribute, HtmlAttributeList, HtmlLanguage, HtmlOpeningElement,
HtmlSelfClosingElement,
};
use biome_rowan::{AstNode, BatchMutationExt, SyntaxResult, SyntaxToken};
use biome_rule_options::use_sorted_attributes::{SortOrder, UseSortedAttributesOptions};
use std::{borrow::Cow, cmp::Ordering, iter::zip};

declare_source_rule! {
/// Enforce attribute sorting in HTML elements.
///
/// This rule checks if the HTML attributes are sorted in a consistent way.
/// Attributes are sorted alphabetically using a [natural sort order](https://en.wikipedia.org/wiki/Natural_sort_order).
///
/// This rule will not consider spread props, Vue, Svelte, or Astro directives as sortable.
/// Instead, it will sort each group of consecutive HTML attributes within the element,
/// leaving any spread props, Vue, Svelte, or Astro directives in place.
/// This prevents breaking the override of certain props using spread
/// props and avoids changing the behavior of Vue or Svelte code.
///
/// ## Examples
///
/// ### Invalid
///
/// ```html,expect_diagnostic
/// <input type="text" id="name" name="name" />
/// ```
///
/// ```html,expect_diagnostic
/// <textarea id="mytextarea" name="textarea" rows="5" cols="20" data-1="" data-11="" data-12="" data-2="">Hello, world!</textarea>
/// ```
///
/// ### Valid
///
/// ```html
/// <input id="name" name="name" type="text" />
/// ```
///
/// ```html
/// <textarea cols="20" data-1="" data-2="" data-11="" data-12="" id="mytextarea" name="textarea" rows="5">Hello, world!</textarea>
/// ```
///
/// ## Options
///
/// The following options are available
///
/// ### `sortOrder`
/// The sort ordering to enforce.
/// Values:
///
/// - `"natural"`
/// - `"lexicographic"`
///
/// Default: `"natural"`
///
/// #### Examples for `"sortOrder": "lexicographic"`
///
/// ```json,options
/// {
/// "options": {
/// "sortOrder": "lexicographic"
/// }
/// }
/// ```
/// ```html,use_options,expect_diagnostic
/// <textarea id="mytextarea" name="textarea" rows="5" cols="20" data-1="" data-2="" data-11="" data-12="">Hello, world!</textarea>
/// ```
///
pub UseSortedAttributes {
version: "next",
name: "useSortedAttributes",
language: "html",
recommended: false,
sources: &[RuleSource::HtmlEslint("sort-attrs").inspired()],
fix_kind: FixKind::Safe,
}
}

impl Rule for UseSortedAttributes {
type Query = Ast<HtmlAttributeList>;
type State = AttributeGroup<SortableHtmlAttribute>;
type Signals = Box<[Self::State]>;
type Options = UseSortedAttributesOptions;

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let attrs = ctx.query();
let options = ctx.options();

let mut current_attr_group = AttributeGroup::default();
let mut attr_groups = Vec::new();
let sort_by = options.sort_order.unwrap_or_default();

let comparator = match sort_by {
SortOrder::Natural => SortableHtmlAttribute::ascii_nat_cmp,
SortOrder::Lexicographic => SortableHtmlAttribute::lexicographic_cmp,
};

// Convert to boolean-based comparator for is_sorted_by
let boolean_comparator = |a: &SortableHtmlAttribute, b: &SortableHtmlAttribute| {
comparator(a, b) != Ordering::Greater
};

for attr in attrs {
match attr {
AnyHtmlAttribute::HtmlAttribute(attr) => {
current_attr_group
.attrs
.push(SortableHtmlAttribute { attr });
}
_ => {
if !current_attr_group.is_empty()
&& !current_attr_group.is_sorted(boolean_comparator)
{
attr_groups.push(current_attr_group);
current_attr_group = AttributeGroup::default();
} else {
// Reuse the same buffer
current_attr_group.clear();
}
}
}
}
if !current_attr_group.is_empty() && !current_attr_group.is_sorted(boolean_comparator) {
attr_groups.push(current_attr_group);
}
attr_groups.into_boxed_slice()
}

fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
Some(RuleDiagnostic::new(
category!("assist/source/useSortedAttributes"),
Comment thread
dyc3 marked this conversation as resolved.
Self::text_range(ctx, state)?,
markup! {
"The attributes are not sorted."
},
))
}

fn text_range(ctx: &RuleContext<Self>, _state: &Self::State) -> Option<TextRange> {
ctx.query().syntax().ancestors().skip(1).find_map(|node| {
HtmlOpeningElement::cast_ref(&node)
.map(|element| element.range())
.or_else(|| HtmlSelfClosingElement::cast_ref(&node).map(|element| element.range()))
})
}
Comment on lines +193 to +199

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

Use the state-specific range to avoid duplicate diagnostics.

text_range currently ignores state, so two unsorted groups in the same element can emit duplicate diagnostics on the same element-wide span. Please narrow the range to the current group (for example, from first to last attribute in state.attrs).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/biome_html_analyze/src/assist/source/use_sorted_attributes.rs` around
lines 145 - 151, text_range currently ignores the provided _state and returns
the whole element range, causing duplicate diagnostics; update text_range(ctx:
&RuleContext<Self>, state: &Self::State) to compute and return a narrower
TextRange covering only the current attribute group (use state.attrs: get the
first and last attribute nodes from state.attrs, compute their combined span
from first.range().start() to last.range().end(), and return that TextRange)
instead of the HtmlOpeningElement/HtmlSelfClosingElement element.range(); this
ensures the diagnostic is tied to the specific group rather than the entire
element.


fn action(ctx: &RuleContext<Self>, state: &Self::State) -> Option<HtmlRuleAction> {
let mut mutation = ctx.root().begin();
let options = ctx.options();
let sort_by = options.sort_order.unwrap_or_default();

let comparator = match sort_by {
SortOrder::Natural => SortableHtmlAttribute::ascii_nat_cmp,
SortOrder::Lexicographic => SortableHtmlAttribute::lexicographic_cmp,
};

for (SortableHtmlAttribute { attr }, SortableHtmlAttribute { attr: sorted_attr }) in
zip(state.attrs.iter(), state.get_sorted_attributes(comparator))
{
mutation.replace_node_discard_trivia(attr.clone(), sorted_attr);
}

Some(RuleAction::new(
rule_action_category!(),
Applicability::Always,
markup! { "Sort the HTML attributes." },
mutation,
))
}
}

#[derive(PartialEq, Eq, Clone)]
pub struct SortableHtmlAttribute {
attr: HtmlAttribute,
}

impl SortableAttribute for SortableHtmlAttribute {
type Language = HtmlLanguage;

fn name(&self) -> SyntaxResult<SyntaxToken<Self::Language>> {
self.attr.name()?.value_token()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Component
client:load
class:list={classes}
set:text={text}
is:raw
dir="auto"
spellcheck="true"
tabindex="-1"
define:vars={vars}
server:defer
id="myid"
/>
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
source: crates/biome_html_analyze/tests/spec_tests.rs
expression: sorted.astro
---
# Input
```html
<Component
client:load
class:list={classes}
set:text={text}
is:raw
dir="auto"
spellcheck="true"
tabindex="-1"
define:vars={vars}
server:defer
id="myid"

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.

same for astro

/>

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Component
client:load
class:list={classes}
set:text={text}
is:raw
spellcheck="true"
tabindex="-1"
dir="auto"
define:vars={vars}
server:defer
id="myid"
/>
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
source: crates/biome_html_analyze/tests/spec_tests.rs
expression: unsorted.astro
---
# Input
```html
<Component
client:load
class:list={classes}
set:text={text}
is:raw
spellcheck="true"
tabindex="-1"
dir="auto"
define:vars={vars}
server:defer
id="myid"
/>

```

# Diagnostics
```
unsorted.astro:1:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

i The attributes are not sorted.

> 1 │ <Component
│ ^^^^^^^^^^
> 2 │ client:load
> 3 │ class:list={classes}
> 4 │ set:text={text}
...
> 10 │ server:defer
> 11 │ id="myid"
> 12 │ />
│ ^^
13 │

i Safe fix: Sort the HTML attributes.

4 4 │ set:text={text}
5 5 │ is:raw
6 │ - ··spellcheck="true"
7 │ - ··tabindex="-1"
8 │ - ··dir="auto"
6 │ + ··dir="auto"
7 │ + ··spellcheck="true"
8 │ + ··tabindex="-1"
9 9 │ define:vars={vars}
10 10 │ server:defer


```
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<!-- should not generate diagnostics -->
<input id="name" name="name" type="text" />
<textarea cols="20" data-1="" data-11="" data-12="" data-2="" id="mytextarea" name="textarea" rows="5">
Hello, world!</textarea>
Loading