Files
sourcekit-lsp/Tests/SourceKitLSPTests/CodeActionTests.swift
Doug Gregor a8b61a52d3 Introduce new refactoring code actions based on the Swift syntax tree.
This change includes a number of new refactoring code actions that
build on the syntax refactorings for the SwiftRefactor module of swift-syntax:

  * Add digit separators to an integer literal, e.g., `1000000` ->
    `1_000_000`.
  * Remove digit separators from an integer literal, e.g., 1_000_000 ->
    1000000.
  * Format a raw string literal, e.g., `"Hello \#(world)"` ->
    `##"Hello\#(world)"##`
  * Migrate to new if let syntax, e.g., `if let x = x { ... }` ->
    `if let x { ... }`
  * Replace opaque parameters with generic parameters, e.g.,
    `func f(p: some P)` --> `func f<T1: P>(p: T1)`.

This is generally easy to do, requiring one conformance to provide a name for the refactoring:

    extension AddSeparatorsToIntegerLiteral: SyntaxRefactoringCodeActionProvider {
      public static var title: String { "Add digit separators" }
    }
2024-04-16 23:00:20 -07:00

486 lines
16 KiB
Swift
Raw 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
//
//===----------------------------------------------------------------------===//
import LSPTestSupport
import LanguageServerProtocol
import SKTestSupport
import SourceKitLSP
import XCTest
final class CodeActionTests: XCTestCase {
typealias CodeActionCapabilities = TextDocumentClientCapabilities.CodeAction
typealias CodeActionLiteralSupport = CodeActionCapabilities.CodeActionLiteralSupport
typealias CodeActionKindCapabilities = CodeActionLiteralSupport.CodeActionKind
private func 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)
}
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: positions["1"]..<positions["1"],
context: .init(),
textDocument: TextDocumentIdentifier(uri)
)
let result = try await testClient.send(request)
XCTAssertEqual(result, .codeActions([]))
}
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
)
guard case .codeActions(let codeActions) = result else {
XCTFail("Expected code actions")
return
}
XCTAssertTrue(codeActions.contains(expectedCodeAction))
}
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
)
XCTAssertEqual(result, .codeActions([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 textDocument = TextDocumentIdentifier(uri)
let actionsRequest = CodeActionRequest(
range: positions["1"]..<positions["1"],
context: .init(diagnostics: diags.diagnostics),
textDocument: textDocument
)
let actionResult = try await testClient.send(actionsRequest)
guard case .codeActions(let codeActions) = actionResult else {
return XCTFail("Expected code actions, not commands as a response")
}
// Check that the Fix-It action contains snippets
guard let quickFixAction = codeActions.filter({ $0.kind == .quickFix }).spm_only else {
return XCTFail("Expected exactly one quick fix action")
}
guard let change = quickFixAction.edit?.changes?[uri]?.spm_only else {
return XCTFail("Expected exactly one change")
}
XCTAssertEqual(
change.newText.trimmingTrailingWhitespace(),
"""
func foo() {
}
"""
)
// Check that the refactor action contains snippets
guard let refactorAction = codeActions.filter({ $0.kind == .refactor }).spm_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.handleNextRequest { (request: ApplyEditRequest) -> ApplyEditResponse in
defer {
editReceived.fulfill()
}
guard let change = request.edit.changes?[uri]?.spm_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 testCodeActionForFixItsProducedBySwiftSyntax() async throws {
let project = try await MultiFileTestProject(files: [
"test.swift": "protocol 1⃣Multi 2⃣ident 3⃣{}",
"compile_commands.json": "[]",
])
let (uri, positions) = try project.openDocument("test.swift")
let report = try await project.testClient.send(
DocumentDiagnosticsRequest(textDocument: TextDocumentIdentifier(uri))
)
guard case .full(let fullReport) = report else {
XCTFail("Expected full diagnostics report")
return
}
XCTAssertEqual(fullReport.items.count, 1)
let diagnostic = try XCTUnwrap(fullReport.items.first)
let codeActions = try XCTUnwrap(diagnostic.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["2"]..<positions["3"], 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["2"]..<positions["3"], newText: ""),
]
]
)
),
]
XCTAssertEqual(expectedCodeActions, codeActions)
}
}