Files
SwiftFormat/Sources/Rules/NoForceUnwrapInTests.swift
2026-01-25 09:02:04 -08:00

370 lines
19 KiB
Swift

// Created by Cal Stephens on 2025-09-16.
// Copyright © 2025 Airbnb Inc. All rights reserved.
import Foundation
public extension FormatRule {
static let noForceUnwrapInTests = FormatRule(
help: "Use XCTUnwrap or #require in test cases, rather than force unwrapping.",
orderAfter: [.urlMacro, .noForceTryInTests, .throwingTests]
) { formatter in
guard let testFramework = formatter.detectTestingFramework() else {
return
}
// Find all of the test case functions in this file
var testCases = [AutoUpdatingIndex]()
formatter.forEach(.keyword("func")) { funcKeywordIndex, _ in
guard let functionDecl = formatter.parseFunctionDeclaration(keywordIndex: funcKeywordIndex),
formatter.isTestCase(at: funcKeywordIndex, in: functionDecl, for: testFramework)
else { return }
testCases.append(funcKeywordIndex.autoUpdating(in: formatter))
}
guard !testCases.isEmpty else { return }
// Collect all of the force unwrap operators. Doing this in its own `forEach`
// ensures that `disable:next` directives are supported at individual `!` indices.
var forceUnwrapOperators = [AutoUpdatingIndex]()
formatter.forEach(.operator("!", .postfix)) { forceUnwrapOperator, _ in
forceUnwrapOperators.append(forceUnwrapOperator.autoUpdating(in: formatter))
}
for testCase in testCases {
guard let functionDecl = formatter.parseFunctionDeclaration(keywordIndex: testCase.index),
let bodyRange = functionDecl.bodyRange
else { return }
let forceUnwrapOperators = forceUnwrapOperators.filter { bodyRange.contains($0.index) }
var convertedAnyForceUnwrapOperators = false
for forceUnwrapOperator in forceUnwrapOperators {
guard formatter.tokens[forceUnwrapOperator] == .operator("!", .postfix) else {
continue
}
// Only convert the `!` if we are within the function body
guard formatter.tryKeywordSupported(at: forceUnwrapOperator.index, in: functionDecl) else {
continue
}
// Preserve `try!`s, this is handled separately by the `noForceTryInTests` rule
if let previousToken = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: forceUnwrapOperator),
formatter.tokens[previousToken] == .keyword("try")
{
continue
}
// Skip if this is an implicitly unwrapped optional type annotation (e.g., let foo: Foo!)
// Look for the pattern: (let|var) identifier : Type !
if let colonIndex = formatter.lastIndex(of: .delimiter(":"), in: 0 ..< forceUnwrapOperator.index),
let _ = formatter.lastIndex(of: .keyword, in: 0 ..< colonIndex, if: { ["let", "var"].contains($0.string) })
{
// Make sure there are no assignment operators between the colon and the !
// This distinguishes type annotations from variable assignments with IUO types
let hasAssignment = formatter.index(of: .operator("=", .infix), in: colonIndex ..< forceUnwrapOperator.index) != nil
if !hasAssignment {
continue
}
}
guard let expressionRange = formatter.parseExpressionRangeContainingForceUnwrap(forceUnwrapOperator.index, in: functionDecl) else {
continue
}
// Convert all eligible ! operators in this expression to ? operators
convertForceUnwrapsInExpression: for i in expressionRange.range.reversed() {
guard formatter.tokens[i] == .operator("!", .postfix),
formatter.tryKeywordSupported(at: i, in: functionDecl)
else { continue }
// Check if this force unwrap is in a function call or subscript call subexpression within this expression.
// If so, skip it. The `XCTUnwrap` / `#require` for the outer expression doesn't apply in this subexpression.
var currentStartOfScope = i
while let scopeStart = formatter.startOfScope(at: currentStartOfScope) {
// If we've gone outside the expression range, then we know this is not part of some subexpression.
if !expressionRange.range.contains(scopeStart) {
break
}
// Check if this is a function call or subscript call by looking at the token before the scope
if let prevIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: scopeStart) {
let prevToken = formatter.tokens[prevIndex]
if prevToken.isIdentifier || prevToken.isOperator(ofType: .postfix) || prevToken.isEndOfScope {
// Skip this operator, and continue to the next one.
continue convertForceUnwrapsInExpression
}
}
// Move to the next outer scope
currentStartOfScope = scopeStart
}
// If we are about to convert an `as!` to an `as?`, and the as? is part of a broader expression with a chained value
// like `(foo as! Bar).baaz`, we have to add an extra `?` after the enclosing parens: `(foo as? Bar)?.baaz`.
if let previousToken = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: i),
formatter.tokens[previousToken] == .keyword("as"),
let tokenAfterAsParenScope = formatter.parseTokenAfterForceCastParenScope(asIndex: previousToken)
{
formatter.insert(.operator("?", .postfix), at: tokenAfterAsParenScope)
}
// If this is the last token in the expression, or the next token is is / as operator, remove the `!`
// rather than replacing it with a `?`.
var shouldRemoveForceUnwrap = false
if let nextToken = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: i) {
if ["is", "as"].contains(formatter.tokens[nextToken].string) || !expressionRange.range.contains(nextToken) {
shouldRemoveForceUnwrap = true
}
} else {
shouldRemoveForceUnwrap = true
}
// Convert `try!`s within the unwrap expression to `try` instead of `try?`
if let previousToken = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: i),
formatter.tokens[previousToken] == .keyword("try")
{
shouldRemoveForceUnwrap = true
}
if shouldRemoveForceUnwrap {
formatter.removeToken(at: i)
} else {
formatter.replaceToken(at: i, with: .operator("?", .postfix))
}
}
// Whether or not the expression needs to be wrapped in `XCTUnwrap` / `#require`
var needsUnwrapMethod = true
// If this expression is the LHS of an assignment operator, changing `foo!.bar = baaz` to `foo?.bar = baaz` is a safe change as-is
if let tokenAfterExpression = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: expressionRange.upperBound),
formatter.tokens[tokenAfterExpression] == .operator("=", .infix)
{
needsUnwrapMethod = false
}
// If this expression is followed by ==, changing `foo!.bar == bar` to `foo?.bar == bar` is a safe change as-is
if let tokenAfterExpression = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: expressionRange.upperBound),
formatter.tokens[tokenAfterExpression] == .operator("==", .infix)
{
needsUnwrapMethod = false
}
// If this expression is within XCTAssertEqual or XCTAssertNil, changing `foo!.bar` to `foo?.bar` is a safe change as-is,
// as long as this isn't a subexpression within a parent operator expression.
if let containingParenScope = formatter.startOfScope(at: expressionRange.lowerBound),
formatter.tokens[containingParenScope] == .startOfScope("("),
let functionNameIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: containingParenScope),
formatter.tokens[functionNameIndex].isIdentifier,
let tokenAfterExpression = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: expressionRange.upperBound),
!formatter.tokens[tokenAfterExpression].isOperator
{
let functionName = formatter.tokens[functionNameIndex].string
if functionName == "XCTAssertNil" {
needsUnwrapMethod = false
} else if functionName == "XCTAssertEqual" {
// Ensure this is `XCTAssertEqual(_:_:)`, not `XCTAssertEqual(_:_:accuracy:)` (which doesn't support optionals)
let arguments = formatter.parseFunctionCallArguments(startOfScope: containingParenScope)
if arguments.count == 2 {
needsUnwrapMethod = false
}
}
}
// If this expression is a standalone method call like `foo!.bar()`, then `foo?.bar()` works perfectly well.
// Heuristic: If the scope containing this code is a code block, and the previous token is part of a completely
// separate expression (or, the start of the function body), then this is a standalone expression.
if let startOfScopeContainingExpression = formatter.startOfScope(at: expressionRange.lowerBound),
formatter.tokens[startOfScopeContainingExpression] == .startOfScope("{"),
let tokenBeforeExpression = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: expressionRange.lowerBound),
!formatter.tokens[tokenBeforeExpression].isOperator
{
if tokenBeforeExpression == functionDecl.bodyRange?.lowerBound {
needsUnwrapMethod = false
}
if let previousExpressionRange = formatter.parseExpressionRange(endingAt: tokenBeforeExpression),
!previousExpressionRange.overlaps(expressionRange.range)
{
needsUnwrapMethod = false
}
}
// Wrap the expression in `try XCTUnwrap(...)` or `try #require(...)`
if needsUnwrapMethod {
// If the expression starts with a prefix operator like !, we have to wrap the try expression in parens.
// `!try XCTUnwrap(...)` is not valid -- it needs to be `!(try XCTUnwrap(...))`.
let startsWithPrefixOperator = formatter.tokens[expressionRange.lowerBound].isOperator(ofType: .prefix)
&& formatter.tokens[expressionRange.lowerBound] != .operator(".", .prefix)
let wrapperTokens: [Token]
switch testFramework {
case .xcTest:
wrapperTokens = [.keyword("try"), .space(" "), .identifier("XCTUnwrap"), .startOfScope("(")]
case .swiftTesting:
wrapperTokens = [.keyword("try"), .space(" "), .operator("#", .prefix), .identifier("require"), .startOfScope("(")]
}
let insertionIndex = startsWithPrefixOperator ? expressionRange.lowerBound + 1 : expressionRange.lowerBound
// Since we're processing right to left, we can insert without worrying about shifting indices
formatter.insert(.endOfScope(")"), at: expressionRange.upperBound + 1)
formatter.insert(wrapperTokens, at: insertionIndex)
if startsWithPrefixOperator {
formatter.insert(.endOfScope(")"), at: expressionRange.upperBound + 1)
formatter.insert(.startOfScope("("), at: insertionIndex)
}
convertedAnyForceUnwrapOperators = true
}
}
// If we found any force unwraps, add a `throws` if it doesn't already exist
if convertedAnyForceUnwrapOperators {
formatter.addThrowsEffect(to: functionDecl)
}
}
} examples: {
"""
```diff
import Testing
struct MyFeatureTests {
- @Test func myFeature() {
- let myValue = foo.bar!.value as! Value
- let otherValue = (foo! as! Other).bar
- otherValue.manager!.prepare()
- #expect(myValue!.property! == other)
+ @Test func myFeature() throws {
+ let myValue = try #require(foo.bar?.value as? Value)
+ let otherValue = try #require((foo as? Other)?.bar)
+ otherValue.manager?.prepare()
+ #expect(myValue?.property == other)
}
}
import XCTest
class MyFeatureTests: XCTestCase {
- func testMyFeature() {
- let myValue = foo.bar!.value as! Value
- let otherValue = (foo! as! Other).bar
- XCTAssertEqual(myValue!.property!, "foo")
+ func testMyFeature() throws {
+ let myValue = try XCTUnwrap(foo.bar?.value as? Value)
+ let otherValue = try XCTUnwrap((foo as? Other)?.bar)
+ XCTAssertEqual(myValue?.property, otherValue)
}
}
```
"""
}
}
extension Formatter {
/// Parses the expression range containing the given force unwrap index
func parseExpressionRangeContainingForceUnwrap(
_ forceUnwrapIndex: Int,
in functionDecl: FunctionDeclaration?
)
-> AutoUpdatingRange?
{
// Parse the expression containing this force unwrap operator
guard var expressionRange = parseExpressionRange(containing: forceUnwrapIndex)?.autoUpdating(in: self) else {
return nil
}
while let asIndexNeedingExpansion = expressionRange.range.first(where: {
guard let tokenAfterForceCastParenScope = parseTokenAfterForceCastParenScope(asIndex: $0) else { return false }
return !expressionRange.range.contains(tokenAfterForceCastParenScope)
}) {
guard let tokenAfterForceCastParenScope = parseTokenAfterForceCastParenScope(asIndex: asIndexNeedingExpansion),
let expandedExpressionRange = parseExpressionRange(containing: tokenAfterForceCastParenScope)?.autoUpdating(in: self)
else { return nil }
expressionRange = expandedExpressionRange
}
// If there are infix operators in the expression, only handle the lhs of the first operator.
// `try` isn't allowed on the RHS of an operator, and multiple nested operators is too complicated.
//
// Handle any infix operator, including operator-like keywords like `is` and `as`.
// However don't exclude `as!`, which we want to handle by converting to `as?`.
let treatAsInfixOperator = { (token: Token, index: Int) in
if token.isOperator(ofType: .infix), token != .operator(".", .infix) {
return true
}
if token == .keyword("is") {
return true
}
if token == .keyword("as"),
let nextToken = self.index(of: .nonSpaceOrLinebreak, after: index),
self.tokens[nextToken] != .operator("!", .postfix)
{
return true
}
return false
}
let firstInfixOperator = expressionRange.range.first(where: { i in
if treatAsInfixOperator(tokens[i], i),
let functionDecl,
tryKeywordSupported(at: i, in: functionDecl),
tokens[i] != .operator(".", .infix)
{
return true
}
return false
})
// Use only a valid subexpression from the LHS. To do this we parse the expression range only within a subformatter for the LHS.
if let infixIndex = firstInfixOperator, let functionDecl {
let lhsTokens = Array(tokens[expressionRange.lowerBound ..< infixIndex])
let lhsFormatter = Formatter(lhsTokens)
let lhsFormatterOffset = expressionRange.lowerBound
guard let lhsForceUnwrapIndex = expressionRange.range.first(where: { i in
tokens[i] == .operator("!", .postfix) && tryKeywordSupported(at: i, in: functionDecl) && i < infixIndex
}) else { return nil }
// Convert the absolute index to the sub-formatter's relative index
let relativeIndex = lhsForceUnwrapIndex - lhsFormatterOffset
// Get the expression range in the sub-formatter
guard let subExpressionRange = lhsFormatter.parseExpressionRangeContainingForceUnwrap(relativeIndex, in: nil) else {
return nil
}
// Convert the sub-formatter range back to absolute indices
let absoluteRange = (subExpressionRange.lowerBound + expressionRange.lowerBound) ... (subExpressionRange.upperBound + expressionRange.lowerBound)
expressionRange = absoluteRange.autoUpdating(in: self)
}
return expressionRange
}
/// If the given token is an `as` token, finds the direct outer paren scope that could potentially contain a method chain on the result of the cast.
/// For example, `(foo as! Bar).quux` returns the `.quux` component.
func parseTokenAfterForceCastParenScope(asIndex: Int) -> Int? {
guard tokens[asIndex] == .keyword("as"),
let tokenAfterAs = index(of: .nonSpaceOrCommentOrLinebreak, after: asIndex),
tokens[tokenAfterAs] == .operator("!", .postfix),
let containingScopeIndex = startOfScope(at: asIndex),
tokens[containingScopeIndex] == .startOfScope("("),
let endOfScope = endOfScope(at: containingScopeIndex),
let tokenAfterParenScope = index(of: .nonLinebreak, after: endOfScope),
tokens[tokenAfterParenScope].isOperator || tokens[tokenAfterParenScope].isStartOfScope
else { return nil }
return tokenAfterParenScope
}
}