diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e35ede47..9031a0d56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ##### Enhancements +- Added the `--retain-equatable-properties` and `--retain-hashable-properties` options to retain all properties on `Equatable` and `Hashable` types. - Expose a stable `@periphery//bazel:generated` package group so Bazel projects can grant visibility to Periphery's generated scan target and use `--bazel-check-visibility` safely. - Added a `--bazel-query` option to override the default Bazel top-level target query. diff --git a/README.md b/README.md index e3ef5f055..652f8ae63 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ - [Unused Imports](#unused-imports) - [Objective-C](#objective-c) - [Codable](#codable) + - [Equatable and Hashable](#equatable-and-hashable) - [XCTestCase](#xctestcase) - [Interface Builder](#interface-builder) - [SPI (System Programming Interface)](#spi-system-programming-interface) @@ -312,6 +313,10 @@ Swift synthesizes additional code for `Codable` types that is not visible to Per If `Codable` conformance is declared by a protocol in an external module not scanned by Periphery, you can instruct Periphery to identify the protocols as `Codable` with `--external-codable-protocols "ExternalProtocol"`. +### Equatable and Hashable + +Swift synthesizes additional code for `Equatable` and `Hashable` types that is not visible to Periphery and can result in false positives for properties not directly referenced from non-synthesized code. If your project contains many such types, you can retain all properties on `Equatable` types with `--retain-equatable-properties`, or all properties on `Hashable` types with `--retain-hashable-properties`. The `Equatable` option also retains properties on `Hashable` types because `Hashable` refines `Equatable`. + ### XCTestCase Any class that inherits `XCTestCase` is automatically retained along with its test methods. However, when a class inherits `XCTestCase` indirectly via another class, e.g., `UnitTestCase`, and that class resides in a target that isn't scanned by Periphery, you need to use the `--external-test-case-classes UnitTestCase` option to instruct Periphery to treat `UnitTestCase` as an `XCTestCase` subclass. diff --git a/Sources/BUILD.bazel b/Sources/BUILD.bazel index 24286f675..322995fdd 100644 --- a/Sources/BUILD.bazel +++ b/Sources/BUILD.bazel @@ -68,6 +68,7 @@ swift_library( "SourceGraph/Mutators/DynamicMemberRetainer.swift", "SourceGraph/Mutators/EntryPointAttributeRetainer.swift", "SourceGraph/Mutators/EnumCaseReferenceBuilder.swift", + "SourceGraph/Mutators/EquatableHashablePropertyRetainer.swift", "SourceGraph/Mutators/ExtensionReferenceBuilder.swift", "SourceGraph/Mutators/ExternalOverrideRetainer.swift", "SourceGraph/Mutators/ExternalTypeProtocolConformanceReferenceRemover.swift", diff --git a/Sources/Configuration/Configuration.swift b/Sources/Configuration/Configuration.swift index 4afc83458..a2d2e78f1 100644 --- a/Sources/Configuration/Configuration.swift +++ b/Sources/Configuration/Configuration.swift @@ -95,6 +95,12 @@ public final class Configuration { @Setting(key: "retain_encodable_properties", defaultValue: false) public var retainEncodableProperties: Bool + @Setting(key: "retain_equatable_properties", defaultValue: false) + public var retainEquatableProperties: Bool + + @Setting(key: "retain_hashable_properties", defaultValue: false) + public var retainHashableProperties: Bool + @Setting(key: "verbose", defaultValue: false) public var verbose: Bool @@ -226,7 +232,8 @@ public final class Configuration { $externalEncodableProtocols, $externalCodableProtocols, $externalTestCaseClasses, $verbose, $quiet, $color, $disableUpdateCheck, $strict, $indexStorePath, $skipBuild, $skipSchemesValidation, $cleanBuild, $buildArguments, $xcodeListArguments, $relativeResults, - $jsonPackageManifestPath, $retainCodableProperties, $retainEncodableProperties, $baseline, $writeBaseline, + $jsonPackageManifestPath, $retainCodableProperties, $retainEncodableProperties, $retainEquatableProperties, + $retainHashableProperties, $baseline, $writeBaseline, $writeResults, $genericProjectConfig, $bazel, $bazelFilter, $bazelQuery, $bazelIndexStore, $bazelCheckVisibility, ] diff --git a/Sources/Frontend/Commands/ScanCommand.swift b/Sources/Frontend/Commands/ScanCommand.swift index 990db0682..d29156377 100644 --- a/Sources/Frontend/Commands/ScanCommand.swift +++ b/Sources/Frontend/Commands/ScanCommand.swift @@ -105,6 +105,12 @@ struct ScanCommand: ParsableCommand { @Flag(help: "Retain properties on Encodable types only") var retainEncodableProperties: Bool = defaultConfiguration.$retainEncodableProperties.defaultValue + @Flag(help: "Retain properties on Equatable types, including Hashable types") + var retainEquatableProperties: Bool = defaultConfiguration.$retainEquatableProperties.defaultValue + + @Flag(help: "Retain properties on Hashable types") + var retainHashableProperties: Bool = defaultConfiguration.$retainHashableProperties.defaultValue + @Flag(help: "Clean existing build artifacts before building") var cleanBuild: Bool = defaultConfiguration.$cleanBuild.defaultValue @@ -217,6 +223,8 @@ struct ScanCommand: ParsableCommand { configuration.apply(\.$relativeResults, relativeResults) configuration.apply(\.$retainCodableProperties, retainCodableProperties) configuration.apply(\.$retainEncodableProperties, retainEncodableProperties) + configuration.apply(\.$retainEquatableProperties, retainEquatableProperties) + configuration.apply(\.$retainHashableProperties, retainHashableProperties) configuration.apply(\.$jsonPackageManifestPath, jsonPackageManifestPath) configuration.apply(\.$baseline, baseline) configuration.apply(\.$writeBaseline, writeBaseline) diff --git a/Sources/SourceGraph/Mutators/EquatableHashablePropertyRetainer.swift b/Sources/SourceGraph/Mutators/EquatableHashablePropertyRetainer.swift new file mode 100644 index 000000000..4fffbac35 --- /dev/null +++ b/Sources/SourceGraph/Mutators/EquatableHashablePropertyRetainer.swift @@ -0,0 +1,37 @@ +import Configuration +import Foundation +import Shared + +final class EquatableHashablePropertyRetainer: SourceGraphMutator { + private let graph: SourceGraph + private let configuration: Configuration + + required init(graph: SourceGraph, configuration: Configuration, swiftVersion _: SwiftVersion) { + self.graph = graph + self.configuration = configuration + } + + func mutate() { + for decl in graph.declarations(ofKinds: Declaration.Kind.discreteConformableKinds) { + guard decl.kind != .class, shouldRetainProperties(of: decl) else { continue } + + for decl in decl.declarations { + guard decl.kind == .varInstance else { continue } + + graph.markRetained(decl) + } + } + } + + private func shouldRetainProperties(of decl: Declaration) -> Bool { + if configuration.retainEquatableProperties, graph.isEquatable(decl) { + return true + } + + if configuration.retainHashableProperties, graph.isHashable(decl) { + return true + } + + return false + } +} diff --git a/Sources/SourceGraph/SourceGraph.swift b/Sources/SourceGraph/SourceGraph.swift index 63534fb5c..5ac8247ba 100644 --- a/Sources/SourceGraph/SourceGraph.swift +++ b/Sources/SourceGraph/SourceGraph.swift @@ -403,4 +403,18 @@ public final class SourceGraph { [.protocol, .typealias].contains($0.declarationKind) && encodableTypes.contains($0.name) } } + + func isEquatable(_ decl: Declaration) -> Bool { + let equatableTypes = ["Equatable", "Hashable"] + + return inheritedTypeReferences(of: decl).contains { + [.protocol, .typealias].contains($0.declarationKind) && equatableTypes.contains($0.name) + } + } + + func isHashable(_ decl: Declaration) -> Bool { + inheritedTypeReferences(of: decl).contains { + [.protocol, .typealias].contains($0.declarationKind) && $0.name == "Hashable" + } + } } diff --git a/Sources/SourceGraph/SourceGraphMutatorRunner.swift b/Sources/SourceGraph/SourceGraphMutatorRunner.swift index c9fc12919..9c2c3ea4c 100644 --- a/Sources/SourceGraph/SourceGraphMutatorRunner.swift +++ b/Sources/SourceGraph/SourceGraphMutatorRunner.swift @@ -44,6 +44,7 @@ public final class SourceGraphMutatorRunner { PropertyWrapperRetainer.self, ResultBuilderRetainer.self, CodablePropertyRetainer.self, + EquatableHashablePropertyRetainer.self, ExternalOverrideRetainer.self, AncestralReferenceEliminator.self, diff --git a/Tests/Fixtures/Sources/RetentionFixtures/testRetainsEquatableProperties.swift b/Tests/Fixtures/Sources/RetentionFixtures/testRetainsEquatableProperties.swift new file mode 100644 index 000000000..289689271 --- /dev/null +++ b/Tests/Fixtures/Sources/RetentionFixtures/testRetainsEquatableProperties.swift @@ -0,0 +1,29 @@ +import Foundation + +public struct FixtureStruct222: Equatable { + let unused: Int + + init(unused: Int) { + self.unused = unused + } +} + +public struct FixtureStruct223: Hashable { + let unused: Int + + init(unused: Int) { + self.unused = unused + } +} + +public final class FixtureClass222: Equatable { + let unused: Int + + init(unused: Int) { + self.unused = unused + } + + public static func == (lhs: FixtureClass222, rhs: FixtureClass222) -> Bool { + true + } +} diff --git a/Tests/Fixtures/Sources/RetentionFixtures/testRetainsHashableProperties.swift b/Tests/Fixtures/Sources/RetentionFixtures/testRetainsHashableProperties.swift new file mode 100644 index 000000000..b05d18152 --- /dev/null +++ b/Tests/Fixtures/Sources/RetentionFixtures/testRetainsHashableProperties.swift @@ -0,0 +1,9 @@ +import Foundation + +public struct FixtureStruct224: Hashable { + let unused: Int + + init(unused: Int) { + self.unused = unused + } +} diff --git a/Tests/PeripheryTests/RetentionTest.swift b/Tests/PeripheryTests/RetentionTest.swift index b81868b78..599bb83ec 100644 --- a/Tests/PeripheryTests/RetentionTest.swift +++ b/Tests/PeripheryTests/RetentionTest.swift @@ -1002,6 +1002,64 @@ final class RetentionTest: FixtureSourceGraphTestCase { } } + func testRetainsEquatableProperties() { + analyze( + retainPublic: true, + retainEquatableProperties: false, + retainAssignOnlyProperties: false + ) { + assertReferenced(.struct("FixtureStruct222")) { + self.assertNotReferenced(.functionConstructor("init(unused:)")) + self.assertAssignOnlyProperty(.varInstance("unused")) + } + } + + analyze( + retainPublic: true, + retainEquatableProperties: true + ) { + assertReferenced(.struct("FixtureStruct222")) { + self.assertNotReferenced(.functionConstructor("init(unused:)")) + self.assertReferenced(.varInstance("unused")) + self.assertNotAssignOnlyProperty(.varInstance("unused")) + } + + assertReferenced(.struct("FixtureStruct223")) { + self.assertNotReferenced(.functionConstructor("init(unused:)")) + self.assertReferenced(.varInstance("unused")) + self.assertNotAssignOnlyProperty(.varInstance("unused")) + } + + assertReferenced(.class("FixtureClass222")) { + self.assertAssignOnlyProperty(.varInstance("unused")) + } + } + } + + func testRetainsHashableProperties() { + analyze( + retainPublic: true, + retainHashableProperties: false, + retainAssignOnlyProperties: false + ) { + assertReferenced(.struct("FixtureStruct224")) { + self.assertNotReferenced(.functionConstructor("init(unused:)")) + self.assertAssignOnlyProperty(.varInstance("unused")) + } + } + + analyze( + retainPublic: true, + retainHashableProperties: true + ) { + assertReferenced(.struct("FixtureStruct224")) { + self.assertNotReferenced(.functionConstructor("init(unused:)")) + self.assertReferenced(.varInstance("unused")) + self.assertNotAssignOnlyProperty(.varInstance("unused")) + } + } + } + func testRetainsFilesOption() { analyze(retainFiles: [testFixturePath.string]) { assertReferenced(.class("FixtureClass100")) diff --git a/Tests/Shared/FixtureSourceGraphTestCase.swift b/Tests/Shared/FixtureSourceGraphTestCase.swift index 09392af50..250c707a3 100644 --- a/Tests/Shared/FixtureSourceGraphTestCase.swift +++ b/Tests/Shared/FixtureSourceGraphTestCase.swift @@ -20,6 +20,8 @@ class FixtureSourceGraphTestCase: SPMSourceGraphTestCase { superfluousIgnoreComments: Bool = true, retainCodableProperties: Bool = false, retainEncodableProperties: Bool = false, + retainEquatableProperties: Bool = false, + retainHashableProperties: Bool = false, retainUnusedProtocolFuncParams: Bool = false, retainAssignOnlyProperties: Bool = false, retainAssignOnlyPropertyTypes: [String] = [], @@ -40,6 +42,8 @@ class FixtureSourceGraphTestCase: SPMSourceGraphTestCase { configuration.externalCodableProtocols = externalCodableProtocols configuration.retainCodableProperties = retainCodableProperties configuration.retainEncodableProperties = retainEncodableProperties + configuration.retainEquatableProperties = retainEquatableProperties + configuration.retainHashableProperties = retainHashableProperties configuration.retainUnusedProtocolFuncParams = retainUnusedProtocolFuncParams configuration.retainAssignOnlyPropertyTypes = retainAssignOnlyPropertyTypes configuration.externalTestCaseClasses = externalTestCaseClasses