Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,27 @@ let targets: [Target] = [
.product(name: "protoc-gen-swift", package: "swift-protobuf"),
]
),

// Code generator SwiftPM command
.plugin(
name: "GRPCProtobufGeneratorCommand",
capability: .command(
intent: .custom(
verb: "generate-grpc-code-from-protos",
description: "Generate Swift code for gRPC services from protobuf definitions."
),
permissions: [
.writeToPackageDirectory(
reason:
"To write the generated Swift files back into the source directory of the package."
)
]
),
dependencies: [
.target(name: "protoc-gen-grpc-swift"),
.product(name: "protoc-gen-swift", package: "swift-protobuf"),
]
),
]

let package = Package(
Expand Down
10 changes: 4 additions & 6 deletions Plugins/GRPCProtobufGenerator/BuildPluginConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@

import Foundation

let configFileName = "grpc-swift-proto-generator-config.json"

/// The config of the build plugin.
struct BuildPluginConfig: Codable {
/// Config defining which components should be considered when generating source.
Expand Down Expand Up @@ -193,14 +191,14 @@ extension BuildPluginConfig.Protoc: Codable {

extension GenerationConfig {
init(buildPluginConfig: BuildPluginConfig, configFilePath: URL, outputPath: URL) {
self.server = buildPluginConfig.generate.servers
self.client = buildPluginConfig.generate.clients
self.message = buildPluginConfig.generate.messages
self.servers = buildPluginConfig.generate.servers
self.clients = buildPluginConfig.generate.clients
self.messages = buildPluginConfig.generate.messages
// Use path to underscores as it ensures output files are unique (files generated from
// "foo/bar.proto" won't collide with those generated from "bar/bar.proto" as they'll be
// uniquely named "foo_bar.(grpc|pb).swift" and "bar_bar.(grpc|pb).swift".
self.fileNaming = .pathToUnderscores
self.visibility = buildPluginConfig.generatedSource.accessLevel
self.accessLevel = buildPluginConfig.generatedSource.accessLevel
self.accessLevelOnImports = buildPluginConfig.generatedSource.accessLevelOnImports
// Generate absolute paths for the imports relative to the config file in which they are specified
self.importPaths = buildPluginConfig.protoc.importPaths.map { relativePath in
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2024, gRPC Authors All rights reserved.
* Copyright 2025, gRPC Authors All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -14,13 +14,12 @@
* limitations under the License.
*/

enum PluginError: Error {
// Build plugin
enum BuildPluginError: Error {
case incompatibleTarget(String)
case noConfigFilesFound
}

extension PluginError: CustomStringConvertible {
extension BuildPluginError: CustomStringConvertible {
var description: String {
switch self {
case .incompatibleTarget(let target):
Expand Down
12 changes: 5 additions & 7 deletions Plugins/GRPCProtobufGenerator/Plugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,9 @@ import PackagePlugin

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

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

@main
struct GRPCProtobufGenerator {
/// Build plugin code common to both invocation types: package manifest Xcode project
/// Build plugin common code
func createBuildCommands(
pluginWorkDirectory: URL,
tool: (String) throws -> PluginContext.Tool,
Expand All @@ -78,7 +76,7 @@ struct GRPCProtobufGenerator {
var commands: [Command] = []
for inputFile in inputFiles {
guard let (configFilePath, config) = configs.findApplicableConfig(for: inputFile) else {
throw PluginError.noConfigFilesFound
throw BuildPluginError.noConfigFilesFound
}

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

// unless *explicitly* opted-out
if config.client || config.server {
if config.clients || config.servers {
let grpcCommand = try protocGenGRPCSwiftCommand(
inputFile: inputFile,
config: config,
Expand All @@ -104,7 +102,7 @@ struct GRPCProtobufGenerator {
}

// unless *explicitly* opted-out
if config.message {
if config.messages {
let protoCommand = try protocGenSwiftCommand(
inputFile: inputFile,
config: config,
Expand Down
255 changes: 255 additions & 0 deletions Plugins/GRPCProtobufGeneratorCommand/CommandConfig.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
/*
* Copyright 2024, gRPC Authors All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import Foundation
import PackagePlugin

struct CommandConfig {
var common: GenerationConfig

var verbose: Bool
var dryRun: Bool

static let defaults = Self(
common: .init(
accessLevel: .internal,
servers: true,
clients: true,
messages: true,
fileNaming: .fullPath,
accessLevelOnImports: false,
importPaths: [],
outputPath: ""
),
verbose: false,
dryRun: false
)

static let parameterGroupSeparator = "--"
}

extension CommandConfig {
static func parse(
argumentExtractor argExtractor: inout ArgumentExtractor,
pluginWorkDirectory: URL
) throws -> CommandConfig {
var config = CommandConfig.defaults

for flag in OptionsAndFlags.allCases {
switch flag {
case .accessLevel:
if let value = argExtractor.extractSingleOption(named: flag.rawValue) {
if let accessLevel = GenerationConfig.AccessLevel(rawValue: value) {
config.common.accessLevel = accessLevel
} else {
throw CommandPluginError.unknownAccessLevel(value)
}
}

case .noServers:
// Handled by `.servers`
continue
case .servers:
let servers = argExtractor.extractFlag(named: OptionsAndFlags.servers.rawValue)
let noServers = argExtractor.extractFlag(named: OptionsAndFlags.noServers.rawValue)
if servers > 0 && noServers > 0 {
throw CommandPluginError.conflictingFlags(
OptionsAndFlags.servers.rawValue,
OptionsAndFlags.noServers.rawValue
)
} else if servers > 0 {
config.common.servers = true
} else if noServers > 0 {
config.common.servers = false
}

case .noClients:
// Handled by `.clients`
continue
case .clients:
let clients = argExtractor.extractFlag(named: OptionsAndFlags.clients.rawValue)
let noClients = argExtractor.extractFlag(named: OptionsAndFlags.noClients.rawValue)
if clients > 0 && noClients > 0 {
throw CommandPluginError.conflictingFlags(
OptionsAndFlags.clients.rawValue,
OptionsAndFlags.noClients.rawValue
)
} else if clients > 0 {
config.common.clients = true
} else if noClients > 0 {
config.common.clients = false
}

case .noMessages:
// Handled by `.messages`
continue
case .messages:
let messages = argExtractor.extractFlag(named: OptionsAndFlags.messages.rawValue)
let noMessages = argExtractor.extractFlag(named: OptionsAndFlags.noMessages.rawValue)
if messages > 0 && noMessages > 0 {
throw CommandPluginError.conflictingFlags(
OptionsAndFlags.messages.rawValue,
OptionsAndFlags.noMessages.rawValue
)
} else if messages > 0 {
config.common.messages = true
} else if noMessages > 0 {
config.common.messages = false
}

case .fileNaming:
if let value = argExtractor.extractSingleOption(named: flag.rawValue) {
if let fileNaming = GenerationConfig.FileNaming(rawValue: value) {
config.common.fileNaming = fileNaming
} else {
throw CommandPluginError.unknownFileNamingStrategy(value)
}
}

case .accessLevelOnImports:
if argExtractor.extractFlag(named: flag.rawValue) > 0 {
config.common.accessLevelOnImports = true
}

case .importPath:
config.common.importPaths = argExtractor.extractOption(named: flag.rawValue)

case .protocPath:
config.common.protocPath = argExtractor.extractSingleOption(named: flag.rawValue)

case .outputPath:
config.common.outputPath =
argExtractor.extractSingleOption(named: flag.rawValue)
?? pluginWorkDirectory.absoluteStringNoScheme

case .verbose:
let verbose = argExtractor.extractFlag(named: flag.rawValue)
config.verbose = verbose != 0

case .dryRun:
let dryRun = argExtractor.extractFlag(named: flag.rawValue)
config.dryRun = dryRun != 0

case .help:
() // handled elsewhere
}
}

if let argument = argExtractor.remainingArguments.first {
throw CommandPluginError.unknownOption(argument)
}

return config
}
}

extension ArgumentExtractor {
mutating func extractSingleOption(named optionName: String) -> String? {
let values = self.extractOption(named: optionName)
if values.count > 1 {
Diagnostics.warning(
"'--\(optionName)' was unexpectedly repeated, the first value will be used."
)
}
return values.first
}
}

/// All valid input options/flags
enum OptionsAndFlags: String, CaseIterable {
case servers
case noServers = "no-servers"
case clients
case noClients = "no-clients"
case messages
case noMessages = "no-messages"
case fileNaming = "file-naming"
case accessLevel = "access-level"
case accessLevelOnImports = "access-level-on-imports"
case importPath = "import-path"
case protocPath = "protoc-path"
case outputPath = "output-path"
case verbose
case dryRun = "dry-run"

case help
}

extension OptionsAndFlags {
func usageDescription() -> String {
switch self {
case .servers:
return "Generate server code. Generated by default."
case .noServers:
return "Do not generate server code. Generated by default."
case .clients:
return "Generate client code. Generated by default."
case .noClients:
return "Do not generate client code. Generated by default."
case .messages:
return "Generate message code. Generated by default."
case .noMessages:
return "Do not generate message code. Generated by default."
case .fileNaming:
return
"The naming scheme for output files [fullPath/pathToUnderscores/dropPath]. Defaults to fullPath."
case .accessLevel:
return
"The access level of the generated source [internal/public/package]. Defaults to internal."
case .accessLevelOnImports:
return "Whether imports should have explicit access levels. Defaults to false."
case .importPath:
return
"The directory in which to search for imports. May be specified multiple times. If none are specified the current working directory is used."
case .protocPath:
return "The path to the protoc binary."
case .dryRun:
return "Print but do not execute the protoc commands."
case .outputPath:
return "The directory into which the generated source files are created."
case .verbose:
return "Emit verbose output."
case .help:
return "Print this help."
}
}

static func printHelp(requested: Bool) {
let printMessage: (String) -> Void
if requested {
printMessage = { message in print(message) }
} else {
printMessage = Stderr.print
}

printMessage(
"Usage: swift package generate-grpc-code-from-protos [flags] [\(CommandConfig.parameterGroupSeparator)] [input files]"
)
printMessage("")
printMessage("Flags:")
printMessage("")

let spacing = 3
let maxLength =
(OptionsAndFlags.allCases.map(\.rawValue).max(by: { $0.count < $1.count })?.count ?? 0)
+ spacing
for flag in OptionsAndFlags.allCases {
printMessage(
" --\(flag.rawValue.padding(toLength: maxLength, withPad: " ", startingAt: 0))\(flag.usageDescription())"
)
}
}
}
Loading