Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/SwiftRefactor/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ add_swift_syntax_library(SwiftRefactor
AddSeparatorsToIntegerLiteral.swift
CallLikeSyntax.swift
CallToTrailingClosures.swift
CollapseNestedIfStmt.swift
ConvertComputedPropertyToStored.swift
ConvertComputedPropertyToZeroParameterFunction.swift
ConvertStoredPropertyToComputed.swift
Expand Down
97 changes: 97 additions & 0 deletions Sources/SwiftRefactor/CollapseNestedIfStmt.swift
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.
Copy link
Member

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.

guard outerIf.body.statements.count == 1,
let firstStatement = outerIf.body.statements.first
else {
Comment on lines +47 to +49
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
guard outerIf.body.statements.count == 1,
let firstStatement = outerIf.body.statements.first
else {
guard let onlyStatement = outerIf.body.statements.only else {

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code doesn’t account for the fact that firstStatement might be an IfExprSyntax directly, right? So it mismatches the comment.


// 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
Copy link
Member

Choose a reason for hiding this comment

The 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 5. Merge conditions.


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
Copy link
Member

Choose a reason for hiding this comment

The 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, [])
Copy link
Member

Choose a reason for hiding this comment

The 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.condition = lastOuterCondition.condition.with(\.trailingTrivia, [])
lastOuterCondition.trailingComma = .commaToken(trailingTrivia: lastOuterCondition.condition.trailingTrivia)
lastOuterCondition.condition.trailingTrivia = Trivia()

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).
Copy link
Member

Choose a reason for hiding this comment

The 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)
}
}
133 changes: 133 additions & 0 deletions Tests/SwiftRefactorTest/CollapseNestedIfStmtTest.swift
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 = [
Copy link
Member

Choose a reason for hiding this comment

The 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 #line trick we use elsewhere or write an assertCollapseIfStmt function that you call for different inputs. I’d prefer the latter option.

(
"""
if a {
if b {
print("hello")
}
}
""",
"""
if a, b {
print("hello")
}
Comment on lines +33 to +34
Copy link
Member

Choose a reason for hiding this comment

The 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 IndentationRemover in SourceKit-LSP.

"""
),
(
"""
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
Copy link
Member

Choose a reason for hiding this comment

The 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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExprSyntax conforms to ExpressibleByStringLiteral, so this isn’t necessary if you convert the string literals straight to an ExprSyntax.

var parser = Parser(source)
return ExprSyntax.parse(from: &parser)
}
}