-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(assist): implement useSortedAttributes for HTML #9547
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
517e7d4
6cce591
dfcd83e
d7cbbe4
e2af7cf
61c7f70
9120d75
7c590fb
65094ee
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" /> | ||
| ``` |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| pub mod class_dedup; | ||
| pub mod sort_attributes; |
| 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(); | ||
| } | ||
| } |
| 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"), | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use the state-specific range to avoid duplicate diagnostics.
🤖 Prompt for AI Agents |
||
|
|
||
| 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" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> |
Uh oh!
There was an error while loading. Please reload this page.