diff --git a/Sources/Basics/Observability.swift b/Sources/Basics/Observability.swift index 28c4401b9ff..1724dcd7c00 100644 --- a/Sources/Basics/Observability.swift +++ b/Sources/Basics/Observability.swift @@ -427,7 +427,7 @@ public struct Diagnostic: Sendable, CustomStringConvertible { } } - public var color: TerminalController.Color { + public var color: TSCBasic.TerminalController.Color { switch self { case .debug: return .white diff --git a/Sources/Basics/ProgressAnimation/Concrete/BlastProgressAnimation.swift b/Sources/Basics/ProgressAnimation/Concrete/BlastProgressAnimation.swift new file mode 100644 index 00000000000..144a8100eea --- /dev/null +++ b/Sources/Basics/ProgressAnimation/Concrete/BlastProgressAnimation.swift @@ -0,0 +1,195 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 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 + +extension FormatStyle where Self == Duration.UnitsFormatStyle { + static var blast: Self { + .units( + allowed: [.hours, .minutes, .seconds, .milliseconds], + width: .narrow, + maximumUnitCount: 2, + fractionalPart: .init(lengthLimits: 0...2)) + } +} + +class BlastProgressAnimation { + // Dependencies + var terminal: TerminalController + + // Configuration + var interactive: Bool + var verbose: Bool + var header: String? + + // Internal state + var mostRecentTask: String + var drawnLines: Int + var state: ProgressState + + required init( + stream: any WritableByteStream, + coloring: TerminalColoring, + interactive: Bool, + verbose: Bool, + header: String? + ) { + self.terminal = TerminalController( + stream: stream, + coloring: coloring) + self.interactive = interactive + self.verbose = verbose + self.header = header + self.mostRecentTask = "" + self.drawnLines = 0 + self.state = .init() + } +} + +extension BlastProgressAnimation: ProgressAnimationProtocol { + func update( + id: Int, + name: String, + event: ProgressTaskState, + at time: ContinuousClock.Instant + ) { + let update = self.state.update( + id: id, + name: name, + state: event, + at: time) + guard let (task, state) = update else { return } + + if self.interactive { + self._clear() + } + + if self.verbose, case .completed(let duration) = state { + self._draw(task: task, duration: duration) + self.terminal.newLine() + } + + if self.interactive { + self._draw() + } else if case .started = state { + // For the non-interactive case, only re-draw the status bar when a + // new task starts + self._drawStates() + self.terminal.write(" ") + self.terminal.write(task.name) + self.terminal.newLine() + } + + self._flush() + } + + func interleave(_ bytes: some Collection) { + if self.interactive { + self._clear() + } + self.terminal.write(bytes) + if self.interactive { + self._draw() + } + self._flush() + } + + func complete(_ message: String?) { + self._complete(message) + self._flush() + } +} + +extension BlastProgressAnimation { + func _draw(state: ProgressTaskState) { + self.terminal.text(styles: .foregroundColor(state.visualColor), .bold) + self.terminal.write(state.visualSymbol) + } + + func _draw(task: ProgressTask, duration: ContinuousClock.Duration?) { + self.terminal.write(" ") + self._draw(state: task.state) + self.terminal.text(styles: .reset) + self.terminal.write(" ") + self.terminal.write(task.name) + if let duration { + self.terminal.text(styles: .foregroundColor(.white), .bold) + self.terminal.write(" (\(duration.formatted(.blast)))") + self.terminal.text(styles: .reset) + } + } + + func _draw(state: ProgressTaskState, count: Int, last: Bool) { + self.terminal.text(styles: .notItalicNorBold, .foregroundColor(state.visualColor)) + self.terminal.write(state.visualSymbol) + self.terminal.write(" \(count)") + self.terminal.text(styles: .defaultForegroundColor, .bold) + if !last { + self.terminal.write(", ") + } + } + + func _drawStates() { + self.terminal.text(styles: .bold) + self.terminal.write("(") + self._draw(state: .discovered, count: self.state.counts.pending, last: false) + self._draw(state: .started, count: self.state.counts.running, last: false) + self._draw(state: .completed(.succeeded), count: self.state.counts.succeeded, last: false) + self._draw(state: .completed(.failed), count: self.state.counts.failed, last: false) + self._draw(state: .completed(.cancelled), count: self.state.counts.cancelled, last: false) + self._draw(state: .completed(.skipped), count: self.state.counts.skipped, last: true) + self.terminal.write(")") + self.terminal.text(styles: .reset) + } + + func _drawMessage(_ message: String?) { + if let message { + self.terminal.write(" ") + self.terminal.write(message) + } + } + + func _draw() { + assert(self.drawnLines == 0) + self._drawStates() + self._drawMessage(self.header) + self.drawnLines += 1 + let tasks = self.state.tasks.values.filter { $0.state == .started }.sorted() + for task in tasks { + self.terminal.newLine() + self._draw(task: task, duration: nil) + self.drawnLines += 1 + } + } + + func _complete(_ message: String?) { + self._clear() + self._drawStates() + self._drawMessage(message ?? self.header) + self.terminal.newLine() + } + + func _clear() { + guard self.drawnLines > 0 else { return } + self.terminal.eraseLine(.entire) + self.terminal.carriageReturn() + for _ in 1..) { + self.terminal.write(bytes) + self.terminal.flush() + } + + func complete(_ message: String?) { + if let message { + self.terminal.write(message) + self.terminal.flush() + } + } +} diff --git a/Sources/Basics/ProgressAnimation/Concrete/NinjaRedrawingProgressAnimation.swift b/Sources/Basics/ProgressAnimation/Concrete/NinjaRedrawingProgressAnimation.swift new file mode 100644 index 00000000000..7d8a03b0fcb --- /dev/null +++ b/Sources/Basics/ProgressAnimation/Concrete/NinjaRedrawingProgressAnimation.swift @@ -0,0 +1,105 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014-2024 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 redrawing ninja-like progress animation. +final class NinjaRedrawingProgressAnimation { + // Dependencies + var terminal: TerminalController + + // Internal state + var text: String + var hasDisplayedProgress: Bool + var state: ProgressState + + required init( + stream: any WritableByteStream, + coloring: TerminalColoring, + interactive: Bool, + verbose: Bool, + header: String? + ) { + self.terminal = TerminalController( + stream: stream, + coloring: coloring) + self.text = "" + self.hasDisplayedProgress = false + self.state = .init() + } +} + +extension NinjaRedrawingProgressAnimation: ProgressAnimationProtocol { + func update( + id: Int, + name: String, + event: ProgressTaskState, + at time: ContinuousClock.Instant + ) { + let update = self.state.update( + id: id, + name: name, + state: event, + at: time) + guard let (task, _) = update else { return } + self.text = task.name + + self._clear() + self._draw() + self._flush() + } + + func interleave(_ bytes: some Collection) { + self._clear() + self.terminal.write(bytes) + self._draw() + self._flush() + } + + func complete(_ message: String?) { + self._complete(message) + self._flush() + } +} + +extension NinjaRedrawingProgressAnimation { + func _draw() { + assert(!self.hasDisplayedProgress) + let progressText = "[\(self.state.counts.completed)/\(self.state.counts.total)] \(self.text)" + // FIXME: self.terminal.width + let width = 80 + if progressText.utf8.count > width { + let suffix = "…" + self.terminal.write(String(progressText.prefix(width - suffix.utf8.count))) + self.terminal.write(suffix) + } else { + self.terminal.write(progressText) + } + self.hasDisplayedProgress = true + } + + func _complete(_ message: String?) { + #warning("TODO") + if self.hasDisplayedProgress { + self.terminal.newLine() + } + } + + func _clear() { + guard self.hasDisplayedProgress else { return } + self.terminal.eraseLine(.entire) + self.terminal.carriageReturn() + self.hasDisplayedProgress = false + } + + func _flush() { + self.terminal.flush() + } +} diff --git a/Sources/Basics/ProgressAnimation/Concrete/PercentMultiLineProgressAnimation.swift b/Sources/Basics/ProgressAnimation/Concrete/PercentMultiLineProgressAnimation.swift new file mode 100644 index 00000000000..ad541fe347f --- /dev/null +++ b/Sources/Basics/ProgressAnimation/Concrete/PercentMultiLineProgressAnimation.swift @@ -0,0 +1,81 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014-2024 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 multi-line percent-based progress animation. +final class PercentMultiLineProgressAnimation { + // Dependencies + var terminal: TerminalController + + // Internal state + var header: String? + var hasDisplayedHeader: Bool + var text: String + var state: ProgressState + + required init( + stream: any WritableByteStream, + coloring: TerminalColoring, + interactive: Bool, + verbose: Bool, + header: String? + ) { + self.terminal = TerminalController( + stream: stream, + coloring: coloring) + self.header = header + self.hasDisplayedHeader = false + self.text = "" + self.state = .init() + } +} + +extension PercentMultiLineProgressAnimation: ProgressAnimationProtocol { + func update( + id: Int, + name: String, + event: ProgressTaskState, + at time: ContinuousClock.Instant + ) { + let update = self.state.update( + id: id, + name: name, + state: event, + at: time) + guard let (task, _) = update else { return } + guard self.text != task.name else { return } + self.text = task.name + + if let header = self.header, !self.hasDisplayedHeader, !header.isEmpty { + self.terminal.write(header) + self.terminal.newLine() + self.hasDisplayedHeader = true + } + + let percentage = self.state.counts.completed * 100 / self.state.counts.total + self.terminal.write("\(percentage)%: ") + self.terminal.write(task.name) + self.terminal.newLine() + self.terminal.flush() + } + + func interleave(_ bytes: some Collection) { + self.terminal.write(bytes) + self.terminal.flush() + } + + func complete(_ message: String?) { + if let message { + self.terminal.write(message) + self.terminal.flush() + } + } +} diff --git a/Sources/Basics/ProgressAnimation/Concrete/PercentRedrawingProgressAnimation.swift b/Sources/Basics/ProgressAnimation/Concrete/PercentRedrawingProgressAnimation.swift new file mode 100644 index 00000000000..f04978bd13e --- /dev/null +++ b/Sources/Basics/ProgressAnimation/Concrete/PercentRedrawingProgressAnimation.swift @@ -0,0 +1,166 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014-2024 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 class TSCBasic.TerminalController +import protocol TSCBasic.WritableByteStream + +/// A redrawing lit-like progress animation. +final class PercentRedrawingProgressAnimation { + // Dependencies + var terminal: TerminalController + + // Configuration + var header: String? + + // Internal state + var text: String + var hasDisplayedHeader: Bool + var hasDisplayedProgress: Bool + var state: ProgressState + + required init( + stream: any WritableByteStream, + coloring: TerminalColoring, + interactive: Bool, + verbose: Bool, + header: String? + ) { + self.terminal = TerminalController( + stream: stream, + coloring: coloring) + self.header = header + self.text = "" + self.hasDisplayedHeader = false + self.hasDisplayedProgress = false + self.state = .init() + } +} + +extension PercentRedrawingProgressAnimation: ProgressAnimationProtocol { + func update( + id: Int, + name: String, + event: ProgressTaskState, + at time: ContinuousClock.Instant + ) { + let update = self.state.update( + id: id, + name: name, + state: event, + at: time) + guard let (task, _) = update else { return } + self.text = task.name + + self._clear() + self._draw() + self._flush() + } + + func interleave(_ bytes: some Collection) { + self._clear() + self.terminal.write(bytes) + self._draw() + self._flush() + } + + func complete(_ message: String?) { + self._complete(message) + self._flush() + } +} + +extension PercentRedrawingProgressAnimation { + /// Draws a progress bar with centered header above and description below. + /// + /// The drawn progress bar looks like the following: + /// + /// ``` + /// ╭──────────────────────────────────────────────╮ + /// │ Building Firmware! │ + /// │75% [==============================----------]│ + /// │Compiling main.swift │ + /// ╰──────────────────────────────────────────────╯ + /// ``` + func _draw() { + // FIXME: self.terminal.width + let width = 80 + if let header = self.header, !self.hasDisplayedHeader { + // Center the header above the bar + let padding = max((width / 2) - (header.utf8.count / 2), 0) + self.terminal.write(String(repeating: " ", count: padding)) + self.terminal.text(styles: .foregroundColor(.cyan), .bold) + self.terminal.write(header) + self.terminal.text(styles: .reset) + self.terminal.newLine() + self.hasDisplayedHeader = true + } + + // Draw '% ' prefix + let percentage = Int(self.state.counts.percentage * 100).clamp(0...100) + let percentageDescription = "\(percentage)" + self.terminal.write(percentageDescription) + self.terminal.write("% ") + let prefixLength = 2 + percentageDescription.utf8.count + + // Draw '[===---]' bar + self.terminal.text(styles: .foregroundColor(.green), .rapidBlink) + self.terminal.write("[") + let barLength = width - prefixLength - 2 + let barCompletedLength = Int(Double(barLength) * Double(self.state.counts.percentage)) + let barRemainingLength = barLength - barCompletedLength + self.terminal.write(String(repeating: "=", count: barCompletedLength)) + self.terminal.write(String(repeating: "-", count: barRemainingLength)) + self.terminal.write("]") + self.terminal.text(styles: .reset) + self.terminal.newLine() + + // Draw task name + if self.text.utf8.count > width { + let prefix = "…" + self.terminal.write(prefix) + self.terminal.write(String(self.text.suffix(width - prefix.utf8.count))) + } else { + self.terminal.write(self.text) + } + self.hasDisplayedProgress = true + } + + func _complete(_ message: String?) { + self._clear() + guard self.hasDisplayedHeader else { return } + self.terminal.carriageReturn() + self.terminal.moveCursorUp(cells: 1) + self.terminal.eraseLine(.entire) + if let message { + self.terminal.write(message) + } + } + + func _clear() { + guard self.hasDisplayedProgress else { return } + self.terminal.eraseLine(.entire) + self.terminal.carriageReturn() + self.terminal.moveCursorUp(cells: 1) + self.terminal.eraseLine(.entire) + self.hasDisplayedProgress = false + } + + func _flush() { + self.terminal.flush() + } +} + +extension Comparable { + func clamp(_ range: ClosedRange) -> Self { + min(max(self, range.lowerBound), range.upperBound) + } +} diff --git a/Sources/Basics/ProgressAnimation/Concrete/PercentSingleLineProgressAnimation.swift b/Sources/Basics/ProgressAnimation/Concrete/PercentSingleLineProgressAnimation.swift new file mode 100644 index 00000000000..0f764c66f69 --- /dev/null +++ b/Sources/Basics/ProgressAnimation/Concrete/PercentSingleLineProgressAnimation.swift @@ -0,0 +1,85 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014-2024 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 single line percent-based progress animation. +final class PercentSingleLineProgressAnimation { + // Dependencies + var terminal: TerminalController + + // Configuration + let header: String? + + // Internal state + var displayedPercentages: Set + var hasDisplayedHeader: Bool + var state: ProgressState + + required init( + stream: any WritableByteStream, + coloring: TerminalColoring, + interactive: Bool, + verbose: Bool, + header: String? + ) { + self.terminal = TerminalController( + stream: stream, + coloring: coloring) + self.header = header + self.displayedPercentages = [] + self.hasDisplayedHeader = false + self.state = .init() + } +} + +extension PercentSingleLineProgressAnimation: ProgressAnimationProtocol { + func update( + id: Int, + name: String, + event: ProgressTaskState, + at time: ContinuousClock.Instant + ) { + let update = self.state.update( + id: id, + name: name, + state: event, + at: time) + guard update != nil else { return } + + if let header = self.header, !self.hasDisplayedHeader { + self.terminal.write(header) + self.terminal.newLine() + self.terminal.flush() + self.hasDisplayedHeader = true + } + + let percentage = self.state.counts.percentage + let roundedPercentage = Int(Double(percentage / 10).rounded(.down)) * 10 + if percentage < 100, + self.displayedPercentages.insert(roundedPercentage).inserted + { + self.terminal.write("\(roundedPercentage).. ") + } + self.terminal.flush() + } + + func interleave(_ bytes: some Collection) { + self.terminal.write(bytes) + self.terminal.flush() + } + + func complete(_ message: String?) { + if let message { + self.terminal.write(message) + self.terminal.flush() + } + } +} diff --git a/Sources/Basics/ProgressAnimation/ThrottledProgressAnimation.swift b/Sources/Basics/ProgressAnimation/Concrete/ThrottledProgressAnimation.swift similarity index 80% rename from Sources/Basics/ProgressAnimation/ThrottledProgressAnimation.swift rename to Sources/Basics/ProgressAnimation/Concrete/ThrottledProgressAnimation.swift index b5b3597f15c..9f79d948166 100644 --- a/Sources/Basics/ProgressAnimation/ThrottledProgressAnimation.swift +++ b/Sources/Basics/ProgressAnimation/Concrete/ThrottledProgressAnimation.swift @@ -14,13 +14,13 @@ import _Concurrency import TSCUtility /// A progress animation wrapper that throttles updates to a given interval. -final class ThrottledProgressAnimation: ProgressAnimationProtocol { - private let animation: ProgressAnimationProtocol +final class ThrottledProgressAnimation: TSCUtility.ProgressAnimationProtocol { + private let animation: TSCUtility.ProgressAnimationProtocol private let shouldUpdate: () -> Bool private var pendingUpdate: (Int, Int, String)? init( - _ animation: ProgressAnimationProtocol, + _ animation: TSCUtility.ProgressAnimationProtocol, now: @escaping () -> C.Instant, interval: C.Duration, clock: C.Type = C.self ) { self.animation = animation @@ -57,29 +57,25 @@ final class ThrottledProgressAnimation: ProgressAnimationProtocol { } } -@_spi(SwiftPMInternal) -extension ProgressAnimationProtocol { - @_spi(SwiftPMInternal) - public func throttled( +extension TSCUtility.ProgressAnimationProtocol { + package func throttled( now: @escaping () -> C.Instant, interval: C.Duration, clock: C.Type = C.self - ) -> some ProgressAnimationProtocol { + ) -> some TSCUtility.ProgressAnimationProtocol { ThrottledProgressAnimation(self, now: now, interval: interval, clock: clock) } - @_spi(SwiftPMInternal) - public func throttled( + package func throttled( clock: C, interval: C.Duration - ) -> some ProgressAnimationProtocol { + ) -> some TSCUtility.ProgressAnimationProtocol { self.throttled(now: { clock.now }, interval: interval, clock: C.self) } - @_spi(SwiftPMInternal) - public func throttled( + package func throttled( interval: ContinuousClock.Duration - ) -> some ProgressAnimationProtocol { + ) -> some TSCUtility.ProgressAnimationProtocol { self.throttled(clock: ContinuousClock(), interval: interval) } } diff --git a/Sources/Basics/ProgressAnimation/Misc/ProgressAnimation.swift b/Sources/Basics/ProgressAnimation/Misc/ProgressAnimation.swift new file mode 100644 index 00000000000..39e8803ccdf --- /dev/null +++ b/Sources/Basics/ProgressAnimation/Misc/ProgressAnimation.swift @@ -0,0 +1,97 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 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 class TSCBasic.TerminalController +import class TSCBasic.LocalFileOutputByteStream +import protocol TSCBasic.WritableByteStream + +public typealias WritableByteStream = TSCBasic.WritableByteStream + +/// Namespace for progress animations. +package enum ProgressAnimation { + /// A lit-like progress animation that adapts to the provided output stream. + static func lit( + interactive: Bool, + verbose: Bool + ) -> ProgressAnimationProtocol.Type { + if !interactive { + PercentSingleLineProgressAnimation.self + } else if !verbose { + PercentRedrawingProgressAnimation.self + } else { + PercentMultiLineProgressAnimation.self + } + } + + /// A ninja-like progress animation that adapts to the provided output + /// stream. + static func ninja( + interactive: Bool, + verbose: Bool + ) -> ProgressAnimationProtocol.Type { + if interactive { + NinjaRedrawingProgressAnimation.self + } else { + NinjaMultiLineProgressAnimation.self + } + } + + package static func make( + configuration: ProgressAnimationConfiguration, + environment: Environment, + stream: WritableByteStream, + verbose: Bool, + header: String? + ) -> any ProgressAnimationProtocol { + let environmentBarStyle: ProgressAnimationStyle? = + if environment["SWIFTPM_TEST_RUNNER_PROGRESS_BAR"] == "lit" { + .lit + } else { + nil + } + let style = + // User requested style + configuration.style + // Falling back to style set in the env + ?? environmentBarStyle + // Default to blast if unknown + ?? .blast + + let capabilities = TerminalCapabilities( + stream: stream, + environment: environment) + let interactive = + // User requested interactivity + configuration.interactive + // Falling back to env x tty determined interactivity + ?? capabilities.interactive + let coloring = + // User requested colors + configuration.coloring + // Falling back to env determined interactivity + ?? capabilities.coloring + // Default to 8 colors if unknown + ?? ._8 + + let type = switch style { + case .blast: BlastProgressAnimation.self + case .ninja: Self.ninja(interactive: interactive, verbose: verbose) + case .lit: Self.lit(interactive: interactive, verbose: verbose) + } + return type.init( + stream: stream, + coloring: coloring, + interactive: interactive, + verbose: verbose, + header: header) + } +} diff --git a/Sources/Basics/ProgressAnimation/Misc/ProgressAnimationConfiguration.swift b/Sources/Basics/ProgressAnimation/Misc/ProgressAnimationConfiguration.swift new file mode 100644 index 00000000000..f6f2417436e --- /dev/null +++ b/Sources/Basics/ProgressAnimation/Misc/ProgressAnimationConfiguration.swift @@ -0,0 +1,27 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 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 ProgressAnimationConfiguration { + var style: ProgressAnimationStyle? + var coloring: TerminalColoring? + var interactive: Bool? + + package init( + style: ProgressAnimationStyle? = nil, + coloring: TerminalColoring? = nil, + interactive: Bool? = nil + ) { + self.style = style + self.coloring = coloring + self.interactive = interactive + } +} diff --git a/Sources/Basics/ProgressAnimation/Misc/ProgressAnimationProtocol.swift b/Sources/Basics/ProgressAnimation/Misc/ProgressAnimationProtocol.swift new file mode 100644 index 00000000000..52f1756c844 --- /dev/null +++ b/Sources/Basics/ProgressAnimation/Misc/ProgressAnimationProtocol.swift @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014-2024 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 protocol TSCBasic.WritableByteStream + +// TODO: This protocol could be extended to handle a task tree +package protocol ProgressAnimationProtocol { + init( + stream: any WritableByteStream, + coloring: TerminalColoring, + interactive: Bool, + verbose: Bool, + header: String?) + + func update( + id: Int, + name: String, + event: ProgressTaskState, + at time: ContinuousClock.Instant) + + /// Interleave some other output with the progress animation. + func interleave(_ bytes: some Collection) + + /// Complete the animation. + func complete(_ message: String?) +} + +extension ProgressAnimationProtocol { + /// Interleave some other output with the progress animation. + package func interleave(_ text: String) { + self.interleave(text.utf8) + } +} \ No newline at end of file diff --git a/Sources/Basics/ProgressAnimation/Misc/ProgressAnimationStyle.swift b/Sources/Basics/ProgressAnimation/Misc/ProgressAnimationStyle.swift new file mode 100644 index 00000000000..0964cee34c6 --- /dev/null +++ b/Sources/Basics/ProgressAnimation/Misc/ProgressAnimationStyle.swift @@ -0,0 +1,17 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 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 ProgressAnimationStyle { + case blast + case ninja + case lit +} diff --git a/Sources/Basics/ProgressAnimation/Misc/ProgressState.swift b/Sources/Basics/ProgressAnimation/Misc/ProgressState.swift new file mode 100644 index 00000000000..cabf3eea6f7 --- /dev/null +++ b/Sources/Basics/ProgressAnimation/Misc/ProgressState.swift @@ -0,0 +1,100 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 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 +// +//===----------------------------------------------------------------------===// + +struct ProgressState { + var counts: ProgressTaskCounts + var tasks: [ProgressTask.ID: ProgressTask] +} + +extension ProgressState { + init() { + self.counts = .zero + self.tasks = [:] + } +} + +enum ProgressTaskStateTMP { + case discovered + case started + case completed(ContinuousClock.Duration) +} + +extension ProgressState { + mutating func update( + id: ProgressTask.ID, + name: String, + state: ProgressTaskState, + at time: ContinuousClock.Instant + ) -> (ProgressTask, ProgressTaskStateTMP)? { + switch state { + case .discovered: + guard self.tasks[id] == nil else { + assertionFailure("unexpected duplicate discovery of task with id \(id)") + return nil + } + let task = ProgressTask( + id: id, + name: name, + start: time, + state: .discovered) + self.tasks[id] = task + self.counts.taskDiscovered() + return (task, .discovered) + + case .started: + guard var task = self.tasks[id] else { + assertionFailure("unexpected start of unknown task with id \(id)") + return nil + } + guard task.state == .discovered else { + assertionFailure("unexpected update to state \(state) of task with id \(id) in state \(task.state)") + return nil + } + + task.state = .started + task.start = time + self.tasks[id] = task + self.counts.taskStarted() + return (task, .started) + + case .completed(let completionEvent): + guard var task = self.tasks[id] else { + assertionFailure("unexpected update to state \(state) of unknown task with id \(id)") + return nil + } + switch task.state { + // Skipped is special, tasks can be skipped and never started + case .discovered where completionEvent == .skipped: + task.state = state + task.end = time + self.tasks[id] = task + self.counts.taskSkipped() + return (task, .completed(task.start.duration(to: time))) + + case .discovered: + assertionFailure("unexpected update to state \(state) of not started task with id \(id)") + return nil + + case .started: + task.state = state + task.end = time + self.tasks[id] = task + self.counts.taskCompleted(completionEvent) + return (task, .completed(task.start.duration(to: time))) + + case .completed: + // Already accounted for + return nil + } + } + } +} diff --git a/Sources/Basics/ProgressAnimation/Misc/ProgressTask.swift b/Sources/Basics/ProgressAnimation/Misc/ProgressTask.swift new file mode 100644 index 00000000000..4cf45957ed8 --- /dev/null +++ b/Sources/Basics/ProgressAnimation/Misc/ProgressTask.swift @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 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 +// +//===----------------------------------------------------------------------===// + +// FIXME: use task tree +//struct ProgressTask { +// var id: Int +// var name: String +// var childTasks: [ProgressTask.ID: ProgressTask] +// var childTaskCounts: ProgressTaskCounts +// var state: ProgressTaskState +// var start: ContinuousClock.Instant +// var end: ContinuousClock.Instant? +//} + +struct ProgressTask { + var id: Int + var name: String + var start: ContinuousClock.Instant + var state: ProgressTaskState + var end: ContinuousClock.Instant? +} + +extension ProgressTask { + var duration: ContinuousClock.Duration? { + guard let end = self.end else { return nil } + return self.start.duration(to: end) + } +} + +extension ProgressTask: Equatable { } + +extension ProgressTask: Comparable { + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.start < rhs.start + } +} + +extension ProgressTask: Identifiable {} diff --git a/Sources/Basics/ProgressAnimation/Misc/ProgressTaskCompletion.swift b/Sources/Basics/ProgressAnimation/Misc/ProgressTaskCompletion.swift new file mode 100644 index 00000000000..deb8b3ad7e3 --- /dev/null +++ b/Sources/Basics/ProgressAnimation/Misc/ProgressTaskCompletion.swift @@ -0,0 +1,22 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 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 +// +//===----------------------------------------------------------------------===// + +package enum ProgressTaskCompletion { + case succeeded + case failed + case cancelled + case skipped +} + +extension ProgressTaskCompletion: Equatable {} + +extension ProgressTaskCompletion: Hashable {} diff --git a/Sources/Basics/ProgressAnimation/Misc/ProgressTaskCounts.swift b/Sources/Basics/ProgressAnimation/Misc/ProgressTaskCounts.swift new file mode 100644 index 00000000000..588fb0976b0 --- /dev/null +++ b/Sources/Basics/ProgressAnimation/Misc/ProgressTaskCounts.swift @@ -0,0 +1,79 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 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 +// +//===----------------------------------------------------------------------===// + +struct ProgressTaskCounts { + private(set) var pending: Int + private(set) var running: Int + private(set) var succeeded: Int + private(set) var failed: Int + private(set) var cancelled: Int + private(set) var skipped: Int + // Should be the sum of all other complete counts + private(set) var completed: Int + // Should be the sum of all other counts + private(set) var total: Int +} + +extension ProgressTaskCounts { + var percentage: Double { Double(self.completed) / Double(self.total) } +} + +extension ProgressTaskCounts { + mutating func taskDiscovered() { + self.pending += 1 + self.total += 1 + } + + mutating func taskStarted() { + self.pending -= 1 + self.running += 1 + } + + mutating func taskSkipped() { + self.pending -= 1 + self.skipped += 1 + self.completed += 1 + } + + mutating func taskCompleted(_ completion: ProgressTaskCompletion) { + self.running -= 1 + self.completed += 1 + switch completion { + case .succeeded: + self.succeeded += 1 + case .failed: + self.failed += 1 + case .cancelled: + self.cancelled += 1 + case .skipped: + self.skipped += 1 + } + } +} + +extension ProgressTaskCounts { + static var zero: Self { + .init( + pending: 0, + running: 0, + succeeded: 0, + failed: 0, + cancelled: 0, + skipped: 0, + completed: 0, + total: 0) + } +} + +extension ProgressTaskCounts: Equatable {} + +extension ProgressTaskCounts: Hashable {} diff --git a/Sources/Basics/ProgressAnimation/Misc/ProgressTaskState.swift b/Sources/Basics/ProgressAnimation/Misc/ProgressTaskState.swift new file mode 100644 index 00000000000..318fe872f60 --- /dev/null +++ b/Sources/Basics/ProgressAnimation/Misc/ProgressTaskState.swift @@ -0,0 +1,58 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 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 +// +//===----------------------------------------------------------------------===// + +// FIXME: maybe split state and event +package enum ProgressTaskState { + case discovered + // FIXME: running state needs %progress for download style tasks + case started + case completed(ProgressTaskCompletion) +} + +extension ProgressTaskState { + var visualColor: ANSITextStyle.Color { + switch self { + case .discovered: .yellow + case .started: .cyan + case .completed(.succeeded): .green + case .completed(.failed): .red + case .completed(.cancelled): .magenta + case .completed(.skipped): .blue + } + } + + var visualSymbol: String { + #if os(macOS) + switch self { + case .discovered: "􀊗 " + case .started: "􀊕 " + case .completed(.succeeded): "􀁢 " + case .completed(.failed): "􀁠 " + case .completed(.cancelled): "􀜪 " + case .completed(.skipped): "􀺅 " + } + #else + switch self { + case .discovered: "⏸" + case .started: "▶" + case .completed(.succeeded): "✔" + case .completed(.failed): "✘" + case .completed(.cancelled): "⏹" + case .completed(.skipped): "⏭ " + } + #endif + } +} + +extension ProgressTaskState: Equatable {} + +extension ProgressTaskState: Hashable {} diff --git a/Sources/Basics/ProgressAnimation/NinjaProgressAnimation.swift b/Sources/Basics/ProgressAnimation/NinjaProgressAnimation.swift deleted file mode 100644 index 5a42adec7c0..00000000000 --- a/Sources/Basics/ProgressAnimation/NinjaProgressAnimation.swift +++ /dev/null @@ -1,102 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift open source project -// -// Copyright (c) 2014-2024 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 class TSCBasic.TerminalController -import protocol TSCBasic.WritableByteStream - -extension ProgressAnimation { - /// A ninja-like progress animation that adapts to the provided output stream. - @_spi(SwiftPMInternal) - public static func ninja( - stream: WritableByteStream, - verbose: Bool - ) -> any ProgressAnimationProtocol { - Self.dynamic( - stream: stream, - verbose: verbose, - ttyTerminalAnimationFactory: { RedrawingNinjaProgressAnimation(terminal: $0) }, - dumbTerminalAnimationFactory: { SingleLinePercentProgressAnimation(stream: stream, header: nil) }, - defaultAnimationFactory: { MultiLineNinjaProgressAnimation(stream: stream) } - ) - } -} - -/// A redrawing ninja-like progress animation. -final class RedrawingNinjaProgressAnimation: ProgressAnimationProtocol { - private let terminal: TerminalController - private var hasDisplayedProgress = false - - init(terminal: TerminalController) { - self.terminal = terminal - } - - func update(step: Int, total: Int, text: String) { - assert(step <= total) - - terminal.clearLine() - - let progressText = "[\(step)/\(total)] \(text)" - let width = terminal.width - if progressText.utf8.count > width { - let suffix = "…" - terminal.write(String(progressText.prefix(width - suffix.utf8.count))) - terminal.write(suffix) - } else { - terminal.write(progressText) - } - - hasDisplayedProgress = true - } - - func complete(success: Bool) { - if hasDisplayedProgress { - terminal.endLine() - } - } - - func clear() { - terminal.clearLine() - } -} - -/// A multi-line ninja-like progress animation. -final class MultiLineNinjaProgressAnimation: ProgressAnimationProtocol { - private struct Info: Equatable { - let step: Int - let total: Int - let text: String - } - - private let stream: WritableByteStream - private var lastDisplayedText: String? = nil - - init(stream: WritableByteStream) { - self.stream = stream - } - - func update(step: Int, total: Int, text: String) { - assert(step <= total) - - guard text != lastDisplayedText else { return } - - stream.send("[\(step)/\(total)] ").send(text) - stream.send("\n") - stream.flush() - lastDisplayedText = text - } - - func complete(success: Bool) { - } - - func clear() { - } -} diff --git a/Sources/Basics/ProgressAnimation/PercentProgressAnimation.swift b/Sources/Basics/ProgressAnimation/PercentProgressAnimation.swift deleted file mode 100644 index fc6d4587e26..00000000000 --- a/Sources/Basics/ProgressAnimation/PercentProgressAnimation.swift +++ /dev/null @@ -1,157 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift open source project -// -// Copyright (c) 2014-2024 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 class TSCBasic.TerminalController -import protocol TSCBasic.WritableByteStream - -extension ProgressAnimation { - /// A percent-based progress animation that adapts to the provided output stream. - @_spi(SwiftPMInternal) - public static func percent( - stream: WritableByteStream, - verbose: Bool, - header: String, - isColorized: Bool - ) -> any ProgressAnimationProtocol { - Self.dynamic( - stream: stream, - verbose: verbose, - ttyTerminalAnimationFactory: { RedrawingPercentProgressAnimation( - terminal: $0, - header: header, - isColorized: isColorized - ) }, - dumbTerminalAnimationFactory: { SingleLinePercentProgressAnimation(stream: stream, header: header) }, - defaultAnimationFactory: { MultiLinePercentProgressAnimation(stream: stream, header: header) } - ) - } -} - -/// A redrawing lit-like progress animation. -final class RedrawingPercentProgressAnimation: ProgressAnimationProtocol { - private let terminal: TerminalController - private let header: String - private let isColorized: Bool - private var hasDisplayedHeader = false - - init(terminal: TerminalController, header: String, isColorized: Bool) { - self.terminal = terminal - self.header = header - self.isColorized = isColorized - } - - /// Creates repeating string for count times. - /// If count is negative, returns empty string. - private func repeating(string: String, count: Int) -> String { - return String(repeating: string, count: max(count, 0)) - } - - func colorizeText(color: TerminalController.Color = .noColor) -> TerminalController.Color { - if self.isColorized { - return color - } - return .noColor - } - - func update(step: Int, total: Int, text: String) { - assert(step <= total) - let isBold = self.isColorized - - let width = terminal.width - - if !hasDisplayedHeader { - let spaceCount = width / 2 - header.utf8.count / 2 - terminal.write(repeating(string: " ", count: spaceCount)) - terminal.write(header, inColor: colorizeText(color: .cyan), bold: isBold) - terminal.endLine() - hasDisplayedHeader = true - } else { - terminal.moveCursor(up: 1) - } - - terminal.clearLine() - let percentage = step * 100 / total - let paddedPercentage = percentage < 10 ? " \(percentage)" : "\(percentage)" - let prefix = "\(paddedPercentage)% " + terminal - .wrap("[", inColor: colorizeText(color: .green), bold: isBold) - terminal.write(prefix) - - let barWidth = width - prefix.utf8.count - let n = Int(Double(barWidth) * Double(percentage) / 100.0) - - terminal.write( - repeating(string: "=", count: n) + repeating(string: "-", count: barWidth - n), - inColor: colorizeText(color: .green) - ) - terminal.write("]", inColor: colorizeText(color: .green), bold: isBold) - terminal.endLine() - - terminal.clearLine() - if text.utf8.count > width { - let prefix = "…" - terminal.write(prefix) - terminal.write(String(text.suffix(width - prefix.utf8.count))) - } else { - terminal.write(text) - } - } - - func complete(success: Bool) { - terminal.endLine() - terminal.endLine() - } - - func clear() { - terminal.clearLine() - terminal.moveCursor(up: 1) - terminal.clearLine() - } -} - -/// A multi-line percent-based progress animation. -final class MultiLinePercentProgressAnimation: ProgressAnimationProtocol { - private struct Info: Equatable { - let percentage: Int - let text: String - } - - private let stream: WritableByteStream - private let header: String - private var hasDisplayedHeader = false - private var lastDisplayedText: String? = nil - - init(stream: WritableByteStream, header: String) { - self.stream = stream - self.header = header - } - - func update(step: Int, total: Int, text: String) { - assert(step <= total) - - if !hasDisplayedHeader, !header.isEmpty { - stream.send(header) - stream.send("\n") - stream.flush() - hasDisplayedHeader = true - } - - let percentage = step * 100 / total - stream.send("\(percentage)%: ").send(text) - stream.send("\n") - stream.flush() - lastDisplayedText = text - } - - func complete(success: Bool) {} - - func clear() {} -} diff --git a/Sources/Basics/ProgressAnimation/ProgressAnimationProtocol.swift b/Sources/Basics/ProgressAnimation/ProgressAnimationProtocol.swift deleted file mode 100644 index 2a596271c8c..00000000000 --- a/Sources/Basics/ProgressAnimation/ProgressAnimationProtocol.swift +++ /dev/null @@ -1,59 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift open source project -// -// Copyright (c) 2014-2024 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 class TSCBasic.TerminalController -import class TSCBasic.LocalFileOutputByteStream -import protocol TSCBasic.WritableByteStream -import protocol TSCUtility.ProgressAnimationProtocol - -@_spi(SwiftPMInternal) -public typealias ProgressAnimationProtocol = TSCUtility.ProgressAnimationProtocol - -/// Namespace to nest public progress animations under. -@_spi(SwiftPMInternal) -public enum ProgressAnimation { - /// Dynamically create a progress animation based on the current stream - /// capabilities and desired verbosity. - /// - /// - Parameters: - /// - stream: A stream to write animations into. - /// - verbose: The verbosity level of other output in the system. - /// - ttyTerminalAnimationFactory: A progress animation to use when the - /// output stream is connected to a terminal with support for special - /// escape sequences. - /// - dumbTerminalAnimationFactory: A progress animation to use when the - /// output stream is connected to a terminal without support for special - /// escape sequences for clearing lines or controlling cursor positions. - /// - defaultAnimationFactory: A progress animation to use when the - /// desired output is verbose or the output stream verbose or is not - /// connected to a terminal, e.g. a pipe or file. - /// - Returns: A progress animation instance matching the stream - /// capabilities and desired verbosity. - static func dynamic( - stream: WritableByteStream, - verbose: Bool, - ttyTerminalAnimationFactory: (TerminalController) -> any ProgressAnimationProtocol, - dumbTerminalAnimationFactory: () -> any ProgressAnimationProtocol, - defaultAnimationFactory: () -> any ProgressAnimationProtocol - ) -> any ProgressAnimationProtocol { - if let terminal = TerminalController(stream: stream), !verbose { - return ttyTerminalAnimationFactory(terminal) - } else if let fileStream = stream as? LocalFileOutputByteStream, - TerminalController.terminalType(fileStream) == .dumb - { - return dumbTerminalAnimationFactory() - } else { - return defaultAnimationFactory() - } - } -} - diff --git a/Sources/Basics/ProgressAnimation/SingleLinePercentProgressAnimation.swift b/Sources/Basics/ProgressAnimation/SingleLinePercentProgressAnimation.swift deleted file mode 100644 index c11b25e4b9b..00000000000 --- a/Sources/Basics/ProgressAnimation/SingleLinePercentProgressAnimation.swift +++ /dev/null @@ -1,55 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift open source project -// -// Copyright (c) 2014-2024 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 class TSCBasic.TerminalController -import protocol TSCBasic.WritableByteStream - -/// A single line percent-based progress animation. -final class SingleLinePercentProgressAnimation: ProgressAnimationProtocol { - private let stream: WritableByteStream - private let header: String? - private var displayedPercentages: Set = [] - private var hasDisplayedHeader = false - - init(stream: WritableByteStream, header: String?) { - self.stream = stream - self.header = header - } - - func update(step: Int, total: Int, text: String) { - if let header = header, !hasDisplayedHeader { - stream.send(header) - stream.send("\n") - stream.flush() - hasDisplayedHeader = true - } - - let percentage = step * 100 / total - let roundedPercentage = Int(Double(percentage / 10).rounded(.down)) * 10 - if percentage != 100, !displayedPercentages.contains(roundedPercentage) { - stream.send(String(roundedPercentage)).send(".. ") - displayedPercentages.insert(roundedPercentage) - } - - stream.flush() - } - - func complete(success: Bool) { - if success { - stream.send("OK") - stream.flush() - } - } - - func clear() { - } -} diff --git a/Sources/Basics/ProgressAnimation/Terminal/TerminalCapabilities+Interactive.swift b/Sources/Basics/ProgressAnimation/Terminal/TerminalCapabilities+Interactive.swift new file mode 100644 index 00000000000..1f7d81b2250 --- /dev/null +++ b/Sources/Basics/ProgressAnimation/Terminal/TerminalCapabilities+Interactive.swift @@ -0,0 +1,40 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 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 TSCLibc +#if os(Windows) +import CRT +#endif + +import protocol TSCBasic.WritableByteStream + +extension TerminalCapabilities { + static func interactive( + stream: WritableByteStream, + environment: Environment + ) -> Bool { + // Explicitly disabled colors via TERM == dumb + if environment.termInteractive == false { return false } + // Interactive if underlying stream is a tty. + return stream.isTTY + } +} + +extension Environment { + /// The interactivity enabled by the `"TERM"` environment variable. + var termInteractive: Bool? { + switch self["TERM"] { + case "dumb": false + default: nil + } + } +} diff --git a/Sources/Basics/ProgressAnimation/Terminal/TerminalCapabilities.swift b/Sources/Basics/ProgressAnimation/Terminal/TerminalCapabilities.swift new file mode 100644 index 00000000000..3336fdbdee5 --- /dev/null +++ b/Sources/Basics/ProgressAnimation/Terminal/TerminalCapabilities.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 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 protocol TSCBasic.WritableByteStream + +package struct TerminalCapabilities { + var coloring: TerminalColoring? + var interactive: Bool +} + +extension TerminalCapabilities { + init( + stream: WritableByteStream, + environment: Environment + ) { + self.coloring = Self.coloring( + stream: stream, + environment: environment + ) + self.interactive = Self.interactive( + stream: stream, + environment: environment + ) + } +} diff --git a/Sources/Basics/ProgressAnimation/Terminal/TerminalColoring.swift b/Sources/Basics/ProgressAnimation/Terminal/TerminalColoring.swift new file mode 100644 index 00000000000..a3a71ad917c --- /dev/null +++ b/Sources/Basics/ProgressAnimation/Terminal/TerminalColoring.swift @@ -0,0 +1,142 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 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 protocol TSCBasic.WritableByteStream + +public enum TerminalColoring { + /// Support for no colors. + case noColors + /// Support for 8 colors. + /// + /// Colors: black, red, green, yellow, blue, magenta, cyan, white. + case _8 + /// Support for 16 colors. + /// + /// Colors: standard and bright variants of ``Color._8`` colors. + case _16 + /// Support for 88 colors. + /// + /// Colors: ``Colors._16`` colors, 4x4x4 color cube, and 8 grayscale + /// colors. + case _88 + /// Support for 256 colors. + /// + /// Colors: ``Colors._16`` colors, 6x6x6 color cube, and 18 grayscale + /// colors. + case _256 + /// Support for 16 million colors. + /// + /// Colors: all combinations of 8 bit red, green, blue colors. + case _16m +} + +extension TerminalCapabilities { + static func coloring( + stream: WritableByteStream, + environment env: Environment + ) -> TerminalColoring? { + // Explicitly disabled colors via CLICOLORS == 0 + if env.cliColor == false { return .noColors } + // Explicitly disabled colors via NO_COLORS != nil + if env.noColor { return .noColors } + // Implicitly disabled colors because Xcode terminal cannot render them. + if env.runningInXcode { return .noColors } + // Disabled colors because output stream is not a tty, CI == nil, + // and CLICOLOR_FORCE == nil. + // FIXME: dont use stream.isTTY + guard stream.isTTY || env.runningInCI || env.cliColorForce else { + return .noColors + } + // Determine color support first by consulting COLORTERM which can + // enable true color (16 million color) support, then checking TERM + // which can enable 256, 16, or 8 colors. + return env.colorTerm ?? env.termColoring + } +} + +extension Environment { + /// Whether the [`"CLICOLOR"`](https://bixense.com/clicolors/) environment + /// variable is enabled, disabled, or undefined. + /// + /// If `true`, colors should be used if the underlying output stream + /// supports it.` If `false`, colors should not be used. + /// + /// - Returns: `nil` if the `"CLICOLOR"` environment variable is undefined, + /// `true` if the `"CLICOLOR"` is defined to a non `"0"` value, `false` + /// otherwise. + var cliColor: Bool? { + self["CLICOLOR"].map { $0 != "0" } + } + + /// Whether the [`"CLICOLOR_FORCE"`](https://bixense.com/clicolors/) + /// environment variable is enabled or not. + /// + /// If `true`, colors should be always be used. + /// + /// - Returns: `true` if the `"CLICOLOR_FORCE"` environment variable is + /// defined, `false` otherwise. + var cliColorForce: Bool { + self["CLICOLOR_FORCE"] != nil + } + + /// Whether the [`"NO_COLOR"`](https://no-color.org/) environment variable + /// is enabled or not. + /// + /// If `true`, colors should not be used. + /// + /// - Returns: `true` if the `"NO_COLOR"` environment variable is defined, + /// `false` otherwise. + var noColor: Bool { + self["NO_COLOR"] != nil + } + + /// The coloring enabled by the `"TERM"` environment variable. + var termColoring: TerminalColoring? { + switch self["TERM"] { + case "dumb": nil + case "xterm": ._8 + case "xterm-16color": ._16 + case "xterm-256color": ._256 + default: nil + } + } + + /// The coloring enabled by the + /// [`"COLORTERM"`](https://github.com/termstandard/colors) environment + /// variable. + var colorTerm: TerminalColoring? { + switch self["COLORTERM"] { + case "truecolor", "24bit": ._16m + default: nil + } + } + + /// Whether the current process is running in CI. + /// + /// If `true`, colors can be used even if the output stream is not a tty. + /// + /// - Returns: `true` if the `"CI"` environment variable is defined, `false` + /// otherwise. + var runningInCI: Bool { + self["CI"] != nil + } + + /// Whether the current process is running in Xcode. + /// + /// If `true`, colors should not be used. + /// + /// - Returns: `true` if the `"XPC_SERVICE_NAME"` environment variable + /// includes `"com.apple.dt.xcode"`, `false` otherwise. + var runningInXcode: Bool { + self["XPC_SERVICE_NAME"]?.contains("com.apple.dt.xcode") ?? false + } +} diff --git a/Sources/Basics/ProgressAnimation/Terminal/TerminalController.swift b/Sources/Basics/ProgressAnimation/Terminal/TerminalController.swift new file mode 100644 index 00000000000..d9ee32b2cb4 --- /dev/null +++ b/Sources/Basics/ProgressAnimation/Terminal/TerminalController.swift @@ -0,0 +1,409 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 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 class TSCBasic.BufferedOutputByteStream +import protocol TSCBasic.WritableByteStream +import TSCLibc +#if os(Windows) +import CRT +#endif + +struct TerminalController { + var stream: WritableByteStream + var buffer: TerminalOutputBuffer + var coloring: TerminalColoring + + init(stream: WritableByteStream, coloring: TerminalColoring) { + // This feels like a very bad place to run this side-effect + Self.enableVT100Interpretation() + self.stream = stream + self.buffer = .init() + self.coloring = coloring + } + + /// Writes a string to the stream. + mutating func write(_ text: String) { + self.buffer.write(text.utf8) + } + + /// Writes bytes to the stream. + mutating func write(_ bytes: some Collection) { + self.buffer.write(bytes) + } + + mutating func newLine() { + self.buffer.write("\n") + } + + mutating func carriageReturn() { + self.buffer.write("\r") + } + + mutating func flush() { + self.buffer.flush { bytes in + self.stream.write(bytes) + self.stream.flush() + } + } +} + +extension TerminalController { + static func enableVT100Interpretation() { +#if os(Windows) + let hOut = GetStdHandle(STD_OUTPUT_HANDLE) + var dwMode: DWORD = 0 + + guard hOut != INVALID_HANDLE_VALUE else { return nil } + guard GetConsoleMode(hOut, &dwMode) else { return nil } + + dwMode |= DWORD(ENABLE_VIRTUAL_TERMINAL_PROCESSING) + guard SetConsoleMode(hOut, dwMode) else { return nil } +#endif + } + + /// Tries to get the terminal width first using COLUMNS env variable and + /// if that fails ioctl method testing on stdout stream. + /// + /// - Returns: Current width of terminal if it was determinable. + static func width() -> Int? { +#if os(Windows) + var csbi: CONSOLE_SCREEN_BUFFER_INFO = CONSOLE_SCREEN_BUFFER_INFO() + if !GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &csbi) { + // GetLastError() + return nil + } + return Int(csbi.srWindow.Right - csbi.srWindow.Left) + 1 +#else + // Try to get from environment. + if let columns = Environment.current["COLUMNS"], let width = Int(columns) { + return width + } + + // Try determining using ioctl. + // Following code does not compile on ppc64le well. TIOCGWINSZ is + // defined in system ioctl.h file which needs to be used. This is + // a temporary arrangement and needs to be fixed. +#if !arch(powerpc64le) + var ws = winsize() +#if os(OpenBSD) + let tiocgwinsz = 0x40087468 + let err = ioctl(1, UInt(tiocgwinsz), &ws) +#else + let err = ioctl(1, UInt(TIOCGWINSZ), &ws) +#endif + if err == 0 { + return Int(ws.ws_col) + } +#endif + return nil +#endif + } +} + +extension TerminalController { + /// ESC character. + private static let escape = "\u{001B}[" + + mutating func moveCursorUp(cells: Int) { self.buffer.write("\(Self.escape)\(cells)A") } + + mutating func moveCursorDown(cells: Int) { self.buffer.write("\(Self.escape)\(cells)B") } + + mutating func moveCursorForward(cells: Int) { self.buffer.write("\(Self.escape)\(cells)C") } + + mutating func moveCursorBackward(cells: Int) { self.buffer.write("\(Self.escape)\(cells)D") } + + mutating func moveCursorNext(lines: Int) { self.buffer.write("\(Self.escape)\(lines)E") } + + mutating func moveCursorPrevious(lines: Int) { self.buffer.write("\(Self.escape)\(lines)F") } + + mutating func positionCursor(column: Int) { self.buffer.write("\(Self.escape)\(column)G") } + + mutating func positionCursor(row: Int, column: Int) { self.buffer.write("\(Self.escape)\(row);\(column)H") } + + mutating func saveCursorPosition() { self.buffer.write("\(Self.escape)s") } + + mutating func restoreCursorPosition() { self.buffer.write("\(Self.escape)u") } + + mutating func hideCursor() { self.buffer.write("\(Self.escape)?25l") } + + mutating func showCursor() { self.buffer.write("\(Self.escape)?25h") } + + enum EraseControl: Int { + /// Clear from cursor to end + case fromCursor = 0 + /// Clear from cursor to beginning + case toCursor = 1 + /// Clear entire + case entire = 2 + } + + /// ANSI escape code for erasing content in the current display. + mutating func eraseDisplay(_ kind: EraseControl) { + self.buffer.write("\(Self.escape)\(kind.rawValue)J") + } + + /// ANSI escape code for erasing content in the current line. + mutating func eraseLine(_ kind: EraseControl) { + self.buffer.write("\(Self.escape)\(kind.rawValue)K") + } + + mutating func text(styles: ANSITextStyle...) { + precondition(!styles.isEmpty) + guard self.coloring != .noColors else { return } + self.buffer.write("\(Self.escape)\(styles[0].rawValue)") + for style in styles.dropFirst() { + self.buffer.write(";\(style.rawValue)") + } + self.buffer.write("m") + } +} + +struct ANSITextStyle { + var rawValue: String +} + +/// Documentation from Wikipedia: https://en.wikipedia.org/wiki/ANSI_escape_code +extension ANSITextStyle { + enum Color: UInt8 { + case black = 0 + case red = 1 + case green = 2 + case yellow = 3 + case blue = 4 + case magenta = 5 + case cyan = 6 + case white = 7 + } + + /// Reset or normal + /// + /// All attributes become turned off. + static var reset: Self { .init(rawValue: "0") } + + /// Bold or increased intensity + /// + /// As with faint, the color change is a PC (SCO / CGA) invention. + static var bold: Self { .init(rawValue: "1") } + + /// Faint, decreased intensity, or dim + /// + /// May be implemented as a light font weight like bold. + static var faint: Self { .init(rawValue: "2") } + + /// Italic + /// + /// Not widely supported. Sometimes treated as inverse or blink. + static var italic: Self { .init(rawValue: "3") } + + /// Underline + /// + /// Style extensions exist for Kitty, VTE, mintty, iTerm2, and Konsole. + static var underline: Self { .init(rawValue: "4") } + + /// Slow blink + /// + /// Sets blinking to less than 150 times per minute. + static var blink: Self { .init(rawValue: "5") } + + /// Rapid blink + /// + /// MS-DOS ANSI.SYS, 150+ per minute; not widely supported. + static var rapidBlink: Self { .init(rawValue: "6") } + + /// Reverse video or invert + /// + /// Swap foreground and background colors; inconsistent emulation. + static var inverted: Self { .init(rawValue: "7") } + + /// Conceal or hide + /// + /// Not widely supported. + static var hidden: Self { .init(rawValue: "8") } + + /// Crossed-out, or strike + /// + /// Characters legible but marked as if for deletion. Not supported in + /// Terminal.app. + static var strikeThrough: Self { .init(rawValue: "9") } + + /// Primary (default) font + static var primaryFont: Self { .init(rawValue: "10") } + + /// Alternative font + /// + /// Select alternative font from 1 to 9. + static func alternateFont(_ font: Int) -> Self { + precondition(font >= 1 && font <= 9) + return .init(rawValue: "\(10 + UInt8(font))") + } + + /// Fraktur (Gothic) + /// + /// Rarely supported. + static var fraktur: Self { .init(rawValue: "20") } + + /// Doubly underlined; or: not bold + /// + /// Double-underline per ECMA-48: 8.3.117 but instead disables bold + /// intensity on several terminals, including in the Linux kernel's console + /// before version 4.17. + static var doubleUnderline: Self { .init(rawValue: "21") } + + /// Normal intensity + /// + /// Neither bold nor faint; color changes where intensity is implemented as + /// such. + static var normalWeight: Self { .init(rawValue: "22") } + + /// Neither italic, nor bold + static var notItalicNorBold: Self { .init(rawValue: "23") } + + /// Not underlined + /// + /// Neither singly nor doubly underlined. + static var notUnderlined: Self { .init(rawValue: "24") } + + /// Not blinking + /// + /// Turn blinking off. + static var notBlinking: Self { .init(rawValue: "25") } + + /// Proportional spacing + /// + /// ITU T.61 and T.416, not known to be used on terminals + static var proportionalSpacing: Self { .init(rawValue: "26") } + + /// Not reversed + static var notReversed: Self { .init(rawValue: "27") } + + /// Reveal + /// + /// Not concealed + static var notHidden: Self { .init(rawValue: "28") } + + /// Not crossed out + static var notStrikethrough: Self { .init(rawValue: "29") } + + /// Set foreground color + static func foregroundColor(_ color: Color) -> Self { + .init(rawValue: "\(30 + color.rawValue)") + } + + /// Set foreground color + /// + /// Next arguments are 5;n or 2;r;g;b + static func foregroundColor(red: UInt8, green: UInt8, blue: UInt8) -> Self { + .init(rawValue: "38;2;\(red);\(green);\(blue)") + } + + /// Default foreground color + /// + /// Implementation defined (according to standard) + static var defaultForegroundColor: Self { .init(rawValue: "39") } + + /// Set background color + static func backgroundColor(_ color: Color) -> Self { + .init(rawValue: "\(40 + color.rawValue)") + } + + /// Set background color + /// + /// Next arguments are 5;n or 2;r;g;b + static func backgroundColor(red: UInt8, green: UInt8, blue: UInt8) -> Self { + .init(rawValue: "48;2;\(red);\(green);\(blue)") + } + + /// Default background color + /// + /// Implementation defined (according to standard) + static var defaultBackgroundColor: Self { .init(rawValue: "49") } + + /// Disable proportional spacing + /// + /// T.61 and T.416 + static var notProportionalSpacing: Self { .init(rawValue: "50") } + + /// Framed + /// + /// Implemented as "emoji variation selector" in mintty. + static var framed: Self { .init(rawValue: "51") } + + /// Encircled + static var encircled: Self { .init(rawValue: "52") } + + /// Overlined + /// + /// Not supported in Terminal.app + static var overlined: Self { .init(rawValue: "53") } + + /// Neither framed nor encircled + static var notFramedNorEncircled: Self { .init(rawValue: "54") } + + /// Not overlined + static var notOverlined: Self { .init(rawValue: "55") } + + /// Set underline color + /// + /// Not in standard; implemented in Kitty, VTE, mintty, and iTerm2. + /// Next arguments are 5;n or 2;r;g;b. + static func underlineColor() -> Self { .init(rawValue: "58") } + + /// Default underline color + /// + /// Not in standard; implemented in Kitty, VTE, mintty, and iTerm2. + static var defaultUnderlineColor: Self { .init(rawValue: "59") } + + /// Ideogram underline or right side line + /// + /// Rarely supported + static var ideogramUnderline: Self { .init(rawValue: "60") } + + /// Ideogram double underline, or double line on the right side + static var ideogramDoubleUnderline: Self { .init(rawValue: "61") } + + /// Ideogram overline or left side line + static var ideogramOverline: Self { .init(rawValue: "62") } + + /// Ideogram double overline, or double line on the left side + static var ideogramDoubleOverline: Self { .init(rawValue: "63") } + + /// Ideogram stress marking + static var ideogramStressMarking: Self { .init(rawValue: "64") } + + /// No ideogram attributes + /// + /// Reset the effects of all of 60 - 64 + static var defaultIdeogram: Self { .init(rawValue: "65") } + + /// Superscript + /// + /// Implemented only in mintty + static var superscript: Self { .init(rawValue: "73") } + + /// Subscript + static var `subscript`: Self { .init(rawValue: "74") } + + /// Neither superscript nor subscript + static var defaultScript: Self { .init(rawValue: "75") } + + /// Set bright foreground color + /// + /// Not in standard; originally implemented by aixterm + static func brightForegroundColor(_ color: Color) -> Self { + .init(rawValue: "\(90 + color.rawValue)") + } + + /// Set bright background color + static func brightBackgroundColor(_ color: Color) -> Self { + .init(rawValue: "\(100 + color.rawValue)") + } +} diff --git a/Sources/Basics/ProgressAnimation/Terminal/TerminalOutputBuffer.swift b/Sources/Basics/ProgressAnimation/Terminal/TerminalOutputBuffer.swift new file mode 100644 index 00000000000..8472e1229a7 --- /dev/null +++ b/Sources/Basics/ProgressAnimation/Terminal/TerminalOutputBuffer.swift @@ -0,0 +1,50 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 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 simple buffer to accumulate output bytes into. +/// +/// This buffer never shrinks. +struct TerminalOutputBuffer { + /// Initial buffer size of the data buffer. + /// + /// This buffer will grow if more space is needed. + static let initialBufferSize = 1024 + + /// The data buffer. + private var buffer: [UInt8] + private var availableBufferSize: Int { + self.buffer.capacity - self.buffer.count + } + + init() { + self.buffer = [] + self.buffer.reserveCapacity(Self.initialBufferSize) + } + + /// Clears the buffer maintaining current capacity. + mutating func flush(_ body: (borrowing [UInt8]) -> ()) { + body(self.buffer) + self.buffer.removeAll(keepingCapacity: true) + } + + /// Write a string as utf8 bytes to the buffer. + mutating func write(_ string: String) { + self.write(string.utf8) + } + + /// Write a collection of bytes to the buffer. + mutating func write(_ bytes: some Collection) { + let byteCount = bytes.count + self.buffer.reserveCapacity(byteCount + self.buffer.count) + self.buffer.append(contentsOf: bytes) + } +} diff --git a/Sources/Basics/WritableByteStream+Extensions.swift b/Sources/Basics/WritableByteStream+Extensions.swift index ace54ecab15..69df448ecb0 100644 --- a/Sources/Basics/WritableByteStream+Extensions.swift +++ b/Sources/Basics/WritableByteStream+Extensions.swift @@ -27,6 +27,6 @@ extension WritableByteStream { guard let fileStream = stream as? LocalFileOutputByteStream else { return false } - return TerminalController.isTTY(fileStream) + return TSCBasic.TerminalController.isTTY(fileStream) } } diff --git a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Product.swift b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Product.swift index 33eb6f4558f..9a8b1894fc6 100644 --- a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Product.swift +++ b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Product.swift @@ -121,6 +121,7 @@ extension LLBuildManifestBuilder { } self.manifest.addWriteLinkFileListCommand( + targetName: buildProduct.product.name, objects: Array(buildProduct.objects), linkFileListPath: buildProduct.linkFileListPath ) diff --git a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Swift.swift b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Swift.swift index b2679e95b5a..277ace5b414 100644 --- a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Swift.swift +++ b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Swift.swift @@ -86,7 +86,6 @@ extension LLBuildManifestBuilder { diagnosticsOutput: .handler(self.observabilityScope.makeDiagnosticsHandler()), fileSystem: self.fileSystem, executor: executor, - compilerIntegratedTooling: false ) try driver.checkLDPathOption(commandLine: commandLine) @@ -302,7 +301,6 @@ extension LLBuildManifestBuilder { args: commandLine, fileSystem: self.fileSystem, executor: executor, - compilerIntegratedTooling: false, externalTargetModuleDetailsMap: dependencyModuleDetailsMap, interModuleDependencyOracle: dependencyOracle ) @@ -381,7 +379,10 @@ extension LLBuildManifestBuilder { let isLibrary = target.target.type == .library || target.target.type == .test let cmdName = target.getCommandName() - self.manifest.addWriteSourcesFileListCommand(sources: target.sources, sourcesFileListPath: target.sourcesFileListPath) + self.manifest.addWriteSourcesFileListCommand( + targetName: target.target.name, + sources: target.sources, + sourcesFileListPath: target.sourcesFileListPath) let outputFileMapPath = target.tempsPath.appending("output-file-map.json") // FIXME: Eliminate side effect. try target.writeOutputFileMap(to: outputFileMapPath) diff --git a/Sources/Build/BuildOperation.swift b/Sources/Build/BuildOperation.swift index 37761bd2e38..a8da2dc10d7 100644 --- a/Sources/Build/BuildOperation.swift +++ b/Sources/Build/BuildOperation.swift @@ -198,6 +198,8 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS /// Alternative path to search for pkg-config `.pc` files. private let pkgConfigDirectories: [AbsolutePath] + private let progressAnimationConfiguration: Basics.ProgressAnimationConfiguration + public convenience init( productsBuildParameters: BuildParameters, toolsBuildParameters: BuildParameters, @@ -210,7 +212,8 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS outputStream: OutputByteStream, logLevel: Basics.Diagnostic.Severity, fileSystem: Basics.FileSystem, - observabilityScope: ObservabilityScope + observabilityScope: ObservabilityScope, + progressAnimationConfiguration: Basics.ProgressAnimationConfiguration ) { self.init( productsBuildParameters: productsBuildParameters, @@ -225,7 +228,8 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS outputStream: outputStream, logLevel: logLevel, fileSystem: fileSystem, - observabilityScope: observabilityScope + observabilityScope: observabilityScope, + progressAnimationConfiguration: progressAnimationConfiguration ) } @@ -242,7 +246,8 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS outputStream: OutputByteStream, logLevel: Basics.Diagnostic.Severity, fileSystem: Basics.FileSystem, - observabilityScope: ObservabilityScope + observabilityScope: ObservabilityScope, + progressAnimationConfiguration: Basics.ProgressAnimationConfiguration ) { /// Checks if stdout stream is tty. var productsBuildParameters = productsBuildParameters @@ -269,6 +274,7 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS self.additionalFileRules = additionalFileRules self.pluginConfiguration = pluginConfiguration self.pkgConfigDirectories = pkgConfigDirectories + self.progressAnimationConfiguration = progressAnimationConfiguration } public func getPackageGraph() async throws -> ModulesGraph { @@ -422,7 +428,9 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS let configuration = self.config.configuration(for: .target) // delegate is only available after createBuildSystem is called - progressTracker.buildStart(configuration: configuration) + progressTracker.buildStart( + configuration: configuration, + action: "Building") // Perform the build. let llbuildTarget = try await computeLLBuildTargetName(for: subset) @@ -443,6 +451,7 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS progressTracker.buildComplete( success: success, duration: duration, + action: "Building", subsetDescriptor: subsetDescriptor ) guard success else { throw Diagnostics.fatalError } @@ -778,13 +787,19 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS self.current = (buildSystem, tracker) // Build the package structure target which will re-generate the llbuild manifest, if necessary. + let buildStartTime = DispatchTime.now() let buildSuccess = buildSystem.build(target: "PackageStructure") + let duration = buildStartTime.distance(to: .now()) // If progress has been printed this will add a newline to separate it from what could be // the output of the command. For instance `swift test --skip-build` may print progress for // the Planning Build stage, followed immediately by a list of tests. Without this finialize() // call the first entry in the list appear on the same line as the Planning Build progress. - tracker.finalize(success: true) + tracker.buildComplete( + success: buildSuccess, + duration: duration, + action: "Planning", + subsetDescriptor: nil) return buildSuccess } @@ -798,10 +813,13 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS config: LLBuildSystemConfiguration ) throws -> (buildSystem: SPMLLBuild.BuildSystem, tracker: LLBuildProgressTracker) { // Figure out which progress bar we have to use during the build. - let progressAnimation = ProgressAnimation.ninja( - stream: config.outputStream, - verbose: config.logLevel.isVerbose - ) + let progressAnimation = ProgressAnimation.make( + configuration: self.progressAnimationConfiguration, + environment: .current, + stream: self.config.outputStream, + verbose: self.config.logLevel.isVerbose, + header: "Building...") + let buildExecutionContext = BuildExecutionContext( productsBuildParameters: config.destinationBuildParameters, toolsBuildParameters: config.toolsBuildParameters, @@ -816,7 +834,6 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS let progressTracker = LLBuildProgressTracker( buildSystem: self, buildExecutionContext: buildExecutionContext, - outputStream: config.outputStream, progressAnimation: progressAnimation, logLevel: config.logLevel, observabilityScope: config.observabilityScope, diff --git a/Sources/Build/LLBuildProgressTracker.swift b/Sources/Build/LLBuildProgressTracker.swift index 8b0c8c3571b..a90d410f866 100644 --- a/Sources/Build/LLBuildProgressTracker.swift +++ b/Sources/Build/LLBuildProgressTracker.swift @@ -136,13 +136,12 @@ public protocol PackageStructureDelegate { /// Convenient llbuild build system delegate implementation final class LLBuildProgressTracker: LLBuildBuildSystemDelegate, SwiftCompilerOutputParserDelegate { - private let outputStream: ThreadSafeOutputByteStream private let progressAnimation: ProgressAnimationProtocol private let logLevel: Basics.Diagnostic.Severity private weak var delegate: SPMBuildCore.BuildSystemDelegate? private let buildSystem: SPMBuildCore.BuildSystem private let queue = DispatchQueue(label: "org.swift.swiftpm.build-delegate") - private var taskTracker = CommandTaskTracker() + private var taskTracker: CommandTaskTracker private var errorMessagesByTarget: [String: [String]] = [:] private let observabilityScope: ObservabilityScope private var cancelled: Bool = false @@ -159,7 +158,6 @@ final class LLBuildProgressTracker: LLBuildBuildSystemDelegate, SwiftCompilerOut init( buildSystem: SPMBuildCore.BuildSystem, buildExecutionContext: BuildExecutionContext, - outputStream: OutputByteStream, progressAnimation: ProgressAnimationProtocol, logLevel: Basics.Diagnostic.Severity, observabilityScope: ObservabilityScope, @@ -167,10 +165,8 @@ final class LLBuildProgressTracker: LLBuildBuildSystemDelegate, SwiftCompilerOut ) { self.buildSystem = buildSystem self.buildExecutionContext = buildExecutionContext - // FIXME: Implement a class convenience initializer that does this once they are supported - // https://forums.swift.org/t/allow-self-x-in-class-convenience-initializers/15924 - self.outputStream = outputStream as? ThreadSafeOutputByteStream ?? ThreadSafeOutputByteStream(outputStream) self.progressAnimation = progressAnimation + self.taskTracker = .init(progressAnimation: progressAnimation) self.logLevel = logLevel self.observabilityScope = observabilityScope self.delegate = delegate @@ -179,19 +175,11 @@ final class LLBuildProgressTracker: LLBuildBuildSystemDelegate, SwiftCompilerOut SwiftCompilerOutputParser(targetName: tool.moduleName, delegate: self) } ?? [:] self.swiftParsers = swiftParsers - - self.taskTracker.onTaskProgressUpdateText = { [weak self] progressText, _ in - guard let self else { return } - self.queue.async { [weak self] in - guard let self else { return } - self.delegate?.buildSystem(self.buildSystem, didUpdateTaskProgress: progressText) - } - } } public func finalize(success: Bool) { self.queue.async { - self.progressAnimation.complete(success: success) + self.progressAnimation.complete("Complete") } } @@ -241,13 +229,16 @@ final class LLBuildProgressTracker: LLBuildBuildSystemDelegate, SwiftCompilerOut } func commandStatusChanged(_ command: SPMLLBuild.Command, kind: CommandStatusKind) { - guard !self.logLevel.isVerbose else { return } guard command.shouldShowStatus else { return } - guard !self.swiftParsers.keys.contains(command.name) else { return } - - self.queue.async { - self.taskTracker.commandStatusChanged(command, kind: kind) - self.updateProgress() + let targetName = self.swiftParsers[command.name]?.targetName + let now = ContinuousClock.now + + queue.async { + self.taskTracker.commandStatusChanged( + command, + kind: kind, + targetName: targetName, + time: now) } } @@ -259,13 +250,20 @@ final class LLBuildProgressTracker: LLBuildBuildSystemDelegate, SwiftCompilerOut func commandStarted(_ command: SPMLLBuild.Command) { guard command.shouldShowStatus else { return } + let targetName = self.swiftParsers[command.name]?.targetName + let now = ContinuousClock.now self.queue.async { self.delegate?.buildSystem(self.buildSystem, didStartCommand: BuildSystemCommand(command)) + if self.logLevel.isVerbose { - self.outputStream.send("\(command.verboseDescription)\n") - self.outputStream.flush() + self.progressAnimation.interleave("\(command.verboseDescription)\n") } + + self.taskTracker.commandStarted( + command, + targetName: targetName, + time: now) } } @@ -275,7 +273,8 @@ final class LLBuildProgressTracker: LLBuildBuildSystemDelegate, SwiftCompilerOut func commandFinished(_ command: SPMLLBuild.Command, result: CommandResult) { guard command.shouldShowStatus else { return } - guard !self.swiftParsers.keys.contains(command.name) else { return } + let targetName = self.swiftParsers[command.name]?.targetName + let now = ContinuousClock.now self.queue.async { if result == .cancelled { @@ -285,11 +284,7 @@ final class LLBuildProgressTracker: LLBuildBuildSystemDelegate, SwiftCompilerOut self.delegate?.buildSystem(self.buildSystem, didFinishCommand: BuildSystemCommand(command)) - if !self.logLevel.isVerbose { - let targetName = self.swiftParsers[command.name]?.targetName - self.taskTracker.commandFinished(command, result: result, targetName: targetName) - self.updateProgress() - } + self.taskTracker.commandFinished(command, result: result, targetName: targetName, time: now) } } @@ -345,9 +340,7 @@ final class LLBuildProgressTracker: LLBuildBuildSystemDelegate, SwiftCompilerOut .result != .failed self.queue.async { if let buffer = self.nonSwiftMessageBuffers[command.name], !shouldFilterOutput { - self.progressAnimation.clear() - self.outputStream.send(buffer) - self.outputStream.flush() + self.progressAnimation.interleave(buffer) self.nonSwiftMessageBuffers[command.name] = nil } } @@ -369,8 +362,7 @@ final class LLBuildProgressTracker: LLBuildBuildSystemDelegate, SwiftCompilerOut command: command.name, message: errorMessage ) { - self.outputStream.send("note: \(adviceMessage)\n") - self.outputStream.flush() + self.progressAnimation.interleave("note: \(adviceMessage)\n") } } } @@ -395,20 +387,18 @@ final class LLBuildProgressTracker: LLBuildBuildSystemDelegate, SwiftCompilerOut /// Invoked right before running an action taken before building. func preparationStepStarted(_ name: String) { + let now = ContinuousClock.now self.queue.async { - self.taskTracker.buildPreparationStepStarted(name) - self.updateProgress() + self.taskTracker.buildPreparationStepStarted(name, time: now) } } /// Invoked when an action taken before building emits output. /// when verboseOnly is set to true, the output will only be printed in verbose logging mode func preparationStepHadOutput(_ name: String, output: String, verboseOnly: Bool) { - self.queue.async { - self.progressAnimation.clear() - if !verboseOnly || self.logLevel.isVerbose { - self.outputStream.send("\(output.spm_chomp())\n") - self.outputStream.flush() + if !verboseOnly || self.logLevel.isVerbose { + self.queue.async { + self.progressAnimation.interleave("\(output.spm_chomp())\n") } } } @@ -416,34 +406,22 @@ final class LLBuildProgressTracker: LLBuildBuildSystemDelegate, SwiftCompilerOut /// Invoked right after running an action taken before building. The result /// indicates whether the action succeeded, failed, or was cancelled. func preparationStepFinished(_ name: String, result: CommandResult) { + let now = ContinuousClock.now self.queue.async { - self.taskTracker.buildPreparationStepFinished(name) - self.updateProgress() + self.taskTracker.buildPreparationStepFinished(name, time: now) } } // MARK: SwiftCompilerOutputParserDelegate - func swiftCompilerOutputParser(_ parser: SwiftCompilerOutputParser, didParse message: SwiftCompilerMessage) { + let now = ContinuousClock.now self.queue.async { - if self.logLevel.isVerbose { - if let text = message.verboseProgressText { - self.outputStream.send("\(text)\n") - self.outputStream.flush() - } - } else { - self.taskTracker.swiftCompilerDidOutputMessage(message, targetName: parser.targetName) - self.updateProgress() + if self.logLevel.isVerbose, let text = message.verboseProgressText { + self.progressAnimation.interleave("\(text)\n") } if let output = message.standardOutput { - // first we want to print the output so users have it handy - if !self.logLevel.isVerbose { - self.progressAnimation.clear() - } - - self.outputStream.send(output) - self.outputStream.flush() + self.progressAnimation.interleave(output) // next we want to try and scoop out any errors from the output (if reasonable size, otherwise this // will be very slow), so they can later be passed to the advice provider in case of failure. @@ -456,24 +434,30 @@ final class LLBuildProgressTracker: LLBuildBuildSystemDelegate, SwiftCompilerOut } } } + + self.taskTracker.swiftCompilerDidOutputMessage( + message, + targetName: parser.targetName, + time: now) } } func swiftCompilerOutputParser(_ parser: SwiftCompilerOutputParser, didFailWith error: Error) { let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription self.observabilityScope.emit(.swiftCompilerOutputParsingError(message)) - self.hadCommandFailure() } - func buildStart(configuration: BuildConfiguration) { - self.queue.sync { - self.progressAnimation.clear() - self.outputStream.send("Building for \(configuration == .debug ? "debugging" : "production")...\n") - self.outputStream.flush() - } - } + func buildStart( + configuration: BuildConfiguration, + action: String + ) { } - func buildComplete(success: Bool, duration: DispatchTimeInterval, subsetDescriptor: String? = nil) { + func buildComplete( + success: Bool, + duration: DispatchTimeInterval, + action: String, + subsetDescriptor: String? = nil + ) { let subsetString = if let subsetDescriptor { "of \(subsetDescriptor) " } else { @@ -481,78 +465,102 @@ final class LLBuildProgressTracker: LLBuildBuildSystemDelegate, SwiftCompilerOut } self.queue.sync { - self.progressAnimation.complete(success: success) - self.delegate?.buildSystem(self.buildSystem, didFinishWithResult: success) - - if success { - let message = self.cancelled ? "Build \(subsetString)cancelled!" : "Build \(subsetString)complete!" - self.progressAnimation.clear() - self.outputStream.send("\(message) (\(duration.descriptionInSeconds))\n") - self.outputStream.flush() - } - } - } - - // MARK: Private - - private func updateProgress() { - if let progressText = taskTracker.latestFinishedText { - self.progressAnimation.update( - step: self.taskTracker.finishedCount, - total: self.taskTracker.totalCount, - text: progressText - ) + let result = !success ? "failed" : + self.cancelled ? "cancelled" : "complete" + self.progressAnimation.complete("\(action) \(subsetString)\(result)!") } } } /// Tracks tasks based on command status and swift compiler output. private struct CommandTaskTracker { - private(set) var totalCount = 0 - private(set) var finishedCount = 0 - private var swiftTaskProgressTexts: [Int: String] = [:] + var progressAnimation: any ProgressAnimationProtocol + var swiftTaskProgressTexts: [Int: String] - /// The last task text before the task list was emptied. - private(set) var latestFinishedText: String? - - var onTaskProgressUpdateText: ((_ text: String, _ targetName: String?) -> Void)? + init(progressAnimation: ProgressAnimationProtocol) { + self.progressAnimation = progressAnimation + self.swiftTaskProgressTexts = [:] + } - mutating func commandStatusChanged(_ command: SPMLLBuild.Command, kind: CommandStatusKind) { + mutating func commandStatusChanged( + _ command: SPMLLBuild.Command, + kind: CommandStatusKind, + targetName: String?, + time: ContinuousClock.Instant + ) { + let event: ProgressTaskState switch kind { - case .isScanning: - self.totalCount += 1 - case .isUpToDate: - self.totalCount -= 1 - case .isComplete: - self.finishedCount += 1 + case .isScanning: event = .discovered + case .isUpToDate: event = .completed(.skipped) + case .isComplete: event = .completed(.succeeded) @unknown default: assertionFailure("unhandled command status kind \(kind) for command \(command)") + return } + let name = self.progressText(of: command, targetName: targetName) + self.progressAnimation.update( + id: command.unstableId, + name: name, + event: event, + at: time) } - mutating func commandFinished(_ command: SPMLLBuild.Command, result: CommandResult, targetName: String?) { - let progressTextValue = self.progressText(of: command, targetName: targetName) - self.onTaskProgressUpdateText?(progressTextValue, targetName) + mutating func commandStarted( + _ command: SPMLLBuild.Command, + targetName: String?, + time: ContinuousClock.Instant + ) { + let name = self.progressText(of: command, targetName: targetName) + self.progressAnimation.update( + id: command.unstableId, + name: name, + event: .started, + at: time) + } - self.latestFinishedText = progressTextValue + mutating func commandFinished( + _ command: SPMLLBuild.Command, + result: CommandResult, + targetName: String?, + time: ContinuousClock.Instant + ) { + let name = self.progressText(of: command, targetName: targetName) + let event: ProgressTaskCompletion + switch result { + case .succeeded: event = .succeeded + case .failed: event = .failed + case .cancelled: event = .cancelled + case .skipped: event = .skipped + @unknown default: + assertionFailure("unhandled command result \(result) for command \(command)") + return + } + self.progressAnimation.update( + id: command.unstableId, + name: name, + event: .completed(event), + at: time) } - mutating func swiftCompilerDidOutputMessage(_ message: SwiftCompilerMessage, targetName: String) { + mutating func swiftCompilerDidOutputMessage( + _ message: SwiftCompilerMessage, + targetName: String, + time: ContinuousClock.Instant + ) { switch message.kind { case .began(let info): - if let text = progressText(of: message, targetName: targetName) { - self.swiftTaskProgressTexts[info.pid] = text - self.onTaskProgressUpdateText?(text, targetName) + if let task = self.progressText(of: message, targetName: targetName) { + self.swiftTaskProgressTexts[info.pid] = task +// self.progressAnimation.update(id: info.pid, name: task, event: .discovered, at: time) +// self.progressAnimation.update(id: info.pid, name: task, event: .started, at: time) } - self.totalCount += 1 case .finished(let info): - if let progressText = swiftTaskProgressTexts[info.pid] { - self.latestFinishedText = progressText - self.swiftTaskProgressTexts[info.pid] = nil + if let task = self.swiftTaskProgressTexts[info.pid] { + swiftTaskProgressTexts[info.pid] = nil +// self.progressAnimation.update(id: info.pid, name: task, event: .completed(.succeeded), at: time) } - self.finishedCount += 1 case .unparsableOutput, .abnormal, .signalled, .skipped: break } @@ -603,13 +611,31 @@ private struct CommandTaskTracker { return nil } - mutating func buildPreparationStepStarted(_: String) { - self.totalCount += 1 - } - - mutating func buildPreparationStepFinished(_ name: String) { - self.latestFinishedText = name - self.finishedCount += 1 + mutating func buildPreparationStepStarted( + _ name: String, + time: ContinuousClock.Instant + ) { + self.progressAnimation.update( + id: name.hash, + name: name, + event: .discovered, + at: time) + self.progressAnimation.update( + id: name.hash, + name: name, + event: .started, + at: time) + } + + mutating func buildPreparationStepFinished( + _ name: String, + time: ContinuousClock.Instant + ) { + self.progressAnimation.update( + id: name.hash, + name: name, + event: .completed(.succeeded), + at: time) } } @@ -670,3 +696,11 @@ extension BuildSystemCommand { ) } } + +extension SPMLLBuild.Command { + var unstableId: Int { + var hasher = Hasher() + hasher.combine(self) + return hasher.finalize() + } +} diff --git a/Sources/Commands/SwiftTestCommand.swift b/Sources/Commands/SwiftTestCommand.swift index a5b1e2cd919..37075a2449f 100644 --- a/Sources/Commands/SwiftTestCommand.swift +++ b/Sources/Commands/SwiftTestCommand.swift @@ -338,7 +338,8 @@ public struct SwiftTestCommand: AsyncSwiftCommand { buildOptions: globalOptions.build, productsBuildParameters: buildParameters, shouldOutputSuccess: swiftCommandState.logLevel <= .info, - observabilityScope: swiftCommandState.observabilityScope + observabilityScope: swiftCommandState.observabilityScope, + progressAnimationConfiguration: swiftCommandState.options.progressAnimation.configuration ) testResults = try runner.run(tests) @@ -372,6 +373,7 @@ public struct SwiftTestCommand: AsyncSwiftCommand { } } + switch results.reduce() { case .success: // Nothing to do here. @@ -873,6 +875,11 @@ struct UnitTest { } } +extension UnitTest { + var progressID: Int { self.specifier.hash } + var progressName: String { "Testing \(self.specifier)" } +} + /// A class to run tests on a XCTest binary. /// /// Note: Executes the XCTest with inherited environment as it is convenient to pass sensitive @@ -1072,7 +1079,8 @@ final class ParallelTestRunner { private let finishedTests = SynchronizedQueue() /// Instance of a terminal progress animation. - private let progressAnimation: ProgressAnimationProtocol + private let progressAnimation: Basics.ProgressAnimationProtocol + private let paLock = NSLock() /// Number of tests that will be executed. private var numTests = 0 @@ -1107,7 +1115,8 @@ final class ParallelTestRunner { buildOptions: BuildOptions, productsBuildParameters: BuildParameters, shouldOutputSuccess: Bool, - observabilityScope: ObservabilityScope + observabilityScope: ObservabilityScope, + progressAnimationConfiguration: ProgressAnimationConfiguration ) { self.bundlePaths = bundlePaths self.cancellator = cancellator @@ -1118,19 +1127,12 @@ final class ParallelTestRunner { // command's result output goes on stdout // ie "swift test" should output to stdout - if Environment.current["SWIFTPM_TEST_RUNNER_PROGRESS_BAR"] == "lit" { - self.progressAnimation = ProgressAnimation.percent( - stream: TSCBasic.stdoutStream, - verbose: false, - header: "Testing:", - isColorized: productsBuildParameters.outputParameters.isColorized - ) - } else { - self.progressAnimation = ProgressAnimation.ninja( - stream: TSCBasic.stdoutStream, - verbose: false - ) - } + self.progressAnimation = ProgressAnimation.make( + configuration: progressAnimationConfiguration, + environment: Environment.current, + stream: TSCBasic.stdoutStream, + verbose: false, + header: "Testing...") self.buildOptions = buildOptions self.productsBuildParameters = productsBuildParameters @@ -1139,14 +1141,27 @@ final class ParallelTestRunner { } /// Updates the progress bar status. - private func updateProgress(for test: UnitTest) { + private func completed(result: TestResult) { numCurrentTest += 1 - progressAnimation.update(step: numCurrentTest, total: numTests, text: "Testing \(test.specifier)") + self.paLock.withLock { + self.progressAnimation.update( + id: result.unitTest.progressID, + name: result.unitTest.progressName, + event: .completed(result.success ? .succeeded : .failed), + at: .now) + } } private func enqueueTests(_ tests: [UnitTest]) throws { // Enqueue all the tests. for test in tests { + self.paLock.withLock { + self.progressAnimation.update( + id: test.progressID, + name: test.progressName, + event: .discovered, + at: .now) + } pendingTests.enqueue(test) } self.numTests = tests.count @@ -1176,6 +1191,13 @@ final class ParallelTestRunner { let thread = Thread { // Dequeue a specifier and run it till we encounter nil. while let test = self.pendingTests.dequeue() { + self.paLock.withLock { + self.progressAnimation.update( + id: test.progressID, + name: test.progressName, + event: .started, + at: .now) + } let additionalArguments = TestRunner.xctestArguments(forTestSpecifiers: CollectionOfOne(test.specifier)) let testRunner = TestRunner( bundlePaths: [test.productPath], @@ -1186,6 +1208,7 @@ final class ParallelTestRunner { observabilityScope: self.observabilityScope, library: .xctest // swift-testing does not use ParallelTestRunner ) + var output = "" let outputLock = NSLock() let start = DispatchTime.now() @@ -1211,7 +1234,7 @@ final class ParallelTestRunner { // Report (consume) the tests which have finished running. while let result = finishedTests.dequeue() { - updateProgress(for: result.unitTest) + completed(result: result) // Store the result. processedTests.append(result) @@ -1227,7 +1250,8 @@ final class ParallelTestRunner { workers.forEach { $0.join() } // Report the completion. - progressAnimation.complete(success: processedTests.get().contains(where: { !$0.success })) + _ = processedTests.get().contains(where: { !$0.success }) + self.paLock.withLock { self.progressAnimation.complete("Testing complete") } // Print test results. for test in processedTests.get() { diff --git a/Sources/CoreCommands/BuildSystemSupport.swift b/Sources/CoreCommands/BuildSystemSupport.swift index 84ba4c01f18..6a96e6603d1 100644 --- a/Sources/CoreCommands/BuildSystemSupport.swift +++ b/Sources/CoreCommands/BuildSystemSupport.swift @@ -68,7 +68,8 @@ private struct NativeBuildSystemFactory: BuildSystemFactory { outputStream: outputStream ?? self.swiftCommandState.outputStream, logLevel: logLevel ?? self.swiftCommandState.logLevel, fileSystem: self.swiftCommandState.fileSystem, - observabilityScope: observabilityScope ?? self.swiftCommandState.observabilityScope) + observabilityScope: observabilityScope ?? self.swiftCommandState.observabilityScope, + progressAnimationConfiguration: self.swiftCommandState.options.progressAnimation.configuration) } } @@ -114,7 +115,7 @@ private struct SwiftBuildSystemFactory: BuildSystemFactory { packageGraphLoader: (() async throws -> ModulesGraph)?, outputStream: OutputByteStream?, logLevel: Diagnostic.Severity?, - observabilityScope: ObservabilityScope? + observabilityScope: ObservabilityScope?, ) throws -> any BuildSystem { return try SwiftBuildSystem( buildParameters: productsBuildParameters ?? self.swiftCommandState.productsBuildParameters, @@ -127,7 +128,8 @@ private struct SwiftBuildSystemFactory: BuildSystemFactory { outputStream: outputStream ?? self.swiftCommandState.outputStream, logLevel: logLevel ?? self.swiftCommandState.logLevel, fileSystem: self.swiftCommandState.fileSystem, - observabilityScope: observabilityScope ?? self.swiftCommandState.observabilityScope + observabilityScope: observabilityScope ?? self.swiftCommandState.observabilityScope, + progressAnimationConfiguration: .init() ) } } diff --git a/Sources/CoreCommands/Options.swift b/Sources/CoreCommands/Options.swift index c892694f383..f04f0c1cdd4 100644 --- a/Sources/CoreCommands/Options.swift +++ b/Sources/CoreCommands/Options.swift @@ -16,6 +16,10 @@ import struct Basics.AbsolutePath import var Basics.localFileSystem import enum Basics.TestingLibrary import struct Basics.Triple +import struct Basics.ProgressAnimationConfiguration +import enum Basics.ProgressAnimationStyle +import enum Basics.TerminalColoring + import struct Foundation.URL @@ -63,6 +67,9 @@ public struct GlobalOptions: ParsableArguments { @OptionGroup(title: "Build Options") public var linker: LinkerOptions + + @OptionGroup(title: "Progress Animation", visibility: .hidden) + public var progressAnimation: ProgressAnimationOptions } public struct LocationOptions: ParsableArguments { @@ -733,6 +740,60 @@ extension TraitConfiguration { } } +public struct ProgressAnimationOptions: ParsableArguments { + public enum ProgressAnimationStyle: String, CaseIterable, ExpressibleByArgument { + case blast + case ninja + case lit + + var style: Basics.ProgressAnimationStyle { + switch self { + case .blast: .blast + case .ninja: .ninja + case .lit: .lit + } + } + } + + public enum TerminalColors: String, CaseIterable, ExpressibleByArgument { + case noColors = "none" + case _8 = "8" + case _16 = "16" + case _88 = "88" + case _256 = "256" + case _16m = "16m" + + var coloring: Basics.TerminalColoring { + switch self { + case .noColors: .noColors + case ._8: ._8 + case ._16: ._16 + case ._88: ._88 + case ._256: ._256 + case ._16m: ._16m + } + } + } + + @Option(name: .long, help: "Progress bar style.") + public var progressBarStyle: ProgressAnimationStyle? + + @Option(name: .long, help: "Override default inferred terminal color support.") + public var termColors: TerminalColors? + + @Option(name: .long, help: "Override default inferred terminal interactivity") + public var termInteractive: Bool? + + package var configuration: Basics.ProgressAnimationConfiguration { + .init( + style: self.progressBarStyle?.style, + coloring: self.termColors?.coloring, + interactive: self.termInteractive) + } + + public init() { } +} + // MARK: - Extensions extension BuildConfiguration { diff --git a/Sources/CoreCommands/SwiftCommandObservabilityHandler.swift b/Sources/CoreCommands/SwiftCommandObservabilityHandler.swift index f555d2331d7..acd3e59d708 100644 --- a/Sources/CoreCommands/SwiftCommandObservabilityHandler.swift +++ b/Sources/CoreCommands/SwiftCommandObservabilityHandler.swift @@ -85,11 +85,9 @@ public struct SwiftCommandObservabilityHandler: ObservabilityHandlerProvider { self.logLevel = logLevel self.outputStream = outputStream self.writer = InteractiveWriter(stream: outputStream) - self.progressAnimation = ProgressAnimation.ninja( - stream: self.outputStream, - verbose: self.logLevel.isVerbose - ) self.colorDiagnostics = colorDiagnostics + self.progressAnimation = NinjaProgressAnimation( + stream: self.outputStream) } func handleDiagnostic(scope: ObservabilityScope, diagnostic: Basics.Diagnostic) { diff --git a/Sources/DriverSupport/DriverSupportUtils.swift b/Sources/DriverSupport/DriverSupportUtils.swift index 1497ec1b94a..0ede89441b6 100644 --- a/Sources/DriverSupport/DriverSupportUtils.swift +++ b/Sources/DriverSupport/DriverSupportUtils.swift @@ -42,7 +42,6 @@ public enum DriverSupport { let driver = try Driver( args: ["swiftc"], executor: executor, - compilerIntegratedTooling: false, compilerExecutableDir: TSCAbsolutePath(toolchain.swiftCompilerPath.parentDirectory) ) let supportedFlagSet = Set(driver.supportedFrontendFlags.map { $0.trimmingCharacters(in: ["-"]) }) diff --git a/Sources/LLBuildManifest/LLBuildManifest.swift b/Sources/LLBuildManifest/LLBuildManifest.swift index 520db084769..a2c81960375 100644 --- a/Sources/LLBuildManifest/LLBuildManifest.swift +++ b/Sources/LLBuildManifest/LLBuildManifest.swift @@ -277,21 +277,23 @@ public struct LLBuildManifest { } public mutating func addWriteLinkFileListCommand( + targetName: String, objects: [Basics.AbsolutePath], linkFileListPath: Basics.AbsolutePath ) { let inputs = WriteAuxiliary.LinkFileList.computeInputs(objects: objects) - let tool = WriteAuxiliaryFile(inputs: inputs, outputFilePath: linkFileListPath) + let tool = WriteAuxiliaryFile(inputs: inputs, outputFilePath: linkFileListPath, description: "Writing Link File List '\(targetName)'") let name = linkFileListPath.pathString addCommand(name: name, tool: tool) } public mutating func addWriteSourcesFileListCommand( + targetName: String, sources: [Basics.AbsolutePath], sourcesFileListPath: Basics.AbsolutePath ) { let inputs = WriteAuxiliary.SourcesFileList.computeInputs(sources: sources) - let tool = WriteAuxiliaryFile(inputs: inputs, outputFilePath: sourcesFileListPath) + let tool = WriteAuxiliaryFile(inputs: inputs, outputFilePath: sourcesFileListPath, description: "Writing Compilation File List '\(targetName)'") let name = sourcesFileListPath.pathString addCommand(name: name, tool: tool) } diff --git a/Sources/LLBuildManifest/Tools.swift b/Sources/LLBuildManifest/Tools.swift index c8afbcd0b01..87c7dcda10f 100644 --- a/Sources/LLBuildManifest/Tools.swift +++ b/Sources/LLBuildManifest/Tools.swift @@ -157,14 +157,16 @@ public struct ShellTool: ToolProtocol { public struct WriteAuxiliaryFile: Equatable, ToolProtocol { public static let name: String = "write-auxiliary-file" + public let description: String public let inputs: [Node] private let outputFilePath: AbsolutePath public let alwaysOutOfDate: Bool - public init(inputs: [Node], outputFilePath: AbsolutePath, alwaysOutOfDate: Bool = false) { + public init(inputs: [Node], outputFilePath: AbsolutePath, alwaysOutOfDate: Bool = false, description: String? = nil) { self.inputs = inputs self.outputFilePath = outputFilePath self.alwaysOutOfDate = alwaysOutOfDate + self.description = description ?? "Write auxiliary file \(outputFilePath.pathString)" } public var outputs: [Node] { @@ -172,7 +174,7 @@ public struct WriteAuxiliaryFile: Equatable, ToolProtocol { } public func write(to stream: inout ManifestToolStream) { - stream["description"] = "Write auxiliary file \(outputFilePath.pathString)" + stream["description"] = self.description } } diff --git a/Sources/SPMBuildCore/BuildSystem/BuildSystemDelegate.swift b/Sources/SPMBuildCore/BuildSystem/BuildSystemDelegate.swift index 163c16f16e3..638e3cfc288 100644 --- a/Sources/SPMBuildCore/BuildSystem/BuildSystemDelegate.swift +++ b/Sources/SPMBuildCore/BuildSystem/BuildSystemDelegate.swift @@ -21,6 +21,7 @@ public protocol BuildSystemDelegate: AnyObject { func buildSystem(_ buildSystem: BuildSystem, didStartCommand command: BuildSystemCommand) /// Called when build task did update progress. + @available(*, deprecated, message: "This method will never be called") func buildSystem(_ buildSystem: BuildSystem, didUpdateTaskProgress text: String) /// Called when build command did finish. diff --git a/Sources/SourceKitLSPAPI/BuildDescription.swift b/Sources/SourceKitLSPAPI/BuildDescription.swift index 8873b3bf500..4fbef0cef35 100644 --- a/Sources/SourceKitLSPAPI/BuildDescription.swift +++ b/Sources/SourceKitLSPAPI/BuildDescription.swift @@ -244,7 +244,8 @@ public struct BuildDescription { outputStream: threadSafeOutput, logLevel: .error, fileSystem: fileSystem, - observabilityScope: observabilityScope + observabilityScope: observabilityScope, + progressAnimationConfiguration: .init() ) let plan = try await operation.generatePlan() diff --git a/Sources/SwiftBuildSupport/SwiftBuildSystem.swift b/Sources/SwiftBuildSupport/SwiftBuildSystem.swift index 704259808a3..e982a48299c 100644 --- a/Sources/SwiftBuildSupport/SwiftBuildSystem.swift +++ b/Sources/SwiftBuildSupport/SwiftBuildSystem.swift @@ -166,6 +166,7 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { private var pifBuilder: AsyncThrowingValueMemoizer = .init() private let fileSystem: FileSystem private let observabilityScope: ObservabilityScope + private let progressAnimationConfiguration: Basics.ProgressAnimationConfiguration /// The output stream for the build delegate. private let outputStream: OutputByteStream @@ -215,7 +216,8 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { outputStream: OutputByteStream, logLevel: Basics.Diagnostic.Severity, fileSystem: FileSystem, - observabilityScope: ObservabilityScope + observabilityScope: ObservabilityScope, + progressAnimationConfiguration: Basics.ProgressAnimationConfiguration ) throws { self.buildParameters = buildParameters self.packageGraphLoader = packageGraphLoader @@ -224,6 +226,7 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { self.logLevel = logLevel self.fileSystem = fileSystem self.observabilityScope = observabilityScope.makeChildScope(description: "Swift Build System") + self.progressAnimationConfiguration = progressAnimationConfiguration } private func supportedSwiftVersions() throws -> [SwiftLanguageVersion] { @@ -260,12 +263,14 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { let parameters = try self.makeBuildParameters() let derivedDataPath = self.buildParameters.dataPath.pathString - let progressAnimation = ProgressAnimation.percent( + // Figure out which progress bar we have to use during the build. + let progressAnimation = ProgressAnimation.make( + configuration: self.progressAnimationConfiguration, + environment: .current, stream: self.outputStream, verbose: self.logLevel.isVerbose, - header: "", - isColorized: self.buildParameters.outputParameters.isColorized - ) + header: "Building...") + do { try await withSession(service: service, name: self.buildParameters.pifManifest.pathString, packageManagerResourcesDirectory: self.packageManagerResourcesDirectory) { session, _ in @@ -345,16 +350,16 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { func emitEvent(_ message: SwiftBuild.SwiftBuildMessage) throws { switch message { case .buildCompleted: - progressAnimation.complete(success: true) - 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) + progressAnimation.complete("Build complete!") +// 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) case .diagnostic(let info): let fixItsDescription = if info.fixIts.hasContent { ": " + info.fixIts.map { String(describing: $0) }.joined(separator: ", ") @@ -399,11 +404,8 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { switch operation.state { case .succeeded: - progressAnimation.update(step: 100, total: 100, text: "") - progressAnimation.complete(success: true) let duration = ContinuousClock.Instant.now - buildStartTime - self.outputStream.send("Build complete! (\(duration))\n") - self.outputStream.flush() + progressAnimation.complete("Build complete! (\(duration))\n") case .failed: self.observabilityScope.emit(error: "Build failed") throw Diagnostics.fatalError diff --git a/Sources/SwiftSDKCommand/InstallSwiftSDK.swift b/Sources/SwiftSDKCommand/InstallSwiftSDK.swift index d1ce17f6beb..60e9d7ab8d9 100644 --- a/Sources/SwiftSDKCommand/InstallSwiftSDK.swift +++ b/Sources/SwiftSDKCommand/InstallSwiftSDK.swift @@ -19,6 +19,7 @@ import PackageModel import var TSCBasic.stdoutStream import class Workspace.Workspace +import class TSCUtility.PercentProgressAnimation struct InstallSwiftSDK: SwiftSDKSubcommand { static let configuration = CommandConfiguration( @@ -61,14 +62,9 @@ struct InstallSwiftSDK: SwiftSDKSubcommand { fileSystem: self.fileSystem, observabilityScope: observabilityScope, outputHandler: { print($0.description) }, - downloadProgressAnimation: ProgressAnimation - .percent( - stream: stdoutStream, - verbose: false, - header: "Downloading", - isColorized: self.colorDiagnostics - ) - .throttled(interval: .milliseconds(300)) + downloadProgressAnimation: PercentProgressAnimation( + stream: stdoutStream, + header: "Downloading...") ) try await store.install( diff --git a/Sources/XCBuildSupport/XCBuildDelegate.swift b/Sources/XCBuildSupport/XCBuildDelegate.swift index acf7f0c7317..1f10f245d98 100644 --- a/Sources/XCBuildSupport/XCBuildDelegate.swift +++ b/Sources/XCBuildSupport/XCBuildDelegate.swift @@ -20,7 +20,7 @@ import protocol TSCBasic.OutputByteStream import enum TSCUtility.Diagnostics import protocol TSCUtility.ProgressAnimationProtocol -public class XCBuildDelegate { +package class XCBuildDelegate { private let buildSystem: SPMBuildCore.BuildSystem private var parser: XCBuildOutputParser! private let observabilityScope: ObservabilityScope @@ -38,7 +38,7 @@ public class XCBuildDelegate { /// True if any output was parsed. fileprivate(set) var didParseAnyOutput: Bool = false - public init( + package init( buildSystem: SPMBuildCore.BuildSystem, outputStream: OutputByteStream, progressAnimation: ProgressAnimationProtocol, @@ -55,7 +55,7 @@ public class XCBuildDelegate { self.parser = XCBuildOutputParser(delegate: self) } - public func parse(bytes: [UInt8]) { + package func parse(bytes: [UInt8]) { parser.parse(bytes: bytes) } } @@ -107,12 +107,8 @@ extension XCBuildDelegate: XCBuildOutputParserDelegate { self.outputStream.send("\(info.data)\n") self.outputStream.flush() } - case .didUpdateProgress(let info): - queue.async { - let percent = Int(info.percentComplete) - self.percentComplete = percent > 0 ? percent : 0 - self.buildSystem.delegate?.buildSystem(self.buildSystem, didUpdateTaskProgress: info.message) - } + case .didUpdateProgress: + break case .buildCompleted(let info): queue.async { switch info.result { diff --git a/Sources/XCBuildSupport/XcodeBuildSystem.swift b/Sources/XCBuildSupport/XcodeBuildSystem.swift index c6467c87bcf..ec1db199f1a 100644 --- a/Sources/XCBuildSupport/XcodeBuildSystem.swift +++ b/Sources/XCBuildSupport/XcodeBuildSystem.swift @@ -28,6 +28,7 @@ import protocol TSCBasic.OutputByteStream import func TSCBasic.withTemporaryFile import enum TSCUtility.Diagnostics +import class TSCUtility.PercentProgressAnimation public final class XcodeBuildSystem: SPMBuildCore.BuildSystem { private let buildParameters: BuildParameters @@ -308,11 +309,9 @@ public final class XcodeBuildSystem: SPMBuildCore.BuildSystem { /// Returns a new instance of `XCBuildDelegate` for a build operation. private func createBuildDelegate() -> XCBuildDelegate { - let progressAnimation = ProgressAnimation.percent( + let progressAnimation = PercentProgressAnimation( stream: self.outputStream, - verbose: self.logLevel.isVerbose, - header: "", - isColorized: buildParameters.outputParameters.isColorized + header: "" ) let delegate = XCBuildDelegate( buildSystem: self, diff --git a/Sources/swift-bootstrap/main.swift b/Sources/swift-bootstrap/main.swift index bb1ebfe311a..0a7ec56d53e 100644 --- a/Sources/swift-bootstrap/main.swift +++ b/Sources/swift-bootstrap/main.swift @@ -33,6 +33,8 @@ import func TSCBasic.topologicalSort import var TSCBasic.stdoutStream import enum TSCBasic.GraphError import struct TSCBasic.OrderedSet +import enum TSCBasic.ProcessEnv + import enum TSCUtility.Diagnostics import struct TSCUtility.Version @@ -346,7 +348,8 @@ struct SwiftBootstrapBuildTool: AsyncParsableCommand { outputStream: TSCBasic.stdoutStream, logLevel: logLevel, fileSystem: self.fileSystem, - observabilityScope: self.observabilityScope + observabilityScope: self.observabilityScope, + progressAnimationConfiguration: .init() ) case .xcode: return try XcodeBuildSystem( @@ -365,7 +368,8 @@ struct SwiftBootstrapBuildTool: AsyncParsableCommand { outputStream: TSCBasic.stdoutStream, logLevel: logLevel, fileSystem: self.fileSystem, - observabilityScope: self.observabilityScope + observabilityScope: self.observabilityScope, + progressAnimationConfiguration: .init() ) } } diff --git a/Tests/BasicsTests/ProgressAnimationTests.swift b/Tests/BasicsTests/ProgressAnimationTests.swift index f3da82245ce..2e2290fab62 100644 --- a/Tests/BasicsTests/ProgressAnimationTests.swift +++ b/Tests/BasicsTests/ProgressAnimationTests.swift @@ -16,7 +16,7 @@ import Testing @_spi(SwiftPMInternal) @testable import Basics -import TSCBasic +import protocol TSCUtility.ProgressAnimationProtocol struct ProgressAnimationTests { class TrackingProgressAnimation: ProgressAnimationProtocol { diff --git a/Tests/BuildTests/BuildOperationTests.swift b/Tests/BuildTests/BuildOperationTests.swift index af243134cb5..a930c21a814 100644 --- a/Tests/BuildTests/BuildOperationTests.swift +++ b/Tests/BuildTests/BuildOperationTests.swift @@ -44,7 +44,8 @@ private func mockBuildOperation( outputStream: BufferedOutputByteStream(), logLevel: .info, fileSystem: fs, - observabilityScope: observabilityScope + observabilityScope: observabilityScope, + progressAnimationConfiguration: .init() ) }