diff --git a/.github/workflows/matrices/plugin-tests.json b/.github/workflows/matrices/plugin-tests.json new file mode 100644 index 0000000..d27466e --- /dev/null +++ b/.github/workflows/matrices/plugin-tests.json @@ -0,0 +1 @@ +{"config":[{"name":"Plugin tests (6.0)","swift_version":"6.0","runner":"ubuntu-latest","image":"swift:6.0-jammy","platform":"Linux","setup_command":"apt-get update -y -q && apt-get install -y -q curl protobuf-compiler","command":"curl -s https://raw.githubusercontent.com/grpc/grpc-swift-protobuf/package_plugins/dev/plugin-tests.sh | bash","command_arguments":""},{"name":"Plugin tests (nightly-6.0)","swift_version":"nightly-6.0","runner":"ubuntu-latest","image":"swiftlang/swift:nightly-6.0-jammy","platform":"Linux","setup_command":"apt-get update -y -q && apt-get install -y -q curl protobuf-compiler","command":"curl -s https://raw.githubusercontent.com/grpc/grpc-swift-protobuf/package_plugins/dev/plugin-tests.sh | bash","command_arguments":""},{"name":"Plugin tests (nightly-main)","swift_version":"nightly-main","runner":"ubuntu-latest","image":"swiftlang/swift:nightly-main-jammy","platform":"Linux","setup_command":"apt-get update -y -q && apt-get install -y -q curl protobuf-compiler","command":"curl -s https://raw.githubusercontent.com/grpc/grpc-swift-protobuf/package_plugins/dev/plugin-tests.sh | bash","command_arguments":""}]} diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index a60a4c0..29c7ce8 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -26,6 +26,13 @@ jobs: linux_nightly_6_0_arguments_override: "--explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable" linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable" + plugin-tests: + name: Plugin tests + uses: apple/swift-nio/.github/workflows/swift_load_test_matrix.yml@main + with: + name: "Plugin tests" + matrix_path: ".github/workflows/matrices/plugin-tests.json" + cxx-interop: name: Cxx interop uses: apple/swift-nio/.github/workflows/cxx_interop.yml@main diff --git a/IntegrationTests/PluginTests/test_01_top_level_config_file/.gitignore b/IntegrationTests/PluginTests/test_01_top_level_config_file/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/IntegrationTests/PluginTests/test_01_top_level_config_file/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/IntegrationTests/PluginTests/test_01_top_level_config_file/Package.swift b/IntegrationTests/PluginTests/test_01_top_level_config_file/Package.swift new file mode 100644 index 0000000..70d8ce2 --- /dev/null +++ b/IntegrationTests/PluginTests/test_01_top_level_config_file/Package.swift @@ -0,0 +1,51 @@ +// swift-tools-version: 6.0 +/* + * 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 PackageDescription + +let package = Package( + name: "grpc-adopter", + platforms: [ + .macOS(.v15), + .iOS(.v18), + .tvOS(.v18), + .watchOS(.v11), + .visionOS(.v2), + ], + dependencies: [ + .package( + path: "../../../../grpc-swift-protobuf" + ), + .package( + url: "https://github.com/grpc/grpc-swift.git", + exact: "2.0.0-beta.2" + ), + ], + targets: [ + .executableTarget( + name: "grpc-adopter", + dependencies: [ + .product(name: "GRPCCore", package: "grpc-swift"), + .product(name: "GRPCInProcessTransport", package: "grpc-swift"), + .product(name: "GRPCProtobuf", package: "grpc-swift-protobuf"), + ], + plugins: [ + .plugin(name: "GRPCGeneratorPlugin", package: "grpc-swift-protobuf") + ] + ) + ] +) diff --git a/IntegrationTests/PluginTests/test_01_top_level_config_file/Sources/Protos/HelloWorld.proto b/IntegrationTests/PluginTests/test_01_top_level_config_file/Sources/Protos/HelloWorld.proto new file mode 100644 index 0000000..32aab3a --- /dev/null +++ b/IntegrationTests/PluginTests/test_01_top_level_config_file/Sources/Protos/HelloWorld.proto @@ -0,0 +1,37 @@ +// Copyright 2015, 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. +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "io.grpc.examples.helloworld"; +option java_outer_classname = "HelloWorldProto"; +option objc_class_prefix = "HLW"; + +package helloworld; + +// The greeting service definition. +service Greeter { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply) {} +} + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greetings +message HelloReply { + string message = 1; +} diff --git a/IntegrationTests/PluginTests/test_01_top_level_config_file/Sources/adopter.swift b/IntegrationTests/PluginTests/test_01_top_level_config_file/Sources/adopter.swift new file mode 100644 index 0000000..1bb2612 --- /dev/null +++ b/IntegrationTests/PluginTests/test_01_top_level_config_file/Sources/adopter.swift @@ -0,0 +1,51 @@ +/* + * 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 GRPCCore +import GRPCInProcessTransport +import GRPCProtobuf + +@main +struct PluginAdopter { + static func main() async throws { + let inProcess = InProcessTransport() + try await withGRPCServer(transport: inProcess.server, services: [Greeter()]) { server in + try await withGRPCClient(transport: inProcess.client) { client in + try await Self.doRPC(Helloworld_Greeter.Client(wrapping: client)) + } + } + } + + static func doRPC(_ greeter: Helloworld_Greeter.Client) async throws { + do { + let reply = try await greeter.sayHello(.with { $0.name = "(ignored)" }) + print("Reply: \(reply.message)") + } catch { + print("Error: \(error)") + } + } +} + +struct Greeter: Helloworld_Greeter.SimpleServiceProtocol { + func sayHello( + request: Helloworld_HelloRequest, + context: ServerContext + ) async throws -> Helloworld_HelloReply { + return .with { reply in + reply.message = "Hello, world!" + } + } +} diff --git a/IntegrationTests/PluginTests/test_01_top_level_config_file/Sources/grpc-swift-config.json b/IntegrationTests/PluginTests/test_01_top_level_config_file/Sources/grpc-swift-config.json new file mode 100644 index 0000000..ddfa409 --- /dev/null +++ b/IntegrationTests/PluginTests/test_01_top_level_config_file/Sources/grpc-swift-config.json @@ -0,0 +1,3 @@ +{ + "visibility": "internal" +} diff --git a/IntegrationTests/PluginTests/test_02_peer_config_file/.gitignore b/IntegrationTests/PluginTests/test_02_peer_config_file/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/IntegrationTests/PluginTests/test_02_peer_config_file/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/IntegrationTests/PluginTests/test_02_peer_config_file/Package.swift b/IntegrationTests/PluginTests/test_02_peer_config_file/Package.swift new file mode 100644 index 0000000..70d8ce2 --- /dev/null +++ b/IntegrationTests/PluginTests/test_02_peer_config_file/Package.swift @@ -0,0 +1,51 @@ +// swift-tools-version: 6.0 +/* + * 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 PackageDescription + +let package = Package( + name: "grpc-adopter", + platforms: [ + .macOS(.v15), + .iOS(.v18), + .tvOS(.v18), + .watchOS(.v11), + .visionOS(.v2), + ], + dependencies: [ + .package( + path: "../../../../grpc-swift-protobuf" + ), + .package( + url: "https://github.com/grpc/grpc-swift.git", + exact: "2.0.0-beta.2" + ), + ], + targets: [ + .executableTarget( + name: "grpc-adopter", + dependencies: [ + .product(name: "GRPCCore", package: "grpc-swift"), + .product(name: "GRPCInProcessTransport", package: "grpc-swift"), + .product(name: "GRPCProtobuf", package: "grpc-swift-protobuf"), + ], + plugins: [ + .plugin(name: "GRPCGeneratorPlugin", package: "grpc-swift-protobuf") + ] + ) + ] +) diff --git a/IntegrationTests/PluginTests/test_02_peer_config_file/Sources/Protos/HelloWorld.proto b/IntegrationTests/PluginTests/test_02_peer_config_file/Sources/Protos/HelloWorld.proto new file mode 100644 index 0000000..32aab3a --- /dev/null +++ b/IntegrationTests/PluginTests/test_02_peer_config_file/Sources/Protos/HelloWorld.proto @@ -0,0 +1,37 @@ +// Copyright 2015, 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. +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "io.grpc.examples.helloworld"; +option java_outer_classname = "HelloWorldProto"; +option objc_class_prefix = "HLW"; + +package helloworld; + +// The greeting service definition. +service Greeter { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply) {} +} + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greetings +message HelloReply { + string message = 1; +} diff --git a/IntegrationTests/PluginTests/test_02_peer_config_file/Sources/Protos/grpc-swift-config.json b/IntegrationTests/PluginTests/test_02_peer_config_file/Sources/Protos/grpc-swift-config.json new file mode 100644 index 0000000..ddfa409 --- /dev/null +++ b/IntegrationTests/PluginTests/test_02_peer_config_file/Sources/Protos/grpc-swift-config.json @@ -0,0 +1,3 @@ +{ + "visibility": "internal" +} diff --git a/IntegrationTests/PluginTests/test_02_peer_config_file/Sources/adopter.swift b/IntegrationTests/PluginTests/test_02_peer_config_file/Sources/adopter.swift new file mode 100644 index 0000000..1bb2612 --- /dev/null +++ b/IntegrationTests/PluginTests/test_02_peer_config_file/Sources/adopter.swift @@ -0,0 +1,51 @@ +/* + * 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 GRPCCore +import GRPCInProcessTransport +import GRPCProtobuf + +@main +struct PluginAdopter { + static func main() async throws { + let inProcess = InProcessTransport() + try await withGRPCServer(transport: inProcess.server, services: [Greeter()]) { server in + try await withGRPCClient(transport: inProcess.client) { client in + try await Self.doRPC(Helloworld_Greeter.Client(wrapping: client)) + } + } + } + + static func doRPC(_ greeter: Helloworld_Greeter.Client) async throws { + do { + let reply = try await greeter.sayHello(.with { $0.name = "(ignored)" }) + print("Reply: \(reply.message)") + } catch { + print("Error: \(error)") + } + } +} + +struct Greeter: Helloworld_Greeter.SimpleServiceProtocol { + func sayHello( + request: Helloworld_HelloRequest, + context: ServerContext + ) async throws -> Helloworld_HelloReply { + return .with { reply in + reply.message = "Hello, world!" + } + } +} diff --git a/IntegrationTests/PluginTests/test_03_separate_service_message_protos/.gitignore b/IntegrationTests/PluginTests/test_03_separate_service_message_protos/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/IntegrationTests/PluginTests/test_03_separate_service_message_protos/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/IntegrationTests/PluginTests/test_03_separate_service_message_protos/Package.swift b/IntegrationTests/PluginTests/test_03_separate_service_message_protos/Package.swift new file mode 100644 index 0000000..70d8ce2 --- /dev/null +++ b/IntegrationTests/PluginTests/test_03_separate_service_message_protos/Package.swift @@ -0,0 +1,51 @@ +// swift-tools-version: 6.0 +/* + * 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 PackageDescription + +let package = Package( + name: "grpc-adopter", + platforms: [ + .macOS(.v15), + .iOS(.v18), + .tvOS(.v18), + .watchOS(.v11), + .visionOS(.v2), + ], + dependencies: [ + .package( + path: "../../../../grpc-swift-protobuf" + ), + .package( + url: "https://github.com/grpc/grpc-swift.git", + exact: "2.0.0-beta.2" + ), + ], + targets: [ + .executableTarget( + name: "grpc-adopter", + dependencies: [ + .product(name: "GRPCCore", package: "grpc-swift"), + .product(name: "GRPCInProcessTransport", package: "grpc-swift"), + .product(name: "GRPCProtobuf", package: "grpc-swift-protobuf"), + ], + plugins: [ + .plugin(name: "GRPCGeneratorPlugin", package: "grpc-swift-protobuf") + ] + ) + ] +) diff --git a/IntegrationTests/PluginTests/test_03_separate_service_message_protos/Sources/Protos/Messages.proto b/IntegrationTests/PluginTests/test_03_separate_service_message_protos/Sources/Protos/Messages.proto new file mode 100644 index 0000000..ce0db32 --- /dev/null +++ b/IntegrationTests/PluginTests/test_03_separate_service_message_protos/Sources/Protos/Messages.proto @@ -0,0 +1,31 @@ +// Copyright 2015, 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. +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "io.grpc.examples.helloworld"; +option java_outer_classname = "HelloWorldProto"; +option objc_class_prefix = "HLW"; + +package helloworld; + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greetings +message HelloReply { + string message = 1; +} diff --git a/IntegrationTests/PluginTests/test_03_separate_service_message_protos/Sources/Protos/Service.proto b/IntegrationTests/PluginTests/test_03_separate_service_message_protos/Sources/Protos/Service.proto new file mode 100644 index 0000000..aadc01e --- /dev/null +++ b/IntegrationTests/PluginTests/test_03_separate_service_message_protos/Sources/Protos/Service.proto @@ -0,0 +1,29 @@ +// Copyright 2015, 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. +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "io.grpc.examples.helloworld"; +option java_outer_classname = "HelloWorldProto"; +option objc_class_prefix = "HLW"; + +package helloworld; + +import "Messages.proto"; + +// The greeting service definition. +service Greeter { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply) {} +} diff --git a/IntegrationTests/PluginTests/test_03_separate_service_message_protos/Sources/Protos/grpc-swift-config.json b/IntegrationTests/PluginTests/test_03_separate_service_message_protos/Sources/Protos/grpc-swift-config.json new file mode 100644 index 0000000..ddfa409 --- /dev/null +++ b/IntegrationTests/PluginTests/test_03_separate_service_message_protos/Sources/Protos/grpc-swift-config.json @@ -0,0 +1,3 @@ +{ + "visibility": "internal" +} diff --git a/IntegrationTests/PluginTests/test_03_separate_service_message_protos/Sources/adopter.swift b/IntegrationTests/PluginTests/test_03_separate_service_message_protos/Sources/adopter.swift new file mode 100644 index 0000000..1bb2612 --- /dev/null +++ b/IntegrationTests/PluginTests/test_03_separate_service_message_protos/Sources/adopter.swift @@ -0,0 +1,51 @@ +/* + * 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 GRPCCore +import GRPCInProcessTransport +import GRPCProtobuf + +@main +struct PluginAdopter { + static func main() async throws { + let inProcess = InProcessTransport() + try await withGRPCServer(transport: inProcess.server, services: [Greeter()]) { server in + try await withGRPCClient(transport: inProcess.client) { client in + try await Self.doRPC(Helloworld_Greeter.Client(wrapping: client)) + } + } + } + + static func doRPC(_ greeter: Helloworld_Greeter.Client) async throws { + do { + let reply = try await greeter.sayHello(.with { $0.name = "(ignored)" }) + print("Reply: \(reply.message)") + } catch { + print("Error: \(error)") + } + } +} + +struct Greeter: Helloworld_Greeter.SimpleServiceProtocol { + func sayHello( + request: Helloworld_HelloRequest, + context: ServerContext + ) async throws -> Helloworld_HelloReply { + return .with { reply in + reply.message = "Hello, world!" + } + } +} diff --git a/Package.swift b/Package.swift index f059a2e..ea19942 100644 --- a/Package.swift +++ b/Package.swift @@ -26,6 +26,14 @@ let products: [Product] = [ name: "protoc-gen-grpc-swift", targets: ["protoc-gen-grpc-swift"] ), + .plugin( + name: "GRPCGeneratorPlugin", + targets: ["GRPCGeneratorPlugin"] + ), + .plugin( + name: "GRPCGeneratorCommand", + targets: ["GRPCGeneratorCommand"] + ), ] let dependencies: [Package.Dependency] = [ @@ -101,6 +109,37 @@ let targets: [Target] = [ ], swiftSettings: defaultSwiftSettings ), + + // Code generator build plugin + .plugin( + name: "GRPCGeneratorPlugin", + capability: .buildTool(), + dependencies: [ + "protoc-gen-grpc-swift", + .product(name: "protoc-gen-swift", package: "swift-protobuf"), + ] + ), + + // // Code generator SwiftPM command + .plugin( + name: "GRPCGeneratorCommand", + 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: [ + "protoc-gen-grpc-swift", + .product(name: "protoc-gen-swift", package: "swift-protobuf"), + ] + ), ] let package = Package( diff --git a/Plugins/GRPCGeneratorCommand/ConfigurationArguments.swift b/Plugins/GRPCGeneratorCommand/ConfigurationArguments.swift new file mode 100644 index 0000000..3d12a09 --- /dev/null +++ b/Plugins/GRPCGeneratorCommand/ConfigurationArguments.swift @@ -0,0 +1,210 @@ +/* + * 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 CommandConfiguration { + var common: CommonConfiguration + + var dryRun: Bool +} + +extension CommandConfiguration { + init(arguments: [String]) throws { + self.common = CommonConfiguration() + self.common.importPaths = [] + + var dryRun: Bool? + + var arguments = arguments + while arguments.count > 0 { + let argument = arguments.removeFirst() + if !argument.hasPrefix("-") { + continue + } + + let flag = try Flag(argument) + guard argument.count > 0 else { + throw PluginError.missingArgumentValue + } + let value = arguments.removeFirst() + + switch flag { + case .visibility: + switch value.lowercased() { + case "internal": + self.common.visibility = .`internal` + case "public": + self.common.visibility = .`public` + case "package": + self.common.visibility = .`package` + default: + Diagnostics.error("Unknown visibility \(value)") + } + case .server: + self.common.server = .init(value) + case .client: + self.common.client = .init(value) + case .message: + self.common.message = .init(value) + case .fileNaming: + switch value.lowercased() { + case "fullPath": + self.common.fileNaming = .fullPath + case "pathToUnderscores": + self.common.fileNaming = .pathToUnderscores + case "dropPath": + self.common.fileNaming = .dropPath + default: + Diagnostics.error("Unknown file naming strategy \(value)") + } + case .protoPathModuleMappings: + self.common.protoPathModuleMappings = value + case .useAccessLevelOnImports: + self.common.useAccessLevelOnImports = .init(value) + case .importPath: + // ! is safe because we set it to an empty array at the top of the method + self.common.importPaths!.append(value) + case .protocPath: + self.common.protocPath = value + case .output: + self.common.outputPath = value + case .dryRun: + dryRun = .init(value) + } + } + + // defaults + self.dryRun = dryRun ?? false + } +} + +func inputFiles(from arguments: [String]) -> [String] { + var files: [String] = [] + var arguments = arguments + while arguments.count > 0 { + let argument = arguments.removeFirst() + if argument.hasPrefix("-") { + _ = arguments.removeFirst() // also discard the value + continue // discard the flag + } + files.append(argument) + } + return files +} + +extension Bool { + private init(_ string: String) { + switch string.lowercased() { + case "true": + self = true + case "false": + self = false + default: + Diagnostics.error("Unknown boolean \(string)") + self = false + } + } +} + +enum Flag: CaseIterable { + case visibility + case server + case client + case message + case fileNaming + case protoPathModuleMappings + case useAccessLevelOnImports + case importPath + case protocPath + case output + + case dryRun + + init(_ argument: String) throws { + switch argument { + case "--visibility": + self = .visibility + case "--server": + self = .server + case "--client": + self = .client + case "--message": + self = .message + case "--file-naming": + self = .fileNaming + case "--proto-path-module-mappings": + self = .protoPathModuleMappings + case "--use-access-level-on-imports": + self = .useAccessLevelOnImports + case "--import-path", "-I": + self = .importPath + case "--protoc-path": + self = .protocPath + case "--output": + self = .output + case "--dry-run": + self = .dryRun + case "--help": + throw PluginError.helpRequested + default: + Diagnostics.error("Unknown flag \(argument)") + throw PluginError.unknownOption(argument) + } + } +} + +extension Flag { + func usageDescription() -> String { + switch self { + case .visibility: + return "--visibility The visibility of the generated files." + case .server: + return "--server Whether server code is generated." + case .client: + return "--client Whether client code is generated." + case .message: + return "--message Whether message code is generated." + case .fileNaming: + return + "--file-naming The naming of output files with respect to the path of the source file." + case .protoPathModuleMappings: + return "--proto-path-module-mappings Path to module map .asciipb file." + case .useAccessLevelOnImports: + return "--use-access-level-on-imports Whether imports should have explicit access levels." + case .importPath: + return "--import-path The directory in which to search for imports." + case .protocPath: + return "--protoc-path The path to the `protoc` binary." + case .dryRun: + return "--dry-run Print but do not execute the protoc commands." + case .output: + return + "--output The path into which the generated source files are created." + } + } + + static func printHelp() { + print("Usage: swift package generate-grpc-code-from-protos [flags] [input files]") + print("") + print("Flags:") + print("") + for flag in Flag.allCases { print(" \(flag.usageDescription())") } + print("") + print(" --help Print this help.") + } +} diff --git a/Plugins/GRPCGeneratorCommand/Plugin.swift b/Plugins/GRPCGeneratorCommand/Plugin.swift new file mode 100644 index 0000000..f0d3ba2 --- /dev/null +++ b/Plugins/GRPCGeneratorCommand/Plugin.swift @@ -0,0 +1,116 @@ +/* + * 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 + +@main +struct GRPCGeneratorCommandPlugin: CommandPlugin { + /// Perform command, the entry-point when using a Package manifest. + func performCommand(context: PluginContext, arguments: [String]) async throws { + + // MARK: Configuration + let commandConfig: CommandConfiguration + do { + commandConfig = try CommandConfiguration(arguments: arguments) + } catch PluginError.helpRequested { + Flag.printHelp() + return // don't throw, the user requested this + } catch { + Flag.printHelp() + throw error + } + let config = commandConfig.common + + let inputFiles = inputFiles(from: arguments) + print("InputFiles: \(inputFiles.joined(separator: ", "))") + + let protocPath = try deriveProtocPath(using: config, tool: context.tool) + let protocGenGRPCSwiftPath = try context.tool(named: "protoc-gen-grpc-swift").url + let protocGenSwiftPath = try context.tool(named: "protoc-gen-swift").url + + let outputDirectory = + config.outputPath.map { URL(fileURLWithPath: $0) } ?? context.pluginWorkDirectoryURL + print("Generated files will be written to: '\(outputDirectory.relativePath)'") + + let inputFileURLs = inputFiles.map { URL(fileURLWithPath: $0) } + + // MARK: proto-gen-grpc-swift + if config.client != false || config.server != false { + let arguments = constructProtocGenGRPCSwiftArguments( + config: config, + using: config.fileNaming, + inputFiles: inputFileURLs, + protoDirectoryPaths: inputFileURLs.map { $0.deletingLastPathComponent() }, + protocGenGRPCSwiftPath: protocGenGRPCSwiftPath, + outputDirectory: outputDirectory + ) + + printProtocInvocation(protocPath, arguments) + if !commandConfig.dryRun { + let process = try Process.run(protocPath, arguments: arguments) + process.waitUntilExit() + + if process.terminationReason == .exit && process.terminationStatus == 0 { + print("Generated gRPC Swift files for \(inputFiles.joined(separator: ", ")).") + } else { + let problem = "\(process.terminationReason):\(process.terminationStatus)" + Diagnostics.error("Generating gRPC Swift files failed: \(problem)") + } + } + } + + // MARK: proto-gen-swift + if config.message != false { + let arguments = constructProtocGenSwiftArguments( + config: config, + using: config.fileNaming, + inputFiles: inputFileURLs, + protoDirectoryPaths: inputFileURLs.map { $0.deletingLastPathComponent() }, + protocGenSwiftPath: protocGenSwiftPath, + outputDirectory: outputDirectory + ) + + printProtocInvocation(protocPath, arguments) + if !commandConfig.dryRun { + let process = try Process.run(protocPath, arguments: arguments) + process.waitUntilExit() + + if process.terminationReason == .exit && process.terminationStatus == 0 { + print("Generated protobuf message Swift files for \(inputFiles.joined(separator: ", ")).") + } else { + let problem = "\(process.terminationReason):\(process.terminationStatus)" + Diagnostics.error("Generating Protobuf message Swift files failed: \(problem)") + } + } + } + } +} + +/// Print a single invocation of `protoc` +/// - Parameters: +/// - executableURL: The path to the `protoc` executable. +/// - arguments: The arguments to be passed to `protoc`. +func printProtocInvocation(_ executableURL: URL, _ arguments: [String]) { + print("protoc invocation:") + print(" \(executableURL.relativePath) \\") + for argument in arguments[.. PluginContext.Tool, + inputFiles: [URL], + configFiles: [URL], + targetName: String + ) throws -> [Command] { + let configs = try readConfigurationFiles(configFiles, pluginWorkDirectory: pluginWorkDirectory) + + let protocGenGRPCSwiftPath = try tool("protoc-gen-grpc-swift").url + let protocGenSwiftPath = try tool("protoc-gen-swift").url + + var commands: [Command] = [] + for inputFile in inputFiles { + guard let configFile = findApplicableConfigFor(file: inputFile, from: configs.keys.map { $0 }) + else { + throw PluginError.noConfigurationFilesFound + } + guard let config = configs[configFile] else { + throw PluginError.expectedConfigurationNotFound(configFile.relativePath) + } + + let protocPath = try deriveProtocPath(using: config, tool: tool) + let protoDirectoryPath = configFile.deletingLastPathComponent() + + // unless *explicitly* opted-out + if config.client != false || config.server != false { + let grpcCommand = try protocGenGRPCSwiftCommand( + inputFile: inputFile, + configFile: configFile, + config: config, + protoDirectoryPath: protoDirectoryPath, + protocPath: protocPath, + protocGenGRPCSwiftPath: protocGenGRPCSwiftPath + ) + commands.append(grpcCommand) + } + + // unless *explicitly* opted-out + if config.message != false { + let protoCommand = try protocGenSwiftCommand( + inputFile: inputFile, + configFile: configFile, + config: config, + protoDirectoryPath: protoDirectoryPath, + protocPath: protocPath, + protocGenSwiftPath: protocGenSwiftPath + ) + commands.append(protoCommand) + } + } + + return commands + } +} + +/// Reads the configuration files at the supplied URLs into memory +/// - Parameter configurationFiles: URLs from which to load configuration +/// - Returns: A map of source URLs to loaded configuration +func readConfigurationFiles( + _ configurationFiles: [URL], + pluginWorkDirectory: URL +) throws -> [URL: CommonConfiguration] { + var configs: [URL: CommonConfiguration] = [:] + for configFile in configurationFiles { + let data = try Data(contentsOf: configFile) + let configuration = try JSONDecoder().decode(ConfigurationFile.self, from: data) + + var config = CommonConfiguration(configurationFile: configuration) + // hard-code full-path to avoid collisions since this goes into a temporary directory anyway + config.fileNaming = .fullPath + // the output directory mandated by the plugin system + config.outputPath = String(pluginWorkDirectory.relativePath) + configs[configFile] = config + } + return configs +} + +/// Finds the most precisely relevant config file for a given proto file URL. +/// - Parameters: +/// - file: The path to the proto file to be matched. +/// - configFiles: The paths to all known configuration files. +/// - Returns: The path to the most precisely relevant config file if one is found, otherwise `nil`. +func findApplicableConfigFor(file: URL, from configFiles: [URL]) -> URL? { + let filePathComponents = file.pathComponents + for endComponent in (0 ..< filePathComponents.count).reversed() { + for configFile in configFiles { + if filePathComponents[.. PackagePlugin.Command { + guard let fileNaming = config.fileNaming else { + assertionFailure("Missing file naming strategy - should be hard-coded.") + throw PluginError.missingFileNamingStrategy + } + + guard let outputPath = config.outputPath else { + assertionFailure("Missing output path - should be hard-coded.") + throw PluginError.missingOutputPath + } + let outputPathURL = URL(fileURLWithPath: outputPath) + + let outputFilePath = deriveOutputFilePath( + for: inputFile, + using: fileNaming, + protoDirectoryPath: protoDirectoryPath, + outputDirectory: outputPathURL, + outputExtension: "grpc.swift" + ) + + let arguments = constructProtocGenGRPCSwiftArguments( + config: config, + using: fileNaming, + inputFiles: [inputFile], + protoDirectoryPaths: [protoDirectoryPath], + protocGenGRPCSwiftPath: protocGenGRPCSwiftPath, + outputDirectory: outputPathURL + ) + + return Command.buildCommand( + displayName: "Generating gRPC Swift files for \(inputFile.relativePath)", + executable: protocPath, + arguments: arguments, + inputFiles: [inputFile, protocGenGRPCSwiftPath], + outputFiles: [outputFilePath] + ) +} + +/// Construct the command to invoke `protoc` with the `proto-gen-swift` plugin. +/// - Parameters: +/// - inputFile: The input `.proto` file. +/// - configFile: The path file containing configuration for this operation. +/// - config: The configuration for this operation. +/// - protoDirectoryPath: The root path to the source `.proto` files used as the reference for relative path naming schemes. +/// - protocPath: The path to `protoc` +/// - protocGenSwiftPath: The path to `proto-gen-grpc-swift`. +/// - Returns: The command to invoke `protoc` with the `proto-gen-swift` plugin. +func protocGenSwiftCommand( + inputFile: URL, + configFile: URL, + config: CommonConfiguration, + protoDirectoryPath: URL, + protocPath: URL, + protocGenSwiftPath: URL +) throws -> PackagePlugin.Command { + guard let fileNaming = config.fileNaming else { + assertionFailure("Missing file naming strategy - should be hard-coded.") + throw PluginError.missingFileNamingStrategy + } + + guard let outputPath = config.outputPath else { + assertionFailure("Missing output path - should be hard-coded.") + throw PluginError.missingOutputPath + } + let outputPathURL = URL(fileURLWithPath: outputPath) + + let outputFilePath = deriveOutputFilePath( + for: inputFile, + using: fileNaming, + protoDirectoryPath: protoDirectoryPath, + outputDirectory: outputPathURL, + outputExtension: "pb.swift" + ) + + let arguments = constructProtocGenSwiftArguments( + config: config, + using: fileNaming, + inputFiles: [inputFile], + protoDirectoryPaths: [protoDirectoryPath], + protocGenSwiftPath: protocGenSwiftPath, + outputDirectory: outputPathURL + ) + + return Command.buildCommand( + displayName: "Generating protobuf Swift files for \(inputFile.relativePath)", + executable: protocPath, + arguments: arguments, + inputFiles: [inputFile, protocGenSwiftPath], + outputFiles: [outputFilePath] + ) +} + +// Entry-point when using Package manifest +extension GRPCGeneratorPlugin: BuildToolPlugin, LocalizedError { + /// 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) + } + let configFiles = swiftTarget.sourceFiles(withSuffix: "grpc-swift-config.json").map { $0.url } + let inputFiles = swiftTarget.sourceFiles(withSuffix: ".proto").map { $0.url } + return try createBuildCommands( + pluginWorkDirectory: context.pluginWorkDirectoryURL, + tool: context.tool, + inputFiles: inputFiles, + configFiles: configFiles, + targetName: target.name + ) + } +} + +#if canImport(XcodeProjectPlugin) +import XcodeProjectPlugin + +// Entry-point when using Xcode projects +extension GRPCGeneratorPlugin: 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 == "grpc-swift-config.json" + }.map { $0.url } + let inputFiles = target.inputFiles.filter { $0.url.lastPathComponent.hasSuffix(".proto") }.map { + $0.url + } + return try createBuildCommands( + pluginWorkDirectory: context.pluginWorkDirectoryURL, + tool: context.tool, + inputFiles: inputFiles, + configFiles: configFiles, + targetName: target.displayName + ) + } +} +#endif diff --git a/Plugins/GRPCGeneratorPlugin/PluginsShared b/Plugins/GRPCGeneratorPlugin/PluginsShared new file mode 120000 index 0000000..de623a5 --- /dev/null +++ b/Plugins/GRPCGeneratorPlugin/PluginsShared @@ -0,0 +1 @@ +../PluginsShared \ No newline at end of file diff --git a/Plugins/PluginsShared/CommonConfiguration.swift b/Plugins/PluginsShared/CommonConfiguration.swift new file mode 100644 index 0000000..c705c2e --- /dev/null +++ b/Plugins/PluginsShared/CommonConfiguration.swift @@ -0,0 +1,77 @@ +/* + * 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. + */ + +/// The configuration common to the build and command plugins. +struct CommonConfiguration: Codable { + /// The visibility of the generated files. + enum Visibility: String, Codable { + /// The generated files should have `internal` access level. + case `internal` = "Internal" + /// The generated files should have `public` access level. + case `public` = "Public" + /// The generated files should have `package` access level. + case `package` = "Package" + } + + /// The naming of output files with respect to the path of the source file. + /// + /// For an input of `foo/bar/baz.proto` the following output file will be generated: + /// - `FullPath`: `foo/bar/baz.grpc.swift` + /// - `PathToUnderscore`: `foo_bar_baz.grpc.swift` + /// - `DropPath`: `baz.grpc.swift` + enum FileNaming: String, Codable { + /// Replicate the input file path with the output file(s). + case fullPath = "FullPath" + /// Convert path directory delimiters to underscores. + case pathToUnderscores = "PathToUnderscores" + /// Generate output files using only the base name of the inout file, ignoring the path. + case dropPath = "DropPath" + } + + /// The visibility of the generated files. + var visibility: Visibility? + /// Whether server code is generated. + var server: Bool? + /// Whether client code is generated. + var client: Bool? + /// Whether message code is generated. + var message: Bool? + // /// Whether reflection data is generated. + // var reflectionData: Bool? + /// The naming of output files with respect to the path of the source file. + var fileNaming: FileNaming? + /// Path to module map .asciipb file. + var protoPathModuleMappings: String? + /// Whether imports should have explicit access levels. + var useAccessLevelOnImports: Bool? + + /// Specify the directory in which to search for + /// imports. May be specified multiple times; + /// directories will be searched in order. + /// The target source directory is always appended + /// to the import paths. + var importPaths: [String]? + + /// The path to the `protoc` binary. + /// + /// If this is not set, SPM will try to find the tool itself. + var protocPath: String? + + /// The path into which the generated source files are created. + /// + /// If this is not set, the plugin will use a default path (see plugin for details). + var outputPath: String? +} diff --git a/Plugins/PluginsShared/PluginError.swift b/Plugins/PluginsShared/PluginError.swift new file mode 100644 index 0000000..8bdbf63 --- /dev/null +++ b/Plugins/PluginsShared/PluginError.swift @@ -0,0 +1,34 @@ +/* + * 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 + +enum PluginError: Error, LocalizedError { + case placeholder + + // Build plugin + case incompatibleTarget(String) + case noConfigurationFilesFound + case expectedConfigurationNotFound(String) + case missingFileNamingStrategy + case missingOutputPath + + // Command plugin + case unknownOption(String) + case missingArgumentValue + + case helpRequested +} diff --git a/Plugins/PluginsShared/PluginUtils.swift b/Plugins/PluginsShared/PluginUtils.swift new file mode 100644 index 0000000..9e9afc5 --- /dev/null +++ b/Plugins/PluginsShared/PluginUtils.swift @@ -0,0 +1,204 @@ +/* + * 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 + +/// Derive the path to the instance of `protoc` to be used. +/// - Parameters: +/// - config: The supplied configuration. If no path is supplied then one is discovered using the `PROTOC_PATH` environment variable or the `findTool`. +/// - findTool: The context-supplied tool which is used to attempt to discover the path to a `protoc` binary. +/// - Returns: The path to the instance of `protoc` to be used. +func deriveProtocPath( + using config: CommonConfiguration, + tool findTool: (String) throws -> PackagePlugin.PluginContext.Tool +) throws -> URL { + if let configuredProtocPath = config.protocPath { + return URL(fileURLWithPath: configuredProtocPath) + } else if let environmentPath = ProcessInfo.processInfo.environment["PROTOC_PATH"] { + // The user set the env variable, so let's take that + return URL(fileURLWithPath: environmentPath) + } else { + // The user didn't set anything so let's try see if SPM can find a binary for us + return try findTool("protoc").url + } +} + +/// Construct the arguments to be passed to `protoc` when invoking the `proto-gen-swift` `protoc` plugin. +/// - Parameters: +/// - config: The configuration for this operation. +/// - fileNaming: The file naming scheme to be used. +/// - inputFiles: The input `.proto` files. +/// - protoDirectoryPaths: The directories in which `protoc` will search for imports. +/// - protocGenSwiftPath: The path to the `proto-gen-swift` `protoc` plugin. +/// - outputDirectory: The directory in which generated source files are created. +/// - Returns: The constructed arguments to be passed to `protoc` when invoking the `proto-gen-swift` `protoc` plugin. +func constructProtocGenSwiftArguments( + config: CommonConfiguration, + using fileNaming: CommonConfiguration.FileNaming?, + inputFiles: [URL], + protoDirectoryPaths: [URL], + protocGenSwiftPath: URL, + outputDirectory: URL +) -> [String] { + // Construct the `protoc` arguments. + var protocArgs = [ + "--plugin=protoc-gen-swift=\(protocGenSwiftPath.relativePath)", + "--swift_out=\(outputDirectory.relativePath)", + ] + + // Add the visibility if it was set + if let visibility = config.visibility { + protocArgs.append("--swift_opt=Visibility=\(visibility.rawValue)") + } + + // Add the file naming + if let fileNaming = fileNaming { + protocArgs.append("--swift_opt=FileNaming=\(fileNaming.rawValue)") + } + + // TODO: Don't currently support implementation only imports + // // Add the implementation only imports flag if it was set + // if let implementationOnlyImports = config.implementationOnlyImports { + // protocArgs.append("--swift_opt=ImplementationOnlyImports=\(implementationOnlyImports)") + // } + + // Add the useAccessLevelOnImports only imports flag if it was set + if let useAccessLevelOnImports = config.useAccessLevelOnImports { + protocArgs.append("--swift_opt=UseAccessLevelOnImports=\(useAccessLevelOnImports)") + } + + protocArgs.append(contentsOf: protoDirectoryPaths.map { "--proto_path=\($0.relativePath)" }) + + protocArgs.append(contentsOf: inputFiles.map { $0.relativePath }) + + return protocArgs +} + +/// Construct the arguments to be passed to `protoc` when invoking the `proto-gen-grpc-swift` `protoc` plugin. +/// - Parameters: +/// - config: The configuration for this operation. +/// - fileNaming: The file naming scheme to be used. +/// - inputFiles: The input `.proto` files. +/// - protoDirectoryPaths: The directories in which `protoc` will search for imports. +/// - protocGenGRPCSwiftPath: The path to the `proto-gen-grpc-swift` `protoc` plugin. +/// - outputDirectory: The directory in which generated source files are created. +/// - Returns: The constructed arguments to be passed to `protoc` when invoking the `proto-gen-grpc-swift` `protoc` plugin. +func constructProtocGenGRPCSwiftArguments( + config: CommonConfiguration, + using fileNaming: CommonConfiguration.FileNaming?, + inputFiles: [URL], + protoDirectoryPaths: [URL], + protocGenGRPCSwiftPath: URL, + outputDirectory: URL +) -> [String] { + // Construct the `protoc` arguments. + var protocArgs = [ + "--plugin=protoc-gen-grpc-swift=\(protocGenGRPCSwiftPath.relativePath)", + "--grpc-swift_out=\(outputDirectory.relativePath)", + ] + + if let importPaths = config.importPaths { + for path in importPaths { + protocArgs.append("-I") + protocArgs.append("\(path)") + } + } + + if let visibility = config.visibility { + protocArgs.append("--grpc-swift_opt=Visibility=\(visibility.rawValue.capitalized)") + } + + if let generateServerCode = config.server { + protocArgs.append("--grpc-swift_opt=Server=\(generateServerCode)") + } + + if let generateClientCode = config.client { + protocArgs.append("--grpc-swift_opt=Client=\(generateClientCode)") + } + + // TODO: Don't currently support reflection data + // if let generateReflectionData = config.reflectionData { + // protocArgs.append("--grpc-swift_opt=ReflectionData=\(generateReflectionData)") + // } + + if let fileNaming = fileNaming { + protocArgs.append("--grpc-swift_opt=FileNaming=\(fileNaming.rawValue)") + } + + if let protoPathModuleMappings = config.protoPathModuleMappings { + protocArgs.append("--grpc-swift_opt=ProtoPathModuleMappings=\(protoPathModuleMappings)") + } + + if let useAccessLevelOnImports = config.useAccessLevelOnImports { + protocArgs.append("--grpc-swift_opt=UseAccessLevelOnImports=\(useAccessLevelOnImports)") + } + + protocArgs.append(contentsOf: protoDirectoryPaths.map { "--proto_path=\($0.relativePath)" }) + + protocArgs.append(contentsOf: inputFiles.map { $0.relativePath }) + + return protocArgs +} + +/// Derive the expected output file path to match the behavior of the `proto-gen-swift` and `proto-gen-grpc-swift` `protoc` plugins. +/// - Parameters: +/// - inputFile: The input `.proto` file. +/// - fileNaming: The file naming scheme. +/// - protoDirectoryPath: The root path to the source `.proto` files used as the reference for relative path naming schemes. +/// - outputDirectory: The directory in which generated source files are created. +/// - outputExtension: The file extension to be appended to generated files in-place of `.proto`. +/// - Returns: The expected output file path. +func deriveOutputFilePath( + for inputFile: URL, + using fileNaming: CommonConfiguration.FileNaming, + protoDirectoryPath: URL, + outputDirectory: URL, + outputExtension: String +) -> URL { + // The name of the output file is based on the name of the input file. + // We validated in the beginning that every file has the suffix of .proto + // This means we can just drop the last 5 elements and append the new suffix + let lastPathComponentRoot = inputFile.lastPathComponent.dropLast(5) + let lastPathComponent = String(lastPathComponentRoot + outputExtension) + + // find the inputFile path relative to the proto directory + var relativePathComponents = inputFile.deletingLastPathComponent().pathComponents + for protoDirectoryPathComponent in protoDirectoryPath.pathComponents { + if relativePathComponents.first == protoDirectoryPathComponent { + relativePathComponents.removeFirst() + } else { + break + } + } + + switch fileNaming { + case .dropPath: + let outputFileName = lastPathComponent + return outputDirectory.appendingPathComponent(outputFileName) + case .fullPath: + let outputFileComponents = relativePathComponents + [lastPathComponent] + var outputFilePath = outputDirectory + for outputFileComponent in outputFileComponents { + outputFilePath.append(component: outputFileComponent) + } + return outputFilePath + case .pathToUnderscores: + let outputFileComponents = relativePathComponents + [lastPathComponent] + let outputFileName = outputFileComponents.joined(separator: "_") + return outputDirectory.appendingPathComponent(outputFileName) + } +} diff --git a/Sources/protoc-gen-grpc-swift/GenerateGRPC.swift b/Sources/protoc-gen-grpc-swift/GenerateGRPC.swift index c38ec83..336e173 100644 --- a/Sources/protoc-gen-grpc-swift/GenerateGRPC.swift +++ b/Sources/protoc-gen-grpc-swift/GenerateGRPC.swift @@ -63,10 +63,6 @@ final class GenerateGRPC: CodeGenerator { ) } - if descriptor.services.isEmpty { - continue - } - try self.generateV2Stubs(descriptor, options: options, outputs: outputs) } } diff --git a/dev/plugin-tests.sh b/dev/plugin-tests.sh new file mode 100755 index 0000000..81478fa --- /dev/null +++ b/dev/plugin-tests.sh @@ -0,0 +1,38 @@ +#!/bin/bash +## 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. + +set -euo pipefail + +log() { printf -- "** %s\n" "$*" >&2; } +error() { printf -- "** ERROR: %s\n" "$*" >&2; } +fatal() { error "$@"; exit 1; } + +source_dir=$(pwd) +pluginTests="${source_dir}/IntegrationTests/PluginTests" + +for dir in "$pluginTests"/test_*/ ; do + if [[ -f "$dir/Package.swift" ]]; then + pluginTest=$(basename "$dir") + log "Building '$pluginTest' plugin test" + + if ! build_output=$(swift build --package-path "$dir" 2>&1); then + # Only print the build output on failure. + echo "$build_output" + fatal "Build failed for '$pluginTest'" + else + log "Build succeeded for '$pluginTest'" + fi + fi +done