Skip to content

Commit 6a7db24

Browse files
authored
command plugins: Optionally print build logs during packageManager.build (#7255)
This commit adds the option for a build run on behalf of a command plugin to echo logs to the console as they are produced, in addition to the current behaviour of accumulating them in a buffer. Console echoing is turned off by default. ### Motivation: When a plugin asks for a target to be built by calling `packageManager.build`, SwiftPM accumulates the build log in a buffer and returns the whole log when the build completes. No feedback is printed on the console while the build is running, so if the target is large the user can't tell whether the build is making progress or is stuck. ### Modifications: * Added `TeeOutputByteStream`, an `OutputByteStream` which copies its inputs to a set of output streams * Added an `echoLogs` parameter to `packageManager.build`, defaulting to `false` to match the current behaviour. * Added a test to verify that logs are echoed and the existing log accumulation continues to work. ### Result: A command plugin can ask for build outputs to be echoed as they are produced. The user will have some feedback that a build is happening, rather than the long pause which can happen now. This change depends on #7254.
1 parent bd821ea commit 6a7db24

File tree

6 files changed

+119
-4
lines changed

6 files changed

+119
-4
lines changed

Fixtures/Miscellaneous/Plugins/CommandPluginTestStub/Plugins/diagnostics-stub/diagnostics_stub.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@ import PackagePlugin
55
struct diagnostics_stub: CommandPlugin {
66
// This is a helper for testing plugin diagnostics. It sends different messages to SwiftPM depending on its arguments.
77
func performCommand(context: PluginContext, arguments: [String]) async throws {
8+
// Build a target, possibly asking SwiftPM to echo the logs as they are produced.
9+
if arguments.contains("build") {
10+
// If echoLogs is true, SwiftPM will print build logs to stderr as they are produced.
11+
// SwiftPM does not add a prefix to these logs.
12+
let result = try packageManager.build(
13+
.product("placeholder"),
14+
parameters: .init(echoLogs: arguments.contains("echologs"))
15+
)
16+
17+
// To verify that logs are also returned correctly to the plugin,
18+
// print the accumulated log buffer lines with a prefix to
19+
// distinguish them from echoed logs. These logs are normal output
20+
// from the plugin and will be printed on stdout.
21+
if arguments.contains("printlogs") {
22+
for line in result.logText.components(separatedBy: "\n") {
23+
print("command plugin: packageManager.build logtext: \(line)")
24+
}
25+
}
26+
}
27+
828
// Anything a plugin writes to standard output appears on standard output.
929
// Printing to stderr will also go to standard output because SwiftPM combines
1030
// stdout and stderr before launching the plugin.

Sources/Commands/Utilities/PluginDelegate.swift

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import Foundation
1616
import PackageModel
1717
import SPMBuildCore
1818

19+
import protocol TSCBasic.OutputByteStream
1920
import class TSCBasic.BufferedOutputByteStream
2021
import class TSCBasic.Process
2122
import struct TSCBasic.ProcessResult
@@ -71,6 +72,40 @@ final class PluginDelegate: PluginInvocationDelegate {
7172
}
7273
}
7374

75+
class TeeOutputByteStream: OutputByteStream {
76+
var downstreams: [OutputByteStream]
77+
78+
public init(_ downstreams: [OutputByteStream]) {
79+
self.downstreams = downstreams
80+
}
81+
82+
var position: Int {
83+
return 0 // should be related to the downstreams somehow
84+
}
85+
86+
public func write(_ byte: UInt8) {
87+
for downstream in downstreams {
88+
downstream.write(byte)
89+
}
90+
}
91+
92+
func write<C: Collection>(_ bytes: C) where C.Element == UInt8 {
93+
for downstream in downstreams {
94+
downstream.write(bytes)
95+
}
96+
}
97+
98+
public func flush() {
99+
for downstream in downstreams {
100+
downstream.flush()
101+
}
102+
}
103+
104+
public func addStream(_ stream: OutputByteStream) {
105+
self.downstreams.append(stream)
106+
}
107+
}
108+
74109
private func performBuildForPlugin(
75110
subset: PluginInvocationBuildSubset,
76111
parameters: PluginInvocationBuildParameters
@@ -117,7 +152,12 @@ final class PluginDelegate: PluginInvocationDelegate {
117152
}
118153

119154
// Create a build operation. We have to disable the cache in order to get a build plan created.
120-
let outputStream = BufferedOutputByteStream()
155+
let bufferedOutputStream = BufferedOutputByteStream()
156+
let outputStream = TeeOutputByteStream([bufferedOutputStream])
157+
if parameters.echoLogs {
158+
outputStream.addStream(swiftTool.outputStream)
159+
}
160+
121161
let buildSystem = try swiftTool.createBuildSystem(
122162
explicitBuildSystem: .native,
123163
explicitProduct: explicitProduct,
@@ -156,7 +196,7 @@ final class PluginDelegate: PluginInvocationDelegate {
156196
}
157197
return PluginInvocationBuildResult(
158198
succeeded: success,
159-
logText: outputStream.bytes.cString,
199+
logText: bufferedOutputStream.bytes.cString,
160200
builtArtifacts: builtArtifacts)
161201
}
162202

Sources/PackagePlugin/PackageManagerProxy.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,10 @@ public struct PackageManager {
5959

6060
/// Controls the amount of detail in the log returned in the build result.
6161
public var logging: BuildLogVerbosity
62-
62+
63+
/// Whether to print build logs to the console
64+
public var echoLogs: Bool
65+
6366
/// Additional flags to pass to all C compiler invocations.
6467
public var otherCFlags: [String] = []
6568

@@ -72,9 +75,10 @@ public struct PackageManager {
7275
/// Additional flags to pass to all linker invocations.
7376
public var otherLinkerFlags: [String] = []
7477

75-
public init(configuration: BuildConfiguration = .debug, logging: BuildLogVerbosity = .concise) {
78+
public init(configuration: BuildConfiguration = .debug, logging: BuildLogVerbosity = .concise, echoLogs: Bool = false) {
7679
self.configuration = configuration
7780
self.logging = logging
81+
self.echoLogs = echoLogs
7882
}
7983
}
8084

@@ -314,6 +318,7 @@ fileprivate extension PluginToHostMessage.BuildParameters {
314318
init(_ parameters: PackageManager.BuildParameters) {
315319
self.configuration = .init(parameters.configuration)
316320
self.logging = .init(parameters.logging)
321+
self.echoLogs = parameters.echoLogs
317322
self.otherCFlags = parameters.otherCFlags
318323
self.otherCxxFlags = parameters.otherCxxFlags
319324
self.otherSwiftcFlags = parameters.otherSwiftcFlags

Sources/PackagePlugin/PluginMessages.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ enum PluginToHostMessage: Codable {
306306
enum LogVerbosity: String, Codable {
307307
case concise, verbose, debug
308308
}
309+
var echoLogs: Bool
309310
var otherCFlags: [String]
310311
var otherCxxFlags: [String]
311312
var otherSwiftcFlags: [String]

Sources/SPMBuildCore/Plugins/PluginInvocation.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,7 @@ public struct PluginInvocationBuildParameters {
875875
public enum LogVerbosity: String {
876876
case concise, verbose, debug
877877
}
878+
public var echoLogs: Bool
878879
public var otherCFlags: [String]
879880
public var otherCxxFlags: [String]
880881
public var otherSwiftcFlags: [String]
@@ -987,6 +988,7 @@ fileprivate extension PluginInvocationBuildParameters {
987988
init(_ parameters: PluginToHostMessage.BuildParameters) {
988989
self.configuration = .init(parameters.configuration)
989990
self.logging = .init(parameters.logging)
991+
self.echoLogs = parameters.echoLogs
990992
self.otherCFlags = parameters.otherCFlags
991993
self.otherCxxFlags = parameters.otherCxxFlags
992994
self.otherSwiftcFlags = parameters.otherSwiftcFlags

Tests/CommandsTests/PackageToolTests.swift

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2071,6 +2071,53 @@ final class PackageToolTests: CommandsTestCase {
20712071
}
20722072
}
20732073

2074+
// Test logging of builds initiated by a command plugin
2075+
func testCommandPluginBuildLogs() throws {
2076+
// Only run the test if the environment in which we're running actually supports Swift concurrency (which the plugin APIs require).
2077+
try XCTSkipIf(!UserToolchain.default.supportsSwiftConcurrency(), "skipping because test environment doesn't support concurrency")
2078+
2079+
// Match patterns for expected messages
2080+
2081+
let isEmpty = StringPattern.equal("")
2082+
2083+
// result.logText printed by the plugin has a prefix
2084+
let containsLogtext = StringPattern.contains("command plugin: packageManager.build logtext: Building for debugging...")
2085+
2086+
// Echoed logs have no prefix
2087+
let containsLogecho = StringPattern.regex("^Building for debugging...\n")
2088+
2089+
// These tests involve building a target, so each test must run with a fresh copy of the fixture
2090+
// otherwise the logs may be different in subsequent tests.
2091+
2092+
// Check than nothing is echoed when echoLogs is false
2093+
try fixture(name: "Miscellaneous/Plugins/CommandPluginTestStub") { fixturePath in
2094+
let (stdout, stderr) = try SwiftPM.Package.execute(["print-diagnostics", "build"], packagePath: fixturePath)
2095+
XCTAssertMatch(stdout, isEmpty)
2096+
XCTAssertMatch(stderr, isEmpty)
2097+
}
2098+
2099+
// Check that logs are returned to the plugin when echoLogs is false
2100+
try fixture(name: "Miscellaneous/Plugins/CommandPluginTestStub") { fixturePath in
2101+
let (stdout, stderr) = try SwiftPM.Package.execute(["print-diagnostics", "build", "printlogs"], packagePath: fixturePath)
2102+
XCTAssertMatch(stdout, containsLogtext)
2103+
XCTAssertMatch(stderr, isEmpty)
2104+
}
2105+
2106+
// Check that logs echoed to the console (on stderr) when echoLogs is true
2107+
try fixture(name: "Miscellaneous/Plugins/CommandPluginTestStub") { fixturePath in
2108+
let (stdout, stderr) = try SwiftPM.Package.execute(["print-diagnostics", "build", "echologs"], packagePath: fixturePath)
2109+
XCTAssertMatch(stdout, isEmpty)
2110+
XCTAssertMatch(stderr, containsLogecho)
2111+
}
2112+
2113+
// Check that logs are returned to the plugin and echoed to the console (on stderr) when echoLogs is true
2114+
try fixture(name: "Miscellaneous/Plugins/CommandPluginTestStub") { fixturePath in
2115+
let (stdout, stderr) = try SwiftPM.Package.execute(["print-diagnostics", "build", "printlogs", "echologs"], packagePath: fixturePath)
2116+
XCTAssertMatch(stdout, containsLogtext)
2117+
XCTAssertMatch(stderr, containsLogecho)
2118+
}
2119+
}
2120+
20742121
func testCommandPluginNetworkingPermissions(permissionsManifestFragment: String, permissionError: String, reason: String, remedy: [String]) throws {
20752122
// Only run the test if the environment in which we're running actually supports Swift concurrency (which the plugin APIs require).
20762123
try XCTSkipIf(!UserToolchain.default.supportsSwiftConcurrency(), "skipping because test environment doesn't support concurrency")

0 commit comments

Comments
 (0)