-
Notifications
You must be signed in to change notification settings - Fork 492
Feat(SwiftRefactor): Implement CollapseNestedIfStmt refactoring #3260
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,97 @@ | ||||||||||
| //===----------------------------------------------------------------------===// | ||||||||||
| // | ||||||||||
| // This source file is part of the Swift.org open source project | ||||||||||
| // | ||||||||||
| // Copyright (c) 2014 - 2026 Apple Inc. and the Swift project authors | ||||||||||
| // Licensed under Apache License v2.0 with Runtime Library Exception | ||||||||||
| // | ||||||||||
| // See https://swift.org/LICENSE.txt for license information | ||||||||||
| // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors | ||||||||||
| // | ||||||||||
| //===----------------------------------------------------------------------===// | ||||||||||
|
|
||||||||||
| #if compiler(>=6) | ||||||||||
| public import SwiftSyntax | ||||||||||
| #else | ||||||||||
| import SwiftSyntax | ||||||||||
| #endif | ||||||||||
|
|
||||||||||
| /// Collapses a nested `if` statement into a single `if` statement with a | ||||||||||
| /// combined condition list. | ||||||||||
| /// | ||||||||||
| /// ## Before | ||||||||||
| /// | ||||||||||
| /// ```swift | ||||||||||
| /// if a { | ||||||||||
| /// if b { | ||||||||||
| /// print("hello") | ||||||||||
| /// } | ||||||||||
| /// } | ||||||||||
| /// ``` | ||||||||||
| /// | ||||||||||
| /// ## After | ||||||||||
| /// | ||||||||||
| /// ```swift | ||||||||||
| /// if a, b { | ||||||||||
| /// print("hello") | ||||||||||
| /// } | ||||||||||
| /// ``` | ||||||||||
| public struct CollapseNestedIfStmt: SyntaxRefactoringProvider { | ||||||||||
| public static func refactor(syntax outerIf: IfExprSyntax, in context: Void) -> IfExprSyntax { | ||||||||||
| // 1. Ensure the outer `if` has no `else` block. | ||||||||||
| guard outerIf.elseBody == nil else { | ||||||||||
| return outerIf | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // 2. Ensure the outer `if` body has exactly one statement. | ||||||||||
| guard outerIf.body.statements.count == 1, | ||||||||||
| let firstStatement = outerIf.body.statements.first | ||||||||||
| else { | ||||||||||
|
Comment on lines
+47
to
+49
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
| return outerIf | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // 3. Check if that statement is an `IfExprSyntax` (inner `if`). | ||||||||||
| // It could be an `expression` statement containing the `IfExpr` or just the expression itself depending on parsing context, | ||||||||||
| // but typically in a code block it's an expression statement. | ||||||||||
| guard let expressionStmt = firstStatement.item.as(ExpressionStmtSyntax.self), | ||||||||||
| let innerIf = expressionStmt.expression.as(IfExprSyntax.self) | ||||||||||
| else { | ||||||||||
| return outerIf | ||||||||||
| } | ||||||||||
|
Comment on lines
+53
to
+60
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The code doesn’t account for the fact that |
||||||||||
|
|
||||||||||
| // 4. Ensure the inner `if` has no `else` block. | ||||||||||
| guard innerIf.elseBody == nil else { | ||||||||||
| return outerIf | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // 5. Merge conditions. | ||||||||||
| // We need to add a trailing comma to the last condition of the outer `if` | ||||||||||
| // before appending the inner `if`'s conditions. | ||||||||||
| var newConditions = outerIf.conditions | ||||||||||
|
|
||||||||||
| // If the last condition doesn't have a trailing comma, verify we need to add one. | ||||||||||
| // Actually, `ConditionElementListSyntax` elements usually handle commas. | ||||||||||
| // We just need to make sure the last element of the *outer* list has a comma now that it's not last. | ||||||||||
|
Comment on lines
+72
to
+74
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You seem to contradict the first line of this comment in the second line. Could you merge these comments to make them consistent? Probably also the one right after |
||||||||||
|
|
||||||||||
| if var lastOuterCondition = newConditions.last { | ||||||||||
| // Remove existing trailing spaces/trivia before comma to be clean, logic can be refined. | ||||||||||
| // But crucially, we must add a comma. | ||||||||||
|
Comment on lines
+77
to
+78
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the logic can be refined, please do so. |
||||||||||
| if lastOuterCondition.trailingComma == nil { | ||||||||||
| lastOuterCondition.condition = lastOuterCondition.condition.with(\.trailingTrivia, []) | ||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We shouldn’t remove all trailing trivia here. Ideally, we’d move it to the trailing trivia of the comma.
Suggested change
|
||||||||||
| lastOuterCondition.trailingComma = .commaToken(trailingTrivia: .space) | ||||||||||
| // Update the list with the modified condition | ||||||||||
| let lastIndex = newConditions.index(before: newConditions.endIndex) | ||||||||||
| newConditions = newConditions.with(\.[lastIndex], lastOuterCondition) | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // Add inner conditions. | ||||||||||
| // We might need to adjust trivia on the first inner condition to look nice (e.g., remove leading newlines if any). | ||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You don’t seem to be doing what you describe in the comment though. |
||||||||||
| let innerConditions = innerIf.conditions | ||||||||||
| newConditions.append(contentsOf: innerConditions) | ||||||||||
|
|
||||||||||
| // 6. Return new `IfExprSyntax` with combined conditions and inner body. | ||||||||||
| return outerIf.with(\.conditions, newConditions) | ||||||||||
| .with(\.body, innerIf.body) | ||||||||||
| } | ||||||||||
| } | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| //===----------------------------------------------------------------------===// | ||
| // | ||
| // This source file is part of the Swift.org open source project | ||
| // | ||
| // Copyright (c) 2014 - 2026 Apple Inc. and the Swift project authors | ||
| // Licensed under Apache License v2.0 with Runtime Library Exception | ||
| // | ||
| // See https://swift.org/LICENSE.txt for license information | ||
| // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors | ||
| // | ||
| //===----------------------------------------------------------------------===// | ||
|
|
||
| import SwiftParser | ||
| import SwiftRefactor | ||
| import SwiftSyntax | ||
| import SwiftSyntaxBuilder | ||
| import XCTest | ||
| import _SwiftSyntaxTestSupport | ||
|
|
||
| final class CollapseNestedIfStmtTest: XCTestCase { | ||
| func testCollapseNestedIfStmt() throws { | ||
| let tests = [ | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If any of these tests fail, it’s hard to know which one it was. Could you either use the |
||
| ( | ||
| """ | ||
| if a { | ||
| if b { | ||
| print("hello") | ||
| } | ||
| } | ||
| """, | ||
| """ | ||
| if a, b { | ||
| print("hello") | ||
| } | ||
|
Comment on lines
+33
to
+34
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you adjust the indentation here so we get if a, b {
print("hello")
}You can take a look at what we do with |
||
| """ | ||
| ), | ||
| ( | ||
| """ | ||
| if let x = y { | ||
| if let z = w { | ||
| body() | ||
| } | ||
| } | ||
| """, | ||
| """ | ||
| if let x = y, let z = w { | ||
| body() | ||
| } | ||
| """ | ||
| ), | ||
| ( | ||
| """ | ||
| if a, b { | ||
| if c { | ||
| body() | ||
| } | ||
| } | ||
| """, | ||
| """ | ||
| if a, b, c { | ||
| body() | ||
| } | ||
| """ | ||
| ), | ||
| ( | ||
| """ | ||
| if a { | ||
| // comment | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be great if we could retain the comments as much as possible. |
||
| if b { | ||
| body() | ||
| } | ||
| } | ||
| """, | ||
| """ | ||
| if a, b { | ||
| body() | ||
| } | ||
| """ | ||
| ), | ||
| ] | ||
|
|
||
| for (input, expected) in tests { | ||
| let inputSyntax = try XCTUnwrap(ExprSyntax.parse(from: input).as(IfExprSyntax.self)) | ||
| let expectedSyntax = try XCTUnwrap(ExprSyntax.parse(from: expected).as(IfExprSyntax.self)) | ||
|
|
||
| try assertRefactor(inputSyntax, context: (), provider: CollapseNestedIfStmt.self, expected: expectedSyntax) | ||
| } | ||
| } | ||
|
|
||
| func testCollapseNestedIfStmtFails() throws { | ||
| let tests = [ | ||
| """ | ||
| if a { | ||
| print("start") | ||
| if b { | ||
| print("hello") | ||
| } | ||
| } | ||
| """, | ||
| """ | ||
| if a { | ||
| if b { | ||
| print("hello") | ||
| } else { | ||
| print("else") | ||
| } | ||
| } | ||
| """, | ||
| """ | ||
| if a { | ||
| if b { | ||
| print("hello") | ||
| } | ||
| } else { | ||
| print("outer else") | ||
| } | ||
| """, | ||
| ] | ||
|
|
||
| for input in tests { | ||
| let inputSyntax = try XCTUnwrap(ExprSyntax.parse(from: input).as(IfExprSyntax.self)) | ||
| // Expect same output as input (no change) | ||
| try assertRefactor(inputSyntax, context: (), provider: CollapseNestedIfStmt.self, expected: inputSyntax) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| extension ExprSyntax { | ||
| static func parse(from source: String) -> ExprSyntax { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| var parser = Parser(source) | ||
| return ExprSyntax.parse(from: &parser) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I personally wouldn’t add numbered lists here because it makes it harder to insert more checks in the future, if necessary. At which point this comment becomes trivial because it describes exactly what’s done below and can thus be removed.