Skip to content

Commit a57dea9

Browse files
authored
Merge pull request #216 from ahoppen/fixits
Provide Fix-Its through codeActions and PublishDiagnostics
2 parents 74b2921 + 857b854 commit a57dea9

File tree

8 files changed

+210
-9
lines changed

8 files changed

+210
-9
lines changed

Sources/LanguageServerProtocol/Requests/CodeActionRequest.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ public struct CodeActionContext: Codable, Hashable {
105105
}
106106
}
107107

108-
public struct CodeAction: Codable, Equatable {
108+
public struct CodeAction: Codable, Hashable {
109109

110110
/// A short, human-readable, title for this code action.
111111
public var title: String

Sources/LanguageServerProtocol/SupportTypes/ClientCapabilities.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,8 +356,14 @@ public struct TextDocumentClientCapabilities: Hashable, Codable {
356356
/// Whether the client accepts diagnostics with related information.
357357
public var relatedInformation: Bool? = nil
358358

359-
public init(relatedInformation: Bool? = nil) {
359+
/// Requests that SourceKit-LSP send `Diagnostic.codeActions`.
360+
/// **LSP Extension from clangd**.
361+
public var codeActionsInline: Bool? = nil
362+
363+
public init(relatedInformation: Bool? = nil,
364+
codeActionsInline: Bool? = nil) {
360365
self.relatedInformation = relatedInformation
366+
self.codeActionsInline = codeActionsInline
361367
}
362368
}
363369

Sources/LanguageServerProtocol/SupportTypes/Diagnostic.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,20 +47,26 @@ public struct Diagnostic: Codable, Hashable {
4747
/// Related diagnostic notes.
4848
public var relatedInformation: [DiagnosticRelatedInformation]?
4949

50+
/// All the code actions that address this diagnostic.
51+
/// **LSP Extension from clangd**.
52+
public var codeActions: [CodeAction]?
53+
5054
public init(
5155
range: Range<Position>,
5256
severity: DiagnosticSeverity?,
5357
code: DiagnosticCode? = nil,
5458
source: String?,
5559
message: String,
56-
relatedInformation: [DiagnosticRelatedInformation]? = nil)
60+
relatedInformation: [DiagnosticRelatedInformation]? = nil,
61+
codeActions: [CodeAction]? = nil)
5762
{
5863
self._range = CustomCodable<PositionRange>(wrappedValue: range)
5964
self.severity = severity
6065
self.code = code
6166
self.source = source
6267
self.message = message
6368
self.relatedInformation = relatedInformation
69+
self.codeActions = codeActions
6470
}
6571
}
6672

Sources/SourceKit/sourcekitd/Diagnostic.swift

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,29 @@ import LSPLogging
1515
import SKSupport
1616
import sourcekitd
1717

18+
extension CodeAction {
19+
init?(fixit: SKResponseDictionary, in snapshot: DocumentSnapshot) {
20+
let keys = fixit.sourcekitd.keys
21+
22+
guard let utf8Offset: Int = fixit[keys.offset],
23+
let length: Int = fixit[keys.length],
24+
let replacement: String = fixit[keys.sourcetext] else {
25+
return nil
26+
}
27+
guard let position = snapshot.positionOf(utf8Offset: utf8Offset),
28+
let endPosition = snapshot.positionOf(utf8Offset: utf8Offset + length) else {
29+
return nil
30+
}
31+
let textEdit = TextEdit(range: position..<endPosition, newText: replacement)
32+
33+
let workspaceEdit = WorkspaceEdit(changes: [snapshot.document.uri:[textEdit]])
34+
self.init(title: "Fix",
35+
kind: .quickFix,
36+
diagnostics: nil,
37+
edit: workspaceEdit)
38+
}
39+
}
40+
1841
extension Diagnostic {
1942

2043
/// Creates a diagnostic from a sourcekitd response dictionary.
@@ -52,6 +75,17 @@ extension Diagnostic {
5275
}
5376
}
5477

78+
var fixits: [CodeAction]? = nil
79+
if let skfixits: SKResponseArray = diag[keys.fixits] {
80+
fixits = []
81+
skfixits.forEach { (_, skfixit) -> Bool in
82+
if let codeAction = CodeAction(fixit: skfixit, in: snapshot) {
83+
fixits?.append(codeAction)
84+
}
85+
return true
86+
}
87+
}
88+
5589
var notes: [DiagnosticRelatedInformation]? = nil
5690
if let sknotes: SKResponseArray = diag[keys.diagnostics] {
5791
notes = []
@@ -71,7 +105,8 @@ extension Diagnostic {
71105
code: nil,
72106
source: "sourcekitd",
73107
message: message,
74-
relatedInformation: notes)
108+
relatedInformation: notes,
109+
codeActions: fixits)
75110
}
76111
}
77112

Sources/SourceKit/sourcekitd/SwiftLanguageServer.swift

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,24 @@ import SKSupport
1919
import sourcekitd
2020
import TSCBasic
2121

22+
fileprivate extension Range {
23+
/// Checks if this range overlaps with the other range, counting an overlap with an empty range as a valid overlap.
24+
/// 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.
25+
/// This implementation over overlap considers such an inclusion of an empty range as a valid overlap.
26+
func overlapsIncludingEmptyRanges(other: Range<Bound>) -> Bool {
27+
switch (self.isEmpty, other.isEmpty) {
28+
case (true, true):
29+
return self.lowerBound == other.lowerBound
30+
case (true, false):
31+
return other.contains(self.lowerBound)
32+
case (false, true):
33+
return self.contains(other.lowerBound)
34+
case (false, false):
35+
return self.overlaps(other)
36+
}
37+
}
38+
}
39+
2240
public final class SwiftLanguageServer: ToolchainLanguageServer {
2341

2442
/// The server's request queue, used to serialize requests and responses to `sourcekitd`.
@@ -155,7 +173,7 @@ extension SwiftLanguageServer {
155173
documentSymbolProvider: true,
156174
codeActionProvider: .value(CodeActionServerCapabilities(
157175
clientCapabilities: initialize.capabilities.textDocument?.codeAction,
158-
codeActionOptions: CodeActionOptions(codeActionKinds: nil),
176+
codeActionOptions: CodeActionOptions(codeActionKinds: [.quickFix, .refactor]),
159177
supportsCodeActions: true)),
160178
colorProvider: .bool(true),
161179
foldingRangeProvider: .bool(true),
@@ -981,9 +999,8 @@ extension SwiftLanguageServer {
981999

9821000
public func codeAction(_ req: Request<CodeActionRequest>) {
9831001
let providersAndKinds: [(provider: CodeActionProvider, kind: CodeActionKind)] = [
984-
(retrieveRefactorCodeActions, .refactor)
985-
//TODO: Implement the providers.
986-
//(retrieveQuickFixCodeActions, .quickFix)
1002+
(retrieveRefactorCodeActions, .refactor),
1003+
(retrieveQuickFixCodeActions, .quickFix)
9871004
]
9881005
let wantedActionKinds = req.params.context.only
9891006
let providers = providersAndKinds.filter { wantedActionKinds?.contains($0.1) != false }
@@ -1061,6 +1078,51 @@ extension SwiftLanguageServer {
10611078
}
10621079
}
10631080

1081+
func retrieveQuickFixCodeActions(_ params: CodeActionRequest, completion: @escaping CodeActionProviderCompletion) {
1082+
guard let cachedDiags = currentDiagnostics[params.textDocument.uri] else {
1083+
completion(.success([]))
1084+
return
1085+
}
1086+
1087+
let codeActions = cachedDiags.flatMap { (cachedDiag) -> [CodeAction] in
1088+
let diag = cachedDiag.diagnostic
1089+
1090+
guard let codeActions = diag.codeActions else {
1091+
// The diagnostic doesn't have fix-its. Don't return anything.
1092+
return []
1093+
}
1094+
1095+
// Check if the diagnostic overlaps with the selected range.
1096+
guard params.range.overlapsIncludingEmptyRanges(other: diag.range) else {
1097+
return []
1098+
}
1099+
1100+
// Check if the set of diagnostics provided by the request contains this diagnostic.
1101+
// For this, only compare the 'basic' properties of the diagnostics, excluding related information and code actions since
1102+
// code actions are only defined in an LSP extension and might not be sent back to us.
1103+
guard params.context.diagnostics.contains(where: { (contextDiag) -> Bool in
1104+
return contextDiag.range == diag.range &&
1105+
contextDiag.severity == diag.severity &&
1106+
contextDiag.code == diag.code &&
1107+
contextDiag.source == diag.source &&
1108+
contextDiag.message == diag.message
1109+
}) else {
1110+
return []
1111+
}
1112+
1113+
// Flip the attachment of diagnostic to code action instead of the code action being attached to the diagnostic
1114+
return codeActions.map({
1115+
var codeAction = $0
1116+
var diagnosticWithoutCodeActions = diag
1117+
diagnosticWithoutCodeActions.codeActions = nil
1118+
codeAction.diagnostics = [diagnosticWithoutCodeActions]
1119+
return codeAction
1120+
})
1121+
}
1122+
1123+
completion(.success(codeActions))
1124+
}
1125+
10641126
public func executeCommand(_ req: Request<ExecuteCommandRequest>) {
10651127
let params = req.params
10661128
//TODO: If there's support for several types of commands, we might need to structure this similarly to the code actions request.

Sources/SourceKit/sourcekitd/SwiftSourceKitFramework.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ struct sourcekitd_keys {
215215
let name: sourcekitd_uid_t
216216
let kind: sourcekitd_uid_t
217217
let notification: sourcekitd_uid_t
218+
let fixits: sourcekitd_uid_t
218219
let diagnostics: sourcekitd_uid_t
219220
let diagnostic_stage: sourcekitd_uid_t
220221
let severity: sourcekitd_uid_t
@@ -263,6 +264,7 @@ struct sourcekitd_keys {
263264
name = api.uid_get_from_cstr("key.name")!
264265
kind = api.uid_get_from_cstr("key.kind")!
265266
notification = api.uid_get_from_cstr("key.notification")!
267+
fixits = api.uid_get_from_cstr("key.fixits")!
266268
diagnostics = api.uid_get_from_cstr("key.diagnostics")!
267269
diagnostic_stage = api.uid_get_from_cstr("key.diagnostic_stage")!
268270
severity = api.uid_get_from_cstr("key.severity")!

Tests/SourceKitTests/LocalSwiftTests.swift

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,12 @@ final class LocalSwiftTests: XCTestCase {
4141
rootPath: nil,
4242
rootURI: nil,
4343
initializationOptions: nil,
44-
capabilities: ClientCapabilities(workspace: nil, textDocument: nil),
44+
capabilities: ClientCapabilities(workspace: nil,
45+
textDocument: TextDocumentClientCapabilities(
46+
codeAction: .init(
47+
codeActionLiteralSupport: .init(
48+
codeActionKind: .init(valueSet: [.quickFix])
49+
)))),
4550
trace: .off,
4651
workspaceFolders: nil))
4752
}
@@ -413,6 +418,89 @@ final class LocalSwiftTests: XCTestCase {
413418
})
414419
}
415420

421+
func testFixitsAreIncludedInPublishDiagnostics() {
422+
let url = URL(fileURLWithPath: "/a.swift")
423+
let uri = DocumentURI(url)
424+
425+
sk.allowUnexpectedNotification = false
426+
427+
sk.sendNoteSync(DidOpenTextDocumentNotification(textDocument: TextDocumentItem(
428+
uri: uri, language: .swift, version: 12,
429+
text: """
430+
func foo() {
431+
let a = 2
432+
}
433+
"""
434+
)), { (note: Notification<PublishDiagnosticsNotification>) in
435+
log("Received diagnostics for open - syntactic")
436+
}, { (note: Notification<PublishDiagnosticsNotification>) in
437+
log("Received diagnostics for open - semantic")
438+
XCTAssertEqual(note.params.diagnostics.count, 1)
439+
let diag = note.params.diagnostics.first!
440+
XCTAssertNotNil(diag.codeActions)
441+
XCTAssertEqual(diag.codeActions!.count, 1)
442+
let fixit = diag.codeActions!.first!
443+
444+
// Expected Fix-it: Replace `let a` with `_` because it's never used
445+
let expectedTextEdit = TextEdit(range: Position(line: 1, utf16index: 2)..<Position(line: 1, utf16index: 7), newText: "_")
446+
XCTAssertEqual(fixit, CodeAction(title: "Fix",
447+
kind: .quickFix,
448+
diagnostics: nil,
449+
edit: WorkspaceEdit(changes: [uri: [expectedTextEdit]], documentChanges: nil),
450+
command: nil))
451+
})
452+
}
453+
454+
func testFixitsAreReturnedFromCodeActions() {
455+
let url = URL(fileURLWithPath: "/a.swift")
456+
let uri = DocumentURI(url)
457+
458+
var diagnostic: Diagnostic! = nil
459+
sk.sendNoteSync(DidOpenTextDocumentNotification(textDocument: TextDocumentItem(
460+
uri: uri, language: .swift, version: 12,
461+
text: """
462+
func foo() {
463+
let a = 2
464+
}
465+
"""
466+
)), { (note: Notification<PublishDiagnosticsNotification>) in
467+
log("Received diagnostics for open - syntactic")
468+
}, { (note: Notification<PublishDiagnosticsNotification>) in
469+
log("Received diagnostics for open - semantic")
470+
XCTAssertEqual(note.params.diagnostics.count, 1)
471+
diagnostic = note.params.diagnostics.first!
472+
})
473+
474+
let request = CodeActionRequest(
475+
range: Position(line: 1, utf16index: 0)..<Position(line: 1, utf16index: 11),
476+
context: CodeActionContext(diagnostics: [diagnostic], only: nil),
477+
textDocument: TextDocumentIdentifier(uri)
478+
)
479+
let response = try! sk.sendSync(request)
480+
481+
XCTAssertNotNil(response)
482+
guard case .codeActions(let codeActions) = response else {
483+
XCTFail("Expected code actions as response")
484+
return
485+
}
486+
XCTAssertEqual(codeActions.count, 1)
487+
let fixit = codeActions.first!
488+
489+
// Expected Fix-it: Replace `let a` with `_` because it's never used
490+
let expectedTextEdit = TextEdit(range: Position(line: 1, utf16index: 2)..<Position(line: 1, utf16index: 7), newText: "_")
491+
XCTAssertEqual(fixit, CodeAction(title: "Fix",
492+
kind: .quickFix,
493+
diagnostics: [Diagnostic(range: Position(line: 1, utf16index: 6)..<Position(line: 1, utf16index: 6),
494+
severity: .warning,
495+
code: nil,
496+
source: "sourcekitd",
497+
message: "initialization of immutable value \'a\' was never used; consider replacing with assignment to \'_\' or removing it",
498+
relatedInformation: [],
499+
codeActions: nil)],
500+
edit: WorkspaceEdit(changes: [uri: [expectedTextEdit]], documentChanges: nil),
501+
command: nil))
502+
}
503+
416504
func testXMLToMarkdownDeclaration() {
417505
XCTAssertEqual(try! xmlDocumentationToMarkdown("""
418506
<Declaration>func foo(_ bar: <Type usr="fake">Baz</Type>)</Declaration>

Tests/SourceKitTests/XCTestManifests.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ extension LocalSwiftTests {
108108
("testEditing", testEditing),
109109
("testEditingNonURL", testEditingNonURL),
110110
("testEditorPlaceholderParsing", testEditorPlaceholderParsing),
111+
("testFixitsAreIncludedInPublishDiagnostics", testFixitsAreIncludedInPublishDiagnostics),
112+
("testFixitsAreReturnedFromCodeActions", testFixitsAreReturnedFromCodeActions),
111113
("testHover", testHover),
112114
("testHoverNameEscaping", testHoverNameEscaping),
113115
("testSymbolInfo", testSymbolInfo),

0 commit comments

Comments
 (0)