From d2ea3468847921226d3755725a2e82b403d8a747 Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Wed, 25 Sep 2024 15:54:29 -0700 Subject: [PATCH] Add rule to remove redundant Equatable implementations --- Rules.md | 56 ++ Sources/DeclarationHelpers.swift | 30 +- Sources/OptionDescriptor.swift | 6 + Sources/Options.swift | 21 + Sources/ParsingHelpers.swift | 52 +- Sources/RuleRegistry.generated.swift | 1 + Sources/Rules/EmptyExtensions.swift | 2 +- Sources/Rules/OrganizeDeclarations.swift | 37 +- Sources/Rules/RedundantEquatable.swift | 378 +++++++++++++ Sources/Rules/UnusedPrivateDeclarations.swift | 2 +- SwiftFormat.xcodeproj/project.pbxproj | 14 + Tests/CodeOrganizationTests.swift | 2 +- Tests/ParsingHelpersTests.swift | 14 +- Tests/ProjectFilePaths.swift | 4 +- Tests/Rules/RedundantEquatableTests.swift | 518 ++++++++++++++++++ 15 files changed, 1076 insertions(+), 61 deletions(-) create mode 100644 Sources/Rules/RedundantEquatable.swift create mode 100644 Tests/Rules/RedundantEquatableTests.swift diff --git a/Rules.md b/Rules.md index b9493633..631faf31 100644 --- a/Rules.md +++ b/Rules.md @@ -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.
+## redundantEquatable + +Omit a hand-written Equatable implementation when the compiler-synthesized conformance would be equivalent. + +Option | Description +--- | --- +`--equatablemacro` | For example: "@Equatable,EquatableMacroLib" + +
+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 +- } + } +``` + +
+
+ ## redundantExtensionACL Remove redundant access control modifiers. diff --git a/Sources/DeclarationHelpers.swift b/Sources/DeclarationHelpers.swift index 678521be..af4a7173 100644 --- a/Sources/DeclarationHelpers.swift +++ b/Sources/DeclarationHelpers.swift @@ -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]) } } } diff --git a/Sources/OptionDescriptor.swift b/Sources/OptionDescriptor.swift index d4acc543..6fa9edb4 100644 --- a/Sources/OptionDescriptor.swift +++ b/Sources/OptionDescriptor.swift @@ -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 diff --git a/Sources/Options.swift b/Sources/Options.swift index b187b77f..0e4e9f49 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -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 + 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 = [], + 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 diff --git a/Sources/ParsingHelpers.swift b/Sources/ParsingHelpers.swift index de11deb2..70ce913a 100644 --- a/Sources/ParsingHelpers.swift +++ b/Sources/ParsingHelpers.swift @@ -2137,7 +2137,7 @@ extension Formatter { } /// Range of tokens forming file header comment - func headerCommentTokenRange(includingDirectives directives: [String]) -> Range? { + func headerCommentTokenRange(includingDirectives directives: [String] = []) -> Range? { 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 diff --git a/Sources/RuleRegistry.generated.swift b/Sources/RuleRegistry.generated.swift index b0cd24dc..4e20a3fc 100644 --- a/Sources/RuleRegistry.generated.swift +++ b/Sources/RuleRegistry.generated.swift @@ -61,6 +61,7 @@ let ruleRegistry: [String: FormatRule] = [ "redundantBackticks": .redundantBackticks, "redundantBreak": .redundantBreak, "redundantClosure": .redundantClosure, + "redundantEquatable": .redundantEquatable, "redundantExtensionACL": .redundantExtensionACL, "redundantFileprivate": .redundantFileprivate, "redundantGet": .redundantGet, diff --git a/Sources/Rules/EmptyExtensions.swift b/Sources/Rules/EmptyExtensions.swift index 9b81a297..56dcb5d9 100644 --- a/Sources/Rules/EmptyExtensions.swift +++ b/Sources/Rules/EmptyExtensions.swift @@ -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, diff --git a/Sources/Rules/OrganizeDeclarations.swift b/Sources/Rules/OrganizeDeclarations.swift index 85f61201..8745bc27 100644 --- a/Sources/Rules/OrganizeDeclarations.swift +++ b/Sources/Rules/OrganizeDeclarations.swift @@ -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 diff --git a/Sources/Rules/RedundantEquatable.swift b/Sources/Rules/RedundantEquatable.swift new file mode 100644 index 00000000..f41001b9 --- /dev/null +++ b/Sources/Rules/RedundantEquatable.swift @@ -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() + + 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? { + 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() + 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) { + 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) + } + } + } +} diff --git a/Sources/Rules/UnusedPrivateDeclarations.swift b/Sources/Rules/UnusedPrivateDeclarations.swift index d01ca0dd..075f1289 100644 --- a/Sources/Rules/UnusedPrivateDeclarations.swift +++ b/Sources/Rules/UnusedPrivateDeclarations.swift @@ -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) }) diff --git a/SwiftFormat.xcodeproj/project.pbxproj b/SwiftFormat.xcodeproj/project.pbxproj index 7a5b09a3..f615aafd 100644 --- a/SwiftFormat.xcodeproj/project.pbxproj +++ b/SwiftFormat.xcodeproj/project.pbxproj @@ -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 = ""; }; 01F3DF8B1DB9FD3F00454944 /* Options.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Options.swift; sourceTree = ""; }; 01F3DF8F1DBA003E00454944 /* InferenceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InferenceTests.swift; sourceTree = ""; }; + 082D644A2CA4719B0072DA14 /* RedundantEquatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedundantEquatable.swift; sourceTree = ""; }; + 082D644F2CA471F00072DA14 /* RedundantEquatableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedundantEquatableTests.swift; sourceTree = ""; }; 2E230CA12C4C1C0700A16E2E /* DeclarationHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeclarationHelpers.swift; sourceTree = ""; }; 2E2BAB8B2C57F6B600590239 /* RuleRegistry.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleRegistry.generated.swift; sourceTree = ""; }; 2E2BAB912C57F6DD00590239 /* InitCoderUnavailable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InitCoderUnavailable.swift; sourceTree = ""; }; @@ -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 */, diff --git a/Tests/CodeOrganizationTests.swift b/Tests/CodeOrganizationTests.swift index 13e39924..fc8d275e 100644 --- a/Tests/CodeOrganizationTests.swift +++ b/Tests/CodeOrganizationTests.swift @@ -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)) diff --git a/Tests/ParsingHelpersTests.swift b/Tests/ParsingHelpersTests.swift index 7a1a1481..f8857d0a 100644 --- a/Tests/ParsingHelpersTests.swift +++ b/Tests/ParsingHelpersTests.swift @@ -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() [] ) } diff --git a/Tests/ProjectFilePaths.swift b/Tests/ProjectFilePaths.swift index f29cfe25..473f0607 100644 --- a/Tests/ProjectFilePaths.swift +++ b/Tests/ProjectFilePaths.swift @@ -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 } diff --git a/Tests/Rules/RedundantEquatableTests.swift b/Tests/Rules/RedundantEquatableTests.swift new file mode 100644 index 00000000..118e0720 --- /dev/null +++ b/Tests/Rules/RedundantEquatableTests.swift @@ -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 { + let bar: Bar + } + + extension Foo: Equatable where Bar: Equatable { + static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.bar == rhs.bar + } + } + """ + + let output = """ + struct Foo { + let bar: Bar + } + + extension Foo: Equatable where Bar: Equatable {} + """ + + testFormatting(for: input, [output], rules: [.redundantEquatable, .emptyBraces]) + } + + func testClassEquatableExtensionWithWhereClause() { + let input = """ + class Foo { + 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) + } +}