From f142a9d8f07e1758bbab1fa25ab33297b267133d Mon Sep 17 00:00:00 2001 From: Owen Voorhees Date: Sat, 6 Sep 2025 20:44:48 -0700 Subject: [PATCH] Expose a low-level BSP interface wrapping SWBBuildServiceSession SWBBuildServer implements BSP message handling and is intended to back a higher level BSP which manages the project model and generates PIF and a build request (SwiftPM). As part of this change, many of the core BSP types are vendored into the new SWBBuildServerProtocol target from SourceKitLSP with minor modifications. --- CMakeLists.txt | 1 + Package.swift | 14 +- .../cmake-smoke-test/cmake-smoke-test.swift | 13 +- Sources/SWBCore/Settings/BuiltinMacros.swift | 2 + Sources/SWBUtil/Lock.swift | 20 + Sources/SwiftBuild/CMakeLists.txt | 9 +- Sources/SwiftBuild/SWBBuildServer.swift | 522 ++++++++++++++++++ Tests/SwiftBuildTests/BuildServerTests.swift | 497 +++++++++++++++++ 8 files changed, 1074 insertions(+), 4 deletions(-) create mode 100644 Sources/SwiftBuild/SWBBuildServer.swift create mode 100644 Tests/SwiftBuildTests/BuildServerTests.swift diff --git a/CMakeLists.txt b/CMakeLists.txt index ed60c850..516f1840 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -81,6 +81,7 @@ add_compile_definitions(USE_STATIC_PLUGIN_INITIALIZATION) find_package(ArgumentParser) find_package(LLBuild) find_package(SwiftDriver) +find_package(SwiftToolsProtocols) find_package(SwiftSystem) find_package(TSC) # NOTE: these two are required for LLBuild dependencies diff --git a/Package.swift b/Package.swift index 81a12a2f..c81c0dad 100644 --- a/Package.swift +++ b/Package.swift @@ -106,7 +106,17 @@ let package = Package( // Libraries .target( name: "SwiftBuild", - dependencies: ["SWBCSupport", "SWBCore", "SWBProtocol", "SWBUtil", "SWBProjectModel"], + dependencies: [ + "SWBCSupport", + "SWBCore", + "SWBProtocol", + "SWBUtil", + "SWBProjectModel", + .product(name: "BuildServerProtocol", package: "swift-tools-protocols"), + .product(name: "LanguageServerProtocol", package: "swift-tools-protocols"), + .product(name: "LanguageServerProtocolTransport", package: "swift-tools-protocols") + + ], exclude: ["CMakeLists.txt"], swiftSettings: swiftSettings(languageMode: .v5)), .target( @@ -460,6 +470,7 @@ if useLocalDependencies { .package(path: "../swift-driver"), .package(path: "../swift-system"), .package(path: "../swift-argument-parser"), + .package(path: "../swift-tools-protocols"), ] if !useLLBuildFramework { package.dependencies += [.package(path: "../llbuild"),] @@ -469,6 +480,7 @@ if useLocalDependencies { .package(url: "https://github.com/swiftlang/swift-driver.git", branch: "main"), .package(url: "https://github.com/apple/swift-system.git", .upToNextMajor(from: "1.5.0")), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.0.3"), + .package(url: "https://github.com/swiftlang/swift-tools-protocols.git", branch: "main" /* TODO: replace with initial version */), ] if !useLLBuildFramework { package.dependencies += [.package(url: "https://github.com/swiftlang/swift-llbuild.git", branch: "main"),] diff --git a/Plugins/cmake-smoke-test/cmake-smoke-test.swift b/Plugins/cmake-smoke-test/cmake-smoke-test.swift index fcba09f3..88556fc7 100644 --- a/Plugins/cmake-smoke-test/cmake-smoke-test.swift +++ b/Plugins/cmake-smoke-test/cmake-smoke-test.swift @@ -58,7 +58,10 @@ struct CMakeSmokeTest: CommandPlugin { let swiftDriverURL = try findDependency("swift-driver", pluginContext: context) let swiftDriverBuildURL = context.pluginWorkDirectoryURL.appending(component: "swift-driver") - for url in [swiftToolsSupportCoreBuildURL, swiftSystemBuildURL, llbuildBuildURL, swiftArgumentParserBuildURL, swiftDriverBuildURL, swiftBuildBuildURL] { + let swiftToolsProtocolsURL = try findDependency("swift-tools-protocols", pluginContext: context) + let swiftToolsProtocolsBuildURL = context.pluginWorkDirectoryURL.appending(component: "swift-tools-protocols") + + for url in [swiftToolsSupportCoreBuildURL, swiftSystemBuildURL, llbuildBuildURL, swiftArgumentParserBuildURL, swiftDriverBuildURL, swiftToolsProtocolsBuildURL, swiftBuildBuildURL] { try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) } @@ -75,7 +78,8 @@ struct CMakeSmokeTest: CommandPlugin { "-DLLBuild_DIR=\(llbuildBuildURL.appending(components: "cmake", "modules").filePath)", "-DTSC_DIR=\(swiftToolsSupportCoreBuildURL.appending(components: "cmake", "modules").filePath)", "-DSwiftDriver_DIR=\(swiftDriverBuildURL.appending(components: "cmake", "modules").filePath)", - "-DSwiftSystem_DIR=\(swiftSystemBuildURL.appending(components: "cmake", "modules").filePath)" + "-DSwiftSystem_DIR=\(swiftSystemBuildURL.appending(components: "cmake", "modules").filePath)", + "-DSwiftToolsProtocols_DIR=\(swiftToolsProtocolsBuildURL.appending(components: "cmake", "modules").filePath)" ] let sharedCMakeArgs = [ @@ -112,6 +116,11 @@ struct CMakeSmokeTest: CommandPlugin { try await Process.checkNonZeroExit(url: ninjaURL, arguments: [], workingDirectory: swiftDriverBuildURL) Diagnostics.progress("Built swift-driver") + Diagnostics.progress("Building swift-tools-protocols") + try await Process.checkNonZeroExit(url: cmakeURL, arguments: sharedCMakeArgs + [swiftToolsProtocolsURL.filePath], workingDirectory: swiftToolsProtocolsBuildURL) + try await Process.checkNonZeroExit(url: ninjaURL, arguments: [], workingDirectory: swiftToolsProtocolsBuildURL) + Diagnostics.progress("Built swift-tools-protocols") + Diagnostics.progress("Building swift-build in \(swiftBuildBuildURL)") try await Process.checkNonZeroExit(url: cmakeURL, arguments: sharedCMakeArgs + [swiftBuildURL.filePath], workingDirectory: swiftBuildBuildURL) try await Process.checkNonZeroExit(url: ninjaURL, arguments: [], workingDirectory: swiftBuildBuildURL) diff --git a/Sources/SWBCore/Settings/BuiltinMacros.swift b/Sources/SWBCore/Settings/BuiltinMacros.swift index 247f44de..5a91c4fd 100644 --- a/Sources/SWBCore/Settings/BuiltinMacros.swift +++ b/Sources/SWBCore/Settings/BuiltinMacros.swift @@ -482,6 +482,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") @@ -1478,6 +1479,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/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 18da3458..184cbef8 100644 --- a/Sources/SwiftBuild/CMakeLists.txt +++ b/Sources/SwiftBuild/CMakeLists.txt @@ -36,6 +36,7 @@ add_library(SwiftBuild SWBBuildOperationBacktraceFrame.swift SWBBuildParameters.swift SWBBuildRequest.swift + SWBBuildServer.swift SWBBuildService.swift SWBBuildServiceConnection.swift SWBBuildServiceConsole.swift @@ -70,7 +71,13 @@ target_link_libraries(SwiftBuild PUBLIC SWBCore SWBProtocol SWBUtil - SWBProjectModel) + SWBProjectModel + SwiftToolsProtocols::LSPCAtomics + SwiftToolsProtocols::SKLogging + SwiftToolsProtocols::SwiftExtensions + SwiftToolsProtocols::BuildServerProtocol + SwiftToolsProtocols::LanguageServerProtocol + SwiftToolsProtocols::LanguageServerProtocolTransport) set_target_properties(SwiftBuild PROPERTIES INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_Swift_MODULE_DIRECTORY}) diff --git a/Sources/SwiftBuild/SWBBuildServer.swift b/Sources/SwiftBuild/SWBBuildServer.swift new file mode 100644 index 00000000..cfc83722 --- /dev/null +++ b/Sources/SwiftBuild/SWBBuildServer.swift @@ -0,0 +1,522 @@ +//===----------------------------------------------------------------------===// +// +// 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 BuildServerProtocol +public import LanguageServerProtocol +public import LanguageServerProtocolTransport +public import ToolsProtocolsSwiftExtensions +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() -> LanguageServerProtocol.VoidResponse { + state = .shutdown + return VoidResponse() + } + + private func waitForBuildSystemUpdates(request: WorkspaceWaitForBuildSystemUpdatesRequest) async -> LanguageServerProtocol.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 -> LanguageServerProtocol.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"))) + case .planningOperationCompleted(let info): + logToClient(.info, "Build Planning Complete", .end(.init())) + case .buildStarted(_): + logToClient(.log, "Building", .begin(.init(title: "Building"))) + case .buildDiagnostic(let info): + logToClient(.log, info.message, .report(.init())) + case .buildCompleted(let info): + switch info.result { + case .ok: + logToClient(.log, "Build Complete", .end(.init())) + case .failed: + logToClient(.log, "Build Failed", .end(.init())) + case .cancelled: + logToClient(.log, "Build Cancelled", .end(.init())) + case .aborted: + logToClient(.log, "Build Aborted", .end(.init())) + } + case .preparationComplete(_): + logToClient(.log, "Build Preparation Complete", .end(.init())) + case .didUpdateProgress(let info): + logToClient(.log, "Progress: \(info.percentComplete)%", .end(.init())) + case .taskStarted(let info): + logToClient(.log, info.executionDescription, .begin(.init(title: info.executionDescription))) + case .taskDiagnostic(let info): + logToClient(.log, info.message, .report(.init())) + case .taskComplete(let info): + logToClient(.log, "Task Complete", .end(.init())) + case .targetDiagnostic(let info): + logToClient(.log, info.message, .report(.init())) + case .diagnostic(let info): + logToClient(.log, info.message, .report(.init())) + case .backtraceFrame, .reportPathMap, .reportBuildDescription, .preparedForIndex, .buildOutput, .targetStarted, .targetComplete, .targetOutput, .targetUpToDate, .taskUpToDate, .taskOutput, .output: + break + } + } + } + + private func logToClient(_ kind: BuildServerProtocol.MessageType, _ message: String, _ structure: BuildServerProtocol.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))) + defer { + logToClient(.log, name, .end(.init())) + } + 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/Tests/SwiftBuildTests/BuildServerTests.swift b/Tests/SwiftBuildTests/BuildServerTests.swift new file mode 100644 index 00000000..de189f10 --- /dev/null +++ b/Tests/SwiftBuildTests/BuildServerTests.swift @@ -0,0 +1,497 @@ +//===----------------------------------------------------------------------===// +// +// 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 BuildServerProtocol +import LanguageServerProtocol +import LanguageServerProtocolTransport +import Synchronization +import SKLogging + +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 { + init() { + LoggingScope.configureDefaultLoggingSubsystem("org.swift.swift-build-tests") + } + + @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() + } + } + } +}