diff --git a/crates/biome_configuration/src/linter/rules.rs b/crates/biome_configuration/src/linter/rules.rs index 450b0ba852e5..3a82d3ec078d 100644 --- a/crates/biome_configuration/src/linter/rules.rs +++ b/crates/biome_configuration/src/linter/rules.rs @@ -2721,6 +2721,9 @@ pub struct Nursery { #[doc = "Disallows package private imports."] #[serde(skip_serializing_if = "Option::is_none")] pub use_import_restrictions: Option>, + #[doc = "Succinct description of the rule."] + #[serde(skip_serializing_if = "Option::is_none")] + pub use_jsx_sort_props: Option>, #[doc = "Enforce the sorting of CSS utility classes."] #[serde(skip_serializing_if = "Option::is_none")] pub use_sorted_classes: Option>, @@ -2766,6 +2769,7 @@ impl Nursery { "useDefaultSwitchClause", "useGenericFontNames", "useImportRestrictions", + "useJsxSortProps", "useSortedClasses", ]; const RECOMMENDED_RULES: &'static [&'static str] = &[ @@ -2820,6 +2824,7 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25]), ]; #[doc = r" Retrieves the recommended rules"] pub(crate) fn is_recommended_true(&self) -> bool { @@ -2956,11 +2961,16 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23])); } } - if let Some(rule) = self.use_sorted_classes.as_ref() { + if let Some(rule) = self.use_jsx_sort_props.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24])); } } + if let Some(rule) = self.use_sorted_classes.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25])); + } + } index_set } pub(crate) fn get_disabled_rules(&self) -> IndexSet { @@ -3085,11 +3095,16 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23])); } } - if let Some(rule) = self.use_sorted_classes.as_ref() { + if let Some(rule) = self.use_jsx_sort_props.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24])); } } + if let Some(rule) = self.use_sorted_classes.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -3222,6 +3237,10 @@ impl Nursery { .use_import_restrictions .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "useJsxSortProps" => self + .use_jsx_sort_props + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "useSortedClasses" => self .use_sorted_classes .as_ref() diff --git a/crates/biome_diagnostics_categories/src/categories.rs b/crates/biome_diagnostics_categories/src/categories.rs index a7e54eb6e584..a773986c7c6a 100644 --- a/crates/biome_diagnostics_categories/src/categories.rs +++ b/crates/biome_diagnostics_categories/src/categories.rs @@ -110,7 +110,6 @@ define_categories! { "lint/correctness/useValidForDirection": "https://biomejs.dev/linter/rules/use-valid-for-direction", "lint/correctness/useYield": "https://biomejs.dev/linter/rules/use-yield", "lint/nursery/colorNoInvalidHex": "https://biomejs.dev/linter/rules/color-no-invalid-hex", - "lint/nursery/useArrayLiterals": "https://biomejs.dev/linter/rules/use-array-literals", "lint/nursery/noColorInvalidHex": "https://biomejs.dev/linter/rules/no-color-invalid-hex", "lint/nursery/noConsole": "https://biomejs.dev/linter/rules/no-console", "lint/nursery/noConstantMathMinMaxClamp": "https://biomejs.dev/linter/rules/no-constant-math-min-max-clamp", @@ -130,13 +129,15 @@ define_categories! { "lint/nursery/noRestrictedImports": "https://biomejs.dev/linter/rules/no-restricted-imports", "lint/nursery/noTypeOnlyImportAttributes": "https://biomejs.dev/linter/rules/no-type-only-import-attributes", "lint/nursery/noUndeclaredDependencies": "https://biomejs.dev/linter/rules/no-undeclared-dependencies", - "lint/nursery/noUselessUndefinedInitialization": "https://biomejs.dev/linter/rules/no-useless-undefined-initialization", "lint/nursery/noUnknownUnit": "https://biomejs.dev/linter/rules/no-unknown-unit", + "lint/nursery/noUselessUndefinedInitialization": "https://biomejs.dev/linter/rules/no-useless-undefined-initialization", + "lint/nursery/useArrayLiterals": "https://biomejs.dev/linter/rules/use-array-literals", "lint/nursery/useBiomeSuppressionComment": "https://biomejs.dev/linter/rules/use-biome-suppression-comment", "lint/nursery/useConsistentNewBuiltin": "https://biomejs.dev/linter/rules/use-consistent-new-builtin", - "lint/nursery/useGenericFontNames": "https://biomejs.dev/linter/rules/use-generic-font-names", "lint/nursery/useDefaultSwitchClause": "https://biomejs.dev/linter/rules/use-default-switch-clause", + "lint/nursery/useGenericFontNames": "https://biomejs.dev/linter/rules/use-generic-font-names", "lint/nursery/useImportRestrictions": "https://biomejs.dev/linter/rules/use-import-restrictions", + "lint/nursery/useJsxSortProps": "https://biomejs.dev/linter/rules/use-jsx-sort-props", "lint/nursery/useSortedClasses": "https://biomejs.dev/linter/rules/use-sorted-classes", "lint/performance/noAccumulatingSpread": "https://biomejs.dev/linter/rules/no-accumulating-spread", "lint/performance/noBarrelFile": "https://biomejs.dev/linter/rules/no-barrel-file", diff --git a/crates/biome_js_analyze/src/lint/nursery.rs b/crates/biome_js_analyze/src/lint/nursery.rs index 1e5ccd6bce2f..9f273125512c 100644 --- a/crates/biome_js_analyze/src/lint/nursery.rs +++ b/crates/biome_js_analyze/src/lint/nursery.rs @@ -18,6 +18,7 @@ pub mod use_array_literals; pub mod use_consistent_new_builtin; pub mod use_default_switch_clause; pub mod use_import_restrictions; +pub mod use_jsx_sort_props; pub mod use_sorted_classes; declare_group! { @@ -40,6 +41,7 @@ declare_group! { self :: use_consistent_new_builtin :: UseConsistentNewBuiltin , self :: use_default_switch_clause :: UseDefaultSwitchClause , self :: use_import_restrictions :: UseImportRestrictions , + self :: use_jsx_sort_props :: UseJsxSortProps , self :: use_sorted_classes :: UseSortedClasses , ] } diff --git a/crates/biome_js_analyze/src/lint/nursery/use_jsx_sort_props.rs b/crates/biome_js_analyze/src/lint/nursery/use_jsx_sort_props.rs new file mode 100644 index 000000000000..ad8f31518797 --- /dev/null +++ b/crates/biome_js_analyze/src/lint/nursery/use_jsx_sort_props.rs @@ -0,0 +1,235 @@ +use std::cmp::Ordering; + +use biome_analyze::{context::RuleContext, declare_rule, Ast, Rule, RuleDiagnostic}; +use biome_console::markup; +use biome_deserialize_macros::Deserializable; +use biome_js_syntax::{ + AnyJsxAttribute, AnyJsxTag, JsxAttribute, JsxAttributeList, JsxTagExpression, +}; +use biome_rowan::{AstNode, TextRange}; +use serde::{Deserialize, Serialize}; + +declare_rule! { + /// Succinct description of the rule. + /// + /// Put context and details about the rule. + /// As a starting point, you can take the description of the corresponding _ESLint_ rule (if any). + /// + /// Try to stay consistent with the descriptions of implemented rules. + /// + /// Add a link to the corresponding ESLint rule (if any): + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```js,expect_diagnostic + /// var a = 1; + /// a = 2; + /// ``` + /// + /// ### Valid + /// + /// ```js + /// // var a = 1; + /// ``` + /// + pub UseJsxSortProps { + version: "next", + name: "useJsxSortProps", + recommended: false, + } +} + +#[derive(Clone, Debug, Default, Deserializable, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UseJsxSortPropsOptions { + #[serde(default, skip_serializing_if = "is_default")] + callbacks_last: bool, + shorthand_first: bool, + shorthand_last: bool, + multiline: MultilineBehavior, + ignore_case: bool, + no_sort_alphabetically: bool, + // TODO: add reserved_first and locale options +} + +#[derive(Clone, Debug, Default, Deserializable, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum MultilineBehavior { + #[default] + Ignore, + First, + Last, +} + +pub enum UseJsxSortPropsState { + Alphabetic, + CallbacksLast, + ShorthandFirst, + ShorthandLast, + MultilineFirst, + MultilineLast, +} + +fn is_default(value: &T) -> bool { + value == &T::default() +} + +impl Rule for UseJsxSortProps { + type Query = Ast; + type State = TextRange; + type Signals = Vec; + type Options = UseJsxSortPropsOptions; + + fn run(ctx: &RuleContext) -> Self::Signals { + let node = ctx.query(); + let options = ctx.options(); + match node.tag() { + Ok(AnyJsxTag::JsxElement(tag)) => { + let Ok(opening) = tag.opening_element() else { + return Vec::new(); + }; + let attrs = opening.attributes(); + lint_props(attrs, options) + } + Ok(AnyJsxTag::JsxSelfClosingElement(closing)) => { + let attrs = closing.attributes(); + lint_props(attrs, options) + } + _ => Vec::new(), + } + } + + fn diagnostic(_ctx: &RuleContext, state: &Self::State) -> Option { + Some(RuleDiagnostic::new( + rule_category!(), + *state, + markup! { + "These JSX props should be sorted." + }, + )) + } +} + +fn lint_props(props: JsxAttributeList, options: &UseJsxSortPropsOptions) -> Vec { + let mut diagnostics = vec![]; + let mut non_spread_props: Option> = None; + for prop in props { + match prop { + AnyJsxAttribute::JsxAttribute(attr) => { + if let Some(non_spread_props) = &mut non_spread_props { + non_spread_props.push(attr); + } else { + non_spread_props = Some(vec![attr]); + } + } + AnyJsxAttribute::JsxSpreadAttribute(_) => { + if let Some(non_spread_props) = non_spread_props.take() { + diagnostics.extend(lint_non_spread_props(&non_spread_props, options)); + } + non_spread_props = None; + } + } + } + if let Some(non_spread_props) = non_spread_props { + diagnostics.extend(lint_non_spread_props(&non_spread_props, options)); + } + diagnostics +} + +fn lint_non_spread_props( + props: &[JsxAttribute], + options: &UseJsxSortPropsOptions, +) -> Option { + let mut sorted_props = props.to_vec(); + sorted_props.sort_by(compare_props(options)); + for (i, prop) in props.iter().enumerate() { + if prop.name().ok()?.text() != sorted_props[i].name().ok()?.text() { + return Some(TextRange::new( + props.first()?.range().start(), + props.last()?.range().end(), + )); + } + } + None +} + +fn compare_props( + options: &UseJsxSortPropsOptions, +) -> impl FnMut(&JsxAttribute, &JsxAttribute) -> Ordering + '_ { + |a: &JsxAttribute, b: &JsxAttribute| -> Ordering { + let (Ok(a_name), Ok(b_name)) = (a.name(), b.name()) else { + return Ordering::Equal; + }; + let (a_name, b_name) = (a_name.text(), b_name.text()); + + if options.callbacks_last { + if is_callback(a) && !is_callback(b) { + return Ordering::Greater; + } + if !is_callback(a) && is_callback(b) { + return Ordering::Less; + } + } + + if options.shorthand_first { + if is_shorthand(a) && !is_shorthand(b) { + return Ordering::Less; + } + if !is_shorthand(a) && is_shorthand(b) { + return Ordering::Greater; + } + } + + if options.shorthand_last { + if is_shorthand(a) && !is_shorthand(b) { + return Ordering::Greater; + } + if !is_shorthand(a) && is_shorthand(b) { + return Ordering::Less; + } + } + + if options.multiline == MultilineBehavior::First { + if is_multiline(a) && !is_multiline(b) { + return Ordering::Less; + } + if !is_multiline(a) && is_multiline(b) { + return Ordering::Greater; + } + } + + if options.multiline == MultilineBehavior::Last { + if is_multiline(a) && !is_multiline(b) { + return Ordering::Greater; + } + if !is_multiline(a) && is_multiline(b) { + return Ordering::Less; + } + } + + if options.no_sort_alphabetically { + return Ordering::Equal; + } + + if options.ignore_case { + return a_name.to_lowercase().cmp(&b_name.to_lowercase()); + } + a_name.cmp(&b_name) + } +} + +fn is_shorthand(prop: &JsxAttribute) -> bool { + prop.initializer().is_some() +} + +fn is_callback(prop: &JsxAttribute) -> bool { + prop.name().is_ok_and(|name| name.text().starts_with("on")) +} + +fn is_multiline(prop: &JsxAttribute) -> bool { + prop.text().contains('\n') +} diff --git a/crates/biome_js_analyze/src/options.rs b/crates/biome_js_analyze/src/options.rs index 59e36288f3eb..0afa33b68df3 100644 --- a/crates/biome_js_analyze/src/options.rs +++ b/crates/biome_js_analyze/src/options.rs @@ -292,6 +292,8 @@ pub type UseImportType = pub type UseIsArray = ::Options; pub type UseIsNan = ::Options; pub type UseJsxKeyInIterable = < lint :: correctness :: use_jsx_key_in_iterable :: UseJsxKeyInIterable as biome_analyze :: Rule > :: Options ; +pub type UseJsxSortProps = + ::Options; pub type UseKeyWithClickEvents = ::Options; pub type UseKeyWithMouseEvents = diff --git a/crates/biome_js_analyze/tests/specs/nursery/useJsxSortProps/sorted.jsx b/crates/biome_js_analyze/tests/specs/nursery/useJsxSortProps/sorted.jsx new file mode 100644 index 000000000000..2d8064e7ea1a --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useJsxSortProps/sorted.jsx @@ -0,0 +1,3 @@ +/* should not generate diagnostics */ +; +; diff --git a/crates/biome_js_analyze/tests/specs/nursery/useJsxSortProps/sorted.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useJsxSortProps/sorted.jsx.snap new file mode 100644 index 000000000000..c4f52fe3a3e2 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useJsxSortProps/sorted.jsx.snap @@ -0,0 +1,11 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: sorted.jsx +--- +# Input +```jsx +/* should not generate diagnostics */ +; +; + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useJsxSortProps/unsorted.jsx b/crates/biome_js_analyze/tests/specs/nursery/useJsxSortProps/unsorted.jsx new file mode 100644 index 000000000000..91ffd2eff703 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useJsxSortProps/unsorted.jsx @@ -0,0 +1 @@ +; diff --git a/crates/biome_js_analyze/tests/specs/nursery/useJsxSortProps/unsorted.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useJsxSortProps/unsorted.jsx.snap new file mode 100644 index 000000000000..998bf83565cc --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useJsxSortProps/unsorted.jsx.snap @@ -0,0 +1,22 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: unsorted.jsx +--- +# Input +```jsx +; + +``` + +# Diagnostics +``` +unsorted.jsx:1:8 lint/nursery/useJsxSortProps ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These JSX props should be sorted. + + > 1 │ ; + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + +``` diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index 3a13cf387fcf..fb4ee1cf8c2b 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -1008,6 +1008,10 @@ export interface Nursery { * Disallows package private imports. */ useImportRestrictions?: RuleConfiguration_for_Null; + /** + * Succinct description of the rule. + */ + useJsxSortProps?: RuleConfiguration_for_Null; /** * Enforce the sorting of CSS utility classes. */ @@ -1977,7 +1981,6 @@ export type Category = | "lint/correctness/useValidForDirection" | "lint/correctness/useYield" | "lint/nursery/colorNoInvalidHex" - | "lint/nursery/useArrayLiterals" | "lint/nursery/noColorInvalidHex" | "lint/nursery/noConsole" | "lint/nursery/noConstantMathMinMaxClamp" @@ -1997,13 +2000,15 @@ export type Category = | "lint/nursery/noRestrictedImports" | "lint/nursery/noTypeOnlyImportAttributes" | "lint/nursery/noUndeclaredDependencies" - | "lint/nursery/noUselessUndefinedInitialization" | "lint/nursery/noUnknownUnit" + | "lint/nursery/noUselessUndefinedInitialization" + | "lint/nursery/useArrayLiterals" | "lint/nursery/useBiomeSuppressionComment" | "lint/nursery/useConsistentNewBuiltin" - | "lint/nursery/useGenericFontNames" | "lint/nursery/useDefaultSwitchClause" + | "lint/nursery/useGenericFontNames" | "lint/nursery/useImportRestrictions" + | "lint/nursery/useJsxSortProps" | "lint/nursery/useSortedClasses" | "lint/performance/noAccumulatingSpread" | "lint/performance/noBarrelFile" diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index 09f1dda29a2d..9f188c36c760 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -1605,6 +1605,13 @@ { "type": "null" } ] }, + "useJsxSortProps": { + "description": "Succinct description of the rule.", + "anyOf": [ + { "$ref": "#/definitions/RuleConfiguration" }, + { "type": "null" } + ] + }, "useSortedClasses": { "description": "Enforce the sorting of CSS utility classes.", "anyOf": [