Files
sourcekit-lsp/Sources/SwiftLanguageService/CodeCompletionSession.swift

812 lines
30 KiB
Swift

//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2020 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//
import Csourcekitd
import Dispatch
import Foundation
@_spi(SourceKitLSP) import LanguageServerProtocol
@_spi(SourceKitLSP) import SKLogging
import SKOptions
import SKUtilities
import SourceKitD
import SourceKitLSP
import SwiftBasicFormat
import SwiftExtensions
import SwiftParser
@_spi(SourceKitLSP) import SwiftRefactor
import SwiftSyntax
@_spi(SourceKitLSP) import ToolsProtocolsSwiftExtensions
/// Uniquely identifies a code completion session. We need this so that when resolving a code completion item, we can
/// verify that the item to resolve belongs to the code completion session that is currently open.
struct CompletionSessionID: Equatable {
private static let nextSessionID = AtomicUInt32(initialValue: 0)
let value: UInt32
init(value: UInt32) {
self.value = value
}
static func next() -> CompletionSessionID {
return CompletionSessionID(value: nextSessionID.fetchAndIncrement())
}
}
/// Data that is attached to a `CompletionItem`.
struct CompletionItemData: LSPAnyCodable {
let uri: DocumentURI
let sessionId: CompletionSessionID
let itemId: Int
init(uri: DocumentURI, sessionId: CompletionSessionID, itemId: Int) {
self.uri = uri
self.sessionId = sessionId
self.itemId = itemId
}
init?(fromLSPDictionary dictionary: [String: LSPAny]) {
guard case .string(let uriString) = dictionary["uri"],
case .int(let sessionId) = dictionary["sessionId"],
case .int(let itemId) = dictionary["itemId"],
let uri = try? DocumentURI(string: uriString)
else {
return nil
}
self.uri = uri
self.sessionId = CompletionSessionID(value: UInt32(sessionId))
self.itemId = itemId
}
func encodeToLSPAny() -> LSPAny {
return .dictionary([
"uri": .string(uri.stringValue),
"sessionId": .int(Int(sessionId.value)),
"itemId": .int(itemId),
])
}
}
/// Represents a code-completion session for a given source location that can be efficiently
/// re-filtered by calling `update()`.
///
/// The first call to `update()` opens the session with sourcekitd, which computes the initial
/// completions. Subsequent calls to `update()` will re-filter the original completions. Finally,
/// before creating a new completion session, you must call `close()`. It is an error to create a
/// new completion session with the same source location before closing the original session.
///
/// At the sourcekitd level, this uses `codecomplete.open`, `codecomplete.update` and
/// `codecomplete.close` requests.
class CodeCompletionSession {
// MARK: - Public static API
/// The queue on which all code completion requests are executed.
///
/// This is needed because sourcekitd has a single, global code completion
/// session and we need to make sure that multiple code completion requests
/// don't race each other.
///
/// Technically, we would only need one queue for each sourcekitd and different
/// sourcekitd could serve code completion requests simultaneously.
///
/// But it's rare to open multiple sourcekitd instances simultaneously and
/// even rarer to interact with them at the same time, so we have a global
/// queue for now to simplify the implementation.
private static let completionQueue = AsyncQueue<Serial>()
/// The code completion session for each sourcekitd instance.
///
/// `sourcekitd` has a global code completion session, that's why we need to
/// have a global mapping from `sourcekitd` to its currently active code
/// completion session.
///
/// - Important: Must only be accessed on `completionQueue`.
/// `nonisolated(unsafe)` fine because this is guarded by `completionQueue`.
private static nonisolated(unsafe) var completionSessions: [ObjectIdentifier: CodeCompletionSession] = [:]
/// Gets the code completion results for the given parameters.
///
/// If a code completion session that is compatible with the parameters
/// already exists, this just performs an update to the filtering. If it does
/// not, this opens a new code completion session with `sourcekitd` and gets
/// the results.
///
/// - Parameters:
/// - sourcekitd: The `sourcekitd` instance from which to get code
/// completion results
/// - snapshot: The document in which to perform completion.
/// - completionPosition: The position at which to perform completion.
/// This is the position at which the code completion token logically
/// starts. For example when completing `foo.ba|`, then the completion
/// position should be after the `.`.
/// - completionUtf8Offset: Same as `completionPosition` but as a UTF-8
/// offset within the buffer.
/// - cursorPosition: The position at which the cursor is positioned. E.g.
/// when completing `foo.ba|`, this is after the `a` (see
/// `completionPosition` for comparison)
/// - compileCommand: The compiler arguments to use.
/// - options: Further options that can be sent from the editor to control
/// completion.
/// - filterText: The text by which to filter code completion results.
/// - mustReuse: If `true` and there is an active session in this
/// `sourcekitd` instance, cancel the request instead of opening a new
/// session.
/// This is set to `true` when triggering a filter from incomplete results
/// so that clients can rely on results being delivered quickly when
/// getting updated results after updating the filter text.
/// - Returns: The code completion results for those parameters.
static func completionList(
sourcekitd: SourceKitD,
snapshot: DocumentSnapshot,
options: SourceKitLSPOptions,
indentationWidth: Trivia?,
completionPosition: Position,
cursorPosition: Position,
compileCommand: SwiftCompileCommand?,
clientCapabilities: ClientCapabilities,
filterText: String
) async throws -> CompletionList {
let task = completionQueue.asyncThrowing {
if let session = completionSessions[ObjectIdentifier(sourcekitd)], session.state == .open {
let isCompatible =
session.snapshot.uri == snapshot.uri
&& session.position == completionPosition
&& session.compileCommand == compileCommand
if isCompatible {
return try await session.update(
filterText: filterText,
position: cursorPosition,
in: snapshot
)
}
// The sessions aren't compatible. Close the existing session and open
// a new one below.
await session.close()
}
let session = CodeCompletionSession(
sourcekitd: sourcekitd,
snapshot: snapshot,
options: options,
indentationWidth: indentationWidth,
position: completionPosition,
compileCommand: compileCommand,
clientCapabilities: clientCapabilities
)
completionSessions[ObjectIdentifier(sourcekitd)] = session
return try await session.open(filterText: filterText, position: cursorPosition, in: snapshot)
}
return try await task.valuePropagatingCancellation
}
static func completionItemResolve(
item: CompletionItem,
sourcekitd: SourceKitD
) async throws -> CompletionItem {
guard let data = CompletionItemData(fromLSPAny: item.data) else {
return item
}
let task = completionQueue.asyncThrowing {
guard let session = completionSessions[ObjectIdentifier(sourcekitd)], data.sessionId == session.id else {
throw ResponseError.unknown("No active completion session for \(data.uri)")
}
return await Self.resolveDocumentation(
in: item,
timeout: session.options.sourcekitdRequestTimeoutOrDefault,
restartTimeout: session.options.semanticServiceRestartTimeoutOrDefault,
sourcekitd: sourcekitd
)
}
return try await task.valuePropagatingCancellation
}
/// Close all code completion sessions for the given files.
///
/// This should only be necessary to do if the dependencies have updated. In all other cases `completionList` will
/// decide whether an existing code completion session can be reused.
static func close(sourcekitd: SourceKitD, uris: Set<DocumentURI>) {
completionQueue.async {
if let session = completionSessions[ObjectIdentifier(sourcekitd)], uris.contains(session.uri),
session.state == .open
{
await session.close()
}
}
}
// MARK: - Implementation
private let id: CompletionSessionID
private let sourcekitd: SourceKitD
private let snapshot: DocumentSnapshot
private let options: SourceKitLSPOptions
/// The inferred indentation width of the source file the completion is being performed in
private let indentationWidth: Trivia?
private let position: Position
private let compileCommand: SwiftCompileCommand?
private let clientSupportsSnippets: Bool
private let clientSupportsDocumentationResolve: Bool
private var state: State = .closed
private enum State {
case closed
case open
}
private nonisolated var uri: DocumentURI { snapshot.uri }
private nonisolated var keys: sourcekitd_api_keys { return sourcekitd.keys }
private init(
sourcekitd: SourceKitD,
snapshot: DocumentSnapshot,
options: SourceKitLSPOptions,
indentationWidth: Trivia?,
position: Position,
compileCommand: SwiftCompileCommand?,
clientCapabilities: ClientCapabilities
) {
self.id = CompletionSessionID.next()
self.sourcekitd = sourcekitd
self.options = options
self.indentationWidth = indentationWidth
self.snapshot = snapshot
self.position = position
self.compileCommand = compileCommand
self.clientSupportsSnippets = clientCapabilities.textDocument?.completion?.completionItem?.snippetSupport ?? false
self.clientSupportsDocumentationResolve =
clientCapabilities.textDocument?.completion?.completionItem?.resolveSupport?.properties.contains("documentation")
?? false
}
private func open(
filterText: String,
position cursorPosition: Position,
in snapshot: DocumentSnapshot
) async throws -> CompletionList {
logger.info("Opening code completion session: \(self.description) filter=\(filterText)")
guard snapshot.version == self.snapshot.version else {
throw ResponseError(code: .invalidRequest, message: "open must use the original snapshot")
}
let sourcekitdPosition = snapshot.sourcekitdPosition(of: self.position)
let req = sourcekitd.dictionary([
keys.line: sourcekitdPosition.line,
keys.column: sourcekitdPosition.utf8Column,
keys.name: uri.pseudoPath,
keys.sourceFile: uri.pseudoPath,
keys.sourceText: snapshot.text,
keys.codeCompleteOptions: optionsDictionary(filterText: filterText),
])
let dict = try await send(sourceKitDRequest: \.codeCompleteOpen, req, snapshot: snapshot)
self.state = .open
guard let completions: SKDResponseArray = dict[keys.results] else {
return CompletionList(isIncomplete: false, items: [])
}
try Task.checkCancellation()
return await self.completionsFromSKDResponse(
completions,
in: snapshot,
completionPos: self.position,
requestPosition: cursorPosition,
isIncomplete: true
)
}
private func update(
filterText: String,
position: Position,
in snapshot: DocumentSnapshot
) async throws -> CompletionList {
logger.info("Updating code completion session: \(self.description) filter=\(filterText)")
let sourcekitdPosition = snapshot.sourcekitdPosition(of: self.position)
let req = sourcekitd.dictionary([
keys.line: sourcekitdPosition.line,
keys.column: sourcekitdPosition.utf8Column,
keys.name: uri.pseudoPath,
keys.sourceFile: uri.pseudoPath,
keys.codeCompleteOptions: optionsDictionary(filterText: filterText),
])
let dict = try await send(sourceKitDRequest: \.codeCompleteUpdate, req, snapshot: snapshot)
guard let completions: SKDResponseArray = dict[keys.results] else {
return CompletionList(isIncomplete: false, items: [])
}
return await self.completionsFromSKDResponse(
completions,
in: snapshot,
completionPos: self.position,
requestPosition: position,
isIncomplete: true
)
}
private func optionsDictionary(
filterText: String
) -> SKDRequestDictionary {
let dict = sourcekitd.dictionary([
// Sorting and priority options.
keys.hideUnderscores: 0,
keys.hideLowPriority: 0,
keys.hideByName: 0,
keys.addInnerOperators: 0,
keys.topNonLiteral: 0,
keys.addCallWithNoDefaultArgs: 1,
// Filtering options.
keys.filterText: filterText,
keys.requestLimit: 200,
keys.useNewAPI: 1,
])
return dict
}
private func close() async {
switch self.state {
case .closed:
// Already closed, nothing to do.
break
case .open:
let sourcekitdPosition = snapshot.sourcekitdPosition(of: self.position)
let req = sourcekitd.dictionary([
keys.line: sourcekitdPosition.line,
keys.column: sourcekitdPosition.utf8Column,
keys.sourceFile: snapshot.uri.pseudoPath,
keys.name: snapshot.uri.pseudoPath,
keys.codeCompleteOptions: [keys.useNewAPI: 1],
])
logger.info("Closing code completion session: \(self.description)")
_ = try? await send(sourceKitDRequest: \.codeCompleteClose, req, snapshot: nil)
self.state = .closed
}
}
// MARK: - Helpers
private func send(
sourceKitDRequest requestUid: any KeyPath<sourcekitd_api_requests, sourcekitd_api_uid_t> & Sendable,
_ request: SKDRequestDictionary,
snapshot: DocumentSnapshot?
) async throws -> SKDResponseDictionary {
try await sourcekitd.send(
requestUid,
request,
timeout: options.sourcekitdRequestTimeoutOrDefault,
restartTimeout: options.semanticServiceRestartTimeoutOrDefault,
documentUrl: snapshot?.uri.arbitrarySchemeURL,
fileContents: snapshot?.text
)
}
private func expandClosurePlaceholders(insertText: String) -> String? {
guard insertText.contains("<#") && insertText.contains("->") else {
// Fast path: There is no closure placeholder to expand
return nil
}
let (strippedPrefix, exprToExpand) = extractExpressionToExpand(from: insertText)
// Note we don't need special handling for macro expansions since
// their insertion text doesn't include the '#', so are parsed as
// function calls here.
var parser = Parser(exprToExpand)
let expr = ExprSyntax.parse(from: &parser)
guard let call = OutermostFunctionCallFinder.findOutermostFunctionCall(in: expr),
let expandedCall = try? ExpandEditorPlaceholdersToLiteralClosures.refactor(
syntax: Syntax(call),
in: ExpandEditorPlaceholdersToLiteralClosures.Context(
format: .custom(
ClosureCompletionFormat(indentationWidth: indentationWidth),
allowNestedPlaceholders: true
)
)
)
else {
return nil
}
let bytesToExpand = Array(exprToExpand.utf8)
var expandedBytes: [UInt8] = []
// Add the prefix that we stripped off to allow expression parsing
expandedBytes += strippedPrefix.utf8
// Add any part of the expression that didn't end up being part of the function call
expandedBytes += bytesToExpand[0..<call.position.utf8Offset]
// Add the expanded function call excluding the added `indentationOfLine`
expandedBytes += expandedCall.syntaxTextBytes
// Add any trailing text that didn't end up being part of the function call
expandedBytes += bytesToExpand[call.endPosition.utf8Offset...]
return String(bytes: expandedBytes, encoding: .utf8)
}
/// Extract the expression to expand by stripping optional chaining prefix if present.
private func extractExpressionToExpand(from insertText: String) -> (strippedPrefix: String, exprToExpand: String) {
if insertText.starts(with: "?.") {
return (strippedPrefix: "?.", exprToExpand: String(insertText.dropFirst(2)))
} else {
return (strippedPrefix: "", exprToExpand: insertText)
}
}
/// If the code completion text returned by sourcekitd, format it using SwiftBasicFormat. This is needed for
/// completion items returned from sourcekitd that already have the trailing closure expanded.
private func formatMultiLineCompletion(insertText: String) -> String? {
// We only need to format the completion result if it's a multi-line completion that needs adjustment of
// indentation.
guard insertText.contains(where: \.isNewline) else {
return nil
}
let (strippedPrefix, exprToExpand) = extractExpressionToExpand(from: insertText)
var parser = Parser(exprToExpand)
let expr = ExprSyntax.parse(from: &parser)
let formatted = expr.formatted(using: ClosureCompletionFormat(indentationWidth: indentationWidth))
return strippedPrefix + formatted.description
}
private func completionsFromSKDResponse(
_ completions: SKDResponseArray,
in snapshot: DocumentSnapshot,
completionPos: Position,
requestPosition: Position,
isIncomplete: Bool
) async -> CompletionList {
let sourcekitd = self.sourcekitd
let keys = sourcekitd.keys
var completionItems = completions.compactMap { (value: SKDResponseDictionary) -> CompletionItem? in
guard let name: String = value[keys.description],
var insertText: String = value[keys.sourceText]
else {
return nil
}
var filterName: String? = value[keys.name]
let typeName: String? = value[sourcekitd.keys.typeName]
let utf8CodeUnitsToErase: Int = value[sourcekitd.keys.numBytesToErase] ?? 0
if let closureExpanded = expandClosurePlaceholders(insertText: insertText) {
insertText = closureExpanded
} else if let multilineFormatted = formatMultiLineCompletion(insertText: insertText) {
insertText = multilineFormatted
}
let text = rewriteSourceKitPlaceholders(in: insertText, clientSupportsSnippets: clientSupportsSnippets)
let isInsertTextSnippet = clientSupportsSnippets && text != insertText
let kind: sourcekitd_api_uid_t? = value[sourcekitd.keys.kind]
let completionKind = kind?.asCompletionItemKind(sourcekitd.values) ?? .value
// Check if this is a keyword that should be converted to a snippet. If so, prefer the snippet text
// as the completion insert text and only compute the `TextEdit` once below.
var isKeywordSnippet = false
if completionKind == .keyword, let snippetText = keywordSnippet(for: name) {
insertText = snippetText
isKeywordSnippet = true
} else {
insertText = text
}
var textEdit = self.computeCompletionTextEdit(
completionPos: completionPos,
requestPosition: requestPosition,
utf8CodeUnitsToErase: utf8CodeUnitsToErase,
newText: insertText,
snapshot: snapshot
)
if completionKind == .method || completionKind == .function, name.first == "(", name.last == ")" {
// sourcekitd makes an assumption that the editor inserts a matching `)` when the user types a `(` to start
// argument completions and thus does not contain the closing parentheses in the insert text. Since we can't
// make that assumption of any editor using SourceKit-LSP, add the closing parenthesis when we are completing
// function arguments, indicated by the completion kind and the completion's name being wrapped in parentheses.
textEdit.newText += ")"
let requestIndex = snapshot.index(of: requestPosition)
if snapshot.text[requestIndex] == ")",
let nextIndex = snapshot.text.index(requestIndex, offsetBy: 1, limitedBy: snapshot.text.endIndex)
{
// Now, in case the editor already added the matching closing parenthesis, replace it by the parenthesis we
// are adding as part of the completion above. While this might seem un-intuitive, it is the behavior that
// VS Code expects. If the text edit's insert text does not contain the ')' and the user types the closing
// parenthesis of a function that takes no arguments, VS Code's completion position is after the closing
// parenthesis but no new completion request is sent since no character has been inserted (only the implicitly
// inserted `)` has been overwritten). VS Code will now delete anything from the position that the completion
// request was run, leaving the user without the closing `)`.
textEdit.range = textEdit.range.lowerBound..<snapshot.position(of: nextIndex)
}
}
if utf8CodeUnitsToErase != 0, filterName != nil {
// To support the case where the client is doing prefix matching on the TextEdit range,
// we need to prepend the deleted text to filterText.
// This also works around a behaviour in VS Code that causes completions to not show up
// if a '.' is being replaced for Optional completion.
let filterPrefix = snapshot.text[snapshot.indexRange(of: textEdit.range.lowerBound..<completionPos)]
filterName = filterPrefix + filterName!
}
// Map SourceKit's not_recommended field to LSP's deprecated
let notRecommended = (value[sourcekitd.keys.notRecommended] ?? 0) != 0
let sortText: String?
if let semanticScore: Double = value[sourcekitd.keys.semanticScore],
let textMatchScore: Double = value[sourcekitd.keys.textMatchScore]
{
let score = semanticScore * textMatchScore
// sourcekitd returns numeric completion item scores with a higher score being better. LSP's sort text is
// lexicographical. Map the numeric score to a lexicographically sortable score by subtracting it from 5_000.
// This gives us a valid range of semantic scores from -5_000 to 5_000 that can be sorted correctly
// lexicographically. This should be sufficient as semantic scores are typically single-digit.
var lexicallySortableScore = 5_000 - score
if lexicallySortableScore < 0 {
logger.fault(
"score out-of-bounds: \(score, privacy: .public), semantic: \(semanticScore, privacy: .public), textual: \(textMatchScore, privacy: .public)"
)
lexicallySortableScore = 0
}
if lexicallySortableScore >= 10_000 {
logger.fault(
"score out-of-bounds: \(score, privacy: .public), semantic: \(semanticScore, privacy: .public), textual: \(textMatchScore, privacy: .public)"
)
lexicallySortableScore = 9_999.99999999
}
sortText = String(format: "%013.8f", lexicallySortableScore) + "-\(name)"
} else {
sortText = nil
}
let data: CompletionItemData? =
if let identifier: Int = value[keys.identifier] {
CompletionItemData(uri: self.uri, sessionId: self.id, itemId: identifier)
} else {
nil
}
return CompletionItem(
label: name,
kind: completionKind,
detail: typeName,
documentation: nil,
deprecated: notRecommended,
sortText: sortText,
filterText: filterName,
insertText: insertText,
insertTextFormat: (isInsertTextSnippet || isKeywordSnippet) ? .snippet : .plain,
textEdit: CompletionItemEdit.textEdit(textEdit),
data: data.encodeToLSPAny()
)
}
if !clientSupportsDocumentationResolve {
let semanticServiceRestartTimeoutOrDefault = self.options.semanticServiceRestartTimeoutOrDefault
completionItems = await completionItems.asyncMap { item in
return await Self.resolveDocumentation(
in: item,
timeout: .seconds(1),
restartTimeout: semanticServiceRestartTimeoutOrDefault,
sourcekitd: sourcekitd
)
}
}
return CompletionList(isIncomplete: isIncomplete, items: completionItems)
}
private static func resolveDocumentation(
in item: CompletionItem,
timeout: Duration,
restartTimeout: Duration,
sourcekitd: SourceKitD
) async -> CompletionItem {
var item = item
if let itemId = CompletionItemData(fromLSPAny: item.data)?.itemId {
let req = sourcekitd.dictionary([
sourcekitd.keys.identifier: itemId
])
let documentationResponse = await orLog("Retrieving documentation for completion item") {
try await sourcekitd.send(
\.codeCompleteDocumentation,
req,
timeout: timeout,
restartTimeout: restartTimeout,
documentUrl: nil,
fileContents: nil
)
}
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 docComment: String = response[sourcekitd.keys.docComment] {
return docComment
}
return response[sourcekitd.keys.docBrief]
}
private func computeCompletionTextEdit(
completionPos: Position,
requestPosition: Position,
utf8CodeUnitsToErase: Int,
newText: String,
snapshot: DocumentSnapshot
) -> TextEdit {
let textEditRangeStart = computeCompletionTextEditStart(
completionPos: completionPos,
requestPosition: requestPosition,
utf8CodeUnitsToErase: utf8CodeUnitsToErase,
snapshot: snapshot
)
return TextEdit(range: textEditRangeStart..<requestPosition, newText: newText)
}
private func computeCompletionTextEditStart(
completionPos: Position,
requestPosition: Position,
utf8CodeUnitsToErase: Int,
snapshot: DocumentSnapshot
) -> Position {
// Compute the TextEdit
if utf8CodeUnitsToErase == 0 {
// Nothing to delete. Fast path and avoid UTF-8/UTF-16 conversions
return completionPos
} else if utf8CodeUnitsToErase == 1 {
// 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
if completionPos.utf16index >= 1 {
// We can delete the character.
return Position(line: completionPos.line, utf16index: completionPos.utf16index - 1)
} else {
// Deleting the character would cross line boundaries. This is not supported by LSP.
// Fall back to ignoring utf8CodeUnitsToErase.
// If we discover that multi-lines replacements are often needed, we can add an LSP extension to support multi-line edits.
return completionPos
}
}
// We need to delete more than one text character. Do the UTF-8/UTF-16 dance.
assert(completionPos.line == requestPosition.line)
// Construct a string index for the edit range start by subtracting the UTF-8 code units to erase from the completion position.
guard let line = snapshot.lineTable.line(at: completionPos.line) else {
logger.fault("Code completion position is in out-of-range line \(completionPos.line)")
return completionPos
}
guard
let deletionStartStringIndex = line.utf8.index(
snapshot.index(of: completionPos),
offsetBy: -utf8CodeUnitsToErase,
limitedBy: line.utf8.startIndex
)
else {
// Deleting the character would cross line boundaries. This is not supported by LSP.
// Fall back to ignoring utf8CodeUnitsToErase.
// If we discover that multi-lines replacements are often needed, we can add an LSP extension to support multi-line edits.
logger.fault("UTF-8 code units to erase \(utf8CodeUnitsToErase) is before start of line")
return completionPos
}
// Compute the UTF-16 offset of the deletion start range.
let deletionStartUtf16Offset = line.utf16.distance(from: line.startIndex, to: deletionStartStringIndex)
precondition(deletionStartUtf16Offset >= 0)
return Position(line: completionPos.line, utf16index: deletionStartUtf16Offset)
}
/// Generate a snippet for control flow keywords like if, for, while, etc.
/// Returns the snippet text if the keyword is a control flow keyword and snippets are supported, otherwise nil.
private func keywordSnippet(for keyword: String) -> String? {
guard clientSupportsSnippets else { return nil }
// Use the `description` of inferred `Trivia` (e.g. " " or "\t").
// Fall back to four spaces to match `BasicFormat`.
let indent = indentationWidth?.description ?? " "
switch keyword {
case "if":
return """
if ${1:condition} {
\(indent)${0:body}
}
"""
case "for":
return """
for ${1:item} in ${2:sequence} {
\(indent)${0:body}
}
"""
case "while":
return """
while ${1:condition} {
\(indent)${0:body}
}
"""
case "guard":
return """
guard ${1:condition} else {
\(indent)${0:body}
}
"""
case "switch":
return """
switch ${1:value} {
case ${2:pattern}:
\(indent)${0:body}
}
"""
case "repeat":
return """
repeat {
\(indent)${0:body}
} while ${1:condition}
"""
default:
return nil
}
}
}
extension CodeCompletionSession: CustomStringConvertible {
nonisolated var description: String {
"\(uri.pseudoPath):\(position)"
}
}
private class OutermostFunctionCallFinder: SyntaxAnyVisitor {
/// Once a `FunctionCallExprSyntax` has been visited, that syntax node.
var foundCall: FunctionCallExprSyntax?
private func shouldVisit(_ node: some SyntaxProtocol) -> Bool {
if foundCall != nil {
return false
}
return true
}
override func visitAny(_ node: Syntax) -> SyntaxVisitorContinueKind {
guard shouldVisit(node) else {
return .skipChildren
}
return .visitChildren
}
override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind {
guard shouldVisit(node) else {
return .skipChildren
}
foundCall = node
return .skipChildren
}
/// Find the innermost `FunctionCallExprSyntax` that contains `position`.
static func findOutermostFunctionCall(
in tree: some SyntaxProtocol
) -> FunctionCallExprSyntax? {
let finder = OutermostFunctionCallFinder(viewMode: .sourceAccurate)
finder.walk(tree)
return finder.foundCall
}
}