From 8ed2d1577611ce20500afe21d2facee7e20d7676 Mon Sep 17 00:00:00 2001 From: William Laverty Date: Thu, 19 Feb 2026 04:06:13 -0800 Subject: [PATCH 1/5] Add redundant_final_actor rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Actors in Swift cannot be subclassed (SE-0306), making the `final` modifier redundant on actor declarations. This new opt-in rule detects and auto-corrects `final actor` to just `actor`. Examples: - `final actor MyActor {}` → `actor MyActor {}` - `public final actor DataStore {}` → `public actor DataStore {}` Closes #6407 --- .../Models/BuiltInRules.swift | 1 + .../Idiomatic/RedundantFinalActorRule.swift | 63 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 Source/SwiftLintBuiltInRules/Rules/Idiomatic/RedundantFinalActorRule.swift diff --git a/Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift b/Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift index d3d2b82214..4064621760 100644 --- a/Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift +++ b/Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift @@ -176,6 +176,7 @@ public let builtInRules: [any Rule.Type] = [ ReduceBooleanRule.self, ReduceIntoRule.self, RedundantDiscardableLetRule.self, + RedundantFinalActorRule.self, RedundantNilCoalescingRule.self, RedundantObjcAttributeRule.self, RedundantSelfRule.self, diff --git a/Source/SwiftLintBuiltInRules/Rules/Idiomatic/RedundantFinalActorRule.swift b/Source/SwiftLintBuiltInRules/Rules/Idiomatic/RedundantFinalActorRule.swift new file mode 100644 index 0000000000..ae414d15d0 --- /dev/null +++ b/Source/SwiftLintBuiltInRules/Rules/Idiomatic/RedundantFinalActorRule.swift @@ -0,0 +1,63 @@ +import SwiftSyntax + +@SwiftSyntaxRule(explicitRewriter: true) +struct RedundantFinalActorRule: OptInRule { + var configuration = SeverityConfiguration(.warning) + + static let description = RuleDescription( + identifier: "redundant_final_actor", + name: "Redundant Final on Actor", + description: "`final` is redundant on an actor declaration because actors cannot be subclassed", + kind: .idiomatic, + nonTriggeringExamples: [ + Example("actor MyActor {}"), + Example("final class MyClass {}"), + Example(""" + @globalActor + actor MyGlobalActor {} + """), + ], + triggeringExamples: [ + Example("↓final actor MyActor {}"), + Example("public ↓final actor DataStore {}"), + Example(""" + @globalActor + ↓final actor MyGlobalActor {} + """), + ], + corrections: [ + Example("final actor MyActor {}"): + Example("actor MyActor {}"), + Example("public final actor DataStore {}"): + Example("public actor DataStore {}"), + ] + ) +} + +private extension RedundantFinalActorRule { + final class Visitor: ViolationsSyntaxVisitor { + override func visitPost(_ node: ActorDeclSyntax) { + if let finalModifier = node.modifiers.first(where: { $0.name.text == "final" }) { + violations.append(finalModifier.positionAfterSkippingLeadingTrivia) + } + } + } + + final class Rewriter: ViolationsSyntaxRewriter { + override func visit(_ node: ActorDeclSyntax) -> DeclSyntax { + guard let finalIndex = node.modifiers.firstIndex(where: { $0.name.text == "final" }) else { + return super.visit(node) + } + numberOfCorrections += 1 + var modifiers = node.modifiers + modifiers.remove(at: finalIndex) + // If no modifiers remain, preserve the leading trivia on the actor keyword + var result = node.with(\.modifiers, modifiers) + if modifiers.isEmpty { + let leadingTrivia = node.modifiers[finalIndex].leadingTrivia + result = result.with(\.actorKeyword.leadingTrivia, leadingTrivia) + } + return super.visit(result) + } + } +} From f655ffaa2d646f4753e0a0402eef1b8f1d88cafa Mon Sep 17 00:00:00 2001 From: William Laverty Date: Sun, 22 Feb 2026 01:07:45 -0800 Subject: [PATCH 2/5] Fix OptInRule conformance and re-register rules - Use @SwiftSyntaxRule(explicitRewriter: true, optIn: true) instead of directly conforming to OptInRule, which caused a type mismatch - Rebase on latest upstream main - Re-run rules and reporters register --- .../Rules/Idiomatic/RedundantFinalActorRule.swift | 4 ++-- Tests/GeneratedTests/GeneratedTests_07.swift | 4 ++-- Tests/GeneratedTests/GeneratedTests_08.swift | 12 ++++++------ Tests/GeneratedTests/GeneratedTests_09.swift | 12 ++++++------ Tests/GeneratedTests/GeneratedTests_10.swift | 6 ++++++ .../Resources/default_rule_configurations.yml | 5 +++++ 6 files changed, 27 insertions(+), 16 deletions(-) diff --git a/Source/SwiftLintBuiltInRules/Rules/Idiomatic/RedundantFinalActorRule.swift b/Source/SwiftLintBuiltInRules/Rules/Idiomatic/RedundantFinalActorRule.swift index ae414d15d0..ab3c6ebd52 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Idiomatic/RedundantFinalActorRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Idiomatic/RedundantFinalActorRule.swift @@ -1,7 +1,7 @@ import SwiftSyntax -@SwiftSyntaxRule(explicitRewriter: true) -struct RedundantFinalActorRule: OptInRule { +@SwiftSyntaxRule(explicitRewriter: true, optIn: true) +struct RedundantFinalActorRule: Rule { var configuration = SeverityConfiguration(.warning) static let description = RuleDescription( diff --git a/Tests/GeneratedTests/GeneratedTests_07.swift b/Tests/GeneratedTests/GeneratedTests_07.swift index 257050f83f..c160cbc226 100644 --- a/Tests/GeneratedTests/GeneratedTests_07.swift +++ b/Tests/GeneratedTests/GeneratedTests_07.swift @@ -151,8 +151,8 @@ final class RedundantDiscardableLetRuleGeneratedTests: SwiftLintTestCase { } } -final class RedundantNilCoalescingRuleGeneratedTests: SwiftLintTestCase { +final class RedundantFinalActorRuleGeneratedTests: SwiftLintTestCase { func testWithDefaultConfiguration() { - verifyRule(RedundantNilCoalescingRule.description) + verifyRule(RedundantFinalActorRule.description) } } diff --git a/Tests/GeneratedTests/GeneratedTests_08.swift b/Tests/GeneratedTests/GeneratedTests_08.swift index bb3481ea86..a18c6bdb96 100644 --- a/Tests/GeneratedTests/GeneratedTests_08.swift +++ b/Tests/GeneratedTests/GeneratedTests_08.swift @@ -7,6 +7,12 @@ @testable import SwiftLintCore import TestHelpers +final class RedundantNilCoalescingRuleGeneratedTests: SwiftLintTestCase { + func testWithDefaultConfiguration() { + verifyRule(RedundantNilCoalescingRule.description) + } +} + final class RedundantObjcAttributeRuleGeneratedTests: SwiftLintTestCase { func testWithDefaultConfiguration() { verifyRule(RedundantObjcAttributeRule.description) @@ -150,9 +156,3 @@ final class StrictFilePrivateRuleGeneratedTests: SwiftLintTestCase { verifyRule(StrictFilePrivateRule.description) } } - -final class StrongIBOutletRuleGeneratedTests: SwiftLintTestCase { - func testWithDefaultConfiguration() { - verifyRule(StrongIBOutletRule.description) - } -} diff --git a/Tests/GeneratedTests/GeneratedTests_09.swift b/Tests/GeneratedTests/GeneratedTests_09.swift index 73827e5186..8195d79e64 100644 --- a/Tests/GeneratedTests/GeneratedTests_09.swift +++ b/Tests/GeneratedTests/GeneratedTests_09.swift @@ -7,6 +7,12 @@ @testable import SwiftLintCore import TestHelpers +final class StrongIBOutletRuleGeneratedTests: SwiftLintTestCase { + func testWithDefaultConfiguration() { + verifyRule(StrongIBOutletRule.description) + } +} + final class SuperfluousElseRuleGeneratedTests: SwiftLintTestCase { func testWithDefaultConfiguration() { verifyRule(SuperfluousElseRule.description) @@ -150,9 +156,3 @@ final class UnneededSynthesizedInitializerRuleGeneratedTests: SwiftLintTestCase verifyRule(UnneededSynthesizedInitializerRule.description) } } - -final class UnneededThrowsRuleGeneratedTests: SwiftLintTestCase { - func testWithDefaultConfiguration() { - verifyRule(UnneededThrowsRule.description) - } -} diff --git a/Tests/GeneratedTests/GeneratedTests_10.swift b/Tests/GeneratedTests/GeneratedTests_10.swift index 002ad994e7..b647b6a303 100644 --- a/Tests/GeneratedTests/GeneratedTests_10.swift +++ b/Tests/GeneratedTests/GeneratedTests_10.swift @@ -7,6 +7,12 @@ @testable import SwiftLintCore import TestHelpers +final class UnneededThrowsRuleGeneratedTests: SwiftLintTestCase { + func testWithDefaultConfiguration() { + verifyRule(UnneededThrowsRule.description) + } +} + final class UnownedVariableCaptureRuleGeneratedTests: SwiftLintTestCase { func testWithDefaultConfiguration() { verifyRule(UnownedVariableCaptureRule.description) diff --git a/Tests/IntegrationTests/Resources/default_rule_configurations.yml b/Tests/IntegrationTests/Resources/default_rule_configurations.yml index 058ad6299b..7987b0c554 100644 --- a/Tests/IntegrationTests/Resources/default_rule_configurations.yml +++ b/Tests/IntegrationTests/Resources/default_rule_configurations.yml @@ -1006,6 +1006,11 @@ redundant_discardable_let: meta: opt-in: false correctable: true +redundant_final_actor: + severity: warning + meta: + opt-in: true + correctable: true redundant_nil_coalescing: severity: warning meta: From aab4aab95800bf7171f6842bda9c3724d3f5cc2f Mon Sep 17 00:00:00 2001 From: William Laverty Date: Mon, 23 Feb 2026 23:03:55 -0800 Subject: [PATCH 3/5] Use ViolationCorrection instead of explicit Rewriter Address SimplyDanny's suggestion to use range-replace correction appended to the violation, removing the need for a separate Rewriter. --- .../Idiomatic/RedundantFinalActorRule.swift | 37 ++++++++----------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/Source/SwiftLintBuiltInRules/Rules/Idiomatic/RedundantFinalActorRule.swift b/Source/SwiftLintBuiltInRules/Rules/Idiomatic/RedundantFinalActorRule.swift index ab3c6ebd52..03d977b5f6 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Idiomatic/RedundantFinalActorRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Idiomatic/RedundantFinalActorRule.swift @@ -1,6 +1,6 @@ import SwiftSyntax -@SwiftSyntaxRule(explicitRewriter: true, optIn: true) +@SwiftSyntaxRule(optIn: true) struct RedundantFinalActorRule: Rule { var configuration = SeverityConfiguration(.warning) @@ -37,27 +37,22 @@ struct RedundantFinalActorRule: Rule { private extension RedundantFinalActorRule { final class Visitor: ViolationsSyntaxVisitor { override func visitPost(_ node: ActorDeclSyntax) { - if let finalModifier = node.modifiers.first(where: { $0.name.text == "final" }) { - violations.append(finalModifier.positionAfterSkippingLeadingTrivia) + guard let finalModifier = node.modifiers.first(where: { $0.name.text == "final" }) else { + return } - } - } - - final class Rewriter: ViolationsSyntaxRewriter { - override func visit(_ node: ActorDeclSyntax) -> DeclSyntax { - guard let finalIndex = node.modifiers.firstIndex(where: { $0.name.text == "final" }) else { - return super.visit(node) - } - numberOfCorrections += 1 - var modifiers = node.modifiers - modifiers.remove(at: finalIndex) - // If no modifiers remain, preserve the leading trivia on the actor keyword - var result = node.with(\.modifiers, modifiers) - if modifiers.isEmpty { - let leadingTrivia = node.modifiers[finalIndex].leadingTrivia - result = result.with(\.actorKeyword.leadingTrivia, leadingTrivia) - } - return super.visit(result) + let start = finalModifier.positionAfterSkippingLeadingTrivia + // endPosition includes trailing trivia (the space after "final") + let end = finalModifier.endPosition + violations.append( + ReasonedRuleViolation( + position: start, + correction: .init( + start: start, + end: end, + replacement: "" + ) + ) + ) } } } From 120b9dae611b4003e01616e1ffc0da4e9e45454f Mon Sep 17 00:00:00 2001 From: William Laverty Date: Wed, 25 Feb 2026 01:13:01 -0800 Subject: [PATCH 4/5] Mark RedundantFinalActorRule as correctable Add correctable: true to the @SwiftSyntaxRule macro so the rule properly conforms to SwiftSyntaxCorrectableRule. The correction was already implemented via ViolationCorrection but the macro parameter was missing. --- .../Rules/Idiomatic/RedundantFinalActorRule.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/SwiftLintBuiltInRules/Rules/Idiomatic/RedundantFinalActorRule.swift b/Source/SwiftLintBuiltInRules/Rules/Idiomatic/RedundantFinalActorRule.swift index 03d977b5f6..61ce9e7610 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Idiomatic/RedundantFinalActorRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Idiomatic/RedundantFinalActorRule.swift @@ -1,6 +1,6 @@ import SwiftSyntax -@SwiftSyntaxRule(optIn: true) +@SwiftSyntaxRule(correctable: true, optIn: true) struct RedundantFinalActorRule: Rule { var configuration = SeverityConfiguration(.warning) From 108aba68aa280aca6f37f60975551bbde10d60d9 Mon Sep 17 00:00:00 2001 From: William Laverty Date: Fri, 27 Feb 2026 01:13:40 -0800 Subject: [PATCH 5/5] Add rationale and extend to final members inside actors - Add rationale explaining actors don't support inheritance (with caveat) - Detect redundant final on functions, properties, and subscripts inside actors - Skip nested class declarations where final is meaningful - Add non-triggering/triggering examples for member declarations --- .../Idiomatic/RedundantFinalActorRule.swift | 73 ++++++++++++++++++- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/Source/SwiftLintBuiltInRules/Rules/Idiomatic/RedundantFinalActorRule.swift b/Source/SwiftLintBuiltInRules/Rules/Idiomatic/RedundantFinalActorRule.swift index 61ce9e7610..6d1424177d 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Idiomatic/RedundantFinalActorRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Idiomatic/RedundantFinalActorRule.swift @@ -7,7 +7,12 @@ struct RedundantFinalActorRule: Rule { static let description = RuleDescription( identifier: "redundant_final_actor", name: "Redundant Final on Actor", - description: "`final` is redundant on an actor declaration because actors cannot be subclassed", + description: "`final` is redundant on an actor declaration and its members because actors cannot be subclassed", + rationale: """ + Actors in Swift currently do not support inheritance, making `final` redundant \ + on both actor declarations and their members. Note that this may change in future \ + Swift versions if actor inheritance is introduced. + """, kind: .idiomatic, nonTriggeringExamples: [ Example("actor MyActor {}"), @@ -16,6 +21,17 @@ struct RedundantFinalActorRule: Rule { @globalActor actor MyGlobalActor {} """), + Example(""" + actor MyActor { + func doWork() {} + var value: Int { 0 } + } + """), + Example(""" + class MyClass { + final func doWork() {} + } + """), ], triggeringExamples: [ Example("↓final actor MyActor {}"), @@ -24,24 +40,75 @@ struct RedundantFinalActorRule: Rule { @globalActor ↓final actor MyGlobalActor {} """), + Example(""" + actor MyActor { + ↓final func doWork() {} + } + """), + Example(""" + actor MyActor { + ↓final var value: Int { 0 } + } + """), ], corrections: [ Example("final actor MyActor {}"): Example("actor MyActor {}"), Example("public final actor DataStore {}"): Example("public actor DataStore {}"), + Example("actor MyActor {\n final func doWork() {}\n}"): + Example("actor MyActor {\n func doWork() {}\n}"), ] ) } private extension RedundantFinalActorRule { final class Visitor: ViolationsSyntaxVisitor { + private var insideActor = false + + override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { + insideActor = true + return .visitChildren + } + override func visitPost(_ node: ActorDeclSyntax) { - guard let finalModifier = node.modifiers.first(where: { $0.name.text == "final" }) else { + if let finalModifier = node.modifiers.first(where: { $0.name.text == "final" }) { + addViolation(for: finalModifier) + } + insideActor = false + } + + override func visitPost(_ node: FunctionDeclSyntax) { + guard insideActor, + let finalModifier = node.modifiers.first(where: { $0.name.text == "final" }) else { + return + } + addViolation(for: finalModifier) + } + + override func visitPost(_ node: VariableDeclSyntax) { + guard insideActor, + let finalModifier = node.modifiers.first(where: { $0.name.text == "final" }) else { return } + addViolation(for: finalModifier) + } + + override func visitPost(_ node: SubscriptDeclSyntax) { + guard insideActor, + let finalModifier = node.modifiers.first(where: { $0.name.text == "final" }) else { + return + } + addViolation(for: finalModifier) + } + + // Don't descend into nested classes where `final` is meaningful + override func visit(_: ClassDeclSyntax) -> SyntaxVisitorContinueKind { + .skipChildren + } + + private func addViolation(for finalModifier: DeclModifierSyntax) { let start = finalModifier.positionAfterSkippingLeadingTrivia - // endPosition includes trailing trivia (the space after "final") let end = finalModifier.endPosition violations.append( ReasonedRuleViolation(