diff --git a/Sources/Basics/Observability.swift b/Sources/Basics/Observability.swift index 404d42a85c4..7d915fd1d1f 100644 --- a/Sources/Basics/Observability.swift +++ b/Sources/Basics/Observability.swift @@ -56,6 +56,10 @@ public class ObservabilitySystem { func handleDiagnostic(scope: ObservabilityScope, diagnostic: Diagnostic) { self.underlying(scope, diagnostic) } + + func print(_ output: String, verbose: Bool) { + self.diagnosticsHandler.print(output, verbose: verbose) + } } public static var NOOP: ObservabilityScope { @@ -128,6 +132,10 @@ public final class ObservabilityScope: DiagnosticsEmitterProtocol, Sendable, Cus return parent?.errorsReportedInAnyScope ?? false } + public func print(_ output: String, verbose: Bool) { + self.diagnosticsHandler.print(output, verbose: verbose) + } + // DiagnosticsEmitterProtocol public func emit(_ diagnostic: Diagnostic) { var diagnostic = diagnostic @@ -150,6 +158,10 @@ public final class ObservabilityScope: DiagnosticsEmitterProtocol, Sendable, Cus self.underlying.handleDiagnostic(scope: scope, diagnostic: diagnostic) } + public func print(_ output: String, verbose: Bool) { + self.underlying.print(output, verbose: verbose) + } + var errorsReported: Bool { self._errorsReported.get() ?? false } @@ -160,6 +172,8 @@ public final class ObservabilityScope: DiagnosticsEmitterProtocol, Sendable, Cus public protocol DiagnosticsHandler: Sendable { func handleDiagnostic(scope: ObservabilityScope, diagnostic: Diagnostic) + + func print(_ output: String, verbose: Bool) } /// Helper protocol to share default behavior. diff --git a/Sources/SwiftBuildSupport/CMakeLists.txt b/Sources/SwiftBuildSupport/CMakeLists.txt index ca9e15f938b..43956e93f4b 100644 --- a/Sources/SwiftBuildSupport/CMakeLists.txt +++ b/Sources/SwiftBuildSupport/CMakeLists.txt @@ -18,7 +18,8 @@ add_library(SwiftBuildSupport STATIC PIF.swift PIFBuilder.swift PluginConfiguration.swift - SwiftBuildSystem.swift) + SwiftBuildSystem.swift + SwiftBuildSystemMessageHandler.swift) target_link_libraries(SwiftBuildSupport PUBLIC Build DriverSupport diff --git a/Sources/SwiftBuildSupport/SwiftBuildSystem.swift b/Sources/SwiftBuildSupport/SwiftBuildSystem.swift index 9e10380367d..637a7a2f4e5 100644 --- a/Sources/SwiftBuildSupport/SwiftBuildSystem.swift +++ b/Sources/SwiftBuildSupport/SwiftBuildSystem.swift @@ -242,6 +242,10 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { public var hasIntegratedAPIDigesterSupport: Bool { true } + public var enableTaskBacktraces: Bool { + self.buildParameters.outputParameters.enableTaskBacktraces + } + public init( buildParameters: BuildParameters, packageGraphLoader: @escaping () async throws -> ModulesGraph, @@ -546,12 +550,12 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { return try await withService(connectionMode: .inProcessStatic(swiftbuildServiceEntryPoint)) { service in let derivedDataPath = self.buildParameters.dataPath - let progressAnimation = ProgressAnimation.ninja( - stream: self.outputStream, - verbose: self.logLevel.isVerbose + let buildMessageHandler = SwiftBuildSystemMessageHandler( + observabilityScope: self.observabilityScope, + outputStream: self.outputStream, + logLevel: self.logLevel ) - var serializedDiagnosticPathsByTargetName: [String: [Basics.AbsolutePath]] = [:] do { try await withSession(service: service, name: self.buildParameters.pifManifest.pathString, toolchain: self.buildParameters.toolchain, packageManagerResourcesDirectory: self.packageManagerResourcesDirectory) { session, _ in self.outputStream.send("Building for \(self.buildParameters.configuration == .debug ? "debugging" : "production")...\n") @@ -591,148 +595,6 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { let request = try await self.makeBuildRequest(session: session, configuredTargets: configuredTargets, derivedDataPath: derivedDataPath, symbolGraphOptions: symbolGraphOptions) - struct BuildState { - private var targetsByID: [Int: SwiftBuild.SwiftBuildMessage.TargetStartedInfo] = [:] - private var activeTasks: [Int: SwiftBuild.SwiftBuildMessage.TaskStartedInfo] = [:] - var collectedBacktraceFrames = SWBBuildOperationCollectedBacktraceFrames() - - mutating func started(task: SwiftBuild.SwiftBuildMessage.TaskStartedInfo) throws { - if activeTasks[task.taskID] != nil { - throw Diagnostics.fatalError - } - activeTasks[task.taskID] = task - } - - mutating func completed(task: SwiftBuild.SwiftBuildMessage.TaskCompleteInfo) throws -> SwiftBuild.SwiftBuildMessage.TaskStartedInfo { - guard let task = activeTasks[task.taskID] else { - throw Diagnostics.fatalError - } - return task - } - - mutating func started(target: SwiftBuild.SwiftBuildMessage.TargetStartedInfo) throws { - if targetsByID[target.targetID] != nil { - throw Diagnostics.fatalError - } - targetsByID[target.targetID] = target - } - - mutating func target(for task: SwiftBuild.SwiftBuildMessage.TaskStartedInfo) throws -> SwiftBuild.SwiftBuildMessage.TargetStartedInfo? { - guard let id = task.targetID else { - return nil - } - guard let target = targetsByID[id] else { - throw Diagnostics.fatalError - } - return target - } - } - - func emitEvent(_ message: SwiftBuild.SwiftBuildMessage, buildState: inout BuildState) throws { - guard !self.logLevel.isQuiet else { return } - switch message { - case .buildCompleted(let info): - progressAnimation.complete(success: info.result == .ok) - if info.result == .cancelled { - self.delegate?.buildSystemDidCancel(self) - } else { - self.delegate?.buildSystem(self, didFinishWithResult: info.result == .ok) - } - case .didUpdateProgress(let progressInfo): - var step = Int(progressInfo.percentComplete) - if step < 0 { step = 0 } - let message = if let targetName = progressInfo.targetName { - "\(targetName) \(progressInfo.message)" - } else { - "\(progressInfo.message)" - } - progressAnimation.update(step: step, total: 100, text: message) - self.delegate?.buildSystem(self, didUpdateTaskProgress: message) - case .diagnostic(let info): - func emitInfoAsDiagnostic(info: SwiftBuildMessage.DiagnosticInfo) { - let fixItsDescription = if info.fixIts.hasContent { - ": " + info.fixIts.map { String(describing: $0) }.joined(separator: ", ") - } else { - "" - } - let message = if let locationDescription = info.location.userDescription { - "\(locationDescription) \(info.message)\(fixItsDescription)" - } else { - "\(info.message)\(fixItsDescription)" - } - let severity: Diagnostic.Severity = switch info.kind { - case .error: .error - case .warning: .warning - case .note: .info - case .remark: .debug - } - self.observabilityScope.emit(severity: severity, message: "\(message)\n") - - for childDiagnostic in info.childDiagnostics { - emitInfoAsDiagnostic(info: childDiagnostic) - } - } - - emitInfoAsDiagnostic(info: info) - case .output(let info): - self.observabilityScope.emit(info: "\(String(decoding: info.data, as: UTF8.self))") - case .taskStarted(let info): - try buildState.started(task: info) - - if let commandLineDisplay = info.commandLineDisplayString { - self.observabilityScope.emit(info: "\(info.executionDescription)\n\(commandLineDisplay)") - } else { - self.observabilityScope.emit(info: "\(info.executionDescription)") - } - - if self.logLevel.isVerbose { - if let commandLineDisplay = info.commandLineDisplayString { - self.outputStream.send("\(info.executionDescription)\n\(commandLineDisplay)") - } else { - self.outputStream.send("\(info.executionDescription)") - } - } - let targetInfo = try buildState.target(for: info) - self.delegate?.buildSystem(self, willStartCommand: BuildSystemCommand(info, targetInfo: targetInfo)) - self.delegate?.buildSystem(self, didStartCommand: BuildSystemCommand(info, targetInfo: targetInfo)) - case .taskComplete(let info): - let startedInfo = try buildState.completed(task: info) - if info.result != .success { - self.observabilityScope.emit(severity: .error, message: "\(startedInfo.ruleInfo) failed with a nonzero exit code. Command line: \(startedInfo.commandLineDisplayString ?? "")") - } - let targetInfo = try buildState.target(for: startedInfo) - self.delegate?.buildSystem(self, didFinishCommand: BuildSystemCommand(startedInfo, targetInfo: targetInfo)) - if let targetName = targetInfo?.targetName { - serializedDiagnosticPathsByTargetName[targetName, default: []].append(contentsOf: startedInfo.serializedDiagnosticsPaths.compactMap { - try? Basics.AbsolutePath(validating: $0.pathString) - }) - } - if self.buildParameters.outputParameters.enableTaskBacktraces { - if let id = SWBBuildOperationBacktraceFrame.Identifier(taskSignatureData: Data(startedInfo.taskSignature.utf8)), - let backtrace = SWBTaskBacktrace(from: id, collectedFrames: buildState.collectedBacktraceFrames) { - let formattedBacktrace = backtrace.renderTextualRepresentation() - if !formattedBacktrace.isEmpty { - self.observabilityScope.emit(info: "Task backtrace:\n\(formattedBacktrace)") - } - } - } - case .targetStarted(let info): - try buildState.started(target: info) - case .backtraceFrame(let info): - if self.buildParameters.outputParameters.enableTaskBacktraces { - buildState.collectedBacktraceFrames.add(frame: info) - } - case .planningOperationStarted, .planningOperationCompleted, .reportBuildDescription, .reportPathMap, .preparedForIndex, .buildStarted, .preparationComplete, .targetUpToDate, .targetComplete, .taskUpToDate: - break - case .buildDiagnostic, .targetDiagnostic, .taskDiagnostic: - break // deprecated - case .buildOutput, .targetOutput, .taskOutput: - break // deprecated - @unknown default: - break - } - } - let operation = try await session.createBuildOperation( request: request, delegate: SwiftBuildSystemPlanningOperationDelegate(), @@ -740,7 +602,6 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { ) var buildDescriptionID: SWBBuildDescriptionID? = nil - var buildState = BuildState() for try await event in try await operation.start() { if case .reportBuildDescription(let info) = event { if buildDescriptionID != nil { @@ -748,7 +609,7 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { } buildDescriptionID = SWBBuildDescriptionID(info.buildDescriptionID) } - try emitEvent(event, buildState: &buildState) + try buildMessageHandler.emitEvent(event, self) } await operation.waitForCompletion() @@ -756,8 +617,8 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { switch operation.state { case .succeeded: guard !self.logLevel.isQuiet else { return } - progressAnimation.update(step: 100, total: 100, text: "") - progressAnimation.complete(success: true) + buildMessageHandler.progressAnimation.update(step: 100, total: 100, text: "") + buildMessageHandler.progressAnimation.complete(success: true) let duration = ContinuousClock.Instant.now - buildStartTime let formattedDuration = duration.formatted(.units(allowed: [.seconds], fractionalPart: .show(length: 2, rounded: .up))) self.outputStream.send("Build complete! (\(formattedDuration))\n") @@ -824,7 +685,7 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { } return BuildResult( - serializedDiagnosticPathsByTargetName: .success(serializedDiagnosticPathsByTargetName), + serializedDiagnosticPathsByTargetName: .success(buildMessageHandler.serializedDiagnosticPathsByTargetName), symbolGraph: SymbolGraphResult( outputLocationForTarget: { target, buildParameters in return ["\(buildParameters.triple.archName)", "\(target).symbolgraphs"] @@ -1299,46 +1160,6 @@ extension String { } } -fileprivate extension SwiftBuild.SwiftBuildMessage.DiagnosticInfo.Location { - var userDescription: String? { - switch self { - case .path(let path, let fileLocation): - switch fileLocation { - case .textual(let line, let column): - var description = "\(path):\(line)" - if let column { description += ":\(column)" } - return description - case .object(let identifier): - return "\(path):\(identifier)" - case .none: - return path - } - - case .buildSettings(let names): - return names.joined(separator: ", ") - - case .buildFiles(let buildFiles, let targetGUID): - return "\(targetGUID): " + buildFiles.map { String(describing: $0) }.joined(separator: ", ") - - case .unknown: - return nil - } - } -} - -fileprivate extension BuildSystemCommand { - init(_ taskStartedInfo: SwiftBuildMessage.TaskStartedInfo, targetInfo: SwiftBuildMessage.TargetStartedInfo?) { - self = .init( - name: taskStartedInfo.executionDescription, - targetName: targetInfo?.targetName, - description: taskStartedInfo.commandLineDisplayString ?? "", - serializedDiagnosticPaths: taskStartedInfo.serializedDiagnosticsPaths.compactMap { - try? Basics.AbsolutePath(validating: $0.pathString) - } - ) - } -} - fileprivate extension Triple { var deploymentTargetSettingName: String? { switch (self.os, self.environment) { diff --git a/Sources/SwiftBuildSupport/SwiftBuildSystemMessageHandler.swift b/Sources/SwiftBuildSupport/SwiftBuildSystemMessageHandler.swift new file mode 100644 index 00000000000..6c43fafbfe3 --- /dev/null +++ b/Sources/SwiftBuildSupport/SwiftBuildSystemMessageHandler.swift @@ -0,0 +1,747 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +@_spi(SwiftPMInternal) +import Basics +import Foundation +@_spi(SwiftPMInternal) +import SPMBuildCore +import enum TSCUtility.Diagnostics +import SWBBuildService +import SwiftBuild +import protocol TSCBasic.OutputByteStream + + +/// Handler for SwiftBuildMessage events sent by the SWBBuildOperation. +public final class SwiftBuildSystemMessageHandler { + private let observabilityScope: ObservabilityScope + private let logLevel: Basics.Diagnostic.Severity + private var buildState: BuildState = .init() + + let progressAnimation: ProgressAnimationProtocol + var serializedDiagnosticPathsByTargetID: [Int: [Basics.AbsolutePath]] = [:] + // FIXME: This eventually gets passed into the BuildResult, which expects a + // dictionary of [String: [AbsolutePath]]. Eventually, we should refactor it + // to accept a dictionary keyed by a unique identifier (possibly `ResolvedModule.ID`), + // and instead use the above dictionary keyed by target ID. + var serializedDiagnosticPathsByTargetName: [String: [Basics.AbsolutePath]] { + serializedDiagnosticPathsByTargetID.reduce(into: [:]) { result, entry in + if let name = buildState.targetsByID[entry.key]?.targetName { + result[name, default: []].append(contentsOf: entry.value) + } + } + } + + /// Tracks the task IDs for failed tasks. + private var failedTasks: [Int] = [] + /// Tracks the tasks by their signature for which we have already emitted output. + private var tasksEmitted: EmittedTasks = .init() + + public init( + observabilityScope: ObservabilityScope, + outputStream: OutputByteStream, + logLevel: Basics.Diagnostic.Severity + ) + { + self.observabilityScope = observabilityScope + self.logLevel = logLevel + self.progressAnimation = ProgressAnimation.ninja( + stream: outputStream, + verbose: self.logLevel.isVerbose + ) + } + + private func emitInfoAsDiagnostic(info: SwiftBuildMessage.DiagnosticInfo) { + let fixItsDescription = if info.fixIts.hasContent { + ": " + info.fixIts.map { String(describing: $0) }.joined(separator: ", ") + } else { + "" + } + let message = if let locationDescription = info.location.userDescription { + "\(locationDescription) \(info.message)\(fixItsDescription)" + } else { + "\(info.message)\(fixItsDescription)" + } + let severity: Diagnostic.Severity = switch info.kind { + case .error: .error + case .warning: .warning + case .note: .info + case .remark: .debug + } + self.observabilityScope.emit(severity: severity, message: "\(message)\n") + + for childDiagnostic in info.childDiagnostics { + emitInfoAsDiagnostic(info: childDiagnostic) + } + } + + private func emitDiagnosticCompilerOutput(_ info: SwiftBuildMessage.TaskStartedInfo) { + // Don't redundantly emit task output. + guard !self.tasksEmitted.contains(info.taskSignature) else { + return + } + // Assure we have a data buffer to decode. + guard let buffer = buildState.dataBuffer(for: info) else { + return + } + + // Decode the buffer to a string + let decodedOutput = String(decoding: buffer, as: UTF8.self) + + // Emit message. + observabilityScope.print(decodedOutput, verbose: self.logLevel.isVerbose) + + // Record that we've emitted the output for a given task signature. + self.tasksEmitted.insert(info) + } + + private func handleTaskOutput( + _ info: SwiftBuildMessage.TaskCompleteInfo, + _ startedInfo: SwiftBuildMessage.TaskStartedInfo, + _ enableTaskBacktraces: Bool + ) throws { + if info.result != .success { + let diagnostics = self.buildState.diagnostics(for: info) + if diagnostics.isEmpty { + // Handle diagnostic via textual compiler output. + emitFailedTaskOutput(info, startedInfo) + } else { + // Handle diagnostic via diagnostic info struct. + diagnostics.forEach({ emitInfoAsDiagnostic(info: $0) }) + } + } else if let data = buildState.dataBuffer(for: startedInfo), !tasksEmitted.contains(startedInfo.taskSignature) { + let decodedOutput = String(decoding: data, as: UTF8.self) + if !decodedOutput.isEmpty { + observabilityScope.emit(info: decodedOutput) + } + } + + // Handle task backtraces, if applicable. + if enableTaskBacktraces { + if let id = SWBBuildOperationBacktraceFrame.Identifier(taskSignatureData: Data(startedInfo.taskSignature.utf8)), + let backtrace = SWBTaskBacktrace(from: id, collectedFrames: buildState.collectedBacktraceFrames) { + let formattedBacktrace = backtrace.renderTextualRepresentation() + if !formattedBacktrace.isEmpty { + self.observabilityScope.emit(info: "Task backtrace:\n\(formattedBacktrace)") + } + } + } + } + + private func emitFailedTaskOutput( + _ info: SwiftBuildMessage.TaskCompleteInfo, + _ startedInfo: SwiftBuildMessage.TaskStartedInfo + ) { + // Assure that the task has failed. + guard info.result != .success else { + return + } + // Don't redundantly emit task output. + guard !tasksEmitted.contains(startedInfo.taskSignature) else { + return + } + + // Track failed tasks. + self.failedTasks.append(info.taskID) + + // Check for existing diagnostics with matching taskID/taskSignature. + // If we've captured the compiler output with formatted diagnostics keyed by + // this task's signature, emit them. + // Note that this is a workaround instead of emitting directly from a `DiagnosticInfo` + // message, as here we receive the formatted code snippet directly from the compiler. + emitDiagnosticCompilerOutput(startedInfo) + + let message = "\(startedInfo.ruleInfo) failed with a nonzero exit code." + // If we have the command line display string available, then we + // should continue to emit this as an error. Otherwise, this doesn't + // give enough information to the user for it to be useful so we can + // demote it to an info-level log. + if let cmdLineDisplayStr = startedInfo.commandLineDisplayString { + self.observabilityScope.emit(severity: .error, message: "\(message) Command line: \(cmdLineDisplayStr)") + } else { + self.observabilityScope.emit(severity: .info, message: message) + } + + // Track that we have emitted output for this task. + tasksEmitted.insert(startedInfo) + } + + func emitEvent(_ message: SwiftBuild.SwiftBuildMessage, _ buildSystem: SwiftBuildSystem) throws { + guard !self.logLevel.isQuiet else { return } + switch message { + case .buildCompleted(let info): + progressAnimation.complete(success: info.result == .ok) + if info.result == .cancelled { + buildSystem.delegate?.buildSystemDidCancel(buildSystem) + } else { + buildSystem.delegate?.buildSystem(buildSystem, didFinishWithResult: info.result == .ok) + } + case .didUpdateProgress(let progressInfo): + var step = Int(progressInfo.percentComplete) + if step < 0 { step = 0 } + let message = if let targetName = progressInfo.targetName { + "\(targetName) \(progressInfo.message)" + } else { + "\(progressInfo.message)" + } + progressAnimation.update(step: step, total: 100, text: message) + buildSystem.delegate?.buildSystem(buildSystem, didUpdateTaskProgress: message) + case .diagnostic(let info): + // If this is representative of a global/target diagnostic + // then we can emit immediately. + // Otherwise, defer emission of diagnostic to matching taskCompleted event. + if info.locationContext.isGlobal || info.locationContext.isTarget { + emitInfoAsDiagnostic(info: info) + } else if info.appendToOutputStream { + buildState.appendDiagnostic(info) + } + case .output(let info): + // Append to buffer-per-task storage + buildState.appendToBuffer(info) + case .taskStarted(let info): + try buildState.started(task: info) + + let targetInfo = try buildState.target(for: info) + buildSystem.delegate?.buildSystem(buildSystem, willStartCommand: BuildSystemCommand(info, targetInfo: targetInfo)) + buildSystem.delegate?.buildSystem(buildSystem, didStartCommand: BuildSystemCommand(info, targetInfo: targetInfo)) + case .taskComplete(let info): + let startedInfo = try buildState.completed(task: info) + + // Handler for failed tasks, if applicable. + try handleTaskOutput(info, startedInfo, buildSystem.enableTaskBacktraces) + + let targetInfo = try buildState.target(for: startedInfo) + buildSystem.delegate?.buildSystem(buildSystem, didFinishCommand: BuildSystemCommand(startedInfo, targetInfo: targetInfo)) + if let targetID = targetInfo?.targetID { + try serializedDiagnosticPathsByTargetID[targetID, default: []].append(contentsOf: startedInfo.serializedDiagnosticsPaths.compactMap { + try Basics.AbsolutePath(validating: $0.pathString) + }) + } + case .targetStarted(let info): + try buildState.started(target: info) + case .backtraceFrame(let info): + if buildSystem.enableTaskBacktraces { + buildState.collectedBacktraceFrames.add(frame: info) + } + case .targetComplete(let info): + _ = try buildState.completed(target: info) + case .planningOperationStarted, .planningOperationCompleted, .reportBuildDescription, .reportPathMap, .preparedForIndex, .buildStarted, .preparationComplete, .targetUpToDate, .taskUpToDate: + break + case .buildDiagnostic, .targetDiagnostic, .taskDiagnostic: + break // deprecated + case .buildOutput, .targetOutput, .taskOutput: + break // deprecated + @unknown default: + break + } + } +} + +// MARK: SwiftBuildSystemMessageHandler.BuildState + +extension SwiftBuildSystemMessageHandler { + /// Manages the state of an active build operation, tracking targets, tasks, buffers, and backtrace frames. + /// This struct maintains the complete state model for build operations, coordinating data between + /// different phases of the build lifecycle. + struct BuildState { + // Targets + internal var targetsByID: [Int: SwiftBuild.SwiftBuildMessage.TargetStartedInfo] = [:] + private var completedTargets: [Int: SwiftBuild.SwiftBuildMessage.TargetCompleteInfo] = [:] + + // Tasks + private var activeTasks: [Int: SwiftBuild.SwiftBuildMessage.TaskStartedInfo] = [:] + private var completedTasks: [Int: SwiftBuild.SwiftBuildMessage.TaskCompleteInfo] = [:] + private var taskIDToSignature: [Int: String] = [:] + + // Per-task buffers + private var taskDataBuffer: TaskDataBuffer = .init() + private var diagnosticsBuffer: TaskDiagnosticBuffer = .init() + + // Backtrace frames + internal var collectedBacktraceFrames = SWBBuildOperationCollectedBacktraceFrames() + + /// Registers the start of a build task, validating that the task hasn't already been started. + /// - Parameter task: The task start information containing task ID and signature + /// - Throws: Fatal error if the task is already active + mutating func started(task: SwiftBuild.SwiftBuildMessage.TaskStartedInfo) throws { + if activeTasks[task.taskID] != nil { + throw Diagnostics.fatalError + } + activeTasks[task.taskID] = task + taskIDToSignature[task.taskID] = task.taskSignature + } + + /// Marks a task as completed and removes it from active tracking. + /// - Parameter task: The task completion information + /// - Returns: The original task start information for the completed task + /// - Throws: Fatal error if the task was not started or already completed + mutating func completed(task: SwiftBuild.SwiftBuildMessage.TaskCompleteInfo) throws -> SwiftBuild.SwiftBuildMessage.TaskStartedInfo { + guard let startedTaskInfo = activeTasks[task.taskID] else { + throw Diagnostics.fatalError + } + if completedTasks[task.taskID] != nil { + throw Diagnostics.fatalError + } + // Track completed task, remove from active tasks. + self.completedTasks[task.taskID] = task + self.activeTasks[task.taskID] = nil + + return startedTaskInfo + } + + /// Registers the start of a build target, validating that the target hasn't already been started. + /// - Parameter target: The target start information containing target ID and name + /// - Throws: Fatal error if the target is already active + mutating func started(target: SwiftBuild.SwiftBuildMessage.TargetStartedInfo) throws { + if targetsByID[target.targetID] != nil { + throw Diagnostics.fatalError + } + targetsByID[target.targetID] = target + } + + /// Marks a target as completed and removes it from active tracking. + /// - Parameter target: The target completion information + /// - Returns: The original target start information for the completed target + /// - Throws: Fatal error if the target was not started + mutating func completed(target: SwiftBuild.SwiftBuildMessage.TargetCompleteInfo) throws -> SwiftBuild.SwiftBuildMessage.TargetStartedInfo { + guard let targetStartedInfo = targetsByID[target.targetID] else { + throw Diagnostics.fatalError + } + + targetsByID[target.targetID] = nil + completedTargets[target.targetID] = target + return targetStartedInfo + } + + /// Retrieves the target information associated with a given task. + /// - Parameter task: The task start information to look up the target for + /// - Returns: The target start information if the task has an associated target, nil otherwise + /// - Throws: Fatal error if the target ID exists but no matching target is found + func target(for task: SwiftBuild.SwiftBuildMessage.TaskStartedInfo) throws -> SwiftBuild.SwiftBuildMessage.TargetStartedInfo? { + guard let id = task.targetID else { + return nil + } + guard let target = targetsByID[id] else { + throw Diagnostics.fatalError + } + return target + } + + /// Retrieves the task signature for a given task ID. + /// - Parameter id: The task ID to look up + /// - Returns: The task signature string if found, nil otherwise + func taskSignature(for id: Int) -> String? { + if let signature = taskIDToSignature[id] { + return signature + } + return nil + } + } +} + +// MARK: - SwiftBuildSystemMessageHandler.BuildState.TaskDataBuffer + +extension SwiftBuildSystemMessageHandler.BuildState { + /// Manages data buffers for build tasks, supporting multiple indexing strategies. + /// This buffer system stores output data from tasks using both task signatures and task IDs, + /// providing flexible access patterns for different build message types and legacy support. + struct TaskDataBuffer { + private var taskSignatureBuffer: [String: Data] = [:] + private var taskIDBuffer: [Int: Data] = [:] + + /// Retrieves data for a task signature key. + /// - Parameter key: The task signature string + /// - Returns: The associated data buffer, or nil if not found + subscript(key: String) -> Data? { + self.taskSignatureBuffer[key] + } + + /// Retrieves or sets data for a task signature key with a default value. + /// - Parameters: + /// - key: The task signature string + /// - defaultValue: The default data to return/store if no value exists + /// - Returns: The stored data buffer or the default value + subscript(key: String, default defaultValue: Data) -> Data { + get { self.taskSignatureBuffer[key] ?? defaultValue } + set { self.taskSignatureBuffer[key] = newValue } + } + + /// Retrieves or sets data using a LocationContext for task identification. + /// - Parameters: + /// - key: The location context containing task or target ID information + /// - defaultValue: The default data to return/store if no value exists + /// - Returns: The stored data buffer or the default value + subscript(key: SwiftBuildMessage.LocationContext, default defaultValue: Data) -> Data { + get { + // Check each ID kind and try to fetch the associated buffer. + // If unable to get a non-nil result, then follow through to the + // next check. + if let taskID = key.taskID, + let result = self.taskIDBuffer[taskID] { + return result + } else { + return defaultValue + } + } + + set { + if let taskID = key.taskID { + self.taskIDBuffer[taskID] = newValue + } + } + } + + /// Retrieves or sets data using a LocationContext2 for task identification. + /// - Parameter key: The location context containing task signature information + /// - Returns: The associated data buffer, or nil if not found + subscript(key: SwiftBuildMessage.LocationContext2) -> Data? { + get { + if let taskSignature = key.taskSignature { + return self.taskSignatureBuffer[taskSignature] + } + + return nil + } + + set { + if let taskSignature = key.taskSignature { + self.taskSignatureBuffer[taskSignature] = newValue + } + } + } + + /// Retrieves data for a specific task using TaskStartedInfo. + /// - Parameter task: The task start information containing signature and ID + /// - Returns: The associated data buffer, or nil if not found + subscript(task: SwiftBuildMessage.TaskStartedInfo) -> Data? { + get { + guard let result = self.taskSignatureBuffer[task.taskSignature] else { + // Default to checking targetID and taskID. + if let result = self.taskIDBuffer[task.taskID] { + return result + } + + return nil + } + + return result + } + } + } + + /// Appends output data to the appropriate task buffer based on location context information. + /// - Parameter info: The output info containing data and location context for storage + mutating func appendToBuffer(_ info: SwiftBuildMessage.OutputInfo) { + // Attempt to key by taskSignature; at times this may not be possible, + // in which case we'd need to fall back to using LocationContext. + guard let taskSignature = info.locationContext2.taskSignature else { + // If we cannot find the task signature from the locationContext2, + // use deprecated locationContext instead to find task signature. + // If this fails to find an associated task signature, track + // relevant IDs from the location context in the task buffer. + if let taskID = info.locationContext.taskID, + let taskSignature = self.taskSignature(for: taskID) { + self.taskDataBuffer[taskSignature, default: .init()].append(info.data) + } + + self.taskDataBuffer[info.locationContext, default: .init()].append(info.data) + + return + } + + self.taskDataBuffer[taskSignature, default: .init()].append(info.data) + } + + /// Retrieves the accumulated data buffer for a specific task. + /// - Parameter task: The task start information to look up data for + /// - Returns: The accumulated data buffer for the task, or nil if no data exists + func dataBuffer(for task: SwiftBuild.SwiftBuildMessage.TaskStartedInfo) -> Data? { + guard let data = taskDataBuffer[task.taskSignature] else { + // Fallback to checking taskID and targetID. + return taskDataBuffer[task] + } + + return data + } + +} + +// MARK: - SwiftBuildSystemMessageHandler.BuildState.TaskDiagnosticBuffer + +extension SwiftBuildSystemMessageHandler.BuildState { + /// Manages diagnostic information buffers for build tasks, organized by task signatures and IDs. + /// This buffer system collects diagnostic messages during task execution for later retrieval + /// and structured reporting of build errors, warnings, and other diagnostic information. + struct TaskDiagnosticBuffer { + private var diagnosticSignatureBuffer: [String: [SwiftBuildMessage.DiagnosticInfo]] = [:] + private var diagnosticIDBuffer: [Int: [SwiftBuildMessage.DiagnosticInfo]] = [:] + + /// Retrieves diagnostic information using LocationContext2 for task identification. + /// - Parameter key: The location context containing task signature information + /// - Returns: Array of diagnostic info for the task, or nil if not found + subscript(key: SwiftBuildMessage.LocationContext2) -> [SwiftBuildMessage.DiagnosticInfo]? { + guard let taskSignature = key.taskSignature else { + return nil + } + return self.diagnosticSignatureBuffer[taskSignature] + } + + /// Retrieves or sets diagnostic information using LocationContext2 with a default value. + /// - Parameters: + /// - key: The location context containing task signature information + /// - defaultValue: The default diagnostic array to return if no value exists + /// - Returns: Array of diagnostic info for the task, or the default value + subscript(key: SwiftBuildMessage.LocationContext2, default defaultValue: [SwiftBuildMessage.DiagnosticInfo]) -> [SwiftBuildMessage.DiagnosticInfo] { + get { self[key] ?? defaultValue } + set { + self[key, default: defaultValue] + } + } + + /// Retrieves diagnostic information using LocationContext for task identification. + /// - Parameter key: The location context containing task ID information + /// - Returns: Array of diagnostic info for the task, or nil if not found + subscript(key: SwiftBuildMessage.LocationContext) -> [SwiftBuildMessage.DiagnosticInfo]? { + guard let taskID = key.taskID else { + return nil + } + + return self.diagnosticIDBuffer[taskID] + } + + /// Retrieves diagnostic information using LocationContext with a default value. + /// - Parameters: + /// - key: The location context containing task ID information + /// - defaultValue: The default diagnostic array to return if no value exists + /// - Returns: Array of diagnostic info for the task, or the default value + subscript(key: SwiftBuildMessage.LocationContext, default defaultValue: [SwiftBuildMessage.DiagnosticInfo]) -> [SwiftBuildMessage.DiagnosticInfo] { + get { self[key] ?? defaultValue } + } + + /// Retrieves or sets diagnostic information using a task signature string. + /// - Parameter key: The task signature string + /// - Returns: Array of diagnostic info for the task signature + subscript(key: String) -> [SwiftBuildMessage.DiagnosticInfo] { + get { self.diagnosticSignatureBuffer[key] ?? [] } + set { self.diagnosticSignatureBuffer[key] = newValue } + } + + /// Retrieves or sets diagnostic information using a task ID. + /// - Parameter key: The task ID + /// - Returns: Array of diagnostic info for the task ID + subscript(key: Int) -> [SwiftBuildMessage.DiagnosticInfo] { + get { self.diagnosticIDBuffer[key] ?? [] } + set { self.diagnosticIDBuffer[key] = newValue } + } + } + + /// Appends a diagnostic message to the appropriate diagnostic buffer. + /// - Parameter info: The diagnostic information to store, containing location context for identification + mutating func appendDiagnostic(_ info: SwiftBuildMessage.DiagnosticInfo) { + guard let taskID = info.locationContext.taskID else { + return + } + + diagnosticsBuffer[taskID].append(info) + } + + /// Retrieves all diagnostic information for a completed task. + /// - Parameter task: The task completion information containing the task ID + /// - Returns: Array of diagnostic info associated with the task + func diagnostics(for task: SwiftBuild.SwiftBuildMessage.TaskCompleteInfo) -> [SwiftBuildMessage.DiagnosticInfo] { + return diagnosticsBuffer[task.taskID] + } +} + +// MARK: - SwiftBuildSystemMessageHandler.EmittedTasks + +extension SwiftBuildSystemMessageHandler { + /// A collection that tracks tasks for which output has already been emitted to prevent duplicate output. + /// This struct ensures that task output is only displayed once during the build process, improving + /// the readability and accuracy of build logs by avoiding redundant messaging. + struct EmittedTasks: Collection { + public typealias Index = Set.Index + public typealias Element = Set.Element + var startIndex: Set.Index { + self.storage.startIndex + } + var endIndex: Set.Index { + self.storage.endIndex + } + + private var storage: Set = [] + + public init() { } + + /// Inserts a task info into the emitted tasks collection. + /// - Parameter task: The task information to mark as emitted + mutating func insert(_ task: TaskInfo) { + storage.insert(task) + } + + subscript(position: Index) -> Element { + return storage[position] + } + + func index(after i: Set.Index) -> Set.Index { + return storage.index(after: i) + } + + /// Checks if a specific task info has been marked as emitted. + /// - Parameter task: The task information to check + /// - Returns: True if the task has already been emitted, false otherwise + func contains(_ task: TaskInfo) -> Bool { + return storage.contains(task) + } + + /// Checks if a task with the given ID has been marked as emitted. + /// - Parameter taskID: The task ID to check + /// - Returns: True if a task with this ID has already been emitted, false otherwise + public func contains(_ taskID: Int) -> Bool { + return storage.contains(where: { $0.taskID == taskID }) + } + + /// Checks if a task with the given signature has been marked as emitted. + /// - Parameter taskSignature: The task signature to check + /// - Returns: True if a task with this signature has already been emitted, false otherwise + public func contains(_ taskSignature: String) -> Bool { + return storage.contains(where: { $0.taskSignature == taskSignature }) + } + + /// Convenience method to insert a task using TaskStartedInfo. + /// - Parameter startedTaskInfo: The task start information to mark as emitted + public mutating func insert(_ startedTaskInfo: SwiftBuildMessage.TaskStartedInfo) { + self.storage.insert(.init(startedTaskInfo)) + } + } + + /// Represents essential identifying information for a build task. + /// This struct encapsulates both the numeric task ID and string task signature, + /// providing efficient lookup and comparison capabilities for task tracking. + struct TaskInfo: Hashable { + let taskID: Int + let taskSignature: String + + /// Initializes TaskInfo from TaskStartedInfo. + /// - Parameter startedTaskInfo: The task start information containing ID and signature + public init(_ startedTaskInfo: SwiftBuildMessage.TaskStartedInfo) { + self.taskID = startedTaskInfo.taskID + self.taskSignature = startedTaskInfo.taskSignature + } + + /// Compares TaskInfo with a task signature string. + /// - Parameters: + /// - lhs: The TaskInfo instance + /// - rhs: The task signature string to compare + /// - Returns: True if the TaskInfo's signature matches the string + public static func ==(lhs: Self, rhs: String) -> Bool { + return lhs.taskSignature == rhs + } + + /// Compares TaskInfo with a task ID integer. + /// - Parameters: + /// - lhs: The TaskInfo instance + /// - rhs: The task ID integer to compare + /// - Returns: True if the TaskInfo's ID matches the integer + public static func ==(lhs: Self, rhs: Int) -> Bool { + return lhs.taskID == rhs + } + } +} + +/// Convenience extensions to extract taskID and targetID from the LocationContext. +extension SwiftBuildMessage.LocationContext { + /// Extracts the task ID from the location context. + /// - Returns: The task ID if the context represents a task or global task, nil otherwise + var taskID: Int? { + switch self { + case .task(let id, _), .globalTask(let id): + return id + case .target, .global: + return nil + } + } + + /// Extracts the target ID from the location context. + /// - Returns: The target ID if the context represents a task or target, nil otherwise + var targetID: Int? { + switch self { + case .task(_, let id), .target(let id): + return id + case .global, .globalTask: + return nil + } + } + + /// Determines if the location context represents a global scope. + /// - Returns: True if the context is global, false otherwise + var isGlobal: Bool { + switch self { + case .global: + return true + case .task, .target, .globalTask: + return false + } + } + + /// Determines if the location context represents a target scope. + /// - Returns: True if the context is target-specific, false otherwise + var isTarget: Bool { + switch self { + case .target: + return true + case .global, .globalTask, .task: + return false + } + } +} + + +fileprivate extension SwiftBuild.SwiftBuildMessage.DiagnosticInfo.Location { + var userDescription: String? { + switch self { + case .path(let path, let fileLocation): + switch fileLocation { + case .textual(let line, let column): + var description = "\(path):\(line)" + if let column { description += ":\(column)" } + return description + case .object(let identifier): + return "\(path):\(identifier)" + case .none: + return path + } + + case .buildSettings(let names): + return names.joined(separator: ", ") + + case .buildFiles(let buildFiles, let targetGUID): + return "\(targetGUID): " + buildFiles.map { String(describing: $0) }.joined(separator: ", ") + + case .unknown: + return nil + } + } +} + +fileprivate extension BuildSystemCommand { + init(_ taskStartedInfo: SwiftBuildMessage.TaskStartedInfo, targetInfo: SwiftBuildMessage.TargetStartedInfo?) { + self = .init( + name: taskStartedInfo.executionDescription, + targetName: targetInfo?.targetName, + description: taskStartedInfo.commandLineDisplayString ?? "", + serializedDiagnosticPaths: taskStartedInfo.serializedDiagnosticsPaths.compactMap { + try? Basics.AbsolutePath(validating: $0.pathString) + } + ) + } +} diff --git a/Sources/_InternalTestSupport/Observability.swift b/Sources/_InternalTestSupport/Observability.swift index 2d934abf8dc..4d92d05a114 100644 --- a/Sources/_InternalTestSupport/Observability.swift +++ b/Sources/_InternalTestSupport/Observability.swift @@ -70,11 +70,17 @@ public struct TestingObservability { // TODO: do something useful with scope func handleDiagnostic(scope: ObservabilityScope, diagnostic: Basics.Diagnostic) { if self.verbose { - print(diagnostic.description) + Swift.print(diagnostic.description) } self.diagnostics.append(diagnostic) } + func print(_ output: String, verbose: Bool) { + if verbose { + Swift.print(output) + } + } + var hasErrors: Bool { self.diagnostics.get().hasErrors } diff --git a/Tests/BasicsTests/ObservabilitySystemTests.swift b/Tests/BasicsTests/ObservabilitySystemTests.swift index c7ae9a1b582..34e5143ae53 100644 --- a/Tests/BasicsTests/ObservabilitySystemTests.swift +++ b/Tests/BasicsTests/ObservabilitySystemTests.swift @@ -309,6 +309,12 @@ struct ObservabilitySystemTest { func handleDiagnostic(scope: ObservabilityScope, diagnostic: Diagnostic) { self._diagnostics.append(diagnostic) } + + func print(_ output: String, verbose: Bool) { + if verbose { + Swift.print(output) + } + } } }