Procházet zdrojové kódy

Code generation build plugin (#28)

## Overview
New build plugin to generate gRPC services and protobuf messages

The SwiftPM build plugin will locate protobuf files in the `Sources`
directory (with the extension `.proto`) and attempt to invoke both the
`protoc-gen-swift` and `protoc-gen-grpc-swift` `protoc` plugins on them
to automatically generate Swift source. Behavior can be modified by
specifying one or more configuration files.

* For a given protobuf definition file the tool will search for
configuration files in the same and all parent directories and will use
the file lowest in the hierarchy.
* Most configuration if not specified will use the protoc plugin's own
defaults.
Rick Newton-Rogers před 10 měsíci
rodič
revize
b036851fb8

+ 14 - 0
Package.swift

@@ -26,6 +26,10 @@ let products: [Product] = [
     name: "protoc-gen-grpc-swift",
     targets: ["protoc-gen-grpc-swift"]
   ),
+  .plugin(
+    name: "GRPCProtobufGenerator",
+    targets: ["GRPCProtobufGenerator"]
+  ),
 ]
 
 let dependencies: [Package.Dependency] = [
@@ -101,6 +105,16 @@ let targets: [Target] = [
     ],
     swiftSettings: defaultSwiftSettings
   ),
+
+  // Code generator build plugin
+  .plugin(
+    name: "GRPCProtobufGenerator",
+    capability: .buildTool(),
+    dependencies: [
+      .target(name: "protoc-gen-grpc-swift"),
+      .product(name: "protoc-gen-swift", package: "swift-protobuf"),
+    ]
+  ),
 ]
 
 let package = Package(

+ 210 - 0
Plugins/GRPCProtobufGenerator/BuildPluginConfig.swift

@@ -0,0 +1,210 @@
+/*
+ * 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
+
+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.
+  struct Generate {
+    /// Whether server code is generated.
+    ///
+    /// Defaults to `true`.
+    var servers: Bool
+    /// Whether client code is generated.
+    ///
+    /// Defaults to `true`.
+    var clients: Bool
+    /// Whether message code is generated.
+    ///
+    /// Defaults to `true`.
+    var messages: Bool
+
+    static let defaults = Self(
+      servers: true,
+      clients: true,
+      messages: true
+    )
+
+    private init(servers: Bool, clients: Bool, messages: Bool) {
+      self.servers = servers
+      self.clients = clients
+      self.messages = messages
+    }
+  }
+
+  /// Config relating to the generated code itself.
+  struct GeneratedSource {
+    /// The visibility of the generated files.
+    ///
+    /// Defaults to `Internal`.
+    var accessLevel: GenerationConfig.AccessLevel
+    /// Whether imports should have explicit access levels.
+    ///
+    /// Defaults to `false`.
+    var useAccessLevelOnImports: Bool
+
+    static let defaults = Self(
+      accessLevel: .internal,
+      useAccessLevelOnImports: false
+    )
+
+    private init(accessLevel: GenerationConfig.AccessLevel, useAccessLevelOnImports: Bool) {
+      self.accessLevel = accessLevel
+      self.useAccessLevelOnImports = useAccessLevelOnImports
+    }
+  }
+
+  /// Config relating to the protoc invocation.
+  struct Protoc {
+    /// Specify the directory in which to search for imports.
+    ///
+    /// Paths are relative to the location of the specifying config file.
+    /// Build plugins only have access to files within the target's source directory.
+    /// May be specified multiple times; directories will be searched in order.
+    /// The target source directory is always appended
+    /// to the import paths.
+    var importPaths: [String]
+
+    /// The path to the `protoc` executable binary.
+    ///
+    /// If this is not set, Swift Package Manager will try to find the tool itself.
+    var executablePath: String?
+
+    static let defaults = Self(
+      importPaths: [],
+      executablePath: nil
+    )
+
+    private init(importPaths: [String], executablePath: String?) {
+      self.importPaths = importPaths
+      self.executablePath = executablePath
+    }
+  }
+
+  /// Config defining which components should be considered when generating source.
+  var generate: Generate
+  /// Config relating to the nature of the generated code.
+  var generatedSource: GeneratedSource
+  /// Config relating to the protoc invocation.
+  var protoc: Protoc
+
+  static let defaults = Self(
+    generate: Generate.defaults,
+    generatedSource: GeneratedSource.defaults,
+    protoc: Protoc.defaults
+  )
+  private init(generate: Generate, generatedSource: GeneratedSource, protoc: Protoc) {
+    self.generate = generate
+    self.generatedSource = generatedSource
+    self.protoc = protoc
+  }
+
+  // Codable conformance with defaults
+  enum CodingKeys: String, CodingKey {
+    case generate
+    case generatedSource
+    case protoc
+  }
+
+  init(from decoder: any Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+
+    self.generate =
+      try container.decodeIfPresent(Generate.self, forKey: .generate) ?? Self.defaults.generate
+    self.generatedSource =
+      try container.decodeIfPresent(GeneratedSource.self, forKey: .generatedSource)
+      ?? Self.defaults.generatedSource
+    self.protoc =
+      try container.decodeIfPresent(Protoc.self, forKey: .protoc) ?? Self.defaults.protoc
+  }
+}
+
+extension BuildPluginConfig.Generate: Codable {
+  // Codable conformance with defaults
+  enum CodingKeys: String, CodingKey {
+    case servers
+    case clients
+    case messages
+  }
+
+  init(from decoder: any Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+
+    self.servers =
+      try container.decodeIfPresent(Bool.self, forKey: .servers) ?? Self.defaults.servers
+    self.clients =
+      try container.decodeIfPresent(Bool.self, forKey: .clients) ?? Self.defaults.clients
+    self.messages =
+      try container.decodeIfPresent(Bool.self, forKey: .messages) ?? Self.defaults.messages
+  }
+}
+
+extension BuildPluginConfig.GeneratedSource: Codable {
+  // Codable conformance with defaults
+  enum CodingKeys: String, CodingKey {
+    case accessLevel
+    case useAccessLevelOnImports
+  }
+
+  init(from decoder: any Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+
+    self.accessLevel =
+      try container.decodeIfPresent(GenerationConfig.AccessLevel.self, forKey: .accessLevel)
+      ?? Self.defaults.accessLevel
+    self.useAccessLevelOnImports =
+      try container.decodeIfPresent(Bool.self, forKey: .useAccessLevelOnImports)
+      ?? Self.defaults.useAccessLevelOnImports
+  }
+}
+
+extension BuildPluginConfig.Protoc: Codable {
+  // Codable conformance with defaults
+  enum CodingKeys: String, CodingKey {
+    case importPaths
+    case executablePath
+  }
+
+  init(from decoder: any Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+
+    self.importPaths =
+      try container.decodeIfPresent([String].self, forKey: .importPaths)
+      ?? Self.defaults.importPaths
+    self.executablePath = try container.decodeIfPresent(String.self, forKey: .executablePath)
+  }
+}
+
+extension GenerationConfig {
+  init(buildPluginConfig: BuildPluginConfig, configFilePath: URL, outputPath: URL) {
+    self.server = buildPluginConfig.generate.servers
+    self.client = buildPluginConfig.generate.clients
+    self.message = buildPluginConfig.generate.messages
+    // hard-code full-path to avoid collisions since this goes into a temporary directory anyway
+    self.fileNaming = .fullPath
+    self.visibility = buildPluginConfig.generatedSource.accessLevel
+    self.useAccessLevelOnImports = buildPluginConfig.generatedSource.useAccessLevelOnImports
+    // Generate absolute paths for the imports relative to the config file in which they are specified
+    self.importPaths = buildPluginConfig.protoc.importPaths.map { relativePath in
+      configFilePath.deletingLastPathComponent().absoluteStringNoScheme + "/" + relativePath
+    }
+    self.protocPath = buildPluginConfig.protoc.executablePath
+    self.outputPath = outputPath.absoluteStringNoScheme
+  }
+}

+ 306 - 0
Plugins/GRPCProtobufGenerator/Plugin.swift

@@ -0,0 +1,306 @@
+/*
+ * 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
+
+// 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)
+    }
+    let configFiles = swiftTarget.sourceFiles(withSuffix: configFileName).map { $0.url }
+    let inputFiles = swiftTarget.sourceFiles(withSuffix: ".proto").map { $0.url }
+    return try createBuildCommands(
+      pluginWorkDirectory: context.pluginWorkDirectoryURL,
+      tool: context.tool,
+      inputFiles: inputFiles,
+      configFiles: configFiles,
+      targetName: target.name
+    )
+  }
+}
+
+#if canImport(XcodeProjectPlugin)
+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
+    }.map { $0.url }
+    let inputFiles = target.inputFiles.filter { $0.url.lastPathComponent.hasSuffix(".proto") }.map {
+      $0.url
+    }
+    return try createBuildCommands(
+      pluginWorkDirectory: context.pluginWorkDirectoryURL,
+      tool: context.tool,
+      inputFiles: inputFiles,
+      configFiles: configFiles,
+      targetName: target.displayName
+    )
+  }
+}
+#endif
+
+@main
+struct GRPCProtobufGenerator {
+  /// Build plugin code common to both invocation types: package manifest Xcode project
+  func createBuildCommands(
+    pluginWorkDirectory: URL,
+    tool: (String) throws -> PluginContext.Tool,
+    inputFiles: [URL],
+    configFiles: [URL],
+    targetName: String
+  ) throws -> [Command] {
+    let configs = try readConfigFiles(configFiles, pluginWorkDirectory: pluginWorkDirectory)
+
+    let protocGenGRPCSwiftPath = try tool("protoc-gen-grpc-swift").url
+    let protocGenSwiftPath = try tool("protoc-gen-swift").url
+
+    var commands: [Command] = []
+    for inputFile in inputFiles {
+      guard let (configFilePath, config) = configs.findApplicableConfig(for: inputFile) else {
+        throw PluginError.noConfigFilesFound
+      }
+
+      let protocPath = try deriveProtocPath(using: config, tool: tool)
+      let protoDirectoryPaths: [String]
+      if config.importPaths.isEmpty {
+        protoDirectoryPaths = [configFilePath.deletingLastPathComponent().absoluteStringNoScheme]
+      } else {
+        protoDirectoryPaths = config.importPaths
+      }
+
+      // unless *explicitly* opted-out
+      if config.client || config.server {
+        let grpcCommand = try protocGenGRPCSwiftCommand(
+          inputFile: inputFile,
+          config: config,
+          baseDirectoryPath: configFilePath.deletingLastPathComponent(),
+          protoDirectoryPaths: protoDirectoryPaths,
+          protocPath: protocPath,
+          protocGenGRPCSwiftPath: protocGenGRPCSwiftPath,
+          configFilePath: configFilePath
+        )
+        commands.append(grpcCommand)
+      }
+
+      // unless *explicitly* opted-out
+      if config.message {
+        let protoCommand = try protocGenSwiftCommand(
+          inputFile: inputFile,
+          config: config,
+          baseDirectoryPath: configFilePath.deletingLastPathComponent(),
+          protoDirectoryPaths: protoDirectoryPaths,
+          protocPath: protocPath,
+          protocGenSwiftPath: protocGenSwiftPath,
+          configFilePath: configFilePath
+        )
+        commands.append(protoCommand)
+      }
+    }
+
+    return commands
+  }
+}
+
+/// Reads the config files at the supplied URLs into memory
+/// - Parameter configFilePaths: URLs from which to load config
+/// - Returns: A map of source URLs to loaded config
+func readConfigFiles(
+  _ configFilePaths: [URL],
+  pluginWorkDirectory: URL
+) throws -> [URL: GenerationConfig] {
+  var configs: [URL: GenerationConfig] = [:]
+  for configFilePath in configFilePaths {
+    let data = try Data(contentsOf: configFilePath)
+    let config = try JSONDecoder().decode(BuildPluginConfig.self, from: data)
+
+    // the output directory mandated by the plugin system
+    configs[configFilePath] = GenerationConfig(
+      buildPluginConfig: config,
+      configFilePath: configFilePath,
+      outputPath: pluginWorkDirectory
+    )
+  }
+  return configs
+}
+
+extension [URL: GenerationConfig] {
+  /// Finds the most relevant config file for a given proto file URL.
+  ///
+  /// The most relevant config file is the lowest of config files which are either a sibling or a parent in the file heirarchy.
+  /// - Parameters:
+  ///   - file: The path to the proto file to be matched.
+  /// - Returns: The path to the most precisely relevant config file if one is found and the config itself, otherwise `nil`.
+  func findApplicableConfig(for file: URL) -> (URL, GenerationConfig)? {
+    let filePathComponents = file.pathComponents
+    for endComponent in (0 ..< filePathComponents.count).reversed() {
+      for (configFilePath, config) in self {
+        if filePathComponents[..<endComponent]
+          == configFilePath.pathComponents[..<(configFilePath.pathComponents.count - 1)]
+        {
+          return (configFilePath, config)
+        }
+      }
+    }
+
+    return nil
+  }
+}
+
+/// Construct the command to invoke `protoc` with the `protoc-gen-grpc-swift` plugin.
+/// - Parameters:
+///   - inputFile: The input `.proto` file.
+///   - config: The config for this operation.
+///   - baseDirectoryPath: The root path to the source `.proto` files used as the reference for relative path naming schemes.
+///   - protoDirectoryPaths: The paths passed to `protoc` in which to look for imported proto files.
+///   - protocPath: The path to `protoc`
+///   - protocGenGRPCSwiftPath: The path to `protoc-gen-grpc-swift`.
+///   - configFilePath: The path to the config file in use.
+/// - Returns: The command to invoke `protoc` with the `protoc-gen-grpc-swift` plugin.
+func protocGenGRPCSwiftCommand(
+  inputFile: URL,
+  config: GenerationConfig,
+  baseDirectoryPath: URL,
+  protoDirectoryPaths: [String],
+  protocPath: URL,
+  protocGenGRPCSwiftPath: URL,
+  configFilePath: URL
+) throws -> PackagePlugin.Command {
+  let outputPathURL = URL(fileURLWithPath: config.outputPath)
+
+  let outputFilePath = deriveOutputFilePath(
+    for: inputFile,
+    baseDirectoryPath: baseDirectoryPath,
+    outputDirectory: outputPathURL,
+    outputExtension: "grpc.swift"
+  )
+
+  let arguments = constructProtocGenGRPCSwiftArguments(
+    config: config,
+    fileNaming: config.fileNaming,
+    inputFiles: [inputFile],
+    protoDirectoryPaths: protoDirectoryPaths,
+    protocGenGRPCSwiftPath: protocGenGRPCSwiftPath,
+    outputDirectory: outputPathURL
+  )
+
+  return Command.buildCommand(
+    displayName: "Generating gRPC Swift files for \(inputFile.absoluteStringNoScheme)",
+    executable: protocPath,
+    arguments: arguments,
+    inputFiles: [
+      inputFile,
+      protocGenGRPCSwiftPath,
+      configFilePath,
+    ],
+    outputFiles: [outputFilePath]
+  )
+}
+
+/// Construct the command to invoke `protoc` with the `protoc-gen-swift` plugin.
+/// - Parameters:
+///   - inputFile: The input `.proto` file.
+///   - config: The config for this operation.
+///   - baseDirectoryPath: The root path to the source `.proto` files used as the reference for relative path naming schemes.
+///   - protoDirectoryPaths: The paths passed to `protoc` in which to look for imported proto files.
+///   - protocPath: The path to `protoc`
+///   - protocGenSwiftPath: The path to `protoc-gen-grpc-swift`.
+///   - configFilePath: The path to the config file in use.
+/// - Returns: The command to invoke `protoc` with the `protoc-gen-swift` plugin.
+func protocGenSwiftCommand(
+  inputFile: URL,
+  config: GenerationConfig,
+  baseDirectoryPath: URL,
+  protoDirectoryPaths: [String],
+  protocPath: URL,
+  protocGenSwiftPath: URL,
+  configFilePath: URL
+) throws -> PackagePlugin.Command {
+  let outputPathURL = URL(fileURLWithPath: config.outputPath)
+
+  let outputFilePath = deriveOutputFilePath(
+    for: inputFile,
+    baseDirectoryPath: baseDirectoryPath,
+    outputDirectory: outputPathURL,
+    outputExtension: "pb.swift"
+  )
+
+  let arguments = constructProtocGenSwiftArguments(
+    config: config,
+    fileNaming: config.fileNaming,
+    inputFiles: [inputFile],
+    protoDirectoryPaths: protoDirectoryPaths,
+    protocGenSwiftPath: protocGenSwiftPath,
+    outputDirectory: outputPathURL
+  )
+
+  return Command.buildCommand(
+    displayName: "Generating Swift Protobuf files for \(inputFile.absoluteStringNoScheme)",
+    executable: protocPath,
+    arguments: arguments,
+    inputFiles: [
+      inputFile,
+      protocGenSwiftPath,
+      configFilePath,
+    ],
+    outputFiles: [outputFilePath]
+  )
+}
+
+/// Derive the expected output file path to match the behavior of the `protoc-gen-swift` and `protoc-gen-grpc-swift` `protoc` plugins
+/// when using the `FullPath` naming scheme.
+/// - Parameters:
+///   - inputFile: The input `.proto` file.
+///   - baseDirectoryPath: The root path to the source `.proto` files used as the reference for relative path naming schemes.
+///   - outputDirectory: The directory in which generated source files are created.
+///   - outputExtension: The file extension to be appended to generated files in-place of `.proto`.
+/// - Returns: The expected output file path.
+func deriveOutputFilePath(
+  for inputFile: URL,
+  baseDirectoryPath: URL,
+  outputDirectory: URL,
+  outputExtension: String
+) -> URL {
+  // The name of the output file is based on the name of the input file.
+  // We validated in the beginning that every file has the suffix of .proto
+  // This means we can just drop the last 5 elements and append the new suffix
+  let lastPathComponentRoot = inputFile.lastPathComponent.dropLast(5)
+  let lastPathComponent = String(lastPathComponentRoot + outputExtension)
+
+  // find the inputFile path relative to the proto directory
+  var relativePathComponents = inputFile.deletingLastPathComponent().pathComponents
+  for protoDirectoryPathComponent in baseDirectoryPath.pathComponents {
+    if relativePathComponents.first == protoDirectoryPathComponent {
+      relativePathComponents.removeFirst()
+    } else {
+      break
+    }
+  }
+
+  let outputFileComponents = relativePathComponents + [lastPathComponent]
+  var outputFilePath = outputDirectory
+  for outputFileComponent in outputFileComponents {
+    outputFilePath.append(component: outputFileComponent)
+  }
+  return outputFilePath
+}

+ 1 - 0
Plugins/GRPCProtobufGenerator/PluginsShared

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

+ 85 - 0
Plugins/PluginsShared/GenerationConfig.swift

@@ -0,0 +1,85 @@
+/*
+ * 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.
+ */
+
+/// The config used when generating code whether called from the build or command plugin.
+struct GenerationConfig {
+  /// The access level (i.e. visibility) of the generated files.
+  enum AccessLevel: String {
+    /// The generated files should have `internal` access level.
+    case `internal` = "Internal"
+    /// The generated files should have `public` access level.
+    case `public` = "Public"
+    /// The generated files should have `package` access level.
+    case `package` = "Package"
+  }
+
+  /// The naming of output files with respect to the path of the source file.
+  ///
+  /// For an input of `foo/bar/baz.proto` the following output file will be generated:
+  /// - `FullPath`: `foo/bar/baz.grpc.swift`
+  /// - `PathToUnderscore`: `foo_bar_baz.grpc.swift`
+  /// - `DropPath`: `baz.grpc.swift`
+  enum FileNaming: String, Codable {
+    /// Replicate the input file path with the output file(s).
+    case fullPath = "FullPath"
+    /// Convert path directory delimiters to underscores.
+    case pathToUnderscores = "PathToUnderscores"
+    /// Generate output files using only the base name of the inout file, ignoring the path.
+    case dropPath = "DropPath"
+  }
+
+  /// The visibility of the generated files.
+  var visibility: AccessLevel
+  /// Whether server code is generated.
+  var server: Bool
+  /// Whether client code is generated.
+  var client: Bool
+  /// Whether message code is generated.
+  var message: 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.
+  var useAccessLevelOnImports: Bool
+
+  /// Specify the directory in which to search for imports.
+  ///
+  /// May be specified multiple times; directories will be searched in order.
+  /// The target source directory is always appended to the import paths.
+  var importPaths: [String]
+
+  /// The path to the `protoc` binary.
+  ///
+  /// If this is not set, Swift Package Manager will try to find the tool itself.
+  var protocPath: String?
+
+  /// The path into which the generated source files are created.
+  var outputPath: String
+}
+
+extension GenerationConfig.AccessLevel: Codable {
+  init?(rawValue: String) {
+    switch rawValue.lowercased() {
+    case "internal":
+      self = .internal
+    case "public":
+      self = .public
+    case "package":
+      self = .package
+    default:
+      return nil
+    }
+  }
+}

+ 32 - 0
Plugins/PluginsShared/PluginError.swift

@@ -0,0 +1,32 @@
+/*
+ * 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.
+ */
+
+enum PluginError: Error {
+  // Build plugin
+  case incompatibleTarget(String)
+  case noConfigFilesFound
+}
+
+extension PluginError: CustomStringConvertible {
+  var description: String {
+    switch self {
+    case .incompatibleTarget(let string):
+      "Build plugin applied to incompatible target."
+    case .noConfigFilesFound:
+      "No config files found. The build plugin relies on the existence of one or more '\(configFileName)' files in the target source."
+    }
+  }
+}

+ 117 - 0
Plugins/PluginsShared/PluginUtils.swift

@@ -0,0 +1,117 @@
+/*
+ * 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
+
+/// 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`.
+///   - findTool: The context-supplied tool which is used to attempt to discover the path to a `protoc` binary.
+/// - Returns: The path to the instance of `protoc` to be used.
+func deriveProtocPath(
+  using config: GenerationConfig,
+  tool findTool: (String) throws -> PackagePlugin.PluginContext.Tool
+) throws -> URL {
+  if let configuredProtocPath = config.protocPath {
+    return URL(fileURLWithPath: configuredProtocPath)
+  } else if let environmentPath = ProcessInfo.processInfo.environment["PROTOC_PATH"] {
+    // The user set the env variable, so let's take that
+    return URL(fileURLWithPath: environmentPath)
+  } else {
+    // The user didn't set anything so let's try see if Swift Package Manager can find a binary for us
+    return try findTool("protoc").url
+  }
+}
+
+/// Construct the arguments to be passed to `protoc` when invoking the `protoc-gen-swift` `protoc` plugin.
+/// - Parameters:
+///   - config: The config for this operation.
+///   - fileNaming: The file naming scheme to be used.
+///   - inputFiles: The input `.proto` files.
+///   - protoDirectoryPaths: The directories in which `protoc` will look for imports.
+///   - protocGenSwiftPath: The path to the `protoc-gen-swift` `protoc` plugin.
+///   - outputDirectory: The directory in which generated source files are created.
+/// - Returns: The constructed arguments to be passed to `protoc` when invoking the `protoc-gen-swift` `protoc` plugin.
+func constructProtocGenSwiftArguments(
+  config: GenerationConfig,
+  fileNaming: GenerationConfig.FileNaming?,
+  inputFiles: [URL],
+  protoDirectoryPaths: [String],
+  protocGenSwiftPath: URL,
+  outputDirectory: URL
+) -> [String] {
+  var protocArgs = [
+    "--plugin=protoc-gen-swift=\(protocGenSwiftPath.absoluteStringNoScheme)",
+    "--swift_out=\(outputDirectory.absoluteStringNoScheme)",
+  ]
+
+  for path in protoDirectoryPaths {
+    protocArgs.append("--proto_path=\(path)")
+  }
+
+  protocArgs.append("--swift_opt=Visibility=\(config.visibility.rawValue)")
+  protocArgs.append("--swift_opt=FileNaming=\(config.fileNaming.rawValue)")
+  protocArgs.append("--swift_opt=UseAccessLevelOnImports=\(config.useAccessLevelOnImports)")
+  protocArgs.append(contentsOf: inputFiles.map { $0.absoluteStringNoScheme })
+
+  return protocArgs
+}
+
+/// Construct the arguments to be passed to `protoc` when invoking the `protoc-gen-grpc-swift` `protoc` plugin.
+/// - Parameters:
+///   - config: The config for this operation.
+///   - fileNaming: The file naming scheme to be used.
+///   - inputFiles: The input `.proto` files.
+///   - protoDirectoryPaths: The directories in which `protoc` will look for imports.
+///   - protocGenGRPCSwiftPath: The path to the `protoc-gen-grpc-swift` `protoc` plugin.
+///   - outputDirectory: The directory in which generated source files are created.
+/// - Returns: The constructed arguments to be passed to `protoc` when invoking the `protoc-gen-grpc-swift` `protoc` plugin.
+func constructProtocGenGRPCSwiftArguments(
+  config: GenerationConfig,
+  fileNaming: GenerationConfig.FileNaming?,
+  inputFiles: [URL],
+  protoDirectoryPaths: [String],
+  protocGenGRPCSwiftPath: URL,
+  outputDirectory: URL
+) -> [String] {
+  var protocArgs = [
+    "--plugin=protoc-gen-grpc-swift=\(protocGenGRPCSwiftPath.absoluteStringNoScheme)",
+    "--grpc-swift_out=\(outputDirectory.absoluteStringNoScheme)",
+  ]
+
+  for path in protoDirectoryPaths {
+    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=FileNaming=\(config.fileNaming.rawValue)")
+  protocArgs.append("--grpc-swift_opt=UseAccessLevelOnImports=\(config.useAccessLevelOnImports)")
+  protocArgs.append(contentsOf: inputFiles.map { $0.absoluteStringNoScheme })
+
+  return protocArgs
+}
+
+extension URL {
+  /// Returns `URL.absoluteString` with the `file://` scheme prefix removed
+  var absoluteStringNoScheme: String {
+    var absoluteString = self.absoluteString
+    absoluteString.trimPrefix("file://")
+    return absoluteString
+  }
+}