diff --git a/Rules.md b/Rules.md
index a0d9960d..23d8d7ab 100644
--- a/Rules.md
+++ b/Rules.md
@@ -37,6 +37,7 @@
* [modifierOrder](#modifierOrder)
* [numberFormatting](#numberFormatting)
* [opaqueGenericParameters](#opaqueGenericParameters)
+* [preferCountWhere](#preferCountWhere)
* [preferForLoop](#preferForLoop)
* [preferKeyPath](#preferKeyPath)
* [redundantBackticks](#redundantBackticks)
@@ -1655,6 +1656,32 @@ Without this declaration, only functions will be reordered, while properties wil
+## preferCountWhere
+
+Prefer `count(where:)` over `filter(_:).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
++ })
+```
+
+
+
+
## preferForLoop
Convert functional `forEach` calls to for loops.
diff --git a/Sources/RuleRegistry.generated.swift b/Sources/RuleRegistry.generated.swift
index 4714e322..1d7f5e71 100644
--- a/Sources/RuleRegistry.generated.swift
+++ b/Sources/RuleRegistry.generated.swift
@@ -56,6 +56,7 @@ let ruleRegistry: [String: FormatRule] = [
"numberFormatting": .numberFormatting,
"opaqueGenericParameters": .opaqueGenericParameters,
"organizeDeclarations": .organizeDeclarations,
+ "preferCountWhere": .preferCountWhere,
"preferForLoop": .preferForLoop,
"preferKeyPath": .preferKeyPath,
"privateStateVariables": .privateStateVariables,
diff --git a/Sources/Rules/PreferCountWhere.swift b/Sources/Rules/PreferCountWhere.swift
new file mode 100644
index 00000000..9b25ad00
--- /dev/null
+++ b/Sources/Rules/PreferCountWhere.swift
@@ -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
+ + })
+ ```
+ """
+ }
+}
diff --git a/SwiftFormat.xcodeproj/project.pbxproj b/SwiftFormat.xcodeproj/project.pbxproj
index 9faedf7f..0653846c 100644
--- a/SwiftFormat.xcodeproj/project.pbxproj
+++ b/SwiftFormat.xcodeproj/project.pbxproj
@@ -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 */; };
@@ -1022,6 +1027,8 @@
2E8DE6F72C57FEB30032BF25 /* WrapSingleLineCommentsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WrapSingleLineCommentsTests.swift; sourceTree = ""; };
2E9DE5052C95F9A1000FEDF8 /* FileMacro.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileMacro.swift; sourceTree = ""; };
2E9DE50A2C95FB4F000FEDF8 /* FileMacroTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileMacroTests.swift; sourceTree = ""; };
+ 2EA8C36F2D04F51A00843B42 /* PreferCountWhere.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferCountWhere.swift; sourceTree = ""; };
+ 2EA8C3742D05282600843B42 /* PreferCountWhereTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferCountWhereTests.swift; sourceTree = ""; };
2EDC9DD92CCE9A7C0085DBE2 /* DeclarationV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeclarationV2.swift; sourceTree = ""; };
2EDC9DDE2CCE9BC70085DBE2 /* DeclarationV2Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeclarationV2Tests.swift; sourceTree = ""; };
2EF737512C5E881C00128F91 /* CodeOrganizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeOrganizationTests.swift; sourceTree = ""; };
@@ -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 */,
@@ -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 */,
@@ -1356,7 +1365,6 @@
2E2BABBB2C57F6DD00590239 /* WrapSingleLineComments.swift */,
2E2BABDF2C57F6DD00590239 /* WrapSwitchCases.swift */,
2E2BABF92C57F6DD00590239 /* YodaConditions.swift */,
- ABC4BA2B2CB9B094002C6874 /* EnvironmentEntry.swift */,
);
path = Rules;
sourceTree = "";
@@ -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 */,
@@ -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 */,
@@ -1476,7 +1486,6 @@
2E8DE6F12C57FEB30032BF25 /* WrapSwitchCasesTests.swift */,
2E8DE6A42C57FEB30032BF25 /* WrapTests.swift */,
2E8DE6AC2C57FEB30032BF25 /* YodaConditionsTests.swift */,
- ABC11AF72CC082D300556471 /* EnvironmentEntryTests.swift */,
);
path = Rules;
sourceTree = "";
@@ -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 */,
@@ -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 */,
@@ -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 */,
@@ -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 */,
@@ -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 */,
diff --git a/Tests/Rules/PreferCountWhereTests.swift b/Tests/Rules/PreferCountWhereTests.swift
new file mode 100644
index 00000000..c2b43362
--- /dev/null
+++ b/Tests/Rules/PreferCountWhereTests.swift
@@ -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)
+ }
+}