diff --git a/Sources/SourceKitLSP/LanguageService.swift b/Sources/SourceKitLSP/LanguageService.swift index 76b1bb5b..058d2c4e 100644 --- a/Sources/SourceKitLSP/LanguageService.swift +++ b/Sources/SourceKitLSP/LanguageService.swift @@ -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) } diff --git a/Sources/SourceKitLSP/SourceKitLSPServer.swift b/Sources/SourceKitLSP/SourceKitLSPServer.swift index 8c30229e..13e763f4 100644 --- a/Sources/SourceKitLSP/SourceKitLSPServer.swift +++ b/Sources/SourceKitLSP/SourceKitLSPServer.swift @@ -832,6 +832,8 @@ extension SourceKitLSPServer: QueueBasedMessageHandler { initialized = true case let request as RequestAndReply: await self.handleRequest(for: request, requestHandler: self.inlayHint) + case let request as RequestAndReply: + await request.reply { try await inlayHintResolve(request: request.params) } case let request as RequestAndReply: await request.reply { try await self.isIndexing(request.params) } case let request as RequestAndReply: @@ -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, diff --git a/Sources/SwiftLanguageService/CMakeLists.txt b/Sources/SwiftLanguageService/CMakeLists.txt index 8af5f9a0..f4964467 100644 --- a/Sources/SwiftLanguageService/CMakeLists.txt +++ b/Sources/SwiftLanguageService/CMakeLists.txt @@ -26,6 +26,7 @@ add_library(SwiftLanguageService STATIC GeneratedInterfaceManager.swift IndentationRemover.swift InlayHints.swift + InlayHintResolve.swift MacroExpansion.swift OpenInterface.swift PlaygroundDiscovery.swift diff --git a/Sources/SwiftLanguageService/CursorInfo.swift b/Sources/SwiftLanguageService/CursorInfo.swift index dc6c2027..f75304e5 100644 --- a/Sources/SwiftLanguageService/CursorInfo.swift +++ b/Sources/SwiftLanguageService/CursorInfo.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) + } } diff --git a/Sources/SwiftLanguageService/InlayHintResolve.swift b/Sources/SwiftLanguageService/InlayHintResolve.swift new file mode 100644 index 00000000..7da9747e --- /dev/null +++ b/Sources/SwiftLanguageService/InlayHintResolve.swift @@ -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 + } +} diff --git a/Sources/SwiftLanguageService/InlayHints.swift b/Sources/SwiftLanguageService/InlayHints.swift index 1133d222..8321c3cf 100644 --- a/Sources/SwiftLanguageService/InlayHints.swift +++ b/Sources/SwiftLanguageService/InlayHints.swift @@ -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 diff --git a/Sources/SwiftLanguageService/SwiftLanguageService.swift b/Sources/SwiftLanguageService/SwiftLanguageService.swift index c5e537a9..78b5b133 100644 --- a/Sources/SwiftLanguageService/SwiftLanguageService.swift +++ b/Sources/SwiftLanguageService/SwiftLanguageService.swift @@ -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 diff --git a/Tests/SourceKitLSPTests/InlayHintTests.swift b/Tests/SourceKitLSPTests/InlayHintTests.swift index 184c8034..e2dea7c2 100644 --- a/Tests/SourceKitLSPTests/InlayHintTests.swift +++ b/Tests/SourceKitLSPTests/InlayHintTests.swift @@ -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)" + ) + } }