diff --git a/Sources/LanguageServerProtocol/ApplyEdit.swift b/Sources/LanguageServerProtocol/ApplyEdit.swift new file mode 100644 index 000000000..df37360bb --- /dev/null +++ b/Sources/LanguageServerProtocol/ApplyEdit.swift @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// Request from the server to the client to modify resources on the client side. +/// +/// - Parameters: +/// - label: An optional label of the workspace edit. +/// - edit: The edits to apply. +public struct ApplyEditRequest: RequestType { + public static let method: String = "workspace/applyEdit" + public typealias Response = ApplyEditResponse? + + /// An optional label of the workspace edit. + /// Used by the client's user interface for things such as + /// the stack to undo the workspace edit. + public var label: String? + + /// The edits to apply. + public var edit: WorkspaceEdit + + public init(label: String? = nil, edit: WorkspaceEdit) { + self.label = label + self.edit = edit + } +} + +public struct ApplyEditResponse: Codable, Hashable, ResponseType { + /// Indicates whether the edit was applied or not. + public var applied: Bool + + /// An optional textual description for why the edit was not applied. + public var failureReason: String? + + public init(applied: Bool, failureReason: String?) { + self.applied = applied + self.failureReason = failureReason + } +} diff --git a/Sources/LanguageServerProtocol/CodeAction.swift b/Sources/LanguageServerProtocol/CodeAction.swift index bf7e15e05..93d34209e 100644 --- a/Sources/LanguageServerProtocol/CodeAction.swift +++ b/Sources/LanguageServerProtocol/CodeAction.swift @@ -11,6 +11,7 @@ //===----------------------------------------------------------------------===// import Foundation +import SKSupport public typealias CodeActionProviderCompletion = (([CodeAction]) -> Void) public typealias CodeActionProvider = ((CodeActionRequest, @escaping CodeActionProviderCompletion) -> Void) @@ -123,15 +124,19 @@ public struct CodeAction: Codable, Equatable, ResponseType { /// The diagnostics that this code action resolves, if applicable. public var diagnostics: [Diagnostic]? + /// The workspace edit this code action performs. + public var edit: WorkspaceEdit? + /// A command this code action executes. /// If a code action provides an edit and a command, /// first the edit is executed and then the command. public var command: Command? - public init(title: String, kind: CodeActionKind? = nil, diagnostics: [Diagnostic]? = nil, command: Command? = nil) { + public init(title: String, kind: CodeActionKind? = nil, diagnostics: [Diagnostic]? = nil, edit: WorkspaceEdit? = nil, command: Command? = nil) { self.title = title self.kind = kind self.diagnostics = diagnostics + self.edit = edit self.command = command } } diff --git a/Sources/LanguageServerProtocol/ExecuteCommand.swift b/Sources/LanguageServerProtocol/ExecuteCommand.swift new file mode 100644 index 000000000..53f8e6dd1 --- /dev/null +++ b/Sources/LanguageServerProtocol/ExecuteCommand.swift @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// +// 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 Foundation + +/// Request sent from the client to to trigger command execution on the server. +/// +/// The execution of this request can be the result of a request that returns a command, +/// such as CodeActionsRequest and CodeLensRequest. In most cases, the server creates a WorkspaceEdit +/// structure and applies the changes to the workspace using the ApplyEditRequest. +/// +/// Servers that provide command execution should set the `executeCommand` server capability. +/// +/// - Parameters: +/// - command: The command to be executed. +/// - arguments: The arguments to use when executing the command. +public struct ExecuteCommandRequest: RequestType { + public static let method: String = "workspace/executeCommand" + + // Note: The LSP type for this response is `Any?`. + public typealias Response = LSPAny? + + /// The command to be executed. + public var command: String + + /// Arguments that the command should be invoked with. + public var arguments: [LSPAny]? + + public init(command: String, arguments: [LSPAny]?) { + self.command = command + self.arguments = arguments + } +} diff --git a/Sources/LanguageServerProtocol/LSPAny.swift b/Sources/LanguageServerProtocol/LSPAny.swift index 4951a08ab..a70b5e8e7 100644 --- a/Sources/LanguageServerProtocol/LSPAny.swift +++ b/Sources/LanguageServerProtocol/LSPAny.swift @@ -68,6 +68,8 @@ extension LSPAny: Encodable { } } +extension LSPAny: ResponseType {} + extension LSPAny: ExpressibleByNilLiteral { public init(nilLiteral _: ()) { self = .null diff --git a/Sources/LanguageServerProtocol/Messages.swift b/Sources/LanguageServerProtocol/Messages.swift index 1f87e191b..67dc7d136 100644 --- a/Sources/LanguageServerProtocol/Messages.swift +++ b/Sources/LanguageServerProtocol/Messages.swift @@ -36,6 +36,7 @@ public let builtinRequests: [_RequestType.Type] = [ DocumentColorRequest.self, ColorPresentationRequest.self, CodeActionRequest.self, + ExecuteCommandRequest.self, // MARK: LSP Extension Requests diff --git a/Sources/LanguageServerProtocol/ServerCapabilities.swift b/Sources/LanguageServerProtocol/ServerCapabilities.swift index 0695e6a58..1a4fe96ce 100644 --- a/Sources/LanguageServerProtocol/ServerCapabilities.swift +++ b/Sources/LanguageServerProtocol/ServerCapabilities.swift @@ -56,7 +56,10 @@ public struct ServerCapabilities: Codable, Hashable { /// The server provides workspace symbol support. public var workspaceSymbolProvider: Bool? - + + /// Whether the server provides "workspace/executeCommand". + public var executeCommandProvider: ExecuteCommandOptions? + // TODO: fill-in the rest. public init( @@ -74,7 +77,8 @@ public struct ServerCapabilities: Codable, Hashable { documentSymbolProvider: Bool? = nil, colorProvider: Bool? = nil, codeActionProvider: CodeActionServerCapabilities? = nil, - workspaceSymbolProvider: Bool? = nil + workspaceSymbolProvider: Bool? = nil, + executeCommandProvider: ExecuteCommandOptions? = nil ) { self.textDocumentSync = textDocumentSync @@ -92,6 +96,7 @@ public struct ServerCapabilities: Codable, Hashable { self.colorProvider = colorProvider self.codeActionProvider = codeActionProvider self.workspaceSymbolProvider = workspaceSymbolProvider + self.executeCommandProvider = executeCommandProvider } public init(from decoder: Decoder) throws { @@ -105,6 +110,7 @@ public struct ServerCapabilities: Codable, Hashable { self.colorProvider = try container.decodeIfPresent(Bool.self, forKey: .colorProvider) self.codeActionProvider = try container.decodeIfPresent(CodeActionServerCapabilities.self, forKey: .codeActionProvider) self.workspaceSymbolProvider = try container.decodeIfPresent(Bool.self, forKey: .workspaceSymbolProvider) + self.executeCommandProvider = try container.decodeIfPresent(ExecuteCommandOptions.self, forKey: .executeCommandProvider) if let textDocumentSync = try? container.decode(TextDocumentSyncOptions.self, forKey: .textDocumentSync) { self.textDocumentSync = textDocumentSync @@ -246,3 +252,13 @@ public struct CodeActionOptions: Codable, Hashable { self.codeActionKinds = codeActionKinds } } + +public struct ExecuteCommandOptions: Codable, Hashable { + + /// The commands to be executed on this server. + public var commands: [String] + + public init(commands: [String]) { + self.commands = commands + } +} diff --git a/Sources/LanguageServerProtocol/TextDocumentIdentifier.swift b/Sources/LanguageServerProtocol/TextDocumentIdentifier.swift index b815dcc5f..1b54f1a19 100644 --- a/Sources/LanguageServerProtocol/TextDocumentIdentifier.swift +++ b/Sources/LanguageServerProtocol/TextDocumentIdentifier.swift @@ -28,7 +28,7 @@ public struct TextDocumentIdentifier: Hashable { // Encode using the key "uri" to match LSP. extension TextDocumentIdentifier: Codable { - private enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case url = "uri" } } diff --git a/Sources/LanguageServerProtocol/WorkspaceEdit.swift b/Sources/LanguageServerProtocol/WorkspaceEdit.swift new file mode 100644 index 000000000..1a49f1cfc --- /dev/null +++ b/Sources/LanguageServerProtocol/WorkspaceEdit.swift @@ -0,0 +1,22 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// A workspace edit represents changes to many resources managed in the workspace. +public struct WorkspaceEdit: Codable, Hashable, ResponseType { + + /// The edits to be applied to existing resources. + public var changes: [URL: [TextEdit]]? + + public init(changes: [URL: [TextEdit]]?) { + self.changes = changes + } +} diff --git a/Sources/SourceKit/SourceKitLSPCommandMetadata.swift b/Sources/SourceKit/SourceKitLSPCommandMetadata.swift new file mode 100644 index 000000000..2401506de --- /dev/null +++ b/Sources/SourceKit/SourceKitLSPCommandMetadata.swift @@ -0,0 +1,95 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2018 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 Foundation +import SKSupport + +/// Represents metadata that SourceKit-LSP injects at every command returned by code actions. +/// The ExecuteCommand is not a TextDocumentRequest, so metadata is injected to allow SourceKit-LSP +/// to determine where a command should be executed. +public struct SourceKitLSPCommandMetadata: Codable, Hashable { + + public var sourcekitlsp_textDocument: TextDocumentIdentifier + + public init?(fromLSPDictionary dictionary: [String: LSPAny]) { + let textDocumentKey = CodingKeys.sourcekitlsp_textDocument.stringValue + let urlKey = TextDocumentIdentifier.CodingKeys.url.stringValue + guard case .dictionary(let textDocumentDict)? = dictionary[textDocumentKey], + case .string(let urlString)? = textDocumentDict[urlKey], + let url = URL(string: urlString) else + { + return nil + } + let textDocument = TextDocumentIdentifier(url) + self.init(textDocument: textDocument) + } + + public init(textDocument: TextDocumentIdentifier) { + self.sourcekitlsp_textDocument = textDocument + } + + public func encodeToLSPAny() -> LSPAny { + let textDocumentArgument = LSPAny.dictionary( + [TextDocumentIdentifier.CodingKeys.url.stringValue: .string(sourcekitlsp_textDocument.url.absoluteString)] + ) + return .dictionary([CodingKeys.sourcekitlsp_textDocument.stringValue: textDocumentArgument]) + } +} + +extension CodeActionRequest { + public func injectMetadata(toResponse response: CodeActionRequestResponse?) -> CodeActionRequestResponse? { + let metadata = SourceKitLSPCommandMetadata(textDocument: textDocument) + let metadataArgument = metadata.encodeToLSPAny() + switch response { + case .codeActions(var codeActions)?: + for i in 0..( @@ -313,7 +314,10 @@ extension SourceKitServer { codeActionOptions: CodeActionOptions(codeActionKinds: nil), supportsCodeActions: false // TODO: Turn it on after a provider is implemented. ), - workspaceSymbolProvider: true + workspaceSymbolProvider: true, + executeCommandProvider: ExecuteCommandOptions( + commands: [] // FIXME: Clangd commands? + ) ))) } @@ -459,7 +463,25 @@ extension SourceKitServer { } func codeAction(_ req: Request, workspace: Workspace) { - toolchainTextDocumentRequest(req, workspace: workspace, fallback: nil) + toolchainTextDocumentRequest(req, workspace: workspace, resultTransformer: { result in + switch result { + case .success(let reply): + return .success(req.params.injectMetadata(toResponse: reply)) + default: + return result + } + }, fallback: nil) + } + + func executeCommand(_ req: Request, workspace: Workspace) { + guard let url = req.params.textDocument?.url else { + log("attempted to perform executeCommand request without an url!", level: .error) + req.reply(nil) + return + } + var params = req.params + params.arguments = params.argumentsWithoutSourceKitMetadata + sendRequest(req, params: params, url: url, workspace: workspace, fallback: nil) } func definition(_ req: Request, workspace: Workspace) { @@ -628,16 +650,28 @@ extension SourceKitServer { func toolchainTextDocumentRequest( _ req: Request, workspace: Workspace, + resultTransformer: ((LSPResult) -> LSPResult)? = nil, fallback: @autoclosure () -> PositionRequest.Response) where PositionRequest: TextDocumentRequest { - guard let service = workspace.documentService[req.params.textDocument.url] else { + sendRequest(req, params: req.params, url: req.params.textDocument.url, workspace: workspace, resultTransformer: resultTransformer, fallback: fallback()) + } + + func sendRequest( + _ req: Request, + params: PositionRequest, + url: URL, + workspace: Workspace, + resultTransformer: ((LSPResult) -> LSPResult)? = nil, + fallback: @autoclosure () -> PositionRequest.Response) + { + guard let service = workspace.documentService[url] else { req.reply(fallback()) return } - let id = service.send(req.params, queue: DispatchQueue.global()) { result in - req.reply(result) + let id = service.send(params, queue: DispatchQueue.global()) { result in + req.reply(resultTransformer?(result) ?? result) } req.cancellationToken.addCancellationHandler { [weak service] in service?.send(CancelRequest(id: id)) diff --git a/Sources/SourceKit/sourcekitd/SwiftLanguageServer.swift b/Sources/SourceKit/sourcekitd/SwiftLanguageServer.swift index 2eee02cfe..f6d8eeec3 100644 --- a/Sources/SourceKit/sourcekitd/SwiftLanguageServer.swift +++ b/Sources/SourceKit/sourcekitd/SwiftLanguageServer.swift @@ -73,6 +73,7 @@ public final class SwiftLanguageServer: LanguageServer { _register(SwiftLanguageServer.documentColor) _register(SwiftLanguageServer.colorPresentation) _register(SwiftLanguageServer.codeAction) + _register(SwiftLanguageServer.executeCommand) } func publishDiagnostics( @@ -167,7 +168,9 @@ extension SwiftLanguageServer { codeActionProvider: CodeActionServerCapabilities( clientCapabilities: request.params.capabilities.textDocument?.codeAction, codeActionOptions: CodeActionOptions(codeActionKinds: nil), - supportsCodeActions: false) // TODO: Turn it on after a provider is implemented. + supportsCodeActions: false), // TODO: Turn it on after a provider is implemented. + executeCommandProvider: ExecuteCommandOptions( + commands: []) ))) } @@ -921,6 +924,34 @@ extension SwiftLanguageServer { } } } + + func executeCommand(_ req: Request) { + //TODO: Implement commands. + return req.reply(nil) + } + + func applyEdit(label: String, edit: WorkspaceEdit) { + let req = ApplyEditRequest(label: label, edit: edit) + let handle = client.send(req, queue: queue) { reply in + switch reply { + case .success(let response): + if response?.applied == false { + let reason: String + if let failureReason = response?.failureReason { + reason = " reason: \(failureReason)" + } else { + reason = "" + } + log("client refused to apply edit for \(label)!\(reason)", level: .warning) + } + case .failure(let error): + log("applyEdit failed: \(error)", level: .warning) + } + } + + // FIXME: cancellation + _ = handle + } } extension DocumentSnapshot { diff --git a/Tests/SourceKitTests/CodeActionTests.swift b/Tests/SourceKitTests/CodeActionTests.swift index e29c66bce..92cb2b3ed 100644 --- a/Tests/SourceKitTests/CodeActionTests.swift +++ b/Tests/SourceKitTests/CodeActionTests.swift @@ -105,6 +105,42 @@ final class CodeActionTests: XCTestCase { XCTAssertEqual(response, .codeActions([unspecifiedAction])) } + func testCodeActionResponseCommandMetadataInjection() { + let url = URL(fileURLWithPath: "/a.swift") + let textDocument = TextDocumentIdentifier(url) + let expectedMetadata: LSPAny = { + 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).. [XCTestCaseEntry] { testCase(CodeActionTests.__allTests__CodeActionTests), testCase(DocumentColorTests.__allTests__DocumentColorTests), testCase(DocumentSymbolTest.__allTests__DocumentSymbolTest), + testCase(ExecuteCommandTests.__allTests__ExecuteCommandTests), testCase(FoldingRangeTests.__allTests__FoldingRangeTests), testCase(ImplementationTests.__allTests__ImplementationTests), testCase(LocalClangTests.__allTests__LocalClangTests),