Merge pull request #2436 from loveucifer/inlay-hint-go-to-definition

This commit is contained in:
Alex Hoppen
2026-01-16 13:16:45 +01:00
committed by GitHub
8 changed files with 368 additions and 8 deletions

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -26,6 +26,7 @@ add_library(SwiftLanguageService STATIC
GeneratedInterfaceManager.swift
IndentationRemover.swift
InlayHints.swift
InlayHintResolve.swift
MacroExpansion.swift
OpenInterface.swift
PlaygroundDiscovery.swift

View File

@@ -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)
}
}

View 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
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)"
)
}
}