From aaee5d81dbb8cfe5e9fd4246d26b3088ab417a10 Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Tue, 10 Dec 2024 10:25:19 -0800 Subject: [PATCH] Add preferCountWhere rule (#1939) --- Rules.md | 27 ++++++ Sources/RuleRegistry.generated.swift | 1 + Sources/Rules/PreferCountWhere.swift | 119 ++++++++++++++++++++++++ SwiftFormat.xcodeproj/project.pbxproj | 18 +++- Tests/Rules/PreferCountWhereTests.swift | 78 ++++++++++++++++ 5 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 Sources/Rules/PreferCountWhere.swift create mode 100644 Tests/Rules/PreferCountWhereTests.swift diff --git a/Rules.md b/Rules.md index edd4e1bd8..c646c9626 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) @@ -1649,6 +1650,32 @@ Default value for `--typeorder` when using `--organizationmode type`:
+## 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 4714e322d..1d7f5e71d 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 000000000..9b25ad007 --- /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 cf4796d5f..ec1d68c74 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 000000000..c2b433621 --- /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) + } +}