Files
sourcekit-lsp/Tests/SourceKitLSPTests/CodeActionTests.swift
2025-10-31 14:11:11 -07:00

1255 lines
38 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2019 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
//
//===----------------------------------------------------------------------===//
@_spi(SourceKitLSP) import LanguageServerProtocol
import SKLogging
import SKTestSupport
import SourceKitLSP
import SwiftExtensions
import SwiftLanguageService
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()
let codeActionKinds = CodeActionKindCapabilities(valueSet: [.refactor, .quickFix])
let codeActionLiteralSupport = CodeActionLiteralSupport(codeActionKind: codeActionKinds)
codeActionCapabilities.codeActionLiteralSupport = codeActionLiteralSupport
documentCapabilities.codeAction = codeActionCapabilities
documentCapabilities.completion = .init(completionItem: .init(snippetSupport: true))
return ClientCapabilities(workspace: nil, textDocument: documentCapabilities)
}()
final class CodeActionTests: SourceKitLSPTestCase {
func testCodeActionResponseLegacySupport() throws {
let command = Command(title: "Title", command: "Command", arguments: [1, "text", 2.2, nil])
let codeAction = CodeAction(title: "1")
let codeAction2 = CodeAction(title: "2", command: command)
var capabilities: TextDocumentClientCapabilities.CodeAction
var capabilityJson: String
var data: Data
var response: CodeActionRequestResponse
capabilityJson =
"""
{
"dynamicRegistration": true,
"codeActionLiteralSupport" : {
"codeActionKind": {
"valueSet": []
}
}
}
"""
data = capabilityJson.data(using: .utf8)!
capabilities = try JSONDecoder().decode(
TextDocumentClientCapabilities.CodeAction.self,
from: data
)
response = .init(codeActions: [codeAction, codeAction2], clientCapabilities: capabilities)
let actions = try JSONDecoder().decode([CodeAction].self, from: JSONEncoder().encode(response))
XCTAssertEqual(actions, [codeAction, codeAction2])
capabilityJson =
"""
{
"dynamicRegistration": true
}
"""
data = capabilityJson.data(using: .utf8)!
capabilities = try JSONDecoder().decode(
TextDocumentClientCapabilities.CodeAction.self,
from: data
)
response = .init(codeActions: [codeAction, codeAction2], clientCapabilities: capabilities)
let commands = try JSONDecoder().decode([Command].self, from: JSONEncoder().encode(response))
XCTAssertEqual(commands, [command])
}
func testCodeActionResponseIgnoresSupportedKinds() throws {
// The client guarantees that unsupported kinds will be handled, and in
// practice some clients use `"codeActionKind":{"valueSet":[]}`, since
// they support all kinds anyway. So to avoid filtering all actions, we
// ignore the supported kinds.
let unspecifiedAction = CodeAction(title: "Unspecified")
let refactorAction = CodeAction(title: "Refactor", kind: .refactor)
let quickfixAction = CodeAction(title: "Quickfix", kind: .quickFix)
let actions = [unspecifiedAction, refactorAction, quickfixAction]
var capabilities: TextDocumentClientCapabilities.CodeAction
var capabilityJson: String
var data: Data
var response: CodeActionRequestResponse
capabilityJson =
"""
{
"dynamicRegistration": true,
"codeActionLiteralSupport" : {
"codeActionKind": {
"valueSet": ["refactor"]
}
}
}
"""
data = capabilityJson.data(using: .utf8)!
capabilities = try JSONDecoder().decode(
TextDocumentClientCapabilities.CodeAction.self,
from: data
)
response = .init(codeActions: actions, clientCapabilities: capabilities)
XCTAssertEqual(response, .codeActions([unspecifiedAction, refactorAction, quickfixAction]))
capabilityJson =
"""
{
"dynamicRegistration": true,
"codeActionLiteralSupport" : {
"codeActionKind": {
"valueSet": []
}
}
}
"""
data = capabilityJson.data(using: .utf8)!
capabilities = try JSONDecoder().decode(
TextDocumentClientCapabilities.CodeAction.self,
from: data
)
response = .init(codeActions: actions, clientCapabilities: capabilities)
XCTAssertEqual(response, .codeActions([unspecifiedAction, refactorAction, quickfixAction]))
}
func testCodeActionResponseCommandMetadataInjection() throws {
let url = URL(fileURLWithPath: "/a.swift")
let textDocument = TextDocumentIdentifier(url)
let expectedMetadata: LSPAny = try {
let metadata = SourceKitLSPCommandMetadata(textDocument: textDocument)
let data = try JSONEncoder().encode(metadata)
return try JSONDecoder().decode(LSPAny.self, from: data)
}()
XCTAssertEqual(expectedMetadata, .dictionary(["sourcekitlsp_textDocument": ["uri": "file:///a.swift"]]))
let command = Command(title: "Title", command: "Command", arguments: [1, "text", 2.2, nil])
let codeAction = CodeAction(title: "1")
let codeAction2 = CodeAction(title: "2", command: command)
let request = CodeActionRequest(
range: Position(line: 0, utf16index: 0)..<Position(line: 1, utf16index: 1),
context: .init(diagnostics: [], only: nil),
textDocument: textDocument
)
var response = request.injectMetadata(toResponse: .commands([command]))
XCTAssertEqual(
response,
.commands([
Command(
title: command.title,
command: command.command,
arguments: command.arguments! + [expectedMetadata]
)
])
)
response = request.injectMetadata(toResponse: .codeActions([codeAction, codeAction2]))
XCTAssertEqual(
response,
.codeActions([
codeAction,
CodeAction(
title: codeAction2.title,
command: Command(
title: command.title,
command: command.command,
arguments: command.arguments! + [expectedMetadata]
)
),
])
)
response = request.injectMetadata(toResponse: nil)
XCTAssertNil(response)
}
func testCommandEncoding() throws {
let dictionary: LSPAny = ["1": [nil, 2], "2": "text", "3": ["4": [1, 2]]]
let array: LSPAny = [1, [2, "string"], dictionary]
let arguments: LSPAny = [1, 2.2, "text", nil, array, dictionary]
let command = Command(title: "Command", command: "command.id", arguments: [arguments, arguments])
let decoded = try JSONDecoder().decode(Command.self, from: JSONEncoder().encode(command))
XCTAssertEqual(decoded, command)
}
func testEmptyCodeActionResult() async throws {
let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport)
let uri = DocumentURI(for: .swift)
let positions = testClient.openDocument(
"""
func foo() -> String {
var a = "hello"
1⃣ return a
}
""",
uri: uri
)
let request = CodeActionRequest(
range: positions["1"]..<positions["1"],
context: .init(),
textDocument: TextDocumentIdentifier(uri)
)
let result = try await testClient.send(request)
XCTAssertEqual(result, .codeActions([]))
}
func testSemanticRefactorLocalRenameResult() async throws {
let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport)
let uri = DocumentURI(for: .swift)
let positions = testClient.openDocument(
"""
func localRename() {
var 1⃣local = 1
_ = local
}
""",
uri: uri
)
let request = CodeActionRequest(
range: Range(positions["1"]),
context: .init(),
textDocument: TextDocumentIdentifier(uri)
)
let result = try await testClient.send(request)
XCTAssertEqual(result?.codeActions?.map(\.title), ["Add documentation"])
}
func testSemanticRefactorLocationCodeActionResult() async throws {
let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport)
let uri = DocumentURI(for: .swift)
let positions = testClient.openDocument(
"""
func foo() -> String {
var a = "1"
return a
}
""",
uri: uri
)
let testPosition = positions["1"]
let request = CodeActionRequest(
range: Range(testPosition),
context: .init(),
textDocument: TextDocumentIdentifier(uri)
)
let result = try await testClient.send(request)
let expectedCommandArgs: LSPAny = [
"actionString": "source.refactoring.kind.localize.string",
"positionRange": [
"start": [
"character": .int(testPosition.utf16index),
"line": .int(testPosition.line),
],
"end": [
"character": .int(testPosition.utf16index),
"line": .int(testPosition.line),
],
],
"title": "Localize String",
"textDocument": ["uri": .string(uri.stringValue)],
]
let metadataArguments: LSPAny = ["sourcekitlsp_textDocument": ["uri": .string(uri.stringValue)]]
let expectedCommand = Command(
title: "Localize String",
command: "semantic.refactor.command",
arguments: [expectedCommandArgs] + [metadataArguments]
)
let expectedCodeAction = CodeAction(
title: "Localize String",
kind: .refactor,
command: expectedCommand
)
assertContains(result?.codeActions ?? [], expectedCodeAction)
}
func testJSONCodableCodeActionResult() async throws {
let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport)
let uri = DocumentURI(for: .swift)
let positions = testClient.openDocument(
"""
1⃣{
"name": "Produce",
"shelves": [
{
"name": "Discount Produce",
"product": {
"name": "Banana",
"points": 200,
"description": "A banana that's perfectly ripe."
}
}
]
}
""",
uri: uri
)
let testPosition = positions["1"]
let request = CodeActionRequest(
range: Range(testPosition),
context: .init(),
textDocument: TextDocumentIdentifier(uri)
)
let result = try await testClient.send(request)
// Make sure we get a JSON conversion action.
let codableAction = result?.codeActions?.first { action in
return action.title == "Create Codable structs from JSON"
}
XCTAssertNotNil(codableAction)
}
func testSemanticRefactorRangeCodeActionResult() async throws {
let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport)
let uri = DocumentURI(for: .swift)
let positions = testClient.openDocument(
"""
func foo() -> String {
1⃣var a = "hello"
return a2
}
""",
uri: uri
)
let startPosition = positions["1"]
let endPosition = positions["2"]
let request = CodeActionRequest(
range: startPosition..<endPosition,
context: .init(),
textDocument: TextDocumentIdentifier(uri)
)
let result = try await testClient.send(request)
let expectedCommandArgs: LSPAny = [
"actionString": "source.refactoring.kind.extract.function",
"positionRange": [
"start": [
"character": .int(startPosition.utf16index),
"line": .int(startPosition.line),
],
"end": [
"character": .int(endPosition.utf16index),
"line": .int(endPosition.line),
],
],
"title": "Extract Method",
"textDocument": ["uri": .string(uri.stringValue)],
]
let metadataArguments: LSPAny = ["sourcekitlsp_textDocument": ["uri": .string(uri.stringValue)]]
let expectedCommand = Command(
title: "Extract Method",
command: "semantic.refactor.command",
arguments: [expectedCommandArgs] + [metadataArguments]
)
let expectedCodeAction = CodeAction(
title: "Extract Method",
kind: .refactor,
command: expectedCommand
)
var resultActions = try XCTUnwrap(result?.codeActions)
// Filter out "Add documentation"; we test it elsewhere
if let addDocIndex = resultActions.firstIndex(where: {
$0.title == "Add documentation"
}
) {
resultActions.remove(at: addDocIndex)
} else {
XCTFail("Missing 'Add documentation'.")
return
}
XCTAssertEqual(resultActions, [expectedCodeAction])
}
func testCodeActionsRemovePlaceholders() async throws {
let testClient = try await TestSourceKitLSPClient(
capabilities: clientCapabilitiesWithCodeActionSupport,
usePullDiagnostics: false
)
let uri = DocumentURI(for: .swift)
let positions = testClient.openDocument(
"""
protocol MyProto {
func foo()
}
struct 1⃣MyStruct: MyProto {
}
""",
uri: uri
)
let diags = try await testClient.nextDiagnosticsNotification()
XCTAssertEqual(diags.uri, uri)
XCTAssertEqual(diags.diagnostics.count, 1)
let diagPosition = try XCTUnwrap(diags.diagnostics.only?.range.lowerBound)
let quickFixActionResult = try await testClient.send(
CodeActionRequest(
range: Range(diagPosition),
context: .init(diagnostics: diags.diagnostics),
textDocument: TextDocumentIdentifier(uri)
)
)
// Check that the Fix-It action contains snippets
guard let quickFixAction = quickFixActionResult?.codeActions?.filter({ $0.kind == .quickFix }).only else {
return XCTFail("Expected exactly one quick fix action")
}
guard let change = quickFixAction.edit?.changes?[uri]?.only else {
return XCTFail("Expected exactly one change")
}
XCTAssertEqual(
change.newText.trimmingTrailingWhitespace(),
"""
func foo() {
}
"""
)
// Check that the refactor action contains snippets
let refactorActionResult = try await testClient.send(
CodeActionRequest(
range: Range(positions["1"]),
context: .init(diagnostics: diags.diagnostics),
textDocument: TextDocumentIdentifier(uri)
)
)
guard let refactorAction = refactorActionResult?.codeActions?.filter({ $0.kind == .refactor }).only else {
return XCTFail("Expected exactly one refactor action")
}
guard let command = refactorAction.command else {
return XCTFail("Expected the refactor action to have a command")
}
let editReceived = self.expectation(description: "Received ApplyEdit request")
testClient.handleSingleRequest { (request: ApplyEditRequest) -> ApplyEditResponse in
defer {
editReceived.fulfill()
}
guard let change = request.edit.changes?[uri]?.only else {
XCTFail("Expected exactly one edit")
return ApplyEditResponse(applied: false, failureReason: "Expected exactly one edit")
}
XCTAssertEqual(
change.newText.trimmingTrailingWhitespace(),
"""
func foo() {
}
"""
)
return ApplyEditResponse(applied: true, failureReason: nil)
}
_ = try await testClient.send(ExecuteCommandRequest(command: command.command, arguments: command.arguments))
try await fulfillmentOfOrThrow(editReceived)
}
func testAddDocumentationCodeActionResult() async throws {
let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport)
let uri = DocumentURI(for: .swift)
let positions = testClient.openDocument(
"""
2⃣func refacto1⃣r(syntax: DeclSyntax, in context: Void) -> DeclSyntax? { }3
""",
uri: uri
)
let testPosition = positions["1"]
let request = CodeActionRequest(
range: Range(testPosition),
context: .init(),
textDocument: TextDocumentIdentifier(uri)
)
let result = try await testClient.send(request)
// Make sure we get an add-documentation action.
let addDocAction = result?.codeActions?.first { action in
return action.title == "Add documentation"
}
XCTAssertNotNil(addDocAction)
}
func testCodeActionForFixItsProducedBySwiftSyntax() async throws {
let project = try await MultiFileTestProject(files: [
"test.swift": "protocol 1⃣Multi2⃣ 3⃣ident 4⃣{}",
"compile_commands.json": "[]",
])
let (uri, positions) = try project.openDocument("test.swift")
let report = try await project.testClient.send(
DocumentDiagnosticsRequest(textDocument: TextDocumentIdentifier(uri))
)
XCTAssertEqual(report.fullReport?.items.count, 1)
let codeActions = try XCTUnwrap(report.fullReport?.items.first?.codeActions)
let expectedCodeActions = [
CodeAction(
title: "Join the identifiers together",
kind: .quickFix,
edit: WorkspaceEdit(
changes: [
uri: [
TextEdit(range: positions["1"]..<positions["2"], newText: "Multiident"),
TextEdit(range: positions["3"]..<positions["4"], newText: ""),
]
]
)
),
CodeAction(
title: "Join the identifiers together with camel-case",
kind: .quickFix,
edit: WorkspaceEdit(
changes: [
uri: [
TextEdit(range: positions["1"]..<positions["2"], newText: "MultiIdent"),
TextEdit(range: positions["3"]..<positions["4"], newText: ""),
]
]
)
),
]
XCTAssertEqual(expectedCodeActions, codeActions)
}
func testPackageManifestEditingCodeActionResult() async throws {
let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport)
let uri = DocumentURI(for: .swift)
let positions = testClient.openDocument(
"""
// swift-tools-version: 5.5
let package = Package(
name: "packages",
targets: [
.tar1⃣get(name: "MyLib"),
]
)
""",
uri: uri
)
let testPosition = positions["1"]
let request = CodeActionRequest(
range: Range(testPosition),
context: .init(),
textDocument: TextDocumentIdentifier(uri)
)
let result = try await testClient.send(request)
let codeActions = try XCTUnwrap(result?.codeActions)
// Make sure we get the expected package manifest editing actions.
let addTestAction = codeActions.first { action in
return action.title == "Add test target (Swift Testing)"
}
XCTAssertNotNil(addTestAction)
XCTAssertTrue(
codeActions.contains { action in
action.title == "Add library target"
}
)
guard let addTestChanges = addTestAction?.edit?.changes else {
XCTFail("Didn't have changes in the 'Add test target (Swift Testing)' action")
return
}
guard let manifestEdits = addTestChanges[uri] else {
XCTFail("Didn't have edits")
return
}
XCTAssertTrue(
manifestEdits.contains { edit in
edit.newText.contains("testTarget")
}
)
XCTAssertTrue(
codeActions.contains { action in
return action.title == "Add product to export this target"
}
)
}
func testPackageManifestEditingCodeActionNoTestResult() async throws {
let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport)
let uri = DocumentURI(for: .swift)
let positions = testClient.openDocument(
"""
// swift-tools-version: 5.5
let package = Package(
name: "packages",
targets: [
.testTar1⃣get(name: "MyLib"),
]
)
""",
uri: uri
)
let testPosition = positions["1"]
let request = CodeActionRequest(
range: Range(testPosition),
context: .init(),
textDocument: TextDocumentIdentifier(uri)
)
let result = try await testClient.send(request)
let codeActions = try XCTUnwrap(result?.codeActions)
// Make sure we get the expected package manifest editing actions.
XCTAssertTrue(
!codeActions.contains { action in
return action.title == "Add test target"
}
)
XCTAssertTrue(
!codeActions.contains { action in
return action.title == "Add product to export this target"
}
)
}
func testConvertIntegerLiteral() async throws {
try await assertCodeActions(
"""
let x = 1⃣12⃣63
""",
ranges: [("1", "2"), ("1", "3")]
) { uri, positions in
[
CodeAction(
title: "Convert 16 to 0b10000",
kind: .refactorInline,
diagnostics: nil,
edit: WorkspaceEdit(
changes: [uri: [TextEdit(range: positions["1"]..<positions["3"], newText: "0b10000")]]
),
command: nil
),
CodeAction(
title: "Convert 16 to 0o20",
kind: .refactorInline,
diagnostics: nil,
edit: WorkspaceEdit(
changes: [uri: [TextEdit(range: positions["1"]..<positions["3"], newText: "0o20")]]
),
command: nil
),
CodeAction(
title: "Convert 16 to 0x10",
kind: .refactorInline,
diagnostics: nil,
edit: WorkspaceEdit(
changes: [uri: [TextEdit(range: positions["1"]..<positions["3"], newText: "0x10")]]
),
command: nil
),
]
}
}
func testFormatRawStringLiteral() async throws {
try await assertCodeActions(
"""
let x = 1⃣#"Hello 2world"#3
""",
ranges: [("1", "3")],
exhaustive: false
) { uri, positions in
[
CodeAction(
title: "Convert string literal to minimal number of \'#\'s",
kind: .refactorInline,
diagnostics: nil,
edit: WorkspaceEdit(
changes: [uri: [TextEdit(range: positions["1"]..<positions["3"], newText: #""Hello world""#)]]
),
command: nil
)
]
}
}
func testFormatRawStringLiteralFromInterpolation() async throws {
try await assertCodeActions(
##"""
let x = 1⃣#"Hello 2\#(name)"#3
"""##,
ranges: [("1", "3")],
exhaustive: false
) { uri, positions in
[
CodeAction(
title: "Convert string literal to minimal number of \'#\'s",
kind: .refactorInline,
diagnostics: nil,
edit: WorkspaceEdit(
changes: [
uri: [
TextEdit(
range: positions["1"]..<positions["3"],
newText: ##"""
##"Hello \#(name)"##
"""##
)
]
]
),
command: nil
)
]
}
}
func testFormatRawStringLiteralDoesNotShowUpWhenInvokedFromInsideInterpolationSegment() async throws {
try await assertCodeActions(
##"""
let x = #"Hello \#(n1ame)"#
"""##
) { uri, positions in
[]
}
}
func testMigrateIfLetSyntax() async throws {
try await assertCodeActions(
##"""
1⃣if 2⃣let 3⃣foo = 4⃣foo {}5
"""##,
markers: ["1", "2", "3", "4"],
ranges: [("1", "4"), ("1", "5")]
) { uri, positions in
[
CodeAction(
title: "Migrate to shorthand 'if let' syntax",
kind: .refactorInline,
diagnostics: nil,
edit: WorkspaceEdit(
changes: [
uri: [
TextEdit(
range: positions["1"]..<positions["5"],
newText: "if let foo {}"
)
]
]
),
command: nil
)
]
}
}
func testMigrateIfLetSyntaxDoesNotShowUpWhenInvokedFromInsideTheBody() async throws {
try await assertCodeActions(
##"""
if let foo = foo 1⃣{
2⃣print(foo)
3⃣}4
"""##
) { uri, positions in
[]
}
}
func testOpaqueParameterToGeneric() async throws {
try await assertCodeActions(
##"""
1⃣func 2⃣someFunction(_ 3⃣input: some4⃣ Value) {}5
"""##,
markers: ["1", "2", "3", "4"],
ranges: [("1", "2"), ("1", "5")],
exhaustive: false
) { uri, positions in
[
CodeAction(
title: "Expand 'some' parameters to generic parameters",
kind: .refactorInline,
diagnostics: nil,
edit: WorkspaceEdit(
changes: [
uri: [
TextEdit(
range: positions["1"]..<positions["5"],
newText: "func someFunction<T1: Value>(_ input: T1) {}"
)
]
]
),
command: nil
)
]
}
}
func testOpaqueParameterToGenericIsNotShownFromTheBody() async throws {
try await assertCodeActions(
##"""
func someFunction(_ input: some Value) 1⃣{
2⃣print("x")
}3
"""##,
exhaustive: false
) { uri, positions in
[]
}
}
func testConvertJSONToCodable() async throws {
try await assertCodeActions(
##"""
1⃣{
2"id": 3⃣1,
"values": 4⃣["foo", "bar"]
}5
"""##,
ranges: [("1", "5")],
exhaustive: false
) { uri, positions in
[
CodeAction(
title: "Create Codable structs from JSON",
kind: .refactorInline,
diagnostics: nil,
edit: WorkspaceEdit(
changes: [
uri: [
TextEdit(
range: positions["1"]..<positions["5"],
newText: """
struct JSONValue: Codable {
var id: Double
var values: [String]
}
"""
)
]
]
),
command: nil
)
]
}
}
func testAddDocumentationRefactorNotAtStartOfFile() async throws {
try await assertCodeActions(
"""
struct Foo {
1⃣func 2⃣refactor(3⃣syntax: 4⃣Decl5⃣Syntax)6⃣ { }7
}
""",
ranges: [("1", "2"), ("1", "6"), ("1", "7")],
exhaustive: false
) { uri, positions in
[
CodeAction(
title: "Add documentation",
kind: .refactorInline,
diagnostics: nil,
edit: WorkspaceEdit(
changes: [
uri: [
TextEdit(
range: Range(positions["1"]),
newText: """
/// A description
/// - Parameter syntax:
\("")
"""
)
]
]
),
command: nil
)
]
}
}
func testAddDocumentationRefactorAtStartOfFile() async throws {
try await assertCodeActions(
"""
1⃣func 2⃣refactor(3⃣syntax: 4⃣Decl5⃣Syntax)6⃣ { }7
""",
ranges: [("1", "2"), ("1", "6"), ("1", "7")],
exhaustive: false
) { uri, positions in
[
CodeAction(
title: "Add documentation",
kind: .refactorInline,
diagnostics: nil,
edit: WorkspaceEdit(
changes: [
uri: [
TextEdit(
range: Range(positions["1"]),
newText: """
/// A description
/// - Parameter syntax:
\("")
"""
)
]
]
),
command: nil
)
]
}
}
func testAddDocumentationDoesNotShowUpIfItIsNotOnItsOwnLine() async throws {
try await assertCodeActions(
"""
var x = 1; var 1⃣y = 2
"""
) { uri, positions in
[]
}
}
func testConvertStringConcatenationToStringInterpolation() async throws {
try await assertCodeActions(
#"""
0
1⃣/*leading*/ #"["# + 2⃣key + ": \(3d) " + 4⃣value + ##"]"## /*trailing*/5
"""#,
markers: ["1", "2", "3", "4", "5"],
ranges: [("1", "2"), ("3", "4"), ("1", "5")],
exhaustive: false
) { uri, positions in
[
CodeAction(
title: "Convert String Concatenation to String Interpolation",
kind: .refactorInline,
edit: WorkspaceEdit(
changes: [
uri: [
TextEdit(
range: positions["0"]..<positions["5"],
newText: ###"""
/*leading*/ ##"[\##(key): \##(d) \##(value)]"## /*trailing*/
"""###
)
]
]
)
)
]
}
}
func testConvertStringConcatenationToStringInterpolationWithInterspersingAndMultilineComments() async throws {
try await assertCodeActions(
"""
1"hello" + /*self.leading1*/ /**self.leading2*/ self //self.trailing1
///concat.leading1
2⃣+/*concat.trailing1
line 1
line 2
line 3
*/ value3
""",
exhaustive: false
) { uri, positions in
[
CodeAction(
title: "Convert String Concatenation to String Interpolation",
kind: .refactorInline,
edit: WorkspaceEdit(
changes: [
uri: [
TextEdit(
range: positions["1"]..<positions["3"],
newText: #"""
"hello\(/*self.leading1*/ /**self.leading2*/ self /*self.trailing1*/ /**concat.leading1*/)\(/*concat.trailing1 line 1 line 2 line 3 */ value)"
"""#
)
]
]
)
)
]
}
}
func testConvertStringConcatenationToStringInterpolationNotShowUpMissingExpr() async throws {
try await assertCodeActions(
###"""
1"Hello" + 2
"""###,
ranges: [("1", "2")]
) { uri, positions in
[]
}
}
func testConvertStringConcatenationToStringInterpolationNotShowUpOnlyOneStringLiteral() async throws {
try await assertCodeActions(
###"""
1"[\(2key): \(3d) 4\(value)]"5
"""###,
ranges: [("1", "2"), ("3", "4"), ("1", "5")]
) { uri, positions in
[]
}
}
func testConvertStringConcatenationToStringInterpolationNotShowUpMultilineStringLiteral() async throws {
try await assertCodeActions(
###"""
"""
1Hello
""" + 2" World"
"""###
) { uri, positions in
[]
}
}
func testRemoveUnusedImports() async throws {
let project = try await SwiftPMTestProject(
files: [
"LibA/LibA.swift": "",
"LibB/LibB.swift": "",
"Test/Test.swift": """
// Some file header
// over multiple lines
1⃣import LibA // LibA implements A
2⃣import Foundation3
// LibB implements B
import LibB4
#warning("Removing imports should work despite warning")
5⃣func test(x: Date) {}
""",
],
manifest: """
let package = Package(
name: "MyLibrary",
targets: [
.target(name: "LibA"),
.target(name: "LibB"),
.target(
name: "Test",
dependencies: ["LibA", "LibB"],
swiftSettings: [.enableUpcomingFeature("MemberImportVisibility")]
)
]
)
""",
capabilities: clientCapabilitiesWithCodeActionSupport,
enableBackgroundIndexing: true
)
let (uri, positions) = try project.openDocument("Test.swift")
let functionResult = try await project.testClient.send(
CodeActionRequest(
range: Range(positions["5"]),
context: CodeActionContext(),
textDocument: TextDocumentIdentifier(uri)
)
)
XCTAssertFalse(
try XCTUnwrap(functionResult?.codeActions).contains(where: {
$0.command?.command == RemoveUnusedImportsCommand.identifier
})
)
let importResult = try await project.testClient.send(
CodeActionRequest(
range: Range(positions["1"]),
context: CodeActionContext(),
textDocument: TextDocumentIdentifier(uri)
)
)
let removeUnusedImportsCommand = try XCTUnwrap(
importResult?.codeActions?.first(where: {
$0.command?.command == "remove.unused.imports.command"
})?.command
)
project.testClient.handleSingleRequest { (request: ApplyEditRequest) -> ApplyEditResponse in
XCTAssertEqual(
request.edit.changes,
[
uri: [
TextEdit(range: positions["1"]..<positions["2"], newText: ""),
TextEdit(range: positions["3"]..<positions["4"], newText: ""),
]
]
)
return ApplyEditResponse(applied: true, failureReason: nil)
}
_ = try await project.testClient.send(
ExecuteCommandRequest(
command: removeUnusedImportsCommand.command,
arguments: removeUnusedImportsCommand.arguments
)
)
}
func testRemoveUnusedImportsNotAvailableIfSourceFileHasError() async throws {
let project = try await SwiftPMTestProject(
files: [
"Test.swift": """
1⃣import Foundation
#error("Some error")
"""
],
manifest: """
let package = Package(
name: "MyLibrary",
targets: [
.target(name: "Test", swiftSettings: [.enableUpcomingFeature("MemberImportVisibility")]c)
]
)
""",
capabilities: clientCapabilitiesWithCodeActionSupport,
enableBackgroundIndexing: true
)
let (uri, positions) = try project.openDocument("Test.swift")
let result = try await project.testClient.send(
CodeActionRequest(
range: Range(positions["1"]),
context: CodeActionContext(),
textDocument: TextDocumentIdentifier(uri)
)
)
XCTAssertFalse(
try XCTUnwrap(result?.codeActions).contains(where: {
$0.command?.command == RemoveUnusedImportsCommand.identifier
})
)
}
/// Retrieves the code action at a set of markers and asserts that it matches a list of expected code actions.
///
/// - Parameters:
/// - markedText: The source file input to get the code actions for.
/// - markers: The list of markers to retrieve code actions at. If `nil` code actions will be retrieved for all
/// markers in `markedText`
/// - ranges: If specified, code actions are also requested for selection ranges between these markers.
/// - exhaustive: Whether `expected` is expected to be a subset of the returned code actions or whether it is
/// expected to exhaustively match all code actions.
/// - expected: A closure that returns the list of expected code actions, given the URI of the test document and the
/// marker positions within.
private func assertCodeActions(
_ markedText: String,
markers: [String]? = nil,
ranges: [(String, String)] = [],
exhaustive: Bool = true,
expected: (_ uri: DocumentURI, _ positions: DocumentPositions) -> [CodeAction],
testName: String = #function,
file: StaticString = #filePath,
line: UInt = #line
) async throws {
let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport)
let uri = DocumentURI(for: .swift, testName: testName)
let positions = testClient.openDocument(markedText, uri: uri)
var ranges = ranges
if let markers {
ranges += markers.map { ($0, $0) }
} else {
ranges += extractMarkers(markedText).markers.map(\.key).map { ($0, $0) }
}
for (startMarker, endMarker) in ranges {
let result = try await testClient.send(
CodeActionRequest(
range: positions[startMarker]..<positions[endMarker],
context: .init(),
textDocument: TextDocumentIdentifier(uri)
)
)
let codeActions = try XCTUnwrap(result?.codeActions, file: file, line: line)
if exhaustive {
XCTAssertEqual(
codeActions,
expected(uri, positions),
"Found unexpected code actions at range \(startMarker)-\(endMarker)",
file: file,
line: line
)
} else {
XCTAssert(
codeActions.contains(expected(uri, positions)),
"""
Code actions did not contain expected at range \(startMarker)-\(endMarker):
\(codeActions)
""",
file: file,
line: line
)
}
}
}
}
private extension CodeActionRequestResponse {
var codeActions: [CodeAction]? {
guard case .codeActions(let actions) = self else {
return nil
}
return actions
}
}