Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 1f17adb

Browse files
authoredFeb 6, 2025
Merge pull request #1938 from ahoppen/completion-item-resolve
Support completionItem/resolve to compute documentation of a code completion item
2 parents 03da7e9 + f6b83db commit 1f17adb

File tree

9 files changed

+238
-53
lines changed

9 files changed

+238
-53
lines changed
 

‎Sources/LanguageServerProtocol/SupportTypes/ClientCapabilities.swift

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,9 +254,33 @@ public struct TextDocumentClientCapabilities: Hashable, Codable, Sendable {
254254

255255
/// Capabilities specific to the `textDocument/...` change notifications.
256256
public struct Completion: Hashable, Codable, Sendable {
257-
258257
/// Capabilities specific to `CompletionItem`.
259258
public struct CompletionItem: Hashable, Codable, Sendable {
259+
public struct TagSupportValueSet: Hashable, Codable, Sendable {
260+
/// The tags supported by the client.
261+
public var valueSet: [CompletionItemTag]
262+
263+
public init(valueSet: [CompletionItemTag]) {
264+
self.valueSet = valueSet
265+
}
266+
}
267+
268+
public struct ResolveSupportProperties: Hashable, Codable, Sendable {
269+
/// The properties that a client can resolve lazily.
270+
public var properties: [String]
271+
272+
public init(properties: [String]) {
273+
self.properties = properties
274+
}
275+
}
276+
277+
public struct InsertTextModeSupportValueSet: Hashable, Codable, Sendable {
278+
public var valueSet: [InsertTextMode]
279+
280+
public init(valueSet: [InsertTextMode]) {
281+
self.valueSet = valueSet
282+
}
283+
}
260284

261285
/// Whether the client supports rich snippets using placeholders, etc.
262286
public var snippetSupport: Bool? = nil
@@ -273,18 +297,48 @@ public struct TextDocumentClientCapabilities: Hashable, Codable, Sendable {
273297
/// Whether the client supports the `preselect` property on a CompletionItem.
274298
public var preselectSupport: Bool? = nil
275299

300+
/// Client supports the tag property on a completion item. Clients supporting tags have to handle unknown tags
301+
/// gracefully. Clients especially need to preserve unknown tags when sending a completion item back to the server
302+
/// in a resolve call.
303+
public var tagSupport: TagSupportValueSet?
304+
305+
/// Client supports insert replace edit to control different behavior if a completion item is inserted in the text
306+
/// or should replace text.
307+
public var insertReplaceSupport: Bool?
308+
309+
/// Indicates which properties a client can resolve lazily on a completion item. Before version 3.16.0 only the
310+
/// predefined properties `documentation` and `detail` could be resolved lazily.
311+
public var resolveSupport: ResolveSupportProperties?
312+
313+
/// The client supports the `insertTextMode` property on a completion item to override the whitespace handling mode
314+
/// as defined by the client (see `insertTextMode`).
315+
public var insertTextModeSupport: InsertTextModeSupportValueSet?
316+
317+
/// The client has support for completion item label details (see also `CompletionItemLabelDetails`).
318+
public var labelDetailsSupport: Bool?
319+
276320
public init(
277321
snippetSupport: Bool? = nil,
278322
commitCharactersSupport: Bool? = nil,
279323
documentationFormat: [MarkupKind]? = nil,
280324
deprecatedSupport: Bool? = nil,
281-
preselectSupport: Bool? = nil
325+
preselectSupport: Bool? = nil,
326+
tagSupport: TagSupportValueSet? = nil,
327+
insertReplaceSupport: Bool? = nil,
328+
resolveSupport: ResolveSupportProperties? = nil,
329+
insertTextModeSupport: InsertTextModeSupportValueSet? = nil,
330+
labelDetailsSupport: Bool? = nil
282331
) {
283332
self.snippetSupport = snippetSupport
284333
self.commitCharactersSupport = commitCharactersSupport
285334
self.documentationFormat = documentationFormat
286335
self.deprecatedSupport = deprecatedSupport
287336
self.preselectSupport = preselectSupport
337+
self.tagSupport = tagSupport
338+
self.insertReplaceSupport = insertReplaceSupport
339+
self.resolveSupport = resolveSupport
340+
self.insertTextModeSupport = insertTextModeSupport
341+
self.labelDetailsSupport = labelDetailsSupport
288342
}
289343
}
290344

‎Sources/SourceKitLSP/Clang/ClangLanguageService.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,10 @@ extension ClangLanguageService {
496496
return try await forwardRequestToClangd(req)
497497
}
498498

499+
func completionItemResolve(_ req: CompletionItemResolveRequest) async throws -> CompletionItem {
500+
return try await forwardRequestToClangd(req)
501+
}
502+
499503
func hover(_ req: HoverRequest) async throws -> HoverResponse? {
500504
return try await forwardRequestToClangd(req)
501505
}

‎Sources/SourceKitLSP/Documentation/DocumentationLanguageService.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ package actor DocumentationLanguageService: LanguageService, Sendable {
103103
CompletionList(isIncomplete: false, items: [])
104104
}
105105

106+
package func completionItemResolve(_ req: CompletionItemResolveRequest) async throws -> CompletionItem {
107+
return req.item
108+
}
109+
106110
package func hover(_ req: HoverRequest) async throws -> HoverResponse? {
107111
nil
108112
}

‎Sources/SourceKitLSP/LanguageService.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ package protocol LanguageService: AnyObject, Sendable {
172172
// MARK: - Text Document
173173

174174
func completion(_ req: CompletionRequest) async throws -> CompletionList
175+
func completionItemResolve(_ req: CompletionItemResolveRequest) async throws -> CompletionItem
175176
func hover(_ req: HoverRequest) async throws -> HoverResponse?
176177
func symbolInfo(_ request: SymbolInfoRequest) async throws -> [SymbolDetails]
177178

‎Sources/SourceKitLSP/SourceKitLSPServer.swift

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,16 @@ package actor SourceKitLSPServer {
281281
}.valuePropagatingCancellation
282282
}
283283

284+
private func documentService(for uri: DocumentURI) async throws -> LanguageService {
285+
guard let workspace = await self.workspaceForDocument(uri: uri) else {
286+
throw ResponseError.workspaceNotOpen(uri)
287+
}
288+
guard let languageService = workspace.documentService(for: uri) else {
289+
throw ResponseError.unknown("No language service for '\(uri)' found")
290+
}
291+
return languageService
292+
}
293+
284294
/// This method must be executed on `workspaceQueue` to ensure that the file handling capabilities of the
285295
/// workspaces don't change during the computation. Otherwise, we could run into a race condition like the following:
286296
/// 1. We don't have an entry for file `a.swift` in `workspaceForUri` and start the computation
@@ -754,6 +764,8 @@ extension SourceKitLSPServer: QueueBasedMessageHandler {
754764
await self.handleRequest(for: request, requestHandler: self.colorPresentation)
755765
case let request as RequestAndReply<CompletionRequest>:
756766
await self.handleRequest(for: request, requestHandler: self.completion)
767+
case let request as RequestAndReply<CompletionItemResolveRequest>:
768+
await request.reply { try await completionItemResolve(request: request.params) }
757769
case let request as RequestAndReply<DeclarationRequest>:
758770
await self.handleRequest(for: request, requestHandler: self.declaration)
759771
case let request as RequestAndReply<DefinitionRequest>:
@@ -1035,7 +1047,7 @@ extension SourceKitLSPServer {
10351047
await registry.clientHasDynamicCompletionRegistration
10361048
? nil
10371049
: LanguageServerProtocol.CompletionOptions(
1038-
resolveProvider: false,
1050+
resolveProvider: true,
10391051
triggerCharacters: [".", "("]
10401052
)
10411053

@@ -1432,6 +1444,15 @@ extension SourceKitLSPServer {
14321444
return try await languageService.completion(req)
14331445
}
14341446

1447+
func completionItemResolve(
1448+
request: CompletionItemResolveRequest
1449+
) async throws -> CompletionItem {
1450+
guard let completionItemData = CompletionItemData(fromLSPAny: request.item.data) else {
1451+
return request.item
1452+
}
1453+
return try await documentService(for: completionItemData.uri).completionItemResolve(request)
1454+
}
1455+
14351456
#if canImport(SwiftDocC)
14361457
func doccDocumentation(_ req: DoccDocumentationRequest) async throws -> DoccDocumentationResponse {
14371458
return try await documentationManager.convertDocumentation(
@@ -1624,18 +1645,12 @@ extension SourceKitLSPServer {
16241645
logger.error("Attempted to perform executeCommand request without an URL")
16251646
return nil
16261647
}
1627-
guard let workspace = await workspaceForDocument(uri: uri) else {
1628-
throw ResponseError.workspaceNotOpen(uri)
1629-
}
1630-
guard let languageService = workspace.documentService(for: uri) else {
1631-
return nil
1632-
}
16331648

16341649
let executeCommand = ExecuteCommandRequest(
16351650
command: req.command,
16361651
arguments: req.argumentsWithoutSourceKitMetadata
16371652
)
1638-
return try await languageService.executeCommand(executeCommand)
1653+
return try await documentService(for: uri).executeCommand(executeCommand)
16391654
}
16401655

16411656
func getReferenceDocument(_ req: GetReferenceDocumentRequest) async throws -> GetReferenceDocumentResponse {

‎Sources/SourceKitLSP/Swift/CodeCompletion.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,6 @@ extension SwiftLanguageService {
3131
let completionPos = await adjustPositionToStartOfIdentifier(req.position, in: snapshot)
3232
let filterText = String(snapshot.text[snapshot.index(of: completionPos)..<snapshot.index(of: req.position)])
3333

34-
let clientSupportsSnippets =
35-
capabilityRegistry.clientCapabilities.textDocument?.completion?.completionItem?.snippetSupport ?? false
3634
let buildSettings = await buildSettings(for: snapshot.uri, fallbackAfterTimeout: false)
3735

3836
let inferredIndentationWidth = BasicFormat.inferIndentation(of: await syntaxTreeManager.syntaxTree(for: snapshot))
@@ -45,8 +43,12 @@ extension SwiftLanguageService {
4543
completionPosition: completionPos,
4644
cursorPosition: req.position,
4745
compileCommand: buildSettings,
48-
clientSupportsSnippets: clientSupportsSnippets,
46+
clientCapabilities: capabilityRegistry.clientCapabilities,
4947
filterText: filterText
5048
)
5149
}
50+
51+
package func completionItemResolve(_ req: CompletionItemResolveRequest) async throws -> CompletionItem {
52+
return try await CodeCompletionSession.completionItemResolve(item: req.item, sourcekitd: sourcekitd)
53+
}
5254
}

‎Sources/SourceKitLSP/Swift/CodeCompletionSession.swift

Lines changed: 102 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -23,28 +23,53 @@ import SwiftParser
2323
@_spi(SourceKitLSP) import SwiftRefactor
2424
import SwiftSyntax
2525

26-
/// Data that is attached to a `CompletionItem`.
27-
private struct CompletionItemData: LSPAnyCodable {
28-
let id: Int?
26+
/// Uniquely identifies a code completion session. We need this so that when resolving a code completion item, we can
27+
/// verify that the item to resolve belongs to the code completion session that is currently open.
28+
struct CompletionSessionID: Equatable {
29+
private static let nextSessionID = AtomicUInt32(initialValue: 0)
30+
31+
let value: UInt32
2932

30-
init(id: Int?) {
31-
self.id = id
33+
init(value: UInt32) {
34+
self.value = value
35+
}
36+
37+
static func next() -> CompletionSessionID {
38+
return CompletionSessionID(value: nextSessionID.fetchAndIncrement())
39+
}
40+
}
41+
42+
/// Data that is attached to a `CompletionItem`.
43+
struct CompletionItemData: LSPAnyCodable {
44+
let uri: DocumentURI
45+
let sessionId: CompletionSessionID
46+
let itemId: Int
47+
48+
init(uri: DocumentURI, sessionId: CompletionSessionID, itemId: Int) {
49+
self.uri = uri
50+
self.sessionId = sessionId
51+
self.itemId = itemId
3252
}
3353

3454
init?(fromLSPDictionary dictionary: [String: LSPAny]) {
35-
if case .int(let id) = dictionary["id"] {
36-
self.id = id
37-
} else {
38-
self.id = nil
55+
guard case .string(let uriString) = dictionary["uri"],
56+
case .int(let sessionId) = dictionary["sessionId"],
57+
case .int(let itemId) = dictionary["itemId"],
58+
let uri = try? DocumentURI(string: uriString)
59+
else {
60+
return nil
3961
}
62+
self.uri = uri
63+
self.sessionId = CompletionSessionID(value: UInt32(sessionId))
64+
self.itemId = itemId
4065
}
4166

4267
func encodeToLSPAny() -> LSPAny {
43-
var dict: [String: LSPAny] = [:]
44-
if let id {
45-
dict["id"] = .int(id)
46-
}
47-
return .dictionary(dict)
68+
return .dictionary([
69+
"uri": .string(uri.stringValue),
70+
"sessionId": .int(Int(sessionId.value)),
71+
"itemId": .int(itemId),
72+
])
4873
}
4974
}
5075

@@ -108,7 +133,6 @@ class CodeCompletionSession {
108133
/// - compileCommand: The compiler arguments to use.
109134
/// - options: Further options that can be sent from the editor to control
110135
/// completion.
111-
/// - clientSupportsSnippets: Whether the editor supports LSP snippets.
112136
/// - filterText: The text by which to filter code completion results.
113137
/// - mustReuse: If `true` and there is an active session in this
114138
/// `sourcekitd` instance, cancel the request instead of opening a new
@@ -125,7 +149,7 @@ class CodeCompletionSession {
125149
completionPosition: Position,
126150
cursorPosition: Position,
127151
compileCommand: SwiftCompileCommand?,
128-
clientSupportsSnippets: Bool,
152+
clientCapabilities: ClientCapabilities,
129153
filterText: String
130154
) async throws -> CompletionList {
131155
let task = completionQueue.asyncThrowing {
@@ -134,7 +158,6 @@ class CodeCompletionSession {
134158
session.snapshot.uri == snapshot.uri
135159
&& session.position == completionPosition
136160
&& session.compileCommand == compileCommand
137-
&& session.clientSupportsSnippets == clientSupportsSnippets
138161

139162
if isCompatible {
140163
return try await session.update(
@@ -155,7 +178,7 @@ class CodeCompletionSession {
155178
indentationWidth: indentationWidth,
156179
position: completionPosition,
157180
compileCommand: compileCommand,
158-
clientSupportsSnippets: clientSupportsSnippets
181+
clientCapabilities: clientCapabilities
159182
)
160183
completionSessions[ObjectIdentifier(sourcekitd)] = session
161184
return try await session.open(filterText: filterText, position: cursorPosition, in: snapshot)
@@ -164,6 +187,26 @@ class CodeCompletionSession {
164187
return try await task.valuePropagatingCancellation
165188
}
166189

190+
static func completionItemResolve(
191+
item: CompletionItem,
192+
sourcekitd: SourceKitD
193+
) async throws -> CompletionItem {
194+
guard let data = CompletionItemData(fromLSPAny: item.data) else {
195+
return item
196+
}
197+
let task = completionQueue.asyncThrowing {
198+
guard let session = completionSessions[ObjectIdentifier(sourcekitd)], data.sessionId == session.id else {
199+
throw ResponseError.unknown("No active completion session for \(data.uri)")
200+
}
201+
return await Self.resolveDocumentation(
202+
in: item,
203+
timeout: session.options.sourcekitdRequestTimeoutOrDefault,
204+
sourcekitd: sourcekitd
205+
)
206+
}
207+
return try await task.valuePropagatingCancellation
208+
}
209+
167210
/// Close all code completion sessions for the given files.
168211
///
169212
/// This should only be necessary to do if the dependencies have updated. In all other cases `completionList` will
@@ -180,6 +223,7 @@ class CodeCompletionSession {
180223

181224
// MARK: - Implementation
182225

226+
private let id: CompletionSessionID
183227
private let sourcekitd: any SourceKitD
184228
private let snapshot: DocumentSnapshot
185229
private let options: SourceKitLSPOptions
@@ -188,6 +232,7 @@ class CodeCompletionSession {
188232
private let position: Position
189233
private let compileCommand: SwiftCompileCommand?
190234
private let clientSupportsSnippets: Bool
235+
private let clientSupportsDocumentationResolve: Bool
191236
private var state: State = .closed
192237

193238
private enum State {
@@ -205,15 +250,20 @@ class CodeCompletionSession {
205250
indentationWidth: Trivia?,
206251
position: Position,
207252
compileCommand: SwiftCompileCommand?,
208-
clientSupportsSnippets: Bool
253+
clientCapabilities: ClientCapabilities
209254
) {
255+
self.id = CompletionSessionID.next()
210256
self.sourcekitd = sourcekitd
211257
self.options = options
212258
self.indentationWidth = indentationWidth
213259
self.snapshot = snapshot
214260
self.position = position
215261
self.compileCommand = compileCommand
216-
self.clientSupportsSnippets = clientSupportsSnippets
262+
self.clientSupportsSnippets = clientCapabilities.textDocument?.completion?.completionItem?.snippetSupport ?? false
263+
self.clientSupportsDocumentationResolve =
264+
clientCapabilities.textDocument?.completion?.completionItem?.resolveSupport?.properties.contains("documentation")
265+
?? false
266+
217267
}
218268

219269
private func open(
@@ -389,7 +439,7 @@ class CodeCompletionSession {
389439
let sourcekitd = self.sourcekitd
390440
let keys = sourcekitd.keys
391441

392-
let completionItems = completions.compactMap { (value: SKDResponseDictionary) -> CompletionItem? in
442+
var completionItems = completions.compactMap { (value: SKDResponseDictionary) -> CompletionItem? in
393443
guard let name: String = value[keys.description],
394444
var insertText: String = value[keys.sourceText]
395445
else {
@@ -456,7 +506,12 @@ class CodeCompletionSession {
456506
sortText = nil
457507
}
458508

459-
let data = CompletionItemData(id: value[keys.identifier] as Int?)
509+
let data: CompletionItemData? =
510+
if let identifier: Int = value[keys.identifier] {
511+
CompletionItemData(uri: self.uri, sessionId: self.id, itemId: identifier)
512+
} else {
513+
nil
514+
}
460515

461516
let kind: sourcekitd_api_uid_t? = value[sourcekitd.keys.kind]
462517
return CompletionItem(
@@ -474,26 +529,34 @@ class CodeCompletionSession {
474529
)
475530
}
476531

477-
// TODO: Only compute documentation if the client doesn't support `completionItem/resolve`
478-
// (https://github.com/swiftlang/sourcekit-lsp/issues/1935)
479-
let withDocumentation = await completionItems.asyncMap { item in
480-
var item = item
481-
482-
if let itemId = CompletionItemData(fromLSPAny: item.data)?.id {
483-
let req = sourcekitd.dictionary([
484-
keys.request: sourcekitd.requests.codeCompleteDocumentation,
485-
keys.identifier: itemId,
486-
])
487-
let documentationResponse = try? await sourcekitd.send(req, timeout: .seconds(1), fileContents: snapshot.text)
488-
if let docString: String = documentationResponse?[keys.docBrief] {
489-
item.documentation = .markupContent(MarkupContent(kind: .markdown, value: docString))
490-
}
532+
if !clientSupportsDocumentationResolve {
533+
completionItems = await completionItems.asyncMap { item in
534+
return await Self.resolveDocumentation(in: item, timeout: .seconds(1), sourcekitd: sourcekitd)
491535
}
492-
493-
return item
494536
}
495537

496-
return CompletionList(isIncomplete: isIncomplete, items: withDocumentation)
538+
return CompletionList(isIncomplete: isIncomplete, items: completionItems)
539+
}
540+
541+
private static func resolveDocumentation(
542+
in item: CompletionItem,
543+
timeout: Duration,
544+
sourcekitd: SourceKitD
545+
) async -> CompletionItem {
546+
var item = item
547+
if let itemId = CompletionItemData(fromLSPAny: item.data)?.itemId {
548+
let req = sourcekitd.dictionary([
549+
sourcekitd.keys.request: sourcekitd.requests.codeCompleteDocumentation,
550+
sourcekitd.keys.identifier: itemId,
551+
])
552+
let documentationResponse = await orLog("Retrieving documentation for completion item") {
553+
try await sourcekitd.send(req, timeout: timeout, fileContents: nil)
554+
}
555+
if let docString: String = documentationResponse?[sourcekitd.keys.docBrief] {
556+
item.documentation = .markupContent(MarkupContent(kind: .markdown, value: docString))
557+
}
558+
}
559+
return item
497560
}
498561

499562
private func computeCompletionTextEdit(

‎Sources/SourceKitLSP/Swift/SwiftLanguageService.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ extension SwiftLanguageService {
377377
),
378378
hoverProvider: .bool(true),
379379
completionProvider: CompletionOptions(
380-
resolveProvider: false,
380+
resolveProvider: true,
381381
triggerCharacters: [".", "("]
382382
),
383383
definitionProvider: nil,

‎Tests/SourceKitLSPTests/SwiftCompletionTests.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import LanguageServerProtocol
1414
import SKTestSupport
1515
import SourceKitLSP
16+
import SwiftExtensions
1617
import XCTest
1718

1819
final class SwiftCompletionTests: XCTestCase {
@@ -1083,6 +1084,47 @@ final class SwiftCompletionTests: XCTestCase {
10831084
["makeInt()", "makeBool()", "makeString()"]
10841085
)
10851086
}
1087+
1088+
func testCompletionItemResolve() async throws {
1089+
try await SkipUnless.sourcekitdSupportsPlugin()
1090+
1091+
let capabilities = ClientCapabilities(
1092+
textDocument: TextDocumentClientCapabilities(
1093+
completion: TextDocumentClientCapabilities.Completion(
1094+
completionItem: TextDocumentClientCapabilities.Completion.CompletionItem(
1095+
resolveSupport: TextDocumentClientCapabilities.Completion.CompletionItem.ResolveSupportProperties(
1096+
properties: ["documentation"]
1097+
)
1098+
)
1099+
)
1100+
)
1101+
)
1102+
1103+
let testClient = try await TestSourceKitLSPClient(capabilities: capabilities)
1104+
let uri = DocumentURI(for: .swift)
1105+
let positions = testClient.openDocument(
1106+
"""
1107+
struct Foo {
1108+
/// Creates a true value
1109+
func makeBool() -> Bool { true }
1110+
}
1111+
func test(foo: Foo) {
1112+
foo.make1️⃣
1113+
}
1114+
""",
1115+
uri: uri
1116+
)
1117+
let completions = try await testClient.send(
1118+
CompletionRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"])
1119+
)
1120+
let item = try XCTUnwrap(completions.items.only)
1121+
XCTAssertNil(item.documentation)
1122+
let resolvedItem = try await testClient.send(CompletionItemResolveRequest(item: item))
1123+
XCTAssertEqual(
1124+
resolvedItem.documentation,
1125+
.markupContent(MarkupContent(kind: .markdown, value: "Creates a true value"))
1126+
)
1127+
}
10861128
}
10871129

10881130
private func countFs(_ response: CompletionList) -> Int {

0 commit comments

Comments
 (0)
Please sign in to comment.