Skip to content

Fetch full documentation in code completion #2207

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Sources/Csourcekitd/include/CodeCompletionSwiftInterop.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 26 additions & 7 deletions Sources/SKTestSupport/SkipUnless.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
//
//===----------------------------------------------------------------------===//

import Csourcekitd
import Foundation
import LanguageServerProtocol
import LanguageServerProtocolExtensions
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Sources/SourceKitD/sourcekitd_functions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
15 changes: 14 additions & 1 deletion Sources/SourceKitLSP/Swift/CodeCompletionSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 17 additions & 6 deletions Sources/SourceKitLSP/Swift/CommentXML.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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" {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Sources/SwiftSourceKitPlugin/CompletionProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]?,
])
}
Expand Down
26 changes: 26 additions & 0 deletions Tests/SourceKitLSPTests/LocalSwiftTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1149,6 +1149,32 @@ final class LocalSwiftTests: XCTestCase {
)
}

func testXMLToMarkdownCommentOmitDeclaration() {
XCTAssertEqual(
try xmlDocumentationToMarkdown(
"""
<Function file="Pi.swift" line="3" column="14">\
<Declaration>func pi()</Declaration>\
<CommentParts>\
<Abstract><Para>Computes π with infinite precision.</Para></Abstract>\
<Discussion>\
<Para>This function doesn’t terminate.</Para>\
</Discussion>\
</CommentParts>\
</Function>
""",
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")
Expand Down
73 changes: 68 additions & 5 deletions Tests/SourceKitLSPTests/SwiftCompletionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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")
Expand All @@ -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️⃣"]..<offsetPosition, newText: "abc")))
XCTAssertEqual(abc.insertText, "abc")
Expand All @@ -109,6 +130,8 @@ final class SwiftCompletionTests: XCTestCase {
"""
struct S {
/// Documentation for `abc`.
///
/// - Note: This is a note.
var abc: Int

func test(a: Int) {
Expand Down Expand Up @@ -165,6 +188,8 @@ final class SwiftCompletionTests: XCTestCase {
"""
struct S {
/// Documentation for `abc`.
///
/// - Note: This is a note.
var abc: Int

func test(a: Int) {
Expand Down Expand Up @@ -1154,6 +1179,7 @@ final class SwiftCompletionTests: XCTestCase {

func testCompletionItemResolve() async throws {
try await SkipUnless.sourcekitdSupportsPlugin()
try await SkipUnless.sourcekitdSupportsFullDocumentationInCompletion()

let capabilities = ClientCapabilities(
textDocument: TextDocumentClientCapabilities(
Expand Down Expand Up @@ -1187,9 +1213,37 @@ final class SwiftCompletionTests: XCTestCase {
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"))
assertMarkdown(
documentation: resolvedItem.documentation,
expected: "Creates a true value"
)
}

func testCompletionBriefDocumentationFallback() async throws {
try await SkipUnless.sourcekitdSupportsPlugin()
try await SkipUnless.sourcekitdSupportsFullDocumentationInCompletion()

let testClient = try await TestSourceKitLSPClient()
let uri = DocumentURI(for: .swift)

// We test completion for result builder build functions since they don't have full documentation
// but still have brief documentation.
let positions = testClient.openDocument(
"""
@resultBuilder
struct AnyBuilder {
static func 1️⃣
}
""",
uri: uri
)
let completions = try await testClient.send(
CompletionRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"])
)
let item = try XCTUnwrap(completions.items.filter { $0.label.contains("buildBlock") }.only)
assertMarkdown(
documentation: item.documentation,
expected: "Required by every result builder to build combined results from statement blocks"
)
}

Expand Down Expand Up @@ -1253,6 +1307,15 @@ private func countFs(_ response: CompletionList) -> 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)
Expand Down
Loading