mirror of
https://github.com/apple/sourcekit-lsp.git
synced 2026-03-02 18:23:24 +01:00
Merge pull request #2436 from loveucifer/inlay-hint-go-to-definition
This commit is contained in:
@@ -256,6 +256,7 @@ package protocol LanguageService: AnyObject, Sendable {
|
||||
func colorPresentation(_ req: ColorPresentationRequest) async throws -> [ColorPresentation]
|
||||
func codeAction(_ req: CodeActionRequest) async throws -> CodeActionRequestResponse?
|
||||
func inlayHint(_ req: InlayHintRequest) async throws -> [InlayHint]
|
||||
func inlayHintResolve(_ req: InlayHintResolveRequest) async throws -> InlayHint
|
||||
func codeLens(_ req: CodeLensRequest) async throws -> [CodeLens]
|
||||
func documentDiagnostic(_ req: DocumentDiagnosticsRequest) async throws -> DocumentDiagnosticReport
|
||||
func documentFormatting(_ req: DocumentFormattingRequest) async throws -> [TextEdit]?
|
||||
@@ -481,6 +482,10 @@ package extension LanguageService {
|
||||
throw ResponseError.requestNotImplemented(InlayHintRequest.self)
|
||||
}
|
||||
|
||||
func inlayHintResolve(_ req: InlayHintResolveRequest) async throws -> InlayHint {
|
||||
return req.inlayHint
|
||||
}
|
||||
|
||||
func codeLens(_ req: CodeLensRequest) async throws -> [CodeLens] {
|
||||
throw ResponseError.requestNotImplemented(CodeLensRequest.self)
|
||||
}
|
||||
|
||||
@@ -832,6 +832,8 @@ extension SourceKitLSPServer: QueueBasedMessageHandler {
|
||||
initialized = true
|
||||
case let request as RequestAndReply<InlayHintRequest>:
|
||||
await self.handleRequest(for: request, requestHandler: self.inlayHint)
|
||||
case let request as RequestAndReply<InlayHintResolveRequest>:
|
||||
await request.reply { try await inlayHintResolve(request: request.params) }
|
||||
case let request as RequestAndReply<IsIndexingRequest>:
|
||||
await request.reply { try await self.isIndexing(request.params) }
|
||||
case let request as RequestAndReply<OutputPathsRequest>:
|
||||
@@ -1098,7 +1100,7 @@ extension SourceKitLSPServer {
|
||||
let inlayHintOptions =
|
||||
await registry.clientHasDynamicInlayHintRegistration
|
||||
? nil
|
||||
: ValueOrBool.value(InlayHintOptions(resolveProvider: false))
|
||||
: ValueOrBool.value(InlayHintOptions(resolveProvider: true))
|
||||
|
||||
let semanticTokensOptions =
|
||||
await registry.clientHasDynamicSemanticTokensRegistration
|
||||
@@ -1948,6 +1950,22 @@ extension SourceKitLSPServer {
|
||||
return try await languageService.inlayHint(req)
|
||||
}
|
||||
|
||||
func inlayHintResolve(
|
||||
request: InlayHintResolveRequest
|
||||
) async throws -> InlayHint {
|
||||
guard case .dictionary(let dict) = request.inlayHint.data,
|
||||
case .string(let uriString) = dict["uri"],
|
||||
let uri = try? DocumentURI(string: uriString)
|
||||
else {
|
||||
return request.inlayHint
|
||||
}
|
||||
guard let workspace = await self.workspaceForDocument(uri: uri) else {
|
||||
return request.inlayHint
|
||||
}
|
||||
let language = try documentManager.latestSnapshot(uri.buildSettingsFile).language
|
||||
return try await primaryLanguageService(for: uri, language, in: workspace).inlayHintResolve(request)
|
||||
}
|
||||
|
||||
func documentDiagnostic(
|
||||
_ req: DocumentDiagnosticsRequest,
|
||||
workspace: Workspace,
|
||||
|
||||
@@ -26,6 +26,7 @@ add_library(SwiftLanguageService STATIC
|
||||
GeneratedInterfaceManager.swift
|
||||
IndentationRemover.swift
|
||||
InlayHints.swift
|
||||
InlayHintResolve.swift
|
||||
MacroExpansion.swift
|
||||
OpenInterface.swift
|
||||
PlaygroundDiscovery.swift
|
||||
|
||||
@@ -207,4 +207,51 @@ extension SwiftLanguageService {
|
||||
additionalParameters: appendAdditionalParameters
|
||||
)
|
||||
}
|
||||
|
||||
/// Because of https://github.com/swiftlang/swift/issues/86432 sourcekitd returns a mangled name instead of a USR
|
||||
/// as the type USR. Work around this by replacing mangled names (starting with `$s`) to a USR, starting with `s:`.
|
||||
/// We also strip the trailing `D` suffix which represents a type mangling - this may not work correctly for generic
|
||||
/// types with type arguments.
|
||||
// TODO: Remove once https://github.com/swiftlang/swift/issues/86432 is fixed
|
||||
private func convertMangledTypeToUSR(_ mangledType: String) -> String {
|
||||
var result = mangledType
|
||||
if result.hasPrefix("$s") {
|
||||
result = "s:" + result.dropFirst(2)
|
||||
}
|
||||
// Strip trailing 'D' (type mangling suffix) to get declaration USR
|
||||
if result.hasSuffix("D") {
|
||||
result = String(result.dropLast())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Get cursor info for a type by looking up its USR.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mangledType: The mangled name of the type
|
||||
/// - snapshot: Document snapshot for context (used to get compile command)
|
||||
/// - Returns: CursorInfo for the type declaration, or `nil` if not found
|
||||
func cursorInfoFromTypeUSR(
|
||||
_ mangledType: String,
|
||||
in snapshot: DocumentSnapshot
|
||||
) async throws -> CursorInfo? {
|
||||
let usr = convertMangledTypeToUSR(mangledType)
|
||||
|
||||
let compileCommand = await self.compileCommand(for: snapshot.uri, fallbackAfterTimeout: true)
|
||||
let documentManager = try self.documentManager
|
||||
|
||||
let keys = self.keys
|
||||
|
||||
let skreq = sourcekitd.dictionary([
|
||||
keys.cancelOnSubsequentRequest: 0,
|
||||
keys.usr: usr,
|
||||
keys.sourceFile: snapshot.uri.sourcekitdSourceFile,
|
||||
keys.primaryFile: snapshot.uri.primaryFile?.pseudoPath,
|
||||
keys.compilerArgs: compileCommand?.compilerArgs as [any SKDRequestValue]?,
|
||||
])
|
||||
|
||||
let dict = try await send(sourcekitdRequest: \.cursorInfo, skreq, snapshot: snapshot)
|
||||
|
||||
return CursorInfo(dict, snapshot: snapshot, documentManager: documentManager, sourcekitd: sourcekitd)
|
||||
}
|
||||
}
|
||||
|
||||
113
Sources/SwiftLanguageService/InlayHintResolve.swift
Normal file
113
Sources/SwiftLanguageService/InlayHintResolve.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
//
|
||||
// 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
|
||||
//
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
import IndexStoreDB
|
||||
@_spi(SourceKitLSP) package import LanguageServerProtocol
|
||||
import SemanticIndex
|
||||
import SourceKitD
|
||||
import SourceKitLSP
|
||||
|
||||
extension SwiftLanguageService {
|
||||
/// Resolves an inlay hint by looking up the type definition location.
|
||||
package func inlayHintResolve(_ req: InlayHintResolveRequest) async throws -> InlayHint {
|
||||
let hint = req.inlayHint
|
||||
|
||||
guard hint.kind == .type,
|
||||
let resolveData = InlayHintResolveData(fromLSPAny: hint.data)
|
||||
else {
|
||||
return hint
|
||||
}
|
||||
|
||||
// Fail if document version has changed since the hint was created
|
||||
let currentSnapshot = try await self.latestSnapshot(for: resolveData.uri)
|
||||
guard currentSnapshot.version == resolveData.version else {
|
||||
return hint
|
||||
}
|
||||
|
||||
let typeLocation = try await lookupTypeDefinitionLocation(
|
||||
snapshot: currentSnapshot,
|
||||
position: resolveData.position
|
||||
)
|
||||
|
||||
guard let typeLocation else {
|
||||
return hint
|
||||
}
|
||||
|
||||
if case .string(let labelText) = hint.label {
|
||||
return InlayHint(
|
||||
position: hint.position,
|
||||
label: .parts([InlayHintLabelPart(value: labelText, location: typeLocation)]),
|
||||
kind: hint.kind,
|
||||
textEdits: hint.textEdits,
|
||||
tooltip: hint.tooltip,
|
||||
paddingLeft: hint.paddingLeft,
|
||||
paddingRight: hint.paddingRight,
|
||||
data: hint.data
|
||||
)
|
||||
}
|
||||
|
||||
return hint
|
||||
}
|
||||
|
||||
/// Looks up the definition location for the type at the given position.
|
||||
///
|
||||
/// This is used by inlay hint resolution to enable go-to-definition on type hints.
|
||||
/// For SDK types, this returns a location in the generated interface.
|
||||
func lookupTypeDefinitionLocation(
|
||||
snapshot: DocumentSnapshot,
|
||||
position: Position
|
||||
) async throws -> Location? {
|
||||
let compileCommand = await self.compileCommand(for: snapshot.uri, fallbackAfterTimeout: false)
|
||||
|
||||
let skreq = sourcekitd.dictionary([
|
||||
keys.cancelOnSubsequentRequest: 0,
|
||||
keys.offset: snapshot.utf8Offset(of: position),
|
||||
keys.sourceFile: snapshot.uri.sourcekitdSourceFile,
|
||||
keys.primaryFile: snapshot.uri.primaryFile?.pseudoPath,
|
||||
keys.compilerArgs: compileCommand?.compilerArgs as [any SKDRequestValue]?,
|
||||
])
|
||||
|
||||
let dict = try await send(sourcekitdRequest: \.cursorInfo, skreq, snapshot: snapshot)
|
||||
|
||||
guard let typeUsr: String = dict[keys.typeUsr] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let typeInfo = try await cursorInfoFromTypeUSR(typeUsr, in: snapshot) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// For local types, return the local declaration
|
||||
if let location = typeInfo.symbolInfo.bestLocalDeclaration {
|
||||
return location
|
||||
}
|
||||
|
||||
// For SDK types, fall back to generated interface
|
||||
if typeInfo.symbolInfo.isSystem ?? false,
|
||||
let systemModule = typeInfo.symbolInfo.systemModule
|
||||
{
|
||||
let interfaceDetails = try await self.openGeneratedInterface(
|
||||
document: snapshot.uri,
|
||||
moduleName: systemModule.moduleName,
|
||||
groupName: systemModule.groupName,
|
||||
symbolUSR: typeInfo.symbolInfo.usr
|
||||
)
|
||||
if let details = interfaceDetails {
|
||||
let position = details.position ?? Position(line: 0, utf16index: 0)
|
||||
return Location(uri: details.uri, range: Range(position))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,45 @@
|
||||
//
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
@_spi(SourceKitLSP) package import LanguageServerProtocol
|
||||
import SourceKitLSP
|
||||
import SwiftExtensions
|
||||
import SwiftSyntax
|
||||
|
||||
package struct InlayHintResolveData: LSPAnyCodable {
|
||||
package let uri: DocumentURI
|
||||
package let position: Position
|
||||
package let version: Int
|
||||
|
||||
package init(uri: DocumentURI, position: Position, version: Int) {
|
||||
self.uri = uri
|
||||
self.position = position
|
||||
self.version = version
|
||||
}
|
||||
|
||||
package init?(fromLSPDictionary dictionary: [String: LSPAny]) {
|
||||
guard case .string(let uriString) = dictionary["uri"],
|
||||
let uri = try? DocumentURI(string: uriString),
|
||||
case .int(let version) = dictionary["version"],
|
||||
let position = Position(fromLSPAny: dictionary["position"])
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
self.uri = uri
|
||||
self.position = position
|
||||
self.version = version
|
||||
}
|
||||
|
||||
package func encodeToLSPAny() -> LSPAny {
|
||||
return .dictionary([
|
||||
"uri": .string(uri.stringValue),
|
||||
"position": position.encodeToLSPAny(),
|
||||
"version": .int(version),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private class IfConfigCollector: SyntaxVisitor {
|
||||
private var ifConfigDecls: [IfConfigDeclSyntax] = []
|
||||
|
||||
@@ -34,12 +68,16 @@ private class IfConfigCollector: SyntaxVisitor {
|
||||
extension SwiftLanguageService {
|
||||
package func inlayHint(_ req: InlayHintRequest) async throws -> [InlayHint] {
|
||||
let uri = req.textDocument.uri
|
||||
let snapshot = try await self.latestSnapshot(for: uri)
|
||||
let version = snapshot.version
|
||||
|
||||
let infos = try await variableTypeInfos(uri, req.range)
|
||||
let typeHints = infos
|
||||
.lazy
|
||||
.filter { !$0.hasExplicitType }
|
||||
.map { info -> InlayHint in
|
||||
let position = info.range.upperBound
|
||||
let variableStart = info.range.lowerBound
|
||||
let label = ": \(info.printedType)"
|
||||
let textEdits: [TextEdit]?
|
||||
if info.canBeFollowedByTypeAnnotation {
|
||||
@@ -47,15 +85,16 @@ extension SwiftLanguageService {
|
||||
} else {
|
||||
textEdits = nil
|
||||
}
|
||||
let resolveData = InlayHintResolveData(uri: uri, position: variableStart, version: version)
|
||||
return InlayHint(
|
||||
position: position,
|
||||
label: .string(label),
|
||||
kind: .type,
|
||||
textEdits: textEdits
|
||||
textEdits: textEdits,
|
||||
data: resolveData.encodeToLSPAny()
|
||||
)
|
||||
}
|
||||
|
||||
let snapshot = try await self.latestSnapshot(for: uri)
|
||||
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
|
||||
let ifConfigDecls = IfConfigCollector.collectIfConfigDecls(in: syntaxTree)
|
||||
let ifConfigHints = ifConfigDecls.compactMap { (ifConfigDecl) -> InlayHint? in
|
||||
|
||||
@@ -413,7 +413,7 @@ extension SwiftLanguageService {
|
||||
range: .bool(true),
|
||||
full: .bool(true)
|
||||
),
|
||||
inlayHintProvider: .value(InlayHintOptions(resolveProvider: false)),
|
||||
inlayHintProvider: .value(InlayHintOptions(resolveProvider: true)),
|
||||
diagnosticProvider: DiagnosticOptions(
|
||||
interFileDependencies: true,
|
||||
workspaceDiagnostics: false
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import SKLogging
|
||||
import SKTestSupport
|
||||
import SourceKitLSP
|
||||
import SwiftExtensions
|
||||
import XCTest
|
||||
|
||||
final class InlayHintTests: SourceKitLSPTestCase {
|
||||
@@ -59,6 +60,23 @@ final class InlayHintTests: SourceKitLSPTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
/// compares hints ignoring the data field (which contains implementation-specific resolve data)
|
||||
private func assertHintsEqual(
|
||||
_ actual: [InlayHint],
|
||||
_ expected: [InlayHint],
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) {
|
||||
XCTAssertEqual(actual.count, expected.count, "Hint count mismatch", file: file, line: line)
|
||||
for (actualHint, expectedHint) in zip(actual, expected) {
|
||||
XCTAssertEqual(actualHint.position, expectedHint.position, file: file, line: line)
|
||||
XCTAssertEqual(actualHint.label, expectedHint.label, file: file, line: line)
|
||||
XCTAssertEqual(actualHint.kind, expectedHint.kind, file: file, line: line)
|
||||
XCTAssertEqual(actualHint.textEdits, expectedHint.textEdits, file: file, line: line)
|
||||
XCTAssertEqual(actualHint.tooltip, expectedHint.tooltip, file: file, line: line)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
func testEmpty() async throws {
|
||||
@@ -73,7 +91,7 @@ final class InlayHintTests: SourceKitLSPTestCase {
|
||||
var y2️⃣ = "test" + "123"
|
||||
"""
|
||||
)
|
||||
XCTAssertEqual(
|
||||
assertHintsEqual(
|
||||
hints,
|
||||
[
|
||||
makeInlayHint(
|
||||
@@ -106,7 +124,7 @@ final class InlayHintTests: SourceKitLSPTestCase {
|
||||
""",
|
||||
range: ("1️⃣", "4️⃣")
|
||||
)
|
||||
XCTAssertEqual(
|
||||
assertHintsEqual(
|
||||
hints,
|
||||
[
|
||||
makeInlayHint(
|
||||
@@ -141,7 +159,7 @@ final class InlayHintTests: SourceKitLSPTestCase {
|
||||
}
|
||||
"""
|
||||
)
|
||||
XCTAssertEqual(
|
||||
assertHintsEqual(
|
||||
hints,
|
||||
[
|
||||
makeInlayHint(
|
||||
@@ -198,7 +216,7 @@ final class InlayHintTests: SourceKitLSPTestCase {
|
||||
}
|
||||
"""
|
||||
)
|
||||
XCTAssertEqual(
|
||||
assertHintsEqual(
|
||||
hints,
|
||||
[
|
||||
makeInlayHint(
|
||||
@@ -267,4 +285,123 @@ final class InlayHintTests: SourceKitLSPTestCase {
|
||||
)
|
||||
XCTAssertEqual(hints, [])
|
||||
}
|
||||
|
||||
func testInlayHintResolve() async throws {
|
||||
let testClient = try await TestSourceKitLSPClient()
|
||||
let uri = DocumentURI(for: .swift)
|
||||
|
||||
let positions = testClient.openDocument(
|
||||
"""
|
||||
struct 1️⃣MyType {}
|
||||
let x2️⃣ = MyType()
|
||||
""",
|
||||
uri: uri
|
||||
)
|
||||
|
||||
let request = InlayHintRequest(textDocument: TextDocumentIdentifier(uri), range: nil)
|
||||
let hints = try await testClient.send(request)
|
||||
|
||||
guard let typeHint = hints.first(where: { $0.kind == .type }) else {
|
||||
XCTFail("Expected type hint")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertNotNil(typeHint.data, "Expected type hint to have data for resolution")
|
||||
|
||||
let resolvedHint = try await testClient.send(InlayHintResolveRequest(inlayHint: typeHint))
|
||||
|
||||
guard case .parts(let parts) = resolvedHint.label else {
|
||||
XCTFail("Expected resolved hint to have label parts, got: \(resolvedHint.label)")
|
||||
return
|
||||
}
|
||||
|
||||
guard let location = parts.only?.location else {
|
||||
XCTFail("Expected label part to have location for go-to-definition")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(location.uri, uri)
|
||||
XCTAssertEqual(location.range, Range(positions["1️⃣"]))
|
||||
}
|
||||
|
||||
func testInlayHintResolveCrossModule() async throws {
|
||||
let project = try await SwiftPMTestProject(
|
||||
files: [
|
||||
"LibA/MyType.swift": """
|
||||
public struct 1️⃣MyType {
|
||||
public init() {}
|
||||
}
|
||||
""",
|
||||
"LibB/UseType.swift": """
|
||||
import LibA
|
||||
let x2️⃣ = MyType()
|
||||
""",
|
||||
],
|
||||
manifest: """
|
||||
let package = Package(
|
||||
name: "MyLibrary",
|
||||
targets: [
|
||||
.target(name: "LibA"),
|
||||
.target(name: "LibB", dependencies: ["LibA"]),
|
||||
]
|
||||
)
|
||||
""",
|
||||
enableBackgroundIndexing: true
|
||||
)
|
||||
|
||||
let (uri, positions) = try project.openDocument("UseType.swift")
|
||||
|
||||
let request = InlayHintRequest(textDocument: TextDocumentIdentifier(uri), range: nil)
|
||||
let hints = try await project.testClient.send(request)
|
||||
|
||||
guard let typeHint = hints.first(where: { $0.kind == .type }) else {
|
||||
XCTFail("Expected type hint for MyType")
|
||||
return
|
||||
}
|
||||
|
||||
let resolvedHint = try await project.testClient.send(InlayHintResolveRequest(inlayHint: typeHint))
|
||||
|
||||
guard case .parts(let parts) = resolvedHint.label,
|
||||
let location = parts.only?.location
|
||||
else {
|
||||
XCTFail("Expected label part to have location for go-to-definition")
|
||||
return
|
||||
}
|
||||
|
||||
// The location should point to LibA/MyType.swift where MyType is defined
|
||||
XCTAssertEqual(location.uri, try project.uri(for: "MyType.swift"))
|
||||
XCTAssertEqual(location.range, try Range(project.position(of: "1️⃣", in: "MyType.swift")))
|
||||
}
|
||||
|
||||
func testInlayHintResolveSDKType() async throws {
|
||||
let project = try await IndexedSingleSwiftFileTestProject(
|
||||
"""
|
||||
let 1️⃣x = "hello"
|
||||
""",
|
||||
indexSystemModules: true
|
||||
)
|
||||
|
||||
let request = InlayHintRequest(textDocument: TextDocumentIdentifier(project.fileURI), range: nil)
|
||||
let hints = try await project.testClient.send(request)
|
||||
|
||||
guard let typeHint = hints.first(where: { $0.kind == .type }) else {
|
||||
XCTFail("Expected type hint for String")
|
||||
return
|
||||
}
|
||||
|
||||
let resolvedHint = try await project.testClient.send(InlayHintResolveRequest(inlayHint: typeHint))
|
||||
|
||||
guard case .parts(let parts) = resolvedHint.label,
|
||||
let location = parts.only?.location
|
||||
else {
|
||||
XCTFail("Expected label part to have location for go-to-definition")
|
||||
return
|
||||
}
|
||||
|
||||
// Should point to generated Swift interface
|
||||
XCTAssertTrue(
|
||||
location.uri.pseudoPath.hasSuffix(".swiftinterface"),
|
||||
"Expected .swiftinterface file, got: \(location.uri.pseudoPath)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user