mirror of
https://github.com/nicklockwood/SwiftFormat.git
synced 2026-05-17 10:30:35 +00:00
Add preferFinalClasses rule (#2196)
This commit is contained in:
@@ -111,6 +111,7 @@
|
||||
* [noExplicitOwnership](#noExplicitOwnership)
|
||||
* [noGuardInTests](#noGuardInTests)
|
||||
* [organizeDeclarations](#organizeDeclarations)
|
||||
* [preferFinalClasses](#preferFinalClasses)
|
||||
* [preferSwiftTesting](#preferSwiftTesting)
|
||||
* [privateStateVariables](#privateStateVariables)
|
||||
* [propertyTypes](#propertyTypes)
|
||||
@@ -1917,6 +1918,39 @@ Prefer `count(where:)` over `filter(_:).count`.
|
||||
</details>
|
||||
<br/>
|
||||
|
||||
## preferFinalClasses
|
||||
|
||||
Prefer defining `final` classes. To suppress this rule, add "Base" to the class name, add a doc comment with mentioning "base class" or "subclass", make the class `open`, or use a `// swiftformat:disable:next preferFinalClasses` directive.
|
||||
|
||||
<details>
|
||||
<summary>Examples</summary>
|
||||
|
||||
```diff
|
||||
- class Foo {}
|
||||
+ final class Foo {}
|
||||
```
|
||||
|
||||
```diff
|
||||
- public class Bar {}
|
||||
+ public final class Bar {}
|
||||
```
|
||||
|
||||
```diff
|
||||
// Preserved classes:
|
||||
open class Baz {}
|
||||
|
||||
class BaseClass {}
|
||||
|
||||
class MyClass {} // Subclassed in this file
|
||||
class MySubclass: MyClass {}
|
||||
|
||||
/// Base class to be subclassed by other features
|
||||
class MyCustomizationPoint {}
|
||||
```
|
||||
|
||||
</details>
|
||||
<br/>
|
||||
|
||||
## preferForLoop
|
||||
|
||||
Convert functional `forEach` calls to for loops.
|
||||
|
||||
@@ -176,6 +176,11 @@ extension Declaration {
|
||||
}
|
||||
}
|
||||
|
||||
/// The range of the doc comment or regular comment immediately preceding this declaration
|
||||
var docCommentRange: ClosedRange<Int>? {
|
||||
formatter.parseDocCommentRange(forDeclarationAt: keywordIndex)
|
||||
}
|
||||
|
||||
/// The `CustomDebugStringConvertible` representation of this declaration
|
||||
var debugDescription: String {
|
||||
guard isValid else {
|
||||
|
||||
@@ -2612,6 +2612,29 @@ extension Formatter {
|
||||
return matches
|
||||
}
|
||||
|
||||
/// Parses the range of the doc comment or regular comment immediately preceding the declaration
|
||||
func parseDocCommentRange(forDeclarationAt keywordIndex: Int) -> ClosedRange<Int>? {
|
||||
let startOfModifiers = startOfModifiers(at: keywordIndex, includingAttributes: true)
|
||||
|
||||
var parseIndex = startOfModifiers
|
||||
var endOfComment: Int?
|
||||
|
||||
while let endOfPreviousLine = index(of: .linebreak, before: parseIndex),
|
||||
let endOfPreviousLineContent = index(of: .nonSpace, before: endOfPreviousLine),
|
||||
tokens[endOfPreviousLineContent].isComment,
|
||||
let startOfScope = startOfScope(at: endOfPreviousLineContent)
|
||||
{
|
||||
parseIndex = startOfScope
|
||||
|
||||
if endOfComment == nil {
|
||||
endOfComment = endOfPreviousLineContent
|
||||
}
|
||||
}
|
||||
|
||||
guard let endOfComment else { return nil }
|
||||
return parseIndex ... endOfComment
|
||||
}
|
||||
|
||||
/// Parses the prorocol composition typealias declaration starting at the given `typealias` keyword index.
|
||||
/// Returns `nil` if the given index isn't a protocol composition typealias.
|
||||
func parseProtocolCompositionTypealias(at typealiasIndex: Int)
|
||||
|
||||
@@ -60,6 +60,7 @@ let ruleRegistry: [String: FormatRule] = [
|
||||
"opaqueGenericParameters": .opaqueGenericParameters,
|
||||
"organizeDeclarations": .organizeDeclarations,
|
||||
"preferCountWhere": .preferCountWhere,
|
||||
"preferFinalClasses": .preferFinalClasses,
|
||||
"preferForLoop": .preferForLoop,
|
||||
"preferKeyPath": .preferKeyPath,
|
||||
"preferSwiftTesting": .preferSwiftTesting,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// PreferFinalClasses.swift
|
||||
// SwiftFormat
|
||||
//
|
||||
// Created by Cal Stephens on 2025-08-25.
|
||||
// Copyright © 2024 Nick Lockwood. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension FormatRule {
|
||||
/// Add the `final` keyword to all classes that are not declared as `open`
|
||||
static let preferFinalClasses = FormatRule(
|
||||
help: """
|
||||
Prefer defining `final` classes. To suppress this rule, add "Base" to the class name, \
|
||||
add a doc comment with mentioning "base class" or "subclass", make the class `open`, \
|
||||
or use a `// swiftformat:disable:next preferFinalClasses` directive.
|
||||
""",
|
||||
disabledByDefault: true
|
||||
) { formatter in
|
||||
// Parse all declarations to understand inheritance relationships
|
||||
let declarations = formatter.parseDeclarations()
|
||||
|
||||
// Find all class names that are inherited from in this file
|
||||
var classesWithSubclasses = Set<String>()
|
||||
declarations.forEachRecursiveDeclaration { declaration in
|
||||
guard declaration.keyword == "class" else { return }
|
||||
|
||||
// Check all conformances - any of them could be a superclass
|
||||
let conformances = formatter.parseConformancesOfType(atKeywordIndex: declaration.keywordIndex)
|
||||
for conformance in conformances {
|
||||
// Extract base class name from generic types like "Container<String>" -> "Container"
|
||||
let baseClassName = conformance.conformance.tokens.first?.string ?? conformance.conformance.string
|
||||
classesWithSubclasses.insert(baseClassName)
|
||||
}
|
||||
}
|
||||
|
||||
// Now process each class declaration
|
||||
declarations.forEachRecursiveDeclaration { declaration in
|
||||
guard declaration.keyword == "class",
|
||||
let className = declaration.name else { return }
|
||||
|
||||
let keywordIndex = declaration.keywordIndex
|
||||
|
||||
// Check if class already has final or open modifiers
|
||||
let hasFinalModifier = formatter.modifiersForDeclaration(at: keywordIndex, contains: "final")
|
||||
let hasOpenModifier = formatter.modifiersForDeclaration(at: keywordIndex, contains: "open")
|
||||
|
||||
// Only add final if the class doesn't already have final or open
|
||||
guard !hasFinalModifier, !hasOpenModifier else { return }
|
||||
|
||||
// Don't add final if this class is inherited from in the same file
|
||||
guard !classesWithSubclasses.contains(className) else { return }
|
||||
|
||||
// Don't add final to classes that contain "Base" (they're likely meant to be subclassed)
|
||||
guard !className.contains("Base") else { return }
|
||||
|
||||
// Don't add final to classes with a comment like "// Base class for XYZ functionality"
|
||||
if let docCommentRange = declaration.docCommentRange {
|
||||
let subclassRelatedTerms = ["base", "subclass"]
|
||||
let docComment = formatter.tokens[docCommentRange].string.lowercased()
|
||||
|
||||
for term in subclassRelatedTerms {
|
||||
if docComment.contains(term) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formatter.insert(tokenize("final "), at: keywordIndex)
|
||||
|
||||
// Convert any open direct child declarations to public (since final classes can't have open members)
|
||||
if let classBody = declaration.body {
|
||||
for childDeclaration in classBody {
|
||||
guard formatter.modifiersForDeclaration(at: childDeclaration.keywordIndex, contains: "open") else { continue }
|
||||
|
||||
// Replace "open" with "public" for direct child declarations
|
||||
if let openIndex = formatter.indexOfModifier("open", forDeclarationAt: childDeclaration.keywordIndex) {
|
||||
formatter.replaceToken(at: openIndex, with: .keyword("public"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} examples: {
|
||||
"""
|
||||
```diff
|
||||
- class Foo {}
|
||||
+ final class Foo {}
|
||||
```
|
||||
|
||||
```diff
|
||||
- public class Bar {}
|
||||
+ public final class Bar {}
|
||||
```
|
||||
|
||||
```diff
|
||||
// Preserved classes:
|
||||
open class Baz {}
|
||||
|
||||
class BaseClass {}
|
||||
|
||||
class MyClass {} // Subclassed in this file
|
||||
class MySubclass: MyClass {}
|
||||
|
||||
/// Base class to be subclassed by other features
|
||||
class MyCustomizationPoint {}
|
||||
```
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -2965,4 +2965,33 @@ class ParsingHelpersTests: XCTestCase {
|
||||
"baaz: baaz.quux",
|
||||
])
|
||||
}
|
||||
|
||||
func testParseCommentRange() throws {
|
||||
let input = """
|
||||
import FooLib
|
||||
|
||||
// Class declaration
|
||||
class MyClass {}
|
||||
|
||||
// Other comment
|
||||
|
||||
/// Foo bar
|
||||
/// baaz quux
|
||||
@Foo
|
||||
struct MyStruct {}
|
||||
"""
|
||||
|
||||
let formatter = Formatter(tokenize(input))
|
||||
let classCommentRange = try XCTUnwrap(formatter.parseDocCommentRange(forDeclarationAt: 9)) // class
|
||||
let structCommentRange = try XCTUnwrap(formatter.parseDocCommentRange(forDeclarationAt: 30)) // struct
|
||||
|
||||
XCTAssertEqual(formatter.tokens[classCommentRange].string, """
|
||||
// Class declaration
|
||||
""")
|
||||
|
||||
XCTAssertEqual(formatter.tokens[structCommentRange].string, """
|
||||
/// Foo bar
|
||||
/// baaz quux
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
//
|
||||
// PreferFinalClassesTests.swift
|
||||
// SwiftFormatTests
|
||||
//
|
||||
// Created by Cal Stephens on 2025-08-25.
|
||||
// Copyright © 2024 Nick Lockwood. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import SwiftFormat
|
||||
|
||||
class PreferFinalClassesTests: XCTestCase {
|
||||
func testBasicClassMadesFinal() {
|
||||
let input = """
|
||||
class Foo {}
|
||||
"""
|
||||
let output = """
|
||||
final class Foo {}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testPublicClassMadesFinal() {
|
||||
let input = """
|
||||
public class Bar {}
|
||||
"""
|
||||
let output = """
|
||||
public final class Bar {}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testPrivateClassMadesFinal() {
|
||||
let input = """
|
||||
private class Baz {}
|
||||
"""
|
||||
let output = """
|
||||
private final class Baz {}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testInternalClassMadesFinal() {
|
||||
let input = """
|
||||
internal class Qux {}
|
||||
"""
|
||||
let output = """
|
||||
internal final class Qux {}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses, exclude: [.redundantInternal])
|
||||
}
|
||||
|
||||
func testOpenClassLeftUnchanged() {
|
||||
let input = """
|
||||
open class OpenClass {}
|
||||
"""
|
||||
testFormatting(for: input, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testAlreadyFinalClassLeftUnchanged() {
|
||||
let input = """
|
||||
final class FinalClass {}
|
||||
"""
|
||||
testFormatting(for: input, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testPublicFinalClassLeftUnchanged() {
|
||||
let input = """
|
||||
public final class PublicFinalClass {}
|
||||
"""
|
||||
testFormatting(for: input, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testPublicOpenClassLeftUnchanged() {
|
||||
let input = """
|
||||
public open class PublicOpenClass {}
|
||||
"""
|
||||
testFormatting(for: input, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testClassFunctionNotAffected() {
|
||||
let input = """
|
||||
struct Foo {
|
||||
class func bar() {}
|
||||
}
|
||||
"""
|
||||
testFormatting(for: input, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testClassVariableNotAffected() {
|
||||
let input = """
|
||||
struct Foo {
|
||||
class var bar: String { "bar" }
|
||||
}
|
||||
"""
|
||||
testFormatting(for: input, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testNestedClass() {
|
||||
let input = """
|
||||
class OuterClass {
|
||||
class InnerClass {}
|
||||
}
|
||||
"""
|
||||
let output = """
|
||||
final class OuterClass {
|
||||
final class InnerClass {}
|
||||
}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses, exclude: [.enumNamespaces])
|
||||
}
|
||||
|
||||
func testClassWithInheritance() {
|
||||
let input = """
|
||||
class Child: Parent {}
|
||||
"""
|
||||
let output = """
|
||||
final class Child: Parent {}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testClassWithProtocolConformance() {
|
||||
let input = """
|
||||
class MyClass: SomeProtocol {}
|
||||
"""
|
||||
let output = """
|
||||
final class MyClass: SomeProtocol {}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testClassWithMultipleModifiers() {
|
||||
let input = """
|
||||
@objc public class MyClass {}
|
||||
"""
|
||||
let output = """
|
||||
@objc public final class MyClass {}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testMultipleClasses() {
|
||||
let input = """
|
||||
class FirstClass {}
|
||||
class SecondClass {}
|
||||
open class ThirdClass {}
|
||||
final class FourthClass {}
|
||||
"""
|
||||
let output = """
|
||||
final class FirstClass {}
|
||||
final class SecondClass {}
|
||||
open class ThirdClass {}
|
||||
final class FourthClass {}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testClassWithComments() {
|
||||
let input = """
|
||||
// This is a class
|
||||
class MyClass {
|
||||
// Some content
|
||||
}
|
||||
"""
|
||||
let output = """
|
||||
// This is a class
|
||||
final class MyClass {
|
||||
// Some content
|
||||
}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses, exclude: [.docComments])
|
||||
}
|
||||
|
||||
func testClassWithSubclassNotMadeFinal() {
|
||||
let input = """
|
||||
class BaseClass {}
|
||||
class SubClass: BaseClass {}
|
||||
"""
|
||||
let output = """
|
||||
class BaseClass {}
|
||||
final class SubClass: BaseClass {}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testMultipleInheritanceLevels() {
|
||||
let input = """
|
||||
class GrandParent {}
|
||||
class Parent: GrandParent {}
|
||||
class Child: Parent {}
|
||||
"""
|
||||
let output = """
|
||||
class GrandParent {}
|
||||
class Parent: GrandParent {}
|
||||
final class Child: Parent {}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testClassWithProtocolConformanceStillMadeFinal() {
|
||||
let input = """
|
||||
protocol SomeProtocol {}
|
||||
class MyClass: SomeProtocol {}
|
||||
"""
|
||||
let output = """
|
||||
protocol SomeProtocol {}
|
||||
final class MyClass: SomeProtocol {}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testClassInheritingFromExternalClassMadeFinal() {
|
||||
let input = """
|
||||
class MyViewController: UIViewController {}
|
||||
"""
|
||||
let output = """
|
||||
final class MyViewController: UIViewController {}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testMixedScenario() {
|
||||
let input = """
|
||||
class BaseClass {}
|
||||
final class AlreadyFinalClass {}
|
||||
open class OpenClass {}
|
||||
class SubClass: BaseClass {}
|
||||
class IndependentClass {}
|
||||
"""
|
||||
let output = """
|
||||
class BaseClass {}
|
||||
final class AlreadyFinalClass {}
|
||||
open class OpenClass {}
|
||||
final class SubClass: BaseClass {}
|
||||
final class IndependentClass {}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testGenericClassWithSubclass() {
|
||||
let input = """
|
||||
class Container<T> {}
|
||||
class StringContainer: Container<String> {}
|
||||
"""
|
||||
let output = """
|
||||
class Container<T> {}
|
||||
final class StringContainer: Container<String> {}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testGenericClassWithGenericSubclass() {
|
||||
let input = """
|
||||
class BaseContainer<T> {}
|
||||
class SpecialContainer<U>: BaseContainer<U> {}
|
||||
"""
|
||||
let output = """
|
||||
class BaseContainer<T> {}
|
||||
final class SpecialContainer<U>: BaseContainer<U> {}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testMultipleGenericParameters() {
|
||||
let input = """
|
||||
class GenericClass<T, U> {}
|
||||
class ConcreteClass: GenericClass<String, Int> {}
|
||||
"""
|
||||
let output = """
|
||||
class GenericClass<T, U> {}
|
||||
final class ConcreteClass: GenericClass<String, Int> {}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testComplexGenericInheritanceChain() {
|
||||
let input = """
|
||||
class BaseContainer<T> {}
|
||||
class MiddleContainer<T>: BaseContainer<T> {}
|
||||
class FinalContainer: MiddleContainer<String> {}
|
||||
"""
|
||||
let output = """
|
||||
class BaseContainer<T> {}
|
||||
class MiddleContainer<T>: BaseContainer<T> {}
|
||||
final class FinalContainer: MiddleContainer<String> {}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testBaseClassNotMadeFinal() {
|
||||
let input = """
|
||||
class BaseClass {}
|
||||
class ClassBase {}
|
||||
class SomeBase {}
|
||||
class BaseSomething {}
|
||||
class ViewControllerBase {}
|
||||
class RegularClass {}
|
||||
"""
|
||||
let output = """
|
||||
class BaseClass {}
|
||||
class ClassBase {}
|
||||
class SomeBase {}
|
||||
class BaseSomething {}
|
||||
class ViewControllerBase {}
|
||||
final class RegularClass {}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testConvertOpenMembersToPublic() {
|
||||
let input = """
|
||||
public class MyClass {
|
||||
open var property1: String = ""
|
||||
open let property2: Int = 0
|
||||
open func method1() {}
|
||||
private var privateProperty: String = ""
|
||||
public func publicMethod() {}
|
||||
}
|
||||
"""
|
||||
let output = """
|
||||
public final class MyClass {
|
||||
public var property1: String = ""
|
||||
public let property2: Int = 0
|
||||
public func method1() {}
|
||||
private var privateProperty: String = ""
|
||||
public func publicMethod() {}
|
||||
}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses)
|
||||
}
|
||||
|
||||
func testNestedClassWithOpenMembersNotConverted() {
|
||||
let input = """
|
||||
public class OuterClass {
|
||||
open var outerProperty: String = ""
|
||||
|
||||
public class InnerClass {
|
||||
open var innerProperty: String = ""
|
||||
}
|
||||
}
|
||||
"""
|
||||
let output = """
|
||||
public final class OuterClass {
|
||||
public var outerProperty: String = ""
|
||||
|
||||
public final class InnerClass {
|
||||
public var innerProperty: String = ""
|
||||
}
|
||||
}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses, exclude: [.enumNamespaces])
|
||||
}
|
||||
|
||||
func testMixedScenarioWithBaseAndOpen() {
|
||||
let input = """
|
||||
class BaseController {}
|
||||
public class MyController {
|
||||
open var title: String = ""
|
||||
open func setup() {}
|
||||
}
|
||||
class UtilityBase {}
|
||||
"""
|
||||
let output = """
|
||||
class BaseController {}
|
||||
public final class MyController {
|
||||
public var title: String = ""
|
||||
public func setup() {}
|
||||
}
|
||||
class UtilityBase {}
|
||||
"""
|
||||
testFormatting(for: input, output, rule: .preferFinalClasses, exclude: [.blankLinesBetweenScopes])
|
||||
}
|
||||
|
||||
func testNonFinalClassWithBaseCommentPreserved() {
|
||||
let input = """
|
||||
/// Base class
|
||||
public class Foo {}
|
||||
|
||||
/// Customization point to be subclassed
|
||||
public class Foo {}
|
||||
|
||||
//subclass this in your custom implementation
|
||||
public class Bar {}
|
||||
"""
|
||||
|
||||
testFormatting(for: input, rule: .preferFinalClasses, exclude: [.docComments, .spaceInsideComments])
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ extension XCTestCase {
|
||||
.markTypes,
|
||||
.blockComments,
|
||||
.unusedPrivateDeclarations,
|
||||
.preferFinalClasses,
|
||||
]
|
||||
let exclude = exclude + defaultExclusions.filter { !rules.contains($0) }
|
||||
let formatResult: (output: String, changes: [SwiftFormat.Formatter.Change])
|
||||
|
||||
Reference in New Issue
Block a user