Skip to content

Commit 80955f8

Browse files
authored
Merge pull request #899 from ahoppen/ahoppen/completion-race-condition
Fix a race condition in code completion
2 parents 10d1f21 + 16aa082 commit 80955f8

File tree

7 files changed

+314
-378
lines changed

7 files changed

+314
-378
lines changed

Sources/LanguageServerProtocol/SupportTypes/SKCompletionOptions.swift

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,10 @@
1515
/// **(LSP Extension)**: This is used as part of an extension to the
1616
/// code-completion request.
1717
public struct SKCompletionOptions: Codable, Hashable {
18-
19-
/// Whether to use server-side filtering or to return all results and let the
20-
/// client handle all filtering.
21-
public var serverSideFiltering: Bool
22-
2318
/// The maximum number of completion results to return, or `nil` for unlimited.
2419
public var maxResults: Int?
2520

26-
public init(serverSideFiltering: Bool = true, maxResults: Int? = 200) {
27-
self.serverSideFiltering = serverSideFiltering
21+
public init(maxResults: Int? = 200) {
2822
self.maxResults = maxResults
2923
}
3024
}

Sources/SourceKitLSP/SourceKitServer.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -758,9 +758,6 @@ extension SourceKitServer {
758758
self.options.indexOptions.listenToUnitEvents = listenToUnitEvents
759759
}
760760
if case .dictionary(let completionOptions) = options["completion"] {
761-
if case .bool(let serverSideFiltering) = completionOptions["serverSideFiltering"] {
762-
self.options.completionOptions.serverSideFiltering = serverSideFiltering
763-
}
764761
switch completionOptions["maxResults"] {
765762
case .none:
766763
break

Sources/SourceKitLSP/Swift/CodeCompletion.swift

Lines changed: 14 additions & 234 deletions
Original file line numberDiff line numberDiff line change
@@ -37,32 +37,6 @@ extension SwiftLanguageServer {
3737

3838
let options = req.sourcekitlspOptions ?? serverOptions.completionOptions
3939

40-
if options.serverSideFiltering {
41-
return try await completionWithServerFiltering(
42-
offset: offset,
43-
completionPos: completionPos,
44-
snapshot: snapshot,
45-
request: req,
46-
options: options
47-
)
48-
} else {
49-
return try await completionWithClientFiltering(
50-
offset: offset,
51-
completionPos: completionPos,
52-
snapshot: snapshot,
53-
request: req,
54-
options: options
55-
)
56-
}
57-
}
58-
59-
private func completionWithServerFiltering(
60-
offset: Int,
61-
completionPos: Position,
62-
snapshot: DocumentSnapshot,
63-
request req: CompletionRequest,
64-
options: SKCompletionOptions
65-
) async throws -> CompletionList {
6640
guard let start = snapshot.indexOf(utf8Offset: offset),
6741
let end = snapshot.index(of: req.position)
6842
else {
@@ -72,164 +46,23 @@ extension SwiftLanguageServer {
7246

7347
let filterText = String(snapshot.text[start..<end])
7448

75-
let session: CodeCompletionSession
76-
if req.context?.triggerKind == .triggerFromIncompleteCompletions {
77-
guard let currentSession = currentCompletionSession else {
78-
logger.error("triggerFromIncompleteCompletions with no existing completion session")
79-
throw ResponseError.serverCancelled
80-
}
81-
guard currentSession.uri == snapshot.uri, currentSession.utf8StartOffset == offset else {
82-
logger.error(
83-
"""
84-
triggerFromIncompleteCompletions with incompatible completion session; expected \
85-
\(currentSession.uri.forLogging)@\(currentSession.utf8StartOffset), \
86-
but got \(snapshot.uri.forLogging)@\(offset)
87-
"""
88-
)
89-
throw ResponseError.serverCancelled
90-
}
91-
session = currentSession
92-
} else {
93-
// FIXME: even if trigger kind is not from incomplete, we could to detect a compatible
94-
// location if we also check that the rest of the snapshot has not changed.
95-
session = CodeCompletionSession(
96-
server: self,
97-
snapshot: snapshot,
98-
utf8Offset: offset,
99-
position: completionPos,
100-
compileCommand: await buildSettings(for: snapshot.uri)
101-
)
102-
103-
await currentCompletionSession?.close()
104-
currentCompletionSession = session
105-
}
106-
107-
return try await session.update(filterText: filterText, position: req.position, in: snapshot, options: options)
108-
}
109-
110-
private func completionWithClientFiltering(
111-
offset: Int,
112-
completionPos: Position,
113-
snapshot: DocumentSnapshot,
114-
request req: CompletionRequest,
115-
options: SKCompletionOptions
116-
) async throws -> CompletionList {
117-
let skreq = SKDRequestDictionary(sourcekitd: sourcekitd)
118-
skreq[keys.request] = requests.codecomplete
119-
skreq[keys.offset] = offset
120-
skreq[keys.sourcefile] = snapshot.uri.pseudoPath
121-
skreq[keys.sourcetext] = snapshot.text
122-
123-
let skreqOptions = SKDRequestDictionary(sourcekitd: sourcekitd)
124-
skreqOptions[keys.codecomplete_sort_byname] = 1
125-
skreq[keys.codecomplete_options] = skreqOptions
126-
127-
// FIXME: SourceKit should probably cache this for us.
128-
if let compileCommand = await buildSettings(for: snapshot.uri) {
129-
skreq[keys.compilerargs] = compileCommand.compilerArgs
130-
}
131-
132-
let dict = try await sourcekitd.send(skreq)
133-
134-
guard let completions: SKDResponseArray = dict[self.keys.results] else {
135-
return CompletionList(isIncomplete: false, items: [])
136-
}
137-
138-
try Task.checkCancellation()
139-
140-
return self.completionsFromSKDResponse(
141-
completions,
142-
in: snapshot,
143-
completionPos: completionPos,
144-
requestPosition: req.position,
145-
isIncomplete: false
49+
let clientSupportsSnippets = capabilityRegistry.clientCapabilities.textDocument?.completion?.completionItem?.snippetSupport ?? false
50+
let buildSettings = await buildSettings(for: snapshot.uri)
51+
return try await CodeCompletionSession.completionList(
52+
sourcekitd: sourcekitd,
53+
snapshot: snapshot,
54+
completionPosition: completionPos,
55+
completionUtf8Offset: offset,
56+
cursorPosition: req.position,
57+
compileCommand: buildSettings,
58+
options: options,
59+
clientSupportsSnippets: clientSupportsSnippets,
60+
filterText: filterText,
61+
mustReuse: req.context?.triggerKind == .triggerFromIncompleteCompletions
14662
)
14763
}
14864

149-
nonisolated func completionsFromSKDResponse(
150-
_ completions: SKDResponseArray,
151-
in snapshot: DocumentSnapshot,
152-
completionPos: Position,
153-
requestPosition: Position,
154-
isIncomplete: Bool
155-
) -> CompletionList {
156-
var result = CompletionList(isIncomplete: isIncomplete, items: [])
157-
158-
completions.forEach { (i, value) -> Bool in
159-
guard let name: String = value[self.keys.description] else {
160-
return true // continue
161-
}
162-
163-
var filterName: String? = value[self.keys.name]
164-
let insertText: String? = value[self.keys.sourcetext]
165-
let typeName: String? = value[self.keys.typename]
166-
let docBrief: String? = value[self.keys.doc_brief]
167-
168-
let completionCapabilities = self.capabilityRegistry.clientCapabilities.textDocument?.completion
169-
let clientSupportsSnippets = completionCapabilities?.completionItem?.snippetSupport == true
170-
let text = insertText.map {
171-
rewriteSourceKitPlaceholders(inString: $0, clientSupportsSnippets: clientSupportsSnippets)
172-
}
173-
let isInsertTextSnippet = clientSupportsSnippets && text != insertText
174-
175-
let textEdit: TextEdit?
176-
if let text = text {
177-
let utf8CodeUnitsToErase: Int = value[self.keys.num_bytes_to_erase] ?? 0
178-
179-
textEdit = self.computeCompletionTextEdit(
180-
completionPos: completionPos,
181-
requestPosition: requestPosition,
182-
utf8CodeUnitsToErase: utf8CodeUnitsToErase,
183-
newText: text,
184-
snapshot: snapshot
185-
)
186-
187-
if utf8CodeUnitsToErase != 0, filterName != nil, let textEdit = textEdit {
188-
// To support the case where the client is doing prefix matching on the TextEdit range,
189-
// we need to prepend the deleted text to filterText.
190-
// This also works around a behaviour in VS Code that causes completions to not show up
191-
// if a '.' is being replaced for Optional completion.
192-
let startIndex = snapshot.lineTable.stringIndexOf(
193-
line: textEdit.range.lowerBound.line,
194-
utf16Column: textEdit.range.lowerBound.utf16index
195-
)!
196-
let endIndex = snapshot.lineTable.stringIndexOf(
197-
line: completionPos.line,
198-
utf16Column: completionPos.utf16index
199-
)!
200-
let filterPrefix = snapshot.text[startIndex..<endIndex]
201-
filterName = filterPrefix + filterName!
202-
}
203-
} else {
204-
textEdit = nil
205-
}
206-
207-
// Map SourceKit's not_recommended field to LSP's deprecated
208-
let notRecommended = (value[self.keys.not_recommended] as Int?).map({ $0 != 0 })
209-
210-
let kind: sourcekitd_uid_t? = value[self.keys.kind]
211-
result.items.append(
212-
CompletionItem(
213-
label: name,
214-
kind: kind?.asCompletionItemKind(self.values) ?? .value,
215-
detail: typeName,
216-
documentation: docBrief != nil ? .markupContent(MarkupContent(kind: .markdown, value: docBrief!)) : nil,
217-
deprecated: notRecommended ?? false,
218-
sortText: nil,
219-
filterText: filterName,
220-
insertText: text,
221-
insertTextFormat: isInsertTextSnippet ? .snippet : .plain,
222-
textEdit: textEdit.map(CompletionItemEdit.textEdit)
223-
)
224-
)
225-
226-
return true
227-
}
228-
229-
return result
230-
}
231-
232-
/// Adjust completion position to the start of identifier characters.
65+
/// Adjust completion position to the start of identifier characters.
23366
private func adjustCompletionLocation(_ pos: Position, in snapshot: DocumentSnapshot) -> Position? {
23467
guard pos.line < snapshot.lineTable.count else {
23568
// Line out of range.
@@ -259,57 +92,4 @@ extension SwiftLanguageServer {
25992
let adjustedOffset = lineSlice.utf16.distance(from: startIndex, to: loc)
26093
return Position(line: pos.line, utf16index: adjustedOffset)
26194
}
262-
263-
private nonisolated func computeCompletionTextEdit(
264-
completionPos: Position,
265-
requestPosition: Position,
266-
utf8CodeUnitsToErase: Int,
267-
newText: String,
268-
snapshot: DocumentSnapshot
269-
) -> TextEdit {
270-
let textEditRangeStart: Position
271-
272-
// Compute the TextEdit
273-
if utf8CodeUnitsToErase == 0 {
274-
// Nothing to delete. Fast path and avoid UTF-8/UTF-16 conversions
275-
textEditRangeStart = completionPos
276-
} else if utf8CodeUnitsToErase == 1 {
277-
// Fast path: Erasing a single UTF-8 byte code unit means we are also need to erase exactly one UTF-16 code unit, meaning we don't need to process the file contents
278-
if completionPos.utf16index >= 1 {
279-
// We can delete the character.
280-
textEditRangeStart = Position(line: completionPos.line, utf16index: completionPos.utf16index - 1)
281-
} else {
282-
// Deleting the character would cross line boundaries. This is not supported by LSP.
283-
// Fall back to ignoring utf8CodeUnitsToErase.
284-
// If we discover that multi-lines replacements are often needed, we can add an LSP extension to support multi-line edits.
285-
textEditRangeStart = completionPos
286-
}
287-
} else {
288-
// We need to delete more than one text character. Do the UTF-8/UTF-16 dance.
289-
assert(completionPos.line == requestPosition.line)
290-
// Construct a string index for the edit range start by subtracting the UTF-8 code units to erase from the completion position.
291-
let line = snapshot.lineTable[completionPos.line]
292-
let completionPosStringIndex = snapshot.lineTable.stringIndexOf(
293-
line: completionPos.line,
294-
utf16Column: completionPos.utf16index
295-
)!
296-
let deletionStartStringIndex = line.utf8.index(completionPosStringIndex, offsetBy: -utf8CodeUnitsToErase)
297-
298-
// Compute the UTF-16 offset of the deletion start range. If the start lies in a previous line, this will be negative
299-
let deletionStartUtf16Offset = line.utf16.distance(from: line.startIndex, to: deletionStartStringIndex)
300-
301-
// Check if we are only deleting on one line. LSP does not support deleting over multiple lines.
302-
if deletionStartUtf16Offset >= 0 {
303-
// We are only deleting characters on the same line. Construct the corresponding text edit.
304-
textEditRangeStart = Position(line: completionPos.line, utf16index: deletionStartUtf16Offset)
305-
} else {
306-
// Deleting the character would cross line boundaries. This is not supported by LSP.
307-
// Fall back to ignoring utf8CodeUnitsToErase.
308-
// If we discover that multi-lines replacements are often needed, we can add an LSP extension to support multi-line edits.
309-
textEditRangeStart = completionPos
310-
}
311-
}
312-
313-
return TextEdit(range: textEditRangeStart..<requestPosition, newText: newText)
314-
}
31595
}

0 commit comments

Comments
 (0)