mirror of
https://github.com/nicklockwood/SwiftFormat.git
synced 2026-05-17 10:30:35 +00:00
321 lines
15 KiB
Swift
321 lines
15 KiB
Swift
//
|
|
// PreferForLoop.swift
|
|
// SwiftFormat
|
|
//
|
|
// Created by Cal Stephens on 8/12/23.
|
|
// Copyright © 2024 Nick Lockwood. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
public extension FormatRule {
|
|
static let preferForLoop = FormatRule(
|
|
help: "Convert functional `forEach` calls to for loops.",
|
|
options: ["anonymous-for-each", "single-line-for-each"]
|
|
) { formatter in
|
|
formatter.forEach(.identifier("forEach")) { forEachIndex, _ in
|
|
// Make sure this is a function call preceded by a `.`
|
|
guard let functionCallDotIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: forEachIndex),
|
|
formatter.tokens[functionCallDotIndex] == .operator(".", .infix),
|
|
let indexAfterForEach = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: forEachIndex),
|
|
let indexBeforeFunctionCallDot = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: functionCallDotIndex)
|
|
else { return }
|
|
|
|
// Parse either `{ ... }` or `({ ... })`
|
|
let forEachCallOpenParenIndex: Int?
|
|
let closureOpenBraceIndex: Int
|
|
let closureCloseBraceIndex: Int
|
|
let forEachCallCloseParenIndex: Int?
|
|
|
|
switch formatter.tokens[indexAfterForEach] {
|
|
case .startOfScope("{"):
|
|
guard let endOfClosureScope = formatter.endOfScope(at: indexAfterForEach) else { return }
|
|
|
|
forEachCallOpenParenIndex = nil
|
|
closureOpenBraceIndex = indexAfterForEach
|
|
closureCloseBraceIndex = endOfClosureScope
|
|
forEachCallCloseParenIndex = nil
|
|
|
|
case .startOfScope("("):
|
|
guard let endOfFunctionCall = formatter.endOfScope(at: indexAfterForEach),
|
|
let indexAfterOpenParen = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: indexAfterForEach),
|
|
formatter.tokens[indexAfterOpenParen] == .startOfScope("{"),
|
|
let endOfClosureScope = formatter.endOfScope(at: indexAfterOpenParen)
|
|
else { return }
|
|
|
|
forEachCallOpenParenIndex = indexAfterForEach
|
|
closureOpenBraceIndex = indexAfterOpenParen
|
|
closureCloseBraceIndex = endOfClosureScope
|
|
forEachCallCloseParenIndex = endOfFunctionCall
|
|
|
|
default:
|
|
return
|
|
}
|
|
|
|
// Abort early for single-line loops
|
|
guard !formatter.options.preserveSingleLineForEach || formatter
|
|
.tokens[closureOpenBraceIndex ..< closureCloseBraceIndex].contains(where: \.isLinebreak)
|
|
else { return }
|
|
|
|
// Ignore closures with capture lists for now since they're rare
|
|
// in this context and add complexity
|
|
guard let firstIndexInClosureBody = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: closureOpenBraceIndex),
|
|
formatter.tokens[firstIndexInClosureBody] != .startOfScope("[")
|
|
else { return }
|
|
|
|
// Parse the value that `forEach` is being called on
|
|
let forLoopSubjectRange: ClosedRange<Int>
|
|
var forLoopSubjectIdentifier: String?
|
|
|
|
// Parse a functional chain backwards from the `forEach` token
|
|
var currentIndex = forEachIndex
|
|
|
|
while let previousDotIndex = formatter.index(of: .nonSpaceOrLinebreak, before: currentIndex),
|
|
formatter.tokens[previousDotIndex] == .operator(".", .infix),
|
|
let tokenBeforeDotIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: previousDotIndex)
|
|
{
|
|
guard let startOfChainComponent = formatter.startOfChainComponent(at: tokenBeforeDotIndex, forLoopSubjectIdentifier: &forLoopSubjectIdentifier) else {
|
|
// If we parse a dot we expect to parse at least one additional component in the chain.
|
|
// Otherwise we'd have a malformed chain that starts with a dot, so abort.
|
|
return
|
|
}
|
|
|
|
currentIndex = startOfChainComponent
|
|
}
|
|
|
|
guard currentIndex != forEachIndex else { return }
|
|
forLoopSubjectRange = currentIndex ... indexBeforeFunctionCallDot
|
|
|
|
// If there is a `try` before the `forEach` we cannot know if the subject is async/throwing or the body,
|
|
// which makes it impossible to know if we should move it or *remove* it, so we must abort (same for await).
|
|
if let tokenIndexBeforeForLoop = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: currentIndex),
|
|
var prevToken = formatter.token(at: tokenIndexBeforeForLoop)
|
|
{
|
|
if prevToken.isUnwrapOperator {
|
|
prevToken = formatter.last(.nonSpaceOrComment, before: tokenIndexBeforeForLoop) ?? .space("")
|
|
}
|
|
if [.keyword("try"), .keyword("await")].contains(prevToken) {
|
|
return
|
|
}
|
|
}
|
|
|
|
// If the chain includes linebreaks, don't convert it to a for loop.
|
|
//
|
|
// In this case converting something like:
|
|
//
|
|
// placeholderStrings
|
|
// .filter { $0.style == .fooBar }
|
|
// .map { $0.uppercased() }
|
|
// .forEach { print($0) }
|
|
//
|
|
// to:
|
|
//
|
|
// for placeholderString in placeholderStrings
|
|
// .filter { $0.style == .fooBar }
|
|
// .map { $0.uppercased() } { print($0) }
|
|
//
|
|
// would be a pretty obvious downgrade.
|
|
if formatter.tokens[forLoopSubjectRange].contains(where: \.isLinebreak) {
|
|
return
|
|
}
|
|
|
|
// The names of the argument to the `forEach` closure.
|
|
// e.g. `["foo"]` in `forEach { foo in ... }`
|
|
// or `["foo, bar"]` in `forEach { (foo: Foo, bar: Bar) in ... }`
|
|
let forEachValueNames: [String]
|
|
let inKeywordIndex: Int?
|
|
let isAnonymousClosure: Bool
|
|
|
|
if let argumentList = formatter.parseClosureArgumentList(at: closureOpenBraceIndex) {
|
|
isAnonymousClosure = false
|
|
forEachValueNames = argumentList.argumentNames
|
|
inKeywordIndex = argumentList.inKeywordIndex
|
|
} else {
|
|
isAnonymousClosure = true
|
|
inKeywordIndex = nil
|
|
|
|
if formatter.options.preserveAnonymousForEach {
|
|
return
|
|
}
|
|
|
|
// We can't introduce an identifier that matches a keyword or already exists in
|
|
// the loop body so choose the first eligible option from a set of potential names
|
|
var eligibleValueNames = ["item", "element", "value"]
|
|
if var identifier = forLoopSubjectIdentifier?.singularized(), !identifier.isSwiftKeyword {
|
|
eligibleValueNames = [identifier] + eligibleValueNames
|
|
}
|
|
|
|
// The chosen name shouldn't already exist in the closure body
|
|
guard let chosenValueName = eligibleValueNames.first(where: { name in
|
|
!formatter.tokens[closureOpenBraceIndex ... closureCloseBraceIndex].contains(where: { $0.string == name })
|
|
}) else { return }
|
|
|
|
forEachValueNames = [chosenValueName]
|
|
}
|
|
|
|
// Validate that the closure body is eligible to be converted to a for loop
|
|
for closureBodyIndex in closureOpenBraceIndex ... closureCloseBraceIndex {
|
|
guard !formatter.indexIsWithinNestedClosure(closureBodyIndex, startOfScopeIndex: closureOpenBraceIndex) else { continue }
|
|
|
|
// We can only handle anonymous closures that just use $0, since we don't have good names to
|
|
// use for other arguments like $1, $2, etc. If the closure has an anonymous argument
|
|
// other than just $0 then we have to ignore it.
|
|
if formatter.tokens[closureBodyIndex].string.hasPrefix("$"),
|
|
let intValue = Int(formatter.tokens[closureBodyIndex].string.dropFirst()),
|
|
intValue != 0
|
|
{
|
|
return
|
|
}
|
|
|
|
// We can convert `return`s to `continue`, but only when `return` is the last token in the scope.
|
|
// It's legal to write something like `return print("foo")` in a `forEach` as long as
|
|
// you're still returning a `Void` value. Since `continue print("foo")` isn't legal,
|
|
// we should just ignore this closure.
|
|
if formatter.tokens[closureBodyIndex] == .keyword("return"),
|
|
let tokenAfterReturnKeyword = formatter.next(.nonSpaceOrComment, after: closureBodyIndex),
|
|
!(tokenAfterReturnKeyword.isLinebreak || tokenAfterReturnKeyword == .endOfScope("}"))
|
|
{
|
|
return
|
|
}
|
|
}
|
|
|
|
// Start updating the `forEach` call to a `for .. in .. {` loop
|
|
for closureBodyIndex in closureOpenBraceIndex ... closureCloseBraceIndex {
|
|
guard !formatter.indexIsWithinNestedClosure(closureBodyIndex, startOfScopeIndex: closureOpenBraceIndex) else { continue }
|
|
|
|
// The for loop won't have any `$0` identifiers anymore, so we have to
|
|
// update those to the value at the current loop index
|
|
if isAnonymousClosure, formatter.tokens[closureBodyIndex].string == "$0" {
|
|
formatter.replaceToken(at: closureBodyIndex, with: .identifier(forEachValueNames[0]))
|
|
}
|
|
|
|
// In a `forEach` closure, `return` continues to the next loop iteration.
|
|
// To get the same behavior in a for loop we convert `return`s to `continue`s.
|
|
if formatter.tokens[closureBodyIndex] == .keyword("return") {
|
|
formatter.replaceToken(at: closureBodyIndex, with: .keyword("continue"))
|
|
}
|
|
}
|
|
|
|
if let forEachCallCloseParenIndex {
|
|
formatter.removeToken(at: forEachCallCloseParenIndex)
|
|
}
|
|
|
|
// Construct the new for loop
|
|
var newTokens: [Token] = [
|
|
.keyword("for"),
|
|
.space(" "),
|
|
]
|
|
|
|
let forEachValueNameTokens: [Token]
|
|
if forEachValueNames.count == 1 {
|
|
newTokens.append(.identifier(forEachValueNames[0]))
|
|
} else {
|
|
newTokens.append(contentsOf: tokenize("(\(forEachValueNames.joined(separator: ", ")))"))
|
|
}
|
|
|
|
newTokens.append(contentsOf: [
|
|
.space(" "),
|
|
.keyword("in"),
|
|
.space(" "),
|
|
])
|
|
|
|
newTokens.append(contentsOf: formatter.tokens[forLoopSubjectRange])
|
|
|
|
newTokens.append(contentsOf: [
|
|
.space(" "),
|
|
.startOfScope("{"),
|
|
])
|
|
|
|
formatter.replaceTokens(
|
|
in: (forLoopSubjectRange.lowerBound) ... (inKeywordIndex ?? closureOpenBraceIndex),
|
|
with: newTokens
|
|
)
|
|
}
|
|
} examples: {
|
|
"""
|
|
```diff
|
|
let strings = ["foo", "bar", "baaz"]
|
|
- strings.forEach { placeholder in
|
|
+ for placeholder in strings {
|
|
print(placeholder)
|
|
}
|
|
|
|
// Supports anonymous closures
|
|
- strings.forEach {
|
|
+ for string in strings {
|
|
- print($0)
|
|
+ print(string)
|
|
}
|
|
|
|
- foo.item().bar[2].baazValues(option: true).forEach {
|
|
+ for baazValue in foo.item().bar[2].baazValues(option: true) {
|
|
- print($0)
|
|
+ print(baazValue)
|
|
}
|
|
|
|
// Doesn't affect long multiline functional chains
|
|
placeholderStrings
|
|
.filter { $0.style == .fooBar }
|
|
.map { $0.uppercased() }
|
|
.forEach { print($0) }
|
|
```
|
|
"""
|
|
}
|
|
}
|
|
|
|
extension Formatter {
|
|
/// Returns the start index of the chain component ending at the given index
|
|
func startOfChainComponent(at index: Int, forLoopSubjectIdentifier: inout String?) -> Int? {
|
|
// The previous item in a dot chain can either be:
|
|
// 1. an identifier like `foo.`
|
|
// 2. a function call like `foo(...).`
|
|
// 3. a subscript like `foo[...].
|
|
// 4. a trailing closure like `map { ... }`
|
|
// 5. Some other combination of parens / subscript like `(foo).`
|
|
// or even `foo["bar"]()()`.
|
|
// And any of these can be preceded by one of the others
|
|
switch tokens[index] {
|
|
case let .identifier(identifierName):
|
|
// Allowlist certain dot chain elements that should be ignored.
|
|
// For example, in `foos.reversed().forEach { ... }` we want
|
|
// `forLoopSubjectIdentifier` to be `foos` rather than `reversed`.
|
|
let chainElementsToIgnore = Set([
|
|
"reversed", "sorted", "shuffled", "enumerated", "dropFirst", "dropLast",
|
|
"map", "flatMap", "compactMap", "filter", "reduce", "lazy",
|
|
])
|
|
|
|
if forLoopSubjectIdentifier == nil || chainElementsToIgnore.contains(forLoopSubjectIdentifier ?? "") {
|
|
// Since we have to pick a single identifier to represent the subject of the for loop,
|
|
// just use the last identifier in the chain
|
|
forLoopSubjectIdentifier = identifierName
|
|
}
|
|
|
|
return index
|
|
|
|
case .endOfScope(")"), .endOfScope("]"):
|
|
let closingParenIndex = index
|
|
guard let startOfScopeIndex = startOfScope(at: closingParenIndex),
|
|
let previousNonSpaceNonCommentIndex = self.index(of: .nonSpaceOrComment, before: startOfScopeIndex)
|
|
else { return nil }
|
|
|
|
// When we find parens for a function call or braces for a subscript,
|
|
// continue parsing at the previous non-space non-comment token.
|
|
// - If the previous token is a newline then this isn't a function call
|
|
// and we'd stop parsing. `foo ()` is a function call but `foo\n()` isn't.
|
|
return startOfChainComponent(at: previousNonSpaceNonCommentIndex, forLoopSubjectIdentifier: &forLoopSubjectIdentifier) ?? startOfScopeIndex
|
|
|
|
case .endOfScope("}"):
|
|
// Stop parsing if we reach a trailing closure.
|
|
// Converting this to a for loop would result in unusual looking syntax like
|
|
// `for string in strings.map { $0.uppercased() } { print(string) }`
|
|
// which causes a warning to be emitted: "trailing closure in this context is
|
|
// confusable with the body of the statement; pass as a parenthesized argument
|
|
// to silence this warning".
|
|
return nil
|
|
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|