diff --git a/Package.swift b/Package.swift index 9aa83bf5..7894a034 100644 --- a/Package.swift +++ b/Package.swift @@ -97,6 +97,7 @@ var targets: [Target] = [ dependencies: [ "LanguageServerProtocolTransport", "SKLogging", + "ToolsProtocolsSwiftExtensions", "ToolsProtocolsTestSupport", ], swiftSettings: globalSwiftSettings diff --git a/Sources/BuildServerProtocol/Messages.swift b/Sources/BuildServerProtocol/Messages.swift index 91e30710..521160eb 100644 --- a/Sources/BuildServerProtocol/Messages.swift +++ b/Sources/BuildServerProtocol/Messages.swift @@ -42,7 +42,22 @@ private let notificationTypes: [NotificationType.Type] = [ extension MessageRegistry { public static let bspProtocol: MessageRegistry = - MessageRegistry(requests: requestTypes, notifications: notificationTypes) + MessageRegistry(requests: requestTypes, notifications: notificationTypes, legacyNames: bspLegacyNames) + + /// Maps current `sourcekit/`-prefixed BSP method names to the legacy names used before the + /// prefix migration. Consumed by `MessageRegistry` (incoming routing) and + /// `LegacyNameFallbackConnection` (outgoing retries). + /// + /// This table is frozen. Do not add new entries for newly introduced methods. + public static let bspLegacyNames: [String: String] = [ + BuildTargetPrepareRequest.method: "buildTarget/prepare", + FileOptionsChangedNotification.method: "build/sourceKitOptionsChanged", + TextDocumentSourceKitOptionsRequest.method: "textDocument/sourceKitOptions", + WorkspaceWaitForBuildSystemUpdatesRequest.method: "workspace/waitForBuildSystemUpdates", + ] + #if compiler(>=6.6) + #warning("Remove the legacy method names") + #endif } @available(*, deprecated, message: "use MessageRegistry.bspProtocol instead") diff --git a/Sources/BuildServerProtocol/Messages/BuildTargetPrepareRequest.swift b/Sources/BuildServerProtocol/Messages/BuildTargetPrepareRequest.swift index 0381859c..91b7ca43 100644 --- a/Sources/BuildServerProtocol/Messages/BuildTargetPrepareRequest.swift +++ b/Sources/BuildServerProtocol/Messages/BuildTargetPrepareRequest.swift @@ -26,7 +26,7 @@ public typealias OriginId = String /// The server communicates during the initialize handshake whether this method is supported or not by setting /// `prepareProvider: true` in `SourceKitInitializeBuildResponseData`. public struct BuildTargetPrepareRequest: BSPRequest, Hashable { - public static let method: String = "buildTarget/prepare" + public static let method: String = "sourcekit/buildTarget/prepare" public typealias Response = BuildTargetPrepareResponse /// A list of build targets to prepare. diff --git a/Sources/BuildServerProtocol/Messages/InitializeBuildRequest.swift b/Sources/BuildServerProtocol/Messages/InitializeBuildRequest.swift index b100e168..bd964793 100644 --- a/Sources/BuildServerProtocol/Messages/InitializeBuildRequest.swift +++ b/Sources/BuildServerProtocol/Messages/InitializeBuildRequest.swift @@ -82,9 +82,13 @@ public struct BuildClientCapabilities: Codable, Hashable, Sendable { /// it's safe to return classpath in ScalacOptionsItem empty. */ public var jvmCompileClasspathReceiver: Bool? - public init(languageIds: [Language], jvmCompileClasspathReceiver: Bool? = nil) { + /// Experimental client capabilities. + public var experimental: LSPAny? + + public init(languageIds: [Language], jvmCompileClasspathReceiver: Bool? = nil, experimental: LSPAny? = nil) { self.languageIds = languageIds self.jvmCompileClasspathReceiver = jvmCompileClasspathReceiver + self.experimental = experimental } } diff --git a/Sources/BuildServerProtocol/Messages/RegisterForChangeNotifications.swift b/Sources/BuildServerProtocol/Messages/RegisterForChangeNotifications.swift index f8c4d9cf..60c1bb98 100644 --- a/Sources/BuildServerProtocol/Messages/RegisterForChangeNotifications.swift +++ b/Sources/BuildServerProtocol/Messages/RegisterForChangeNotifications.swift @@ -57,7 +57,7 @@ public struct FileOptionsChangedNotification: BSPNotification { public var workingDirectory: String? } - public static let method: String = "build/sourceKitOptionsChanged" + public static let method: String = "sourcekit/build/sourceKitOptionsChanged" /// The URI of the document that has changed settings. public var uri: URI diff --git a/Sources/BuildServerProtocol/Messages/TextDocumentSourceKitOptionsRequest.swift b/Sources/BuildServerProtocol/Messages/TextDocumentSourceKitOptionsRequest.swift index 8cfb7c94..65835539 100644 --- a/Sources/BuildServerProtocol/Messages/TextDocumentSourceKitOptionsRequest.swift +++ b/Sources/BuildServerProtocol/Messages/TextDocumentSourceKitOptionsRequest.swift @@ -20,7 +20,7 @@ public import LanguageServerProtocol /// /// The request may return `nil` if it doesn't have any build settings for this file in the given target. public struct TextDocumentSourceKitOptionsRequest: BSPRequest, Hashable { - public static let method: String = "textDocument/sourceKitOptions" + public static let method: String = "sourcekit/textDocument/sourceKitOptions" public typealias Response = TextDocumentSourceKitOptionsResponse? /// The URI of the document to get options for diff --git a/Sources/BuildServerProtocol/Messages/WorkspaceWaitForBuildSystemUpdates.swift b/Sources/BuildServerProtocol/Messages/WorkspaceWaitForBuildSystemUpdates.swift index 06b72f45..2ebcccd3 100644 --- a/Sources/BuildServerProtocol/Messages/WorkspaceWaitForBuildSystemUpdates.swift +++ b/Sources/BuildServerProtocol/Messages/WorkspaceWaitForBuildSystemUpdates.swift @@ -19,7 +19,7 @@ public import LanguageServerProtocol public struct WorkspaceWaitForBuildSystemUpdatesRequest: BSPRequest, Hashable { public typealias Response = VoidResponse - public static let method: String = "workspace/waitForBuildSystemUpdates" + public static let method: String = "sourcekit/workspace/waitForBuildSystemUpdates" public init() {} } diff --git a/Sources/LanguageServerProtocol/Connection.swift b/Sources/LanguageServerProtocol/Connection.swift index 8d743eaf..b1e9a60e 100644 --- a/Sources/LanguageServerProtocol/Connection.swift +++ b/Sources/LanguageServerProtocol/Connection.swift @@ -28,8 +28,11 @@ public protocol Connection: Sendable { /// Send a request with a pre-defined request ID and (asynchronously) receive a reply. /// /// The request ID must not conflict with any request ID generated by `nextRequestID()`. + /// `method` is the JSON-RPC method string; pass `Request.method` for the normal case or a + /// legacy method name when talking to an old peer. func send( _ request: Request, + method: String, id: RequestID, reply: @escaping @Sendable (LSPResult) -> Void ) @@ -42,7 +45,7 @@ extension Connection { reply: @escaping @Sendable (LSPResult) -> Void ) -> RequestID { let id = nextRequestID() - self.send(request, id: id, reply: reply) + self.send(request, method: Request.method, id: id, reply: reply) return id } } diff --git a/Sources/LanguageServerProtocol/MessageRegistry.swift b/Sources/LanguageServerProtocol/MessageRegistry.swift index 8639aa83..e38b7de7 100644 --- a/Sources/LanguageServerProtocol/MessageRegistry.swift +++ b/Sources/LanguageServerProtocol/MessageRegistry.swift @@ -14,9 +14,26 @@ public final class MessageRegistry: Sendable { private let methodToRequest: [String: _RequestType.Type] private let methodToNotification: [String: NotificationType.Type] - public init(requests: [_RequestType.Type], notifications: [NotificationType.Type]) { - self.methodToRequest = Dictionary(uniqueKeysWithValues: requests.map { ($0.method, $0) }) - self.methodToNotification = Dictionary(uniqueKeysWithValues: notifications.map { ($0.method, $0) }) + public init( + requests: [_RequestType.Type], + notifications: [NotificationType.Type], + legacyNames: [String: String] = [:] + ) { + var methodToRequest: [String: _RequestType.Type] = Dictionary( + uniqueKeysWithValues: requests.map { ($0.method, $0) } + ) + for request in requests { + if let legacy = legacyNames[request.method] { methodToRequest[legacy] = request } + } + self.methodToRequest = methodToRequest + + var methodToNotification: [String: NotificationType.Type] = Dictionary( + uniqueKeysWithValues: notifications.map { ($0.method, $0) } + ) + for notification in notifications { + if let legacy = legacyNames[notification.method] { methodToNotification[legacy] = notification } + } + self.methodToNotification = methodToNotification } /// Returns the type of the message named `method`, or nil if it is unknown. diff --git a/Sources/LanguageServerProtocol/Messages.swift b/Sources/LanguageServerProtocol/Messages.swift index f76ff971..3ac7a77c 100644 --- a/Sources/LanguageServerProtocol/Messages.swift +++ b/Sources/LanguageServerProtocol/Messages.swift @@ -134,7 +134,33 @@ public let builtinNotifications: [NotificationType.Type] = [ extension MessageRegistry { public static let lspProtocol: MessageRegistry = - MessageRegistry(requests: builtinRequests, notifications: builtinNotifications) + MessageRegistry(requests: builtinRequests, notifications: builtinNotifications, legacyNames: lspLegacyNames) + + /// Maps current `sourcekit/`-prefixed method names to the legacy names used before the prefix + /// migration. Consumed by `MessageRegistry` (incoming routing) and `LegacyNameFallbackConnection` + /// (outgoing retries). + /// + /// This table is frozen. Do not add new entries for newly introduced methods. + public static let lspLegacyNames: [String: String] = [ + DidChangeActiveDocumentNotification.method: "window/didChangeActiveDocument", + DoccDocumentationRequest.method: "textDocument/doccDocumentation", + DocumentTestsRequest.method: "textDocument/tests", + GetReferenceDocumentRequest.method: "workspace/getReferenceDocument", + IsIndexingRequest.method: "sourceKit/_isIndexing", + OutputPathsRequest.method: "workspace/_outputPaths", + PeekDocumentsRequest.method: "workspace/peekDocuments", + SetOptionsRequest.method: "workspace/_setOptions", + SourceKitOptionsRequest.method: "workspace/_sourceKitOptions", + SynchronizeRequest.method: "workspace/synchronize", + TriggerReindexRequest.method: "workspace/triggerReindex", + WorkspacePlaygroundsRefreshRequest.method: "workspace/playgrounds/refresh", + WorkspacePlaygroundsRequest.method: "workspace/playgrounds", + WorkspaceTestsRefreshRequest.method: "workspace/tests/refresh", + WorkspaceTestsRequest.method: "workspace/tests", + ] + #if compiler(>=6.6) + #warning("Remove the legacy method names") + #endif } // MARK: Miscellaneous Message Types diff --git a/Sources/LanguageServerProtocol/Notifications/DidChangeActiveDocumentNotification.swift b/Sources/LanguageServerProtocol/Notifications/DidChangeActiveDocumentNotification.swift index d098483d..4e919d3c 100644 --- a/Sources/LanguageServerProtocol/Notifications/DidChangeActiveDocumentNotification.swift +++ b/Sources/LanguageServerProtocol/Notifications/DidChangeActiveDocumentNotification.swift @@ -11,7 +11,7 @@ //===----------------------------------------------------------------------===// public struct DidChangeActiveDocumentNotification: LSPNotification { - public static let method: String = "window/didChangeActiveDocument" + public static let method: String = "sourcekit/window/didChangeActiveDocument" /// The document that is being displayed in the active editor or `null` to indicate that either no document is active /// or that the currently open document is not handled by SourceKit-LSP. diff --git a/Sources/LanguageServerProtocol/Requests/DoccDocumentationRequest.swift b/Sources/LanguageServerProtocol/Requests/DoccDocumentationRequest.swift index 58d447cf..81998709 100644 --- a/Sources/LanguageServerProtocol/Requests/DoccDocumentationRequest.swift +++ b/Sources/LanguageServerProtocol/Requests/DoccDocumentationRequest.swift @@ -44,7 +44,7 @@ /// This request is an extension to LSP supported by SourceKit-LSP. /// The client is expected to display the documentation in an editor using swift-docc-render. public struct DoccDocumentationRequest: TextDocumentRequest, Hashable { - public static let method: String = "textDocument/doccDocumentation" + public static let method: String = "sourcekit/textDocument/doccDocumentation" public typealias Response = DoccDocumentationResponse /// The document in which to lookup the symbol location. diff --git a/Sources/LanguageServerProtocol/Requests/DocumentTestsRequest.swift b/Sources/LanguageServerProtocol/Requests/DocumentTestsRequest.swift index c79dffac..f3cfdf2b 100644 --- a/Sources/LanguageServerProtocol/Requests/DocumentTestsRequest.swift +++ b/Sources/LanguageServerProtocol/Requests/DocumentTestsRequest.swift @@ -14,7 +14,7 @@ /// /// **(LSP Extension)** public struct DocumentTestsRequest: TextDocumentRequest, Hashable { - public static let method: String = "textDocument/tests" + public static let method: String = "sourcekit/textDocument/tests" public typealias Response = [TestItem] public var textDocument: TextDocumentIdentifier diff --git a/Sources/LanguageServerProtocol/Requests/GetReferenceDocumentRequest.swift b/Sources/LanguageServerProtocol/Requests/GetReferenceDocumentRequest.swift index 646c5911..21184451 100644 --- a/Sources/LanguageServerProtocol/Requests/GetReferenceDocumentRequest.swift +++ b/Sources/LanguageServerProtocol/Requests/GetReferenceDocumentRequest.swift @@ -24,7 +24,7 @@ /// Enable the experimental client capability `"workspace/getReferenceDocument"` so that the server responds with /// reference document URLs for certain requests or commands whenever possible. public struct GetReferenceDocumentRequest: LSPRequest { - public static let method: String = "workspace/getReferenceDocument" + public static let method: String = "sourcekit/workspace/getReferenceDocument" public typealias Response = GetReferenceDocumentResponse public var uri: DocumentURI diff --git a/Sources/LanguageServerProtocol/Requests/IsIndexingRequest.swift b/Sources/LanguageServerProtocol/Requests/IsIndexingRequest.swift index 8666d5e3..c7adecd4 100644 --- a/Sources/LanguageServerProtocol/Requests/IsIndexingRequest.swift +++ b/Sources/LanguageServerProtocol/Requests/IsIndexingRequest.swift @@ -11,7 +11,7 @@ //===----------------------------------------------------------------------===// public struct IsIndexingRequest: LSPRequest { - public static let method: String = "sourceKit/_isIndexing" + public static let method: String = "sourcekit/isIndexing" public typealias Response = IsIndexingResponse public init() {} diff --git a/Sources/LanguageServerProtocol/Requests/OutputPathsRequest.swift b/Sources/LanguageServerProtocol/Requests/OutputPathsRequest.swift index f6fed318..30516baa 100644 --- a/Sources/LanguageServerProtocol/Requests/OutputPathsRequest.swift +++ b/Sources/LanguageServerProtocol/Requests/OutputPathsRequest.swift @@ -15,7 +15,7 @@ /// /// **(LSP Extension)**. public struct OutputPathsRequest: LSPRequest, Hashable { - public static let method: String = "workspace/_outputPaths" + public static let method: String = "sourcekit/workspace/outputPaths" public typealias Response = OutputPathsResponse /// The target whose output file paths to get. diff --git a/Sources/LanguageServerProtocol/Requests/PeekDocumentsRequest.swift b/Sources/LanguageServerProtocol/Requests/PeekDocumentsRequest.swift index cc586b50..058f9fa4 100644 --- a/Sources/LanguageServerProtocol/Requests/PeekDocumentsRequest.swift +++ b/Sources/LanguageServerProtocol/Requests/PeekDocumentsRequest.swift @@ -29,7 +29,7 @@ /// It requires the experimental client capability `"workspace/peekDocuments"` to use. /// It also needs the client to handle the request and present the "peeked" editor. public struct PeekDocumentsRequest: LSPRequest { - public static let method: String = "workspace/peekDocuments" + public static let method: String = "sourcekit/workspace/peekDocuments" public typealias Response = PeekDocumentsResponse public var uri: DocumentURI diff --git a/Sources/LanguageServerProtocol/Requests/SetOptionsRequest.swift b/Sources/LanguageServerProtocol/Requests/SetOptionsRequest.swift index 395fe3a8..b3d4c363 100644 --- a/Sources/LanguageServerProtocol/Requests/SetOptionsRequest.swift +++ b/Sources/LanguageServerProtocol/Requests/SetOptionsRequest.swift @@ -14,7 +14,7 @@ // /// Any options not specified in this request will be left as-is. public struct SetOptionsRequest: LSPRequest { - public static let method: String = "workspace/_setOptions" + public static let method: String = "sourcekit/workspace/setOptions" public typealias Response = VoidResponse /// `true` to pause background indexing or `false` to resume background indexing. diff --git a/Sources/LanguageServerProtocol/Requests/SourceKitOptionsRequest.swift b/Sources/LanguageServerProtocol/Requests/SourceKitOptionsRequest.swift index 17f5f184..a10c3300 100644 --- a/Sources/LanguageServerProtocol/Requests/SourceKitOptionsRequest.swift +++ b/Sources/LanguageServerProtocol/Requests/SourceKitOptionsRequest.swift @@ -18,7 +18,7 @@ /// /// **(LSP Extension)**. public struct SourceKitOptionsRequest: LSPRequest, Hashable { - public static let method: String = "workspace/_sourceKitOptions" + public static let method: String = "sourcekit/workspace/sourceKitOptions" public typealias Response = SourceKitOptionsResponse /// The document to get options for diff --git a/Sources/LanguageServerProtocol/Requests/SynchronizeRequest.swift b/Sources/LanguageServerProtocol/Requests/SynchronizeRequest.swift index cd7b66d5..89a10c0d 100644 --- a/Sources/LanguageServerProtocol/Requests/SynchronizeRequest.swift +++ b/Sources/LanguageServerProtocol/Requests/SynchronizeRequest.swift @@ -27,7 +27,7 @@ /// user a result based on the data that is available and (let the user) re-perform the action if the underlying index /// data has changed. public struct SynchronizeRequest: LSPRequest { - public static let method: String = "workspace/synchronize" + public static let method: String = "sourcekit/workspace/synchronize" public typealias Response = VoidResponse /// Wait for the build server to have an up-to-date build graph by sending a `workspace/waitForBuildSystemUpdates` to diff --git a/Sources/LanguageServerProtocol/Requests/TriggerReindexRequest.swift b/Sources/LanguageServerProtocol/Requests/TriggerReindexRequest.swift index e1baf628..b165b3a2 100644 --- a/Sources/LanguageServerProtocol/Requests/TriggerReindexRequest.swift +++ b/Sources/LanguageServerProtocol/Requests/TriggerReindexRequest.swift @@ -18,7 +18,7 @@ /// /// **LSP Extension** public struct TriggerReindexRequest: LSPRequest { - public static let method: String = "workspace/triggerReindex" + public static let method: String = "sourcekit/workspace/triggerReindex" public typealias Response = VoidResponse public init() {} diff --git a/Sources/LanguageServerProtocol/Requests/WorkspacePlaygroundsRefreshRequest.swift b/Sources/LanguageServerProtocol/Requests/WorkspacePlaygroundsRefreshRequest.swift index 985a2088..f4135c85 100644 --- a/Sources/LanguageServerProtocol/Requests/WorkspacePlaygroundsRefreshRequest.swift +++ b/Sources/LanguageServerProtocol/Requests/WorkspacePlaygroundsRefreshRequest.swift @@ -19,7 +19,7 @@ /// /// **(LSP Extension)** public struct WorkspacePlaygroundsRefreshRequest: LSPRequest { - public static let method: String = "workspace/playgrounds/refresh" + public static let method: String = "sourcekit/workspace/playgrounds/refresh" public typealias Response = VoidResponse public init() {} diff --git a/Sources/LanguageServerProtocol/Requests/WorkspacePlaygroundsRequest.swift b/Sources/LanguageServerProtocol/Requests/WorkspacePlaygroundsRequest.swift index c630a37a..614540a3 100644 --- a/Sources/LanguageServerProtocol/Requests/WorkspacePlaygroundsRequest.swift +++ b/Sources/LanguageServerProtocol/Requests/WorkspacePlaygroundsRequest.swift @@ -14,7 +14,7 @@ /// /// **(LSP Extension)** public struct WorkspacePlaygroundsRequest: LSPRequest, Hashable { - public static let method: String = "workspace/playgrounds" + public static let method: String = "sourcekit/workspace/playgrounds" public typealias Response = [Playground] public init() {} diff --git a/Sources/LanguageServerProtocol/Requests/WorkspaceTestsRefreshRequest.swift b/Sources/LanguageServerProtocol/Requests/WorkspaceTestsRefreshRequest.swift index 027c5fd1..3ba2f4db 100644 --- a/Sources/LanguageServerProtocol/Requests/WorkspaceTestsRefreshRequest.swift +++ b/Sources/LanguageServerProtocol/Requests/WorkspaceTestsRefreshRequest.swift @@ -19,7 +19,7 @@ /// /// **(LSP Extension)** public struct WorkspaceTestsRefreshRequest: LSPRequest { - public static let method: String = "workspace/tests/refresh" + public static let method: String = "sourcekit/workspace/tests/refresh" public typealias Response = VoidResponse public init() {} diff --git a/Sources/LanguageServerProtocol/Requests/WorkspaceTestsRequest.swift b/Sources/LanguageServerProtocol/Requests/WorkspaceTestsRequest.swift index a760b682..75f027d2 100644 --- a/Sources/LanguageServerProtocol/Requests/WorkspaceTestsRequest.swift +++ b/Sources/LanguageServerProtocol/Requests/WorkspaceTestsRequest.swift @@ -14,7 +14,7 @@ /// /// **(LSP Extension)** public struct WorkspaceTestsRequest: LSPRequest, Hashable { - public static let method: String = "workspace/tests" + public static let method: String = "sourcekit/workspace/tests" public typealias Response = [TestItem] public init() {} diff --git a/Sources/LanguageServerProtocolTransport/CMakeLists.txt b/Sources/LanguageServerProtocolTransport/CMakeLists.txt index ab73cffe..29673b7d 100644 --- a/Sources/LanguageServerProtocolTransport/CMakeLists.txt +++ b/Sources/LanguageServerProtocolTransport/CMakeLists.txt @@ -4,6 +4,7 @@ add_library(LanguageServerProtocolTransport DisableSigpipe.swift DocumentURI+CustomLogStringConvertible.swift JSONRPCConnection.swift + LegacyNameFallbackConnection.swift LocalConnection.swift LoggableMessageTypes.swift MessageCoding.swift diff --git a/Sources/LanguageServerProtocolTransport/Connection+Send.swift b/Sources/LanguageServerProtocolTransport/Connection+Send.swift index eda5db07..c458dc88 100644 --- a/Sources/LanguageServerProtocolTransport/Connection+Send.swift +++ b/Sources/LanguageServerProtocolTransport/Connection+Send.swift @@ -14,11 +14,13 @@ public import LanguageServerProtocol @_spi(SourceKitLSP) private import ToolsProtocolsSwiftExtensions extension Connection { - public func send(_ request: R) async throws -> R.Response { + public func send(_ request: R, method: String = R.method) async throws -> R.Response { return try await withCancellableCheckedThrowingContinuation { continuation in - return self.send(request) { result in + let id = self.nextRequestID() + self.send(request, method: method, id: id) { result in continuation.resume(with: result) } + return id } cancel: { requestID in self.send(CancelRequestNotification(id: requestID)) } diff --git a/Sources/LanguageServerProtocolTransport/JSONRPCConnection.swift b/Sources/LanguageServerProtocolTransport/JSONRPCConnection.swift index 16ced07e..d94ba0ff 100644 --- a/Sources/LanguageServerProtocolTransport/JSONRPCConnection.swift +++ b/Sources/LanguageServerProtocolTransport/JSONRPCConnection.swift @@ -432,7 +432,7 @@ public final class JSONRPCConnection: Connection { switch message { case .notification(let notification): notification._handle(receiveHandler) - case .request(let request, let id): + case .request(let request, _, let id): request._handle(receiveHandler, id: id) { (response, id) in self.sendReply(response, id: id) } @@ -514,7 +514,7 @@ public final class JSONRPCConnection: Connection { message: "sourcekit-lsp failed to encode a notification to the editor" ) return - case .request(_, _): + case .request(_, _, _): // We want to send a notification to the editor but failed to encode it. We don't know the `reply` handle for // the request at this point so we can't synthesize an errorResponse for the request. This means that the // request will never receive a reply. Inform the user about it. @@ -620,6 +620,7 @@ public final class JSONRPCConnection: Connection { /// When the receiving end replies to the request, execute `reply` with the response. public func send( _ request: Request, + method: String, id: RequestID, reply: @escaping @Sendable (LSPResult) -> Void ) { @@ -638,7 +639,7 @@ public final class JSONRPCConnection: Connection { logger.info( """ Received reply for request \(id, privacy: .public) from \(self.name, privacy: .public) - \(Request.method, privacy: .public) + \(method, privacy: .public) \(response.forLogging) """ ) @@ -646,7 +647,7 @@ public final class JSONRPCConnection: Connection { logger.error( """ Received error for request \(id, privacy: .public) from \(self.name, privacy: .public) - \(Request.method, privacy: .public) + \(method, privacy: .public) \(error.forLogging) """ ) @@ -660,8 +661,7 @@ public final class JSONRPCConnection: Connection { \(request.forLogging) """ ) - - self.sendAssumingOnQueue(.request(request, id: id)) + self.sendAssumingOnQueue(.request(request, method: method, id: id)) } } diff --git a/Sources/LanguageServerProtocolTransport/LegacyNameFallbackConnection.swift b/Sources/LanguageServerProtocolTransport/LegacyNameFallbackConnection.swift new file mode 100644 index 00000000..a650ecb4 --- /dev/null +++ b/Sources/LanguageServerProtocolTransport/LegacyNameFallbackConnection.swift @@ -0,0 +1,86 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +public import LanguageServerProtocol +@_spi(SourceKitLSP) import ToolsProtocolsSwiftExtensions + +/// A `Connection` wrapper that retries requests using a legacy method name when the peer +/// responds with `methodNotFound` for a `sourcekit/`-prefixed method. +/// +/// On the first successful response to a legacy name for a given method, the wrapper records that +/// method and from that point on sends requests for it with the legacy name directly, skipping +/// the primary method. +/// +/// Pass `MessageRegistry.lspLegacyNames` or `MessageRegistry.bspLegacyNames` as `legacyNames` +/// as appropriate. +public final class LegacyNameFallbackConnection: Connection, Sendable { + /// The underlying transport connection. + public let inner: any Connection + + /// Maps current method names to legacy names (new → old). + private let legacyNames: [String: String] + + /// The set of method names for which the peer has successfully responded using the legacy name. + /// Requests for these methods are sent with the legacy name directly, skipping the primary method. + private let methodNamesPreferringLegacyName: ThreadSafeBox> = .init(initialValue: []) + + public init(_ inner: any Connection, legacyNames: [String: String]) { + self.inner = inner + self.legacyNames = legacyNames + } + + public func nextRequestID() -> RequestID { + inner.nextRequestID() + } + + public func send(_ notification: some NotificationType) { + inner.send(notification) + } + + public func send( + _ request: R, + method: String, + id: RequestID, + reply: @escaping @Sendable (LSPResult) -> Void + ) { + guard let legacyName = legacyNames[method] else { + inner.send(request, method: method, id: id, reply: reply) + return + } + if methodNamesPreferringLegacyName.value.contains(method) { + inner.send(request, method: legacyName, id: id, reply: reply) + return + } + inner.send(request, method: method, id: id) { [weak self] result in + guard let self else { + reply(result) + return + } + if case .failure(let error) = result, error.code == .methodNotFound { + self.inner.send(request, method: legacyName, id: self.inner.nextRequestID()) { [weak self] legacyResult in + if case .success = legacyResult { + self?.methodNamesPreferringLegacyName.value.insert(method) + } + reply(legacyResult) + } + } else { + reply(result) + } + } + } + + /// Forward to the inner `JSONRPCConnection.changeReceiveHandler` if the inner connection is a + /// `JSONRPCConnection`. No-op for other connection types. + public func changeReceiveHandler(_ handler: any MessageHandler) { + (inner as? JSONRPCConnection)?.changeReceiveHandler(handler) + } +} diff --git a/Sources/LanguageServerProtocolTransport/LocalConnection.swift b/Sources/LanguageServerProtocolTransport/LocalConnection.swift index f910ceb1..1bce428f 100644 --- a/Sources/LanguageServerProtocolTransport/LocalConnection.swift +++ b/Sources/LanguageServerProtocolTransport/LocalConnection.swift @@ -105,6 +105,7 @@ public final class LocalConnection: Connection, Sendable { public func send( _ request: Request, + method: String, id: RequestID, reply: @Sendable @escaping (LSPResult) -> Void ) { diff --git a/Sources/LanguageServerProtocolTransport/MessageCoding.swift b/Sources/LanguageServerProtocolTransport/MessageCoding.swift index 03353dbc..392db46b 100644 --- a/Sources/LanguageServerProtocolTransport/MessageCoding.swift +++ b/Sources/LanguageServerProtocolTransport/MessageCoding.swift @@ -15,7 +15,7 @@ public import LanguageServerProtocol @_spi(Testing) @frozen public enum JSONRPCMessage { case notification(NotificationType) - case request(_RequestType, id: RequestID) + case request(_RequestType, method: String, id: RequestID) case response(ResponseType, id: RequestID) case errorResponse(ResponseError, id: RequestID?) } @@ -81,7 +81,7 @@ extension JSONRPCMessage: Codable { let params = try messageType.init(from: container.superDecoder(forKey: .params)) - self = .request(params, id: id) + self = .request(params, method: method, id: id) case (let id?, nil, true, nil): msgKind = .response @@ -148,8 +148,8 @@ extension JSONRPCMessage: Codable { try container.encode(type(of: params).method, forKey: .method) try params.encode(to: container.superEncoder(forKey: .params)) - case .request(let params, let id): - try container.encode(type(of: params).method, forKey: .method) + case .request(let params, let method, let id): + try container.encode(method, forKey: .method) try container.encode(id, forKey: .id) try params.encode(to: container.superEncoder(forKey: .params)) diff --git a/Tests/LanguageServerProtocolTransportTests/CodingTests.swift b/Tests/LanguageServerProtocolTransportTests/CodingTests.swift index 5558ec10..a0d3b96b 100644 --- a/Tests/LanguageServerProtocolTransportTests/CodingTests.swift +++ b/Tests/LanguageServerProtocolTransportTests/CodingTests.swift @@ -354,13 +354,14 @@ final class CodingTests: XCTestCase { decoder.userInfo = defaultCodingInfo let decodedValue = try decoder.decode(JSONRPCMessage.self, from: json.data(using: .utf8)!) - guard case JSONRPCMessage.request(let decodedValueOpaque, let decodedID) = decodedValue, + guard case JSONRPCMessage.request(let decodedValueOpaque, let decodedMethod, let decodedID) = decodedValue, let decodedRequest = decodedValueOpaque as? ShutdownRequest else { XCTFail("decodedValue \(decodedValue) is not a ShutdownRequest") return } + XCTAssertEqual(ShutdownRequest.method, decodedMethod) XCTAssertEqual(.number(1), decodedID, "expected request ID 1") XCTAssertEqual(ShutdownRequest(), decodedRequest) } @@ -377,14 +378,21 @@ private func checkMessageCoding( file: StaticString = #filePath, line: UInt = #line ) { - checkCoding(JSONRPCMessage.request(value, id: id), json: json, userInfo: defaultCodingInfo, file: file, line: line) { - guard case JSONRPCMessage.request(let decodedValueOpaque, let decodedID) = $0, + checkCoding( + JSONRPCMessage.request(value, method: Request.method, id: id), + json: json, + userInfo: defaultCodingInfo, + file: file, + line: line + ) { + guard case JSONRPCMessage.request(let decodedValueOpaque, let decodedMethod, let decodedID) = $0, let decodedValue = decodedValueOpaque as? Request else { XCTFail("decodedValue \($0) does not match expected \(value)", file: file, line: line) return } + XCTAssertEqual(Request.method, decodedMethod, "method decoding", file: file, line: line) XCTAssertEqual(id, decodedID, "requestID decoding", file: file, line: line) XCTAssertEqual(value, decodedValue, file: file, line: line) } diff --git a/Tests/LanguageServerProtocolTransportTests/LegacyNameFallbackConnectionTests.swift b/Tests/LanguageServerProtocolTransportTests/LegacyNameFallbackConnectionTests.swift new file mode 100644 index 00000000..6683268a --- /dev/null +++ b/Tests/LanguageServerProtocolTransportTests/LegacyNameFallbackConnectionTests.swift @@ -0,0 +1,235 @@ +//===----------------------------------------------------------------------===// +// +// 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 LanguageServerProtocol +import LanguageServerProtocolTransport +import Testing +import ToolsProtocolsSwiftExtensions + +// MARK: - Fixture request types + +private struct NoLegacyRequest: RequestType { + static let method = "sourcekit/noLegacy" + typealias Response = VoidResponse +} + +private struct WithLegacyRequest: RequestType { + static let method = "sourcekit/one" + typealias Response = VoidResponse +} + +private struct WithLegacyRequest2: RequestType { + static let method = "sourcekit/two" + typealias Response = VoidResponse +} + +private let testLegacyNames: [String: String] = [ + WithLegacyRequest.method: "sourcekit-lsp/one", + WithLegacyRequest2.method: "sourcekit-lsp/two", +] + +// MARK: - Mock connection + +/// A controllable `Connection` for unit tests. +/// +/// Calls are recorded in `calls`. `responses` maps method names to explicit +/// results; any method not in the dictionary defaults to `.methodNotFound`. +private final class MockConnection: Connection, Sendable { + struct State { + var calls: [Call] = [] + var responses: [String: LSPResult] = [:] + var nextIDValue: Int = 0 + } + + struct Call: Equatable { + var method: String + var id: RequestID + } + + let state: ThreadSafeBox = .init(initialValue: State()) + + var calls: [Call] { state.value.calls } + func clearCalls() { state.value.calls.removeAll() } + var responses: [String: LSPResult] { + get { state.value.responses } + set { state.value.responses = newValue } + } + + func nextRequestID() -> RequestID { + state.withLock { + $0.nextIDValue += 1 + return .number($0.nextIDValue) + } + } + + func send(_ notification: some NotificationType) {} + + func send( + _ request: Request, + method: String, + id: RequestID, + reply: @escaping @Sendable (LSPResult) -> Void + ) { + let voidResult: LSPResult = state.withLock { + $0.calls.append(Call(method: method, id: id)) + return $0.responses[method] ?? .failure(.methodNotFound(method)) + } + switch voidResult { + case .failure(let error): + reply(.failure(error)) + case .success(let voidResponse): + if let response = voidResponse as? Request.Response { + reply(.success(response)) + } else { + reply(.failure(.internalError("MockConnection: unsupported response type"))) + } + } + } +} + +// MARK: - Tests + +@Suite struct LegacyNameFallbackConnectionTests { + + // No legacyName: pass through regardless of the reply – no retries, flag never set. + @Test func testNoLegacyMethods() async { + let mock = MockConnection() // "sourcekit/noLegacy" → methodNotFound (default) + let conn = LegacyNameFallbackConnection(mock, legacyNames: testLegacyNames) + + let error = await #expect(throws: ResponseError.self) { + try await conn.send(NoLegacyRequest()) + } + #expect(error?.code == .methodNotFound) + #expect(mock.calls.count == 1) + #expect(mock.calls[0].method == "sourcekit/noLegacy") + + // Confirm the flag was NOT set: a fresh send still uses the primary name. + mock.clearCalls() + _ = try? await conn.send(NoLegacyRequest()) + #expect(mock.calls.count == 1) + #expect(mock.calls[0].method == "sourcekit/noLegacy") + } + + // Primary method succeeds: no fallback, flag stays false. + @Test func testSuccess() async throws { + let mock = MockConnection() + mock.responses["sourcekit/one"] = .success(VoidResponse()) + let conn = LegacyNameFallbackConnection(mock, legacyNames: testLegacyNames) + + _ = try await conn.send(WithLegacyRequest()) + + #expect(mock.calls.count == 1) + #expect(mock.calls[0].method == "sourcekit/one") + } + + // Primary returns methodNotFound: falls back to the single legacy name. + @Test func testMethodNotFound() async throws { + // "sourcekit/one" not in responses → methodNotFound; legacy succeeds. + let mock = MockConnection() + mock.responses["sourcekit-lsp/one"] = .success(VoidResponse()) + let conn = LegacyNameFallbackConnection(mock, legacyNames: testLegacyNames) + + _ = try await conn.send(WithLegacyRequest()) + + #expect(mock.calls.count == 2) + #expect(mock.calls[0].method == "sourcekit/one") + #expect(mock.calls[1].method == "sourcekit-lsp/one") + } + + // Both primary and legacy return methodNotFound: error is propagated, flag stays false. + @Test func testLegacyMethodNotFound() async { + let mock = MockConnection() // all methods default to methodNotFound + let conn = LegacyNameFallbackConnection(mock, legacyNames: testLegacyNames) + + let error = await #expect(throws: ResponseError.self) { + try await conn.send(WithLegacyRequest()) + } + #expect(error?.code == .methodNotFound) + #expect(mock.calls.count == 2) + + // Flag should NOT have been set: next request still tries the primary first. + mock.clearCalls() + _ = try? await conn.send(WithLegacyRequest()) + #expect(mock.calls.count == 2) + #expect(mock.calls[0].method == "sourcekit/one") + #expect(mock.calls[1].method == "sourcekit-lsp/one") + } + + // After the flag is set, subsequent requests skip the primary method entirely. + @Test func testFlagSticksAfterFirstMethodNotFound() async throws { + let mock = MockConnection() + mock.responses["sourcekit-lsp/one"] = .success(VoidResponse()) + let conn = LegacyNameFallbackConnection(mock, legacyNames: testLegacyNames) + + // First request: triggers flag. + _ = try await conn.send(WithLegacyRequest()) + #expect(mock.calls.count == 2) + + // Second request: should go directly to legacy name, bypassing the primary. + mock.clearCalls() + _ = try await conn.send(WithLegacyRequest()) + + #expect(mock.calls.count == 1) + #expect(mock.calls[0].method == "sourcekit-lsp/one") + } + + // A non-methodNotFound error does not flip the flag; the next request still tries primary first. + @Test func testNonMethodNotFoundError() async { + let mock = MockConnection() + mock.responses["sourcekit/one"] = .failure(.internalError("unexpected error")) + let conn = LegacyNameFallbackConnection(mock, legacyNames: testLegacyNames) + + let error = await #expect(throws: ResponseError.self) { + try await conn.send(WithLegacyRequest()) + } + #expect(error?.code == .internalError) + #expect(mock.calls.count == 1) + #expect(mock.calls[0].method == "sourcekit/one") + + // Flag should NOT have been set: next request still tries the primary method. + mock.responses["sourcekit/one"] = .success(VoidResponse()) + mock.clearCalls() + _ = try? await conn.send(WithLegacyRequest()) + #expect(mock.calls.count == 1) + #expect(mock.calls[0].method == "sourcekit/one") + } + + // Falling back for one method must not affect other methods: each method tracks its own state. + @Test func testFallbackIsPerMethod() async throws { + let mock = MockConnection() + mock.responses["sourcekit-lsp/one"] = .success(VoidResponse()) + mock.responses["sourcekit/two"] = .success(VoidResponse()) + let conn = LegacyNameFallbackConnection(mock, legacyNames: testLegacyNames) + + // Trigger legacy fallback for "sourcekit/one". + _ = try await conn.send(WithLegacyRequest()) + #expect(mock.calls.map(\.method) == ["sourcekit/one", "sourcekit-lsp/one"]) + + // "sourcekit/two" succeeds on the primary — its fallback state is independent. + mock.clearCalls() + _ = try await conn.send(WithLegacyRequest2()) + #expect(mock.calls.map(\.method) == ["sourcekit/two"]) + + // "sourcekit/one" now goes directly to legacy because its flag was set. + mock.clearCalls() + _ = try await conn.send(WithLegacyRequest()) + #expect(mock.calls.map(\.method) == ["sourcekit-lsp/one"]) + } + + // Notifications are forwarded to the inner connection unchanged. + @Test func testNotification() { + let mock = MockConnection() + let conn = LegacyNameFallbackConnection(mock, legacyNames: testLegacyNames) + conn.send(CancelRequestNotification(id: .number(42))) + // Notifications don't appear in `calls`; just ensure it doesn't crash. + } +}