Skip to content

Commit 95e3f75

Browse files
committed
Pass through a plugin invocation delegate instead of just a text output callback handler, and adjust call sites. A few other cleanup items in addition.
1 parent fa48908 commit 95e3f75

File tree

6 files changed

+135
-31
lines changed

6 files changed

+135
-31
lines changed

Sources/Commands/SwiftPackageTool.swift

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -909,7 +909,7 @@ extension SwiftPackageTool {
909909

910910
// Complain if we didn't find exactly one.
911911
if matchingPlugins.isEmpty {
912-
swiftTool.observabilityScope.emit(error: "No plugins found for '\(command)'")
912+
swiftTool.observabilityScope.emit(error: "No command plugins found for '\(command)'")
913913
throw ExitCode.failure
914914
}
915915
else if matchingPlugins.count > 1 {
@@ -949,10 +949,10 @@ extension SwiftPackageTool {
949949
let pluginScriptRunner = DefaultPluginScriptRunner(cacheDir: cacheDir, toolchain: try swiftTool.getToolchain().configuration)
950950

951951
// The `outputs` directory contains subdirectories for each combination of package, target, and plugin. Each usage of a plugin has an output directory that is writable by the plugin, where it can write additional files, and to which it can configure tools to write their outputs, etc.
952+
// FIXME: Revisit this path.
952953
let outputDir = pluginsDir.appending(component: "outputs")
953954

954955
// Build the map of tools that are available to the plugin. This should include the tools in the executables in the toolchain, as well as any executables the plugin depends on (built executables as well as prebuilt binaries).
955-
// FIXME: At the moment we just pass the built products directory for the host. We will need to extend this with a map of the names of tools available to each plugin. In particular this would not work with any binary targets.
956956
let dataDir = try swiftTool.getActiveWorkspace().location.pluginWorkingDirectory
957957
let builtToolsDir = dataDir.appending(components: "plugin-tools")
958958

@@ -961,7 +961,6 @@ extension SwiftPackageTool {
961961

962962
// FIXME: Need to determine the correct root package.
963963

964-
// FIXME: Need to
965964
// Determine the tools to which this plugin has access, and create a name-to-path mapping from tool
966965
// names to the corresponding paths. Built tools are assumed to be in the build tools directory.
967966
let accessibleTools = plugin.accessibleTools(for: pluginScriptRunner.hostTriple)
@@ -973,8 +972,41 @@ extension SwiftPackageTool {
973972
dict[name] = path
974973
}
975974
})
975+
976+
class PluginOutputEmitter {
977+
var bufferedOutput: Data
978+
init() {
979+
self.bufferedOutput = Data()
980+
}
981+
func emit(data: Data) {
982+
bufferedOutput += data
983+
while let newlineIdx = bufferedOutput.firstIndex(of: UInt8(ascii: "\n")) {
984+
let lineData = bufferedOutput.prefix(upTo: newlineIdx)
985+
print("🧩 \(String(decoding: lineData, as: UTF8.self))")
986+
bufferedOutput = bufferedOutput.suffix(from: newlineIdx.advanced(by: 1))
987+
}
988+
}
989+
}
990+
991+
// Set up a delegate to handle callbacks from the command plugin.
992+
let delegateQueue = DispatchQueue(label: "plugin-invocation")
993+
struct PluginDelegate: PluginInvocationDelegate {
994+
let swiftTool: SwiftTool
995+
let delegateQueue: DispatchQueue
996+
var outputEmitter = PluginOutputEmitter()
997+
998+
func pluginEmittedOutput(data: Data) {
999+
dispatchPrecondition(condition: .onQueue(delegateQueue))
1000+
outputEmitter.emit(data: data)
1001+
}
1002+
1003+
func pluginEmittedDiagnostic(severity: PluginInvocationDiagnosticSeverity, message: String, file: String?, line: Int?) {
1004+
}
1005+
}
1006+
let pluginDelegate = PluginDelegate(swiftTool: swiftTool, delegateQueue: delegateQueue)
1007+
9761008

977-
// Run the plugin.
1009+
// Run the command plugin.
9781010
let buildEnvironment = try swiftTool.buildParameters().buildEnvironment
9791011
let result = try tsc_await { plugin.invoke(
9801012
action: .performCommand(targets: Array(targets.values), arguments: arguments),
@@ -985,11 +1017,12 @@ extension SwiftPackageTool {
9851017
toolNamesToPaths: toolNamesToPaths,
9861018
fileSystem: localFileSystem,
9871019
observabilityScope: swiftTool.observabilityScope,
988-
callbackQueue: DispatchQueue(label: "plugin-invocation"),
1020+
callbackQueue: delegateQueue,
1021+
delegate: pluginDelegate,
9891022
completion: $0) }
9901023

991-
// Temporary: emit any output from the plugin.
992-
print(result.textOutput)
1024+
// Should we also emit a final line of output regarding the result?
1025+
print(result)
9931026
}
9941027
}
9951028
}

Sources/PackagePlugin/Plugin.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ extension Plugin {
159159
} catch {
160160
internalError("Couldn’t encode output JSON: \(error).")
161161
}
162-
try pluginHostConnection.sendMessage(.provideResult(output: outputStruct.output))
162+
try pluginHostConnection.sendMessage(.pluginFinished(result: outputStruct.output))
163163

164164
default:
165165
internalError("unexpected top-level message \(message)")
@@ -185,12 +185,17 @@ internal fileprivate(set) var pluginHostConnection: PluginHostConnection!
185185

186186
/// A message that the host can send to the plugin.
187187
enum HostToPluginMessage: Decodable {
188+
/// The host is requesting that the plugin perform one of its declared plugin actions.
188189
case performAction(input: WireInput)
190+
191+
/// A response of an error while trying to complete a request.
192+
case errorResponse(error: String)
189193
}
190194

191195
/// A message that the plugin can send to the host.
192196
enum PluginToHostMessage: Encodable {
193-
case provideResult(output: WireOutput)
197+
/// The plugin has finished the requested action and is returning a result.
198+
case pluginFinished(result: WireOutput)
194199
}
195200

196201

Sources/SPMBuildCore/PluginInvocation.swift

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ extension PluginTarget {
5555
fileSystem: FileSystem,
5656
observabilityScope: ObservabilityScope,
5757
callbackQueue: DispatchQueue,
58+
delegate: PluginInvocationDelegate,
5859
completion: @escaping (Result<PluginInvocationResult, Error>) -> Void
5960
) {
6061
// Create the plugin working directory if needed (but don't do anything with it if it already exists).
@@ -80,8 +81,6 @@ extension PluginTarget {
8081
return callbackQueue.async { completion(.failure(PluginEvaluationError.couldNotSerializePluginInput(underlyingError: error))) }
8182
}
8283

83-
let callbackQueue = DispatchQueue(label: "plugin-invocation")
84-
8584
// Call the plugin script runner to actually invoke the plugin.
8685
var outputText = Data()
8786
scriptRunner.runPluginScript(
@@ -92,9 +91,7 @@ extension PluginTarget {
9291
fileSystem: fileSystem,
9392
observabilityScope: observabilityScope,
9493
callbackQueue: callbackQueue,
95-
outputHandler: { data in
96-
outputText.append(contentsOf: data)
97-
},
94+
delegate: delegate,
9895
completion: { result in
9996
// Translate the PluginScriptRunnerOutput into a PluginInvocationResult.
10097
dispatchPrecondition(condition: .onQueue(callbackQueue))
@@ -235,7 +232,34 @@ extension PackageGraph {
235232
// Assign a plugin working directory based on the package, target, and plugin.
236233
let pluginOutputDir = outputDir.appending(components: package.identity.description, target.name, pluginTarget.name)
237234

238-
// Invoke the plugin.
235+
// Set up a delegate to handle callbacks from the build tool plugin.
236+
let delegateQueue = DispatchQueue(label: "plugin-invocation")
237+
class PluginDelegate: PluginInvocationDelegate {
238+
let delegateQueue: DispatchQueue
239+
var bufferedData: Data
240+
241+
init(delegateQueue: DispatchQueue) {
242+
self.delegateQueue = delegateQueue
243+
self.bufferedData = Data()
244+
}
245+
246+
func pluginEmittedOutput(data: Data) {
247+
dispatchPrecondition(condition: .onQueue(delegateQueue))
248+
// Send the data by newline, taking into account any buffered partial line.
249+
bufferedData += data
250+
while let newlineIdx = bufferedData.firstIndex(of: UInt8(ascii: "\n")) {
251+
let lineData = bufferedData[bufferedData.startIndex..<newlineIdx.advanced(by: 1)]
252+
print("🧩 \(String(decoding: lineData, as: UTF8.self))")
253+
bufferedData = bufferedData[newlineIdx.advanced(by: 1)...]
254+
}
255+
}
256+
257+
func pluginEmittedDiagnostic(severity: PluginInvocationDiagnosticSeverity, message: String, file: String?, line: Int?) {
258+
dispatchPrecondition(condition: .onQueue(delegateQueue))
259+
}
260+
}
261+
262+
// Invoke the build tool plugin.
239263
let result = try tsc_await { pluginTarget.invoke(
240264
action: .createBuildToolCommands(target: target),
241265
package: package,
@@ -245,7 +269,8 @@ extension PackageGraph {
245269
toolNamesToPaths: toolNamesToPaths,
246270
fileSystem: fileSystem,
247271
observabilityScope: observabilityScope,
248-
callbackQueue: DispatchQueue(label: "plugin-invocation"),
272+
callbackQueue: delegateQueue,
273+
delegate: PluginDelegate(delegateQueue: delegateQueue),
249274
completion: $0) }
250275
pluginResults.append(result)
251276
}
@@ -385,7 +410,7 @@ public protocol PluginScriptRunner {
385410
fileSystem: FileSystem,
386411
observabilityScope: ObservabilityScope,
387412
callbackQueue: DispatchQueue,
388-
outputHandler: @escaping (Data) -> Void,
413+
delegate: PluginInvocationDelegate,
389414
completion: @escaping (Result<PluginScriptRunnerOutput, Error>) -> Void
390415
)
391416

@@ -395,6 +420,19 @@ public protocol PluginScriptRunner {
395420
}
396421

397422

423+
public protocol PluginInvocationDelegate {
424+
/// Called for each piece of textual output data emitted by the plugin. Note that there is no guarantee that the data begins and ends on a UTF-8 byte sequence boundary (much less on a line boundary) so the delegate should buffer partial data as appropriate.
425+
func pluginEmittedOutput(data: Data)
426+
427+
/// Called when a plugin emits a diagnostic through the PackagePlugin APIs.
428+
func pluginEmittedDiagnostic(severity: PluginInvocationDiagnosticSeverity, message: String, file: String?, line: Int?)
429+
}
430+
431+
public enum PluginInvocationDiagnosticSeverity: String, Decodable {
432+
case error, warning, remark
433+
}
434+
435+
398436
/// Serializable context that's passed as input to an invocation of a plugin.
399437
/// This is the transport data to a particular invocation of a plugin for a
400438
/// particular purpose; everything we can communicate to the plugin is here.

Sources/Workspace/DefaultPluginScriptRunner.swift

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner {
5858
fileSystem: FileSystem,
5959
observabilityScope: ObservabilityScope,
6060
callbackQueue: DispatchQueue,
61-
outputHandler: @escaping (Data) -> Void,
61+
delegate: PluginInvocationDelegate,
6262
completion: @escaping (Result<PluginScriptRunnerOutput, Error>) -> Void
6363
) {
6464
// If needed, compile the plugin script to an executable (asynchronously).
@@ -80,7 +80,7 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner {
8080
input: input,
8181
observabilityScope: observabilityScope,
8282
callbackQueue: callbackQueue,
83-
outputHandler: outputHandler,
83+
delegate: delegate,
8484
completion: completion)
8585
case .failure(let error):
8686
// Compilation failed, so just call the callback block on the appropriate queue.
@@ -250,7 +250,7 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner {
250250
input: PluginScriptRunnerInput,
251251
observabilityScope: ObservabilityScope,
252252
callbackQueue: DispatchQueue,
253-
outputHandler: @escaping (Data) -> Void,
253+
delegate: PluginInvocationDelegate,
254254
completion: @escaping (Result<PluginScriptRunnerOutput, Error>) -> Void
255255
) {
256256
// Construct the command line. Currently we just invoke the executable built from the plugin without any parameters.
@@ -279,7 +279,8 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner {
279279
func handle(message: PluginToHostMessage) {
280280
dispatchPrecondition(condition: .onQueue(callbackQueue))
281281
switch message {
282-
case .provideResult(let output):
282+
case .pluginFinished(let output):
283+
// The plugin has indicated that it's finished the action it was requested to perform, and is returning a response.
283284
result = output
284285
outputQueue.async {
285286
try? outputHandle.close()
@@ -309,7 +310,7 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner {
309310
let newData = fileHandle.availableData
310311
if newData.isEmpty { return }
311312
//print("[output] \(String(decoding: newData, as: UTF8.self))")
312-
callbackQueue.async { outputHandler(newData) }
313+
callbackQueue.async { delegate.pluginEmittedOutput(data: newData) }
313314
}
314315
}
315316
process.standardError = stderrPipe
@@ -387,13 +388,18 @@ extension DefaultPluginScriptRunnerError: CustomStringConvertible {
387388
}
388389

389390
/// A message that the host can send to the plugin.
390-
enum HostToPluginMessage: Codable {
391+
enum HostToPluginMessage: Encodable {
392+
/// The host is requesting that the plugin perform one of its declared plugin actions.
391393
case performAction(input: PluginScriptRunnerInput)
394+
395+
/// A response of an error while trying to complete a request.
396+
case errorResponse(error: String)
392397
}
393398

394399
/// A message that the plugin can send to the host.
395-
enum PluginToHostMessage: Codable {
396-
case provideResult(output: PluginScriptRunnerOutput)
400+
enum PluginToHostMessage: Decodable {
401+
/// The plugin has finished the requested action and is returning a result.
402+
case pluginFinished(result: PluginScriptRunnerOutput)
397403
}
398404

399405
fileprivate extension FileHandle {

Tests/FunctionalTests/PluginTests.swift

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -270,12 +270,32 @@ class PluginTests: XCTestCase {
270270
let pluginTarget = try XCTUnwrap(package.targets.map(\.underlyingTarget).first{ $0.name == "MyPlugin" } as? PluginTarget)
271271
XCTAssertEqual(pluginTarget.type, .plugin)
272272

273-
// Invoke it.
273+
// Set up a delegate to handle callbacks from the command plugin.
274+
let delegateQueue = DispatchQueue(label: "plugin-invocation")
275+
class PluginDelegate: PluginInvocationDelegate {
276+
let delegateQueue: DispatchQueue
277+
var outputData = Data()
278+
279+
init(delegateQueue: DispatchQueue) {
280+
self.delegateQueue = delegateQueue
281+
}
282+
283+
func pluginEmittedOutput(data: Data) {
284+
dispatchPrecondition(condition: .onQueue(delegateQueue))
285+
outputData.append(contentsOf: data)
286+
}
287+
288+
func pluginEmittedDiagnostic(severity: PluginInvocationDiagnosticSeverity, message: String, file: String?, line: Int?) {
289+
}
290+
}
291+
let pluginDelegate = PluginDelegate(delegateQueue: delegateQueue)
292+
293+
// Invoke the command plugin.
274294
let pluginCacheDir = tmpPath.appending(component: "plugin-cache")
275295
let pluginOutputDir = tmpPath.appending(component: "plugin-output")
276296
let pluginScriptRunner = DefaultPluginScriptRunner(cacheDir: pluginCacheDir, toolchain: ToolchainConfiguration.default)
277297
let target = try XCTUnwrap(package.targets.first{ $0.underlyingTarget == libraryTarget })
278-
let result = try tsc_await { pluginTarget.invoke(
298+
let _ = try tsc_await { pluginTarget.invoke(
279299
action: .performCommand(
280300
targets: [ target ],
281301
arguments: ["veni", "vidi", "vici"]),
@@ -286,11 +306,13 @@ class PluginTests: XCTestCase {
286306
toolNamesToPaths: [:],
287307
fileSystem: localFileSystem,
288308
observabilityScope: observability.topScope,
289-
callbackQueue: DispatchQueue(label: "plugin-invocation"),
309+
callbackQueue: delegateQueue,
310+
delegate: pluginDelegate,
290311
completion: $0) }
291312

292313
// Check the results.
293-
XCTAssertTrue(result.textOutput.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix("This is MyCommandPlugin."))
314+
let outputText = String(decoding: pluginDelegate.outputData, as: UTF8.self)
315+
XCTAssertTrue(outputText.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix("This is MyCommandPlugin."))
294316
}
295317
}
296318
}

Tests/SPMBuildCoreTests/PluginInvocationTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ class PluginInvocationTests: XCTestCase {
9696
fileSystem: FileSystem,
9797
observabilityScope: ObservabilityScope,
9898
callbackQueue: DispatchQueue,
99-
outputHandler: @escaping (Data) -> Void,
99+
delegate: PluginInvocationDelegate,
100100
completion: @escaping (Result<PluginScriptRunnerOutput, Error>) -> Void
101101
) {
102102
// Check that we were given the right sources.
@@ -116,7 +116,7 @@ class PluginInvocationTests: XCTestCase {
116116
XCTAssertEqual(input.targets[1].dependencies.count, 0, "unexpected target dependencies: \(dump(input.targets[1].dependencies))")
117117

118118
// Pretend the plugin emitted some output.
119-
callbackQueue.sync { outputHandler(Data("Hello Plugin!".utf8)) }
119+
callbackQueue.sync { delegate.pluginEmittedOutput(data: Data("Hello Plugin!".utf8)) }
120120

121121
// Return a serialized output PluginInvocationResult JSON.
122122
let result = PluginScriptRunnerOutput(

0 commit comments

Comments
 (0)