Skip to content

Code generation command plugin #40

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

Merged
merged 19 commits into from
Mar 5, 2025
Merged

Code generation command plugin #40

merged 19 commits into from
Mar 5, 2025

Conversation

rnro
Copy link
Collaborator

@rnro rnro commented Jan 30, 2025

Motivation:

To make it simpler to generate gRPC stubs with protoc-gen-grpc-swift and protoc-gen-swift.

Modifications:

  • Add a new command plugin
  • Refactor some errors

The command plugin can be invoked from the CLI as:

swift package generate-grpc-code-from-protos --import-path /path/to/Protos -- /path/to/Protos/HelloWorld.proto

The plugin has flexible configuration:

❯ swift package generate-grpc-code-from-protos --help
Usage: swift package generate-grpc-code-from-protos [flags] [--] [input files]

Flags:

  --servers                   Indicate that server code is to be generated. Generated by default.
  --no-servers                Indicate that server code is not to be generated. Generated by default.
  --clients                   Indicate that client code is to be generated. Generated by default.
  --no-clients                Indicate that client code is not to be generated. Generated by default.
  --messages                  Indicate that message code is to be generated. Generated by default.
  --no-messages               Indicate that message code is not to be generated. Generated by default.
  --file-naming               The naming scheme for output files [fullPath/pathToUnderscores/dropPath]. Defaults to fullPath.
  --access-level              The access level of the generated source [internal/public/package]. Defaults to internal.
  --access-level-on-imports   Whether imports should have explicit access levels. Defaults to false.
  --import-path               The directory in which to search for imports.
  --protoc-path               The path to the protoc binary.
  --output-path               The path into which the generated source files are created.
  --verbose                   Emit verbose output.
  --dry-run                   Print but do not execute the protoc commands.
  --help                      Print this help.
  • When executing, the command prints the protoc invocations it uses for ease of debugging. The --dry-run flag can be supplied for this purpose or so that they may be extracted and used separately e.g. in a script.
  • If no protoc path is supplied then Swift Package Manager will attempt to locate it.
  • If no output directory is supplied then generated files are placed a Swift Package Manager build directory.

Result:

More convenient code generation

This PR is split out of #26

Motivation:

To make it simpler to generate gRPC stubs with `protoc-gen-grpc-swift` and `protoc-gen-swift`.

Modifications:

* Add a new command plugin
  `swift package generate-grpc-code-from-protos/path/to/Protos/HelloWorld.proto --import-path /path/to/Protos`
* Refactor some errors

Result:

More convenient code generation
@rnro rnro added the 🔨 semver/patch No public API change. label Jan 30, 2025
@rnro rnro requested a review from glbrntt January 30, 2025 09:04
Copy link
Collaborator

@glbrntt glbrntt left a comment

Choose a reason for hiding this comment

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

This looks good so far! Most of the comments are around the UX.

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

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

Package.swift Outdated

// Code generator SwiftPM command
.plugin(
name: "GRPCGeneratorCommand",
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 include "Protobuf" in the name. I find "Command" a weird suffix but that stems more from me finding the "command plugin" naming weird as well.

Suggested change
name: "GRPCGeneratorCommand",
name: "GRPCProtobufGeneratorCommand",

struct CommandConfig {
var common: GenerationConfig

var dryRun: Bool
Copy link
Collaborator

Choose a reason for hiding this comment

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

Love this, great idea!

case "package":
config.common.accessLevel = .`package`
default:
Diagnostics.error("Unknown accessLevel \(value)")
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 parrot out the name of the argument as passed by the user ("--access-level") rather than the property name.

switch flag {
case .accessLevel:
let accessLevel = argExtractor.extractOption(named: flag.rawValue)
if let value = accessLevel.first {
Copy link
Collaborator

Choose a reason for hiding this comment

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

If there's more than one value we should either warn or throw an error.

Copy link
Collaborator

Choose a reason for hiding this comment

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

(This feedback applies to all of these options.)

let inputFileURLs = inputFiles.map { URL(fileURLWithPath: $0) }

// MARK: protoc-gen-grpc-swift
if config.clients != false || config.servers != false {
Copy link
Collaborator

Choose a reason for hiding this comment

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

These should be non-optional now so can be more idiomatic:

Suggested change
if config.clients != false || config.servers != false {
if config.clients || config.servers {

}

// MARK: protoc-gen-swift
if config.messages != false {
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 config.messages != false {
if config.messages {

/// - arguments: The arguments to be passed to `protoc`.
func printProtocInvocation(_ executableURL: URL, _ arguments: [String]) {
print("protoc invocation:")
print(" \(executableURL.absoluteStringNoScheme) \\")
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
print(" \(executableURL.absoluteStringNoScheme) \\")
print("\(executableURL.absoluteStringNoScheme) \\")

/// - executableURL: The path to the `protoc` executable.
/// - arguments: The arguments to be passed to `protoc`.
func printProtocInvocation(_ executableURL: URL, _ arguments: [String]) {
print("protoc invocation:")
Copy link
Collaborator

Choose a reason for hiding this comment

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

Not sure this is necessary; protoc ... will make it obvious.

func printProtocInvocation(_ executableURL: URL, _ arguments: [String]) {
print("protoc invocation:")
print(" \(executableURL.absoluteStringNoScheme) \\")
for argument in arguments[..<arguments.count.advanced(by: -1)] {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can't you just do print(arguments.joined(separator: " \\\n "))?

rnro added 2 commits February 17, 2025 15:35
@rnro rnro requested a review from glbrntt February 17, 2025 15:39
rnro added 4 commits February 17, 2025 15:57
Comment on lines 43 to 48
static func helpRequested(
argumentExtractor: inout ArgumentExtractor
) -> Bool {
let help = argumentExtractor.extractFlag(named: OptionsAndFlags.help.rawValue)
return help != 0
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This function is tiny and only used one time so I think the indirection hurts it.

You'd be better off just inlining it:

if argumentExtractor.extractFlag(named: OptionsAndFlags.help.rawValue) > 0 {
  // ...
}

Comment on lines 70 to 74
let servers = argExtractor.extractFlag(named: OptionsAndFlags.servers.rawValue)
let noServers = argExtractor.extractFlag(named: OptionsAndFlags.noServers.rawValue)
if noServers > servers {
config.common.servers = 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 think if the user specifies both flags then they've made a mistake and we should emit a diagnostic and fail. It should be okay to specify the same flag more than once though.

Same comment for clients and messages.

}

case .servers, .noServers:
if flag == .noServers { continue } // only process this once
Copy link
Collaborator

Choose a reason for hiding this comment

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

This threw me for a second because I expected to be looping over the arguments and checking for known flags/options as opposed to looping over the known flags/options and extracting them (as that requires scanning the args multiple times) although this is how the arg extractor wants you to do it.

Very minor but I think it'd be more obvious if they were treated as separate cases:

case .noServers:
  // Handled by `.servers`
  continue

let accessLevelOnImports = argExtractor.extractOption(named: flag.rawValue)
if let value = extractSingleValue(flag, values: accessLevelOnImports) {
guard let accessLevelOnImports = Bool(value) else {
throw CommandPluginError.invalidArgumentValue(name: flag.rawValue, value: value)
Copy link
Collaborator

Choose a reason for hiding this comment

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

When do we use a diagnostic vs an error?

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 idea was to try to be permissive and just limp on but that got a bit muddied so I've reversed that now and I just throw an error if there's an issue.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Great, I think that's the better approach.


func extractSingleValue(_ flag: OptionsAndFlags, values: [String]) -> String? {
if values.count > 1 {
Stderr.print(
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is there a warning diagnostic?

Copy link
Collaborator Author

@rnro rnro Mar 3, 2025

Choose a reason for hiding this comment

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

There is, I've used it now :)

}
}

func extractSingleValue(_ flag: OptionsAndFlags, values: [String]) -> String? {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This seems like an appropriate extension to have on arg extractor

Comment on lines 136 to 146
if argExtractor.remainingArguments.isEmpty {
throw CommandPluginError.missingInputFile
}

for argument in argExtractor.remainingArguments {
if argument.hasPrefix("--") {
throw CommandPluginError.unknownOption(argument)
}
}

return (config, argExtractor.remainingArguments)
Copy link
Collaborator

Choose a reason for hiding this comment

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

This setup is such that we allow inputs to be interspersed with options:

--clients foo.proto -verbose --servers bar.proto --dry-run

That's a bit unexpected for a CLI and you can end up with a mistyped flag/option as an input (e.g. -verbose). I think the easiest thing to do is have args after a "--" and then split the args on that, everything before goes to the extractor, everything after is an input (everything is input if there's no "--"):

--clients -verbose --servers --dry-run -- foo.proto bar.proto

case accessLevelOnImports = "access-level-on-imports"
case importPath = "import-path"
case protocPath = "protoc-path"
case output
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should this be output-path? (We have import path and protoc path 🤷‍♂️)


@main
struct GRPCProtobufGeneratorCommandPlugin: CommandPlugin {
/// Perform command, the entry-point when using a Package manifest.
Copy link
Collaborator

Choose a reason for hiding this comment

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

when using a package manifest? Not sure I understand this one.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ah, I forgot to implement the path which applies to Xcode projects XcodeCommandPlugin.

Copy link
Collaborator

Choose a reason for hiding this comment

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

That wasn't what I had in mind but I now understand what the comment means. I don't think it's accurate though: you can have a package manifest and still use an Xcode project. I think the comment really means the entry point when invoked from the command line via swift package something something?

Copy link
Collaborator Author

@rnro rnro Mar 3, 2025

Choose a reason for hiding this comment

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

Is that what it means? How do you invoke a command plugin via Xcode? Or rather how do you invoke the Xcode flavor?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes, I believe so. IIRC you right click on a package in the project navigator and any commands are in there.

Copy link
Collaborator

Choose a reason for hiding this comment

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

You don't get a choice of flavour: you can only invoke the Xcode flavour using that UI.

@rnro rnro requested a review from glbrntt March 3, 2025 11:48
let accessLevelOnImports = argExtractor.extractOption(named: flag.rawValue)
if let value = extractSingleValue(flag, values: accessLevelOnImports) {
guard let accessLevelOnImports = Bool(value) else {
throw CommandPluginError.invalidArgumentValue(name: flag.rawValue, value: value)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Great, I think that's the better approach.

func usageDescription() -> String {
switch self {
case .servers:
return "Indicate that server code is to be generated. Generated by default."
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: brevity is important, if you can say it as clearly in fewer words then you should. I think "Generate server code" says the same as "Indicate that server code is to be generated"

case .accessLevelOnImports:
return "Whether imports should have explicit access levels. Defaults to false."
case .importPath:
return "The directory in which to search for imports."
Copy link
Collaborator

Choose a reason for hiding this comment

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

Worth noting that it can be specified multiple times and IIUC the current working directory is used if not specified?

Stderr.print("Generated gRPC Swift files for \(inputFiles.joined(separator: ", ")).")
}
} else {
let problem = "\(process.terminationReason):\(process.terminationStatus)"
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is presumably meant to be part of the error?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It is, yeah, I've done some restructuring of the protoc output handling in general which has included this line

"Generated protobuf message Swift files for \(inputFiles.joined(separator: ", "))."
)
} else {
let problem = "\(process.terminationReason):\(process.terminationStatus)"
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, is this meant to be part of the error?


@main
struct GRPCProtobufGeneratorCommandPlugin: CommandPlugin {
/// Perform command, the entry-point when using a Package manifest.
Copy link
Collaborator

Choose a reason for hiding this comment

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

That wasn't what I had in mind but I now understand what the comment means. I don't think it's accurate though: you can have a package manifest and still use an Xcode project. I think the comment really means the entry point when invoked from the command line via swift package something something?

var description: String {
switch self {
case .missingArgumentValue:
"Provided option does not have a value."
Copy link
Collaborator

Choose a reason for hiding this comment

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

Which option?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It turns out this was left over from an earlier era. I have removed it now.

}

case .accessLevelOnImports:
if let value = argExtractor.extractSingleOption(named: flag.rawValue) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Totally missed this last time (sorry): this is a boolean option ... which is otherwise known as a flag. Let's just make it a flag (no need for the --no version as the default isn't enabled).

rnro and others added 5 commits March 3, 2025 16:08
Co-authored-by: George Barnett <gbarnett@apple.com>
Co-authored-by: George Barnett <gbarnett@apple.com>
@rnro rnro requested a review from glbrntt March 4, 2025 15:16

try printProtocOutput(outputPipe, verbose: verbose)

guard process.terminationReason == .exit && process.terminationStatus == 0 else {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Sorry, this is a massive pet-peeve of mine: guard is great for early termination so that the rest of your function doesn't have an extra level of nesting.

Here though, there isn't really an early exit: there's a bunch of code in the else early-exit path and none in the guard path.

I would much rather we either invert this and exit early because the process exited cleanly i.e. if process.terminationReason == .exit && process.terminationStatus == 0 { return } as this then removes the nesting from the large error-handling block.

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 agree with you - I think this snuck in when I outlined a method and didn’t refactor the guard

Comment on lines 26 to 27
executable: String?,
arguments: [String]?,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Aren't these always non-nil?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

They are, yeah, I'll make them non-optionals.

rnro and others added 5 commits March 4, 2025 15:41
Co-authored-by: George Barnett <gbarnett@apple.com>
Co-authored-by: George Barnett <gbarnett@apple.com>
Co-authored-by: George Barnett <gbarnett@apple.com>
@rnro rnro requested a review from glbrntt March 5, 2025 10:15
Copy link
Collaborator

@glbrntt glbrntt left a comment

Choose a reason for hiding this comment

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

LGTM, thanks @rnro!

@glbrntt glbrntt added 🆕 semver/minor Adds new public API. and removed 🔨 semver/patch No public API change. labels Mar 5, 2025
@glbrntt glbrntt enabled auto-merge (squash) March 5, 2025 10:35
@glbrntt glbrntt merged commit 8726dda into grpc:main Mar 5, 2025
27 checks passed
@rnro rnro deleted the command_plugin branch March 5, 2025 11:45
glbrntt added a commit to glbrntt/grpc-swift-protobuf that referenced this pull request Mar 5, 2025
### Motivation:

To make it simpler to generate gRPC stubs with `protoc-gen-grpc-swift`
and `protoc-gen-swift`.

### Modifications:

* Add a new command plugin
* Refactor some errors

The command plugin can be invoked from the CLI as:
```
swift package generate-grpc-code-from-protos --import-path /path/to/Protos -- /path/to/Protos/HelloWorld.proto
```

The plugin has flexible configuration:
```
❯ swift package generate-grpc-code-from-protos --help
Usage: swift package generate-grpc-code-from-protos [flags] [--] [input files]

Flags:

  --servers                   Indicate that server code is to be generated. Generated by default.
  --no-servers                Indicate that server code is not to be generated. Generated by default.
  --clients                   Indicate that client code is to be generated. Generated by default.
  --no-clients                Indicate that client code is not to be generated. Generated by default.
  --messages                  Indicate that message code is to be generated. Generated by default.
  --no-messages               Indicate that message code is not to be generated. Generated by default.
  --file-naming               The naming scheme for output files [fullPath/pathToUnderscores/dropPath]. Defaults to fullPath.
  --access-level              The access level of the generated source [internal/public/package]. Defaults to internal.
  --access-level-on-imports   Whether imports should have explicit access levels. Defaults to false.
  --import-path               The directory in which to search for imports.
  --protoc-path               The path to the protoc binary.
  --output-path               The path into which the generated source files are created.
  --verbose                   Emit verbose output.
  --dry-run                   Print but do not execute the protoc commands.
  --help                      Print this help.
```
* When executing, the command prints the `protoc` invocations it uses
for ease of debugging. The `--dry-run` flag can be supplied for this
purpose or so that they may be extracted and used separately e.g. in a
script.
* If no `protoc` path is supplied then Swift Package Manager will
attempt to locate it.
* If no `output` directory is supplied then generated files are placed a
Swift Package Manager build directory.
  
### Result:

More convenient code generation

This PR is split out of
grpc#26

---------

Co-authored-by: George Barnett <gbarnett@apple.com>
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.

None yet

2 participants