diff --git a/Sources/LanguageServerProtocol/Requests/CodeActionRequest.swift b/Sources/LanguageServerProtocol/Requests/CodeActionRequest.swift index 94704153..9335f04f 100644 --- a/Sources/LanguageServerProtocol/Requests/CodeActionRequest.swift +++ b/Sources/LanguageServerProtocol/Requests/CodeActionRequest.swift @@ -105,7 +105,7 @@ public struct CodeActionContext: Codable, Hashable { } } -public struct CodeAction: Codable, Equatable { +public struct CodeAction: Codable, Hashable { /// A short, human-readable, title for this code action. public var title: String diff --git a/Sources/LanguageServerProtocol/SupportTypes/ClientCapabilities.swift b/Sources/LanguageServerProtocol/SupportTypes/ClientCapabilities.swift index 3d01b9c1..0fb96fef 100644 --- a/Sources/LanguageServerProtocol/SupportTypes/ClientCapabilities.swift +++ b/Sources/LanguageServerProtocol/SupportTypes/ClientCapabilities.swift @@ -356,8 +356,14 @@ public struct TextDocumentClientCapabilities: Hashable, Codable { /// Whether the client accepts diagnostics with related information. public var relatedInformation: Bool? = nil - public init(relatedInformation: Bool? = nil) { + /// Requests that SourceKit-LSP send `Diagnostic.codeActions`. + /// **LSP Extension from clangd**. + public var codeActionsInline: Bool? = nil + + public init(relatedInformation: Bool? = nil, + codeActionsInline: Bool? = nil) { self.relatedInformation = relatedInformation + self.codeActionsInline = codeActionsInline } } diff --git a/Sources/LanguageServerProtocol/SupportTypes/Diagnostic.swift b/Sources/LanguageServerProtocol/SupportTypes/Diagnostic.swift index 7df17ea0..12b595f1 100644 --- a/Sources/LanguageServerProtocol/SupportTypes/Diagnostic.swift +++ b/Sources/LanguageServerProtocol/SupportTypes/Diagnostic.swift @@ -47,13 +47,18 @@ public struct Diagnostic: Codable, Hashable { /// Related diagnostic notes. public var relatedInformation: [DiagnosticRelatedInformation]? + /// All the code actions that address this diagnostic. + /// **LSP Extension from clangd**. + public var codeActions: [CodeAction]? + public init( range: Range, severity: DiagnosticSeverity?, code: DiagnosticCode? = nil, source: String?, message: String, - relatedInformation: [DiagnosticRelatedInformation]? = nil) + relatedInformation: [DiagnosticRelatedInformation]? = nil, + codeActions: [CodeAction]? = nil) { self._range = CustomCodable(wrappedValue: range) self.severity = severity @@ -61,6 +66,7 @@ public struct Diagnostic: Codable, Hashable { self.source = source self.message = message self.relatedInformation = relatedInformation + self.codeActions = codeActions } } diff --git a/Sources/SourceKit/sourcekitd/Diagnostic.swift b/Sources/SourceKit/sourcekitd/Diagnostic.swift index db2d1fbf..6ec0cc70 100644 --- a/Sources/SourceKit/sourcekitd/Diagnostic.swift +++ b/Sources/SourceKit/sourcekitd/Diagnostic.swift @@ -15,6 +15,29 @@ import LSPLogging import SKSupport import sourcekitd +extension CodeAction { + init?(fixit: SKResponseDictionary, in snapshot: DocumentSnapshot) { + let keys = fixit.sourcekitd.keys + + guard let utf8Offset: Int = fixit[keys.offset], + let length: Int = fixit[keys.length], + let replacement: String = fixit[keys.sourcetext] else { + return nil + } + guard let position = snapshot.positionOf(utf8Offset: utf8Offset), + let endPosition = snapshot.positionOf(utf8Offset: utf8Offset + length) else { + return nil + } + let textEdit = TextEdit(range: position.. Bool in + if let codeAction = CodeAction(fixit: skfixit, in: snapshot) { + fixits?.append(codeAction) + } + return true + } + } + var notes: [DiagnosticRelatedInformation]? = nil if let sknotes: SKResponseArray = diag[keys.diagnostics] { notes = [] @@ -71,7 +105,8 @@ extension Diagnostic { code: nil, source: "sourcekitd", message: message, - relatedInformation: notes) + relatedInformation: notes, + codeActions: fixits) } } diff --git a/Sources/SourceKit/sourcekitd/SwiftLanguageServer.swift b/Sources/SourceKit/sourcekitd/SwiftLanguageServer.swift index 10350dc3..0df6c9d0 100644 --- a/Sources/SourceKit/sourcekitd/SwiftLanguageServer.swift +++ b/Sources/SourceKit/sourcekitd/SwiftLanguageServer.swift @@ -19,6 +19,24 @@ import SKSupport import sourcekitd import TSCBasic +fileprivate extension Range { + /// Checks if this range overlaps with the other range, counting an overlap with an empty range as a valid overlap. + /// The standard library implementation makes `1..<3.overlaps(2..<2)` return false because the second range is empty and thus the overlap is also empty. + /// This implementation over overlap considers such an inclusion of an empty range as a valid overlap. + func overlapsIncludingEmptyRanges(other: Range) -> Bool { + switch (self.isEmpty, other.isEmpty) { + case (true, true): + return self.lowerBound == other.lowerBound + case (true, false): + return other.contains(self.lowerBound) + case (false, true): + return self.contains(other.lowerBound) + case (false, false): + return self.overlaps(other) + } + } +} + public final class SwiftLanguageServer: ToolchainLanguageServer { /// The server's request queue, used to serialize requests and responses to `sourcekitd`. @@ -155,7 +173,7 @@ extension SwiftLanguageServer { documentSymbolProvider: true, codeActionProvider: .value(CodeActionServerCapabilities( clientCapabilities: initialize.capabilities.textDocument?.codeAction, - codeActionOptions: CodeActionOptions(codeActionKinds: nil), + codeActionOptions: CodeActionOptions(codeActionKinds: [.quickFix, .refactor]), supportsCodeActions: true)), colorProvider: .bool(true), foldingRangeProvider: .bool(true), @@ -981,9 +999,8 @@ extension SwiftLanguageServer { public func codeAction(_ req: Request) { let providersAndKinds: [(provider: CodeActionProvider, kind: CodeActionKind)] = [ - (retrieveRefactorCodeActions, .refactor) - //TODO: Implement the providers. - //(retrieveQuickFixCodeActions, .quickFix) + (retrieveRefactorCodeActions, .refactor), + (retrieveQuickFixCodeActions, .quickFix) ] let wantedActionKinds = req.params.context.only let providers = providersAndKinds.filter { wantedActionKinds?.contains($0.1) != false } @@ -1061,6 +1078,51 @@ extension SwiftLanguageServer { } } + func retrieveQuickFixCodeActions(_ params: CodeActionRequest, completion: @escaping CodeActionProviderCompletion) { + guard let cachedDiags = currentDiagnostics[params.textDocument.uri] else { + completion(.success([])) + return + } + + let codeActions = cachedDiags.flatMap { (cachedDiag) -> [CodeAction] in + let diag = cachedDiag.diagnostic + + guard let codeActions = diag.codeActions else { + // The diagnostic doesn't have fix-its. Don't return anything. + return [] + } + + // Check if the diagnostic overlaps with the selected range. + guard params.range.overlapsIncludingEmptyRanges(other: diag.range) else { + return [] + } + + // Check if the set of diagnostics provided by the request contains this diagnostic. + // For this, only compare the 'basic' properties of the diagnostics, excluding related information and code actions since + // code actions are only defined in an LSP extension and might not be sent back to us. + guard params.context.diagnostics.contains(where: { (contextDiag) -> Bool in + return contextDiag.range == diag.range && + contextDiag.severity == diag.severity && + contextDiag.code == diag.code && + contextDiag.source == diag.source && + contextDiag.message == diag.message + }) else { + return [] + } + + // Flip the attachment of diagnostic to code action instead of the code action being attached to the diagnostic + return codeActions.map({ + var codeAction = $0 + var diagnosticWithoutCodeActions = diag + diagnosticWithoutCodeActions.codeActions = nil + codeAction.diagnostics = [diagnosticWithoutCodeActions] + return codeAction + }) + } + + completion(.success(codeActions)) + } + public func executeCommand(_ req: Request) { let params = req.params //TODO: If there's support for several types of commands, we might need to structure this similarly to the code actions request. diff --git a/Sources/SourceKit/sourcekitd/SwiftSourceKitFramework.swift b/Sources/SourceKit/sourcekitd/SwiftSourceKitFramework.swift index 2c1288cd..1626d5f7 100644 --- a/Sources/SourceKit/sourcekitd/SwiftSourceKitFramework.swift +++ b/Sources/SourceKit/sourcekitd/SwiftSourceKitFramework.swift @@ -215,6 +215,7 @@ struct sourcekitd_keys { let name: sourcekitd_uid_t let kind: sourcekitd_uid_t let notification: sourcekitd_uid_t + let fixits: sourcekitd_uid_t let diagnostics: sourcekitd_uid_t let diagnostic_stage: sourcekitd_uid_t let severity: sourcekitd_uid_t @@ -263,6 +264,7 @@ struct sourcekitd_keys { name = api.uid_get_from_cstr("key.name")! kind = api.uid_get_from_cstr("key.kind")! notification = api.uid_get_from_cstr("key.notification")! + fixits = api.uid_get_from_cstr("key.fixits")! diagnostics = api.uid_get_from_cstr("key.diagnostics")! diagnostic_stage = api.uid_get_from_cstr("key.diagnostic_stage")! severity = api.uid_get_from_cstr("key.severity")! diff --git a/Tests/SourceKitTests/LocalSwiftTests.swift b/Tests/SourceKitTests/LocalSwiftTests.swift index bcd5cb86..95e26760 100644 --- a/Tests/SourceKitTests/LocalSwiftTests.swift +++ b/Tests/SourceKitTests/LocalSwiftTests.swift @@ -41,7 +41,12 @@ final class LocalSwiftTests: XCTestCase { rootPath: nil, rootURI: nil, initializationOptions: nil, - capabilities: ClientCapabilities(workspace: nil, textDocument: nil), + capabilities: ClientCapabilities(workspace: nil, + textDocument: TextDocumentClientCapabilities( + codeAction: .init( + codeActionLiteralSupport: .init( + codeActionKind: .init(valueSet: [.quickFix]) + )))), trace: .off, workspaceFolders: nil)) } @@ -413,6 +418,89 @@ final class LocalSwiftTests: XCTestCase { }) } + func testFixitsAreIncludedInPublishDiagnostics() { + let url = URL(fileURLWithPath: "/a.swift") + let uri = DocumentURI(url) + + sk.allowUnexpectedNotification = false + + sk.sendNoteSync(DidOpenTextDocumentNotification(textDocument: TextDocumentItem( + uri: uri, language: .swift, version: 12, + text: """ + func foo() { + let a = 2 + } + """ + )), { (note: Notification) in + log("Received diagnostics for open - syntactic") + }, { (note: Notification) in + log("Received diagnostics for open - semantic") + XCTAssertEqual(note.params.diagnostics.count, 1) + let diag = note.params.diagnostics.first! + XCTAssertNotNil(diag.codeActions) + XCTAssertEqual(diag.codeActions!.count, 1) + let fixit = diag.codeActions!.first! + + // Expected Fix-it: Replace `let a` with `_` because it's never used + let expectedTextEdit = TextEdit(range: Position(line: 1, utf16index: 2)..) in + log("Received diagnostics for open - syntactic") + }, { (note: Notification) in + log("Received diagnostics for open - semantic") + XCTAssertEqual(note.params.diagnostics.count, 1) + diagnostic = note.params.diagnostics.first! + }) + + let request = CodeActionRequest( + range: Position(line: 1, utf16index: 0)..func foo(_ bar: Baz) diff --git a/Tests/SourceKitTests/XCTestManifests.swift b/Tests/SourceKitTests/XCTestManifests.swift index a95e9ec0..2fa08475 100644 --- a/Tests/SourceKitTests/XCTestManifests.swift +++ b/Tests/SourceKitTests/XCTestManifests.swift @@ -108,6 +108,8 @@ extension LocalSwiftTests { ("testEditing", testEditing), ("testEditingNonURL", testEditingNonURL), ("testEditorPlaceholderParsing", testEditorPlaceholderParsing), + ("testFixitsAreIncludedInPublishDiagnostics", testFixitsAreIncludedInPublishDiagnostics), + ("testFixitsAreReturnedFromCodeActions", testFixitsAreReturnedFromCodeActions), ("testHover", testHover), ("testHoverNameEscaping", testHoverNameEscaping), ("testSymbolInfo", testSymbolInfo),