Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
0dbbf51
Add if let to guard tranformation
PhantomInTheWire Jan 4, 2026
36c5546
Tests for if let to guard tranformation
PhantomInTheWire Jan 4, 2026
911b78e
chore: simplify expr
PhantomInTheWire Jan 7, 2026
de47f71
Remove hard-coded never-returning function detection in statementGuar…
PhantomInTheWire Jan 7, 2026
61f9cd8
Refactor statementGuaranteesExit to use switch over statement kind
PhantomInTheWire Jan 7, 2026
7151f74
Optimize early-exit check to avoid unnecessary analysis when elseBody…
PhantomInTheWire Jan 7, 2026
0ce651f
Clarify switch exhaustiveness comment: intentionally not supported
PhantomInTheWire Jan 7, 2026
107047a
Refactor to look through ExpressionStmtSyntax wrapper, clean up comments
PhantomInTheWire Jan 7, 2026
55b74d3
Fix failing tests
PhantomInTheWire Jan 9, 2026
cda177c
Add adjustingIndentation helper to preserve comments in conversions
PhantomInTheWire Jan 9, 2026
b13de8f
Use adjustingIndentation in if-let/guard conversions
PhantomInTheWire Jan 9, 2026
e80eee0
Add test for single-line guard body indentation
PhantomInTheWire Jan 9, 2026
ae85e1a
Simplify isFunctionBoundary to use .kind comparison
PhantomInTheWire Jan 9, 2026
8688fb5
Add matching predicate overload to findParentOfSelf
PhantomInTheWire Jan 9, 2026
2598f31
Use findParentOfSelf for findConvertibleIfExpr
PhantomInTheWire Jan 9, 2026
069dd15
Use findParentOfSelf for findConvertibleGuardStmt
PhantomInTheWire Jan 9, 2026
3f71cc6
Remove unused trimmingTrailingWhitespace extension
PhantomInTheWire Jan 9, 2026
196070f
Remove LanguageServerProtocol prefix
PhantomInTheWire Jan 9, 2026
cf4d125
Move if <-> guard conversion tests to IfGuardConversionTests.swift
PhantomInTheWire Jan 9, 2026
dae0a23
Refactor Position comparison to use direct Comparable conformance
PhantomInTheWire Jan 9, 2026
d1eb43e
Refactor validateCodeAction to use apply(edits:to:) helper
PhantomInTheWire Jan 9, 2026
2e56f16
Refactor eligibility tests to use validateCodeAction with following s…
PhantomInTheWire Jan 9, 2026
325f52d
Restrict if-guard conversion to standalone statements and simplify na…
PhantomInTheWire Jan 9, 2026
a68f3eb
Use .last instead of manual index manipulation in if-guard conversion…
PhantomInTheWire Jan 9, 2026
e4de57b
Remove guard-to-if conversion (to be added in separate PR)
PhantomInTheWire Jan 9, 2026
45c94d0
Use ?? false instead of == true in ConvertIfLetToGuard
PhantomInTheWire Jan 9, 2026
c3cc223
Make isConvertibleToGuard private
PhantomInTheWire Jan 9, 2026
29b883f
Remove redundant elseKeyword check
PhantomInTheWire Jan 9, 2026
840fa55
Simplify guaranteed exit check for if-else chains
PhantomInTheWire Jan 9, 2026
71df07d
Remove redundant findParentOfSelf overload by adding default parameter
PhantomInTheWire Jan 9, 2026
4db0f19
remove redundant import
PhantomInTheWire Jan 9, 2026
13f9916
Add subscript to function-level boundaries
PhantomInTheWire Jan 9, 2026
ad79e92
use swift-syntax indentation APIs
PhantomInTheWire Jan 9, 2026
8d59437
Refactor indentation logic to use swift-syntax APIs and fix CRLF hand…
PhantomInTheWire Jan 9, 2026
92a04dc
Cleanup: Remove fallback indentation logic and update tests
PhantomInTheWire Jan 9, 2026
0fca80c
Add IndentationAdjuster utility for relative indentation management
PhantomInTheWire Jan 9, 2026
2d13b1c
Improve 'Convert to guard' refactoring: ensure robust indentation and…
PhantomInTheWire Jan 9, 2026
ce86427
Refactor IndentationAdjuster to IndentationRemover using SyntaxProtoc…
PhantomInTheWire Jan 9, 2026
58f2e77
defualt to 4 spaces like swift syntax
PhantomInTheWire Jan 9, 2026
1b673f3
Refactor: remove redundant isNewline check as isWhitespace includes it
PhantomInTheWire Jan 9, 2026
32022db
Address review feedback: refine exit guarantee logic and simplify con…
PhantomInTheWire Jan 9, 2026
9d50f14
Refactor: apply review feedback cleanups and improve type safety in I…
PhantomInTheWire Jan 9, 2026
eb5e8d9
Fix IfGuardConversionTests indentation failures by providing more con…
PhantomInTheWire Jan 10, 2026
428eda0
Refactor ConvertIfLetToGuard to simplify newline logic
PhantomInTheWire Jan 10, 2026
09b818f
Add droppingLast(while:) extension to Collection and use it in Conver…
PhantomInTheWire Jan 10, 2026
09e5f22
testConvertIfLetToGuardWithCRLF adjustments
PhantomInTheWire Jan 10, 2026
c464882
Refactor IndentationRemover to use state-based logic and improve newl…
PhantomInTheWire Jan 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions Sources/SwiftExtensions/Collection+DropLast.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//===----------------------------------------------------------------------===//
//
// 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
//
//===----------------------------------------------------------------------===//

public extension Collection {
/// Returns an array by skipping elements from the end while `predicate` returns `true`.
func droppingLast(while predicate: (Element) throws -> Bool) rethrows -> [Element] {
return try Array(self.reversed().drop(while: predicate).reversed())
}
}
2 changes: 2 additions & 0 deletions Sources/SwiftLanguageService/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ add_library(SwiftLanguageService STATIC
ClosureCompletionFormat.swift
CodeActions/AddDocumentation.swift
CodeActions/ApplyDeMorganLaw.swift
CodeActions/ConvertIfLetToGuard.swift
CodeActions/ConvertIntegerLiteral.swift
CodeActions/ConvertJSONToCodableStruct.swift
CodeActions/ConvertStringConcatenationToStringInterpolation.swift
Expand All @@ -23,6 +24,7 @@ add_library(SwiftLanguageService STATIC
ExpandMacroCommand.swift
FoldingRange.swift
GeneratedInterfaceManager.swift
IndentationRemover.swift
InlayHints.swift
MacroExpansion.swift
OpenInterface.swift
Expand Down
270 changes: 270 additions & 0 deletions Sources/SwiftLanguageService/CodeActions/ConvertIfLetToGuard.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
//===----------------------------------------------------------------------===//
//
// 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 []
}

let baseIndentation = ifExpr.firstToken(viewMode: .sourceAccurate)?.indentationOfLine ?? []
let indentStep = BasicFormat.inferIndentation(of: ifExpr.body) ?? .spaces(4)

let guardStmt = buildGuardStatement(
from: ifExpr,
elseBody: Array(followingStatements),
baseIndentation: baseIndentation,
indentStep: indentStep
)
let newBodyStatements = ifExpr.body.statements

let rangeStart = ifExpr.positionAfterSkippingLeadingTrivia
let rangeEnd = lastStatement.endPosition

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.
var pieces = Array(adjustedStmt.leadingTrivia)
while let first = pieces.first, first.isWhitespace {
pieces.removeFirst()
}
adjustedStmt = adjustedStmt.with(\.leadingTrivia, .newline + baseIndentation + Trivia(pieces: pieces))
}
replacementText += adjustedStmt.description
}

let edit = TextEdit(
range: scope.snapshot.absolutePositionRange(of: rangeStart..<rangeEnd),
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 {
guard let ifExpr else { return false }
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
}

for condition in ifExpr.conditions {
if let optionalBinding = condition.condition.as(OptionalBindingConditionSyntax.self) {
if optionalBinding.pattern.is(ExpressionPatternSyntax.self) {
return false
}
} else if condition.condition.is(MatchingPatternConditionSyntax.self) {
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 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))
}
}

case .decl:
break
}

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,
let lastPiece = lastStmt.trailingTrivia.pieces.last,
lastPiece.isNewline
{
let newTrivia = Trivia(pieces: lastStmt.trailingTrivia.pieces.dropLast())
lastStmt = lastStmt.with(\.trailingTrivia, newTrivia)
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.droppingLast(while: { $0.isSpaceOrTab })

lastCondition = lastCondition.with(\.trailingTrivia, Trivia(pieces: Array(trimmedPieces)))
var newConditions = Array(conditions.dropLast())
newConditions.append(lastCondition)
return ConditionElementListSyntax(newConditions)
}
}

fileprivate extension TriviaPiece {
var isSpaceOrTab: Bool {
switch self {
case .spaces, .tabs:
return true
default:
return false
}
}
}

private func isFunctionBoundary(_ syntax: Syntax) -> Bool {
[.functionDecl, .initializerDecl, .accessorDecl, .subscriptDecl, .closureExpr].contains(syntax.kind)
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ let allSyntaxCodeActions: [any SyntaxCodeActionProvider.Type] = {
AddSeparatorsToIntegerLiteral.self,
ApplyDeMorganLaw.self,
ConvertComputedPropertyToZeroParameterFunction.self,
ConvertIfLetToGuard.self,
ConvertIntegerLiteral.self,
ConvertJSONToCodableStruct.self,
ConvertStringConcatenationToStringInterpolation.self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,14 +140,16 @@ extension ConvertComputedPropertyToZeroParameterFunction: SyntaxRefactoringCodeA
}

extension SyntaxProtocol {
/// Finds the innermost parent of the given type while not walking outside of nodes that satisfy `stoppingIf`.
/// Finds the innermost parent of the given type that satisfies `matching`,
/// while not walking outside of nodes that satisfy `stoppingIf`.
func findParentOfSelf<ParentType: SyntaxProtocol>(
ofType: ParentType.Type,
stoppingIf: (Syntax) -> Bool
stoppingIf: (Syntax) -> Bool,
matching: (ParentType) -> Bool = { _ in true }
) -> ParentType? {
var node: Syntax? = Syntax(self)
while let unwrappedNode = node, !stoppingIf(unwrappedNode) {
if let expectedType = unwrappedNode.as(ParentType.self) {
if let expectedType = unwrappedNode.as(ParentType.self), matching(expectedType) {
return expectedType
}
node = unwrappedNode.parent
Expand Down
Loading