Skip to content

update plugin argument parsing so that execution arguments can be passed after command plugin name #6128

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Feb 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/Basics/FileSystem/FileSystem+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ extension FileSystem {
}

public func writeFileContents(_ path: AbsolutePath, provider: () -> String) throws {
return try self.writeFileContents(path, string: provider())
return try self.writeFileContents(path, body: { stream in stream <<< provider() })
}
}

Expand Down
126 changes: 93 additions & 33 deletions Sources/Commands/PackageTools/PluginCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,23 @@ struct PluginCommand: SwiftCommand {
@OptionGroup(visibility: .hidden)
var globalOptions: GlobalOptions

@Flag(name: .customLong("list"),
help: "List the available command plugins")
@Flag(
name: .customLong("list"),
help: "List the available command plugins"
)
var listCommands: Bool = false

struct PluginOptions: ParsableArguments {
@Flag(name: .customLong("allow-writing-to-package-directory"),
help: "Allow the plugin to write to the package directory")
@Flag(
name: .customLong("allow-writing-to-package-directory"),
help: "Allow the plugin to write to the package directory"
)
var allowWritingToPackageDirectory: Bool = false

@Option(name: .customLong("allow-writing-to-directory"),
help: "Allow the plugin to write to an additional directory")
@Option(
name: .customLong("allow-writing-to-directory"),
help: "Allow the plugin to write to an additional directory"
)
var additionalAllowedWritableDirectories: [String] = []

enum NetworkPermission: String, EnumerableFlag, ExpressibleByArgument {
Expand All @@ -58,53 +64,81 @@ struct PluginCommand: SwiftCommand {
@Argument(help: "Verb of the command plugin to invoke")
var command: String = ""

@Argument(parsing: .unconditionalRemaining,
help: "Arguments to pass to the command plugin")
@Argument(
parsing: .unconditionalRemaining,
help: "Arguments to pass to the command plugin"
)
var arguments: [String] = []

func run(_ swiftTool: SwiftTool) throws {
// Check for a missing plugin command verb.
if command == "" && !listCommands {
if self.command == "" && !self.listCommands {
throw ValidationError("Missing expected plugin command")
}

// Load the workspace and resolve the package graph.
let packageGraph = try swiftTool.loadPackageGraph()

// List the available plugins, if asked to.
if listCommands {
if self.listCommands {
let packageGraph = try swiftTool.loadPackageGraph()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice

let allPlugins = PluginCommand.availableCommandPlugins(in: packageGraph)
for plugin in allPlugins.sorted(by: { $0.name < $1.name }) {
guard case .command(let intent, _) = plugin.capability else { return }
var line = "‘\(intent.invocationVerb)’ (plugin ‘\(plugin.name)’"
if let package = packageGraph.packages.first(where: { $0.targets.contains(where: { $0.name == plugin.name }) }) {
line += " in package ‘\(package.manifest.displayName)’"
if let package = packageGraph.packages
.first(where: { $0.targets.contains(where: { $0.name == plugin.name }) })
{
line += " in package ‘\(package.manifest.displayName)’"
}
line += ")"
print(line)
}
return
}

try Self.run(
command: self.command,
options: self.pluginOptions,
arguments: self.arguments,
swiftTool: swiftTool
)
}

static func run(
command: String,
options: PluginOptions,
arguments: [String],
swiftTool: SwiftTool
) throws {
// Load the workspace and resolve the package graph.
let packageGraph = try swiftTool.loadPackageGraph()

swiftTool.observabilityScope.emit(info: "Finding plugin for command ‘\(command)’")
let matchingPlugins = PluginCommand.findPlugins(matching: command, in: packageGraph)

// Complain if we didn't find exactly one.
if matchingPlugins.isEmpty {
throw ValidationError("No command plugins found for ‘\(command)’")
}
else if matchingPlugins.count > 1 {
throw ValidationError("Unknown subcommand or plugin name ‘\(command)’")
} else if matchingPlugins.count > 1 {
throw ValidationError("\(matchingPlugins.count) plugins found for ‘\(command)’")
}

// handle plugin execution arguments that got passed after the plugin name
let unparsedArguments = Array(arguments.drop(while: { $0 == command }))
let pluginArguments = try PluginArguments.parse(unparsedArguments)
// merge the relevant plugin execution options
let pluginOptions = options.merged(with: pluginArguments.pluginOptions)
// sandbox is special since its generic not a specific plugin option
swiftTool.shouldDisableSandbox = swiftTool.shouldDisableSandbox || pluginArguments.globalOptions.security
.shouldDisableSandbox

// At this point we know we found exactly one command plugin, so we run it. In SwiftPM CLI, we have only one root package.
try PluginCommand.run(
plugin: matchingPlugins[0],
package: packageGraph.rootPackages[0],
packageGraph: packageGraph,
options: pluginOptions,
arguments: arguments,
swiftTool: swiftTool)
arguments: unparsedArguments,
swiftTool: swiftTool
)
}

static func run(
Expand All @@ -115,10 +149,14 @@ struct PluginCommand: SwiftCommand {
arguments: [String],
swiftTool: SwiftTool
) throws {
swiftTool.observabilityScope.emit(info: "Running command plugin \(plugin) on package \(package) with options \(options) and arguments \(arguments)")
swiftTool.observabilityScope
.emit(
info: "Running command plugin \(plugin) on package \(package) with options \(options) and arguments \(arguments)"
)

// The `plugins` directory is inside the workspace's main data directory, and contains all temporary files related to this plugin in the workspace.
let pluginsDir = try swiftTool.getActiveWorkspace().location.pluginWorkingDirectory.appending(component: plugin.name)
let pluginsDir = try swiftTool.getActiveWorkspace().location.pluginWorkingDirectory
.appending(component: plugin.name)

// The `cache` directory is in the plugin’s directory and is where the plugin script runner caches compiled plugin binaries and any other derived information for this plugin.
let pluginScriptRunner = try swiftTool.getPluginScriptRunner(
Expand All @@ -128,7 +166,7 @@ struct PluginCommand: SwiftCommand {
// The `outputs` directory contains subdirectories for each combination of package and command 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.
let outputDir = pluginsDir.appending(component: "outputs")

var allowNetworkConnections = [SandboxNetworkPermission.init(options.allowNetworkConnections)]
var allowNetworkConnections = [SandboxNetworkPermission(options.allowNetworkConnections)]
// Determine the set of directories under which plugins are allowed to write. We always include the output directory.
var writableDirectories = [outputDir]
if options.allowWritingToPackageDirectory {
Expand All @@ -154,7 +192,9 @@ struct PluginCommand: SwiftCommand {

switch scope {
case .all, .local:
let portsString = scope.ports.isEmpty ? "on all ports" : "on ports: \(scope.ports.map { "\($0)" }.joined(separator: ", "))"
let portsString = scope.ports
.isEmpty ? "on all ports" :
"on ports: \(scope.ports.map { "\($0)" }.joined(separator: ", "))"
permissionString = "allow \(scope.label) network connections \(portsString)"
case .docker, .unixDomainSocket:
permissionString = "allow \(scope.label) connections"
Expand All @@ -163,7 +203,8 @@ struct PluginCommand: SwiftCommand {
}

reasonString = reason
remedyOption = "--allow-network-connections \(PluginCommand.PluginOptions.NetworkPermission.init(scope).defaultValueDescription)"
remedyOption =
"--allow-network-connections \(PluginCommand.PluginOptions.NetworkPermission(scope).defaultValueDescription)"
}

let problem = "Plugin ‘\(plugin.name)’ wants permission to \(permissionString)."
Expand All @@ -190,28 +231,34 @@ struct PluginCommand: SwiftCommand {
writableDirectories.append(package.path)
case .allowNetworkConnections(let scope, _):
allowNetworkConnections.append(.init(scope))
break
}
}
}

for pathString in options.additionalAllowedWritableDirectories {
writableDirectories.append(try AbsolutePath(validating: pathString, relativeTo: swiftTool.originalWorkingDirectory))
writableDirectories
.append(try AbsolutePath(validating: pathString, relativeTo: swiftTool.originalWorkingDirectory))
}

// Make sure that the package path is read-only unless it's covered by any of the explicitly writable directories.
let readOnlyDirectories = writableDirectories.contains{ package.path.isDescendantOfOrEqual(to: $0) } ? [] : [package.path]
let readOnlyDirectories = writableDirectories
.contains { package.path.isDescendantOfOrEqual(to: $0) } ? [] : [package.path]

// Use the directory containing the compiler as an additional search directory, and add the $PATH.
let toolSearchDirs = [try swiftTool.getDestinationToolchain().swiftCompilerPath.parentDirectory]
+ getEnvSearchPaths(pathString: ProcessEnv.path, currentWorkingDirectory: .none)

// Build or bring up-to-date any executable host-side tools on which this plugin depends. Add them and any binary dependencies to the tool-names-to-path map.
let buildSystem = try swiftTool.createBuildSystem(explicitBuildSystem: .native, cacheBuildManifest: false)
let accessibleTools = try plugin.processAccessibleTools(packageGraph: packageGraph, fileSystem: swiftTool.fileSystem, environment: try swiftTool.buildParameters().buildEnvironment, for: try pluginScriptRunner.hostTriple) { name, path in
let accessibleTools = try plugin.processAccessibleTools(
packageGraph: packageGraph,
fileSystem: swiftTool.fileSystem,
environment: try swiftTool.buildParameters().buildEnvironment,
for: try pluginScriptRunner.hostTriple
) { name, _ in
// Build the product referenced by the tool, and add the executable to the tool map. Product dependencies are not supported within a package, so if the tool happens to be from the same package, we instead find the executable that corresponds to the product. There is always one, because of autogeneration of implicit executables with the same name as the target if there isn't an explicit one.
try buildSystem.build(subset: .product(name))
if let builtTool = try buildSystem.buildPlan.buildProducts.first(where: { $0.product.name == name}) {
if let builtTool = try buildSystem.buildPlan.buildProducts.first(where: { $0.product.name == name }) {
return builtTool.binaryPath
} else {
return nil
Expand Down Expand Up @@ -240,25 +287,38 @@ struct PluginCommand: SwiftCommand {
observabilityScope: swiftTool.observabilityScope,
callbackQueue: delegateQueue,
delegate: pluginDelegate,
completion: $0) }
completion: $0
) }

// TODO: We should also emit a final line of output regarding the result.
}

static func availableCommandPlugins(in graph: PackageGraph) -> [PluginTarget] {
return graph.allTargets.compactMap{ $0.underlyingTarget as? PluginTarget }
graph.allTargets.compactMap { $0.underlyingTarget as? PluginTarget }
}

static func findPlugins(matching verb: String, in graph: PackageGraph) -> [PluginTarget] {
// Find and return the command plugins that match the command.
return Self.availableCommandPlugins(in: graph).filter {
Self.availableCommandPlugins(in: graph).filter {
// Filter out any non-command plugins and any whose verb is different.
guard case .command(let intent, _) = $0.capability else { return false }
return verb == intent.invocationVerb
}
}
}

// helper to parse plugin arguments passed after the plugin name
struct PluginArguments: ParsableCommand {
@OptionGroup
var globalOptions: GlobalOptions

@OptionGroup()
var pluginOptions: PluginCommand.PluginOptions

@Argument(parsing: .unconditionalRemaining)
var remaining: [String] = []
}

extension PluginCommandIntent {
var invocationVerb: String {
switch self {
Expand Down
46 changes: 21 additions & 25 deletions Sources/Commands/PackageTools/SwiftPackageTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ extension SwiftPackageTool {
print(SwiftPackageTool.helpMessage())
return
}

// Check for edge cases and unknown options to match the behavior in the absence of plugins.
if command.isEmpty {
throw ValidationError("Unknown argument '\(command)'")
Expand All @@ -109,31 +109,27 @@ extension SwiftPackageTool {
}

// Otherwise see if we can find a plugin.

// We first have to try to resolve the package graph to find any plugins.
// TODO: Ideally we should only resolve plugin dependencies, if we had a way of distinguishing them.
let packageGraph = try swiftTool.loadPackageGraph()

// Otherwise find all plugins that match the command verb.
swiftTool.observabilityScope.emit(info: "Finding plugin for command '\(command)'")
let matchingPlugins = PluginCommand.findPlugins(matching: command, in: packageGraph)

// Complain if we didn't find exactly one. We have to formulate the error message taking into account that this might be a misspelled subcommand.
if matchingPlugins.isEmpty {
throw ValidationError("Unknown subcommand or plugin name '\(command)'")
}
else if matchingPlugins.count > 1 {
throw ValidationError("\(matchingPlugins.count) plugins found for '\(command)'")
}

// At this point we know we found exactly one command plugin, so we run it.
try PluginCommand.run(
plugin: matchingPlugins[0],
package: packageGraph.rootPackages[0],
packageGraph: packageGraph,
options: pluginOptions,
arguments: Array( remaining.dropFirst()),
swiftTool: swiftTool)
command: command,
options: self.pluginOptions,
arguments: self.remaining,
swiftTool: swiftTool
)
}
}
}

extension PluginCommand.PluginOptions {
func merged(with other: Self) -> Self {
// validate against developer mistake
assert(Mirror(reflecting: self).children.count == 3, "Property added to PluginOptions without updating merged(with:)!")
// actual merge
var merged = self
merged.allowWritingToPackageDirectory = merged.allowWritingToPackageDirectory || other.allowWritingToPackageDirectory
merged.additionalAllowedWritableDirectories.append(contentsOf: other.additionalAllowedWritableDirectories)
if other.allowNetworkConnections != .none {
merged.allowNetworkConnections = other.allowNetworkConnections
}
return merged
}
}
2 changes: 1 addition & 1 deletion Sources/CoreCommands/BuildSystemSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ extension SwiftTool {
pluginConfiguration: .init(
scriptRunner: self.getPluginScriptRunner(),
workDirectory: try self.getActiveWorkspace().location.pluginWorkingDirectory,
disableSandbox: self.options.security.shouldDisableSandbox
disableSandbox: self.shouldDisableSandbox
),
additionalFileRules: FileRuleDescription.swiftpmFileTypes,
pkgConfigDirectories: self.options.locations.pkgConfigDirectories,
Expand Down
8 changes: 6 additions & 2 deletions Sources/CoreCommands/SwiftTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@ public final class SwiftTool {
/// The min severity at which to log diagnostics
public let logLevel: Basics.Diagnostic.Severity

// should use sandbox on external subcommands
public var shouldDisableSandbox: Bool

/// The file system in use
public let fileSystem: FileSystem

Expand Down Expand Up @@ -215,6 +218,7 @@ public final class SwiftTool {
self.observabilityHandler = SwiftToolObservabilityHandler(outputStream: outputStream, logLevel: self.logLevel)
let observabilitySystem = ObservabilitySystem(self.observabilityHandler)
self.observabilityScope = observabilitySystem.topScope
self.shouldDisableSandbox = options.security.shouldDisableSandbox
self.toolWorkspaceConfiguration = toolWorkspaceConfiguration
self.workspaceDelegateProvider = workspaceDelegateProvider
self.workspaceLoaderProvider = workspaceLoaderProvider
Expand Down Expand Up @@ -513,7 +517,7 @@ public final class SwiftTool {
fileSystem: self.fileSystem,
cacheDir: cacheDir,
toolchain: self.getHostToolchain(),
enableSandbox: !self.options.security.shouldDisableSandbox,
enableSandbox: !self.shouldDisableSandbox,
verboseOutput: self.logLevel <= .info
)
// register the plugin runner system with the cancellation handler
Expand Down Expand Up @@ -729,7 +733,7 @@ public final class SwiftTool {
return try ManifestLoader(
// Always use the host toolchain's resources for parsing manifest.
toolchain: self.getHostToolchain(),
isManifestSandboxEnabled: !self.options.security.shouldDisableSandbox,
isManifestSandboxEnabled: !self.shouldDisableSandbox,
cacheDir: cachePath,
extraManifestFlags: extraManifestFlags,
restrictImports: nil
Expand Down
Loading