From 57df8df8b56410a0307ee69a57ee9ceeeb074723 Mon Sep 17 00:00:00 2001 From: Divya Prakash Date: Tue, 27 Jan 2026 14:45:08 +0530 Subject: [PATCH 1/2] feat: add inlay hints for trailing closure parameters --- Sources/SKOptions/SourceKitLSPOptions.swift | 35 +- Sources/SwiftLanguageService/CMakeLists.txt | 1 + Sources/SwiftLanguageService/InlayHints.swift | 11 +- .../TrailingClosureInlayHints.swift | 229 ++++++++++++ .../TrailingClosureInlayHintTests.swift | 331 ++++++++++++++++++ 5 files changed, 603 insertions(+), 4 deletions(-) create mode 100644 Sources/SwiftLanguageService/TrailingClosureInlayHints.swift create mode 100644 Tests/SourceKitLSPTests/TrailingClosureInlayHintTests.swift diff --git a/Sources/SKOptions/SourceKitLSPOptions.swift b/Sources/SKOptions/SourceKitLSPOptions.swift index aae11a091..9e6fe0bb7 100644 --- a/Sources/SKOptions/SourceKitLSPOptions.swift +++ b/Sources/SKOptions/SourceKitLSPOptions.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors +// 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 @@ -226,6 +226,29 @@ public struct SourceKitLSPOptions: Sendable, Codable, Equatable { } } + public struct InlayHintsOptions: Sendable, Codable, Equatable { + /// Whether to show inlay hints for trailing closure labels. + /// + /// When enabled, shows parameter names as inlay hints immediately before the opening brace + /// of trailing closures, helping identify which parameter each closure satisfies. + /// Default is `false`. + public var trailingClosureLabels: Bool? + + public var trailingClosureLabelsOrDefault: Bool { + trailingClosureLabels ?? false + } + + public init(trailingClosureLabels: Bool? = nil) { + self.trailingClosureLabels = trailingClosureLabels + } + + static func merging(base: InlayHintsOptions, override: InlayHintsOptions?) -> InlayHintsOptions { + return InlayHintsOptions( + trailingClosureLabels: override?.trailingClosureLabels ?? base.trailingClosureLabels + ) + } + } + public struct LoggingOptions: Sendable, Codable, Equatable { /// The level from which one onwards log messages should be written. public var level: String? @@ -350,7 +373,12 @@ public struct SourceKitLSPOptions: Sendable, Codable, Equatable { get { logging ?? .init() } set { logging = newValue } } - + /// Options related to inlay hints. + public var inlayHints: InlayHintsOptions? + public var inlayHintsOrDefault: InlayHintsOptions { + get { inlayHints ?? .init() } + set { inlayHints = newValue } + } /// Options modifying the behavior of sourcekitd. private var sourcekitd: SourceKitDOptions? public var sourcekitdOrDefault: SourceKitDOptions { @@ -467,6 +495,7 @@ public struct SourceKitLSPOptions: Sendable, Codable, Equatable { clangdOptions: [String]? = nil, index: IndexOptions? = .init(), logging: LoggingOptions? = .init(), + inlayHints: InlayHintsOptions? = .init(), sourcekitd: SourceKitDOptions? = .init(), defaultWorkspaceType: WorkspaceType? = nil, generatedFilesPath: String? = nil, @@ -488,6 +517,7 @@ public struct SourceKitLSPOptions: Sendable, Codable, Equatable { self.clangdOptions = clangdOptions self.index = index self.logging = logging + self.inlayHints = inlayHints self.sourcekitd = sourcekitd self.generatedFilesPath = generatedFilesPath self.defaultWorkspaceType = defaultWorkspaceType @@ -552,6 +582,7 @@ public struct SourceKitLSPOptions: Sendable, Codable, Equatable { clangdOptions: override?.clangdOptions ?? base.clangdOptions, index: IndexOptions.merging(base: base.indexOrDefault, override: override?.index), logging: LoggingOptions.merging(base: base.loggingOrDefault, override: override?.logging), + inlayHints: InlayHintsOptions.merging(base: base.inlayHintsOrDefault, override: override?.inlayHints), sourcekitd: SourceKitDOptions.merging(base: base.sourcekitdOrDefault, override: override?.sourcekitd), defaultWorkspaceType: override?.defaultWorkspaceType ?? base.defaultWorkspaceType, generatedFilesPath: override?.generatedFilesPath ?? base.generatedFilesPath, diff --git a/Sources/SwiftLanguageService/CMakeLists.txt b/Sources/SwiftLanguageService/CMakeLists.txt index f49644670..76808dd60 100644 --- a/Sources/SwiftLanguageService/CMakeLists.txt +++ b/Sources/SwiftLanguageService/CMakeLists.txt @@ -27,6 +27,7 @@ add_library(SwiftLanguageService STATIC IndentationRemover.swift InlayHints.swift InlayHintResolve.swift + TrailingClosureInlayHints.swift MacroExpansion.swift OpenInterface.swift PlaygroundDiscovery.swift diff --git a/Sources/SwiftLanguageService/InlayHints.swift b/Sources/SwiftLanguageService/InlayHints.swift index 8321c3cf2..375345eaa 100644 --- a/Sources/SwiftLanguageService/InlayHints.swift +++ b/Sources/SwiftLanguageService/InlayHints.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// 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 @@ -118,6 +118,13 @@ extension SwiftLanguageService { ) } - return Array(typeHints + ifConfigHints) + // Generate trailing closure inlay hints + let trailingClosureHints = await trailingClosureInlayHints( + uri: uri, + range: req.range, + options: self.options + ) + + return Array(typeHints + ifConfigHints + trailingClosureHints) } } diff --git a/Sources/SwiftLanguageService/TrailingClosureInlayHints.swift b/Sources/SwiftLanguageService/TrailingClosureInlayHints.swift new file mode 100644 index 000000000..a3d9d5620 --- /dev/null +++ b/Sources/SwiftLanguageService/TrailingClosureInlayHints.swift @@ -0,0 +1,229 @@ +//===----------------------------------------------------------------------===// +// +// 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 +@_spi(SourceKitLSP) package import LanguageServerProtocol +package import SKOptions +import SourceKitD +import SourceKitLSP +import SwiftSyntax + +/// Collects trailing closure inlay hints for function calls. +private class TrailingClosureHintCollector: SyntaxVisitor { + private var hints: [TrailingClosureHintInfo] = [] + + override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind { + if let trailingClosure = node.trailingClosure { + let hintInfo = TrailingClosureHintInfo( + trailingClosure: trailingClosure, + functionCall: node + ) + hints.append(hintInfo) + } + return .visitChildren + } + + static func collectTrailingClosures(in tree: some SyntaxProtocol) -> [TrailingClosureHintInfo] { + let visitor = TrailingClosureHintCollector(viewMode: .sourceAccurate) + visitor.walk(tree) + return visitor.hints + } +} + +/// Information about a trailing closure that may need an inlay hint. +struct TrailingClosureHintInfo { + let trailingClosure: ClosureExprSyntax + let functionCall: FunctionCallExprSyntax + + /// The opening brace of the trailing closure. + var openingBrace: TokenSyntax { + trailingClosure.leftBrace + } +} + +extension SwiftLanguageService { + /// Generates inlay hints for trailing closures in the given range. + /// + /// Trailing closure hints display the parameter name immediately before the opening brace + /// of a trailing closure, helping identify which parameter the closure satisfies. + /// + /// - Parameters: + /// - uri: The document URI. + /// - range: Optional range to filter hints. If nil, hints are generated for the entire document. + /// - options: Server configuration options. + /// + /// - Returns: An array of inlay hints for trailing closures. + package func trailingClosureInlayHints( + uri: DocumentURI, + range: Range?, + options: SourceKitLSPOptions + ) async -> [InlayHint] { + // Return early if feature is disabled + guard options.inlayHintsOrDefault.trailingClosureLabelsOrDefault else { + return [] + } + + do { + let snapshot = try await self.latestSnapshot(for: uri) + let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot) + + let closureInfos = TrailingClosureHintCollector.collectTrailingClosures(in: syntaxTree) + + var hints: [InlayHint] = [] + + for closureInfo in closureInfos { + // Check if the closure is within the requested range + if let range { + let openingBracePosition = snapshot.position( + of: closureInfo.openingBrace.endPositionBeforeTrailingTrivia + ) + guard openingBracePosition >= range.lowerBound && openingBracePosition < range.upperBound else { + continue + } + } + + // Try to get the parameter label from the function signature + if let parameterLabel = await getTrailingClosureParameterLabel( + for: closureInfo.functionCall, + in: snapshot + ) { + let hintPosition = snapshot.position(of: closureInfo.openingBrace.endPositionBeforeTrailingTrivia) + let label = ": \(parameterLabel)" + + let hint = InlayHint( + position: hintPosition, + label: .string(label), + kind: .parameter, + paddingLeft: false, + paddingRight: false + ) + hints.append(hint) + } + } + + return hints + } catch { + // If any error occurs during hint generation, return empty array + return [] + } + } + + /// Retrieves the parameter label for a trailing closure in a function call. + /// + /// This queries sourcekitd to determine the function's signature and identifies + /// the parameter that the trailing closure satisfies. + /// + /// - Parameters: + /// - functionCall: The function call expression containing the trailing closure. + /// - snapshot: The document snapshot. + /// + /// - Returns: The parameter label if it can be determined, or nil if the information is unavailable. + private func getTrailingClosureParameterLabel( + for functionCall: FunctionCallExprSyntax, + in snapshot: DocumentSnapshot + ) async -> String? { + let compileCommand = await self.compileCommand(for: snapshot.uri, fallbackAfterTimeout: false) + + // Query sourcekitd at the position of the function call to get information about the called function + do { + let calleePosition = snapshot.position(of: functionCall.calledExpression.endPositionBeforeTrailingTrivia) + let calleeOffset = snapshot.utf8Offset(of: calleePosition) + let skreq = sourcekitd.dictionary([ + keys.cancelOnSubsequentRequest: 0, + keys.offset: calleeOffset, + 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) + + // Get the function signature and identify the trailing closure parameter + // Try docFullAsXML first, then fallback to annotatedDecl + var signature: String? + if let xmlSig: String = dict[keys.docFullAsXML] { + signature = xmlSig + } else if let annotDecl: String = dict[keys.annotatedDecl] { + signature = annotDecl + } + + if let signature { + return extractTrailingClosureParameterName(from: signature) + } + + return nil + } catch { + // If sourcekitd query fails, we can't determine the parameter label + return nil + } + } + + /// Extracts the trailing closure parameter name from a function signature. + /// + /// - Parameter signature: The function signature string (may be XML or plain text). + /// - Returns: The parameter name if it can be determined. + private func extractTrailingClosureParameterName(from signature: String) -> String? { + // Common trailing closure parameter names in order of likelihood + let commonNames = [ + "content", // SwiftUI views + "label", // SwiftUI controls + "body", // View bodies + "completion", // Async operations + "handler", // Event handlers + "onComplete", // Callbacks + "onSuccess", // Async results + "onFailure", // Error handlers + ] + + // Check for these common names in the signature + for name in commonNames { + // Look for parameter pattern: name: @escaping? (args) -> ReturnType + let patterns = [ + "\\b\(name)\\s*:\\s*@escaping\\s*\\(", // @escaping version + "\\b\(name)\\s*:\\s*\\([^)]*\\)\\s*->", // non-escaping version + "\\b\(name)\\s*:\\s*@\\w+\\s*\\(\\)", // simple closure + ] + + for pattern in patterns { + if let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) { + let range = NSRange(signature.startIndex..., in: signature) + if regex.firstMatch(in: signature, options: [], range: range) != nil { + return name + } + } + } + } + + // Try to extract any closure parameter name using a more generic pattern + // Look for: word: (something) -> or word: @escaping (something) -> + let genericPattern = "\\b([a-zA-Z_]\\w*)\\s*:\\s*(?:@escaping\\s+)?\\([^)]*\\)\\s*->" + if let regex = try? NSRegularExpression(pattern: genericPattern, options: []) { + let range = NSRange(signature.startIndex..., in: signature) + if let match = regex.firstMatch(in: signature, options: [], range: range), + match.numberOfRanges > 1, + let paramRange = Range(match.range(at: 1), in: signature) + { + let paramName = String(signature[paramRange]) + return paramName + } + } + + return nil + } +} + + +func testHint() { + let numbers = [1, 2] + numbers.forEach { number in + print(number) + } +} \ No newline at end of file diff --git a/Tests/SourceKitLSPTests/TrailingClosureInlayHintTests.swift b/Tests/SourceKitLSPTests/TrailingClosureInlayHintTests.swift new file mode 100644 index 000000000..6331ac8cb --- /dev/null +++ b/Tests/SourceKitLSPTests/TrailingClosureInlayHintTests.swift @@ -0,0 +1,331 @@ +//===----------------------------------------------------------------------===// +// +// 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 SKLogging +import SKOptions +import SKTestSupport +import SourceKitLSP +import SwiftExtensions +import XCTest + +final class TrailingClosureInlayHintTests: SourceKitLSPTestCase { + // MARK: - Helpers + + func performInlayHintRequest( + markedText: String, + range: (fromMarker: String, toMarker: String)? = nil, + enableTrailingClosureHints: Bool = true + ) async throws -> (DocumentPositions, [InlayHint]) { + var options: SourceKitLSPOptions? = nil + if !enableTrailingClosureHints { + options = try await SourceKitLSPOptions.testDefault() + options?.inlayHintsOrDefault.trailingClosureLabels = false + } + + let testClient = try await TestSourceKitLSPClient(options: options) + let uri = DocumentURI(for: .swift) + + let (positions, text) = DocumentPositions.extract(from: markedText) + testClient.openDocument(text, uri: uri) + + let range: Range? = + if let range { + positions[range.fromMarker].. InlayHint { + return InlayHint( + position: position, + label: .string(label), + kind: .parameter, + paddingLeft: false, + paddingRight: false + ) + } + + /// 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) + } + } + + // MARK: - Tests + + /// Test 1: Standard SwiftUI pattern with content closure + func testStandardSwiftUIContent() async throws { + let (positions, hints) = try await performInlayHintRequest( + markedText: """ + import SwiftUI + + struct ContentView: View { + @State var isPresented = false + var body: some View { + Text("Hello") + .sheet(isPresented: $isPresented) 1️⃣{ + Text("Sheet") + } + } + } + """, + range: ("1️⃣", "1️⃣") + ) + + let trailingClosureHints = hints.filter { $0.kind == .parameter } + + // We expect one parameter hint for the trailing closure (if the feature correctly identifies "content") + // Note: The exact parameter name depends on successful sourcekitd lookup + XCTAssertTrue( + trailingClosureHints.isEmpty || trailingClosureHints.count == 1, + "Expected 0 or 1 trailing closure hints" + ) + + if let hint = trailingClosureHints.first { + XCTAssertEqual(hint.position, positions["1️⃣"]) + // The label should contain a colon followed by parameter name + if case .string(let label) = hint.label { + XCTAssertTrue(label.starts(with: ":"), "Label should start with colon") + } + } + } + + /// Test 2: Multiple trailing closures (only first should be hinted) + func testMultipleTrailingClosures() async throws { + let (positions, hints) = try await performInlayHintRequest( + markedText: """ + func withMultipleClosures( + first: (String) -> Void, + second: (Int) -> Void + ) {} + + withMultipleClosures { _ in + print("first") + 1️⃣} second: { _ in + print("second") + } + """ + ) + + let trailingClosureHints = hints.filter { $0.kind == .parameter } + + // The first closure (unlabeled) should potentially get a hint if sourcekitd can determine it + // The second labeled closure should not get a hint since it's explicitly labeled + for hint in trailingClosureHints { + if case .string(let label) = hint.label { + // Should not be ": second" since that's already explicitly labeled + XCTAssertNotEqual(label, ": second") + } + } + } + + /// Test 3: Feature disabled via configuration + func testConfigurationToggle() async throws { + let (positions, hints) = try await performInlayHintRequest( + markedText: """ + import SwiftUI + + struct ContentView: View { + var body: some View { + Text("Hello") + .sheet(isPresented: $false) 1️⃣{ + Text("Sheet") + } + } + } + """, + range: ("1️⃣", "1️⃣"), + enableTrailingClosureHints: false + ) + + let trailingClosureHints = hints.filter { $0.kind == .parameter } + XCTAssertEqual(trailingClosureHints.count, 0, "Hints should be disabled when feature is off") + } + + /// Test 4: No closures in standard function call + func testNoClosuresInStandardCall() async throws { + let (_, hints) = try await performInlayHintRequest( + markedText: """ + func add(a: Int, b: Int) -> Int { + return a + b + } + + let result = add(a: 5, b: 10) + """ + ) + + let trailingClosureHints = hints.filter { $0.kind == .parameter } + XCTAssertEqual(trailingClosureHints.count, 0, "Standard function calls should not generate trailing closure hints") + } + + /// Test 5: Function call with explicit closure argument (not trailing) + func testExplicitClosureArgumentNotTrailing() async throws { + let (_, hints) = try await performInlayHintRequest( + markedText: """ + func map(fn: (Int) -> T) -> [T] { + return [] + } + + let results = map(fn: { $0 * 2 }) + """ + ) + + let trailingClosureHints = hints.filter { $0.kind == .parameter } + XCTAssertEqual(trailingClosureHints.count, 0, "Explicit closure arguments should not generate hints") + } + + /// Test 6: Array forEach with trailing closure + func testArrayForEachTrailingClosure() async throws { + let (positions, hints) = try await performInlayHintRequest( + markedText: """ + let numbers = [1, 2, 3] + numbers.forEach 1️⃣{ n in + print(n) + } + """ + ) + + let trailingClosureHints = hints.filter { $0.kind == .parameter } + + // forEach typically has a 'body' parameter for the trailing closure + // If sourcekitd can identify it, we should see a hint + if let hint = trailingClosureHints.first { + XCTAssertEqual(hint.position, positions["1️⃣"]) + } + } + + /// Test 7: No hint for closure with explicit label + func testNoHintForExplicitlyLabeledClosure() async throws { + let (_, hints) = try await performInlayHintRequest( + markedText: """ + func customFunc(content: @escaping () -> Void, completion: @escaping () -> Void) {} + + customFunc(content: { + print("content") + }, completion: { + print("completion") + }) + """ + ) + + let trailingClosureHints = hints.filter { $0.kind == .parameter } + // These are explicitly labeled, so no trailing closure hints should appear + XCTAssertEqual(trailingClosureHints.count, 0) + } + + /// Test 8: Closure in completion handler pattern + func testCompletionHandlerPattern() async throws { + let (positions, hints) = try await performInlayHintRequest( + markedText: """ + func fetchData(completion: @escaping (String) -> Void) {} + + fetchData 1️⃣{ data in + print(data) + } + """ + ) + + let trailingClosureHints = hints.filter { $0.kind == .parameter } + + // Should potentially show "completion" parameter + if let hint = trailingClosureHints.first { + XCTAssertEqual(hint.position, positions["1️⃣"]) + } + } + + /// Test 9: Nested trailing closures + func testNestedTrailingClosures() async throws { + let (positions, hints) = try await performInlayHintRequest( + markedText: """ + func outer(onComplete: @escaping () -> Void) {} + func inner(onFinish: @escaping () -> Void) {} + + outer 1️⃣{ + inner 2️⃣{ + print("done") + } + } + """ + ) + + let trailingClosureHints = hints.filter { $0.kind == .parameter } + + // Should have hints for both closures if sourcekitd can identify them + XCTAssertTrue(trailingClosureHints.count <= 2, "Should have at most 2 trailing closure hints") + } + + /// Test 10: Range filtering for trailing closure hints + func testRangeFiltering() async throws { + let (positions, hints) = try await performInlayHintRequest( + markedText: """ + func fn1(completion: @escaping () -> Void) {} + func fn2(handler: @escaping () -> Void) {} + + fn1 1️⃣{ print("first") } + 2️⃣fn2 3️⃣{ print("second") } + """, + range: ("2️⃣", "3️⃣") + ) + + let trailingClosureHints = hints.filter { $0.kind == .parameter } + + // Only the second trailing closure should be included in the range + for hint in trailingClosureHints { + let isInRange = hint.position >= positions["2️⃣"] && hint.position <= positions["3️⃣"] + XCTAssertTrue(isInRange, "Hint should be within requested range") + } + } + + /// Test 11: Optional type with trailing closure + func testOptionalMethodWithTrailingClosure() async throws { + let (positions, hints) = try await performInlayHintRequest( + markedText: """ + let optional: (() -> Void)? = nil + optional? 1️⃣{ print("optional") } + """ + ) + + // Optional method call with trailing closure + // This is a complex case that may or may not generate hints depending on sourcekitd support + let trailingClosureHints = hints.filter { $0.kind == .parameter } + XCTAssertTrue(trailingClosureHints.count <= 1) + } + + /// Test 12: Empty trailing closure + func testEmptyTrailingClosure() async throws { + let (positions, hints) = try await performInlayHintRequest( + markedText: """ + func fn(completion: @escaping () -> Void) {} + fn 1️⃣{ } + """ + ) + + let trailingClosureHints = hints.filter { $0.kind == .parameter } + + // Empty closures should still get hints + if let hint = trailingClosureHints.first { + XCTAssertEqual(hint.position, positions["1️⃣"]) + } + } +} From 6cf9f91fa4218c3632e6c78f6330e30666fd44ea Mon Sep 17 00:00:00 2001 From: Divya Prakash Date: Thu, 29 Jan 2026 08:09:39 +0530 Subject: [PATCH 2/2] refactor: implement structured parameter matching using signatureHelp --- .../TrailingClosureInlayHints.swift | 129 +++++++----------- .../TrailingClosureInlayHintTests.swift | 25 +++- 2 files changed, 68 insertions(+), 86 deletions(-) diff --git a/Sources/SwiftLanguageService/TrailingClosureInlayHints.swift b/Sources/SwiftLanguageService/TrailingClosureInlayHints.swift index a3d9d5620..cfa6e1a12 100644 --- a/Sources/SwiftLanguageService/TrailingClosureInlayHints.swift +++ b/Sources/SwiftLanguageService/TrailingClosureInlayHints.swift @@ -83,7 +83,7 @@ extension SwiftLanguageService { // Check if the closure is within the requested range if let range { let openingBracePosition = snapshot.position( - of: closureInfo.openingBrace.endPositionBeforeTrailingTrivia + of: closureInfo.openingBrace.positionAfterSkippingLeadingTrivia ) guard openingBracePosition >= range.lowerBound && openingBracePosition < range.upperBound else { continue @@ -95,7 +95,7 @@ extension SwiftLanguageService { for: closureInfo.functionCall, in: snapshot ) { - let hintPosition = snapshot.position(of: closureInfo.openingBrace.endPositionBeforeTrailingTrivia) + let hintPosition = snapshot.position(of: closureInfo.openingBrace.positionAfterSkippingLeadingTrivia) let label = ": \(parameterLabel)" let hint = InlayHint( @@ -118,8 +118,9 @@ extension SwiftLanguageService { /// Retrieves the parameter label for a trailing closure in a function call. /// - /// This queries sourcekitd to determine the function's signature and identifies - /// the parameter that the trailing closure satisfies. + /// Uses sourcekitd's signatureHelp to get structured parameter information, + /// then determines which parameter the trailing closure satisfies based on + /// the number of labeled arguments. /// /// - Parameters: /// - functionCall: The function call expression containing the trailing closure. @@ -132,98 +133,64 @@ extension SwiftLanguageService { ) async -> String? { let compileCommand = await self.compileCommand(for: snapshot.uri, fallbackAfterTimeout: false) - // Query sourcekitd at the position of the function call to get information about the called function + // Use signatureHelp request at the position just before the trailing closure + // This gives us structured parameter information do { - let calleePosition = snapshot.position(of: functionCall.calledExpression.endPositionBeforeTrailingTrivia) - let calleeOffset = snapshot.utf8Offset(of: calleePosition) + // Position the query at the opening parenthesis or just before the trailing closure + let queryPosition: AbsolutePosition + if let leftParen = functionCall.leftParen { + queryPosition = leftParen.endPosition + } else { + // For calls without parentheses, use the end of the called expression + queryPosition = functionCall.calledExpression.endPosition + } + + let position = snapshot.position(of: queryPosition) + let offset = snapshot.utf8Offset(of: position) + let skreq = sourcekitd.dictionary([ - keys.cancelOnSubsequentRequest: 0, - keys.offset: calleeOffset, + keys.offset: offset, 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) + let dict = try await send(sourcekitdRequest: \.signatureHelp, skreq, snapshot: snapshot) - // Get the function signature and identify the trailing closure parameter - // Try docFullAsXML first, then fallback to annotatedDecl - var signature: String? - if let xmlSig: String = dict[keys.docFullAsXML] { - signature = xmlSig - } else if let annotDecl: String = dict[keys.annotatedDecl] { - signature = annotDecl + // Extract parameter information from the signature help response + guard let signatures: SKDResponseArray = dict[keys.signatures], + signatures.count > 0, + let firstSignature = signatures[0] as? SKDResponseDictionary, + let parameters: SKDResponseArray = firstSignature[keys.parameters] + else { + return nil } - if let signature { - return extractTrailingClosureParameterName(from: signature) - } + // Count the number of labeled arguments provided before the trailing closure + let labeledArgsCount = functionCall.arguments.count - return nil - } catch { - // If sourcekitd query fails, we can't determine the parameter label - return nil - } - } + // The trailing closure satisfies the parameter at index = labeledArgsCount + guard labeledArgsCount < parameters.count else { + return nil + } - /// Extracts the trailing closure parameter name from a function signature. - /// - /// - Parameter signature: The function signature string (may be XML or plain text). - /// - Returns: The parameter name if it can be determined. - private func extractTrailingClosureParameterName(from signature: String) -> String? { - // Common trailing closure parameter names in order of likelihood - let commonNames = [ - "content", // SwiftUI views - "label", // SwiftUI controls - "body", // View bodies - "completion", // Async operations - "handler", // Event handlers - "onComplete", // Callbacks - "onSuccess", // Async results - "onFailure", // Error handlers - ] - - // Check for these common names in the signature - for name in commonNames { - // Look for parameter pattern: name: @escaping? (args) -> ReturnType - let patterns = [ - "\\b\(name)\\s*:\\s*@escaping\\s*\\(", // @escaping version - "\\b\(name)\\s*:\\s*\\([^)]*\\)\\s*->", // non-escaping version - "\\b\(name)\\s*:\\s*@\\w+\\s*\\(\\)", // simple closure - ] - - for pattern in patterns { - if let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) { - let range = NSRange(signature.startIndex..., in: signature) - if regex.firstMatch(in: signature, options: [], range: range) != nil { - return name - } - } + // Get the parameter at the trailing closure's position + guard let parameter = parameters[labeledArgsCount] as? SKDResponseDictionary, + let paramName: String = parameter[keys.name] + else { + return nil } - } - // Try to extract any closure parameter name using a more generic pattern - // Look for: word: (something) -> or word: @escaping (something) -> - let genericPattern = "\\b([a-zA-Z_]\\w*)\\s*:\\s*(?:@escaping\\s+)?\\([^)]*\\)\\s*->" - if let regex = try? NSRegularExpression(pattern: genericPattern, options: []) { - let range = NSRange(signature.startIndex..., in: signature) - if let match = regex.firstMatch(in: signature, options: [], range: range), - match.numberOfRanges > 1, - let paramRange = Range(match.range(at: 1), in: signature) - { - let paramName = String(signature[paramRange]) - return paramName + // Extract just the external parameter name (before any colon) + // signatureHelp returns full parameter syntax like "content: () -> Content" + if let colonIndex = paramName.firstIndex(of: ":") { + let extracted = String(paramName[..