Files
SwiftFormat/Sources/Rules/ConditionalAssignment.swift
2025-11-11 21:04:58 +00:00

286 lines
13 KiB
Swift

//
// ConditionalAssignment.swift
// SwiftFormat
//
// Created by Cal Stephens on 2/14/23.
// Copyright © 2024 Nick Lockwood. All rights reserved.
//
import Foundation
public extension FormatRule {
static let conditionalAssignment = FormatRule(
help: "Assign properties using if / switch expressions.",
orderAfter: [.redundantReturn],
options: ["conditional-assignment"]
) { formatter in
// If / switch expressions were added in Swift 5.9 (SE-0380)
guard formatter.options.swiftVersion >= "5.9" else {
return
}
formatter.forEach(.keyword) { startOfConditional, keywordToken in
// Look for an if/switch expression where the first branch starts with `identifier =`
guard ["if", "switch"].contains(keywordToken.string),
let conditionalBranches = formatter.conditionalBranches(at: startOfConditional),
var startOfFirstBranch = conditionalBranches.first?.startOfBranch
else { return }
// Traverse any nested if/switch branches until we find the first code branch
while let firstTokenInBranch = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: startOfFirstBranch),
["if", "switch"].contains(formatter.tokens[firstTokenInBranch].string),
let nestedConditionalBranches = formatter.conditionalBranches(at: firstTokenInBranch),
let startOfNestedBranch = nestedConditionalBranches.first?.startOfBranch
{
startOfFirstBranch = startOfNestedBranch
}
// Check if the first branch starts with the pattern `lvalue =`.
guard let firstTokenIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: startOfFirstBranch),
let lvalueRange = formatter.parseExpressionRange(startingAt: firstTokenIndex),
let equalsIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: lvalueRange.upperBound),
formatter.tokens[equalsIndex] == .operator("=", .infix)
else { return }
guard conditionalBranches.allSatisfy({ formatter.isExhaustiveSingleStatementAssignment($0, lvalueRange: lvalueRange) }),
formatter.conditionalBranchesAreExhaustive(conditionKeywordIndex: startOfConditional, branches: conditionalBranches)
else {
return
}
// If this expression follows a property like `let identifier: Type`, we just
// have to insert an `=` between property and the conditional.
// - Find the introducer (let/var), parse the property, and verify that the identifier
// matches the identifier assigned on each conditional branch.
if let introducerIndex = formatter.indexOfLastSignificantKeyword(at: startOfConditional, excluding: ["if", "switch"]),
["let", "var"].contains(formatter.tokens[introducerIndex].string),
let property = formatter.parsePropertyDeclaration(atIntroducerIndex: introducerIndex),
formatter.tokens[lvalueRange.lowerBound].string == property.identifier,
property.value == nil,
let typeRange = property.typeRange,
let nextTokenAfterProperty = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: typeRange.upperBound),
nextTokenAfterProperty == startOfConditional
{
formatter.removeAssignmentFromAllBranches(of: conditionalBranches)
let rangeBetweenTypeAndConditional = (typeRange.upperBound + 1) ..< startOfConditional
// If there are no comments between the type and conditional,
// we reformat it from:
//
// let foo: Foo\n
// if condition {
//
// to:
//
// let foo: Foo = if condition {
//
if formatter.tokens[rangeBetweenTypeAndConditional].allSatisfy(\.isSpaceOrLinebreak) {
formatter.replaceTokens(in: rangeBetweenTypeAndConditional, with: [
.space(" "),
.operator("=", .infix),
.space(" "),
])
}
// But if there are comments, then we shouldn't just delete them.
// Instead we just insert `= ` after the type.
else {
formatter.insert([.operator("=", .infix), .space(" ")], at: startOfConditional)
}
}
// Otherwise we insert an `identifier =` before the if/switch expression
else if !formatter.options.conditionalAssignmentOnlyAfterNewProperties {
// In this case we should only apply the conversion if this is a top-level condition,
// and not nested in some parent condition. In large complex if/switch conditions
// with multiple layers of nesting, for example, this prevents us from making any
// changes unless the entire set of nested conditions can be converted as a unit.
// - First attempt to find and parse a parent if / switch condition.
var startOfParentScope = formatter.startOfScope(at: startOfConditional)
// If we're inside a switch case, expand to look at the whole switch statement
while let currentStartOfParentScope = startOfParentScope,
formatter.tokens[currentStartOfParentScope] == .startOfScope(":"),
let caseToken = formatter.index(of: .endOfScope("case"), before: currentStartOfParentScope)
{
startOfParentScope = formatter.startOfScope(at: caseToken)
}
if let startOfParentScope,
let mostRecentIfOrSwitch = formatter.index(of: .keyword, before: startOfParentScope, if: { ["if", "switch"].contains($0.string) }),
let conditionalBranches = formatter.conditionalBranches(at: mostRecentIfOrSwitch),
let startOfFirstParentBranch = conditionalBranches.first?.startOfBranch,
let endOfLastParentBranch = conditionalBranches.last?.endOfBranch,
// If this condition is contained within a parent condition, do nothing.
// We should only convert the entire set of nested conditions together as a unit.
(startOfFirstParentBranch ... endOfLastParentBranch).contains(startOfConditional)
{ return }
let lvalueTokens = formatter.tokens[lvalueRange]
// Now we can remove the `identifier =` from each branch,
// and instead add it before the if / switch expression.
formatter.removeAssignmentFromAllBranches(of: conditionalBranches)
let identifierEqualsTokens = lvalueTokens + [
.space(" "),
.operator("=", .infix),
.space(" "),
]
formatter.insert(identifierEqualsTokens, at: startOfConditional)
}
}
} examples: {
"""
```diff
- let foo: String
- if condition {
+ let foo = if condition {
- foo = "foo"
+ "foo"
} else {
- foo = "bar"
+ "bar"
}
- let foo: String
- switch condition {
+ let foo = switch condition {
case true:
- foo = "foo"
+ "foo"
case false:
- foo = "bar"
+ "bar"
}
// With --condassignment always (disabled by default)
- switch condition {
+ foo.bar = switch condition {
case true:
- foo.bar = "baaz"
+ "baaz"
case false:
- foo.bar = "quux"
+ "quux"
}
```
"""
}
}
extension Formatter {
/// Whether or not the conditional statement that starts at the given index
/// has branches that are exhaustive
func conditionalBranchesAreExhaustive(
conditionKeywordIndex: Int,
branches: [Formatter.ConditionalBranch]
) -> Bool {
// Switch statements are compiler-guaranteed to be exhaustive
if tokens[conditionKeywordIndex] == .keyword("switch") {
return true
}
// If statements are only exhaustive if the last branch
// is `else` (not `else if`).
else if tokens[conditionKeywordIndex] == .keyword("if"),
let lastCondition = branches.last,
let tokenBeforeLastCondition = index(of: .nonSpaceOrCommentOrLinebreak, before: lastCondition.startOfBranch)
{
return tokens[tokenBeforeLastCondition] == .keyword("else")
}
return false
}
/// Whether or not the given conditional branch body qualifies as a single statement
/// that assigns a value to `identifier`. This is either:
/// 1. a single assignment to `lvalue =`
/// 2. a single `if` or `switch` statement where each of the branches also qualify,
/// and the statement is exhaustive.
func isExhaustiveSingleStatementAssignment(_ branch: Formatter.ConditionalBranch, lvalueRange: ClosedRange<Int>) -> Bool {
guard let firstTokenIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: branch.startOfBranch) else { return false }
// If this is an if/switch statement, verify that all of the branches are also
// single-statement assignments and that the statement is exhaustive.
if let conditionalBranches = conditionalBranches(at: firstTokenIndex),
let lastConditionalStatement = conditionalBranches.last
{
let allBranchesAreExhaustiveSingleStatement = conditionalBranches.allSatisfy { branch in
isExhaustiveSingleStatementAssignment(branch, lvalueRange: lvalueRange)
}
let isOnlyStatementInScope = next(.nonSpaceOrCommentOrLinebreak, after: lastConditionalStatement.endOfBranch)?.isEndOfScope == true
let isExhaustive = conditionalBranchesAreExhaustive(
conditionKeywordIndex: firstTokenIndex,
branches: conditionalBranches
)
return allBranchesAreExhaustiveSingleStatement
&& isOnlyStatementInScope
&& isExhaustive
}
// Otherwise we expect this to be of the pattern `lvalue = (statement)`
else if let firstExpressionRange = parseExpressionRange(startingAt: firstTokenIndex),
tokens[firstExpressionRange] == tokens[lvalueRange],
let equalsIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: firstExpressionRange.upperBound),
tokens[equalsIndex] == .operator("=", .infix),
let valueStartIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: equalsIndex)
{
// We know this branch starts with `identifier =`, but have to check that the
// remaining code in the branch is a single statement. To do that we can
// create a temporary formatter with the branch body _excluding_ `identifier =`.
let assignmentStatementRange = valueStartIndex ..< branch.endOfBranch
var tempScopeTokens = [Token]()
tempScopeTokens.append(.startOfScope("{"))
tempScopeTokens.append(contentsOf: tokens[assignmentStatementRange])
tempScopeTokens.append(.endOfScope("}"))
let tempFormatter = Formatter(tempScopeTokens, options: options)
guard tempFormatter.blockBodyHasSingleStatement(
atStartOfScope: 0,
includingConditionalStatements: true,
includingReturnStatements: false
) else {
return false
}
// In Swift 5.9, there's a bug that prevents you from writing an
// if or switch expression using an `as?` on one of the branches:
// https://github.com/apple/swift/issues/68764
//
// let result = if condition {
// foo as? String
// } else {
// "bar"
// }
//
if tempFormatter.conditionalBranchHasUnsupportedCastOperator(startOfScopeIndex: 0) {
return false
}
return true
}
return false
}
/// Removes the `identifier =` from each conditional branch
func removeAssignmentFromAllBranches(of conditionalBranches: [ConditionalBranch]) {
forEachRecursiveConditionalBranch(in: conditionalBranches) { branch in
guard let firstTokenIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: branch.startOfBranch),
let firstExpressionRange = parseExpressionRange(startingAt: firstTokenIndex),
let equalsIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: firstExpressionRange.upperBound),
tokens[equalsIndex] == .operator("=", .infix),
let valueStartIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: equalsIndex)
else { return }
removeTokens(in: firstTokenIndex ..< valueStartIndex)
}
}
}