diff --git a/Rules.md b/Rules.md index 5af47190..afeb5ec9 100644 --- a/Rules.md +++ b/Rules.md @@ -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`.
+## preferExplicitFalse + +Prefer `== false` over `!` prefix negation. + +
+Examples + +```diff +- if !flag { ++ if flag == false { +``` + +```diff +- guard !array.isEmpty else { return } ++ guard array.isEmpty == false else { return } +``` + +
+
+ ## 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. diff --git a/Sources/RuleRegistry.generated.swift b/Sources/RuleRegistry.generated.swift index 0e82d801..33e96b4a 100644 --- a/Sources/RuleRegistry.generated.swift +++ b/Sources/RuleRegistry.generated.swift @@ -62,6 +62,7 @@ let ruleRegistry: [String: FormatRule] = [ "opaqueGenericParameters": .opaqueGenericParameters, "organizeDeclarations": .organizeDeclarations, "preferCountWhere": .preferCountWhere, + "preferExplicitFalse": .preferExplicitFalse, "preferFinalClasses": .preferFinalClasses, "preferForLoop": .preferForLoop, "preferKeyPath": .preferKeyPath, diff --git a/Sources/Rules/PreferExplicitFalse.swift b/Sources/Rules/PreferExplicitFalse.swift new file mode 100644 index 00000000..0b27f539 --- /dev/null +++ b/Sources/Rules/PreferExplicitFalse.swift @@ -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 = [ + "==", "!=", "===", "!==", "~=", "<", ">", "<=", ">=", + ] + + /// 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") + } +} diff --git a/Tests/Rules/PreferExplicitFalseTests.swift b/Tests/Rules/PreferExplicitFalseTests.swift new file mode 100644 index 00000000..d5716b71 --- /dev/null +++ b/Tests/Rules/PreferExplicitFalseTests.swift @@ -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) + } +} diff --git a/Tests/XCTestCase+testFormatting.swift b/Tests/XCTestCase+testFormatting.swift index 7dedaac3..4cf05e73 100644 --- a/Tests/XCTestCase+testFormatting.swift +++ b/Tests/XCTestCase+testFormatting.swift @@ -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])