Skip to content

Commit

Permalink
Add preferCountWhere rule (#1939)
Browse files Browse the repository at this point in the history
  • Loading branch information
calda authored and nicklockwood committed Dec 22, 2024
1 parent a6f5327 commit 53fade9
Show file tree
Hide file tree
Showing 5 changed files with 241 additions and 2 deletions.
27 changes: 27 additions & 0 deletions Rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
* [modifierOrder](#modifierOrder)
* [numberFormatting](#numberFormatting)
* [opaqueGenericParameters](#opaqueGenericParameters)
* [preferCountWhere](#preferCountWhere)
* [preferForLoop](#preferForLoop)
* [preferKeyPath](#preferKeyPath)
* [redundantBackticks](#redundantBackticks)
Expand Down Expand Up @@ -1649,6 +1650,32 @@ Default value for `--typeorder` when using `--organizationmode type`:
</details>
<br/>

## preferCountWhere

Prefer `count(where:)` over `filter(_:).count`.

<details>
<summary>Examples</summary>

```diff
- planets.filter { !$0.moons.isEmpty }.count
+ planets.count(where: { !$0.moons.isEmpty })

- planets.filter { planet in
- planet.moons.filter { moon in
- moon.hasAtmosphere
- }.count > 1
- }.count
+ planets.count(where: { planet in
+ planet.moons.count(where: { moon in
+ moon.hasAtmosphere
+ }) > 1
+ })
```

</details>
<br/>

## preferForLoop

Convert functional `forEach` calls to for loops.
Expand Down
1 change: 1 addition & 0 deletions Sources/RuleRegistry.generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ let ruleRegistry: [String: FormatRule] = [
"numberFormatting": .numberFormatting,
"opaqueGenericParameters": .opaqueGenericParameters,
"organizeDeclarations": .organizeDeclarations,
"preferCountWhere": .preferCountWhere,
"preferForLoop": .preferForLoop,
"preferKeyPath": .preferKeyPath,
"privateStateVariables": .privateStateVariables,
Expand Down
119 changes: 119 additions & 0 deletions Sources/Rules/PreferCountWhere.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
//
// PreferCountWhere.swift
// SwiftFormat
//
// Created by Cal Stephens on 12/7/24.
// Copyright © 2024 Nick Lockwood. All rights reserved.
//

import Foundation

public extension FormatRule {
static let preferCountWhere = FormatRule(
help: "Prefer `count(where:)` over `filter(_:).count`.")
{ formatter in
// count(where:) was added in Swift 6.0
guard formatter.options.swiftVersion >= "6.0" else { return }

formatter.forEach(.identifier("filter")) { filterIndex, _ in
guard let nextIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: filterIndex) else { return }

// Parse the `filter` call, which takes exactly one closure
// and is either `filter { ... }` or `filter({ ... })`
let openParen: Int?
let startOfClosure: Int
let endOfClosure: Int
let closeParen: Int?

if formatter.tokens[nextIndex] == .startOfScope("("),
let startOfClosureIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: nextIndex),
formatter.tokens[startOfClosureIndex] == .startOfScope("{"),
let endOfClosureIndex = formatter.endOfScope(at: startOfClosureIndex),
let tokenAfterClosure = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: endOfClosureIndex),
formatter.tokens[tokenAfterClosure] == .endOfScope(")")
{
openParen = nextIndex
startOfClosure = startOfClosureIndex
endOfClosure = endOfClosureIndex
closeParen = tokenAfterClosure
}

else if formatter.tokens[nextIndex] == .startOfScope("{"),
let endOfClosureIndex = formatter.endOfScope(at: nextIndex)
{
openParen = nil
startOfClosure = nextIndex
endOfClosure = endOfClosureIndex
closeParen = nil
}

else {
return
}

// Check if there's a `.count` property access after the filter call
guard let dotIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: closeParen ?? endOfClosure),
formatter.tokens[dotIndex] == .operator(".", .infix),
let countIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: dotIndex),
formatter.tokens[countIndex] == .identifier("count")
else { return }

// Ensure the `.count` is a property access, not a method call.
if let tokenAfterCount = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: countIndex),
formatter.tokens[tokenAfterCount].isStartOfScope
{ return }

// Remove the `.count` property access.
formatter.removeToken(at: countIndex)
formatter.removeToken(at: dotIndex)

// Replace the `filter(_:)` call with `count(where:)`.
// Since the `where` label provides semantic value,
// convert to the non-trailing-closure form.

// Replace `filter({ ... })` with `count(where: { ... })`.
if let openParen = openParen, let closeParen = closeParen {
formatter.replaceToken(at: filterIndex, with: .identifier("count"))

formatter.insert(
[.identifier("where"), .delimiter(":"), .space(" ")],
at: openParen + 1
)
}

// Replace `filter { ... }` with `count(where: { ... })`.
else {
formatter.insert(.endOfScope(")"), at: endOfClosure + 1)

formatter.insert(
[.startOfScope("("), .identifier("where"), .delimiter(":"), .space(" ")],
at: startOfClosure
)

if formatter.tokens[filterIndex + 1].isSpace {
formatter.removeToken(at: filterIndex + 1)
}

formatter.replaceToken(at: filterIndex, with: .identifier("count"))
}
}
} examples: {
"""
```diff
- planets.filter { !$0.moons.isEmpty }.count
+ planets.count(where: { !$0.moons.isEmpty })
- planets.filter { planet in
- planet.moons.filter { moon in
- moon.hasAtmosphere
- }.count > 1
- }.count
+ planets.count(where: { planet in
+ planet.moons.count(where: { moon in
+ moon.hasAtmosphere
+ }) > 1
+ })
```
"""
}
}
18 changes: 16 additions & 2 deletions SwiftFormat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,11 @@
2E9DE5082C95F9A1000FEDF8 /* FileMacro.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9DE5052C95F9A1000FEDF8 /* FileMacro.swift */; };
2E9DE5092C95F9A1000FEDF8 /* FileMacro.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9DE5052C95F9A1000FEDF8 /* FileMacro.swift */; };
2E9DE50F2C95FBB6000FEDF8 /* FileMacroTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9DE50A2C95FB4F000FEDF8 /* FileMacroTests.swift */; };
2EA8C3702D04F51A00843B42 /* PreferCountWhere.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA8C36F2D04F51A00843B42 /* PreferCountWhere.swift */; };
2EA8C3712D04F51A00843B42 /* PreferCountWhere.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA8C36F2D04F51A00843B42 /* PreferCountWhere.swift */; };
2EA8C3722D04F51A00843B42 /* PreferCountWhere.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA8C36F2D04F51A00843B42 /* PreferCountWhere.swift */; };
2EA8C3732D04F51A00843B42 /* PreferCountWhere.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA8C36F2D04F51A00843B42 /* PreferCountWhere.swift */; };
2EA8C3752D05282600843B42 /* PreferCountWhereTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA8C3742D05282600843B42 /* PreferCountWhereTests.swift */; };
2EDC9DDA2CCE9A7C0085DBE2 /* DeclarationV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EDC9DD92CCE9A7C0085DBE2 /* DeclarationV2.swift */; };
2EDC9DDB2CCE9A7C0085DBE2 /* DeclarationV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EDC9DD92CCE9A7C0085DBE2 /* DeclarationV2.swift */; };
2EDC9DDC2CCE9A7C0085DBE2 /* DeclarationV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EDC9DD92CCE9A7C0085DBE2 /* DeclarationV2.swift */; };
Expand Down Expand Up @@ -1022,6 +1027,8 @@
2E8DE6F72C57FEB30032BF25 /* WrapSingleLineCommentsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WrapSingleLineCommentsTests.swift; sourceTree = "<group>"; };
2E9DE5052C95F9A1000FEDF8 /* FileMacro.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileMacro.swift; sourceTree = "<group>"; };
2E9DE50A2C95FB4F000FEDF8 /* FileMacroTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileMacroTests.swift; sourceTree = "<group>"; };
2EA8C36F2D04F51A00843B42 /* PreferCountWhere.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferCountWhere.swift; sourceTree = "<group>"; };
2EA8C3742D05282600843B42 /* PreferCountWhereTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferCountWhereTests.swift; sourceTree = "<group>"; };
2EDC9DD92CCE9A7C0085DBE2 /* DeclarationV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeclarationV2.swift; sourceTree = "<group>"; };
2EDC9DDE2CCE9BC70085DBE2 /* DeclarationV2Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeclarationV2Tests.swift; sourceTree = "<group>"; };
2EF737512C5E881C00128F91 /* CodeOrganizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeOrganizationTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1267,6 +1274,7 @@
2E2BABAE2C57F6DD00590239 /* EmptyBraces.swift */,
580496D42C584E8F004B7DBF /* EmptyExtensions.swift */,
2E2BABD92C57F6DD00590239 /* EnumNamespaces.swift */,
ABC4BA2B2CB9B094002C6874 /* EnvironmentEntry.swift */,
2E2BABC72C57F6DD00590239 /* ExtensionAccessControl.swift */,
2E2BABC02C57F6DD00590239 /* FileHeader.swift */,
2E9DE5052C95F9A1000FEDF8 /* FileMacro.swift */,
Expand All @@ -1287,6 +1295,7 @@
2E2BABF62C57F6DD00590239 /* NumberFormatting.swift */,
2E2BABED2C57F6DD00590239 /* OpaqueGenericParameters.swift */,
2E2BABBF2C57F6DD00590239 /* OrganizeDeclarations.swift */,
2EA8C36F2D04F51A00843B42 /* PreferCountWhere.swift */,
2E2BABCE2C57F6DD00590239 /* PreferForLoop.swift */,
2E2BABDB2C57F6DD00590239 /* PreferKeyPath.swift */,
9BDB4F1C2C94773600C93995 /* PrivateStateVariables.swift */,
Expand Down Expand Up @@ -1356,7 +1365,6 @@
2E2BABBB2C57F6DD00590239 /* WrapSingleLineComments.swift */,
2E2BABDF2C57F6DD00590239 /* WrapSwitchCases.swift */,
2E2BABF92C57F6DD00590239 /* YodaConditions.swift */,
ABC4BA2B2CB9B094002C6874 /* EnvironmentEntry.swift */,
);
path = Rules;
sourceTree = "<group>";
Expand Down Expand Up @@ -1390,6 +1398,7 @@
2E8DE6B62C57FEB30032BF25 /* EmptyBracesTests.swift */,
012242D92CD355B000B96EF8 /* EmptyExtensionsTests.swift */,
2E8DE6972C57FEB30032BF25 /* EnumNamespacesTests.swift */,
ABC11AF72CC082D300556471 /* EnvironmentEntryTests.swift */,
2E8DE6992C57FEB30032BF25 /* ExtensionAccessControlTests.swift */,
2E8DE6962C57FEB30032BF25 /* FileHeaderTests.swift */,
2E9DE50A2C95FB4F000FEDF8 /* FileMacroTests.swift */,
Expand All @@ -1410,6 +1419,7 @@
2E8DE6B02C57FEB30032BF25 /* NumberFormattingTests.swift */,
2E8DE69E2C57FEB30032BF25 /* OpaqueGenericParametersTests.swift */,
2E8DE6E22C57FEB30032BF25 /* OrganizeDeclarationsTests.swift */,
2EA8C3742D05282600843B42 /* PreferCountWhereTests.swift */,
2E8DE6982C57FEB30032BF25 /* PreferForLoopTests.swift */,
2E8DE6BF2C57FEB30032BF25 /* PreferKeyPathTests.swift */,
9BDB4F1A2C94760000C93995 /* PrivateStateVariablesTests.swift */,
Expand Down Expand Up @@ -1476,7 +1486,6 @@
2E8DE6F12C57FEB30032BF25 /* WrapSwitchCasesTests.swift */,
2E8DE6A42C57FEB30032BF25 /* WrapTests.swift */,
2E8DE6AC2C57FEB30032BF25 /* YodaConditionsTests.swift */,
ABC11AF72CC082D300556471 /* EnvironmentEntryTests.swift */,
);
path = Rules;
sourceTree = "<group>";
Expand Down Expand Up @@ -1882,6 +1891,7 @@
2E2BADA32C57F6DD00590239 /* RedundantTypedThrows.swift in Sources */,
2E2BAD4B2C57F6DD00590239 /* RedundantVoidReturnType.swift in Sources */,
2E2BAC332C57F6DD00590239 /* Semicolons.swift in Sources */,
2EA8C3702D04F51A00843B42 /* PreferCountWhere.swift in Sources */,
2E2BAC0B2C57F6DD00590239 /* Indent.swift in Sources */,
2E2BACC32C57F6DD00590239 /* SpaceInsideBrackets.swift in Sources */,
2EDC9DDA2CCE9A7C0085DBE2 /* DeclarationV2.swift in Sources */,
Expand Down Expand Up @@ -2104,6 +2114,7 @@
2E8DE7252C57FEB30032BF25 /* BracesTests.swift in Sources */,
2E8DE74B2C57FEB30032BF25 /* LeadingDelimitersTests.swift in Sources */,
2E8DE7552C57FEB30032BF25 /* RedundantTypedThrowsTests.swift in Sources */,
2EA8C3752D05282600843B42 /* PreferCountWhereTests.swift in Sources */,
015CE8B12B448CCE00924504 /* SingularizeTests.swift in Sources */,
2E8DE7042C57FEB30032BF25 /* ExtensionAccessControlTests.swift in Sources */,
015F83FB2BF1448D0060A07E /* ReporterTests.swift in Sources */,
Expand Down Expand Up @@ -2233,6 +2244,7 @@
2E2BAC682C57F6DD00590239 /* LinebreakAtEndOfFile.swift in Sources */,
2E2BAC8C2C57F6DD00590239 /* SpaceInsideComments.swift in Sources */,
2E2BAC102C57F6DD00590239 /* WrapMultilineConditionalAssignment.swift in Sources */,
2EA8C3712D04F51A00843B42 /* PreferCountWhere.swift in Sources */,
2E2BACC02C57F6DD00590239 /* TypeSugar.swift in Sources */,
2E2BACEC2C57F6DD00590239 /* RedundantObjc.swift in Sources */,
2E2BACE02C57F6DD00590239 /* RedundantReturn.swift in Sources */,
Expand Down Expand Up @@ -2373,6 +2385,7 @@
01BBD85B21DAA2A700457380 /* Globs.swift in Sources */,
2E2BAD112C57F6DD00590239 /* RedundantProperty.swift in Sources */,
01A8320824EC7F7700A9D0EB /* FormattingHelpers.swift in Sources */,
2EA8C3722D04F51A00843B42 /* PreferCountWhere.swift in Sources */,
082D644B2CA4719E0072DA14 /* RedundantEquatable.swift in Sources */,
2E2BACFD2C57F6DD00590239 /* BlankLinesBetweenImports.swift in Sources */,
2E2BACF12C57F6DD00590239 /* ConditionalAssignment.swift in Sources */,
Expand Down Expand Up @@ -2522,6 +2535,7 @@
2E2BADB22C57F6DD00590239 /* RedundantType.swift in Sources */,
9028F7851DA4B435009FE5B4 /* Formatter.swift in Sources */,
2E2BAD122C57F6DD00590239 /* RedundantProperty.swift in Sources */,
2EA8C3732D04F51A00843B42 /* PreferCountWhere.swift in Sources */,
082D644D2CA4719E0072DA14 /* RedundantEquatable.swift in Sources */,
E487212A201E3DD50014845E /* RulesStore.swift in Sources */,
2E2BACFE2C57F6DD00590239 /* BlankLinesBetweenImports.swift in Sources */,
Expand Down
78 changes: 78 additions & 0 deletions Tests/Rules/PreferCountWhereTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//
// PreferCountWhereTests.swift
// SwiftFormatTests
//
// Created by Cal Stephens on 12/7/24.
// Copyright © 2024 Nick Lockwood. All rights reserved.
//

import Foundation
import XCTest
@testable import SwiftFormat

final class PreferCountWhereTests: XCTestCase {
func testConvertFilterToCountWhere() {
let input = """
planets.filter({ !$0.moons.isEmpty }).count
"""

let output = """
planets.count(where: { !$0.moons.isEmpty })
"""

let options = FormatOptions(swiftVersion: "6.0")
testFormatting(for: input, output, rule: .preferCountWhere, options: options)
}

func testConvertFilterTrailingClosureToCountWhere() {
let input = """
planets.filter { !$0.moons.isEmpty }.count
"""

let output = """
planets.count(where: { !$0.moons.isEmpty })
"""

let options = FormatOptions(swiftVersion: "6.0")
testFormatting(for: input, output, rule: .preferCountWhere, options: options)
}

func testConvertNestedFilter() {
let input = """
planets.filter { planet in
planet.moons.filter { moon in
moon.hasAtmosphere
}.count > 1
}.count
"""

let output = """
planets.count(where: { planet in
planet.moons.count(where: { moon in
moon.hasAtmosphere
}) > 1
})
"""

let options = FormatOptions(swiftVersion: "6.0")
testFormatting(for: input, output, rule: .preferCountWhere, options: options)
}

func testPreservesFilterBeforeSwift6() {
let input = """
planets.filter { !$0.moons.isEmpty }.count
"""

let options = FormatOptions(swiftVersion: "5.10")
testFormatting(for: input, rule: .preferCountWhere, options: options)
}

func testPreservesCountMethod() {
let input = """
planets.filter { !$0.moons.isEmpty }.count(of: earth)
"""

let options = FormatOptions(swiftVersion: "6.0")
testFormatting(for: input, rule: .preferCountWhere, options: options)
}
}

0 comments on commit 53fade9

Please sign in to comment.