Files
sourcekit-lsp/Sources/SwiftLanguageService/CodeActions/ConvertIfLetToGuard.swift
Karan Lokchandani 55d75954e5 Add if let to guard transform (#2420)
fixes: https://github.com/swiftlang/sourcekit-lsp/issues/1569

mostly works but not sure what to do with many edge cases and has a todo
for switch statements, also this will probably have conflicts with
https://github.com/swiftlang/sourcekit-lsp/pull/2406 marking as draft
till that merges and i can resolve the conflicts.


https://github.com/user-attachments/assets/a6d07f9d-6f09-4330-8cd0-2d24bd6973fb

---------

Signed-off-by: Karan <karanlokchandani@protonmail.com>
2026-01-13 22:46:54 +01:00

271 lines
8.9 KiB
Swift

//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2026 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//
import Foundation
@_spi(SourceKitLSP) import LanguageServerProtocol
import SourceKitLSP
import SwiftBasicFormat
import SwiftExtensions
import SwiftSyntax
import SwiftSyntaxBuilder
/// Syntactic code action provider to convert an if-let with early-exit pattern to a guard-let statement.
///
/// ## Before
/// ```swift
/// if let value = optional {
/// // use value
/// return value
/// }
/// return nil
/// ```
///
/// ## After
/// ```swift
/// guard let value = optional else {
/// return nil
/// }
/// // use value
/// return value
/// ```
@_spi(Testing) public struct ConvertIfLetToGuard: SyntaxCodeActionProvider {
static func codeActions(in scope: SyntaxCodeActionScope) -> [CodeAction] {
guard let ifExpr = findConvertibleIfExpr(in: scope) else {
return []
}
var current = Syntax(ifExpr)
if let parent = current.parent, parent.is(ExpressionStmtSyntax.self) {
current = parent
}
guard current.parent?.is(CodeBlockItemSyntax.self) ?? false else {
return []
}
guard let codeBlockItem = current.parent?.as(CodeBlockItemSyntax.self),
let codeBlockItemList = codeBlockItem.parent?.as(CodeBlockItemListSyntax.self)
else {
return []
}
guard let ifIndex = codeBlockItemList.index(of: codeBlockItem) else {
return []
}
let followingStatements = codeBlockItemList[codeBlockItemList.index(after: ifIndex)...]
guard let lastStatement = followingStatements.last else {
return []
}
// The statements following the 'if' will become the 'else' block of the 'guard'.
// They must guarantee an exit (return, throw, break, continue).
guard bodyGuaranteesExit(CodeBlockSyntax(statements: CodeBlockItemListSyntax(Array(followingStatements)))) else {
return []
}
let baseIndentation = ifExpr.firstToken(viewMode: .sourceAccurate)?.indentationOfLine ?? []
let indentStep = BasicFormat.inferIndentation(of: ifExpr.root) ?? .spaces(4)
let guardStmt = buildGuardStatement(
from: ifExpr,
elseBody: Array(followingStatements),
baseIndentation: baseIndentation,
indentStep: indentStep
)
let newBodyStatements = ifExpr.body.statements
var replacementText = guardStmt.description
let remover = IndentationRemover(indentation: indentStep)
for (index, stmt) in newBodyStatements.enumerated() {
var adjustedStmt = remover.rewrite(stmt)
if index == 0 {
// The first statement moved out of the if-block should be placed on a new line
// at the base indentation level. We strip any leading newlines and indentation
// and replace them with a single newline + base indentation.
let pieces = adjustedStmt.leadingTrivia.drop(while: \.isWhitespace)
adjustedStmt.leadingTrivia = .newline + baseIndentation + Trivia(pieces: Array(pieces))
}
replacementText += adjustedStmt.description
}
let edit = TextEdit(
range: scope.snapshot.absolutePositionRange(
of: ifExpr.positionAfterSkippingLeadingTrivia..<lastStatement.endPosition
),
newText: replacementText
)
return [
CodeAction(
title: "Convert to guard",
kind: .refactorInline,
edit: WorkspaceEdit(changes: [scope.snapshot.uri: [edit]])
)
]
}
private static func findConvertibleIfExpr(in scope: SyntaxCodeActionScope) -> IfExprSyntax? {
var node: Syntax? = scope.innermostNodeContainingRange
while let c = node, !isFunctionBoundary(c) {
if let ifExpr = c.as(IfExprSyntax.self) {
if isConvertibleToGuard(ifExpr) && isTopLevelInCodeBlock(ifExpr) {
return ifExpr
}
// If we found an IfExpr but it's not the one we want, stop here
// to avoid picking an outer one when the user is in an inner expression-if.
return nil
}
node = c.parent
}
return nil
}
private static func isTopLevelInCodeBlock(_ ifExpr: IfExprSyntax) -> Bool {
var current = Syntax(ifExpr)
if let parent = current.parent, parent.is(ExpressionStmtSyntax.self) {
current = parent
}
return current.parent?.is(CodeBlockItemSyntax.self) ?? false
}
private static func isConvertibleToGuard(_ ifExpr: IfExprSyntax) -> Bool {
guard ifExpr.elseBody == nil else {
return false
}
guard ifExpr.conditions.allSatisfy(isSupportedCondition) else {
return false
}
// Changing if-let to guard would change the lifetime of deferred blocks.
if ifExpr.body.statements.contains(where: { $0.item.is(DeferStmtSyntax.self) }) {
return false
}
return bodyGuaranteesExit(ifExpr.body)
}
private static func isSupportedCondition(_ condition: ConditionElementSyntax) -> Bool {
if let optionalBinding = condition.condition.as(OptionalBindingConditionSyntax.self) {
return !optionalBinding.pattern.is(ExpressionPatternSyntax.self)
}
if condition.condition.is(MatchingPatternConditionSyntax.self) {
return false
}
return true
}
private static func bodyGuaranteesExit(_ codeBlock: CodeBlockSyntax) -> Bool {
return codeBlock.statements.reversed().contains { statementGuaranteesExit($0.item) }
}
/// Checks if a statement guarantees control flow will not continue past it.
///
/// - Note: Does not attempt to detect never-returning functions like `fatalError`
/// because that requires semantic information (return type `Never`).
/// - Note: Switch statements are conservatively treated as non-exiting since
/// checking exhaustiveness is complex.
private static func statementGuaranteesExit(_ statement: CodeBlockItemSyntax.Item) -> Bool {
switch statement {
case .stmt(let stmt):
switch stmt.kind {
case .returnStmt, .throwStmt, .breakStmt, .continueStmt:
return true
default:
if let exprStmt = stmt.as(ExpressionStmtSyntax.self) {
return statementGuaranteesExit(.expr(exprStmt.expression))
}
}
case .expr(let expr):
if let ifExpr = expr.as(IfExprSyntax.self), let elseBody = ifExpr.elseBody {
guard bodyGuaranteesExit(ifExpr.body) else {
return false
}
switch elseBody {
case .codeBlock(let block):
return bodyGuaranteesExit(block)
case .ifExpr(let elseIf):
return statementGuaranteesExit(CodeBlockItemSyntax.Item(elseIf))
#if RESILIENT_LIBRARIES
@unknown default:
return false
#endif
}
}
case .decl:
break
#if RESILIENT_LIBRARIES
@unknown default:
break
#endif
}
return false
}
private static func buildGuardStatement(
from ifExpr: IfExprSyntax,
elseBody: [CodeBlockItemSyntax],
baseIndentation: Trivia,
indentStep: Trivia
) -> GuardStmtSyntax {
var elseStatementsList = elseBody.enumerated().map { index, stmt in
return stmt.indented(by: indentStep)
}
if var lastStmt = elseStatementsList.last,
lastStmt.trailingTrivia.pieces.last?.isNewline ?? false
{
lastStmt.trailingTrivia = Trivia(pieces: lastStmt.trailingTrivia.pieces.dropLast())
elseStatementsList[elseStatementsList.count - 1] = lastStmt
}
let elseBlock = CodeBlockSyntax(
leftBrace: .leftBraceToken(),
statements: CodeBlockItemListSyntax(elseStatementsList),
rightBrace: .rightBraceToken(leadingTrivia: .newline + baseIndentation)
)
return GuardStmtSyntax(
guardKeyword: .keyword(.guard, trailingTrivia: .space),
conditions: normalizeConditionsTrivia(ifExpr.conditions),
elseKeyword: .keyword(.else, leadingTrivia: .space, trailingTrivia: .space),
body: elseBlock
)
}
/// Normalize conditions trivia by stripping trailing whitespace from the end of the last condition.
/// This prevents double spaces before the `else` keyword while preserving spaces before comments.
private static func normalizeConditionsTrivia(
_ conditions: ConditionElementListSyntax
) -> ConditionElementListSyntax {
guard var lastCondition = conditions.last else {
return conditions
}
let trimmedPieces = lastCondition.trailingTrivia.pieces.droppingLast(while: \TriviaPiece.isSpaceOrTab)
lastCondition.trailingTrivia = Trivia(pieces: Array(trimmedPieces))
var newConditions = Array(conditions.dropLast())
newConditions.append(lastCondition)
return ConditionElementListSyntax(newConditions)
}
}
private func isFunctionBoundary(_ syntax: Syntax) -> Bool {
[.functionDecl, .initializerDecl, .accessorDecl, .subscriptDecl, .closureExpr].contains(syntax.kind)
}