diff --git a/Sources/SwiftExtensions/Collection+DropLast.swift b/Sources/SwiftExtensions/Collection+DropLast.swift new file mode 100644 index 000000000..17df2429a --- /dev/null +++ b/Sources/SwiftExtensions/Collection+DropLast.swift @@ -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()) + } +} diff --git a/Sources/SwiftLanguageService/CMakeLists.txt b/Sources/SwiftLanguageService/CMakeLists.txt index d7df889ad..8af5f9a04 100644 --- a/Sources/SwiftLanguageService/CMakeLists.txt +++ b/Sources/SwiftLanguageService/CMakeLists.txt @@ -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 @@ -23,6 +24,7 @@ add_library(SwiftLanguageService STATIC ExpandMacroCommand.swift FoldingRange.swift GeneratedInterfaceManager.swift + IndentationRemover.swift InlayHints.swift MacroExpansion.swift OpenInterface.swift diff --git a/Sources/SwiftLanguageService/CodeActions/ConvertIfLetToGuard.swift b/Sources/SwiftLanguageService/CodeActions/ConvertIfLetToGuard.swift new file mode 100644 index 000000000..ce5cd6505 --- /dev/null +++ b/Sources/SwiftLanguageService/CodeActions/ConvertIfLetToGuard.swift @@ -0,0 +1,250 @@ +//===----------------------------------------------------------------------===// +// +// 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.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.. 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 + } + + 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, + 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.droppingLast(while: \.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) +} diff --git a/Sources/SwiftLanguageService/CodeActions/SyntaxCodeActions.swift b/Sources/SwiftLanguageService/CodeActions/SyntaxCodeActions.swift index 3d9bf753b..06b0610d7 100644 --- a/Sources/SwiftLanguageService/CodeActions/SyntaxCodeActions.swift +++ b/Sources/SwiftLanguageService/CodeActions/SyntaxCodeActions.swift @@ -20,6 +20,7 @@ let allSyntaxCodeActions: [any SyntaxCodeActionProvider.Type] = { AddSeparatorsToIntegerLiteral.self, ApplyDeMorganLaw.self, ConvertComputedPropertyToZeroParameterFunction.self, + ConvertIfLetToGuard.self, ConvertIntegerLiteral.self, ConvertJSONToCodableStruct.self, ConvertStringConcatenationToStringInterpolation.self, diff --git a/Sources/SwiftLanguageService/CodeActions/SyntaxRefactoringCodeActionProvider.swift b/Sources/SwiftLanguageService/CodeActions/SyntaxRefactoringCodeActionProvider.swift index 2c309c6cb..bcdb053f3 100644 --- a/Sources/SwiftLanguageService/CodeActions/SyntaxRefactoringCodeActionProvider.swift +++ b/Sources/SwiftLanguageService/CodeActions/SyntaxRefactoringCodeActionProvider.swift @@ -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( 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 diff --git a/Sources/SwiftLanguageService/IndentationRemover.swift b/Sources/SwiftLanguageService/IndentationRemover.swift new file mode 100644 index 000000000..e66da67e8 --- /dev/null +++ b/Sources/SwiftLanguageService/IndentationRemover.swift @@ -0,0 +1,93 @@ +//===----------------------------------------------------------------------===// +// +// 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(RawSyntax) @_spi(SwiftSyntax) import SwiftSyntax + +/// SyntaxRewriter that removes indentation for lines starting with a newline. +class IndentationRemover: SyntaxRewriter { + private let indentation: [TriviaPiece] + private var shouldUnindent: Bool + + init(indentation: Trivia, indentFirstLine: Bool = false) { + self.indentation = indentation.decomposed.pieces + self.shouldUnindent = indentFirstLine + super.init(viewMode: .sourceAccurate) + } + + private func unindentAfterNewlines(_ content: String) -> String { + let lines = content.components(separatedBy: .newlines) + var result: [String] = [] + + if let first = lines.first { + result.append(first) + } + + let pattern = Trivia(pieces: indentation).description + for line in lines.dropFirst() { + if line.hasPrefix(pattern) { + result.append(String(line.dropFirst(pattern.count))) + } else { + result.append(line) + } + } + return result.joined(separator: "\n") + } + + private func unindent(_ trivia: Trivia) -> Trivia { + var result: [TriviaPiece] = [] + result.reserveCapacity(trivia.count) + + var remainingPieces = trivia.decomposed.pieces + while let piece = remainingPieces.first { + remainingPieces.removeFirst() + switch piece { + case .newlines, .carriageReturns, .carriageReturnLineFeeds: + shouldUnindent = true + result.append(piece) + case .blockComment(let content): + result.append(.blockComment(unindentAfterNewlines(content))) + case .docBlockComment(let content): + result.append(.docBlockComment(unindentAfterNewlines(content))) + case .unexpectedText(let content): + result.append(.unexpectedText(unindentAfterNewlines(content))) + default: + result.append(piece) + } + + if shouldUnindent { + if remainingPieces.starts(with: indentation) { + remainingPieces.removeFirst(indentation.count) + } + shouldUnindent = false + } + } + shouldUnindent = false + return Trivia(pieces: result) + } + + override func visit(_ token: TokenSyntax) -> TokenSyntax { + let indentedLeadingTrivia = unindent(token.leadingTrivia) + + if case .stringSegment(let content) = token.tokenKind, + let last = content.last, + last.isNewline + { + shouldUnindent = true + } + + return + token + .with(\.leadingTrivia, indentedLeadingTrivia) + .with(\.trailingTrivia, unindent(token.trailingTrivia)) + } +} diff --git a/Tests/SemanticIndexTests/TaskSchedulerTests.swift b/Tests/SemanticIndexTests/TaskSchedulerTests.swift index 15fbc08e4..aa381dffb 100644 --- a/Tests/SemanticIndexTests/TaskSchedulerTests.swift +++ b/Tests/SemanticIndexTests/TaskSchedulerTests.swift @@ -554,10 +554,6 @@ private func assertNotContains( // MARK: - Collection utilities fileprivate extension Collection { - func dropLast(while predicate: (Element) -> Bool) -> [Element] { - return Array(self.reversed().drop(while: predicate).reversed()) - } - func count(where predicate: (Element) -> Bool) -> Int { return self.filter(predicate).count } diff --git a/Tests/SourceKitLSPTests/IfGuardConversionTests.swift b/Tests/SourceKitLSPTests/IfGuardConversionTests.swift new file mode 100644 index 000000000..b5e221500 --- /dev/null +++ b/Tests/SourceKitLSPTests/IfGuardConversionTests.swift @@ -0,0 +1,629 @@ +//===----------------------------------------------------------------------===// +// +// 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 LanguageServerProtocol +import SKTestSupport +import SKUtilities +import SourceKitLSP +@_spi(Testing) import SwiftLanguageService +import SwiftParser +import SwiftSyntax +import XCTest + +private typealias CodeActionCapabilities = TextDocumentClientCapabilities.CodeAction +private typealias CodeActionLiteralSupport = CodeActionCapabilities.CodeActionLiteralSupport +private typealias CodeActionKindCapabilities = CodeActionLiteralSupport.CodeActionKindValueSet + +private let clientCapabilitiesWithCodeActionSupport: ClientCapabilities = { + var documentCapabilities = TextDocumentClientCapabilities() + var codeActionCapabilities = CodeActionCapabilities() + codeActionCapabilities.codeActionLiteralSupport = .init( + codeActionKind: .init(valueSet: [.refactorInline]) + ) + documentCapabilities.codeAction = codeActionCapabilities + documentCapabilities.completion = .init(completionItem: .init(snippetSupport: true)) + return ClientCapabilities(workspace: nil, textDocument: documentCapabilities) +}() + +final class IfGuardConversionTests: SourceKitLSPTestCase { + private func validateCodeAction( + input: String, + expectedOutput: String?, + title: String, + file: StaticString = #filePath, + line: UInt = #line + ) async throws { + let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport) + let uri = DocumentURI(for: .swift) + let positions = testClient.openDocument(input, uri: uri) + + // Determine range + let range: Range + if input.contains("1️⃣") && input.contains("2️⃣") { + range = positions["1️⃣"].. String { + let spaces = String(repeating: " ", count: indent) + return """ + \(spaces)print(1) + \(spaces)print(2) + \(spaces)print(3) + """ + } + + func testConvertIfLetToGuard() async throws { + try await validateCodeAction( + input: """ + func test() -> Int? { + 1️⃣if let value = optional { + print(value) + return value + } + return nil + } + """, + expectedOutput: """ + func test() -> Int? { + guard let value = optional else { + return nil + } + print(value) + return value + } + """, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardNotShownWithoutEarlyExit() async throws { + try await validateCodeAction( + input: """ + func test() { + 1️⃣if let value = optional { + print(value) + } + return + } + """, + expectedOutput: nil, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardNotShownWhenPartOfExpression() async throws { + try await validateCodeAction( + input: """ + func test() -> Int? { + if let a = optional { + let x = 1️⃣if let b = optional { b } else { nil } + return a + } + return nil + } + """, + expectedOutput: nil, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardShownWithReturn() async throws { + try await validateCodeAction( + input: """ + func test() -> Int? { + 1️⃣if let value = optional { + \(context(indent: 4)) + return value + } + return nil + } + """, + expectedOutput: """ + func test() -> Int? { + guard let value = optional else { + return nil + } + \(context(indent: 2)) + return value + } + """, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardShownWithThrow() async throws { + try await validateCodeAction( + input: """ + func test() throws -> Int { + 1️⃣if let value = optional { + \(context(indent: 4)) + throw MyError() + } + return 0 + } + """, + expectedOutput: """ + func test() throws -> Int { + guard let value = optional else { + return 0 + } + \(context(indent: 2)) + throw MyError() + } + """, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardShownWithBreak() async throws { + try await validateCodeAction( + input: """ + func test() { + while true { + 1️⃣if let value = optional { + \(context(indent: 6)) + break + } + print("loop") + } + } + """, + expectedOutput: """ + func test() { + while true { + guard let value = optional else { + print("loop") + } + \(context(indent: 4)) + break + } + } + """, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardShownWithContinue() async throws { + try await validateCodeAction( + input: """ + func test() { + while true { + 1️⃣if let value = optional { + \(context(indent: 6)) + continue + } + print("loop") + } + } + """, + expectedOutput: """ + func test() { + while true { + guard let value = optional else { + print("loop") + } + \(context(indent: 4)) + continue + } + } + """, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardNotShownWithFatalError() async throws { + try await validateCodeAction( + input: """ + func test() -> Int { + 1️⃣if let value = optional { + fatalError("unreachable") + } + return 0 + } + """, + expectedOutput: nil, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardShownWithIfElseBothExiting() async throws { + // Note: Nested if-else as last statement should be detected as exiting + // when both branches guarantee exit. + try await validateCodeAction( + input: """ + func test() -> Int? { + 1️⃣if let value = optional { + if value > 0 { + return value + } else { + return nil + } + } + return nil + } + """, + expectedOutput: """ + func test() -> Int? { + guard let value = optional else { + return nil + } + if value > 0 { + return value + } else { + return nil + } + } + """, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardNotShownWithElse() async throws { + try await validateCodeAction( + input: """ + func test() { + 1️⃣if let value = optional { + print(value) + } else { + print("none") + } + return + } + """, + expectedOutput: nil, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardNotShownWithDefer() async throws { + try await validateCodeAction( + input: """ + func test() -> Int? { + 1️⃣if let value = optional { + defer { cleanup(value) } + return value + } + return nil + } + """, + expectedOutput: nil, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardNotShownWithCasePattern() async throws { + try await validateCodeAction( + input: """ + func test() -> Int? { + 1️⃣if case let .some(value) = optional { + return value + } + return nil + } + """, + expectedOutput: nil, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardNotShownWithSwitchExit() async throws { + // Switch statements are conservatively treated as not guaranteeing exit + // even if all cases return, because checking exhaustiveness is complex. + // TODO: A future implementation could analyze switch exhaustiveness. + try await validateCodeAction( + input: """ + func test() -> Int? { + 1️⃣if let value = optional { + switch value { + case 0: return nil + default: return value + } + } + return nil + } + """, + expectedOutput: nil, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardWithComments() async throws { + // Note: Comments inside the if body have their leading trivia replaced + // during the transformation, so inline comments are preserved but + // leading comments on the first statement may be adjusted. + try await validateCodeAction( + input: """ + func test() -> Int? { + // Check if we have a value + 1️⃣if let value = optional /* unwrap */ { + print(value) // Use the value + return value // return it + } + return nil // fallback + } + """, + expectedOutput: """ + func test() -> Int? { + // Check if we have a value + guard let value = optional /* unwrap */ else { + return nil // fallback + } + print(value) // Use the value + return value // return it + } + """, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardMultipleConditions() async throws { + try await validateCodeAction( + input: """ + func test() -> Int? { + 1️⃣if let a = optA, let b = optB, a > 0 { + \(context(indent: 4)) + return a + b + } + return nil + } + """, + expectedOutput: """ + func test() -> Int? { + guard let a = optA, let b = optB, a > 0 else { + return nil + } + \(context(indent: 2)) + return a + b + } + """, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardPreserves4SpaceIndent() async throws { + // BasicFormat.inferIndentation requires at least 3 lines of code to infer indentation. + try await validateCodeAction( + input: """ + func test() -> Int? { + 1️⃣if let value = optional { + print(value) + print(value) + print(value) + return value + } + return nil + } + """, + expectedOutput: """ + func test() -> Int? { + guard let value = optional else { + return nil + } + print(value) + print(value) + print(value) + return value + } + """, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardSelectsInnermostCandidate() async throws { + // When cursor is on inner if-let, only the inner one should be converted + try await validateCodeAction( + input: """ + func test() -> Int? { + if let outer = optA { + 1️⃣if let inner = optB { + \(context(indent: 6)) + return inner + } + return outer + } + return nil + } + """, + expectedOutput: """ + func test() -> Int? { + if let outer = optA { + guard let inner = optB else { + return outer + } + \(context(indent: 4)) + return inner + } + return nil + } + """, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardSelectsOuterWhenCursorOnOuter() async throws { + // When cursor is on outer if-let, only the outer one should be converted + // (inner doesn't qualify because it doesn't guarantee exit from outer scope) + try await validateCodeAction( + input: """ + func test() -> Int? { + 1️⃣if let outer = optA { + print(outer) + return outer + } + return nil + } + """, + expectedOutput: """ + func test() -> Int? { + guard let outer = optA else { + return nil + } + print(outer) + return outer + } + """, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardWithCRLF() async throws { + // Test that CRLF line endings in input are handled correctly. + try await validateCodeAction( + input: """ + func test() -> Int? {\r\n\ + 1️⃣if let value = optional {\r\n\ + print(value)\r\n\ + return value\r\n\ + }\r\n\ + return nil\r\n\ + } + """, + expectedOutput: """ + func test() -> Int? {\r\n\ + guard let value = optional else {\r\n\ + return nil\n\ + }\n\ + print(value)\r\n\ + return value\r\n\ + } + """, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardWithBodyComments() async throws { + try await validateCodeAction( + input: """ + func test() -> Int? { + 1️⃣if let value = optional { + // A comment before the first statement + print(value) + /* A block comment + spanning multiple lines */ + return value + } + return nil + } + """, + expectedOutput: """ + func test() -> Int? { + guard let value = optional else { + return nil + } + // A comment before the first statement + print(value) + /* A block comment + spanning multiple lines */ + return value + } + """, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardWithMultiLineStatement() async throws { + try await validateCodeAction( + input: """ + func test() -> Int? { + 1️⃣if let value = optional { + print( + value, + "is here" + ) + return value + } + return nil + } + """, + expectedOutput: """ + func test() -> Int? { + guard let value = optional else { + return nil + } + print( + value, + "is here" + ) + return value + } + """, + title: "Convert to guard" + ) + } + + func testConvertIfLetToGuardWithFirstStatementLineComment() async throws { + try await validateCodeAction( + input: """ + func test() -> Int? { + 1️⃣if let value = optional { + // This comment is attached to 'return' + return value + } + return nil + } + """, + expectedOutput: """ + func test() -> Int? { + guard let value = optional else { + return nil + } + // This comment is attached to 'return' + return value + } + """, + title: "Convert to guard" + ) + } +}