Files
SwiftFormat/Sources/Rules/TrailingClosures.swift

188 lines
8.7 KiB
Swift

//
// TrailingClosures.swift
// SwiftFormat
//
// Created by Nick Lockwood on 1/17/17.
// Copyright © 2024 Nick Lockwood. All rights reserved.
//
import Foundation
public extension FormatRule {
/// Convert closure arguments to trailing closure syntax where possible
static let trailingClosures = FormatRule(
help: "Use trailing closure syntax where applicable.",
options: ["trailing-closures", "never-trailing"]
) { formatter in
let useTrailing = Set([
"async", "asyncAfter", "sync", "autoreleasepool",
] + formatter.options.trailingClosures)
let nonTrailing = Set([
"performBatchUpdates",
"expect", // Special case to support autoclosure arguments in the Nimble framework
] + formatter.options.neverTrailing)
formatter.forEach(.startOfScope("(")) { functionOpenParen, _ in
guard let identifierIndex = formatter.parseFunctionIdentifier(beforeStartOfScope: functionOpenParen) else { return }
let name = formatter.tokens[identifierIndex].string
guard !nonTrailing.contains(name), !formatter.isConditionalStatement(at: functionOpenParen),
!formatter.isAttribute(at: identifierIndex)
else {
return
}
// Parse all arguments to detect multiple trailing closures
let arguments = formatter.parseFunctionCallArguments(startOfScope: functionOpenParen)
let trailingClosures = arguments.suffix(while: { arg in
let range = arg.valueRange
guard let first = formatter.index(of: .nonSpaceOrCommentOrLinebreak, in: range.lowerBound ..< range.upperBound + 1),
let last = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: range.upperBound + 1, if: { _ in true }),
formatter.tokens[first] == .startOfScope("{"),
formatter.tokens[last] == .endOfScope("}"),
formatter.index(of: .endOfScope("}"), after: first) == last
else {
return false
}
return true
})
// Ensure the function doesn't already have a trailing closure
guard let functionCallClosingParen = formatter.endOfScope(at: functionOpenParen),
formatter.next(.nonSpaceOrCommentOrLinebreak, after: functionCallClosingParen) != .startOfScope("{")
else { return }
// Handle a single trailing closure
if trailingClosures.count == 1 {
guard trailingClosures[0].label == nil || useTrailing.contains(name) else { return }
let closure = trailingClosures[0]
let range = closure.valueRange
guard let closingBraceIndex = formatter.index(of: .nonSpaceOrComment, before: functionCallClosingParen, if: { $0 == .endOfScope("}") }),
let openingBraceIndex = formatter.index(of: .startOfScope("{"), before: closingBraceIndex),
formatter.index(of: .endOfScope("}"), before: openingBraceIndex) == nil,
var startIndex = formatter.index(of: .nonSpaceOrLinebreak, before: openingBraceIndex)
else { return }
switch formatter.tokens[startIndex] {
case .delimiter(","), .startOfScope("("):
break
case .delimiter(":"):
if let commaIndex = formatter.index(of: .delimiter(","), before: openingBraceIndex) {
startIndex = commaIndex
} else if formatter.index(of: .startOfScope("("), before: openingBraceIndex) == functionOpenParen {
startIndex = functionOpenParen
} else {
return
}
default:
return
}
let wasParen = (startIndex == functionOpenParen)
formatter.removeParen(at: functionCallClosingParen)
formatter.replaceTokens(in: startIndex ..< openingBraceIndex, with:
wasParen ? [.space(" ")] : [.endOfScope(")"), .space(" ")])
return
}
else if trailingClosures.count >= 2 {
guard trailingClosures[0].label == nil,
trailingClosures.dropFirst().allSatisfy({ $0.label != nil })
else { return }
// Remove the closing paren and any trailing comma.
// If the closing paren is on its own line, remove the whole line.
let lineWithClosingParen = formatter.startOfLine(at: functionCallClosingParen) ... formatter.endOfLine(at: functionCallClosingParen)
let tokenBeforeClosingParen = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: functionCallClosingParen)
if formatter.tokens(in: lineWithClosingParen)?.string.trimmingCharacters(in: .whitespacesAndNewlines) == ")" {
formatter.removeTokens(in: lineWithClosingParen)
} else {
formatter.removeToken(at: functionCallClosingParen)
}
if let tokenBeforeClosingParen, formatter.tokens[tokenBeforeClosingParen] == .delimiter(",") {
formatter.removeToken(at: tokenBeforeClosingParen)
}
// Remove the comma before each closure
for (index, closure) in trailingClosures.enumerated().reversed() {
let closureRange = closure.valueRange.autoUpdating(in: formatter)
let closureOnOneLine = formatter.onSameLine(closureRange.lowerBound, closureRange.upperBound)
if index == trailingClosures.indices.first,
let indexBeforeFirstClosure = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: closure.valueRange.lowerBound)
{
// If all of the arguments were closures, remove the parens completely
if formatter.tokens[indexBeforeFirstClosure] == .startOfScope("(") {
formatter.removeToken(at: indexBeforeFirstClosure)
}
// Otherwise, the previous comma becomes the new closing paren
else if formatter.tokens[indexBeforeFirstClosure] == .delimiter(",") {
formatter.replaceToken(at: indexBeforeFirstClosure, with: .endOfScope(")"))
}
// Unwrap the line so the trailing closure label immediately follows the closing paren
if !formatter.onSameLine(indexBeforeFirstClosure, closure.valueRange.lowerBound) {
formatter.unwrapLine(before: closureRange.lowerBound, preservingComments: true)
}
}
else if let closureLabelIndex = closure.labelIndex?.autoUpdating(in: formatter),
let commaIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: closureLabelIndex),
formatter.tokens[commaIndex] == .delimiter(",")
{
let commaWasOnPreviousLine = !formatter.onSameLine(commaIndex, closureLabelIndex.index)
formatter.removeToken(at: commaIndex)
// Unwrap the line so the trailing closure label immediately follows the previous end of scope
if commaWasOnPreviousLine {
formatter.unwrapLine(before: closureLabelIndex.index, preservingComments: true)
}
}
// If the closure was originally written on a single line, wrap it now.
if closureOnOneLine {
formatter.wrapLine(before: closureRange.upperBound)
formatter.wrapLine(before: closureRange.lowerBound + 1)
}
}
}
}
} examples: {
"""
```diff
- DispatchQueue.main.async(execute: { ... })
+ DispatchQueue.main.async {
```
```diff
- let foo = bar.map({ ... }).joined()
+ let foo = bar.map { ... }.joined()
```
```diff
- withAnimation(.spring, {
- isVisible = true
- }, completion: {
- handleCompletion()
- })
+ withAnimation(.spring) {
+ isVisible = true
+ } completion: {
+ handleCompletion()
+ }
```
"""
}
}
extension Collection {
func suffix(while condition: (Element) -> Bool) -> [Element] {
reversed().prefix(while: condition).reversed()
}
}