Add rule to remove redundant Equatable implementations

This commit is contained in:
Cal Stephens
2024-09-25 15:54:29 -07:00
committed by Cal Stephens
parent 46b3afc372
commit d2ea346884
15 changed files with 1076 additions and 61 deletions
+56
View File
@@ -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.
+25 -5
View File
@@ -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])
}
}
}
+6
View File
@@ -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
+21
View File
@@ -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
+37 -15
View File
@@ -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
+1
View File
@@ -61,6 +61,7 @@ let ruleRegistry: [String: FormatRule] = [
"redundantBackticks": .redundantBackticks,
"redundantBreak": .redundantBreak,
"redundantClosure": .redundantClosure,
"redundantEquatable": .redundantEquatable,
"redundantExtensionACL": .redundantExtensionACL,
"redundantFileprivate": .redundantFileprivate,
"redundantGet": .redundantGet,
+1 -1
View File
@@ -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,
+4 -33
View File
@@ -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
+378
View File
@@ -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) })
+14
View File
@@ -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 */,
+1 -1
View File
@@ -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))
+10 -4
View File
@@ -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()
[]
)
}
+3 -1
View File
@@ -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
}
+518
View File
@@ -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)
}
}