Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 }
biome_text_edit = { workspace = true }
camino = { workspace = true }
Expand Down
18 changes: 16 additions & 2 deletions crates/biome_analyze/src/rule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,10 @@ pub enum RuleSource<'a> {
EslintYml(&'a str),
/// Rules from [Eslint CSS](https://github.com/eslint/css)
EslintCss(&'a str),
/// Rules from [Eslint Plugin Svelte](https://sveltejs.github.io/eslint-plugin-svelte/)
EslintSvelte(&'a str),
/// Rules from [Eslint Plugin Astro](https://ota-meshi.github.io/eslint-plugin-astro/)
EslintAstro(&'a str),
/// Rules from [Eslint Plugin Drizzle](https://orm.drizzle.team/docs/eslint-plugin)
EslintDrizzle(&'a str),
/// Action for https://github.com/keithamus/sort-package-json
Expand Down Expand Up @@ -246,6 +250,8 @@ impl<'a> std::fmt::Display for RuleSource<'a> {
Self::EslintMarkdown(_) => write!(f, "@eslint/markdown"),
Self::EslintYml(_) => write!(f, "eslint-plugin-yml"),
Self::EslintCss(_) => write!(f, "@eslint/css"),
Self::EslintSvelte(_) => write!(f, "eslint-plugin-svelte"),
Self::EslintAstro(_) => write!(f, "eslint-plugin-astro"),
Self::EslintDrizzle(_) => write!(f, "eslint-plugin-drizzle"),
Self::SortPackageJson => write!(f, "sort-package-json"),
}
Expand Down Expand Up @@ -313,8 +319,10 @@ impl<'a> RuleSource<'a> {
Self::EslintMarkdown(_) => 42,
Self::EslintYml(_) => 43,
Self::EslintCss(_) => 44,
Self::EslintDrizzle(_) => 45,
Self::SortPackageJson => 46,
Self::EslintSvelte(_) => 45,
Self::EslintAstro(_) => 46,
Self::EslintDrizzle(_) => 47,
Self::SortPackageJson => 48,
}
}

Expand Down Expand Up @@ -379,6 +387,8 @@ impl<'a> RuleSource<'a> {
| Self::EslintJson(rule_name)
| Self::EslintMarkdown(rule_name)
| Self::EslintYml(rule_name)
| Self::EslintSvelte(rule_name)
| Self::EslintAstro(rule_name)
| Self::EslintDrizzle(rule_name) => rule_name,
Self::SortPackageJson => "sort-package-json",
}
Expand Down Expand Up @@ -432,6 +442,8 @@ impl<'a> RuleSource<'a> {
Self::EslintMarkdown(_) => "markdown",
Self::EslintYml(_) => "yml",
Self::EslintCss(_) => "css",
Self::EslintSvelte(_) => "svelte",
Self::EslintAstro(_) => "astro",
Self::EslintDrizzle(_) => "drizzle",
}
}
Expand Down Expand Up @@ -491,6 +503,8 @@ impl<'a> RuleSource<'a> {
Self::EslintMarkdown(rule_name) => format!("https://github.com/eslint/markdown/blob/main/docs/rules/{rule_name}.md"),
Self::EslintYml(rule_name) => format!("https://ota-meshi.github.io/eslint-plugin-yml/rules/{rule_name}.html"),
Self::EslintCss(rule_name) => format!("https://github.com/eslint/css/blob/main/docs/rules/{rule_name}.md"),
Self::EslintSvelte(rule_name) => format!("https://sveltejs.github.io/eslint-plugin-svelte/rules/{rule_name}"),
Self::EslintAstro(rule_name) => format!("https://ota-meshi.github.io/eslint-plugin-astro/rules/{rule_name}"),
Self::EslintDrizzle(rule_name) => format!("https://orm.drizzle.team/docs/eslint-plugin#{rule_name}"),
Self::SortPackageJson => "https://github.com/keithamus/sort-package-json".to_string(),
}
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;
111 changes: 111 additions & 0 deletions crates/biome_analyze/src/shared/sort_attributes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
use biome_rowan::{AstNode, Language, SyntaxToken, TriviaPieceKind};
use biome_string_case::StrLikeExtension;
use std::cmp::Ordering;

pub trait SortableAttribute {
type Language: Language;

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

fn node(&self) -> &impl AstNode<Language = Self::Language>;

fn replace_token(
self,
prev_token: SyntaxToken<Self::Language>,
next_token: SyntaxToken<Self::Language>,
) -> Option<Self>
where
Self: Sized;

fn ascii_nat_cmp(&self, other: &Self) -> Ordering {
match (self.name(), other.name()) {
(Some(self_name), Some(other_name)) => self_name
.text_trimmed()
.ascii_nat_cmp(other_name.text_trimmed()),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal,
}
}

fn lexicographic_cmp(&self, other: &Self) -> Ordering {
match (self.name(), other.name()) {
(Some(self_name), Some(other_name)) => self_name
.text_trimmed()
.lexicographic_cmp(other_name.text_trimmed()),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal,
}
}
}

#[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) -> Option<Vec<T>>
where
F: FnMut(&T, &T) -> Ordering,
{
let mut attrs = self.attrs.clone();
attrs.sort_by(comparator);

let mut iter = attrs.iter_mut().peekable();

while let Some(sorted_attr) = iter.next() {
if iter.peek().is_some() {
// Make sure sorted_attr has trailing whitespace if it is not the last attribute in the group
let ends_in_whitespace = sorted_attr
.node()
.syntax()
.last_trailing_trivia()
.and_then(|last_trivia| last_trivia.last())
.is_some_and(|last| last.is_whitespace() || last.is_newline());

let next_starts_with_whitespace = iter
.peek()
.and_then(|next_sorted_attr| {
next_sorted_attr.node().syntax().first_leading_trivia()
})
.and_then(|first_trivia| first_trivia.first())
.is_some_and(|first| first.is_whitespace() || first.is_newline());

if !ends_in_whitespace && !next_starts_with_whitespace {
let old_last_token = sorted_attr.node().syntax().last_token().unwrap();
let new_last_token =
old_last_token.with_trailing_trivia([(TriviaPieceKind::Whitespace, " ")]);

*sorted_attr = sorted_attr
.clone()
.replace_token(old_last_token, new_last_token)?;
}
}
}

Some(attrs)
}

pub fn clear(&mut self) {
self.attrs.clear();
}
}
Loading
Loading