diff --git a/Sources/Csourcekitd/include/CodeCompletionSwiftInterop.h b/Sources/Csourcekitd/include/CodeCompletionSwiftInterop.h index eb676cb9f..b9465ef04 100644 --- a/Sources/Csourcekitd/include/CodeCompletionSwiftInterop.h +++ b/Sources/Csourcekitd/include/CodeCompletionSwiftInterop.h @@ -324,6 +324,12 @@ typedef struct { void (^_Null_unspecified handler)(const char *_Null_unspecified) ); + void (*_Nullable completion_item_get_doc_full)( + _Nonnull swiftide_api_completion_response_t, + _Nonnull swiftide_api_completion_item_t, + void (^_Nonnull handler)(const char *_Nullable) + ); + void (*_Nonnull completion_item_get_associated_usrs)( _Null_unspecified swiftide_api_completion_response_t, _Null_unspecified swiftide_api_completion_item_t, diff --git a/Sources/SKTestSupport/SkipUnless.swift b/Sources/SKTestSupport/SkipUnless.swift index ede127af3..b2374706c 100644 --- a/Sources/SKTestSupport/SkipUnless.swift +++ b/Sources/SKTestSupport/SkipUnless.swift @@ -10,6 +10,7 @@ // //===----------------------------------------------------------------------===// +import Csourcekitd import Foundation import LanguageServerProtocol import LanguageServerProtocolExtensions @@ -250,13 +251,8 @@ package actor SkipUnless { line: UInt = #line ) async throws { return try await shared.skipUnlessSupportedByToolchain(swiftVersion: SwiftVersion(6, 2), file: file, line: line) { - guard let sourcekitdPath = await ToolchainRegistry.forTesting.default?.sourcekitd else { - throw GenericError("Could not find SourceKitD") - } - let sourcekitd = try await SourceKitD.getOrCreate( - dylibPath: sourcekitdPath, - pluginPaths: try sourceKitPluginPaths - ) + let sourcekitd = try await getSourceKitD() + do { let response = try await sourcekitd.send( \.codeCompleteSetPopularAPI, @@ -274,6 +270,17 @@ package actor SkipUnless { } } + package static func sourcekitdSupportsFullDocumentationInCompletion( + file: StaticString = #filePath, + line: UInt = #line + ) async throws { + return try await shared.skipUnlessSupportedByToolchain(swiftVersion: SwiftVersion(6, 2), file: file, line: line) { + let sourcekitd = try await getSourceKitD() + + return sourcekitd.ideApi.completion_item_get_doc_full != nil + } + } + package static func canLoadPluginsBuiltByToolchain( file: StaticString = #filePath, line: UInt = #line @@ -352,6 +359,18 @@ package actor SkipUnless { return .featureSupported } } + + private static func getSourceKitD() async throws -> SourceKitD { + guard let sourcekitdPath = await ToolchainRegistry.forTesting.default?.sourcekitd else { + throw GenericError("Could not find SourceKitD") + } + let sourcekitd = try await SourceKitD.getOrCreate( + dylibPath: sourcekitdPath, + pluginPaths: try sourceKitPluginPaths + ) + + return sourcekitd + } } // MARK: - Parsing Swift compiler version diff --git a/Sources/SourceKitD/sourcekitd_functions.swift b/Sources/SourceKitD/sourcekitd_functions.swift index 6edb883c2..705d7f3d5 100644 --- a/Sources/SourceKitD/sourcekitd_functions.swift +++ b/Sources/SourceKitD/sourcekitd_functions.swift @@ -146,6 +146,7 @@ extension sourcekitd_ide_api_functions_t { completion_item_get_source_text: try loadRequired("swiftide_completion_item_get_source_text"), completion_item_get_type_name: try loadRequired("swiftide_completion_item_get_type_name"), completion_item_get_doc_brief: try loadRequired("swiftide_completion_item_get_doc_brief"), + completion_item_get_doc_full: loadOptional("swiftide_completion_item_get_doc_full"), completion_item_get_associated_usrs: try loadRequired("swiftide_completion_item_get_associated_usrs"), completion_item_get_kind: try loadRequired("swiftide_completion_item_get_kind"), completion_item_get_associated_kind: try loadRequired("swiftide_completion_item_get_associated_kind"), diff --git a/Sources/SourceKitLSP/Swift/CodeCompletionSession.swift b/Sources/SourceKitLSP/Swift/CodeCompletionSession.swift index d558828b0..6250c93d1 100644 --- a/Sources/SourceKitLSP/Swift/CodeCompletionSession.swift +++ b/Sources/SourceKitLSP/Swift/CodeCompletionSession.swift @@ -574,13 +574,26 @@ class CodeCompletionSession { fileContents: nil ) } - if let docString: String = documentationResponse?[sourcekitd.keys.docBrief] { + + if let response = documentationResponse, + let docString = documentationString(from: response, sourcekitd: sourcekitd) + { item.documentation = .markupContent(MarkupContent(kind: .markdown, value: docString)) } } return item } + private static func documentationString(from response: SKDResponseDictionary, sourcekitd: SourceKitD) -> String? { + if let docFullAsXML: String = response[sourcekitd.keys.docFullAsXML] { + return orLog("Converting XML documentation to markdown") { + try xmlDocumentationToMarkdown(docFullAsXML, includeDeclaration: false) + } + } + + return response[sourcekitd.keys.docBrief] + } + private func computeCompletionTextEdit( completionPos: Position, requestPosition: Position, diff --git a/Sources/SourceKitLSP/Swift/CommentXML.swift b/Sources/SourceKitLSP/Swift/CommentXML.swift index 3aee62188..6562c24af 100644 --- a/Sources/SourceKitLSP/Swift/CommentXML.swift +++ b/Sources/SourceKitLSP/Swift/CommentXML.swift @@ -24,13 +24,17 @@ enum CommentXMLError: Error { /// Converts from sourcekit's XML documentation format to Markdown. /// /// This code should go away and sourcekitd should return the Markdown directly. -package func xmlDocumentationToMarkdown(_ xmlString: String) throws -> String { +/// +/// - Parameters: +/// - xmlString: The XML string to convert. +/// - includeDeclaration: If true, the declaration will be included at the top of the output markdown. +package func xmlDocumentationToMarkdown(_ xmlString: String, includeDeclaration: Bool = true) throws -> String { let xml = try XMLDocument(xmlString: xmlString) guard let root = xml.rootElement() else { throw CommentXMLError.noRootElement } - var convert = XMLToMarkdown() + var convert = XMLToMarkdown(includeDeclaration: includeDeclaration) convert.out.reserveCapacity(xmlString.utf16.count) convert.toMarkdown(root) return convert.out @@ -42,6 +46,11 @@ private struct XMLToMarkdown { let indentWidth: Int = 4 var lineNumber: Int = 0 var inParam: Bool = false + let includeDeclaration: Bool + + init(includeDeclaration: Bool) { + self.includeDeclaration = includeDeclaration + } mutating func newlineIfNeeded(count: Int = 1) { if !out.isEmpty && out.last! != "\n" { @@ -74,10 +83,12 @@ private struct XMLToMarkdown { mutating func toMarkdown(_ node: XMLElement) { switch node.name { case "Declaration": - newlineIfNeeded(count: 2) - out += "```swift\n" - toMarkdown(node.children) - out += "\n```\n" + if includeDeclaration { + newlineIfNeeded(count: 2) + out += "```swift\n" + toMarkdown(node.children) + out += "\n```\n" + } case "Name", "USR", "Direction": break diff --git a/Sources/SwiftSourceKitPlugin/ASTCompletion/CompletionSession.swift b/Sources/SwiftSourceKitPlugin/ASTCompletion/CompletionSession.swift index dcd89f548..a8d392d77 100644 --- a/Sources/SwiftSourceKitPlugin/ASTCompletion/CompletionSession.swift +++ b/Sources/SwiftSourceKitPlugin/ASTCompletion/CompletionSession.swift @@ -241,6 +241,16 @@ struct ExtendedCompletionInfo { return result } + var fullDocumentation: String? { + var result: String? = nil + session.sourcekitd.ideApi.completion_item_get_doc_full?(session.response, rawItem) { + if let cstr = $0 { + result = String(cString: cstr) + } + } + return result + } + var associatedUSRs: [String] { var result: [String] = [] session.sourcekitd.ideApi.completion_item_get_associated_usrs(session.response, rawItem) { ptr, len in diff --git a/Sources/SwiftSourceKitPlugin/CompletionProvider.swift b/Sources/SwiftSourceKitPlugin/CompletionProvider.swift index d02ed6d7b..575b693f9 100644 --- a/Sources/SwiftSourceKitPlugin/CompletionProvider.swift +++ b/Sources/SwiftSourceKitPlugin/CompletionProvider.swift @@ -264,6 +264,7 @@ actor CompletionProvider { return request.sourcekitd.responseDictionary([ request.sourcekitd.keys.docBrief: info.briefDocumentation, + request.sourcekitd.keys.docFullAsXML: info.fullDocumentation, request.sourcekitd.keys.associatedUSRs: info.associatedUSRs as [SKDResponseValue]?, ]) } diff --git a/Tests/SourceKitLSPTests/LocalSwiftTests.swift b/Tests/SourceKitLSPTests/LocalSwiftTests.swift index e281365a7..b2b70aa64 100644 --- a/Tests/SourceKitLSPTests/LocalSwiftTests.swift +++ b/Tests/SourceKitLSPTests/LocalSwiftTests.swift @@ -1149,6 +1149,32 @@ final class LocalSwiftTests: XCTestCase { ) } + func testXMLToMarkdownCommentOmitDeclaration() { + XCTAssertEqual( + try xmlDocumentationToMarkdown( + """ + \ + func pi()\ + \ + Computes π with infinite precision.\ + \ + This function doesn’t terminate.\ + \ + \ + + """, + includeDeclaration: false + ), + """ + Computes π with infinite precision. + + ### Discussion + + This function doesn’t terminate. + """ + ) + } + func testSymbolInfo() async throws { let testClient = try await TestSourceKitLSPClient() let url = URL(fileURLWithPath: "/\(UUID())/a.swift") diff --git a/Tests/SourceKitLSPTests/SwiftCompletionTests.swift b/Tests/SourceKitLSPTests/SwiftCompletionTests.swift index d08e0233c..a48e7810e 100644 --- a/Tests/SourceKitLSPTests/SwiftCompletionTests.swift +++ b/Tests/SourceKitLSPTests/SwiftCompletionTests.swift @@ -31,6 +31,7 @@ final class SwiftCompletionTests: XCTestCase { func testCompletionBasic() async throws { try await SkipUnless.sourcekitdSupportsPlugin() + try await SkipUnless.sourcekitdSupportsFullDocumentationInCompletion() let testClient = try await TestSourceKitLSPClient() let uri = DocumentURI(for: .swift) @@ -39,6 +40,8 @@ final class SwiftCompletionTests: XCTestCase { """ struct S { /// Documentation for `abc`. + /// + /// - Note: This is a note. var abc: Int func test(a: Int) { @@ -67,7 +70,16 @@ final class SwiftCompletionTests: XCTestCase { if let abc = abc { XCTAssertEqual(abc.kind, .property) XCTAssertEqual(abc.detail, "Int") - XCTAssertEqual(abc.documentation, .markupContent(MarkupContent(kind: .markdown, value: "Documentation for abc."))) + assertMarkdown( + documentation: abc.documentation, + expected: """ + Documentation for `abc`. + + ### Discussion + + This is a note. + """ + ) XCTAssertEqual(abc.filterText, "abc") XCTAssertEqual(abc.textEdit, .textEdit(TextEdit(range: Range(positions["1️⃣"]), newText: "abc"))) XCTAssertEqual(abc.insertText, "abc") @@ -87,7 +99,16 @@ final class SwiftCompletionTests: XCTestCase { // If we switch to server-side filtering this will change. XCTAssertEqual(abc.kind, .property) XCTAssertEqual(abc.detail, "Int") - XCTAssertEqual(abc.documentation, .markupContent(MarkupContent(kind: .markdown, value: "Documentation for abc."))) + assertMarkdown( + documentation: abc.documentation, + expected: """ + Documentation for `abc`. + + ### Discussion + + This is a note. + """ + ) XCTAssertEqual(abc.filterText, "abc") XCTAssertEqual(abc.textEdit, .textEdit(TextEdit(range: positions["1️⃣"].. Int { return response.items.filter { $0.label.hasPrefix("f") }.count } +private func assertMarkdown( + documentation: StringOrMarkupContent?, + expected: String, + file: StaticString = #filePath, + line: UInt = #line +) { + XCTAssertEqual(documentation, .markupContent(MarkupContent(kind: .markdown, value: expected)), file: file, line: line) +} + fileprivate extension Position { func adding(columns: Int) -> Position { return Position(line: line, utf16index: utf16index + columns) diff --git a/Tests/SwiftSourceKitPluginTests/SwiftSourceKitPluginTests.swift b/Tests/SwiftSourceKitPluginTests/SwiftSourceKitPluginTests.swift index 59bd49884..6ea386100 100644 --- a/Tests/SwiftSourceKitPluginTests/SwiftSourceKitPluginTests.swift +++ b/Tests/SwiftSourceKitPluginTests/SwiftSourceKitPluginTests.swift @@ -46,6 +46,8 @@ final class SwiftSourceKitPluginTests: XCTestCase { func testBasicCompletion() async throws { try await SkipUnless.sourcekitdSupportsPlugin() + try await SkipUnless.sourcekitdSupportsFullDocumentationInCompletion() + let sourcekitd = try await getSourceKitD() let path = scratchFilePath() let positions = try await sourcekitd.openDocument( @@ -203,7 +205,8 @@ final class SwiftSourceKitPluginTests: XCTestCase { XCTAssertEqual(result2.items.count, 1) XCTAssertEqual(result2.items[0].name, "") let doc = try await sourcekitd.completeDocumentation(id: result2.items[0].id) - XCTAssertEqual(doc.docBrief, nil) + XCTAssertNil(doc.docFullAsXML) + XCTAssertNil(doc.docBrief) } func testMultipleFiles() async throws { @@ -403,6 +406,8 @@ final class SwiftSourceKitPluginTests: XCTestCase { func testDocumentation() async throws { try await SkipUnless.sourcekitdSupportsPlugin() + try await SkipUnless.sourcekitdSupportsFullDocumentationInCompletion() + let sourcekitd = try await getSourceKitD() let path = scratchFilePath() let positions = try await sourcekitd.openDocument( @@ -436,14 +441,44 @@ final class SwiftSourceKitPluginTests: XCTestCase { let sym3 = try unwrap(result.items.first(where: { $0.name == "foo3()" }), "did not find foo3; got \(result.items)") let sym1Doc = try await sourcekitd.completeDocumentation(id: sym1.id) + XCTAssertEqual( + sym1Doc.docFullAsXML, + """ + \ + foo1()\ + s:1a1PP4foo1yyF\ + func foo1()\ + \ + Protocol P foo1\ + \ + This documentation comment was inherited from P.\ + \ + \ + + """ + ) XCTAssertEqual(sym1Doc.docBrief, "Protocol P foo1") XCTAssertEqual(sym1Doc.associatedUSRs, ["s:1a1SV4foo1yyF", "s:1a1PP4foo1yyF"]) let sym2Doc = try await sourcekitd.completeDocumentation(id: sym2.id) + XCTAssertEqual( + sym2Doc.docFullAsXML, + """ + \ + foo2()\ + s:1a1SV4foo2yyF\ + func foo2()\ + \ + Struct S foo2\ + \ + + """ + ) XCTAssertEqual(sym2Doc.docBrief, "Struct S foo2") XCTAssertEqual(sym2Doc.associatedUSRs, ["s:1a1SV4foo2yyF"]) let sym3Doc = try await sourcekitd.completeDocumentation(id: sym3.id) + XCTAssertNil(sym3Doc.docFullAsXML) XCTAssertNil(sym3Doc.docBrief) XCTAssertEqual(sym3Doc.associatedUSRs, ["s:1a1SV4foo3yyF"]) } @@ -1766,11 +1801,13 @@ fileprivate struct CompletionResult: Equatable, Sendable { } fileprivate struct CompletionDocumentation { + var docFullAsXML: String? = nil var docBrief: String? = nil var associatedUSRs: [String] = [] init(_ dict: SKDResponseDictionary) { let keys = dict.sourcekitd.keys + self.docFullAsXML = dict[keys.docFullAsXML] self.docBrief = dict[keys.docBrief] self.associatedUSRs = dict[keys.associatedUSRs]?.asStringArray ?? [] }