diff --git a/crates/biome_configuration/src/analyzer/assists/actions.rs b/crates/biome_configuration/src/analyzer/assists/actions.rs index 81c2a267d850..196c0068d891 100644 --- a/crates/biome_configuration/src/analyzer/assists/actions.rs +++ b/crates/biome_configuration/src/analyzer/assists/actions.rs @@ -99,29 +99,42 @@ impl Actions { #[serde(rename_all = "camelCase", default, deny_unknown_fields)] #[doc = r" A list of rules that belong to this group"] pub struct Source { + #[doc = "Enforce props sorting in JSX elements."] + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_jsx_props: Option, #[doc = "Sorts the keys of a JSON object in natural order"] #[serde(skip_serializing_if = "Option::is_none")] pub use_sorted_keys: Option, } impl Source { const GROUP_NAME: &'static str = "source"; - pub(crate) const GROUP_RULES: &'static [&'static str] = &["useSortedKeys"]; + pub(crate) const GROUP_RULES: &'static [&'static str] = &["sortJsxProps", "useSortedKeys"]; pub(crate) fn get_enabled_rules(&self) -> FxHashSet> { let mut index_set = FxHashSet::default(); - if let Some(rule) = self.use_sorted_keys.as_ref() { + if let Some(rule) = self.sort_jsx_props.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0])); } } + if let Some(rule) = self.use_sorted_keys.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1])); + } + } index_set } pub(crate) fn get_disabled_rules(&self) -> FxHashSet> { let mut index_set = FxHashSet::default(); - if let Some(rule) = self.use_sorted_keys.as_ref() { + if let Some(rule) = self.sort_jsx_props.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0])); } } + if let Some(rule) = self.use_sorted_keys.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -133,6 +146,7 @@ impl Source { rule_name: &str, ) -> Option { match rule_name { + "sortJsxProps" => self.sort_jsx_props.as_ref().copied(), "useSortedKeys" => self.use_sorted_keys.as_ref().copied(), _ => None, } diff --git a/crates/biome_js_analyze/src/assists/source.rs b/crates/biome_js_analyze/src/assists/source.rs index d8474b4d8169..bcd993b8f8fa 100644 --- a/crates/biome_js_analyze/src/assists/source.rs +++ b/crates/biome_js_analyze/src/assists/source.rs @@ -3,12 +3,14 @@ use biome_analyze::declare_assists_group; pub mod organize_imports; +pub mod sort_jsx_props; declare_assists_group! { pub Source { name : "source" , rules : [ self :: organize_imports :: OrganizeImports , + self :: sort_jsx_props :: SortJsxProps , ] } } diff --git a/crates/biome_js_analyze/src/assists/source/sort_jsx_props.rs b/crates/biome_js_analyze/src/assists/source/sort_jsx_props.rs new file mode 100644 index 000000000000..dc93b7e98987 --- /dev/null +++ b/crates/biome_js_analyze/src/assists/source/sort_jsx_props.rs @@ -0,0 +1,138 @@ +use std::{borrow::Cow, cmp::Ordering, iter::zip}; + +use biome_analyze::{ + context::RuleContext, declare_source_rule, ActionCategory, Ast, Rule, RuleAction, RuleSource, + RuleSourceKind, SourceActionKind, +}; +use biome_console::markup; +use biome_diagnostics::Applicability; +use biome_js_syntax::{AnyJsxAttribute, JsxAttribute, JsxAttributeList}; +use biome_rowan::{AstNode, BatchMutationExt}; + +use crate::JsRuleAction; + +declare_source_rule! { + /// Enforce props sorting in JSX elements. + /// + /// This rule checks if the JSX props are sorted in a consistent way. + /// Props are sorted alphabetically. + /// This rule will not consider spread props as sortable. + /// Instead, whenever it encounters a spread prop, it will sort all the + /// previous non spread props up until the nearest spread prop, if one + /// exist. + /// This prevents breaking the override of certain props using spread + /// props. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```js,expect_diagnostic + /// ; + /// ``` + /// + /// ### Valid + /// + /// ```js + /// ; + /// ; + /// ; + /// ``` + /// + pub SortJsxProps { + version: "next", + name: "sortJsxProps", + language: "js", + recommended: false, + sources: &[RuleSource::EslintReact("jsx-sort-props")], + source_kind: RuleSourceKind::SameLogic, + } +} + +impl Rule for SortJsxProps { + type Query = Ast; + type State = PropGroup; + type Signals = Vec; + type Options = (); + + fn run(ctx: &RuleContext) -> Self::Signals { + let props = ctx.query().clone(); + let mut current_prop_group = PropGroup::default(); + let mut prop_groups = Vec::new(); + for prop in props.clone() { + match prop { + AnyJsxAttribute::JsxAttribute(attr) => { + current_prop_group.props.push(PropElement { prop: attr }); + } + // spread prop reset sort order + AnyJsxAttribute::JsxSpreadAttribute(_) => { + prop_groups.push(current_prop_group); + current_prop_group = PropGroup::default(); + } + } + } + prop_groups.push(current_prop_group); + prop_groups + } + + fn action(ctx: &RuleContext, state: &Self::State) -> Option { + if state.is_sorted() { + return None; + } + let mut mutation = ctx.root().begin(); + + for (PropElement { prop }, PropElement { prop: sorted_prop }) in + zip(state.props.clone(), state.get_sorted_props()) + { + mutation.replace_node(prop, sorted_prop); + } + + Some(RuleAction::new( + rule_action_category!(), + Applicability::Always, + markup! { "Sort the JSX props." }, + mutation, + )) + } +} + +#[derive(PartialEq, Eq, Clone)] +pub struct PropElement { + prop: JsxAttribute, +} + +impl Ord for PropElement { + fn cmp(&self, other: &Self) -> Ordering { + let (Ok(self_name), Ok(other_name)) = (self.prop.name(), other.prop.name()) else { + return Ordering::Equal; + }; + let (a_name, b_name) = (self_name.text(), other_name.text()); + + a_name.cmp(&b_name) + } +} + +impl PartialOrd for PropElement { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[derive(Clone, Default)] +pub struct PropGroup { + props: Vec, +} + +impl PropGroup { + fn is_sorted(&self) -> bool { + let mut new_props = self.props.clone(); + new_props.sort(); + new_props == self.props + } + + fn get_sorted_props(&self) -> Vec { + let mut new_props = self.props.clone(); + new_props.sort(); + new_props + } +} diff --git a/crates/biome_js_analyze/src/options.rs b/crates/biome_js_analyze/src/options.rs index ec27bf46b6bd..a9df1277280c 100644 --- a/crates/biome_js_analyze/src/options.rs +++ b/crates/biome_js_analyze/src/options.rs @@ -256,6 +256,8 @@ pub type NoYodaExpression = ::Options; pub type OrganizeImports = ::Options; +pub type SortJsxProps = + ::Options; pub type UseAdjacentOverloadSignatures = < lint :: nursery :: use_adjacent_overload_signatures :: UseAdjacentOverloadSignatures as biome_analyze :: Rule > :: Options ; pub type UseAltText = ::Options; pub type UseAnchorContent = diff --git a/crates/biome_js_analyze/tests/specs/source/sortJsxProps/sorted.jsx b/crates/biome_js_analyze/tests/specs/source/sortJsxProps/sorted.jsx new file mode 100644 index 000000000000..dbf72185650e --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/source/sortJsxProps/sorted.jsx @@ -0,0 +1,3 @@ +; +; + diff --git a/crates/biome_js_analyze/tests/specs/source/sortJsxProps/sorted.jsx.snap b/crates/biome_js_analyze/tests/specs/source/sortJsxProps/sorted.jsx.snap new file mode 100644 index 000000000000..5c46497dc87b --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/source/sortJsxProps/sorted.jsx.snap @@ -0,0 +1,11 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: sorted.jsx +--- +# Input +```jsx +; +; + + +``` diff --git a/crates/biome_js_analyze/tests/specs/source/sortJsxProps/unsorted.jsx b/crates/biome_js_analyze/tests/specs/source/sortJsxProps/unsorted.jsx new file mode 100644 index 000000000000..0abf6a20f3c3 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/source/sortJsxProps/unsorted.jsx @@ -0,0 +1,2 @@ +; +; diff --git a/crates/biome_js_analyze/tests/specs/source/sortJsxProps/unsorted.jsx.snap b/crates/biome_js_analyze/tests/specs/source/sortJsxProps/unsorted.jsx.snap new file mode 100644 index 000000000000..335cfe7108cd --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/source/sortJsxProps/unsorted.jsx.snap @@ -0,0 +1,35 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: unsorted.jsx +--- +# Input +```jsx +; +; + +``` + +# Actions +```diff +@@ -1,2 +1,2 @@ +-; ++; + ; + +``` + +```diff +@@ -1,2 +1,2 @@ + ; +-; ++; + +``` + +```diff +@@ -1,2 +1,2 @@ + ; +-; ++; + +``` diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index e22361ba1b93..ddcd176aa02f 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -661,6 +661,10 @@ export type VcsClientKind = "git"; * A list of rules that belong to this group */ export interface Source { + /** + * Enforce props sorting in JSX elements. + */ + sortJsxProps?: RuleAssistConfiguration; /** * Sorts the keys of a JSON object in natural order */ diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index 3768bb955b22..5820c14b705f 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -2890,6 +2890,13 @@ "description": "A list of rules that belong to this group", "type": "object", "properties": { + "sortJsxProps": { + "description": "Enforce props sorting in JSX elements.", + "anyOf": [ + { "$ref": "#/definitions/RuleAssistConfiguration" }, + { "type": "null" } + ] + }, "useSortedKeys": { "description": "Sorts the keys of a JSON object in natural order", "anyOf": [