-
Notifications
You must be signed in to change notification settings - Fork 8
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
Conversation
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
There was a problem hiding this 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", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"protoc-gen-grpc-swift", | |
.target(name: "protoc-gen-grpc-swift"), |
Package.swift
Outdated
|
||
// Code generator SwiftPM command | ||
.plugin( | ||
name: "GRPCGeneratorCommand", |
There was a problem hiding this comment.
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.
name: "GRPCGeneratorCommand", | |
name: "GRPCProtobufGeneratorCommand", |
struct CommandConfig { | ||
var common: GenerationConfig | ||
|
||
var dryRun: Bool |
There was a problem hiding this comment.
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)") |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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:
if config.clients != false || config.servers != false { | |
if config.clients || config.servers { |
} | ||
|
||
// MARK: protoc-gen-swift | ||
if config.messages != false { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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) \\") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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:") |
There was a problem hiding this comment.
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)] { |
There was a problem hiding this comment.
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 "))
?
static func helpRequested( | ||
argumentExtractor: inout ArgumentExtractor | ||
) -> Bool { | ||
let help = argumentExtractor.extractFlag(named: OptionsAndFlags.help.rawValue) | ||
return help != 0 | ||
} |
There was a problem hiding this comment.
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 {
// ...
}
let servers = argExtractor.extractFlag(named: OptionsAndFlags.servers.rawValue) | ||
let noServers = argExtractor.extractFlag(named: OptionsAndFlags.noServers.rawValue) | ||
if noServers > servers { | ||
config.common.servers = false | ||
} |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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? { |
There was a problem hiding this comment.
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
if argExtractor.remainingArguments.isEmpty { | ||
throw CommandPluginError.missingInputFile | ||
} | ||
|
||
for argument in argExtractor.remainingArguments { | ||
if argument.hasPrefix("--") { | ||
throw CommandPluginError.unknownOption(argument) | ||
} | ||
} | ||
|
||
return (config, argExtractor.remainingArguments) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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) |
There was a problem hiding this comment.
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." |
There was a problem hiding this comment.
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." |
There was a problem hiding this comment.
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)" |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)" |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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." |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Which option?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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).
Co-authored-by: George Barnett <gbarnett@apple.com>
Co-authored-by: George Barnett <gbarnett@apple.com>
|
||
try printProtocOutput(outputPipe, verbose: verbose) | ||
|
||
guard process.terminationReason == .exit && process.terminationStatus == 0 else { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
executable: String?, | ||
arguments: [String]?, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
Co-authored-by: George Barnett <gbarnett@apple.com>
Co-authored-by: George Barnett <gbarnett@apple.com>
Co-authored-by: George Barnett <gbarnett@apple.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM, thanks @rnro!
### 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>
Motivation:
To make it simpler to generate gRPC stubs with
protoc-gen-grpc-swift
andprotoc-gen-swift
.Modifications:
The command plugin can be invoked from the CLI as:
The plugin has flexible configuration:
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.protoc
path is supplied then Swift Package Manager will attempt to locate it.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