diff --git a/Package.swift b/Package.swift index 9e498086..5e78faec 100644 --- a/Package.swift +++ b/Package.swift @@ -84,6 +84,7 @@ let package = Package( .library(name: "SWBUtil", targets: ["SWBUtil"]), .library(name: "SWBProjectModel", targets: ["SWBProjectModel"]), .library(name: "SWBBuildService", targets: ["SWBBuildService"]), + .library(name: "SWBBuildServerProtocol", targets: ["SWBBuildServerProtocol"]), ], targets: [ // Executables @@ -106,7 +107,7 @@ let package = Package( // Libraries .target( name: "SwiftBuild", - dependencies: ["SWBCSupport", "SWBCore", "SWBProtocol", "SWBUtil", "SWBProjectModel"], + dependencies: ["SWBCSupport", "SWBCore", "SWBProtocol", "SWBUtil", "SWBProjectModel", "SWBBuildServerProtocol"], exclude: ["CMakeLists.txt"], swiftSettings: swiftSettings(languageMode: .v5)), .target( @@ -215,6 +216,11 @@ let package = Package( dependencies: ["SWBUtil", "SWBCSupport"], exclude: ["CMakeLists.txt"], swiftSettings: swiftSettings(languageMode: .v6)), + .target( + name: "SWBBuildServerProtocol", + dependencies: ["SWBUtil"], + exclude: ["CMakeLists.txt"], + swiftSettings: swiftSettings(languageMode: .v6)), .target( name: "SWBAndroidPlatform", diff --git a/Sources/CMakeLists.txt b/Sources/CMakeLists.txt index c0d0304a..b5ce0858 100644 --- a/Sources/CMakeLists.txt +++ b/Sources/CMakeLists.txt @@ -64,6 +64,7 @@ add_subdirectory(SWBCore) add_subdirectory(SWBTaskConstruction) add_subdirectory(SWBAndroidPlatform) add_subdirectory(SWBApplePlatform) +add_subdirectory(SWBBuildServerProtocol) add_subdirectory(SWBGenericUnixPlatform) add_subdirectory(SWBQNXPlatform) add_subdirectory(SWBUniversalPlatform) diff --git a/Sources/SWBBuildServerProtocol/AsyncQueue.swift b/Sources/SWBBuildServerProtocol/AsyncQueue.swift new file mode 100644 index 00000000..bd1ec926 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/AsyncQueue.swift @@ -0,0 +1,194 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation + +/// Abstraction layer so we can store a heterogeneous collection of tasks in an +/// array. +private protocol AnyTask: Sendable { + func waitForCompletion() async + + func cancel() +} + +extension Task: AnyTask { + func waitForCompletion() async { + _ = try? await value + } +} + +/// A type that is able to track dependencies between tasks. +public protocol DependencyTracker: Sendable, Hashable { + /// Whether the task described by `self` needs to finish executing before `other` can start executing. + func isDependency(of other: Self) -> Bool +} + +/// A dependency tracker where each task depends on every other, i.e. a serial +/// queue. +public struct Serial: DependencyTracker { + public func isDependency(of other: Serial) -> Bool { + return true + } +} + +package struct PendingTask: Sendable { + /// The task that is pending. + fileprivate let task: any AnyTask + + /// A unique value used to identify the task. This allows tasks to get + /// removed from `pendingTasks` again after they finished executing. + fileprivate let id: UUID +} + +/// A list of pending tasks that can be sent across actor boundaries and is guarded by a lock. +/// +/// - Note: Unchecked sendable because the tasks are being protected by a lock. +private final class PendingTasks: Sendable { + /// Lock guarding `pendingTasks`. + private let lock = NSLock() + + /// Pending tasks that have not finished execution yet. + /// + /// - Important: This must only be accessed while `lock` has been acquired. + private nonisolated(unsafe) var tasksByMetadata: [TaskMetadata: [PendingTask]] = [:] + + init() { + self.lock.name = "AsyncQueue" + } + + /// Capture a lock and execute the closure, which may modify the pending tasks. + func withLock( + _ body: (_ tasksByMetadata: inout [TaskMetadata: [PendingTask]]) throws -> T + ) rethrows -> T { + try lock.withLock { + try body(&tasksByMetadata) + } + } +} + +/// A queue that allows the execution of asynchronous blocks of code. +public final class AsyncQueue: Sendable { + private let pendingTasks: PendingTasks = PendingTasks() + + public init() {} + + /// Schedule a new closure to be executed on the queue. + /// + /// If this is a serial queue, all previously added tasks are guaranteed to + /// finished executing before this closure gets executed. + /// + /// If this is a barrier, all previously scheduled tasks are guaranteed to + /// finish execution before the barrier is executed and all tasks that are + /// added later will wait until the barrier finishes execution. + @discardableResult + public func async( + priority: TaskPriority? = nil, + metadata: TaskMetadata, + @_inheritActorContext operation: @escaping @Sendable () async -> Success + ) -> Task { + let throwingTask = asyncThrowing(priority: priority, metadata: metadata, operation: operation) + return Task(priority: priority) { + do { + return try await throwingTask.valuePropagatingCancellation + } catch { + // We know this can never happen because `operation` does not throw. + preconditionFailure("Executing a task threw an error even though the operation did not throw") + } + } + } + + /// Same as ``AsyncQueue/async(priority:barrier:operation:)`` but allows the + /// operation to throw. + /// + /// - Important: The caller is responsible for handling any errors thrown from + /// the operation by awaiting the result of the returned task. + public func asyncThrowing( + priority: TaskPriority? = nil, + metadata: TaskMetadata, + @_inheritActorContext operation: @escaping @Sendable () async throws -> Success + ) -> Task { + let id = UUID() + + return pendingTasks.withLock { tasksByMetadata in + // Build the list of tasks that need to finished execution before this one + // can be executed + var dependencies: [PendingTask] = [] + for (pendingMetadata, pendingTasks) in tasksByMetadata { + guard pendingMetadata.isDependency(of: metadata) else { + // No dependency + continue + } + if metadata.isDependency(of: metadata), let lastPendingTask = pendingTasks.last { + // This kind of task depends on all other tasks of the same kind finishing. It is sufficient to just wait on + // the last task with this metadata, it will have all the other tasks with the same metadata as transitive + // dependencies. + dependencies.append(lastPendingTask) + } else { + // We depend on tasks with this metadata, but they don't have any dependencies between them, eg. + // `documentUpdate` depends on all `documentRequest` but `documentRequest` don't have dependencies between + // them. We need to depend on all of them unless we knew that we depended on some other task that already + // depends on all of these. But determining that would also require knowledge about the entire dependency + // graph, which is likely as expensive as depending on all of these tasks. + dependencies += pendingTasks + } + } + + // Schedule the task. + let task = Task(priority: priority) { [pendingTasks] in + // IMPORTANT: The only throwing call in here must be the call to + // operation. Otherwise the assumption that the task will never throw + // if `operation` does not throw, which we are making in `async` does + // not hold anymore. + for dependency in dependencies { + await dependency.task.waitForCompletion() + } + + let result = try await operation() + + pendingTasks.withLock { tasksByMetadata in + tasksByMetadata[metadata, default: []].removeAll(where: { $0.id == id }) + if tasksByMetadata[metadata]?.isEmpty ?? false { + tasksByMetadata[metadata] = nil + } + } + + return result + } + + tasksByMetadata[metadata, default: []].append(PendingTask(task: task, id: id)) + + return task + } + } +} + +/// Convenience overloads for serial queues. +extension AsyncQueue where TaskMetadata == Serial { + /// Same as ``async(priority:operation:)`` but specialized for serial queues + /// that don't specify any metadata. + @discardableResult + public func async( + priority: TaskPriority? = nil, + @_inheritActorContext operation: @escaping @Sendable () async -> Success + ) -> Task { + return self.async(priority: priority, metadata: Serial(), operation: operation) + } + + /// Same as ``asyncThrowing(priority:metadata:operation:)`` but specialized + /// for serial queues that don't specify any metadata. + public func asyncThrowing( + priority: TaskPriority? = nil, + @_inheritActorContext operation: @escaping @Sendable () async throws -> Success + ) -> Task { + return self.asyncThrowing(priority: priority, metadata: Serial(), operation: operation) + } +} diff --git a/Sources/SWBBuildServerProtocol/AsyncUtils.swift b/Sources/SWBBuildServerProtocol/AsyncUtils.swift new file mode 100644 index 00000000..5224b813 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/AsyncUtils.swift @@ -0,0 +1,45 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +public import Foundation +import SWBUtil +import Synchronization + +public extension Task { + /// Awaits the value of the result. + /// + /// If the current task is cancelled, this will cancel the subtask as well. + var valuePropagatingCancellation: Success { + get async throws { + try await withTaskCancellationHandler { + return try await self.value + } onCancel: { + self.cancel() + } + } + } +} + +extension Task where Failure == Never { + /// Awaits the value of the result. + /// + /// If the current task is cancelled, this will cancel the subtask as well. + public var valuePropagatingCancellation: Success { + get async { + await withTaskCancellationHandler { + return await self.value + } onCancel: { + self.cancel() + } + } + } +} diff --git a/Sources/SWBBuildServerProtocol/BuildShutdownRequest.swift b/Sources/SWBBuildServerProtocol/BuildShutdownRequest.swift new file mode 100644 index 00000000..d19da892 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/BuildShutdownRequest.swift @@ -0,0 +1,23 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// Like the language server protocol, the shutdown build request is +/// sent from the client to the server. It asks the server to shut down, +/// but to not exit (otherwise the response might not be delivered +/// correctly to the client). There is a separate exit notification +/// that asks the server to exit. +public struct BuildShutdownRequest: RequestType { + public static let method: String = "build/shutdown" + public typealias Response = VoidResponse + + public init() {} +} diff --git a/Sources/SWBBuildServerProtocol/BuildSystemMessageDependencyTracker.swift b/Sources/SWBBuildServerProtocol/BuildSystemMessageDependencyTracker.swift new file mode 100644 index 00000000..efb9f7e4 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/BuildSystemMessageDependencyTracker.swift @@ -0,0 +1,86 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// A lightweight way of describing tasks that are created from handling BSP +/// requests or notifications for the purpose of dependency tracking. +public enum BuildSystemMessageDependencyTracker: QueueBasedMessageHandlerDependencyTracker { + /// A task that modifies some state. It is a barrier for all requests that read state. + case stateChange + + /// A task that reads state, such as getting all build targets. These tasks can be run concurrently with other tasks + /// that read state but needs to wait for all state changes to be handled first. + case stateRead + + /// A task that is responsible for logging information to the client. They can be run concurrently to any state read + /// and changes but logging tasks must be ordered among each other. + case taskProgress + + /// Whether this request needs to finish before `other` can start executing. + public func isDependency(of other: BuildSystemMessageDependencyTracker) -> Bool { + switch (self, other) { + case (.stateChange, .stateChange): return true + case (.stateChange, .stateRead): return true + case (.stateRead, .stateChange): return true + case (.stateRead, .stateRead): return false + case (.taskProgress, .taskProgress): return true + case (.taskProgress, _): return false + case (_, .taskProgress): return false + } + } + + public init(_ notification: some NotificationType) { + switch notification { + case is OnBuildExitNotification: + self = .stateChange + case is OnBuildInitializedNotification: + self = .stateChange + case is OnBuildLogMessageNotification: + self = .taskProgress + case is OnBuildTargetDidChangeNotification: + self = .stateChange + case is OnWatchedFilesDidChangeNotification: + self = .stateChange + case is TaskFinishNotification: + self = .taskProgress + case is TaskProgressNotification: + self = .taskProgress + case is TaskStartNotification: + self = .taskProgress + default: + self = .stateRead + } + } + + public init(_ request: some RequestType) { + switch request { + case is BuildShutdownRequest: + self = .stateChange + case is BuildTargetPrepareRequest: + self = .stateRead + case is BuildTargetSourcesRequest: + self = .stateRead + case is TaskStartNotification, is TaskProgressNotification, is TaskFinishNotification: + self = .taskProgress + case is InitializeBuildRequest: + self = .stateChange + case is TextDocumentSourceKitOptionsRequest: + self = .stateRead + case is WorkspaceBuildTargetsRequest: + self = .stateRead + case is WorkspaceWaitForBuildSystemUpdatesRequest: + self = .stateRead + + default: + self = .stateChange + } + } +} diff --git a/Sources/SWBBuildServerProtocol/BuildTarget.swift b/Sources/SWBBuildServerProtocol/BuildTarget.swift new file mode 100644 index 00000000..334aff9c --- /dev/null +++ b/Sources/SWBBuildServerProtocol/BuildTarget.swift @@ -0,0 +1,235 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// Build target contains metadata about an artifact (for example library, test, or binary artifact). +/// Using vocabulary of other build tools: +/// - sbt: a build target is a combined project + config. Example: +/// - a regular JVM project with main and test configurations will have 2 build targets, one for main and one for test. +/// - a single configuration in a single project that contains both Java and Scala sources maps to one BuildTarget. +/// - a project with crossScalaVersions 2.11 and 2.12 containing main and test configuration in each will have 4 build targets. +/// - a Scala 2.11 and 2.12 cross-built project for Scala.js and the JVM with main and test configurations will have 8 build targets. +/// - Pants: a pants target corresponds one-to-one with a BuildTarget +/// - Bazel: a bazel target corresponds one-to-one with a BuildTarget +/// +/// The general idea is that the BuildTarget data structure should contain only information that is fast or cheap to compute +public struct BuildTarget: Codable, Hashable, Sendable { + /// The target’s unique identifier + public var id: BuildTargetIdentifier + + /// A human readable name for this target. + /// May be presented in the user interface. + /// Should be unique if possible. + /// The id.uri is used if `nil`. + public var displayName: String? + + /// The directory where this target belongs to. Multiple build targets are + /// allowed to map to the same base directory, and a build target is not + /// required to have a base directory. A base directory does not determine the + /// sources of a target, see `buildTarget/sources`. + public var baseDirectory: URI? + + /// Free-form string tags to categorize or label this build target. + /// For example, can be used by the client to: + /// - customize how the target should be translated into the client's project + /// model. + /// - group together different but related targets in the user interface. + /// - display icons or colors in the user interface. + /// Pre-defined tags are listed in `BuildTargetTag` but clients and servers + /// are free to define new tags for custom purposes. + public var tags: [BuildTargetTag] + + /// The set of languages that this target contains. + /// The ID string for each language is defined in the LSP. + public var languageIds: [Language] + + /// The direct upstream build target dependencies of this build target + public var dependencies: [BuildTargetIdentifier] + + /// The capabilities of this build target. + public var capabilities: BuildTargetCapabilities + + /// Kind of data to expect in the `data` field. If this field is not set, the kind of data is not specified. + public var dataKind: BuildTargetDataKind? + + /// Language-specific metadata about this target. + /// See ScalaBuildTarget as an example. + public var data: LSPAny? + + public init( + id: BuildTargetIdentifier, + displayName: String? = nil, + baseDirectory: URI? = nil, + tags: [BuildTargetTag] = [], + capabilities: BuildTargetCapabilities = BuildTargetCapabilities(), + languageIds: [Language], + dependencies: [BuildTargetIdentifier], + dataKind: BuildTargetDataKind? = nil, + data: LSPAny? = nil + ) { + self.id = id + self.displayName = displayName + self.baseDirectory = baseDirectory + self.tags = tags + self.capabilities = capabilities + self.languageIds = languageIds + self.dependencies = dependencies + self.dataKind = dataKind + self.data = data + } +} + +/// A unique identifier for a target, can use any URI-compatible encoding as long as it is unique within the workspace. +/// Clients should not infer metadata out of the URI structure such as the path or query parameters, use `BuildTarget` +/// instead. +public struct BuildTargetIdentifier: Codable, Hashable, Sendable { + /// The target's Uri + public var uri: URI + + public init(uri: URI) { + self.uri = uri + } +} + +public typealias URI = DocumentURI + +/// A list of predefined tags that can be used to categorize build targets. +public struct BuildTargetTag: Codable, Hashable, RawRepresentable, Sendable { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + /// Target contains source code for producing any kind of application, may + /// have but does not require the `canRun` capability. + public static let application: Self = Self(rawValue: "application") + + /// Target contains source code to measure performance of a program, may have + /// but does not require the `canRun` build target capability. + public static let benchmark: Self = Self(rawValue: "benchmark") + + /// Target contains source code for integration testing purposes, may have + /// but does not require the `canTest` capability. The difference between + /// "test" and "integration-test" is that integration tests traditionally run + /// slower compared to normal tests and require more computing resources to + /// execute. + public static let integrationTest: Self = Self(rawValue: "integration-test") + + /// Target contains re-usable functionality for downstream targets. May have + /// any combination of capabilities. + public static let library: Self = Self(rawValue: "library") + + /// Actions on the target such as build and test should only be invoked manually + /// and explicitly. For example, triggering a build on all targets in the workspace + /// should by default not include this target. + /// The original motivation to add the "manual" tag comes from a similar functionality + /// that exists in Bazel, where targets with this tag have to be specified explicitly + /// on the command line. + public static let manual: Self = Self(rawValue: "manual") + + /// Target should be ignored by IDEs. + public static let noIDE: Self = Self(rawValue: "no-ide") + + /// Target contains source code for testing purposes, may have but does not + /// require the `canTest` capability. + public static let test: Self = Self(rawValue: "test") + + /// This is a target of a dependency from the project the user opened, eg. a target that builds a SwiftPM dependency. + /// + /// **(BSP Extension)** + public static let dependency: Self = Self(rawValue: "dependency") + + /// This target only exists to provide compiler arguments for SourceKit-LSP can't be built standalone. + /// + /// For example, a SwiftPM package manifest is in a non-buildable target. + /// + /// **(BSP Extension)** + public static let notBuildable: Self = Self(rawValue: "not-buildable") +} + +/// Clients can use these capabilities to notify users what BSP endpoints can and cannot be used and why. +public struct BuildTargetCapabilities: Codable, Hashable, Sendable { + /// This target can be compiled by the BSP server. + public var canCompile: Bool? + + /// This target can be tested by the BSP server. + public var canTest: Bool? + + /// This target can be run by the BSP server. + public var canRun: Bool? + + /// This target can be debugged by the BSP server. + public var canDebug: Bool? + + public init(canCompile: Bool? = nil, canTest: Bool? = nil, canRun: Bool? = nil, canDebug: Bool? = nil) { + self.canCompile = canCompile + self.canTest = canTest + self.canRun = canRun + self.canDebug = canDebug + } +} + +public struct BuildTargetDataKind: RawRepresentable, Codable, Hashable, Sendable { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + /// `data` field must contain a CargoBuildTarget object. + public static let cargo = BuildTargetDataKind(rawValue: "cargo") + + /// `data` field must contain a CppBuildTarget object. + public static let cpp = BuildTargetDataKind(rawValue: "cpp") + + /// `data` field must contain a JvmBuildTarget object. + public static let jvm = BuildTargetDataKind(rawValue: "jvm") + + /// `data` field must contain a PythonBuildTarget object. + public static let python = BuildTargetDataKind(rawValue: "python") + + /// `data` field must contain a SbtBuildTarget object. + public static let sbt = BuildTargetDataKind(rawValue: "sbt") + + /// `data` field must contain a ScalaBuildTarget object. + public static let scala = BuildTargetDataKind(rawValue: "scala") + + /// `data` field must contain a SourceKitBuildTarget object. + public static let sourceKit = BuildTargetDataKind(rawValue: "sourceKit") +} + +public struct SourceKitBuildTarget: LSPAnyCodable, Codable { + /// The toolchain that should be used to build this target. The URI should point to the directory that contains the + /// `usr` directory. On macOS, this is typically a bundle ending in `.xctoolchain`. If the toolchain is installed to + /// `/` on Linux, the toolchain URI would point to `/`. + /// + /// If no toolchain is given, SourceKit-LSP will pick a toolchain to use for this target. + public var toolchain: URI? + + public init(toolchain: URI? = nil) { + self.toolchain = toolchain + } + + public init(fromLSPDictionary dictionary: [String: LSPAny]) { + if case .string(let toolchain) = dictionary[CodingKeys.toolchain.stringValue] { + self.toolchain = try? URI(string: toolchain) + } + } + + public func encodeToLSPAny() -> LSPAny { + var result: [String: LSPAny] = [:] + if let toolchain { + result[CodingKeys.toolchain.stringValue] = .string(toolchain.stringValue) + } + return .dictionary(result) + } +} diff --git a/Sources/SWBBuildServerProtocol/BuildTargetPrepareRequest.swift b/Sources/SWBBuildServerProtocol/BuildTargetPrepareRequest.swift new file mode 100644 index 00000000..88878409 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/BuildTargetPrepareRequest.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +public typealias OriginId = String + +/// The prepare build target request is sent from the client to the server to prepare the given list of build targets +/// for editor functionality. +/// +/// To do so, the build server should perform any work that is necessary to typecheck the files in the given target. +/// This includes, but is not limited to: Building Swift modules for all dependencies and running code generation scripts. +/// Compared to a full build, the build server may skip actions that are not necessary for type checking, such as object +/// file generation but the exact steps necessary are dependent on the build system. SwiftPM implements this step using +/// the `swift build --experimental-prepare-for-indexing` command. +/// +/// The server communicates during the initialize handshake whether this method is supported or not by setting +/// `prepareProvider: true` in `SourceKitInitializeBuildResponseData`. +public struct BuildTargetPrepareRequest: RequestType, Hashable { + public static let method: String = "buildTarget/prepare" + public typealias Response = VoidResponse + + /// A list of build targets to prepare. + public var targets: [BuildTargetIdentifier] + + /// A unique identifier generated by the client to identify this request. + /// The server may include this id in triggered notifications or responses. + public var originId: OriginId? + + public init(targets: [BuildTargetIdentifier], originId: OriginId? = nil) { + self.targets = targets + self.originId = originId + } +} diff --git a/Sources/SWBBuildServerProtocol/BuildTargetSourcesRequest.swift b/Sources/SWBBuildServerProtocol/BuildTargetSourcesRequest.swift new file mode 100644 index 00000000..ff606c44 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/BuildTargetSourcesRequest.swift @@ -0,0 +1,193 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// The build target sources request is sent from the client to the server to +/// query for the list of text documents and directories that belong to a +/// build target. The sources response must not include sources that are +/// external to the workspace. +public struct BuildTargetSourcesRequest: RequestType, Hashable { + public static let method: String = "buildTarget/sources" + public typealias Response = BuildTargetSourcesResponse + + public var targets: [BuildTargetIdentifier] + + public init(targets: [BuildTargetIdentifier]) { + self.targets = targets + } +} + +public struct BuildTargetSourcesResponse: ResponseType, Hashable { + public var items: [SourcesItem] + + public init(items: [SourcesItem]) { + self.items = items + } +} + +public struct SourcesItem: Codable, Hashable, Sendable { + public var target: BuildTargetIdentifier + + /// The text documents and directories that belong to this build target. + public var sources: [SourceItem] + + /// The root directories from where source files should be relativized. + /// Example: ["file://Users/name/dev/metals/src/main/scala"] + public var roots: [URI]? + + public init(target: BuildTargetIdentifier, sources: [SourceItem], roots: [URI]? = nil) { + self.target = target + self.sources = sources + self.roots = roots + } +} + +public struct SourceItem: Codable, Hashable, Sendable { + /// Either a text document or a directory. A directory entry must end with a + /// forward slash "/" and a directory entry implies that every nested text + /// document within the directory belongs to this source item. + public var uri: URI + + /// Type of file of the source item, such as whether it is file or directory. + public var kind: SourceItemKind + + /// Indicates if this source is automatically generated by the build and is + /// not intended to be manually edited by the user. + public var generated: Bool + + /// Kind of data to expect in the `data` field. If this field is not set, the kind of data is not specified. + public var dataKind: SourceItemDataKind? + + /// Language-specific metadata about this source item. + public var data: LSPAny? + + /// If `dataKind` is `sourceKit`, the `data` interpreted as `SourceKitSourceItemData`, otherwise `nil`. + public var sourceKitData: SourceKitSourceItemData? { + guard dataKind == .sourceKit else { + return nil + } + return SourceKitSourceItemData(fromLSPAny: data) + } + + public init( + uri: URI, + kind: SourceItemKind, + generated: Bool, + dataKind: SourceItemDataKind? = nil, + data: LSPAny? = nil + ) { + self.uri = uri + self.kind = kind + self.generated = generated + self.dataKind = dataKind + self.data = data + } +} + +public enum SourceItemKind: Int, Codable, Hashable, Sendable { + /// The source item references a normal file. + case file = 1 + + /// The source item references a directory. + case directory = 2 +} + +public struct SourceItemDataKind: RawRepresentable, Codable, Hashable, Sendable { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + /// `data` field must contain a JvmSourceItemData object. + public static let jvm = SourceItemDataKind(rawValue: "jvm") + + /// `data` field must contain a `SourceKitSourceItemData` object. + /// + /// **(BSP Extension)** + public static let sourceKit = SourceItemDataKind(rawValue: "sourceKit") +} + +/// **(BSP Extension)** + +public enum SourceKitSourceItemKind: String, Codable { + /// A source file that belongs to the target + case source = "source" + + /// A header file that is clearly associated with one target. + /// + /// For example header files in SwiftPM projects are always associated to one target and SwiftPM can provide build + /// settings for that header file. + /// + /// In general, build servers don't need to list all header files in the `buildTarget/sources` request: Semantic + /// functionality for header files is usually provided by finding a main file that includes the header file and + /// inferring build settings from it. Listing header files in `buildTarget/sources` allows SourceKit-LSP to provide + /// semantic functionality for header files if they haven't been included by any main file. + case header = "header" + + /// A SwiftDocC documentation catalog usually ending in the ".docc" extension. + case doccCatalog = "doccCatalog" +} + +public struct SourceKitSourceItemData: LSPAnyCodable, Codable { + /// The language of the source file. If `nil`, the language is inferred from the file extension. + public var language: Language? + + /// The kind of source file that this source item represents. If omitted, the item is assumed to be a normal source file, + /// ie. omitting this key is equivalent to specifying it as `source`. + public var kind: SourceKitSourceItemKind? + + /// The output path that is used during indexing for this file, ie. the `-index-unit-output-path`, if it is specified + /// in the compiler arguments or the file that is passed as `-o`, if `-index-unit-output-path` is not specified. + /// + /// This allows SourceKit-LSP to remove index entries for source files that are removed from a target but remain + /// present on disk. + /// + /// The server communicates during the initialize handshake whether it populates this property by setting + /// `outputPathsProvider: true` in `SourceKitInitializeBuildResponseData`. + public var outputPath: String? + + public init(language: Language? = nil, kind: SourceKitSourceItemKind? = nil, outputPath: String? = nil) { + self.language = language + self.kind = kind + self.outputPath = outputPath + } + + public init?(fromLSPDictionary dictionary: [String: LSPAny]) { + if case .string(let language) = dictionary[CodingKeys.language.stringValue] { + self.language = Language(rawValue: language) + } + if case .string(let rawKind) = dictionary[CodingKeys.kind.stringValue] { + self.kind = SourceKitSourceItemKind(rawValue: rawKind) + } + // Backwards compatibility for isHeader + if case .bool(let isHeader) = dictionary["isHeader"], isHeader { + self.kind = .header + } + if case .string(let outputFilePath) = dictionary[CodingKeys.outputPath.stringValue] { + self.outputPath = outputFilePath + } + } + + public func encodeToLSPAny() -> LSPAny { + var result: [String: LSPAny] = [:] + if let language { + result[CodingKeys.language.stringValue] = .string(language.rawValue) + } + if let kind { + result[CodingKeys.kind.stringValue] = .string(kind.rawValue) + } + if let outputPath { + result[CodingKeys.outputPath.stringValue] = .string(outputPath) + } + return .dictionary(result) + } +} diff --git a/Sources/SWBBuildServerProtocol/CMakeLists.txt b/Sources/SWBBuildServerProtocol/CMakeLists.txt new file mode 100644 index 00000000..1dc09413 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/CMakeLists.txt @@ -0,0 +1,72 @@ +#[[ +This source file is part of the Swift open source project + +Copyright (c) 2025 Apple Inc. and the Swift project authors +Licensed under Apache License v2.0 with Runtime Library Exception + +See http://swift.org/LICENSE.txt for license information +See http://swift.org/CONTRIBUTORS.txt for Swift project authors +]] + +add_library(SWBBuildServerProtocol + AsyncQueue.swift + AsyncUtils.swift + BuildShutdownRequest.swift + BuildSystemMessageDependencyTracker.swift + BuildTarget.swift + BuildTargetPrepareRequest.swift + BuildTargetSourcesRequest.swift + CancelRequestNotification.swift + Connection.swift + CreateWorkDoneProgressRequest.swift + CustomCodable.swift + DidChangeWatchedFilesNotification.swift + DocumentURI.swift + Error.swift + FileEvent.swift + FileSystemWatcher.swift + InitializeBuildRequest.swift + Language.swift + LocalConnection.swift + Location.swift + LogMessageNotification.swift + LSPAny.swift + Message.swift + MessageRegistry.swift + Messages.swift + MessageType.swift + MillisecondsSince1970Date.swift + OnBuildExitNotification.swift + OnBuildInitializedNotification.swift + OnBuildLogMessageNotification.swift + OnBuildTargetDidChangeNotification.swift + OnWatchedFilesDidChangeNotification.swift + Position.swift + PositionRange.swift + ProgressToken.swift + QueueBasedMessageHandler.swift + RequestAndReply.swift + RequestID.swift + ResponseError+Init.swift + StatusCode.swift + TaskFinishNotification.swift + TaskId.swift + TaskProgressNotification.swift + TaskStartNotification.swift + TextDocumentIdentifier.swift + TextDocumentSourceKitOptionsRequest.swift + WindowMessageType.swift + WorkspaceBuildTargetsRequest.swift + WorkspaceWaitForBuildSystemUpdates.swift) +set_target_properties(SWBBuildServerProtocol PROPERTIES + Swift_LANGUAGE_VERSION 6) +target_link_libraries(SWBBuildServerProtocol PUBLIC + SWBUtil) + +set_target_properties(SWBBuildServerProtocol PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_Swift_MODULE_DIRECTORY}) + +set_property(GLOBAL APPEND PROPERTY SWIFTBUILD_EXPORTS SWBBuildServerProtocol) + +install(TARGETS SWBBuildServerProtocol + ARCHIVE DESTINATION "${SwiftBuild_INSTALL_LIBDIR}") diff --git a/Sources/SWBBuildServerProtocol/CancelRequestNotification.swift b/Sources/SWBBuildServerProtocol/CancelRequestNotification.swift new file mode 100644 index 00000000..f6428176 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/CancelRequestNotification.swift @@ -0,0 +1,30 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// Notification that the given request (previously made) should be cancelled, if possible. +/// +/// Cancellation is not guaranteed and the underlying request may finish normally. If the request is +/// successfully cancelled, it should return the `.cancelled` error code. +/// +/// As with any `$` requests, the server is free to ignore this notification. +/// +/// - Parameter id: The request to cancel. +public struct CancelRequestNotification: NotificationType, Hashable { + public static let method: String = "$/cancelRequest" + + /// The request to cancel. + public var id: RequestID + + public init(id: RequestID) { + self.id = id + } +} diff --git a/Sources/SWBBuildServerProtocol/Connection.swift b/Sources/SWBBuildServerProtocol/Connection.swift new file mode 100644 index 00000000..66dcf3e7 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/Connection.swift @@ -0,0 +1,118 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// An abstract connection, allow messages to be sent to a (potentially remote) `MessageHandler`. +public protocol Connection: Sendable { + /// Send a notification without a reply. + func send(_ notification: some NotificationType) + + /// Generate a new request ID to be used in the `send` method that does not take an explicit request ID. + /// + /// These request IDs need to be unique and must not conflict with any request ID that clients might manually specify + /// to `send(_:id:reply:)`. + /// + /// To allow, this request IDs starting with `sk-` are reserved to only be generated by this method and are not + /// allowed to be passed directly to `send(_:id:reply:)`. Thus, generating request IDs prefixed with `sk-` here is + /// safe. Similarly returning UUID-based requests IDs is safe because UUIDs are already unique. + func nextRequestID() -> RequestID + + /// 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()`. + func send( + _ request: Request, + id: RequestID, + reply: @escaping @Sendable (LSPResult) -> Void + ) +} + +extension Connection { + /// Send a request and (asynchronously) receive a reply. + public func send( + _ request: Request, + reply: @escaping @Sendable (LSPResult) -> Void + ) -> RequestID { + let id = nextRequestID() + self.send(request, id: id, reply: reply) + return id + } +} + +extension Connection { + public func send(_ request: Request) async throws -> Request.Response { + return try await withCheckedThrowingContinuation { continuation in + _ = send(request, reply: { response in + switch response { + case .success(let value): + continuation.resume(returning: value) + case .failure(let error): + continuation.resume(throwing: error) + } + }) + } + } +} + +/// An abstract message handler, such as a language server or client. +public protocol MessageHandler: AnyObject, Sendable { + + /// Handle a notification without a reply. + /// + /// The method should return as soon as the notification has been sufficiently + /// handled to avoid out-of-order requests, e.g. once the notification has + /// been forwarded to clangd. + func handle(_ notification: some NotificationType) + + /// Handle a request and (asynchronously) receive a reply. + /// + /// The method should return as soon as the request has been sufficiently + /// handled to avoid out-of-order requests, e.g. once the corresponding + /// request has been sent to sourcekitd. The actual semantic computation + /// should occur after the method returns and report the result via `reply`. + func handle( + _ request: Request, + id: RequestID, + reply: @Sendable @escaping (LSPResult) -> Void + ) +} + +// MARK: - WeakMessageHelper + +/// Wrapper around a weak `MessageHandler`. +/// +/// This allows us to eg. set the ``TestSourceKitLSPClient`` as the message handler of +/// `SourceKitLSPServer` without retaining it. +public final class WeakMessageHandler: MessageHandler, Sendable { + // `nonisolated(unsafe)` is fine because `handler` is never modified, only if the weak reference is deallocated, which + // is atomic. + private nonisolated(unsafe) weak var handler: (any MessageHandler)? + + public init(_ handler: any MessageHandler) { + self.handler = handler + } + + public func handle(_ params: some NotificationType) { + handler?.handle(params) + } + + public func handle( + _ params: Request, + id: RequestID, + reply: @Sendable @escaping (LSPResult) -> Void + ) { + guard let handler = handler else { + reply(.failure(.unknown("Handler has been deallocated"))) + return + } + handler.handle(params, id: id, reply: reply) + } +} diff --git a/Sources/SWBBuildServerProtocol/CreateWorkDoneProgressRequest.swift b/Sources/SWBBuildServerProtocol/CreateWorkDoneProgressRequest.swift new file mode 100644 index 00000000..e40af393 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/CreateWorkDoneProgressRequest.swift @@ -0,0 +1,23 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +public struct CreateWorkDoneProgressRequest: RequestType { + public static let method: String = "window/workDoneProgress/create" + public typealias Response = VoidResponse + + /// The token to be used to report progress. + public var token: ProgressToken + + public init(token: ProgressToken) { + self.token = token + } +} diff --git a/Sources/SWBBuildServerProtocol/CustomCodable.swift b/Sources/SWBBuildServerProtocol/CustomCodable.swift new file mode 100644 index 00000000..bc5a8896 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/CustomCodable.swift @@ -0,0 +1,115 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// Property wrapper allowing per-property customization of how the value is +/// encoded/decoded when using Codable. +/// +/// CustomCodable is generic over a `CustomCoder: CustomCodableWrapper`, which +/// wraps the underlying value, and provides the specific Codable implementation. +/// Since each instance of CustomCodable provides its own CustomCoder wrapper, +/// properties of the same type can provide different Codable implementations +/// within the same container. +/// +/// Example: change the encoding of a property `foo` in the following struct to +/// do its encoding through a String instead of the normal Codable implementation. +/// +/// ``` +/// struct MyStruct: Codable { +/// @CustomCodable var foo: Int +/// } +/// +/// struct SillyIntCoding: CustomCodableWrapper { +/// init(from decoder: Decoder) throws { +/// wrappedValue = try Int(decoder.singleValueContainer().decoder(String.self))! +/// } +/// func encode(to encoder: Encoder) throws { +/// try encoder.singleValueContainer().encode("\(wrappedValue)") +/// } +/// var wrappedValue: Int { get } +/// init(wrappedValue: WrappedValue) { self.wrappedValue = wrappedValue } +/// } +/// ``` +@propertyWrapper +public struct CustomCodable { + + public typealias CustomCoder = CustomCoder + + /// The underlying value. + public var wrappedValue: CustomCoder.WrappedValue + + public init(wrappedValue: CustomCoder.WrappedValue) { + self.wrappedValue = wrappedValue + } +} + +extension CustomCodable: Sendable where CustomCoder.WrappedValue: Sendable {} + +extension CustomCodable: Codable { + public init(from decoder: any Decoder) throws { + self.wrappedValue = try CustomCoder(from: decoder).wrappedValue + } + + public func encode(to encoder: any Encoder) throws { + try CustomCoder(wrappedValue: self.wrappedValue).encode(to: encoder) + } +} + +extension CustomCodable: Equatable where CustomCoder.WrappedValue: Equatable {} +extension CustomCodable: Hashable where CustomCoder.WrappedValue: Hashable {} + +/// Wrapper type providing a Codable implementation for use with `CustomCodable`. +public protocol CustomCodableWrapper: Codable { + + /// The type of the underlying value being wrapped. + associatedtype WrappedValue + + /// The underlying value. + var wrappedValue: WrappedValue { get } + + /// Create a wrapper from an underlying value. + init(wrappedValue: WrappedValue) +} + +extension Optional: CustomCodableWrapper where Wrapped: CustomCodableWrapper { + public var wrappedValue: Wrapped.WrappedValue? { self?.wrappedValue } + public init(wrappedValue: Wrapped.WrappedValue?) { + self = wrappedValue.flatMap { Wrapped.init(wrappedValue: $0) } + } +} + +// The following extensions allow us to encode `CustomCodable>` +// using `encodeIfPresent` (and `decodeIfPresent`) in synthesized `Codable` +// conformances. Without these, we would encode `nil` using `encodeNil` instead +// of skipping the key. + +extension KeyedDecodingContainer { + public func decode( + _ type: CustomCodable>.Type, + forKey key: Key + ) throws -> CustomCodable> { + CustomCodable>(wrappedValue: try decodeIfPresent(T.self, forKey: key)?.wrappedValue) + } +} + +extension KeyedEncodingContainer { + public mutating func encode( + _ value: CustomCodable>, + forKey key: Key + ) throws { + try encodeIfPresent( + value.wrappedValue.map { + type(of: value).CustomCoder(wrappedValue: $0) + }, + forKey: key + ) + } +} diff --git a/Sources/SWBBuildServerProtocol/DidChangeWatchedFilesNotification.swift b/Sources/SWBBuildServerProtocol/DidChangeWatchedFilesNotification.swift new file mode 100644 index 00000000..5eea009e --- /dev/null +++ b/Sources/SWBBuildServerProtocol/DidChangeWatchedFilesNotification.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// Notification from the client when changes to watched files are detected. +/// +/// - Parameter changes: The set of file changes. +public struct DidChangeWatchedFilesNotification: NotificationType { + public static let method: String = "workspace/didChangeWatchedFiles" + + /// The file changes. + public var changes: [FileEvent] + + public init(changes: [FileEvent]) { + self.changes = changes + } +} diff --git a/Sources/SWBBuildServerProtocol/DocumentURI.swift b/Sources/SWBBuildServerProtocol/DocumentURI.swift new file mode 100644 index 00000000..b7b87ede --- /dev/null +++ b/Sources/SWBBuildServerProtocol/DocumentURI.swift @@ -0,0 +1,116 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +public import Foundation + +struct FailedToConstructDocumentURIFromStringError: Error, CustomStringConvertible { + let string: String + + var description: String { + return "Failed to construct DocumentURI from '\(string)'" + } +} + +public struct DocumentURI: Codable, Hashable, Sendable { + /// The URL that store the URIs value + private let storage: URL + + public var description: String { + return storage.description + } + + public var fileURL: URL? { + if storage.isFileURL { + return storage + } else { + return nil + } + } + + /// The URL representation of the URI. Note that this URL can have an arbitrary scheme and might + /// not represent a file URL. + public var arbitrarySchemeURL: URL { storage } + + /// The document's URL scheme, if present. + public var scheme: String? { + return storage.scheme + } + + /// Returns a filepath if the URI is a URL. If the URI is not a URL, returns + /// the full URI as a fallback. + /// This value is intended to be used when interacting with sourcekitd which + /// expects a file path but is able to handle arbitrary strings as well in a + /// fallback mode that drops semantic functionality. + public var pseudoPath: String { + if storage.isFileURL { + return storage.withUnsafeFileSystemRepresentation { filePathPtr in + guard let filePathPtr else { + return "" + } + let filePath = String(cString: filePathPtr) + #if os(Windows) + // VS Code spells file paths with a lowercase drive letter, while the rest of Windows APIs use an uppercase + // drive letter. Normalize the drive letter spelling to be uppercase. + if filePath.first?.isASCII ?? false, filePath.first?.isLetter ?? false, filePath.first?.isLowercase ?? false, + filePath.count > 1, filePath[filePath.index(filePath.startIndex, offsetBy: 1)] == ":" + { + return filePath.first!.uppercased() + filePath.dropFirst() + } + #endif + return filePath + } + } else { + return storage.absoluteString + } + } + + /// Returns the URI as a string. + public var stringValue: String { + return storage.absoluteString + } + + /// Construct a DocumentURI from the given URI string, automatically parsing + /// it either as a URL or an opaque URI. + public init(string: String) throws { + guard let url = URL(string: string) else { + throw FailedToConstructDocumentURIFromStringError(string: string) + } + self.init(url) + } + + public init(_ url: URL) { + self.storage = url + assert(self.storage.scheme != nil, "Received invalid URI without a scheme '\(self.storage.absoluteString)'") + } + + public init(filePath: String, isDirectory: Bool) { + self.init(URL(fileURLWithPath: filePath, isDirectory: isDirectory)) + } + + public init(from decoder: any Decoder) throws { + try self.init(string: decoder.singleValueContainer().decode(String.self)) + } + + /// Equality check to handle escape sequences in file URLs. + public static func == (lhs: DocumentURI, rhs: DocumentURI) -> Bool { + return lhs.storage.scheme == rhs.storage.scheme && lhs.pseudoPath == rhs.pseudoPath + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(self.storage.scheme) + hasher.combine(self.pseudoPath) + } + + public func encode(to encoder: any Encoder) throws { + try storage.absoluteString.encode(to: encoder) + } +} diff --git a/Sources/SWBBuildServerProtocol/Error.swift b/Sources/SWBBuildServerProtocol/Error.swift new file mode 100644 index 00000000..4ab242d6 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/Error.swift @@ -0,0 +1,210 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// A convenience wrapper for `Result` where the error is a `ResponseError`. +public typealias LSPResult = Swift.Result + +/// Error code suitable for use between language server and client. +public struct ErrorCode: RawRepresentable, Codable, Hashable, Sendable { + + public var rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + // MARK: JSON RPC + public static let parseError: ErrorCode = ErrorCode(rawValue: -32700) + public static let invalidRequest: ErrorCode = ErrorCode(rawValue: -32600) + public static let methodNotFound: ErrorCode = ErrorCode(rawValue: -32601) + public static let invalidParams: ErrorCode = ErrorCode(rawValue: -32602) + public static let internalError: ErrorCode = ErrorCode(rawValue: -32603) + + /// This is the start range of JSON-RPC reserved error codes. + /// It doesn't denote a real error code. No LSP error codes should + /// be defined between the start and end range. For backwards + /// compatibility the `ServerNotInitialized` and the `UnknownErrorCode` + /// are left in the range. + public static let jsonrpcReservedErrorRangeStart = ErrorCode(rawValue: -32099) + public static let serverErrorStart: ErrorCode = jsonrpcReservedErrorRangeStart + + /// Error code indicating that a server received a notification or + /// request before the server has received the `initialize` request. + public static let serverNotInitialized = ErrorCode(rawValue: -32002) + public static let unknownErrorCode = ErrorCode(rawValue: -32001) + + /// This is the end range of JSON-RPC reserved error codes. + /// It doesn't denote a real error code. + public static let jsonrpcReservedErrorRangeEnd = ErrorCode(rawValue: -32000) + /// Deprecated, use jsonrpcReservedErrorRangeEnd + public static let serverErrorEnd = jsonrpcReservedErrorRangeEnd + + /// This is the start range of LSP reserved error codes. + /// It doesn't denote a real error code. + public static let lspReservedErrorRangeStart = ErrorCode(rawValue: -32899) + + /// A request failed but it was syntactically correct, e.g the + /// method name was known and the parameters were valid. The error + /// message should contain human readable information about why + /// the request failed. + public static let requestFailed = ErrorCode(rawValue: -32803) + + /// The server cancelled the request. This error code should + /// only be used for requests that explicitly support being + /// server cancellable. + public static let serverCancelled = ErrorCode(rawValue: -32802) + + /// The server detected that the content of a document got + /// modified outside normal conditions. A server should + /// NOT send this error code if it detects a content change + /// in it unprocessed messages. The result even computed + /// on an older state might still be useful for the client. + /// + /// If a client decides that a result is not of any use anymore + /// the client should cancel the request. + public static let contentModified = ErrorCode(rawValue: -32801) + + /// The client has canceled a request and a server as detected + /// the cancel. + public static let cancelled: ErrorCode = ErrorCode(rawValue: -32800) + + /// This is the end range of LSP reserved error codes. + /// It doesn't denote a real error code. + public static let lspReservedErrorRangeEnd = ErrorCode(rawValue: -32800) + + // MARK: SourceKit-LSP specific error codes + public static let workspaceNotOpen: ErrorCode = ErrorCode(rawValue: -32003) +} + +/// An error response represented by a code and message. +public struct ResponseError: Error, Codable, Hashable { + public var code: ErrorCode + public var message: String + public var data: LSPAny? + + public init(code: ErrorCode, message: String, data: LSPAny? = nil) { + self.code = code + self.message = message + self.data = data + } +} + +extension ResponseError { + // MARK: Convenience properties for common errors. + + public static let cancelled: ResponseError = ResponseError(code: .cancelled, message: "request cancelled by client") + + public static let serverCancelled: ResponseError = ResponseError( + code: .serverCancelled, + message: "request cancelled by server" + ) + + public static func workspaceNotOpen(_ uri: DocumentURI) -> ResponseError { + return ResponseError(code: .workspaceNotOpen, message: "No workspace containing '\(uri)' found") + } + + public static func invalidParams(_ message: String) -> ResponseError { + return ResponseError(code: .invalidParams, message: message) + } + + public static func methodNotFound(_ method: String) -> ResponseError { + return ResponseError(code: .methodNotFound, message: "method not found: \(method)") + } + + public static func unknown(_ message: String) -> ResponseError { + return ResponseError(code: .unknownErrorCode, message: message) + } + + public static func requestFailed(_ message: String) -> ResponseError { + return ResponseError(code: .requestFailed, message: message) + } + + public static func internalError(_ message: String) -> ResponseError { + return ResponseError(code: .internalError, message: message) + } +} + +/// An error during message decoding. +public struct MessageDecodingError: Error, Hashable { + + /// The error code. + public var code: ErrorCode + + /// A free-form description of the error. + public var message: String + + /// If it was possible to recover the request id, it is stored here. This can be used e.g. to reply with a `ResponseError` to invalid requests. + public var id: RequestID? + + @frozen public enum MessageKind: Sendable { + case request + case response + case notification + case unknown + } + + /// What kind of message was being decoded, or `.unknown`. + public var messageKind: MessageKind + + public init(code: ErrorCode, message: String, id: RequestID? = nil, messageKind: MessageKind = .unknown) { + self.code = code + self.message = message + self.id = id + self.messageKind = messageKind + } +} + +extension MessageDecodingError { + public static func methodNotFound( + _ method: String, + id: RequestID? = nil, + messageKind: MessageKind = .unknown + ) -> MessageDecodingError { + return MessageDecodingError( + code: .methodNotFound, + message: "method not found: \(method)", + id: id, + messageKind: messageKind + ) + } + + public static func invalidRequest( + _ reason: String, + id: RequestID? = nil, + messageKind: MessageKind = .unknown + ) -> MessageDecodingError { + return MessageDecodingError(code: .invalidRequest, message: reason, id: id, messageKind: messageKind) + } + + public static func invalidParams( + _ reason: String, + id: RequestID? = nil, + messageKind: MessageKind = .unknown + ) -> MessageDecodingError { + return MessageDecodingError(code: .invalidParams, message: reason, id: id, messageKind: messageKind) + } + + public static func parseError( + _ reason: String, + id: RequestID? = nil, + messageKind: MessageKind = .unknown + ) -> MessageDecodingError { + return MessageDecodingError(code: .parseError, message: reason, id: id, messageKind: messageKind) + } +} + +extension ResponseError { + /// Converts a `MessageDecodingError` to a `ResponseError`. + public init(_ decodingError: MessageDecodingError) { + self.init(code: decodingError.code, message: decodingError.message) + } +} diff --git a/Sources/SWBBuildServerProtocol/FileEvent.swift b/Sources/SWBBuildServerProtocol/FileEvent.swift new file mode 100644 index 00000000..ea15a896 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/FileEvent.swift @@ -0,0 +1,39 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// An event describing a file change. +public struct FileEvent: Codable, Hashable, Sendable { + public var uri: DocumentURI + public var type: FileChangeType + + public init(uri: DocumentURI, type: FileChangeType) { + self.uri = uri + self.type = type + } +} +/// The type of file event. +/// +/// In LSP, this is an integer, so we don't use a closed set. +public struct FileChangeType: RawRepresentable, Codable, Hashable, Sendable { + public var rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + /// The file was created. + public static let created: FileChangeType = FileChangeType(rawValue: 1) + /// The file was changed. + public static let changed: FileChangeType = FileChangeType(rawValue: 2) + /// The file was deleted. + public static let deleted: FileChangeType = FileChangeType(rawValue: 3) +} diff --git a/Sources/SWBBuildServerProtocol/FileSystemWatcher.swift b/Sources/SWBBuildServerProtocol/FileSystemWatcher.swift new file mode 100644 index 00000000..2e76d25a --- /dev/null +++ b/Sources/SWBBuildServerProtocol/FileSystemWatcher.swift @@ -0,0 +1,68 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// Defines a watcher interested in specific file system change events. +public struct FileSystemWatcher: Codable, Hashable, Sendable { + /// The glob pattern to watch. + public var globPattern: String + + /// The kind of events of interest. If omitted it defaults to + /// WatchKind.create | WatchKind.change | WatchKind.delete. + public var kind: WatchKind? + + public init(globPattern: String, kind: WatchKind? = nil) { + self.globPattern = globPattern + self.kind = kind + } +} + +extension FileSystemWatcher: LSPAnyCodable { + public init?(fromLSPDictionary dictionary: [String: LSPAny]) { + guard let globPatternAny = dictionary[CodingKeys.globPattern.stringValue] else { return nil } + guard case .string(let globPattern) = globPatternAny else { return nil } + self.globPattern = globPattern + + guard let kindValue = dictionary[CodingKeys.kind.stringValue] else { + self.kind = nil + return + } + + switch kindValue { + case .null: self.kind = nil + case .int(let value): self.kind = WatchKind(rawValue: value) + default: return nil + } + } + + public func encodeToLSPAny() -> LSPAny { + var encoded = [CodingKeys.globPattern.stringValue: LSPAny.string(globPattern)] + if let kind = kind { + encoded[CodingKeys.kind.stringValue] = LSPAny.int(kind.rawValue) + } + return .dictionary(encoded) + } +} + +/// The type of file event a watcher is interested in. +/// +/// In LSP, this is an integer, so we don't use a closed set. +public struct WatchKind: OptionSet, Codable, Hashable, Sendable { + public var rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let create: WatchKind = WatchKind(rawValue: 1) + public static let change: WatchKind = WatchKind(rawValue: 2) + public static let delete: WatchKind = WatchKind(rawValue: 4) +} diff --git a/Sources/SWBBuildServerProtocol/InitializeBuildRequest.swift b/Sources/SWBBuildServerProtocol/InitializeBuildRequest.swift new file mode 100644 index 00000000..66cd797b --- /dev/null +++ b/Sources/SWBBuildServerProtocol/InitializeBuildRequest.swift @@ -0,0 +1,357 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// Like the language server protocol, the initialize request is sent +/// as the first request from the client to the server. If the server +/// receives a request or notification before the initialize request +/// it should act as follows: +/// +/// - For a request the response should be an error with code: -32002. +/// The message can be picked by the server. +/// +/// - Notifications should be dropped, except for the exit notification. +/// This will allow the exit of a server without an initialize request. +/// +/// Until the server has responded to the initialize request with an +/// InitializeBuildResult, the client must not send any additional +/// requests or notifications to the server. +public struct InitializeBuildRequest: RequestType, Hashable { + public static let method: String = "build/initialize" + public typealias Response = InitializeBuildResponse + + /// Name of the client + public var displayName: String + + /// The version of the client + public var version: String + + /// The BSP version that the client speaks= + public var bspVersion: String + + /// The rootUri of the workspace + public var rootUri: URI + + /// The capabilities of the client + public var capabilities: BuildClientCapabilities + + /// Kind of data to expect in the `data` field. If this field is not set, the kind of data is not specified. */ + public var dataKind: InitializeBuildRequestDataKind? + + /// Additional metadata about the client + public var data: LSPAny? + + public init( + displayName: String, + version: String, + bspVersion: String, + rootUri: URI, + capabilities: BuildClientCapabilities, + dataKind: InitializeBuildRequestDataKind? = nil, + data: LSPAny? = nil + ) { + self.displayName = displayName + self.version = version + self.bspVersion = bspVersion + self.rootUri = rootUri + self.capabilities = capabilities + self.dataKind = dataKind + self.data = data + } +} + +public struct BuildClientCapabilities: Codable, Hashable, Sendable { + /// The languages that this client supports. + /// The ID strings for each language is defined in the LSP. + /// The server must never respond with build targets for other + /// languages than those that appear in this list. + public var languageIds: [Language] + + /// Mirror capability to BuildServerCapabilities.jvmCompileClasspathProvider + /// The client will request classpath via `buildTarget/jvmCompileClasspath` so + /// it's safe to return classpath in ScalacOptionsItem empty. */ + public var jvmCompileClasspathReceiver: Bool? + + public init(languageIds: [Language], jvmCompileClasspathReceiver: Bool? = nil) { + self.languageIds = languageIds + self.jvmCompileClasspathReceiver = jvmCompileClasspathReceiver + } +} + +public struct InitializeBuildRequestDataKind: RawRepresentable, Hashable, Codable, Sendable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } +} + +public struct InitializeBuildResponse: ResponseType, Hashable { + /// Name of the server + public var displayName: String + + /// The version of the server + public var version: String + + /// The BSP version that the server speaks + public var bspVersion: String + + /// The capabilities of the build server + public var capabilities: BuildServerCapabilities + + /// Kind of data to expect in the `data` field. If this field is not set, the kind of data is not specified. + public var dataKind: InitializeBuildResponseDataKind? + + /// Optional metadata about the server + public var data: LSPAny? + + public init( + displayName: String, + version: String, + bspVersion: String, + capabilities: BuildServerCapabilities, + dataKind: InitializeBuildResponseDataKind? = nil, + data: LSPAny? = nil + ) { + self.displayName = displayName + self.version = version + self.bspVersion = bspVersion + self.capabilities = capabilities + self.dataKind = dataKind + self.data = data + } +} + +public struct BuildServerCapabilities: Codable, Hashable, Sendable { + /// The languages the server supports compilation via method buildTarget/compile. + public var compileProvider: CompileProvider? + + /// The languages the server supports test execution via method buildTarget/test + public var testProvider: TestProvider? + + /// The languages the server supports run via method buildTarget/run + public var runProvider: RunProvider? + + /// The languages the server supports debugging via method debugSession/start. + public var debugProvider: DebugProvider? + + /// The server can provide a list of targets that contain a + /// single text document via the method buildTarget/inverseSources + public var inverseSourcesProvider: Bool? + + /// The server provides sources for library dependencies + /// via method buildTarget/dependencySources + public var dependencySourcesProvider: Bool? + + /// The server provides all the resource dependencies + /// via method buildTarget/resources + public var resourcesProvider: Bool? + + /// The server provides all output paths + /// via method buildTarget/outputPaths + public var outputPathsProvider: Bool? + + /// The server sends notifications to the client on build + /// target change events via `buildTarget/didChange` + public var buildTargetChangedProvider: Bool? + + /// The server can respond to `buildTarget/jvmRunEnvironment` requests with the + /// necessary information required to launch a Java process to run a main class. + public var jvmRunEnvironmentProvider: Bool? + + /// The server can respond to `buildTarget/jvmTestEnvironment` requests with the + /// necessary information required to launch a Java process for testing or + /// debugging. + public var jvmTestEnvironmentProvider: Bool? + + /// The server can respond to `workspace/cargoFeaturesState` and + /// `setCargoFeatures` requests. In other words, supports Cargo Features extension. + public var cargoFeaturesProvider: Bool? + + /// Reloading the build state through workspace/reload is supported + public var canReload: Bool? + + /// The server can respond to `buildTarget/jvmCompileClasspath` requests with the + /// necessary information about the target's classpath. + public var jvmCompileClasspathProvider: Bool? + + public init( + compileProvider: CompileProvider? = nil, + testProvider: TestProvider? = nil, + runProvider: RunProvider? = nil, + debugProvider: DebugProvider? = nil, + inverseSourcesProvider: Bool? = nil, + dependencySourcesProvider: Bool? = nil, + resourcesProvider: Bool? = nil, + outputPathsProvider: Bool? = nil, + buildTargetChangedProvider: Bool? = nil, + jvmRunEnvironmentProvider: Bool? = nil, + jvmTestEnvironmentProvider: Bool? = nil, + cargoFeaturesProvider: Bool? = nil, + canReload: Bool? = nil, + jvmCompileClasspathProvider: Bool? = nil + ) { + self.compileProvider = compileProvider + self.testProvider = testProvider + self.runProvider = runProvider + self.debugProvider = debugProvider + self.inverseSourcesProvider = inverseSourcesProvider + self.dependencySourcesProvider = dependencySourcesProvider + self.resourcesProvider = resourcesProvider + self.outputPathsProvider = outputPathsProvider + self.buildTargetChangedProvider = buildTargetChangedProvider + self.jvmRunEnvironmentProvider = jvmRunEnvironmentProvider + self.jvmTestEnvironmentProvider = jvmTestEnvironmentProvider + self.cargoFeaturesProvider = cargoFeaturesProvider + self.canReload = canReload + self.jvmCompileClasspathProvider = jvmCompileClasspathProvider + } +} + +public struct CompileProvider: Codable, Hashable, Sendable { + public var languageIds: [Language] + + public init(languageIds: [Language]) { + self.languageIds = languageIds + } +} + +public struct TestProvider: Codable, Hashable, Sendable { + public var languageIds: [Language] + + public init(languageIds: [Language]) { + self.languageIds = languageIds + } +} + +public struct RunProvider: Codable, Hashable, Sendable { + public var languageIds: [Language] + + public init(languageIds: [Language]) { + self.languageIds = languageIds + } +} + +public struct DebugProvider: Codable, Hashable, Sendable { + public var languageIds: [Language] + + public init(languageIds: [Language]) { + self.languageIds = languageIds + } +} + +public struct InitializeBuildResponseDataKind: RawRepresentable, Hashable, Codable, Sendable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + /// `data` field must contain a `SourceKitInitializeBuildResponseData` object. + public static let sourceKit = InitializeBuildResponseDataKind(rawValue: "sourceKit") +} + +public struct SourceKitInitializeBuildResponseData: LSPAnyCodable, Codable, Sendable { + /// The directory to which the index store is written during compilation, ie. the path passed to `-index-store-path` + /// for `swiftc` or `clang` invocations + public var indexDatabasePath: String? + + /// The path at which SourceKit-LSP can store its index database, aggregating data from `indexStorePath` + public var indexStorePath: String? + + /// Whether the server implements the `buildTarget/outputPaths` request. + public var outputPathsProvider: Bool? + + /// Whether the build server supports the `buildTarget/prepare` request. + public var prepareProvider: Bool? + + /// Whether the server implements the `textDocument/sourceKitOptions` request. + public var sourceKitOptionsProvider: Bool? + + /// The files to watch for changes. + public var watchers: [FileSystemWatcher]? + + @available(*, deprecated, message: "Use initializer with alphabetical order of parameters") + @_disfavoredOverload + public init( + indexDatabasePath: String? = nil, + indexStorePath: String? = nil, + watchers: [FileSystemWatcher]? = nil, + prepareProvider: Bool? = nil, + sourceKitOptionsProvider: Bool? = nil + ) { + self.indexDatabasePath = indexDatabasePath + self.indexStorePath = indexStorePath + self.watchers = watchers + self.prepareProvider = prepareProvider + self.sourceKitOptionsProvider = sourceKitOptionsProvider + } + + public init( + indexDatabasePath: String? = nil, + indexStorePath: String? = nil, + outputPathsProvider: Bool? = nil, + prepareProvider: Bool? = nil, + sourceKitOptionsProvider: Bool? = nil, + watchers: [FileSystemWatcher]? = nil + ) { + self.indexDatabasePath = indexDatabasePath + self.indexStorePath = indexStorePath + self.outputPathsProvider = outputPathsProvider + self.prepareProvider = prepareProvider + self.sourceKitOptionsProvider = sourceKitOptionsProvider + self.watchers = watchers + } + + public init?(fromLSPDictionary dictionary: [String: LSPAny]) { + if case .string(let indexDatabasePath) = dictionary[CodingKeys.indexDatabasePath.stringValue] { + self.indexDatabasePath = indexDatabasePath + } + if case .string(let indexStorePath) = dictionary[CodingKeys.indexStorePath.stringValue] { + self.indexStorePath = indexStorePath + } + if case .bool(let outputPathsProvider) = dictionary[CodingKeys.outputPathsProvider.stringValue] { + self.outputPathsProvider = outputPathsProvider + } + if case .bool(let prepareProvider) = dictionary[CodingKeys.prepareProvider.stringValue] { + self.prepareProvider = prepareProvider + } + if case .bool(let sourceKitOptionsProvider) = dictionary[CodingKeys.sourceKitOptionsProvider.stringValue] { + self.sourceKitOptionsProvider = sourceKitOptionsProvider + } + if let watchers = dictionary[CodingKeys.watchers.stringValue] { + self.watchers = [FileSystemWatcher](fromLSPArray: watchers) + } + } + + public func encodeToLSPAny() -> LSPAny { + var result: [String: LSPAny] = [:] + if let indexDatabasePath { + result[CodingKeys.indexDatabasePath.stringValue] = .string(indexDatabasePath) + } + if let indexStorePath { + result[CodingKeys.indexStorePath.stringValue] = .string(indexStorePath) + } + if let outputPathsProvider { + result[CodingKeys.outputPathsProvider.stringValue] = .bool(outputPathsProvider) + } + if let prepareProvider { + result[CodingKeys.prepareProvider.stringValue] = .bool(prepareProvider) + } + if let sourceKitOptionsProvider { + result[CodingKeys.sourceKitOptionsProvider.stringValue] = .bool(sourceKitOptionsProvider) + } + if let watchers { + result[CodingKeys.watchers.stringValue] = watchers.encodeToLSPAny() + } + return .dictionary(result) + } +} diff --git a/Sources/SWBBuildServerProtocol/LSPAny.swift b/Sources/SWBBuildServerProtocol/LSPAny.swift new file mode 100644 index 00000000..6f232d29 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/LSPAny.swift @@ -0,0 +1,232 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// Representation of 'any' in the Language Server Protocol, which is equivalent +/// to an arbitrary JSON value. +public enum LSPAny: Hashable, Sendable { + case null + case int(Int) + case bool(Bool) + case double(Double) + case string(String) + case array([LSPAny]) + case dictionary([String: LSPAny]) +} + +extension LSPAny: Decodable { + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([LSPAny].self) { + self = .array(value) + } else if let value = try? container.decode([String: LSPAny].self) { + self = .dictionary(value) + } else { + let error = "LSPAny cannot be decoded: Unrecognized type." + throw DecodingError.dataCorruptedError(in: container, debugDescription: error) + } + } +} + +extension LSPAny: Encodable { + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case .int(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .double(let value): + try container.encode(value) + case .string(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .dictionary(let value): + try container.encode(value) + } + } +} + +extension LSPAny: ResponseType {} + +extension LSPAny: ExpressibleByNilLiteral { + public init(nilLiteral _: ()) { + self = .null + } +} + +extension LSPAny: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .int(value) + } +} + +extension LSPAny: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +extension LSPAny: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .double(value) + } +} + +extension LSPAny: ExpressibleByStringLiteral { + public init(extendedGraphemeClusterLiteral value: String) { + self = .string(value) + } + + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension LSPAny: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: LSPAny...) { + self = .array(elements) + } +} + +extension LSPAny: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, LSPAny)...) { + let dict = [String: LSPAny](elements, uniquingKeysWith: { first, _ in first }) + self = .dictionary(dict) + } +} + +public protocol LSPAnyCodable { + init?(fromLSPDictionary dictionary: [String: LSPAny]) + func encodeToLSPAny() -> LSPAny +} + +extension LSPAnyCodable { + public init?(fromLSPAny lspAny: LSPAny?) { + guard case .dictionary(let dictionary) = lspAny else { + return nil + } + self.init(fromLSPDictionary: dictionary) + } +} + +extension Optional: LSPAnyCodable where Wrapped: LSPAnyCodable { + public init?(fromLSPAny value: LSPAny) { + if case .null = value { + self = .none + return + } + guard case .dictionary(let dict) = value else { + return nil + } + guard let wrapped = Wrapped.init(fromLSPDictionary: dict) else { + return nil + } + self = .some(wrapped) + } + + public init?(fromLSPDictionary dictionary: [String: LSPAny]) { + return nil + } + + public func encodeToLSPAny() -> LSPAny { + guard let wrapped = self else { return .null } + return wrapped.encodeToLSPAny() + } +} + +extension Array: LSPAnyCodable where Element: LSPAnyCodable { + public init?(fromLSPArray array: LSPAny) { + guard case .array(let array) = array else { + return nil + } + + var result = [Element]() + for element in array { + switch element { + case .dictionary(let dict): + if let value = Element(fromLSPDictionary: dict) { + result.append(value) + } else { + return nil + } + case .array(let value): + if let value = value as? [Element] { + result.append(contentsOf: value) + } else { + return nil + } + case .string(let value): + if let value = value as? Element { + result.append(value) + } else { + return nil + } + case .int(let value): + if let value = value as? Element { + result.append(value) + } else { + return nil + } + case .double(let value): + if let value = value as? Element { + result.append(value) + } else { + return nil + } + case .bool(let value): + if let value = value as? Element { + result.append(value) + } else { + return nil + } + case .null: + // null is not expected for non-optional Element + return nil + } + } + self = result + } + + public init?(fromLSPDictionary dictionary: [String: LSPAny]) { + return nil + } + + public func encodeToLSPAny() -> LSPAny { + return .array(map { $0.encodeToLSPAny() }) + } +} + +extension String: LSPAnyCodable { + public init?(fromLSPDictionary dictionary: [String: LSPAny]) { + nil + } + + public func encodeToLSPAny() -> LSPAny { + .string(self) + } +} + +public typealias LSPObject = [String: LSPAny] +public typealias LSPArray = [LSPAny] diff --git a/Sources/SWBBuildServerProtocol/Language.swift b/Sources/SWBBuildServerProtocol/Language.swift new file mode 100644 index 00000000..4733ce1b --- /dev/null +++ b/Sources/SWBBuildServerProtocol/Language.swift @@ -0,0 +1,165 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// A source code language identifier, such as "swift", or "objective-c". +public struct Language: RawRepresentable, Codable, Equatable, Hashable, Sendable { + public typealias LanguageId = String + + public let rawValue: LanguageId + public init(rawValue: LanguageId) { + self.rawValue = rawValue + } + + /// Clang-compatible language name suitable for use with `-x `. + public var xflag: String? { + switch self { + case .swift: return "swift" + case .c: return "c" + case .cpp: return "c++" + case .objective_c: return "objective-c" + case .objective_cpp: return "objective-c++" + default: return nil + } + } + + /// Clang-compatible language name for a header file. See `xflag`. + public var xflagHeader: String? { + return xflag.map { "\($0)-header" } + } +} + +extension Language: CustomStringConvertible, CustomDebugStringConvertible { + public var debugDescription: String { + return rawValue + } + + public var description: String { + switch self { + case .abap: return "ABAP" + case .bat: return "Windows Bat" + case .bibtex: return "BibTeX" + case .clojure: return "Clojure" + case .coffeescript: return "Coffeescript" + case .c: return "C" + case .cpp: return "C++" + case .csharp: return "C#" + case .css: return "CSS" + case .diff: return "Diff" + case .dart: return "Dart" + case .dockerfile: return "Dockerfile" + case .fsharp: return "F#" + case .git_commit: return "Git (commit)" + case .git_rebase: return "Git (rebase)" + case .go: return "Go" + case .groovy: return "Groovy" + case .handlebars: return "Handlebars" + case .html: return "HTML" + case .ini: return "Ini" + case .java: return "Java" + case .javaScript: return "JavaScript" + case .javaScriptReact: return "JavaScript React" + case .json: return "JSON" + case .latex: return "LaTeX" + case .less: return "Less" + case .lua: return "Lua" + case .makefile: return "Makefile" + case .markdown: return "Markdown" + case .objective_c: return "Objective-C" + case .objective_cpp: return "Objective-C++" + case .perl: return "Perl" + case .perl6: return "Perl 6" + case .php: return "PHP" + case .powershell: return "Powershell" + case .jade: return "Pug" + case .python: return "Python" + case .r: return "R" + case .razor: return "Razor (cshtml)" + case .ruby: return "Ruby" + case .rust: return "Rust" + case .scss: return "SCSS (syntax using curly brackets)" + case .sass: return "SCSS (indented syntax)" + case .scala: return "Scala" + case .shaderLab: return "ShaderLab" + case .shellScript: return "Shell Script (Bash)" + case .sql: return "SQL" + case .swift: return "Swift" + case .tutorial: return "Tutorial" + case .typeScript: return "TypeScript" + case .typeScriptReact: return "TypeScript React" + case .tex: return "TeX" + case .vb: return "Visual Basic" + case .xml: return "XML" + case .xsl: return "XSL" + case .yaml: return "YAML" + default: return rawValue + } + } +} + +public extension Language { + static let abap = Language(rawValue: "abap") + static let bat = Language(rawValue: "bat") // Windows Bat + static let bibtex = Language(rawValue: "bibtex") + static let clojure = Language(rawValue: "clojure") + static let coffeescript = Language(rawValue: "coffeescript") + static let c = Language(rawValue: "c") + static let cpp = Language(rawValue: "cpp") // C++, not C preprocessor + static let csharp = Language(rawValue: "csharp") + static let css = Language(rawValue: "css") + static let diff = Language(rawValue: "diff") + static let dart = Language(rawValue: "dart") + static let dockerfile = Language(rawValue: "dockerfile") + static let fsharp = Language(rawValue: "fsharp") + static let git_commit = Language(rawValue: "git-commit") + static let git_rebase = Language(rawValue: "git-rebase") + static let go = Language(rawValue: "go") + static let groovy = Language(rawValue: "groovy") + static let handlebars = Language(rawValue: "handlebars") + static let html = Language(rawValue: "html") + static let ini = Language(rawValue: "ini") + static let java = Language(rawValue: "java") + static let javaScript = Language(rawValue: "javascript") + static let javaScriptReact = Language(rawValue: "javascriptreact") + static let json = Language(rawValue: "json") + static let latex = Language(rawValue: "latex") + static let less = Language(rawValue: "less") + static let lua = Language(rawValue: "lua") + static let makefile = Language(rawValue: "makefile") + static let markdown = Language(rawValue: "markdown") + static let objective_c = Language(rawValue: "objective-c") + static let objective_cpp = Language(rawValue: "objective-cpp") + static let perl = Language(rawValue: "perl") + static let perl6 = Language(rawValue: "perl6") + static let php = Language(rawValue: "php") + static let powershell = Language(rawValue: "powershell") + static let jade = Language(rawValue: "jade") + static let python = Language(rawValue: "python") + static let r = Language(rawValue: "r") + static let razor = Language(rawValue: "razor") // Razor (cshtml) + static let ruby = Language(rawValue: "ruby") + static let rust = Language(rawValue: "rust") + static let scss = Language(rawValue: "scss") // SCSS (syntax using curly brackets) + static let sass = Language(rawValue: "sass") // SCSS (indented syntax) + static let scala = Language(rawValue: "scala") + static let shaderLab = Language(rawValue: "shaderlab") + static let shellScript = Language(rawValue: "shellscript") // Shell Script (Bash) + static let sql = Language(rawValue: "sql") + static let swift = Language(rawValue: "swift") + static let tutorial = Language(rawValue: "tutorial") // LSP Extension: Swift DocC Tutorial + static let typeScript = Language(rawValue: "typescript") + static let typeScriptReact = Language(rawValue: "typescriptreact") // TypeScript React + static let tex = Language(rawValue: "tex") + static let vb = Language(rawValue: "vb") // Visual Basic + static let xml = Language(rawValue: "xml") + static let xsl = Language(rawValue: "xsl") + static let yaml = Language(rawValue: "yaml") +} diff --git a/Sources/SWBBuildServerProtocol/LocalConnection.swift b/Sources/SWBBuildServerProtocol/LocalConnection.swift new file mode 100644 index 00000000..5ab90313 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/LocalConnection.swift @@ -0,0 +1,114 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Dispatch +import Foundation +import SWBUtil +import Synchronization + +/// A connection between two message handlers in the same process. +/// +/// You must call `start(handler:)` before sending any messages, and must call `close()` when finished to avoid a memory leak. +/// +/// ``` +/// let client: MessageHandler = ... +/// let server: MessageHandler = ... +/// let conn = LocalConnection() +/// conn.start(handler: server) +/// conn.send(...) // handled by server +/// conn.close() +/// ``` +public final class LocalConnection: Connection, Sendable { + private enum State { + case ready, started, closed + } + + /// A name of the endpoint for this connection, used for logging, e.g. `clangd`. + private let name: String + + /// The queue guarding `_nextRequestID`. + private let queue: DispatchQueue = DispatchQueue(label: "local-connection-queue") + + private let _nextRequestID = SWBMutex(0) + + /// - Important: Must only be accessed from `queue` + nonisolated(unsafe) private var state: State = .ready + + /// - Important: Must only be accessed from `queue` + nonisolated(unsafe) private var handler: (any MessageHandler)? = nil + + public init(receiverName: String) { + self.name = receiverName + } + + public convenience init(receiverName: String, handler: any MessageHandler) { + self.init(receiverName: receiverName) + self.start(handler: handler) + } + + deinit { + queue.sync { + if state != .closed { + closeAssumingOnQueue() + } + } + } + + public func start(handler: any MessageHandler) { + queue.sync { + precondition(state == .ready) + state = .started + self.handler = handler + } + } + + /// - Important: Must only be called from `queue` + private func closeAssumingOnQueue() { + dispatchPrecondition(condition: .onQueue(queue)) + precondition(state != .closed) + handler = nil + state = .closed + } + + public func close() { + queue.sync { + closeAssumingOnQueue() + } + } + + public func nextRequestID() -> RequestID { + return .string("sk-\(_nextRequestID.fetchAndIncrement())") + } + + public func send(_ notification: Notification) { + guard let handler = queue.sync(execute: { handler }) else { + return + } + handler.handle(notification) + } + + public func send( + _ request: Request, + id: RequestID, + reply: @Sendable @escaping (LSPResult) -> Void + ) { + guard let handler = queue.sync(execute: { handler }) else { + reply(.failure(.serverCancelled)) + return + } + + precondition(self.state == .started) + handler.handle(request, id: id) { result in + reply(result) + } + } +} diff --git a/Sources/SWBBuildServerProtocol/Location.swift b/Sources/SWBBuildServerProtocol/Location.swift new file mode 100644 index 00000000..965fdb2b --- /dev/null +++ b/Sources/SWBBuildServerProtocol/Location.swift @@ -0,0 +1,40 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// Range within a particular document. +/// +/// For a location where the document is implied, use `Position` or `Range`. +public struct Location: ResponseType, Hashable, Codable, CustomDebugStringConvertible, Comparable, Sendable { + public static func < (lhs: Location, rhs: Location) -> Bool { + if lhs.uri != rhs.uri { + return lhs.uri.stringValue < rhs.uri.stringValue + } + if lhs.range.lowerBound != rhs.range.lowerBound { + return lhs.range.lowerBound < rhs.range.lowerBound + } + return lhs.range.upperBound < rhs.range.upperBound + } + + public var uri: DocumentURI + + @CustomCodable + public var range: Range + + public init(uri: DocumentURI, range: Range) { + self.uri = uri + self._range = CustomCodable(wrappedValue: range) + } + + public var debugDescription: String { + return "\(uri):\(range.lowerBound)-\(range.upperBound)" + } +} diff --git a/Sources/SWBBuildServerProtocol/LogMessageNotification.swift b/Sources/SWBBuildServerProtocol/LogMessageNotification.swift new file mode 100644 index 00000000..22fbb406 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/LogMessageNotification.swift @@ -0,0 +1,199 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// Notification from the server containing a log message. +/// +/// - Parameters: +/// - type: The kind of log message. +/// - message: The contents of the message. +public struct LogMessageNotification: NotificationType, Hashable { + public static let method: String = "window/logMessage" + + /// The kind of log message. + public var type: WindowMessageType + + /// The contents of the message. + public var message: String + + /// If specified, the client should log the message to a log with this name instead of the standard log for this LSP + /// server. + /// + /// **(LSP Extension)** + public var logName: String? + + /// If specified, allows grouping log messages that belong to the same originating task together, instead of logging + /// them in chronological order in which they were produced. + /// + /// **(LSP Extension)** guarded by the experimental `structured-logs` feature. + public var structure: StructuredLogKind? + + public init(type: WindowMessageType, message: String, logName: String? = nil, structure: StructuredLogKind? = nil) { + self.type = type + self.message = message + self.logName = logName + self.structure = structure + } +} + +public enum StructuredLogKind: Codable, Hashable, Sendable { + case begin(StructuredLogBegin) + case report(StructuredLogReport) + case end(StructuredLogEnd) + + public var taskID: String { + switch self { + case .begin(let begin): return begin.taskID + case .report(let report): return report.taskID + case .end(let end): return end.taskID + } + } + + public init(from decoder: any Decoder) throws { + if let begin = try? StructuredLogBegin(from: decoder) { + self = .begin(begin) + } else if let report = try? StructuredLogReport(from: decoder) { + self = .report(report) + } else if let end = try? StructuredLogEnd(from: decoder) { + self = .end(end) + } else { + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected StructuredLogBegin, StructuredLogReport, or StructuredLogEnd" + ) + throw DecodingError.dataCorrupted(context) + } + } + + public func encode(to encoder: any Encoder) throws { + switch self { + case .begin(let begin): + try begin.encode(to: encoder) + case .report(let report): + try report.encode(to: encoder) + case .end(let end): + try end.encode(to: encoder) + } + } +} + +/// Indicates the beginning of a new task that may receive updates with `StructuredLogReport` or `StructuredLogEnd` +/// payloads. +public struct StructuredLogBegin: Codable, Hashable, Sendable { + /// A succinct title that can be used to describe the task that started this structured. + public var title: String + + /// A unique identifier, identifying the task this structured log message belongs to. + public var taskID: String + + private enum CodingKeys: CodingKey { + case kind + case title + case taskID + } + + public init(title: String, taskID: String) { + self.title = title + self.taskID = taskID + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guard try container.decode(String.self, forKey: .kind) == "begin" else { + throw DecodingError.dataCorruptedError( + forKey: .kind, + in: container, + debugDescription: "Kind of StructuredLogBegin is not 'begin'" + ) + } + + self.title = try container.decode(String.self, forKey: .title) + self.taskID = try container.decode(String.self, forKey: .taskID) + + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("begin", forKey: .kind) + try container.encode(self.title, forKey: .title) + try container.encode(self.taskID, forKey: .taskID) + } +} + +/// Adds a new log message to a structured log without ending it. +public struct StructuredLogReport: Codable, Hashable, Sendable { + /// A unique identifier, identifying the task this structured log message belongs to. + public var taskID: String + + private enum CodingKeys: CodingKey { + case kind + case taskID + } + + public init(taskID: String) { + self.taskID = taskID + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guard try container.decode(String.self, forKey: .kind) == "report" else { + throw DecodingError.dataCorruptedError( + forKey: .kind, + in: container, + debugDescription: "Kind of StructuredLogReport is not 'report'" + ) + } + + self.taskID = try container.decode(String.self, forKey: .taskID) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("report", forKey: .kind) + try container.encode(self.taskID, forKey: .taskID) + } +} + +/// Ends a structured log. No more `StructuredLogReport` updates should be sent for this task ID. +/// +/// The task ID may be re-used for new structured logs by beginning a new structured log for that task. +public struct StructuredLogEnd: Codable, Hashable, Sendable { + /// A unique identifier, identifying the task this structured log message belongs to. + public var taskID: String + + private enum CodingKeys: CodingKey { + case kind + case taskID + } + + public init(taskID: String) { + self.taskID = taskID + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guard try container.decode(String.self, forKey: .kind) == "end" else { + throw DecodingError.dataCorruptedError( + forKey: .kind, + in: container, + debugDescription: "Kind of StructuredLogEnd is not 'end'" + ) + } + + self.taskID = try container.decode(String.self, forKey: .taskID) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("end", forKey: .kind) + try container.encode(self.taskID, forKey: .taskID) + } +} diff --git a/Sources/SWBBuildServerProtocol/Message.swift b/Sources/SWBBuildServerProtocol/Message.swift new file mode 100644 index 00000000..a5107109 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/Message.swift @@ -0,0 +1,76 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +public protocol MessageType: Codable, Sendable {} + +/// `RequestType` with no associated type or same-type requirements. Most users should prefer +/// `RequestType`. +public protocol _RequestType: MessageType { + + /// The name of the request. + static var method: String { get } + + /// *Implementation detail*. Dispatch `self` to the given handler and reply on `connection`. + /// Only needs to be declared as a protocol requirement of `_RequestType` so we can call the implementation on `RequestType` from the underscored type. + func _handle( + _ handler: any MessageHandler, + id: RequestID, + reply: @Sendable @escaping (LSPResult, RequestID) -> Void + ) +} + +/// A request, which must have a unique `method` name as well as an associated response type. +public protocol RequestType: _RequestType { + + /// The type of of the response to this request. + associatedtype Response: ResponseType +} + +/// A notification, which must have a unique `method` name. +public protocol NotificationType: MessageType { + + /// The name of the request. + static var method: String { get } +} + +/// A response. +public protocol ResponseType: MessageType {} + +extension RequestType { + public func _handle( + _ handler: any MessageHandler, + id: RequestID, + reply: @Sendable @escaping (LSPResult, RequestID) -> Void + ) { + handler.handle(self, id: id) { response in + reply(response.map({ $0 as (any ResponseType) }), id) + } + } +} + +extension NotificationType { + public func _handle(_ handler: any MessageHandler) { + handler.handle(self) + } +} + +/// A `textDocument/*` notification, which takes a text document identifier +/// indicating which document it operates in or on. +public protocol TextDocumentNotification: NotificationType { + var textDocument: TextDocumentIdentifier { get } +} + +/// A `textDocument/*` request, which takes a text document identifier +/// indicating which document it operates in or on. +public protocol TextDocumentRequest: RequestType { + var textDocument: TextDocumentIdentifier { get } +} diff --git a/Sources/SWBBuildServerProtocol/MessageRegistry.swift b/Sources/SWBBuildServerProtocol/MessageRegistry.swift new file mode 100644 index 00000000..ea0d70a4 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/MessageRegistry.swift @@ -0,0 +1,33 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +public final class MessageRegistry: Sendable { + + private let methodToRequest: [String: _RequestType.Type] + private let methodToNotification: [String: NotificationType.Type] + + public init(requests: [any _RequestType.Type], notifications: [any NotificationType.Type]) { + self.methodToRequest = Dictionary(uniqueKeysWithValues: requests.map { ($0.method, $0) }) + self.methodToNotification = Dictionary(uniqueKeysWithValues: notifications.map { ($0.method, $0) }) + } + + /// Returns the type of the message named `method`, or nil if it is unknown. + public func requestType(for method: String) -> _RequestType.Type? { + return methodToRequest[method] + } + + /// Returns the type of the message named `method`, or nil if it is unknown. + public func notificationType(for method: String) -> NotificationType.Type? { + return methodToNotification[method] + } + +} diff --git a/Sources/SWBBuildServerProtocol/MessageType.swift b/Sources/SWBBuildServerProtocol/MessageType.swift new file mode 100644 index 00000000..c32d486a --- /dev/null +++ b/Sources/SWBBuildServerProtocol/MessageType.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +public enum BuildServerMessageType: Int, Sendable, Codable { + /// An error message. + case error = 1 + + /// A warning message. + case warning = 2 + + /// An information message. + case info = 3 + + /// A log message. + case log = 4 +} diff --git a/Sources/SWBBuildServerProtocol/Messages.swift b/Sources/SWBBuildServerProtocol/Messages.swift new file mode 100644 index 00000000..b6644a6f --- /dev/null +++ b/Sources/SWBBuildServerProtocol/Messages.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +fileprivate let requestTypes: [any _RequestType.Type] = [ + BuildShutdownRequest.self, + BuildTargetPrepareRequest.self, + BuildTargetSourcesRequest.self, + CreateWorkDoneProgressRequest.self, + InitializeBuildRequest.self, + TextDocumentSourceKitOptionsRequest.self, + WorkspaceBuildTargetsRequest.self, + WorkspaceWaitForBuildSystemUpdatesRequest.self, +] + +fileprivate let notificationTypes: [any NotificationType.Type] = [ + CancelRequestNotification.self, + OnBuildExitNotification.self, + OnBuildInitializedNotification.self, + OnBuildLogMessageNotification.self, + OnBuildTargetDidChangeNotification.self, + OnWatchedFilesDidChangeNotification.self, + TaskFinishNotification.self, + TaskProgressNotification.self, + TaskStartNotification.self, +] + +public let bspRegistry = MessageRegistry(requests: requestTypes, notifications: notificationTypes) + +public struct VoidResponse: ResponseType, Hashable { + public init() {} +} + +extension Optional: MessageType where Wrapped: MessageType {} +extension Optional: ResponseType where Wrapped: ResponseType {} + +extension Array: MessageType where Element: MessageType {} +extension Array: ResponseType where Element: ResponseType {} diff --git a/Sources/SWBBuildServerProtocol/MillisecondsSince1970Date.swift b/Sources/SWBBuildServerProtocol/MillisecondsSince1970Date.swift new file mode 100644 index 00000000..6737e4f5 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/MillisecondsSince1970Date.swift @@ -0,0 +1,32 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +public import Foundation + +public struct MillisecondsSince1970Date: CustomCodableWrapper { + public var wrappedValue: Date + + public init(wrappedValue: Date) { + self.wrappedValue = wrappedValue + } + + public init(from decoder: any Decoder) throws { + let millisecondsSince1970 = try decoder.singleValueContainer().decode(Int64.self) + self.wrappedValue = Date(timeIntervalSince1970: Double(millisecondsSince1970) / 1_000) + } + + public func encode(to encoder: any Encoder) throws { + let millisecondsSince1970 = Int64((wrappedValue.timeIntervalSince1970 * 1_000).rounded()) + var container = encoder.singleValueContainer() + try container.encode(millisecondsSince1970) + } +} diff --git a/Sources/SWBBuildServerProtocol/OnBuildExitNotification.swift b/Sources/SWBBuildServerProtocol/OnBuildExitNotification.swift new file mode 100644 index 00000000..8cd44556 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/OnBuildExitNotification.swift @@ -0,0 +1,21 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// Like the language server protocol, a notification to ask the +/// server to exit its process. The server should exit with success +/// code 0 if the shutdown request has been received before; +/// otherwise with error code 1. +public struct OnBuildExitNotification: NotificationType { + public static let method: String = "build/exit" + + public init() {} +} diff --git a/Sources/SWBBuildServerProtocol/OnBuildInitializedNotification.swift b/Sources/SWBBuildServerProtocol/OnBuildInitializedNotification.swift new file mode 100644 index 00000000..538af9cf --- /dev/null +++ b/Sources/SWBBuildServerProtocol/OnBuildInitializedNotification.swift @@ -0,0 +1,18 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// Like the language server protocol, the initialized notification is sent from the client to the server after the client received the result of the initialize request but before the client is sending any other request or notification to the server. The server can use the initialized notification for example to initialize intensive computation such as dependency resolution or compilation. The initialized notification may only be sent once. +public struct OnBuildInitializedNotification: NotificationType { + public static let method: String = "build/initialized" + + public init() {} +} diff --git a/Sources/SWBBuildServerProtocol/OnBuildLogMessageNotification.swift b/Sources/SWBBuildServerProtocol/OnBuildLogMessageNotification.swift new file mode 100644 index 00000000..3b6bef3f --- /dev/null +++ b/Sources/SWBBuildServerProtocol/OnBuildLogMessageNotification.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// The log message notification is sent from a server to a client to ask the client to log a particular message in its console. +/// +/// A `build/logMessage`` notification is similar to LSP's `window/logMessage``. +public struct OnBuildLogMessageNotification: NotificationType { + public static let method: String = "build/logMessage" + + /// The message type. + public var type: BuildServerMessageType + + /// The actual message. + public var message: String + + /// If specified, allows grouping log messages that belong to the same originating task together instead of logging + /// them in chronological order in which they were produced. + public var structure: StructuredLogKind? + + public init(type: BuildServerMessageType, message: String, structure: StructuredLogKind?) { + self.type = type + self.message = message + self.structure = structure + } +} diff --git a/Sources/SWBBuildServerProtocol/OnBuildTargetDidChangeNotification.swift b/Sources/SWBBuildServerProtocol/OnBuildTargetDidChangeNotification.swift new file mode 100644 index 00000000..1f275021 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/OnBuildTargetDidChangeNotification.swift @@ -0,0 +1,71 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// The build target changed notification is sent from the server to the client +/// to signal a change in a build target. The server communicates during the +/// initialize handshake whether this method is supported or not. +public struct OnBuildTargetDidChangeNotification: NotificationType, Equatable { + public static let method: String = "buildTarget/didChange" + + /// **(BSP Extension)** + /// `changes` can be `nil` to indicate that all targets might have changed. + public var changes: [BuildTargetEvent]? + + public init(changes: [BuildTargetEvent]?) { + self.changes = changes + } +} + +public struct BuildTargetEvent: Codable, Hashable, Sendable { + /// The identifier for the changed build target. + public var target: BuildTargetIdentifier + + /// The kind of change for this build target. + public var kind: BuildTargetEventKind? + + /// Kind of data to expect in the `data` field. If this field is not set, the kind of data is not specified. + public var dataKind: BuildTargetEventDataKind? + + /// Any additional metadata about what information changed. + public var data: LSPAny? + + public init( + target: BuildTargetIdentifier, + kind: BuildTargetEventKind?, + dataKind: BuildTargetEventDataKind?, + data: LSPAny? + ) { + self.target = target + self.kind = kind + self.dataKind = dataKind + self.data = data + } +} + +public enum BuildTargetEventKind: Int, Codable, Hashable, Sendable { + /// The build target is new. + case created = 1 + + /// The build target has changed. + case changed = 2 + + /// The build target has been deleted. + case deleted = 3 +} + +public struct BuildTargetEventDataKind: RawRepresentable, Codable, Hashable, Sendable { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } +} diff --git a/Sources/SWBBuildServerProtocol/OnWatchedFilesDidChangeNotification.swift b/Sources/SWBBuildServerProtocol/OnWatchedFilesDidChangeNotification.swift new file mode 100644 index 00000000..f08586c3 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/OnWatchedFilesDidChangeNotification.swift @@ -0,0 +1,14 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// Notification sent from SourceKit-LSP to the build server to indicate that files within the project have been modified. +public typealias OnWatchedFilesDidChangeNotification = DidChangeWatchedFilesNotification diff --git a/Sources/SWBBuildServerProtocol/Position.swift b/Sources/SWBBuildServerProtocol/Position.swift new file mode 100644 index 00000000..bfa02a3e --- /dev/null +++ b/Sources/SWBBuildServerProtocol/Position.swift @@ -0,0 +1,68 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// Position within a text document, expressed as a zero-based line and column (utf-16 code unit offset). +public struct Position: Hashable, Codable, Sendable { + + /// Line number within a document (zero-based). + public var line: Int + + /// UTF-16 code-unit offset from the start of a line (zero-based). + public var utf16index: Int + + public init(line: Int, utf16index: Int) { + self.line = line + self.utf16index = utf16index + } +} + +extension Position { + private enum CodingKeys: String, CodingKey { + case line + case utf16index = "character" + } +} + +extension Position: Comparable { + public static func < (lhs: Position, rhs: Position) -> Bool { + return (lhs.line, lhs.utf16index) < (rhs.line, rhs.utf16index) + } +} + +extension Position: LSPAnyCodable { + public init?(fromLSPDictionary dictionary: [String: LSPAny]) { + guard case .int(let line) = dictionary[CodingKeys.line.stringValue], + case .int(let utf16index) = dictionary[CodingKeys.utf16index.stringValue] + else { + return nil + } + self.line = line + self.utf16index = utf16index + } + + public func encodeToLSPAny() -> LSPAny { + return .dictionary([ + CodingKeys.line.stringValue: .int(line), + CodingKeys.utf16index.stringValue: .int(utf16index), + ]) + } +} + +extension Position: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { + "\(line + 1):\(utf16index+1)" + } + + public var debugDescription: String { + "Position(line: \(line), utf16index: \(utf16index))" + } +} diff --git a/Sources/SWBBuildServerProtocol/PositionRange.swift b/Sources/SWBBuildServerProtocol/PositionRange.swift new file mode 100644 index 00000000..e7c66858 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/PositionRange.swift @@ -0,0 +1,94 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +extension Range where Bound == Position { + + /// Create a range for a single position. + public init(_ pos: Position) { + self = pos..`, for use with `CustomCodable`. +public struct PositionRange: CustomCodableWrapper { + public var wrappedValue: Range + + public init(wrappedValue: Range) { + self.wrappedValue = wrappedValue + } + + fileprivate enum CodingKeys: String, CodingKey { + case lowerBound = "start" + case upperBound = "end" + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let lhs = try container.decode(Position.self, forKey: .lowerBound) + let rhs = try container.decode(Position.self, forKey: .upperBound) + self.wrappedValue = lhs..>`, for use with `CustomCodable`. +public struct PositionRangeArray: CustomCodableWrapper { + public var wrappedValue: [Range] + + public init(wrappedValue: [Range]) { + self.wrappedValue = wrappedValue + } + + public init(from decoder: any Decoder) throws { + var values: [Range] = [] + var arrayContainer = try decoder.unkeyedContainer() + values.reserveCapacity(arrayContainer.count ?? 1) + + while !arrayContainer.isAtEnd { + let range = try arrayContainer.decode(PositionRange.self) + values.append(range.wrappedValue) + } + self.wrappedValue = values + } + + public func encode(to encoder: any Encoder) throws { + var arrayContainer = encoder.unkeyedContainer() + for rangeValue in wrappedValue { + try arrayContainer.encode(PositionRange(wrappedValue: rangeValue)) + } + } +} + +extension Range: LSPAnyCodable where Bound == Position { + public init?(fromLSPDictionary dictionary: [String: LSPAny]) { + guard case .dictionary(let start)? = dictionary[PositionRange.CodingKeys.lowerBound.stringValue], + let startPosition = Position(fromLSPDictionary: start), + case .dictionary(let end)? = dictionary[PositionRange.CodingKeys.upperBound.stringValue], + let endPosition = Position(fromLSPDictionary: end) + else { + return nil + } + self = startPosition.. LSPAny { + return .dictionary([ + PositionRange.CodingKeys.lowerBound.stringValue: lowerBound.encodeToLSPAny(), + PositionRange.CodingKeys.upperBound.stringValue: upperBound.encodeToLSPAny(), + ]) + } +} diff --git a/Sources/SWBBuildServerProtocol/ProgressToken.swift b/Sources/SWBBuildServerProtocol/ProgressToken.swift new file mode 100644 index 00000000..d7e186e3 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/ProgressToken.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +public enum ProgressToken: Codable, Hashable, Sendable { + case integer(Int) + case string(String) + + public init(from decoder: any Decoder) throws { + if let integer = try? Int(from: decoder) { + self = .integer(integer) + } else if let string = try? String(from: decoder) { + self = .string(string) + } else { + let context = DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Expected Int or String") + throw DecodingError.dataCorrupted(context) + } + } + + public func encode(to encoder: any Encoder) throws { + switch self { + case .integer(let integer): + try integer.encode(to: encoder) + case .string(let string): + try string.encode(to: encoder) + } + } +} diff --git a/Sources/SWBBuildServerProtocol/QueueBasedMessageHandler.swift b/Sources/SWBBuildServerProtocol/QueueBasedMessageHandler.swift new file mode 100644 index 00000000..db48c2c9 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/QueueBasedMessageHandler.swift @@ -0,0 +1,183 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation +import SWBUtil +import Synchronization + +/// Side structure in which `QueueBasedMessageHandler` can keep track of active requests etc. +/// +/// All of these could be requirements on `QueueBasedMessageHandler` but having them in a separate type means that +/// types conforming to `QueueBasedMessageHandler` only have to have a single member and it also ensures that these +/// fields are not accessible outside of the implementation of `QueueBasedMessageHandler`. +public actor QueueBasedMessageHandlerHelper { + /// The category in which signposts for message handling should be logged. + fileprivate let signpostLoggingCategory: String + + /// Whether a new logging scope should be created when handling a notification / request. + private let createLoggingScope: Bool + + /// The queue on which we start and stop keeping track of cancellation. + /// + /// Having a queue for this ensures that we started keeping track of a + /// request's task before handling any cancellation request for it. + private let cancellationMessageHandlingQueue = AsyncQueue() + + /// Notifications don't have an ID. This represents the next ID we can use to identify a notification. + private let notificationIDForLogging = SWBMutex(1) + + /// The requests that we are currently handling. + /// + /// Used to cancel the tasks if the client requests cancellation. + private var inProgressRequestsByID: [RequestID: Task<(), Never>] = [:] + + /// Up to 10 request IDs that have recently finished. + /// + /// This is only used so we don't log an error when receiving a `CancelRequestNotification` for a request that has + /// just returned a response. + private var recentlyFinishedRequests: [RequestID] = [] + + public init(signpostLoggingCategory: String, createLoggingScope: Bool) { + self.signpostLoggingCategory = signpostLoggingCategory + self.createLoggingScope = createLoggingScope + } + + /// Cancel the request with the given ID. + /// + /// Cancellation is performed automatically when a `$/cancelRequest` notification is received. This can be called to + /// implicitly cancel requests based on some criteria. + public nonisolated func cancelRequest(id: RequestID) { + // Since the request is very cheap to execute and stops other requests + // from performing more work, we execute it with a high priority. + cancellationMessageHandlingQueue.async(priority: .high) { + if let task = await self.inProgressRequestsByID[id] { + task.cancel() + return + } + } + } + + fileprivate nonisolated func setInProgressRequest(id: RequestID, request: some RequestType, task: Task<(), Never>?) { + self.cancellationMessageHandlingQueue.async(priority: .background) { + await self.setInProgressRequestImpl(id: id, request: request, task: task) + } + } + + private func setInProgressRequestImpl(id: RequestID, request: some RequestType, task: Task<(), Never>?) { + self.inProgressRequestsByID[id] = task + if task == nil { + self.recentlyFinishedRequests.append(id) + while self.recentlyFinishedRequests.count > 10 { + self.recentlyFinishedRequests.removeFirst() + } + } + } +} + +public protocol QueueBasedMessageHandlerDependencyTracker: DependencyTracker { + init(_ notification: some NotificationType) + init(_ request: some RequestType) +} + +/// A `MessageHandler` that handles all messages on an `AsyncQueue` and tracks dependencies between requests using +/// `DependencyTracker`, ensuring that requests which depend on each other are not executed out-of-order. +public protocol QueueBasedMessageHandler: MessageHandler { + associatedtype DependencyTracker: QueueBasedMessageHandlerDependencyTracker + + /// The queue on which all messages (notifications, requests, responses) are + /// handled. + /// + /// The queue is blocked until the message has been sufficiently handled to + /// avoid out-of-order handling of messages. For sourcekitd, this means that + /// a request has been sent to sourcekitd and for clangd, this means that we + /// have forwarded the request to clangd. + /// + /// The actual semantic handling of the message happens off this queue. + var messageHandlingQueue: AsyncQueue { get } + + var messageHandlingHelper: QueueBasedMessageHandlerHelper { get } + + /// Called when a notification has been received but before it is being handled in `messageHandlingQueue`. + /// + /// Adopters can use this to implicitly cancel requests when a notification is received. + func didReceive(notification: some NotificationType) + + /// Called when a request has been received but before it is being handled in `messageHandlingQueue`. + /// + /// Adopters can use this to implicitly cancel requests when a notification is received. + func didReceive(request: some RequestType, id: RequestID) + + /// Perform the actual handling of `notification`. + func handle(notification: some NotificationType) async + + /// Perform the actual handling of `request`. + func handle( + request: Request, + id: RequestID, + reply: @Sendable @escaping (LSPResult) -> Void + ) async +} + +extension QueueBasedMessageHandler { + public func didReceive(notification: some NotificationType) {} + public func didReceive(request: some RequestType, id: RequestID) {} + + public func handle(_ notification: some NotificationType) { + // Request cancellation needs to be able to overtake any other message we + // are currently handling. Ordering is not important here. We thus don't + // need to execute it on `messageHandlingQueue`. + if let notification = notification as? CancelRequestNotification { + self.messageHandlingHelper.cancelRequest(id: notification.id) + return + } + self.didReceive(notification: notification) + + messageHandlingQueue.async(metadata: DependencyTracker(notification)) { + await self.handle(notification: notification) + } + } + + public func handle( + _ request: Request, + id: RequestID, + reply: @Sendable @escaping (LSPResult) -> Void + ) { + self.didReceive(request: request, id: id) + + let task = messageHandlingQueue.async(metadata: DependencyTracker(request)) { + await withTaskCancellationHandler { + await self.handle(request: request, id: id, reply: reply) + } onCancel: { + } + // We have handled the request and can't cancel it anymore. + // Stop keeping track of it to free the memory. + self.messageHandlingHelper.setInProgressRequest(id: id, request: request, task: nil) + } + // Keep track of the ID -> Task management with low priority. Once we cancel + // a request, the cancellation task runs with a high priority and depends on + // this task, which will elevate this task's priority. + self.messageHandlingHelper.setInProgressRequest(id: id, request: request, task: task) + } +} + +fileprivate extension RequestID { + /// Returns a numeric value for this request ID. + /// + /// For request IDs that are numbers, this is straightforward. For string-based request IDs, this uses a hash to + /// convert the string into a number. + var numericValue: Int { + switch self { + case .number(let number): return number + case .string(let string): return Int(string) ?? abs(string.hashValue) + } + } +} diff --git a/Sources/SWBBuildServerProtocol/RequestAndReply.swift b/Sources/SWBBuildServerProtocol/RequestAndReply.swift new file mode 100644 index 00000000..ec7976d5 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/RequestAndReply.swift @@ -0,0 +1,43 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SWBUtil +import Synchronization + +/// A request and a callback that returns the request's reply +public final class RequestAndReply: Sendable { + public let params: Params + private let replyBlock: @Sendable (LSPResult) -> Void + + /// Whether a reply has been made. Every request must reply exactly once. + private let replied = SWBMutex(false) + + public init(_ request: Params, reply: @escaping @Sendable (LSPResult) -> Void) { + self.params = request + self.replyBlock = reply + } + + deinit { + precondition(replied.withLock { $0 }, "request never received a reply") + } + + /// Call the `replyBlock` with the result produced by the given closure. + public func reply(_ body: @Sendable () async throws -> Params.Response) async { + precondition(!replied.withLock { $0 }, "replied to request more than once") + replied.withLock { $0 = true } + do { + replyBlock(.success(try await body())) + } catch { + replyBlock(.failure(ResponseError(error))) + } + } +} diff --git a/Sources/SWBBuildServerProtocol/RequestID.swift b/Sources/SWBBuildServerProtocol/RequestID.swift new file mode 100644 index 00000000..79695a02 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/RequestID.swift @@ -0,0 +1,48 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +public enum RequestID: Hashable, Sendable { + case string(String) + case number(Int) +} + +extension RequestID: Codable { + public init(from decoder: any Decoder) throws { + let value = try decoder.singleValueContainer() + if let intValue = try? value.decode(Int.self) { + self = .number(intValue) + } else if let strValue = try? value.decode(String.self) { + self = .string(strValue) + } else { + throw MessageDecodingError.invalidRequest("could not decode request id") + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + } + } +} + +extension RequestID: CustomStringConvertible { + public var description: String { + switch self { + case .number(let n): return String(n) + case .string(let s): return "\"\(s)\"" + } + } +} diff --git a/Sources/SWBBuildServerProtocol/ResponseError+Init.swift b/Sources/SWBBuildServerProtocol/ResponseError+Init.swift new file mode 100644 index 00000000..f6baa24a --- /dev/null +++ b/Sources/SWBBuildServerProtocol/ResponseError+Init.swift @@ -0,0 +1,24 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +extension ResponseError { + package init(_ error: some Error) { + switch error { + case let error as ResponseError: + self = error + case is CancellationError: + self = .cancelled + default: + self = .unknown("Unknown error: \(error)") + } + } +} diff --git a/Sources/SWBBuildServerProtocol/StatusCode.swift b/Sources/SWBBuildServerProtocol/StatusCode.swift new file mode 100644 index 00000000..05da79a4 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/StatusCode.swift @@ -0,0 +1,22 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +public enum StatusCode: Int, Codable, Hashable, Sendable { + /// Execution was successful. + case ok = 1 + + /// Execution failed. + case error = 2 + + /// Execution was cancelled. + case cancelled = 3 +} diff --git a/Sources/SWBBuildServerProtocol/TaskFinishNotification.swift b/Sources/SWBBuildServerProtocol/TaskFinishNotification.swift new file mode 100644 index 00000000..b4a318ee --- /dev/null +++ b/Sources/SWBBuildServerProtocol/TaskFinishNotification.swift @@ -0,0 +1,239 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +public import Foundation + +/// A `build/taskFinish` notification must always be sent after a `build/taskStart`` with the same `taskId` was sent. +public struct TaskFinishNotification: NotificationType { + public static let method: String = "build/taskFinish" + + /// Unique id of the task with optional reference to parent task id. + public var taskId: TaskId + + /// A unique identifier generated by the client to identify this request. + public var originId: String? + + /// Timestamp of when the event started in milliseconds since Epoch. + @CustomCodable + public var eventTime: Date? + + /// Message describing the task. + public var message: String? + + /// Task completion status. + public var status: StatusCode + + /// Kind of data to expect in the `data` field. If this field is not set, the kind of data is not specified. + public var dataKind: TaskFinishDataKind? + + /// Optional metadata about the task. + /// + /// Objects for specific tasks like compile, test, etc are specified in the protocol. + public var data: LSPAny? + + public init( + taskId: TaskId, + originId: String? = nil, + eventTime: Date? = nil, + message: String? = nil, + status: StatusCode, + dataKind: TaskFinishDataKind? = nil, + data: LSPAny? = nil + ) { + self.taskId = taskId + self.originId = originId + self.eventTime = eventTime + self.message = message + self.status = status + self.dataKind = dataKind + self.data = data + } + +} + +/// Task finish notifications may contain an arbitrary interface in their `data` field. +/// +/// The kind of interface that is contained in a notification must be specified in the `dataKind` field. +public struct TaskFinishDataKind: RawRepresentable, Codable, Hashable, Sendable { + public let rawValue: String + public init(rawValue: String) { + self.rawValue = rawValue + } + + /// `data` field must contain a `CompileReport` object. + public static let compileReport = TaskStartDataKind(rawValue: "compile-report") + + /// `data` field must contain a `TestFinish` object. + public static let testFinish = TaskStartDataKind(rawValue: "test-finish") + + /// `data` field must contain a `TestReport` object. + public static let testReport = TaskStartDataKind(rawValue: "test-report") +} + +/// This structure is embedded in the `TaskFinishNotification.data` field, when the `dataKind` field contains +/// `"compile-report"`. +/// +/// The completion of a compilation task should be signalled with a `build/taskFinish` notification. When the +/// compilation unit is a build target, the notification's `dataKind` field must be `compile-report` and the `data` +/// field must include a `CompileReportData` object. +public struct CompileReportData: Codable, Hashable, Sendable { + /// The build target that was compiled. + public var target: BuildTargetIdentifier + + /// An optional request id to know the origin of this report. + /// + /// Deprecated: Use the field in TaskFinishParams instead . + public var originId: String? + + /// The total number of reported errors compiling this target. + public var errors: Int + + /// The total number of reported warnings compiling the target. + public var warnings: Int + + /// The total number of milliseconds it took to compile the target. + @CustomCodable + public var time: Date? + + /// The compilation was a noOp compilation. + public var noOp: Bool? + + public init( + target: BuildTargetIdentifier, + originId: String? = nil, + errors: Int, + warnings: Int, + time: Date? = nil, + noOp: Bool? = nil + ) { + self.target = target + self.originId = originId + self.errors = errors + self.warnings = warnings + self.time = time + self.noOp = noOp + } +} + +/// This structure is embedded in the `TaskFinishNotification.data` field, when the `dataKind` field contains +/// `"test-finish"`. +public struct TestFinishData: Codable, Hashable, Sendable { + /// Name or description of the test. + public var displayName: String? + + /// Information about completion of the test, for example an error message. + public var message: String? + + /// Completion status of the test. + public var status: TestStatus + + /// Source location of the test, as LSP location. + public var location: Location? + + /// Kind of data to expect in the `data` field. If this field is not set, the kind of data is not specified. + public var dataKind: TestFinishDataKind? + + /// Optionally, structured metadata about the test completion. + /// For example: stack traces, expected/actual values. + public var data: LSPAny? + + public init( + displayName: String? = nil, + message: String? = nil, + status: TestStatus, + location: Location? = nil, + dataKind: TestFinishDataKind? = nil, + data: LSPAny? = nil + ) { + self.displayName = displayName + self.message = message + self.status = status + self.location = location + self.dataKind = dataKind + self.data = data + } + +} + +public enum TestStatus: Int, Codable, Hashable, Sendable { + /// The test passed successfully. + case passed = 1 + + /// The test failed. + case failed = 2 + + /// The test was marked as ignored. + case ignored = 3 + + /// The test execution was cancelled. + case cancelled = 4 + + /// The was not included in execution. + case skipped = 5 +} + +public struct TestFinishDataKind: RawRepresentable, Codable, Hashable, Sendable { + public var rawValue: String + + public init?(rawValue: String) { + self.rawValue = rawValue + } +} + +/// This structure is embedded in the `TaskFinishNotification.data` field, when the `dataKind` field contains +/// `"test-report"`. +public struct TestReportData: Codable, Hashable, Sendable { + /// Deprecated: Use the field in TaskFinishParams instead + public var originId: String? + + /// The build target that was compiled. + public var target: BuildTargetIdentifier + + /// The total number of successful tests. + public var passed: Int + + /// The total number of failed tests. + public var failed: Int + + /// The total number of ignored tests. + public var ignored: Int + + /// The total number of cancelled tests. + public var cancelled: Int + + /// The total number of skipped tests. + public var skipped: Int + + /// The total number of milliseconds tests take to run (e.g. doesn't include compile times). + public var time: Int64? + + public init( + originId: String? = nil, + target: BuildTargetIdentifier, + passed: Int, + failed: Int, + ignored: Int, + cancelled: Int, + skipped: Int, + time: Int64? = nil + ) { + self.originId = originId + self.target = target + self.passed = passed + self.failed = failed + self.ignored = ignored + self.cancelled = cancelled + self.skipped = skipped + self.time = time + } + +} diff --git a/Sources/SWBBuildServerProtocol/TaskId.swift b/Sources/SWBBuildServerProtocol/TaskId.swift new file mode 100644 index 00000000..b4e92da7 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/TaskId.swift @@ -0,0 +1,32 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +public typealias TaskIdentifier = String + +public struct TaskId: Sendable, Codable, Hashable { + /// A unique identifier + public var id: TaskIdentifier + + /// The parent task ids, if any. A non-empty parents field means + /// this task is a sub-task of every parent task id. The child-parent + /// relationship of tasks makes it possible to render tasks in + /// a tree-like user interface or inspect what caused a certain task + /// execution. + /// OriginId should not be included in the parents field, there is a separate + /// field for that. + public var parents: [TaskIdentifier]? + + public init(id: TaskIdentifier, parents: [TaskIdentifier]? = nil) { + self.id = id + self.parents = parents + } +} diff --git a/Sources/SWBBuildServerProtocol/TaskProgressNotification.swift b/Sources/SWBBuildServerProtocol/TaskProgressNotification.swift new file mode 100644 index 00000000..9363b2c1 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/TaskProgressNotification.swift @@ -0,0 +1,81 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +public import Foundation + +/// After a `taskStart` and before `taskFinish` for a `taskId`, the server may send any number of progress +/// notifications. +public struct TaskProgressNotification: NotificationType { + public static let method: String = "build/taskProgress" + + /// Unique id of the task with optional reference to parent task id + public var taskId: TaskId + + /// A unique identifier generated by the client to identify this request. + public var originId: String? + + /// Timestamp of when the event started in milliseconds since Epoch. + @CustomCodable + public var eventTime: Date? + + /// Message describing the task. + public var message: String? + + /// If known, total amount of work units in this task. + public var total: Int? + + /// If known, completed amount of work units in this task. + public var progress: Int? + + /// Name of a work unit. For example, "files" or "tests". May be empty. + public var unit: String? + + /// Kind of data to expect in the `data` field. If this field is not set, the kind of data is not specified. + public var dataKind: TaskProgressDataKind? + + /// Optional metadata about the task. + /// + /// Objects for specific tasks like compile, test, etc are specified in the protocol. + public var data: LSPAny? + + public init( + taskId: TaskId, + originId: String? = nil, + eventTime: Date? = nil, + message: String? = nil, + total: Int? = nil, + progress: Int? = nil, + unit: String? = nil, + dataKind: TaskProgressDataKind? = nil, + data: LSPAny? = nil + ) { + self.taskId = taskId + self.originId = originId + self.eventTime = eventTime + self.message = message + self.total = total + self.progress = progress + self.unit = unit + self.dataKind = dataKind + self.data = data + } +} + +/// Task progress notifications may contain an arbitrary interface in their `data` field. +/// +/// The kind of interface that is contained in a notification must be specified in the `dataKind` field. +public struct TaskProgressDataKind: RawRepresentable, Codable, Hashable, Sendable { + public let rawValue: String + public init(rawValue: String) { + self.rawValue = rawValue + } +} diff --git a/Sources/SWBBuildServerProtocol/TaskStartNotification.swift b/Sources/SWBBuildServerProtocol/TaskStartNotification.swift new file mode 100644 index 00000000..8eea2d06 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/TaskStartNotification.swift @@ -0,0 +1,157 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +public import Foundation + +/// The BSP server can inform the client on the execution state of any task in the build tool. +/// The execution of some tasks, such as compilation or tests, must always be reported by the server. +/// +/// The server may also send additional task notifications for actions not covered by the protocol, such as resolution +/// or packaging. BSP clients can then display this information to their users at their discretion. +/// +/// When beginning a task, the server may send `build/taskStart`, intermediate updates may be sent in +/// `build/taskProgress`. +/// +/// If a `build/taskStart` notification has been sent, the server must send build/taskFinish on completion of the same +/// task. +/// +/// `build/taskStart`, `build/taskProgress` and `build/taskFinish` notifications for the same task must use the same +/// `taskId`. +/// +/// Tasks that are spawned by another task should reference the originating task's `taskId` in their own `taskId`'s +/// `parent` field. Tasks spawned directly by a request should reference the request's `originId` parent. +public struct TaskStartNotification: NotificationType { + public static let method: String = "build/taskStart" + + /// Unique id of the task with optional reference to parent task id + public var taskId: TaskId + + /// A unique identifier generated by the client to identify this request. + public var originId: String? + + /// Timestamp of when the event started in milliseconds since Epoch. + @CustomCodable + public var eventTime: Date? + + /// Message describing the task. + public var message: String? + + /** Kind of data to expect in the `data` field. If this field is not set, the kind of data is not specified. */ + public var dataKind: TaskStartDataKind? + + /// Optional metadata about the task. + // Objects for specific tasks like compile, test, etc are specified in the protocol. + public var data: LSPAny? + + public init( + taskId: TaskId, + originId: String? = nil, + eventTime: Date? = nil, + message: String? = nil, + dataKind: TaskStartDataKind? = nil, + data: LSPAny? = nil + ) { + self.taskId = taskId + self.originId = originId + self.eventTime = eventTime + self.message = message + self.dataKind = dataKind + self.data = data + } +} + +/// Task start notifications may contain an arbitrary interface in their `data` field. +/// +/// The kind of interface that is contained in a notification must be specified in the `dataKind` field. +public struct TaskStartDataKind: RawRepresentable, Codable, Hashable, Sendable { + public let rawValue: String + public init(rawValue: String) { + self.rawValue = rawValue + } + + /// `data` field must contain a `CompileTaskData` object. + public static let compileTask = TaskStartDataKind(rawValue: "compile-task") + + /// `data` field must contain a `TestStart` object. + public static let testStart = TaskStartDataKind(rawValue: "test-start") + + /// `data` field must contain a `TestTask` object. + public static let testTask = TaskStartDataKind(rawValue: "test-task") +} + +/// This structure is embedded in the `TaskStartNotification.data` field, when the `dataKind` field contains +/// "compile-task". +/// +/// The beginning of a compilation unit may be signalled to the client with a `build/taskStart` notification. +/// +/// When the compilation unit is a build target, the notification's `dataKind` field must be "compile-task" and the +/// `data` field must include a `CompileTaskData` object +public struct CompileTaskData: Codable, Hashable, Sendable { + public var target: BuildTargetIdentifier + + public init(target: BuildTargetIdentifier) { + self.target = target + } +} + +/// This structure is embedded in the `TaskStartNotification.data` field, when the `dataKind` field contains +/// "test-start". +public struct TestStartData: Codable, Hashable, Sendable { + /// Name or description of the test. + public var displayName: String + + /// Source location of the test, as LSP location. + public var location: Location? + + public init(displayName: String, location: Location? = nil) { + self.displayName = displayName + self.location = location + } +} + +/// This structure is embedded in the `TaskStartNotification.data` field, when the `dataKind` field contains +/// "test-task". +/// +/// The beginning of a testing unit may be signalled to the client with a `build/taskStart`` notification. +/// When the testing unit is a build target, the notification's `dataKind` field must be `test-task` and the `data` +/// field must include a `TestTaskData` object. +public struct TestTaskData: Codable, Hashable, Sendable { + public var target: BuildTargetIdentifier + + public init(target: BuildTargetIdentifier) { + self.target = target + } +} + +/// If `data` contains a string value for the `workDoneProgressTitle` key, then the task's message will be displayed in +/// the client as a work done progress with that title. +public struct WorkDoneProgressTask: LSPAnyCodable { + /// The title with which the work done progress should be created in the client. + public let title: String + + public init(title: String) { + self.title = title + } + + public init?(fromLSPDictionary dictionary: [String: LSPAny]) { + guard case .string(let title) = dictionary["workDoneProgressTitle"] else { + return nil + } + self.title = title + } + + public func encodeToLSPAny() -> LSPAny { + return .dictionary([ + "workDoneProgressTitle": .string(title) + ]) + } +} diff --git a/Sources/SWBBuildServerProtocol/TextDocumentIdentifier.swift b/Sources/SWBBuildServerProtocol/TextDocumentIdentifier.swift new file mode 100644 index 00000000..223e9272 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/TextDocumentIdentifier.swift @@ -0,0 +1,20 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +public struct TextDocumentIdentifier: Codable, Sendable, Hashable { + /// The text document's URI. + public var uri: URI + + public init(_ uri: URI) { + self.uri = uri + } +} diff --git a/Sources/SWBBuildServerProtocol/TextDocumentSourceKitOptionsRequest.swift b/Sources/SWBBuildServerProtocol/TextDocumentSourceKitOptionsRequest.swift new file mode 100644 index 00000000..6ea88e65 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/TextDocumentSourceKitOptionsRequest.swift @@ -0,0 +1,59 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// The `TextDocumentSourceKitOptionsRequest` request is sent from the client to the server to query for the list of +/// compiler options necessary to compile this file in the given target. +/// +/// The build settings are considered up-to-date and can be cached by SourceKit-LSP until a +/// `DidChangeBuildTargetNotification` is sent for the requested target. +/// +/// The request may return `nil` if it doesn't have any build settings for this file in the given target. +public struct TextDocumentSourceKitOptionsRequest: RequestType, Hashable { + public static let method: String = "textDocument/sourceKitOptions" + public typealias Response = TextDocumentSourceKitOptionsResponse? + + /// The URI of the document to get options for + public var textDocument: TextDocumentIdentifier + + /// The target for which the build setting should be returned. + /// + /// A source file might be part of multiple targets and might have different compiler arguments in those two targets, + /// thus the target is necessary in this request. + public var target: BuildTargetIdentifier + + /// The language with which the document was opened in the editor. + public var language: Language + + public init(textDocument: TextDocumentIdentifier, target: BuildTargetIdentifier, language: Language) { + self.textDocument = textDocument + self.target = target + self.language = language + } +} + +public struct TextDocumentSourceKitOptionsResponse: ResponseType, Hashable { + /// The compiler options required for the requested file. + public var compilerArguments: [String] + + /// The working directory for the compile command. + public var workingDirectory: String? + + /// Additional data that will not be interpreted by SourceKit-LSP but made available to clients in the + /// `workspace/_sourceKitOptions` LSP requests. + public var data: LSPAny? + + public init(compilerArguments: [String], workingDirectory: String? = nil, data: LSPAny? = nil) { + self.compilerArguments = compilerArguments + self.workingDirectory = workingDirectory + self.data = data + } +} diff --git a/Sources/SWBBuildServerProtocol/WindowMessageType.swift b/Sources/SWBBuildServerProtocol/WindowMessageType.swift new file mode 100644 index 00000000..6f73895f --- /dev/null +++ b/Sources/SWBBuildServerProtocol/WindowMessageType.swift @@ -0,0 +1,20 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// The type of message (error, etc.) for a ShowMessage or LogMessage request. Corresponds to `MessageType` in LSP. +public enum WindowMessageType: Int, Codable, Hashable, Sendable { + + case error = 1 + case warning = 2 + case info = 3 + case log = 4 +} diff --git a/Sources/SWBBuildServerProtocol/WorkspaceBuildTargetsRequest.swift b/Sources/SWBBuildServerProtocol/WorkspaceBuildTargetsRequest.swift new file mode 100644 index 00000000..d33856b1 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/WorkspaceBuildTargetsRequest.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// The workspace build targets request is sent from the client to the server to +/// ask for the list of all available build targets in the workspace. +public struct WorkspaceBuildTargetsRequest: RequestType, Hashable { + public static let method: String = "workspace/buildTargets" + public typealias Response = WorkspaceBuildTargetsResponse + + public init() {} +} + +public struct WorkspaceBuildTargetsResponse: ResponseType, Hashable { + /// The build targets in this workspace that contain sources with the given language ids. + public var targets: [BuildTarget] + + public init(targets: [BuildTarget]) { + self.targets = targets + } +} diff --git a/Sources/SWBBuildServerProtocol/WorkspaceWaitForBuildSystemUpdates.swift b/Sources/SWBBuildServerProtocol/WorkspaceWaitForBuildSystemUpdates.swift new file mode 100644 index 00000000..20b18979 --- /dev/null +++ b/Sources/SWBBuildServerProtocol/WorkspaceWaitForBuildSystemUpdates.swift @@ -0,0 +1,23 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// This request is a no-op and doesn't have any effects. +/// +/// If the build server is currently updating the build graph, this request should return after those updates have +/// finished processing. +public struct WorkspaceWaitForBuildSystemUpdatesRequest: RequestType, Hashable { + public typealias Response = VoidResponse + + public static let method: String = "workspace/waitForBuildSystemUpdates" + + public init() {} +} diff --git a/Sources/SWBBuildService/BuildDescriptionMessages.swift b/Sources/SWBBuildService/BuildDescriptionMessages.swift index e6c4a3e5..547e0468 100644 --- a/Sources/SWBBuildService/BuildDescriptionMessages.swift +++ b/Sources/SWBBuildService/BuildDescriptionMessages.swift @@ -95,6 +95,11 @@ struct BuildDescriptionConfiguredTargetsMsg: MessageHandler { func handle(request: Request, message: BuildDescriptionConfiguredTargetsRequest) async throws -> BuildDescriptionConfiguredTargetsResponse { let buildDescription = try await request.buildDescription(for: message) + var configuredTargetIdentifiersByGUID: [String: ConfiguredTargetIdentifier] = [:] + for configuredTarget in buildDescription.allConfiguredTargets { + configuredTargetIdentifiersByGUID[configuredTarget.guid.stringValue] = ConfiguredTargetIdentifier(rawGUID: configuredTarget.guid.stringValue, targetGUID: TargetGUID(rawValue: configuredTarget.target.guid)) + } + let dependencyRelationships = Dictionary( buildDescription.targetDependencies.map { (ConfiguredTarget.GUID(id: $0.target.guid), [$0]) }, uniquingKeysWith: { $0 + $1 } @@ -116,10 +121,9 @@ struct BuildDescriptionConfiguredTargetsMsg: MessageHandler { let dependencyRelationships = dependencyRelationships[configuredTarget.guid] return BuildDescriptionConfiguredTargetsResponse.ConfiguredTargetInfo( - guid: ConfiguredTargetGUID(configuredTarget.guid.stringValue), - target: TargetGUID(rawValue: configuredTarget.target.guid), + identifier: ConfiguredTargetIdentifier(rawGUID: configuredTarget.guid.stringValue, targetGUID: TargetGUID(rawValue: configuredTarget.target.guid)), name: configuredTarget.target.name, - dependencies: Set(dependencyRelationships?.flatMap(\.targetDependencies).map { ConfiguredTargetGUID($0.guid) } ?? []), + dependencies: Set(dependencyRelationships?.flatMap(\.targetDependencies).compactMap { configuredTargetIdentifiersByGUID[$0.guid] } ?? []), toolchain: toolchain ) } @@ -143,7 +147,7 @@ fileprivate extension SourceLanguage { struct BuildDescriptionConfiguredTargetSourcesMsg: MessageHandler { private struct UnknownConfiguredTargetIDError: Error, CustomStringConvertible { - let configuredTarget: ConfiguredTargetGUID + let configuredTarget: ConfiguredTargetIdentifier var description: String { "Unknown configured target: \(configuredTarget)" } } @@ -161,9 +165,9 @@ struct BuildDescriptionConfiguredTargetSourcesMsg: MessageHandler { } let indexingInfoInput = TaskGenerateIndexingInfoInput(requestedSourceFile: nil, outputPathOnly: true, enableIndexBuildArena: false) - let sourcesItems = try message.configuredTargets.map { configuredTargetGuid in - guard let target = configuredTargetsByID[ConfiguredTarget.GUID(id: configuredTargetGuid.rawValue)] else { - throw UnknownConfiguredTargetIDError(configuredTarget: configuredTargetGuid) + let sourcesItems = try message.configuredTargets.map { configuredTargetIdentifier in + guard let target = configuredTargetsByID[ConfiguredTarget.GUID(id: configuredTargetIdentifier.rawGUID)] else { + throw UnknownConfiguredTargetIDError(configuredTarget: configuredTargetIdentifier) } let sourceFiles = buildDescription.taskStore.tasksForTarget(target).flatMap { task in task.generateIndexingInfo(input: indexingInfoInput).compactMap { (entry) -> SourceFileInfo? in @@ -174,7 +178,7 @@ struct BuildDescriptionConfiguredTargetSourcesMsg: MessageHandler { ) } } - return ConfiguredTargetSourceFilesInfo(configuredTarget: configuredTargetGuid, sourceFiles: sourceFiles) + return ConfiguredTargetSourceFilesInfo(configuredTarget: configuredTargetIdentifier, sourceFiles: sourceFiles) } return BuildDescriptionConfiguredTargetSourcesResponse(targetSourceFileInfos: sourcesItems) } @@ -190,7 +194,7 @@ struct IndexBuildSettingsMsg: MessageHandler { func handle(request: Request, message: IndexBuildSettingsRequest) async throws -> IndexBuildSettingsResponse { let (buildRequest, buildDescription) = try await request.buildRequestAndDescription(for: message) - let configuredTarget = buildDescription.allConfiguredTargets.filter { $0.guid.stringValue == message.configuredTarget.rawValue }.only + let configuredTarget = buildDescription.allConfiguredTargets.filter { $0.guid.stringValue == message.configuredTarget.rawGUID }.only let indexingInfoInput = TaskGenerateIndexingInfoInput( requestedSourceFile: message.file, diff --git a/Sources/SWBCore/Settings/BuiltinMacros.swift b/Sources/SWBCore/Settings/BuiltinMacros.swift index 48dcf3ce..25d5aa46 100644 --- a/Sources/SWBCore/Settings/BuiltinMacros.swift +++ b/Sources/SWBCore/Settings/BuiltinMacros.swift @@ -481,6 +481,7 @@ public final class BuiltinMacros { public static let BUILD_DIR = BuiltinMacros.declarePathMacro("BUILD_DIR") public static let BUILD_LIBRARY_FOR_DISTRIBUTION = BuiltinMacros.declareBooleanMacro("BUILD_LIBRARY_FOR_DISTRIBUTION") public static let BUILD_PACKAGE_FOR_DISTRIBUTION = BuiltinMacros.declareBooleanMacro("BUILD_PACKAGE_FOR_DISTRIBUTION") + public static let BUILD_SERVER_PROTOCOL_TARGET_TAGS = BuiltinMacros.declareBooleanMacro("BUILD_SERVER_PROTOCOL_TARGET_TAGS") public static let BUILD_VARIANTS = BuiltinMacros.declareStringListMacro("BUILD_VARIANTS") public static let BuiltBinaryPath = BuiltinMacros.declareStringMacro("BuiltBinaryPath") public static let BUNDLE_FORMAT = BuiltinMacros.declareStringMacro("BUNDLE_FORMAT") @@ -1469,6 +1470,7 @@ public final class BuiltinMacros { BUILD_DIR, BUILD_LIBRARY_FOR_DISTRIBUTION, BUILD_PACKAGE_FOR_DISTRIBUTION, + BUILD_SERVER_PROTOCOL_TARGET_TAGS, BUILD_STYLE, BUILD_VARIANTS, BUILT_PRODUCTS_DIR, diff --git a/Sources/SWBProtocol/BuildDescriptionMessages.swift b/Sources/SWBProtocol/BuildDescriptionMessages.swift index 36cace79..2859a885 100644 --- a/Sources/SWBProtocol/BuildDescriptionMessages.swift +++ b/Sources/SWBProtocol/BuildDescriptionMessages.swift @@ -14,11 +14,13 @@ public import SWBUtil // MARK: Support types -public struct ConfiguredTargetGUID: Hashable, Sendable, Codable { - public var rawValue: String +public struct ConfiguredTargetIdentifier: Hashable, Sendable, Codable { + public var rawGUID: String + public var targetGUID: TargetGUID - public init(_ rawValue: String) { - self.rawValue = rawValue + public init(rawGUID: String, targetGUID: TargetGUID) { + self.rawGUID = rawGUID + self.targetGUID = targetGUID } } @@ -61,25 +63,21 @@ public struct BuildDescriptionConfiguredTargetsResponse: Message, SerializableCo public struct ConfiguredTargetInfo: SerializableCodable, Equatable, Sendable { /// The GUID of this configured target - public let guid: ConfiguredTargetGUID - - /// The GUID of the target from which this configured target was created - public let target: TargetGUID + public let identifier: ConfiguredTargetIdentifier /// A name of the target that may be displayed to the user public let name: String /// The configured targets that this target depends on - public let dependencies: Set + public let dependencies: Set /// The path of the toolchain that should be used to build this target. /// /// `nil` if the toolchain for this target could not be determined due to an error. public let toolchain: Path? - public init(guid: ConfiguredTargetGUID, target: TargetGUID, name: String, dependencies: Set, toolchain: Path?) { - self.guid = guid - self.target = target + public init(identifier: ConfiguredTargetIdentifier, name: String, dependencies: Set, toolchain: Path?) { + self.identifier = identifier self.name = name self.dependencies = dependencies self.toolchain = toolchain @@ -108,9 +106,9 @@ public struct BuildDescriptionConfiguredTargetSourcesRequest: SessionMessage, Re public let request: BuildRequestMessagePayload /// The configured targets for which to load source file information - public let configuredTargets: [ConfiguredTargetGUID] + public let configuredTargets: [ConfiguredTargetIdentifier] - public init(sessionHandle: String, buildDescriptionID: BuildDescriptionID, request: BuildRequestMessagePayload, configuredTargets: [ConfiguredTargetGUID]) { + public init(sessionHandle: String, buildDescriptionID: BuildDescriptionID, request: BuildRequestMessagePayload, configuredTargets: [ConfiguredTargetIdentifier]) { self.sessionHandle = sessionHandle self.buildDescriptionID = buildDescriptionID self.request = request @@ -148,12 +146,12 @@ public struct BuildDescriptionConfiguredTargetSourcesResponse: Message, Serializ public struct ConfiguredTargetSourceFilesInfo: SerializableCodable, Equatable, Sendable { /// The configured target to which this info belongs - public let configuredTarget: ConfiguredTargetGUID + public let configuredTarget: ConfiguredTargetIdentifier /// Information about the source files in this source file public let sourceFiles: [SourceFileInfo] - public init(configuredTarget: ConfiguredTargetGUID, sourceFiles: [SourceFileInfo]) { + public init(configuredTarget: ConfiguredTargetIdentifier, sourceFiles: [SourceFileInfo]) { self.configuredTarget = configuredTarget self.sourceFiles = sourceFiles } @@ -182,7 +180,7 @@ public struct IndexBuildSettingsRequest: SessionMessage, RequestMessage, Seriali public let request: BuildRequestMessagePayload /// The configured target in whose context the build settings of the source file should be loaded - public let configuredTarget: ConfiguredTargetGUID + public let configuredTarget: ConfiguredTargetIdentifier /// The path of the source file for which the build settings should be loaded public let file: Path @@ -191,7 +189,7 @@ public struct IndexBuildSettingsRequest: SessionMessage, RequestMessage, Seriali sessionHandle: String, buildDescriptionID: BuildDescriptionID, request: BuildRequestMessagePayload, - configuredTarget: ConfiguredTargetGUID, + configuredTarget: ConfiguredTargetIdentifier, file: Path ) { self.sessionHandle = sessionHandle diff --git a/Sources/SWBUtil/Lock.swift b/Sources/SWBUtil/Lock.swift index b45625c6..9abf9538 100644 --- a/Sources/SWBUtil/Lock.swift +++ b/Sources/SWBUtil/Lock.swift @@ -138,3 +138,23 @@ extension SWBMutex where Value: ~Copyable, Value == Void { try withLock { _ throws(E) -> sending Result in return try body() } } } + +extension SWBMutex where Value: BinaryInteger & Sendable { + public func fetchAndIncrement() -> Value { + withLock { value in + let retVal = value + value += 1 + return retVal + } + } +} + +extension SWBMutex { + public func takeValue() -> Value where Value == Optional { + withLock { value in + let retVal = value + value = nil + return retVal + } + } +} diff --git a/Sources/SwiftBuild/CMakeLists.txt b/Sources/SwiftBuild/CMakeLists.txt index 9a1bbea2..e6ad54f4 100644 --- a/Sources/SwiftBuild/CMakeLists.txt +++ b/Sources/SwiftBuild/CMakeLists.txt @@ -36,13 +36,14 @@ add_library(SwiftBuild SWBBuildOperationBacktraceFrame.swift SWBBuildParameters.swift SWBBuildRequest.swift + SWBBuildServer.swift SWBBuildService.swift SWBBuildServiceConnection.swift SWBBuildServiceConsole.swift SWBBuildServiceSession.swift SWBChannel.swift SWBClientExchangeSupport.swift - SWBConfiguredTargetGUID.swift + SWBConfiguredTargetIdentifier.swift SWBConfiguredTargetInfo.swift SWBConfiguredTargetSourceFilesInfo.swift SWBDocumentationSupport.swift @@ -66,6 +67,7 @@ add_library(SwiftBuild set_target_properties(SwiftBuild PROPERTIES Swift_LANGUAGE_VERSION 5) target_link_libraries(SwiftBuild PUBLIC + SWBBuildServerProtocol SWBCSupport SWBCore SWBProtocol diff --git a/Sources/SwiftBuild/SWBBuildServer.swift b/Sources/SwiftBuild/SWBBuildServer.swift new file mode 100644 index 00000000..9eeb6c28 --- /dev/null +++ b/Sources/SwiftBuild/SWBBuildServer.swift @@ -0,0 +1,519 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +public import SWBBuildServerProtocol +import SWBProtocol +import SWBUtil +import Foundation + +/// Wraps a `SWBBuildServiceSession` to expose Build Server Protocol functionality. +public actor SWBBuildServer: QueueBasedMessageHandler { + /// The session used for underlying build system functionality. + private let session: SWBBuildServiceSession + enum PIFSource { + // PIF should be loaded from the container at the given path + case container(String) + // PIF will be transferred to the session externally + case session + } + /// The source of PIF describing the workspace for this build server. + private let pifSource: PIFSource + /// The build request representing preparation. + private let buildRequest: SWBBuildRequest + /// The currently planned build description used to fulfill requests. + private var buildDescriptionID: SWBBuildDescriptionID? = nil + + private var indexStorePath: String? { + buildRequest.parameters.arenaInfo?.indexDataStoreFolderPath.map { + Path($0).dirname.join("index-store").str + } + } + private var indexDatabasePath: String? { + buildRequest.parameters.arenaInfo?.indexDataStoreFolderPath + } + + public let messageHandlingHelper = QueueBasedMessageHandlerHelper( + signpostLoggingCategory: "build-server-message-handling", + createLoggingScope: false + ) + public let messageHandlingQueue = AsyncQueue() + /// Used to serialize workspace loading. + private let workspaceLoadingQueue = AsyncQueue() + /// Used to serialize preparation builds, which cannot run concurrently. + private let preparationQueue = AsyncQueue() + /// Connection used to send messages to the client of the build server (an LSP or higher-level BSP implementation). + private let connectionToClient: any Connection + + /// Represents the lifetime of the build server implementation.. + enum ServerState: CustomStringConvertible { + case waitingForInitializeRequest + case waitingForInitializedNotification + case running + case shutdown + + var description: String { + switch self { + case .waitingForInitializeRequest: + "waiting for initialization request" + case .waitingForInitializedNotification: + "waiting for initialization notification" + case .running: + "running" + case .shutdown: + "shutdown" + } + } + } + var state: ServerState = .waitingForInitializeRequest + /// Allows customization of server exit behavior. + var exitHandler: (Int) async -> Void + + public static let sessionPIFURI = DocumentURI(.init(string: "swift-build://session-pif")!) + + public init(session: SWBBuildServiceSession, containerPath: String, buildRequest: SWBBuildRequest, connectionToClient: any Connection, exitHandler: @escaping (Int) async -> Void) { + self.init(session: session, pifSource: .container(containerPath), buildRequest: buildRequest, connectionToClient: connectionToClient, exitHandler: exitHandler) + } + + public init(session: SWBBuildServiceSession, buildRequest: SWBBuildRequest, connectionToClient: any Connection, exitHandler: @escaping (Int) async -> Void) { + self.init(session: session, pifSource: .session, buildRequest: buildRequest, connectionToClient: connectionToClient, exitHandler: exitHandler) + } + + private init(session: SWBBuildServiceSession, pifSource: PIFSource, buildRequest: SWBBuildRequest, connectionToClient: any Connection, exitHandler: @escaping (Int) async -> Void) { + self.session = session + self.pifSource = pifSource + self.buildRequest = Self.preparationRequest(for: buildRequest) + self.connectionToClient = connectionToClient + self.exitHandler = exitHandler + } + + /// Derive a request suitable from preparation from one suitable for a normal build. + private static func preparationRequest(for buildRequest: SWBBuildRequest) -> SWBBuildRequest { + var updatedBuildRequest = buildRequest + updatedBuildRequest.buildCommand = .prepareForIndexing( + buildOnlyTheseTargets: nil, + enableIndexBuildArena: true + ) + updatedBuildRequest.enableIndexBuildArena = true + updatedBuildRequest.continueBuildingAfterErrors = true + + updatedBuildRequest.parameters.action = "indexbuild" + var overridesTable = buildRequest.parameters.overrides.commandLine ?? SWBSettingsTable() + overridesTable.set(value: "YES", for: "ONLY_ACTIVE_ARCH") + updatedBuildRequest.parameters.overrides.commandLine = overridesTable + for targetIndex in updatedBuildRequest.configuredTargets.indices { + updatedBuildRequest.configuredTargets[targetIndex].parameters?.action = "indexbuild" + var overridesTable = updatedBuildRequest.configuredTargets[targetIndex].parameters?.overrides.commandLine ?? SWBSettingsTable() + overridesTable.set(value: "YES", for: "ONLY_ACTIVE_ARCH") + updatedBuildRequest.configuredTargets[targetIndex].parameters?.overrides.commandLine = overridesTable + } + + return updatedBuildRequest + } + + public func handle(notification: some NotificationType) async { + switch notification { + case is OnBuildExitNotification: + if state == .shutdown { + await exitHandler(0) + } else { + await exitHandler(1) + } + case is OnBuildInitializedNotification: + guard state == .waitingForInitializedNotification else { + logToClient(.error, "Build initialized notification received while the build server is \(state.description)") + break + } + state = .running + case let notification as OnWatchedFilesDidChangeNotification: + guard state == .running else { + logToClient(.error, "Watched files changed notification received while the build server is \(state.description)") + break + } + for change in notification.changes { + switch pifSource { + case .container(let containerPath): + if change.uri == DocumentURI(.init(filePath: containerPath)) { + scheduleRegeneratingBuildDescription() + return + } + case .session: + if change.uri == Self.sessionPIFURI { + scheduleRegeneratingBuildDescription() + return + } + } + } + default: + logToClient(.error, "Unknown notification type received") + break + } + } + + public func handle( + request: Request, + id: RequestID, + reply: @Sendable @escaping (LSPResult) -> Void + ) async { + let request = RequestAndReply(request, reply: reply) + if !(request.params is InitializeBuildRequest) { + let state = self.state + guard state == .running else { + await request.reply { throw ResponseError.unknown("Request received while the build server is \(state.description)") } + return + } + } + switch request { + case let request as RequestAndReply: + await request.reply { await shutdown() } + case let request as RequestAndReply: + await request.reply { try await prepare(request: request.params) } + case let request as RequestAndReply: + await request.reply { try await buildTargetSources(request: request.params) } + case let request as RequestAndReply: + await request.reply { try await self.initialize(request: request.params) } + case let request as RequestAndReply: + await request.reply { try await sourceKitOptions(request: request.params) } + case let request as RequestAndReply: + await request.reply { try await buildTargets(request: request.params) } + case let request as RequestAndReply: + await request.reply { await waitForBuildSystemUpdates(request: request.params) } + default: + await request.reply { throw ResponseError.methodNotFound(Request.method) } + } + } + + private func initialize(request: InitializeBuildRequest) throws -> InitializeBuildResponse { + guard state == .waitingForInitializeRequest else { + throw ResponseError.unknown("Received initialization request while the build server is \(state)") + } + state = .waitingForInitializedNotification + scheduleRegeneratingBuildDescription() + return InitializeBuildResponse( + displayName: "Swift Build Server (Session: \(session.uid))", + version: "", + bspVersion: "2.2.0", + capabilities: BuildServerCapabilities(), + dataKind: .sourceKit, + data: SourceKitInitializeBuildResponseData( + indexDatabasePath: indexDatabasePath, + indexStorePath: indexStorePath, + outputPathsProvider: true, + prepareProvider: true, + sourceKitOptionsProvider: true, + watchers: [] + ).encodeToLSPAny() + ) + } + + private func shutdown() -> SWBBuildServerProtocol.VoidResponse { + state = .shutdown + return VoidResponse() + } + + private func waitForBuildSystemUpdates(request: WorkspaceWaitForBuildSystemUpdatesRequest) async -> SWBBuildServerProtocol.VoidResponse { + await workspaceLoadingQueue.async {}.valuePropagatingCancellation + return VoidResponse() + } + + private func scheduleRegeneratingBuildDescription() { + workspaceLoadingQueue.async { + do { + try await self.logTaskToClient(name: "Generating build description") { log in + switch self.pifSource { + case .container(let containerPath): + try await self.session.loadWorkspace(containerPath: containerPath) + case .session: + break + } + try await self.session.setSystemInfo(.default()) + let buildDescriptionOperation = try await self.session.createBuildOperationForBuildDescriptionOnly( + request: self.buildRequest, + delegate: PlanningOperationDelegate() + ) + var buildDescriptionID: BuildDescriptionID? + for try await event in try await buildDescriptionOperation.start() { + guard case .reportBuildDescription(let info) = event else { + continue + } + guard buildDescriptionID == nil else { + throw ResponseError.unknown("Unexpectedly reported multiple build descriptions") + } + buildDescriptionID = BuildDescriptionID(info.buildDescriptionID) + } + guard let buildDescriptionID else { + throw ResponseError.unknown("Failed to get build description ID") + } + self.buildDescriptionID = SWBBuildDescriptionID(buildDescriptionID) + } + } catch { + self.logToClient(.error, "Error generating build description: \(error)") + } + } + } + + private func buildTargets(request: WorkspaceBuildTargetsRequest) async throws -> WorkspaceBuildTargetsResponse { + try await logTaskToClient(name: "Computing targets list") { _ in + guard let buildDescriptionID else { + throw ResponseError.unknown("No build description") + } + let targets = try await session.configuredTargets( + buildDescription: buildDescriptionID, + buildRequest: buildRequest + ).asyncMap { targetInfo in + let tags = try await session.evaluateMacroAsStringList( + "BUILD_SERVER_PROTOCOL_TARGET_TAGS", + level: .target(targetInfo.identifier.targetGUID.rawValue), + buildParameters: buildRequest.parameters, + overrides: nil + ).filter { + !$0.isEmpty + }.map { + BuildTargetTag(rawValue: $0) + } + let toolchain: DocumentURI? = + if let toolchain = targetInfo.toolchain { + DocumentURI(filePath: toolchain.pathString, isDirectory: true) + } else { + nil + } + + return BuildTarget( + id: try BuildTargetIdentifier(configuredTargetIdentifier: targetInfo.identifier), + displayName: targetInfo.name, + baseDirectory: nil, + tags: tags, + capabilities: BuildTargetCapabilities(), + languageIds: [.c, .cpp, .objective_c, .objective_cpp, .swift], + dependencies: try targetInfo.dependencies.map { + try BuildTargetIdentifier(configuredTargetIdentifier: $0) + }, + dataKind: .sourceKit, + data: SourceKitBuildTarget(toolchain: toolchain).encodeToLSPAny() + ) + } + + return WorkspaceBuildTargetsResponse(targets: targets) + } + } + + private func buildTargetSources(request: BuildTargetSourcesRequest) async throws -> BuildTargetSourcesResponse { + try await logTaskToClient(name: "Computing sources list") { _ in + guard let buildDescriptionID else { + throw ResponseError.unknown("No build description") + } + let response = try await session.sources( + of: request.targets.map { try $0.configuredTargetIdentifier }, + buildDescription: buildDescriptionID, + buildRequest: buildRequest + ) + let sourcesItems = try response.compactMap { (swbSourcesItem) -> SourcesItem? in + let sources = swbSourcesItem.sourceFiles.map { sourceFile in + SourceItem( + uri: DocumentURI(URL(filePath: sourceFile.path.pathString)), + kind: .file, + // Should `generated` check if the file path is a descendant of OBJROOT/DERIVED_SOURCES_DIR? + // SourceKit-LSP doesn't use this currently. + generated: false, + dataKind: .sourceKit, + data: SourceKitSourceItemData( + language: Language(sourceFile.language), + outputPath: sourceFile.indexOutputPath + ).encodeToLSPAny() + ) + } + return SourcesItem( + target: try BuildTargetIdentifier(configuredTargetIdentifier: swbSourcesItem.configuredTarget), + sources: sources + ) + } + return BuildTargetSourcesResponse(items: sourcesItems) + } + } + + private func sourceKitOptions(request: TextDocumentSourceKitOptionsRequest) async throws -> TextDocumentSourceKitOptionsResponse? { + try await logTaskToClient(name: "Computing compiler options") { _ in + guard let buildDescriptionID else { + throw ResponseError.unknown("No build description") + } + guard let fileURL = request.textDocument.uri.fileURL else { + throw ResponseError.unknown("Text document is not a file") + } + let response = try await session.indexCompilerArguments( + of: AbsolutePath(validating: fileURL.filePath.str), + in: request.target.configuredTargetIdentifier, + buildDescription: buildDescriptionID, + buildRequest: buildRequest + ) + return TextDocumentSourceKitOptionsResponse(compilerArguments: response) + } + } + + private func prepare(request: BuildTargetPrepareRequest) async throws -> SWBBuildServerProtocol.VoidResponse { + try await preparationQueue.asyncThrowing { + var updatedBuildRequest = self.buildRequest + let targetGUIDs = try request.targets.map { + try $0.configuredTargetIdentifier.targetGUID.rawValue + } + updatedBuildRequest.buildCommand = .prepareForIndexing( + buildOnlyTheseTargets: targetGUIDs, + enableIndexBuildArena: true + ) + let buildOperation = try await self.session.createBuildOperation( + request: updatedBuildRequest, + delegate: PlanningOperationDelegate() + ) + try await self.logTaskToClient(name: "Preparing targets") { taskID in + let events = try await buildOperation.start() + await self.reportEventStream(events) + await buildOperation.waitForCompletion() + } + }.valuePropagatingCancellation + return VoidResponse() + } + + private func reportEventStream(_ events: AsyncStream) async { + let buildTaskID = UUID().uuidString + for try await event in events { + switch event { + case .planningOperationStarted(let info): + logToClient(.log, "Planning Build", .begin(.init(title: "Planning Build", taskID: info.planningOperationID))) + case .planningOperationCompleted(let info): + logToClient(.info, "Build Planning Complete", .end(.init(taskID: info.planningOperationID))) + case .buildStarted(_): + logToClient(.log, "Building", .begin(.init(title: "Building", taskID: buildTaskID))) + case .buildDiagnostic(let info): + logToClient(.log, info.message, .report(.init(taskID: buildTaskID))) + case .buildCompleted(let info): + switch info.result { + case .ok: + logToClient(.log, "Build Complete", .end(.init(taskID: buildTaskID))) + case .failed: + logToClient(.log, "Build Failed", .end(.init(taskID: buildTaskID))) + case .cancelled: + logToClient(.log, "Build Cancelled", .end(.init(taskID: buildTaskID))) + case .aborted: + logToClient(.log, "Build Aborted", .end(.init(taskID: buildTaskID))) + } + case .preparationComplete(_): + logToClient(.log, "Build Preparation Complete", .end(.init(taskID: buildTaskID))) + case .didUpdateProgress(let info): + logToClient(.log, "Progress: \(info.percentComplete)%", .end(.init(taskID: buildTaskID))) + case .taskStarted(let info): + logToClient(.log, info.executionDescription, .begin(.init(title: info.executionDescription, taskID: "\(buildTaskID)-task-\(info.taskID)"))) + case .taskDiagnostic(let info): + logToClient(.log, info.message, .report(.init(taskID: "\(buildTaskID)-task-\(info.taskID)"))) + case .taskComplete(let info): + logToClient(.log, "Task Complete", .end(.init(taskID: "\(buildTaskID)-task-\(info.taskID)"))) + case .targetDiagnostic(let info): + logToClient(.log, info.message, .report(.init(taskID: buildTaskID))) + case .diagnostic(let info): + logToClient(.log, info.message, .report(.init(taskID: buildTaskID))) + case .backtraceFrame, .reportPathMap, .reportBuildDescription, .preparedForIndex, .buildOutput, .targetStarted, .targetComplete, .targetOutput, .targetUpToDate, .taskUpToDate, .taskOutput, .output: + break + } + } + } + + private func logToClient(_ kind: BuildServerMessageType, _ message: String, _ structure: StructuredLogKind? = nil) { + connectionToClient.send( + OnBuildLogMessageNotification(type: .log, message: "\(message)", structure: structure) + ) + } + + private func logTaskToClient(name: String, _ perform: (String) async throws -> T) async throws -> T { + let taskID = UUID().uuidString + logToClient(.log, name, .begin(.init(title: name, taskID: taskID))) + defer { + logToClient(.log, name, .end(.init(taskID: taskID))) + } + return try await perform(taskID) + } +} + +extension BuildTargetIdentifier { + static let swiftBuildBuildServerTargetScheme = "swift-build" + + init(configuredTargetIdentifier: SWBConfiguredTargetIdentifier) throws { + var components = URLComponents() + components.scheme = Self.swiftBuildBuildServerTargetScheme + components.host = "configured-target" + components.queryItems = [ + URLQueryItem(name: "configuredTargetGUID", value: configuredTargetIdentifier.rawGUID), + URLQueryItem(name: "targetGUID", value: configuredTargetIdentifier.targetGUID.rawValue), + ] + + struct FailedToConvertSwiftBuildTargetToUrlError: Swift.Error, CustomStringConvertible { + var configuredTargetIdentifier: SWBConfiguredTargetIdentifier + + var description: String { + return "Failed to generate URL for configured target '\(configuredTargetIdentifier.rawGUID)'" + } + } + + guard let url = components.url else { + throw FailedToConvertSwiftBuildTargetToUrlError(configuredTargetIdentifier: configuredTargetIdentifier) + } + + self.init(uri: URI(url)) + } + + var isSwiftBuildBuildServerTargetID: Bool { + uri.scheme == Self.swiftBuildBuildServerTargetScheme + } + + var configuredTargetIdentifier: SWBConfiguredTargetIdentifier { + get throws { + struct InvalidTargetIdentifierError: Swift.Error, CustomStringConvertible { + var target: BuildTargetIdentifier + + var description: String { + return "Invalid target identifier \(target)" + } + } + guard let components = URLComponents(url: self.uri.arbitrarySchemeURL, resolvingAgainstBaseURL: false) else { + throw InvalidTargetIdentifierError(target: self) + } + guard let configuredTargetGUID = components.queryItems?.last(where: { $0.name == "configuredTargetGUID" })?.value else { + throw InvalidTargetIdentifierError(target: self) + } + guard let targetGUID = components.queryItems?.last(where: { $0.name == "targetGUID" })?.value else { + throw InvalidTargetIdentifierError(target: self) + } + + return SWBConfiguredTargetIdentifier(rawGUID: configuredTargetGUID, targetGUID: SWBTargetGUID(TargetGUID(rawValue: targetGUID))) + } + } +} + +private final class PlanningOperationDelegate: SWBPlanningOperationDelegate, Sendable { + func provisioningTaskInputs(targetGUID: String, provisioningSourceData: SWBProvisioningTaskInputsSourceData) async -> SWBProvisioningTaskInputs { + return SWBProvisioningTaskInputs() + } + + func executeExternalTool(commandLine: [String], workingDirectory: String?, environment: [String : String]) async throws -> SWBExternalToolResult { + .deferred + } +} + +fileprivate extension Language { + init?(_ language: SWBSourceLanguage?) { + switch language { + case nil: return nil + case .c: self = .c + case .cpp: self = .cpp + case .metal: return nil + case .objectiveC: self = .objective_c + case .objectiveCpp: self = .objective_cpp + case .swift: self = .swift + } + } +} diff --git a/Sources/SwiftBuild/SWBBuildServiceSession.swift b/Sources/SwiftBuild/SWBBuildServiceSession.swift index ae3baad4..14f28354 100644 --- a/Sources/SwiftBuild/SWBBuildServiceSession.swift +++ b/Sources/SwiftBuild/SWBBuildServiceSession.swift @@ -393,25 +393,25 @@ public final class SWBBuildServiceSession: Sendable { return response.configuredTargets.map { SWBConfiguredTargetInfo($0) } } - public func sources(of configuredTargets: [SWBConfiguredTargetGUID], buildDescription: SWBBuildDescriptionID, buildRequest: SWBBuildRequest) async throws -> [SWBConfiguredTargetSourceFilesInfo] { + public func sources(of configuredTargets: [SWBConfiguredTargetIdentifier], buildDescription: SWBBuildDescriptionID, buildRequest: SWBBuildRequest) async throws -> [SWBConfiguredTargetSourceFilesInfo] { let response = try await service.send( request: BuildDescriptionConfiguredTargetSourcesRequest( sessionHandle: uid, buildDescriptionID: BuildDescriptionID(buildDescription), request: buildRequest.messagePayloadRepresentation, - configuredTargets: configuredTargets.map { ConfiguredTargetGUID($0) } + configuredTargets: configuredTargets.map { ConfiguredTargetIdentifier($0) } ) ) return response.targetSourceFileInfos.map { SWBConfiguredTargetSourceFilesInfo($0) } } - public func indexCompilerArguments(of file: AbsolutePath, in configuredTarget: SWBConfiguredTargetGUID, buildDescription: SWBBuildDescriptionID, buildRequest: SWBBuildRequest) async throws -> [String] { + public func indexCompilerArguments(of file: AbsolutePath, in configuredTarget: SWBConfiguredTargetIdentifier, buildDescription: SWBBuildDescriptionID, buildRequest: SWBBuildRequest) async throws -> [String] { let buildSettings = try await service.send( request: IndexBuildSettingsRequest( sessionHandle: uid, buildDescriptionID: BuildDescriptionID(buildDescription), request: buildRequest.messagePayloadRepresentation, - configuredTarget: ConfiguredTargetGUID(configuredTarget), + configuredTarget: ConfiguredTargetIdentifier(configuredTarget), file: Path(file.pathString) ) ) diff --git a/Sources/SwiftBuild/SWBConfiguredTargetGUID.swift b/Sources/SwiftBuild/SWBConfiguredTargetGUID.swift deleted file mode 100644 index 4b193169..00000000 --- a/Sources/SwiftBuild/SWBConfiguredTargetGUID.swift +++ /dev/null @@ -1,31 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift open source project -// -// Copyright (c) 2025 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See http://swift.org/LICENSE.txt for license information -// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import SWBProtocol - -public struct SWBConfiguredTargetGUID: RawRepresentable, Hashable, Sendable { - public var rawValue: String - - public init(rawValue: String) { - self.rawValue = rawValue - } - - init(_ guid: ConfiguredTargetGUID) { - self.init(rawValue: guid.rawValue) - } -} - -extension ConfiguredTargetGUID { - init(_ guid: SWBConfiguredTargetGUID) { - self.init(guid.rawValue) - } -} diff --git a/Sources/SwiftBuild/SWBConfiguredTargetIdentifier.swift b/Sources/SwiftBuild/SWBConfiguredTargetIdentifier.swift new file mode 100644 index 00000000..2e1d3dad --- /dev/null +++ b/Sources/SwiftBuild/SWBConfiguredTargetIdentifier.swift @@ -0,0 +1,33 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SWBProtocol + +public struct SWBConfiguredTargetIdentifier: Hashable, Sendable { + public var rawGUID: String + public var targetGUID: SWBTargetGUID + + public init(rawGUID: String, targetGUID: SWBTargetGUID) { + self.rawGUID = rawGUID + self.targetGUID = targetGUID + } + + init(configuredTargetIdentifier: ConfiguredTargetIdentifier) { + self.init(rawGUID: configuredTargetIdentifier.rawGUID, targetGUID: SWBTargetGUID(configuredTargetIdentifier.targetGUID)) + } +} + +extension ConfiguredTargetIdentifier { + init(_ identifier: SWBConfiguredTargetIdentifier) { + self.init(rawGUID: identifier.rawGUID, targetGUID: TargetGUID(rawValue: identifier.targetGUID.rawValue)) + } +} diff --git a/Sources/SwiftBuild/SWBConfiguredTargetInfo.swift b/Sources/SwiftBuild/SWBConfiguredTargetInfo.swift index 410924c1..55b41e36 100644 --- a/Sources/SwiftBuild/SWBConfiguredTargetInfo.swift +++ b/Sources/SwiftBuild/SWBConfiguredTargetInfo.swift @@ -14,25 +14,21 @@ import SWBProtocol public struct SWBConfiguredTargetInfo { /// The GUID of this configured target - public let guid: SWBConfiguredTargetGUID - - /// The GUID of the target from which this configured target was created - public let target: SWBTargetGUID + public let identifier: SWBConfiguredTargetIdentifier /// A name of the target that may be displayed to the user public let name: String /// The configured targets that this target depends on - public let dependencies: Set + public let dependencies: Set /// The path of the toolchain that should be used to build this target. /// /// `nil` if the toolchain for this target could not be determined due to an error. public let toolchain: AbsolutePath? - public init(guid: SWBConfiguredTargetGUID, target: SWBTargetGUID, name: String, dependencies: Set, toolchain: AbsolutePath?) { - self.guid = guid - self.target = target + public init(identifier: SWBConfiguredTargetIdentifier, name: String, dependencies: Set, toolchain: AbsolutePath?) { + self.identifier = identifier self.name = name self.dependencies = dependencies self.toolchain = toolchain @@ -40,10 +36,9 @@ public struct SWBConfiguredTargetInfo { init(_ configuredTargetInfo: BuildDescriptionConfiguredTargetsResponse.ConfiguredTargetInfo) { self.init( - guid: SWBConfiguredTargetGUID(configuredTargetInfo.guid), - target: SWBTargetGUID(configuredTargetInfo.target), + identifier: SWBConfiguredTargetIdentifier(configuredTargetIdentifier: configuredTargetInfo.identifier), name: configuredTargetInfo.name, - dependencies: Set(configuredTargetInfo.dependencies.map { SWBConfiguredTargetGUID($0) }), + dependencies: Set(configuredTargetInfo.dependencies.map { SWBConfiguredTargetIdentifier(configuredTargetIdentifier: $0) }), toolchain: AbsolutePath(configuredTargetInfo.toolchain) ) } diff --git a/Sources/SwiftBuild/SWBConfiguredTargetSourceFilesInfo.swift b/Sources/SwiftBuild/SWBConfiguredTargetSourceFilesInfo.swift index d87f5927..6ad3b2ce 100644 --- a/Sources/SwiftBuild/SWBConfiguredTargetSourceFilesInfo.swift +++ b/Sources/SwiftBuild/SWBConfiguredTargetSourceFilesInfo.swift @@ -45,18 +45,18 @@ public struct SWBConfiguredTargetSourceFilesInfo: Equatable, Sendable { } /// The configured target to which this info belongs - public let configuredTarget: SWBConfiguredTargetGUID + public let configuredTarget: SWBConfiguredTargetIdentifier /// Information about the source files in this source file public let sourceFiles: [SourceFileInfo] - public init(configuredTarget: SWBConfiguredTargetGUID, sourceFiles: [SWBConfiguredTargetSourceFilesInfo.SourceFileInfo]) { + public init(configuredTarget: SWBConfiguredTargetIdentifier, sourceFiles: [SWBConfiguredTargetSourceFilesInfo.SourceFileInfo]) { self.configuredTarget = configuredTarget self.sourceFiles = sourceFiles } init(_ sourceFilesInfo: BuildDescriptionConfiguredTargetSourcesResponse.ConfiguredTargetSourceFilesInfo) { - self.configuredTarget = SWBConfiguredTargetGUID(sourceFilesInfo.configuredTarget) + self.configuredTarget = SWBConfiguredTargetIdentifier(configuredTargetIdentifier: sourceFilesInfo.configuredTarget) self.sourceFiles = sourceFilesInfo.sourceFiles.map { SourceFileInfo($0) } } } diff --git a/Tests/SwiftBuildTests/BuildServerTests.swift b/Tests/SwiftBuildTests/BuildServerTests.swift new file mode 100644 index 00000000..0281f5ec --- /dev/null +++ b/Tests/SwiftBuildTests/BuildServerTests.swift @@ -0,0 +1,490 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation +import Testing +@_spi(Testing) import SwiftBuild +import SwiftBuildTestSupport +import SWBBuildService +import SWBCore +import SWBUtil +import SWBTestSupport +import SWBProtocol +import SWBBuildServerProtocol +import Synchronization + +final fileprivate class CollectingMessageHandler: MessageHandler { + + let notifications: SWBMutex<[any NotificationType]> = .init([]) + + func handle(_ notification: some NotificationType) { + notifications.withLock { + $0.append(notification) + } + } + + func handle(_ request: Request, id: RequestID, reply: @escaping @Sendable (LSPResult) -> Void) where Request : RequestType {} +} + +extension Connection { + fileprivate func send(_ request: Request) async throws -> Request.Response { + return try await withCheckedThrowingContinuation { continuation in + _ = send(request, reply: { response in + switch response { + case .success(let value): + continuation.resume(returning: value) + case .failure(let error): + continuation.resume(throwing: error) + } + }) + } + } +} + +fileprivate func withBuildServerConnection(setup: (Path) async throws -> (TestWorkspace, SWBBuildRequest), body: (any Connection, CollectingMessageHandler, Path) async throws -> Void) async throws { + try await withTemporaryDirectory { (temporaryDirectory: NamedTemporaryDirectory) in + try await withAsyncDeferrable { deferrable in + let tmpDir = temporaryDirectory.path + let testSession = try await TestSWBSession(temporaryDirectory: temporaryDirectory) + await deferrable.addBlock { + await #expect(throws: Never.self) { + try await testSession.close() + } + } + + let (workspace, request) = try await setup(tmpDir) + try await testSession.sendPIF(workspace) + + let connectionToServer = LocalConnection(receiverName: "server") + let connectionToClient = LocalConnection(receiverName: "client") + let buildServer = SWBBuildServer(session: testSession.session, buildRequest: request, connectionToClient: connectionToClient, exitHandler: { _ in }) + let collectingMessageHandler = CollectingMessageHandler() + + connectionToServer.start(handler: buildServer) + connectionToClient.start(handler: collectingMessageHandler) + _ = try await connectionToServer.send( + InitializeBuildRequest( + displayName: "test-bsp-client", + version: "1.0.0", + bspVersion: "2.2.0", + rootUri: URI(URL(filePath: tmpDir.str)), + capabilities: .init(languageIds: [.swift, .c, .objective_c, .cpp, .objective_cpp]) + ) + ) + connectionToServer.send(OnBuildInitializedNotification()) + _ = try await connectionToServer.send(WorkspaceWaitForBuildSystemUpdatesRequest()) + + try await body(connectionToServer, collectingMessageHandler, tmpDir) + + _ = try await connectionToServer.send(BuildShutdownRequest()) + connectionToServer.send(OnBuildExitNotification()) + connectionToServer.close() + } + } +} + +@Suite +fileprivate struct BuildServerTests: CoreBasedTests { + @Test(.requireSDKs(.host)) + func workspaceTargets() async throws { + try await withBuildServerConnection(setup: { tmpDir in + let testWorkspace = TestWorkspace( + "aWorkspace", + sourceRoot: tmpDir.join("Test"), + projects: [ + TestProject( + "aProject", + defaultConfigurationName: "Debug", + groupTree: TestGroup( + "Foo", + children: [ + TestFile("a.swift"), + TestFile("b.swift"), + TestFile("c.swift") + ] + ), + targets: [ + TestStandardTarget( + "Target", + type: .dynamicLibrary, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [:]) + ], + buildPhases: [ + TestSourcesBuildPhase([ + "a.swift" + ]) + ] + ), + TestStandardTarget( + "Target2", + type: .dynamicLibrary, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "BUILD_SERVER_PROTOCOL_TARGET_TAGS": "dependency" + ]) + ], + buildPhases: [ + TestSourcesBuildPhase([ + "b.swift" + ]) + ] + ), + TestStandardTarget( + "Tests", + type: .unitTest, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "BUILD_SERVER_PROTOCOL_TARGET_TAGS": "test" + ]) + ], + buildPhases: [ + TestSourcesBuildPhase([ + "c.swift" + ]) + ], + dependencies: [ + "Target", + "Target2" + ] + ) + ] + ) + ]) + + var request = SWBBuildRequest() + request.parameters = SWBBuildParameters() + request.parameters.action = "build" + request.parameters.configurationName = "Debug" + for target in testWorkspace.projects.flatMap({ $0.targets }) { + request.add(target: SWBConfiguredTarget(guid: target.guid)) + } + + return (testWorkspace, request) + }) { connection, _, _ in + let targetsResponse = try await connection.send(WorkspaceBuildTargetsRequest()) + let firstLibrary = try #require(targetsResponse.targets.filter { $0.displayName == "Target" }.only) + let secondLibrary = try #require(targetsResponse.targets.filter { $0.displayName == "Target2" }.only) + let tests = try #require(targetsResponse.targets.filter { $0.displayName == "Tests" }.only) + + #expect(firstLibrary.dependencies == []) + #expect(secondLibrary.dependencies == []) + #expect(Set(tests.dependencies) == Set([firstLibrary.id, secondLibrary.id])) + + #expect(firstLibrary.tags == []) + #expect(secondLibrary.tags == [.dependency]) + #expect(Set(tests.tags) == Set([.test])) + } + } + + @Test(.requireSDKs(.host)) + func targetSources() async throws { + try await withBuildServerConnection(setup: { tmpDir in + let testWorkspace = TestWorkspace( + "aWorkspace", + sourceRoot: tmpDir.join("Test"), + projects: [ + TestProject( + "aProject", + defaultConfigurationName: "Debug", + groupTree: TestGroup( + "Foo", + children: [ + TestFile("a.swift"), + TestFile("b.c"), + ] + ), + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [:]) + ], + targets: [ + TestStandardTarget( + "Target", + type: .dynamicLibrary, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [:]) + ], + buildPhases: [ + TestSourcesBuildPhase([ + "a.swift", + "b.c" + ]) + ] + ), + ] + ) + ]) + + var request = SWBBuildRequest() + request.parameters = SWBBuildParameters() + request.parameters.action = "build" + request.parameters.configurationName = "Debug" + for target in testWorkspace.projects.flatMap({ $0.targets }) { + request.add(target: SWBConfiguredTarget(guid: target.guid)) + } + request.parameters.activeRunDestination = .host + + return (testWorkspace, request) + }) { connection, _, tmpDir in + let targetsResponse = try await connection.send(WorkspaceBuildTargetsRequest()) + let target = try #require(targetsResponse.targets.only) + let sourcesResponse = try await connection.send(BuildTargetSourcesRequest(targets: [target.id])) + + do { + let sourceA = try #require(sourcesResponse.items.only?.sources.filter { $0.uri.fileURL?.lastPathComponent == "a.swift" }.only) + #expect(sourceA.uri == DocumentURI(URL(filePath: tmpDir.join("Test/aProject/a.swift").str))) + #expect(sourceA.kind == .file) + #expect(!sourceA.generated) + #expect(sourceA.dataKind == .sourceKit) + let data = try #require(SourceKitSourceItemData(fromLSPAny: sourceA.data)) + #expect(data.language == .swift) + #expect(data.outputPath?.hasSuffix("a.o") == true) + } + + do { + let sourceB = try #require(sourcesResponse.items.only?.sources.filter { $0.uri.fileURL?.lastPathComponent == "b.c" }.only) + #expect(sourceB.uri == DocumentURI(URL(filePath: tmpDir.join("Test/aProject/b.c").str))) + #expect(sourceB.kind == .file) + #expect(!sourceB.generated) + #expect(sourceB.dataKind == .sourceKit) + let data = try #require(SourceKitSourceItemData(fromLSPAny: sourceB.data)) + #expect(data.language == .c) + #expect(data.outputPath?.hasSuffix("b.o") == true) + } + } + } + + @Test(.requireSDKs(.host), .skipHostOS(.windows)) + func basicPreparationAndCompilerArgs() async throws { + try await withBuildServerConnection(setup: { tmpDir in + let testWorkspace = TestWorkspace( + "aWorkspace", + sourceRoot: tmpDir.join("Test"), + projects: [ + TestProject( + "aProject", + defaultConfigurationName: "Debug", + groupTree: TestGroup( + "Foo", + children: [ + TestFile("a.swift"), + TestFile("b.swift"), + ] + ), + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "PRODUCT_NAME": "$(TARGET_NAME)", + "CODE_SIGNING_ALLOWED": "NO", + "SWIFT_VERSION": "5.0", + ]) + ], + targets: [ + TestStandardTarget( + "Target", + type: .dynamicLibrary, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [:]) + ], + buildPhases: [ + TestSourcesBuildPhase([ + "b.swift", + ]) + ] + ), + TestStandardTarget( + "Target2", + type: .dynamicLibrary, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [:]) + ], + buildPhases: [ + TestSourcesBuildPhase([ + "a.swift", + ]) + ], + dependencies: ["Target"] + ), + ] + ) + ]) + + var request = SWBBuildRequest() + request.parameters = SWBBuildParameters() + request.parameters.action = "build" + request.parameters.configurationName = "Debug" + for target in testWorkspace.projects.flatMap({ $0.targets }) { + request.add(target: SWBConfiguredTarget(guid: target.guid)) + } + request.parameters.activeRunDestination = .host + + try localFS.createDirectory(tmpDir.join("Test/aProject"), recursive: true) + try localFS.write(tmpDir.join("Test/aProject/b.swift"), contents: "public let x = 42") + try localFS.write(tmpDir.join("Test/aProject/a.swift"), contents: """ + import Target + public func foo() { + print(x) + } + """) + + return (testWorkspace, request) + }) { connection, collector, tmpDir in + let targetsResponse = try await connection.send(WorkspaceBuildTargetsRequest()) + let target = try #require(targetsResponse.targets.filter { $0.displayName == "Target2" }.only) + let sourcesResponse = try await connection.send(BuildTargetSourcesRequest(targets: [target.id])) + let sourceA = try #require(sourcesResponse.items.only?.sources.filter { $0.uri.fileURL?.lastPathComponent == "a.swift" }.only) + // Prepare, request compiler args for a source file, and then ensure those args work. + _ = try await connection.send(BuildTargetPrepareRequest(targets: [target.id])) + let logs = collector.notifications.withLock { notifications in + notifications.compactMap { notification in + (notification as? OnBuildLogMessageNotification)?.message + } + } + #expect(logs.contains("Build Complete")) + let optionsResponse = try #require(try await connection.send(TextDocumentSourceKitOptionsRequest(textDocument: .init(sourceA.uri), target: target.id, language: .swift))) + try await runProcess([swiftCompilerPath.str] + optionsResponse.compilerArguments + ["-typecheck"], workingDirectory: optionsResponse.workingDirectory.map { Path($0) }) + } + } + + @Test(.requireSDKs(.host)) + func pifUpdate() async throws { + try await withTemporaryDirectory { (temporaryDirectory: NamedTemporaryDirectory) in + try await withAsyncDeferrable { deferrable in + let tmpDir = temporaryDirectory.path + let testSession = try await TestSWBSession(temporaryDirectory: temporaryDirectory) + await deferrable.addBlock { + await #expect(throws: Never.self) { + try await testSession.close() + } + } + + let workspace = TestWorkspace( + "aWorkspace", + sourceRoot: tmpDir.join("Test"), + projects: [ + TestProject( + "aProject", + defaultConfigurationName: "Debug", + groupTree: TestGroup( + "Foo", + children: [ + TestFile("a.swift"), + ] + ), + targets: [ + TestStandardTarget( + "Target", + guid: "TargetGUID", + type: .dynamicLibrary, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [:]) + ], + buildPhases: [ + TestSourcesBuildPhase([ + "a.swift" + ]) + ] + ), + ] + ) + ]) + var request = SWBBuildRequest() + request.parameters = SWBBuildParameters() + request.parameters.action = "build" + request.parameters.configurationName = "Debug" + for target in workspace.projects.flatMap({ $0.targets }) { + request.add(target: SWBConfiguredTarget(guid: target.guid)) + } + try await testSession.sendPIF(workspace) + + let connectionToServer = LocalConnection(receiverName: "server") + let connectionToClient = LocalConnection(receiverName: "client") + let buildServer = SWBBuildServer(session: testSession.session, buildRequest: request, connectionToClient: connectionToClient, exitHandler: { _ in }) + let collectingMessageHandler = CollectingMessageHandler() + + connectionToServer.start(handler: buildServer) + connectionToClient.start(handler: collectingMessageHandler) + _ = try await connectionToServer.send( + InitializeBuildRequest( + displayName: "test-bsp-client", + version: "1.0.0", + bspVersion: "2.2.0", + rootUri: URI(URL(filePath: tmpDir.str)), + capabilities: .init(languageIds: [.swift, .c, .objective_c, .cpp, .objective_cpp]) + ) + ) + connectionToServer.send(OnBuildInitializedNotification()) + _ = try await connectionToServer.send(WorkspaceWaitForBuildSystemUpdatesRequest()) + + let targetsResponse = try await connectionToServer.send(WorkspaceBuildTargetsRequest()) + #expect(targetsResponse.targets.map(\.displayName).sorted() == ["Target"]) + + let updatedWorkspace = TestWorkspace( + "aWorkspace", + sourceRoot: tmpDir.join("Test"), + projects: [ + TestProject( + "aProject", + defaultConfigurationName: "Debug", + groupTree: TestGroup( + "Foo", + children: [ + TestFile("a.swift"), + TestFile("b.swift"), + ] + ), + targets: [ + TestStandardTarget( + "Target2", + guid: "Target2GUID", + type: .dynamicLibrary, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [:]) + ], + buildPhases: [ + TestSourcesBuildPhase([ + "b.swift" + ]) + ] + ), + TestStandardTarget( + "Target", + guid: "TargetGUID", + type: .dynamicLibrary, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [:]) + ], + buildPhases: [ + TestSourcesBuildPhase([ + "a.swift" + ]) + ], + dependencies: ["Target2"] + ), + ] + ) + ]) + try await testSession.sendPIF(updatedWorkspace) + connectionToServer.send(OnWatchedFilesDidChangeNotification(changes: [ + .init(uri: SWBBuildServer.sessionPIFURI, type: .changed) + ])) + _ = try await connectionToServer.send(WorkspaceWaitForBuildSystemUpdatesRequest()) + + let updatedTargetsResponse = try await connectionToServer.send(WorkspaceBuildTargetsRequest()) + #expect(updatedTargetsResponse.targets.map(\.displayName).sorted() == ["Target", "Target2"]) + + _ = try await connectionToServer.send(BuildShutdownRequest()) + connectionToServer.send(OnBuildExitNotification()) + connectionToServer.close() + } + } + } +} diff --git a/Tests/SwiftBuildTests/InspectBuildDescriptionTests.swift b/Tests/SwiftBuildTests/InspectBuildDescriptionTests.swift index 7af073cf..de98291d 100644 --- a/Tests/SwiftBuildTests/InspectBuildDescriptionTests.swift +++ b/Tests/SwiftBuildTests/InspectBuildDescriptionTests.swift @@ -68,7 +68,7 @@ fileprivate struct InspectBuildDescriptionTests { #expect(frameworkTargetInfo.dependencies == []) #expect(frameworkTargetInfo.toolchain != nil) let appTargetInfo = try #require(targetInfos.filter { $0.name == "MyApp" }.only) - #expect(appTargetInfo.dependencies == [frameworkTargetInfo.guid]) + #expect(appTargetInfo.dependencies == [frameworkTargetInfo.identifier]) #expect(appTargetInfo.toolchain != nil) } } @@ -122,8 +122,8 @@ fileprivate struct InspectBuildDescriptionTests { let appTargetInfo = try #require(targetInfos.filter { $0.name == "MyApp" }.only) let otherAppTargetInfo = try #require(targetInfos.filter { $0.name == "MyOtherApp" }.only) - let appSources = try #require(await testSession.session.sources(of: [appTargetInfo.guid], buildDescription: buildDescriptionID, buildRequest: request).only) - #expect(appSources.configuredTarget == appTargetInfo.guid) + let appSources = try #require(await testSession.session.sources(of: [appTargetInfo.identifier], buildDescription: buildDescriptionID, buildRequest: request).only) + #expect(appSources.configuredTarget == appTargetInfo.identifier) print(appSources.sourceFiles) let myAppFile = try #require(appSources.sourceFiles.only) #expect(myAppFile.path.pathString.hasSuffix("MyApp.swift")) @@ -131,11 +131,11 @@ fileprivate struct InspectBuildDescriptionTests { #expect(myAppFile.indexOutputPath != nil) let combinedSources = try await testSession.session.sources( - of: [appTargetInfo.guid, otherAppTargetInfo.guid], + of: [appTargetInfo.identifier, otherAppTargetInfo.identifier], buildDescription: buildDescriptionID, buildRequest: request ) - #expect(Set(combinedSources.map(\.configuredTarget)) == [appTargetInfo.guid, otherAppTargetInfo.guid]) + #expect(Set(combinedSources.map(\.configuredTarget)) == [appTargetInfo.identifier, otherAppTargetInfo.identifier]) #expect(Set(combinedSources.flatMap(\.sourceFiles).map { URL(filePath: $0.path.pathString).lastPathComponent }) == ["MyApp.swift", "MyOtherApp.swift"]) let emptyTargetListInfos = try await testSession.session.sources(of: [], buildDescription: buildDescriptionID, buildRequest: request) @@ -143,7 +143,7 @@ fileprivate struct InspectBuildDescriptionTests { await #expect(throws: (any Error).self) { try await testSession.session.sources( - of: [SWBConfiguredTargetGUID(rawValue: "does-not-exist")], + of: [SWBConfiguredTargetIdentifier(rawGUID: "does-not-exist", targetGUID: .init(rawValue: "does-not-exist"))], buildDescription: buildDescriptionID, buildRequest: request ) @@ -201,13 +201,13 @@ fileprivate struct InspectBuildDescriptionTests { let targetInfos = try await testSession.session.configuredTargets(buildDescription: buildDescriptionID, buildRequest: request) let appTargetInfo = try #require(targetInfos.filter { $0.name == "MyApp" }.only) let otherAppTargetInfo = try #require(targetInfos.filter { $0.name == "MyOtherApp" }.only) - let appSources = try #require(await testSession.session.sources(of: [appTargetInfo.guid], buildDescription: buildDescriptionID, buildRequest: request).only) + let appSources = try #require(await testSession.session.sources(of: [appTargetInfo.identifier], buildDescription: buildDescriptionID, buildRequest: request).only) let myAppFile = try #require(Set(appSources.sourceFiles.map(\.path)).filter { $0.pathString.hasSuffix("MyApp.swift") }.only) - let appIndexSettings = try await testSession.session.indexCompilerArguments(of: myAppFile, in: appTargetInfo.guid, buildDescription: buildDescriptionID, buildRequest: request) + let appIndexSettings = try await testSession.session.indexCompilerArguments(of: myAppFile, in: appTargetInfo.identifier, buildDescription: buildDescriptionID, buildRequest: request) #expect(appIndexSettings.contains("-DMY_APP")) #expect(!appIndexSettings.contains("-DMY_OTHER_APP")) - let otherAppIndexSettings = try await testSession.session.indexCompilerArguments(of: myAppFile, in: otherAppTargetInfo.guid, buildDescription: buildDescriptionID, buildRequest: request) + let otherAppIndexSettings = try await testSession.session.indexCompilerArguments(of: myAppFile, in: otherAppTargetInfo.identifier, buildDescription: buildDescriptionID, buildRequest: request) #expect(otherAppIndexSettings.contains("-DMY_OTHER_APP")) } }