mirror of
https://github.com/nicklockwood/SwiftFormat.git
synced 2026-05-17 10:30:35 +00:00
Add rule to remove redundant Equatable implementations
This commit is contained in:
committed by
Cal Stephens
parent
46b3afc372
commit
d2ea346884
@@ -105,6 +105,7 @@
|
||||
* [organizeDeclarations](#organizeDeclarations)
|
||||
* [privateStateVariables](#privateStateVariables)
|
||||
* [propertyTypes](#propertyTypes)
|
||||
* [redundantEquatable](#redundantEquatable)
|
||||
* [redundantProperty](#redundantProperty)
|
||||
* [sortSwitchCases](#sortSwitchCases)
|
||||
* [spacingGuards](#spacingGuards)
|
||||
@@ -1794,6 +1795,61 @@ which are called immediately.
|
||||
</details>
|
||||
<br/>
|
||||
|
||||
## redundantEquatable
|
||||
|
||||
Omit a hand-written Equatable implementation when the compiler-synthesized conformance would be equivalent.
|
||||
|
||||
Option | Description
|
||||
--- | ---
|
||||
`--equatablemacro` | For example: "@Equatable,EquatableMacroLib"
|
||||
|
||||
<details>
|
||||
<summary>Examples</summary>
|
||||
|
||||
```diff
|
||||
struct Foo: Equatable {
|
||||
let bar: Bar
|
||||
let baaz: Baaz
|
||||
|
||||
- static func ==(lhs: Foo, rhs: Foo) -> Bool {
|
||||
- lhs.bar == rhs.bar
|
||||
- && lhs.baaz == rhs.baaz
|
||||
- }
|
||||
}
|
||||
|
||||
class Bar: Equatable {
|
||||
let baaz: Baaz
|
||||
|
||||
static func ==(lhs: Bar, rhs: Bar) -> Bool {
|
||||
lhs.baaz == rhs.baaz
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If your project includes a macro that generates the `static func ==` implementation
|
||||
for the attached class, you can specify `--equatablemacro @Equatable,MyMacroLib`
|
||||
and this rule will also migrate eligible classes to use your macro instead of
|
||||
a hand-written Equatable conformance:
|
||||
|
||||
```diff
|
||||
// --equatablemacro @Equatable,MyMacroLib
|
||||
import FooLib
|
||||
+ import MyMacroLib
|
||||
|
||||
+ @Equatable
|
||||
+ class Bar {
|
||||
- class Bar: Equatable {
|
||||
let baaz: Baaz
|
||||
|
||||
- static func ==(lhs: Bar, rhs: Bar) -> Bool {
|
||||
- lhs.baaz == rhs.baaz
|
||||
- }
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
<br/>
|
||||
|
||||
## redundantExtensionACL
|
||||
|
||||
Remove redundant access control modifiers.
|
||||
|
||||
@@ -162,6 +162,11 @@ enum Declaration: Hashable {
|
||||
modifiers.first(where: Declaration.swiftUIPropertyWrappers.contains)
|
||||
}
|
||||
|
||||
/// Whether or not this declaration represents a stored instance property
|
||||
var isStoredInstanceProperty: Bool {
|
||||
!modifiers.contains("static") && isStoredProperty
|
||||
}
|
||||
|
||||
/// Whether or not this declaration represents a stored property
|
||||
var isStoredProperty: Bool {
|
||||
guard keyword == "let" || keyword == "var" else { return false }
|
||||
@@ -184,6 +189,18 @@ enum Declaration: Hashable {
|
||||
// Otherwise, if the property doesn't have a body, then it must not be a computed property.
|
||||
return true
|
||||
}
|
||||
|
||||
/// The original index of this declaration's primary keyword in the given formatter
|
||||
func originalKeywordIndex(in formatter: Formatter) -> Int? {
|
||||
formatter.index(of: .keyword(keyword), after: originalRange.lowerBound - 1)
|
||||
}
|
||||
|
||||
/// Computes the fully qualified name of this declaration, given the array of parent declarations.
|
||||
func fullyQualifiedName(parentDeclarations: [Declaration]) -> String? {
|
||||
guard let name = name else { return nil }
|
||||
let typeNames = parentDeclarations.compactMap(\.name) + [name]
|
||||
return typeNames.joined(separator: ".")
|
||||
}
|
||||
}
|
||||
|
||||
extension Formatter {
|
||||
@@ -836,8 +853,8 @@ extension Declaration {
|
||||
|
||||
extension Formatter {
|
||||
/// Recursively calls the `operation` for every declaration in the source file
|
||||
func forEachRecursiveDeclaration(_ operation: (Declaration) -> Void) {
|
||||
parseDeclarations().forEachRecursiveDeclaration(operation)
|
||||
func forEachRecursiveDeclaration(_ operation: (Declaration, _ parents: [Declaration]) -> Void) {
|
||||
parseDeclarations().forEachRecursiveDeclaration(operation: operation, parents: [])
|
||||
}
|
||||
|
||||
/// Applies `mapRecursiveDeclarations` in place
|
||||
@@ -857,11 +874,14 @@ extension Formatter {
|
||||
|
||||
extension Array where Element == Declaration {
|
||||
/// Applies `operation` to every recursive declaration of this array of declarations
|
||||
func forEachRecursiveDeclaration(_ operation: (Declaration) -> Void) {
|
||||
func forEachRecursiveDeclaration(
|
||||
operation: (Declaration, _ parents: [Declaration]) -> Void,
|
||||
parents: [Declaration] = []
|
||||
) {
|
||||
for declaration in self {
|
||||
operation(declaration)
|
||||
operation(declaration, parents)
|
||||
if let body = declaration.body {
|
||||
body.forEachRecursiveDeclaration(operation)
|
||||
body.forEachRecursiveDeclaration(operation: operation, parents: parents + [declaration])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1247,6 +1247,12 @@ struct _Descriptors {
|
||||
help: "Sort SwiftUI props: none, alphabetize, first-appearance-sort",
|
||||
keyPath: \.swiftUIPropertiesSortMode
|
||||
)
|
||||
let equatableMacroInfo = OptionDescriptor(
|
||||
argumentName: "equatablemacro",
|
||||
displayName: "The name and module of an Equatable conformance macro",
|
||||
help: "For example: \"@Equatable,EquatableMacroLib\"",
|
||||
keyPath: \.equatableMacroInfo
|
||||
)
|
||||
|
||||
// MARK: - Internal
|
||||
|
||||
|
||||
@@ -589,6 +589,24 @@ public enum SwiftUIPropertiesSortMode: String, CaseIterable {
|
||||
case firstAppearanceSort = "first-appearance-sort"
|
||||
}
|
||||
|
||||
public struct EquatableMacroInfo: RawRepresentable {
|
||||
/// The name of this macro, e.g. `@Equatable`
|
||||
let macro: String
|
||||
/// The name of the module defining this macro, e.g. `EquatableMacroLib`
|
||||
let moduleName: String
|
||||
|
||||
public init?(rawValue: String) {
|
||||
let components = rawValue.components(separatedBy: ",")
|
||||
guard components.count == 2 else { return nil }
|
||||
macro = components[0]
|
||||
moduleName = components[1]
|
||||
}
|
||||
|
||||
public var rawValue: String {
|
||||
"\(macro),\(moduleName)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration options for formatting. These aren't actually used by the
|
||||
/// Formatter class itself, but it makes them available to the format rules.
|
||||
public struct FormatOptions: CustomStringConvertible {
|
||||
@@ -699,6 +717,7 @@ public struct FormatOptions: CustomStringConvertible {
|
||||
public var timeZone: FormatTimeZone
|
||||
public var nilInit: NilInitType
|
||||
public var preservedPrivateDeclarations: Set<String>
|
||||
public var equatableMacroInfo: EquatableMacroInfo?
|
||||
|
||||
/// Deprecated
|
||||
public var indentComments: Bool
|
||||
@@ -825,6 +844,7 @@ public struct FormatOptions: CustomStringConvertible {
|
||||
timeZone: FormatTimeZone = .system,
|
||||
nilInit: NilInitType = .remove,
|
||||
preservedPrivateDeclarations: Set<String> = [],
|
||||
equatableMacroInfo: EquatableMacroInfo? = nil,
|
||||
// Doesn't really belong here, but hard to put elsewhere
|
||||
fragment: Bool = false,
|
||||
ignoreConflictMarkers: Bool = false,
|
||||
@@ -941,6 +961,7 @@ public struct FormatOptions: CustomStringConvertible {
|
||||
self.timeZone = timeZone
|
||||
self.nilInit = nilInit
|
||||
self.preservedPrivateDeclarations = preservedPrivateDeclarations
|
||||
self.equatableMacroInfo = equatableMacroInfo
|
||||
// Doesn't really belong here, but hard to put elsewhere
|
||||
self.fragment = fragment
|
||||
self.ignoreConflictMarkers = ignoreConflictMarkers
|
||||
|
||||
@@ -2137,7 +2137,7 @@ extension Formatter {
|
||||
}
|
||||
|
||||
/// Range of tokens forming file header comment
|
||||
func headerCommentTokenRange(includingDirectives directives: [String]) -> Range<Int>? {
|
||||
func headerCommentTokenRange(includingDirectives directives: [String] = []) -> Range<Int>? {
|
||||
guard !options.fragment else {
|
||||
return nil
|
||||
}
|
||||
@@ -2257,40 +2257,62 @@ extension Formatter {
|
||||
return (equalsIndex, andTokenIndices, fullProtocolCompositionType.range.upperBound)
|
||||
}
|
||||
|
||||
/// Parses the external parameter labels of the function with its `(` start of scope
|
||||
/// token at the given index.
|
||||
func parseFunctionDeclarationArgumentLabels(startOfScope: Int) -> [String?] {
|
||||
/// A function argument like `with foo: Foo`.
|
||||
struct FunctionArgument: Equatable {
|
||||
/// The external label of this argument. `nil` if omitted with an `_`.
|
||||
let externalLabel: String?
|
||||
/// The internal label of this argument. `nil` if omitted with an `_`.
|
||||
let internalLabel: String?
|
||||
/// The type of the argument
|
||||
var type: String
|
||||
}
|
||||
|
||||
/// Parses the arguments of the function with its `(` start of scope token at the given index.
|
||||
func parseFunctionDeclarationArguments(startOfScope: Int) -> [FunctionArgument] {
|
||||
assert(tokens[startOfScope] == .startOfScope("("))
|
||||
guard let endOfScope = endOfScope(at: startOfScope) else { return [] }
|
||||
|
||||
var argumentLabels: [String?] = []
|
||||
var arguments: [FunctionArgument] = []
|
||||
|
||||
var currentIndex = startOfScope
|
||||
while let nextArgumentColon = index(of: .delimiter(":"), in: (currentIndex + 1) ..< endOfScope) {
|
||||
let colonIndex = nextArgumentColon
|
||||
currentIndex = nextArgumentColon
|
||||
|
||||
// If there is only one label, the param has the same internal and external label.
|
||||
// If there are two labels, the first one is the external label.
|
||||
guard let internalLabelIndex = index(of: .nonSpaceOrComment, before: nextArgumentColon),
|
||||
guard let internalLabelIndex = index(of: .nonSpaceOrComment, before: colonIndex),
|
||||
tokens[internalLabelIndex].isIdentifier || tokens[internalLabelIndex].string == "_"
|
||||
else { continue }
|
||||
|
||||
var externalLabelToken = tokens[internalLabelIndex]
|
||||
var externalLabelIndex = internalLabelIndex
|
||||
|
||||
if let externalLabelIndex = index(of: .nonSpaceOrComment, before: internalLabelIndex),
|
||||
tokens[externalLabelIndex].isIdentifier || tokens[externalLabelIndex].string == "_"
|
||||
if let possibleExternalLabelIndex = index(of: .nonSpaceOrComment, before: internalLabelIndex),
|
||||
tokens[possibleExternalLabelIndex].isIdentifier || tokens[possibleExternalLabelIndex].string == "_"
|
||||
{
|
||||
externalLabelToken = tokens[externalLabelIndex]
|
||||
externalLabelIndex = possibleExternalLabelIndex
|
||||
}
|
||||
|
||||
if externalLabelToken.string == "_" {
|
||||
argumentLabels.append(nil)
|
||||
} else {
|
||||
argumentLabels.append(externalLabelToken.string)
|
||||
guard let startOfType = index(of: .nonSpaceOrComment, after: colonIndex),
|
||||
let type = parseType(at: startOfType)?.name
|
||||
else { continue }
|
||||
|
||||
let identifierString: (Token) -> String? = { token in
|
||||
if token.string == "_" {
|
||||
return nil
|
||||
} else {
|
||||
return token.string
|
||||
}
|
||||
}
|
||||
|
||||
arguments.append(FunctionArgument(
|
||||
externalLabel: identifierString(tokens[externalLabelIndex]),
|
||||
internalLabel: identifierString(tokens[internalLabelIndex]),
|
||||
type: type
|
||||
))
|
||||
}
|
||||
|
||||
return argumentLabels
|
||||
return arguments
|
||||
}
|
||||
|
||||
/// Parses the parameter labels of the function call with its `(` start of scope
|
||||
|
||||
@@ -61,6 +61,7 @@ let ruleRegistry: [String: FormatRule] = [
|
||||
"redundantBackticks": .redundantBackticks,
|
||||
"redundantBreak": .redundantBreak,
|
||||
"redundantClosure": .redundantClosure,
|
||||
"redundantEquatable": .redundantEquatable,
|
||||
"redundantExtensionACL": .redundantExtensionACL,
|
||||
"redundantFileprivate": .redundantFileprivate,
|
||||
"redundantGet": .redundantGet,
|
||||
|
||||
@@ -16,7 +16,7 @@ public extension FormatRule {
|
||||
) { formatter in
|
||||
var emptyExtensions = [Declaration]()
|
||||
|
||||
formatter.forEachRecursiveDeclaration { declaration in
|
||||
formatter.forEachRecursiveDeclaration { declaration, _ in
|
||||
let declarationModifiers = Set(declaration.modifiers)
|
||||
guard declaration.keyword == "extension",
|
||||
let declarationBody = declaration.body,
|
||||
|
||||
@@ -386,37 +386,8 @@ extension Formatter {
|
||||
|
||||
// Whether or not this declaration is an instance property that can affect
|
||||
// the parameters struct's synthesized memberwise initializer
|
||||
func affectsSynthesizedMemberwiseInitializer(
|
||||
_ declaration: Declaration,
|
||||
_ category: Category
|
||||
) -> Bool {
|
||||
switch category.type {
|
||||
case .swiftUIPropertyWrapper, .instanceProperty:
|
||||
return true
|
||||
|
||||
case .instancePropertyWithBody:
|
||||
// `instancePropertyWithBody` represents some stored properties,
|
||||
// but also computed properties. Only stored properties,
|
||||
// not computed properties, affect the synthesized init.
|
||||
//
|
||||
// This is a stored property if and only if
|
||||
// the declaration body has a `didSet` or `willSet` keyword,
|
||||
// based on the grammar for a variable declaration:
|
||||
// https://docs.swift.org/swift-book/ReferenceManual/Declarations.html#grammar_variable-declaration
|
||||
let parser = Formatter(declaration.tokens)
|
||||
|
||||
if let bodyOpenBrace = parser.index(of: .startOfScope("{"), after: -1),
|
||||
let nextToken = parser.next(.nonSpaceOrCommentOrLinebreak, after: bodyOpenBrace),
|
||||
[.identifier("willSet"), .identifier("didSet")].contains(nextToken)
|
||||
{
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
func affectsSynthesizedMemberwiseInitializer(_ declaration: Declaration) -> Bool {
|
||||
declaration.isStoredInstanceProperty
|
||||
}
|
||||
|
||||
// Whether or not the two given declaration orderings preserve
|
||||
@@ -426,11 +397,11 @@ extension Formatter {
|
||||
_ rhs: [CategorizedDeclaration]
|
||||
) -> Bool {
|
||||
let lhsPropertiesOrder = lhs
|
||||
.filter { affectsSynthesizedMemberwiseInitializer($0.declaration, $0.category) }
|
||||
.filter { affectsSynthesizedMemberwiseInitializer($0.declaration) }
|
||||
.map(\.declaration)
|
||||
|
||||
let rhsPropertiesOrder = rhs
|
||||
.filter { affectsSynthesizedMemberwiseInitializer($0.declaration, $0.category) }
|
||||
.filter { affectsSynthesizedMemberwiseInitializer($0.declaration) }
|
||||
.map(\.declaration)
|
||||
|
||||
return lhsPropertiesOrder == rhsPropertiesOrder
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
// Created by Cal Stephens on 9/25/24.
|
||||
// Copyright © 2024 Airbnb Inc. All rights reserved.
|
||||
|
||||
public extension FormatRule {
|
||||
static let redundantEquatable = FormatRule(
|
||||
help: "Omit a hand-written Equatable implementation when the compiler-synthesized conformance would be equivalent.",
|
||||
disabledByDefault: true,
|
||||
options: ["equatablemacro"]
|
||||
) { formatter in
|
||||
// Find all of the types with an `Equatable` conformance and a manually-implemented `static func ==` implementation.
|
||||
let declarations = formatter.parseDeclarations()
|
||||
let typesManuallyImplementingEquatableConformance = formatter.manuallyImplementedEquatableTypes(in: declarations)
|
||||
|
||||
// To avoid invalidating indices within the `typesManuallyImplementingEquatableConformance`,
|
||||
// compute all of the modifications we need to make and then apply them in reverse order at the end.
|
||||
var modificationsByIndex = [Int: () -> Void]()
|
||||
var importsToAddIfNeeded = Set<String>()
|
||||
|
||||
for equatableType in typesManuallyImplementingEquatableConformance {
|
||||
let isEligibleForAutoEquatableConformance: Bool
|
||||
switch equatableType.typeDeclaration.keyword {
|
||||
case "struct":
|
||||
// The compiler automatically synthesizes Equatable implementations for structs
|
||||
isEligibleForAutoEquatableConformance = true
|
||||
case "class":
|
||||
// Projects can define an `@Equatable` macro that generates the Equatable implementation for classes
|
||||
isEligibleForAutoEquatableConformance = formatter.options.equatableMacroInfo != nil
|
||||
default:
|
||||
// This rule doesn't support other kinds of types.
|
||||
isEligibleForAutoEquatableConformance = false
|
||||
}
|
||||
|
||||
guard isEligibleForAutoEquatableConformance,
|
||||
let typeBody = equatableType.typeDeclaration.body,
|
||||
let typeKeywordIndex = equatableType.typeDeclaration.originalKeywordIndex(in: formatter)
|
||||
else { continue }
|
||||
|
||||
// Find all of the stored instance properties in this type.
|
||||
// The synthesized Equatable implementation would compare each of these.
|
||||
let storedInstanceProperties = Set(typeBody.filter(\.isStoredInstanceProperty).map(\.name))
|
||||
|
||||
// Find all of the properties compared using `lhs.{property} == rhs.{property}`
|
||||
let comparedProperties = formatter.parseComparedProperties(inEquatableImplementation: equatableType.equatableFunction)
|
||||
|
||||
// If the set of compared properties match the set of stored instance properties,
|
||||
// then the manually implemented `==` function is redundant and can be removed.
|
||||
guard comparedProperties == storedInstanceProperties else {
|
||||
continue
|
||||
}
|
||||
|
||||
// The compiler automatically synthesizes Equatable implementations for structs
|
||||
if equatableType.typeDeclaration.keyword == "struct" {
|
||||
let rangeToRemove = equatableType.equatableFunction.originalRange
|
||||
modificationsByIndex[rangeToRemove.lowerBound] = {
|
||||
formatter.removeTokens(in: rangeToRemove)
|
||||
}
|
||||
}
|
||||
|
||||
// In projects using an `@Equatable` macro, the Equatable implementation
|
||||
// can be generated by that macro instead of written manually.
|
||||
else if let equatableMacroInfo = formatter.options.equatableMacroInfo {
|
||||
let conformanceIndex = equatableType.equatableConformanceIndex
|
||||
|
||||
// Exclude cases where the Equatable conformance is defined in an extension with a where clause,
|
||||
// since this wouldn't usually be captured in the generated conformance.
|
||||
if let startOfExtensionTypeBody = formatter.index(of: .startOfScope("{"), after: conformanceIndex),
|
||||
formatter.index(of: .keyword("where"), in: conformanceIndex ..< startOfExtensionTypeBody) != nil
|
||||
{
|
||||
continue
|
||||
}
|
||||
|
||||
// Remove the `==` implementation
|
||||
let rangeToRemove = equatableType.equatableFunction.originalRange
|
||||
modificationsByIndex[rangeToRemove.lowerBound] = {
|
||||
formatter.removeTokens(in: rangeToRemove)
|
||||
}
|
||||
|
||||
// Remove the `: Equatable` conformance.
|
||||
// - If this type uses as `: Hashable` conformance, we have to preserve that.
|
||||
if formatter.tokens[conformanceIndex] == .identifier("Equatable") {
|
||||
modificationsByIndex[conformanceIndex] = {
|
||||
formatter.removeConformance(at: conformanceIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// Add the `@Equatable` macro
|
||||
modificationsByIndex[typeKeywordIndex] = {
|
||||
let startOfModifiers = formatter.startOfModifiers(at: typeKeywordIndex, includingAttributes: true)
|
||||
|
||||
formatter.insert(
|
||||
[.keyword(equatableMacroInfo.macro), .space(" ")],
|
||||
at: startOfModifiers
|
||||
)
|
||||
}
|
||||
|
||||
// Import the module that defines the `@Equatable` macro if needed
|
||||
importsToAddIfNeeded.insert(equatableMacroInfo.moduleName)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the modifications in backwards order to avoid invalidating existing indices
|
||||
for (_, applyModification) in modificationsByIndex.sorted(by: { $0.key < $1.key }).reversed() {
|
||||
applyModification()
|
||||
}
|
||||
|
||||
formatter.addImports(importsToAddIfNeeded)
|
||||
} examples: {
|
||||
"""
|
||||
```diff
|
||||
struct Foo: Equatable {
|
||||
let bar: Bar
|
||||
let baaz: Baaz
|
||||
|
||||
- static func ==(lhs: Foo, rhs: Foo) -> Bool {
|
||||
- lhs.bar == rhs.bar
|
||||
- && lhs.baaz == rhs.baaz
|
||||
- }
|
||||
}
|
||||
|
||||
class Bar: Equatable {
|
||||
let baaz: Baaz
|
||||
|
||||
static func ==(lhs: Bar, rhs: Bar) -> Bool {
|
||||
lhs.baaz == rhs.baaz
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If your project includes a macro that generates the `static func ==` implementation
|
||||
for the attached class, you can specify `--equatablemacro @Equatable,MyMacroLib`
|
||||
and this rule will also migrate eligible classes to use your macro instead of
|
||||
a hand-written Equatable conformance:
|
||||
|
||||
```diff
|
||||
// --equatablemacro @Equatable,MyMacroLib
|
||||
import FooLib
|
||||
+ import MyMacroLib
|
||||
|
||||
+ @Equatable
|
||||
+ class Bar {
|
||||
- class Bar: Equatable {
|
||||
let baaz: Baaz
|
||||
|
||||
- static func ==(lhs: Bar, rhs: Bar) -> Bool {
|
||||
- lhs.baaz == rhs.baaz
|
||||
- }
|
||||
}
|
||||
```
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
extension Formatter {
|
||||
struct EquatableType {
|
||||
/// The main type declaration of the type that has an Equatable conformance
|
||||
let typeDeclaration: Declaration
|
||||
/// The Equatable `static func ==` implementation, which could be defined in an extension.
|
||||
let equatableFunction: Declaration
|
||||
/// The index of the `: Equatable` conformance, which could be defined in an extension.
|
||||
let equatableConformanceIndex: Int
|
||||
}
|
||||
|
||||
/// Finds all of the types in the current file with an Equatable conformance,
|
||||
/// which also have a manually-implemented `static func ==` method.
|
||||
func manuallyImplementedEquatableTypes(in declarations: [Declaration]) -> [EquatableType] {
|
||||
var typeDeclarationsByFullyQualifiedName: [String: Declaration] = [:]
|
||||
var typesWithEquatableConformances: [(fullyQualifiedTypeName: String, equatableConformanceIndex: Int)] = []
|
||||
var equatableImplementationsByFullyQualifiedName: [String: Declaration] = [:]
|
||||
|
||||
declarations.forEachRecursiveDeclaration { declaration, parentDeclarations in
|
||||
guard let declarationName = declaration.name else { return }
|
||||
let fullyQualifiedName = declaration.fullyQualifiedName(parentDeclarations: parentDeclarations)
|
||||
|
||||
if declaration.definesType, let fullyQualifiedName = fullyQualifiedName {
|
||||
typeDeclarationsByFullyQualifiedName[fullyQualifiedName] = declaration
|
||||
}
|
||||
|
||||
// Support the Equatable conformance being declared in an extension
|
||||
// separately from the Equatable
|
||||
if declaration.definesType || declaration.keyword == "extension",
|
||||
let keywordIndex = declaration.originalKeywordIndex(in: self),
|
||||
let fullyQualifiedName = fullyQualifiedName
|
||||
{
|
||||
let conformances = parseConformancesOfType(atKeywordIndex: keywordIndex)
|
||||
|
||||
// Both an Equatable and Hashable conformance will cause the Equatable conformance to be synthesized
|
||||
if let equatableConformance = conformances.first(where: {
|
||||
$0.conformance == "Equatable" || $0.conformance == "Hashable"
|
||||
}) {
|
||||
typesWithEquatableConformances.append((
|
||||
fullyQualifiedTypeName: fullyQualifiedName,
|
||||
equatableConformanceIndex: equatableConformance.index
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if declaration.keyword == "func",
|
||||
declarationName == "==",
|
||||
let funcKeywordIndex = declaration.originalKeywordIndex(in: self),
|
||||
modifiersForDeclaration(at: funcKeywordIndex, contains: "static"),
|
||||
let startOfArguments = index(of: .startOfScope("("), after: funcKeywordIndex)
|
||||
{
|
||||
let functionArguments = parseFunctionDeclarationArguments(startOfScope: startOfArguments)
|
||||
|
||||
if functionArguments.count == 2,
|
||||
// The external label doesn't matter, it can be `_` or `lhs/rhs`.
|
||||
functionArguments[0].internalLabel == "lhs",
|
||||
functionArguments[1].internalLabel == "rhs",
|
||||
functionArguments[0].type == functionArguments[1].type
|
||||
{
|
||||
var comparedTypeName = functionArguments[0].type
|
||||
|
||||
if let parentDeclaration = parentDeclarations.last {
|
||||
// If the function uses `Self`, resolve that to the name of the parent type
|
||||
if comparedTypeName == "Self",
|
||||
let parentDeclarationName = parentDeclaration.fullyQualifiedName(parentDeclarations: parentDeclarations.dropLast())
|
||||
{
|
||||
comparedTypeName = parentDeclarationName
|
||||
}
|
||||
|
||||
// If the function uses `Bar` in an extension `Foo.Bar`, then resolve
|
||||
// the name of the compared type to be the fully-qualified `Foo.Bar` type.
|
||||
if parentDeclaration.keyword == "extension",
|
||||
let extendedType = parentDeclaration.name,
|
||||
comparedTypeName != extendedType,
|
||||
extendedType.hasSuffix("." + comparedTypeName)
|
||||
{
|
||||
comparedTypeName = extendedType
|
||||
}
|
||||
|
||||
// If the function uses `Bar` in a type `Bar`, then resolve the
|
||||
// the name of the compared type to be the fully-qualified parent type.
|
||||
// - For example, `Bar` could be defined in a parent `Foo` type.
|
||||
if comparedTypeName == parentDeclaration.name,
|
||||
let parentDeclarationName = parentDeclaration.fullyQualifiedName(parentDeclarations: parentDeclarations.dropLast())
|
||||
{
|
||||
comparedTypeName = parentDeclarationName
|
||||
}
|
||||
}
|
||||
|
||||
equatableImplementationsByFullyQualifiedName[comparedTypeName] = declaration
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return typesWithEquatableConformances.compactMap { typeName, equatableConformanceIndex in
|
||||
guard let typeDeclaration = typeDeclarationsByFullyQualifiedName[typeName],
|
||||
let equatableImplementation = equatableImplementationsByFullyQualifiedName[typeName]
|
||||
else { return nil }
|
||||
|
||||
return EquatableType(
|
||||
typeDeclaration: typeDeclaration,
|
||||
equatableFunction: equatableImplementation,
|
||||
equatableConformanceIndex: equatableConformanceIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the set of properties that are compared in the given Equatable `func`,
|
||||
/// following the pattern `lhs.{property} == rhs.{property}`.
|
||||
/// - Returns `nil` if there are any comparisons that don't match this pattern.
|
||||
func parseComparedProperties(inEquatableImplementation equatableImplementation: Declaration) -> Set<String>? {
|
||||
guard let funcIndex = equatableImplementation.originalKeywordIndex(in: self),
|
||||
let startOfBody = index(of: .startOfScope("{"), after: funcIndex),
|
||||
let firstIndexInBody = index(of: .nonSpaceOrCommentOrLinebreak, after: startOfBody),
|
||||
let endOfBody = endOfScope(at: startOfBody)
|
||||
else { return nil }
|
||||
|
||||
var validComparedProperties = Set<String>()
|
||||
var currentIndex = firstIndexInBody
|
||||
|
||||
// Skip over any `return` keyword that may be present
|
||||
if tokens[currentIndex] == .keyword("return"),
|
||||
let nextIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: currentIndex)
|
||||
{
|
||||
currentIndex = nextIndex
|
||||
}
|
||||
|
||||
while currentIndex < endOfBody {
|
||||
// Parse the current `lhs.{property} == rhs.{property}` pattern
|
||||
guard tokens[currentIndex] == .identifier("lhs"),
|
||||
let lhsDotIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: currentIndex),
|
||||
tokens[lhsDotIndex] == .operator(".", .infix),
|
||||
let lhsPropertyName = index(of: .nonSpaceOrCommentOrLinebreak, after: lhsDotIndex),
|
||||
tokens[lhsPropertyName].isIdentifierOrKeyword,
|
||||
let equalsIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: lhsPropertyName),
|
||||
tokens[equalsIndex] == .operator("==", .infix),
|
||||
let rhsIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: equalsIndex),
|
||||
tokens[rhsIndex] == .identifier("rhs"),
|
||||
let rhsDotIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: rhsIndex),
|
||||
tokens[rhsDotIndex] == .operator(".", .infix),
|
||||
let rhsPropertyName = index(of: .nonSpaceOrCommentOrLinebreak, after: rhsDotIndex),
|
||||
tokens[rhsPropertyName] == tokens[lhsPropertyName],
|
||||
let indexAfterComparison = index(of: .nonSpaceOrCommentOrLinebreak, after: rhsPropertyName)
|
||||
else {
|
||||
// If we find a non-matching comparison, we have to avoid modifying this declaration
|
||||
return nil
|
||||
}
|
||||
|
||||
validComparedProperties.insert(tokens[lhsPropertyName].string)
|
||||
|
||||
// Skip over any `&&` operators connecting two comparisons
|
||||
if tokens[indexAfterComparison] == .operator("&&", .infix),
|
||||
let indexAfterAndOperator = index(of: .nonSpaceOrCommentOrLinebreak, after: indexAfterComparison)
|
||||
{
|
||||
currentIndex = indexAfterAndOperator
|
||||
}
|
||||
|
||||
else {
|
||||
currentIndex = indexAfterComparison
|
||||
}
|
||||
}
|
||||
|
||||
return validComparedProperties
|
||||
}
|
||||
|
||||
/// Removes the protocol conformance at the given index.
|
||||
/// e.g. can remove `Foo` from `Type: Foo, Bar {` (becomes `Type: Bar {`).
|
||||
func removeConformance(at conformanceIndex: Int) {
|
||||
guard let previousToken = index(of: .nonSpaceOrCommentOrLinebreak, before: conformanceIndex),
|
||||
let nextToken = index(of: .nonSpaceOrCommentOrLinebreak, after: conformanceIndex)
|
||||
else { return }
|
||||
|
||||
// The first conformance will be preceded by a colon.
|
||||
// Every conformance but the last one will be followed by a comma.
|
||||
// - for example: `Type: Foo, Bar, Baaz {`
|
||||
let isFirstConformance = tokens[previousToken] == .delimiter(":")
|
||||
let isLastConformance = tokens[nextToken] != .delimiter(",")
|
||||
let isOnlyConformance = isFirstConformance && isLastConformance
|
||||
|
||||
if isLastConformance || isOnlyConformance {
|
||||
removeTokens(in: previousToken ... conformanceIndex)
|
||||
} else {
|
||||
// When changing `Foo, Bar` to just `Bar`, also remove the space between them
|
||||
if token(at: nextToken + 1)?.isSpace == true {
|
||||
removeTokens(in: conformanceIndex ... (nextToken + 1))
|
||||
} else {
|
||||
removeTokens(in: conformanceIndex ... (nextToken + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds imports for the given list of modules to this file if not already present
|
||||
func addImports(_ importsToAddIfNeeded: Set<String>) {
|
||||
let importRanges = parseImports()
|
||||
let currentImports = Set(importRanges.flatMap { $0.map(\.module) })
|
||||
|
||||
for importToAddIfNeeded in importsToAddIfNeeded {
|
||||
guard !currentImports.contains(importToAddIfNeeded) else { continue }
|
||||
|
||||
let newImport: [Token] = [.keyword("import"), .space(" "), .identifier(importToAddIfNeeded)]
|
||||
|
||||
// If there are any existing imports, add the new import in the existing group
|
||||
if let firstImportIndex = index(of: .keyword("import"), after: -1) {
|
||||
insert(newImport + [linebreakToken(for: firstImportIndex)], at: firstImportIndex)
|
||||
}
|
||||
|
||||
// Otherwise if there are no imports:
|
||||
// - Make sure to insert the comment after any header comment if present
|
||||
// - Include a blank line after the import
|
||||
else {
|
||||
let insertionIndex: Int
|
||||
if let headerCommentRange = headerCommentTokenRange(), !headerCommentRange.isEmpty {
|
||||
insertionIndex = headerCommentRange.upperBound
|
||||
} else {
|
||||
insertionIndex = 0
|
||||
}
|
||||
|
||||
let newImportWithBlankLine = newImport + [
|
||||
linebreakToken(for: insertionIndex),
|
||||
linebreakToken(for: insertionIndex),
|
||||
]
|
||||
|
||||
insert(newImportWithBlankLine, at: insertionIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ public extension FormatRule {
|
||||
|
||||
// Collect all of the `private` or `fileprivate` declarations in the file
|
||||
var privateDeclarations: [Declaration] = []
|
||||
formatter.forEachRecursiveDeclaration { declaration in
|
||||
formatter.forEachRecursiveDeclaration { declaration, _ in
|
||||
let declarationModifiers = Set(declaration.modifiers)
|
||||
let hasDisallowedModifiers = disallowedModifiers.contains(where: { declarationModifiers.contains($0) })
|
||||
|
||||
|
||||
@@ -69,6 +69,11 @@
|
||||
08180DCF2C4EB67F00FD60FF /* DeclarationHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E230CA12C4C1C0700A16E2E /* DeclarationHelpers.swift */; };
|
||||
08180DD02C4EB67F00FD60FF /* DeclarationHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E230CA12C4C1C0700A16E2E /* DeclarationHelpers.swift */; };
|
||||
08180DD12C4EB68000FD60FF /* DeclarationHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E230CA12C4C1C0700A16E2E /* DeclarationHelpers.swift */; };
|
||||
082D644B2CA4719E0072DA14 /* RedundantEquatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 082D644A2CA4719B0072DA14 /* RedundantEquatable.swift */; };
|
||||
082D644C2CA4719E0072DA14 /* RedundantEquatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 082D644A2CA4719B0072DA14 /* RedundantEquatable.swift */; };
|
||||
082D644D2CA4719E0072DA14 /* RedundantEquatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 082D644A2CA4719B0072DA14 /* RedundantEquatable.swift */; };
|
||||
082D644E2CA4719E0072DA14 /* RedundantEquatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 082D644A2CA4719B0072DA14 /* RedundantEquatable.swift */; };
|
||||
082D64502CA471F30072DA14 /* RedundantEquatableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 082D644F2CA471F00072DA14 /* RedundantEquatableTests.swift */; };
|
||||
2E230CA22C4C1C0700A16E2E /* DeclarationHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E230CA12C4C1C0700A16E2E /* DeclarationHelpers.swift */; };
|
||||
2E2BAB8C2C57F6B600590239 /* RuleRegistry.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E2BAB8B2C57F6B600590239 /* RuleRegistry.generated.swift */; };
|
||||
2E2BAB8D2C57F6B600590239 /* RuleRegistry.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E2BAB8B2C57F6B600590239 /* RuleRegistry.generated.swift */; };
|
||||
@@ -779,6 +784,8 @@
|
||||
01F17E841E258A4900DCD359 /* CommandLineTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommandLineTests.swift; sourceTree = "<group>"; };
|
||||
01F3DF8B1DB9FD3F00454944 /* Options.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Options.swift; sourceTree = "<group>"; };
|
||||
01F3DF8F1DBA003E00454944 /* InferenceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InferenceTests.swift; sourceTree = "<group>"; };
|
||||
082D644A2CA4719B0072DA14 /* RedundantEquatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedundantEquatable.swift; sourceTree = "<group>"; };
|
||||
082D644F2CA471F00072DA14 /* RedundantEquatableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedundantEquatableTests.swift; sourceTree = "<group>"; };
|
||||
2E230CA12C4C1C0700A16E2E /* DeclarationHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeclarationHelpers.swift; sourceTree = "<group>"; };
|
||||
2E2BAB8B2C57F6B600590239 /* RuleRegistry.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleRegistry.generated.swift; sourceTree = "<group>"; };
|
||||
2E2BAB912C57F6DD00590239 /* InitCoderUnavailable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InitCoderUnavailable.swift; sourceTree = "<group>"; };
|
||||
@@ -1211,6 +1218,7 @@
|
||||
2E2BAB902C57F6DD00590239 /* Rules */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
082D644A2CA4719B0072DA14 /* RedundantEquatable.swift */,
|
||||
2E2BABF02C57F6DD00590239 /* Acronyms.swift */,
|
||||
2E2BABE22C57F6DD00590239 /* AndOperator.swift */,
|
||||
2E2BABE82C57F6DD00590239 /* AnyObjectProtocol.swift */,
|
||||
@@ -1331,6 +1339,7 @@
|
||||
2E8DE68C2C57FEB30032BF25 /* Rules */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
082D644F2CA471F00072DA14 /* RedundantEquatableTests.swift */,
|
||||
2E8DE6D02C57FEB30032BF25 /* AcronymsTests.swift */,
|
||||
2E8DE6F02C57FEB30032BF25 /* AndOperatorTests.swift */,
|
||||
2E8DE6BB2C57FEB30032BF25 /* AnyObjectProtocolTests.swift */,
|
||||
@@ -1837,6 +1846,7 @@
|
||||
2E2BAC372C57F6DD00590239 /* HoistPatternLet.swift in Sources */,
|
||||
2E2BAD672C57F6DD00590239 /* HoistAwait.swift in Sources */,
|
||||
2E2BAD832C57F6DD00590239 /* DocComments.swift in Sources */,
|
||||
082D644E2CA4719E0072DA14 /* RedundantEquatable.swift in Sources */,
|
||||
2E2BAC2B2C57F6DD00590239 /* Todos.swift in Sources */,
|
||||
2E2BAC6F2C57F6DD00590239 /* AssertionFailures.swift in Sources */,
|
||||
2E2BAD5B2C57F6DD00590239 /* AnyObjectProtocol.swift in Sources */,
|
||||
@@ -2032,6 +2042,7 @@
|
||||
2E8DE7422C57FEB30032BF25 /* ConsecutiveSpacesTests.swift in Sources */,
|
||||
2E8DE7312C57FEB30032BF25 /* HoistAwaitTests.swift in Sources */,
|
||||
2E8DE7132C57FEB30032BF25 /* RedundantInitTests.swift in Sources */,
|
||||
082D64502CA471F30072DA14 /* RedundantEquatableTests.swift in Sources */,
|
||||
2E8DE7142C57FEB30032BF25 /* RedundantRawValuesTests.swift in Sources */,
|
||||
2E8DE7262C57FEB30032BF25 /* AnyObjectProtocolTests.swift in Sources */,
|
||||
2E8DE70F2C57FEB30032BF25 /* WrapTests.swift in Sources */,
|
||||
@@ -2076,6 +2087,7 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
082D644C2CA4719E0072DA14 /* RedundantEquatable.swift in Sources */,
|
||||
01A0EAD51D5DC08A00A0A8E3 /* FormatRule.swift in Sources */,
|
||||
2E2BAC6C2C57F6DD00590239 /* SpaceInsideGenerics.swift in Sources */,
|
||||
2E2BAD2C2C57F6DD00590239 /* WrapEnumCases.swift in Sources */,
|
||||
@@ -2322,6 +2334,7 @@
|
||||
01BBD85B21DAA2A700457380 /* Globs.swift in Sources */,
|
||||
2E2BAD112C57F6DD00590239 /* RedundantProperty.swift in Sources */,
|
||||
01A8320824EC7F7700A9D0EB /* FormattingHelpers.swift in Sources */,
|
||||
082D644B2CA4719E0072DA14 /* RedundantEquatable.swift in Sources */,
|
||||
2E2BACFD2C57F6DD00590239 /* BlankLinesBetweenImports.swift in Sources */,
|
||||
2E2BACF12C57F6DD00590239 /* ConditionalAssignment.swift in Sources */,
|
||||
2E2BAD4D2C57F6DD00590239 /* RedundantVoidReturnType.swift in Sources */,
|
||||
@@ -2467,6 +2480,7 @@
|
||||
2E2BADB22C57F6DD00590239 /* RedundantType.swift in Sources */,
|
||||
9028F7851DA4B435009FE5B4 /* Formatter.swift in Sources */,
|
||||
2E2BAD122C57F6DD00590239 /* RedundantProperty.swift in Sources */,
|
||||
082D644D2CA4719E0072DA14 /* RedundantEquatable.swift in Sources */,
|
||||
E487212A201E3DD50014845E /* RulesStore.swift in Sources */,
|
||||
2E2BACFE2C57F6DD00590239 /* BlankLinesBetweenImports.swift in Sources */,
|
||||
2E2BACF22C57F6DD00590239 /* ConditionalAssignment.swift in Sources */,
|
||||
|
||||
@@ -79,7 +79,7 @@ class CodeOrganizationTests: XCTestCase {
|
||||
|
||||
var helperFuncArgLabels: [String?]? = nil
|
||||
if bodyDeclaration.keyword == "func", let startOfScope = formatter.index(of: .startOfScope("("), after: bodyDeclaration.originalRange.lowerBound) {
|
||||
helperFuncArgLabels = formatter.parseFunctionDeclarationArgumentLabels(startOfScope: startOfScope)
|
||||
helperFuncArgLabels = formatter.parseFunctionDeclarationArguments(startOfScope: startOfScope).map(\.externalLabel)
|
||||
}
|
||||
|
||||
allRuleFileHelpers.append((name: helperName, fileName: fileName, funcArgLabels: helperFuncArgLabels))
|
||||
|
||||
@@ -2534,20 +2534,26 @@ class ParsingHelpersTests: XCTestCase {
|
||||
|
||||
// MARK: parseFunctionDeclarationArgumentLabels
|
||||
|
||||
func testParseFunctionDeclarationArgumentLabels() {
|
||||
func testParseFunctionDeclarationArguments() {
|
||||
let input = """
|
||||
func foo(_ foo: Foo, bar: Bar, quux _: Quux, last baaz: Baaz) {}
|
||||
func bar() {}
|
||||
"""
|
||||
|
||||
let formatter = Formatter(tokenize(input))
|
||||
|
||||
XCTAssertEqual(
|
||||
formatter.parseFunctionDeclarationArgumentLabels(startOfScope: 3), // foo(...)
|
||||
[nil, "bar", "quux", "last"]
|
||||
formatter.parseFunctionDeclarationArguments(startOfScope: 3), // foo(...)
|
||||
[
|
||||
Formatter.FunctionArgument(externalLabel: nil, internalLabel: "foo", type: "Foo"),
|
||||
Formatter.FunctionArgument(externalLabel: "bar", internalLabel: "bar", type: "Bar"),
|
||||
Formatter.FunctionArgument(externalLabel: "quux", internalLabel: nil, type: "Quux"),
|
||||
Formatter.FunctionArgument(externalLabel: "last", internalLabel: "baaz", type: "Baaz"),
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
formatter.parseFunctionDeclarationArgumentLabels(startOfScope: 40), // bar()
|
||||
formatter.parseFunctionDeclarationArguments(startOfScope: 40), // bar()
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,11 +40,13 @@ let ruleRegistryURL =
|
||||
private func allSwiftFiles(inDirectory directory: String) -> [URL] {
|
||||
var swiftFiles: [URL] = []
|
||||
let directory = projectDirectory.appendingPathComponent(directory)
|
||||
_ = enumerateFiles(withInputURL: directory) { fileURL, _, _ in
|
||||
let errors = enumerateFiles(withInputURL: directory) { fileURL, _, _ in
|
||||
{
|
||||
guard fileURL.pathExtension == "swift" else { return }
|
||||
swiftFiles.append(fileURL)
|
||||
}
|
||||
}
|
||||
assert(errors.isEmpty, "Encountered errors accessing files in \(directory): \(errors)")
|
||||
assert(!swiftFiles.isEmpty, "Could not load files in \(directory)")
|
||||
return swiftFiles
|
||||
}
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
// Created by Cal Stephens on 9/25/24.
|
||||
// Copyright © 2024 Airbnb Inc. All rights reserved.
|
||||
|
||||
import XCTest
|
||||
@testable import SwiftFormat
|
||||
|
||||
final class RedundantEquatableTests: XCTestCase {
|
||||
func testRemoveSimpleEquatableConformanceOnType() {
|
||||
let input = """
|
||||
struct Foo: Equatable {
|
||||
let bar: Bar
|
||||
let baaz: Baaz
|
||||
|
||||
static func ==(lhs: Foo, rhs: Foo) -> Bool {
|
||||
lhs.bar == rhs.bar
|
||||
&& lhs.baaz == rhs.baaz
|
||||
}
|
||||
}
|
||||
|
||||
struct Baaz: Hashable {
|
||||
let foo: Foo
|
||||
|
||||
static func ==(_ lhs: Baaz, _ rhs: Baaz) -> Bool {
|
||||
return lhs.foo == rhs.foo
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let output = """
|
||||
struct Foo: Equatable {
|
||||
let bar: Bar
|
||||
let baaz: Baaz
|
||||
}
|
||||
|
||||
struct Baaz: Hashable {
|
||||
let foo: Foo
|
||||
}
|
||||
"""
|
||||
|
||||
testFormatting(for: input, [output], rules: [.redundantEquatable, .blankLinesAtEndOfScope])
|
||||
}
|
||||
|
||||
func testRemoveSimpleEquatableConformanceInExtensionType() {
|
||||
let input = """
|
||||
struct Foo {
|
||||
static let shared: Foo = .init()
|
||||
|
||||
let bar: Bar
|
||||
|
||||
var baaz: Baaz {
|
||||
didSet {
|
||||
print("Updated baaz")
|
||||
}
|
||||
}
|
||||
|
||||
var quux: Quux {
|
||||
Quux(baaz)
|
||||
}
|
||||
}
|
||||
|
||||
extension Foo: Equatable {
|
||||
static func ==(lhs: Foo, rhs: Foo) -> Bool {
|
||||
lhs.bar == rhs.bar && lhs.baaz == rhs.baaz
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let output = """
|
||||
struct Foo {
|
||||
static let shared: Foo = .init()
|
||||
|
||||
let bar: Bar
|
||||
|
||||
var baaz: Baaz {
|
||||
didSet {
|
||||
print("Updated baaz")
|
||||
}
|
||||
}
|
||||
|
||||
var quux: Quux {
|
||||
Quux(baaz)
|
||||
}
|
||||
}
|
||||
|
||||
extension Foo: Equatable {}
|
||||
"""
|
||||
|
||||
testFormatting(for: input, [output], rules: [.redundantEquatable, .emptyBraces])
|
||||
}
|
||||
|
||||
func testRemoveSimpleEquatableConformanceUsingSelfInExtensionType() {
|
||||
let input = """
|
||||
struct Foo {
|
||||
let bar: Bar
|
||||
let baaz: Baaz
|
||||
}
|
||||
|
||||
extension Foo: Equatable {
|
||||
static func ==(lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.bar == rhs.bar
|
||||
&& lhs.baaz == rhs.baaz
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let output = """
|
||||
struct Foo {
|
||||
let bar: Bar
|
||||
let baaz: Baaz
|
||||
}
|
||||
|
||||
extension Foo: Equatable {}
|
||||
"""
|
||||
|
||||
testFormatting(for: input, [output], rules: [.redundantEquatable, .emptyBraces])
|
||||
}
|
||||
|
||||
func testPreservesEquatableImplementationNotComparingAllProperties() {
|
||||
let input = """
|
||||
struct Foo: Equatable {
|
||||
let bar: Bar
|
||||
let baaz: Baaz
|
||||
|
||||
static func == (_ lhs: Foo, _ rhs: Foo) -> Equatable {
|
||||
lhs.bar == rhs.bar
|
||||
}
|
||||
}
|
||||
|
||||
struct Baaz: Equatable {
|
||||
let foo: Foo
|
||||
|
||||
static func == (_ lhs: Foo, _ rhs: Baaz) -> Equatable {
|
||||
lhs.foo.bar == rhs.foo.bar
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
testFormatting(for: input, rule: .redundantEquatable)
|
||||
}
|
||||
|
||||
func testPreservesEquatableImplementationInClass() {
|
||||
let input = """
|
||||
class Foo: Equatable {
|
||||
let bar: Bar
|
||||
let baaz: Baaz
|
||||
|
||||
static func == (_ lhs: Foo, _ rhs: Foo) -> Equatable {
|
||||
lhs.bar == rhs.bar && lhs.baaz == rhs.baaz
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
testFormatting(for: input, rule: .redundantEquatable)
|
||||
}
|
||||
|
||||
func testAdoptsEquatableMacroOnClass() {
|
||||
let input = """
|
||||
import FooLib
|
||||
|
||||
class Foo: Equatable {
|
||||
let bar: Bar
|
||||
let baaz: Baaz
|
||||
|
||||
static func ==(lhs: Foo, rhs: Foo) -> Equatable {
|
||||
lhs.bar == rhs.bar && lhs.baaz == rhs.baaz
|
||||
}
|
||||
}
|
||||
|
||||
class Quux: Equatable {
|
||||
let bar: Bar
|
||||
let baaz: Baaz
|
||||
}
|
||||
|
||||
extension Quux: Equatable, OtherConformance {
|
||||
static func ==(_ lhs: Quux, _ rhs: Quux) -> Equatable {
|
||||
lhs.bar == rhs.bar && lhs.baaz == rhs.baaz
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let output = """
|
||||
import FooLib
|
||||
import MyEquatableMacroLib
|
||||
|
||||
@Equatable
|
||||
class Foo {
|
||||
let bar: Bar
|
||||
let baaz: Baaz
|
||||
}
|
||||
|
||||
@Equatable
|
||||
class Quux {
|
||||
let bar: Bar
|
||||
let baaz: Baaz
|
||||
}
|
||||
|
||||
extension Quux: OtherConformance {}
|
||||
"""
|
||||
|
||||
let options = FormatOptions(
|
||||
typeAttributes: .prevLine,
|
||||
equatableMacroInfo: EquatableMacroInfo(rawValue: "@Equatable,MyEquatableMacroLib")
|
||||
)
|
||||
|
||||
testFormatting(
|
||||
for: input, [output],
|
||||
rules: [.redundantEquatable, .emptyBraces, .blankLinesAtEndOfScope, .wrapAttributes, .sortImports],
|
||||
options: options
|
||||
)
|
||||
}
|
||||
|
||||
func testStructEquatableExtensionWithWhereClause() {
|
||||
let input = """
|
||||
struct Foo<Bar> {
|
||||
let bar: Bar
|
||||
}
|
||||
|
||||
extension Foo: Equatable where Bar: Equatable {
|
||||
static func ==(lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.bar == rhs.bar
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let output = """
|
||||
struct Foo<Bar> {
|
||||
let bar: Bar
|
||||
}
|
||||
|
||||
extension Foo: Equatable where Bar: Equatable {}
|
||||
"""
|
||||
|
||||
testFormatting(for: input, [output], rules: [.redundantEquatable, .emptyBraces])
|
||||
}
|
||||
|
||||
func testClassEquatableExtensionWithWhereClause() {
|
||||
let input = """
|
||||
class Foo<Bar> {
|
||||
let bar: Bar
|
||||
}
|
||||
|
||||
extension Foo: Equatable where Bar: Equatable {
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.bar == rhs.bar
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let options = FormatOptions(
|
||||
typeAttributes: .prevLine,
|
||||
equatableMacroInfo: EquatableMacroInfo(rawValue: "@Equatable,MyEquatableMacroLib")
|
||||
)
|
||||
|
||||
testFormatting(for: input, rule: .redundantEquatable, options: options)
|
||||
}
|
||||
|
||||
func testPreservesHashableConformance() {
|
||||
let input = """
|
||||
class Foo {
|
||||
let bar: Bar
|
||||
}
|
||||
|
||||
extension Foo: Hashable {
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.bar == rhs.bar
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(bar)
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let output = """
|
||||
import MyEquatableMacroLib
|
||||
|
||||
@Equatable
|
||||
class Foo {
|
||||
let bar: Bar
|
||||
}
|
||||
|
||||
extension Foo: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(bar)
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let options = FormatOptions(typeAttributes: .prevLine, equatableMacroInfo: EquatableMacroInfo(rawValue: "@Equatable,MyEquatableMacroLib"))
|
||||
testFormatting(for: input, [output], rules: [.redundantEquatable, .blankLinesAtEndOfScope, .wrapAttributes], options: options)
|
||||
}
|
||||
|
||||
func testInsertsImportBelowHeaderCommentWithNoOtherComments() {
|
||||
let input = """
|
||||
// Created by Cal Stephens on 9/25/24.
|
||||
// Copyright © 2024 Airbnb Inc. All rights reserved.
|
||||
|
||||
class Foo {
|
||||
let bar: Bar
|
||||
}
|
||||
|
||||
extension Foo: Equatable {
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.bar == rhs.bar
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let output = """
|
||||
// Created by Cal Stephens on 9/25/24.
|
||||
// Copyright © 2024 Airbnb Inc. All rights reserved.
|
||||
|
||||
import MyEquatableMacroLib
|
||||
|
||||
@Equatable
|
||||
class Foo {
|
||||
let bar: Bar
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
let options = FormatOptions(
|
||||
typeAttributes: .prevLine,
|
||||
equatableMacroInfo: EquatableMacroInfo(rawValue: "@Equatable,MyEquatableMacroLib")
|
||||
)
|
||||
|
||||
testFormatting(
|
||||
for: input, [output],
|
||||
rules: [.redundantEquatable, .blankLinesAtEndOfScope, .emptyExtensions, .wrapAttributes, .consecutiveBlankLines],
|
||||
options: options
|
||||
)
|
||||
}
|
||||
|
||||
func testInsertsImportBelowHeaderCommentWithOtherComments() {
|
||||
let input = """
|
||||
// Created by Cal Stephens on 9/25/24.
|
||||
// Copyright © 2024 Airbnb Inc. All rights reserved.
|
||||
|
||||
import BarLib
|
||||
import FooLib
|
||||
|
||||
class Foo {
|
||||
let bar: Bar
|
||||
}
|
||||
|
||||
extension Foo: Equatable {
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.bar == rhs.bar
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let output = """
|
||||
// Created by Cal Stephens on 9/25/24.
|
||||
// Copyright © 2024 Airbnb Inc. All rights reserved.
|
||||
|
||||
import BarLib
|
||||
import FooLib
|
||||
import MyEquatableMacroLib
|
||||
|
||||
@Equatable
|
||||
class Foo {
|
||||
let bar: Bar
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
let options = FormatOptions(
|
||||
typeAttributes: .prevLine,
|
||||
equatableMacroInfo: EquatableMacroInfo(rawValue: "@Equatable,MyEquatableMacroLib")
|
||||
)
|
||||
|
||||
testFormatting(
|
||||
for: input, [output],
|
||||
rules: [.redundantEquatable, .blankLinesAtEndOfScope, .emptyExtensions, .wrapAttributes, .consecutiveBlankLines, .sortImports],
|
||||
options: options
|
||||
)
|
||||
}
|
||||
|
||||
func testRemoveSimpleEquatableConformanceOnNestedType() {
|
||||
let input = """
|
||||
enum Foo {
|
||||
enum Bar {
|
||||
struct Baaz: Equatable {
|
||||
let foo: String
|
||||
let bar: String
|
||||
|
||||
static func ==(lhs: Baaz, rhs: Baaz) -> Bool {
|
||||
lhs.foo == rhs.foo
|
||||
&& lhs.bar == rhs.bar
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let output = """
|
||||
enum Foo {
|
||||
enum Bar {
|
||||
struct Baaz: Equatable {
|
||||
let foo: String
|
||||
let bar: String
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
testFormatting(for: input, [output], rules: [.redundantEquatable, .blankLinesAtEndOfScope])
|
||||
}
|
||||
|
||||
func testRemoveSimpleSelfEquatableConformanceOnNestedType() {
|
||||
let input = """
|
||||
enum Foo {
|
||||
enum Bar {
|
||||
struct Baaz: Equatable {
|
||||
let foo: String
|
||||
let bar: String
|
||||
|
||||
static func ==(lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.foo == rhs.foo
|
||||
&& lhs.bar == rhs.bar
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let output = """
|
||||
enum Foo {
|
||||
enum Bar {
|
||||
struct Baaz: Equatable {
|
||||
let foo: String
|
||||
let bar: String
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
testFormatting(for: input, [output], rules: [.redundantEquatable, .blankLinesAtEndOfScope])
|
||||
}
|
||||
|
||||
func testRemoveSimpleEquatableConformanceOnNestedTypeWithExtension() {
|
||||
let input = """
|
||||
enum Foo {
|
||||
enum Bar {
|
||||
struct Baaz {
|
||||
let foo: String
|
||||
let bar: String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Foo.Bar.Baaz: Equatable {
|
||||
static func ==(lhs: Baaz, rhs: Baaz) -> Bool {
|
||||
lhs.foo == rhs.foo
|
||||
&& lhs.bar == rhs.bar
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let output = """
|
||||
enum Foo {
|
||||
enum Bar {
|
||||
struct Baaz {
|
||||
let foo: String
|
||||
let bar: String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Foo.Bar.Baaz: Equatable {}
|
||||
"""
|
||||
|
||||
testFormatting(for: input, [output], rules: [.redundantEquatable, .emptyBraces])
|
||||
}
|
||||
|
||||
func testAdoptsEquatableMacroOnNestedTypeWithExtension() {
|
||||
let input = """
|
||||
enum Foo {
|
||||
enum Bar {
|
||||
final class Baaz {
|
||||
let foo: String
|
||||
let bar: String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Foo.Bar.Baaz: Equatable {
|
||||
static func ==(lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.foo == rhs.foo
|
||||
&& lhs.bar == rhs.bar
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let output = """
|
||||
import MyEquatableMacroLib
|
||||
|
||||
enum Foo {
|
||||
enum Bar {
|
||||
@Equatable
|
||||
final class Baaz {
|
||||
let foo: String
|
||||
let bar: String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
let options = FormatOptions(
|
||||
typeAttributes: .prevLine,
|
||||
equatableMacroInfo: EquatableMacroInfo(rawValue: "@Equatable,MyEquatableMacroLib")
|
||||
)
|
||||
|
||||
testFormatting(for: input, [output], rules: [.redundantEquatable, .emptyBraces, .wrapAttributes, .emptyExtensions, .consecutiveBlankLines], options: options)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user