Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Code generation build plugin #28

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open

Code generation build plugin #28

wants to merge 6 commits into from

Conversation

rnro
Copy link
Collaborator

@rnro rnro commented Jan 8, 2025

Overview

New build plugin to generate gRPC services and protobuf messages

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

Configuration

  • The configuration file is JSON which may contain the following entries.
/// The configuration of the plugin.
struct ConfigurationFile: Codable {
  enum Visibility: String, Codable {            /// The visibility of the generated files.
    case `internal`                             /// The generated files should have `internal` access level.
    case `public`                               /// The generated files should have `public` access level.
    case `package`                              /// The generated files should have `package` access level.
  }

  var visibility: Visibility?                   /// The visibility of the generated files.
  var server: Bool?                             /// Whether server code is generated.
  var client: Bool?                             /// Whether client code is generated.
  var message: Bool?                            /// Whether message code is generated.
  var protoPathModuleMappings: String?          /// Path to module map .asciipb file.
  var useAccessLevelOnImports: Bool?            /// Whether imports should have explicit access levels.

  /// 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]?

  var protocPath: String?                       /// The path to the `protoc` binary. If this is not set, SPM will try to find the tool itself.
}
  • For a given protobuf definition file the tool will search for configuration files in the same and all parent directories and will use the file lowest in the hierarchy.
  • Most configuration if not specified will use the protoc-plugin's own defaults.

Adoption

Users must add the plugin to any target which wishes to make use of it

  targets: [
    .executableTarget(
      name: "plugin-adopter",
      dependencies: [
        // ...
      ],
      plugins: [
        .plugin(name: "GRPCGeneratorPlugin", package: "grpc-swift-protobuf")
      ]
    )
  ]

This PR is split out of #26

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.
@rnro rnro added the 🆕 semver/minor Adds new public API. label Jan 8, 2025
@rnro rnro requested review from glbrntt January 8, 2025 13:56
Package.swift Outdated
Comment on lines 30 to 31
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?

Package.swift Outdated
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"),

Plugins/GRPCGeneratorPlugin/ConfigurationFile.swift Outdated Show resolved Hide resolved
*/

/// 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!

Comment on lines 37 to 38
// /// 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

)

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)",

Comment on lines 73 to 77
// 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)")
// }
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's remove this


if let importPaths = config.importPaths {
for path in importPaths {
protocArgs.append("-I")
Copy link
Collaborator

Choose a reason for hiding this comment

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

-I is a synonym for --proto_path which we also set below, why do we have both and have two source of values for them? Also, the protoc-gen-swift args only have --proto_path it seems. Somethin' ain't right here!

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 two sources of values is because one is user-specified and one is a default one which we add based on the path to the proto file we're currently processing. I've made them both use --proto_path and fixed up the missing source.

Comment on lines 133 to 136
// TODO: Don't currently support reflection data
// if let generateReflectionData = config.reflectionData {
// protocArgs.append("--grpc-swift_opt=ReflectionData=\(generateReflectionData)")
// }
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's remove this

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

Choose a reason for hiding this comment

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

IIRC the output file path is only needed by the build plugin which can only use the .fullPath file naming. If so we can simplify this, right?

Comment on lines 69 to 74
let defaultVisibility: GenerationConfig.Visibility = .internal
let defaultServer = true
let defaultClient = true
let defaultMessage = true
let defaultUseAccessLevelOnImports = false
let defaultImportPaths: [String] = []
Copy link
Collaborator

Choose a reason for hiding this comment

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

We don't want to carry around the defaults on each instance, we should just have a static instance:

static let defaults = Self(...)

}

extension GenerationConfig {
init(configurationFile: BuildPluginConfig, configurationFilePath: URL, outputPath: URL) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: s/configuration/config

// hard-code full-path to avoid collisions since this goes into a temporary directory anyway
self.fileNaming = .fullPath
self.useAccessLevelOnImports = configurationFile.useAccessLevelOnImports
self.importPaths = []
Copy link
Collaborator

Choose a reason for hiding this comment

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

We set this below

Comment on lines 105 to 107
// 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
Copy link
Collaborator

Choose a reason for hiding this comment

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

This doesn't tie together: the comment says we're generating absolute paths relative to the config file, but the implementation uses the relative path of the config file?

Plugins/GRPCProtobufGenerator/Plugin.swift Outdated Show resolved Hide resolved
/// Whether client code is generated.
var client: Bool
/// Whether message code is generated.
var message: Bool
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: wondering whether this should be messages? WDYT?

case .incompatibleTarget(let string):
"Build plugin applied to incompatible target."
case .noConfigurationFilesFound:
"No configuration files found."
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 expand on this to say that there needs to be a file called "blah" somewhere?

/// - Returns: The constructed arguments to be passed to `protoc` when invoking the `proto-gen-swift` `protoc` plugin.
func constructProtocGenSwiftArguments(
config: GenerationConfig,
using fileNaming: GenerationConfig.FileNaming?,
Copy link
Collaborator

Choose a reason for hiding this comment

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

using in the context of constructing args feels way outta place. Let's just use fileNaming here

Comment on lines 63 to 64
protocArgs.append("--proto_path")
protocArgs.append("\(path)")
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 work? The docs say -IPATH, --proto_path=PATH so it looks like you need an = here.. 🤷‍♂️

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Empirically, yes it does seem to work.

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

Choose a reason for hiding this comment

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

I don't think we should set this and use the config paths, we should do one or the other.

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 agree, one is optional and comes from user configuration and one is determined by the plugin and is required.

Copy link
Collaborator

Choose a reason for hiding this comment

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

The value provided by the plugin is a sensible default. Explicitly setting paths in the config should replace that default value.

Using both is quite surprising behaviour IMO.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Including the directory which contains the source file is I think more than just a sensible default, it's required by protoc. If you don't specify it you get this:

 File does not reside within any path specified using --proto_path (or -I).  You must specify a --proto_path which encompasses this file.  Note that the proto_path must be an exact prefix of the .proto file names -- protoc is too dumb to figure out when two paths (e.g. absolute and relative) are equivalent (it's harder than you think).

I think it is helpful for us to add this to avoid users having to enumerate every directory containing source files.

Moving this from being a parameter to explicitly being derived at this level from the input proto files might make sense to clarify what's happening.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we're talking about different things. I agree that we should use a sensible value if the user doesn't express an opinion about which paths to use.

What I'm arguing is that if the user expresses an opinion then the plugin shouldn't have an opinion: it should use the paths specified by the user and nothing else.


For what it's worth protoc doesn't require --proto_path/-I to be set; it uses the current working directory. We obviously can't rely on that for the plugin so we do need a default value.

I'm not sure that the directory of the source file is the best choice though: the directory of the config file is likely to be a better choice as it will include the input proto file and likely any protos depended on by the input proto file.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🆕 semver/minor Adds new public API.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants