diff --git a/CHANGELOG.md b/CHANGELOG.md index da9acb7dc..44863eb7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Added the `--retain-unused-imported-modules` option. - Added the `--format gitlab-codemagic` formatting option for GitLabs Code Quality artifact reports +- Added the `--retain-ibaction` option and `retain_ibaction` configuration key to retain Interface Builder actions in Swift Package Manager targets. ##### Bug Fixes diff --git a/README.md b/README.md index c55fcda1f..1d95eb9af 100644 --- a/README.md +++ b/README.md @@ -321,6 +321,8 @@ Any class that inherits `XCTestCase` is automatically retained along with its te If your project contains Interface Builder files (such as storyboards and XIBs), Periphery will take these into account when identifying unused declarations. However, Periphery currently only identifies unused classes. This limitation exists because Periphery does not yet fully parse Interface Builder files (see [issue #212](https://github.com/peripheryapp/periphery/issues/212)). Due to Periphery's design principle of avoiding false positives, it is assumed that if a class is referenced in an Interface Builder file, all of its `IBOutlets` and `IBActions` are used, even if they might not be in reality. This approach will be revised to accurately identify unused `IBActions` and `IBOutlets` once Periphery gains the capability to parse Interface Builder files. +If your Swift Package Manager targets include Interface Builder actions that aren't referenced during analysis, enable the `retain_ibaction` setting in `.periphery.yml`, or pass `--retain-ibaction` on the command line. With this setting, methods annotated with `@IBAction` or `@IBSegueAction` (and their containing types) are retained automatically. + ## Comment Commands For whatever reason, you may want to keep some unused code. Source code comment commands can be used to ignore specific declarations and exclude them from the results. diff --git a/Sources/BUILD.bazel b/Sources/BUILD.bazel index 02c8f414e..ba6170149 100644 --- a/Sources/BUILD.bazel +++ b/Sources/BUILD.bazel @@ -68,6 +68,7 @@ swift_library( "SourceGraph/Mutators/ExternalOverrideRetainer.swift", "SourceGraph/Mutators/ExternalTypeProtocolConformanceReferenceRemover.swift", "SourceGraph/Mutators/GenericClassAndStructConstructorReferenceBuilder.swift", + "SourceGraph/Mutators/InterfaceBuilderActionRetainer.swift", "SourceGraph/Mutators/InterfaceBuilderPropertyRetainer.swift", "SourceGraph/Mutators/ObjCAccessibleRetainer.swift", "SourceGraph/Mutators/PropertyWrapperRetainer.swift", diff --git a/Sources/Configuration/Configuration.swift b/Sources/Configuration/Configuration.swift index e5e5c9ecc..0254cfe1f 100644 --- a/Sources/Configuration/Configuration.swift +++ b/Sources/Configuration/Configuration.swift @@ -74,6 +74,9 @@ public final class Configuration { @Setting(key: "retain_swift_ui_previews", defaultValue: false) public var retainSwiftUIPreviews: Bool + @Setting(key: "retain_ibaction", defaultValue: false) + public var retainIbaction: Bool + @Setting(key: "disable_redundant_public_analysis", defaultValue: false) public var disableRedundantPublicAnalysis: Bool @@ -203,7 +206,7 @@ public final class Configuration { lazy var settings: [any AbstractSetting] = [ $project, $schemes, $excludeTargets, $excludeTests, $indexExclude, $reportExclude, $reportInclude, $outputFormat, $retainPublic, $retainFiles, $retainAssignOnlyProperties, $retainAssignOnlyPropertyTypes, $retainObjcAccessible, - $retainObjcAnnotated, $retainUnusedProtocolFuncParams, $retainSwiftUIPreviews, $disableRedundantPublicAnalysis, + $retainObjcAnnotated, $retainUnusedProtocolFuncParams, $retainSwiftUIPreviews, $retainIbaction, $disableRedundantPublicAnalysis, $disableUnusedImportAnalysis, $retainUnusedImportedModules, $externalEncodableProtocols, $externalCodableProtocols, $externalTestCaseClasses, $verbose, $quiet, $disableUpdateCheck, $strict, $indexStorePath, $skipBuild, $skipSchemesValidation, $cleanBuild, $buildArguments, $xcodeListArguments, $relativeResults, $jsonPackageManifestPath, diff --git a/Sources/Frontend/Commands/ScanCommand.swift b/Sources/Frontend/Commands/ScanCommand.swift index 1aa846347..22bf15aa9 100644 --- a/Sources/Frontend/Commands/ScanCommand.swift +++ b/Sources/Frontend/Commands/ScanCommand.swift @@ -93,6 +93,9 @@ struct ScanCommand: FrontendCommand { @Flag(help: "Retain SwiftUI previews") var retainSwiftUIPreviews: Bool = defaultConfiguration.$retainSwiftUIPreviews.defaultValue + @Flag(help: "Retain declarations annotated with @IBAction or @IBSegueAction") + var retainIbaction: Bool = defaultConfiguration.$retainIbaction.defaultValue + @Flag(help: "Retain properties on Codable types (including Encodable and Decodable)") var retainCodableProperties: Bool = defaultConfiguration.$retainCodableProperties.defaultValue @@ -173,6 +176,7 @@ struct ScanCommand: FrontendCommand { configuration.apply(\.$retainObjcAnnotated, retainObjcAnnotated) configuration.apply(\.$retainUnusedProtocolFuncParams, retainUnusedProtocolFuncParams) configuration.apply(\.$retainSwiftUIPreviews, retainSwiftUIPreviews) + configuration.apply(\.$retainIbaction, retainIbaction) configuration.apply(\.$disableRedundantPublicAnalysis, disableRedundantPublicAnalysis) configuration.apply(\.$disableUnusedImportAnalysis, disableUnusedImportAnalysis) configuration.apply(\.$retainUnusedImportedModules, retainUnusedImportedModules) diff --git a/Sources/SourceGraph/Mutators/InterfaceBuilderActionRetainer.swift b/Sources/SourceGraph/Mutators/InterfaceBuilderActionRetainer.swift new file mode 100644 index 000000000..eaf7b1ef3 --- /dev/null +++ b/Sources/SourceGraph/Mutators/InterfaceBuilderActionRetainer.swift @@ -0,0 +1,26 @@ +import Configuration +import Foundation +import Shared + +final class InterfaceBuilderActionRetainer: SourceGraphMutator { + private let graph: SourceGraph + private let configuration: Configuration + private static let actionAttributes: Set = ["IBAction", "IBSegueAction"] + + required init(graph: SourceGraph, configuration: Configuration, swiftVersion _: SwiftVersion) { + self.graph = graph + self.configuration = configuration + } + + func mutate() { + guard configuration.retainIbaction else { return } + + graph.allDeclarations + .lazy + .filter { !$0.attributes.isDisjoint(with: Self.actionAttributes) } + .forEach { declaration in + graph.markRetained(declaration) + declaration.ancestralDeclarations.forEach { graph.markRetained($0) } + } + } +} diff --git a/Sources/SourceGraph/SourceGraphMutatorRunner.swift b/Sources/SourceGraph/SourceGraphMutatorRunner.swift index 93aaf963c..241a4f7f9 100644 --- a/Sources/SourceGraph/SourceGraphMutatorRunner.swift +++ b/Sources/SourceGraph/SourceGraphMutatorRunner.swift @@ -33,6 +33,7 @@ public final class SourceGraphMutatorRunner { DynamicMemberRetainer.self, UnusedParameterRetainer.self, AssetReferenceRetainer.self, + InterfaceBuilderActionRetainer.self, EntryPointAttributeRetainer.self, PubliclyAccessibleRetainer.self, XCTestRetainer.self, diff --git a/Tests/Fixtures/Sources/RetentionFixtures/testRetainsInterfaceBuilderActionsWhenConfigured.swift b/Tests/Fixtures/Sources/RetentionFixtures/testRetainsInterfaceBuilderActionsWhenConfigured.swift new file mode 100644 index 000000000..b7ae85c3c --- /dev/null +++ b/Tests/Fixtures/Sources/RetentionFixtures/testRetainsInterfaceBuilderActionsWhenConfigured.swift @@ -0,0 +1,3 @@ +class RetainIbactionFixture { + @IBAction func tapped(_ sender: Any) {} +} diff --git a/Tests/PeripheryTests/RetentionTest.swift b/Tests/PeripheryTests/RetentionTest.swift index 2339629a9..e5d5fadc5 100644 --- a/Tests/PeripheryTests/RetentionTest.swift +++ b/Tests/PeripheryTests/RetentionTest.swift @@ -1,3 +1,4 @@ +import Configuration import SystemPackage @testable import TestShared import XCTest @@ -984,9 +985,13 @@ final class RetentionTest: FixtureSourceGraphTestCase { } func testRetainsFilesOption() { - analyze(retainFiles: [testFixturePath.string]) { - assertReferenced(.class("FixtureClass100")) - } + let retainConfiguration = Configuration() + retainConfiguration.retainFiles = [testFixturePath.string] + retainConfiguration.buildFilenameMatchers() + + index(sourceFiles: [testFixturePath], configuration: retainConfiguration) + + assertReferenced(.class("FixtureClass100")) analyze(retainFiles: []) { assertNotReferenced(.class("FixtureClass100")) @@ -1665,6 +1670,18 @@ final class RetentionTest: FixtureSourceGraphTestCase { } } + func testRetainsInterfaceBuilderActionsWhenConfigured() { + let configuration = Configuration() + configuration.retainIbaction = true + configuration.buildFilenameMatchers() + + index(sourceFiles: [testFixturePath], configuration: configuration) + + assertReferenced(.class("RetainIbactionFixture")) { + self.assertReferenced(.functionMethodInstance("tapped(_:)")) + } + } + // MARK: - Known Failures // https://github.com/apple/swift/issues/56165 diff --git a/Tests/Shared/SourceGraphTestCase.swift b/Tests/Shared/SourceGraphTestCase.swift index 6cceed00e..8da4cb557 100644 --- a/Tests/Shared/SourceGraphTestCase.swift +++ b/Tests/Shared/SourceGraphTestCase.swift @@ -81,7 +81,7 @@ open class SourceGraphTestCase: XCTestCase { } else { guard let declaration = materialize(description, file: file, line: line) else { return } - if !Self.graph.usedDeclarations.contains(declaration) { + if !Self.graph.usedDeclarations.contains(declaration) && !Self.graph.isRetained(declaration) { XCTFail("Expected declaration to be referenced: \(declaration)", file: file, line: line) }