diff --git a/.changeset/eleven-baths-wave.md b/.changeset/eleven-baths-wave.md new file mode 100644 index 000000000000..a718c025c6ca --- /dev/null +++ b/.changeset/eleven-baths-wave.md @@ -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 + +``` diff --git a/Cargo.lock b/Cargo.lock index e4630f8cc3cf..2dcb20515f8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -126,6 +126,7 @@ dependencies = [ "biome_diagnostics", "biome_parser", "biome_rowan", + "biome_string_case", "biome_suppression", "biome_text_edit", "camino", diff --git a/crates/biome_analyze/Cargo.toml b/crates/biome_analyze/Cargo.toml index 82ab054d9089..945e9b939510 100644 --- a/crates/biome_analyze/Cargo.toml +++ b/crates/biome_analyze/Cargo.toml @@ -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 } diff --git a/crates/biome_analyze/src/rule.rs b/crates/biome_analyze/src/rule.rs index 00343d9d447b..3171ff4c9cd0 100644 --- a/crates/biome_analyze/src/rule.rs +++ b/crates/biome_analyze/src/rule.rs @@ -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 @@ -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"), } @@ -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, } } @@ -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", } @@ -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", } } @@ -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(), } diff --git a/crates/biome_analyze/src/shared/mod.rs b/crates/biome_analyze/src/shared/mod.rs index 7771177ed268..cf28c81f1cd3 100644 --- a/crates/biome_analyze/src/shared/mod.rs +++ b/crates/biome_analyze/src/shared/mod.rs @@ -1 +1,2 @@ pub mod class_dedup; +pub mod sort_attributes; diff --git a/crates/biome_analyze/src/shared/sort_attributes.rs b/crates/biome_analyze/src/shared/sort_attributes.rs new file mode 100644 index 000000000000..461b298a18e3 --- /dev/null +++ b/crates/biome_analyze/src/shared/sort_attributes.rs @@ -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>; + + fn node(&self) -> &impl AstNode; + + fn replace_token( + self, + prev_token: SyntaxToken, + next_token: SyntaxToken, + ) -> Option + 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 { + pub attrs: Vec, +} + +impl Default for AttributeGroup { + fn default() -> Self { + Self { attrs: Vec::new() } + } +} + +impl AttributeGroup { + pub fn is_empty(&self) -> bool { + self.attrs.is_empty() + } + + pub fn is_sorted(&self, comparator: F) -> bool + where + F: Fn(&T, &T) -> bool, + { + self.attrs.is_sorted_by(comparator) + } + + pub fn get_sorted_attributes(&self, comparator: F) -> Option> + 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(); + } +} diff --git a/crates/biome_html_analyze/src/assist/source/use_sorted_attributes.rs b/crates/biome_html_analyze/src/assist/source/use_sorted_attributes.rs new file mode 100644 index 000000000000..cf301c39d21d --- /dev/null +++ b/crates/biome_html_analyze/src/assist/source/use_sorted_attributes.rs @@ -0,0 +1,555 @@ +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::{ + AnyAstroDirective, AnyHtmlAttribute, AnySvelteBindingProperty, AnySvelteDirective, + AnyVueDirective, AnyVueDirectiveArgument, AstroDirectiveValue, HtmlAttributeList, HtmlLanguage, + HtmlOpeningElement, HtmlSelfClosingElement, SvelteDirectiveValue, +}; +use biome_rowan::{AstNode, AstNodeExt, BatchMutationExt, 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 HTML attributes, along with Astro, Svelte, and Vue directives, + /// are sorted in a consistent way. + /// The sort order is: + /// - Regular HTML attributes, sorted alphabetically according to the `sortOrder` option + /// - Astro directives, sorted alphabetically according to `sortOrder` + /// - Svelte directives, sorted according to eslint-plugin-svelte's [`sort-attributes` rule](https://sveltejs.github.io/eslint-plugin-svelte/rules/sort-attributes/) + /// - Vue directives, sorted according to the [Vue.js Style Guide](https://eslint.vuejs.org/rules/attributes-order) + /// + /// If two attributes belong to the same category, they will be sorted alphabetically + /// according to `sortOrder`. + /// + /// This rule will not consider spread props or the [Vue `v-bind="object"` syntax](https://vuejs.org/guide/essentials/template-syntax.html#dynamically-binding-multiple-attributes) + /// as sortable. + /// Instead, it will sort each group of consecutive sortable attributes within the element, + /// leaving any spread props or `v-bind="object"` attributes in place. + /// This prevents breaking the override of certain props using spread + /// props or `v-bind="object"`. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```html,expect_diagnostic + /// + /// ``` + /// + /// ```html,expect_diagnostic + /// + /// ``` + /// + /// ```astro,expect_diagnostic + /// ... + /// ``` + /// + /// ```svelte,expect_diagnostic + /// + /// ``` + /// + /// ```svelte,expect_diagnostic + ///
...
+ /// ``` + /// + /// ```vue,expect_diagnostic + /// + /// ``` + /// + /// ### Valid + /// + /// ```html + /// + /// ``` + /// + /// ```html + /// + /// ``` + /// + /// ```astro + /// ... + /// ``` + /// + /// ```svelte + /// + /// ``` + /// + /// ```svelte + ///
...
+ /// ``` + /// + /// ```vue + /// + /// ``` + /// + /// ## Options + /// + /// The following options are available + /// + /// ### `sortOrder` + /// The sort ordering to enforce. + /// Values: + /// + /// - `"[natural](https://en.wikipedia.org/wiki/Natural_sort_order)"` + /// - `"[lexicographic](https://en.wikipedia.org/wiki/Lexicographic_order)"` + /// + /// Default: `"natural"` + /// + /// #### Examples for `"sortOrder": "lexicographic"` + /// + /// ```json,options + /// { + /// "options": { + /// "sortOrder": "lexicographic" + /// } + /// } + /// ``` + /// ```html,use_options,expect_diagnostic + /// + /// ``` + /// + pub UseSortedAttributes { + version: "next", + name: "useSortedAttributes", + language: "html", + recommended: false, + sources: &[RuleSource::HtmlEslint("sort-attrs").inspired(), RuleSource::EslintVueJs("attributes-order").inspired(), RuleSource::EslintSvelte("sort-attributes").inspired(), RuleSource::EslintAstro("sort-attributes").inspired()], + fix_kind: FixKind::Safe, + } +} + +impl Rule for UseSortedAttributes { + type Query = Ast; + type State = AttributeGroup; + type Signals = Box<[Self::State]>; + type Options = UseSortedAttributesOptions; + + fn run(ctx: &RuleContext) -> 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 = get_comparator(sort_by); + + // Convert to boolean-based comparator for is_sorted_by + let boolean_comparator = |a: &SortableHtmlAttribute, b: &SortableHtmlAttribute| { + comparator(a, b) != Ordering::Greater + }; + + let reset_attr_group = |mut current_group: AttributeGroup, + groups: &mut Vec<_>| { + if !current_group.is_empty() && !current_group.is_sorted(boolean_comparator) { + groups.push(current_group); + AttributeGroup::default() + } else { + // Reuse the same buffer + current_group.clear(); + current_group + } + }; + + for attr in attrs { + match attr { + AnyHtmlAttribute::HtmlSpreadAttribute(_) => { + current_attr_group = reset_attr_group(current_attr_group, &mut attr_groups); + } + attr => { + if is_v_bind_object(&attr) { + current_attr_group = reset_attr_group(current_attr_group, &mut attr_groups); + } else { + 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); + } + attr_groups.into_boxed_slice() + } + + fn diagnostic(ctx: &RuleContext, state: &Self::State) -> Option { + Some(RuleDiagnostic::new( + category!("assist/source/useSortedAttributes"), + Self::text_range(ctx, state)?, + markup! { + "The attributes are not sorted." + }, + )) + } + + fn text_range(ctx: &RuleContext, _state: &Self::State) -> Option { + 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())) + }) + } + + fn action(ctx: &RuleContext, state: &Self::State) -> Option { + let mut mutation = ctx.root().begin(); + let options = ctx.options(); + let sort_by = options.sort_order.unwrap_or_default(); + + let comparator = get_comparator(sort_by); + + for (SortableHtmlAttribute(attr), SortableHtmlAttribute(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, + )) + } +} + +fn is_v_bind_object(attr: &AnyHtmlAttribute) -> bool { + if let AnyHtmlAttribute::AnyVueDirective(AnyVueDirective::VueDirective(dir)) = attr { + if let Ok(attr_name) = dir.name_token().as_ref().map(|token| token.text_trimmed()) { + attr_name == "v-bind" && dir.arg().is_none() + } else { + false + } + } else { + false + } +} + +#[derive(PartialEq, Eq, Clone, PartialOrd, Ord)] +enum SortCategory { + HtmlAttribute, + AstroClassDirective, + AstroClientDirective, + AstroDefineDirective, + AstroIsDirective, + AstroServerDirective, + AstroSetDirective, + SvelteBindThisDirective, + SvelteStyleDirective, + SvelteClassDirective, + SvelteBindDirective, + SvelteUseDirective, + SvelteTransitionDirective, + SvelteInDirective, + SvelteOutDirective, + SvelteAnimateDirective, + SvelteAttachAttribute, + + VueDefinition, + VueListRendering, + VueConditional, + VueRenderModifier, + VueUnique, + VueSlot, + VueTwoWayBinding, + VueCustomDirective, + // `v-bind`, etc. + VueOtherAttribute, + VueEvent, + VueContent, + + Unknown, +} + +#[derive(PartialEq, Eq, Clone)] +pub struct SortableHtmlAttribute(AnyHtmlAttribute); + +impl SortableHtmlAttribute { + fn category(&self) -> SortCategory { + match &self.0 { + AnyHtmlAttribute::HtmlAttribute(attr) => { + if let Ok(attr_name) = attr + .name() + .and_then(|name| name.value_token()) + .as_ref() + .map(|token| token.text_trimmed()) + { + match attr_name { + // Vue ref attribute + "ref" => SortCategory::VueUnique, + _ => SortCategory::HtmlAttribute, + } + } else { + SortCategory::HtmlAttribute + } + } + AnyHtmlAttribute::HtmlAttributeSingleTextExpression(_) => SortCategory::HtmlAttribute, + AnyHtmlAttribute::AnyAstroDirective(AnyAstroDirective::AstroClassDirective(_)) => { + SortCategory::AstroClassDirective + } + AnyHtmlAttribute::AnyAstroDirective(AnyAstroDirective::AstroClientDirective(_)) => { + SortCategory::AstroClientDirective + } + AnyHtmlAttribute::AnyAstroDirective(AnyAstroDirective::AstroDefineDirective(_)) => { + SortCategory::AstroDefineDirective + } + AnyHtmlAttribute::AnyAstroDirective(AnyAstroDirective::AstroIsDirective(_)) => { + SortCategory::AstroIsDirective + } + AnyHtmlAttribute::AnyAstroDirective(AnyAstroDirective::AstroServerDirective(_)) => { + SortCategory::AstroServerDirective + } + AnyHtmlAttribute::AnyAstroDirective(AnyAstroDirective::AstroSetDirective(_)) => { + SortCategory::AstroSetDirective + } + AnyHtmlAttribute::AnySvelteDirective(AnySvelteDirective::SvelteStyleDirective(_)) => { + SortCategory::SvelteStyleDirective + } + AnyHtmlAttribute::AnySvelteDirective(AnySvelteDirective::SvelteClassDirective(_)) => { + SortCategory::SvelteClassDirective + } + AnyHtmlAttribute::AnySvelteDirective(AnySvelteDirective::SvelteBindDirective(dir)) => { + if let Some(token) = dir + .value() + .ok() + .and_then(|value| svelte_directive_value_token(&value)) + { + match token.text_trimmed() { + "this" => SortCategory::SvelteBindThisDirective, + _ => SortCategory::SvelteBindDirective, + } + } else { + SortCategory::SvelteBindDirective + } + } + AnyHtmlAttribute::AnySvelteDirective(AnySvelteDirective::SvelteUseDirective(_)) => { + SortCategory::SvelteUseDirective + } + AnyHtmlAttribute::AnySvelteDirective( + AnySvelteDirective::SvelteTransitionDirective(_), + ) => SortCategory::SvelteTransitionDirective, + AnyHtmlAttribute::AnySvelteDirective(AnySvelteDirective::SvelteInDirective(_)) => { + SortCategory::SvelteInDirective + } + AnyHtmlAttribute::AnySvelteDirective(AnySvelteDirective::SvelteOutDirective(_)) => { + SortCategory::SvelteOutDirective + } + AnyHtmlAttribute::AnySvelteDirective(AnySvelteDirective::SvelteAnimateDirective(_)) => { + SortCategory::SvelteAnimateDirective + } + AnyHtmlAttribute::SvelteAttachAttribute(_) => SortCategory::SvelteAttachAttribute, + AnyHtmlAttribute::AnyVueDirective(AnyVueDirective::VueBogusDirective(_)) => { + SortCategory::Unknown + } + AnyHtmlAttribute::AnyVueDirective(AnyVueDirective::VueDirective(dir)) => { + if let Ok(attr_name) = dir.name_token().as_ref().map(|token| token.text_trimmed()) { + match attr_name { + "v-for" => SortCategory::VueListRendering, + "v-if" | "v-else-if" | "v-else" | "v-show" | "v-cloak" => { + SortCategory::VueConditional + } + "v-once" | "v-pre" => SortCategory::VueRenderModifier, + "v-slot" => SortCategory::VueSlot, + "v-model" => SortCategory::VueTwoWayBinding, + "v-on" => SortCategory::VueEvent, + "v-text" | "v-html" => SortCategory::VueContent, + "v-bind" => SortCategory::VueOtherAttribute, + _ => SortCategory::VueCustomDirective, + } + } else { + SortCategory::VueCustomDirective + } + } + AnyHtmlAttribute::AnyVueDirective(AnyVueDirective::VueVBindShorthandDirective(dir)) => { + if let Ok(arg) = dir.arg().and_then(|arg| arg.arg()) { + match arg { + AnyVueDirectiveArgument::VueBogusDirectiveArgument(_) => { + SortCategory::Unknown + } + AnyVueDirectiveArgument::VueDynamicArgument(_) => { + SortCategory::VueOtherAttribute + } + AnyVueDirectiveArgument::VueStaticArgument(arg) => { + if let Ok(arg_name) = + arg.name_token().as_ref().map(|token| token.text_trimmed()) + { + match arg_name { + "is" => SortCategory::VueDefinition, + "key" => SortCategory::VueUnique, + _ => SortCategory::VueOtherAttribute, + } + } else { + SortCategory::VueCustomDirective + } + } + } + } else { + SortCategory::VueCustomDirective + } + } + AnyHtmlAttribute::AnyVueDirective(AnyVueDirective::VueVOnShorthandDirective(_)) => { + SortCategory::VueEvent + } + AnyHtmlAttribute::AnyVueDirective(AnyVueDirective::VueVSlotShorthandDirective(_)) => { + SortCategory::VueSlot + } + _ => SortCategory::Unknown, + } + } +} + +fn svelte_directive_value_token( + directive: &SvelteDirectiveValue, +) -> Option> { + match &directive.property().ok()? { + AnySvelteBindingProperty::SvelteLiteral(l) => l.value_token().ok(), + AnySvelteBindingProperty::SvelteName(n) => n.ident_token().ok(), + } +} + +fn vue_directive_arg_token(arg: &AnyVueDirectiveArgument) -> Option> { + match arg { + AnyVueDirectiveArgument::VueBogusDirectiveArgument(_) => None, + AnyVueDirectiveArgument::VueDynamicArgument(_) => None, + AnyVueDirectiveArgument::VueStaticArgument(arg) => arg.name_token().ok(), + } +} + +fn astro_directive_value_token( + directive: &AstroDirectiveValue, +) -> Option> { + directive.name().ok()?.value_token().ok() +} + +impl SortableAttribute for SortableHtmlAttribute { + type Language = HtmlLanguage; + + /// Returns the value of the attribute to be compared against another attribute from the same category. + fn name(&self) -> Option> { + match &self.0 { + AnyHtmlAttribute::HtmlAttribute(attr) => attr.name().ok()?.value_token().ok(), + AnyHtmlAttribute::HtmlAttributeSingleTextExpression(attr) => { + attr.expression().ok()?.html_literal_token().ok() + } + AnyHtmlAttribute::AnyAstroDirective(AnyAstroDirective::AstroClassDirective(dir)) => { + astro_directive_value_token(&dir.value().ok()?) + } + AnyHtmlAttribute::AnyAstroDirective(AnyAstroDirective::AstroClientDirective(dir)) => { + astro_directive_value_token(&dir.value().ok()?) + } + AnyHtmlAttribute::AnyAstroDirective(AnyAstroDirective::AstroDefineDirective(dir)) => { + astro_directive_value_token(&dir.value().ok()?) + } + AnyHtmlAttribute::AnyAstroDirective(AnyAstroDirective::AstroIsDirective(dir)) => { + astro_directive_value_token(&dir.value().ok()?) + } + AnyHtmlAttribute::AnyAstroDirective(AnyAstroDirective::AstroServerDirective(dir)) => { + astro_directive_value_token(&dir.value().ok()?) + } + AnyHtmlAttribute::AnyAstroDirective(AnyAstroDirective::AstroSetDirective(dir)) => { + astro_directive_value_token(&dir.value().ok()?) + } + AnyHtmlAttribute::AnySvelteDirective(AnySvelteDirective::SvelteStyleDirective(dir)) => { + svelte_directive_value_token(&dir.value().ok()?) + } + AnyHtmlAttribute::AnySvelteDirective(AnySvelteDirective::SvelteClassDirective(dir)) => { + svelte_directive_value_token(&dir.value().ok()?) + } + AnyHtmlAttribute::AnySvelteDirective(AnySvelteDirective::SvelteBindDirective(dir)) => { + svelte_directive_value_token(&dir.value().ok()?) + } + AnyHtmlAttribute::AnySvelteDirective(AnySvelteDirective::SvelteUseDirective(dir)) => { + svelte_directive_value_token(&dir.value().ok()?) + } + AnyHtmlAttribute::AnySvelteDirective( + AnySvelteDirective::SvelteTransitionDirective(dir), + ) => svelte_directive_value_token(&dir.value().ok()?), + AnyHtmlAttribute::AnySvelteDirective(AnySvelteDirective::SvelteInDirective(dir)) => { + svelte_directive_value_token(&dir.value().ok()?) + } + AnyHtmlAttribute::AnySvelteDirective(AnySvelteDirective::SvelteOutDirective(dir)) => { + svelte_directive_value_token(&dir.value().ok()?) + } + AnyHtmlAttribute::AnySvelteDirective(AnySvelteDirective::SvelteAnimateDirective( + dir, + )) => svelte_directive_value_token(&dir.value().ok()?), + AnyHtmlAttribute::SvelteAttachAttribute(_) => None, + AnyHtmlAttribute::AnyVueDirective(AnyVueDirective::VueDirective(dir)) => { + match dir.name_token().ok()?.text_trimmed() { + "v-on" | "v-bind" | "v-slot" => dir + .arg()? + .arg() + .ok() + .and_then(|arg| vue_directive_arg_token(&arg)), + _ => dir.name_token().ok(), + } + } + AnyHtmlAttribute::AnyVueDirective(AnyVueDirective::VueVBindShorthandDirective(dir)) => { + vue_directive_arg_token(&dir.arg().ok()?.arg().ok()?) + } + AnyHtmlAttribute::AnyVueDirective(AnyVueDirective::VueVSlotShorthandDirective(dir)) => { + vue_directive_arg_token(&dir.arg().ok()?) + } + AnyHtmlAttribute::AnyVueDirective(AnyVueDirective::VueVOnShorthandDirective(dir)) => { + vue_directive_arg_token(&dir.arg().ok()?) + } + _ => None, + } + } + + fn node(&self) -> &impl AstNode { + &self.0 + } + + fn replace_token( + self, + prev_token: SyntaxToken, + next_token: SyntaxToken, + ) -> Option + where + Self: Sized, + { + Some(Self( + self.0 + .replace_token_discard_trivia(prev_token, next_token)?, + )) + } +} + +fn compare_html_attributes( + a: &SortableHtmlAttribute, + b: &SortableHtmlAttribute, + comparator: fn(&SortableHtmlAttribute, &SortableHtmlAttribute) -> Ordering, +) -> Ordering { + // Sort by category first + if a.category() != b.category() { + return a.category().cmp(&b.category()); + } + + // If category is the same, sort according to comparator + comparator(a, b) +} + +fn ascii_nat_cmp(a: &SortableHtmlAttribute, b: &SortableHtmlAttribute) -> Ordering { + compare_html_attributes(a, b, SortableHtmlAttribute::ascii_nat_cmp) +} + +fn lexicographic_cmp(a: &SortableHtmlAttribute, b: &SortableHtmlAttribute) -> Ordering { + compare_html_attributes(a, b, SortableHtmlAttribute::lexicographic_cmp) +} + +fn get_comparator( + sort_order: SortOrder, +) -> fn(&SortableHtmlAttribute, &SortableHtmlAttribute) -> Ordering { + match sort_order { + SortOrder::Natural => ascii_nat_cmp, + SortOrder::Lexicographic => lexicographic_cmp, + } +} diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/astro/sorted.astro b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/astro/sorted.astro new file mode 100644 index 000000000000..36399e951b7f --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/astro/sorted.astro @@ -0,0 +1,28 @@ + + + diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/astro/sorted.astro.snap b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/astro/sorted.astro.snap new file mode 100644 index 000000000000..c59623cff1aa --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/astro/sorted.astro.snap @@ -0,0 +1,36 @@ +--- +source: crates/biome_html_analyze/tests/spec_tests.rs +expression: sorted.astro +--- +# Input +```astro + + + + +``` diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/astro/unsorted.astro b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/astro/unsorted.astro new file mode 100644 index 000000000000..7c7e38233b01 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/astro/unsorted.astro @@ -0,0 +1,28 @@ + + + diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/astro/unsorted.astro.snap b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/astro/unsorted.astro.snap new file mode 100644 index 000000000000..58f5adda9f04 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/astro/unsorted.astro.snap @@ -0,0 +1,160 @@ +--- +source: crates/biome_html_analyze/tests/spec_tests.rs +expression: unsorted.astro +--- +# Input +```astro + + + + +``` + +# Diagnostics +``` +unsorted.astro:1:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + > 1 │ 2 │ client:load + > 3 │ class:list={classes} + > 4 │ set:text={text} + ... + > 12 │ id="myid" + > 13 │ /> + │ ^^ + 14 │ + 15 │ + 14 14 │ + + +``` + +``` +unsorted.astro:15:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 13 │ /> + 14 │ + > 15 │ 16 │ client:load + ... + > 26 │ server:defer + > 27 │ id="myid" + > 28 │ /> + │ ^^ + 29 │ + + i Safe fix: Sort the HTML attributes. + + 14 14 │ + 15 15 │ + 14 │ + > 15 │ 16 │ client:load + ... + > 26 │ server:defer + > 27 │ id="myid" + > 28 │ /> + │ ^^ + 29 │ + + i Safe fix: Sort the HTML attributes. + + 19 19 │ is:raw + 20 20 │ {...props} + 21 │ - ··spellcheck="true" + 22 │ - ··tabindex="-1" + 23 │ - ··dir="auto" + 24 │ - ··define:vars={vars} + 25 │ - ··set:html={rawHTMLString} + 21 │ + ··dir="auto" + 22 │ + ··id="myid" + 23 │ + ··spellcheck="true" + 24 │ + ··tabindex="-1" + 25 │ + ··define:vars={vars} + 26 26 │ server:defer + 27 │ - ··id="myid" + 27 │ + ··set:html={rawHTMLString} + 28 28 │ /> + 29 29 │ + + +``` diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/sorted-lexicographic.html b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/sorted-lexicographic.html new file mode 100644 index 000000000000..65d870142ee5 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/sorted-lexicographic.html @@ -0,0 +1,4 @@ + + + diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/sorted-lexicographic.html.snap b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/sorted-lexicographic.html.snap new file mode 100644 index 000000000000..1a379ad3b482 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/sorted-lexicographic.html.snap @@ -0,0 +1,12 @@ +--- +source: crates/biome_html_analyze/tests/spec_tests.rs +expression: sorted-lexicographic.html +--- +# Input +```html + + + + +``` diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/sorted-lexicographic.options.json b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/sorted-lexicographic.options.json new file mode 100644 index 000000000000..6c8dd47ea560 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/sorted-lexicographic.options.json @@ -0,0 +1,15 @@ +{ + "$schema": "../../../../../../../packages/@biomejs/biome/configuration_schema.json", + "assist": { + "actions": { + "source": { + "useSortedAttributes": { + "level": "on", + "options": { + "sortOrder": "lexicographic" + } + } + } + } + } +} diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/sorted.html b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/sorted.html new file mode 100644 index 000000000000..2529d4e38fcf --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/sorted.html @@ -0,0 +1,8 @@ + + + + + + + diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/sorted.html.snap b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/sorted.html.snap new file mode 100644 index 000000000000..59f0fe8acea8 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/sorted.html.snap @@ -0,0 +1,16 @@ +--- +source: crates/biome_html_analyze/tests/spec_tests.rs +expression: sorted.html +--- +# Input +```html + + + + + + + + +``` diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/unsorted-lexicographic.html b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/unsorted-lexicographic.html new file mode 100644 index 000000000000..1f9fcfbd9bb6 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/unsorted-lexicographic.html @@ -0,0 +1,4 @@ + + + diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/unsorted-lexicographic.html.snap b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/unsorted-lexicographic.html.snap new file mode 100644 index 000000000000..77fdf96cdeed --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/unsorted-lexicographic.html.snap @@ -0,0 +1,59 @@ +--- +source: crates/biome_html_analyze/tests/spec_tests.rs +expression: unsorted-lexicographic.html +--- +# Input +```html + + + + +``` + +# Diagnostics +``` +unsorted-lexicographic.html:2:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 1 │ + > 2 │ + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 3 │ + + i Safe fix: Sort the HTML attributes. + + 1 1 │ + 2 │ - + 2 │ + + 3 3 │ + + +``` + +``` +unsorted-lexicographic.html:3:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 1 │ + 2 │ + > 3 │ + 5 │ + + i Safe fix: Sort the HTML attributes. + + 1 1 │ + 2 2 │ + 3 │ - + 3 │ + + 4 4 │ Hello, world! + 5 5 │ + + +``` diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/unsorted-lexicographic.options.json b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/unsorted-lexicographic.options.json new file mode 100644 index 000000000000..6c8dd47ea560 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/unsorted-lexicographic.options.json @@ -0,0 +1,15 @@ +{ + "$schema": "../../../../../../../packages/@biomejs/biome/configuration_schema.json", + "assist": { + "actions": { + "source": { + "useSortedAttributes": { + "level": "on", + "options": { + "sortOrder": "lexicographic" + } + } + } + } + } +} diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/unsorted.html b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/unsorted.html new file mode 100644 index 000000000000..fdcf951acf42 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/unsorted.html @@ -0,0 +1,8 @@ + + + + + + + diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/unsorted.html.snap b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/unsorted.html.snap new file mode 100644 index 000000000000..8663781deb76 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/html/unsorted.html.snap @@ -0,0 +1,109 @@ +--- +source: crates/biome_html_analyze/tests/spec_tests.rs +expression: unsorted.html +--- +# Input +```html + + + + + + + + +``` + +# Diagnostics +``` +unsorted.html:2:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 1 │ + > 2 │ + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 3 │ + + i Safe fix: Sort the HTML attributes. + + 1 1 │ + 2 │ - + 2 │ + + 3 3 │ + + +``` + +``` +unsorted.html:3:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 1 │ + 2 │ + > 3 │ + 5 │ + + i Safe fix: Sort the HTML attributes. + + 1 1 │ + 2 2 │ + 3 │ - + 3 │ + + 4 4 │ Hello, world! + 5 5 │ + + +``` + +``` +unsorted.html:6:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 4 │ Hello, world! + 5 │ + > 6 │ + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 7 │ + 8 │ + + i Safe fix: Sort the HTML attributes. + + 4 4 │ Hello, world! + 5 5 │ + 6 │ - + 6 │ + + 7 7 │ + 8 8 │ + + +``` + +``` +unsorted.html:8:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 6 │ + 7 │ + > 8 │ + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 9 │ + + i Safe fix: Sort the HTML attributes. + + 6 6 │ + 7 7 │ + 8 │ - + 8 │ + + 9 9 │ + + +``` diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/svelte/sorted.svelte b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/svelte/sorted.svelte new file mode 100644 index 000000000000..9d64080b5ec2 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/svelte/sorted.svelte @@ -0,0 +1,56 @@ + + +
some text
+ +some alt text + +
...
+ +
...
diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/svelte/sorted.svelte.snap b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/svelte/sorted.svelte.snap new file mode 100644 index 000000000000..c760a5881e03 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/svelte/sorted.svelte.snap @@ -0,0 +1,64 @@ +--- +source: crates/biome_html_analyze/tests/spec_tests.rs +expression: sorted.svelte +--- +# Input +```svelte + + +
some text
+ +some alt text + +
...
+ +
...
+ +``` diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/svelte/unsorted.svelte b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/svelte/unsorted.svelte new file mode 100644 index 000000000000..44994a937910 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/svelte/unsorted.svelte @@ -0,0 +1,56 @@ + + +
some text
+ +some alt text + +
...
+ +
...
diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/svelte/unsorted.svelte.snap b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/svelte/unsorted.svelte.snap new file mode 100644 index 000000000000..ff742a2ab0f8 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/svelte/unsorted.svelte.snap @@ -0,0 +1,321 @@ +--- +source: crates/biome_html_analyze/tests/spec_tests.rs +expression: unsorted.svelte +--- +# Input +```svelte + + +
some text
+ +some alt text + +
...
+ +
...
+ +``` + +# Diagnostics +``` +unsorted.svelte:1:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + > 1 │ + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + 3 │
some text
+ + i Safe fix: Sort the HTML attributes. + + 1 │ - + 1 │ + + 2 2 │ + 3 3 │
some text
+ + +``` + +``` +unsorted.svelte:2:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 1 │ + > 2 │ + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 3 │
some text
+ 4 │ + + i Safe fix: Sort the HTML attributes. + + 1 1 │ + 2 │ - + 2 │ + + 3 3 │
some text
+ 4 4 │ + + +``` + +``` +unsorted.svelte:3:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 1 │ + 2 │ + > 3 │
some text
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 4 │ + 5 │ some alt text + + i Safe fix: Sort the HTML attributes. + + 1 1 │ + 2 2 │ + 3 │ - some·text + 3 │ + some·text + 4 4 │ + 5 5 │ some alt text + + +``` + +``` +unsorted.svelte:5:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 3 │
some text
+ 4 │ + > 5 │ some alt text + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 6 │ + 7 │
some text
+ 4 4 │ + 5 │ - + 5 │ + + 6 6 │ + 7 7 │
+ 6 │ + > 7 │
8 │ bind:value2={a} + ... + > 29 │ style:color="red" + > 30 │ >...
+ │ ^ + 31 │ + 32 │
...
+ 31 31 │ + + +``` + +``` +unsorted.svelte:32:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 30 │ >...
+ 31 │ + > 32 │
33 │ bind:value2={a} + ... + > 54 │ animate:flip + > 55 │ style:color="red" + > 56 │ >...
+ │ ^ + 57 │ + + i Safe fix: Sort the HTML attributes. + + 42 42 │ {@attach myAttachment} + 43 43 │ {...props} + 44 │ - ····spellcheck="true" + 45 │ - ····in:fly={{·y:·200·}} + 46 │ - ····tabindex="-1" + 47 │ - ····dir="auto" + 48 │ - ····class:cool={cool} + 49 │ - ····{foo} + 50 │ - ····class:myClass={foo} + 51 │ - ····animate:whizz + 52 │ - ····bind:this={canvas} + 53 │ - ····transition:fly={{·y:·200,·duration:·2000·}} + 44 │ + ····dir="auto" + 45 │ + ····{foo} + 46 │ + ····spellcheck="true" + 47 │ + ····tabindex="-1" + 48 │ + ····bind:this={canvas} + 49 │ + ····style:color="red" + 50 │ + ····class:cool={cool} + 51 │ + ····class:myClass={foo} + 52 │ + ····transition:fly={{·y:·200,·duration:·2000·}} + 53 │ + ····in:fly={{·y:·200·}} + 54 54 │ animate:flip + 55 │ - ····style:color="red" + 55 │ + ····animate:whizz + 56 56 │ >... + 57 57 │ + + +``` + +``` +unsorted.svelte:32:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 30 │ >... + 31 │ + > 32 │
33 │ bind:value2={a} + ... + > 54 │ animate:flip + > 55 │ style:color="red" + > 56 │ >...
+ │ ^ + 57 │ + + i Safe fix: Sort the HTML attributes. + + 31 31 │ + 32 32 │

+ +
+ + +
+ + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/vue/sorted.vue.snap b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/vue/sorted.vue.snap new file mode 100644 index 000000000000..89e047800b24 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/vue/sorted.vue.snap @@ -0,0 +1,71 @@ +--- +source: crates/biome_html_analyze/tests/spec_tests.rs +expression: sorted.vue +--- +# Input +```vue +

+ +
+ + +
+ + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +``` diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/vue/unsorted.vue b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/vue/unsorted.vue new file mode 100644 index 000000000000..7e817071aec9 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/vue/unsorted.vue @@ -0,0 +1,63 @@ +

+ +
+ + +
+ + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
diff --git a/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/vue/unsorted.vue.snap b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/vue/unsorted.vue.snap new file mode 100644 index 000000000000..71b30efeb9c3 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/source/useSortedAttributes/vue/unsorted.vue.snap @@ -0,0 +1,472 @@ +--- +source: crates/biome_html_analyze/tests/spec_tests.rs +expression: unsorted.vue +--- +# Input +```vue +

+ +
+ + +
+ + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +``` + +# Diagnostics +``` +unsorted.vue:1:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + > 1 │

+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + 3 │

+ 1 │ +

+ 2 2 │ + 3 3 │

+ 2 │ + > 3 │ 4 │ v-slot:default + ... + > 19 │ :class="{ red: isRed }" + > 20 │ >
+ │ ^ + 21 │ + 22 │ + + i Safe fix: Sort the HTML attributes. + + 2 2 │ + 3 3 │ + 21 21 │ + + +``` + +``` +unsorted.vue:23:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 22 │ + > 23 │ 24 │ v-slot:default + ... + > 40 │ :class="{ red: isRed }" + > 41 │ > + │ ^ + 42 │ + 43 │ + + i Safe fix: Sort the HTML attributes. + + 22 22 │ + 23 23 │ + > 23 │ 24 │ v-slot:default + ... + > 40 │ :class="{ red: isRed }" + > 41 │ > + │ ^ + 42 │ + 43 │ + + i Safe fix: Sort the HTML attributes. + + 27 27 │ ref="my-ref" + 28 28 │ v-bind="{ id: someProp, 'other-attr': otherProp }" + 29 │ - ··:key="item.id" + 30 │ - ··v-bind:src="'/path/to/images/'·+·fileName" + 31 │ - ··spellcheck="true" + 32 │ - ··v-for="item·in·items" + 33 │ - ··tabindex="-1" + 34 │ - ··@scroll.passive="onScroll" + 35 │ - ··:is="tabs[currentTab]" + 36 │ - ··v-text="msg" + 37 │ - ··dir="auto" + 38 │ - ··v-model="countModel" + 39 │ - ··v-if="awesome" + 40 │ - ··:class="{·red:·isRed·}" + 29 │ + ··dir="auto" + 30 │ + ··spellcheck="true" + 31 │ + ··tabindex="-1" + 32 │ + ··:is="tabs[currentTab]" + 33 │ + ··v-for="item·in·items" + 34 │ + ··v-if="awesome" + 35 │ + ··:key="item.id" + 36 │ + ··v-model="countModel" + 37 │ + ··:class="{·red:·isRed·}" + 38 │ + ··v-bind:src="'/path/to/images/'·+·fileName" + 39 │ + ··@scroll.passive="onScroll" + 40 │ + ··v-text="msg" + 41 41 │ > + 42 42 │ + + +``` + +``` +unsorted.vue:44:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 43 │ + > 44 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 45 │
+ 46 │ + + i Safe fix: Sort the HTML attributes. + + 42 42 │ + 43 43 │ + 44 │ - + 44 │ + + 45 45 │ + 46 46 │ + + +``` + +``` +unsorted.vue:47:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 45 │ + 46 │ + > 47 │
+ │ ^^^^^^^^^^^^^^^^^^ + 48 │ + 49 │
+ + i Safe fix: Sort the HTML attributes. + + 45 45 │ + 46 46 │ + 47 │ - + 47 │ + + 48 48 │ + 49 49 │
+ + +``` + +``` +unsorted.vue:49:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 47 │
+ 48 │ + > 49 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 50 │ + 51 │
+ + i Safe fix: Sort the HTML attributes. + + 47 47 │
+ 48 48 │ + 49 │ - + 49 │ + + 50 50 │ + 51 51 │
+ + +``` + +``` +unsorted.vue:51:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 49 │
+ 50 │ + > 51 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 52 │ + 53 │
+ + i Safe fix: Sort the HTML attributes. + + 49 49 │
+ 50 50 │ + 51 │ - + 51 │ + + 52 52 │ + 53 53 │
+ + +``` + +``` +unsorted.vue:53:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 51 │
+ 52 │ + > 53 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 54 │ + 55 │
+ + i Safe fix: Sort the HTML attributes. + + 51 51 │
+ 52 52 │ + 53 │ - + 53 │ + + 54 54 │ + 55 55 │
+ + +``` + +``` +unsorted.vue:55:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 53 │
+ 54 │ + > 55 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 56 │ + 57 │
+ + i Safe fix: Sort the HTML attributes. + + 53 53 │
+ 54 54 │ + 55 │ - + 55 │ + + 56 56 │ + 57 57 │
+ + +``` + +``` +unsorted.vue:57:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 55 │
+ 56 │ + > 57 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 58 │ + 59 │
+ + i Safe fix: Sort the HTML attributes. + + 55 55 │
+ 56 56 │ + 57 │ - + 57 │ + + 58 58 │ + 59 59 │
+ + +``` + +``` +unsorted.vue:59:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 57 │
+ 58 │ + > 59 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 60 │ + 61 │
+ + i Safe fix: Sort the HTML attributes. + + 57 57 │
+ 58 58 │ + 59 │ - + 59 │ + + 60 60 │ + 61 61 │
+ + +``` + +``` +unsorted.vue:61:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 59 │
+ 60 │ + > 61 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 62 │ + 63 │
+ + i Safe fix: Sort the HTML attributes. + + 59 59 │
+ 60 60 │ + 61 │ - + 61 │ + + 62 62 │ + 63 63 │
+ + +``` + +``` +unsorted.vue:63:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 61 │
+ 62 │ + > 63 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 64 │ + + i Safe fix: Sort the HTML attributes. + + 61 61 │
+ 62 62 │ + 63 │ - + 63 │ + + 64 64 │ + + +``` diff --git a/crates/biome_js_analyze/src/assist/source/use_sorted_attributes.rs b/crates/biome_js_analyze/src/assist/source/use_sorted_attributes.rs index f81f0c984e60..781d50fd23e1 100644 --- a/crates/biome_js_analyze/src/assist/source/use_sorted_attributes.rs +++ b/crates/biome_js_analyze/src/assist/source/use_sorted_attributes.rs @@ -1,5 +1,6 @@ use std::{borrow::Cow, cmp::Ordering, iter::zip}; +use biome_analyze::shared::sort_attributes::{AttributeGroup, SortableAttribute}; use biome_analyze::{ Ast, FixKind, Rule, RuleAction, RuleDiagnostic, RuleSource, context::RuleContext, declare_source_rule, @@ -8,11 +9,11 @@ use biome_console::markup; use biome_deserialize::TextRange; use biome_diagnostics::{Applicability, category}; use biome_js_syntax::{ - AnyJsxAttribute, JsxAttribute, JsxAttributeList, JsxOpeningElement, JsxSelfClosingElement, + AnyJsxAttribute, JsLanguage, JsxAttribute, JsxAttributeList, JsxOpeningElement, + JsxSelfClosingElement, }; -use biome_rowan::{AstNode, BatchMutationExt}; +use biome_rowan::{AstNode, AstNodeExt, BatchMutationExt, SyntaxToken}; use biome_rule_options::use_sorted_attributes::{SortOrder, UseSortedAttributesOptions}; -use biome_string_case::StrLikeExtension; use crate::JsRuleAction; @@ -82,30 +83,31 @@ declare_source_rule! { impl Rule for UseSortedAttributes { type Query = Ast; - type State = PropGroup; + type State = AttributeGroup; type Signals = Box<[Self::State]>; type Options = UseSortedAttributesOptions; fn run(ctx: &RuleContext) -> Self::Signals { let props = ctx.query(); - let mut current_prop_group = PropGroup::default(); + let mut current_prop_group = AttributeGroup::default(); let mut prop_groups = Vec::new(); let options = ctx.options(); let sort_by = options.sort_order.unwrap_or_default(); let comparator = match sort_by { - SortOrder::Natural => PropElement::ascii_nat_cmp, - SortOrder::Lexicographic => PropElement::lexicographic_cmp, + SortOrder::Natural => SortableJsxAttribute::ascii_nat_cmp, + SortOrder::Lexicographic => SortableJsxAttribute::lexicographic_cmp, }; // Convert to boolean-based comparator for is_sorted_by - let boolean_comparator = - |a: &PropElement, b: &PropElement| comparator(a, b) != Ordering::Greater; + let boolean_comparator = |a: &SortableJsxAttribute, b: &SortableJsxAttribute| { + comparator(a, b) != Ordering::Greater + }; for prop in props { match prop { AnyJsxAttribute::JsxAttribute(attr) => { - current_prop_group.props.push(PropElement { prop: attr }); + current_prop_group.attrs.push(SortableJsxAttribute(attr)); } // spread prop reset sort order AnyJsxAttribute::JsxSpreadAttribute(_) => { @@ -113,7 +115,7 @@ impl Rule for UseSortedAttributes { && !current_prop_group.is_sorted(boolean_comparator) { prop_groups.push(current_prop_group); - current_prop_group = PropGroup::default(); + current_prop_group = AttributeGroup::default(); } else { // Reuse the same buffer current_prop_group.clear(); @@ -152,14 +154,14 @@ impl Rule for UseSortedAttributes { let sort_by = options.sort_order.unwrap_or_default(); let comparator = match sort_by { - SortOrder::Natural => PropElement::ascii_nat_cmp, - SortOrder::Lexicographic => PropElement::lexicographic_cmp, + SortOrder::Natural => SortableJsxAttribute::ascii_nat_cmp, + SortOrder::Lexicographic => SortableJsxAttribute::lexicographic_cmp, }; - for (PropElement { prop }, PropElement { prop: sorted_prop }) in - zip(state.props.iter(), state.get_sorted_props(comparator)) + for (SortableJsxAttribute(attr), SortableJsxAttribute(sorted_attr)) in + zip(state.attrs.iter(), state.get_sorted_attributes(comparator)?) { - mutation.replace_node_discard_trivia(prop.clone(), sorted_prop); + mutation.replace_node_discard_trivia(attr.clone(), sorted_attr); } Some(RuleAction::new( @@ -172,65 +174,30 @@ impl Rule for UseSortedAttributes { } #[derive(PartialEq, Eq, Clone)] -pub struct PropElement { - prop: JsxAttribute, -} +pub struct SortableJsxAttribute(JsxAttribute); -impl PropElement { - pub fn ascii_nat_cmp(&self, other: &Self) -> Ordering { - let (Ok(self_name), Ok(other_name)) = (self.prop.name(), other.prop.name()) else { - return Ordering::Equal; - }; - let (Ok(self_name), Ok(other_name)) = (self_name.name(), other_name.name()) else { - return Ordering::Equal; - }; +impl SortableAttribute for SortableJsxAttribute { + type Language = JsLanguage; - self_name - .text_trimmed() - .ascii_nat_cmp(other_name.text_trimmed()) + fn name(&self) -> Option> { + self.0.name().ok()?.name_token().ok() } - pub fn lexicographic_cmp(&self, other: &Self) -> Ordering { - let (Ok(self_name), Ok(other_name)) = (self.prop.name(), other.prop.name()) else { - return Ordering::Equal; - }; - let (Ok(self_name), Ok(other_name)) = (self_name.name(), other_name.name()) else { - return Ordering::Equal; - }; - - self_name - .text_trimmed() - .lexicographic_cmp(other_name.text_trimmed()) + fn node(&self) -> &impl AstNode { + &self.0 } -} - -#[derive(Clone, Default)] -pub struct PropGroup { - props: Vec, -} -impl PropGroup { - fn is_empty(&self) -> bool { - self.props.is_empty() - } - - fn is_sorted(&self, comparator: F) -> bool + fn replace_token( + self, + prev_token: SyntaxToken, + next_token: SyntaxToken, + ) -> Option where - F: Fn(&PropElement, &PropElement) -> bool, + Self: Sized, { - self.props.is_sorted_by(comparator) - } - - fn get_sorted_props(&self, comparator: F) -> Vec - where - F: FnMut(&PropElement, &PropElement) -> Ordering, - { - let mut new_props = self.props.clone(); - new_props.sort_by(comparator); - new_props - } - - fn clear(&mut self) { - self.props.clear(); + Some(Self( + self.0 + .replace_token_discard_trivia(prev_token, next_token)?, + )) } } diff --git a/crates/biome_js_analyze/tests/specs/source/useSortedAttributes/unsorted.jsx b/crates/biome_js_analyze/tests/specs/source/useSortedAttributes/unsorted.jsx index 51cb93063441..fe3021c594bc 100644 --- a/crates/biome_js_analyze/tests/specs/source/useSortedAttributes/unsorted.jsx +++ b/crates/biome_js_analyze/tests/specs/source/useSortedAttributes/unsorted.jsx @@ -4,3 +4,10 @@ />; ; ; + +{/* ; */} + +; + +{/* ; */} + diff --git a/crates/biome_js_analyze/tests/specs/source/useSortedAttributes/unsorted.jsx.snap b/crates/biome_js_analyze/tests/specs/source/useSortedAttributes/unsorted.jsx.snap index c28055390519..0601de0b3577 100644 --- a/crates/biome_js_analyze/tests/specs/source/useSortedAttributes/unsorted.jsx.snap +++ b/crates/biome_js_analyze/tests/specs/source/useSortedAttributes/unsorted.jsx.snap @@ -11,6 +11,13 @@ expression: unsorted.jsx ; ; +{/* ; */} + +; + +{/* ; */} + + ``` # Diagnostics @@ -30,13 +37,13 @@ unsorted.jsx:1:1 assist/source/useSortedAttributes FIXABLE ━━━━━━ i Safe fix: Sort the JSX props. - 1 1 │ ; - 5 5 │ ; + 1 1 │ ; + 5 5 │ ; ``` @@ -55,12 +62,12 @@ unsorted.jsx:5:1 assist/source/useSortedAttributes FIXABLE ━━━━━━ i Safe fix: Sort the JSX props. - 3 3 │ firstName="John" - 4 4 │ />; - 5 │ - ; - 5 │ + ; - 6 6 │ ; - 7 7 │ + 3 3 │ firstName="John" + 4 4 │ />; + 5 │ - ; + 5 │ + ; + 6 6 │ ; + 7 7 │ ``` @@ -79,12 +86,12 @@ unsorted.jsx:5:1 assist/source/useSortedAttributes FIXABLE ━━━━━━ i Safe fix: Sort the JSX props. - 3 3 │ firstName="John" - 4 4 │ />; - 5 │ - ; - 5 │ + ; - 6 6 │ ; - 7 7 │ + 3 3 │ firstName="John" + 4 4 │ />; + 5 │ - ; + 5 │ + ; + 6 6 │ ; + 7 7 │ ``` @@ -99,14 +106,40 @@ unsorted.jsx:6:1 assist/source/useSortedAttributes FIXABLE ━━━━━━ > 6 │ ; │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 7 │ + 8 │ {/* ; */} + + i Safe fix: Sort the JSX props. + + 4 4 │ />; + 5 5 │ ; + 6 │ - ; + 6 │ + ; + 7 7 │ + 8 8 │ {/* ; */} + + +``` + +``` +unsorted.jsx:10:1 assist/source/useSortedAttributes FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i The attributes are not sorted. + + 8 │ {/* ; */} + 9 │ + > 10 │ ; + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 11 │ + 12 │ {/* ; */} i Safe fix: Sort the JSX props. - 4 4 │ />; - 5 5 │ ; - 6 │ - ; - 6 │ + ; - 7 7 │ + 8 8 │ {/* ; */} + 9 9 │ + 10 │ - ; + 10 │ + ; + 11 11 │ + 12 12 │ {/* ; */} ``` diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index 8a75b3572760..d19e56613753 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -1029,7 +1029,7 @@ See https://biomejs.dev/assist/actions/organize-imports */ recommended?: boolean; /** - * Enforce attribute sorting in JSX elements. + * Enforce attribute sorting in HTML elements. See https://biomejs.dev/assist/actions/use-sorted-attributes */ useSortedAttributes?: UseSortedAttributesConfiguration; diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index 9637094a68a2..38dc9155aae3 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -11946,7 +11946,7 @@ "type": ["boolean", "null"] }, "useSortedAttributes": { - "description": "Enforce attribute sorting in JSX elements.\nSee https://biomejs.dev/assist/actions/use-sorted-attributes", + "description": "Enforce attribute sorting in HTML elements.\nSee https://biomejs.dev/assist/actions/use-sorted-attributes", "anyOf": [ { "$ref": "#/$defs/UseSortedAttributesConfiguration" }, { "type": "null" }