From a28ee0f0c85b06c55f110277138d91f9887fede7 Mon Sep 17 00:00:00 2001 From: Carson McManus Date: Sun, 2 Jun 2024 14:46:06 -0400 Subject: [PATCH] feat(lint): add `useJsxCurlyBraceConvention` --- .../migrate/eslint_any_rule_to_biome.rs | 14 + .../biome_configuration/src/linter/rules.rs | 49 ++- .../src/categories.rs | 1 + crates/biome_js_analyze/src/lint/nursery.rs | 2 + .../nursery/use_jsx_curly_brace_convention.rs | 348 ++++++++++++++++++ crates/biome_js_analyze/src/options.rs | 1 + .../useJsxCurlyBraceConvention/invalid.jsx | 5 + .../invalid.jsx.snap | 74 ++++ .../useJsxCurlyBraceConvention/valid.jsx | 18 + .../useJsxCurlyBraceConvention/valid.jsx.snap | 26 ++ .../@biomejs/backend-jsonrpc/src/workspace.ts | 5 + .../@biomejs/biome/configuration_schema.json | 7 + 12 files changed, 535 insertions(+), 15 deletions(-) create mode 100644 crates/biome_js_analyze/src/lint/nursery/use_jsx_curly_brace_convention.rs create mode 100644 crates/biome_js_analyze/tests/specs/nursery/useJsxCurlyBraceConvention/invalid.jsx create mode 100644 crates/biome_js_analyze/tests/specs/nursery/useJsxCurlyBraceConvention/invalid.jsx.snap create mode 100644 crates/biome_js_analyze/tests/specs/nursery/useJsxCurlyBraceConvention/valid.jsx create mode 100644 crates/biome_js_analyze/tests/specs/nursery/useJsxCurlyBraceConvention/valid.jsx.snap diff --git a/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs b/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs index fb664749979c..b6765878d6e5 100644 --- a/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs +++ b/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs @@ -1249,6 +1249,20 @@ pub(crate) fn migrate_eslint_any_rule( let rule = group.no_implicit_boolean.get_or_insert(Default::default()); rule.set_level(rule_severity.into()); } + "react/jsx-curly-brace-presence" => { + if !options.include_inspired { + results.has_inspired_rules = true; + return false; + } + if !options.include_nursery { + return false; + } + let group = rules.nursery.get_or_insert_with(Default::default); + let rule = group + .use_jsx_curly_brace_convention + .get_or_insert(Default::default()); + rule.set_level(rule_severity.into()); + } "react/jsx-fragments" => { let group = rules.style.get_or_insert_with(Default::default); let rule = group.use_fragment_syntax.get_or_insert(Default::default()); diff --git a/crates/biome_configuration/src/linter/rules.rs b/crates/biome_configuration/src/linter/rules.rs index a8f9afa3af6a..258af2cbb16f 100644 --- a/crates/biome_configuration/src/linter/rules.rs +++ b/crates/biome_configuration/src/linter/rules.rs @@ -2939,6 +2939,9 @@ pub struct Nursery { #[doc = "Disallows package private imports."] #[serde(skip_serializing_if = "Option::is_none")] pub use_import_restrictions: Option>, + #[doc = "This rule allows you to enforce curly braces or disallow unnecessary curly braces in JSX props and/or children."] + #[serde(skip_serializing_if = "Option::is_none")] + pub use_jsx_curly_brace_convention: Option>, #[doc = "Enforce using the digits argument with Number#toFixed()."] #[serde(skip_serializing_if = "Option::is_none")] pub use_number_to_fixed_digits_argument: @@ -3020,6 +3023,7 @@ impl Nursery { "useGenericFontNames", "useImportExtensions", "useImportRestrictions", + "useJsxCurlyBraceConvention", "useNumberToFixedDigitsArgument", "useSemanticElements", "useSortedClasses", @@ -3072,7 +3076,7 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[37]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[38]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[42]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[43]), ]; const ALL_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), @@ -3123,6 +3127,7 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[45]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[46]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[47]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[48]), ]; #[doc = r" Retrieves the recommended rules"] pub(crate) fn is_recommended_true(&self) -> bool { @@ -3344,41 +3349,46 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[40])); } } - if let Some(rule) = self.use_number_to_fixed_digits_argument.as_ref() { + if let Some(rule) = self.use_jsx_curly_brace_convention.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[41])); } } - if let Some(rule) = self.use_semantic_elements.as_ref() { + if let Some(rule) = self.use_number_to_fixed_digits_argument.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[42])); } } - if let Some(rule) = self.use_sorted_classes.as_ref() { + if let Some(rule) = self.use_semantic_elements.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[43])); } } - if let Some(rule) = self.use_throw_new_error.as_ref() { + 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[44])); } } - if let Some(rule) = self.use_throw_only_error.as_ref() { + if let Some(rule) = self.use_throw_new_error.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[45])); } } - if let Some(rule) = self.use_top_level_regex.as_ref() { + if let Some(rule) = self.use_throw_only_error.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[46])); } } - if let Some(rule) = self.use_valid_autocomplete.as_ref() { + if let Some(rule) = self.use_top_level_regex.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[47])); } } + if let Some(rule) = self.use_valid_autocomplete.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[48])); + } + } index_set } pub(crate) fn get_disabled_rules(&self) -> FxHashSet> { @@ -3588,41 +3598,46 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[40])); } } - if let Some(rule) = self.use_number_to_fixed_digits_argument.as_ref() { + if let Some(rule) = self.use_jsx_curly_brace_convention.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[41])); } } - if let Some(rule) = self.use_semantic_elements.as_ref() { + if let Some(rule) = self.use_number_to_fixed_digits_argument.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[42])); } } - if let Some(rule) = self.use_sorted_classes.as_ref() { + if let Some(rule) = self.use_semantic_elements.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[43])); } } - if let Some(rule) = self.use_throw_new_error.as_ref() { + 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[44])); } } - if let Some(rule) = self.use_throw_only_error.as_ref() { + if let Some(rule) = self.use_throw_new_error.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[45])); } } - if let Some(rule) = self.use_top_level_regex.as_ref() { + if let Some(rule) = self.use_throw_only_error.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[46])); } } - if let Some(rule) = self.use_valid_autocomplete.as_ref() { + if let Some(rule) = self.use_top_level_regex.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[47])); } } + if let Some(rule) = self.use_valid_autocomplete.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[48])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -3823,6 +3838,10 @@ impl Nursery { .use_import_restrictions .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "useJsxCurlyBraceConvention" => self + .use_jsx_curly_brace_convention + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "useNumberToFixedDigitsArgument" => self .use_number_to_fixed_digits_argument .as_ref() diff --git a/crates/biome_diagnostics_categories/src/categories.rs b/crates/biome_diagnostics_categories/src/categories.rs index cfc037b7e21e..1e871428fabb 100644 --- a/crates/biome_diagnostics_categories/src/categories.rs +++ b/crates/biome_diagnostics_categories/src/categories.rs @@ -159,6 +159,7 @@ define_categories! { "lint/nursery/useGenericFontNames": "https://biomejs.dev/linter/rules/use-generic-font-names", "lint/nursery/useImportExtensions": "https://biomejs.dev/linter/rules/use-import-extensions", "lint/nursery/useImportRestrictions": "https://biomejs.dev/linter/rules/use-import-restrictions", + "lint/nursery/useJsxCurlyBraceConvention": "https://biomejs.dev/linter/rules/use-jsx-curly-brace-convention", "lint/nursery/useNumberToFixedDigitsArgument": "https://biomejs.dev/linter/rules/use-number-to-fixed-digits-argument", "lint/nursery/useSemanticElements": "https://biomejs.dev/linter/rules/use-semantic-elements", "lint/nursery/useSortedClasses": "https://biomejs.dev/linter/rules/use-sorted-classes", diff --git a/crates/biome_js_analyze/src/lint/nursery.rs b/crates/biome_js_analyze/src/lint/nursery.rs index 7f080f742fa9..90325a774edb 100644 --- a/crates/biome_js_analyze/src/lint/nursery.rs +++ b/crates/biome_js_analyze/src/lint/nursery.rs @@ -25,6 +25,7 @@ pub mod use_explicit_length_check; pub mod use_focusable_interactive; pub mod use_import_extensions; pub mod use_import_restrictions; +pub mod use_jsx_curly_brace_convention; pub mod use_number_to_fixed_digits_argument; pub mod use_semantic_elements; pub mod use_sorted_classes; @@ -60,6 +61,7 @@ declare_lint_group! { self :: use_focusable_interactive :: UseFocusableInteractive , self :: use_import_extensions :: UseImportExtensions , self :: use_import_restrictions :: UseImportRestrictions , + self :: use_jsx_curly_brace_convention :: UseJsxCurlyBraceConvention , self :: use_number_to_fixed_digits_argument :: UseNumberToFixedDigitsArgument , self :: use_semantic_elements :: UseSemanticElements , self :: use_sorted_classes :: UseSortedClasses , diff --git a/crates/biome_js_analyze/src/lint/nursery/use_jsx_curly_brace_convention.rs b/crates/biome_js_analyze/src/lint/nursery/use_jsx_curly_brace_convention.rs new file mode 100644 index 000000000000..5096ccb54df8 --- /dev/null +++ b/crates/biome_js_analyze/src/lint/nursery/use_jsx_curly_brace_convention.rs @@ -0,0 +1,348 @@ +use biome_analyze::{ + context::RuleContext, declare_rule, ActionCategory, Ast, FixKind, Rule, RuleDiagnostic, + RuleSource, RuleSourceKind, +}; +use biome_console::markup; +use biome_js_factory::make; +use biome_js_syntax::{ + AnyJsExpression, AnyJsLiteralExpression, AnyJsxAttributeValue, AnyJsxChild, JsSyntaxKind, + JsSyntaxToken, JsxAttributeInitializerClause, JsxExpressionAttributeValue, T, +}; +use biome_rowan::{declare_node_union, AstNode, BatchMutationExt, TextRange}; + +use crate::JsRuleAction; + +declare_rule! { + /// This rule allows you to enforce curly braces or disallow unnecessary curly braces in JSX props and/or children. + /// + /// For situations where JSX expressions are unnecessary, please refer to [the React doc](https://facebook.github.io/react/docs/jsx-in-depth.html) and [this page about JSX gotchas](https://github.com/facebook/react/blob/v15.4.0-rc.3/docs/docs/02.3-jsx-gotchas.md#html-entities). + /// + /// By default, this rule will check for and warn about unnecessary curly braces in both JSX props and children. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```jsx,expect_diagnostic + /// {'Hello world'}; + /// + /// ; + /// + /// />; + /// ``` + /// + /// ### Valid + /// + /// ```js + /// Hello world; + /// ; + /// ; + /// } />; + /// ``` + /// + pub UseJsxCurlyBraceConvention { + version: "next", + name: "useJsxCurlyBraceConvention", + language: "js", + recommended: false, + sources: &[RuleSource::EslintReact("jsx-curly-brace-presence")], + source_kind: RuleSourceKind::Inspired, + fix_kind: FixKind::Safe, + } +} + +declare_node_union! { + pub UseJsxCurlyBraceConventionQuery = JsxAttributeInitializerClause | AnyJsxChild +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CurlyBraceResolution { + /// The user should add curly braces around the expression. + AddBraces, + /// The user should remove the curly braces around the expression. + RemoveBraces, +} + +impl Rule for UseJsxCurlyBraceConvention { + type Query = Ast; + type State = CurlyBraceResolution; + type Signals = Option; + type Options = (); + + fn run(ctx: &RuleContext) -> Self::Signals { + let query = ctx.query(); + let has_curly_braces = has_curly_braces(query); + match query { + UseJsxCurlyBraceConventionQuery::JsxAttributeInitializerClause(attr) => { + handle_attr_init_clause(attr, has_curly_braces) + } + UseJsxCurlyBraceConventionQuery::AnyJsxChild(child) => { + handle_jsx_child(child, has_curly_braces) + } + } + } + + fn diagnostic(ctx: &RuleContext, state: &Self::State) -> Option { + let node = ctx.query(); + let source_range = match &node { + UseJsxCurlyBraceConventionQuery::JsxAttributeInitializerClause(node) => { + node.value().map(|value| value.range()) + } + _ => Ok(node.range()), + } + .unwrap_or(node.range()); + + let diag = match (state, node) { + (CurlyBraceResolution::AddBraces, UseJsxCurlyBraceConventionQuery::JsxAttributeInitializerClause(_)) => RuleDiagnostic::new( + rule_category!(), + source_range, + markup! { + "Should have curly braces around expression." + }, + ) + .note(markup! { + "JSX attribute value should be wrapped in curly braces. This will make the JSX attribute value more readable." + }), + (CurlyBraceResolution::AddBraces, UseJsxCurlyBraceConventionQuery::AnyJsxChild(_)) => RuleDiagnostic::new( + rule_category!(), + source_range, + markup! { + "Should have curly braces around expression." + }, + ) + .note(markup! { + "JSX child should be wrapped in curly braces." + }), + (CurlyBraceResolution::RemoveBraces, UseJsxCurlyBraceConventionQuery::JsxAttributeInitializerClause(_)) => RuleDiagnostic::new( + rule_category!(), + source_range, + markup! { + "Should not have curly braces around expression." + }, + ) + .note(markup! { + "JSX attribute value does not need to be wrapped in curly braces." + }), + (CurlyBraceResolution::RemoveBraces, UseJsxCurlyBraceConventionQuery::AnyJsxChild(_)) => RuleDiagnostic::new( + rule_category!(), + source_range, + markup! { + "Should not have curly braces around expression. " + }, + ) + .note(markup! { + "JSX child does not need to be wrapped in curly braces." + }) + }; + + Some(diag) + } + + fn action(ctx: &RuleContext, state: &Self::State) -> Option { + let node = ctx.query(); + let mut mutation = ctx.root().begin(); + match (state, node) { + ( + CurlyBraceResolution::AddBraces, + UseJsxCurlyBraceConventionQuery::JsxAttributeInitializerClause(node), + ) => { + let value = node + .value() + .and_then(|value| match value { + AnyJsxAttributeValue::AnyJsxTag(node) => { + let expr = make::jsx_tag_expression(node); + let value = make::jsx_expression_attribute_value( + make::token(T!['{']), + AnyJsExpression::JsxTagExpression(expr), + make::token(T!['}']), + ); + + Ok(AnyJsxAttributeValue::JsxExpressionAttributeValue(value)) + } + AnyJsxAttributeValue::JsxExpressionAttributeValue(node) => { + Ok(AnyJsxAttributeValue::JsxExpressionAttributeValue(node)) + } + AnyJsxAttributeValue::JsxString(node) => { + let value = make::jsx_expression_attribute_value( + make::token(T!['{']), + AnyJsExpression::AnyJsLiteralExpression( + AnyJsLiteralExpression::JsStringLiteralExpression( + make::js_string_literal_expression(node.value_token()?), + ), + ), + make::token(T!['}']), + ); + Ok(AnyJsxAttributeValue::JsxExpressionAttributeValue(value)) + } + }) + .ok()?; + mutation.replace_node( + node.clone(), + make::jsx_attribute_initializer_clause(make::token(T![=]), value), + ); + } + (CurlyBraceResolution::AddBraces, UseJsxCurlyBraceConventionQuery::AnyJsxChild(_)) => { + // this should never get hit + return None; + } + ( + CurlyBraceResolution::RemoveBraces, + UseJsxCurlyBraceConventionQuery::JsxAttributeInitializerClause(node), + ) => { + let str_literal = node.value().ok().and_then(|value| { + if let AnyJsxAttributeValue::JsxExpressionAttributeValue(node) = value { + node.expression().ok().and_then(|expr| { + if let AnyJsExpression::AnyJsLiteralExpression( + AnyJsLiteralExpression::JsStringLiteralExpression(node), + ) = expr + { + Some(node) + } else { + None + } + }) + } else { + None + } + })?; + let jsx_string = make::jsx_string(str_literal.value_token().ok()?); + let value = AnyJsxAttributeValue::JsxString(jsx_string); + mutation.replace_node( + node.clone(), + make::jsx_attribute_initializer_clause(make::token(T![=]), value), + ); + } + ( + CurlyBraceResolution::RemoveBraces, + UseJsxCurlyBraceConventionQuery::AnyJsxChild(node), + ) => { + if let AnyJsxChild::JsxExpressionChild(expr) = node { + let str_literal = expr.expression().and_then(|expr| { + if let AnyJsExpression::AnyJsLiteralExpression( + AnyJsLiteralExpression::JsStringLiteralExpression(node), + ) = expr + { + Some(node) + } else { + None + } + })?; + let text = &str_literal.value_token().ok()?.token_text(); + // trim the quotes off of the string literal + let text_trimmed = text.clone().slice(TextRange::new( + 1.into(), + text.len().checked_sub(1.into()).unwrap_or(text.len()), + )); + let jsx_text = biome_js_syntax::AnyJsxChild::JsxText(make::jsx_text( + JsSyntaxToken::new_detached( + JsSyntaxKind::JS_STRING_LITERAL, + &format!("{text_trimmed}"), + [], + [], + ), + )) + .into_syntax() + .into(); + mutation.replace_element(node.clone().into_syntax().into(), jsx_text); + } + } + } + + let msg = match state { + CurlyBraceResolution::AddBraces => "Add curly braces around the expression.", + CurlyBraceResolution::RemoveBraces => "Remove curly braces around the expression.", + }; + + Some(JsRuleAction::new( + ActionCategory::QuickFix, + ctx.metadata().applicability(), + markup! { {msg} }.to_owned(), + mutation, + )) + } +} + +fn handle_attr_init_clause( + attr: &JsxAttributeInitializerClause, + has_curly_braces: bool, +) -> Option { + let Ok(node) = attr.value() else { + return None; + }; + + match node { + AnyJsxAttributeValue::AnyJsxTag(_) => Some(CurlyBraceResolution::AddBraces), + AnyJsxAttributeValue::JsxExpressionAttributeValue(node) => { + if has_curly_braces && contains_string_literal(&node) { + Some(CurlyBraceResolution::RemoveBraces) + } else if !has_curly_braces && contains_jsx_tag(&node) { + Some(CurlyBraceResolution::AddBraces) + } else { + None + } + } + AnyJsxAttributeValue::JsxString(_) => None, + } +} + +fn handle_jsx_child(child: &AnyJsxChild, has_curly_braces: bool) -> Option { + match child { + AnyJsxChild::JsxExpressionChild(child) => child + .expression() + .as_ref() + .and_then(|node| node.as_any_js_literal_expression()) + .and_then(|node| node.as_js_string_literal_expression()) + .and({ + if has_curly_braces { + Some(CurlyBraceResolution::RemoveBraces) + } else { + None + } + }), + AnyJsxChild::JsxText(_) => None, + _ => None, + } +} + +fn has_curly_braces(node: &UseJsxCurlyBraceConventionQuery) -> bool { + match node { + UseJsxCurlyBraceConventionQuery::JsxAttributeInitializerClause(node) => { + node.value().map_or(false, |node| { + if let AnyJsxAttributeValue::JsxExpressionAttributeValue(attr) = node { + attr.l_curly_token().is_ok() || attr.r_curly_token().is_ok() + } else { + false + } + }) + } + UseJsxCurlyBraceConventionQuery::AnyJsxChild(node) => match node { + AnyJsxChild::JsxElement(_) => false, + AnyJsxChild::JsxExpressionChild(node) => { + node.l_curly_token().is_ok() || node.r_curly_token().is_ok() + } + AnyJsxChild::JsxFragment(_) => false, + AnyJsxChild::JsxSelfClosingElement(_) => false, + AnyJsxChild::JsxSpreadChild(_) => true, + AnyJsxChild::JsxText(_) => false, + }, + } +} + +fn contains_string_literal(node: &JsxExpressionAttributeValue) -> bool { + node.expression() + .map(|expr| { + matches!( + expr, + AnyJsExpression::AnyJsLiteralExpression( + AnyJsLiteralExpression::JsStringLiteralExpression(_) + ) + ) + }) + .unwrap_or_default() +} + +fn contains_jsx_tag(node: &JsxExpressionAttributeValue) -> bool { + node.expression() + .map(|expr| matches!(expr, AnyJsExpression::JsxTagExpression(_))) + .unwrap_or_default() +} diff --git a/crates/biome_js_analyze/src/options.rs b/crates/biome_js_analyze/src/options.rs index 7fdc6c1feaee..0418b03dfa80 100644 --- a/crates/biome_js_analyze/src/options.rs +++ b/crates/biome_js_analyze/src/options.rs @@ -305,6 +305,7 @@ pub type UseImportType = ::Options; pub type UseIsArray = ::Options; pub type UseIsNan = ::Options; +pub type UseJsxCurlyBraceConvention = < lint :: nursery :: use_jsx_curly_brace_convention :: UseJsxCurlyBraceConvention as biome_analyze :: Rule > :: Options ; pub type UseJsxKeyInIterable = < lint :: correctness :: use_jsx_key_in_iterable :: UseJsxKeyInIterable as biome_analyze :: Rule > :: Options ; pub type UseKeyWithClickEvents = ::Options; diff --git a/crates/biome_js_analyze/tests/specs/nursery/useJsxCurlyBraceConvention/invalid.jsx b/crates/biome_js_analyze/tests/specs/nursery/useJsxCurlyBraceConvention/invalid.jsx new file mode 100644 index 000000000000..6163100e5ddf --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useJsxCurlyBraceConvention/invalid.jsx @@ -0,0 +1,5 @@ +{'Hello world'}; + +; + + />; diff --git a/crates/biome_js_analyze/tests/specs/nursery/useJsxCurlyBraceConvention/invalid.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useJsxCurlyBraceConvention/invalid.jsx.snap new file mode 100644 index 000000000000..5bf7d14f04b6 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useJsxCurlyBraceConvention/invalid.jsx.snap @@ -0,0 +1,74 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: invalid.jsx +--- +# Input +```jsx +{'Hello world'}; + +; + + />; + +``` + +# Diagnostics +``` +invalid.jsx:1:6 lint/nursery/useJsxCurlyBraceConvention FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Should not have curly braces around expression. + + > 1 │ {'Hello world'}; + │ ^^^^^^^^^^^^^^^ + 2 │ + 3 │ ; + + i JSX child does not need to be wrapped in curly braces. + + i Safe fix: Remove curly braces around the expression. + + 1 │ {'Hello·world'}; + │ -- -- + +``` + +``` +invalid.jsx:3:10 lint/nursery/useJsxCurlyBraceConvention FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Should not have curly braces around expression. + + 1 │ {'Hello world'}; + 2 │ + > 3 │ ; + │ ^^^^^^^ + 4 │ + 5 │ />; + + i JSX attribute value does not need to be wrapped in curly braces. + + i Safe fix: Remove curly braces around the expression. + + 3 │ ; + │ - - + +``` + +``` +invalid.jsx:5:10 lint/nursery/useJsxCurlyBraceConvention FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Should have curly braces around expression. + + 3 │ ; + 4 │ + > 5 │ />; + │ ^^^^^^^ + 6 │ + + i JSX attribute value should be wrapped in curly braces. This will make the JSX attribute value more readable. + + i Safe fix: Add curly braces around the expression. + + 5 │ ·}·/>; + │ + ++ + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useJsxCurlyBraceConvention/valid.jsx b/crates/biome_js_analyze/tests/specs/nursery/useJsxCurlyBraceConvention/valid.jsx new file mode 100644 index 000000000000..933ab07d5334 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useJsxCurlyBraceConvention/valid.jsx @@ -0,0 +1,18 @@ +/* should not generate diagnostics */ + +Hello world; + +; + +Baz; + +; + +let baz = 4; +; +Baz is {baz}; +{baz} is Baz; + +} />; + + diff --git a/crates/biome_js_analyze/tests/specs/nursery/useJsxCurlyBraceConvention/valid.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useJsxCurlyBraceConvention/valid.jsx.snap new file mode 100644 index 000000000000..8205fdade0db --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useJsxCurlyBraceConvention/valid.jsx.snap @@ -0,0 +1,26 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: valid.jsx +--- +# Input +```jsx +/* should not generate diagnostics */ + +Hello world; + +; + +Baz; + +; + +let baz = 4; +; +Baz is {baz}; +{baz} is Baz; + +} />; + + + +``` diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index 7acc1f4959ff..3d0d9302a055 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -1142,6 +1142,10 @@ export interface Nursery { * Disallows package private imports. */ useImportRestrictions?: RuleConfiguration_for_Null; + /** + * This rule allows you to enforce curly braces or disallow unnecessary curly braces in JSX props and/or children. + */ + useJsxCurlyBraceConvention?: RuleFixConfiguration_for_Null; /** * Enforce using the digits argument with Number#toFixed(). */ @@ -2413,6 +2417,7 @@ export type Category = | "lint/nursery/useGenericFontNames" | "lint/nursery/useImportExtensions" | "lint/nursery/useImportRestrictions" + | "lint/nursery/useJsxCurlyBraceConvention" | "lint/nursery/useNumberToFixedDigitsArgument" | "lint/nursery/useSemanticElements" | "lint/nursery/useSortedClasses" diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index 3ebf0888ed32..e6ebc583e6b6 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -1955,6 +1955,13 @@ { "type": "null" } ] }, + "useJsxCurlyBraceConvention": { + "description": "This rule allows you to enforce curly braces or disallow unnecessary curly braces in JSX props and/or children.", + "anyOf": [ + { "$ref": "#/definitions/RuleFixConfiguration" }, + { "type": "null" } + ] + }, "useNumberToFixedDigitsArgument": { "description": "Enforce using the digits argument with Number#toFixed().", "anyOf": [