Repository: dprint/dprint-plugin-typescript (rust)

Issue:
Bracket line setting for JSX
In a similar spirit to the `jsxElement.spaceBeforeSelfClosingTagSlash` rule, it would be great to have a rule similar to [Prettier's `bracket-line`](https://prettier.io/docs/en/options.html#bracket-line) for formatting non-self closing JSX elements :)

Pull request:
feat: add `jsx.bracketPosition`
Closes #435

Patch:
diff --git a/deployment/schema.json b/deployment/schema.json
index 4eeaf08..f12962b 100644
--- a/deployment/schema.json
+++ b/deployment/schema.json
@@ -99,6 +99,21 @@
         "description": ""
       }]
     },
+    "jsx.bracketPosition": {
+      "description": "If the end angle bracket of a jsx open element or self closing element should be on the same or next line when the attributes span multiple lines.",
+      "type": "string",
+      "default": "nextLine",
+      "oneOf": [{
+        "const": "maintain",
+        "description": "Maintains the position of the end angle bracket."
+      }, {
+        "const": "sameLine",
+        "description": "Forces the end angle bracket to be on the same line."
+      }, {
+        "const": "nextLine",
+        "description": "Forces the end angle bracket to be on the next line."
+      }]
+    },
     "newLineKind": {
       "description": "The kind of newline to use.",
       "type": "string",
diff --git a/src/configuration/builder.rs b/src/configuration/builder.rs
index 0fa156e..0cb4e95 100644
--- a/src/configuration/builder.rs
+++ b/src/configuration/builder.rs
@@ -128,6 +128,14 @@ impl ConfigurationBuilder {
     self.insert("jsx.forceNewLinesSurroundingContent", value.into())
   }
 
+  /// If the end angle bracket of a jsx open element or self closing element
+  /// should be on the same or next line when the attributes span multiple lines.
+  ///
+  /// Default: `nextLine`
+  pub fn jsx_bracket_position(&mut self, value: SameOrNextLinePosition) -> &mut Self {
+    self.insert("jsx.bracketPosition", value.to_string().into())
+  }
+
   /// Whether statements should end in a semi-colon.
   ///
   /// Default: `SemiColons::Prefer`
@@ -173,7 +181,7 @@ impl ConfigurationBuilder {
   /// Where to place the expression of a statement that could possibly be on one line (ex. `if (true) console.log(5);`).
   ///
   /// Default: SingleBodyPosition::Maintain
-  pub fn single_body_position(&mut self, value: SingleBodyPosition) -> &mut Self {
+  pub fn single_body_position(&mut self, value: SameOrNextLinePosition) -> &mut Self {
     self.insert("singleBodyPosition", value.to_string().into())
   }
 
@@ -769,23 +777,23 @@ impl ConfigurationBuilder {
 
   /* single body position */
 
-  pub fn if_statement_single_body_position(&mut self, value: SingleBodyPosition) -> &mut Self {
+  pub fn if_statement_single_body_position(&mut self, value: SameOrNextLinePosition) -> &mut Self {
     self.insert("ifStatement.singleBodyPosition", value.to_string().into())
   }
 
-  pub fn for_statement_single_body_position(&mut self, value: SingleBodyPosition) -> &mut Self {
+  pub fn for_statement_single_body_position(&mut self, value: SameOrNextLinePosition) -> &mut Self {
     self.insert("forStatement.singleBodyPosition", value.to_string().into())
   }
 
-  pub fn for_in_statement_single_body_position(&mut self, value: SingleBodyPosition) -> &mut Self {
+  pub fn for_in_statement_single_body_position(&mut self, value: SameOrNextLinePosition) -> &mut Self {
     self.insert("forInStatement.singleBodyPosition", value.to_string().into())
   }
 
-  pub fn for_of_statement_single_body_position(&mut self, value: SingleBodyPosition) -> &mut Self {
+  pub fn for_of_statement_single_body_position(&mut self, value: SameOrNextLinePosition) -> &mut Self {
     self.insert("forOfStatement.singleBodyPosition", value.to_string().into())
   }
 
-  pub fn while_statement_single_body_position(&mut self, value: SingleBodyPosition) -> &mut Self {
+  pub fn while_statement_single_body_position(&mut self, value: SameOrNextLinePosition) -> &mut Self {
     self.insert("whileStatement.singleBodyPosition", value.to_string().into())
   }
 
@@ -1038,11 +1046,12 @@ mod tests {
       .jsx_quote_style(JsxQuoteStyle::PreferSingle)
       .jsx_multi_line_parens(JsxMultiLineParens::Never)
       .jsx_force_new_lines_surrounding_content(true)
+      .jsx_bracket_position(SameOrNextLinePosition::Maintain)
       .semi_colons(SemiColons::Prefer)
       .brace_position(BracePosition::NextLine)
       .next_control_flow_position(NextControlFlowPosition::SameLine)
       .operator_position(OperatorPosition::SameLine)
-      .single_body_position(SingleBodyPosition::SameLine)
+      .single_body_position(SameOrNextLinePosition::SameLine)
       .trailing_commas(TrailingCommas::Never)
       .use_braces(UseBraces::WhenNotSingleLine)
       .quote_props(QuoteProps::AsNeeded)
@@ -1122,11 +1131,11 @@ mod tests {
       .conditional_expression_operator_position(OperatorPosition::SameLine)
       .conditional_type_operator_position(OperatorPosition::SameLine)
       /* single body position */
-      .if_statement_single_body_position(SingleBodyPosition::SameLine)
-      .for_statement_single_body_position(SingleBodyPosition::SameLine)
-      .for_in_statement_single_body_position(SingleBodyPosition::SameLine)
-      .for_of_statement_single_body_position(SingleBodyPosition::SameLine)
-      .while_statement_single_body_position(SingleBodyPosition::SameLine)
+      .if_statement_single_body_position(SameOrNextLinePosition::SameLine)
+      .for_statement_single_body_position(SameOrNextLinePosition::SameLine)
+      .for_in_statement_single_body_position(SameOrNextLinePosition::SameLine)
+      .for_of_statement_single_body_position(SameOrNextLinePosition::SameLine)
+      .while_statement_single_body_position(SameOrNextLinePosition::SameLine)
       /* trailing commas */
       .arguments_trailing_commas(TrailingCommas::Never)
       .parameters_trailing_commas(TrailingCommas::Never)
@@ -1219,7 +1228,7 @@ mod tests {
       .while_statement_space_around(true);
 
     let inner_config = config.get_inner_config();
-    assert_eq!(inner_config.len(), 172);
+    assert_eq!(inner_config.len(), 173);
     let diagnostics = resolve_config(inner_config, &resolve_global_config(ConfigKeyMap::new(), &Default::default()).config).diagnostics;
     assert_eq!(diagnostics.len(), 0);
   }
diff --git a/src/configuration/resolve_config.rs b/src/configuration/resolve_config.rs
index 147a7e9..c7f259f 100644
--- a/src/configuration/resolve_config.rs
+++ b/src/configuration/resolve_config.rs
@@ -36,7 +36,7 @@ pub fn resolve_config(config: ConfigKeyMap, global_config: &GlobalConfiguration)
   let brace_position = get_value(&mut config, "bracePosition", BracePosition::SameLineUnlessHanging, &mut diagnostics);
   let next_control_flow_position = get_value(&mut config, "nextControlFlowPosition", NextControlFlowPosition::SameLine, &mut diagnostics);
   let operator_position = get_value(&mut config, "operatorPosition", OperatorPosition::NextLine, &mut diagnostics);
-  let single_body_position = get_value(&mut config, "singleBodyPosition", SingleBodyPosition::Maintain, &mut diagnostics);
+  let single_body_position = get_value(&mut config, "singleBodyPosition", SameOrNextLinePosition::Maintain, &mut diagnostics);
   let trailing_commas = get_value(&mut config, "trailingCommas", TrailingCommas::OnlyMultiLine, &mut diagnostics);
   let use_braces = get_value(&mut config, "useBraces", UseBraces::WhenNotSingleLine, &mut diagnostics);
   let prefer_hanging = get_value(&mut config, "preferHanging", false, &mut diagnostics);
@@ -83,6 +83,7 @@ pub fn resolve_config(config: ConfigKeyMap, global_config: &GlobalConfiguration)
     jsx_quote_style: get_value(&mut config, "jsx.quoteStyle", quote_style.to_jsx_quote_style(), &mut diagnostics),
     jsx_multi_line_parens: get_value(&mut config, "jsx.multiLineParens", JsxMultiLineParens::Prefer, &mut diagnostics),
     jsx_force_new_lines_surrounding_content: get_value(&mut config, "jsx.forceNewLinesSurroundingContent", false, &mut diagnostics),
+    jsx_bracket_position: get_value(&mut config, "jsx.bracketPosition", SameOrNextLinePosition::NextLine, &mut diagnostics),
     member_expression_line_per_expression: get_value(&mut config, "memberExpression.linePerExpression", false, &mut diagnostics),
     type_literal_separator_kind_single_line: get_value(
       &mut config,
diff --git a/src/configuration/types.rs b/src/configuration/types.rs
index 923de25..1a1cf24 100644
--- a/src/configuration/types.rs
+++ b/src/configuration/types.rs
@@ -109,10 +109,10 @@ pub enum OperatorPosition {
 
 generate_str_to_from![OperatorPosition, [Maintain, "maintain"], [SameLine, "sameLine"], [NextLine, "nextLine"]];
 
-/// Where to place the expression of a statement that could possibly be on one line (ex. `if (true) console.log(5);`).
+/// Where to place a node that could be on the same line or next line.
 #[derive(Clone, PartialEq, Copy, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
-pub enum SingleBodyPosition {
+pub enum SameOrNextLinePosition {
   /// Maintains the position of the expression.
   Maintain,
   /// Forces the whole statement to be on one line.
@@ -121,7 +121,7 @@ pub enum SingleBodyPosition {
   NextLine,
 }
 
-generate_str_to_from![SingleBodyPosition, [Maintain, "maintain"], [SameLine, "sameLine"], [NextLine, "nextLine"]];
+generate_str_to_from![SameOrNextLinePosition, [Maintain, "maintain"], [SameLine, "sameLine"], [NextLine, "nextLine"]];
 
 /// If braces should be used or not in certain scenarios.
 #[derive(Clone, PartialEq, Copy, Serialize, Deserialize)]
@@ -289,6 +289,8 @@ pub struct Configuration {
   pub jsx_multi_line_parens: JsxMultiLineParens,
   #[serde(rename = "jsx.forceNewLinesSurroundingContent")]
   pub jsx_force_new_lines_surrounding_content: bool,
+  #[serde(rename = "jsx.bracketPosition")]
+  pub jsx_bracket_position: SameOrNextLinePosition,
   #[serde(rename = "memberExpression.linePerExpression")]
   pub member_expression_line_per_expression: bool,
   #[serde(rename = "typeLiteral.separatorKind.singleLine")]
@@ -420,15 +422,15 @@ pub struct Configuration {
   pub conditional_type_operator_position: OperatorPosition,
   /* single body position */
   #[serde(rename = "ifStatement.singleBodyPosition")]
-  pub if_statement_single_body_position: SingleBodyPosition,
+  pub if_statement_single_body_position: SameOrNextLinePosition,
   #[serde(rename = "forStatement.singleBodyPosition")]
-  pub for_statement_single_body_position: SingleBodyPosition,
+  pub for_statement_single_body_position: SameOrNextLinePosition,
   #[serde(rename = "forInStatement.singleBodyPosition")]
-  pub for_in_statement_single_body_position: SingleBodyPosition,
+  pub for_in_statement_single_body_position: SameOrNextLinePosition,
   #[serde(rename = "forOfStatement.singleBodyPosition")]
-  pub for_of_statement_single_body_position: SingleBodyPosition,
+  pub for_of_statement_single_body_position: SameOrNextLinePosition,
   #[serde(rename = "whileStatement.singleBodyPosition")]
-  pub while_statement_single_body_position: SingleBodyPosition,
+  pub while_statement_single_body_position: SameOrNextLinePosition,
   /* trailing commas */
   #[serde(rename = "arguments.trailingCommas")]
   pub arguments_trailing_commas: TrailingCommas,
diff --git a/src/generation/generate.rs b/src/generation/generate.rs
index 48063f9..ad8e712 100644
--- a/src/generation/generate.rs
+++ b/src/generation/generate.rs
@@ -694,7 +694,7 @@ fn gen_catch_clause<'a>(node: &'a CatchClause, context: &mut Context<'a>) -> Pri
 
   let try_stmt = node.parent();
   let single_body_position = if try_stmt.finalizer.is_some() {
-    Some(SingleBodyPosition::NextLine)
+    Some(SameOrNextLinePosition::NextLine)
   } else {
     None
   };
@@ -3583,6 +3583,7 @@ fn gen_jsx_namespaced_name<'a>(node: &'a JSXNamespacedName, context: &mut Contex
 fn gen_jsx_opening_element<'a>(node: &'a JSXOpeningElement, context: &mut Context<'a>) -> PrintItems {
   let space_before_self_closing_tag_slash = context.config.jsx_element_space_before_self_closing_tag_slash;
   let force_use_new_lines = get_force_is_multi_line(node, context);
+  let prefer_newline_before_close_bracket = get_should_prefer_newline_before_close_bracket(node, context);
   let start_lsil = LineStartIndentLevel::new("openingElementStart");
   let mut items = PrintItems::new();
 
@@ -3601,6 +3602,8 @@ fn gen_jsx_opening_element<'a>(node: &'a JSXOpeningElement, context: &mut Contex
       items.push_str(" ");
     }
   } else if !node.attrs.is_empty() {
+    let mut multi_line_options = ir_helpers::MultiLineOptions::surround_newlines_indented();
+    multi_line_options.newline_at_end = prefer_newline_before_close_bracket;
     items.extend(gen_separated_values(
       GenSeparatedValuesParams {
         nodes: node.attrs.iter().map(|p| NodeOrSeparator::Node(p.into())).collect(),
@@ -3611,7 +3614,7 @@ fn gen_jsx_opening_element<'a>(node: &'a JSXOpeningElement, context: &mut Contex
         single_line_space_at_start: true,
         single_line_space_at_end,
         custom_single_line_separator: None,
-        multi_line_options: ir_helpers::MultiLineOptions::surround_newlines_indented(),
+        multi_line_options,
         force_possible_newline_at_start: false,
         node_sorter: None,
       },
@@ -3633,12 +3636,12 @@ fn gen_jsx_opening_element<'a>(node: &'a JSXOpeningElement, context: &mut Contex
   }
 
   if node.self_closing() {
-    if node.attrs.is_empty() && space_before_self_closing_tag_slash {
+    if (node.attrs.is_empty() || !prefer_newline_before_close_bracket) && space_before_self_closing_tag_slash {
       items.push_str(""); // force current line indentation
       items.extend(space_if_not_start_line());
     }
     items.push_str("/");
-  } else if context.config.jsx_attributes_prefer_hanging {
+  } else if context.config.jsx_attributes_prefer_hanging && prefer_newline_before_close_bracket {
     items.push_condition(conditions::new_line_if_hanging(start_lsil, None));
   }
   items.push_str(">");
@@ -3655,6 +3658,20 @@ fn gen_jsx_opening_element<'a>(node: &'a JSXOpeningElement, context: &mut Contex
     }
   }
 
+  fn get_should_prefer_newline_before_close_bracket(node: &JSXOpeningElement, context: &mut Context) -> bool {
+    match context.config.jsx_bracket_position {
+      SameOrNextLinePosition::Maintain => {
+        if let Some(last_attr) = node.attrs.last() {
+          last_attr.end_line_fast(context.program) < node.end_line_fast(context.program)
+        } else {
+          false
+        }
+      }
+      SameOrNextLinePosition::NextLine => true,
+      SameOrNextLinePosition::SameLine => false,
+    }
+  }
+
   fn is_jsx_attr_with_string(node: &JSXAttrOrSpread) -> bool {
     if let JSXAttrOrSpread::JSXAttr(attrib) = node {
       if let Some(value) = attrib.value {
@@ -4986,7 +5003,7 @@ fn gen_try_stmt<'a>(node: &'a TryStmt, context: &mut Context<'a>) -> PrintItems
         body_node: node.block.into(),
         use_braces: UseBraces::Always, // braces required
         brace_position: context.config.try_statement_brace_position,
-        single_body_position: Some(SingleBodyPosition::NextLine),
+        single_body_position: Some(SameOrNextLinePosition::NextLine),
         requires_braces_condition_ref: None,
         start_header_info: None,
         end_header_info: None,
@@ -5030,7 +5047,7 @@ fn gen_try_stmt<'a>(node: &'a TryStmt, context: &mut Context<'a>) -> PrintItems
           body_node: finalizer.into(),
           use_braces: UseBraces::Always, // braces required
           brace_position,
-          single_body_position: Some(SingleBodyPosition::NextLine),
+          single_body_position: Some(SameOrNextLinePosition::NextLine),
           requires_braces_condition_ref: None,
           start_header_info: None,
           end_header_info: None,
@@ -7949,7 +7966,7 @@ struct GenHeaderWithConditionalBraceBodyOptions<'a> {
   generated_header: PrintItems,
   use_braces: UseBraces,
   brace_position: BracePosition,
-  single_body_position: Option<SingleBodyPosition>,
+  single_body_position: Option<SameOrNextLinePosition>,
   requires_braces_condition_ref: Option<ConditionReference>,
 }
 
@@ -7998,7 +8015,7 @@ struct GenConditionalBraceBodyOptions<'a> {
   body_node: Node<'a>,
   use_braces: UseBraces,
   brace_position: BracePosition,
-  single_body_position: Option<SingleBodyPosition>,
+  single_body_position: Option<SameOrNextLinePosition>,
   requires_braces_condition_ref: Option<ConditionReference>,
   start_header_info: Option<(LineNumber, LineStartIndentLevel)>,
   end_header_info: Option<LineNumber>,
@@ -8240,7 +8257,7 @@ fn gen_conditional_brace_body<'a>(opts: GenConditionalBraceBodyOptions<'a>, cont
   fn get_should_use_new_line<'a>(
     body_node: Node,
     body_should_be_multi_line: bool,
-    single_body_position: &Option<SingleBodyPosition>,
+    single_body_position: &Option<SameOrNextLinePosition>,
     context: &mut Context<'a>,
   ) -> bool {
     if body_should_be_multi_line {
@@ -8248,11 +8265,11 @@ fn gen_conditional_brace_body<'a>(opts: GenConditionalBraceBodyOptions<'a>, cont
     }
     if let Some(single_body_position) = single_body_position {
       return match single_body_position {
-        SingleBodyPosition::Maintain => {
+        SameOrNextLinePosition::Maintain => {
           get_body_stmt_start_line(body_node, context) > body_node.previous_token_fast(context.program).start_line_fast(context.program)
         }
-        SingleBodyPosition::NextLine => true,
-        SingleBodyPosition::SameLine => {
+        SameOrNextLinePosition::NextLine => true,
+        SameOrNextLinePosition::SameLine => {
           if let Node::BlockStmt(block_stmt) = body_node {
             if block_stmt.stmts.len() != 1 {
               return true;


Test patch:
diff --git a/tests/specs/jsx/JsxElement/JsxElement_BracketPosition_Maintain.txt b/tests/specs/jsx/JsxElement/JsxElement_BracketPosition_Maintain.txt
new file mode 100644
index 0000000..75a4aa0
--- /dev/null
+++ b/tests/specs/jsx/JsxElement/JsxElement_BracketPosition_Maintain.txt
@@ -0,0 +1,64 @@
+-- file.tsx --
+~~  lineWidth: 50, jsx.bracketPosition: maintain ~~
+== should maintain the bracket position when there's attributes ==
+const t = (
+    <Test
+    prop={5}
+    other={10}>
+        Test
+    </Test>
+);
+const u = (
+    <Test
+        prop={5}
+        other={10}
+    >
+    </Test>
+);
+const v = (
+    <Test
+    >
+    </Test>
+);
+const w = (
+    <Test
+        prop={5}
+        other={10}
+    />
+);
+const x = (
+    <Test
+        prop={5}
+        other={10} />
+);
+
+[expect]
+const t = (
+    <Test
+        prop={5}
+        other={10}>
+        Test
+    </Test>
+);
+const u = (
+    <Test
+        prop={5}
+        other={10}
+    >
+    </Test>
+);
+const v = (
+    <Test>
+    </Test>
+);
+const w = (
+    <Test
+        prop={5}
+        other={10}
+    />
+);
+const x = (
+    <Test
+        prop={5}
+        other={10} />
+);
diff --git a/tests/specs/jsx/JsxElement/JsxElement_BracketPosition_NextLine.txt b/tests/specs/jsx/JsxElement/JsxElement_BracketPosition_NextLine.txt
new file mode 100644
index 0000000..0494161
--- /dev/null
+++ b/tests/specs/jsx/JsxElement/JsxElement_BracketPosition_NextLine.txt
@@ -0,0 +1,55 @@
+-- file.tsx --
+~~  lineWidth: 50, jsx.bracketPosition: nextLine ~~
+== should move the bracket position to the next line ==
+const t = (
+    <Test
+    prop={5}
+    other={10}>
+        Test
+    </Test>
+);
+const u = (
+    <Test
+        prop={5}
+        other={10}
+    >
+    </Test>
+);
+const v = (
+    <Test
+    >
+    </Test>
+);
+const w = (
+    <Test
+        prop={5}
+        other={10}
+    />
+);
+
+[expect]
+const t = (
+    <Test
+        prop={5}
+        other={10}
+    >
+        Test
+    </Test>
+);
+const u = (
+    <Test
+        prop={5}
+        other={10}
+    >
+    </Test>
+);
+const v = (
+    <Test>
+    </Test>
+);
+const w = (
+    <Test
+        prop={5}
+        other={10}
+    />
+);
diff --git a/tests/specs/jsx/JsxElement/JsxElement_BracketPosition_SameLine.txt b/tests/specs/jsx/JsxElement/JsxElement_BracketPosition_SameLine.txt
new file mode 100644
index 0000000..3c1feb0
--- /dev/null
+++ b/tests/specs/jsx/JsxElement/JsxElement_BracketPosition_SameLine.txt
@@ -0,0 +1,52 @@
+-- file.tsx --
+~~  lineWidth: 50, jsx.bracketPosition: sameLine ~~
+== should move the bracket position to the same line ==
+const t = (
+    <Test
+    prop={5}
+    other={10}>
+        Test
+    </Test>
+);
+const u = (
+    <Test
+        prop={5}
+        other={10}
+    >
+    </Test>
+);
+const v = (
+    <Test
+    >
+    </Test>
+);
+const w = (
+    <Test
+        prop={5}
+        other={10}
+    />
+);
+
+[expect]
+const t = (
+    <Test
+        prop={5}
+        other={10}>
+        Test
+    </Test>
+);
+const u = (
+    <Test
+        prop={5}
+        other={10}>
+    </Test>
+);
+const v = (
+    <Test>
+    </Test>
+);
+const w = (
+    <Test
+        prop={5}
+        other={10} />
+);