From fe56f5d050d1404b2016e9a1d477b5f27a62e684 Mon Sep 17 00:00:00 2001 From: Horia Ciurdar Date: Wed, 13 May 2026 14:30:00 -0700 Subject: [PATCH] Add Equatable and Hashable property retention --- CHANGELOG.md | 2 +- README.md | 5 ++ Sources/BUILD.bazel | 1 + Sources/Configuration/Configuration.swift | 9 ++- Sources/Frontend/Commands/ScanCommand.swift | 8 +++ .../EquatableHashablePropertyRetainer.swift | 37 ++++++++++++ Sources/SourceGraph/SourceGraph.swift | 14 +++++ .../SourceGraphMutatorRunner.swift | 1 + .../testRetainsEquatableProperties.swift | 29 ++++++++++ .../testRetainsHashableProperties.swift | 9 +++ Tests/PeripheryTests/RetentionTest.swift | 58 +++++++++++++++++++ Tests/Shared/FixtureSourceGraphTestCase.swift | 4 ++ 12 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 Sources/SourceGraph/Mutators/EquatableHashablePropertyRetainer.swift create mode 100644 Tests/Fixtures/Sources/RetentionFixtures/testRetainsEquatableProperties.swift create mode 100644 Tests/Fixtures/Sources/RetentionFixtures/testRetainsHashableProperties.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 01a21b3a0..2c98efebd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ ##### Enhancements -- None. +- Added the `--retain-equatable-properties` and `--retain-hashable-properties` options to retain all properties on `Equatable` and `Hashable` types. ##### Bug Fixes diff --git a/README.md b/README.md index 17d3ad87a..c6af2bca0 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 88aa09087..45c6e4b4a 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 @@ -223,7 +229,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, $bazelIndexStore, $bazelCheckVisibility, ] diff --git a/Sources/Frontend/Commands/ScanCommand.swift b/Sources/Frontend/Commands/ScanCommand.swift index c6bb3a63e..8100ace3d 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 @@ -214,6 +220,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