From 1dc4bc4fab3bf1a2b16f59f8b2a3c8cd288e3028 Mon Sep 17 00:00:00 2001 From: Ian Leitch Date: Sat, 19 Aug 2023 17:13:12 +0200 Subject: [PATCH] Automatic code removal, closes #666 --- Package.resolved | 4 +- Package.swift | 4 + Sources/Frontend/Commands/ScanBehavior.swift | 5 + Sources/Frontend/Commands/ScanCommand.swift | 4 + Sources/Frontend/Scan.swift | 4 +- .../EmptyExtensionSyntaxRemover.swift | 35 ++ .../CodeRemoval/EmptyFileVisitor.swift | 30 ++ .../PublicAccessibilitySyntaxRemover.swift | 194 ++++++++++ .../RedundantProtocolSyntaxRemover.swift | 218 +++++++++++ .../CodeRemoval/ScanResultRemover.swift | 78 ++++ .../CodeRemoval/SyntaxRemover.swift | 6 + .../CodeRemoval/TriviaSplitting.swift | 43 +++ .../UnusedDeclarationSyntaxRemover.swift | 142 +++++++ Sources/PeripheryKit/ScanResult.swift | 9 + Sources/PeripheryKit/ScanResultBuilder.swift | 19 +- .../Mutators/ExtensionReferenceBuilder.swift | 1 + .../SourceGraph/SourceGraph.swift | 8 +- Sources/Shared/Configuration.swift | 11 + ...estClassRedundantPublicAccessibility.swift | 11 + .../RemovalFixtures/testEmptyExtension.swift | 9 + .../RemovalFixtures/testEmptyFile.swift | 5 + .../RemovalFixtures/testFunction.swift | 9 + ...FunctionRedundantPublicAccessibility.swift | 9 + ...tializerRedundantPublicAccessibility.swift | 11 + .../testLeadingTriviaSplitting.swift | 9 + ...PropertyRedundantPublicAccessibility.swift | 9 + .../testRedundantProtocol.swift | 22 ++ ...antPublicAccessibilityWithAttributes.swift | 9 + .../RemovalFixtures/testRootDeclaration.swift | 3 + .../RemovalFixtures/testSimpleProperty.swift | 4 + ...ubscriptRedundantPublicAccessibility.swift | 14 + .../testUnusedCodableProperty.swift | 9 + .../RemovalFixtures/testUnusedEnumCase.swift | 10 + .../testUnusedEnumInListCase.swift | 10 + .../RemovalFixtures/testUnusedExtension.swift | 9 + .../testUnusedInitializer.swift | 10 + .../testUnusedNestedDeclaration.swift | 5 + .../testUnusedNestedTypeWithExtension.swift | 7 + .../RemovalFixtures/testUnusedSubscript.swift | 3 + .../RemovalFixtures/testUnusedTypealias.swift | 3 + Tests/PeripheryTests/RemovalTest.swift | 358 ++++++++++++++++++ Tests/Shared/FixtureSourceGraphTestCase.swift | 6 +- Tests/Shared/SourceGraphTestCase.swift | 2 +- 43 files changed, 1362 insertions(+), 9 deletions(-) create mode 100644 Sources/PeripheryKit/CodeRemoval/EmptyExtensionSyntaxRemover.swift create mode 100644 Sources/PeripheryKit/CodeRemoval/EmptyFileVisitor.swift create mode 100644 Sources/PeripheryKit/CodeRemoval/PublicAccessibilitySyntaxRemover.swift create mode 100644 Sources/PeripheryKit/CodeRemoval/RedundantProtocolSyntaxRemover.swift create mode 100644 Sources/PeripheryKit/CodeRemoval/ScanResultRemover.swift create mode 100644 Sources/PeripheryKit/CodeRemoval/SyntaxRemover.swift create mode 100644 Sources/PeripheryKit/CodeRemoval/TriviaSplitting.swift create mode 100644 Sources/PeripheryKit/CodeRemoval/UnusedDeclarationSyntaxRemover.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testClassRedundantPublicAccessibility.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testEmptyExtension.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testEmptyFile.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testFunction.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testFunctionRedundantPublicAccessibility.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testInitializerRedundantPublicAccessibility.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testLeadingTriviaSplitting.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testPropertyRedundantPublicAccessibility.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testRedundantProtocol.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testRedundantPublicAccessibilityWithAttributes.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testRootDeclaration.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testSimpleProperty.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testSubscriptRedundantPublicAccessibility.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testUnusedCodableProperty.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testUnusedEnumCase.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testUnusedEnumInListCase.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testUnusedExtension.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testUnusedInitializer.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testUnusedNestedDeclaration.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testUnusedNestedTypeWithExtension.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testUnusedSubscript.swift create mode 100644 Tests/Fixtures/RemovalFixtures/testUnusedTypealias.swift create mode 100644 Tests/PeripheryTests/RemovalTest.swift diff --git a/Package.resolved b/Package.resolved index c9f557da9..4fbd379ba 100644 --- a/Package.resolved +++ b/Package.resolved @@ -77,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/tuist/xcodeproj", "state" : { - "revision" : "447c159b0c5fb047a024fd8d942d4a76cf47dde0", - "version" : "8.16.0" + "revision" : "a3e5d54f8c8a2964ee54870fda33b28651416581", + "version" : "8.17.0" } }, { diff --git a/Package.swift b/Package.swift index ae6b7dc66..5e11cf58e 100644 --- a/Package.swift +++ b/Package.swift @@ -85,6 +85,10 @@ var targets: [PackageDescription.Target] = [ ], path: "Tests/Fixtures/RetentionFixtures" ), + .target( + name: "RemovalFixtures", + path: "Tests/Fixtures/RemovalFixtures" + ), .target( name: "UnusedParameterFixtures", path: "Tests/Fixtures/UnusedParameterFixtures", diff --git a/Sources/Frontend/Commands/ScanBehavior.swift b/Sources/Frontend/Commands/ScanBehavior.swift index 6ef8d5087..806ed4355 100644 --- a/Sources/Frontend/Commands/ScanBehavior.swift +++ b/Sources/Frontend/Commands/ScanBehavior.swift @@ -63,6 +63,11 @@ final class ScanBehavior { results = try block(project) let interval = logger.beginInterval("result:output") let filteredResults = OutputDeclarationFilter().filter(results) + + if configuration.autoRemove { + try ScanResultRemover().remove(results: filteredResults) + } + let output = try configuration.outputFormat.formatter.init(configuration: configuration).format(filteredResults) if configuration.outputFormat.supportsAuxiliaryOutput { diff --git a/Sources/Frontend/Commands/ScanCommand.swift b/Sources/Frontend/Commands/ScanCommand.swift index 795981de7..76fe38b32 100644 --- a/Sources/Frontend/Commands/ScanCommand.swift +++ b/Sources/Frontend/Commands/ScanCommand.swift @@ -90,6 +90,9 @@ struct ScanCommand: FrontendCommand { @Flag(help: "Retain properties on Codable types") var retainCodableProperties: Bool = defaultConfiguration.$retainCodableProperties.defaultValue + @Flag(help: "Automatically remove code that can be done so safely without introducing build errors (experimental)") + var autoRemove: Bool = defaultConfiguration.$autoRemove.defaultValue + @Flag(help: "Clean existing build artifacts before building") var cleanBuild: Bool = defaultConfiguration.$cleanBuild.defaultValue @@ -148,6 +151,7 @@ struct ScanCommand: FrontendCommand { configuration.apply(\.$externalEncodableProtocols, externalEncodableProtocols) configuration.apply(\.$externalCodableProtocols, externalCodableProtocols) configuration.apply(\.$externalTestCaseClasses, externalTestCaseClasses) + configuration.apply(\.$autoRemove, autoRemove) configuration.apply(\.$verbose, verbose) configuration.apply(\.$quiet, quiet) configuration.apply(\.$disableUpdateCheck, disableUpdateCheck) diff --git a/Sources/Frontend/Scan.swift b/Sources/Frontend/Scan.swift index 2c4059b2c..9ae9e5179 100644 --- a/Sources/Frontend/Scan.swift +++ b/Sources/Frontend/Scan.swift @@ -58,9 +58,9 @@ final class Scan { logger.endInterval(analyzeInterval) let resultInterval = logger.beginInterval("result:build") - let result = ScanResultBuilder.build(for: graph) + let results = ScanResultBuilder.build(for: graph) logger.endInterval(resultInterval) - return result + return results } } diff --git a/Sources/PeripheryKit/CodeRemoval/EmptyExtensionSyntaxRemover.swift b/Sources/PeripheryKit/CodeRemoval/EmptyExtensionSyntaxRemover.swift new file mode 100644 index 000000000..5c4aba673 --- /dev/null +++ b/Sources/PeripheryKit/CodeRemoval/EmptyExtensionSyntaxRemover.swift @@ -0,0 +1,35 @@ +import Foundation +import Foundation +import SwiftParser +import SwiftSyntax +import SystemPackage + +final class EmptyExtensionSyntaxRemover: SyntaxRewriter, TriviaSplitting { + private let locationBuilder: SourceLocationBuilder + + init(locationBuilder: SourceLocationBuilder) { + self.locationBuilder = locationBuilder + } + + func perform(syntax: SourceFileSyntax) -> SourceFileSyntax { + visit(syntax) + } + + override func visit(_ node: CodeBlockItemListSyntax) -> CodeBlockItemListSyntax { + let newChildren = node.compactMap { child -> CodeBlockItemSyntax? in + guard let extDecl = child.item.as(ExtensionDeclSyntax.self) else { return child } + + let members = extDecl.memberBlock.members + let hasMembers = !(members.count == 0 || (members.count == 1 && members.first?.decl.is(MissingDeclSyntax.self) ?? false)) + let hasInheritance = extDecl.inheritanceClause != nil + + if !hasMembers, !hasInheritance { + return remainingTriviaDecl(from: child.item.leadingTrivia) + } + + return child + } + + return CodeBlockItemListSyntax(newChildren) + } +} diff --git a/Sources/PeripheryKit/CodeRemoval/EmptyFileVisitor.swift b/Sources/PeripheryKit/CodeRemoval/EmptyFileVisitor.swift new file mode 100644 index 000000000..1e423be8c --- /dev/null +++ b/Sources/PeripheryKit/CodeRemoval/EmptyFileVisitor.swift @@ -0,0 +1,30 @@ +import Foundation +import Foundation +import SwiftParser +import SwiftSyntax +import SystemPackage + +final class EmptyFileVisitor: SyntaxVisitor, TriviaSplitting { + private var isEmpty = false + + init() { + super.init(viewMode: .sourceAccurate) + } + + func perform(syntax: SourceFileSyntax) -> Bool { + walk(syntax) + return isEmpty + } + + override func visit(_ node: SourceFileSyntax) -> SyntaxVisitorContinueKind { + if node.statements.count == 0 { + isEmpty = true + } else { + isEmpty = node.statements.allSatisfy { + $0.item.is(ImportDeclSyntax.self) || $0.item.is(MissingDeclSyntax.self) + } + } + + return .skipChildren + } +} diff --git a/Sources/PeripheryKit/CodeRemoval/PublicAccessibilitySyntaxRemover.swift b/Sources/PeripheryKit/CodeRemoval/PublicAccessibilitySyntaxRemover.swift new file mode 100644 index 000000000..1c8ac6f7c --- /dev/null +++ b/Sources/PeripheryKit/CodeRemoval/PublicAccessibilitySyntaxRemover.swift @@ -0,0 +1,194 @@ +import Foundation +import SwiftParser +import SwiftSyntax +import SystemPackage + +final class PublicAccessibilitySyntaxRemover: SyntaxRewriter, SyntaxRemover { + private let resultLocation: SourceLocation + private let locationBuilder: SourceLocationBuilder + + init(resultLocation: SourceLocation, replacements: [String], locationBuilder: SourceLocationBuilder) { + self.resultLocation = resultLocation + self.locationBuilder = locationBuilder + } + + func perform(syntax: SourceFileSyntax) -> SourceFileSyntax { + visit(syntax) + } + + override func visit(_ node: ClassDeclSyntax) -> DeclSyntax { + let newNode = removePublicModifier( + from: node, + at: node.name.positionAfterSkippingLeadingTrivia, + triviaRecipient: \.classKeyword + ) + return super.visit(newNode) + } + + override func visit(_ node: StructDeclSyntax) -> DeclSyntax { + let newNode = removePublicModifier( + from: node, + at: node.name.positionAfterSkippingLeadingTrivia, + triviaRecipient: \.structKeyword + ) + return super.visit(newNode) + } + + override func visit(_ node: EnumDeclSyntax) -> DeclSyntax { + let newNode = removePublicModifier( + from: node, + at: node.name.positionAfterSkippingLeadingTrivia, + triviaRecipient: \.enumKeyword + ) + return super.visit(newNode) + } + + override func visit(_ node: ExtensionDeclSyntax) -> DeclSyntax { + let newNode = removePublicModifier( + from: node, + at: node.extendedType.positionAfterSkippingLeadingTrivia, + triviaRecipient: \.extensionKeyword + ) + return super.visit(newNode) + } + + override func visit(_ node: ProtocolDeclSyntax) -> DeclSyntax { + let newNode = removePublicModifier( + from: node, + at: node.name.positionAfterSkippingLeadingTrivia, + triviaRecipient: \.protocolKeyword + ) + return super.visit(newNode) + } + + override func visit(_ node: InitializerDeclSyntax) -> DeclSyntax { + let newNode = removePublicModifier( + from: node, + at: node.initKeyword.positionAfterSkippingLeadingTrivia, + triviaRecipient: \.initKeyword + ) + return super.visit(newNode) + } + + override func visit(_ node: FunctionDeclSyntax) -> DeclSyntax { + let newNode = removePublicModifier( + from: node, + at: node.name.positionAfterSkippingLeadingTrivia, + triviaRecipient: \.funcKeyword + ) + return super.visit(newNode) + } + + override func visit(_ node: SubscriptDeclSyntax) -> DeclSyntax { + let newNode = removePublicModifier( + from: node, + at: node.subscriptKeyword.positionAfterSkippingLeadingTrivia, + triviaRecipient: \.subscriptKeyword + ) + return super.visit(newNode) + } + + override func visit(_ node: TypeAliasDeclSyntax) -> DeclSyntax { + let newNode = removePublicModifier( + from: node, + at: node.name.positionAfterSkippingLeadingTrivia, + triviaRecipient: \.typealiasKeyword + ) + return super.visit(newNode) + } + + override func visit(_ node: VariableDeclSyntax) -> DeclSyntax { + let newNode = removePublicModifier( + from: node, + at: node.bindings.positionAfterSkippingLeadingTrivia, + triviaRecipient: \.bindingSpecifier + ) + return super.visit(newNode) + } + + override func visit(_ node: ActorDeclSyntax) -> DeclSyntax { + let newNode = removePublicModifier( + from: node, + at: node.name.positionAfterSkippingLeadingTrivia, + triviaRecipient: \.actorKeyword + ) + return super.visit(newNode) + } + + override func visit(_ node: AssociatedTypeDeclSyntax) -> DeclSyntax { + let newNode = removePublicModifier( + from: node, + at: node.name.positionAfterSkippingLeadingTrivia, + triviaRecipient: \.associatedtypeKeyword + ) + return super.visit(newNode) + } + + override func visit(_ node: PrecedenceGroupDeclSyntax) -> DeclSyntax { + let newNode = removePublicModifier( + from: node, + at: node.name.positionAfterSkippingLeadingTrivia, + triviaRecipient: \.precedencegroupKeyword + ) + return super.visit(newNode) + } + + // MARK: - Private + + private func removePublicModifier( + from node: T, + at position: AbsolutePosition, + triviaRecipient: WritableKeyPath + ) -> T { + var removedLeadingTrivia = Trivia(pieces: []) + var didRemove = false + + var newModifiers = node.modifiers.filter { modifier in + if locationBuilder.location(at: position) == resultLocation, + modifier.name.text == "public" { + didRemove = true + removedLeadingTrivia = modifier.leadingTrivia + return false + } + + return true + } + + var newNode = node + + if didRemove { + if newModifiers.count == 0 { + let triviaRecipientNode = node[keyPath: triviaRecipient] + let newTriviaRecipientNode = triviaRecipientNode + .with(\.leadingTrivia, removedLeadingTrivia + newModifiers.leadingTrivia) + newNode = newNode.with(triviaRecipient, newTriviaRecipientNode) + } else { + newModifiers = newModifiers + .with(\.leadingTrivia, removedLeadingTrivia + newModifiers.leadingTrivia) + } + + return newNode.with(\.modifiers, newModifiers) + } else { + return node + } + } +} + +protocol PublicModifiedDecl: SyntaxProtocol { + var modifiers: DeclModifierListSyntax { get set } +} + +extension ClassDeclSyntax: PublicModifiedDecl {} +extension StructDeclSyntax: PublicModifiedDecl {} +extension EnumDeclSyntax: PublicModifiedDecl {} +extension EnumCaseDeclSyntax: PublicModifiedDecl {} +extension ExtensionDeclSyntax: PublicModifiedDecl {} +extension ProtocolDeclSyntax: PublicModifiedDecl {} +extension InitializerDeclSyntax: PublicModifiedDecl {} +extension FunctionDeclSyntax: PublicModifiedDecl {} +extension SubscriptDeclSyntax: PublicModifiedDecl {} +extension TypeAliasDeclSyntax: PublicModifiedDecl {} +extension VariableDeclSyntax: PublicModifiedDecl {} +extension ActorDeclSyntax: PublicModifiedDecl {} +extension AssociatedTypeDeclSyntax: PublicModifiedDecl {} +extension PrecedenceGroupDeclSyntax: PublicModifiedDecl {} diff --git a/Sources/PeripheryKit/CodeRemoval/RedundantProtocolSyntaxRemover.swift b/Sources/PeripheryKit/CodeRemoval/RedundantProtocolSyntaxRemover.swift new file mode 100644 index 000000000..f8daed1f3 --- /dev/null +++ b/Sources/PeripheryKit/CodeRemoval/RedundantProtocolSyntaxRemover.swift @@ -0,0 +1,218 @@ +import Foundation +import SwiftParser +import SwiftSyntax +import SystemPackage + +final class RedundantProtocolSyntaxRemover: SyntaxRewriter, SyntaxRemover, TriviaSplitting { + private let resultLocation: SourceLocation + private let replacements: [String] + private let locationBuilder: SourceLocationBuilder + + init(resultLocation: SourceLocation, replacements: [String], locationBuilder: SourceLocationBuilder) { + self.resultLocation = resultLocation + self.replacements = replacements + self.locationBuilder = locationBuilder + } + + func perform(syntax: SourceFileSyntax) -> SourceFileSyntax { + visit(syntax) + } + + override func visit(_ node: CodeBlockItemListSyntax) -> CodeBlockItemListSyntax { + let node = super.visit(node) + var didRemoveDeclaration = false + + let newChildren = node.compactMap { child -> CodeBlockItemSyntax? in + guard let name = child.item.as(ProtocolDeclSyntax.self)?.name else { return child } + + if resultLocation == locationBuilder.location(at: name.positionAfterSkippingLeadingTrivia) { + didRemoveDeclaration = true + return remainingTriviaDecl(from: child.item.leadingTrivia) + } + + return child + } + + if didRemoveDeclaration { + return CodeBlockItemListSyntax(newChildren) + } + + return node + } + + override func visit(_ node: ClassDeclSyntax) -> DeclSyntax { + guard let node = super.visit(node).as(ClassDeclSyntax.self) else { return DeclSyntax(node) } + guard let inheritanceClause = node.inheritanceClause else { return DeclSyntax(node) } + + var didRemoveDeclaration = false + + let newInheritedTypes = inheritanceClause.inheritedTypes.filter { type in + let typeLocation = locationBuilder.location(at: type.type.positionAfterSkippingLeadingTrivia) + + if resultLocation == typeLocation { + didRemoveDeclaration = true + return false + } + + return true + } + + if didRemoveDeclaration { + return replacingInheritedTypes( + node: node, + inheritanceClause: inheritanceClause, + newInheritedTypes: newInheritedTypes, + triviaRecipient: \.name + ) + } + + return DeclSyntax(node) + } + + override func visit(_ node: StructDeclSyntax) -> DeclSyntax { + guard let node = super.visit(node).as(StructDeclSyntax.self) else { return DeclSyntax(node) } + guard let inheritanceClause = node.inheritanceClause else { return DeclSyntax(node) } + + var didRemoveDeclaration = false + + let newInheritedTypes = inheritanceClause.inheritedTypes.filter { type in + let typeLocation = locationBuilder.location(at: type.type.positionAfterSkippingLeadingTrivia) + + if resultLocation == typeLocation { + didRemoveDeclaration = true + return false + } + + return true + } + + if didRemoveDeclaration { + return replacingInheritedTypes( + node: node, + inheritanceClause: inheritanceClause, + newInheritedTypes: newInheritedTypes, + triviaRecipient: \.name + ) + } + + return DeclSyntax(node) + } + + override func visit(_ node: EnumDeclSyntax) -> DeclSyntax { + guard let node = super.visit(node).as(EnumDeclSyntax.self) else { return DeclSyntax(node) } + guard let inheritanceClause = node.inheritanceClause else { return DeclSyntax(node) } + + var didRemoveDeclaration = false + + let newInheritedTypes = inheritanceClause.inheritedTypes.filter { type in + let typeLocation = locationBuilder.location(at: type.type.positionAfterSkippingLeadingTrivia) + + if resultLocation == typeLocation { + didRemoveDeclaration = true + return false + } + + return true + } + + if didRemoveDeclaration { + return replacingInheritedTypes( + node: node, + inheritanceClause: inheritanceClause, + newInheritedTypes: newInheritedTypes, + triviaRecipient: \.name + ) + } + + return DeclSyntax(node) + } + + override func visit(_ node: ExtensionDeclSyntax) -> DeclSyntax { + guard let node = super.visit(node).as(ExtensionDeclSyntax.self) else { return DeclSyntax(node) } + guard let inheritanceClause = node.inheritanceClause else { return DeclSyntax(node) } + + var didRemoveDeclaration = false + + let newInheritedTypes = inheritanceClause.inheritedTypes.filter { type in + let typeLocation = locationBuilder.location(at: type.type.positionAfterSkippingLeadingTrivia) + + if resultLocation == typeLocation { + didRemoveDeclaration = true + return false + } + + return true + } + + if didRemoveDeclaration { + return replacingInheritedTypes( + node: node, + inheritanceClause: inheritanceClause, + newInheritedTypes: newInheritedTypes, + triviaRecipient: \.extendedType + ) + } + + return DeclSyntax(node) + } + + // MARK: - Private + + private func replacingInheritedTypes( + node: T, + inheritanceClause: InheritanceClauseSyntax, + newInheritedTypes: InheritedTypeListSyntax, + triviaRecipient: WritableKeyPath + ) -> DeclSyntax { + var newInheritedTypes = newInheritedTypes + + if !replacements.isEmpty, let last = newInheritedTypes.last { + // Before appending more types we need to add a comma to the current final item. + let newLast = last + .with(\.trailingTrivia, []) + .with(\.trailingComma, .commaToken(trailingTrivia: .space)) + let endIndex = newInheritedTypes.index(newInheritedTypes.startIndex, offsetBy: newInheritedTypes.count - 1) + newInheritedTypes[endIndex] = newLast + } + + for replacement in replacements { + let inheritedType = InheritedTypeSyntax( + type: IdentifierTypeSyntax(name: .identifier(replacement)), + trailingComma: .commaToken(), + trailingTrivia: .space + ) + newInheritedTypes.append(inheritedType) + } + + let newNode: T + + if newInheritedTypes.count > 0 { + // Remove the trailing coma from the final type + let endIndex = newInheritedTypes.index(newInheritedTypes.startIndex, offsetBy: newInheritedTypes.count - 1) + var newType = newInheritedTypes[endIndex] + let preservedTrivia = newType.trailingTrivia + newType = newType.with(\.trailingComma, nil) + newType = newType.with(\.trailingTrivia, preservedTrivia) + newInheritedTypes[endIndex] = newType + + let newInheritanceClause = inheritanceClause.with(\.inheritedTypes, newInheritedTypes) + newNode = node.with(\.inheritanceClause, newInheritanceClause) + } else { + let triviaRecipientNode = node[keyPath: triviaRecipient] + let newExtendedType = triviaRecipientNode.with(\.trailingTrivia, triviaRecipientNode.trailingTrivia + inheritanceClause.trailingTrivia) + newNode = node + .with(\.inheritanceClause, nil) + .with(triviaRecipient, newExtendedType) + } + + return DeclSyntax(newNode) + } +} + +protocol TypeDeclWithInheritanceClause: DeclSyntaxProtocol { + var inheritanceClause: InheritanceClauseSyntax? { get set } +} +extension ExtensionDeclSyntax: TypeDeclWithInheritanceClause {} +extension ClassDeclSyntax: TypeDeclWithInheritanceClause {} +extension StructDeclSyntax: TypeDeclWithInheritanceClause {} +extension EnumDeclSyntax: TypeDeclWithInheritanceClause {} diff --git a/Sources/PeripheryKit/CodeRemoval/ScanResultRemover.swift b/Sources/PeripheryKit/CodeRemoval/ScanResultRemover.swift new file mode 100644 index 000000000..48adee9c4 --- /dev/null +++ b/Sources/PeripheryKit/CodeRemoval/ScanResultRemover.swift @@ -0,0 +1,78 @@ +import Foundation +import SwiftParser +import SwiftSyntax +import Shared +import SystemPackage + +public final class ScanResultRemover { + private let configuration: Configuration + + public init(configuration: Configuration = .shared) { + self.configuration = configuration + } + + public func remove(results: [ScanResult]) throws { + let locationsByFile: [SourceFile: [(SourceLocation, [String], SyntaxRemover.Type)]] = results.reduce(into: .init()) { dict, result in + let location = result.declaration.location + let file = result.declaration.location.file + + switch result.annotation { + case .unused, .assignOnlyProperty: + dict[file, default: []].append((location, [], UnusedDeclarationSyntaxRemover.self)) + case .redundantPublicAccessibility: + dict[file, default: []].append((location, [], PublicAccessibilitySyntaxRemover.self)) + case let .redundantProtocol(references, inherited): + dict[file, default: []].append((location, [], RedundantProtocolSyntaxRemover.self)) + let replacements = inherited.sorted() + + for reference in references { + let location = reference.location + dict[location.file, default: []].append((location, replacements, RedundantProtocolSyntaxRemover.self)) + } + } + } + + for (file, locations) in locationsByFile { + let source = try String(contentsOf: file.path.url) + var syntax = Parser.parse(source: source) + let locationConverter = SourceLocationConverter(fileName: file.path.string, tree: syntax) + let locationBuilder = SourceLocationBuilder(file: file, locationConverter: locationConverter) + let sortedLocations = locations.sorted { $0.0 > $1.0 } + + for (location, replacements, removerType) in sortedLocations { + let remover = removerType.init( + resultLocation: location, + replacements: replacements, + locationBuilder: locationBuilder) + syntax = remover.perform(syntax: syntax) + } + + syntax = EmptyExtensionSyntaxRemover( + locationBuilder: locationBuilder + ).perform(syntax: syntax) + + let isFileEmpty = EmptyFileVisitor().perform(syntax: syntax) + + var outputPath = file.path + + if let outputBasePath = configuration.removalOutputBasePath, + let fileName = file.path.lastComponent { + outputPath = outputBasePath.appending(fileName) + } + + if isFileEmpty { + let fileManager = FileManager.default + if fileManager.fileExists(atPath: outputPath.string) { + try fileManager.removeItem(at: outputPath.url) + } + } else { + var output = "" + syntax.write(to: &output) + if output != source { + let outputData = output.data(using: .utf8)! + try outputData.write(to: outputPath.url, options: .atomic) + } + } + } + } +} diff --git a/Sources/PeripheryKit/CodeRemoval/SyntaxRemover.swift b/Sources/PeripheryKit/CodeRemoval/SyntaxRemover.swift new file mode 100644 index 000000000..ddf3b8a16 --- /dev/null +++ b/Sources/PeripheryKit/CodeRemoval/SyntaxRemover.swift @@ -0,0 +1,6 @@ +import SwiftSyntax + +protocol SyntaxRemover { + init(resultLocation: SourceLocation, replacements: [String], locationBuilder: SourceLocationBuilder) + func perform(syntax: SourceFileSyntax) -> SourceFileSyntax +} diff --git a/Sources/PeripheryKit/CodeRemoval/TriviaSplitting.swift b/Sources/PeripheryKit/CodeRemoval/TriviaSplitting.swift new file mode 100644 index 000000000..99d2a217c --- /dev/null +++ b/Sources/PeripheryKit/CodeRemoval/TriviaSplitting.swift @@ -0,0 +1,43 @@ +import Foundation +import SwiftParser +import SwiftSyntax + +protocol TriviaSplitting { + func remainingTriviaDecl(from trivia: Trivia) -> T? +} + +protocol TriviaInitializedItem { + init(triviaDecl: DeclSyntax) +} + +extension TriviaSplitting { + func remainingTriviaDecl(from trivia: Trivia) -> T? { + let lines = trivia.description.split(separator: "\n", omittingEmptySubsequences: false) + .reversed() + .dropFirst() // Drop the newline that all trivia ends with + + let blankLineIdx = lines.firstIndex { line in + line.trimmingCharacters(in: .whitespaces).isEmpty + } + + guard let blankLineIdx else { return nil } + + let remainingLines = lines[blankLineIdx.. SourceFileSyntax { + visit(syntax) + } + + override func visit(_ node: CodeBlockItemListSyntax) -> CodeBlockItemListSyntax { + let node = super.visit(node) + var didRemoveDeclaration = false + + let newChildren = node.compactMap { child -> CodeBlockItemSyntax? in + if hasRemovableChild(from: child.item) { + didRemoveDeclaration = true + return remainingTriviaDecl(from: child.item.leadingTrivia) + } + + return child + } + + if didRemoveDeclaration { + return CodeBlockItemListSyntax(newChildren) + } + + return node + } + + override func visit(_ node: MemberBlockItemListSyntax) -> MemberBlockItemListSyntax { + let node = super.visit(node) + var didRemoveDeclaration = false + + let newMembers = node.compactMap { member -> MemberBlockItemSyntax? in + if let varDecl = member.decl.as(VariableDeclSyntax.self) { + guard varDecl.bindings.count == 1, // TODO: Handle multiple bindings, + let binding = varDecl.bindings.first + else { return member } + + let patternLocation = locationBuilder.location(at: binding.pattern.positionAfterSkippingLeadingTrivia) + if resultLocation == patternLocation { + didRemoveDeclaration = true + return remainingTriviaDecl(from: varDecl.leadingTrivia) + } + + return member + } else if let subscriptDecl = member.decl.as(SubscriptDeclSyntax.self) { + let patternLocation = locationBuilder.location(at: subscriptDecl.subscriptKeyword.positionAfterSkippingLeadingTrivia) + if resultLocation == patternLocation { + didRemoveDeclaration = true + return remainingTriviaDecl(from: subscriptDecl.leadingTrivia) + } + + return member + } else if let enumDecl = member.decl.as(EnumCaseDeclSyntax.self) { + let indexToRemove = enumDecl.elements.firstIndex { element in + locationBuilder.location(at: element.positionAfterSkippingLeadingTrivia) == resultLocation + } + + guard let indexToRemove else { return member } + + didRemoveDeclaration = true + + var newElements = enumDecl.elements + + newElements.remove(at: indexToRemove) + + if newElements.count == 0 { + return remainingTriviaDecl(from: enumDecl.leadingTrivia) + } + + // Remove the trailing coma from the final element + let endIndex = newElements.index(newElements.startIndex, offsetBy: newElements.count - 1) + newElements[endIndex] = newElements[endIndex].with(\.trailingComma, nil) + + let newEnumDecl = enumDecl.with(\.elements, newElements) + return member.with(\.decl, DeclSyntax(newEnumDecl)) + } else if hasRemovableChild(from: member.decl) { + didRemoveDeclaration = true + return remainingTriviaDecl(from: member.decl.leadingTrivia) + } + + return member + } + + if didRemoveDeclaration { + return MemberBlockItemListSyntax(newMembers) + } + + return node + } + + // MARK: - Private + + private func hasRemovableChild(from node: SyntaxProtocol) -> Bool { + return node.children(viewMode: .sourceAccurate).contains { child in + var position: AbsolutePosition? + + if let initDecl = child.as(InitializerDeclSyntax.self) { + position = initDecl.initKeyword.positionAfterSkippingLeadingTrivia + } else if let identifier = child.as(IdentifierTypeSyntax.self) { + position = identifier.name.positionAfterSkippingLeadingTrivia + } else if let member = child.as(MemberTypeSyntax.self) { + return hasRemovableChild(from: member) + } else if let token = child.as(TokenSyntax.self) { + if token.tokenKind.isRemovableKind { + position = token.positionAfterSkippingLeadingTrivia + } + } + + guard let position else { return false } + + if resultLocation == locationBuilder.location(at: position) { + return true + } + + return false + } + } +} + +extension TokenKind { + var isRemovableKind: Bool { + switch self { + case .identifier, .binaryOperator, .prefixOperator, .postfixOperator: + return true + case .keyword(let keyword) where keyword == .`init`: + return true + default: + return false + } + } +} diff --git a/Sources/PeripheryKit/ScanResult.swift b/Sources/PeripheryKit/ScanResult.swift index 00dfca5d8..1ae8b9c76 100644 --- a/Sources/PeripheryKit/ScanResult.swift +++ b/Sources/PeripheryKit/ScanResult.swift @@ -6,6 +6,15 @@ public struct ScanResult { case assignOnlyProperty case redundantProtocol(references: Set, inherited: Set) case redundantPublicAccessibility(modules: Set) + + var isRemovable: Bool { + switch self { + case .unused, .redundantPublicAccessibility, .redundantProtocol: + return true + default: + return false + } + } } let declaration: Declaration diff --git a/Sources/PeripheryKit/ScanResultBuilder.swift b/Sources/PeripheryKit/ScanResultBuilder.swift index 1855b8072..554522066 100644 --- a/Sources/PeripheryKit/ScanResultBuilder.swift +++ b/Sources/PeripheryKit/ScanResultBuilder.swift @@ -9,8 +9,23 @@ public struct ScanResultBuilder { let redundantProtocols = graph.redundantProtocols.filter { !removableDeclarations.contains($0.0) } let redundantPublicAccessibility = graph.redundantPublicAccessibility.filter { !removableDeclarations.contains($0.0) } - let annotatedRemovableDeclarations: [ScanResult] = removableDeclarations.map { - .init(declaration: $0, annotation: .unused) + let annotatedRemovableDeclarations: [ScanResult] = removableDeclarations.flatMap { + var extensionResults = [ScanResult]() + + if $0.kind.isExtendableKind, + !graph.retainedDeclarations.contains($0), + !graph.ignoredDeclarations.contains($0) { + let decls = $0.descendentDeclarations.union([$0]) + + for decl in decls { + let extensions = graph.extensions[decl, default: []] + for ext in extensions { + extensionResults.append(ScanResult(declaration: ext, annotation: .unused)) + } + } + } + + return [ScanResult(declaration: $0, annotation: .unused)] + extensionResults } let annotatedAssignOnlyProperties: [ScanResult] = assignOnlyProperties.map { .init(declaration: $0, annotation: .assignOnlyProperty) diff --git a/Sources/PeripheryKit/SourceGraph/Mutators/ExtensionReferenceBuilder.swift b/Sources/PeripheryKit/SourceGraph/Mutators/ExtensionReferenceBuilder.swift index 0780357ef..20adf409c 100644 --- a/Sources/PeripheryKit/SourceGraph/Mutators/ExtensionReferenceBuilder.swift +++ b/Sources/PeripheryKit/SourceGraph/Mutators/ExtensionReferenceBuilder.swift @@ -44,6 +44,7 @@ final class ExtensionReferenceBuilder: SourceGraphMutator { extensionDeclaration.references.forEach { $0.parent = extendedDeclaration } extensionDeclaration.related.forEach { $0.parent = extendedDeclaration } + graph.markExtension(extensionDeclaration, extending: extendedDeclaration) graph.remove(extensionDeclaration) } } diff --git a/Sources/PeripheryKit/SourceGraph/SourceGraph.swift b/Sources/PeripheryKit/SourceGraph/SourceGraph.swift index 7916a77fe..7b47ccf5d 100644 --- a/Sources/PeripheryKit/SourceGraph/SourceGraph.swift +++ b/Sources/PeripheryKit/SourceGraph/SourceGraph.swift @@ -20,6 +20,7 @@ public final class SourceGraph { private(set) var indexedModules: Set = [] private(set) var unusedModuleImports: Set = [] private(set) var assignOnlyProperties: Set = [] + private(set) var extensions: [Declaration: Set] = [:] private var allDeclarationsByKind: [Declaration.Kind: Set] = [:] private var allExplicitDeclarationsByUsr: [String: Declaration] = [:] @@ -259,6 +260,12 @@ public final class SourceGraph { } } + func markExtension(_ extensionDecl: Declaration, extending extendedDecl: Declaration) { + withLock { + _ = extensions[extendedDecl, default: []].insert(extensionDecl) + } + } + func inheritedTypeReferences(of decl: Declaration, seenDeclarations: Set = []) -> Set { var references = Set() @@ -356,4 +363,3 @@ public final class SourceGraph { } } } - diff --git a/Sources/Shared/Configuration.swift b/Sources/Shared/Configuration.swift index 6ebde096e..b6fb24726 100644 --- a/Sources/Shared/Configuration.swift +++ b/Sources/Shared/Configuration.swift @@ -83,6 +83,9 @@ public final class Configuration { @Setting(key: "retain_codable_properties", defaultValue: false) public var retainCodableProperties: Bool + @Setting(key: "auto_remove", defaultValue: false) + public var autoRemove: Bool + @Setting(key: "verbose", defaultValue: false) public var verbose: Bool @@ -109,6 +112,7 @@ public final class Configuration { // Non user facing. public var guidedSetup: Bool = false + public var removalOutputBasePath: FilePath? // Dependencies. private var logger: BaseLogger // Must use BaseLogger as Logger depends upon Configuration. @@ -204,6 +208,10 @@ public final class Configuration { config[$enableUnusedImportsAnalysis.key] = enableUnusedImportsAnalysis } + if $autoRemove.hasNonDefaultValue { + config[$autoRemove.key] = autoRemove + } + if $verbose.hasNonDefaultValue { config[$verbose.key] = verbose } @@ -307,6 +315,8 @@ public final class Configuration { $disableRedundantPublicAnalysis.assign(value) case $enableUnusedImportsAnalysis.key: $enableUnusedImportsAnalysis.assign(value) + case $autoRemove.key: + $autoRemove.assign(value) case $verbose.key: $verbose.assign(value) case $quiet.key: @@ -353,6 +363,7 @@ public final class Configuration { $retainSwiftUIPreviews.reset() $disableRedundantPublicAnalysis.reset() $enableUnusedImportsAnalysis.reset() + $autoRemove.reset() $externalEncodableProtocols.reset() $externalCodableProtocols.reset() $externalTestCaseClasses.reset() diff --git a/Tests/Fixtures/RemovalFixtures/testClassRedundantPublicAccessibility.swift b/Tests/Fixtures/RemovalFixtures/testClassRedundantPublicAccessibility.swift new file mode 100644 index 000000000..b6af5d0a3 --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testClassRedundantPublicAccessibility.swift @@ -0,0 +1,11 @@ +// periphery:ignore +final class ClassRedundantPublicAccessibilityRetainer { + // periphery:ignore + func retain() { + ClassRedundantPublicAccessibility().someFunc() + } +} + +public final class ClassRedundantPublicAccessibility { + public func someFunc() {} +} diff --git a/Tests/Fixtures/RemovalFixtures/testEmptyExtension.swift b/Tests/Fixtures/RemovalFixtures/testEmptyExtension.swift new file mode 100644 index 000000000..84ea8eef3 --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testEmptyExtension.swift @@ -0,0 +1,9 @@ +public class EmptyExtension {} +extension EmptyExtension { + func unused() {} +} +extension EmptyExtension { + // Only comments. +} +public protocol EmptyExtensionProtocol {} +extension EmptyExtension: EmptyExtensionProtocol {} diff --git a/Tests/Fixtures/RemovalFixtures/testEmptyFile.swift b/Tests/Fixtures/RemovalFixtures/testEmptyFile.swift new file mode 100644 index 000000000..627847166 --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testEmptyFile.swift @@ -0,0 +1,5 @@ +import Foundation + +// comment + +class EmptyFile {} diff --git a/Tests/Fixtures/RemovalFixtures/testFunction.swift b/Tests/Fixtures/RemovalFixtures/testFunction.swift new file mode 100644 index 000000000..367d7ea0a --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testFunction.swift @@ -0,0 +1,9 @@ +public class FunctionRemoval { + public func used1() {} + func unused1() {} +} + +extension FunctionRemoval { + public func used2() {} + func unused2() {} +} diff --git a/Tests/Fixtures/RemovalFixtures/testFunctionRedundantPublicAccessibility.swift b/Tests/Fixtures/RemovalFixtures/testFunctionRedundantPublicAccessibility.swift new file mode 100644 index 000000000..11a972f5d --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testFunctionRedundantPublicAccessibility.swift @@ -0,0 +1,9 @@ +// periphery:ignore +final class FunctionRedundantPublicAccessibilityRetainer { + // periphery:ignore + func retain() { + somePublicFunc() + } +} + +public func somePublicFunc() {} diff --git a/Tests/Fixtures/RemovalFixtures/testInitializerRedundantPublicAccessibility.swift b/Tests/Fixtures/RemovalFixtures/testInitializerRedundantPublicAccessibility.swift new file mode 100644 index 000000000..d3afad730 --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testInitializerRedundantPublicAccessibility.swift @@ -0,0 +1,11 @@ +// periphery:ignore +final class InitializerRedundantPublicAccessibilityRetainer { + // periphery:ignore + func retain() { + _ = InitializerRedundantPublicAccessibility() + } +} + +public class InitializerRedundantPublicAccessibility { + public init() {} +} diff --git a/Tests/Fixtures/RemovalFixtures/testLeadingTriviaSplitting.swift b/Tests/Fixtures/RemovalFixtures/testLeadingTriviaSplitting.swift new file mode 100644 index 000000000..c70ad15b6 --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testLeadingTriviaSplitting.swift @@ -0,0 +1,9 @@ +// Trivia to remain, 1 + +// Trivia to remain, 2 + +// Trivia to remove, 1 +// Trivia to remove, 2 +class LeadingTriviaSplittingUnused {} + +public class LeadingTriviaSplittingUsed {} diff --git a/Tests/Fixtures/RemovalFixtures/testPropertyRedundantPublicAccessibility.swift b/Tests/Fixtures/RemovalFixtures/testPropertyRedundantPublicAccessibility.swift new file mode 100644 index 000000000..f3737416f --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testPropertyRedundantPublicAccessibility.swift @@ -0,0 +1,9 @@ +// periphery:ignore +final class PropertyRedundantPublicAccessibilityRetainer { + // periphery:ignore + func retain() { + _ = somePublicProperty + } +} + +public let somePublicProperty: Int = 1 diff --git a/Tests/Fixtures/RemovalFixtures/testRedundantProtocol.swift b/Tests/Fixtures/RemovalFixtures/testRedundantProtocol.swift new file mode 100644 index 000000000..b5b48e560 --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testRedundantProtocol.swift @@ -0,0 +1,22 @@ +protocol RedundantProtocol3_Existential1 {} +protocol RedundantProtocol3_Existential2 {} +protocol RedundantProtocol2: RedundantProtocol3_Existential1, RedundantProtocol3_Existential2 {} +protocol RedundantProtocol1 {} +class RedundantProtocolClass1: RedundantProtocol1, RedundantProtocol2, CustomStringConvertible { + var description: String = "" +} +class RedundantProtocolClass2 {} +extension RedundantProtocolClass2: RedundantProtocol1 {} +class RedundantProtocolClass3 { + class RedundantProtocolClass4: CustomStringConvertible, RedundantProtocol1 { + var description: String = "" + } +} + +public class RedundantProtocolRetainer { + public func retain() { + _ = RedundantProtocolClass1() + _ = RedundantProtocolClass2.self + _ = RedundantProtocolClass3.RedundantProtocolClass4.self + } +} diff --git a/Tests/Fixtures/RemovalFixtures/testRedundantPublicAccessibilityWithAttributes.swift b/Tests/Fixtures/RemovalFixtures/testRedundantPublicAccessibilityWithAttributes.swift new file mode 100644 index 000000000..7a93363e5 --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testRedundantPublicAccessibilityWithAttributes.swift @@ -0,0 +1,9 @@ +// periphery:ignore +private final class Retainer { + func retain() { + redundantPublicAccessibilityWithAttributes() + } +} + +@available(*, message: "hi mum") +public func redundantPublicAccessibilityWithAttributes() {} diff --git a/Tests/Fixtures/RemovalFixtures/testRootDeclaration.swift b/Tests/Fixtures/RemovalFixtures/testRootDeclaration.swift new file mode 100644 index 000000000..3400856cc --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testRootDeclaration.swift @@ -0,0 +1,3 @@ +public class UsedRootDeclaration1 {} +class UnusedRootDeclaration {} +public class UsedRootDeclaration2 {} diff --git a/Tests/Fixtures/RemovalFixtures/testSimpleProperty.swift b/Tests/Fixtures/RemovalFixtures/testSimpleProperty.swift new file mode 100644 index 000000000..ceb52b558 --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testSimpleProperty.swift @@ -0,0 +1,4 @@ +public class SimplePropertyRemoval { + public var used = 1 + var unused = 1 +} diff --git a/Tests/Fixtures/RemovalFixtures/testSubscriptRedundantPublicAccessibility.swift b/Tests/Fixtures/RemovalFixtures/testSubscriptRedundantPublicAccessibility.swift new file mode 100644 index 000000000..9354a1c89 --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testSubscriptRedundantPublicAccessibility.swift @@ -0,0 +1,14 @@ +// periphery:ignore +final class SubscriptRedundantPublicAccessibilityRetainer { + // periphery:ignore + func retain() { + _ = SubscriptRedundantPublicAccessibility()[1] + } +} + +public final class SubscriptRedundantPublicAccessibility { + public subscript(param: Int) -> Int { + return 0 + } +} + diff --git a/Tests/Fixtures/RemovalFixtures/testUnusedCodableProperty.swift b/Tests/Fixtures/RemovalFixtures/testUnusedCodableProperty.swift new file mode 100644 index 000000000..7952faa03 --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testUnusedCodableProperty.swift @@ -0,0 +1,9 @@ +public class UnusedCodableProperty: Codable { + public var used: String? + var unused: String? + + enum CodingKeys: CodingKey { + case used + case unused + } +} diff --git a/Tests/Fixtures/RemovalFixtures/testUnusedEnumCase.swift b/Tests/Fixtures/RemovalFixtures/testUnusedEnumCase.swift new file mode 100644 index 000000000..c32a265ff --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testUnusedEnumCase.swift @@ -0,0 +1,10 @@ +enum EnumCaseRemoval { + case used + case unused +} + +public class EnumCaseRemovalRetainer { + public func retain() { + _ = EnumCaseRemoval.used + } +} diff --git a/Tests/Fixtures/RemovalFixtures/testUnusedEnumInListCase.swift b/Tests/Fixtures/RemovalFixtures/testUnusedEnumInListCase.swift new file mode 100644 index 000000000..87c5584af --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testUnusedEnumInListCase.swift @@ -0,0 +1,10 @@ +enum EnumInListCaseRemoval { + case used1, unused1, used2, unused2 +} + +public class EnumInListCaseRemovalRetainer { + public func retain() { + _ = EnumInListCaseRemoval.used1 + _ = EnumInListCaseRemoval.used2 + } +} diff --git a/Tests/Fixtures/RemovalFixtures/testUnusedExtension.swift b/Tests/Fixtures/RemovalFixtures/testUnusedExtension.swift new file mode 100644 index 000000000..93900e707 --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testUnusedExtension.swift @@ -0,0 +1,9 @@ +class UnusedExtension { + class Inner {} +} +extension UnusedExtension { + func someFunc() {} +} +extension UnusedExtension.Inner { + func someFunc() {} +} diff --git a/Tests/Fixtures/RemovalFixtures/testUnusedInitializer.swift b/Tests/Fixtures/RemovalFixtures/testUnusedInitializer.swift new file mode 100644 index 000000000..06953e91a --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testUnusedInitializer.swift @@ -0,0 +1,10 @@ +public class UnusedInitializer { + public init(used: Int) {} + init(unused1: Int) {} +} + +extension UnusedInitializer { + convenience init(unused2: Int) { + self.init(unused1: unused2) + } +} diff --git a/Tests/Fixtures/RemovalFixtures/testUnusedNestedDeclaration.swift b/Tests/Fixtures/RemovalFixtures/testUnusedNestedDeclaration.swift new file mode 100644 index 000000000..37e0ef8ca --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testUnusedNestedDeclaration.swift @@ -0,0 +1,5 @@ +public class NestedDeclaration { + public class NestedDeclarationInner { + func unused() {} + } +} diff --git a/Tests/Fixtures/RemovalFixtures/testUnusedNestedTypeWithExtension.swift b/Tests/Fixtures/RemovalFixtures/testUnusedNestedTypeWithExtension.swift new file mode 100644 index 000000000..b2a9c768f --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testUnusedNestedTypeWithExtension.swift @@ -0,0 +1,7 @@ +class UnusedNestedTypeWithExtension { + class Inner {} +} + +extension UnusedNestedTypeWithExtension.Inner { + func unused() {} +} diff --git a/Tests/Fixtures/RemovalFixtures/testUnusedSubscript.swift b/Tests/Fixtures/RemovalFixtures/testUnusedSubscript.swift new file mode 100644 index 000000000..4e3e83304 --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testUnusedSubscript.swift @@ -0,0 +1,3 @@ +public class UnusedSubscript { + subscript(key: String) -> Bool { true } +} diff --git a/Tests/Fixtures/RemovalFixtures/testUnusedTypealias.swift b/Tests/Fixtures/RemovalFixtures/testUnusedTypealias.swift new file mode 100644 index 000000000..bc32e990e --- /dev/null +++ b/Tests/Fixtures/RemovalFixtures/testUnusedTypealias.swift @@ -0,0 +1,3 @@ +public enum CustomTypes { + typealias UnusedTypealias = Int +} diff --git a/Tests/PeripheryTests/RemovalTest.swift b/Tests/PeripheryTests/RemovalTest.swift new file mode 100644 index 000000000..008be4750 --- /dev/null +++ b/Tests/PeripheryTests/RemovalTest.swift @@ -0,0 +1,358 @@ +import XCTest +import Shared +import SystemPackage +@testable import TestShared +@testable import PeripheryKit + +final class RemovalTest: FixtureSourceGraphTestCase { + private static let outputBasePath = FilePath("/tmp/periphery/RemovalTest") + + static override func setUp() { + super.setUp() + + configuration.targets = ["RemovalFixtures"] + + _ = try? Shell.shared.exec(["rm", "-rf", outputBasePath.string]) + _ = try? Shell.shared.exec(["mkdir", "-p", outputBasePath.string]) + configuration.removalOutputBasePath = outputBasePath + + build(driver: SPMProjectDriver.self) + } + + func testRootDeclaration() throws { + try assertOutput( + """ + public class UsedRootDeclaration1 {} + public class UsedRootDeclaration2 {} + """ + ) + } + + func testSimpleProperty() throws { + try assertOutput( + """ + public class SimplePropertyRemoval { + public var used = 1 + } + """ + ) + } + + func testMultipleBindingProperty() throws { + // TOOD + } + + func testFunction() throws { + try assertOutput( + """ + public class FunctionRemoval { + public func used1() {} + } + + extension FunctionRemoval { + public func used2() {} + } + """ + ) + } + + func testUnusedTypealias() throws { + try assertOutput( + """ + public enum CustomTypes { + } + """ + ) + } + + func testUnusedEnumCase() throws { + try assertOutput( + """ + enum EnumCaseRemoval { + case used + } + + public class EnumCaseRemovalRetainer { + public func retain() { + _ = EnumCaseRemoval.used + } + } + """ + ) + } + + func testUnusedEnumInListCase() throws { + try assertOutput( + """ + enum EnumInListCaseRemoval { + case used1, used2 + } + + public class EnumInListCaseRemovalRetainer { + public func retain() { + _ = EnumInListCaseRemoval.used1 + _ = EnumInListCaseRemoval.used2 + } + } + """ + ) + } + + func testUnusedExtension() throws { + try assertNoFile() + } + + func testUnusedInitializer() throws { + try assertOutput( + """ + public class UnusedInitializer { + public init(used: Int) {} + } + """ + ) + } + + func testUnusedNestedDeclaration() throws { + try assertOutput( + """ + public class NestedDeclaration { + public class NestedDeclarationInner { + } + } + """ + ) + } + + func testUnusedSubscript() throws { + try assertOutput( + """ + public class UnusedSubscript { + } + """ + ) + } + + func testRedundantProtocol() throws { + try assertOutput( + """ + protocol RedundantProtocol3_Existential1 {} + protocol RedundantProtocol3_Existential2 {} + class RedundantProtocolClass1: CustomStringConvertible, RedundantProtocol3_Existential1, RedundantProtocol3_Existential2 { + var description: String = "" + } + class RedundantProtocolClass2 {} + class RedundantProtocolClass3 { + class RedundantProtocolClass4: CustomStringConvertible { + var description: String = "" + } + } + + public class RedundantProtocolRetainer { + public func retain() { + _ = RedundantProtocolClass1() + _ = RedundantProtocolClass2.self + _ = RedundantProtocolClass3.RedundantProtocolClass4.self + } + } + """ + ) + } + + func testUnusedNestedTypeWithExtension() throws { + try assertNoFile() + } + + func testUnusedCodableProperty() throws { + try assertOutput( + """ + public class UnusedCodableProperty: Codable { + public var used: String? + + enum CodingKeys: CodingKey { + case used + case unused + } + } + """ + ) + } + + // MARK: - Misc. + + func testLeadingTriviaSplitting() throws { + try assertOutput( + """ + // Trivia to remain, 1 + + // Trivia to remain, 2 + + + public class LeadingTriviaSplittingUsed {} + """ + ) + } + + func testEmptyExtension() throws { + try assertOutput( + """ + public class EmptyExtension {} + public protocol EmptyExtensionProtocol {} + extension EmptyExtension: EmptyExtensionProtocol {} + """ + ) + } + + func testEmptyFile() throws { + try assertNoFile() + } + + // MARK: - Redundant Public Accessibility + + func testClassRedundantPublicAccessibility() throws { + try assertOutput( + retainPublic: false, + disableRedundantPublicAnalysis: false, + """ + // periphery:ignore + final class ClassRedundantPublicAccessibilityRetainer { + // periphery:ignore + func retain() { + ClassRedundantPublicAccessibility().someFunc() + } + } + + final class ClassRedundantPublicAccessibility { + func someFunc() {} + } + """ + ) + } + + func testFunctionRedundantPublicAccessibility() throws { + try assertOutput( + retainPublic: false, + disableRedundantPublicAnalysis: false, + """ + // periphery:ignore + final class FunctionRedundantPublicAccessibilityRetainer { + // periphery:ignore + func retain() { + somePublicFunc() + } + } + + func somePublicFunc() {} + """ + ) + } + + func testSubscriptRedundantPublicAccessibility() throws { + try assertOutput( + retainPublic: false, + disableRedundantPublicAnalysis: false, + """ + // periphery:ignore + final class SubscriptRedundantPublicAccessibilityRetainer { + // periphery:ignore + func retain() { + _ = SubscriptRedundantPublicAccessibility()[1] + } + } + + final class SubscriptRedundantPublicAccessibility { + subscript(param: Int) -> Int { + return 0 + } + } + """ + ) + } + + func testPropertyRedundantPublicAccessibility() throws { + try assertOutput( + retainPublic: false, + disableRedundantPublicAnalysis: false, + """ + // periphery:ignore + final class PropertyRedundantPublicAccessibilityRetainer { + // periphery:ignore + func retain() { + _ = somePublicProperty + } + } + + let somePublicProperty: Int = 1 + """ + ) + } + + func testInitializerRedundantPublicAccessibility() throws { + try assertOutput( + retainPublic: false, + disableRedundantPublicAnalysis: false, + """ + // periphery:ignore + final class InitializerRedundantPublicAccessibilityRetainer { + // periphery:ignore + func retain() { + _ = InitializerRedundantPublicAccessibility() + } + } + + class InitializerRedundantPublicAccessibility { + init() {} + } + """ + ) + } + + func testRedundantPublicAccessibilityWithAttributes() throws { + try assertOutput( + retainPublic: false, + disableRedundantPublicAnalysis: false, + """ + // periphery:ignore + private final class Retainer { + func retain() { + redundantPublicAccessibilityWithAttributes() + } + } + + @available(*, message: "hi mum") + func redundantPublicAccessibilityWithAttributes() {} + """ + ) + } + + // MARK: - Private + + private func assertOutput( + retainPublic: Bool = true, + disableRedundantPublicAnalysis: Bool = true, + _ expectedOutput: String, + file: StaticString = #file, + line: UInt = #line + ) throws { + let results = analyze( + retainPublic: retainPublic, + disableRedundantPublicAnalysis: disableRedundantPublicAnalysis + ) {} + try ScanResultRemover().remove(results: results) + + let outputPath = Self.outputBasePath.appending(testFixturePath.lastComponent!) + let output = try String(contentsOf: outputPath.url) + + XCTAssertEqual(output.trimmed, expectedOutput, file: file, line: line) + } + + private func assertNoFile() throws { + let results = analyze( + retainPublic: true, + disableRedundantPublicAnalysis: false + ) {} + try ScanResultRemover().remove(results: results) + + let outputPath = Self.outputBasePath.appending(testFixturePath.lastComponent!) + XCTAssertFalse(FileManager.default.fileExists(atPath: outputPath.string)) + } +} diff --git a/Tests/Shared/FixtureSourceGraphTestCase.swift b/Tests/Shared/FixtureSourceGraphTestCase.swift index 54585394c..64d0dbf93 100644 --- a/Tests/Shared/FixtureSourceGraphTestCase.swift +++ b/Tests/Shared/FixtureSourceGraphTestCase.swift @@ -8,14 +8,17 @@ class FixtureSourceGraphTestCase: SourceGraphTestCase { _sourceFiles = nil } + @discardableResult func analyze(retainPublic: Bool = false, retainObjcAccessible: Bool = false, retainObjcAnnotated: Bool = false, + disableRedundantPublicAnalysis: Bool = false, testBlock: () throws -> Void - ) rethrows { + ) rethrows -> [ScanResult] { configuration.retainPublic = retainPublic configuration.retainObjcAccessible = retainObjcAccessible configuration.retainObjcAnnotated = retainObjcAnnotated + configuration.disableRedundantPublicAnalysis = disableRedundantPublicAnalysis configuration.indexExclude = Self.sourceFiles.subtracting([testFixturePath]).map { $0.string } configuration.resetMatchers() @@ -25,6 +28,7 @@ class FixtureSourceGraphTestCase: SourceGraphTestCase { Self.index() try testBlock() + return Self.results } // MARK: - Private diff --git a/Tests/Shared/SourceGraphTestCase.swift b/Tests/Shared/SourceGraphTestCase.swift index ce2f28772..a88d06cfc 100644 --- a/Tests/Shared/SourceGraphTestCase.swift +++ b/Tests/Shared/SourceGraphTestCase.swift @@ -6,10 +6,10 @@ import Shared open class SourceGraphTestCase: XCTestCase { static var driver: ProjectDriver! static var configuration: Configuration! + static var results: [ScanResult] = [] private static var graph = SourceGraph() private static var allIndexedDeclarations: Set = [] - private static var results: [ScanResult] = [] var configuration: Configuration { Self.configuration }