diff --git a/Sources/LanguageServerProtocol/SupportTypes/ClientCapabilities.swift b/Sources/LanguageServerProtocol/SupportTypes/ClientCapabilities.swift index d899f63f2..231b6057b 100644 --- a/Sources/LanguageServerProtocol/SupportTypes/ClientCapabilities.swift +++ b/Sources/LanguageServerProtocol/SupportTypes/ClientCapabilities.swift @@ -254,9 +254,33 @@ public struct TextDocumentClientCapabilities: Hashable, Codable, Sendable { /// Capabilities specific to the `textDocument/...` change notifications. public struct Completion: Hashable, Codable, Sendable { - /// Capabilities specific to `CompletionItem`. public struct CompletionItem: Hashable, Codable, Sendable { + public struct TagSupportValueSet: Hashable, Codable, Sendable { + /// The tags supported by the client. + public var valueSet: [CompletionItemTag] + + public init(valueSet: [CompletionItemTag]) { + self.valueSet = valueSet + } + } + + public struct ResolveSupportProperties: Hashable, Codable, Sendable { + /// The properties that a client can resolve lazily. + public var properties: [String] + + public init(properties: [String]) { + self.properties = properties + } + } + + public struct InsertTextModeSupportValueSet: Hashable, Codable, Sendable { + public var valueSet: [InsertTextMode] + + public init(valueSet: [InsertTextMode]) { + self.valueSet = valueSet + } + } /// Whether the client supports rich snippets using placeholders, etc. public var snippetSupport: Bool? = nil @@ -273,18 +297,48 @@ public struct TextDocumentClientCapabilities: Hashable, Codable, Sendable { /// Whether the client supports the `preselect` property on a CompletionItem. public var preselectSupport: Bool? = nil + /// Client supports the tag property on a completion item. Clients supporting tags have to handle unknown tags + /// gracefully. Clients especially need to preserve unknown tags when sending a completion item back to the server + /// in a resolve call. + public var tagSupport: TagSupportValueSet? + + /// Client supports insert replace edit to control different behavior if a completion item is inserted in the text + /// or should replace text. + public var insertReplaceSupport: Bool? + + /// Indicates which properties a client can resolve lazily on a completion item. Before version 3.16.0 only the + /// predefined properties `documentation` and `detail` could be resolved lazily. + public var resolveSupport: ResolveSupportProperties? + + /// The client supports the `insertTextMode` property on a completion item to override the whitespace handling mode + /// as defined by the client (see `insertTextMode`). + public var insertTextModeSupport: InsertTextModeSupportValueSet? + + /// The client has support for completion item label details (see also `CompletionItemLabelDetails`). + public var labelDetailsSupport: Bool? + public init( snippetSupport: Bool? = nil, commitCharactersSupport: Bool? = nil, documentationFormat: [MarkupKind]? = nil, deprecatedSupport: Bool? = nil, - preselectSupport: Bool? = nil + preselectSupport: Bool? = nil, + tagSupport: TagSupportValueSet? = nil, + insertReplaceSupport: Bool? = nil, + resolveSupport: ResolveSupportProperties? = nil, + insertTextModeSupport: InsertTextModeSupportValueSet? = nil, + labelDetailsSupport: Bool? = nil ) { self.snippetSupport = snippetSupport self.commitCharactersSupport = commitCharactersSupport self.documentationFormat = documentationFormat self.deprecatedSupport = deprecatedSupport self.preselectSupport = preselectSupport + self.tagSupport = tagSupport + self.insertReplaceSupport = insertReplaceSupport + self.resolveSupport = resolveSupport + self.insertTextModeSupport = insertTextModeSupport + self.labelDetailsSupport = labelDetailsSupport } } diff --git a/Sources/SourceKitLSP/Clang/ClangLanguageService.swift b/Sources/SourceKitLSP/Clang/ClangLanguageService.swift index 6e0f0ca57..c3f9990d8 100644 --- a/Sources/SourceKitLSP/Clang/ClangLanguageService.swift +++ b/Sources/SourceKitLSP/Clang/ClangLanguageService.swift @@ -496,6 +496,10 @@ extension ClangLanguageService { return try await forwardRequestToClangd(req) } + func completionItemResolve(_ req: CompletionItemResolveRequest) async throws -> CompletionItem { + return try await forwardRequestToClangd(req) + } + func hover(_ req: HoverRequest) async throws -> HoverResponse? { return try await forwardRequestToClangd(req) } diff --git a/Sources/SourceKitLSP/Documentation/DocumentationLanguageService.swift b/Sources/SourceKitLSP/Documentation/DocumentationLanguageService.swift index 3de930cfb..d49792907 100644 --- a/Sources/SourceKitLSP/Documentation/DocumentationLanguageService.swift +++ b/Sources/SourceKitLSP/Documentation/DocumentationLanguageService.swift @@ -103,6 +103,10 @@ package actor DocumentationLanguageService: LanguageService, Sendable { CompletionList(isIncomplete: false, items: []) } + package func completionItemResolve(_ req: CompletionItemResolveRequest) async throws -> CompletionItem { + return req.item + } + package func hover(_ req: HoverRequest) async throws -> HoverResponse? { nil } diff --git a/Sources/SourceKitLSP/LanguageService.swift b/Sources/SourceKitLSP/LanguageService.swift index 30bb57739..0d10e2dfb 100644 --- a/Sources/SourceKitLSP/LanguageService.swift +++ b/Sources/SourceKitLSP/LanguageService.swift @@ -172,6 +172,7 @@ package protocol LanguageService: AnyObject, Sendable { // MARK: - Text Document func completion(_ req: CompletionRequest) async throws -> CompletionList + func completionItemResolve(_ req: CompletionItemResolveRequest) async throws -> CompletionItem func hover(_ req: HoverRequest) async throws -> HoverResponse? func symbolInfo(_ request: SymbolInfoRequest) async throws -> [SymbolDetails] diff --git a/Sources/SourceKitLSP/SourceKitLSPServer.swift b/Sources/SourceKitLSP/SourceKitLSPServer.swift index 2c529db14..857eb8486 100644 --- a/Sources/SourceKitLSP/SourceKitLSPServer.swift +++ b/Sources/SourceKitLSP/SourceKitLSPServer.swift @@ -281,6 +281,16 @@ package actor SourceKitLSPServer { }.valuePropagatingCancellation } + private func documentService(for uri: DocumentURI) async throws -> LanguageService { + guard let workspace = await self.workspaceForDocument(uri: uri) else { + throw ResponseError.workspaceNotOpen(uri) + } + guard let languageService = workspace.documentService(for: uri) else { + throw ResponseError.unknown("No language service for '\(uri)' found") + } + return languageService + } + /// This method must be executed on `workspaceQueue` to ensure that the file handling capabilities of the /// workspaces don't change during the computation. Otherwise, we could run into a race condition like the following: /// 1. We don't have an entry for file `a.swift` in `workspaceForUri` and start the computation @@ -754,6 +764,8 @@ extension SourceKitLSPServer: QueueBasedMessageHandler { await self.handleRequest(for: request, requestHandler: self.colorPresentation) case let request as RequestAndReply: await self.handleRequest(for: request, requestHandler: self.completion) + case let request as RequestAndReply: + await request.reply { try await completionItemResolve(request: request.params) } case let request as RequestAndReply: await self.handleRequest(for: request, requestHandler: self.declaration) case let request as RequestAndReply: @@ -1035,7 +1047,7 @@ extension SourceKitLSPServer { await registry.clientHasDynamicCompletionRegistration ? nil : LanguageServerProtocol.CompletionOptions( - resolveProvider: false, + resolveProvider: true, triggerCharacters: [".", "("] ) @@ -1432,6 +1444,15 @@ extension SourceKitLSPServer { return try await languageService.completion(req) } + func completionItemResolve( + request: CompletionItemResolveRequest + ) async throws -> CompletionItem { + guard let completionItemData = CompletionItemData(fromLSPAny: request.item.data) else { + return request.item + } + return try await documentService(for: completionItemData.uri).completionItemResolve(request) + } + #if canImport(SwiftDocC) func doccDocumentation(_ req: DoccDocumentationRequest) async throws -> DoccDocumentationResponse { return try await documentationManager.convertDocumentation( @@ -1624,18 +1645,12 @@ extension SourceKitLSPServer { logger.error("Attempted to perform executeCommand request without an URL") return nil } - guard let workspace = await workspaceForDocument(uri: uri) else { - throw ResponseError.workspaceNotOpen(uri) - } - guard let languageService = workspace.documentService(for: uri) else { - return nil - } let executeCommand = ExecuteCommandRequest( command: req.command, arguments: req.argumentsWithoutSourceKitMetadata ) - return try await languageService.executeCommand(executeCommand) + return try await documentService(for: uri).executeCommand(executeCommand) } func getReferenceDocument(_ req: GetReferenceDocumentRequest) async throws -> GetReferenceDocumentResponse { diff --git a/Sources/SourceKitLSP/Swift/CodeCompletion.swift b/Sources/SourceKitLSP/Swift/CodeCompletion.swift index ff0415d37..6e4de6a53 100644 --- a/Sources/SourceKitLSP/Swift/CodeCompletion.swift +++ b/Sources/SourceKitLSP/Swift/CodeCompletion.swift @@ -31,8 +31,6 @@ extension SwiftLanguageService { let completionPos = await adjustPositionToStartOfIdentifier(req.position, in: snapshot) let filterText = String(snapshot.text[snapshot.index(of: completionPos).. CompletionItem { + return try await CodeCompletionSession.completionItemResolve(item: req.item, sourcekitd: sourcekitd) + } } diff --git a/Sources/SourceKitLSP/Swift/CodeCompletionSession.swift b/Sources/SourceKitLSP/Swift/CodeCompletionSession.swift index 490eb9bd1..248585f08 100644 --- a/Sources/SourceKitLSP/Swift/CodeCompletionSession.swift +++ b/Sources/SourceKitLSP/Swift/CodeCompletionSession.swift @@ -23,28 +23,53 @@ import SwiftParser @_spi(SourceKitLSP) import SwiftRefactor import SwiftSyntax -/// Data that is attached to a `CompletionItem`. -private struct CompletionItemData: LSPAnyCodable { - let id: Int? +/// Uniquely identifies a code completion session. We need this so that when resolving a code completion item, we can +/// verify that the item to resolve belongs to the code completion session that is currently open. +struct CompletionSessionID: Equatable { + private static let nextSessionID = AtomicUInt32(initialValue: 0) + + let value: UInt32 - init(id: Int?) { - self.id = id + init(value: UInt32) { + self.value = value + } + + static func next() -> CompletionSessionID { + return CompletionSessionID(value: nextSessionID.fetchAndIncrement()) + } +} + +/// Data that is attached to a `CompletionItem`. +struct CompletionItemData: LSPAnyCodable { + let uri: DocumentURI + let sessionId: CompletionSessionID + let itemId: Int + + init(uri: DocumentURI, sessionId: CompletionSessionID, itemId: Int) { + self.uri = uri + self.sessionId = sessionId + self.itemId = itemId } init?(fromLSPDictionary dictionary: [String: LSPAny]) { - if case .int(let id) = dictionary["id"] { - self.id = id - } else { - self.id = nil + guard case .string(let uriString) = dictionary["uri"], + case .int(let sessionId) = dictionary["sessionId"], + case .int(let itemId) = dictionary["itemId"], + let uri = try? DocumentURI(string: uriString) + else { + return nil } + self.uri = uri + self.sessionId = CompletionSessionID(value: UInt32(sessionId)) + self.itemId = itemId } func encodeToLSPAny() -> LSPAny { - var dict: [String: LSPAny] = [:] - if let id { - dict["id"] = .int(id) - } - return .dictionary(dict) + return .dictionary([ + "uri": .string(uri.stringValue), + "sessionId": .int(Int(sessionId.value)), + "itemId": .int(itemId), + ]) } } @@ -108,7 +133,6 @@ class CodeCompletionSession { /// - compileCommand: The compiler arguments to use. /// - options: Further options that can be sent from the editor to control /// completion. - /// - clientSupportsSnippets: Whether the editor supports LSP snippets. /// - filterText: The text by which to filter code completion results. /// - mustReuse: If `true` and there is an active session in this /// `sourcekitd` instance, cancel the request instead of opening a new @@ -125,7 +149,7 @@ class CodeCompletionSession { completionPosition: Position, cursorPosition: Position, compileCommand: SwiftCompileCommand?, - clientSupportsSnippets: Bool, + clientCapabilities: ClientCapabilities, filterText: String ) async throws -> CompletionList { let task = completionQueue.asyncThrowing { @@ -134,7 +158,6 @@ class CodeCompletionSession { session.snapshot.uri == snapshot.uri && session.position == completionPosition && session.compileCommand == compileCommand - && session.clientSupportsSnippets == clientSupportsSnippets if isCompatible { return try await session.update( @@ -155,7 +178,7 @@ class CodeCompletionSession { indentationWidth: indentationWidth, position: completionPosition, compileCommand: compileCommand, - clientSupportsSnippets: clientSupportsSnippets + clientCapabilities: clientCapabilities ) completionSessions[ObjectIdentifier(sourcekitd)] = session return try await session.open(filterText: filterText, position: cursorPosition, in: snapshot) @@ -164,6 +187,26 @@ class CodeCompletionSession { return try await task.valuePropagatingCancellation } + static func completionItemResolve( + item: CompletionItem, + sourcekitd: SourceKitD + ) async throws -> CompletionItem { + guard let data = CompletionItemData(fromLSPAny: item.data) else { + return item + } + let task = completionQueue.asyncThrowing { + guard let session = completionSessions[ObjectIdentifier(sourcekitd)], data.sessionId == session.id else { + throw ResponseError.unknown("No active completion session for \(data.uri)") + } + return await Self.resolveDocumentation( + in: item, + timeout: session.options.sourcekitdRequestTimeoutOrDefault, + sourcekitd: sourcekitd + ) + } + return try await task.valuePropagatingCancellation + } + /// Close all code completion sessions for the given files. /// /// This should only be necessary to do if the dependencies have updated. In all other cases `completionList` will @@ -180,6 +223,7 @@ class CodeCompletionSession { // MARK: - Implementation + private let id: CompletionSessionID private let sourcekitd: any SourceKitD private let snapshot: DocumentSnapshot private let options: SourceKitLSPOptions @@ -188,6 +232,7 @@ class CodeCompletionSession { private let position: Position private let compileCommand: SwiftCompileCommand? private let clientSupportsSnippets: Bool + private let clientSupportsDocumentationResolve: Bool private var state: State = .closed private enum State { @@ -205,15 +250,20 @@ class CodeCompletionSession { indentationWidth: Trivia?, position: Position, compileCommand: SwiftCompileCommand?, - clientSupportsSnippets: Bool + clientCapabilities: ClientCapabilities ) { + self.id = CompletionSessionID.next() self.sourcekitd = sourcekitd self.options = options self.indentationWidth = indentationWidth self.snapshot = snapshot self.position = position self.compileCommand = compileCommand - self.clientSupportsSnippets = clientSupportsSnippets + self.clientSupportsSnippets = clientCapabilities.textDocument?.completion?.completionItem?.snippetSupport ?? false + self.clientSupportsDocumentationResolve = + clientCapabilities.textDocument?.completion?.completionItem?.resolveSupport?.properties.contains("documentation") + ?? false + } private func open( @@ -389,7 +439,7 @@ class CodeCompletionSession { let sourcekitd = self.sourcekitd let keys = sourcekitd.keys - let completionItems = completions.compactMap { (value: SKDResponseDictionary) -> CompletionItem? in + var completionItems = completions.compactMap { (value: SKDResponseDictionary) -> CompletionItem? in guard let name: String = value[keys.description], var insertText: String = value[keys.sourceText] else { @@ -449,7 +499,12 @@ class CodeCompletionSession { sortText = nil } - let data = CompletionItemData(id: value[keys.identifier] as Int?) + let data: CompletionItemData? = + if let identifier: Int = value[keys.identifier] { + CompletionItemData(uri: self.uri, sessionId: self.id, itemId: identifier) + } else { + nil + } let kind: sourcekitd_api_uid_t? = value[sourcekitd.keys.kind] return CompletionItem( @@ -467,26 +522,34 @@ class CodeCompletionSession { ) } - // TODO: Only compute documentation if the client doesn't support `completionItem/resolve` - // (https://github.com/swiftlang/sourcekit-lsp/issues/1935) - let withDocumentation = await completionItems.asyncMap { item in - var item = item - - if let itemId = CompletionItemData(fromLSPAny: item.data)?.id { - let req = sourcekitd.dictionary([ - keys.request: sourcekitd.requests.codeCompleteDocumentation, - keys.identifier: itemId, - ]) - let documentationResponse = try? await sourcekitd.send(req, timeout: .seconds(1), fileContents: snapshot.text) - if let docString: String = documentationResponse?[keys.docBrief] { - item.documentation = .markupContent(MarkupContent(kind: .markdown, value: docString)) - } + if !clientSupportsDocumentationResolve { + completionItems = await completionItems.asyncMap { item in + return await Self.resolveDocumentation(in: item, timeout: .seconds(1), sourcekitd: sourcekitd) } - - return item } - return CompletionList(isIncomplete: isIncomplete, items: withDocumentation) + return CompletionList(isIncomplete: isIncomplete, items: completionItems) + } + + private static func resolveDocumentation( + in item: CompletionItem, + timeout: Duration, + sourcekitd: SourceKitD + ) async -> CompletionItem { + var item = item + if let itemId = CompletionItemData(fromLSPAny: item.data)?.itemId { + let req = sourcekitd.dictionary([ + sourcekitd.keys.request: sourcekitd.requests.codeCompleteDocumentation, + sourcekitd.keys.identifier: itemId, + ]) + let documentationResponse = await orLog("Retrieving documentation for completion item") { + try await sourcekitd.send(req, timeout: timeout, fileContents: nil) + } + if let docString: String = documentationResponse?[sourcekitd.keys.docBrief] { + item.documentation = .markupContent(MarkupContent(kind: .markdown, value: docString)) + } + } + return item } private func computeCompletionTextEdit( diff --git a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift index ecce56826..fd895b2d7 100644 --- a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift +++ b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift @@ -377,7 +377,7 @@ extension SwiftLanguageService { ), hoverProvider: .bool(true), completionProvider: CompletionOptions( - resolveProvider: false, + resolveProvider: true, triggerCharacters: [".", "("] ), definitionProvider: nil, diff --git a/Tests/SourceKitLSPTests/SwiftCompletionTests.swift b/Tests/SourceKitLSPTests/SwiftCompletionTests.swift index ec934b8f9..96eb9c059 100644 --- a/Tests/SourceKitLSPTests/SwiftCompletionTests.swift +++ b/Tests/SourceKitLSPTests/SwiftCompletionTests.swift @@ -13,6 +13,7 @@ import LanguageServerProtocol import SKTestSupport import SourceKitLSP +import SwiftExtensions import XCTest final class SwiftCompletionTests: XCTestCase { @@ -1083,6 +1084,47 @@ final class SwiftCompletionTests: XCTestCase { ["makeInt()", "makeBool()", "makeString()"] ) } + + func testCompletionItemResolve() async throws { + try await SkipUnless.sourcekitdSupportsPlugin() + + let capabilities = ClientCapabilities( + textDocument: TextDocumentClientCapabilities( + completion: TextDocumentClientCapabilities.Completion( + completionItem: TextDocumentClientCapabilities.Completion.CompletionItem( + resolveSupport: TextDocumentClientCapabilities.Completion.CompletionItem.ResolveSupportProperties( + properties: ["documentation"] + ) + ) + ) + ) + ) + + let testClient = try await TestSourceKitLSPClient(capabilities: capabilities) + let uri = DocumentURI(for: .swift) + let positions = testClient.openDocument( + """ + struct Foo { + /// Creates a true value + func makeBool() -> Bool { true } + } + func test(foo: Foo) { + foo.make1️⃣ + } + """, + uri: uri + ) + let completions = try await testClient.send( + CompletionRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"]) + ) + let item = try XCTUnwrap(completions.items.only) + XCTAssertNil(item.documentation) + let resolvedItem = try await testClient.send(CompletionItemResolveRequest(item: item)) + XCTAssertEqual( + resolvedItem.documentation, + .markupContent(MarkupContent(kind: .markdown, value: "Creates a true value")) + ) + } } private func countFs(_ response: CompletionList) -> Int {