|
| 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 | +} |
0 commit comments