From a5010cb5967be02b76a277e47102896a1388c978 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Mon, 6 Jan 2025 10:18:01 +0000 Subject: [PATCH] Code generation build plugin Motivation: To make code generation more convenient for adopters. Modifications: * New build plugin to generate gRPC services and protobuf messages Result: * Users will be able to make use of the build plugin. --- Package.swift | 14 + .../ConfigurationFile.swift | 79 +++++ Plugins/GRPCGeneratorPlugin/Plugin.swift | 270 ++++++++++++++++++ Plugins/GRPCGeneratorPlugin/PluginsShared | 1 + .../PluginsShared/CommonConfiguration.swift | 77 +++++ Plugins/PluginsShared/PluginError.swift | 28 ++ Plugins/PluginsShared/PluginUtils.swift | 204 +++++++++++++ 7 files changed, 673 insertions(+) create mode 100644 Plugins/GRPCGeneratorPlugin/ConfigurationFile.swift create mode 100644 Plugins/GRPCGeneratorPlugin/Plugin.swift create mode 120000 Plugins/GRPCGeneratorPlugin/PluginsShared create mode 100644 Plugins/PluginsShared/CommonConfiguration.swift create mode 100644 Plugins/PluginsShared/PluginError.swift create mode 100644 Plugins/PluginsShared/PluginUtils.swift diff --git a/Package.swift b/Package.swift index 5c2d938..b9ae8a5 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: "GRPCGeneratorPlugin", + targets: ["GRPCGeneratorPlugin"] + ), ] let dependencies: [Package.Dependency] = [ @@ -101,6 +105,16 @@ let targets: [Target] = [ ], swiftSettings: defaultSwiftSettings ), + + // Code generator build plugin + .plugin( + name: "GRPCGeneratorPlugin", + capability: .buildTool(), + dependencies: [ + "protoc-gen-grpc-swift", + .product(name: "protoc-gen-swift", package: "swift-protobuf"), + ] + ), ] let package = Package( diff --git a/Plugins/GRPCGeneratorPlugin/ConfigurationFile.swift b/Plugins/GRPCGeneratorPlugin/ConfigurationFile.swift new file mode 100644 index 0000000..97808cc --- /dev/null +++ b/Plugins/GRPCGeneratorPlugin/ConfigurationFile.swift @@ -0,0 +1,79 @@ +/* + * 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 configuration of the plugin. +struct ConfigurationFile: Codable { + /// The visibility of the generated files. + enum Visibility: String, Codable { + /// The generated files should have `internal` access level. + case `internal` + /// The generated files should have `public` access level. + case `public` + /// The generated files should have `package` access level. + case `package` + } + + /// The visibility of the generated files. + var visibility: Visibility? + /// Whether server code is generated. + var server: Bool? + /// Whether client code is generated. + var client: Bool? + /// Whether message code is generated. + var message: Bool? + // /// Whether reflection data is generated. + // var reflectionData: Bool? + /// Path to module map .asciipb file. + var protoPathModuleMappings: String? + /// 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, SPM will try to find the tool itself. + var protocPath: String? +} + +extension CommonConfiguration { + init(configurationFile: ConfigurationFile) { + if let visibility = configurationFile.visibility { + self.visibility = .init(visibility) + } + self.server = configurationFile.server + self.client = configurationFile.client + self.protoPathModuleMappings = configurationFile.protoPathModuleMappings + self.useAccessLevelOnImports = configurationFile.useAccessLevelOnImports + self.importPaths = configurationFile.importPaths + self.protocPath = configurationFile.protocPath + } +} + +extension CommonConfiguration.Visibility { + init(_ configurationFileVisibility: ConfigurationFile.Visibility) { + switch configurationFileVisibility { + case .internal: self = .internal + case .public: self = .public + case .package: self = .package + } + } +} diff --git a/Plugins/GRPCGeneratorPlugin/Plugin.swift b/Plugins/GRPCGeneratorPlugin/Plugin.swift new file mode 100644 index 0000000..2eceb5f --- /dev/null +++ b/Plugins/GRPCGeneratorPlugin/Plugin.swift @@ -0,0 +1,270 @@ +/* + * 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 + +@main +struct GRPCGeneratorPlugin { + /// 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 readConfigurationFiles(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 configFile = findApplicableConfigFor(file: inputFile, from: configs.keys.map { $0 }) + else { + throw PluginError.noConfigurationFilesFound + } + guard let config = configs[configFile] else { + throw PluginError.expectedConfigurationNotFound(configFile.relativePath) + } + + let protocPath = try deriveProtocPath(using: config, tool: tool) + let protoDirectoryPath = inputFile.deletingLastPathComponent() + + // unless *explicitly* opted-out + if config.client != false || config.server != false { + let grpcCommand = try protocGenGRPCSwiftCommand( + inputFile: inputFile, + configFile: configFile, + config: config, + protoDirectoryPath: protoDirectoryPath, + protocPath: protocPath, + protocGenGRPCSwiftPath: protocGenGRPCSwiftPath + ) + commands.append(grpcCommand) + } + + // unless *explicitly* opted-out + if config.message != false { + let protoCommand = try protocGenSwiftCommand( + inputFile: inputFile, + configFile: configFile, + config: config, + protoDirectoryPath: protoDirectoryPath, + protocPath: protocPath, + protocGenSwiftPath: protocGenSwiftPath + ) + commands.append(protoCommand) + } + } + + return commands + } +} + +/// Reads the configuration files at the supplied URLs into memory +/// - Parameter configurationFiles: URLs from which to load configuration +/// - Returns: A map of source URLs to loaded configuration +func readConfigurationFiles( + _ configurationFiles: [URL], + pluginWorkDirectory: URL +) throws -> [URL: CommonConfiguration] { + var configs: [URL: CommonConfiguration] = [:] + for configFile in configurationFiles { + let data = try Data(contentsOf: configFile) + let configuration = try JSONDecoder().decode(ConfigurationFile.self, from: data) + + var config = CommonConfiguration(configurationFile: configuration) + // hard-code full-path to avoid collisions since this goes into a temporary directory anyway + config.fileNaming = .fullPath + // the output directory mandated by the plugin system + config.outputPath = String(pluginWorkDirectory.relativePath) + configs[configFile] = config + } + return configs +} + +/// Finds the most precisely relevant config file for a given proto file URL. +/// - Parameters: +/// - file: The path to the proto file to be matched. +/// - configFiles: The paths to all known configuration files. +/// - Returns: The path to the most precisely relevant config file if one is found, otherwise `nil`. +func findApplicableConfigFor(file: URL, from configFiles: [URL]) -> URL? { + let filePathComponents = file.pathComponents + for endComponent in (0 ..< filePathComponents.count).reversed() { + for configFile in configFiles { + if filePathComponents[.. PackagePlugin.Command { + guard let fileNaming = config.fileNaming else { + assertionFailure("Missing file naming strategy - should be hard-coded.") + throw PluginError.missingFileNamingStrategy + } + + guard let outputPath = config.outputPath else { + assertionFailure("Missing output path - should be hard-coded.") + throw PluginError.missingOutputPath + } + let outputPathURL = URL(fileURLWithPath: outputPath) + + let outputFilePath = deriveOutputFilePath( + for: inputFile, + using: fileNaming, + protoDirectoryPath: protoDirectoryPath, + outputDirectory: outputPathURL, + outputExtension: "grpc.swift" + ) + + let arguments = constructProtocGenGRPCSwiftArguments( + config: config, + using: fileNaming, + inputFiles: [inputFile], + protoDirectoryPaths: [protoDirectoryPath], + protocGenGRPCSwiftPath: protocGenGRPCSwiftPath, + outputDirectory: outputPathURL + ) + + return Command.buildCommand( + displayName: "Generating gRPC Swift files for \(inputFile.relativePath)", + executable: protocPath, + arguments: arguments, + inputFiles: [inputFile, protocGenGRPCSwiftPath], + outputFiles: [outputFilePath] + ) +} + +/// Construct the command to invoke `protoc` with the `proto-gen-swift` plugin. +/// - Parameters: +/// - inputFile: The input `.proto` file. +/// - configFile: The path file containing configuration for this operation. +/// - config: The configuration for this operation. +/// - protoDirectoryPath: The root path to the source `.proto` files used as the reference for relative path naming schemes. +/// - protocPath: The path to `protoc` +/// - protocGenSwiftPath: The path to `proto-gen-grpc-swift`. +/// - Returns: The command to invoke `protoc` with the `proto-gen-swift` plugin. +func protocGenSwiftCommand( + inputFile: URL, + configFile: URL, + config: CommonConfiguration, + protoDirectoryPath: URL, + protocPath: URL, + protocGenSwiftPath: URL +) throws -> PackagePlugin.Command { + guard let fileNaming = config.fileNaming else { + assertionFailure("Missing file naming strategy - should be hard-coded.") + throw PluginError.missingFileNamingStrategy + } + + guard let outputPath = config.outputPath else { + assertionFailure("Missing output path - should be hard-coded.") + throw PluginError.missingOutputPath + } + let outputPathURL = URL(fileURLWithPath: outputPath) + + let outputFilePath = deriveOutputFilePath( + for: inputFile, + using: fileNaming, + protoDirectoryPath: protoDirectoryPath, + outputDirectory: outputPathURL, + outputExtension: "pb.swift" + ) + + let arguments = constructProtocGenSwiftArguments( + config: config, + using: fileNaming, + inputFiles: [inputFile], + protoDirectoryPaths: [protoDirectoryPath], + protocGenSwiftPath: protocGenSwiftPath, + outputDirectory: outputPathURL + ) + + return Command.buildCommand( + displayName: "Generating protobuf Swift files for \(inputFile.relativePath)", + executable: protocPath, + arguments: arguments, + inputFiles: [inputFile, protocGenSwiftPath], + outputFiles: [outputFilePath] + ) +} + +// Entry-point when using Package manifest +extension GRPCGeneratorPlugin: BuildToolPlugin, LocalizedError { + /// 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: "grpc-swift-config.json").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 GRPCGeneratorPlugin: 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 == "grpc-swift-config.json" + }.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 diff --git a/Plugins/GRPCGeneratorPlugin/PluginsShared b/Plugins/GRPCGeneratorPlugin/PluginsShared new file mode 120000 index 0000000..de623a5 --- /dev/null +++ b/Plugins/GRPCGeneratorPlugin/PluginsShared @@ -0,0 +1 @@ +../PluginsShared \ No newline at end of file diff --git a/Plugins/PluginsShared/CommonConfiguration.swift b/Plugins/PluginsShared/CommonConfiguration.swift new file mode 100644 index 0000000..c705c2e --- /dev/null +++ b/Plugins/PluginsShared/CommonConfiguration.swift @@ -0,0 +1,77 @@ +/* + * 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 configuration common to the build and command plugins. +struct CommonConfiguration: Codable { + /// The visibility of the generated files. + enum Visibility: String, Codable { + /// 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: Visibility? + /// Whether server code is generated. + var server: Bool? + /// Whether client code is generated. + var client: Bool? + /// Whether message code is generated. + var message: Bool? + // /// Whether reflection data is generated. + // var reflectionData: Bool? + /// The naming of output files with respect to the path of the source file. + var fileNaming: FileNaming? + /// Path to module map .asciipb file. + var protoPathModuleMappings: String? + /// 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, SPM will try to find the tool itself. + var protocPath: String? + + /// The path into which the generated source files are created. + /// + /// If this is not set, the plugin will use a default path (see plugin for details). + var outputPath: String? +} diff --git a/Plugins/PluginsShared/PluginError.swift b/Plugins/PluginsShared/PluginError.swift new file mode 100644 index 0000000..99ffacb --- /dev/null +++ b/Plugins/PluginsShared/PluginError.swift @@ -0,0 +1,28 @@ +/* + * 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 + +enum PluginError: Error, LocalizedError { + // Build plugin + case incompatibleTarget(String) + case noConfigurationFilesFound + case expectedConfigurationNotFound(String) + case missingFileNamingStrategy + case missingOutputPath + + case helpRequested +} diff --git a/Plugins/PluginsShared/PluginUtils.swift b/Plugins/PluginsShared/PluginUtils.swift new file mode 100644 index 0000000..9e9afc5 --- /dev/null +++ b/Plugins/PluginsShared/PluginUtils.swift @@ -0,0 +1,204 @@ +/* + * 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 configuration. 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: CommonConfiguration, + 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 SPM can find a binary for us + return try findTool("protoc").url + } +} + +/// Construct the arguments to be passed to `protoc` when invoking the `proto-gen-swift` `protoc` plugin. +/// - Parameters: +/// - config: The configuration for this operation. +/// - fileNaming: The file naming scheme to be used. +/// - inputFiles: The input `.proto` files. +/// - protoDirectoryPaths: The directories in which `protoc` will search for imports. +/// - protocGenSwiftPath: The path to the `proto-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 `proto-gen-swift` `protoc` plugin. +func constructProtocGenSwiftArguments( + config: CommonConfiguration, + using fileNaming: CommonConfiguration.FileNaming?, + inputFiles: [URL], + protoDirectoryPaths: [URL], + protocGenSwiftPath: URL, + outputDirectory: URL +) -> [String] { + // Construct the `protoc` arguments. + var protocArgs = [ + "--plugin=protoc-gen-swift=\(protocGenSwiftPath.relativePath)", + "--swift_out=\(outputDirectory.relativePath)", + ] + + // Add the visibility if it was set + if let visibility = config.visibility { + protocArgs.append("--swift_opt=Visibility=\(visibility.rawValue)") + } + + // Add the file naming + if let fileNaming = fileNaming { + protocArgs.append("--swift_opt=FileNaming=\(fileNaming.rawValue)") + } + + // TODO: Don't currently support implementation only imports + // // Add the implementation only imports flag if it was set + // if let implementationOnlyImports = config.implementationOnlyImports { + // protocArgs.append("--swift_opt=ImplementationOnlyImports=\(implementationOnlyImports)") + // } + + // Add the useAccessLevelOnImports only imports flag if it was set + if let useAccessLevelOnImports = config.useAccessLevelOnImports { + protocArgs.append("--swift_opt=UseAccessLevelOnImports=\(useAccessLevelOnImports)") + } + + protocArgs.append(contentsOf: protoDirectoryPaths.map { "--proto_path=\($0.relativePath)" }) + + protocArgs.append(contentsOf: inputFiles.map { $0.relativePath }) + + return protocArgs +} + +/// Construct the arguments to be passed to `protoc` when invoking the `proto-gen-grpc-swift` `protoc` plugin. +/// - Parameters: +/// - config: The configuration for this operation. +/// - fileNaming: The file naming scheme to be used. +/// - inputFiles: The input `.proto` files. +/// - protoDirectoryPaths: The directories in which `protoc` will search for imports. +/// - protocGenGRPCSwiftPath: The path to the `proto-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 `proto-gen-grpc-swift` `protoc` plugin. +func constructProtocGenGRPCSwiftArguments( + config: CommonConfiguration, + using fileNaming: CommonConfiguration.FileNaming?, + inputFiles: [URL], + protoDirectoryPaths: [URL], + protocGenGRPCSwiftPath: URL, + outputDirectory: URL +) -> [String] { + // Construct the `protoc` arguments. + var protocArgs = [ + "--plugin=protoc-gen-grpc-swift=\(protocGenGRPCSwiftPath.relativePath)", + "--grpc-swift_out=\(outputDirectory.relativePath)", + ] + + if let importPaths = config.importPaths { + for path in importPaths { + protocArgs.append("-I") + protocArgs.append("\(path)") + } + } + + if let visibility = config.visibility { + protocArgs.append("--grpc-swift_opt=Visibility=\(visibility.rawValue.capitalized)") + } + + if let generateServerCode = config.server { + protocArgs.append("--grpc-swift_opt=Server=\(generateServerCode)") + } + + if let generateClientCode = config.client { + protocArgs.append("--grpc-swift_opt=Client=\(generateClientCode)") + } + + // TODO: Don't currently support reflection data + // if let generateReflectionData = config.reflectionData { + // protocArgs.append("--grpc-swift_opt=ReflectionData=\(generateReflectionData)") + // } + + if let fileNaming = fileNaming { + protocArgs.append("--grpc-swift_opt=FileNaming=\(fileNaming.rawValue)") + } + + if let protoPathModuleMappings = config.protoPathModuleMappings { + protocArgs.append("--grpc-swift_opt=ProtoPathModuleMappings=\(protoPathModuleMappings)") + } + + if let useAccessLevelOnImports = config.useAccessLevelOnImports { + protocArgs.append("--grpc-swift_opt=UseAccessLevelOnImports=\(useAccessLevelOnImports)") + } + + protocArgs.append(contentsOf: protoDirectoryPaths.map { "--proto_path=\($0.relativePath)" }) + + protocArgs.append(contentsOf: inputFiles.map { $0.relativePath }) + + return protocArgs +} + +/// Derive the expected output file path to match the behavior of the `proto-gen-swift` and `proto-gen-grpc-swift` `protoc` plugins. +/// - Parameters: +/// - inputFile: The input `.proto` file. +/// - fileNaming: The file naming scheme. +/// - protoDirectoryPath: 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, + using fileNaming: CommonConfiguration.FileNaming, + protoDirectoryPath: 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 protoDirectoryPath.pathComponents { + if relativePathComponents.first == protoDirectoryPathComponent { + relativePathComponents.removeFirst() + } else { + break + } + } + + switch fileNaming { + case .dropPath: + let outputFileName = lastPathComponent + return outputDirectory.appendingPathComponent(outputFileName) + case .fullPath: + let outputFileComponents = relativePathComponents + [lastPathComponent] + var outputFilePath = outputDirectory + for outputFileComponent in outputFileComponents { + outputFilePath.append(component: outputFileComponent) + } + return outputFilePath + case .pathToUnderscores: + let outputFileComponents = relativePathComponents + [lastPathComponent] + let outputFileName = outputFileComponents.joined(separator: "_") + return outputDirectory.appendingPathComponent(outputFileName) + } +}