Skip to content

Commit 5fe256c

Browse files
committed
Add preferFinalClasses rule (#2196)
1 parent b9df952 commit 5fe256c

File tree

8 files changed

+592
-0
lines changed

8 files changed

+592
-0
lines changed

Rules.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@
111111
* [noExplicitOwnership](#noExplicitOwnership)
112112
* [noGuardInTests](#noGuardInTests)
113113
* [organizeDeclarations](#organizeDeclarations)
114+
* [preferFinalClasses](#preferFinalClasses)
114115
* [preferSwiftTesting](#preferSwiftTesting)
115116
* [privateStateVariables](#privateStateVariables)
116117
* [propertyTypes](#propertyTypes)
@@ -1917,6 +1918,39 @@ Prefer `count(where:)` over `filter(_:).count`.
19171918
</details>
19181919
<br/>
19191920

1921+
## preferFinalClasses
1922+
1923+
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.
1924+
1925+
<details>
1926+
<summary>Examples</summary>
1927+
1928+
```diff
1929+
- class Foo {}
1930+
+ final class Foo {}
1931+
```
1932+
1933+
```diff
1934+
- public class Bar {}
1935+
+ public final class Bar {}
1936+
```
1937+
1938+
```diff
1939+
// Preserved classes:
1940+
open class Baz {}
1941+
1942+
class BaseClass {}
1943+
1944+
class MyClass {} // Subclassed in this file
1945+
class MySubclass: MyClass {}
1946+
1947+
/// Base class to be subclassed by other features
1948+
class MyCustomizationPoint {}
1949+
```
1950+
1951+
</details>
1952+
<br/>
1953+
19201954
## preferForLoop
19211955

19221956
Convert functional `forEach` calls to for loops.

Sources/Declaration.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,11 @@ extension Declaration {
176176
}
177177
}
178178

179+
/// The range of the doc comment or regular comment immediately preceding this declaration
180+
var docCommentRange: ClosedRange<Int>? {
181+
formatter.parseDocCommentRange(forDeclarationAt: keywordIndex)
182+
}
183+
179184
/// The `CustomDebugStringConvertible` representation of this declaration
180185
var debugDescription: String {
181186
guard isValid else {

Sources/ParsingHelpers.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2612,6 +2612,29 @@ extension Formatter {
26122612
return matches
26132613
}
26142614

2615+
/// Parses the range of the doc comment or regular comment immediately preceding the declaration
2616+
func parseDocCommentRange(forDeclarationAt keywordIndex: Int) -> ClosedRange<Int>? {
2617+
let startOfModifiers = startOfModifiers(at: keywordIndex, includingAttributes: true)
2618+
2619+
var parseIndex = startOfModifiers
2620+
var endOfComment: Int?
2621+
2622+
while let endOfPreviousLine = index(of: .linebreak, before: parseIndex),
2623+
let endOfPreviousLineContent = index(of: .nonSpace, before: endOfPreviousLine),
2624+
tokens[endOfPreviousLineContent].isComment,
2625+
let startOfScope = startOfScope(at: endOfPreviousLineContent)
2626+
{
2627+
parseIndex = startOfScope
2628+
2629+
if endOfComment == nil {
2630+
endOfComment = endOfPreviousLineContent
2631+
}
2632+
}
2633+
2634+
guard let endOfComment else { return nil }
2635+
return parseIndex ... endOfComment
2636+
}
2637+
26152638
/// Parses the prorocol composition typealias declaration starting at the given `typealias` keyword index.
26162639
/// Returns `nil` if the given index isn't a protocol composition typealias.
26172640
func parseProtocolCompositionTypealias(at typealiasIndex: Int)

Sources/RuleRegistry.generated.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ let ruleRegistry: [String: FormatRule] = [
6060
"opaqueGenericParameters": .opaqueGenericParameters,
6161
"organizeDeclarations": .organizeDeclarations,
6262
"preferCountWhere": .preferCountWhere,
63+
"preferFinalClasses": .preferFinalClasses,
6364
"preferForLoop": .preferForLoop,
6465
"preferKeyPath": .preferKeyPath,
6566
"preferSwiftTesting": .preferSwiftTesting,
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
//
2+
// PreferFinalClasses.swift
3+
// SwiftFormat
4+
//
5+
// Created by Cal Stephens on 2025-08-25.
6+
// Copyright © 2024 Nick Lockwood. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
public extension FormatRule {
12+
/// Add the `final` keyword to all classes that are not declared as `open`
13+
static let preferFinalClasses = FormatRule(
14+
help: """
15+
Prefer defining `final` classes. To suppress this rule, add "Base" to the class name, \
16+
add a doc comment with mentioning "base class" or "subclass", make the class `open`, \
17+
or use a `// swiftformat:disable:next preferFinalClasses` directive.
18+
""",
19+
disabledByDefault: true
20+
) { formatter in
21+
// Parse all declarations to understand inheritance relationships
22+
let declarations = formatter.parseDeclarations()
23+
24+
// Find all class names that are inherited from in this file
25+
var classesWithSubclasses = Set<String>()
26+
declarations.forEachRecursiveDeclaration { declaration in
27+
guard declaration.keyword == "class" else { return }
28+
29+
// Check all conformances - any of them could be a superclass
30+
let conformances = formatter.parseConformancesOfType(atKeywordIndex: declaration.keywordIndex)
31+
for conformance in conformances {
32+
// Extract base class name from generic types like "Container<String>" -> "Container"
33+
let baseClassName = conformance.conformance.tokens.first?.string ?? conformance.conformance.string
34+
classesWithSubclasses.insert(baseClassName)
35+
}
36+
}
37+
38+
// Now process each class declaration
39+
declarations.forEachRecursiveDeclaration { declaration in
40+
guard declaration.keyword == "class",
41+
let className = declaration.name else { return }
42+
43+
let keywordIndex = declaration.keywordIndex
44+
45+
// Check if class already has final or open modifiers
46+
let hasFinalModifier = formatter.modifiersForDeclaration(at: keywordIndex, contains: "final")
47+
let hasOpenModifier = formatter.modifiersForDeclaration(at: keywordIndex, contains: "open")
48+
49+
// Only add final if the class doesn't already have final or open
50+
guard !hasFinalModifier, !hasOpenModifier else { return }
51+
52+
// Don't add final if this class is inherited from in the same file
53+
guard !classesWithSubclasses.contains(className) else { return }
54+
55+
// Don't add final to classes that contain "Base" (they're likely meant to be subclassed)
56+
guard !className.contains("Base") else { return }
57+
58+
// Don't add final to classes with a comment like "// Base class for XYZ functionality"
59+
if let docCommentRange = declaration.docCommentRange {
60+
let subclassRelatedTerms = ["base", "subclass"]
61+
let docComment = formatter.tokens[docCommentRange].string.lowercased()
62+
63+
for term in subclassRelatedTerms {
64+
if docComment.contains(term) {
65+
return
66+
}
67+
}
68+
}
69+
70+
formatter.insert(tokenize("final "), at: keywordIndex)
71+
72+
// Convert any open direct child declarations to public (since final classes can't have open members)
73+
if let classBody = declaration.body {
74+
for childDeclaration in classBody {
75+
guard formatter.modifiersForDeclaration(at: childDeclaration.keywordIndex, contains: "open") else { continue }
76+
77+
// Replace "open" with "public" for direct child declarations
78+
if let openIndex = formatter.indexOfModifier("open", forDeclarationAt: childDeclaration.keywordIndex) {
79+
formatter.replaceToken(at: openIndex, with: .keyword("public"))
80+
}
81+
}
82+
}
83+
}
84+
} examples: {
85+
"""
86+
```diff
87+
- class Foo {}
88+
+ final class Foo {}
89+
```
90+
91+
```diff
92+
- public class Bar {}
93+
+ public final class Bar {}
94+
```
95+
96+
```diff
97+
// Preserved classes:
98+
open class Baz {}
99+
100+
class BaseClass {}
101+
102+
class MyClass {} // Subclassed in this file
103+
class MySubclass: MyClass {}
104+
105+
/// Base class to be subclassed by other features
106+
class MyCustomizationPoint {}
107+
```
108+
"""
109+
}
110+
}

Tests/ParsingHelpersTests.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2965,4 +2965,33 @@ class ParsingHelpersTests: XCTestCase {
29652965
"baaz: baaz.quux",
29662966
])
29672967
}
2968+
2969+
func testParseCommentRange() throws {
2970+
let input = """
2971+
import FooLib
2972+
2973+
// Class declaration
2974+
class MyClass {}
2975+
2976+
// Other comment
2977+
2978+
/// Foo bar
2979+
/// baaz quux
2980+
@Foo
2981+
struct MyStruct {}
2982+
"""
2983+
2984+
let formatter = Formatter(tokenize(input))
2985+
let classCommentRange = try XCTUnwrap(formatter.parseDocCommentRange(forDeclarationAt: 9)) // class
2986+
let structCommentRange = try XCTUnwrap(formatter.parseDocCommentRange(forDeclarationAt: 30)) // struct
2987+
2988+
XCTAssertEqual(formatter.tokens[classCommentRange].string, """
2989+
// Class declaration
2990+
""")
2991+
2992+
XCTAssertEqual(formatter.tokens[structCommentRange].string, """
2993+
/// Foo bar
2994+
/// baaz quux
2995+
""")
2996+
}
29682997
}

0 commit comments

Comments
 (0)