mirror of
https://github.com/nicklockwood/SwiftFormat.git
synced 2026-05-17 10:30:35 +00:00
69672c5d8d
Co-authored-by: calda <1811727+calda@users.noreply.github.com>
321 lines
16 KiB
Swift
321 lines
16 KiB
Swift
// 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.",
|
|
options: ["equatable-macro"]
|
|
) { 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)
|
|
|
|
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.equatableMacro != .none
|
|
default:
|
|
// This rule doesn't support other kinds of types.
|
|
isEligibleForAutoEquatableConformance = false
|
|
}
|
|
|
|
guard isEligibleForAutoEquatableConformance,
|
|
let typeBody = equatableType.typeDeclaration.body
|
|
else { continue }
|
|
|
|
// Find all of the stored instance properties in this type.
|
|
// The synthesized Equatable implementation would compare each of these.
|
|
let storedInstanceProperties = typeBody.filter(\.isStoredInstanceProperty)
|
|
let storedInstancePropertyNames = Set(storedInstanceProperties.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 == storedInstancePropertyNames else {
|
|
continue
|
|
}
|
|
|
|
// Don't remove functions that have attributes (e.g. @usableFromInline, @inlinable)
|
|
// since these attributes can't be applied to synthesized Equatable conformances
|
|
guard equatableType.equatableFunction.attributes.isEmpty else {
|
|
continue
|
|
}
|
|
|
|
// The compiler automatically synthesizes Equatable implementations for structs
|
|
// as long as all of the properties are themselves Equatable. This is usually true
|
|
//
|
|
if equatableType.typeDeclaration.keyword == "struct",
|
|
!storedInstanceProperties.contains(where: { $0.parsePropertyDeclaration()?.type?.isKnownNonEquatableType == true })
|
|
{
|
|
equatableType.equatableFunction.remove()
|
|
}
|
|
|
|
// In projects using an `@Equatable` macro, the Equatable implementation
|
|
// can be generated by that macro instead of written manually.
|
|
else if case let .macro(macro, module: module) = formatter.options.equatableMacro {
|
|
let declarationWithEquatableConformance = equatableType.declarationWithEquatableConformance
|
|
|
|
guard let equatableConformance = formatter.parseConformancesOfType(atKeywordIndex: declarationWithEquatableConformance.keywordIndex).first(where: { $0.conformance.string == "Equatable" || $0.conformance.string == "Hashable" })
|
|
else { continue }
|
|
|
|
// 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: equatableConformance.index),
|
|
formatter.index(of: .keyword("where"), in: equatableConformance.index ..< startOfExtensionTypeBody) != nil
|
|
{
|
|
continue
|
|
}
|
|
|
|
// Remove the `==` implementation
|
|
equatableType.equatableFunction.remove()
|
|
|
|
// Remove the `: Equatable` conformance.
|
|
// - If this type uses as `: Hashable` conformance, we have to preserve that.
|
|
if equatableConformance.conformance.string == "Equatable" {
|
|
formatter.removeConformance(at: equatableConformance.index)
|
|
}
|
|
|
|
// Add the `@Equatable` macro
|
|
formatter.insert(
|
|
[.keyword(macro), .space(" ")],
|
|
at: equatableType.typeDeclaration.startOfModifiersIndex(includingAttributes: true)
|
|
)
|
|
|
|
// Import the module that defines the `@Equatable` macro if needed
|
|
formatter.addImports([module])
|
|
}
|
|
}
|
|
} 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 `--equatable-macro @Equatable,MyMacroLib`
|
|
and this rule will also migrate eligible classes to use your macro instead of
|
|
a hand-written Equatable conformance:
|
|
|
|
```diff
|
|
// --equatable-macro @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 declaration that contains the `: Equatable` conformance, which may be an extension.
|
|
let declarationWithEquatableConformance: Declaration
|
|
}
|
|
|
|
/// 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, declarationWithEquatableConformance: Declaration)] = []
|
|
var typesWithStrideableConformances: Set<String> = []
|
|
var equatableImplementationsByFullyQualifiedName: [String: Declaration] = [:]
|
|
|
|
declarations.forEachRecursiveDeclaration { declaration in
|
|
guard let declarationName = declaration.name else { return }
|
|
|
|
if declaration.definesType, let fullyQualifiedName = declaration.fullyQualifiedName {
|
|
typeDeclarationsByFullyQualifiedName[fullyQualifiedName] = declaration
|
|
}
|
|
|
|
// Support the Equatable conformance being declared in an extension
|
|
// separately from the Equatable
|
|
if declaration is TypeDeclaration,
|
|
let fullyQualifiedName = declaration.fullyQualifiedName
|
|
{
|
|
let conformances = parseConformancesOfType(atKeywordIndex: declaration.keywordIndex)
|
|
|
|
// Both an Equatable and Hashable conformance will cause the Equatable conformance to be synthesized
|
|
if conformances.contains(where: {
|
|
$0.conformance.string == "Equatable" || $0.conformance.string == "Hashable"
|
|
}) {
|
|
typesWithEquatableConformances.append((
|
|
fullyQualifiedTypeName: fullyQualifiedName,
|
|
declarationWithEquatableConformance: declaration
|
|
))
|
|
}
|
|
|
|
// Strideable provides a default `==` implementation, so a custom `==` may not be redundant
|
|
if conformances.contains(where: { $0.conformance.string == "Strideable" }) {
|
|
typesWithStrideableConformances.insert(fullyQualifiedName)
|
|
}
|
|
}
|
|
|
|
if declaration.keyword == "func",
|
|
declarationName == "==",
|
|
modifiersForDeclaration(at: declaration.keywordIndex, contains: "static"),
|
|
let startOfArguments = index(of: .startOfScope("("), after: declaration.keywordIndex)
|
|
{
|
|
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.string
|
|
|
|
if let parentDeclaration = declaration.parent {
|
|
// If the function uses `Self`, resolve that to the name of the parent type
|
|
if comparedTypeName == "Self",
|
|
let parentDeclarationName = parentDeclaration.fullyQualifiedName
|
|
{
|
|
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
|
|
{
|
|
comparedTypeName = parentDeclarationName
|
|
}
|
|
}
|
|
|
|
equatableImplementationsByFullyQualifiedName[comparedTypeName] = declaration
|
|
}
|
|
}
|
|
}
|
|
|
|
return typesWithEquatableConformances.compactMap { typeName, declarationWithEquatableConformance in
|
|
// Types conforming to Strideable get a default `==` implementation via that protocol,
|
|
// so a custom `==` on such a type may be intentionally overriding that default.
|
|
guard !typesWithStrideableConformances.contains(typeName) else { return nil }
|
|
|
|
guard let typeDeclaration = typeDeclarationsByFullyQualifiedName[typeName],
|
|
let equatableImplementation = equatableImplementationsByFullyQualifiedName[typeName]
|
|
else { return nil }
|
|
|
|
return EquatableType(
|
|
typeDeclaration: typeDeclaration,
|
|
equatableFunction: equatableImplementation,
|
|
declarationWithEquatableConformance: declarationWithEquatableConformance
|
|
)
|
|
}
|
|
}
|
|
|
|
/// 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>? {
|
|
let funcIndex = equatableImplementation.keywordIndex
|
|
|
|
guard 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
|
|
}
|
|
}
|
|
|
|
extension TypeName {
|
|
/// Whether or not this type name is known to be non-Equatable
|
|
var isKnownNonEquatableType: Bool {
|
|
let knownNonEquatableTypes = ["AnyClass", "Any.Type"]
|
|
return knownNonEquatableTypes.contains(string) || isTuple
|
|
}
|
|
}
|