From a5010cb5967be02b76a277e47102896a1388c978 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Mon, 6 Jan 2025 10:18:01 +0000 Subject: [PATCH 1/9] 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) + } +} From af7cfe065748062fe4df8083f6f11232f8e69a99 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Tue, 14 Jan 2025 14:48:42 +0000 Subject: [PATCH 2/9] review comments --- Package.swift | 8 +- .../ConfigurationFile.swift | 79 ---------- .../BuildPluginConfig.swift | 127 ++++++++++++++++ .../Plugin.swift | 142 +++++++++--------- .../PluginsShared | 0 ...iguration.swift => GenerationConfig.swift} | 40 ++--- Plugins/PluginsShared/PluginError.swift | 16 +- Plugins/PluginsShared/PluginUtils.swift | 132 +++------------- 8 files changed, 253 insertions(+), 291 deletions(-) delete mode 100644 Plugins/GRPCGeneratorPlugin/ConfigurationFile.swift create mode 100644 Plugins/GRPCProtobufGenerator/BuildPluginConfig.swift rename Plugins/{GRPCGeneratorPlugin => GRPCProtobufGenerator}/Plugin.swift (65%) rename Plugins/{GRPCGeneratorPlugin => GRPCProtobufGenerator}/PluginsShared (100%) rename Plugins/PluginsShared/{CommonConfiguration.swift => GenerationConfig.swift} (70%) diff --git a/Package.swift b/Package.swift index b9ae8a5..b87e8a4 100644 --- a/Package.swift +++ b/Package.swift @@ -27,8 +27,8 @@ let products: [Product] = [ targets: ["protoc-gen-grpc-swift"] ), .plugin( - name: "GRPCGeneratorPlugin", - targets: ["GRPCGeneratorPlugin"] + name: "GRPCProtobufGenerator", + targets: ["GRPCProtobufGenerator"] ), ] @@ -108,10 +108,10 @@ let targets: [Target] = [ // Code generator build plugin .plugin( - name: "GRPCGeneratorPlugin", + name: "GRPCProtobufGenerator", capability: .buildTool(), dependencies: [ - "protoc-gen-grpc-swift", + .target(name: "protoc-gen-grpc-swift"), .product(name: "protoc-gen-swift", package: "swift-protobuf"), ] ), diff --git a/Plugins/GRPCGeneratorPlugin/ConfigurationFile.swift b/Plugins/GRPCGeneratorPlugin/ConfigurationFile.swift deleted file mode 100644 index 97808cc..0000000 --- a/Plugins/GRPCGeneratorPlugin/ConfigurationFile.swift +++ /dev/null @@ -1,79 +0,0 @@ -/* - * 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/GRPCProtobufGenerator/BuildPluginConfig.swift b/Plugins/GRPCProtobufGenerator/BuildPluginConfig.swift new file mode 100644 index 0000000..32bab7e --- /dev/null +++ b/Plugins/GRPCProtobufGenerator/BuildPluginConfig.swift @@ -0,0 +1,127 @@ +/* + * 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 configuration of the build plugin. +struct BuildPluginConfig: Codable { + /// The visibility of the generated files. + /// + /// Defaults to `Internal`. + var visibility: GenerationConfig.Visibility + /// Whether server code is generated. + /// + /// Defaults to `true`. + var server: Bool + /// Whether client code is generated. + /// + /// Defaults to `true`. + var client: Bool + /// Whether message code is generated. + /// + /// Defaults to `true`. + var message: Bool + /// Whether imports should have explicit access levels. + /// + /// Defaults to `false`. + var useAccessLevelOnImports: Bool + + /// 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` binary. + /// + /// If this is not set, Swift Package Manager will try to find the tool itself. + var protocPath: String? + + // Codable conformance with defaults + enum CodingKeys: String, CodingKey { + case visibility + case server + case client + case message + case useAccessLevelOnImports + case importPaths + case protocPath + } + + let defaultVisibility: GenerationConfig.Visibility = .internal + let defaultServer = true + let defaultClient = true + let defaultMessage = true + let defaultUseAccessLevelOnImports = false + let defaultImportPaths: [String] = [] + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.visibility = + try container.decodeIfPresent(GenerationConfig.Visibility.self, forKey: .visibility) + ?? defaultVisibility + self.server = try container.decodeIfPresent(Bool.self, forKey: .server) ?? defaultServer + self.client = try container.decodeIfPresent(Bool.self, forKey: .client) ?? defaultClient + self.message = try container.decodeIfPresent(Bool.self, forKey: .message) ?? defaultMessage + self.useAccessLevelOnImports = + try container.decodeIfPresent(Bool.self, forKey: .useAccessLevelOnImports) + ?? defaultUseAccessLevelOnImports + self.importPaths = + try container.decodeIfPresent([String].self, forKey: .importPaths) ?? defaultImportPaths + self.protocPath = try container.decodeIfPresent(String.self, forKey: .protocPath) + } +} + +extension GenerationConfig { + init(configurationFile: BuildPluginConfig, configurationFilePath: URL, outputPath: URL) { + self.visibility = configurationFile.visibility + self.server = configurationFile.server + self.client = configurationFile.client + self.message = configurationFile.message + // hard-code full-path to avoid collisions since this goes into a temporary directory anyway + self.fileNaming = .fullPath + self.useAccessLevelOnImports = configurationFile.useAccessLevelOnImports + self.importPaths = [] + + // Generate absolute paths for the imports relative to the config file in which they are specified + self.importPaths = configurationFile.importPaths.map { relativePath in + configurationFilePath.deletingLastPathComponent().relativePath + "/" + relativePath + } + self.protocPath = configurationFile.protocPath + self.outputPath = outputPath.relativePath + } +} + +extension GenerationConfig.Visibility: 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/GRPCGeneratorPlugin/Plugin.swift b/Plugins/GRPCProtobufGenerator/Plugin.swift similarity index 65% rename from Plugins/GRPCGeneratorPlugin/Plugin.swift rename to Plugins/GRPCProtobufGenerator/Plugin.swift index 2eceb5f..4916dad 100644 --- a/Plugins/GRPCGeneratorPlugin/Plugin.swift +++ b/Plugins/GRPCProtobufGenerator/Plugin.swift @@ -18,8 +18,8 @@ import Foundation import PackagePlugin @main -struct GRPCGeneratorPlugin { - /// Code common to both invocation types: package manifest Xcode project +struct GRPCProtobufGenerator { + /// Build plugin code common to both invocation types: package manifest Xcode project func createBuildCommands( pluginWorkDirectory: URL, tool: (String) throws -> PluginContext.Tool, @@ -34,13 +34,9 @@ struct GRPCGeneratorPlugin { var commands: [Command] = [] for inputFile in inputFiles { - guard let configFile = findApplicableConfigFor(file: inputFile, from: configs.keys.map { $0 }) - else { + guard let config = configs.findApplicableConfig(for: inputFile) 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() @@ -49,7 +45,6 @@ struct GRPCGeneratorPlugin { if config.client != false || config.server != false { let grpcCommand = try protocGenGRPCSwiftCommand( inputFile: inputFile, - configFile: configFile, config: config, protoDirectoryPath: protoDirectoryPath, protocPath: protocPath, @@ -62,7 +57,6 @@ struct GRPCGeneratorPlugin { if config.message != false { let protoCommand = try protocGenSwiftCommand( inputFile: inputFile, - configFile: configFile, config: config, protoDirectoryPath: protoDirectoryPath, protocPath: protocPath, @@ -82,46 +76,46 @@ struct GRPCGeneratorPlugin { func readConfigurationFiles( _ configurationFiles: [URL], pluginWorkDirectory: URL -) throws -> [URL: CommonConfiguration] { - var configs: [URL: CommonConfiguration] = [:] +) throws -> [URL: GenerationConfig] { + var configs: [URL: GenerationConfig] = [:] for configFile in configurationFiles { let data = try Data(contentsOf: configFile) - let configuration = try JSONDecoder().decode(ConfigurationFile.self, from: data) + let configuration = try JSONDecoder().decode(BuildPluginConfig.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 + configs[configFile] = GenerationConfig( + configurationFile: configuration, + configurationFilePath: configFile, + outputPath: pluginWorkDirectory + ) } 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[.. GenerationConfig? { + let filePathComponents = file.pathComponents + for endComponent in (0 ..< filePathComponents.count).reversed() { + for (configFilePath, config) in self { + if filePathComponents[.. URL? { /// - Returns: The command to invoke `protoc` with the `proto-gen-grpc-swift` plugin. func protocGenGRPCSwiftCommand( inputFile: URL, - configFile: URL, - config: CommonConfiguration, + config: GenerationConfig, protoDirectoryPath: URL, protocPath: URL, protocGenGRPCSwiftPath: 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 outputPathURL = URL(fileURLWithPath: config.outputPath) let outputFilePath = deriveOutputFilePath( for: inputFile, - using: fileNaming, protoDirectoryPath: protoDirectoryPath, outputDirectory: outputPathURL, outputExtension: "grpc.swift" @@ -156,7 +139,7 @@ func protocGenGRPCSwiftCommand( let arguments = constructProtocGenGRPCSwiftArguments( config: config, - using: fileNaming, + using: config.fileNaming, inputFiles: [inputFile], protoDirectoryPaths: [protoDirectoryPath], protocGenGRPCSwiftPath: protocGenGRPCSwiftPath, @@ -175,7 +158,6 @@ func protocGenGRPCSwiftCommand( /// 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` @@ -183,26 +165,15 @@ func protocGenGRPCSwiftCommand( /// - Returns: The command to invoke `protoc` with the `proto-gen-swift` plugin. func protocGenSwiftCommand( inputFile: URL, - configFile: URL, - config: CommonConfiguration, + config: GenerationConfig, 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 outputPathURL = URL(fileURLWithPath: config.outputPath) let outputFilePath = deriveOutputFilePath( for: inputFile, - using: fileNaming, protoDirectoryPath: protoDirectoryPath, outputDirectory: outputPathURL, outputExtension: "pb.swift" @@ -210,7 +181,7 @@ func protocGenSwiftCommand( let arguments = constructProtocGenSwiftArguments( config: config, - using: fileNaming, + using: config.fileNaming, inputFiles: [inputFile], protoDirectoryPaths: [protoDirectoryPath], protocGenSwiftPath: protocGenSwiftPath, @@ -218,7 +189,7 @@ func protocGenSwiftCommand( ) return Command.buildCommand( - displayName: "Generating protobuf Swift files for \(inputFile.relativePath)", + displayName: "Generating Swift Protobuf files for \(inputFile.relativePath)", executable: protocPath, arguments: arguments, inputFiles: [inputFile, protocGenSwiftPath], @@ -226,14 +197,51 @@ func protocGenSwiftCommand( ) } +/// 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. +/// - 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, + 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 + } + } + + let outputFileComponents = relativePathComponents + [lastPathComponent] + var outputFilePath = outputDirectory + for outputFileComponent in outputFileComponents { + outputFilePath.append(component: outputFileComponent) + } + return outputFilePath +} + // Entry-point when using Package manifest -extension GRPCGeneratorPlugin: BuildToolPlugin, LocalizedError { +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: "grpc-swift-config.json").map { $0.url } + let configFiles = swiftTarget.sourceFiles(withSuffix: configFileName).map { $0.url } let inputFiles = swiftTarget.sourceFiles(withSuffix: ".proto").map { $0.url } return try createBuildCommands( pluginWorkDirectory: context.pluginWorkDirectoryURL, @@ -249,11 +257,11 @@ extension GRPCGeneratorPlugin: BuildToolPlugin, LocalizedError { import XcodeProjectPlugin // Entry-point when using Xcode projects -extension GRPCGeneratorPlugin: XcodeBuildToolPlugin { +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 == "grpc-swift-config.json" + $0.url.lastPathComponent == configFileName }.map { $0.url } let inputFiles = target.inputFiles.filter { $0.url.lastPathComponent.hasSuffix(".proto") }.map { $0.url diff --git a/Plugins/GRPCGeneratorPlugin/PluginsShared b/Plugins/GRPCProtobufGenerator/PluginsShared similarity index 100% rename from Plugins/GRPCGeneratorPlugin/PluginsShared rename to Plugins/GRPCProtobufGenerator/PluginsShared diff --git a/Plugins/PluginsShared/CommonConfiguration.swift b/Plugins/PluginsShared/GenerationConfig.swift similarity index 70% rename from Plugins/PluginsShared/CommonConfiguration.swift rename to Plugins/PluginsShared/GenerationConfig.swift index c705c2e..56993fa 100644 --- a/Plugins/PluginsShared/CommonConfiguration.swift +++ b/Plugins/PluginsShared/GenerationConfig.swift @@ -14,10 +14,10 @@ * limitations under the License. */ -/// The configuration common to the build and command plugins. -struct CommonConfiguration: Codable { +/// The configuration used when generating code whether called from the build or command plugin. +struct GenerationConfig { /// The visibility of the generated files. - enum Visibility: String, Codable { + enum Visibility: String { /// The generated files should have `internal` access level. case `internal` = "Internal" /// The generated files should have `public` access level. @@ -25,7 +25,6 @@ struct CommonConfiguration: Codable { /// 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: @@ -42,36 +41,29 @@ struct CommonConfiguration: Codable { } /// The visibility of the generated files. - var visibility: Visibility? + var visibility: Visibility /// Whether server code is generated. - var server: Bool? + var server: Bool /// Whether client code is generated. - var client: Bool? + var client: Bool /// Whether message code is generated. - var message: Bool? - // /// Whether reflection data is generated. - // var reflectionData: Bool? + var message: 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? + var fileNaming: FileNaming /// Whether imports should have explicit access levels. - var useAccessLevelOnImports: Bool? + 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]? + /// 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. + /// 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. - /// - /// If this is not set, the plugin will use a default path (see plugin for details). - var outputPath: String? + var outputPath: String } diff --git a/Plugins/PluginsShared/PluginError.swift b/Plugins/PluginsShared/PluginError.swift index 99ffacb..714f39c 100644 --- a/Plugins/PluginsShared/PluginError.swift +++ b/Plugins/PluginsShared/PluginError.swift @@ -16,13 +16,19 @@ import Foundation -enum PluginError: Error, LocalizedError { +enum PluginError: Error { // Build plugin case incompatibleTarget(String) case noConfigurationFilesFound - case expectedConfigurationNotFound(String) - case missingFileNamingStrategy - case missingOutputPath +} - case helpRequested +extension PluginError: CustomStringConvertible { + var description: String { + switch self { + case .incompatibleTarget(let string): + "Build plugin applied to incompatible target." + case .noConfigurationFilesFound: + "No configuration files found." + } + } } diff --git a/Plugins/PluginsShared/PluginUtils.swift b/Plugins/PluginsShared/PluginUtils.swift index 9e9afc5..5e8fc17 100644 --- a/Plugins/PluginsShared/PluginUtils.swift +++ b/Plugins/PluginsShared/PluginUtils.swift @@ -23,7 +23,7 @@ import PackagePlugin /// - 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, + using config: GenerationConfig, tool findTool: (String) throws -> PackagePlugin.PluginContext.Tool ) throws -> URL { if let configuredProtocPath = config.protocPath { @@ -32,7 +32,7 @@ func deriveProtocPath( // 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 + // 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 } } @@ -47,42 +47,27 @@ func deriveProtocPath( /// - 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?, + config: GenerationConfig, + using fileNaming: GenerationConfig.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)") + for path in config.importPaths { + protocArgs.append("--proto_path") + protocArgs.append("\(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: protoDirectoryPaths.map { "--proto_path=\($0.relativePath)" }) - protocArgs.append(contentsOf: inputFiles.map { $0.relativePath }) return protocArgs @@ -98,107 +83,30 @@ func constructProtocGenSwiftArguments( /// - 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?, + config: GenerationConfig, + using fileNaming: GenerationConfig.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)") + for path in config.importPaths { + protocArgs.append("--proto_path") + protocArgs.append("\(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: 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) - } -} From 256b3e98e61ea02f0fb92eeabdd511b6d14ed8cb Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Tue, 14 Jan 2025 16:28:03 +0000 Subject: [PATCH 3/9] add config file as a registered input file --- Plugins/GRPCProtobufGenerator/Plugin.swift | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/Plugins/GRPCProtobufGenerator/Plugin.swift b/Plugins/GRPCProtobufGenerator/Plugin.swift index 4916dad..a200fef 100644 --- a/Plugins/GRPCProtobufGenerator/Plugin.swift +++ b/Plugins/GRPCProtobufGenerator/Plugin.swift @@ -34,7 +34,7 @@ struct GRPCProtobufGenerator { var commands: [Command] = [] for inputFile in inputFiles { - guard let config = configs.findApplicableConfig(for: inputFile) else { + guard let (configFilePath, config) = configs.findApplicableConfig(for: inputFile) else { throw PluginError.noConfigurationFilesFound } @@ -60,7 +60,8 @@ struct GRPCProtobufGenerator { config: config, protoDirectoryPath: protoDirectoryPath, protocPath: protocPath, - protocGenSwiftPath: protocGenSwiftPath + protocGenSwiftPath: protocGenSwiftPath, + configFilePath: configFilePath ) commands.append(protoCommand) } @@ -96,15 +97,15 @@ extension [URL: GenerationConfig] { /// Finds the most precisely relevant config file for a given proto file URL. /// - 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, otherwise `nil`. - func findApplicableConfig(for file: URL) -> GenerationConfig? { + /// - 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) @@ -192,7 +195,11 @@ func protocGenSwiftCommand( displayName: "Generating Swift Protobuf files for \(inputFile.relativePath)", executable: protocPath, arguments: arguments, - inputFiles: [inputFile, protocGenSwiftPath], + inputFiles: [ + inputFile, + protocGenSwiftPath, + configFilePath + ], outputFiles: [outputFilePath] ) } From 1749e565cab62e7a8abfce78e785d080722d1c74 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Tue, 14 Jan 2025 17:02:54 +0000 Subject: [PATCH 4/9] Update Plugins/GRPCProtobufGenerator/Plugin.swift Co-authored-by: George Barnett --- Plugins/GRPCProtobufGenerator/Plugin.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/GRPCProtobufGenerator/Plugin.swift b/Plugins/GRPCProtobufGenerator/Plugin.swift index a200fef..c3f282c 100644 --- a/Plugins/GRPCProtobufGenerator/Plugin.swift +++ b/Plugins/GRPCProtobufGenerator/Plugin.swift @@ -42,7 +42,7 @@ struct GRPCProtobufGenerator { let protoDirectoryPath = inputFile.deletingLastPathComponent() // unless *explicitly* opted-out - if config.client != false || config.server != false { + if config.client || config.server { let grpcCommand = try protocGenGRPCSwiftCommand( inputFile: inputFile, config: config, From 87197c63886015b8c2687c9dbb22bd38511adb16 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Tue, 14 Jan 2025 17:06:29 +0000 Subject: [PATCH 5/9] Update Plugins/GRPCProtobufGenerator/Plugin.swift Co-authored-by: George Barnett --- Plugins/GRPCProtobufGenerator/Plugin.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Plugins/GRPCProtobufGenerator/Plugin.swift b/Plugins/GRPCProtobufGenerator/Plugin.swift index c3f282c..0ed74b7 100644 --- a/Plugins/GRPCProtobufGenerator/Plugin.swift +++ b/Plugins/GRPCProtobufGenerator/Plugin.swift @@ -75,18 +75,18 @@ struct GRPCProtobufGenerator { /// - Parameter configurationFiles: URLs from which to load configuration /// - Returns: A map of source URLs to loaded configuration func readConfigurationFiles( - _ configurationFiles: [URL], + _ configFilePaths: [URL], pluginWorkDirectory: URL ) throws -> [URL: GenerationConfig] { var configs: [URL: GenerationConfig] = [:] - for configFile in configurationFiles { + for configFilePath in configFilePaths { let data = try Data(contentsOf: configFile) - let configuration = try JSONDecoder().decode(BuildPluginConfig.self, from: data) + let config = try JSONDecoder().decode(BuildPluginConfig.self, from: data) // the output directory mandated by the plugin system - configs[configFile] = GenerationConfig( - configurationFile: configuration, - configurationFilePath: configFile, + configs[configFilePath] = GenerationConfig( + configurationFile: config, + configurationFilePath: configFilePath, outputPath: pluginWorkDirectory ) } From 585e85601e12ea0b0a91e1d08af6a9c8dd43a140 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Fri, 17 Jan 2025 15:52:51 +0000 Subject: [PATCH 6/9] review comments, rethink config & imports --- .../BuildPluginConfig.swift | 251 ++++++++++++------ Plugins/GRPCProtobufGenerator/Plugin.swift | 174 ++++++------ Plugins/PluginsShared/GenerationConfig.swift | 24 +- Plugins/PluginsShared/PluginError.swift | 6 +- Plugins/PluginsShared/PluginUtils.swift | 63 +++-- 5 files changed, 317 insertions(+), 201 deletions(-) diff --git a/Plugins/GRPCProtobufGenerator/BuildPluginConfig.swift b/Plugins/GRPCProtobufGenerator/BuildPluginConfig.swift index 32bab7e..9e2a470 100644 --- a/Plugins/GRPCProtobufGenerator/BuildPluginConfig.swift +++ b/Plugins/GRPCProtobufGenerator/BuildPluginConfig.swift @@ -18,110 +18,193 @@ import Foundation let configFileName = "grpc-swift-proto-generator-config.json" -/// The configuration of the build plugin. +/// The config of the build plugin. struct BuildPluginConfig: Codable { - /// The visibility of the generated files. - /// - /// Defaults to `Internal`. - var visibility: GenerationConfig.Visibility - /// Whether server code is generated. - /// - /// Defaults to `true`. - var server: Bool - /// Whether client code is generated. - /// - /// Defaults to `true`. - var client: Bool - /// Whether message code is generated. - /// - /// Defaults to `true`. - var message: Bool - /// Whether imports should have explicit access levels. - /// - /// Defaults to `false`. - var useAccessLevelOnImports: Bool - - /// 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` binary. - /// - /// If this is not set, Swift Package Manager will try to find the tool itself. - var protocPath: String? + /// 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 visibility - case server - case client - case message - case useAccessLevelOnImports - case importPaths - case protocPath + 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 } +} - let defaultVisibility: GenerationConfig.Visibility = .internal - let defaultServer = true - let defaultClient = true - let defaultMessage = true - let defaultUseAccessLevelOnImports = false - let defaultImportPaths: [String] = [] +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.visibility = - try container.decodeIfPresent(GenerationConfig.Visibility.self, forKey: .visibility) - ?? defaultVisibility - self.server = try container.decodeIfPresent(Bool.self, forKey: .server) ?? defaultServer - self.client = try container.decodeIfPresent(Bool.self, forKey: .client) ?? defaultClient - self.message = try container.decodeIfPresent(Bool.self, forKey: .message) ?? defaultMessage + self.accessLevel = + try container.decodeIfPresent(GenerationConfig.AccessLevel.self, forKey: .accessLevel) + ?? Self.defaults.accessLevel self.useAccessLevelOnImports = try container.decodeIfPresent(Bool.self, forKey: .useAccessLevelOnImports) - ?? defaultUseAccessLevelOnImports + ?? 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) ?? defaultImportPaths - self.protocPath = try container.decodeIfPresent(String.self, forKey: .protocPath) + try container.decodeIfPresent([String].self, forKey: .importPaths) + ?? Self.defaults.importPaths + self.executablePath = try container.decodeIfPresent(String.self, forKey: .executablePath) } } extension GenerationConfig { - init(configurationFile: BuildPluginConfig, configurationFilePath: URL, outputPath: URL) { - self.visibility = configurationFile.visibility - self.server = configurationFile.server - self.client = configurationFile.client - self.message = configurationFile.message + 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.useAccessLevelOnImports = configurationFile.useAccessLevelOnImports - self.importPaths = [] - + 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 = configurationFile.importPaths.map { relativePath in - configurationFilePath.deletingLastPathComponent().relativePath + "/" + relativePath - } - self.protocPath = configurationFile.protocPath - self.outputPath = outputPath.relativePath - } -} - -extension GenerationConfig.Visibility: Codable { - init?(rawValue: String) { - switch rawValue.lowercased() { - case "internal": - self = .internal - case "public": - self = .public - case "package": - self = .package - default: - return nil + 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 index 0ed74b7..78b5ecf 100644 --- a/Plugins/GRPCProtobufGenerator/Plugin.swift +++ b/Plugins/GRPCProtobufGenerator/Plugin.swift @@ -17,6 +17,49 @@ 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 @@ -27,7 +70,7 @@ struct GRPCProtobufGenerator { configFiles: [URL], targetName: String ) throws -> [Command] { - let configs = try readConfigurationFiles(configFiles, pluginWorkDirectory: pluginWorkDirectory) + let configs = try readConfigFiles(configFiles, pluginWorkDirectory: pluginWorkDirectory) let protocGenGRPCSwiftPath = try tool("protoc-gen-grpc-swift").url let protocGenSwiftPath = try tool("protoc-gen-swift").url @@ -35,18 +78,24 @@ struct GRPCProtobufGenerator { var commands: [Command] = [] for inputFile in inputFiles { guard let (configFilePath, config) = configs.findApplicableConfig(for: inputFile) else { - throw PluginError.noConfigurationFilesFound + throw PluginError.noConfigFilesFound } let protocPath = try deriveProtocPath(using: config, tool: tool) - let protoDirectoryPath = inputFile.deletingLastPathComponent() + let protoDirectoryPaths: [String] + if !config.importPaths.isEmpty { + protoDirectoryPaths = config.importPaths + } else { + protoDirectoryPaths = [configFilePath.deletingLastPathComponent().absoluteStringNoScheme] + } // unless *explicitly* opted-out if config.client || config.server { let grpcCommand = try protocGenGRPCSwiftCommand( inputFile: inputFile, config: config, - protoDirectoryPath: protoDirectoryPath, + baseDirectoryPath: configFilePath.deletingLastPathComponent(), + protoDirectoryPaths: protoDirectoryPaths, protocPath: protocPath, protocGenGRPCSwiftPath: protocGenGRPCSwiftPath ) @@ -54,11 +103,12 @@ struct GRPCProtobufGenerator { } // unless *explicitly* opted-out - if config.message != false { + if config.message { let protoCommand = try protocGenSwiftCommand( inputFile: inputFile, config: config, - protoDirectoryPath: protoDirectoryPath, + baseDirectoryPath: configFilePath.deletingLastPathComponent(), + protoDirectoryPaths: protoDirectoryPaths, protocPath: protocPath, protocGenSwiftPath: protocGenSwiftPath, configFilePath: configFilePath @@ -71,22 +121,22 @@ struct GRPCProtobufGenerator { } } -/// 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( +/// 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: configFile) + 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( - configurationFile: config, - configurationFilePath: configFilePath, + buildPluginConfig: config, + configFilePath: configFilePath, outputPath: pluginWorkDirectory ) } @@ -114,18 +164,20 @@ extension [URL: GenerationConfig] { } } -/// Construct the command to invoke `protoc` with the `proto-gen-grpc-swift` plugin. +/// Construct the command to invoke `protoc` with the `protoc-gen-grpc-swift` plugin. /// - Parameters: /// - inputFile: The input `.proto` file. -/// - config: The configuration for this operation. -/// - protoDirectoryPath: The root path to the source `.proto` files used as the reference for relative path naming schemes. +/// - 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 `proto-gen-grpc-swift`. -/// - Returns: The command to invoke `protoc` with the `proto-gen-grpc-swift` plugin. +/// - protocGenGRPCSwiftPath: The path to `protoc-gen-grpc-swift`. +/// - Returns: The command to invoke `protoc` with the `protoc-gen-grpc-swift` plugin. func protocGenGRPCSwiftCommand( inputFile: URL, config: GenerationConfig, - protoDirectoryPath: URL, + baseDirectoryPath: URL, + protoDirectoryPaths: [String], protocPath: URL, protocGenGRPCSwiftPath: URL ) throws -> PackagePlugin.Command { @@ -133,22 +185,22 @@ func protocGenGRPCSwiftCommand( let outputFilePath = deriveOutputFilePath( for: inputFile, - protoDirectoryPath: protoDirectoryPath, + baseDirectoryPath: baseDirectoryPath, outputDirectory: outputPathURL, outputExtension: "grpc.swift" ) let arguments = constructProtocGenGRPCSwiftArguments( config: config, - using: config.fileNaming, + fileNaming: config.fileNaming, inputFiles: [inputFile], - protoDirectoryPaths: [protoDirectoryPath], + protoDirectoryPaths: protoDirectoryPaths, protocGenGRPCSwiftPath: protocGenGRPCSwiftPath, outputDirectory: outputPathURL ) return Command.buildCommand( - displayName: "Generating gRPC Swift files for \(inputFile.relativePath)", + displayName: "Generating gRPC Swift files for \(inputFile.absoluteStringNoScheme)", executable: protocPath, arguments: arguments, inputFiles: [inputFile, protocGenGRPCSwiftPath], @@ -156,19 +208,21 @@ func protocGenGRPCSwiftCommand( ) } -/// Construct the command to invoke `protoc` with the `proto-gen-swift` plugin. +/// Construct the command to invoke `protoc` with the `protoc-gen-swift` plugin. /// - Parameters: /// - inputFile: The input `.proto` file. -/// - config: The configuration for this operation. -/// - protoDirectoryPath: The root path to the source `.proto` files used as the reference for relative path naming schemes. +/// - 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 `proto-gen-grpc-swift`. +/// - 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 `proto-gen-swift` plugin. +/// - Returns: The command to invoke `protoc` with the `protoc-gen-swift` plugin. func protocGenSwiftCommand( inputFile: URL, config: GenerationConfig, - protoDirectoryPath: URL, + baseDirectoryPath: URL, + protoDirectoryPaths: [String], protocPath: URL, protocGenSwiftPath: URL, configFilePath: URL @@ -177,43 +231,44 @@ func protocGenSwiftCommand( let outputFilePath = deriveOutputFilePath( for: inputFile, - protoDirectoryPath: protoDirectoryPath, + baseDirectoryPath: baseDirectoryPath, outputDirectory: outputPathURL, outputExtension: "pb.swift" ) let arguments = constructProtocGenSwiftArguments( config: config, - using: config.fileNaming, + fileNaming: config.fileNaming, inputFiles: [inputFile], - protoDirectoryPaths: [protoDirectoryPath], + protoDirectoryPaths: protoDirectoryPaths, protocGenSwiftPath: protocGenSwiftPath, outputDirectory: outputPathURL ) return Command.buildCommand( - displayName: "Generating Swift Protobuf files for \(inputFile.relativePath)", + displayName: "Generating Swift Protobuf files for \(inputFile.absoluteStringNoScheme)", executable: protocPath, arguments: arguments, inputFiles: [ inputFile, protocGenSwiftPath, - configFilePath + configFilePath, ], outputFiles: [outputFilePath] ) } -/// Derive the expected output file path to match the behavior of the `proto-gen-swift` and `proto-gen-grpc-swift` `protoc` plugins. +/// 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. -/// - protoDirectoryPath: The root path to the source `.proto` files used as the reference for relative path naming schemes. +/// - 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, - protoDirectoryPath: URL, + baseDirectoryPath: URL, outputDirectory: URL, outputExtension: String ) -> URL { @@ -225,7 +280,7 @@ func deriveOutputFilePath( // find the inputFile path relative to the proto directory var relativePathComponents = inputFile.deletingLastPathComponent().pathComponents - for protoDirectoryPathComponent in protoDirectoryPath.pathComponents { + for protoDirectoryPathComponent in baseDirectoryPath.pathComponents { if relativePathComponents.first == protoDirectoryPathComponent { relativePathComponents.removeFirst() } else { @@ -240,46 +295,3 @@ func deriveOutputFilePath( } return outputFilePath } - -// 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 diff --git a/Plugins/PluginsShared/GenerationConfig.swift b/Plugins/PluginsShared/GenerationConfig.swift index 56993fa..9309f50 100644 --- a/Plugins/PluginsShared/GenerationConfig.swift +++ b/Plugins/PluginsShared/GenerationConfig.swift @@ -14,10 +14,10 @@ * limitations under the License. */ -/// The configuration used when generating code whether called from the build or command plugin. +/// The config used when generating code whether called from the build or command plugin. struct GenerationConfig { - /// The visibility of the generated files. - enum Visibility: String { + /// 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. @@ -25,6 +25,7 @@ struct GenerationConfig { /// 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: @@ -41,7 +42,7 @@ struct GenerationConfig { } /// The visibility of the generated files. - var visibility: Visibility + var visibility: AccessLevel /// Whether server code is generated. var server: Bool /// Whether client code is generated. @@ -67,3 +68,18 @@ struct GenerationConfig { /// 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 index 714f39c..5d88c12 100644 --- a/Plugins/PluginsShared/PluginError.swift +++ b/Plugins/PluginsShared/PluginError.swift @@ -19,7 +19,7 @@ import Foundation enum PluginError: Error { // Build plugin case incompatibleTarget(String) - case noConfigurationFilesFound + case noConfigFilesFound } extension PluginError: CustomStringConvertible { @@ -27,8 +27,8 @@ extension PluginError: CustomStringConvertible { switch self { case .incompatibleTarget(let string): "Build plugin applied to incompatible target." - case .noConfigurationFilesFound: - "No configuration files found." + 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 index 5e8fc17..0fb3621 100644 --- a/Plugins/PluginsShared/PluginUtils.swift +++ b/Plugins/PluginsShared/PluginUtils.swift @@ -19,7 +19,7 @@ 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`. +/// - 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( @@ -37,67 +37,64 @@ func deriveProtocPath( } } -/// Construct the arguments to be passed to `protoc` when invoking the `proto-gen-swift` `protoc` plugin. +/// Construct the arguments to be passed to `protoc` when invoking the `protoc-gen-swift` `protoc` plugin. /// - Parameters: -/// - config: The configuration for this operation. +/// - 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 search for imports. -/// - protocGenSwiftPath: The path to the `proto-gen-swift` `protoc` plugin. +/// - 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 `proto-gen-swift` `protoc` plugin. +/// - Returns: The constructed arguments to be passed to `protoc` when invoking the `protoc-gen-swift` `protoc` plugin. func constructProtocGenSwiftArguments( config: GenerationConfig, - using fileNaming: GenerationConfig.FileNaming?, + fileNaming: GenerationConfig.FileNaming?, inputFiles: [URL], - protoDirectoryPaths: [URL], + protoDirectoryPaths: [String], protocGenSwiftPath: URL, outputDirectory: URL ) -> [String] { var protocArgs = [ - "--plugin=protoc-gen-swift=\(protocGenSwiftPath.relativePath)", - "--swift_out=\(outputDirectory.relativePath)", + "--plugin=protoc-gen-swift=\(protocGenSwiftPath.absoluteStringNoScheme)", + "--swift_out=\(outputDirectory.absoluteStringNoScheme)", ] - for path in config.importPaths { - protocArgs.append("--proto_path") - protocArgs.append("\(path)") + 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: protoDirectoryPaths.map { "--proto_path=\($0.relativePath)" }) - protocArgs.append(contentsOf: inputFiles.map { $0.relativePath }) + protocArgs.append(contentsOf: inputFiles.map { $0.absoluteStringNoScheme }) return protocArgs } -/// Construct the arguments to be passed to `protoc` when invoking the `proto-gen-grpc-swift` `protoc` plugin. +/// Construct the arguments to be passed to `protoc` when invoking the `protoc-gen-grpc-swift` `protoc` plugin. /// - Parameters: -/// - config: The configuration for this operation. +/// - 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 search for imports. -/// - protocGenGRPCSwiftPath: The path to the `proto-gen-grpc-swift` `protoc` plugin. +/// - 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 `proto-gen-grpc-swift` `protoc` plugin. +/// - Returns: The constructed arguments to be passed to `protoc` when invoking the `protoc-gen-grpc-swift` `protoc` plugin. func constructProtocGenGRPCSwiftArguments( config: GenerationConfig, - using fileNaming: GenerationConfig.FileNaming?, + fileNaming: GenerationConfig.FileNaming?, inputFiles: [URL], - protoDirectoryPaths: [URL], + protoDirectoryPaths: [String], protocGenGRPCSwiftPath: URL, outputDirectory: URL ) -> [String] { var protocArgs = [ - "--plugin=protoc-gen-grpc-swift=\(protocGenGRPCSwiftPath.relativePath)", - "--grpc-swift_out=\(outputDirectory.relativePath)", + "--plugin=protoc-gen-grpc-swift=\(protocGenGRPCSwiftPath.absoluteStringNoScheme)", + "--grpc-swift_out=\(outputDirectory.absoluteStringNoScheme)", ] - for path in config.importPaths { - protocArgs.append("--proto_path") - protocArgs.append("\(path)") + for path in protoDirectoryPaths { + protocArgs.append("--proto_path=\(path)") } protocArgs.append("--grpc-swift_opt=Visibility=\(config.visibility.rawValue.capitalized)") @@ -105,8 +102,16 @@ func constructProtocGenGRPCSwiftArguments( 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: protoDirectoryPaths.map { "--proto_path=\($0.relativePath)" }) - protocArgs.append(contentsOf: inputFiles.map { $0.relativePath }) + 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 + } +} From b41a9eb40d3d5bedb5a85bd847feee8af88636ef Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Tue, 21 Jan 2025 07:53:58 +0000 Subject: [PATCH 7/9] comments --- Plugins/GRPCProtobufGenerator/Plugin.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Plugins/GRPCProtobufGenerator/Plugin.swift b/Plugins/GRPCProtobufGenerator/Plugin.swift index 78b5ecf..8ff7c72 100644 --- a/Plugins/GRPCProtobufGenerator/Plugin.swift +++ b/Plugins/GRPCProtobufGenerator/Plugin.swift @@ -144,7 +144,9 @@ func readConfigFiles( } extension [URL: GenerationConfig] { - /// Finds the most precisely relevant config file for a given proto file URL. + /// 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`. From 86f889372cf5d79d7a03746c50955953a8547b28 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Tue, 21 Jan 2025 12:06:04 +0000 Subject: [PATCH 8/9] Update Plugins/GRPCProtobufGenerator/Plugin.swift Co-authored-by: George Barnett --- Plugins/GRPCProtobufGenerator/Plugin.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Plugins/GRPCProtobufGenerator/Plugin.swift b/Plugins/GRPCProtobufGenerator/Plugin.swift index 8ff7c72..bfe13f2 100644 --- a/Plugins/GRPCProtobufGenerator/Plugin.swift +++ b/Plugins/GRPCProtobufGenerator/Plugin.swift @@ -83,10 +83,10 @@ struct GRPCProtobufGenerator { let protocPath = try deriveProtocPath(using: config, tool: tool) let protoDirectoryPaths: [String] - if !config.importPaths.isEmpty { - protoDirectoryPaths = config.importPaths - } else { + if config.importPaths.isEmpty { protoDirectoryPaths = [configFilePath.deletingLastPathComponent().absoluteStringNoScheme] + } else { + protoDirectoryPaths = config.importPaths } // unless *explicitly* opted-out From 15fbb1ad08d34ca305680c0d7bc354820fc92007 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Tue, 21 Jan 2025 12:06:54 +0000 Subject: [PATCH 9/9] review comments --- Plugins/GRPCProtobufGenerator/Plugin.swift | 13 ++++++++++--- Plugins/PluginsShared/PluginError.swift | 2 -- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Plugins/GRPCProtobufGenerator/Plugin.swift b/Plugins/GRPCProtobufGenerator/Plugin.swift index bfe13f2..3c872b3 100644 --- a/Plugins/GRPCProtobufGenerator/Plugin.swift +++ b/Plugins/GRPCProtobufGenerator/Plugin.swift @@ -97,7 +97,8 @@ struct GRPCProtobufGenerator { baseDirectoryPath: configFilePath.deletingLastPathComponent(), protoDirectoryPaths: protoDirectoryPaths, protocPath: protocPath, - protocGenGRPCSwiftPath: protocGenGRPCSwiftPath + protocGenGRPCSwiftPath: protocGenGRPCSwiftPath, + configFilePath: configFilePath ) commands.append(grpcCommand) } @@ -174,6 +175,7 @@ extension [URL: GenerationConfig] { /// - 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, @@ -181,7 +183,8 @@ func protocGenGRPCSwiftCommand( baseDirectoryPath: URL, protoDirectoryPaths: [String], protocPath: URL, - protocGenGRPCSwiftPath: URL + protocGenGRPCSwiftPath: URL, + configFilePath: URL ) throws -> PackagePlugin.Command { let outputPathURL = URL(fileURLWithPath: config.outputPath) @@ -205,7 +208,11 @@ func protocGenGRPCSwiftCommand( displayName: "Generating gRPC Swift files for \(inputFile.absoluteStringNoScheme)", executable: protocPath, arguments: arguments, - inputFiles: [inputFile, protocGenGRPCSwiftPath], + inputFiles: [ + inputFile, + protocGenGRPCSwiftPath, + configFilePath, + ], outputFiles: [outputFilePath] ) } diff --git a/Plugins/PluginsShared/PluginError.swift b/Plugins/PluginsShared/PluginError.swift index 5d88c12..08b3374 100644 --- a/Plugins/PluginsShared/PluginError.swift +++ b/Plugins/PluginsShared/PluginError.swift @@ -14,8 +14,6 @@ * limitations under the License. */ -import Foundation - enum PluginError: Error { // Build plugin case incompatibleTarget(String)