Files
SwiftFormat/Sources/Rules/RedundantClosure.swift
2025-10-04 08:46:46 +01:00

209 lines
11 KiB
Swift

//
// RedundantClosure.swift
// SwiftFormat
//
// Created by Cal Stephens on 9/28/21.
// Copyright © 2024 Nick Lockwood. All rights reserved.
//
import Foundation
public extension FormatRule {
static let redundantClosure = FormatRule(
help: """
Removes redundant closures bodies, containing a single statement,
which are called immediately.
""",
orderAfter: [.redundantReturn]
) { formatter in
formatter.forEach(.startOfScope("{")) { closureStartIndex, _ in
var startIndex = closureStartIndex
if formatter.isStartOfClosure(at: closureStartIndex),
var closureEndIndex = formatter.endOfScope(at: closureStartIndex),
// Closures that are called immediately are redundant
// (as long as there's exactly one statement inside them)
var closureCallOpenParenIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: closureEndIndex),
var closureCallCloseParenIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: closureCallOpenParenIndex),
formatter.token(at: closureCallOpenParenIndex) == .startOfScope("("),
formatter.token(at: closureCallCloseParenIndex) == .endOfScope(")"),
// Make sure to exclude closures that are completely empty,
// because removing them could break the build.
formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: closureStartIndex) != closureEndIndex
{
// Whether or not this closure has a single, simple expression in its body.
// These closures can always be simplified / removed regardless of the context.
let hasSingleSimpleExpression = formatter.blockBodyHasSingleStatement(
atStartOfScope: closureStartIndex,
includingConditionalStatements: false,
includingReturnStatements: true
)
// Whether or not this closure has a single if/switch expression in its body.
// Since if/switch expressions are only valid in the `return` position or as an `=` assignment,
// these closures can only sometimes be simplified / removed.
let hasSingleConditionalExpression = !hasSingleSimpleExpression &&
formatter.blockBodyHasSingleStatement(
atStartOfScope: closureStartIndex,
includingConditionalStatements: true,
includingReturnStatements: true,
includingReturnInConditionalStatements: false
)
guard hasSingleSimpleExpression || hasSingleConditionalExpression else {
return
}
// This rule also doesn't support closures with an `in` token.
// - We can't just remove this, because it could have important type information.
// For example, `let double = { () -> Double in 100 }()` and `let double = 100` have different types.
// - We could theoretically support more sophisticated checks / transforms here,
// but this seems like an edge case so we choose not to handle it.
for inIndex in closureStartIndex ... closureEndIndex
where formatter.token(at: inIndex) == .keyword("in")
{
if !formatter.indexIsWithinNestedClosure(inIndex, startOfScopeIndex: closureStartIndex) {
return
}
}
// If the closure calls a single function, which throws or returns `Never`,
// then removing the closure will cause a compilation failure.
// - We maintain a list of known functions that return `Never`.
// We could expand this to be user-provided if necessary.
for i in closureStartIndex ... closureEndIndex {
switch formatter.tokens[i] {
case .identifier("fatalError"), .identifier("preconditionFailure"), .keyword("throw"):
if !formatter.indexIsWithinNestedClosure(i, startOfScopeIndex: closureStartIndex) {
return
}
default:
break
}
}
// If closure is preceded by try and/or await then remove those too
if let prevIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: startIndex, if: {
$0 == .keyword("await")
}) {
startIndex = prevIndex
}
if let prevIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: startIndex, if: {
$0 == .keyword("try")
}) {
startIndex = prevIndex
}
// Since if/switch expressions are only valid in the `return` position or as an `=` assignment,
// these closures can only sometimes be simplified / removed.
if hasSingleConditionalExpression {
// Find the `{` start of scope or `=` and verify that the entire following expression consists of just this closure.
var startOfScopeContainingClosure = formatter.startOfScope(at: startIndex)
var assignmentBeforeClosure = formatter.index(of: .operator("=", .infix), before: startIndex)
if let assignmentBeforeClosure, formatter.isConditionalStatement(at: assignmentBeforeClosure) {
// Not valid to use conditional expression directly in condition body
return
}
let potentialStartOfExpressionContainingClosure: Int?
switch (startOfScopeContainingClosure, assignmentBeforeClosure) {
case (nil, nil):
potentialStartOfExpressionContainingClosure = nil
case (.some(let startOfScope), nil):
guard formatter.tokens[startOfScope] == .startOfScope("{") else { return }
potentialStartOfExpressionContainingClosure = startOfScope
case (nil, let .some(assignmentBeforeClosure)):
potentialStartOfExpressionContainingClosure = assignmentBeforeClosure
case let (.some(startOfScope), .some(assignmentBeforeClosure)):
potentialStartOfExpressionContainingClosure = max(startOfScope, assignmentBeforeClosure)
}
if let potentialStartOfExpressionContainingClosure {
guard var startOfExpressionIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: potentialStartOfExpressionContainingClosure)
else { return }
// Skip over any return token that may be present
if formatter.tokens[startOfExpressionIndex] == .keyword("return"),
let nextTokenIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: startOfExpressionIndex)
{
startOfExpressionIndex = nextTokenIndex
}
// Parse the expression and require that entire expression is simply just this closure.
guard let expressionRange = formatter.parseExpressionRange(startingAt: startOfExpressionIndex),
expressionRange == startIndex ... closureCallCloseParenIndex
else { return }
}
}
// If the closure is a property with an explicit `Void` type,
// we can't remove the closure since the build would break
// if the method is `@discardableResult`
// https://github.com/nicklockwood/SwiftFormat/issues/1236
if let equalsIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: startIndex),
formatter.token(at: equalsIndex) == .operator("=", .infix),
let colonIndex = formatter.index(of: .delimiter(":"), before: equalsIndex),
let nextIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: colonIndex),
formatter.endOfVoidType(at: nextIndex) != nil
{
return
}
// First we remove the spaces and linebreaks between the { } and the remainder of the closure body
// - This requires a bit of bookkeeping, but makes sure we don't remove any
// whitespace characters outside of the closure itself
while formatter.token(at: closureStartIndex + 1)?.isSpaceOrLinebreak == true {
formatter.removeToken(at: closureStartIndex + 1)
closureCallOpenParenIndex -= 1
closureCallCloseParenIndex -= 1
closureEndIndex -= 1
}
while formatter.token(at: closureEndIndex - 1)?.isSpaceOrLinebreak == true {
formatter.removeToken(at: closureEndIndex - 1)
closureCallOpenParenIndex -= 1
closureCallCloseParenIndex -= 1
closureEndIndex -= 1
}
// remove the trailing }() tokens, working backwards to not invalidate any indices
formatter.removeToken(at: closureCallCloseParenIndex)
formatter.removeToken(at: closureCallOpenParenIndex)
formatter.removeToken(at: closureEndIndex)
// Remove the initial return token, and any trailing space, if present.
if let returnIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: closureStartIndex),
formatter.token(at: returnIndex)?.string == "return"
{
while formatter.token(at: returnIndex + 1)?.isSpaceOrLinebreak == true {
formatter.removeToken(at: returnIndex + 1)
}
formatter.removeToken(at: returnIndex)
}
// Finally, remove then open `{` token
formatter.removeTokens(in: startIndex ... closureStartIndex)
}
}
} examples: {
"""
```diff
- let foo = { Foo() }()
+ let foo = Foo()
```
```diff
- lazy var bar = {
- Bar(baaz: baaz,
- quux: quux)
- }()
+ lazy var bar = Bar(baaz: baaz,
+ quux: quux)
```
"""
}
}