Преглед на файлове

Code generation command plugin (#40)

### Motivation:

To make it simpler to generate gRPC stubs with `protoc-gen-grpc-swift`
and `protoc-gen-swift`.

### Modifications:

* Add a new command plugin
* Refactor some errors

The command plugin can be invoked from the CLI as:
```
swift package generate-grpc-code-from-protos --import-path /path/to/Protos -- /path/to/Protos/HelloWorld.proto
```

The plugin has flexible configuration:
```
❯ swift package generate-grpc-code-from-protos --help
Usage: swift package generate-grpc-code-from-protos [flags] [--] [input files]

Flags:

  --servers                   Indicate that server code is to be generated. Generated by default.
  --no-servers                Indicate that server code is not to be generated. Generated by default.
  --clients                   Indicate that client code is to be generated. Generated by default.
  --no-clients                Indicate that client code is not to be generated. Generated by default.
  --messages                  Indicate that message code is to be generated. Generated by default.
  --no-messages               Indicate that message code is not to be generated. Generated by default.
  --file-naming               The naming scheme for output files [fullPath/pathToUnderscores/dropPath]. Defaults to fullPath.
  --access-level              The access level of the generated source [internal/public/package]. Defaults to internal.
  --access-level-on-imports   Whether imports should have explicit access levels. Defaults to false.
  --import-path               The directory in which to search for imports.
  --protoc-path               The path to the protoc binary.
  --output-path               The path into which the generated source files are created.
  --verbose                   Emit verbose output.
  --dry-run                   Print but do not execute the protoc commands.
  --help                      Print this help.
```
* When executing, the command prints the `protoc` invocations it uses
for ease of debugging. The `--dry-run` flag can be supplied for this
purpose or so that they may be extracted and used separately e.g. in a
script.
* If no `protoc` path is supplied then Swift Package Manager will
attempt to locate it.
* If no `output` directory is supplied then generated files are placed a
Swift Package Manager build directory.
  
### Result:

More convenient code generation

This PR is split out of
https://github.com/grpc/grpc-swift-protobuf/pull/26

---------

Co-authored-by: George Barnett <gbarnett@apple.com>
Rick Newton-Rogers преди 9 месеца
родител
ревизия
8726dda0b5

+ 21 - 0
Package.swift

@@ -115,6 +115,27 @@ let targets: [Target] = [
       .product(name: "protoc-gen-swift", package: "swift-protobuf"),
     ]
   ),
+
+  // Code generator SwiftPM command
+  .plugin(
+    name: "GRPCProtobufGeneratorCommand",
+    capability: .command(
+      intent: .custom(
+        verb: "generate-grpc-code-from-protos",
+        description: "Generate Swift code for gRPC services from protobuf definitions."
+      ),
+      permissions: [
+        .writeToPackageDirectory(
+          reason:
+            "To write the generated Swift files back into the source directory of the package."
+        )
+      ]
+    ),
+    dependencies: [
+      .target(name: "protoc-gen-grpc-swift"),
+      .product(name: "protoc-gen-swift", package: "swift-protobuf"),
+    ]
+  ),
 ]
 
 let package = Package(

+ 4 - 6
Plugins/GRPCProtobufGenerator/BuildPluginConfig.swift

@@ -16,8 +16,6 @@
 
 import Foundation
 
-let configFileName = "grpc-swift-proto-generator-config.json"
-
 /// The config of the build plugin.
 struct BuildPluginConfig: Codable {
   /// Config defining which components should be considered when generating source.
@@ -193,14 +191,14 @@ extension BuildPluginConfig.Protoc: Codable {
 
 extension GenerationConfig {
   init(buildPluginConfig: BuildPluginConfig, configFilePath: URL, outputPath: URL) {
-    self.server = buildPluginConfig.generate.servers
-    self.client = buildPluginConfig.generate.clients
-    self.message = buildPluginConfig.generate.messages
+    self.servers = buildPluginConfig.generate.servers
+    self.clients = buildPluginConfig.generate.clients
+    self.messages = buildPluginConfig.generate.messages
     // Use path to underscores as it ensures output files are unique (files generated from
     // "foo/bar.proto" won't collide with those generated from "bar/bar.proto" as they'll be
     // uniquely named "foo_bar.(grpc|pb).swift" and "bar_bar.(grpc|pb).swift".
     self.fileNaming = .pathToUnderscores
-    self.visibility = buildPluginConfig.generatedSource.accessLevel
+    self.accessLevel = buildPluginConfig.generatedSource.accessLevel
     self.accessLevelOnImports = buildPluginConfig.generatedSource.accessLevelOnImports
     // Generate absolute paths for the imports relative to the config file in which they are specified
     self.importPaths = buildPluginConfig.protoc.importPaths.map { relativePath in

+ 3 - 4
Plugins/PluginsShared/PluginError.swift → Plugins/GRPCProtobufGenerator/BuildPluginError.swift

@@ -1,5 +1,5 @@
 /*
- * Copyright 2024, gRPC Authors All rights reserved.
+ * Copyright 2025, gRPC Authors All rights reserved.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,13 +14,12 @@
  * limitations under the License.
  */
 
-enum PluginError: Error {
-  // Build plugin
+enum BuildPluginError: Error {
   case incompatibleTarget(String)
   case noConfigFilesFound
 }
 
-extension PluginError: CustomStringConvertible {
+extension BuildPluginError: CustomStringConvertible {
   var description: String {
     switch self {
     case .incompatibleTarget(let target):

+ 5 - 7
Plugins/GRPCProtobufGenerator/Plugin.swift

@@ -19,10 +19,9 @@ import PackagePlugin
 
 // Entry-point when using Package manifest
 extension GRPCProtobufGenerator: BuildToolPlugin {
-  /// Create build commands, the entry-point when using a Package manifest.
   func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
     guard let swiftTarget = target as? SwiftSourceModuleTarget else {
-      throw PluginError.incompatibleTarget(target.name)
+      throw BuildPluginError.incompatibleTarget(target.name)
     }
     let configFiles = swiftTarget.sourceFiles(withSuffix: configFileName).map { $0.url }
     let inputFiles = swiftTarget.sourceFiles(withSuffix: ".proto").map { $0.url }
@@ -41,7 +40,6 @@ import XcodeProjectPlugin
 
 // Entry-point when using Xcode projects
 extension GRPCProtobufGenerator: XcodeBuildToolPlugin {
-  /// Create build commands, the entry-point when using an Xcode project.
   func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] {
     let configFiles = target.inputFiles.filter {
       $0.url.lastPathComponent == configFileName
@@ -62,7 +60,7 @@ extension GRPCProtobufGenerator: XcodeBuildToolPlugin {
 
 @main
 struct GRPCProtobufGenerator {
-  /// Build plugin code common to both invocation types: package manifest Xcode project
+  /// Build plugin common code
   func createBuildCommands(
     pluginWorkDirectory: URL,
     tool: (String) throws -> PluginContext.Tool,
@@ -78,7 +76,7 @@ struct GRPCProtobufGenerator {
     var commands: [Command] = []
     for inputFile in inputFiles {
       guard let (configFilePath, config) = configs.findApplicableConfig(for: inputFile) else {
-        throw PluginError.noConfigFilesFound
+        throw BuildPluginError.noConfigFilesFound
       }
 
       let protocPath = try deriveProtocPath(using: config, tool: tool)
@@ -90,7 +88,7 @@ struct GRPCProtobufGenerator {
       }
 
       // unless *explicitly* opted-out
-      if config.client || config.server {
+      if config.clients || config.servers {
         let grpcCommand = try protocGenGRPCSwiftCommand(
           inputFile: inputFile,
           config: config,
@@ -104,7 +102,7 @@ struct GRPCProtobufGenerator {
       }
 
       // unless *explicitly* opted-out
-      if config.message {
+      if config.messages {
         let protoCommand = try protocGenSwiftCommand(
           inputFile: inputFile,
           config: config,

+ 255 - 0
Plugins/GRPCProtobufGeneratorCommand/CommandConfig.swift

@@ -0,0 +1,255 @@
+/*
+ * Copyright 2024, gRPC Authors All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import Foundation
+import PackagePlugin
+
+struct CommandConfig {
+  var common: GenerationConfig
+
+  var verbose: Bool
+  var dryRun: Bool
+
+  static let defaults = Self(
+    common: .init(
+      accessLevel: .internal,
+      servers: true,
+      clients: true,
+      messages: true,
+      fileNaming: .fullPath,
+      accessLevelOnImports: false,
+      importPaths: [],
+      outputPath: ""
+    ),
+    verbose: false,
+    dryRun: false
+  )
+
+  static let parameterGroupSeparator = "--"
+}
+
+extension CommandConfig {
+  static func parse(
+    argumentExtractor argExtractor: inout ArgumentExtractor,
+    pluginWorkDirectory: URL
+  ) throws -> CommandConfig {
+    var config = CommandConfig.defaults
+
+    for flag in OptionsAndFlags.allCases {
+      switch flag {
+      case .accessLevel:
+        if let value = argExtractor.extractSingleOption(named: flag.rawValue) {
+          if let accessLevel = GenerationConfig.AccessLevel(rawValue: value) {
+            config.common.accessLevel = accessLevel
+          } else {
+            throw CommandPluginError.unknownAccessLevel(value)
+          }
+        }
+
+      case .noServers:
+        // Handled by `.servers`
+        continue
+      case .servers:
+        let servers = argExtractor.extractFlag(named: OptionsAndFlags.servers.rawValue)
+        let noServers = argExtractor.extractFlag(named: OptionsAndFlags.noServers.rawValue)
+        if servers > 0 && noServers > 0 {
+          throw CommandPluginError.conflictingFlags(
+            OptionsAndFlags.servers.rawValue,
+            OptionsAndFlags.noServers.rawValue
+          )
+        } else if servers > 0 {
+          config.common.servers = true
+        } else if noServers > 0 {
+          config.common.servers = false
+        }
+
+      case .noClients:
+        // Handled by `.clients`
+        continue
+      case .clients:
+        let clients = argExtractor.extractFlag(named: OptionsAndFlags.clients.rawValue)
+        let noClients = argExtractor.extractFlag(named: OptionsAndFlags.noClients.rawValue)
+        if clients > 0 && noClients > 0 {
+          throw CommandPluginError.conflictingFlags(
+            OptionsAndFlags.clients.rawValue,
+            OptionsAndFlags.noClients.rawValue
+          )
+        } else if clients > 0 {
+          config.common.clients = true
+        } else if noClients > 0 {
+          config.common.clients = false
+        }
+
+      case .noMessages:
+        // Handled by `.messages`
+        continue
+      case .messages:
+        let messages = argExtractor.extractFlag(named: OptionsAndFlags.messages.rawValue)
+        let noMessages = argExtractor.extractFlag(named: OptionsAndFlags.noMessages.rawValue)
+        if messages > 0 && noMessages > 0 {
+          throw CommandPluginError.conflictingFlags(
+            OptionsAndFlags.messages.rawValue,
+            OptionsAndFlags.noMessages.rawValue
+          )
+        } else if messages > 0 {
+          config.common.messages = true
+        } else if noMessages > 0 {
+          config.common.messages = false
+        }
+
+      case .fileNaming:
+        if let value = argExtractor.extractSingleOption(named: flag.rawValue) {
+          if let fileNaming = GenerationConfig.FileNaming(rawValue: value) {
+            config.common.fileNaming = fileNaming
+          } else {
+            throw CommandPluginError.unknownFileNamingStrategy(value)
+          }
+        }
+
+      case .accessLevelOnImports:
+        if argExtractor.extractFlag(named: flag.rawValue) > 0 {
+          config.common.accessLevelOnImports = true
+        }
+
+      case .importPath:
+        config.common.importPaths = argExtractor.extractOption(named: flag.rawValue)
+
+      case .protocPath:
+        config.common.protocPath = argExtractor.extractSingleOption(named: flag.rawValue)
+
+      case .outputPath:
+        config.common.outputPath =
+          argExtractor.extractSingleOption(named: flag.rawValue)
+          ?? pluginWorkDirectory.absoluteStringNoScheme
+
+      case .verbose:
+        let verbose = argExtractor.extractFlag(named: flag.rawValue)
+        config.verbose = verbose != 0
+
+      case .dryRun:
+        let dryRun = argExtractor.extractFlag(named: flag.rawValue)
+        config.dryRun = dryRun != 0
+
+      case .help:
+        ()  // handled elsewhere
+      }
+    }
+
+    if let argument = argExtractor.remainingArguments.first {
+      throw CommandPluginError.unknownOption(argument)
+    }
+
+    return config
+  }
+}
+
+extension ArgumentExtractor {
+  mutating func extractSingleOption(named optionName: String) -> String? {
+    let values = self.extractOption(named: optionName)
+    if values.count > 1 {
+      Diagnostics.warning(
+        "'--\(optionName)' was unexpectedly repeated, the first value will be used."
+      )
+    }
+    return values.first
+  }
+}
+
+/// All valid input options/flags
+enum OptionsAndFlags: String, CaseIterable {
+  case servers
+  case noServers = "no-servers"
+  case clients
+  case noClients = "no-clients"
+  case messages
+  case noMessages = "no-messages"
+  case fileNaming = "file-naming"
+  case accessLevel = "access-level"
+  case accessLevelOnImports = "access-level-on-imports"
+  case importPath = "import-path"
+  case protocPath = "protoc-path"
+  case outputPath = "output-path"
+  case verbose
+  case dryRun = "dry-run"
+
+  case help
+}
+
+extension OptionsAndFlags {
+  func usageDescription() -> String {
+    switch self {
+    case .servers:
+      return "Generate server code. Generated by default."
+    case .noServers:
+      return "Do not generate server code. Generated by default."
+    case .clients:
+      return "Generate client code. Generated by default."
+    case .noClients:
+      return "Do not generate client code. Generated by default."
+    case .messages:
+      return "Generate message code. Generated by default."
+    case .noMessages:
+      return "Do not generate message code. Generated by default."
+    case .fileNaming:
+      return
+        "The naming scheme for output files [fullPath/pathToUnderscores/dropPath]. Defaults to fullPath."
+    case .accessLevel:
+      return
+        "The access level of the generated source [internal/public/package]. Defaults to internal."
+    case .accessLevelOnImports:
+      return "Whether imports should have explicit access levels. Defaults to false."
+    case .importPath:
+      return
+        "The directory in which to search for imports. May be specified multiple times. If none are specified the current working directory is used."
+    case .protocPath:
+      return "The path to the protoc binary."
+    case .dryRun:
+      return "Print but do not execute the protoc commands."
+    case .outputPath:
+      return "The directory into which the generated source files are created."
+    case .verbose:
+      return "Emit verbose output."
+    case .help:
+      return "Print this help."
+    }
+  }
+
+  static func printHelp(requested: Bool) {
+    let printMessage: (String) -> Void
+    if requested {
+      printMessage = { message in print(message) }
+    } else {
+      printMessage = Stderr.print
+    }
+
+    printMessage(
+      "Usage: swift package generate-grpc-code-from-protos [flags] [\(CommandConfig.parameterGroupSeparator)] [input files]"
+    )
+    printMessage("")
+    printMessage("Flags:")
+    printMessage("")
+
+    let spacing = 3
+    let maxLength =
+      (OptionsAndFlags.allCases.map(\.rawValue).max(by: { $0.count < $1.count })?.count ?? 0)
+      + spacing
+    for flag in OptionsAndFlags.allCases {
+      printMessage(
+        "  --\(flag.rawValue.padding(toLength: maxLength, withPad: " ", startingAt: 0))\(flag.usageDescription())"
+      )
+    }
+  }
+}

+ 65 - 0
Plugins/GRPCProtobufGeneratorCommand/CommandPluginError.swift

@@ -0,0 +1,65 @@
+/*
+ * Copyright 2025, gRPC Authors All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+enum CommandPluginError: Error {
+  case invalidArgumentValue(name: String, value: String)
+  case missingInputFile
+  case unknownOption(String)
+  case unknownAccessLevel(String)
+  case unknownFileNamingStrategy(String)
+  case conflictingFlags(String, String)
+  case generationFailure(
+    errorDescription: String,
+    executable: String,
+    arguments: [String],
+    stdErr: String?
+  )
+  case tooManyParameterSeparators
+}
+
+extension CommandPluginError: CustomStringConvertible {
+  var description: String {
+    switch self {
+    case .invalidArgumentValue(let name, let value):
+      return "Invalid value '\(value)', for '\(name)'."
+    case .missingInputFile:
+      return "No input file(s) specified."
+    case .unknownOption(let name):
+      return "Provided option is unknown: \(name)."
+    case .unknownAccessLevel(let value):
+      return "Provided access level is unknown: \(value)."
+    case .unknownFileNamingStrategy(let value):
+      return "Provided file naming strategy is unknown: \(value)."
+    case .conflictingFlags(let flag1, let flag2):
+      return "Provided flags conflict: '\(flag1)' and '\(flag2)'."
+    case .generationFailure(let errorDescription, let executable, let arguments, let stdErr):
+      var message = """
+        Code generation failed with: \(errorDescription).
+        \tExecutable: \(executable)
+        \tArguments: \(arguments.joined(separator: " "))
+        """
+      if let stdErr {
+        message += """
+          \n\tprotoc error output:
+          \t\(stdErr)
+          """
+      }
+      return message
+    case .tooManyParameterSeparators:
+      return "Unexpected parameter structure, too many '--' separators."
+    }
+  }
+}

+ 245 - 0
Plugins/GRPCProtobufGeneratorCommand/Plugin.swift

@@ -0,0 +1,245 @@
+/*
+ * Copyright 2024, gRPC Authors All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import Foundation
+import PackagePlugin
+
+extension GRPCProtobufGeneratorCommandPlugin: CommandPlugin {
+  func performCommand(context: PluginContext, arguments: [String]) async throws {
+    try self.performCommand(
+      arguments: arguments,
+      tool: context.tool,
+      pluginWorkDirectoryURL: context.pluginWorkDirectoryURL
+    )
+  }
+}
+
+#if canImport(XcodeProjectPlugin)
+import XcodeProjectPlugin
+
+// Entry-point when using Xcode projects
+extension GRPCProtobufGeneratorCommandPlugin: XcodeCommandPlugin {
+  func performCommand(context: XcodeProjectPlugin.XcodePluginContext, arguments: [String]) throws {
+    try self.performCommand(
+      arguments: arguments,
+      tool: context.tool,
+      pluginWorkDirectoryURL: context.pluginWorkDirectoryURL
+    )
+  }
+}
+#endif
+
+@main
+struct GRPCProtobufGeneratorCommandPlugin {
+  /// Command plugin common code
+  func performCommand(
+    arguments: [String],
+    tool: (String) throws -> PluginContext.Tool,
+    pluginWorkDirectoryURL: URL
+  ) throws {
+    let flagsAndOptions: [String]
+    let inputFiles: [String]
+
+    let separatorCount = arguments.count { $0 == CommandConfig.parameterGroupSeparator }
+    switch separatorCount {
+    case 0:
+      var argExtractor = ArgumentExtractor(arguments)
+      // check if help requested
+      if argExtractor.extractFlag(named: OptionsAndFlags.help.rawValue) > 0 {
+        OptionsAndFlags.printHelp(requested: true)
+        return
+      }
+
+      inputFiles = arguments
+      flagsAndOptions = []
+
+    case 1:
+      let splitIndex = arguments.firstIndex(of: CommandConfig.parameterGroupSeparator)!
+      flagsAndOptions = Array(arguments[..<splitIndex])
+      inputFiles = Array(arguments[splitIndex.advanced(by: 1)...])
+
+    default:
+      throw CommandPluginError.tooManyParameterSeparators
+    }
+
+    var argExtractor = ArgumentExtractor(flagsAndOptions)
+    // help requested
+    if argExtractor.extractFlag(named: OptionsAndFlags.help.rawValue) > 0 {
+      OptionsAndFlags.printHelp(requested: true)
+      return
+    }
+
+    // MARK: Configuration
+    let commandConfig: CommandConfig
+    do {
+      commandConfig = try CommandConfig.parse(
+        argumentExtractor: &argExtractor,
+        pluginWorkDirectory: pluginWorkDirectoryURL
+      )
+    } catch {
+      throw error
+    }
+
+    if commandConfig.verbose {
+      Stderr.print("InputFiles: \(inputFiles.joined(separator: ", "))")
+    }
+
+    let config = commandConfig.common
+    let protocPath = try deriveProtocPath(using: config, tool: tool)
+    let protocGenGRPCSwiftPath = try tool("protoc-gen-grpc-swift").url
+    let protocGenSwiftPath = try tool("protoc-gen-swift").url
+
+    let outputDirectory = URL(fileURLWithPath: config.outputPath)
+    if commandConfig.verbose {
+      Stderr.print(
+        "Generated files will be written to: '\(outputDirectory.absoluteStringNoScheme)'"
+      )
+    }
+
+    let inputFileURLs = inputFiles.map { URL(fileURLWithPath: $0) }
+
+    // MARK: protoc-gen-grpc-swift
+    if config.clients || config.servers {
+      let arguments = constructProtocGenGRPCSwiftArguments(
+        config: config,
+        fileNaming: config.fileNaming,
+        inputFiles: inputFileURLs,
+        protoDirectoryPaths: config.importPaths,
+        protocGenGRPCSwiftPath: protocGenGRPCSwiftPath,
+        outputDirectory: outputDirectory
+      )
+
+      try executeProtocInvocation(
+        executableURL: protocPath,
+        arguments: arguments,
+        verbose: commandConfig.verbose,
+        dryRun: commandConfig.dryRun
+      )
+
+      if !commandConfig.dryRun, commandConfig.verbose {
+        Stderr.print("Generated gRPC Swift files for \(inputFiles.joined(separator: ", ")).")
+      }
+    }
+
+    // MARK: protoc-gen-swift
+    if config.messages {
+      let arguments = constructProtocGenSwiftArguments(
+        config: config,
+        fileNaming: config.fileNaming,
+        inputFiles: inputFileURLs,
+        protoDirectoryPaths: config.importPaths,
+        protocGenSwiftPath: protocGenSwiftPath,
+        outputDirectory: outputDirectory
+      )
+
+      let completionStatus = try executeProtocInvocation(
+        executableURL: protocPath,
+        arguments: arguments,
+        verbose: commandConfig.verbose,
+        dryRun: commandConfig.dryRun
+      )
+
+      if !commandConfig.dryRun, commandConfig.verbose {
+        Stderr.print(
+          "Generated protobuf message Swift files for \(inputFiles.joined(separator: ", "))."
+        )
+      }
+    }
+  }
+}
+
+/// Execute a single invocation of `protoc`, printing output and if in verbose mode the invocation
+/// - Parameters:
+///   - executableURL: The path to the `protoc` executable.
+///   - arguments: The arguments to be passed to `protoc`.
+///   - verbose: Whether or not to print verbose output
+///   - dryRun: If this invocation is a dry-run, i.e. will not actually be executed
+func executeProtocInvocation(
+  executableURL: URL,
+  arguments: [String],
+  verbose: Bool,
+  dryRun: Bool
+) throws {
+  if verbose {
+    Stderr.print("\(executableURL.absoluteStringNoScheme) \\")
+    Stderr.print("  \(arguments.joined(separator: " \\\n  "))")
+  }
+
+  if dryRun {
+    return
+  }
+
+  let process = Process()
+  process.executableURL = executableURL
+  process.arguments = arguments
+
+  let outputPipe = Pipe()
+  let errorPipe = Pipe()
+  process.standardOutput = outputPipe
+  process.standardError = errorPipe
+
+  do {
+    try process.run()
+  } catch {
+    try printProtocOutput(outputPipe, verbose: verbose)
+    let stdErr: String?
+    if let errorData = try errorPipe.fileHandleForReading.readToEnd() {
+      stdErr = String(decoding: errorData, as: UTF8.self)
+    } else {
+      stdErr = nil
+    }
+    throw CommandPluginError.generationFailure(
+      errorDescription: "\(error)",
+      executable: executableURL.absoluteStringNoScheme,
+      arguments: arguments,
+      stdErr: stdErr
+    )
+  }
+  process.waitUntilExit()
+
+  try printProtocOutput(outputPipe, verbose: verbose)
+
+  if process.terminationReason == .exit && process.terminationStatus == 0 {
+    return
+  }
+
+  let stdErr: String?
+  if let errorData = try errorPipe.fileHandleForReading.readToEnd() {
+    stdErr = String(decoding: errorData, as: UTF8.self)
+  } else {
+    stdErr = nil
+  }
+  let problem = "\(process.terminationReason):\(process.terminationStatus)"
+  throw CommandPluginError.generationFailure(
+    errorDescription: problem,
+    executable: executableURL.absoluteStringNoScheme,
+    arguments: arguments,
+    stdErr: stdErr
+  )
+
+  return
+}
+
+func printProtocOutput(_ stdOut: Pipe, verbose: Bool) throws {
+  if verbose, let outputData = try stdOut.fileHandleForReading.readToEnd() {
+    let output = String(decoding: outputData, as: UTF8.self)
+    let lines = output.split { $0.isNewline }
+    print("protoc output:")
+    for line in lines {
+      print("\t\(line)")
+    }
+  }
+}

+ 1 - 0
Plugins/GRPCProtobufGeneratorCommand/PluginsShared

@@ -0,0 +1 @@
+../PluginsShared

+ 20 - 5
Plugins/PluginsShared/GenerationConfig.swift

@@ -32,7 +32,7 @@ struct GenerationConfig {
   /// - `FullPath`: `foo/bar/baz.grpc.swift`
   /// - `PathToUnderscore`: `foo_bar_baz.grpc.swift`
   /// - `DropPath`: `baz.grpc.swift`
-  enum FileNaming: String, Codable {
+  enum FileNaming: String {
     /// Replicate the input file path with the output file(s).
     case fullPath = "FullPath"
     /// Convert path directory delimiters to underscores.
@@ -42,13 +42,13 @@ struct GenerationConfig {
   }
 
   /// The visibility of the generated files.
-  var visibility: AccessLevel
+  var accessLevel: AccessLevel
   /// Whether server code is generated.
-  var server: Bool
+  var servers: Bool
   /// Whether client code is generated.
-  var client: Bool
+  var clients: Bool
   /// Whether message code is generated.
-  var message: Bool
+  var messages: Bool
   /// The naming of output files with respect to the path of the source file.
   var fileNaming: FileNaming
   /// Whether imports should have explicit access levels.
@@ -83,3 +83,18 @@ extension GenerationConfig.AccessLevel: Codable {
     }
   }
 }
+
+extension GenerationConfig.FileNaming: Codable {
+  init?(rawValue: String) {
+    switch rawValue.lowercased() {
+    case "fullpath":
+      self = .fullPath
+    case "pathtounderscores":
+      self = .pathToUnderscores
+    case "droppath":
+      self = .dropPath
+    default:
+      return nil
+    }
+  }
+}

+ 17 - 4
Plugins/PluginsShared/PluginUtils.swift

@@ -17,6 +17,8 @@
 import Foundation
 import PackagePlugin
 
+let configFileName = "grpc-swift-proto-generator-config.json"
+
 /// Derive the path to the instance of `protoc` to be used.
 /// - Parameters:
 ///   - config: The supplied config. If no path is supplied then one is discovered using the `PROTOC_PATH` environment variable or the `findTool`.
@@ -63,7 +65,7 @@ func constructProtocGenSwiftArguments(
     protocArgs.append("--proto_path=\(path)")
   }
 
-  protocArgs.append("--swift_opt=Visibility=\(config.visibility.rawValue)")
+  protocArgs.append("--swift_opt=Visibility=\(config.accessLevel.rawValue)")
   protocArgs.append("--swift_opt=FileNaming=\(config.fileNaming.rawValue)")
   protocArgs.append("--swift_opt=UseAccessLevelOnImports=\(config.accessLevelOnImports)")
   protocArgs.append(contentsOf: inputFiles.map { $0.absoluteStringNoScheme })
@@ -97,9 +99,9 @@ func constructProtocGenGRPCSwiftArguments(
     protocArgs.append("--proto_path=\(path)")
   }
 
-  protocArgs.append("--grpc-swift_opt=Visibility=\(config.visibility.rawValue.capitalized)")
-  protocArgs.append("--grpc-swift_opt=Server=\(config.server)")
-  protocArgs.append("--grpc-swift_opt=Client=\(config.client)")
+  protocArgs.append("--grpc-swift_opt=Visibility=\(config.accessLevel.rawValue.capitalized)")
+  protocArgs.append("--grpc-swift_opt=Server=\(config.servers)")
+  protocArgs.append("--grpc-swift_opt=Client=\(config.clients)")
   protocArgs.append("--grpc-swift_opt=FileNaming=\(config.fileNaming.rawValue)")
   protocArgs.append("--grpc-swift_opt=UseAccessLevelOnImports=\(config.accessLevelOnImports)")
   protocArgs.append(contentsOf: inputFiles.map { $0.absoluteStringNoScheme })
@@ -117,3 +119,14 @@ extension URL {
     return absoluteString
   }
 }
+
+enum Stderr {
+  private static let newLine = "\n".data(using: .utf8)!
+
+  static func print(_ message: String) {
+    if let data = message.data(using: .utf8) {
+      FileHandle.standardError.write(data)
+      FileHandle.standardError.write(Self.newLine)
+    }
+  }
+}