From f5d026ce41851e866d29bf6b9dbdaf564363b3c9 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Tue, 22 Jul 2025 09:38:46 +0100 Subject: [PATCH] Allow commnad plugin to take a dir as input Motivation: The command plugin requires proto files to be passed as separate inputs. While expanding these with a wildcard is straightforward, doing so usually requires the user to also set an import path. Modifications: - Allow directories to be used as input to the command plugin. Doing so also uses the directory as an import path. - Improve the help text for the command plugin and provide example calls Result: Easier to use the command plugin. In many cases users will only need to specify the directory containing their protos and the output directory. --- .../CommandConfig.swift | 113 +++++++++--------- .../CommandPluginError.swift | 6 +- .../GRPCProtobufGeneratorCommand/Plugin.swift | 55 +++++++-- 3 files changed, 108 insertions(+), 66 deletions(-) diff --git a/Plugins/GRPCProtobufGeneratorCommand/CommandConfig.swift b/Plugins/GRPCProtobufGeneratorCommand/CommandConfig.swift index 2ac25dd..6000480 100644 --- a/Plugins/GRPCProtobufGeneratorCommand/CommandConfig.swift +++ b/Plugins/GRPCProtobufGeneratorCommand/CommandConfig.swift @@ -185,67 +185,68 @@ enum OptionsAndFlags: String, CaseIterable { } 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 var helpText: String { + """ + USAGE: swift package generate-grpc-code-from-protos [[ [--]] ... + + ARGUMENTS: + The '.proto' files or directories containing them. + + OPTIONS: + --servers/--no-servers Generate server code (default: --servers) + --clients/--no-clients Generate client code (default: --clients) + --messages/--no-messages Generate message code (default: --messages) + --access-level Access level of generated code (internal/public/package) + (default: internal) + --access-level-on-imports Whether imports have explicit access levels + --protoc-path Path to the protoc binary + --import-path Directory to search for imports, may be specified + multiple times. If none are specified the current + working directory is used. + --file-naming The naming scheme for generated files + (fullPath/pathToUnderscores/dropPath) + (default: fullPath). + --output-path Directory to generate files into + --verbose Emit verbose output + --dry-run Print but don't execute the protoc commands + --help Print this help + + EXAMPLES: + + swift package generate-grpc-code-from-protos service.proto + Generates servers, clients, and messages from 'service.proto' into + the current working directory. + + swift package generate-grpc-code-from-protos --no-clients --no-messages -- service1.proto service2.proto + Generate only servers from service1.proto and service2.proto into the + current working directory. + + swift package generate-grpc-code-from-protos --output-path Generated --access-level public -- Protos + Generate server, clients, and messages from all .proto files contained + within the 'Protos' directory into the 'Generated' directory at the + public access level. + + swift package --allow-writing-to-package-directory generate-grpc-code-from-protos --output-path Sources/Generated -- service.proto + Generates code from service.proto into the Sources/Generated directory + within a Swift Package without asking for permission to do so. + + PERMISSIONS: + Swift Package Manager command plugins require permission to create files. + You'll be prompted to give generate-grpc-code-from-protos permission + when running it. + + You can grant permissions by specifying --allow-writing-to-package-directory + or --allow-writing-to-directory to the swift package command. + + See swift package plugin --help for more info. + """ } static func printHelp(requested: Bool) { - let printMessage: (String) -> Void if requested { - printMessage = { message in print(message) } + print(Self.helpText) } 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())" - ) + Stderr.print(Self.helpText) } } } diff --git a/Plugins/GRPCProtobufGeneratorCommand/CommandPluginError.swift b/Plugins/GRPCProtobufGeneratorCommand/CommandPluginError.swift index 8d0f27c..640d615 100644 --- a/Plugins/GRPCProtobufGeneratorCommand/CommandPluginError.swift +++ b/Plugins/GRPCProtobufGeneratorCommand/CommandPluginError.swift @@ -47,15 +47,15 @@ extension CommandPluginError: CustomStringConvertible { case .invalidInputFiles(let files): var lines: [String] = [] - lines.append("Invalid input file(s)") + lines.append("Found \(files.count) invalid input file(s)") lines.append("") - lines.append("Found \(files.count) input(s) not ending in '.proto':") + lines.append("The following don't exist or aren't '.proto' files or directories:") for file in files { lines.append("- \(file)") } lines.append("") lines.append("All options must be before '--', and all input files must be") - lines.append("after '--'. Input files must end in '.proto'.") + lines.append("after '--'. Inputs be '.proto' files or directories.") lines.append("") lines.append("See --help for more information.") return lines.joined(separator: "\n") diff --git a/Plugins/GRPCProtobufGeneratorCommand/Plugin.swift b/Plugins/GRPCProtobufGeneratorCommand/Plugin.swift index 29af0c2..b17ca99 100644 --- a/Plugins/GRPCProtobufGeneratorCommand/Plugin.swift +++ b/Plugins/GRPCProtobufGeneratorCommand/Plugin.swift @@ -57,13 +57,10 @@ struct GRPCProtobufGeneratorCommandPlugin { } // Split the input into options and input files. - let (flagsAndOptions, inputFiles) = self.splitArgs(arguments) + let (flagsAndOptions, inputs) = self.splitArgs(arguments) // Check the input files all look like protos - let nonProtoInputs = inputFiles.filter { !$0.hasSuffix(".proto") } - if !nonProtoInputs.isEmpty { - throw CommandPluginError.invalidInputFiles(nonProtoInputs) - } + let (inputFiles, extraImportPaths) = try self.checkAndExpandInputs(inputs) if inputFiles.isEmpty { throw CommandPluginError.missingInputFile @@ -101,7 +98,7 @@ struct GRPCProtobufGeneratorCommandPlugin { config: config, fileNaming: config.fileNaming, inputFiles: inputFileURLs, - protoDirectoryPaths: config.importPaths, + protoDirectoryPaths: config.importPaths + extraImportPaths, protocGenGRPCSwiftPath: protocGenGRPCSwiftPath, outputDirectory: outputDirectory ) @@ -124,7 +121,7 @@ struct GRPCProtobufGeneratorCommandPlugin { config: config, fileNaming: config.fileNaming, inputFiles: inputFileURLs, - protoDirectoryPaths: config.importPaths, + protoDirectoryPaths: config.importPaths + extraImportPaths, protocGenSwiftPath: protocGenSwiftPath, outputDirectory: outputDirectory ) @@ -159,6 +156,50 @@ struct GRPCProtobufGeneratorCommandPlugin { return (options, inputs) } + + private func checkAndExpandInputs( + _ files: [String] + ) throws -> (protos: [String], directories: [String]) { + let fileManager = FileManager.default + var invalidInputFiles = [String]() + var protos = [String]() + var dirs = [String]() + + for file in files { + // Check that each file: + // a) exists, and + // b) is either a '.proto' file or a directory. + // + // If the file is a directory then all '.proto' files within the directory + // are added as input files. + var isDirectory = ObjCBool(false) + let exists = fileManager.fileExists(atPath: file, isDirectory: &isDirectory) + + if !exists { + invalidInputFiles.append(file) + } else if isDirectory.boolValue { + dirs.append(file) + // Do a deep traversal of the directory. + if var enumerator = fileManager.enumerator(atPath: file) { + while let path = enumerator.nextObject() as? String { + if path.hasSuffix(".proto") { + protos.append(path) + } + } + } + } else if file.hasSuffix(".proto") { + protos.append(file) + } else { + invalidInputFiles.append(file) + } + } + + if !invalidInputFiles.isEmpty { + throw CommandPluginError.invalidInputFiles(invalidInputFiles) + } + + return (protos, dirs) + } } /// Execute a single invocation of `protoc`, printing output and if in verbose mode the invocation