mirror of
https://github.com/nicklockwood/SwiftFormat.git
synced 2026-06-16 10:34:34 +00:00
Add preferExplicitFalse rule (#2357)
This commit is contained in:
committed by
Cal Stephens
parent
beb10e1e98
commit
0c8a4613ef
@@ -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.
|
||||
|
||||
@@ -62,6 +62,7 @@ let ruleRegistry: [String: FormatRule] = [
|
||||
"opaqueGenericParameters": .opaqueGenericParameters,
|
||||
"organizeDeclarations": .organizeDeclarations,
|
||||
"preferCountWhere": .preferCountWhere,
|
||||
"preferExplicitFalse": .preferExplicitFalse,
|
||||
"preferFinalClasses": .preferFinalClasses,
|
||||
"preferForLoop": .preferForLoop,
|
||||
"preferKeyPath": .preferKeyPath,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user