diff --git a/Package.swift b/Package.swift index 7259583..9be796e 100644 --- a/Package.swift +++ b/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( diff --git a/Plugins/GRPCProtobufGenerator/BuildPluginConfig.swift b/Plugins/GRPCProtobufGenerator/BuildPluginConfig.swift new file mode 100644 index 0000000..9e2a470 --- /dev/null +++ b/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 + } +} diff --git a/Plugins/GRPCProtobufGenerator/Plugin.swift b/Plugins/GRPCProtobufGenerator/Plugin.swift new file mode 100644 index 0000000..3c872b3 --- /dev/null +++ b/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[.. 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 +} diff --git a/Plugins/GRPCProtobufGenerator/PluginsShared b/Plugins/GRPCProtobufGenerator/PluginsShared new file mode 120000 index 0000000..de623a5 --- /dev/null +++ b/Plugins/GRPCProtobufGenerator/PluginsShared @@ -0,0 +1 @@ +../PluginsShared \ No newline at end of file diff --git a/Plugins/PluginsShared/GenerationConfig.swift b/Plugins/PluginsShared/GenerationConfig.swift new file mode 100644 index 0000000..9309f50 --- /dev/null +++ b/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 + } + } +} diff --git a/Plugins/PluginsShared/PluginError.swift b/Plugins/PluginsShared/PluginError.swift new file mode 100644 index 0000000..08b3374 --- /dev/null +++ b/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." + } + } +} diff --git a/Plugins/PluginsShared/PluginUtils.swift b/Plugins/PluginsShared/PluginUtils.swift new file mode 100644 index 0000000..0fb3621 --- /dev/null +++ b/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 + } +}