Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add preferCountWhere rule #1939

Merged
merged 1 commit into from
Dec 10, 2024
Merged
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
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 warning on line 51 in Sources/Rules/PreferCountWhere.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Rules/PreferCountWhere.swift#L51

Added line #L51 was not covered by tests
}

// 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)
}
}
Loading