Skip to content

Commit 8726dda

Browse files
rnroglbrntt
andauthored
Code generation command plugin (#40)
### 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 --------- Co-authored-by: George Barnett <[email protected]>
1 parent 108c131 commit 8726dda

File tree

10 files changed

+636
-26
lines changed

10 files changed

+636
-26
lines changed

Package.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,27 @@ let targets: [Target] = [
115115
.product(name: "protoc-gen-swift", package: "swift-protobuf"),
116116
]
117117
),
118+
119+
// Code generator SwiftPM command
120+
.plugin(
121+
name: "GRPCProtobufGeneratorCommand",
122+
capability: .command(
123+
intent: .custom(
124+
verb: "generate-grpc-code-from-protos",
125+
description: "Generate Swift code for gRPC services from protobuf definitions."
126+
),
127+
permissions: [
128+
.writeToPackageDirectory(
129+
reason:
130+
"To write the generated Swift files back into the source directory of the package."
131+
)
132+
]
133+
),
134+
dependencies: [
135+
.target(name: "protoc-gen-grpc-swift"),
136+
.product(name: "protoc-gen-swift", package: "swift-protobuf"),
137+
]
138+
),
118139
]
119140

120141
let package = Package(

Plugins/GRPCProtobufGenerator/BuildPluginConfig.swift

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@
1616

1717
import Foundation
1818

19-
let configFileName = "grpc-swift-proto-generator-config.json"
20-
2119
/// The config of the build plugin.
2220
struct BuildPluginConfig: Codable {
2321
/// Config defining which components should be considered when generating source.
@@ -193,14 +191,14 @@ extension BuildPluginConfig.Protoc: Codable {
193191

194192
extension GenerationConfig {
195193
init(buildPluginConfig: BuildPluginConfig, configFilePath: URL, outputPath: URL) {
196-
self.server = buildPluginConfig.generate.servers
197-
self.client = buildPluginConfig.generate.clients
198-
self.message = buildPluginConfig.generate.messages
194+
self.servers = buildPluginConfig.generate.servers
195+
self.clients = buildPluginConfig.generate.clients
196+
self.messages = buildPluginConfig.generate.messages
199197
// Use path to underscores as it ensures output files are unique (files generated from
200198
// "foo/bar.proto" won't collide with those generated from "bar/bar.proto" as they'll be
201199
// uniquely named "foo_bar.(grpc|pb).swift" and "bar_bar.(grpc|pb).swift".
202200
self.fileNaming = .pathToUnderscores
203-
self.visibility = buildPluginConfig.generatedSource.accessLevel
201+
self.accessLevel = buildPluginConfig.generatedSource.accessLevel
204202
self.accessLevelOnImports = buildPluginConfig.generatedSource.accessLevelOnImports
205203
// Generate absolute paths for the imports relative to the config file in which they are specified
206204
self.importPaths = buildPluginConfig.protoc.importPaths.map { relativePath in

Plugins/PluginsShared/PluginError.swift renamed to Plugins/GRPCProtobufGenerator/BuildPluginError.swift

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2024, gRPC Authors All rights reserved.
2+
* Copyright 2025, gRPC Authors All rights reserved.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -14,13 +14,12 @@
1414
* limitations under the License.
1515
*/
1616

17-
enum PluginError: Error {
18-
// Build plugin
17+
enum BuildPluginError: Error {
1918
case incompatibleTarget(String)
2019
case noConfigFilesFound
2120
}
2221

23-
extension PluginError: CustomStringConvertible {
22+
extension BuildPluginError: CustomStringConvertible {
2423
var description: String {
2524
switch self {
2625
case .incompatibleTarget(let target):

Plugins/GRPCProtobufGenerator/Plugin.swift

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,9 @@ import PackagePlugin
1919

2020
// Entry-point when using Package manifest
2121
extension GRPCProtobufGenerator: BuildToolPlugin {
22-
/// Create build commands, the entry-point when using a Package manifest.
2322
func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
2423
guard let swiftTarget = target as? SwiftSourceModuleTarget else {
25-
throw PluginError.incompatibleTarget(target.name)
24+
throw BuildPluginError.incompatibleTarget(target.name)
2625
}
2726
let configFiles = swiftTarget.sourceFiles(withSuffix: configFileName).map { $0.url }
2827
let inputFiles = swiftTarget.sourceFiles(withSuffix: ".proto").map { $0.url }
@@ -41,7 +40,6 @@ import XcodeProjectPlugin
4140

4241
// Entry-point when using Xcode projects
4342
extension GRPCProtobufGenerator: XcodeBuildToolPlugin {
44-
/// Create build commands, the entry-point when using an Xcode project.
4543
func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] {
4644
let configFiles = target.inputFiles.filter {
4745
$0.url.lastPathComponent == configFileName
@@ -62,7 +60,7 @@ extension GRPCProtobufGenerator: XcodeBuildToolPlugin {
6260

6361
@main
6462
struct GRPCProtobufGenerator {
65-
/// Build plugin code common to both invocation types: package manifest Xcode project
63+
/// Build plugin common code
6664
func createBuildCommands(
6765
pluginWorkDirectory: URL,
6866
tool: (String) throws -> PluginContext.Tool,
@@ -78,7 +76,7 @@ struct GRPCProtobufGenerator {
7876
var commands: [Command] = []
7977
for inputFile in inputFiles {
8078
guard let (configFilePath, config) = configs.findApplicableConfig(for: inputFile) else {
81-
throw PluginError.noConfigFilesFound
79+
throw BuildPluginError.noConfigFilesFound
8280
}
8381

8482
let protocPath = try deriveProtocPath(using: config, tool: tool)
@@ -90,7 +88,7 @@ struct GRPCProtobufGenerator {
9088
}
9189

9290
// unless *explicitly* opted-out
93-
if config.client || config.server {
91+
if config.clients || config.servers {
9492
let grpcCommand = try protocGenGRPCSwiftCommand(
9593
inputFile: inputFile,
9694
config: config,
@@ -104,7 +102,7 @@ struct GRPCProtobufGenerator {
104102
}
105103

106104
// unless *explicitly* opted-out
107-
if config.message {
105+
if config.messages {
108106
let protoCommand = try protocGenSwiftCommand(
109107
inputFile: inputFile,
110108
config: config,
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
/*
2+
* Copyright 2024, gRPC Authors All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import Foundation
18+
import PackagePlugin
19+
20+
struct CommandConfig {
21+
var common: GenerationConfig
22+
23+
var verbose: Bool
24+
var dryRun: Bool
25+
26+
static let defaults = Self(
27+
common: .init(
28+
accessLevel: .internal,
29+
servers: true,
30+
clients: true,
31+
messages: true,
32+
fileNaming: .fullPath,
33+
accessLevelOnImports: false,
34+
importPaths: [],
35+
outputPath: ""
36+
),
37+
verbose: false,
38+
dryRun: false
39+
)
40+
41+
static let parameterGroupSeparator = "--"
42+
}
43+
44+
extension CommandConfig {
45+
static func parse(
46+
argumentExtractor argExtractor: inout ArgumentExtractor,
47+
pluginWorkDirectory: URL
48+
) throws -> CommandConfig {
49+
var config = CommandConfig.defaults
50+
51+
for flag in OptionsAndFlags.allCases {
52+
switch flag {
53+
case .accessLevel:
54+
if let value = argExtractor.extractSingleOption(named: flag.rawValue) {
55+
if let accessLevel = GenerationConfig.AccessLevel(rawValue: value) {
56+
config.common.accessLevel = accessLevel
57+
} else {
58+
throw CommandPluginError.unknownAccessLevel(value)
59+
}
60+
}
61+
62+
case .noServers:
63+
// Handled by `.servers`
64+
continue
65+
case .servers:
66+
let servers = argExtractor.extractFlag(named: OptionsAndFlags.servers.rawValue)
67+
let noServers = argExtractor.extractFlag(named: OptionsAndFlags.noServers.rawValue)
68+
if servers > 0 && noServers > 0 {
69+
throw CommandPluginError.conflictingFlags(
70+
OptionsAndFlags.servers.rawValue,
71+
OptionsAndFlags.noServers.rawValue
72+
)
73+
} else if servers > 0 {
74+
config.common.servers = true
75+
} else if noServers > 0 {
76+
config.common.servers = false
77+
}
78+
79+
case .noClients:
80+
// Handled by `.clients`
81+
continue
82+
case .clients:
83+
let clients = argExtractor.extractFlag(named: OptionsAndFlags.clients.rawValue)
84+
let noClients = argExtractor.extractFlag(named: OptionsAndFlags.noClients.rawValue)
85+
if clients > 0 && noClients > 0 {
86+
throw CommandPluginError.conflictingFlags(
87+
OptionsAndFlags.clients.rawValue,
88+
OptionsAndFlags.noClients.rawValue
89+
)
90+
} else if clients > 0 {
91+
config.common.clients = true
92+
} else if noClients > 0 {
93+
config.common.clients = false
94+
}
95+
96+
case .noMessages:
97+
// Handled by `.messages`
98+
continue
99+
case .messages:
100+
let messages = argExtractor.extractFlag(named: OptionsAndFlags.messages.rawValue)
101+
let noMessages = argExtractor.extractFlag(named: OptionsAndFlags.noMessages.rawValue)
102+
if messages > 0 && noMessages > 0 {
103+
throw CommandPluginError.conflictingFlags(
104+
OptionsAndFlags.messages.rawValue,
105+
OptionsAndFlags.noMessages.rawValue
106+
)
107+
} else if messages > 0 {
108+
config.common.messages = true
109+
} else if noMessages > 0 {
110+
config.common.messages = false
111+
}
112+
113+
case .fileNaming:
114+
if let value = argExtractor.extractSingleOption(named: flag.rawValue) {
115+
if let fileNaming = GenerationConfig.FileNaming(rawValue: value) {
116+
config.common.fileNaming = fileNaming
117+
} else {
118+
throw CommandPluginError.unknownFileNamingStrategy(value)
119+
}
120+
}
121+
122+
case .accessLevelOnImports:
123+
if argExtractor.extractFlag(named: flag.rawValue) > 0 {
124+
config.common.accessLevelOnImports = true
125+
}
126+
127+
case .importPath:
128+
config.common.importPaths = argExtractor.extractOption(named: flag.rawValue)
129+
130+
case .protocPath:
131+
config.common.protocPath = argExtractor.extractSingleOption(named: flag.rawValue)
132+
133+
case .outputPath:
134+
config.common.outputPath =
135+
argExtractor.extractSingleOption(named: flag.rawValue)
136+
?? pluginWorkDirectory.absoluteStringNoScheme
137+
138+
case .verbose:
139+
let verbose = argExtractor.extractFlag(named: flag.rawValue)
140+
config.verbose = verbose != 0
141+
142+
case .dryRun:
143+
let dryRun = argExtractor.extractFlag(named: flag.rawValue)
144+
config.dryRun = dryRun != 0
145+
146+
case .help:
147+
() // handled elsewhere
148+
}
149+
}
150+
151+
if let argument = argExtractor.remainingArguments.first {
152+
throw CommandPluginError.unknownOption(argument)
153+
}
154+
155+
return config
156+
}
157+
}
158+
159+
extension ArgumentExtractor {
160+
mutating func extractSingleOption(named optionName: String) -> String? {
161+
let values = self.extractOption(named: optionName)
162+
if values.count > 1 {
163+
Diagnostics.warning(
164+
"'--\(optionName)' was unexpectedly repeated, the first value will be used."
165+
)
166+
}
167+
return values.first
168+
}
169+
}
170+
171+
/// All valid input options/flags
172+
enum OptionsAndFlags: String, CaseIterable {
173+
case servers
174+
case noServers = "no-servers"
175+
case clients
176+
case noClients = "no-clients"
177+
case messages
178+
case noMessages = "no-messages"
179+
case fileNaming = "file-naming"
180+
case accessLevel = "access-level"
181+
case accessLevelOnImports = "access-level-on-imports"
182+
case importPath = "import-path"
183+
case protocPath = "protoc-path"
184+
case outputPath = "output-path"
185+
case verbose
186+
case dryRun = "dry-run"
187+
188+
case help
189+
}
190+
191+
extension OptionsAndFlags {
192+
func usageDescription() -> String {
193+
switch self {
194+
case .servers:
195+
return "Generate server code. Generated by default."
196+
case .noServers:
197+
return "Do not generate server code. Generated by default."
198+
case .clients:
199+
return "Generate client code. Generated by default."
200+
case .noClients:
201+
return "Do not generate client code. Generated by default."
202+
case .messages:
203+
return "Generate message code. Generated by default."
204+
case .noMessages:
205+
return "Do not generate message code. Generated by default."
206+
case .fileNaming:
207+
return
208+
"The naming scheme for output files [fullPath/pathToUnderscores/dropPath]. Defaults to fullPath."
209+
case .accessLevel:
210+
return
211+
"The access level of the generated source [internal/public/package]. Defaults to internal."
212+
case .accessLevelOnImports:
213+
return "Whether imports should have explicit access levels. Defaults to false."
214+
case .importPath:
215+
return
216+
"The directory in which to search for imports. May be specified multiple times. If none are specified the current working directory is used."
217+
case .protocPath:
218+
return "The path to the protoc binary."
219+
case .dryRun:
220+
return "Print but do not execute the protoc commands."
221+
case .outputPath:
222+
return "The directory into which the generated source files are created."
223+
case .verbose:
224+
return "Emit verbose output."
225+
case .help:
226+
return "Print this help."
227+
}
228+
}
229+
230+
static func printHelp(requested: Bool) {
231+
let printMessage: (String) -> Void
232+
if requested {
233+
printMessage = { message in print(message) }
234+
} else {
235+
printMessage = Stderr.print
236+
}
237+
238+
printMessage(
239+
"Usage: swift package generate-grpc-code-from-protos [flags] [\(CommandConfig.parameterGroupSeparator)] [input files]"
240+
)
241+
printMessage("")
242+
printMessage("Flags:")
243+
printMessage("")
244+
245+
let spacing = 3
246+
let maxLength =
247+
(OptionsAndFlags.allCases.map(\.rawValue).max(by: { $0.count < $1.count })?.count ?? 0)
248+
+ spacing
249+
for flag in OptionsAndFlags.allCases {
250+
printMessage(
251+
" --\(flag.rawValue.padding(toLength: maxLength, withPad: " ", startingAt: 0))\(flag.usageDescription())"
252+
)
253+
}
254+
}
255+
}

0 commit comments

Comments
 (0)