Add preferExplicitFalse rule (#2357)

This commit is contained in:
Giyeop Hyeon (Jamie)
2026-03-03 08:09:27 -08:00
committed by Cal Stephens
parent beb10e1e98
commit 0c8a4613ef
5 changed files with 634 additions and 0 deletions
+21
View File
@@ -122,6 +122,7 @@
* [noExplicitOwnership](#noExplicitOwnership)
* [noGuardInTests](#noGuardInTests)
* [organizeDeclarations](#organizeDeclarations)
* [preferExplicitFalse](#preferExplicitFalse)
* [preferFinalClasses](#preferFinalClasses)
* [preferSwiftTesting](#preferSwiftTesting)
* [privateStateVariables](#privateStateVariables)
@@ -2002,6 +2003,26 @@ Prefer `count(where:)` over `filter(_:).count`.
</details>
<br/>
## preferExplicitFalse
Prefer `== false` over `!` prefix negation.
<details>
<summary>Examples</summary>
```diff
- if !flag {
+ if flag == false {
```
```diff
- guard !array.isEmpty else { return }
+ guard array.isEmpty == false else { return }
```
</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.
+1
View File
@@ -62,6 +62,7 @@ let ruleRegistry: [String: FormatRule] = [
"opaqueGenericParameters": .opaqueGenericParameters,
"organizeDeclarations": .organizeDeclarations,
"preferCountWhere": .preferCountWhere,
"preferExplicitFalse": .preferExplicitFalse,
"preferFinalClasses": .preferFinalClasses,
"preferForLoop": .preferForLoop,
"preferKeyPath": .preferKeyPath,
+124
View File
@@ -0,0 +1,124 @@
//
// PreferExplicitFalse.swift
// SwiftFormat
//
// Created by KYHyeon on 02/08/2026.
// Copyright © 2026 Nick Lockwood. All rights reserved.
//
import Foundation
public extension FormatRule {
/// Convert prefix `!` negation to explicit `== false` comparison.
/// This improves readability for teams who find the `!` prefix easy to miss.
static let preferExplicitFalse = FormatRule(
help: "Prefer `== false` over `!` prefix negation.",
disabledByDefault: true
) { formatter in
formatter.forEach(.operator("!", .prefix)) { notIndex, _ in
guard let operandStart = formatter.index(
of: .nonSpaceOrCommentOrLinebreak,
after: notIndex
) else {
return
}
if formatter.tokens[operandStart].isOperator(ofType: .prefix) {
return
}
if formatter.currentScope(at: notIndex) == .startOfScope("#if") {
return
}
// Skip if adjacent to a comparison or casting operator —
// inserting `== false` would create a non-associative chain
// or change precedence. e.g., `a == !b` → `a == b == false`
if let prevIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: notIndex),
formatter.isComparisonOrCastingOperator(at: prevIndex)
{
return
}
guard let operandEnd = formatter.endOfPrefixOperand(
at: operandStart
) else {
return
}
if let nextIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: operandEnd),
formatter.isComparisonOrCastingOperator(at: nextIndex)
{
return
}
formatter.insert([
.space(" "),
.operator("==", .infix),
.space(" "),
.identifier("false"),
], at: operandEnd + 1)
formatter.removeToken(at: notIndex)
}
} examples: {
"""
```diff
- if !flag {
+ if flag == false {
```
```diff
- guard !array.isEmpty else { return }
+ guard array.isEmpty == false else { return }
```
"""
}
}
// MARK: - Helpers
extension Formatter {
static let comparisonOperators: Set<String> = [
"==", "!=", "===", "!==", "~=", "<", ">", "<=", ">=",
]
/// Finds the end of the postfix expression starting at `index`, which is the
/// first non-space token after a prefix `!`. Uses `parseExpressionRange` for
/// expression parsing, then finds the boundary before any infix operators,
/// since the prefix `!` only binds to the immediate postfix expression.
func endOfPrefixOperand(at index: Int) -> Int? {
guard let expressionRange = parseExpressionRange(
startingAt: index
) else {
return nil
}
// parseExpressionRange includes infix operators in the expression range,
// but `!` binds tighter than any infix operator. Find the earliest
// infix operator (excluding member access `.`) or `is`/`as` keyword.
let searchRange = index ..< expressionRange.upperBound + 1
let infixIndex = self.index(in: searchRange, where: {
$0.isOperator(ofType: .infix) && $0 != .operator(".", .infix)
})
let isIndex = self.index(of: .keyword("is"), in: index ... expressionRange.upperBound)
let asIndex = self.index(of: .keyword("as"), in: index ... expressionRange.upperBound)
if let breakIndex = [infixIndex, isIndex, asIndex].compactMap({ $0 }).min() {
return self.index(of: .nonSpaceOrCommentOrLinebreak, before: breakIndex)
}
return expressionRange.upperBound
}
/// Whether the token at `index` is a comparison operator (`==`, `!=`, etc.)
/// or a casting keyword (`is`, `as`) — operators that would conflict with
/// an inserted `== false`.
func isComparisonOrCastingOperator(at index: Int) -> Bool {
let token = tokens[index]
if case let .operator(op, .infix) = token {
return Self.comparisonOperators.contains(op)
}
return token == .keyword("is") || token == .keyword("as")
}
}
+487
View File
@@ -0,0 +1,487 @@
//
// PreferExplicitFalseTests.swift
// SwiftFormatTests
//
// Created by KYHyeon on 02/08/2026.
// Copyright © 2026 Nick Lockwood. All rights reserved.
//
import XCTest
@testable import SwiftFormat
final class PreferExplicitFalseTests: XCTestCase {
func testBasicNegation() {
let input = """
if !flag {
print("false")
}
"""
let output = """
if flag == false {
print("false")
}
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testGuardNegation() {
let input = """
guard !array.isEmpty else { return }
"""
let output = """
guard array.isEmpty == false else { return }
"""
testFormatting(for: input, output, rule: .preferExplicitFalse,
exclude: [.wrapConditionalBodies])
}
func testWhileNegation() {
let input = """
while !finished {
doWork()
}
"""
let output = """
while finished == false {
doWork()
}
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testPropertyNegation() {
let input = """
if !view.isHidden {
view.show()
}
"""
let output = """
if view.isHidden == false {
view.show()
}
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testFunctionCallNegation() {
let input = """
if !foo.bar() {
handleFalse()
}
"""
let output = """
if foo.bar() == false {
handleFalse()
}
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testMethodCallNegation() {
let input = """
if !array.contains(value) {
addValue(value)
}
"""
let output = """
if array.contains(value) == false {
addValue(value)
}
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testParenthesizedExpressionNegation() {
let input = """
if !(a && b) {
handleBothFalse()
}
"""
let output = """
if (a && b) == false {
handleBothFalse()
}
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testComplexExpressionNegation() {
let input = """
if !(foo.bar() && baz.qux()) {
handleComplexFalse()
}
"""
let output = """
if (foo.bar() && baz.qux()) == false {
handleComplexFalse()
}
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testNestedPropertyNegation() {
let input = """
if !self.view.subviews.isEmpty {
addSubviews()
}
"""
let output = """
if self.view.subviews.isEmpty == false {
addSubviews()
}
"""
testFormatting(for: input, output, rule: .preferExplicitFalse,
exclude: [.redundantSelf])
}
func testChainedMethodCallNegation() {
let input = """
if !foo.bar().baz() {
handleChainedFalse()
}
"""
let output = """
if foo.bar().baz() == false {
handleChainedFalse()
}
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testMultipleNegationsInSameLine() {
let input = """
if !a && !b {
handleBothFalse()
}
"""
let output = """
if a == false && b == false {
handleBothFalse()
}
"""
testFormatting(for: input, output, rule: .preferExplicitFalse,
exclude: [.andOperator])
}
func testNegationInTernary() {
let input = """
let result = !condition ? "false" : "true"
"""
let output = """
let result = condition == false ? "false" : "true"
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testNegationInReturnStatement() {
let input = """
func check() -> Bool {
return !isValid
}
"""
let output = """
func check() -> Bool {
return isValid == false
}
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testNegationInAssignment() {
let input = """
let isFalse = !someCondition
"""
let output = """
let isFalse = someCondition == false
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testNegationInFunctionParameter() {
let input = """
processData(data: !isProcessed)
"""
let output = """
processData(data: isProcessed == false)
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testNegationWithComments() {
let input = """
if !flag { // check if false
doSomething()
}
"""
let output = """
if flag == false { // check if false
doSomething()
}
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testNoChangeForPostfixNot() {
let input = """
let value = optional!
"""
testFormatting(for: input, rule: .preferExplicitFalse)
}
func testNoChangeForComparisonOperators() {
let input = """
if a != b {
doSomething()
}
"""
testFormatting(for: input, rule: .preferExplicitFalse)
}
func testNoChangeForExistingEqualFalse() {
let input = """
if flag == false {
doSomething()
}
"""
testFormatting(for: input, rule: .preferExplicitFalse)
}
func testNoChangeForExistingEqualTrue() {
let input = """
if flag == true {
doSomething()
}
"""
testFormatting(for: input, rule: .preferExplicitFalse)
}
func testNoChangeForOptionalBool() {
let input = """
if optionalBool! {
doSomething()
}
"""
testFormatting(for: input, rule: .preferExplicitFalse)
}
func testNoChangeForBinaryNot() {
let input = """
let result = ~value
"""
testFormatting(for: input, rule: .preferExplicitFalse)
}
func testSubscriptNegation() {
let input = """
if !array[0] {
processFirstElement()
}
"""
let output = """
if array[0] == false {
processFirstElement()
}
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testForceUnwrapPropertyNegation() {
let input = """
if !foo!.isValid {
handleInvalidFoo()
}
"""
let output = """
if foo!.isValid == false {
handleInvalidFoo()
}
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testNegationInClosure() {
let input = """
let closure = {
if !condition {
return false
}
return true
}
"""
let output = """
let closure = {
if condition == false {
return false
}
return true
}
"""
testFormatting(for: input, output, rule: .preferExplicitFalse, exclude: [.wrapFunctionBodies])
}
func testNegationInSwitchCase() {
let input = """
switch value {
case let x where !x.isValid:
handleInvalid(x)
default:
break
}
"""
let output = """
switch value {
case let x where x.isValid == false:
handleInvalid(x)
default:
break
}
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testNegationInWhereClause() {
let input = """
for item in items where !item.isProcessed {
process(item)
}
"""
let output = """
for item in items where item.isProcessed == false {
process(item)
}
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testNegationInComputedProperty() {
let input = """
var isEmpty: Bool {
return !items.isEmpty
}
"""
let output = """
var isEmpty: Bool {
return items.isEmpty == false
}
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testNegationInArrayLiteral() {
let input = """
let array = [!a, !b, !c]
"""
let output = """
let array = [a == false, b == false, c == false]
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testNegationInDictionaryLiteral() {
let input = """
let dict = ["a": !value, "b": !other]
"""
let output = """
let dict = ["a": value == false, "b": other == false]
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testClosureArgumentNegation() {
let input = """
let result = !items.contains(where: { $0.isValid })
"""
let output = """
let result = items.contains(where: { $0.isValid }) == false
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testTrailingClosureNegation() {
let input = """
let result = !myArray.contains {
$0 == value
}
"""
let output = """
let result = myArray.contains {
$0 == value
} == false
"""
testFormatting(for: input, output, rule: .preferExplicitFalse)
}
func testNoChangeForNegationBeforeEquals() {
let input = """
print(!a == b)
"""
testFormatting(for: input, rule: .preferExplicitFalse)
}
func testNoChangeForNegationBeforeNotEquals() {
let input = """
print(!a != b)
"""
testFormatting(for: input, rule: .preferExplicitFalse)
}
func testNoChangeForNegationAfterEquals() {
let input = """
print(a == !b)
"""
testFormatting(for: input, rule: .preferExplicitFalse)
}
func testNoChangeForNegationAfterNotEquals() {
let input = """
print(a != !b)
"""
testFormatting(for: input, rule: .preferExplicitFalse)
}
func testNoChangeForPreprocessorDirective() {
let input = """
#if !DEBUG
#error("Not supported")
#endif
"""
testFormatting(for: input, rule: .preferExplicitFalse, exclude: [.indent])
}
func testNoChangeForPreprocessorCanImport() {
let input = """
#if !canImport(UIKit)
#error("UIKit required")
#endif
"""
testFormatting(for: input, rule: .preferExplicitFalse, exclude: [.indent])
}
func testNoChangeForNegationBeforeIs() {
let input = """
print(!foo is Bar)
"""
testFormatting(for: input, rule: .preferExplicitFalse)
}
func testNoChangeForNegationBeforeAs() {
let input = """
print(!foo as? Bar)
"""
testFormatting(for: input, rule: .preferExplicitFalse)
}
func testForceUnwrappedNegationBeforeEquals() {
let input = """
print(!foo! == bar)
"""
testFormatting(for: input, rule: .preferExplicitFalse)
}
func testDoubleNegationAfterEquals() {
let input = """
print(a == !!b)
"""
testFormatting(for: input, rule: .preferExplicitFalse)
}
}
+1
View File
@@ -83,6 +83,7 @@ extension XCTestCase {
.blockComments,
.unusedPrivateDeclarations,
.preferFinalClasses,
.preferExplicitFalse,
]
let exclude = exclude + defaultExclusions.filter { !rules.contains($0) }
let formatResult: (output: String, changes: [SwiftFormat.Formatter.Change])