Files
sourcekit-lsp/Sources/SwiftLanguageService/SignatureHelp.swift
Ahmed Elrefaey a5854f4ecf Add signature help LSP request support (#2250)
Depends on https://github.com/swiftlang/swift/pull/83378

---

Adds support for the LSP signature help request.

> [!NOTE]
> As of https://github.com/swiftlang/swift/pull/83378, SourceKitD still
doesn't separate parameter documentation from the signature
documentation and thus parameters don't have their own separate
documentation. This should just work once SourceKitD implements this
functionality and we'll only need to modify the tests.
2025-09-05 14:52:56 +01:00

171 lines
6.0 KiB
Swift

//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2025 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 Foundation
package import LanguageServerProtocol
import SKLogging
import SourceKitD
import SourceKitLSP
import SwiftBasicFormat
import SwiftExtensions
fileprivate extension String {
func utf16Offset(of utf8Offset: Int, callerFile: StaticString = #fileID, callerLine: UInt = #line) -> Int {
guard
let stringIndex = self.utf8.index(self.startIndex, offsetBy: utf8Offset, limitedBy: self.endIndex)
else {
logger.fault(
"""
UTF-8 offset is past the end of the string while getting UTF-16 offset of \(utf8Offset) \
(\(callerFile, privacy: .public):\(callerLine, privacy: .public))
"""
)
return self.utf16.count
}
return self.utf16.distance(from: self.startIndex, to: stringIndex)
}
}
fileprivate extension ParameterInformation {
init?(_ parameter: SKDResponseDictionary, _ signatureLabel: String, _ keys: sourcekitd_api_keys) {
guard let nameOffset = parameter[keys.nameOffset] as Int?,
let nameLength = parameter[keys.nameLength] as Int?
else {
return nil
}
let documentation: StringOrMarkupContent? =
if let docComment: String = parameter[keys.docComment] {
.markupContent(MarkupContent(kind: .markdown, value: docComment))
} else {
nil
}
let labelStart = signatureLabel.utf16Offset(of: nameOffset)
let labelEnd = signatureLabel.utf16Offset(of: nameOffset + nameLength)
self.init(
label: .offsets(start: labelStart, end: labelEnd),
documentation: documentation
)
}
}
fileprivate extension SignatureInformation {
init?(_ signature: SKDResponseDictionary, _ keys: sourcekitd_api_keys) {
guard let label = signature[keys.name] as String?,
let skParameters = signature[keys.parameters] as SKDResponseArray?
else {
return nil
}
let parameters = skParameters.compactMap { ParameterInformation($0, label, keys) }
let activeParameter: Int? =
if let activeParam: Int = signature[keys.activeParameter] {
activeParam
} else if !parameters.isEmpty {
// If we have parameters and no active parameter is present, we return
// an out-of-range index that way editors don't show an active parameter.
// As of LSP 3.17, not returning an active parameter defaults to choosing
// the first parameter which isn't the desired behavior.
// LSP 3.17 states that out-of-range values are treated as 0 but this is
// not the case in VS Code so we rely on that as a workaround.
// LSP 3.18 defines a `noActiveParameterSupport` option which allows the
// active parameter to be `null` causing editors not to show an active
// parameter which would be the best solution here.
parameters.count
} else {
nil
}
let documentation: StringOrMarkupContent? =
if let docComment: String = signature[keys.docComment] {
.markupContent(MarkupContent(kind: .markdown, value: docComment))
} else {
nil
}
self.init(
label: label,
documentation: documentation,
parameters: parameters,
activeParameter: activeParameter
)
}
/// Checks if two signatures are identical except for the active parameter.
/// This is used to match the active signature given a previously active signature.
func isSame(_ other: SignatureInformation) -> Bool {
self.label == other.label && self.documentation == other.documentation && self.parameters == other.parameters
}
}
fileprivate extension SignatureHelp {
init?(_ dict: SKDResponseDictionary, _ keys: sourcekitd_api_keys) {
guard let skSignatures = dict[keys.signatures] as SKDResponseArray?,
let activeSignature = dict[keys.activeSignature] as Int?
else {
return nil
}
let signatures = skSignatures.compactMap { SignatureInformation($0, keys) }
guard !signatures.isEmpty else {
return nil
}
self.init(
signatures: signatures,
activeSignature: activeSignature,
activeParameter: signatures[activeSignature].activeParameter
)
}
}
extension SwiftLanguageService {
package func signatureHelp(_ req: SignatureHelpRequest) async throws -> SignatureHelp? {
let snapshot = try documentManager.latestSnapshot(req.textDocument.uri)
let adjustedPosition = await adjustPositionToStartOfArgument(req.position, in: snapshot)
let compileCommand = await compileCommand(for: snapshot.uri, fallbackAfterTimeout: false)
let skreq = sourcekitd.dictionary([
keys.offset: snapshot.utf8Offset(of: adjustedPosition),
keys.sourceFile: snapshot.uri.sourcekitdSourceFile,
keys.primaryFile: snapshot.uri.primaryFile?.pseudoPath,
keys.compilerArgs: compileCommand?.compilerArgs as [SKDRequestValue]?,
])
let dict = try await send(sourcekitdRequest: \.signatureHelp, skreq, snapshot: snapshot)
guard var signatureHelp = SignatureHelp(dict, keys) else {
return nil
}
// Persist the active signature and parameter from the previous request if it exists.
guard let activeSignatureHelp = req.context?.activeSignatureHelp,
let activeSignatureIndex = activeSignatureHelp.activeSignature,
let activeSignature = activeSignatureHelp.signatures[safe: activeSignatureIndex],
let matchingSignatureIndex = signatureHelp.signatures.firstIndex(where: activeSignature.isSame)
else {
return signatureHelp
}
signatureHelp.activeSignature = matchingSignatureIndex
signatureHelp.activeParameter = signatureHelp.signatures[matchingSignatureIndex].activeParameter
return signatureHelp
}
}