Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ let products: [Product] = [
name: "protoc-gen-grpc-swift",
targets: ["protoc-gen-grpc-swift"]
),
.plugin(
name: "GRPCGeneratorPlugin",
targets: ["GRPCGeneratorPlugin"]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having plugin in the name is weird. I think we also want to nod to it being the generator for protobuf, so can we call it GRPCProtobufGenerator?

),
]

let dependencies: [Package.Dependency] = [
Expand Down Expand Up @@ -101,6 +105,16 @@ let targets: [Target] = [
],
swiftSettings: defaultSwiftSettings
),

// Code generator build plugin
.plugin(
name: "GRPCGeneratorPlugin",
capability: .buildTool(),
dependencies: [
"protoc-gen-grpc-swift",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, for consistency:

Suggested change
"protoc-gen-grpc-swift",
.target(name: "protoc-gen-grpc-swift"),

.product(name: "protoc-gen-swift", package: "swift-protobuf"),
]
),
]

let package = Package(
Expand Down
79 changes: 79 additions & 0 deletions Plugins/GRPCGeneratorPlugin/ConfigurationFile.swift
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit for consistency: gRPC uses "config" instead of "configuration".

"File" is also out of place here; we should nod to its use for generation here instead. "GeneratorConfig"? "PluginConfig"? "Config"?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So "File" is in the name because it corresponds specifically to the format of the configuration file on disk which is why it is Codable, there is an upcoming structure which knows about configuration which is passed as flags and the common configuration representation is already in this PR. I'm not sure how that fits into your existing naming schemes.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It being on disk is incidental though. The name should indicate what it's config for not where the config comes from.

If plugins were (much) more flexible you could imagine getting the config from e.g. a config service. Using the name ConfigFile for that type would be weird!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original intent was to separate the on-disk format from the internal representation but I think combined with the abstraction we'll have for the more general config when combined with the command plugin that would lead to too much layering in this small application since none of it is public API.

/// 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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you document any defaults here as well?

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?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can get rid of this as it's commented out

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can if you'd prefer - I didn't know how imminent the use of reflection data would be

/// Path to module map .asciipb file.
var protoPathModuleMappings: String?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this actually work? (if so, neat!)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure - I just connected the pipes. I'll remove it for now and we can come back to it later.

/// 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]?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there any restrictions here? Presumably we can't "escape" out of the source directory of the target?

Since they can't be absolute they must be relative, but relative to where?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added documentation clarifying this.


/// The path to the `protoc` binary.
///
/// If this is not set, SPM will try to find the tool itself.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// 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?
}

extension CommonConfiguration {
init(configurationFile: ConfigurationFile) {
if let visibility = configurationFile.visibility {
self.visibility = .init(visibility)
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just have one Visibility type which is shared?

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
}
}
}
270 changes: 270 additions & 0 deletions Plugins/GRPCGeneratorPlugin/Plugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
/*
* Copyright 2024, gRPC Authors All rights reserved.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Copyright 2024, gRPC Authors All rights reserved.
* Copyright 2025, gRPC Authors All rights reserved.

*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* 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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be impossible, right?

I also find it a bit weird that findApplicableConfigFor returns the config file URL. Surely we only care about the config and that's what it should return?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've restructured this now to return the config object.

}

let protocPath = try deriveProtocPath(using: config, tool: tool)
let protoDirectoryPath = inputFile.deletingLastPathComponent()

// unless *explicitly* opted-out
if config.client != false || config.server != false {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure the optionality of all the config is useful to be honest. I think, where possible, we should make config values required but allow them to be unspecified in the JSON (i.e. use decodeIfPresent).

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[..<endComponent]
== configFile.pathComponents[..<(configFile.pathComponents.count - 1)]
{
return configFile
}
}
}

return nil
}

/// Construct the command to invoke `protoc` with the `proto-gen-grpc-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`
/// - protocGenGRPCSwiftPath: The path to `proto-gen-grpc-swift`.
/// - Returns: The command to invoke `protoc` with the `proto-gen-grpc-swift` plugin.
func protocGenGRPCSwiftCommand(
inputFile: URL,
configFile: URL,
config: CommonConfiguration,
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
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IDK if an error is appropriate here; the user literally can't do anything about it. I think we should precondition that the file naming is full path here. If it isn't then we've made a mistake.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the config restructuring this doesn't exist anymore.


guard let outputPath = config.outputPath else {
assertionFailure("Missing output path - should be hard-coded.")
throw PluginError.missingOutputPath
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here re: error, if this isn't set then we've screwed up and the user can't do anything about it.

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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here re: errors

}
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)",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
displayName: "Generating protobuf Swift files for \(inputFile.relativePath)",
displayName: "Generating Swift Protobuf 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 }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use a clearer name here (I know it's the name used for v1) -- this config is specific to protobuf code generation, so maybe grpc-swift-proto-generator-config.json?

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
1 change: 1 addition & 0 deletions Plugins/GRPCGeneratorPlugin/PluginsShared
Loading
Loading