Skip to content

Commit 0dd702e

Browse files
committed
Add a way to invoke command plugins from swift package. Temporarily this uses a swift package plugin subcommand, but the proposal calls for making non-builtin subcommands of swift package be searched for as plugins, e.g. swift package generate-documentation would look for a generate-documentation plugin.
Since this is currently being pitched, it's gated on the SWIFTPM_ENABLE_COMMAND_PLUGINS environment variable.
1 parent ff5715e commit 0dd702e

File tree

3 files changed

+195
-3
lines changed

3 files changed

+195
-3
lines changed

Sources/Commands/SwiftPackageTool.swift

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ public struct SwiftPackageTool: ParsableCommand {
6060
ComputeChecksum.self,
6161
ArchiveSource.self,
6262
CompletionTool.self,
63-
] + (ProcessInfo.processInfo.environment["SWIFTPM_ENABLE_SNIPPETS"] == "1" ? [Learn.self] : [ParsableCommand.Type]()),
63+
]
64+
+ (ProcessInfo.processInfo.environment["SWIFTPM_ENABLE_SNIPPETS"] == "1" ? [Learn.self] : [])
65+
+ (ProcessInfo.processInfo.environment["SWIFTPM_ENABLE_COMMAND_PLUGINS"] == "1" ? [PluginCommand.self] : []),
6466
helpNames: [.short, .long, .customLong("help", withSingleDash: true)])
6567

6668
@OptionGroup()
@@ -863,6 +865,138 @@ extension SwiftPackageTool {
863865
swiftTool.outputStream.flush()
864866
}
865867
}
868+
869+
// Experimental command to invoke user command plugins. This will probably change so that command that is not
870+
// recognized as a built-in command will cause `swift-package` to search for plugin commands, instead of using
871+
// a separate `plugin` subcommand for this.
872+
struct PluginCommand: SwiftCommand {
873+
static let configuration = CommandConfiguration(
874+
commandName: "plugin",
875+
abstract: "Invoke a command plugin (note: the use of a `plugin` subcommand for this is temporary)"
876+
)
877+
878+
@OptionGroup(_hiddenFromHelp: true)
879+
var swiftOptions: SwiftToolOptions
880+
881+
/// The specific target to apply the plugin to.
882+
@Option(name: .customLong("target"), help: "Target(s) to which the plugin command should be applied")
883+
var targetNames: [String] = []
884+
885+
@Argument(help: "Name of the command plugin to invoke")
886+
var command: String
887+
888+
@Argument(help: "Any arguments to pass to the plugin")
889+
var arguments: [String] = []
890+
891+
func findPlugins(matching command: String, in graph: PackageGraph) -> [PluginTarget] {
892+
// Find all the available plugin targets.
893+
let availablePlugins = graph.allTargets.compactMap{ $0.underlyingTarget as? PluginTarget }
894+
895+
// Find and return the plugins that match the command.
896+
return availablePlugins.filter {
897+
// Filter out any non-command plugins.
898+
guard case .command(let intent, _) = $0.capability else { return false }
899+
// FIXME: We shouldn't hardcode these verbs.
900+
switch intent {
901+
case .documentationGeneration:
902+
return command == "generate-documentation"
903+
case .sourceCodeFormatting:
904+
return command == "format-source-code"
905+
case .custom(let verb, _):
906+
return command == verb
907+
}
908+
}
909+
}
910+
911+
func run(_ swiftTool: SwiftTool) throws {
912+
// Load the workspace and resolve the package graph.
913+
let packageGraph = try swiftTool.loadPackageGraph()
914+
915+
// Find the plugins that match the command.
916+
let matchingPlugins = findPlugins(matching: command, in: packageGraph)
917+
918+
// Complain if we didn't find exactly one.
919+
if matchingPlugins.isEmpty {
920+
swiftTool.observabilityScope.emit(error: "No plugins found for '\(command)'")
921+
throw ExitCode.failure
922+
}
923+
else if matchingPlugins.count > 1 {
924+
swiftTool.observabilityScope.emit(error: "\(matchingPlugins.count) plugins found for '\(command)'")
925+
throw ExitCode.failure
926+
}
927+
928+
// At this point we know we found exactly one command plugin, so we run it.
929+
let plugin = matchingPlugins[0]
930+
print("Running plugin \(plugin)")
931+
932+
// Find the targets (if any) specified by the user.
933+
var targets: [String: ResolvedTarget] = [:]
934+
for target in packageGraph.allTargets {
935+
if targetNames.contains(target.name) {
936+
if targets[target.name] != nil {
937+
swiftTool.observabilityScope.emit(error: "Ambiguous target name: ‘\(target.name)")
938+
throw ExitCode.failure
939+
}
940+
targets[target.name] = target
941+
}
942+
}
943+
assert(targets.count <= targetNames.count)
944+
if targets.count != targetNames.count {
945+
let unknownTargetNames = Set(targetNames).subtracting(targets.keys)
946+
swiftTool.observabilityScope.emit(error: "Unknown targets: ‘\(unknownTargetNames.sorted().joined(separator: "’, ‘"))")
947+
throw ExitCode.failure
948+
}
949+
950+
// Configure the plugin invocation inputs.
951+
952+
// The `plugins` directory is inside the workspace's main data directory, and contains all temporary files related to all plugins in the workspace.
953+
let dataDir = try swiftTool.getActiveWorkspace().location.workingDirectory
954+
let pluginsDir = dataDir.appending(component: "plugins")
955+
956+
// The `cache` directory is in the plugins directory and is where the plugin script runner caches compiled plugin binaries and any other derived information.
957+
let cacheDir = pluginsDir.appending(component: "cache")
958+
let pluginScriptRunner = DefaultPluginScriptRunner(cacheDir: cacheDir, toolchain: try swiftTool.getToolchain().configuration, enableSandbox: false)
959+
960+
// 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.
961+
let outputDir = pluginsDir.appending(component: "outputs")
962+
963+
// 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).
964+
// 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.
965+
let builtToolsDir = dataDir.appending(components: "plugin-tools")
966+
967+
// Create the cache directory, if needed.
968+
try localFileSystem.createDirectory(cacheDir, recursive: true)
969+
970+
// FIXME: Need to determine the correct root package.
971+
972+
// FIXME: Need to
973+
// Determine the tools to which this plugin has access, and create a name-to-path mapping from tool
974+
// names to the corresponding paths. Built tools are assumed to be in the build tools directory.
975+
let accessibleTools = plugin.accessibleTools(for: pluginScriptRunner.hostTriple)
976+
let toolNamesToPaths = accessibleTools.reduce(into: [String: AbsolutePath](), { dict, tool in
977+
switch tool {
978+
case .builtTool(let name, let path):
979+
dict[name] = builtToolsDir.appending(path)
980+
case .vendedTool(let name, let path):
981+
dict[name] = path
982+
}
983+
})
984+
985+
// Run the plugin.
986+
let result = try plugin.invoke(
987+
action: .performCommand(targets: Array(targets.values), arguments: arguments),
988+
package: packageGraph.rootPackages[0], // FIXME: This should be the package that contains all the targets (and we should make sure all are in one)
989+
buildEnvironment: try swiftTool.buildParameters().buildEnvironment,
990+
scriptRunner: pluginScriptRunner,
991+
outputDirectory: outputDir,
992+
toolNamesToPaths: toolNamesToPaths,
993+
observabilityScope: swiftTool.observabilityScope,
994+
fileSystem: localFileSystem)
995+
996+
// Temporary: emit any output from the plugin.
997+
print(result.textOutput)
998+
}
999+
}
8661000
}
8671001

8681002
extension SwiftPackageTool {

Sources/SPMBuildCore/PluginInvocation.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,15 +294,15 @@ extension PackageGraph {
294294

295295

296296
/// A description of a tool to which a plugin has access.
297-
enum PluginAccessibleTool: Hashable {
297+
public enum PluginAccessibleTool: Hashable {
298298
/// A tool that is built by an ExecutableTarget (the path is relative to the built-products directory).
299299
case builtTool(name: String, path: RelativePath)
300300

301301
/// A tool that is vended by a BinaryTarget (the path is absolute and refers to an unpackaged binary target).
302302
case vendedTool(name: String, path: AbsolutePath)
303303
}
304304

305-
extension PluginTarget {
305+
public extension PluginTarget {
306306

307307
/// The set of tools that are accessible to this plugin.
308308
func accessibleTools(for hostTriple: Triple) -> Set<PluginAccessibleTool> {

Tests/CommandsTests/PackageToolTests.swift

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,4 +1106,62 @@ final class PackageToolTests: CommandsTestCase {
11061106
}
11071107
}
11081108
}
1109+
1110+
func testCommandPlugin() throws {
1111+
1112+
try testWithTemporaryDirectory { tmpPath in
1113+
// Create a sample package with a library target and a plugin.
1114+
let packageDir = tmpPath.appending(components: "MyPackage")
1115+
try localFileSystem.writeFileContents(packageDir.appending(component: "Package.swift")) {
1116+
$0 <<< """
1117+
// swift-tools-version: 999.0
1118+
import PackageDescription
1119+
let package = Package(
1120+
name: "MyPackage",
1121+
targets: [
1122+
.target(
1123+
name: "MyLibrary",
1124+
plugins: [
1125+
"MyPlugin",
1126+
]
1127+
),
1128+
.plugin(
1129+
name: "MyPlugin",
1130+
capability: .command(
1131+
intent: .custom(verb: "mycmd", description: "What is mycmd anyway?"),
1132+
permissions: [.packageWritability(reason: "YOLO")]
1133+
)
1134+
),
1135+
]
1136+
)
1137+
"""
1138+
}
1139+
try localFileSystem.writeFileContents(packageDir.appending(components: "Sources", "MyLibrary", "library.swift")) {
1140+
$0 <<< """
1141+
public func Foo() { }
1142+
"""
1143+
}
1144+
try localFileSystem.writeFileContents(packageDir.appending(components: "Plugins", "MyPlugin", "plugin.swift")) {
1145+
$0 <<< """
1146+
import PackagePlugin
1147+
1148+
@main
1149+
struct MyCommandPlugin: CommandPlugin {
1150+
func performCommand(
1151+
context: PluginContext,
1152+
targets: [Target],
1153+
arguments: [String]
1154+
) throws {
1155+
print("This is MyCommandPlugin.")
1156+
}
1157+
}
1158+
"""
1159+
}
1160+
1161+
// Invoke it, and check the results.
1162+
let result = try SwiftPMProduct.SwiftPackage.executeProcess(["plugin", "mycmd"], packagePath: packageDir, env: ["SWIFTPM_ENABLE_COMMAND_PLUGINS": "1"])
1163+
XCTAssertEqual(result.exitStatus, .terminated(code: 0))
1164+
XCTAssert(try result.utf8Output().contains("This is MyCommandPlugin."))
1165+
}
1166+
}
11091167
}

0 commit comments

Comments
 (0)