Files
Steffeeen 6039da983a Fix completion replacement range extending into the next token
Ensure that `identifierEndPosition` in `CompletionRequestContext` does
not accidentally use the end position of the following token.
2026-07-15 17:52:29 +02:00

164 lines
5.8 KiB
Swift

//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2026 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
//
//===----------------------------------------------------------------------===//
@_spi(SourceKitLSP) import LanguageServerProtocol
import SourceKitLSP
import SwiftSyntax
struct CompletionRequestContext {
let cursorPosition: Position
/// If the completion is triggered in the middle of an identifier, this is the position after the end of the
/// identifier. This is needed to compute the correct text edit for completions that replace an existing identifier.
let identifierEndPosition: Position?
let followingCallContext: FollowingCallContext
init(req: CompletionRequest, snapshot: DocumentSnapshot, tree: SourceFileSyntax) {
self.cursorPosition = req.position
self.identifierEndPosition = findEndOfIdentifier(position: req.position, snapshot: snapshot, tree: tree)
self.followingCallContext = findFollowingCallContext(position: req.position, snapshot: snapshot, tree: tree)
}
}
enum FollowingCallContext {
case none
case emptyParens(positionAfterClosingParen: Position)
case nonEmptyParens
case trailingClosure
}
private func findEndOfIdentifier(
position: Position,
snapshot: DocumentSnapshot,
tree: SourceFileSyntax
) -> Position? {
let absolutePosition = snapshot.absolutePosition(of: position)
let token = tree.token(at: absolutePosition)
guard let token, token.isIdentifierOrKeyword, token.trimmedRange.contains(absolutePosition) else {
return nil
}
return snapshot.position(of: token.endPositionBeforeTrailingTrivia)
}
private func findIdentifierUnderCursor(
position: Position,
snapshot: DocumentSnapshot,
tree: SourceFileSyntax
) -> TokenSyntax? {
let absolutePosition = snapshot.absolutePosition(of: position)
let token = tree.token(at: absolutePosition)
guard let token else {
return nil
}
// We also consider keywords here for cases where the user is trying to complete a function that also contains a
// keyword in its name. If only the keyword part of the function name is typed, we still want to show the completion
// for that function.
// For example, if the user has a function `func selfTest()`, we want to show the completion for `selfTest` even if
// the user has only typed `self` so far.
if token.isIdentifierOrKeyword {
return token
}
// If the cursor is right after an identifier, we also want to consider that identifier for completion.
let previousToken = token.previousToken(viewMode: .sourceAccurate)
guard let previousToken, previousToken.isIdentifierOrKeyword, previousToken.endPosition == absolutePosition else {
return nil
}
return previousToken
}
private extension TokenSyntax {
var isIdentifierOrKeyword: Bool {
switch tokenKind {
case .identifier, .keyword:
return true
default:
return false
}
}
}
private func findFollowingCallContext(
position: Position,
snapshot: DocumentSnapshot,
tree: SourceFileSyntax
) -> FollowingCallContext {
guard let identifier = findIdentifierUnderCursor(position: position, snapshot: snapshot, tree: tree),
let parent = identifier.parent
else {
return .none
}
let functionCall: FunctionCallExprSyntax
if let declReference = parent.as(DeclReferenceExprSyntax.self) {
if let call = declReference.parent?.as(FunctionCallExprSyntax.self) {
functionCall = call
} else if let memberAccess = declReference.parent?.as(MemberAccessExprSyntax.self),
let call = memberAccess.parent?.as(FunctionCallExprSyntax.self)
{
functionCall = call
} else {
return .none
}
} else if let macroExpansion = parent.as(MacroExpansionExprSyntax.self), let leftParen = macroExpansion.leftParen {
return determineFollowingCallContextParenthesisKind(
leftParen: leftParen,
rightParen: macroExpansion.rightParen,
snapshot: snapshot
)
} else {
return .none
}
if let leftParen = functionCall.leftParen,
// We don't want to report empty parentheses for code like this: `foo() { doSomethingInClosure() }` as that would
// lead to the completion logic trying to replace the parentheses with the call argument labels
functionCall.trailingClosure == nil
{
return determineFollowingCallContextParenthesisKind(
leftParen: leftParen,
rightParen: functionCall.rightParen,
snapshot: snapshot
)
}
// Braces on the next line are also parsed into a ClosureExprSyntax, so we need to ensure that the left brace is
// actually on the same line as the cursor to consider it a trailing closure context.
if let trailingClosure = functionCall.trailingClosure,
snapshot.position(of: trailingClosure.leftBrace.positionAfterSkippingLeadingTrivia).line == position.line
{
return .trailingClosure
}
return .none
}
private func determineFollowingCallContextParenthesisKind(
leftParen: TokenSyntax,
rightParen: TokenSyntax?,
snapshot: DocumentSnapshot
) -> FollowingCallContext {
if let rightParen, leftParen.nextToken(viewMode: .sourceAccurate) == rightParen,
// Only actually consider the parens as empty if there is no comment or newline in between. A comment should not be
// overwrittten and replacing parens across multiple lines is likely not what the user intended.
!leftParen.trailingTrivia.contains(where: { $0.isComment || $0.isNewline }),
!rightParen.leadingTrivia.contains(where: { $0.isComment || $0.isNewline })
{
return .emptyParens(positionAfterClosingParen: snapshot.position(of: rightParen.endPositionBeforeTrailingTrivia))
} else {
return .nonEmptyParens
}
}