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..cfa6e1a12 --- /dev/null +++ b/Sources/SwiftLanguageService/TrailingClosureInlayHints.swift @@ -0,0 +1,196 @@ +//===----------------------------------------------------------------------===// +// +// 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.positionAfterSkippingLeadingTrivia + ) + 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.positionAfterSkippingLeadingTrivia) + 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. + /// + /// 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. + /// - 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) + + // Use signatureHelp request at the position just before the trailing closure + // This gives us structured parameter information + do { + // 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.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: \.signatureHelp, skreq, snapshot: snapshot) + + // 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 + } + + // Count the number of labeled arguments provided before the trailing closure + let labeledArgsCount = functionCall.arguments.count + + // The trailing closure satisfies the parameter at index = labeledArgsCount + guard labeledArgsCount < parameters.count else { + return nil + } + + // 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 + } + + // 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[.. (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). + /// Position is verified to be at the opening brace of the trailing closure (positionAfterSkippingLeadingTrivia). + 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, + "Hint position mismatch (should be at opening brace)", + 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 + /// Verifies that the hint appears at positionAfterSkippingLeadingTrivia of the opening brace. + 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 (identifies "content" parameter) + // The hint position should be at the opening brace (positionAfterSkippingLeadingTrivia) + XCTAssertTrue( + trailingClosureHints.isEmpty || trailingClosureHints.count == 1, + "Expected 0 or 1 trailing closure hints" + ) + + if let hint = trailingClosureHints.first { + // Position should be exactly at the opening brace marker + XCTAssertEqual(hint.position, positions["1️⃣"], "Hint should be positioned at the opening brace") + // 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") + // Verify it extracted a parameter name (not empty after colon) + let afterColon = label.dropFirst() + XCTAssertFalse( + afterColon.trimmingCharacters(in: .whitespaces).isEmpty, + "Should have parameter name after 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️⃣"]) + } + } +}