Skip to content

Commit 4d70dd9

Browse files
committed
Add the plugin-side declarations for command plugins, per the pitch, and change the invocations to call them. Add a unit test that calls through to a command plugin and verifies the output received from it.
1 parent 7404efd commit 4d70dd9

File tree

7 files changed

+316
-88
lines changed

7 files changed

+316
-88
lines changed

Sources/PackagePlugin/Plugin.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ extension Plugin {
6464
// that SwiftPM specified.
6565
let commands: [Command]
6666
switch input.pluginAction {
67+
6768
case .createBuildToolCommands(let target):
6869
// Check that the plugin implements the appropriate protocol for its
6970
// declared capability.
@@ -73,6 +74,20 @@ extension Plugin {
7374

7475
// Ask the plugin to create build commands for the input target.
7576
commands = try plugin.createBuildCommands(context: context, target: target)
77+
78+
case .performCommand(let targets, let arguments):
79+
// Check that the plugin implements the appropriate protocol for its
80+
// declared capability.
81+
guard let plugin = plugin as? CommandPlugin else {
82+
throw PluginDeserializationError.malformedInputJSON("Plugin declared with `command` capability but doesn't conform to `CommandPlugin` protocol")
83+
}
84+
85+
// Invoke the plugin.
86+
try plugin.performCommand(context: context, targets: targets, arguments: arguments)
87+
88+
// For command plugin there are currently no return commands (any
89+
// commands invoked by the plugin are invoked directly).
90+
commands = []
7691
}
7792

7893
// Construct the output structure to send to SwiftPM.

Sources/PackagePlugin/PluginInput.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ struct PluginInput {
2020
let pluginAction: PluginAction
2121
enum PluginAction {
2222
case createBuildToolCommands(target: Target)
23+
case performCommand(targets: [Target], arguments: [String])
2324
}
2425

2526
internal init(from data: Data) throws {
@@ -41,6 +42,8 @@ struct PluginInput {
4142
switch input.pluginAction {
4243
case .createBuildToolCommands(let targetId):
4344
self.pluginAction = .createBuildToolCommands(target: try deserializer.target(for: targetId))
45+
case .performCommand(let targetIds, let arguments):
46+
self.pluginAction = .performCommand(targets: try targetIds.map{ try deserializer.target(for: $0) }, arguments: arguments)
4447
}
4548
}
4649
}
@@ -297,6 +300,7 @@ fileprivate struct WireInput: Decodable {
297300
/// the capabilities declared for the plugin.
298301
enum PluginAction: Decodable {
299302
case createBuildToolCommands(targetId: Target.Id)
303+
case performCommand(targetIds: [Target.Id], arguments: [String])
300304
}
301305

302306
/// A single absolute path in the wire structure, represented as a tuple

Sources/PackagePlugin/Protocols.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,22 @@ extension BuildToolPlugin {
7373
toolNamesToPaths: context.toolNamesToPaths))
7474
}
7575
}
76+
77+
/// Defines functionality for all plugins that have a `customCommand` capability.
78+
public protocol CommandPlugin: Plugin {
79+
/// Invoked by SwiftPM to perform the custom actions of the command.
80+
func performCommand(
81+
/// The context in which the plugin is invoked. This is the same for all
82+
/// kinds of plugins, and provides access to the package graph, to cache
83+
/// directories, etc.
84+
context: PluginContext,
85+
86+
/// The targets to which the command should be applied. If the invoker of
87+
/// the command has not specified particular targets, this will be a list
88+
/// of all the targets in the package to which the command is applied.
89+
targets: [Target],
90+
91+
/// Any literal arguments passed after the verb in the command invocation.
92+
arguments: [String]
93+
) throws
94+
}

Sources/SPMBuildCore/PluginInvocation.swift

Lines changed: 154 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,144 @@ import TSCUtility
1818

1919
public typealias Diagnostic = Basics.Diagnostic
2020

21+
public enum PluginAction {
22+
case createBuildToolCommands(target: ResolvedTarget)
23+
case performCommand(targets: [ResolvedTarget], arguments: [String])
24+
}
25+
26+
extension PluginTarget {
27+
/// Invokes the plugin by compiling its source code (if needed) and then running it as a subprocess. The specified
28+
/// plugin action determines which entry point is called in the subprocess, and the package and the tool mapping
29+
/// determine the context that is available to the plugin.
30+
///
31+
/// The working directory should be a path in the file system into which the plugin is allowed to write information
32+
/// that persists between all invocations of a plugin for the same purpose. The exact meaning of "same" means here
33+
/// depends on the particular plugin; for a build tool plugin, it might be the combination of the plugin and target
34+
/// for which it is being invoked.
35+
///
36+
/// Note that errors thrown by this function relate to problems actually invoking the plugin. Any diagnostics that
37+
/// are emitted by the plugin are contained in the returned result structure.
38+
///
39+
/// - Parameters:
40+
/// - action: The plugin action (i.e. entry point) to invoke, possibly containing parameters.
41+
/// - package: The root of the package graph to pass down to the plugin.
42+
/// - scriptRunner: Entity responsible for actually running the code of the plugin.
43+
/// - outputDirectory: A directory under which the plugin can write anything it wants to.
44+
/// - toolNamesToPaths: A mapping from name of tools available to the plugin to the corresponding absolute paths.
45+
/// - fileSystem: The file system to which all of the paths refers.
46+
///
47+
/// - Returns: A PluginInvocationResult that contains the results of invoking the plugin.
48+
public func invoke(
49+
action: PluginAction,
50+
package: ResolvedPackage,
51+
buildEnvironment: BuildEnvironment,
52+
scriptRunner: PluginScriptRunner,
53+
outputDirectory: AbsolutePath,
54+
toolNamesToPaths: [String: AbsolutePath],
55+
observabilityScope: ObservabilityScope,
56+
fileSystem: FileSystem
57+
) throws -> PluginInvocationResult {
58+
// Create the plugin working directory if needed (but don't do anything with it if it already exists).
59+
do {
60+
try fileSystem.createDirectory(outputDirectory, recursive: true)
61+
}
62+
catch {
63+
throw PluginEvaluationError.outputDirectoryCouldNotBeCreated(path: outputDirectory, underlyingError: error)
64+
}
65+
66+
// Create the input context to send to the plugin.
67+
// TODO: Some of this could probably be cached.
68+
var serializer = PluginScriptRunnerInputSerializer(buildEnvironment: buildEnvironment)
69+
let inputStruct = try serializer.makePluginScriptRunnerInput(
70+
rootPackage: package,
71+
pluginWorkDir: outputDirectory,
72+
builtProductsDir: outputDirectory, // FIXME — what is this parameter needed for?
73+
toolNamesToPaths: toolNamesToPaths,
74+
pluginAction: action)
75+
76+
// Serialize the PluginEvaluationInput to JSON.
77+
let encoder = JSONEncoder()
78+
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
79+
let inputJSON = try encoder.encode(inputStruct)
80+
81+
// Call the plugin script runner to actually invoke the plugin.
82+
// TODO: This should be asynchronous.
83+
let (outputJSON, outputText) = try scriptRunner.runPluginScript(
84+
sources: sources,
85+
inputJSON: inputJSON,
86+
pluginArguments: [],
87+
toolsVersion: self.apiVersion,
88+
writableDirectories: [outputDirectory],
89+
observabilityScope: observabilityScope,
90+
fileSystem: fileSystem)
91+
92+
// Deserialize the JSON to an PluginScriptRunnerOutput.
93+
let outputStruct: PluginScriptRunnerOutput
94+
do {
95+
let decoder = JSONDecoder()
96+
outputStruct = try decoder.decode(PluginScriptRunnerOutput.self, from: outputJSON)
97+
}
98+
catch {
99+
throw PluginEvaluationError.decodingPluginOutputFailed(json: outputJSON, underlyingError: error)
100+
}
101+
102+
// Generate emittable Diagnostics from the plugin output.
103+
let diagnostics: [Diagnostic] = try outputStruct.diagnostics.map { diag in
104+
let metadata: ObservabilityMetadata? = try diag.file.map {
105+
var metadata = ObservabilityMetadata()
106+
metadata.fileLocation = try .init(.init(validating: $0), line: diag.line)
107+
return metadata
108+
}
109+
110+
switch diag.severity {
111+
case .error:
112+
return .error(diag.message, metadata: metadata)
113+
case .warning:
114+
return .warning(diag.message, metadata: metadata)
115+
case .remark:
116+
return .info(diag.message, metadata: metadata)
117+
}
118+
}
119+
120+
// FIXME: Validate the plugin output structure here, e.g. paths, etc.
121+
122+
// Generate commands from the plugin output. This is where we translate from the transport JSON to our
123+
// internal form. We deal with BuildCommands and PrebuildCommands separately.
124+
// FIXME: This feels a bit too specific to have here.
125+
// FIXME: Also there is too much repetition here, need to unify it.
126+
let buildCommands = outputStruct.buildCommands.map { cmd in
127+
PluginInvocationResult.BuildCommand(
128+
configuration: .init(
129+
displayName: cmd.displayName,
130+
executable: cmd.executable,
131+
arguments: cmd.arguments,
132+
environment: cmd.environment,
133+
workingDirectory: cmd.workingDirectory.map{ AbsolutePath($0) }),
134+
inputFiles: cmd.inputFiles.map{ AbsolutePath($0) },
135+
outputFiles: cmd.outputFiles.map{ AbsolutePath($0) })
136+
}
137+
let prebuildCommands = outputStruct.prebuildCommands.map { cmd in
138+
PluginInvocationResult.PrebuildCommand(
139+
configuration: .init(
140+
displayName: cmd.displayName,
141+
executable: cmd.executable,
142+
arguments: cmd.arguments,
143+
environment: cmd.environment,
144+
workingDirectory: cmd.workingDirectory.map{ AbsolutePath($0) }),
145+
outputFilesDirectory: AbsolutePath(cmd.outputFilesDirectory))
146+
}
147+
148+
// Create and return an evaluation result for the invocation.
149+
return PluginInvocationResult(
150+
plugin: self,
151+
diagnostics: diagnostics,
152+
textOutput: String(decoding: outputText, as: UTF8.self),
153+
buildCommands: buildCommands,
154+
prebuildCommands: prebuildCommands)
155+
}
156+
}
157+
158+
21159
extension PackageGraph {
22160

23161
/// Traverses the graph of reachable targets in a package graph, and applies plugins to targets as needed. Each
@@ -93,85 +231,20 @@ extension PackageGraph {
93231
}
94232
})
95233

96-
// Give each invocation of a plugin a separate output directory.
234+
// Assign a plugin working directory based on the package, target, and plugin.
97235
let pluginOutputDir = outputDir.appending(components: package.identity.description, target.name, pluginTarget.name)
98-
do {
99-
try fileSystem.createDirectory(pluginOutputDir, recursive: true)
100-
}
101-
catch {
102-
throw PluginEvaluationError.outputDirectoryCouldNotBeCreated(path: pluginOutputDir, underlyingError: error)
103-
}
104236

105-
// Create the input context to pass when applying the plugin to the target.
106-
var serializer = PluginScriptRunnerInputSerializer(buildEnvironment: buildEnvironment)
107-
let pluginInput = try serializer.makePluginScriptRunnerInput(
108-
rootPackage: package,
109-
pluginWorkDir: pluginOutputDir,
110-
builtProductsDir: builtToolsDir,
237+
// Invoke the plugin.
238+
let result = try pluginTarget.invoke(
239+
action: .createBuildToolCommands(target: target),
240+
package: package,
241+
buildEnvironment: buildEnvironment,
242+
scriptRunner: pluginScriptRunner,
243+
outputDirectory: pluginOutputDir,
111244
toolNamesToPaths: toolNamesToPaths,
112-
pluginAction: .createBuildToolCommands(target: target))
113-
114-
// Run the plugin in the context of the target. The details of this are left to the plugin runner.
115-
// TODO: This should be asynchronous.
116-
let (pluginOutput, emittedText) = try runPluginScript(
117-
sources: pluginTarget.sources,
118-
input: pluginInput,
119-
toolsVersion: package.manifest.toolsVersion,
120-
writableDirectories: [pluginOutputDir],
121-
pluginScriptRunner: pluginScriptRunner,
122245
observabilityScope: observabilityScope,
123-
fileSystem: fileSystem
124-
)
125-
126-
// Generate emittable Diagnostics from the plugin output.
127-
let diagnostics: [Diagnostic] = try pluginOutput.diagnostics.map { diag in
128-
let metadata: ObservabilityMetadata? = try diag.file.map {
129-
var metadata = ObservabilityMetadata()
130-
metadata.fileLocation = try .init(.init(validating: $0), line: diag.line)
131-
return metadata
132-
}
133-
134-
switch diag.severity {
135-
case .error:
136-
return .error(diag.message, metadata: metadata)
137-
case .warning:
138-
return .warning(diag.message, metadata: metadata)
139-
case .remark:
140-
return .info(diag.message, metadata: metadata)
141-
}
142-
}
143-
144-
// Extract any emitted text output (received from the stdout/stderr of the plugin invocation).
145-
let textOutput = String(decoding: emittedText, as: UTF8.self)
146-
147-
// FIXME: Validate the plugin output structure here, e.g. paths, etc.
148-
149-
// Generate commands from the plugin output. This is where we translate from the transport JSON to our
150-
// internal form. We deal with BuildCommands and PrebuildCommands separately.
151-
let buildCommands = pluginOutput.buildCommands.map { cmd in
152-
PluginInvocationResult.BuildCommand(
153-
configuration: .init(
154-
displayName: cmd.displayName,
155-
executable: cmd.executable,
156-
arguments: cmd.arguments,
157-
environment: cmd.environment,
158-
workingDirectory: cmd.workingDirectory.map{ AbsolutePath($0) }),
159-
inputFiles: cmd.inputFiles.map{ AbsolutePath($0) },
160-
outputFiles: cmd.outputFiles.map{ AbsolutePath($0) })
161-
}
162-
let prebuildCommands = pluginOutput.prebuildCommands.map { cmd in
163-
PluginInvocationResult.PrebuildCommand(
164-
configuration: .init(
165-
displayName: cmd.displayName,
166-
executable: cmd.executable,
167-
arguments: cmd.arguments,
168-
environment: cmd.environment,
169-
workingDirectory: cmd.workingDirectory.map{ AbsolutePath($0) }),
170-
outputFilesDirectory: AbsolutePath(cmd.outputFilesDirectory))
171-
}
172-
173-
// Create an evaluation result from the usage of the plugin by the target.
174-
pluginResults.append(PluginInvocationResult(plugin: pluginTarget, diagnostics: diagnostics, textOutput: textOutput, buildCommands: buildCommands, prebuildCommands: prebuildCommands))
246+
fileSystem: fileSystem)
247+
pluginResults.append(result)
175248
}
176249

177250
// Associate the list of results with the target. The list will have one entry for each plugin used by the target.
@@ -200,6 +273,7 @@ extension PackageGraph {
200273
let (outputJSON, stdoutText) = try pluginScriptRunner.runPluginScript(
201274
sources: sources,
202275
inputJSON: inputJSON,
276+
pluginArguments: [],
203277
toolsVersion: toolsVersion,
204278
writableDirectories: writableDirectories,
205279
observabilityScope: observabilityScope,
@@ -341,6 +415,7 @@ public protocol PluginScriptRunner {
341415
func runPluginScript(
342416
sources: Sources,
343417
inputJSON: Data,
418+
pluginArguments: [String],
344419
toolsVersion: ToolsVersion,
345420
writableDirectories: [AbsolutePath],
346421
observabilityScope: ObservabilityScope,
@@ -380,6 +455,7 @@ struct PluginScriptRunnerInput: Codable {
380455
/// the capabilities declared for the plugin.
381456
enum PluginAction: Codable {
382457
case createBuildToolCommands(targetId: Target.Id)
458+
case performCommand(targetIds: [Target.Id], arguments: [String])
383459
}
384460

385461
/// A single absolute path in the wire structure, represented as a tuple
@@ -551,13 +627,6 @@ struct PluginScriptRunnerInputSerializer {
551627
var packages: [PluginScriptRunnerInput.Package] = []
552628
var packagesToIds: [ResolvedPackage: PluginScriptRunnerInput.Package.Id] = [:]
553629

554-
/// The action that SwiftPM wants to invoke on the plugin.
555-
enum PluginAction {
556-
case createBuildToolCommands(target: ResolvedTarget)
557-
}
558-
559-
/// Constructs the codable, serialized input to the plugin, based on a
560-
/// plugin action, a package subgraph, and other parameters.
561630
mutating func makePluginScriptRunnerInput(
562631
rootPackage: ResolvedPackage,
563632
pluginWorkDir: AbsolutePath,
@@ -573,8 +642,10 @@ struct PluginScriptRunnerInputSerializer {
573642
switch pluginAction {
574643
case .createBuildToolCommands(let target):
575644
serializedPluginAction = .createBuildToolCommands(targetId: try serialize(target: target)!)
645+
case .performCommand(let targets, let arguments):
646+
serializedPluginAction = .performCommand(targetIds: try targets.compactMap { try serialize(target: $0) }, arguments: arguments)
576647
}
577-
let input = PluginScriptRunnerInput(
648+
return PluginScriptRunnerInput(
578649
paths: paths,
579650
targets: targets,
580651
products: products,
@@ -584,7 +655,6 @@ struct PluginScriptRunnerInputSerializer {
584655
builtProductsDirId: builtProductsDirId,
585656
toolNamesToPathIds: toolNamesToPathIds,
586657
pluginAction: serializedPluginAction)
587-
return input
588658
}
589659

590660
/// Adds a path to the serialized structure, if it isn't already there.

0 commit comments

Comments
 (0)