Skip to content

Commit a5010cb

Browse files
committed
Code generation build plugin
Motivation: To make code generation more convenient for adopters. Modifications: * New build plugin to generate gRPC services and protobuf messages Result: * Users will be able to make use of the build plugin.
1 parent be41136 commit a5010cb

File tree

7 files changed

+673
-0
lines changed

7 files changed

+673
-0
lines changed

Package.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ let products: [Product] = [
2626
name: "protoc-gen-grpc-swift",
2727
targets: ["protoc-gen-grpc-swift"]
2828
),
29+
.plugin(
30+
name: "GRPCGeneratorPlugin",
31+
targets: ["GRPCGeneratorPlugin"]
32+
),
2933
]
3034

3135
let dependencies: [Package.Dependency] = [
@@ -101,6 +105,16 @@ let targets: [Target] = [
101105
],
102106
swiftSettings: defaultSwiftSettings
103107
),
108+
109+
// Code generator build plugin
110+
.plugin(
111+
name: "GRPCGeneratorPlugin",
112+
capability: .buildTool(),
113+
dependencies: [
114+
"protoc-gen-grpc-swift",
115+
.product(name: "protoc-gen-swift", package: "swift-protobuf"),
116+
]
117+
),
104118
]
105119

106120
let package = Package(
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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+
/// The configuration of the plugin.
18+
struct ConfigurationFile: Codable {
19+
/// The visibility of the generated files.
20+
enum Visibility: String, Codable {
21+
/// The generated files should have `internal` access level.
22+
case `internal`
23+
/// The generated files should have `public` access level.
24+
case `public`
25+
/// The generated files should have `package` access level.
26+
case `package`
27+
}
28+
29+
/// The visibility of the generated files.
30+
var visibility: Visibility?
31+
/// Whether server code is generated.
32+
var server: Bool?
33+
/// Whether client code is generated.
34+
var client: Bool?
35+
/// Whether message code is generated.
36+
var message: Bool?
37+
// /// Whether reflection data is generated.
38+
// var reflectionData: Bool?
39+
/// Path to module map .asciipb file.
40+
var protoPathModuleMappings: String?
41+
/// Whether imports should have explicit access levels.
42+
var useAccessLevelOnImports: Bool?
43+
44+
/// Specify the directory in which to search for
45+
/// imports. May be specified multiple times;
46+
/// directories will be searched in order.
47+
/// The target source directory is always appended
48+
/// to the import paths.
49+
var importPaths: [String]?
50+
51+
/// The path to the `protoc` binary.
52+
///
53+
/// If this is not set, SPM will try to find the tool itself.
54+
var protocPath: String?
55+
}
56+
57+
extension CommonConfiguration {
58+
init(configurationFile: ConfigurationFile) {
59+
if let visibility = configurationFile.visibility {
60+
self.visibility = .init(visibility)
61+
}
62+
self.server = configurationFile.server
63+
self.client = configurationFile.client
64+
self.protoPathModuleMappings = configurationFile.protoPathModuleMappings
65+
self.useAccessLevelOnImports = configurationFile.useAccessLevelOnImports
66+
self.importPaths = configurationFile.importPaths
67+
self.protocPath = configurationFile.protocPath
68+
}
69+
}
70+
71+
extension CommonConfiguration.Visibility {
72+
init(_ configurationFileVisibility: ConfigurationFile.Visibility) {
73+
switch configurationFileVisibility {
74+
case .internal: self = .internal
75+
case .public: self = .public
76+
case .package: self = .package
77+
}
78+
}
79+
}
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
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+
@main
21+
struct GRPCGeneratorPlugin {
22+
/// Code common to both invocation types: package manifest Xcode project
23+
func createBuildCommands(
24+
pluginWorkDirectory: URL,
25+
tool: (String) throws -> PluginContext.Tool,
26+
inputFiles: [URL],
27+
configFiles: [URL],
28+
targetName: String
29+
) throws -> [Command] {
30+
let configs = try readConfigurationFiles(configFiles, pluginWorkDirectory: pluginWorkDirectory)
31+
32+
let protocGenGRPCSwiftPath = try tool("protoc-gen-grpc-swift").url
33+
let protocGenSwiftPath = try tool("protoc-gen-swift").url
34+
35+
var commands: [Command] = []
36+
for inputFile in inputFiles {
37+
guard let configFile = findApplicableConfigFor(file: inputFile, from: configs.keys.map { $0 })
38+
else {
39+
throw PluginError.noConfigurationFilesFound
40+
}
41+
guard let config = configs[configFile] else {
42+
throw PluginError.expectedConfigurationNotFound(configFile.relativePath)
43+
}
44+
45+
let protocPath = try deriveProtocPath(using: config, tool: tool)
46+
let protoDirectoryPath = inputFile.deletingLastPathComponent()
47+
48+
// unless *explicitly* opted-out
49+
if config.client != false || config.server != false {
50+
let grpcCommand = try protocGenGRPCSwiftCommand(
51+
inputFile: inputFile,
52+
configFile: configFile,
53+
config: config,
54+
protoDirectoryPath: protoDirectoryPath,
55+
protocPath: protocPath,
56+
protocGenGRPCSwiftPath: protocGenGRPCSwiftPath
57+
)
58+
commands.append(grpcCommand)
59+
}
60+
61+
// unless *explicitly* opted-out
62+
if config.message != false {
63+
let protoCommand = try protocGenSwiftCommand(
64+
inputFile: inputFile,
65+
configFile: configFile,
66+
config: config,
67+
protoDirectoryPath: protoDirectoryPath,
68+
protocPath: protocPath,
69+
protocGenSwiftPath: protocGenSwiftPath
70+
)
71+
commands.append(protoCommand)
72+
}
73+
}
74+
75+
return commands
76+
}
77+
}
78+
79+
/// Reads the configuration files at the supplied URLs into memory
80+
/// - Parameter configurationFiles: URLs from which to load configuration
81+
/// - Returns: A map of source URLs to loaded configuration
82+
func readConfigurationFiles(
83+
_ configurationFiles: [URL],
84+
pluginWorkDirectory: URL
85+
) throws -> [URL: CommonConfiguration] {
86+
var configs: [URL: CommonConfiguration] = [:]
87+
for configFile in configurationFiles {
88+
let data = try Data(contentsOf: configFile)
89+
let configuration = try JSONDecoder().decode(ConfigurationFile.self, from: data)
90+
91+
var config = CommonConfiguration(configurationFile: configuration)
92+
// hard-code full-path to avoid collisions since this goes into a temporary directory anyway
93+
config.fileNaming = .fullPath
94+
// the output directory mandated by the plugin system
95+
config.outputPath = String(pluginWorkDirectory.relativePath)
96+
configs[configFile] = config
97+
}
98+
return configs
99+
}
100+
101+
/// Finds the most precisely relevant config file for a given proto file URL.
102+
/// - Parameters:
103+
/// - file: The path to the proto file to be matched.
104+
/// - configFiles: The paths to all known configuration files.
105+
/// - Returns: The path to the most precisely relevant config file if one is found, otherwise `nil`.
106+
func findApplicableConfigFor(file: URL, from configFiles: [URL]) -> URL? {
107+
let filePathComponents = file.pathComponents
108+
for endComponent in (0 ..< filePathComponents.count).reversed() {
109+
for configFile in configFiles {
110+
if filePathComponents[..<endComponent]
111+
== configFile.pathComponents[..<(configFile.pathComponents.count - 1)]
112+
{
113+
return configFile
114+
}
115+
}
116+
}
117+
118+
return nil
119+
}
120+
121+
/// Construct the command to invoke `protoc` with the `proto-gen-grpc-swift` plugin.
122+
/// - Parameters:
123+
/// - inputFile: The input `.proto` file.
124+
/// - configFile: The path file containing configuration for this operation.
125+
/// - config: The configuration for this operation.
126+
/// - protoDirectoryPath: The root path to the source `.proto` files used as the reference for relative path naming schemes.
127+
/// - protocPath: The path to `protoc`
128+
/// - protocGenGRPCSwiftPath: The path to `proto-gen-grpc-swift`.
129+
/// - Returns: The command to invoke `protoc` with the `proto-gen-grpc-swift` plugin.
130+
func protocGenGRPCSwiftCommand(
131+
inputFile: URL,
132+
configFile: URL,
133+
config: CommonConfiguration,
134+
protoDirectoryPath: URL,
135+
protocPath: URL,
136+
protocGenGRPCSwiftPath: URL
137+
) throws -> PackagePlugin.Command {
138+
guard let fileNaming = config.fileNaming else {
139+
assertionFailure("Missing file naming strategy - should be hard-coded.")
140+
throw PluginError.missingFileNamingStrategy
141+
}
142+
143+
guard let outputPath = config.outputPath else {
144+
assertionFailure("Missing output path - should be hard-coded.")
145+
throw PluginError.missingOutputPath
146+
}
147+
let outputPathURL = URL(fileURLWithPath: outputPath)
148+
149+
let outputFilePath = deriveOutputFilePath(
150+
for: inputFile,
151+
using: fileNaming,
152+
protoDirectoryPath: protoDirectoryPath,
153+
outputDirectory: outputPathURL,
154+
outputExtension: "grpc.swift"
155+
)
156+
157+
let arguments = constructProtocGenGRPCSwiftArguments(
158+
config: config,
159+
using: fileNaming,
160+
inputFiles: [inputFile],
161+
protoDirectoryPaths: [protoDirectoryPath],
162+
protocGenGRPCSwiftPath: protocGenGRPCSwiftPath,
163+
outputDirectory: outputPathURL
164+
)
165+
166+
return Command.buildCommand(
167+
displayName: "Generating gRPC Swift files for \(inputFile.relativePath)",
168+
executable: protocPath,
169+
arguments: arguments,
170+
inputFiles: [inputFile, protocGenGRPCSwiftPath],
171+
outputFiles: [outputFilePath]
172+
)
173+
}
174+
175+
/// Construct the command to invoke `protoc` with the `proto-gen-swift` plugin.
176+
/// - Parameters:
177+
/// - inputFile: The input `.proto` file.
178+
/// - configFile: The path file containing configuration for this operation.
179+
/// - config: The configuration for this operation.
180+
/// - protoDirectoryPath: The root path to the source `.proto` files used as the reference for relative path naming schemes.
181+
/// - protocPath: The path to `protoc`
182+
/// - protocGenSwiftPath: The path to `proto-gen-grpc-swift`.
183+
/// - Returns: The command to invoke `protoc` with the `proto-gen-swift` plugin.
184+
func protocGenSwiftCommand(
185+
inputFile: URL,
186+
configFile: URL,
187+
config: CommonConfiguration,
188+
protoDirectoryPath: URL,
189+
protocPath: URL,
190+
protocGenSwiftPath: URL
191+
) throws -> PackagePlugin.Command {
192+
guard let fileNaming = config.fileNaming else {
193+
assertionFailure("Missing file naming strategy - should be hard-coded.")
194+
throw PluginError.missingFileNamingStrategy
195+
}
196+
197+
guard let outputPath = config.outputPath else {
198+
assertionFailure("Missing output path - should be hard-coded.")
199+
throw PluginError.missingOutputPath
200+
}
201+
let outputPathURL = URL(fileURLWithPath: outputPath)
202+
203+
let outputFilePath = deriveOutputFilePath(
204+
for: inputFile,
205+
using: fileNaming,
206+
protoDirectoryPath: protoDirectoryPath,
207+
outputDirectory: outputPathURL,
208+
outputExtension: "pb.swift"
209+
)
210+
211+
let arguments = constructProtocGenSwiftArguments(
212+
config: config,
213+
using: fileNaming,
214+
inputFiles: [inputFile],
215+
protoDirectoryPaths: [protoDirectoryPath],
216+
protocGenSwiftPath: protocGenSwiftPath,
217+
outputDirectory: outputPathURL
218+
)
219+
220+
return Command.buildCommand(
221+
displayName: "Generating protobuf Swift files for \(inputFile.relativePath)",
222+
executable: protocPath,
223+
arguments: arguments,
224+
inputFiles: [inputFile, protocGenSwiftPath],
225+
outputFiles: [outputFilePath]
226+
)
227+
}
228+
229+
// Entry-point when using Package manifest
230+
extension GRPCGeneratorPlugin: BuildToolPlugin, LocalizedError {
231+
/// Create build commands, the entry-point when using a Package manifest.
232+
func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
233+
guard let swiftTarget = target as? SwiftSourceModuleTarget else {
234+
throw PluginError.incompatibleTarget(target.name)
235+
}
236+
let configFiles = swiftTarget.sourceFiles(withSuffix: "grpc-swift-config.json").map { $0.url }
237+
let inputFiles = swiftTarget.sourceFiles(withSuffix: ".proto").map { $0.url }
238+
return try createBuildCommands(
239+
pluginWorkDirectory: context.pluginWorkDirectoryURL,
240+
tool: context.tool,
241+
inputFiles: inputFiles,
242+
configFiles: configFiles,
243+
targetName: target.name
244+
)
245+
}
246+
}
247+
248+
#if canImport(XcodeProjectPlugin)
249+
import XcodeProjectPlugin
250+
251+
// Entry-point when using Xcode projects
252+
extension GRPCGeneratorPlugin: XcodeBuildToolPlugin {
253+
/// Create build commands, the entry-point when using an Xcode project.
254+
func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] {
255+
let configFiles = target.inputFiles.filter {
256+
$0.url.lastPathComponent == "grpc-swift-config.json"
257+
}.map { $0.url }
258+
let inputFiles = target.inputFiles.filter { $0.url.lastPathComponent.hasSuffix(".proto") }.map {
259+
$0.url
260+
}
261+
return try createBuildCommands(
262+
pluginWorkDirectory: context.pluginWorkDirectoryURL,
263+
tool: context.tool,
264+
inputFiles: inputFiles,
265+
configFiles: configFiles,
266+
targetName: target.displayName
267+
)
268+
}
269+
}
270+
#endif
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../PluginsShared

0 commit comments

Comments
 (0)