diff --git a/Rules.md b/Rules.md index a2899d52..1cf737ba 100644 --- a/Rules.md +++ b/Rules.md @@ -69,6 +69,7 @@ * [redundantRawValues](#redundantRawValues) * [redundantReturn](#redundantReturn) * [redundantSelf](#redundantSelf) +* [redundantSendable](#redundantSendable) * [redundantStaticSelf](#redundantStaticSelf) * [redundantSwiftTestingSuite](#redundantSwiftTestingSuite) * [redundantThrows](#redundantThrows) @@ -2929,6 +2930,29 @@ by using `--self init-only`:
+## redundantSendable + +Remove redundant explicit Sendable conformance from non-public structs and enums. + +
+Examples + +```diff +- struct CacheEntry: Sendable { ++ struct CacheEntry { + let id: String + } + +- fileprivate enum ParsingState: Sendable { ++ fileprivate enum ParsingState { + case idle + case running + } +``` + +
+
+ ## redundantStaticSelf Remove explicit `Self` where applicable. diff --git a/Sources/RuleRegistry.generated.swift b/Sources/RuleRegistry.generated.swift index 09f9cf3b..1713676d 100644 --- a/Sources/RuleRegistry.generated.swift +++ b/Sources/RuleRegistry.generated.swift @@ -92,6 +92,7 @@ let ruleRegistry: [String: FormatRule] = [ "redundantRawValues": .redundantRawValues, "redundantReturn": .redundantReturn, "redundantSelf": .redundantSelf, + "redundantSendable": .redundantSendable, "redundantStaticSelf": .redundantStaticSelf, "redundantSwiftTestingSuite": .redundantSwiftTestingSuite, "redundantThrows": .redundantThrows, diff --git a/Sources/Rules/RedundantSendable.swift b/Sources/Rules/RedundantSendable.swift new file mode 100644 index 00000000..e356cbee --- /dev/null +++ b/Sources/Rules/RedundantSendable.swift @@ -0,0 +1,103 @@ +// +// RedundantSendable.swift +// SwiftFormat +// +// Created by Nacho Soto on 2/20/2026. +// + +import Foundation + +public extension FormatRule { + static let redundantSendable = FormatRule( + help: "Remove redundant explicit Sendable conformance from non-public structs and enums." + ) { formatter in + let declarations = formatter.parseDeclarations() + + declarations.forEachRecursiveDeclaration { declaration in + guard let typeDeclaration = declaration.asTypeDeclaration, + typeDeclaration.keyword == "struct" || typeDeclaration.keyword == "enum" + else { return } + + switch typeDeclaration.visibility() { + case .public, .open: + return + case .internal, .package, .fileprivate, .private, nil: + break + } + + guard let sendableConformance = typeDeclaration.conformances.first(where: { + formatter.isRedundantSendableConformance($0.conformance) + }) else { return } + + formatter.removeConformance( + at: sendableConformance.index, + range: sendableConformance.conformance.range + ) + } + } examples: { + """ + ```diff + - struct CacheEntry: Sendable { + + struct CacheEntry { + let id: String + } + + - fileprivate enum ParsingState: Sendable { + + fileprivate enum ParsingState { + case idle + case running + } + ``` + """ + } +} + +extension Formatter { + func isRedundantSendableConformance(_ conformance: TypeName) -> Bool { + let significantTokens = conformance.tokens.filter { !$0.isSpaceOrCommentOrLinebreak } + + guard !significantTokens.contains(where: { $0.isAttribute && $0.string == "@unchecked" }) else { + return false + } + + if significantTokens == [.identifier("Sendable")] { + return true + } + + guard significantTokens.count == 3, + significantTokens[0] == .identifier("Swift"), + significantTokens[2] == .identifier("Sendable") + else { + return false + } + + let dotToken = significantTokens[1] + return dotToken.isOperator(".") || dotToken == .delimiter(".") + } + + func removeConformance(at conformanceIndex: Int, range conformanceRange: ClosedRange) { + guard let previousTokenIndex = index(of: .nonSpaceOrCommentOrLinebreak, before: conformanceIndex), + let nextTokenIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: conformanceRange.upperBound) + else { return } + + let removalRange: ClosedRange + if tokens[nextTokenIndex] == .delimiter(",") { + let upperBound: Int + if token(at: nextTokenIndex + 1)?.isSpace == true { + upperBound = nextTokenIndex + 1 + } else { + upperBound = nextTokenIndex + } + removalRange = conformanceIndex ... upperBound + } else { + removalRange = previousTokenIndex ... conformanceRange.upperBound + } + + // Avoid removing inline comments attached to the conformance list. + guard !tokens[removalRange].contains(where: \.isComment) else { + return + } + + removeTokens(in: removalRange) + } +} diff --git a/Tests/Rules/RedundantSendableTests.swift b/Tests/Rules/RedundantSendableTests.swift new file mode 100644 index 00000000..60dca028 --- /dev/null +++ b/Tests/Rules/RedundantSendableTests.swift @@ -0,0 +1,119 @@ +// +// RedundantSendableTests.swift +// SwiftFormatTests +// +// Created by Nacho Soto on 2/20/2026. +// + +import XCTest +@testable import SwiftFormat + +final class RedundantSendableTests: XCTestCase { + func testRemovesSendableFromNestedAndTopLevelNonPublicValueTypes() { + let input = """ + struct Outer { + enum NestedImplicitAccess: Sendable {} + private struct NestedPrivate: Sendable {} + @available(*, deprecated) + enum NestedAttributed: Sendable {} + } + + struct TopLevelImplicitAccess: Sendable {} + fileprivate enum TopLevelFileprivate: Sendable {} + """ + + let output = """ + struct Outer { + enum NestedImplicitAccess {} + private struct NestedPrivate {} + @available(*, deprecated) + enum NestedAttributed {} + } + + struct TopLevelImplicitAccess {} + fileprivate enum TopLevelFileprivate {} + """ + + testFormatting( + for: input, + [output], + rules: [.redundantSendable], + exclude: [.enumNamespaces, .redundantFileprivate] + ) + } + + func testDoesNotRemoveSendableFromValidCases() { + let input = """ + public struct PublicValue: Sendable {} + public enum PublicEnum: Sendable {} + private final class PrivateReference: Sendable {} + private struct PrivateUnchecked: @unchecked Sendable {} + struct NoExplicitSendable {} + struct Generic: Equatable where T: Sendable {} + """ + + testFormatting(for: input, rules: [.redundantSendable], exclude: [.simplifyGenericConstraints]) + } + + func testIgnoresCommentsAndStrings() { + let input = """ + func demo() { + let example = \"\"\" + enum FakeNested: Sendable {} + private struct AlsoFake: Sendable {} + \"\"\" + _ = example + // struct CommentFake: Sendable {} + /* + fileprivate enum BlockCommentFake: Sendable {} + */ + } + """ + + testFormatting(for: input, rules: [.redundantSendable], exclude: [.indent]) + } + + func testRemovesQualifiedSendableFromMixedConformanceLists() { + let input = """ + struct First: Sendable, Codable {} + struct Middle: Codable, Swift.Sendable, Hashable {} + enum Last: CaseIterable, Sendable { + case value + } + """ + + let output = """ + struct First: Codable {} + struct Middle: Codable, Hashable {} + enum Last: CaseIterable { + case value + } + """ + + testFormatting(for: input, [output], rules: [.redundantSendable]) + } + + func testRemovesSendableFromPackageValueTypes() { + let input = """ + package struct PackageStruct: Sendable { + package let value: Int + } + + package enum PackageEnum: Sendable { + case value(Int) + } + """ + + let output = """ + package struct PackageStruct { + package let value: Int + } + + package enum PackageEnum { + case value(Int) + } + """ + + testFormatting(for: input, [output], rules: [.redundantSendable]) + } +}