Skip to content

Commit 6d58cd4

Browse files
committed
Code generation command plugin
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
1 parent 4bb3bea commit 6d58cd4

File tree

10 files changed

+465
-22
lines changed

10 files changed

+465
-22
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: "GRPCGeneratorCommand",
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+
"protoc-gen-grpc-swift",
136+
.product(name: "protoc-gen-swift", package: "swift-protobuf"),
137+
]
138+
),
118139
]
119140

120141
let package = Package(
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
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 dryRun: Bool
24+
25+
static let defaults = Self(
26+
common: .init(
27+
accessLevel: .internal,
28+
servers: true,
29+
clients: true,
30+
messages: true,
31+
fileNaming: .fullPath,
32+
accessLevelOnImports: false,
33+
importPaths: [],
34+
outputPath: ""
35+
),
36+
dryRun: false
37+
)
38+
}
39+
40+
extension CommandConfig {
41+
static func parse(
42+
arguments: [String],
43+
pluginWorkDirectory: URL
44+
) throws -> (CommandConfig, [String]) {
45+
var config = CommandConfig.defaults
46+
47+
var argExtractor = ArgumentExtractor(arguments)
48+
49+
for flag in Flag.allCases {
50+
switch flag {
51+
case .accessLevel:
52+
let accessLevel = argExtractor.extractOption(named: flag.rawValue)
53+
if let value = accessLevel.first {
54+
switch value.lowercased() {
55+
case "internal":
56+
config.common.accessLevel = .`internal`
57+
case "public":
58+
config.common.accessLevel = .`public`
59+
case "package":
60+
config.common.accessLevel = .`package`
61+
default:
62+
Diagnostics.error("Unknown accessLevel \(value)")
63+
}
64+
}
65+
case .servers:
66+
let servers = argExtractor.extractOption(named: flag.rawValue)
67+
if let value = servers.first {
68+
guard let servers = Bool(value) else {
69+
throw CommandPluginError.invalidArgumentValue(value)
70+
}
71+
config.common.servers = servers
72+
}
73+
case .clients:
74+
let clients = argExtractor.extractOption(named: flag.rawValue)
75+
if let value = clients.first {
76+
guard let clients = Bool(value) else {
77+
throw CommandPluginError.invalidArgumentValue(value)
78+
}
79+
config.common.clients = clients
80+
}
81+
case .messages:
82+
let messages = argExtractor.extractOption(named: flag.rawValue)
83+
if let value = messages.first {
84+
guard let messages = Bool(value) else {
85+
throw CommandPluginError.invalidArgumentValue(value)
86+
}
87+
config.common.messages = messages
88+
}
89+
case .fileNaming:
90+
let fileNaming = argExtractor.extractOption(named: flag.rawValue)
91+
if let value = fileNaming.first {
92+
switch value.lowercased() {
93+
case "fullPath":
94+
config.common.fileNaming = .fullPath
95+
case "pathToUnderscores":
96+
config.common.fileNaming = .pathToUnderscores
97+
case "dropPath":
98+
config.common.fileNaming = .dropPath
99+
default:
100+
Diagnostics.error("Unknown file naming strategy \(value)")
101+
}
102+
}
103+
case .accessLevelOnImports:
104+
let accessLevelOnImports = argExtractor.extractOption(named: flag.rawValue)
105+
if let value = accessLevelOnImports.first {
106+
guard let accessLevelOnImports = Bool(value) else {
107+
throw CommandPluginError.invalidArgumentValue(value)
108+
}
109+
config.common.accessLevelOnImports = accessLevelOnImports
110+
}
111+
case .importPath:
112+
config.common.importPaths = argExtractor.extractOption(named: flag.rawValue)
113+
case .protocPath:
114+
let protocPath = argExtractor.extractOption(named: flag.rawValue)
115+
config.common.protocPath = protocPath.first
116+
case .output:
117+
let output = argExtractor.extractOption(named: flag.rawValue)
118+
config.common.outputPath = output.first ?? pluginWorkDirectory.absoluteStringNoScheme
119+
case .dryRun:
120+
let dryRun = argExtractor.extractFlag(named: flag.rawValue)
121+
config.dryRun = dryRun != 0
122+
case .help:
123+
let help = argExtractor.extractFlag(named: flag.rawValue)
124+
if help != 0 {
125+
throw CommandPluginError.helpRequested
126+
}
127+
}
128+
}
129+
130+
if argExtractor.remainingArguments.isEmpty {
131+
throw CommandPluginError.missingInputFile
132+
}
133+
134+
for argument in argExtractor.remainingArguments {
135+
if argument.hasPrefix("--") {
136+
throw CommandPluginError.unknownOption(argument)
137+
}
138+
}
139+
140+
return (config, argExtractor.remainingArguments)
141+
}
142+
}
143+
144+
/// All valid input options/flags
145+
enum Flag: CaseIterable, RawRepresentable {
146+
typealias RawValue = String
147+
148+
case servers
149+
case clients
150+
case messages
151+
case fileNaming
152+
case accessLevel
153+
case accessLevelOnImports
154+
case importPath
155+
case protocPath
156+
case output
157+
case dryRun
158+
159+
case help
160+
161+
init?(rawValue: String) {
162+
switch rawValue {
163+
case "servers":
164+
self = .servers
165+
case "clients":
166+
self = .clients
167+
case "messages":
168+
self = .messages
169+
case "file-naming":
170+
self = .fileNaming
171+
case "access-level":
172+
self = .accessLevel
173+
case "use-access-level-on-imports":
174+
self = .accessLevelOnImports
175+
case "import-path":
176+
self = .importPath
177+
case "protoc-path":
178+
self = .protocPath
179+
case "output":
180+
self = .output
181+
case "dry-run":
182+
self = .dryRun
183+
case "help":
184+
self = .help
185+
default:
186+
return nil
187+
}
188+
return nil
189+
}
190+
191+
var rawValue: String {
192+
switch self {
193+
case .servers:
194+
"servers"
195+
case .clients:
196+
"clients"
197+
case .messages:
198+
"messages"
199+
case .fileNaming:
200+
"file-naming"
201+
case .accessLevel:
202+
"access-level"
203+
case .accessLevelOnImports:
204+
"access-level-on-imports"
205+
case .importPath:
206+
"import-path"
207+
case .protocPath:
208+
"protoc-path"
209+
case .output:
210+
"output"
211+
case .dryRun:
212+
"dry-run"
213+
case .help:
214+
"help"
215+
}
216+
}
217+
}
218+
219+
extension Flag {
220+
func usageDescription() -> String {
221+
switch self {
222+
case .servers:
223+
return "Whether server code is generated. Defaults to true."
224+
case .clients:
225+
return "Whether client code is generated. Defaults to true."
226+
case .messages:
227+
return "Whether message code is generated. Defaults to true."
228+
case .fileNaming:
229+
return
230+
"The naming scheme for output files [fullPath/pathToUnderscores/dropPath]. Defaults to fullPath."
231+
case .accessLevel:
232+
return
233+
"The access level of the generated source [internal/public/package]. Defaults to internal."
234+
case .accessLevelOnImports:
235+
return "Whether imports should have explicit access levels. Defaults to false."
236+
case .importPath:
237+
return "The directory in which to search for imports."
238+
case .protocPath:
239+
return "The path to the `protoc` binary."
240+
case .dryRun:
241+
return "Print but do not execute the protoc commands."
242+
case .output:
243+
return "The path into which the generated source files are created."
244+
case .help:
245+
return "Print this help."
246+
}
247+
}
248+
249+
static func printHelp() {
250+
print("Usage: swift package generate-grpc-code-from-protos [flags] [input files]")
251+
print("")
252+
print("Flags:")
253+
print("")
254+
255+
let spacing = 3
256+
let maxLength =
257+
(Flag.allCases.map(\.rawValue).max(by: { $0.count < $1.count })?.count ?? 0) + spacing
258+
for flag in Flag.allCases {
259+
print(
260+
" --\(flag.rawValue.padding(toLength: maxLength, withPad: " ", startingAt: 0))\(flag.usageDescription())"
261+
)
262+
}
263+
}
264+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright 2025, 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+
enum CommandPluginError: Error {
18+
case helpRequested
19+
case missingArgumentValue
20+
case invalidArgumentValue(String)
21+
case missingInputFile
22+
case unknownOption(String)
23+
}
24+
25+
extension CommandPluginError: CustomStringConvertible {
26+
var description: String {
27+
switch self {
28+
case .helpRequested:
29+
"User requested help."
30+
case .missingArgumentValue:
31+
"Provided option does not have a value."
32+
case .invalidArgumentValue:
33+
"Invalid option value."
34+
case .missingInputFile:
35+
"No input file(s) specified."
36+
case .unknownOption(let value):
37+
"Provided option is unknown: \(value)."
38+
}
39+
}
40+
}

0 commit comments

Comments
 (0)