Skip to content

Commit 03da4a4

Browse files
authored
Merge pull request #1937 from ahoppen/use-plugin
Use the SourceKit plugin for the code completion request
2 parents 5196c48 + 2e21be8 commit 03da4a4

12 files changed

+383
-208
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
#if compiler(>=6)
14+
package import LanguageServerProtocol
15+
#else
16+
import LanguageServerProtocol
17+
#endif
18+
19+
extension Array<CompletionItem> {
20+
/// Remove `sortText` and `data` from all completion items as these are not stable across runs. Instead, sort items
21+
/// by `sortText` to ensure we test them in the order that an editor would display them in.
22+
package var clearingUnstableValues: [CompletionItem] {
23+
return
24+
self
25+
.sorted(by: { ($0.sortText ?? "") < ($1.sortText ?? "") })
26+
.map {
27+
var item = $0
28+
item.sortText = nil
29+
item.data = nil
30+
return item
31+
}
32+
}
33+
}

Sources/SourceKitLSP/Swift/CodeCompletion.swift

+1-3
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,7 @@ extension SwiftLanguageService {
2929
let snapshot = try documentManager.latestSnapshot(req.textDocument.uri)
3030

3131
let completionPos = await adjustPositionToStartOfIdentifier(req.position, in: snapshot)
32-
let offset = snapshot.utf8Offset(of: completionPos)
33-
let filterText = String(snapshot.text[snapshot.indexOf(utf8Offset: offset)..<snapshot.index(of: req.position)])
32+
let filterText = String(snapshot.text[snapshot.index(of: completionPos)..<snapshot.index(of: req.position)])
3433

3534
let clientSupportsSnippets =
3635
capabilityRegistry.clientCapabilities.textDocument?.completion?.completionItem?.snippetSupport ?? false
@@ -44,7 +43,6 @@ extension SwiftLanguageService {
4443
options: options,
4544
indentationWidth: inferredIndentationWidth,
4645
completionPosition: completionPos,
47-
completionUtf8Offset: offset,
4846
cursorPosition: req.position,
4947
compileCommand: buildSettings,
5048
clientSupportsSnippets: clientSupportsSnippets,

Sources/SourceKitLSP/Swift/CodeCompletionSession.swift

+94-21
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,31 @@ 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?
29+
30+
init(id: Int?) {
31+
self.id = id
32+
}
33+
34+
init?(fromLSPDictionary dictionary: [String: LSPAny]) {
35+
if case .int(let id) = dictionary["id"] {
36+
self.id = id
37+
} else {
38+
self.id = nil
39+
}
40+
}
41+
42+
func encodeToLSPAny() -> LSPAny {
43+
var dict: [String: LSPAny] = [:]
44+
if let id {
45+
dict["id"] = .int(id)
46+
}
47+
return .dictionary(dict)
48+
}
49+
}
50+
2651
/// Represents a code-completion session for a given source location that can be efficiently
2752
/// re-filtered by calling `update()`.
2853
///
@@ -98,7 +123,6 @@ class CodeCompletionSession {
98123
options: SourceKitLSPOptions,
99124
indentationWidth: Trivia?,
100125
completionPosition: Position,
101-
completionUtf8Offset: Int,
102126
cursorPosition: Position,
103127
compileCommand: SwiftCompileCommand?,
104128
clientSupportsSnippets: Bool,
@@ -107,8 +131,9 @@ class CodeCompletionSession {
107131
let task = completionQueue.asyncThrowing {
108132
if let session = completionSessions[ObjectIdentifier(sourcekitd)], session.state == .open {
109133
let isCompatible =
110-
session.snapshot.uri == snapshot.uri && session.utf8StartOffset == completionUtf8Offset
111-
&& session.position == completionPosition && session.compileCommand == compileCommand
134+
session.snapshot.uri == snapshot.uri
135+
&& session.position == completionPosition
136+
&& session.compileCommand == compileCommand
112137
&& session.clientSupportsSnippets == clientSupportsSnippets
113138

114139
if isCompatible {
@@ -128,7 +153,6 @@ class CodeCompletionSession {
128153
snapshot: snapshot,
129154
options: options,
130155
indentationWidth: indentationWidth,
131-
utf8Offset: completionUtf8Offset,
132156
position: completionPosition,
133157
compileCommand: compileCommand,
134158
clientSupportsSnippets: clientSupportsSnippets
@@ -161,7 +185,6 @@ class CodeCompletionSession {
161185
private let options: SourceKitLSPOptions
162186
/// The inferred indentation width of the source file the completion is being performed in
163187
private let indentationWidth: Trivia?
164-
private let utf8StartOffset: Int
165188
private let position: Position
166189
private let compileCommand: SwiftCompileCommand?
167190
private let clientSupportsSnippets: Bool
@@ -180,7 +203,6 @@ class CodeCompletionSession {
180203
snapshot: DocumentSnapshot,
181204
options: SourceKitLSPOptions,
182205
indentationWidth: Trivia?,
183-
utf8Offset: Int,
184206
position: Position,
185207
compileCommand: SwiftCompileCommand?,
186208
clientSupportsSnippets: Bool
@@ -189,30 +211,30 @@ class CodeCompletionSession {
189211
self.options = options
190212
self.indentationWidth = indentationWidth
191213
self.snapshot = snapshot
192-
self.utf8StartOffset = utf8Offset
193214
self.position = position
194215
self.compileCommand = compileCommand
195216
self.clientSupportsSnippets = clientSupportsSnippets
196217
}
197218

198219
private func open(
199220
filterText: String,
200-
position: Position,
221+
position cursorPosition: Position,
201222
in snapshot: DocumentSnapshot
202223
) async throws -> CompletionList {
203224
logger.info("Opening code completion session: \(self.description) filter=\(filterText)")
204225
guard snapshot.version == self.snapshot.version else {
205226
throw ResponseError(code: .invalidRequest, message: "open must use the original snapshot")
206227
}
207228

229+
let sourcekitdPosition = snapshot.sourcekitdPosition(of: self.position)
208230
let req = sourcekitd.dictionary([
209231
keys.request: sourcekitd.requests.codeCompleteOpen,
210-
keys.offset: utf8StartOffset,
232+
keys.line: sourcekitdPosition.line,
233+
keys.column: sourcekitdPosition.utf8Column,
211234
keys.name: uri.pseudoPath,
212235
keys.sourceFile: uri.pseudoPath,
213236
keys.sourceText: snapshot.text,
214237
keys.codeCompleteOptions: optionsDictionary(filterText: filterText),
215-
keys.compilerArgs: compileCommand?.compilerArgs as [SKDRequestValue]?,
216238
])
217239

218240
let dict = try await sourcekitd.send(
@@ -228,11 +250,11 @@ class CodeCompletionSession {
228250

229251
try Task.checkCancellation()
230252

231-
return self.completionsFromSKDResponse(
253+
return await self.completionsFromSKDResponse(
232254
completions,
233255
in: snapshot,
234256
completionPos: self.position,
235-
requestPosition: position,
257+
requestPosition: cursorPosition,
236258
isIncomplete: true
237259
)
238260
}
@@ -243,10 +265,13 @@ class CodeCompletionSession {
243265
in snapshot: DocumentSnapshot
244266
) async throws -> CompletionList {
245267
logger.info("Updating code completion session: \(self.description) filter=\(filterText)")
268+
let sourcekitdPosition = snapshot.sourcekitdPosition(of: self.position)
246269
let req = sourcekitd.dictionary([
247270
keys.request: sourcekitd.requests.codeCompleteUpdate,
248-
keys.offset: utf8StartOffset,
271+
keys.line: sourcekitdPosition.line,
272+
keys.column: sourcekitdPosition.utf8Column,
249273
keys.name: uri.pseudoPath,
274+
keys.sourceFile: uri.pseudoPath,
250275
keys.codeCompleteOptions: optionsDictionary(filterText: filterText),
251276
])
252277

@@ -259,7 +284,7 @@ class CodeCompletionSession {
259284
return CompletionList(isIncomplete: false, items: [])
260285
}
261286

262-
return self.completionsFromSKDResponse(
287+
return await self.completionsFromSKDResponse(
263288
completions,
264289
in: snapshot,
265290
completionPos: self.position,
@@ -281,6 +306,7 @@ class CodeCompletionSession {
281306
// Filtering options.
282307
keys.filterText: filterText,
283308
keys.requestLimit: 200,
309+
keys.useNewAPI: 1,
284310
])
285311
return dict
286312
}
@@ -291,9 +317,12 @@ class CodeCompletionSession {
291317
// Already closed, nothing to do.
292318
break
293319
case .open:
320+
let sourcekitdPosition = snapshot.sourcekitdPosition(of: self.position)
294321
let req = sourcekitd.dictionary([
295322
keys.request: sourcekitd.requests.codeCompleteClose,
296-
keys.offset: utf8StartOffset,
323+
keys.line: sourcekitdPosition.line,
324+
keys.column: sourcekitdPosition.utf8Column,
325+
keys.sourceFile: snapshot.uri.pseudoPath,
297326
keys.name: snapshot.uri.pseudoPath,
298327
])
299328
logger.info("Closing code completion session: \(self.description)")
@@ -356,7 +385,10 @@ class CodeCompletionSession {
356385
completionPos: Position,
357386
requestPosition: Position,
358387
isIncomplete: Bool
359-
) -> CompletionList {
388+
) async -> CompletionList {
389+
let sourcekitd = self.sourcekitd
390+
let keys = sourcekitd.keys
391+
360392
let completionItems = completions.compactMap { (value: SKDResponseDictionary) -> CompletionItem? in
361393
guard let name: String = value[keys.description],
362394
var insertText: String = value[keys.sourceText]
@@ -366,7 +398,6 @@ class CodeCompletionSession {
366398

367399
var filterName: String? = value[keys.name]
368400
let typeName: String? = value[sourcekitd.keys.typeName]
369-
let docBrief: String? = value[sourcekitd.keys.docBrief]
370401
let utf8CodeUnitsToErase: Int = value[sourcekitd.keys.numBytesToErase] ?? 0
371402

372403
if let closureExpanded = expandClosurePlaceholders(insertText: insertText) {
@@ -398,22 +429,64 @@ class CodeCompletionSession {
398429
// Map SourceKit's not_recommended field to LSP's deprecated
399430
let notRecommended = (value[sourcekitd.keys.notRecommended] ?? 0) != 0
400431

432+
let sortText: String?
433+
if let semanticScore: Double = value[sourcekitd.keys.semanticScore] {
434+
// sourcekitd returns numeric completion item scores with a higher score being better. LSP's sort text is
435+
// lexicographical. Map the numeric score to a lexicographically sortable score by subtracting it from 5_000.
436+
// This gives us a valid range of semantic scores from -5_000 to 5_000 that can be sorted correctly
437+
// lexicographically. This should be sufficient as semantic scores are typically single-digit.
438+
var lexicallySortableScore = 5_000 - semanticScore
439+
if lexicallySortableScore < 0 {
440+
logger.fault("Semantic score out-of-bounds: \(semanticScore, privacy: .public)")
441+
lexicallySortableScore = 0
442+
}
443+
if lexicallySortableScore >= 10_000 {
444+
logger.fault("Semantic score out-of-bounds: \(semanticScore, privacy: .public)")
445+
lexicallySortableScore = 9_999.99999999
446+
}
447+
sortText = String(format: "%013.8f", lexicallySortableScore) + "-\(name)"
448+
} else {
449+
sortText = nil
450+
}
451+
452+
let data = CompletionItemData(id: value[keys.identifier] as Int?)
453+
401454
let kind: sourcekitd_api_uid_t? = value[sourcekitd.keys.kind]
402455
return CompletionItem(
403456
label: name,
404457
kind: kind?.asCompletionItemKind(sourcekitd.values) ?? .value,
405458
detail: typeName,
406-
documentation: docBrief != nil ? .markupContent(MarkupContent(kind: .markdown, value: docBrief!)) : nil,
459+
documentation: nil,
407460
deprecated: notRecommended,
408-
sortText: nil,
461+
sortText: sortText,
409462
filterText: filterName,
410463
insertText: text,
411464
insertTextFormat: isInsertTextSnippet ? .snippet : .plain,
412-
textEdit: textEdit.map(CompletionItemEdit.textEdit)
465+
textEdit: textEdit.map(CompletionItemEdit.textEdit),
466+
data: data.encodeToLSPAny()
413467
)
414468
}
415469

416-
return CompletionList(isIncomplete: isIncomplete, items: completionItems)
470+
// TODO: Only compute documentation if the client doesn't support `completionItem/resolve`
471+
// (https://github.com/swiftlang/sourcekit-lsp/issues/1935)
472+
let withDocumentation = await completionItems.asyncMap { item in
473+
var item = item
474+
475+
if let itemId = CompletionItemData(fromLSPAny: item.data)?.id {
476+
let req = sourcekitd.dictionary([
477+
keys.request: sourcekitd.requests.codeCompleteDocumentation,
478+
keys.identifier: itemId,
479+
])
480+
let documentationResponse = try? await sourcekitd.send(req, timeout: .seconds(1), fileContents: snapshot.text)
481+
if let docString: String = documentationResponse?[keys.docBrief] {
482+
item.documentation = .markupContent(MarkupContent(kind: .markdown, value: docString))
483+
}
484+
}
485+
486+
return item
487+
}
488+
489+
return CompletionList(isIncomplete: isIncomplete, items: withDocumentation)
417490
}
418491

419492
private func computeCompletionTextEdit(

Sources/SourceKitLSP/Swift/SwiftLanguageService.swift

+27
Original file line numberDiff line numberDiff line change
@@ -1163,6 +1163,16 @@ extension SwiftLanguageService: SKDNotificationHandler {
11631163

11641164
// MARK: - Position conversion
11651165

1166+
/// A line:column position as it is used in sourcekitd, using UTF-8 for the column index and using a one-based line and
1167+
/// column number.
1168+
struct SourceKitDPosition {
1169+
/// Line number within a document (one-based).
1170+
public var line: Int
1171+
1172+
/// UTF-8 code-unit offset from the start of a line (1-based).
1173+
public var utf8Column: Int
1174+
}
1175+
11661176
extension DocumentSnapshot {
11671177

11681178
// MARK: String.Index <-> Raw UTF-8
@@ -1380,6 +1390,23 @@ extension DocumentSnapshot {
13801390
)
13811391
}
13821392

1393+
// MARK: Position <-> SourceKitDPosition
1394+
1395+
func sourcekitdPosition(
1396+
of position: Position,
1397+
callerFile: StaticString = #fileID,
1398+
callerLine: UInt = #line
1399+
) -> SourceKitDPosition {
1400+
let utf8Column = lineTable.utf8ColumnAt(
1401+
line: position.line,
1402+
utf16Column: position.utf16index,
1403+
callerFile: callerFile,
1404+
callerLine: callerLine
1405+
)
1406+
// FIXME: Introduce new type for UTF-8 based positions
1407+
return SourceKitDPosition(line: position.line + 1, utf8Column: utf8Column + 1)
1408+
}
1409+
13831410
// MAR: Position <-> SymbolLocation
13841411

13851412
/// Converts the given UTF-8-offset-based `SymbolLocation` to a UTF-16-based line:column `Position`.

Sources/SwiftSourceKitPlugin/ASTCompletion/ASTCompletionItem.swift

+6-5
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import CompletionScoring
1414
import Csourcekitd
1515
import Foundation
16+
import SKLogging
1617
import SourceKitD
1718
import SwiftExtensions
1819

@@ -392,12 +393,12 @@ extension CompletionItem {
392393
self.typeName = astItem.typeName(in: session)
393394
var editRange = completionReplaceRange
394395
if astItem.numBytesToErase(in: session) > 0 {
395-
var newCol = editRange.lowerBound.utf8Column - astItem.numBytesToErase(in: session)
396-
if newCol < 1 {
397-
assertionFailure("num_bytes_to_erase crosses line boundary")
398-
newCol = 1
396+
let newCol = editRange.lowerBound.utf8Column - astItem.numBytesToErase(in: session)
397+
if newCol >= 1 {
398+
editRange = Position(line: editRange.lowerBound.line, utf8Column: newCol)..<editRange.upperBound
399+
} else {
400+
session.logger.error("num_bytes_to_erase crosses line boundary. Resetting num_bytes_to_erase to 0.")
399401
}
400-
editRange = Position(line: editRange.lowerBound.line, utf8Column: newCol)..<editRange.upperBound
401402
}
402403
self.textEdit = TextEdit(range: editRange, newText: astItem.sourceText(in: session))
403404
self.kind = astItem.kind

Sources/SwiftSourceKitPlugin/ASTCompletion/CompletionSession.swift

+3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import CompletionScoring
1414
import Csourcekitd
1515
import Foundation
16+
import SKLogging
1617
import SourceKitD
1718

1819
/// Represents a code completion session.
@@ -52,6 +53,8 @@ final class CompletionSession {
5253
/// Convenience accessor to the `SourceKitD` instance.
5354
var sourcekitd: SourceKitD { connection.sourcekitd }
5455

56+
var logger: Logger { connection.logger }
57+
5558
init(
5659
connection: Connection,
5760
location: Location,

0 commit comments

Comments
 (0)