mirror of
https://github.com/nicklockwood/SwiftFormat.git
synced 2026-05-17 10:30:35 +00:00
370 lines
19 KiB
Swift
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
|
|
}
|
|
}
|