Skip to content

Commit 1982798

Browse files
authored
Fetch full documentation in code completion (#2207)
When resolving documentation for code completion items, we fetch full documentation through the newly added `swiftide_completion_item_get_doc_full_copy` SourceKitD function, if not found we fallback to brief documentation as before using `swiftide_completion_item_get_doc_brief`. > [!NOTE] > Unlike brief documentation, SourceKitD doesn't cache full documentation for completion results to avoid bloating memory with a lot of large strings. > > As of now, SourceKit-LSP doesn't cache completion item documentation either, should we introduce a new full documentation cache (e.g. using `LRUCache`)?
1 parent 6301397 commit 1982798

File tree

8 files changed

+196
-14
lines changed

8 files changed

+196
-14
lines changed

Sources/Csourcekitd/include/CodeCompletionSwiftInterop.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,16 @@ typedef struct {
324324
void (^_Null_unspecified handler)(const char *_Null_unspecified)
325325
);
326326

327+
void (*_Nullable completion_item_get_doc_full_as_xml)(
328+
_Nonnull swiftide_api_completion_response_t,
329+
_Nonnull swiftide_api_completion_item_t,
330+
void (^_Nonnull handler)(const char *_Nullable));
331+
332+
void (*_Nullable completion_item_get_doc_raw)(
333+
_Nonnull swiftide_api_completion_response_t,
334+
_Nonnull swiftide_api_completion_item_t,
335+
void (^_Nonnull handler)(const char *_Nullable));
336+
327337
void (*_Nonnull completion_item_get_associated_usrs)(
328338
_Null_unspecified swiftide_api_completion_response_t,
329339
_Null_unspecified swiftide_api_completion_item_t,

Sources/SKTestSupport/SkipUnless.swift

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
//
1111
//===----------------------------------------------------------------------===//
1212

13+
import Csourcekitd
1314
import Foundation
1415
import InProcessClient
1516
import LanguageServerProtocol
@@ -250,13 +251,8 @@ package actor SkipUnless {
250251
line: UInt = #line
251252
) async throws {
252253
return try await shared.skipUnlessSupportedByToolchain(swiftVersion: SwiftVersion(6, 2), file: file, line: line) {
253-
guard let sourcekitdPath = await ToolchainRegistry.forTesting.default?.sourcekitd else {
254-
throw GenericError("Could not find SourceKitD")
255-
}
256-
let sourcekitd = try await SourceKitD.getOrCreate(
257-
dylibPath: sourcekitdPath,
258-
pluginPaths: try sourceKitPluginPaths
259-
)
254+
let sourcekitd = try await getSourceKitD()
255+
260256
do {
261257
let response = try await sourcekitd.send(
262258
\.codeCompleteSetPopularAPI,
@@ -274,6 +270,17 @@ package actor SkipUnless {
274270
}
275271
}
276272

273+
package static func sourcekitdSupportsFullDocumentationInCompletion(
274+
file: StaticString = #filePath,
275+
line: UInt = #line
276+
) async throws {
277+
return try await shared.skipUnlessSupportedByToolchain(swiftVersion: SwiftVersion(6, 2), file: file, line: line) {
278+
let sourcekitd = try await getSourceKitD()
279+
280+
return sourcekitd.ideApi.completion_item_get_doc_raw != nil
281+
}
282+
}
283+
277284
package static func canLoadPluginsBuiltByToolchain(
278285
file: StaticString = #filePath,
279286
line: UInt = #line
@@ -424,6 +431,18 @@ package actor SkipUnless {
424431
}
425432
#endif
426433
}
434+
435+
private static func getSourceKitD() async throws -> SourceKitD {
436+
guard let sourcekitdPath = await ToolchainRegistry.forTesting.default?.sourcekitd else {
437+
throw GenericError("Could not find SourceKitD")
438+
}
439+
let sourcekitd = try await SourceKitD.getOrCreate(
440+
dylibPath: sourcekitdPath,
441+
pluginPaths: try sourceKitPluginPaths
442+
)
443+
444+
return sourcekitd
445+
}
427446
}
428447

429448
// MARK: - Parsing Swift compiler version

Sources/SourceKitD/sourcekitd_functions.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ extension sourcekitd_ide_api_functions_t {
146146
completion_item_get_source_text: try loadRequired("swiftide_completion_item_get_source_text"),
147147
completion_item_get_type_name: try loadRequired("swiftide_completion_item_get_type_name"),
148148
completion_item_get_doc_brief: try loadRequired("swiftide_completion_item_get_doc_brief"),
149+
completion_item_get_doc_full_as_xml: loadOptional("swiftide_completion_item_get_doc_full_as_xml"),
150+
completion_item_get_doc_raw: loadOptional("swiftide_completion_item_get_doc_raw"),
149151
completion_item_get_associated_usrs: try loadRequired("swiftide_completion_item_get_associated_usrs"),
150152
completion_item_get_kind: try loadRequired("swiftide_completion_item_get_kind"),
151153
completion_item_get_associated_kind: try loadRequired("swiftide_completion_item_get_associated_kind"),

Sources/SwiftLanguageService/CodeCompletionSession.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,13 +575,24 @@ class CodeCompletionSession {
575575
fileContents: nil
576576
)
577577
}
578-
if let docString: String = documentationResponse?[sourcekitd.keys.docBrief] {
578+
579+
if let response = documentationResponse,
580+
let docString = documentationString(from: response, sourcekitd: sourcekitd)
581+
{
579582
item.documentation = .markupContent(MarkupContent(kind: .markdown, value: docString))
580583
}
581584
}
582585
return item
583586
}
584587

588+
private static func documentationString(from response: SKDResponseDictionary, sourcekitd: SourceKitD) -> String? {
589+
if let docComment: String = response[sourcekitd.keys.docComment] {
590+
return docComment
591+
}
592+
593+
return response[sourcekitd.keys.docBrief]
594+
}
595+
585596
private func computeCompletionTextEdit(
586597
completionPos: Position,
587598
requestPosition: Position,

Sources/SwiftSourceKitPlugin/ASTCompletion/CompletionSession.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,26 @@ struct ExtendedCompletionInfo {
241241
return result
242242
}
243243

244+
var fullDocumentationAsXML: String? {
245+
var result: String? = nil
246+
session.sourcekitd.ideApi.completion_item_get_doc_full_as_xml?(session.response, rawItem) {
247+
if let cstr = $0 {
248+
result = String(cString: cstr)
249+
}
250+
}
251+
return result
252+
}
253+
254+
var rawDocumentation: String? {
255+
var result: String? = nil
256+
session.sourcekitd.ideApi.completion_item_get_doc_raw?(session.response, rawItem) {
257+
if let cstr = $0 {
258+
result = String(cString: cstr)
259+
}
260+
}
261+
return result
262+
}
263+
244264
var associatedUSRs: [String] {
245265
var result: [String] = []
246266
session.sourcekitd.ideApi.completion_item_get_associated_usrs(session.response, rawItem) { ptr, len in

Sources/SwiftSourceKitPlugin/CompletionProvider.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,8 @@ actor CompletionProvider {
266266

267267
return request.sourcekitd.responseDictionary([
268268
request.sourcekitd.keys.docBrief: info.briefDocumentation,
269+
request.sourcekitd.keys.docFullAsXML: info.fullDocumentationAsXML,
270+
request.sourcekitd.keys.docComment: info.rawDocumentation,
269271
request.sourcekitd.keys.associatedUSRs: info.associatedUSRs as [SKDResponseValue]?,
270272
])
271273
}

Tests/SourceKitLSPTests/SwiftCompletionTests.swift

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ final class SwiftCompletionTests: XCTestCase {
3131

3232
func testCompletionBasic() async throws {
3333
try await SkipUnless.sourcekitdSupportsPlugin()
34+
try await SkipUnless.sourcekitdSupportsFullDocumentationInCompletion()
3435

3536
let testClient = try await TestSourceKitLSPClient()
3637
let uri = DocumentURI(for: .swift)
@@ -39,6 +40,13 @@ final class SwiftCompletionTests: XCTestCase {
3940
"""
4041
struct S {
4142
/// Documentation for `abc`.
43+
///
44+
/// _More_ documentation for `abc`.
45+
///
46+
/// Usage:
47+
/// ```swift
48+
/// S().abc
49+
/// ```
4250
var abc: Int
4351
4452
func test(a: Int) {
@@ -67,7 +75,19 @@ final class SwiftCompletionTests: XCTestCase {
6775
if let abc = abc {
6876
XCTAssertEqual(abc.kind, .property)
6977
XCTAssertEqual(abc.detail, "Int")
70-
XCTAssertEqual(abc.documentation, .markupContent(MarkupContent(kind: .markdown, value: "Documentation for abc.")))
78+
assertMarkdown(
79+
documentation: abc.documentation,
80+
expected: """
81+
Documentation for `abc`.
82+
83+
_More_ documentation for `abc`.
84+
85+
Usage:
86+
```swift
87+
S().abc
88+
```
89+
"""
90+
)
7191
XCTAssertEqual(abc.filterText, "abc")
7292
XCTAssertEqual(abc.textEdit, .textEdit(TextEdit(range: Range(positions["1️⃣"]), newText: "abc")))
7393
XCTAssertEqual(abc.insertText, "abc")
@@ -87,7 +107,19 @@ final class SwiftCompletionTests: XCTestCase {
87107
// If we switch to server-side filtering this will change.
88108
XCTAssertEqual(abc.kind, .property)
89109
XCTAssertEqual(abc.detail, "Int")
90-
XCTAssertEqual(abc.documentation, .markupContent(MarkupContent(kind: .markdown, value: "Documentation for abc.")))
110+
assertMarkdown(
111+
documentation: abc.documentation,
112+
expected: """
113+
Documentation for `abc`.
114+
115+
_More_ documentation for `abc`.
116+
117+
Usage:
118+
```swift
119+
S().abc
120+
```
121+
"""
122+
)
91123
XCTAssertEqual(abc.filterText, "abc")
92124
XCTAssertEqual(abc.textEdit, .textEdit(TextEdit(range: positions["1️⃣"]..<offsetPosition, newText: "abc")))
93125
XCTAssertEqual(abc.insertText, "abc")
@@ -1154,6 +1186,7 @@ final class SwiftCompletionTests: XCTestCase {
11541186

11551187
func testCompletionItemResolve() async throws {
11561188
try await SkipUnless.sourcekitdSupportsPlugin()
1189+
try await SkipUnless.sourcekitdSupportsFullDocumentationInCompletion()
11571190

11581191
let capabilities = ClientCapabilities(
11591192
textDocument: TextDocumentClientCapabilities(
@@ -1187,9 +1220,37 @@ final class SwiftCompletionTests: XCTestCase {
11871220
let item = try XCTUnwrap(completions.items.only)
11881221
XCTAssertNil(item.documentation)
11891222
let resolvedItem = try await testClient.send(CompletionItemResolveRequest(item: item))
1190-
XCTAssertEqual(
1191-
resolvedItem.documentation,
1192-
.markupContent(MarkupContent(kind: .markdown, value: "Creates a true value"))
1223+
assertMarkdown(
1224+
documentation: resolvedItem.documentation,
1225+
expected: "Creates a true value"
1226+
)
1227+
}
1228+
1229+
func testCompletionBriefDocumentationFallback() async throws {
1230+
try await SkipUnless.sourcekitdSupportsPlugin()
1231+
try await SkipUnless.sourcekitdSupportsFullDocumentationInCompletion()
1232+
1233+
let testClient = try await TestSourceKitLSPClient()
1234+
let uri = DocumentURI(for: .swift)
1235+
1236+
// We test completion for result builder build functions since they don't have full documentation
1237+
// but still have brief documentation.
1238+
let positions = testClient.openDocument(
1239+
"""
1240+
@resultBuilder
1241+
struct AnyBuilder {
1242+
static func 1️⃣
1243+
}
1244+
""",
1245+
uri: uri
1246+
)
1247+
let completions = try await testClient.send(
1248+
CompletionRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"])
1249+
)
1250+
let item = try XCTUnwrap(completions.items.filter { $0.label.contains("buildBlock") }.only)
1251+
assertMarkdown(
1252+
documentation: item.documentation,
1253+
expected: "Required by every result builder to build combined results from statement blocks"
11931254
)
11941255
}
11951256

@@ -1253,6 +1314,20 @@ private func countFs(_ response: CompletionList) -> Int {
12531314
return response.items.filter { $0.label.hasPrefix("f") }.count
12541315
}
12551316

1317+
private func assertMarkdown(
1318+
documentation: StringOrMarkupContent?,
1319+
expected: String,
1320+
file: StaticString = #filePath,
1321+
line: UInt = #line
1322+
) {
1323+
XCTAssertEqual(
1324+
documentation,
1325+
.markupContent(MarkupContent(kind: .markdown, value: expected)),
1326+
file: file,
1327+
line: line
1328+
)
1329+
}
1330+
12561331
fileprivate extension Position {
12571332
func adding(columns: Int) -> Position {
12581333
return Position(line: line, utf16index: utf16index + columns)

Tests/SwiftSourceKitPluginTests/SwiftSourceKitPluginTests.swift

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ final class SwiftSourceKitPluginTests: XCTestCase {
4646

4747
func testBasicCompletion() async throws {
4848
try await SkipUnless.sourcekitdSupportsPlugin()
49+
try await SkipUnless.sourcekitdSupportsFullDocumentationInCompletion()
50+
4951
let sourcekitd = try await getSourceKitD()
5052
let path = scratchFilePath()
5153
let positions = try await sourcekitd.openDocument(
@@ -203,7 +205,9 @@ final class SwiftSourceKitPluginTests: XCTestCase {
203205
XCTAssertEqual(result2.items.count, 1)
204206
XCTAssertEqual(result2.items[0].name, "")
205207
let doc = try await sourcekitd.completeDocumentation(id: result2.items[0].id)
206-
XCTAssertEqual(doc.docBrief, nil)
208+
XCTAssertNil(doc.docComment)
209+
XCTAssertNil(doc.docFullAsXML)
210+
XCTAssertNil(doc.docBrief)
207211
}
208212

209213
func testMultipleFiles() async throws {
@@ -403,6 +407,8 @@ final class SwiftSourceKitPluginTests: XCTestCase {
403407

404408
func testDocumentation() async throws {
405409
try await SkipUnless.sourcekitdSupportsPlugin()
410+
try await SkipUnless.sourcekitdSupportsFullDocumentationInCompletion()
411+
406412
let sourcekitd = try await getSourceKitD()
407413
let path = scratchFilePath()
408414
let positions = try await sourcekitd.openDocument(
@@ -436,14 +442,47 @@ final class SwiftSourceKitPluginTests: XCTestCase {
436442
let sym3 = try unwrap(result.items.first(where: { $0.name == "foo3()" }), "did not find foo3; got \(result.items)")
437443

438444
let sym1Doc = try await sourcekitd.completeDocumentation(id: sym1.id)
445+
XCTAssertEqual(sym1Doc.docComment, "Protocol P foo1")
446+
XCTAssertEqual(
447+
sym1Doc.docFullAsXML,
448+
"""
449+
<Function file="\(path)" line="3" column="8">\
450+
<Name>foo1()</Name>\
451+
<USR>s:1a1PP4foo1yyF</USR>\
452+
<Declaration>func foo1()</Declaration>\
453+
<CommentParts>\
454+
<Abstract><Para>Protocol P foo1</Para></Abstract>\
455+
<Discussion><Note>\
456+
<Para>This documentation comment was inherited from <codeVoice>P</codeVoice>.</Para>\
457+
</Note></Discussion>\
458+
</CommentParts>\
459+
</Function>
460+
"""
461+
)
439462
XCTAssertEqual(sym1Doc.docBrief, "Protocol P foo1")
440463
XCTAssertEqual(sym1Doc.associatedUSRs, ["s:1a1SV4foo1yyF", "s:1a1PP4foo1yyF"])
441464

442465
let sym2Doc = try await sourcekitd.completeDocumentation(id: sym2.id)
466+
XCTAssertEqual(sym2Doc.docComment, "Struct S foo2")
467+
XCTAssertEqual(
468+
sym2Doc.docFullAsXML,
469+
"""
470+
<Function file="\(path)" line="8" column="8">\
471+
<Name>foo2()</Name>\
472+
<USR>s:1a1SV4foo2yyF</USR>\
473+
<Declaration>func foo2()</Declaration>\
474+
<CommentParts>\
475+
<Abstract><Para>Struct S foo2</Para></Abstract>\
476+
</CommentParts>\
477+
</Function>
478+
"""
479+
)
443480
XCTAssertEqual(sym2Doc.docBrief, "Struct S foo2")
444481
XCTAssertEqual(sym2Doc.associatedUSRs, ["s:1a1SV4foo2yyF"])
445482

446483
let sym3Doc = try await sourcekitd.completeDocumentation(id: sym3.id)
484+
XCTAssertNil(sym3Doc.docComment)
485+
XCTAssertNil(sym3Doc.docFullAsXML)
447486
XCTAssertNil(sym3Doc.docBrief)
448487
XCTAssertEqual(sym3Doc.associatedUSRs, ["s:1a1SV4foo3yyF"])
449488
}
@@ -1766,11 +1805,15 @@ private struct CompletionResult: Equatable, Sendable {
17661805
}
17671806

17681807
private struct CompletionDocumentation {
1808+
var docComment: String? = nil
1809+
var docFullAsXML: String? = nil
17691810
var docBrief: String? = nil
17701811
var associatedUSRs: [String] = []
17711812

17721813
init(_ dict: SKDResponseDictionary) {
17731814
let keys = dict.sourcekitd.keys
1815+
self.docComment = dict[keys.docComment]
1816+
self.docFullAsXML = dict[keys.docFullAsXML]
17741817
self.docBrief = dict[keys.docBrief]
17751818
self.associatedUSRs = dict[keys.associatedUSRs]?.asStringArray ?? []
17761819
}

0 commit comments

Comments
 (0)