Skip to content

Commit 2a2f966

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 2a2f966

File tree

10 files changed

+455
-22
lines changed

10 files changed

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