diff --git a/Sources/SKOptions/ExperimentalFeatures.swift b/Sources/SKOptions/ExperimentalFeatures.swift index 455f5b516..a31497f91 100644 --- a/Sources/SKOptions/ExperimentalFeatures.swift +++ b/Sources/SKOptions/ExperimentalFeatures.swift @@ -50,6 +50,9 @@ public enum ExperimentalFeature: String, Codable, Sendable, CaseIterable { /// - Note: Internal option, for testing only case synchronizeCopyFileMap = "synchronize-copy-file-map" + /// Surface inferred actor isolation for closures as inlay hints. + case inferredClosureIsolationInlayHints = "inferred-isolation-inlay-hints" + /// All non-internal experimental features. public static var allNonInternalCases: [ExperimentalFeature] { allCases.filter { !$0.isInternal } @@ -74,6 +77,8 @@ public enum ExperimentalFeature: String, Codable, Sendable, CaseIterable { return true case .synchronizeCopyFileMap: return true + case .inferredClosureIsolationInlayHints: + return false } } } diff --git a/Sources/SourceKitD/sourcekitd_uids.swift b/Sources/SourceKitD/sourcekitd_uids.swift index b9a0326d6..3c7f3d9fe 100644 --- a/Sources/SourceKitD/sourcekitd_uids.swift +++ b/Sources/SourceKitD/sourcekitd_uids.swift @@ -367,6 +367,8 @@ package struct sourcekitd_api_keys { package let variableType: sourcekitd_api_uid_t /// `key.variable_type_explicit` package let variableTypeExplicit: sourcekitd_api_uid_t + /// `key.actor_isolation` + package let actorIsolation: sourcekitd_api_uid_t /// `key.fully_qualified` package let fullyQualified: sourcekitd_api_uid_t /// `key.canonicalize_type` @@ -708,6 +710,7 @@ package struct sourcekitd_api_keys { variableLength = api.uid_get_from_cstr("key.variable_length")! variableType = api.uid_get_from_cstr("key.variable_type")! variableTypeExplicit = api.uid_get_from_cstr("key.variable_type_explicit")! + actorIsolation = api.uid_get_from_cstr("key.actor_isolation")! fullyQualified = api.uid_get_from_cstr("key.fully_qualified")! canonicalizeType = api.uid_get_from_cstr("key.canonicalize_type")! internalDiagnostic = api.uid_get_from_cstr("key.internal_diagnostic")! @@ -886,6 +889,8 @@ package struct sourcekitd_api_requests { package let collectExpressionType: sourcekitd_api_uid_t /// `source.request.variable.type` package let collectVariableType: sourcekitd_api_uid_t + /// `source.request.inferred_isolation.collect` + package let collectInferredIsolation: sourcekitd_api_uid_t /// `source.request.configuration.global` package let globalConfiguration: sourcekitd_api_uid_t /// `source.request.dependency_updated` @@ -955,6 +960,7 @@ package struct sourcekitd_api_requests { testNotification = api.uid_get_from_cstr("source.request.test_notification")! collectExpressionType = api.uid_get_from_cstr("source.request.expression.type")! collectVariableType = api.uid_get_from_cstr("source.request.variable.type")! + collectInferredIsolation = api.uid_get_from_cstr("source.request.inferred_isolation.collect")! globalConfiguration = api.uid_get_from_cstr("source.request.configuration.global")! dependencyUpdated = api.uid_get_from_cstr("source.request.dependency_updated")! diagnostics = api.uid_get_from_cstr("source.request.diagnostics")! diff --git a/Sources/SwiftLanguageService/CMakeLists.txt b/Sources/SwiftLanguageService/CMakeLists.txt index a4912e23f..cdc809f6a 100644 --- a/Sources/SwiftLanguageService/CMakeLists.txt +++ b/Sources/SwiftLanguageService/CMakeLists.txt @@ -13,6 +13,7 @@ add_library(SwiftLanguageService STATIC ExpandMacroCommand.swift FoldingRange.swift GeneratedInterfaceManager.swift + InferredIsolationInfo.swift InlayHints.swift InlayHintManager.swift InlayHintResolve.swift diff --git a/Sources/SwiftLanguageService/InferredIsolationInfo.swift b/Sources/SwiftLanguageService/InferredIsolationInfo.swift new file mode 100644 index 000000000..0c952b38f --- /dev/null +++ b/Sources/SwiftLanguageService/InferredIsolationInfo.swift @@ -0,0 +1,101 @@ +//===----------------------------------------------------------------------===// +// +// 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 Dispatch +@_spi(SourceKitLSP) import LanguageServerProtocol +import SKOptions +import SourceKitD +import SourceKitLSP + +/// One inferred actor isolation as returned by sourcekitd's +/// `source.request.inferred_isolation.collect`. Currently covers explicit +/// closures only. +struct InferredIsolationInfo { + /// Range of the entity to which the inferred isolation applies and the inlay + /// should be attached. + var range: Range + + /// Pretty-printed actor isolation (e.g. `@MainActor`, `nonisolated`). + var isolation: String + + /// What kind of entity this isolation is attached to. Currently always + /// `"closure"`. + var kind: String + + init?(_ dict: SKDResponseDictionary, in snapshot: DocumentSnapshot) { + let keys = dict.sourcekitd.keys + + guard let offset: Int = dict[keys.offset], + let length: Int = dict[keys.length], + let isolation: String = dict[keys.actorIsolation], + let kind: String = dict[keys.kind] + else { + return nil + } + + self.range = snapshot.positionOf(utf8Offset: offset)..? = nil + ) async throws -> [InferredIsolationInfo] { + // TODO: too defensive? + guard options.hasExperimentalFeature(.inferredClosureIsolationInlayHints) else { + return [] + } + + let snapshot = try await self.latestSnapshot(for: uri) + + let skreq = sourcekitd.dictionary([ + keys.cancelOnSubsequentRequest: 0, + keys.sourceFile: snapshot.uri.sourcekitdSourceFile, + keys.primaryFile: snapshot.uri.primaryFile?.pseudoPath, + keys.compilerArgs: await self.compileCommand(for: uri, fallbackAfterTimeout: false)?.compilerArgs + as [any SKDRequestValue]?, + ]) + + if let range = range { + let start = snapshot.utf8Offset(of: range.lowerBound) + let end = snapshot.utf8Offset(of: range.upperBound) + skreq.set(keys.offset, to: start) + skreq.set(keys.length, to: end - start) + } + + let dict = try await send(sourcekitdRequest: \.collectInferredIsolation, skreq, snapshot: snapshot) + guard let skResults: SKDResponseArray = dict[keys.results] else { + return [] + } + + var results: [InferredIsolationInfo] = [] + results.reserveCapacity(skResults.count) + // swift-format-ignore: ReplaceForEachWithForLoop + skResults.forEach { (_, skItem) -> Bool in + guard let info = InferredIsolationInfo(skItem, in: snapshot) else { + assertionFailure("InferredIsolationInfo failed to deserialize") + return true + } + results.append(info) + return true + } + return results + } +} diff --git a/Sources/SwiftLanguageService/InlayHintManager.swift b/Sources/SwiftLanguageService/InlayHintManager.swift index f712219fa..13c17e121 100644 --- a/Sources/SwiftLanguageService/InlayHintManager.swift +++ b/Sources/SwiftLanguageService/InlayHintManager.swift @@ -13,6 +13,7 @@ import Foundation @_spi(SourceKitLSP) package import LanguageServerProtocol @_spi(SourceKitLSP) import SKLogging +import SKOptions import SKUtilities import SourceKitLSP import SwiftExtensions @@ -49,7 +50,7 @@ actor InlayHintManager { /// Cached inlay hints for each document. /// /// Each entry stores hints for the full document and the document version they were computed for. - /// - Note: The capacity has been chosen without scientific measurements. 20 seems like a resonable number of open documents a client may have. + /// - Note: The capacity has been chosen without scientific measurements. 20 seems like a reasonable number of open documents a client may have. private var cache = LRUCache(capacity: 20) /// Documents that currently have a background inlay-hint recomputation in progress. @@ -210,7 +211,7 @@ actor InlayHintManager { // type can make type hints for all other variables that use the edited variable stale). Caching and returning // inlay hints for only a subrange of the document would add a lot of complexity, because we would need to track // which hints are valid for which ranges and versions. - let updatedHints = try await computeTypeInlayHints(swiftLanguageService: service, for: snapshot, range: nil) + let updatedHints = try await computeInlayHints(swiftLanguageService: service, for: snapshot, range: nil) try Task.checkCancellation() @@ -250,7 +251,33 @@ actor InlayHintManager { ) } - func computeTypeInlayHints( + func computeInlayHints( + swiftLanguageService service: SwiftLanguageService, + for snapshot: DocumentSnapshot, + range: Range? + ) async throws -> [InlayHint] { + try await withThrowingTaskGroup { group -> [InlayHint] in + group.addTask { + try await self.computeTypeInlayHints(swiftLanguageService: service, for: snapshot, range: range) + } + + if (service.options.hasExperimentalFeature(.inferredClosureIsolationInlayHints)) { + group.addTask { + // TODO: how should we deal with errors? + try await self.computeIsolationInlayHints(swiftLanguageService: service, snapshot: snapshot, range: range) + } + } + + var results = [InlayHint]() + for try await hints in group { + results.append(contentsOf: hints) + } + + return results.sorted { $0.position < $1.position } + } + } + + private func computeTypeInlayHints( swiftLanguageService service: SwiftLanguageService, for snapshot: DocumentSnapshot, range: Range? @@ -278,7 +305,30 @@ actor InlayHintManager { data: resolveData.encodeToLSPAny() ) } - .sorted { $0.position < $1.position } + } + + private func computeIsolationInlayHints( + swiftLanguageService service: SwiftLanguageService, + snapshot: DocumentSnapshot, + range: Range? + ) async throws -> [InlayHint] { + let infos = try await service.inferredIsolations(snapshot.uri, range) + return infos + .lazy + .filter { $0.kind == "closure" } // Only kind right now + .map { info -> InlayHint in + // Anchor the inlay right after the closure's opening brace. + // TODO: is there a better way to do this? + let offset = snapshot.utf8Offset(of: info.range.lowerBound) + let anchor = snapshot.positionOf(utf8Offset: offset + 1) + + return InlayHint( + position: anchor, + label: .string("\(info.isolation)"), + kind: .type, // TODO: is this appropriate? + paddingLeft: true, + ) + } } func removeCachedInlayHints(for uri: DocumentURI) { diff --git a/Sources/SwiftLanguageService/InlayHints.swift b/Sources/SwiftLanguageService/InlayHints.swift index dd53ca2c6..b435686db 100644 --- a/Sources/SwiftLanguageService/InlayHints.swift +++ b/Sources/SwiftLanguageService/InlayHints.swift @@ -29,12 +29,13 @@ extension SwiftLanguageService { // The client does not support workspace/inlayHint/refresh. // We have to compute inlay hints on every request, because we cannot trigger a refresh when the inlay hints have been recomputed in the background. let snapshot = try await latestSnapshot(for: uri) - async let typeInlayHints = inlayHintManager.computeTypeInlayHints( + let typeInlayHints = try await inlayHintManager.computeInlayHints( swiftLanguageService: self, for: snapshot, range: req.range ) - return try await typeInlayHints + computeIfConfigInlayHints(snapshot: snapshot, range: req.range) + return try await typeInlayHints + + computeIfConfigInlayHints(snapshot: snapshot, range: req.range) } if let hints = await inlayHintManager.getCachedInlayHints( @@ -42,7 +43,8 @@ extension SwiftLanguageService { for: snapshot, range: req.range ) { - return try await hints + computeIfConfigInlayHints(snapshot: snapshot, range: req.range) + return try await hints + + computeIfConfigInlayHints(snapshot: snapshot, range: req.range) } // No cached hints are available. The inlay hint manager has scheduled a refresh task if needed, so we can just diff --git a/Tests/SourceKitLSPTests/InlayHintTests.swift b/Tests/SourceKitLSPTests/InlayHintTests.swift index 72f8a2bae..f6a28eadb 100644 --- a/Tests/SourceKitLSPTests/InlayHintTests.swift +++ b/Tests/SourceKitLSPTests/InlayHintTests.swift @@ -793,3 +793,117 @@ final class InlayHintTests: SourceKitLSPTestCase { } } } + +// // MARK: - Inferred isolation hints +// +// import SKOptions +// +// FIXME: figure out how to get this to run successfully against a dev toolchain +// +// /// Sends an inlay-hint request against \p markedText with the +// /// `inferred-isolation-inlay-hints` experimental feature enabled, and +// /// returns the inferred-isolation hints (i.e. those that aren't the standard +// /// inferred-type hints). +// private func performInferredIsolationHintRequest( +// markedText: String +// ) async throws -> (DocumentPositions, [InlayHint]) { +// let options = try await SourceKitLSPOptions.testDefault( +// experimentalFeatures: [.inferredClosureIsolationInlayHints, .synchronizeCopyFileMap] +// ) +// 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 request = InlayHintRequest(textDocument: TextDocumentIdentifier(uri), range: nil) +// let hints = try await testClient.send(request) +// // The inlay-hint endpoint also returns inferred-type hints; filter those +// // out so the per-test expectations don't have to track them. +// let isolationHints = hints.filter { +// if case .string(let label) = $0.label { +// return label.contains("@") || label.contains("nonisolated") || label.contains("actor-isolated") +// } +// return false +// } +// return (positions, isolationHints) +// } +// +// func testInferredIsolation_basicClosures() async throws { +// let (_, hints) = try await performInferredIsolationHintRequest( +// markedText: """ +// @MainActor +// final class C { +// var n = 0 +// func work() async { +// let inheritedMain = { +// self.n += 1 +// } +// inheritedMain() +// +// Task.detached { +// print("detached") +// } +// } +// } +// """ +// ) +// +// XCTAssertEqual(hints.count, 2) +// XCTAssertEqual(hints.first?.label, .string("@MainActor")) +// XCTAssertEqual(hints.first?.kind, .type) +// XCTAssertEqual(hints.last?.label, .string("nonisolated")) +// XCTAssertEqual(hints.last?.kind, .type) +// } +// +// func testInferredIsolation_explicitClosureIsolationSuppressed() async throws { +// let (_, hints) = try await performInferredIsolationHintRequest( +// markedText: """ +// @globalActor +// actor MyActor { +// static let shared = MyActor() +// } +// +// func explicitMain() { +// let _ = { @MainActor in 2 } +// } +// +// func explicitCustom() { +// let _ = { @MyActor in 3 } +// } +// """ +// ) +// +// // Both closures have isolation written explicitly in source -- no hints. +// XCTAssertEqual(hints, []) +// } +// +// func testInferredIsolation_disabledByDefault() async throws { +// // Without the experimental feature flag, the inferred-isolation hints +// // should not be produced even though the closure inherits @MainActor. +// let testClient = try await TestSourceKitLSPClient() +// let uri = DocumentURI(for: .swift) +// testClient.openDocument( +// """ +// @MainActor +// final class C { +// var n = 0 +// func work() async { +// let _ = { self.n += 1 } +// } +// } +// """, +// uri: uri +// ) +// +// let request = InlayHintRequest(textDocument: TextDocumentIdentifier(uri), range: nil) +// let hints = try await testClient.send(request) +// let isolationHints = hints.filter { +// if case .string(let label) = $0.label { +// return label.contains("@MainActor") || label.contains("nonisolated") +// } +// return false +// } +// XCTAssertEqual(isolationHints, []) +// } +