Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Sources/SKOptions/ExperimentalFeatures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -74,6 +77,8 @@ public enum ExperimentalFeature: String, Codable, Sendable, CaseIterable {
return true
case .synchronizeCopyFileMap:
return true
case .inferredClosureIsolationInlayHints:
return false
}
}
}
6 changes: 6 additions & 0 deletions Sources/SourceKitD/sourcekitd_uids.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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")!
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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")!
Expand Down
1 change: 1 addition & 0 deletions Sources/SwiftLanguageService/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ add_library(SwiftLanguageService STATIC
ExpandMacroCommand.swift
FoldingRange.swift
GeneratedInterfaceManager.swift
InferredIsolationInfo.swift
InlayHints.swift
InlayHintManager.swift
InlayHintResolve.swift
Expand Down
101 changes: 101 additions & 0 deletions Sources/SwiftLanguageService/InferredIsolationInfo.swift
Original file line number Diff line number Diff line change
@@ -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<Position>

/// 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)..<snapshot.positionOf(utf8Offset: offset + length)
self.isolation = isolation
self.kind = kind
}
}

extension SwiftLanguageService {
/// Collects inferred actor isolation for every explicit closure in the file.
/// Skips closures whose isolation is written explicitly in the signature.
///
/// - Parameter range: Restrict collection to closures overlapping this range
/// of the source file. If `nil`, the entire file is collected.
func inferredIsolations(
_ uri: DocumentURI,
_ range: Range<Position>? = 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
}
}
58 changes: 54 additions & 4 deletions Sources/SwiftLanguageService/InlayHintManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import Foundation
@_spi(SourceKitLSP) package import LanguageServerProtocol
@_spi(SourceKitLSP) import SKLogging
import SKOptions
import SKUtilities
import SourceKitLSP
import SwiftExtensions
Expand Down Expand Up @@ -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<DocumentURI, InlayHintCacheEntry>(capacity: 20)

/// Documents that currently have a background inlay-hint recomputation in progress.
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -250,7 +251,33 @@ actor InlayHintManager {
)
}

func computeTypeInlayHints(
func computeInlayHints(
swiftLanguageService service: SwiftLanguageService,
for snapshot: DocumentSnapshot,
range: Range<Position>?
) 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<Position>?
Expand Down Expand Up @@ -278,7 +305,30 @@ actor InlayHintManager {
data: resolveData.encodeToLSPAny()
)
}
.sorted { $0.position < $1.position }
}

private func computeIsolationInlayHints(
swiftLanguageService service: SwiftLanguageService,
snapshot: DocumentSnapshot,
range: Range<Position>?
) 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) {
Expand Down
8 changes: 5 additions & 3 deletions Sources/SwiftLanguageService/InlayHints.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,22 @@ 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(
swiftLanguageService: self,
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
Expand Down
114 changes: 114 additions & 0 deletions Tests/SourceKitLSPTests/InlayHintTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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, [])
// }