From f77119933f2bc85bea115f8cc24af18dc6e38b29 Mon Sep 17 00:00:00 2001 From: Steffen Heil | secforge Date: Sun, 26 Oct 2025 16:51:38 +0100 Subject: [PATCH] feat: add conditionalExpression.indentStyle configuration option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new configuration option `conditionalExpression.indentStyle` with two modes: - "align" (default): All branches of nested ternaries align at the same indentation level Example: ``` const result = condition1 ? value1 : condition2 ? value2 : value3; ``` - "structural": Nested ternaries increase indentation to reflect nesting structure Example: ``` const result = condition1 ? value1 : condition2 ? value2 : value3; ``` The structural mode properly handles wrapper nodes (parentheses, type assertions, satisfies expressions, non-null assertions) by traversing through them to find the top-most conditional expression in the chain, ensuring consistent indentation. Implementation includes: - Configuration schema updates with new enum type - Helper function to traverse wrapper nodes in structural mode - Refactored conditional expression generation for both modes - Comprehensive test specs for align and structural indentation styles 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- deployment/schema.json | 15 +++ src/configuration/builder.rs | 7 +- src/configuration/resolve_config.rs | 1 + src/configuration/types.rs | 15 +++ src/generation/generate.rs | 103 ++++++++++----- ...onditionalExpression_IndentStyle_Align.txt | 118 ++++++++++++++++++ ...ionalExpression_IndentStyle_Structural.txt | 118 ++++++++++++++++++ 7 files changed, 345 insertions(+), 32 deletions(-) create mode 100644 tests/specs/expressions/ConditionalExpression/ConditionalExpression_IndentStyle_Align.txt create mode 100644 tests/specs/expressions/ConditionalExpression/ConditionalExpression_IndentStyle_Structural.txt diff --git a/deployment/schema.json b/deployment/schema.json index 50c4f059..5bc3e3c1 100644 --- a/deployment/schema.json +++ b/deployment/schema.json @@ -153,6 +153,18 @@ "description": "Forces no braces when the header is one line and body is one line. Otherwise forces braces." }] }, + "ternaryIndentStyle": { + "description": "How ternary conditional expressions should be indented.", + "type": "string", + "default": "align", + "oneOf": [{ + "const": "align", + "description": "Align the ? and : operators under the condition." + }, { + "const": "structural", + "description": "Use structural indentation for the ternary expression." + }] + }, "bracePosition": { "description": "Where to place the opening brace.", "type": "string", @@ -1181,6 +1193,9 @@ "conditionalExpression.operatorPosition": { "$ref": "#/definitions/operatorPosition" }, + "conditionalExpression.indentStyle": { + "$ref": "#/definitions/ternaryIndentStyle" + }, "conditionalType.operatorPosition": { "$ref": "#/definitions/operatorPosition" }, diff --git a/src/configuration/builder.rs b/src/configuration/builder.rs index aeaaa4d3..a98c4968 100644 --- a/src/configuration/builder.rs +++ b/src/configuration/builder.rs @@ -822,6 +822,10 @@ impl ConfigurationBuilder { self.insert("conditionalExpression.operatorPosition", value.to_string().into()) } + pub fn conditional_expression_indent_style(&mut self, value: TernaryIndentStyle) -> &mut Self { + self.insert("conditionalExpression.indentStyle", value.to_string().into()) + } + pub fn conditional_type_operator_position(&mut self, value: OperatorPosition) -> &mut Self { self.insert("conditionalType.operatorPosition", value.to_string().into()) } @@ -1122,6 +1126,7 @@ mod tests { .arrow_function_use_parentheses(UseParentheses::Maintain) .binary_expression_line_per_expression(false) .conditional_expression_line_per_expression(true) + .conditional_expression_indent_style(TernaryIndentStyle::Align) .member_expression_line_per_expression(false) .type_literal_separator_kind(SemiColonOrComma::Comma) .type_literal_separator_kind_single_line(SemiColonOrComma::Comma) @@ -1297,7 +1302,7 @@ mod tests { .while_statement_space_around(true); let inner_config = config.get_inner_config(); - assert_eq!(inner_config.len(), 182); + assert_eq!(inner_config.len(), 183); let diagnostics = resolve_config(inner_config, &Default::default()).diagnostics; assert_eq!(diagnostics.len(), 0); } diff --git a/src/configuration/resolve_config.rs b/src/configuration/resolve_config.rs index 486b2365..1a2a4fa8 100644 --- a/src/configuration/resolve_config.rs +++ b/src/configuration/resolve_config.rs @@ -202,6 +202,7 @@ pub fn resolve_config(config: ConfigKeyMap, global_config: &GlobalConfiguration) /* operator position */ binary_expression_operator_position: get_value(&mut config, "binaryExpression.operatorPosition", operator_position, &mut diagnostics), conditional_expression_operator_position: get_value(&mut config, "conditionalExpression.operatorPosition", operator_position, &mut diagnostics), + conditional_expression_indent_style: get_value(&mut config, "conditionalExpression.indentStyle", TernaryIndentStyle::Align, &mut diagnostics), conditional_type_operator_position: get_value(&mut config, "conditionalType.operatorPosition", operator_position, &mut diagnostics), /* single body position */ if_statement_single_body_position: get_value(&mut config, "ifStatement.singleBodyPosition", single_body_position, &mut diagnostics), diff --git a/src/configuration/types.rs b/src/configuration/types.rs index 1e67d7b7..172fcd39 100644 --- a/src/configuration/types.rs +++ b/src/configuration/types.rs @@ -137,6 +137,19 @@ pub enum OperatorPosition { generate_str_to_from![OperatorPosition, [Maintain, "maintain"], [SameLine, "sameLine"], [NextLine, "nextLine"]]; +/// How to indent ternary expression branches. +#[derive(Clone, PartialEq, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TernaryIndentStyle { + /// Align all ternary branches at the same indentation level (original behavior). + Align, + /// Add structural indentation for nested ternary branches. + Structural, +} + +generate_str_to_from![TernaryIndentStyle, [Align, "align"], [Structural, "structural"]]; + + /// 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")] @@ -466,6 +479,8 @@ pub struct Configuration { pub binary_expression_operator_position: OperatorPosition, #[serde(rename = "conditionalExpression.operatorPosition")] pub conditional_expression_operator_position: OperatorPosition, + #[serde(rename = "conditionalExpression.indentStyle")] + pub conditional_expression_indent_style: TernaryIndentStyle, #[serde(rename = "conditionalType.operatorPosition")] pub conditional_type_operator_position: OperatorPosition, /* single body position */ diff --git a/src/generation/generate.rs b/src/generation/generate.rs index d5090dc9..083744d9 100644 --- a/src/generation/generate.rs +++ b/src/generation/generate.rs @@ -2348,6 +2348,7 @@ fn gen_class_expr<'a>(node: &ClassExpr<'a>, context: &mut Context<'a>) -> PrintI } fn gen_conditional_expr<'a>(node: &CondExpr<'a>, context: &mut Context<'a>) -> PrintItems { + let is_structural_mode = context.config.conditional_expression_indent_style == TernaryIndentStyle::Structural; let question_token = context.token_finder.get_first_operator_after(&node.test, "?").unwrap(); let colon_token = context.token_finder.get_first_operator_after(&node.cons, ":").unwrap(); let line_per_expression = context.config.conditional_expression_line_per_expression; @@ -2376,14 +2377,18 @@ fn gen_conditional_expr<'a>(node: &CondExpr<'a>, context: &mut Context<'a>) -> P let top_most_il = top_most_data.il; - items.extend(ir_helpers::new_line_group(with_queued_indent({ + items.extend({ let mut items = gen_node(node.test.into(), context); if question_position == OperatorPosition::SameLine { items.push_sc(sc!(" ?")); } items.extend(question_comment_items.trailing_line); - items - }))); + if is_structural_mode && no_conditional_or_alternate(node.into()) { + items + } else { + ir_helpers::new_line_group(with_queued_indent(items)) + } + }); items.extend(question_comment_items.previous_lines); @@ -2416,19 +2421,32 @@ fn gen_conditional_expr<'a>(node: &CondExpr<'a>, context: &mut Context<'a>) -> P items }); - items.push_condition({ - let mut items = PrintItems::new(); - items.extend(question_comment_items.leading_line); + let cons_and_alt_section = { + let mut section_items = PrintItems::new(); + section_items.extend(question_comment_items.leading_line); if question_position == OperatorPosition::NextLine { - items.push_sc(sc!("? ")); + section_items.push_sc(sc!("? ")); } - items.extend(gen_node(node.cons.into(), context)); + let cons_items = if is_structural_mode && top_most_data.is_top_most { + ir_helpers::new_line_group(with_queued_indent(gen_node(node.cons.into(), context))) + } else { + gen_node(node.cons.into(), context) + }; + section_items.extend(cons_items); if colon_position == OperatorPosition::SameLine { - items.push_sc(sc!(" :")); + section_items.push_sc(sc!(" :")); } - items.extend(colon_comment_items.trailing_line); - indent_if_sol_and_same_indent_as_top_most(ir_helpers::new_line_group(items), top_most_il) - }); + section_items.extend(colon_comment_items.trailing_line); + section_items + }; + if is_structural_mode { + items.extend(cons_and_alt_section); + } else { + items.push_condition(indent_if_sol_and_same_indent_as_top_most( + ir_helpers::new_line_group(cons_and_alt_section), + top_most_il, + )); + } items.extend(colon_comment_items.previous_lines); @@ -2443,16 +2461,19 @@ fn gen_conditional_expr<'a>(node: &CondExpr<'a>, context: &mut Context<'a>) -> P items.push_signal(Signal::SpaceOrNewLine); } - items.push_condition({ - let mut items = PrintItems::new(); - items.extend(colon_comment_items.leading_line); - if colon_position == OperatorPosition::NextLine { - items.push_sc(sc!(": ")); - } - items.push_info(before_alternate_ln); - items.extend(gen_node(node.alt.into(), context)); - indent_if_sol_and_same_indent_as_top_most(ir_helpers::new_line_group(items), top_most_il) - }); + let mut alt_items = PrintItems::new(); + alt_items.extend(colon_comment_items.leading_line); + if colon_position == OperatorPosition::NextLine { + alt_items.push_sc(sc!(": ")); + } + alt_items.push_info(before_alternate_ln); + if is_structural_mode { + alt_items.extend(ir_helpers::new_line_group(with_queued_indent(gen_node(node.alt.into(), context)))); + items.extend(alt_items); + } else { + alt_items.extend(gen_node(node.alt.into(), context)); + items.push_condition(indent_if_sol_and_same_indent_as_top_most(ir_helpers::new_line_group(alt_items), top_most_il)); + } items.push_info(end_ln); if let Some(reevaluation) = multi_line_reevaluation { @@ -2476,20 +2497,40 @@ fn gen_conditional_expr<'a>(node: &CondExpr<'a>, context: &mut Context<'a>) -> P is_top_most: bool, } + fn no_conditional_or_alternate(node: Node) -> bool { + match node.parent() { + Some(Node::CondExpr(parent_conditional)) => parent_conditional.alt.range() == node.range(), + _ => true, + } + } + fn get_top_most_data<'a>(node: &CondExpr<'a>, context: &mut Context<'a>) -> TopMostData { - // The "top most" node in nested conditionals follows the ancestors up through - // the alternate expressions. + // The "top most" node in nested conditionals follows the ancestors up through the alternate expressions. let mut top_most_node = node; + // In structural mode, skip over wrapper nodes (parens, type assertions, etc.) to find the top-most conditional in the chain + let mut current_start = node.start(); + let is_structural_mode = context.config.conditional_expression_indent_style == TernaryIndentStyle::Structural; for ancestor in context.parent_stack.iter() { - if let Node::CondExpr(parent) = ancestor { - if parent.alt.start() == top_most_node.start() { - top_most_node = parent; - } else { - break; + match ancestor { + node @ (Node::CondExpr(_) | Node::ParenExpr(_) | Node::TsAsExpr(_) | Node::TsSatisfiesExpr(_) | Node::TsNonNullExpr(_)) => { + let (outer_start, inner_start) = match node { + Node::CondExpr(parent) => (parent.start(), parent.alt.start()), + Node::ParenExpr(paren) if is_structural_mode => (paren.start(), paren.expr.start()), + Node::TsAsExpr(as_expr) if is_structural_mode => (as_expr.start(), as_expr.expr.start()), + Node::TsSatisfiesExpr(satisfies_expr) if is_structural_mode => (satisfies_expr.start(), satisfies_expr.expr.start()), + Node::TsNonNullExpr(non_null_expr) if is_structural_mode => (non_null_expr.start(), non_null_expr.expr.start()), + _ => break, + }; + if inner_start != current_start { + break; + } + if let Node::CondExpr(parent) = node { + top_most_node = parent; + } + current_start = outer_start; } - } else { - break; + _ => break, } } diff --git a/tests/specs/expressions/ConditionalExpression/ConditionalExpression_IndentStyle_Align.txt b/tests/specs/expressions/ConditionalExpression/ConditionalExpression_IndentStyle_Align.txt new file mode 100644 index 00000000..784ec9c0 --- /dev/null +++ b/tests/specs/expressions/ConditionalExpression/ConditionalExpression_IndentStyle_Align.txt @@ -0,0 +1,118 @@ +~~ conditionalExpression.indentStyle: align ~~ +== should handle simple ternary with align indentation == +const result = condition + ? value1 + : value2; + +[expect] +const result = condition + ? value1 + : value2; + +== should handle nested ternary with align indentation == +const result = condition1 + ? value1 + : condition2 + ? value2 + : condition3 + ? value3 + : value4; + +[expect] +const result = condition1 + ? value1 + : condition2 + ? value2 + : condition3 + ? value3 + : value4; + +== should handle deeply nested ternary == +const test = this.log(`long text here`) + ? this.log(`another long text`) + : this.log(`third long text`) + ? this.log(`fourth long text`) + ? this.log(`fifth long text`) + : this.log(`sixth long text`) + : this.log(`seventh long text`); + +[expect] +const test = this.log(`long text here`) + ? this.log(`another long text`) + : this.log(`third long text`) + ? this.log(`fourth long text`) + ? this.log(`fifth long text`) + : this.log(`sixth long text`) + : this.log(`seventh long text`); + +== should handle ternary with function calls == +const value = isValid(data) + ? processData(data) + : hasFallback(data) + ? getFallback(data) + : getDefault(); + +[expect] +const value = isValid(data) + ? processData(data) + : hasFallback(data) + ? getFallback(data) + : getDefault(); + +== should handle parenthesized nested ternary == +const result = condition1 + ? value1 + : (condition2 + ? value2 + : condition3 + ? value3 + : value4); + +[expect] +const result = condition1 + ? value1 + : (condition2 + ? value2 + : condition3 + ? value3 + : value4); + +== should handle nested ternary wrapped in assertion == +const outcome = condition1 + ? value1 + : ((condition2 + ? value2 + : condition3 + ? value3 + : value4) as Result); + +[expect] +const outcome = condition1 + ? value1 + : ((condition2 + ? value2 + : condition3 + ? value3 + : value4) as Result); + +== should handle ternary in else branch == +if (condition) { + doSomething(); +} else { + const result = check1 + ? value1 + : check2 + ? value2 + : value3; +} + +[expect] +if (condition) { + doSomething(); +} else { + const result = check1 + ? value1 + : check2 + ? value2 + : value3; +} diff --git a/tests/specs/expressions/ConditionalExpression/ConditionalExpression_IndentStyle_Structural.txt b/tests/specs/expressions/ConditionalExpression/ConditionalExpression_IndentStyle_Structural.txt new file mode 100644 index 00000000..e3ca7a5b --- /dev/null +++ b/tests/specs/expressions/ConditionalExpression/ConditionalExpression_IndentStyle_Structural.txt @@ -0,0 +1,118 @@ +~~ conditionalExpression.indentStyle: structural ~~ +== should handle simple ternary with structural indentation == +const result = condition + ? value1 + : value2; + +[expect] +const result = condition + ? value1 + : value2; + +== should handle nested ternary with structural indentation == +const result = condition1 + ? value1 + : condition2 + ? value2 + : condition3 + ? value3 + : value4; + +[expect] +const result = condition1 + ? value1 + : condition2 + ? value2 + : condition3 + ? value3 + : value4; + +== should handle deeply nested ternary == +const test = this.log(`long text here`) + ? this.log(`another long text`) + : this.log(`third long text`) + ? this.log(`fourth long text`) + ? this.log(`fifth long text`) + : this.log(`sixth long text`) + : this.log(`seventh long text`); + +[expect] +const test = this.log(`long text here`) + ? this.log(`another long text`) + : this.log(`third long text`) + ? this.log(`fourth long text`) + ? this.log(`fifth long text`) + : this.log(`sixth long text`) + : this.log(`seventh long text`); + +== should handle ternary with function calls == +const value = isValid(data) + ? processData(data) + : hasFallback(data) + ? getFallback(data) + : getDefault(); + +[expect] +const value = isValid(data) + ? processData(data) + : hasFallback(data) + ? getFallback(data) + : getDefault(); + +== should handle parenthesized nested ternary == +const result = condition1 + ? value1 + : (condition2 + ? value2 + : condition3 + ? value3 + : value4); + +[expect] +const result = condition1 + ? value1 + : (condition2 + ? value2 + : condition3 + ? value3 + : value4); + +== should handle nested ternary wrapped in assertion == +const outcome = condition1 + ? value1 + : ((condition2 + ? value2 + : condition3 + ? value3 + : value4) as Result); + +[expect] +const outcome = condition1 + ? value1 + : ((condition2 + ? value2 + : condition3 + ? value3 + : value4) as Result); + +== should handle ternary in else branch == +if (condition) { + doSomething(); +} else { + const result = check1 + ? value1 + : check2 + ? value2 + : value3; +} + +[expect] +if (condition) { + doSomething(); +} else { + const result = check1 + ? value1 + : check2 + ? value2 + : value3; +}